using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; public class LevelRuleController : MonoBehaviour { public static LevelRuleController Instance { get; private set; } [Header("Rule Configs")] public string resourcesPath = "so/levelRules"; public LevelRuleConfig_SO[] configOverrides = Array.Empty(); [Header("Runtime")] [SerializeField] private bool verboseLogs = false; private readonly Queue[] recentHitTimes = new Queue[5]; private readonly Queue[] recentSkillCastTimes = new Queue[5]; private readonly Dictionary timedRoutines = new Dictionary(); private readonly Dictionary actionTriggerCounts = new Dictionary(); private readonly HashSet firedHpPhases = new HashSet(); private LevelRuleConfig_SO activeConfig; private LevelRuleStage currentStageRule; private EnemyCombatant currentEnemy; private EnemyData_SO currentEnemyData; private SongData currentSong; private int currentDifficulty = -1; private int currentStageIndex = -1; private int supportTrackIndex = -1; private int supportCharges; private int adjacentSupportCastCount; private int lastSupportCastSlot = -1; private float lastSupportCastTime = -999f; private int echoStacks; private int lastEchoCastSlot = -1; private float lastEchoCastTime = -999f; private float nextConfigResolveTime; // The (song, difficulty) pair we have already run FindConfig for. Lets the periodic // Update skip re-resolving (and re-running Resources.LoadAll) when the current song has // NO matching config — otherwise activeConfig stays null and the null-check early-out // never trips, re-scanning Resources every 0.5s for the whole song. private SongData resolvedForSong; private int resolvedForDifficulty = -1; private bool hasResolvedForCurrent; private int pendingSettlementScoreBonus; private bool handlingEnemyDamagedActions; public int PendingSettlementScoreBonus => Mathf.Max(0, pendingSettlementScoreBonus); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void Bootstrap() { EnsureInstance(); } public static LevelRuleController EnsureInstance() { if (Instance != null) return Instance; LevelRuleController existing = SceneObjectLookupCache.FindAny(); if (existing != null) { Instance = existing; return existing; } GameObject runtimeObject = new GameObject("__level_rule_controller_runtime"); DontDestroyOnLoad(runtimeObject); Instance = runtimeObject.AddComponent(); return Instance; } public static int ConsumePendingSettlementScoreBonus() { if (Instance == null) return 0; if (GameConfig.autoPlayEnabled) { Instance.pendingSettlementScoreBonus = 0; return 0; } Instance.AddFinalAverageHpScoreIfConfigured(); int value = Mathf.Max(0, Instance.pendingSettlementScoreBonus); Instance.pendingSettlementScoreBonus = 0; return value; } private void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); } else if (Instance != this) { Destroy(gameObject); return; } for (int i = 0; i < recentHitTimes.Length; i++) { recentHitTimes[i] = new Queue(); recentSkillCastTimes[i] = new Queue(); } } private void OnEnable() { SceneManager.sceneLoaded += HandleSceneLoaded; GameplayLevelRuleEventBus.TapJudged += HandleTapJudged; GameplayLevelRuleEventBus.HoldStarted += HandleHoldStarted; GameplayLevelRuleEventBus.HoldCompleted += HandleHoldCompleted; GameplayLevelRuleEventBus.HoldBroken += HandleHoldBroken; GameplayLevelRuleEventBus.AllySkillCast += HandleAllySkillCast; GameplayLevelRuleEventBus.EnemySpawned += HandleEnemySpawned; GameplayLevelRuleEventBus.EnemyDied += HandleEnemyDied; GameplayLevelRuleEventBus.EnemyDamaged += HandleEnemyDamaged; GameplayLevelRuleEventBus.EnemyManaFull += HandleEnemyManaFull; TryResolveActiveConfig(true); } private void OnDisable() { SceneManager.sceneLoaded -= HandleSceneLoaded; GameplayLevelRuleEventBus.TapJudged -= HandleTapJudged; GameplayLevelRuleEventBus.HoldStarted -= HandleHoldStarted; GameplayLevelRuleEventBus.HoldCompleted -= HandleHoldCompleted; GameplayLevelRuleEventBus.HoldBroken -= HandleHoldBroken; GameplayLevelRuleEventBus.AllySkillCast -= HandleAllySkillCast; GameplayLevelRuleEventBus.EnemySpawned -= HandleEnemySpawned; GameplayLevelRuleEventBus.EnemyDied -= HandleEnemyDied; GameplayLevelRuleEventBus.EnemyDamaged -= HandleEnemyDamaged; GameplayLevelRuleEventBus.EnemyManaFull -= HandleEnemyManaFull; } private void Update() { if (Time.unscaledTime < nextConfigResolveTime) return; nextConfigResolveTime = Time.unscaledTime + 0.5f; TryResolveActiveConfig(false); } private void HandleSceneLoaded(Scene scene, LoadSceneMode mode) { ResetRuntimeState(); nextConfigResolveTime = 0f; hasResolvedForCurrent = false; TryResolveActiveConfig(true); } private void TryResolveActiveConfig(bool force) { BeatmapManager beatmapManager = BeatmapManager.Instance; if (beatmapManager == null || beatmapManager.assignedSongData == null) { if (force) SetActiveConfig(null, null, -1); return; } SongData song = beatmapManager.assignedSongData; int difficulty = beatmapManager.assignedDifficulty; // Skip re-resolving when we've already resolved for this exact (song, difficulty), // regardless of whether a config matched (null result is still a resolved result). if (!force && hasResolvedForCurrent && resolvedForSong == song && resolvedForDifficulty == difficulty) return; LevelRuleConfig_SO matched = FindConfig(song, difficulty); resolvedForSong = song; resolvedForDifficulty = difficulty; hasResolvedForCurrent = true; SetActiveConfig(matched, song, difficulty); } private LevelRuleConfig_SO FindConfig(SongData song, int difficulty) { if (configOverrides != null) { for (int i = 0; i < configOverrides.Length; i++) { LevelRuleConfig_SO config = configOverrides[i]; if (config != null && config.Matches(song, difficulty)) return config; } } LevelRuleConfig_SO[] resources = Resources.LoadAll(resourcesPath); if (resources != null) { for (int i = 0; i < resources.Length; i++) { LevelRuleConfig_SO config = resources[i]; if (config != null && config.Matches(song, difficulty)) return config; } } return null; } private void SetActiveConfig(LevelRuleConfig_SO config, SongData song, int difficulty) { if (activeConfig == config && currentSong == song && currentDifficulty == difficulty) return; activeConfig = config; currentSong = song; currentDifficulty = difficulty; ResetRuntimeState(); if (activeConfig != null) { supportTrackIndex = ResolveSupportTrackIndex(); if (verboseLogs) Debug.Log($"[LevelRuleController] Active rule: {activeConfig.name}, supportTrack={supportTrackIndex}"); } } private void ResetRuntimeState() { currentStageRule = null; currentEnemy = null; currentEnemyData = null; currentStageIndex = -1; supportTrackIndex = -1; supportCharges = 0; adjacentSupportCastCount = 0; lastSupportCastSlot = -1; lastSupportCastTime = -999f; echoStacks = 0; lastEchoCastSlot = -1; lastEchoCastTime = -999f; pendingSettlementScoreBonus = 0; actionTriggerCounts.Clear(); firedHpPhases.Clear(); for (int i = 0; i < recentHitTimes.Length; i++) { recentHitTimes[i].Clear(); recentSkillCastTimes[i].Clear(); } foreach (Coroutine routine in timedRoutines.Values) { if (routine != null) StopCoroutine(routine); } timedRoutines.Clear(); } private int ResolveSupportTrackIndex() { if (activeConfig == null) return -1; if (!activeConfig.useLeastNoteTrackAsSupportTrack) return activeConfig.supportTrackIndex >= 0 && activeConfig.supportTrackIndex < 5 ? activeConfig.supportTrackIndex : -1; Beatmap beatmap = BeatmapManager.Instance != null ? BeatmapManager.Instance.beatmap : null; if (beatmap == null || beatmap.notes == null || beatmap.notes.Length == 0) return activeConfig.supportTrackIndex >= 0 && activeConfig.supportTrackIndex < 5 ? activeConfig.supportTrackIndex : -1; int[] counts = new int[5]; for (int i = 0; i < beatmap.notes.Length; i++) { int track = beatmap.notes[i] != null ? beatmap.notes[i].trackIndex : -1; if (track >= 0 && track < counts.Length) counts[track]++; } int bestTrack = 0; int bestCount = int.MaxValue; for (int i = 0; i < counts.Length; i++) { if (counts[i] < bestCount) { bestCount = counts[i]; bestTrack = i; } } return bestTrack; } private void HandleTapJudged(int trackIndex, string result, float songTime, NoteData noteData) { if (activeConfig == null) return; if (IsHit(result)) RecordRecent(recentHitTimes, trackIndex, songTime, 10f); } private void HandleHoldStarted(int trackIndex, string result, float songTime, NoteData noteData) { if (activeConfig == null || currentStageRule == null) return; if (IsHit(result)) RecordRecent(recentHitTimes, trackIndex, songTime, 10f); ExecuteHoldActions(currentStageRule.onHoldStart, trackIndex); } private void HandleHoldCompleted(int trackIndex, string result, float songTime, NoteData noteData) { if (activeConfig == null || currentStageRule == null) return; if (IsHit(result)) RecordRecent(recentHitTimes, trackIndex, songTime, 10f); ExecuteHoldActions(currentStageRule.onHoldComplete, trackIndex); } private void HandleHoldBroken(int trackIndex, string result, float songTime, NoteData noteData) { if (activeConfig == null || currentStageRule == null) return; ExecuteHoldActions(currentStageRule.onHoldBreak, trackIndex); } private void HandleAllySkillCast(int slotIndex, float songTime) { if (activeConfig == null) return; RecordRecent(recentSkillCastTimes, slotIndex, songTime, 10f); UpdateSupportCharge(slotIndex, songTime); UpdateEcho(slotIndex, songTime); } private void HandleEnemySpawned(EnemyCombatant enemy, int stageIndex, EnemyData_SO data) { if (activeConfig == null) return; currentEnemy = enemy; currentEnemyData = data; currentStageIndex = stageIndex; currentStageRule = ResolveStageRule(stageIndex, data); firedHpPhases.Clear(); actionTriggerCounts.Clear(); if (currentStageRule == null) return; if (currentStageRule.clearEchoOnSpawn) ClearEcho(); if (currentStageRule.clearSupportChargesOnSpawn) supportCharges = 0; ExecuteActions(currentStageRule.onSpawn, -1, enemy); } private void HandleEnemyDied(EnemyCombatant enemy, int stageIndex, EnemyData_SO data) { if (activeConfig == null) return; LevelRuleStage stage = ResolveStageRule(stageIndex, data); if (stage != null) ExecuteActions(stage.onDeath, -1, enemy); if (activeConfig.awardAllAlliesAliveScorePerEnemy && activeConfig.allAlliesAliveScoreOnEnemyDeath > 0 && AreAllConfiguredAlliesAlive()) AddPendingScore(activeConfig.allAlliesAliveScoreOnEnemyDeath); } private void HandleEnemyDamaged(EnemyCombatant enemy, float actualDamage, GameObject source) { if (activeConfig == null || currentStageRule == null || enemy == null || enemy != currentEnemy) return; if (!handlingEnemyDamagedActions) { handlingEnemyDamagedActions = true; try { ExecuteActions(currentStageRule.onDamaged, -1, enemy); } finally { handlingEnemyDamagedActions = false; } } EvaluateHpPhases(enemy); } private void HandleEnemyManaFull(EnemyCombatant enemy) { if (activeConfig == null || currentStageRule == null || enemy == null || enemy != currentEnemy) return; ExecuteActions(currentStageRule.onManaFull, -1, enemy); } public bool ShouldPreventEnemyDeath(EnemyCombatant enemy) { if (activeConfig == null || currentStageRule == null || enemy == null || enemy != currentEnemy) return false; if (currentStageRule.minimumDeathSongTime <= 0f) return false; return GameplayClock.NowSongTime < currentStageRule.minimumDeathSongTime; } private LevelRuleStage ResolveStageRule(int stageIndex, EnemyData_SO data) { if (activeConfig == null || activeConfig.stages == null) return null; for (int i = 0; i < activeConfig.stages.Length; i++) { LevelRuleStage stage = activeConfig.stages[i]; if (stage != null && stage.Matches(stageIndex, data)) return stage; } return null; } private void ExecuteHoldActions(LevelRuleHoldAction[] groups, int triggerTrack) { if (groups == null) return; for (int i = 0; i < groups.Length; i++) { LevelRuleHoldAction group = groups[i]; if (group == null) continue; ExecuteActions(group.actions, triggerTrack, currentEnemy); } } private void ExecuteActions(LevelRuleAction[] actions, int triggerTrack, EnemyCombatant eventEnemy) { if (actions == null) return; for (int i = 0; i < actions.Length; i++) ExecuteAction(actions[i], triggerTrack, eventEnemy); } private void ExecuteAction(LevelRuleAction action, int triggerTrack, EnemyCombatant eventEnemy) { if (action == null || action.actionType == LevelRuleActionType.None) return; if (action.onlyIfAllAlliesAlive && !AreAllConfiguredAlliesAlive()) return; string key = BuildActionKey(action, eventEnemy); if (action.maxTriggersPerEnemy > 0) { actionTriggerCounts.TryGetValue(key, out int count); if (count >= action.maxTriggersPerEnemy) return; actionTriggerCounts[key] = count + 1; } if (action.requireSupportCharge && supportCharges <= 0) return; if (action.actionType == LevelRuleActionType.AddTeamScore) { AddPendingScore(Mathf.RoundToInt(ResolveRawAmount(action))); if (action.scoreReward > 0) AddPendingScore(action.scoreReward); return; } if (action.actionType == LevelRuleActionType.SpendSupportChargeForEnemyVulnerability) { if (supportCharges <= 0 || currentEnemy == null) return; supportCharges--; ApplyTimedResistance(currentEnemy, -Mathf.Abs(ResolveRawAmount(action)), Mathf.Max(0.01f, action.duration), key); if (action.scoreReward > 0) AddPendingScore(action.scoreReward); return; } List targets = ResolveTargets(action.target, triggerTrack); for (int i = 0; i < targets.Count; i++) { GameObject target = targets[i]; if (target == null) continue; ApplyActionToTarget(action, target, key); } } private void ApplyActionToTarget(LevelRuleAction action, GameObject target, string key) { AllyCombatant ally = target.GetComponent(); EnemyCombatant enemy = target.GetComponent(); float amount = ResolveAmountForTarget(action, ally, enemy); switch (action.actionType) { case LevelRuleActionType.Damage: if (ally != null) ally.ReceiveDamage(amount, currentEnemy != null ? currentEnemy.gameObject : null); else if (enemy != null) enemy.ReceiveDamage(amount, currentEnemy != null ? currentEnemy.gameObject : null); break; case LevelRuleActionType.Heal: if (ally != null) ally.ReceiveHeal(amount, currentEnemy != null ? currentEnemy.gameObject : null); else if (enemy != null) enemy.ReceiveHeal(amount, currentEnemy != null ? currentEnemy.gameObject : null); break; case LevelRuleActionType.ManaDelta: if (ally != null) ally.ModifyMana(Mathf.RoundToInt(amount), true, true); else if (enemy != null) enemy.ModifyMana(Mathf.RoundToInt(amount), true); break; case LevelRuleActionType.AttackDelta: if (ally != null) ally.ModifyAttack(Mathf.RoundToInt(amount)); else if (enemy != null) enemy.ModifyAttack(Mathf.RoundToInt(amount)); break; case LevelRuleActionType.ResistanceDeltaTimed: if (enemy != null) ApplyTimedResistance(enemy, amount, Mathf.Max(0.01f, action.duration), key); break; case LevelRuleActionType.HealReceivedMultiplierTimed: ApplyTimedHealMultiplier(target, amount, Mathf.Max(0.01f, action.duration), key); break; case LevelRuleActionType.DamageCurrentEnemyByOwnAttack: if (currentEnemy != null) currentEnemy.ReceiveDamage(Mathf.Max(0, currentEnemy.attack), currentEnemy.gameObject); break; case LevelRuleActionType.ClearEnemyMana: if (enemy != null) enemy.ModifyMana(-enemy.currentMana, true); break; } } private float ResolveRawAmount(LevelRuleAction action) { if (action == null) return 0f; float amount = action.amount; if (action.amountScalesByDifficultyLevel) amount *= GetCurrentDifficultyLevel(); return amount; } private float ResolveAmountForTarget(LevelRuleAction action, AllyCombatant ally, EnemyCombatant enemy) { float amount = ResolveRawAmount(action); if (!action.amountIsPercentOfMaxHp) return amount; if (ally != null) return ally.maxHP * amount; if (enemy != null) return enemy.maxHP * amount; return amount; } private List ResolveTargets(LevelRuleTarget target, int triggerTrack) { List targets = new List(); teamUIController ui = teamUIController.Instance; switch (target) { case LevelRuleTarget.CurrentEnemy: if (currentEnemy != null) targets.Add(currentEnemy.gameObject); break; case LevelRuleTarget.AllLivingAllies: AddAllLivingAllies(targets, ui); break; case LevelRuleTarget.TriggerTrackAlly: AddAllyBySlot(targets, ui, triggerTrack); break; case LevelRuleTarget.HighestCurrentManaAlly: AddAllyBySlot(targets, ui, FindBestAllySlotByMana(ui)); break; case LevelRuleTarget.HighestAttackAlly: AddAllyBySlot(targets, ui, FindBestAllySlotByAttack(ui)); break; case LevelRuleTarget.HighestRecentHitTrackAlly: AddAllyBySlot(targets, ui, FindMostRecentCountTrack(recentHitTimes, 8f)); break; case LevelRuleTarget.HighestRecentSkillCastAlly: AddAllyBySlot(targets, ui, FindMostRecentCountTrack(recentSkillCastTimes, 8f)); break; } return targets; } private void AddAllLivingAllies(List targets, teamUIController ui) { if (ui == null) return; for (int i = 0; i < 5; i++) { GameObject go = ui.GetAllyObjectBySlot(i); AllyCombatant ally = go != null ? go.GetComponent() : null; if (ally != null && !ally.IsDead && ally.maxHP > 0) targets.Add(go); } } private void AddAllyBySlot(List targets, teamUIController ui, int slot) { if (ui == null || slot < 0 || slot >= 5) return; GameObject go = ui.GetAllyObjectBySlot(slot); if (go != null) targets.Add(go); } private int FindBestAllySlotByMana(teamUIController ui) { int bestSlot = -1; int bestMana = int.MinValue; if (ui == null) return -1; for (int i = 0; i < 5; i++) { AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent(); if (ally == null || ally.IsDead || ally.maxHP <= 0) continue; if (ally.currentMana > bestMana) { bestMana = ally.currentMana; bestSlot = i; } } return bestSlot; } private int FindBestAllySlotByAttack(teamUIController ui) { int bestSlot = -1; int bestAttack = int.MinValue; if (ui == null) return -1; for (int i = 0; i < 5; i++) { AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent(); if (ally == null || ally.IsDead || ally.maxHP <= 0) continue; if (ally.attack > bestAttack) { bestAttack = ally.attack; bestSlot = i; } } return bestSlot; } private void UpdateSupportCharge(int slotIndex, float songTime) { if (activeConfig == null || supportTrackIndex < 0 || activeConfig.adjacentSkillCastsPerSupportCharge <= 0) return; if (lastSupportCastSlot >= 0 && Mathf.Abs(slotIndex - lastSupportCastSlot) == 1 && songTime - lastSupportCastTime <= activeConfig.adjacentSkillCastWindow) adjacentSupportCastCount++; else adjacentSupportCastCount = 1; lastSupportCastSlot = slotIndex; lastSupportCastTime = songTime; if (adjacentSupportCastCount >= activeConfig.adjacentSkillCastsPerSupportCharge) { supportCharges = Mathf.Clamp(supportCharges + 1, 0, Mathf.Max(0, activeConfig.maxSupportCharges)); adjacentSupportCastCount = 0; if (verboseLogs) Debug.Log($"[LevelRuleController] Support charge +1 => {supportCharges}"); } } private void UpdateEcho(int slotIndex, float songTime) { if (activeConfig == null || !activeConfig.enableAdjacentSkillEcho) return; if (lastEchoCastSlot >= 0 && Mathf.Abs(slotIndex - lastEchoCastSlot) == 1 && songTime - lastEchoCastTime <= activeConfig.echoWindow) echoStacks = Mathf.Clamp(echoStacks + 1, 0, Mathf.Max(1, activeConfig.maxEchoStacks)); else echoStacks = 1; lastEchoCastSlot = slotIndex; lastEchoCastTime = songTime; if (echoStacks >= activeConfig.echoThreshold && currentEnemy != null) { float delta = Mathf.Clamp(activeConfig.echoResistancePerStack * echoStacks, 0f, 0.6f); ApplyTimedResistance(currentEnemy, delta, Mathf.Max(0.01f, activeConfig.echoResistanceDuration), "echo_resistance"); if (activeConfig.echoManaPenalty > 0) { GameObject allyGo = teamUIController.Instance != null ? teamUIController.Instance.GetAllyObjectBySlot(slotIndex) : null; AllyCombatant ally = allyGo != null ? allyGo.GetComponent() : null; ally?.ModifyMana(-activeConfig.echoManaPenalty, true, true); } } } private void ClearEcho() { echoStacks = 0; lastEchoCastSlot = -1; lastEchoCastTime = -999f; } private void EvaluateHpPhases(EnemyCombatant enemy) { if (currentStageRule.hpPhases == null || enemy.maxHP <= 0) return; float hpPercent = Mathf.Clamp01((float)enemy.currentHP / enemy.maxHP); for (int i = 0; i < currentStageRule.hpPhases.Length; i++) { LevelRuleHpPhase phase = currentStageRule.hpPhases[i]; if (phase == null) continue; string key = $"{currentStageIndex}:{enemy.GetInstanceID()}:phase:{i}"; if (phase.triggerOnce && firedHpPhases.Contains(key)) continue; if (hpPercent <= phase.hpPercentThreshold) { firedHpPhases.Add(key); ExecuteActions(phase.actions, -1, enemy); } } } private void ApplyTimedResistance(EnemyCombatant enemy, float delta, float duration, string key) { if (enemy == null) return; string routineKey = $"res:{enemy.GetInstanceID()}:{key}"; StopTimedRoutine(routineKey); timedRoutines[routineKey] = StartCoroutine(TimedResistanceRoutine(enemy, delta, duration, routineKey)); } private IEnumerator TimedResistanceRoutine(EnemyCombatant enemy, float delta, float duration, string routineKey) { if (enemy == null) yield break; enemy.damageResistance = Mathf.Clamp(enemy.damageResistance + delta, -0.95f, 0.6f); yield return GameplayClock.WaitForSeconds(duration); if (enemy != null) enemy.damageResistance = Mathf.Clamp(enemy.damageResistance - delta, -0.95f, 0.6f); timedRoutines.Remove(routineKey); } private void ApplyTimedHealMultiplier(GameObject target, float multiplier, float duration, string key) { ICombatant combatant = target != null ? target.GetComponent() : null; if (combatant == null) return; string routineKey = $"heal:{target.GetInstanceID()}:{key}"; StopTimedRoutine(routineKey); timedRoutines[routineKey] = StartCoroutine(TimedHealMultiplierRoutine(target, combatant, multiplier, duration, routineKey)); } private IEnumerator TimedHealMultiplierRoutine(GameObject target, ICombatant combatant, float multiplier, float duration, string routineKey) { if (target == null || combatant == null) yield break; Buff buff = new Buff(routineKey) { description = "Level rule heal received multiplier", duration = duration, healReceivedMultiplier = Mathf.Max(0f, multiplier) }; combatant.ApplyBuff(buff, currentEnemy != null ? currentEnemy.gameObject : null); yield return GameplayClock.WaitForSeconds(duration); if (target != null) combatant.RemoveBuff(routineKey); timedRoutines.Remove(routineKey); } private void StopTimedRoutine(string key) { if (timedRoutines.TryGetValue(key, out Coroutine routine) && routine != null) StopCoroutine(routine); timedRoutines.Remove(key); } private void RecordRecent(Queue[] buffers, int slot, float songTime, float keepWindow) { if (slot < 0 || slot >= buffers.Length) return; Queue queue = buffers[slot]; queue.Enqueue(songTime); while (queue.Count > 0 && songTime - queue.Peek() > keepWindow) queue.Dequeue(); } private int FindMostRecentCountTrack(Queue[] buffers, float window) { float now = GameplayClock.NowSongTime; int bestTrack = -1; int bestCount = -1; for (int i = 0; i < buffers.Length; i++) { Queue queue = buffers[i]; while (queue.Count > 0 && now - queue.Peek() > window) queue.Dequeue(); if (queue.Count > bestCount) { bestCount = queue.Count; bestTrack = i; } } return bestTrack; } private bool AreAllConfiguredAlliesAlive() { teamUIController ui = teamUIController.Instance; if (ui == null) return false; bool hasAny = false; for (int i = 0; i < 5; i++) { AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent(); if (ally == null || ally.maxHP <= 0) continue; hasAny = true; if (ally.IsDead) return false; } return hasAny; } private int GetCurrentDifficultyLevel() { BeatmapManager beatmapManager = BeatmapManager.Instance; SongData song = beatmapManager != null ? beatmapManager.assignedSongData : currentSong; int difficulty = beatmapManager != null ? beatmapManager.assignedDifficulty : currentDifficulty; if (song != null && song.chartFiles != null) { for (int i = 0; i < song.chartFiles.Count; i++) { ChartFileEntry entry = song.chartFiles[i]; if (entry != null && entry.difficulty == difficulty) return Mathf.Max(1, Mathf.RoundToInt(entry.difficultyLEVEL)); } } return Mathf.Max(1, difficulty); } private void AddPendingScore(int amount) { if (amount <= 0 || GameConfig.autoPlayEnabled) return; pendingSettlementScoreBonus += amount; if (verboseLogs) Debug.Log($"[LevelRuleController] Pending score +{amount}, total={pendingSettlementScoreBonus}"); } private void AddFinalAverageHpScoreIfConfigured() { if (activeConfig == null || activeConfig.finalAverageHpPercentScore <= 0) return; teamUIController ui = teamUIController.Instance; if (ui == null) return; float sumPercent = 0f; int count = 0; for (int i = 0; i < 5; i++) { AllyCombatant ally = ui.GetAllyObjectBySlot(i)?.GetComponent(); if (ally == null || ally.maxHP <= 0) continue; sumPercent += Mathf.Clamp01((float)Mathf.Max(0, ally.currentHP) / ally.maxHP); count++; } if (count <= 0) return; AddPendingScore(Mathf.RoundToInt((sumPercent / count) * activeConfig.finalAverageHpPercentScore)); } private static bool IsHit(string result) { return !string.Equals(result, "Miss", StringComparison.OrdinalIgnoreCase); } private string BuildActionKey(LevelRuleAction action, EnemyCombatant enemy) { string id = !string.IsNullOrWhiteSpace(action.actionId) ? action.actionId : action.actionType.ToString(); int enemyId = enemy != null ? enemy.GetInstanceID() : 0; return $"{currentStageIndex}:{enemyId}:{id}"; } }