备份,准备做双端

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
+103 -7
View File
@@ -55,10 +55,18 @@ public class AllyCombatant : MonoBehaviour, ICombatant
[Tooltip("Documentation text normalized.")]
public int attack = 0;
// Keep an unbuffed attack baseline so temporary Buff.attackMultiplier can be applied/reverted correctly.
// True (permanent) attack baseline. Only permanent changes touch this: initial stats,
// level-ups, LevelRule AttackDelta, and duration<=0 skills. Timed skill buffs must NOT
// bake into this — they live in _attackModifiers so percentages stay computed off the
// unbuffed base and re-triggering a timed buff refreshes instead of compounding.
private int _attackBaseUnbuffed = 0;
private bool _attackBaseInitialized = false;
// Additive attack modifiers keyed by source (skillId). Each source contributes exactly one
// entry; re-applying the same source replaces its value (idempotent), so "+10% from A" and
// "+20% from B" sum to +30% of base rather than compounding multiplicatively per note hit.
private readonly Dictionary<string, int> _attackModifiers = new Dictionary<string, int>();
// scoring fields
[Header("Scoring")]
[Tooltip("Base score value for a perfect hit on this track")]
@@ -122,6 +130,11 @@ public class AllyCombatant : MonoBehaviour, ICombatant
private Coroutine skillIconAutoFadeCoroutine;
private float lastSkillIconPushTime = -1f;
// Pool of removed-skill-icon GameObjects reused by PlaySkillIconExitAnim, so each icon
// eviction reuses an instance instead of new GameObject(...) + Destroy every time.
private readonly Stack<GameObject> _removedSkillIconPool = new Stack<GameObject>();
private readonly List<GameObject> _removedSkillIconAll = new List<GameObject>();
// Track skills that are currently active because HP percent condition is satisfied.
private HashSet<string> _activeHpPercentSkills = new HashSet<string>();
@@ -914,10 +927,11 @@ public class AllyCombatant : MonoBehaviour, ICombatant
if (!Application.isPlaying) return;
// Render removed icon under the slot parent to avoid disturbing slot layout positions.
var go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
var go = AcquireRemovedSkillIcon();
RectTransform hostRt = hostSlot.rectTransform;
RectTransform parentRt = hostRt != null ? hostRt.parent as RectTransform : null;
go.transform.SetParent(parentRt != null ? parentRt : hostSlot.transform, false);
go.SetActive(true);
var rt = go.GetComponent<RectTransform>();
if (hostRt != null)
@@ -947,7 +961,32 @@ public class AllyCombatant : MonoBehaviour, ICombatant
if (gameObject.activeInHierarchy)
StartCoroutine(SkillIconExitCoroutine(go, rt, img, removedColor, hostSlot));
else
Destroy(go);
ReleaseRemovedSkillIcon(go);
}
private GameObject AcquireRemovedSkillIcon()
{
GameObject go = null;
while (_removedSkillIconPool.Count > 0)
{
go = _removedSkillIconPool.Pop();
if (go != null) break;
go = null;
}
if (go == null)
{
go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
_removedSkillIconAll.Add(go);
}
return go;
}
private void ReleaseRemovedSkillIcon(GameObject go)
{
if (go == null) return;
go.SetActive(false);
go.transform.SetParent(transform, false);
_removedSkillIconPool.Push(go);
}
private IEnumerator SkillIconExitCoroutine(GameObject go, RectTransform rt, Image img, Color baseColor, Image hostSlot)
@@ -995,7 +1034,7 @@ public class AllyCombatant : MonoBehaviour, ICombatant
yield return null;
}
if (go != null) Destroy(go);
ReleaseRemovedSkillIcon(go);
}
private void InitializeStatsFromData()
@@ -1228,13 +1267,23 @@ public class AllyCombatant : MonoBehaviour, ICombatant
ScoreManager.Instance?.RecalculateTotal();
}
// 性能:记录上次写入的 "cur/max" 字符串及其目标 Text 对象。仅当数值或目标对象变化时才写 .text,
// 避免每次判定对同一 Text 重复赋相同字符串触发 TMP 网格重建。目标对象变化(换局/UI 重建)必写。
private string _lastScoreUiText;
private UnityEngine.Object _lastScoreUiTarget;
private void UpdateScoreUI()
{
string value = $"{currentScore}/{maxTrackScore}";
if (currentScoreText != null)
{
currentScoreText.text = value;
LogVerbose($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})");
if (!ReferenceEquals(_lastScoreUiTarget, currentScoreText) || _lastScoreUiText != value)
{
currentScoreText.text = value;
_lastScoreUiTarget = currentScoreText;
_lastScoreUiText = value;
LogVerbose($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})");
}
return;
}
@@ -1242,6 +1291,10 @@ public class AllyCombatant : MonoBehaviour, ICombatant
var ui = teamUIController.Instance;
if (ui != null)
{
// 性能:fallback 走固定的 teammateXX 字段(slotIndex 固定,目标稳定),值未变则跳过整段写入(等效)。
if (_lastScoreUiText == value) return;
_lastScoreUiText = value;
_lastScoreUiTarget = null;
switch (slotIndex)
{
case 0:
@@ -1590,8 +1643,13 @@ public class AllyCombatant : MonoBehaviour, ICombatant
EnsureAttackBaseInitialized();
float mult = GetTotalAttackMultiplier();
int modifierSum = 0;
foreach (var kv in _attackModifiers) modifierSum += kv.Value;
int old = attack;
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult));
// Percentage buffs are pre-summed into _attackModifiers off the base, so the final value is
// (base + Σ sourceModifiers) with the legacy multiplicative Buff path (mult, normally 1) on top.
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult) + modifierSum);
if (old != attack)
{
EvaluateAttackZeroTriggers();
@@ -1600,6 +1658,33 @@ public class AllyCombatant : MonoBehaviour, ICombatant
}
}
// Permanent base attack, unaffected by timed buffs. Skills that compute a percentage of
// attack should read this so the percentage is always relative to the unbuffed value.
public int GetBaseAttack()
{
EnsureAttackBaseInitialized();
return _attackBaseUnbuffed;
}
// Set/clear an additive attack modifier contributed by a single source (keyed by skillId).
// Re-applying the same source replaces its prior contribution instead of stacking, which is
// what keeps repeated OnNoteHit percentage buffs from compounding every note.
public void SetAttackModifier(string sourceKey, int delta)
{
if (string.IsNullOrEmpty(sourceKey)) { ModifyAttack(delta); return; }
EnsureAttackBaseInitialized();
if (delta == 0) _attackModifiers.Remove(sourceKey);
else _attackModifiers[sourceKey] = delta;
RecalculateAttackFromBuffs();
}
public void ClearAttackModifier(string sourceKey)
{
if (string.IsNullOrEmpty(sourceKey)) return;
if (_attackModifiers.Remove(sourceKey)) RecalculateAttackFromBuffs();
}
// Permanent base change (level stats, LevelRule AttackDelta, duration<=0 skills).
public void ModifyAttack(int delta)
{
EnsureAttackBaseInitialized();
@@ -2590,8 +2675,19 @@ public class AllyCombatant : MonoBehaviour, ICombatant
state.iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(redirector, PlayerBudeffIconType.dmg_redirect_toSelf, 0f, duration);
}
// Frame guard so the global redirect list is swept once per frame, not once per ally
// (this is static/global state; running it 5× per frame was pure redundant work).
private static int s_lastRedirectCleanupFrame = -1;
private static void CleanupGlobalRedirects()
{
int frame = Time.frameCount;
if (frame == s_lastRedirectCleanupFrame) return;
s_lastRedirectCleanupFrame = frame;
// Nothing registered -> nothing to sweep.
if (s_nextDamageRedirectStates.Count == 0) return;
for (int i = s_nextDamageRedirectStates.Count - 1; i >= 0; i--)
{
var state = s_nextDamageRedirectStates[i];
+58 -24
View File
@@ -771,9 +771,17 @@ public class EffectSystem : MonoBehaviour
// - AdjacentAllies -> use AllyCombatant.slotIndex (from source) and teamUIController helpers
// - CurrentEnemies -> specificTarget if provided else all objects with tag "Enemy"
// - AllEntities -> find all enemies + allies
// Reusable gathering buffer for ResolveTargets. Safe to reuse because it never escapes
// this method (the deduped result is copied into a fresh `uniq` list that IS returned)
// and the gathering phase never re-enters ResolveTargets. The returned list cannot share
// this buffer: ApplyEffect's switch can re-enter EffectSystem.ApplyEffect (e.g. via
// ModifyHP -> skill triggers), which would clobber a shared return buffer mid-iteration.
private readonly List<GameObject> _resolveGatherBuffer = new List<GameObject>(8);
private List<GameObject> ResolveTargets(Selector selector, GameObject source, GameObject specificTarget)
{
List<GameObject> list = new List<GameObject>();
List<GameObject> list = _resolveGatherBuffer;
list.Clear();
// prefer teamUIController when available for authoritative ally/enemy objects
var ui = teamUIController.Instance != null ? teamUIController.Instance : SceneObjectLookupCache.FindAny<teamUIController>();
@@ -810,13 +818,13 @@ public class EffectSystem : MonoBehaviour
if (go != null && !list.Contains(go)) list.Add(go);
}
if (source != null)
list.RemoveAll(g => g == null || g == source || g.gameObject == source.gameObject);
RemoveSelfFromList(list, source);
}
else
{
list.AddRange(FindAllAllies());
if (source != null)
list.RemoveAll(g => g == null || g == source || g.gameObject == source.gameObject);
RemoveSelfFromList(list, source);
}
break;
@@ -910,33 +918,59 @@ public class EffectSystem : MonoBehaviour
break;
}
// remove nulls and duplicates
list.RemoveAll(x => x == null);
var uniq = new List<GameObject>();
foreach (var g in list)
if (!uniq.Contains(g)) uniq.Add(g);
// Filter out dead or empty combatants so they won't receive further effects (damage/score/etc.).
// This prevents "dead units still get hit / still gain score" edge cases.
uniq.RemoveAll(go =>
// Build the deduped, alive-only result. `uniq` is a fresh list because it escapes
// (ApplyEffect can re-enter EffectSystem, which would clobber a shared buffer). The
// filtering is done inline (no RemoveAll closures) to avoid per-call lambda allocs on
// this per-effect/per-damage-tick hot path.
var uniq = new List<GameObject>(list.Count);
for (int i = 0; i < list.Count; i++)
{
if (go == null) return true;
var ally = go.GetComponent<AllyCombatant>();
if (ally != null) return ally.IsDead || ally.maxHP == 0; // Skip dead or empty allies
var enemy = go.GetComponent<EnemyCombatant>();
if (enemy != null) return enemy.IsDead || enemy.currentHP <= 0;
return false;
});
GameObject g = list[i];
if (g == null) continue;
if (uniq.Contains(g)) continue;
// Skip dead or empty combatants so they won't receive further effects
// (prevents "dead units still get hit / still gain score" edge cases).
var ally = g.GetComponent<AllyCombatant>();
if (ally != null)
{
if (ally.IsDead || ally.maxHP == 0) continue;
}
else
{
var enemy = g.GetComponent<EnemyCombatant>();
if (enemy != null && (enemy.IsDead || enemy.currentHP <= 0)) continue;
}
uniq.Add(g);
}
// Debug: log resolved targets for diagnosis (guarded to avoid log spam in normal gameplay).
if (uniq.Count == 0)
LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} returned 0 targets (source={(source ? source.name : "null")}, specificTarget={(specificTarget ? specificTarget.name : "null")})");
else
LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} resolved {uniq.Count} targets: {string.Join(",", uniq.ConvertAll(x => x != null ? x.name : "null"))}");
// Debug: log resolved targets for diagnosis. Guarded by the flag directly so the
// per-target ConvertAll + string.Join (allocating) never runs in normal gameplay —
// ResolveTargets is on the per-effect/per-damage-tick hot path.
if (GameConfig.verboseLogs)
{
if (uniq.Count == 0)
LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} returned 0 targets (source={(source ? source.name : "null")}, specificTarget={(specificTarget ? specificTarget.name : "null")})");
else
LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} resolved {uniq.Count} targets: {string.Join(",", uniq.ConvertAll(x => x != null ? x.name : "null"))}");
}
return uniq;
}
// Allocation-free removal of `source` (and nulls) from a gathered target list, replacing
// a per-call closure-capturing RemoveAll on the ResolveTargets hot path.
private static void RemoveSelfFromList(List<GameObject> list, GameObject source)
{
for (int i = list.Count - 1; i >= 0; i--)
{
GameObject g = list[i];
if (g == null || g == source || g.gameObject == source.gameObject)
{
list.RemoveAt(i);
}
}
}
private IEnumerable<GameObject> FindAllAllies()
{
if (_cachedAlliesFrame == Time.frameCount) return _cachedAllies;
+46 -6
View File
@@ -19,10 +19,14 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
public float damageResistance = 0f;
public int attack = 0;
// Keep an unbuffed attack baseline so temporary Buff.attackMultiplier can be applied/reverted correctly.
// True (permanent) attack baseline. Timed buffs live in _attackModifiers instead of baking here,
// so percentages stay computed off the unbuffed base and re-triggers refresh rather than compound.
private int _attackBaseUnbuffed = 0;
private bool _attackBaseInitialized = false;
// Additive attack modifiers keyed by source (skillId); one entry per source, replaced on re-apply.
private readonly Dictionary<string, int> _attackModifiers = new Dictionary<string, int>();
private List<Buff> activeBuffs = new List<Buff>();
// Events to notify manager/UI
@@ -43,6 +47,7 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
// This enemy GameObject can be reused for multiple enemy entries in sequence.
// Clear any runtime buffs so they don't leak across enemy transitions.
activeBuffs.Clear();
_attackModifiers.Clear();
_attackBaseInitialized = false;
_attackBaseUnbuffed = 0;
_lastAttacker = null;
@@ -94,6 +99,12 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger.OnEnemyRevive);
}
// Reused only for the formula vars (safe: used synchronously in the leaf before any
// nested ExecuteSkill call). The `executed` set is intentionally NOT pooled: an enemy
// skill can restore the enemy's own mana and re-enter TryTriggerConfiguredSkills(OnManaFull)
// mid-loop, so a shared set could be cleared under the outer iteration.
private readonly Dictionary<string, float> _enemyFormulaVarsBuffer = new Dictionary<string, float>();
private void TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger when)
{
if (sourceData == null) return;
@@ -131,11 +142,15 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
executed.Add(def);
float perTick = 0f;
var vars = new Dictionary<string, float>();
var vars = _enemyFormulaVarsBuffer;
vars.Clear();
vars["maxHP"] = maxHP;
vars["maxMana"] = maxMana;
vars["damageResistance"] = damageResistance;
vars["attack"] = attack;
// Attack-buff effects compute their percentage off the unbuffed base so repeated triggers
// add by source instead of compounding; output effects keep using current attack.
bool attackPercentOffBase = def.effectType == EffectType.IncreaseAttack || def.effectType == EffectType.DecreaseAttack;
vars["attack"] = attackPercentOffBase ? GetBaseAttack() : attack;
vars["enemy_currentHP"] = currentHP;
if (!string.IsNullOrWhiteSpace(def.formula))
@@ -269,7 +284,8 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
public void ApplyBuff(Buff buff, GameObject source)
{
if (buff == null) return;
Debug.LogWarning($"[EnemyCombatant] ApplyBuff: {buff.description} (id={buff.buffId}) to {name}. AtkMult={buff.attackMultiplier}, HealMult={buff.healReceivedMultiplier}, ScoreMult={buff.scoreMultiplier}");
if (GameConfig.verboseLogs)
Debug.LogWarning($"[EnemyCombatant] ApplyBuff: {buff.description} (id={buff.buffId}) to {name}. AtkMult={buff.attackMultiplier}, HealMult={buff.healReceivedMultiplier}, ScoreMult={buff.scoreMultiplier}");
activeBuffs.Add(buff);
// Apply reversible multipliers. attackMultiplier is applied via baseline+recalc to ensure it can be reverted.
if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs();
@@ -334,7 +350,30 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
{
EnsureAttackBaseInitialized();
float mult = GetTotalAttackMultiplier();
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult));
int modifierSum = 0;
foreach (var kv in _attackModifiers) modifierSum += kv.Value;
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult) + modifierSum);
}
public int GetBaseAttack()
{
EnsureAttackBaseInitialized();
return _attackBaseUnbuffed;
}
public void SetAttackModifier(string sourceKey, int delta)
{
if (string.IsNullOrEmpty(sourceKey)) { ModifyAttack(delta); return; }
EnsureAttackBaseInitialized();
if (delta == 0) _attackModifiers.Remove(sourceKey);
else _attackModifiers[sourceKey] = delta;
RecalculateAttackFromBuffs();
}
public void ClearAttackModifier(string sourceKey)
{
if (string.IsNullOrEmpty(sourceKey)) return;
if (_attackModifiers.Remove(sourceKey)) RecalculateAttackFromBuffs();
}
public void ModifyAttack(int delta)
@@ -414,7 +453,8 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
// If mana reached full, trigger configured skills and reset mana
if (currentMana >= maxMana && maxMana > 0 && Application.isPlaying)
{
Debug.Log($"[EnemyCombatant] Mana full for {gameObject.name} (slot) - triggering OnManaFull");
if (GameConfig.verboseLogs)
Debug.Log($"[EnemyCombatant] Mana full for {gameObject.name} (slot) - triggering OnManaFull");
try
{
TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger.OnManaFull);
@@ -18,6 +18,13 @@ public static class GameplaySkillLogger
private static float s_sessionStartRealtime;
private static int s_lastSecondBucket = -1;
// Master gate. This is a diagnostic disk logger: when disabled, every Record* method
// returns before doing any Sanitize/string-concat/StringBuilder work, so it costs
// nothing on the per-damage/per-note/per-score hot paths in production. Latched from
// GameConfig at BeginSession so a whole session is consistently on or off. Hot call
// sites can also check GameplaySkillLogger.Enabled to skip building argument strings.
public static bool Enabled { get; private set; }
private const float RapidDuplicateSkillThresholdSeconds = 0.05f;
// Off-thread writer: judge/skill events push formatted lines into this queue and a
@@ -45,6 +52,14 @@ public static class GameplaySkillLogger
public static void BeginSession(string sessionLabel)
{
// Latch the gate for the whole session from the debug flags. Disabled in production
// (both flags default false) so the logger adds zero hot-path cost.
Enabled = GameConfig.verboseLogs || GameConfig.skillDebugMode;
if (!Enabled)
{
return;
}
lock (s_fileLock)
{
BeginSessionInternal(sessionLabel);
@@ -58,6 +73,7 @@ public static class GameplaySkillLogger
string attributeSummary,
string targetSummary)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
@@ -106,6 +122,7 @@ public static class GameplaySkillLogger
float hitTime,
float scheduledEndTime)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
@@ -141,6 +158,7 @@ public static class GameplaySkillLogger
int allIdol,
int totalScore)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
@@ -173,6 +191,7 @@ public static class GameplaySkillLogger
float damageResistance,
string sourceName)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
@@ -200,6 +219,7 @@ public static class GameplaySkillLogger
int maxMana,
string source)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
@@ -226,6 +246,7 @@ public static class GameplaySkillLogger
int maxValue,
string sourceName)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
@@ -248,6 +269,7 @@ public static class GameplaySkillLogger
public static void RecordConflictHint(string subsystem, string issueType, string details)
{
if (!Enabled) return;
lock (s_fileLock)
{
EnsureSessionLocked();
+111 -19
View File
@@ -18,6 +18,9 @@ public class ScoreManager : MonoBehaviour
private readonly float[] _idolscoreLastTargetY = new float[5];
private readonly float[] _idolscorePulseAmplitude = new float[5];
private readonly float[] _idolscorePulseSeed = new float[5];
// True once a key has fully converged to its target (no wobble, displayY == targetY).
// While settled we skip the per-frame noise + localScale write; cleared when target changes.
private readonly bool[] _idolscoreSettled = new bool[5];
// per-track pm score sums (red, green, yellow, purple, blue)
public int red_pmScore_sum = 0;
@@ -69,6 +72,12 @@ public class ScoreManager : MonoBehaviour
int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue
};
// 性能:RecalculateTotal 里 5 个分槽 "cur/max" 上次写入的字符串及其目标 Text 对象。
// 仅当"数值(cur/max)变化"或"目标对象变化(换局/换场景导致 UI 重建)"时才写 .text,
// 避免每判定对 5 个槽位无条件赋值触发 TMP 网格重建。写入逻辑与原先完全一致,只是跳过重复写。
private readonly string[] _lastSlotScoreText = new string[5];
private readonly UnityEngine.Object[] _lastSlotScoreTarget = new UnityEngine.Object[5];
// 仅当数值变化时写 TMP.text(避免相同字符串触发无谓网格重建)。
private void SetSumTextIfChanged(TextMeshProUGUI field, int value, int cacheIndex)
{
@@ -105,13 +114,21 @@ public class ScoreManager : MonoBehaviour
public int countLate = 0;
public float totalOffsetMs = 0f;
public int offsetCount = 0;
// Raw per-hit samples (ms) for median/suggestion. Miss (0) not included.
private readonly System.Collections.Generic.List<float> offsetSamples =
new System.Collections.Generic.List<float>();
/// <summary>
/// 只读访问判定偏移样本列表(供直方图等外部组件读取)。
/// </summary>
public System.Collections.Generic.IReadOnlyList<float> OffsetSamples => offsetSamples;
public void ResetStatistics()
{
countPerfect = countGreat = countGood = countMiss = 0;
perfectClearBonusPm = 0;
perfectClearBonusApplied = false;
for (int i = 0; i < 5; i++)
{
trackPerfectCounts[i] = 0;
@@ -123,11 +140,17 @@ public class ScoreManager : MonoBehaviour
countEarly = countLate = 0;
totalOffsetMs = 0f;
offsetCount = 0;
offsetSamples.Clear();
}
/// <summary>
/// Records a timing offset in milliseconds.
/// Positive = Early, Negative = Late.
/// Fired whenever a timing offset is recorded (Positive = Early, Negative = Late).
/// Display-only hook (e.g. realtime offset bar); does not affect scoring/judgement.
/// </summary>
public static event System.Action<float> OnOffsetRecorded;
/// <summary>
/// Records a timing offset in milliseconds. Positive = Early, Negative = Late.
/// </summary>
public void RecordOffset(float offsetMs)
{
@@ -135,9 +158,26 @@ public class ScoreManager : MonoBehaviour
totalOffsetMs += offsetMs;
offsetCount++;
offsetSamples.Add(offsetMs);
if (offsetMs > 0) countEarly++;
else if (offsetMs < 0) countLate++;
OnOffsetRecorded?.Invoke(offsetMs);
}
/// <summary>
/// Returns median offset (ms) across all recorded hits. More robust than mean for
/// calibration suggestions — single outlier taps don't skew it.
/// </summary>
public float GetMedianOffsetMs()
{
int n = offsetSamples.Count;
if (n == 0) return 0f;
var sorted = new System.Collections.Generic.List<float>(offsetSamples);
sorted.Sort();
int mid = n / 2;
return (n % 2 == 1) ? sorted[mid] : 0.5f * (sorted[mid - 1] + sorted[mid]);
}
private void Awake()
@@ -200,6 +240,10 @@ public class ScoreManager : MonoBehaviour
Transform t = _idolscoreKeys[i];
if (t == null) continue;
// Settled: display converged to target and wobble decayed. Nothing changes frame to
// frame, so skip the noise sample and localScale write until the target moves again.
if (_idolscoreSettled[i]) continue;
Vector3 baseScale = _idolscoreKeyBaseScaleCached[i] ? _idolscoreKeyBaseScales[i] : t.localScale;
float targetY = Mathf.Max(0f, _idolscoreTargetY[i]);
@@ -213,6 +257,16 @@ public class ScoreManager : MonoBehaviour
float y = Mathf.Max(0f, _idolscoreDisplayY[i] + noise * _idolscorePulseAmplitude[i]);
t.localScale = new Vector3(baseScale.x, y, baseScale.z);
// Mark settled once the smoothed value has essentially reached the target and the
// wobble amplitude has decayed to zero, so the next frame can early-out.
if (Mathf.Abs(_idolscoreDisplayY[i] - targetY) < 0.0005f && _idolscorePulseAmplitude[i] < 0.0005f)
{
_idolscoreDisplayY[i] = targetY;
_idolscorePulseAmplitude[i] = 0f;
t.localScale = new Vector3(baseScale.x, targetY, baseScale.z);
_idolscoreSettled[i] = true;
}
}
}
@@ -382,6 +436,8 @@ public class ScoreManager : MonoBehaviour
{
_idolscoreLastTargetY[i] = targetY;
_idolscorePulseAmplitude[i] = UnityEngine.Random.Range(0.08f, 0.24f);
// Target moved (and/or wobble re-seeded): resume per-frame animation.
_idolscoreSettled[i] = false;
}
if (targetY <= 0.001f)
@@ -638,13 +694,37 @@ public class ScoreManager : MonoBehaviour
var ui = teamUIController.Instance;
// helper lambda to set text on TMP or legacy Text under a parent fallback
void SetScoreText(TextMeshProUGUI assignedTmp, GameObject parent, int cur, int max, string slotName)
void SetScoreText(TextMeshProUGUI assignedTmp, GameObject parent, int cur, int max, string slotName, int slotIdx)
{
string value = cur + "/" + max;
// 【性能】值变门控:仅当 (目标 Text 对象, value) 与该槽位上次写入不同才赋值 .text。
// 目标对象或数值任一变化都会写(换局/换场景使 UI 重建时目标对象变,必写一次),
// 因此可见文本与原先逐次无条件赋值完全一致,只是跳过"同对象同值"的重复写。
// 用纯判断函数(非闭包 lambda)避免额外 GC:返回 true 表示需要写并已更新缓存。
bool ShouldWriteSlot(UnityEngine.Object target)
{
if (slotIdx >= 0 && slotIdx < 5 &&
ReferenceEquals(_lastSlotScoreTarget[slotIdx], target) &&
_lastSlotScoreText[slotIdx] == value)
{
return false; // 同对象同值,已是该文本,跳过写入(等效)。
}
if (slotIdx >= 0 && slotIdx < 5)
{
_lastSlotScoreTarget[slotIdx] = target;
_lastSlotScoreText[slotIdx] = value;
}
return true;
}
if (assignedTmp != null)
{
assignedTmp.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to assigned TMP for {slotName}: '{value}' -> {assignedTmp.gameObject.name}");
if (ShouldWriteSlot(assignedTmp))
{
assignedTmp.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to assigned TMP for {slotName}: '{value}' -> {assignedTmp.gameObject.name}");
}
return;
}
if (parent != null)
@@ -661,15 +741,21 @@ public class ScoreManager : MonoBehaviour
var tmp = slotTmpFallback[idx];
if (tmp != null)
{
tmp.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}");
if (ShouldWriteSlot(tmp))
{
tmp.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}");
}
return;
}
var legacy = slotLegacyFallback[idx];
if (legacy != null)
{
legacy.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}");
if (ShouldWriteSlot(legacy))
{
legacy.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}");
}
return;
}
}
@@ -678,15 +764,21 @@ public class ScoreManager : MonoBehaviour
var tmp = parent.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null)
{
tmp.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}");
if (ShouldWriteSlot(tmp))
{
tmp.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}");
}
return;
}
var legacy = parent.GetComponentInChildren<Text>(true);
if (legacy != null)
{
legacy.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}");
if (ShouldWriteSlot(legacy))
{
legacy.text = value;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}");
}
return;
}
}
@@ -695,11 +787,11 @@ public class ScoreManager : MonoBehaviour
}
// Update each slot: use the explicit TMP fields if set; otherwise try parent object fields
SetScoreText(ui.teammate01_current_scoreText, ui.objectFather_ally01, tmpCurrents[0], tmpMaxes[0], "teammate01");
SetScoreText(ui.teammate02_current_scoreText, ui.objectFather_ally02, tmpCurrents[1], tmpMaxes[1], "teammate02");
SetScoreText(ui.teammate03_current_scoreText, ui.objectFather_ally03, tmpCurrents[2], tmpMaxes[2], "teammate03");
SetScoreText(ui.teammate04_current_scoreText, ui.objectFather_ally04, tmpCurrents[3], tmpMaxes[3], "teammate04");
SetScoreText(ui.teammate05_current_scoreText, ui.objectFather_ally05, tmpCurrents[4], tmpMaxes[4], "teammate05");
SetScoreText(ui.teammate01_current_scoreText, ui.objectFather_ally01, tmpCurrents[0], tmpMaxes[0], "teammate01", 0);
SetScoreText(ui.teammate02_current_scoreText, ui.objectFather_ally02, tmpCurrents[1], tmpMaxes[1], "teammate02", 1);
SetScoreText(ui.teammate03_current_scoreText, ui.objectFather_ally03, tmpCurrents[2], tmpMaxes[2], "teammate03", 2);
SetScoreText(ui.teammate04_current_scoreText, ui.objectFather_ally04, tmpCurrents[3], tmpMaxes[3], "teammate04", 3);
SetScoreText(ui.teammate05_current_scoreText, ui.objectFather_ally05, tmpCurrents[4], tmpMaxes[4], "teammate05", 4);
// update total score (try TMP first, then legacy Text)
if (ui.currentTotalScore != null)
+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;
}
}
+12 -3
View File
@@ -9,6 +9,11 @@ public class SkillDefinition : ScriptableObject
// Cache compiled RPN for formulas to avoid re-tokenizing every trigger.
private static readonly Dictionary<string, List<Token>> s_rpnCache = new Dictionary<string, List<Token>>();
private static readonly HashSet<string> s_badFormulaCache = new HashSet<string>();
// Reused across EvalRPN calls to avoid a per-evaluation allocation. Formula eval runs
// synchronously on the main thread and is not re-entrant, so a shared instance is safe.
private static readonly Stack<float> s_evalStack = new Stack<float>();
// Warn once per unknown variable name instead of on every evaluation.
private static readonly HashSet<string> s_warnedUnknownVars = new HashSet<string>();
public enum SkillTrigger
{
None,
@@ -301,7 +306,8 @@ public class SkillDefinition : ScriptableObject
private static float EvalRPN(List<Token> rpn, Dictionary<string, float> vars)
{
var st = new Stack<float>();
var st = s_evalStack;
st.Clear();
foreach (var t in rpn)
{
if (t.type == TokType.Number)
@@ -314,8 +320,11 @@ public class SkillDefinition : ScriptableObject
if (vars != null && vars.TryGetValue(t.text, out float v)) st.Push(v);
else
{
// unknown identifiers treat as 0 but warn
Debug.LogWarning($"SkillDefinition: unknown variable '{t.text}' in formula, treated as 0");
// unknown identifiers treat as 0 but warn (once per unique name)
if (s_warnedUnknownVars.Add(t.text))
{
Debug.LogWarning($"SkillDefinition: unknown variable '{t.text}' in formula, treated as 0");
}
st.Push(0f);
}
}