935 lines
32 KiB
C#
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}";
|
|
}
|
|
}
|