Files
2026-07-30 23:15:58 +08:00

935 lines
32 KiB
C#

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<LevelRuleConfig_SO>();
[Header("Runtime")]
[SerializeField] private bool verboseLogs = false;
private readonly Queue<float>[] recentHitTimes = new Queue<float>[5];
private readonly Queue<float>[] recentSkillCastTimes = new Queue<float>[5];
private readonly Dictionary<string, Coroutine> timedRoutines = new Dictionary<string, Coroutine>();
private readonly Dictionary<string, int> actionTriggerCounts = new Dictionary<string, int>();
private readonly HashSet<string> firedHpPhases = new HashSet<string>();
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<LevelRuleController>();
if (existing != null)
{
Instance = existing;
return existing;
}
GameObject runtimeObject = new GameObject("__level_rule_controller_runtime");
DontDestroyOnLoad(runtimeObject);
Instance = runtimeObject.AddComponent<LevelRuleController>();
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<float>();
recentSkillCastTimes[i] = new Queue<float>();
}
}
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<LevelRuleConfig_SO>(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<GameObject> 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<AllyCombatant>();
EnemyCombatant enemy = target.GetComponent<EnemyCombatant>();
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<GameObject> ResolveTargets(LevelRuleTarget target, int triggerTrack)
{
List<GameObject> targets = new List<GameObject>();
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<GameObject> 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<AllyCombatant>() : null;
if (ally != null && !ally.IsDead && ally.maxHP > 0)
targets.Add(go);
}
}
private void AddAllyBySlot(List<GameObject> 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<AllyCombatant>();
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<AllyCombatant>();
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<AllyCombatant>() : 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<ICombatant>() : 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<float>[] buffers, int slot, float songTime, float keepWindow)
{
if (slot < 0 || slot >= buffers.Length)
return;
Queue<float> queue = buffers[slot];
queue.Enqueue(songTime);
while (queue.Count > 0 && songTime - queue.Peek() > keepWindow)
queue.Dequeue();
}
private int FindMostRecentCountTrack(Queue<float>[] buffers, float window)
{
float now = GameplayClock.NowSongTime;
int bestTrack = -1;
int bestCount = -1;
for (int i = 0; i < buffers.Length; i++)
{
Queue<float> 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<AllyCombatant>();
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<AllyCombatant>();
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}";
}
}