using System; using System.Collections.Generic; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using UnityEngine; namespace BumpCombat.Progression { public readonly struct RunMissionDefinition { public RunMissionDefinition( string id, string name, string condition, int target, int experienceReward, bool requiresGuardUnlock = false, bool requiresChargeUnlock = false) { Id = id; Name = name; Condition = condition; Target = target; ExperienceReward = experienceReward; RequiresGuardUnlock = requiresGuardUnlock; RequiresChargeUnlock = requiresChargeUnlock; } public string Id { get; } public string Name { get; } public string Condition { get; } public int Target { get; } public int ExperienceReward { get; } public bool RequiresGuardUnlock { get; } public bool RequiresChargeUnlock { get; } } public readonly struct RunMissionProgress { public RunMissionProgress( RunMissionDefinition definition, int progress, bool completed, bool locked) { Definition = definition; Progress = progress; Completed = completed; Locked = locked; } public RunMissionDefinition Definition { get; } public int Progress { get; } public bool Completed { get; } public bool Locked { get; } } /// Tracks the fixed run missions from confirmed gameplay results. [DisallowMultipleComponent] public sealed class RunMissionTracker : MonoBehaviour { public const int MissionCount = 11; private static readonly string[] DefinitionIds = { MissionIds.FirstSweep, MissionIds.GrowthFooting, MissionIds.BodyBreakthrough, MissionIds.ArtifactUse, MissionIds.RearAttack, MissionIds.CrowdControl, MissionIds.CancelAttack, MissionIds.GuardDefense, MissionIds.MatchShield, MissionIds.ChargedMoment, MissionIds.Rampage, }; private readonly int[] progress = new int[MissionCount]; private readonly bool[] completed = new bool[MissionCount]; private readonly HashSet effectiveArtifactCastIds = new(); private readonly HashSet chargedArtifactCastIds = new(); private readonly Dictionary> targetsPerArtifactCast = new(); private readonly HashSet rearBumpedEnemyLifetimes = new(); private readonly HashSet guardActivationsThatBlocked = new(); private readonly HashSet processedEnemyDeaths = new(); private readonly Queue recentNonsummonedKillTimes = new(); private RunManager runManager; private ExperienceSystem experienceSystem; private PlayerHealth playerHealth; private UserProfileStore profileStore; private bool runHasStarted; public static RunMissionTracker Instance { get; private set; } public static float RampageWindowSeconds => GameplayConstants.Current.LevelUp.RampageWindowSeconds; public static int RampageKillTarget => GetDefinition(MissionCount - 1).Target; public event Action OnProgressChanged; public UserProfileStore ProfileStore => profileStore; public int MissionsCompletedThisRun { get { int result = 0; for (int i = 0; i < completed.Length; i++) { if (completed[i]) result++; } return result; } } public static RunMissionDefinition GetDefinition(int index) { index = Mathf.Clamp(index, 0, MissionCount - 1); LevelUpConstants constants = GameplayConstants.Current.LevelUp; MissionTuning tuning = constants.GetMission(index, default); string condition = string.Format( tuning.ConditionFormat ?? string.Empty, tuning.Target, constants.RampageWindowSeconds); return new RunMissionDefinition( DefinitionIds[index], tuning.DisplayName ?? string.Empty, condition, tuning.Target, tuning.ExperienceReward, tuning.RequiresGuardUnlock, tuning.RequiresChargeUnlock); } private void Awake() { if (Instance == null || Instance == this) { Instance = this; } profileStore = new UserProfileStore(); } private void Start() { runManager = RunManager.Instance; experienceSystem = FindAnyObjectByType(); playerHealth = FindAnyObjectByType(); if (runManager != null) { runManager.OnRunStarted += HandleRunStarted; runManager.OnGameOver += HandleGameOver; } if (experienceSystem != null) { experienceSystem.OnExperienceOrbPickedUp += HandleExperienceOrbPickedUp; } if (playerHealth != null) { playerHealth.OnGuardAttackBlocked += HandleGuardAttackBlocked; } CombatEvents.OnEnemyDied += HandleEnemyDied; CombatEvents.OnValidHit += HandleValidHit; CombatEvents.OnArtifactEffectiveHit += HandleArtifactEffectiveHit; CombatEvents.OnRearAttackCancelled += HandleRearAttackCancelled; if (runManager != null && !runManager.IsTitleScreen && !runManager.IsGameOver) { HandleRunStarted(); } } private void Update() { if (!runHasStarted || completed[MissionCount - 1]) { return; } RefreshRampageProgress(GetCombatTime()); } private void OnDestroy() { if (runManager != null) { runManager.OnRunStarted -= HandleRunStarted; runManager.OnGameOver -= HandleGameOver; } if (experienceSystem != null) { experienceSystem.OnExperienceOrbPickedUp -= HandleExperienceOrbPickedUp; } if (playerHealth != null) { playerHealth.OnGuardAttackBlocked -= HandleGuardAttackBlocked; } CombatEvents.OnEnemyDied -= HandleEnemyDied; CombatEvents.OnValidHit -= HandleValidHit; CombatEvents.OnArtifactEffectiveHit -= HandleArtifactEffectiveHit; CombatEvents.OnRearAttackCancelled -= HandleRearAttackCancelled; if (Instance == this) { Instance = null; } } public RunMissionProgress GetProgress(int index) { index = Mathf.Clamp(index, 0, MissionCount - 1); RunMissionDefinition definition = GetDefinition(index); bool locked = IsLocked(definition); return new RunMissionProgress( definition, progress[index], completed[index], locked); } public int GetLifetimeMissionCompletions() { return profileStore?.GetTotalMissionCompletions() ?? 0; } public int GetLifetimeRampageCompletions() { return profileStore?.GetMissionCompletions(MissionIds.Rampage) ?? 0; } public void UseProfileStoreForTests(UserProfileStore isolatedStore) { if (isolatedStore != null) { profileStore = isolatedStore; } } private void HandleRunStarted() { Array.Clear(progress, 0, progress.Length); Array.Clear(completed, 0, completed.Length); effectiveArtifactCastIds.Clear(); chargedArtifactCastIds.Clear(); targetsPerArtifactCast.Clear(); rearBumpedEnemyLifetimes.Clear(); guardActivationsThatBlocked.Clear(); processedEnemyDeaths.Clear(); recentNonsummonedKillTimes.Clear(); runHasStarted = true; OnProgressChanged?.Invoke(); } private void HandleGameOver() { runHasStarted = false; } private void HandleEnemyDied(GameObject target) { if (!runHasStarted || target == null) { return; } EnemyController enemy = target.GetComponent(); if (enemy == null || enemy.IsSummoned || (completed[0] && completed[MissionCount - 1]) || !processedEnemyDeaths.Add(enemy.SpawnLifetimeIdentity)) { return; } if (!enemy.IsEventEnemy) { AddProgress(0, 1); } if (!completed[MissionCount - 1]) { float now = GetCombatTime(); recentNonsummonedKillTimes.Enqueue(now); RefreshRampageProgress(now); } } private void HandleExperienceOrbPickedUp() { if (runHasStarted) { AddProgress(1, 1); } } private void HandleValidHit(CombatHitResult result) { if (!runHasStarted || result.IsArtifactHit || result.IsDash || result.Damage <= 0f || result.Target == null) { return; } AddProgress(2, 1); if (result.Side != HitSide.Back) { return; } EnemyController enemy = result.Target.GetComponent(); if (!completed[4] && enemy != null && rearBumpedEnemyLifetimes.Add(enemy.SpawnLifetimeIdentity)) { AddProgress(4, 1); } } private void HandleArtifactEffectiveHit(ArtifactEffectiveHitResult result) { if (!runHasStarted || result.Target == null || result.CastIdentity <= 0) { return; } if (!completed[3] && effectiveArtifactCastIds.Add(result.CastIdentity)) { AddProgress(3, 1); } if (result.IsCharged && !completed[9] && chargedArtifactCastIds.Add(result.CastIdentity)) { AddProgress(9, 1); } if (result.MatchedShieldRequirement && !completed[8]) { AddProgress(8, 1); } EnemyController enemy = result.Target.GetComponent(); if (enemy == null) { return; } if (completed[5]) { return; } if (!targetsPerArtifactCast.TryGetValue( result.CastIdentity, out HashSet targets)) { targets = new HashSet(); targetsPerArtifactCast.Add(result.CastIdentity, targets); } if (targets.Add(enemy.SpawnLifetimeIdentity) && targets.Count > progress[5]) { progress[5] = Mathf.Min( GetDefinition(5).Target, targets.Count); OnProgressChanged?.Invoke(); if (progress[5] >= GetDefinition(5).Target) { targetsPerArtifactCast.Clear(); CompleteMission(5); } } } private void HandleRearAttackCancelled(GameObject target) { if (!runHasStarted || target == null) { return; } EnemyController enemy = target.GetComponent(); if (!completed[6] && enemy != null && !enemy.IsEventEnemy && !enemy.IsSummoned) { AddProgress(6, 1); } } private void HandleGuardAttackBlocked(int guardActivationIdentity) { if (!runHasStarted || guardActivationIdentity <= 0) { return; } if (!completed[7] && guardActivationsThatBlocked.Add(guardActivationIdentity)) { AddProgress(7, 1); } } private void RefreshRampageProgress(float now) { if (completed[MissionCount - 1]) { return; } float cutoff = now - RampageWindowSeconds; while (recentNonsummonedKillTimes.Count > 0 && recentNonsummonedKillTimes.Peek() < cutoff) { recentNonsummonedKillTimes.Dequeue(); } int recentKills = Mathf.Min( RampageKillTarget, recentNonsummonedKillTimes.Count); int index = MissionCount - 1; if (progress[index] != recentKills) { progress[index] = recentKills; OnProgressChanged?.Invoke(); } if (recentKills >= RampageKillTarget) { CompleteMission(index); } } private void AddProgress(int index, int amount) { if (index < 0 || index >= MissionCount || amount <= 0 || completed[index] || IsLocked(GetDefinition(index))) { return; } RunMissionDefinition definition = GetDefinition(index); int next = Mathf.Min(definition.Target, progress[index] + amount); if (next == progress[index]) { return; } progress[index] = next; OnProgressChanged?.Invoke(); if (next >= definition.Target) { CompleteMission(index); } } private void CompleteMission(int index) { if (completed[index]) { return; } completed[index] = true; RunMissionDefinition definition = GetDefinition(index); if (index == MissionCount - 1) { recentNonsummonedKillTimes.Clear(); } if (runManager != null && runManager.IsProductionRun) { profileStore?.IncrementMissionCompletion(definition.Id); } OnProgressChanged?.Invoke(); // Keep accepting the remaining contacts from an action even if this // award opens a level-up selection and pauses the same frame. experienceSystem?.AddMissionExperience(definition.ExperienceReward); } private bool IsLocked(RunMissionDefinition definition) { if (runManager == null) { return false; } return (definition.RequiresGuardUnlock && !runManager.IsGuardUnlocked) || (definition.RequiresChargeUnlock && !runManager.IsArtifactChargeUnlocked); } private float GetCombatTime() { return runManager != null ? runManager.ElapsedTime : Time.time; } } }