Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Audio;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Spawning;
|
||||
using BumpCombat.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace BumpCombat.Progression
|
||||
{
|
||||
public sealed class ArtifactRewardController : MonoBehaviour
|
||||
{
|
||||
private enum RewardSource
|
||||
{
|
||||
Elite,
|
||||
EliteRed,
|
||||
EliteBlue,
|
||||
MidBoss,
|
||||
}
|
||||
|
||||
private const int RewardOptionCount = 2;
|
||||
private const int DebugOptionCount = 6;
|
||||
private const int StartupColorCount = 3;
|
||||
private const float OptionCardWidth = 380f;
|
||||
private const float OptionCardHeight = 128f;
|
||||
private const float OptionLabelHeight = 64f;
|
||||
|
||||
private readonly Queue<RewardSource> pendingRewards = new();
|
||||
private readonly HashSet<RewardSource> queuedSources = new();
|
||||
private readonly ActiveArtifactDefinition[] shownArtifacts =
|
||||
new ActiveArtifactDefinition[DebugOptionCount];
|
||||
private ActiveArtifactController artifacts;
|
||||
private SpawnDirector spawnDirector;
|
||||
private GameObject panel;
|
||||
private Text titleText;
|
||||
private Text[] optionTexts;
|
||||
private Image[] optionCardImages = System.Array.Empty<Image>();
|
||||
private Image[] optionIconImages = System.Array.Empty<Image>();
|
||||
private int shownCount;
|
||||
private int selectedIndex;
|
||||
private bool debugStartupSelection;
|
||||
private int startupSelectionCount;
|
||||
private bool debugStartupSelectionPending;
|
||||
private int debugStartupSelectionEarliestFrame;
|
||||
private int debugSelectionsRequired;
|
||||
private int debugSelectionsRemaining;
|
||||
private int ignoreInputThroughFrame;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public static bool GrantCatalogForTests { get; set; }
|
||||
#endif
|
||||
|
||||
private void Start()
|
||||
{
|
||||
artifacts = FindAnyObjectByType<ActiveArtifactController>();
|
||||
spawnDirector = FindAnyObjectByType<SpawnDirector>();
|
||||
if (artifacts == null || spawnDirector == null)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
CreatePanel();
|
||||
spawnDirector.OnEventEnemyDefeated += HandleEventEnemyDefeated;
|
||||
if (RunManager.Instance != null)
|
||||
{
|
||||
RunManager.Instance.OnSelectionChanged += HandleSelectionChanged;
|
||||
RunManager.Instance.OnRunStarted += HandleRunStarted;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
if (GrantCatalogForTests)
|
||||
{
|
||||
artifacts.DebugGrantCatalogArtifacts();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
QueueStartupSelection(
|
||||
RunManager.Instance?.StartupArtifactSelectionCount ?? 3);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (spawnDirector != null)
|
||||
{
|
||||
spawnDirector.OnEventEnemyDefeated -= HandleEventEnemyDefeated;
|
||||
}
|
||||
if (RunManager.Instance != null)
|
||||
{
|
||||
RunManager.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
||||
RunManager.Instance.OnRunStarted -= HandleRunStarted;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (debugStartupSelectionPending
|
||||
&& Time.frameCount >= debugStartupSelectionEarliestFrame
|
||||
&& RunManager.Instance != null
|
||||
&& !RunManager.Instance.IsTitleScreen
|
||||
&& !RunManager.Instance.IsSelectionOpen
|
||||
&& !RunManager.Instance.IsPaused
|
||||
&& !RunManager.Instance.IsGameOver)
|
||||
{
|
||||
if (OpenStartupSelection())
|
||||
{
|
||||
debugStartupSelectionPending = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (panel == null
|
||||
|| !panel.activeSelf
|
||||
|| shownCount == 0
|
||||
|| Time.frameCount <= ignoreInputThroughFrame
|
||||
|| Keyboard.current == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keyboard.current.leftArrowKey.wasPressedThisFrame
|
||||
|| Keyboard.current.upArrowKey.wasPressedThisFrame)
|
||||
{
|
||||
selectedIndex = (selectedIndex + shownCount - 1) % shownCount;
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_move");
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
else if (Keyboard.current.rightArrowKey.wasPressedThisFrame
|
||||
|| Keyboard.current.downArrowKey.wasPressedThisFrame)
|
||||
{
|
||||
selectedIndex = (selectedIndex + 1) % shownCount;
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_move");
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
else if (Keyboard.current.spaceKey.wasPressedThisFrame)
|
||||
{
|
||||
ChooseOption(selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRunStarted()
|
||||
{
|
||||
QueueStartupSelection(
|
||||
RunManager.Instance?.StartupArtifactSelectionCount ?? 3);
|
||||
}
|
||||
|
||||
private void QueueStartupSelection(int selectionCount)
|
||||
{
|
||||
startupSelectionCount = Mathf.Max(0, selectionCount);
|
||||
debugStartupSelectionPending = true;
|
||||
debugStartupSelectionEarliestFrame = Time.frameCount + 1;
|
||||
}
|
||||
|
||||
private bool ChooseOption(int optionIndex)
|
||||
{
|
||||
if (panel == null
|
||||
|| !panel.activeSelf
|
||||
|| optionIndex < 0
|
||||
|| optionIndex >= shownCount
|
||||
|| !artifacts.TryAddArtifact(shownArtifacts[optionIndex]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
|
||||
if (artifacts.OwnedArtifactCount == 1)
|
||||
{
|
||||
GetComponent<RunTutorialController>()?.NotifyArtifactAcquired();
|
||||
}
|
||||
|
||||
if (debugStartupSelection)
|
||||
{
|
||||
debugSelectionsRemaining--;
|
||||
if (debugSelectionsRemaining > 0)
|
||||
{
|
||||
// Startup selection is deliberately color ordered. Each
|
||||
// step exposes only the two definitions in the next color
|
||||
// group, so a failed duplicate-color click cannot consume
|
||||
// a slot or leave an invalid loadout.
|
||||
if (!PopulateStartupOptions())
|
||||
{
|
||||
debugStartupSelection = false;
|
||||
CloseSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedIndex = 0;
|
||||
UpdateDebugTitle();
|
||||
ShowOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
debugStartupSelection = false;
|
||||
}
|
||||
|
||||
CloseSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool OpenStartupSelection()
|
||||
{
|
||||
if (RunManager.Instance == null
|
||||
|| !RunManager.Instance.TryOpenSelection(this))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
debugSelectionsRequired = Mathf.Min(
|
||||
startupSelectionCount,
|
||||
artifacts.MaxOwnedArtifacts - artifacts.OwnedArtifactCount);
|
||||
if (debugSelectionsRequired <= 0)
|
||||
{
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
debugStartupSelection = true;
|
||||
debugSelectionsRemaining = debugSelectionsRequired;
|
||||
if (!PopulateStartupOptions())
|
||||
{
|
||||
debugStartupSelection = false;
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedIndex = 0;
|
||||
ShowOptions();
|
||||
UpdateDebugTitle();
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool PopulateStartupOptions()
|
||||
{
|
||||
shownCount = 0;
|
||||
int selectedCount = debugSelectionsRequired - debugSelectionsRemaining;
|
||||
if (selectedCount < 0 || selectedCount >= StartupColorCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ArtifactColor requiredColor = GetStartupColor(selectedCount);
|
||||
for (int i = 0;
|
||||
i < artifacts.CatalogCount && shownCount < DebugOptionCount;
|
||||
i++)
|
||||
{
|
||||
ActiveArtifactDefinition definition =
|
||||
artifacts.GetCatalogArtifactAt(i);
|
||||
if (definition == null
|
||||
|| definition.ArtifactColor != requiredColor
|
||||
|| artifacts.OwnsArtifact(definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
shownArtifacts[shownCount++] = definition;
|
||||
}
|
||||
|
||||
return shownCount > 0;
|
||||
}
|
||||
|
||||
private static ArtifactColor GetStartupColor(int selectionIndex)
|
||||
{
|
||||
return selectionIndex switch
|
||||
{
|
||||
0 => ArtifactColor.Green,
|
||||
1 => ArtifactColor.Red,
|
||||
_ => ArtifactColor.Blue,
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetArtifactColorName(ArtifactColor color)
|
||||
{
|
||||
return color switch
|
||||
{
|
||||
ArtifactColor.Green => "GREEN",
|
||||
ArtifactColor.Red => "RED",
|
||||
_ => "BLUE",
|
||||
};
|
||||
}
|
||||
|
||||
private void HandleEventEnemyDefeated(RunTimedEvent timedEvent)
|
||||
{
|
||||
if (timedEvent == RunTimedEvent.Elite)
|
||||
{
|
||||
if (RunManager.Instance?.IsProductionRun == true)
|
||||
{
|
||||
int spawnedEliteCount =
|
||||
spawnDirector?.TimedEliteSpawnCount ?? 0;
|
||||
if (spawnedEliteCount == 2)
|
||||
{
|
||||
QueueReward(RewardSource.EliteRed);
|
||||
}
|
||||
else if (spawnedEliteCount == 3)
|
||||
{
|
||||
QueueReward(RewardSource.EliteBlue);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
QueueReward(RewardSource.Elite);
|
||||
}
|
||||
}
|
||||
else if (timedEvent == RunTimedEvent.MidBoss
|
||||
&& RunManager.Instance?.IsProductionRun != true)
|
||||
{
|
||||
QueueReward(RewardSource.MidBoss);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueReward(RewardSource source)
|
||||
{
|
||||
if (artifacts == null
|
||||
|| artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!queuedSources.Add(source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRewards.Enqueue(source);
|
||||
TryOpenPendingReward();
|
||||
}
|
||||
|
||||
private void TryOpenPendingReward()
|
||||
{
|
||||
if (pendingRewards.Count == 0 || artifacts == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts)
|
||||
{
|
||||
pendingRewards.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (RunManager.Instance == null
|
||||
|| !RunManager.Instance.TryOpenSelection(this))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RewardSource source = pendingRewards.Peek();
|
||||
bool hasRequiredColor = TryGetRewardColor(
|
||||
source,
|
||||
out ArtifactColor requiredColor);
|
||||
List<ActiveArtifactDefinition> candidates = new();
|
||||
for (int i = 0; i < artifacts.CatalogCount; i++)
|
||||
{
|
||||
ActiveArtifactDefinition definition =
|
||||
artifacts.GetCatalogArtifactAt(i);
|
||||
if (definition != null
|
||||
&& !artifacts.OwnsArtifact(definition)
|
||||
&& (!hasRequiredColor
|
||||
|| definition.ArtifactColor == requiredColor))
|
||||
{
|
||||
candidates.Add(definition);
|
||||
}
|
||||
}
|
||||
|
||||
shownCount = Mathf.Min(RewardOptionCount, candidates.Count);
|
||||
if (shownCount == 0)
|
||||
{
|
||||
pendingRewards.Clear();
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRewards.Dequeue();
|
||||
debugStartupSelection = false;
|
||||
for (int i = 0; i < shownCount; i++)
|
||||
{
|
||||
int choiceIndex = Random.Range(0, candidates.Count);
|
||||
shownArtifacts[i] = candidates[choiceIndex];
|
||||
candidates.RemoveAt(choiceIndex);
|
||||
}
|
||||
|
||||
titleText.text = source switch
|
||||
{
|
||||
RewardSource.Elite => "ELITE REWARD - CHOOSE ARTIFACT",
|
||||
RewardSource.EliteRed => "ELITE REWARD - CHOOSE RED ARTIFACT",
|
||||
RewardSource.EliteBlue => "ELITE REWARD - CHOOSE BLUE ARTIFACT",
|
||||
RewardSource.MidBoss => "MID BOSS REWARD - CHOOSE ARTIFACT",
|
||||
_ => "CHOOSE ARTIFACT",
|
||||
};
|
||||
selectedIndex = 0;
|
||||
ShowOptions();
|
||||
}
|
||||
|
||||
private void HandleSelectionChanged(bool isOpen)
|
||||
{
|
||||
if (!isOpen && (panel == null || !panel.activeSelf))
|
||||
{
|
||||
TryOpenPendingReward();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSelectionVisual()
|
||||
{
|
||||
for (int i = 0; i < optionTexts.Length; i++)
|
||||
{
|
||||
bool ownedDebugOption = debugStartupSelection
|
||||
&& i < shownCount
|
||||
&& artifacts.OwnsArtifact(shownArtifacts[i]);
|
||||
bool selected = i == selectedIndex && i < shownCount;
|
||||
optionTexts[i].color = ownedDebugOption
|
||||
? new Color(0.35f, 1f, 0.55f)
|
||||
: selected
|
||||
? new Color(1f, 0.85f, 0.2f)
|
||||
: Color.white;
|
||||
if (i < optionCardImages.Length)
|
||||
{
|
||||
PresentationUiStyle.ApplyCard(
|
||||
optionCardImages[i],
|
||||
selected || ownedDebugOption);
|
||||
optionCardImages[i].gameObject.SetActive(
|
||||
optionTexts[i].gameObject.activeSelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowOptions()
|
||||
{
|
||||
RefreshOptionTexts();
|
||||
LayoutOptions();
|
||||
panel.SetActive(true);
|
||||
ignoreInputThroughFrame = Time.frameCount;
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
|
||||
private void RefreshOptionTexts()
|
||||
{
|
||||
for (int i = 0; i < optionTexts.Length; i++)
|
||||
{
|
||||
bool visible = i < shownCount;
|
||||
optionTexts[i].gameObject.SetActive(visible);
|
||||
if (!visible)
|
||||
{
|
||||
if (i < optionIconImages.Length)
|
||||
{
|
||||
optionIconImages[i].gameObject.SetActive(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
ActiveArtifactDefinition definition = shownArtifacts[i];
|
||||
Sprite icon = PresentationUiStyle.GetArtifactIcon(definition);
|
||||
optionIconImages[i].sprite = icon;
|
||||
optionIconImages[i].enabled = icon != null;
|
||||
optionIconImages[i].gameObject.SetActive(icon != null);
|
||||
optionTexts[i].text = icon != null
|
||||
? definition.DisplayName
|
||||
: $"{definition.PlaceholderSymbol}\n{definition.DisplayName}";
|
||||
if (debugStartupSelection && artifacts.OwnsArtifact(definition))
|
||||
{
|
||||
optionTexts[i].text += "\nSELECTED";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LayoutOptions()
|
||||
{
|
||||
bool twoRows = shownCount > RewardOptionCount;
|
||||
for (int i = 0; i < shownCount; i++)
|
||||
{
|
||||
int row = twoRows ? i / RewardOptionCount : 0;
|
||||
int column = twoRows ? i % RewardOptionCount : i;
|
||||
int rowStart = row * RewardOptionCount;
|
||||
int rowCount = Mathf.Min(
|
||||
RewardOptionCount,
|
||||
shownCount - rowStart);
|
||||
Vector2 cardPosition = new Vector2(
|
||||
(column - (rowCount - 1) * 0.5f) * 430f,
|
||||
twoRows ? 90f - row * 190f : 0f);
|
||||
if (i < optionCardImages.Length)
|
||||
{
|
||||
optionCardImages[i].rectTransform.anchoredPosition =
|
||||
cardPosition;
|
||||
}
|
||||
optionTexts[i].rectTransform.anchoredPosition =
|
||||
cardPosition + Vector2.down * 25f;
|
||||
if (i < optionIconImages.Length)
|
||||
{
|
||||
optionIconImages[i].rectTransform.anchoredPosition =
|
||||
cardPosition + Vector2.up * 20f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDebugTitle()
|
||||
{
|
||||
int selectedCount =
|
||||
debugSelectionsRequired - debugSelectionsRemaining;
|
||||
if (startupSelectionCount <= 1)
|
||||
{
|
||||
titleText.text = "CHOOSE GREEN ARTIFACT "
|
||||
+ $"({selectedCount}/{debugSelectionsRequired})";
|
||||
return;
|
||||
}
|
||||
|
||||
ArtifactColor requiredColor = GetStartupColor(
|
||||
Mathf.Clamp(selectedCount, 0, StartupColorCount - 1));
|
||||
titleText.text =
|
||||
$"CHOOSE {GetArtifactColorName(requiredColor)} ARTIFACT "
|
||||
+ $"({selectedCount}/{debugSelectionsRequired})";
|
||||
}
|
||||
|
||||
private static bool TryGetRewardColor(
|
||||
RewardSource source,
|
||||
out ArtifactColor color)
|
||||
{
|
||||
switch (source)
|
||||
{
|
||||
case RewardSource.EliteRed:
|
||||
color = ArtifactColor.Red;
|
||||
return true;
|
||||
case RewardSource.EliteBlue:
|
||||
color = ArtifactColor.Blue;
|
||||
return true;
|
||||
default:
|
||||
color = ArtifactColor.Green;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseSelection()
|
||||
{
|
||||
panel.SetActive(false);
|
||||
RunManager.Instance?.CloseSelection(this);
|
||||
}
|
||||
|
||||
private void CreatePanel()
|
||||
{
|
||||
Font font = PresentationUiStyle.GetGalmuriFont();
|
||||
panel = PresentationUiStyle.CreatePanel(
|
||||
transform,
|
||||
"Artifact Reward Selection",
|
||||
new Vector2(0.5f, 0.5f),
|
||||
Vector2.zero,
|
||||
new Vector2(1500f, 650f),
|
||||
new Color(0.03f, 0.04f, 0.08f, 0.96f));
|
||||
PresentationUiStyle.ApplyPanel(panel.GetComponent<Image>());
|
||||
|
||||
titleText = PresentationUiStyle.CreateText(
|
||||
panel.transform,
|
||||
"Title",
|
||||
font,
|
||||
38,
|
||||
TextAnchor.MiddleCenter,
|
||||
new Vector2(0f, 255f),
|
||||
new Vector2(1200f, 60f));
|
||||
optionTexts = new Text[DebugOptionCount];
|
||||
optionCardImages = new Image[DebugOptionCount];
|
||||
optionIconImages = new Image[DebugOptionCount];
|
||||
for (int i = 0; i < optionTexts.Length; i++)
|
||||
{
|
||||
optionTexts[i] = PresentationUiStyle.CreateText(
|
||||
panel.transform,
|
||||
$"Option {i + 1}",
|
||||
font,
|
||||
26,
|
||||
TextAnchor.MiddleCenter,
|
||||
Vector2.zero,
|
||||
new Vector2(OptionCardWidth, OptionLabelHeight));
|
||||
optionCardImages[i] = PresentationUiStyle.CreateCardBackdrop(
|
||||
optionTexts[i],
|
||||
$"{optionTexts[i].name} Card",
|
||||
new Vector2(OptionCardWidth, OptionCardHeight));
|
||||
RectTransform optionRect = optionTexts[i].rectTransform;
|
||||
optionIconImages[i] = PresentationUiStyle.CreateImage(
|
||||
optionTexts[i].transform.parent,
|
||||
$"{optionTexts[i].name} Icon",
|
||||
Vector2.zero,
|
||||
new Vector2(48f, 48f));
|
||||
optionIconImages[i].rectTransform.anchorMin = optionRect.anchorMin;
|
||||
optionIconImages[i].rectTransform.anchorMax = optionRect.anchorMax;
|
||||
optionIconImages[i].rectTransform.pivot = optionRect.pivot;
|
||||
optionIconImages[i].rectTransform.anchoredPosition =
|
||||
optionRect.anchoredPosition + Vector2.up * 20f;
|
||||
optionIconImages[i].transform.SetSiblingIndex(
|
||||
optionTexts[i].transform.GetSiblingIndex());
|
||||
optionIconImages[i].enabled = false;
|
||||
}
|
||||
|
||||
Text help = PresentationUiStyle.CreateText(
|
||||
panel.transform,
|
||||
"Help",
|
||||
font,
|
||||
22,
|
||||
TextAnchor.MiddleCenter,
|
||||
new Vector2(0f, -275f),
|
||||
new Vector2(900f, 40f));
|
||||
help.text = "Arrow keys: select Space: confirm";
|
||||
PresentationUiStyle.ApplyText(help, 22);
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public int DebugShownCount => shownCount;
|
||||
public bool DebugIsSelectionVisible => panel != null && panel.activeSelf;
|
||||
|
||||
public ActiveArtifactDefinition DebugGetShownArtifact(int index)
|
||||
{
|
||||
return index >= 0 && index < shownCount
|
||||
? shownArtifacts[index]
|
||||
: null;
|
||||
}
|
||||
|
||||
public bool DebugChooseOption(int optionIndex)
|
||||
{
|
||||
return ChooseOption(optionIndex);
|
||||
}
|
||||
|
||||
public void DebugQueueRedArtifactReward()
|
||||
{
|
||||
QueueReward(RewardSource.EliteRed);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76b8e4d8b6e94e13a97b8a423730b001
|
||||
@@ -0,0 +1,57 @@
|
||||
using BumpCombat.Constants;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Progression
|
||||
{
|
||||
[RequireComponent(typeof(Rigidbody2D), typeof(CircleCollider2D))]
|
||||
public sealed class ExperienceOrb : MonoBehaviour
|
||||
{
|
||||
private Rigidbody2D body;
|
||||
private ExperienceSystem experienceSystem;
|
||||
private Transform player;
|
||||
private int value;
|
||||
private bool pickedUp;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
body = GetComponent<Rigidbody2D>();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
experienceSystem = FindAnyObjectByType<ExperienceSystem>();
|
||||
player = experienceSystem != null ? experienceSystem.transform : null;
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
if (player == null || pickedUp)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float distance = Vector2.Distance(body.position, player.position);
|
||||
ItemConstants tuning = GameplayConstants.Current.Items;
|
||||
if (distance <= tuning.ExperienceOrbPickupRadius)
|
||||
{
|
||||
pickedUp = true;
|
||||
experienceSystem.CollectExperienceOrb(value);
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (distance <= tuning.ExperienceOrbMagnetRadius)
|
||||
{
|
||||
body.MovePosition(Vector2.MoveTowards(
|
||||
body.position,
|
||||
player.position,
|
||||
tuning.ExperienceOrbMagnetSpeed * Time.fixedDeltaTime));
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize(int experienceValue)
|
||||
{
|
||||
value = Mathf.Max(0, experienceValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 970238969bf960c41a3a66c9a708039b
|
||||
@@ -0,0 +1,78 @@
|
||||
using BumpCombat.Core;
|
||||
using System;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Player;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Progression
|
||||
{
|
||||
[RequireComponent(typeof(PlayerStats))]
|
||||
public sealed class ExperienceSystem : MonoBehaviour
|
||||
{
|
||||
private PlayerStats playerStats;
|
||||
private float fractionalExperience;
|
||||
|
||||
public event Action<int, int, int> OnExperienceChanged;
|
||||
public event Action<int> OnLevelGained;
|
||||
public event Action OnExperienceOrbPickedUp;
|
||||
|
||||
public int Level { get; private set; } = 1;
|
||||
public int CurrentExperience { get; private set; }
|
||||
public int RequiredExperience => RequiredExperienceForLevel(Level);
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
playerStats = GetComponent<PlayerStats>();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
OnExperienceChanged?.Invoke(CurrentExperience, RequiredExperience, Level);
|
||||
}
|
||||
|
||||
public void AddExperience(int amount)
|
||||
{
|
||||
AddExperienceInternal(amount);
|
||||
}
|
||||
|
||||
public void AddMissionExperience(int amount)
|
||||
{
|
||||
AddExperienceInternal(amount);
|
||||
}
|
||||
|
||||
public void CollectExperienceOrb(int amount)
|
||||
{
|
||||
AddExperienceInternal(amount);
|
||||
OnExperienceOrbPickedUp?.Invoke();
|
||||
}
|
||||
|
||||
private void AddExperienceInternal(int amount)
|
||||
{
|
||||
fractionalExperience += Mathf.Max(0f, playerStats.Evaluate(
|
||||
CharacterStat.ExperienceGain,
|
||||
Mathf.Max(0, amount)));
|
||||
int wholeExperience = Mathf.FloorToInt(
|
||||
fractionalExperience + 0.0001f);
|
||||
fractionalExperience = Mathf.Max(
|
||||
0f,
|
||||
fractionalExperience - wholeExperience);
|
||||
CurrentExperience += wholeExperience;
|
||||
while (CurrentExperience >= RequiredExperience)
|
||||
{
|
||||
CurrentExperience -= RequiredExperience;
|
||||
Level++;
|
||||
OnLevelGained?.Invoke(Level);
|
||||
}
|
||||
|
||||
OnExperienceChanged?.Invoke(CurrentExperience, RequiredExperience, Level);
|
||||
}
|
||||
|
||||
public static int RequiredExperienceForLevel(int level)
|
||||
{
|
||||
LevelUpConstants tuning = GameplayConstants.Current.LevelUp;
|
||||
return tuning.BaseExperienceToNextLevel
|
||||
+ (Mathf.Max(1, level) - 1)
|
||||
* tuning.AdditionalExperiencePerLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 009a56771162b9246ac1ce54ed280538
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Audio;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace BumpCombat.Progression
|
||||
{
|
||||
public sealed class LevelUpController : MonoBehaviour
|
||||
{
|
||||
private const int MaximumSupportedOptions = 3;
|
||||
|
||||
[SerializeField] private GameObject panel;
|
||||
[SerializeField] private Text[] optionTexts;
|
||||
[System.NonSerialized] private ModifierDefinition[] modifierCatalog;
|
||||
|
||||
private readonly ModifierDefinition[] shownModifiers =
|
||||
new ModifierDefinition[MaximumSupportedOptions];
|
||||
private Image[] optionCardImages = System.Array.Empty<Image>();
|
||||
private ExperienceSystem experienceSystem;
|
||||
private PlayerStats playerStats;
|
||||
private PlayerHealth playerHealth;
|
||||
private int selectedIndex;
|
||||
private int shownCount;
|
||||
private int pendingSelections;
|
||||
private int ignoreInputThroughFrame;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
experienceSystem = FindAnyObjectByType<ExperienceSystem>();
|
||||
if (experienceSystem == null || panel == null)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
playerStats = experienceSystem.GetComponent<PlayerStats>();
|
||||
playerHealth = experienceSystem.GetComponent<PlayerHealth>();
|
||||
experienceSystem.OnLevelGained += QueueSelection;
|
||||
if (RunManager.Instance != null)
|
||||
{
|
||||
RunManager.Instance.OnSelectionChanged += HandleSelectionChanged;
|
||||
}
|
||||
PresentationUiStyle.ApplyPanel(panel.GetComponent<Image>());
|
||||
ApplySelectionTextStyle();
|
||||
CreateCardBackdrops();
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (experienceSystem != null)
|
||||
{
|
||||
experienceSystem.OnLevelGained -= QueueSelection;
|
||||
}
|
||||
if (RunManager.Instance != null)
|
||||
{
|
||||
RunManager.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!panel.activeSelf
|
||||
|| shownCount == 0
|
||||
|| Time.frameCount <= ignoreInputThroughFrame
|
||||
|| Keyboard.current == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keyboard.current.leftArrowKey.wasPressedThisFrame
|
||||
|| Keyboard.current.upArrowKey.wasPressedThisFrame)
|
||||
{
|
||||
selectedIndex = (selectedIndex + shownCount - 1) % shownCount;
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_move");
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
else if (Keyboard.current.rightArrowKey.wasPressedThisFrame
|
||||
|| Keyboard.current.downArrowKey.wasPressedThisFrame)
|
||||
{
|
||||
selectedIndex = (selectedIndex + 1) % shownCount;
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_move");
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
else if (Keyboard.current.spaceKey.wasPressedThisFrame)
|
||||
{
|
||||
bool applied = shownModifiers[selectedIndex].TryApply(
|
||||
playerStats,
|
||||
playerHealth);
|
||||
if (applied)
|
||||
{
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
|
||||
}
|
||||
panel.SetActive(false);
|
||||
RunManager.Instance?.CloseSelection(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueSelection(int level)
|
||||
{
|
||||
pendingSelections++;
|
||||
TryOpenPendingSelection();
|
||||
}
|
||||
|
||||
private void TryOpenPendingSelection()
|
||||
{
|
||||
if (pendingSelections == 0
|
||||
|| RunManager.Instance == null
|
||||
|| !RunManager.Instance.TryOpenSelection(this))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BuildChoices())
|
||||
{
|
||||
pendingSelections--;
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSelections--;
|
||||
selectedIndex = 0;
|
||||
panel.SetActive(true);
|
||||
ignoreInputThroughFrame = Time.frameCount;
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
|
||||
private bool BuildChoices()
|
||||
{
|
||||
List<ModifierDefinition> pool = new();
|
||||
ModifierDefinition[] definitions =
|
||||
modifierCatalog ?? GameplayConstants.Current.LevelUp.ModifierDefinitions;
|
||||
if (definitions != null)
|
||||
{
|
||||
foreach (ModifierDefinition definition in definitions)
|
||||
{
|
||||
if (definition != null && definition.CanApply(playerStats))
|
||||
{
|
||||
pool.Add(definition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shownCount = Mathf.Min(
|
||||
Mathf.Clamp(
|
||||
GameplayConstants.Current.LevelUp.OptionCount,
|
||||
1,
|
||||
MaximumSupportedOptions),
|
||||
optionTexts?.Length ?? 0,
|
||||
pool.Count);
|
||||
for (int i = 0; i < shownCount; i++)
|
||||
{
|
||||
int choiceIndex = Random.Range(0, pool.Count);
|
||||
shownModifiers[i] = pool[choiceIndex];
|
||||
pool.RemoveAt(choiceIndex);
|
||||
optionTexts[i].text = shownModifiers[i].DisplayName;
|
||||
optionTexts[i].gameObject.SetActive(true);
|
||||
}
|
||||
for (int i = shownCount; i < (optionTexts?.Length ?? 0); i++)
|
||||
{
|
||||
optionTexts[i].gameObject.SetActive(false);
|
||||
}
|
||||
return shownCount > 0;
|
||||
}
|
||||
|
||||
private void HandleSelectionChanged(bool isOpen)
|
||||
{
|
||||
if (!isOpen && !panel.activeSelf)
|
||||
{
|
||||
TryOpenPendingSelection();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSelectionVisual()
|
||||
{
|
||||
for (int i = 0; i < (optionTexts?.Length ?? 0); i++)
|
||||
{
|
||||
bool selected = i == selectedIndex && i < shownCount;
|
||||
optionTexts[i].color = selected
|
||||
? new Color(1f, 0.85f, 0.2f)
|
||||
: Color.white;
|
||||
if (i < optionCardImages.Length)
|
||||
{
|
||||
PresentationUiStyle.ApplyCard(
|
||||
optionCardImages[i],
|
||||
selected);
|
||||
optionCardImages[i].gameObject.SetActive(
|
||||
optionTexts[i].gameObject.activeSelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySelectionTextStyle()
|
||||
{
|
||||
Text[] texts = panel.GetComponentsInChildren<Text>(true);
|
||||
foreach (Text text in texts)
|
||||
{
|
||||
PresentationUiStyle.ApplyText(text, Mathf.Max(1, text.fontSize));
|
||||
if (text.name == "Title")
|
||||
{
|
||||
text.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
text.resizeTextForBestFit = true;
|
||||
text.resizeTextMinSize = 24;
|
||||
text.resizeTextMaxSize = 38;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateCardBackdrops()
|
||||
{
|
||||
optionCardImages = new Image[optionTexts?.Length ?? 0];
|
||||
for (int i = 0; i < optionCardImages.Length; i++)
|
||||
{
|
||||
Text optionText = optionTexts[i];
|
||||
if (optionText == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
optionCardImages[i] = PresentationUiStyle.CreateCardBackdrop(
|
||||
optionText,
|
||||
$"Option Card {i + 1}",
|
||||
optionText.rectTransform.sizeDelta);
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public bool DebugIsSelectionVisible => panel != null && panel.activeSelf;
|
||||
|
||||
public void Configure(
|
||||
GameObject selectionPanel,
|
||||
Text[] options,
|
||||
ModifierDefinition[] definitions)
|
||||
{
|
||||
panel = selectionPanel;
|
||||
optionTexts = options;
|
||||
modifierCatalog = definitions;
|
||||
}
|
||||
|
||||
public ModifierDefinition GetShownModifier(int index)
|
||||
{
|
||||
return index >= 0 && index < shownCount
|
||||
? shownModifiers[index]
|
||||
: null;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8edf39fa136d4c0448af81e2b9c53958
|
||||
@@ -0,0 +1,76 @@
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Progression
|
||||
{
|
||||
[CreateAssetMenu(
|
||||
fileName = "ModifierDefinition",
|
||||
menuName = "BumpCombat/Progression/Modifier Definition")]
|
||||
public sealed class ModifierDefinition : ScriptableObject
|
||||
{
|
||||
[SerializeField] private string modifierId;
|
||||
[SerializeField] private string displayName;
|
||||
[SerializeField] private CharacterStat stat;
|
||||
[SerializeField] private ModifierOperation operation;
|
||||
[SerializeField] private float value;
|
||||
[SerializeField, Min(1)] private int maximumStacks = 1;
|
||||
[SerializeField, Min(0f)] private float healOnApply;
|
||||
|
||||
public string ModifierId => modifierId;
|
||||
public string DisplayName => displayName;
|
||||
public CharacterStat Stat => stat;
|
||||
public ModifierOperation Operation => operation;
|
||||
public float Value => value;
|
||||
public int MaximumStacks => maximumStacks;
|
||||
public float HealOnApply => healOnApply;
|
||||
|
||||
public bool CanApply(PlayerStats playerStats)
|
||||
{
|
||||
return playerStats != null
|
||||
&& playerStats.GetStackCount(modifierId, stat) < maximumStacks;
|
||||
}
|
||||
|
||||
public bool TryApply(
|
||||
PlayerStats playerStats,
|
||||
PlayerHealth playerHealth)
|
||||
{
|
||||
if (!CanApply(playerStats)
|
||||
|| !playerStats.AddModifier(new StatModifier(
|
||||
modifierId,
|
||||
stat,
|
||||
operation,
|
||||
value,
|
||||
maximumStacks)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (healOnApply > 0f && playerHealth != null)
|
||||
{
|
||||
playerHealth.Heal(healOnApply);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public void Configure(
|
||||
string id,
|
||||
string label,
|
||||
CharacterStat targetStat,
|
||||
ModifierOperation modifierOperation,
|
||||
float modifierValue,
|
||||
int maxStacks,
|
||||
float immediateHeal = 0f)
|
||||
{
|
||||
modifierId = id;
|
||||
displayName = label;
|
||||
stat = targetStat;
|
||||
operation = modifierOperation;
|
||||
value = modifierValue;
|
||||
maximumStacks = Mathf.Max(1, maxStacks);
|
||||
healOnApply = Mathf.Max(0f, immediateHeal);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b7f00bdc3dc4bc1b52505c5d2aaf001
|
||||
@@ -0,0 +1,507 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Tracks the fixed run missions from confirmed gameplay results.</summary>
|
||||
[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<int> effectiveArtifactCastIds = new();
|
||||
private readonly HashSet<int> chargedArtifactCastIds = new();
|
||||
private readonly Dictionary<int, HashSet<int>> targetsPerArtifactCast = new();
|
||||
private readonly HashSet<int> rearBumpedEnemyLifetimes = new();
|
||||
private readonly HashSet<int> guardActivationsThatBlocked = new();
|
||||
private readonly HashSet<int> processedEnemyDeaths = new();
|
||||
private readonly Queue<float> 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<ExperienceSystem>();
|
||||
playerHealth = FindAnyObjectByType<PlayerHealth>();
|
||||
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<EnemyController>();
|
||||
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<EnemyController>();
|
||||
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<EnemyController>();
|
||||
if (enemy == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (completed[5])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetsPerArtifactCast.TryGetValue(
|
||||
result.CastIdentity,
|
||||
out HashSet<int> targets))
|
||||
{
|
||||
targets = new HashSet<int>();
|
||||
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<EnemyController>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b05340e212354ba7b5b5485b246874b7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,433 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Progression
|
||||
{
|
||||
public static class MissionIds
|
||||
{
|
||||
public const string FirstSweep = "mission.first_sweep";
|
||||
public const string GrowthFooting = "mission.growth_footing";
|
||||
public const string BodyBreakthrough = "mission.body_breakthrough";
|
||||
public const string ArtifactUse = "mission.artifact_use";
|
||||
public const string RearAttack = "mission.rear_attack";
|
||||
public const string CrowdControl = "mission.crowd_control";
|
||||
public const string CancelAttack = "mission.cancel_attack";
|
||||
public const string GuardDefense = "mission.guard_defense";
|
||||
public const string MatchShield = "mission.match_shield";
|
||||
public const string ChargedMoment = "mission.charged_moment";
|
||||
public const string Rampage = "mission.rampage";
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class UserProfile
|
||||
{
|
||||
public int schemaVersion = UserProfileStore.CurrentSchemaVersion;
|
||||
public List<UserProfileMissionTotal> missionTotals = new();
|
||||
public List<UserProfileAchievementState> achievements = new();
|
||||
public List<string> unlockedSkinIds = new();
|
||||
public string selectedSkinId = string.Empty;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class UserProfileMissionTotal
|
||||
{
|
||||
public string missionId;
|
||||
public int completions;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class UserProfileAchievementState
|
||||
{
|
||||
public string achievementId;
|
||||
public bool unlocked;
|
||||
}
|
||||
|
||||
public readonly struct SteamIntegerStatSnapshot
|
||||
{
|
||||
public SteamIntegerStatSnapshot(string apiName, int value)
|
||||
{
|
||||
ApiName = apiName;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public string ApiName { get; }
|
||||
public int Value { get; }
|
||||
}
|
||||
|
||||
public readonly struct SteamAchievementDefinition
|
||||
{
|
||||
public SteamAchievementDefinition(string achievementId, string steamApiName)
|
||||
{
|
||||
AchievementId = achievementId;
|
||||
SteamApiName = steamApiName;
|
||||
}
|
||||
|
||||
public string AchievementId { get; }
|
||||
public string SteamApiName { get; }
|
||||
}
|
||||
|
||||
/// <summary>Local versioned profile persistence and Steam INT stat mapping only.</summary>
|
||||
public sealed class UserProfileStore
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
public const int MaximumSteamIntegerStat = int.MaxValue;
|
||||
public const string DefaultFileName = "user-profile.json";
|
||||
|
||||
private static readonly (string MissionId, string ApiName)[] SteamIntegerMappings =
|
||||
{
|
||||
(MissionIds.FirstSweep, "bc_mission_first_sweep_completions"),
|
||||
(MissionIds.GrowthFooting, "bc_mission_growth_footing_completions"),
|
||||
(MissionIds.BodyBreakthrough, "bc_mission_body_breakthrough_completions"),
|
||||
(MissionIds.ArtifactUse, "bc_mission_artifact_use_completions"),
|
||||
(MissionIds.RearAttack, "bc_mission_rear_attack_completions"),
|
||||
(MissionIds.CrowdControl, "bc_mission_crowd_control_completions"),
|
||||
(MissionIds.CancelAttack, "bc_mission_cancel_attack_completions"),
|
||||
(MissionIds.GuardDefense, "bc_mission_guard_defense_completions"),
|
||||
(MissionIds.MatchShield, "bc_mission_match_shield_completions"),
|
||||
(MissionIds.ChargedMoment, "bc_mission_charged_moment_completions"),
|
||||
(MissionIds.Rampage, "bc_mission_rampage_completions"),
|
||||
};
|
||||
private static readonly Regex SchemaVersionPattern = new(
|
||||
"\\\"schemaVersion\\\"\\s*:\\s*(-?\\d+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private readonly string filePath;
|
||||
|
||||
public UserProfileStore(string filePath = null)
|
||||
{
|
||||
this.filePath = string.IsNullOrWhiteSpace(filePath)
|
||||
? Path.Combine(Application.persistentDataPath, DefaultFileName)
|
||||
: filePath;
|
||||
Load();
|
||||
}
|
||||
|
||||
public UserProfile Profile { get; private set; }
|
||||
public string FilePath => filePath;
|
||||
public bool CanSave { get; private set; }
|
||||
public string LoadIssue { get; private set; }
|
||||
|
||||
public int GetMissionCompletions(string missionId)
|
||||
{
|
||||
if (Profile?.missionTotals == null || string.IsNullOrEmpty(missionId))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Profile.missionTotals.Count; i++)
|
||||
{
|
||||
UserProfileMissionTotal total = Profile.missionTotals[i];
|
||||
if (total != null && total.missionId == missionId)
|
||||
{
|
||||
return Mathf.Max(0, total.completions);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int GetTotalMissionCompletions()
|
||||
{
|
||||
if (Profile?.missionTotals == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
for (int i = 0; i < Profile.missionTotals.Count; i++)
|
||||
{
|
||||
UserProfileMissionTotal entry = Profile.missionTotals[i];
|
||||
if (entry != null)
|
||||
{
|
||||
total += Mathf.Max(0, entry.completions);
|
||||
}
|
||||
}
|
||||
|
||||
return total > int.MaxValue ? int.MaxValue : (int)total;
|
||||
}
|
||||
|
||||
public bool IncrementMissionCompletion(string missionId)
|
||||
{
|
||||
if (!CanSave || string.IsNullOrWhiteSpace(missionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Profile.missionTotals == null)
|
||||
{
|
||||
Profile.missionTotals = new List<UserProfileMissionTotal>();
|
||||
}
|
||||
|
||||
UserProfileMissionTotal existing = null;
|
||||
for (int i = 0; i < Profile.missionTotals.Count; i++)
|
||||
{
|
||||
UserProfileMissionTotal entry = Profile.missionTotals[i];
|
||||
if (entry != null && entry.missionId == missionId)
|
||||
{
|
||||
existing = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
existing = new UserProfileMissionTotal { missionId = missionId };
|
||||
Profile.missionTotals.Add(existing);
|
||||
}
|
||||
|
||||
existing.completions = SaturatingAdd(existing.completions, 1);
|
||||
return Save();
|
||||
}
|
||||
|
||||
public bool UnlockAchievement(string achievementId)
|
||||
{
|
||||
if (!CanSave || string.IsNullOrWhiteSpace(achievementId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Profile.achievements == null)
|
||||
{
|
||||
Profile.achievements = new List<UserProfileAchievementState>();
|
||||
}
|
||||
|
||||
for (int i = 0; i < Profile.achievements.Count; i++)
|
||||
{
|
||||
UserProfileAchievementState state = Profile.achievements[i];
|
||||
if (state != null && state.achievementId == achievementId)
|
||||
{
|
||||
if (state.unlocked)
|
||||
{
|
||||
return LoadIssue == "save-failed" ? Save() : true;
|
||||
}
|
||||
|
||||
state.unlocked = true;
|
||||
return Save();
|
||||
}
|
||||
}
|
||||
|
||||
Profile.achievements.Add(new UserProfileAchievementState
|
||||
{
|
||||
achievementId = achievementId,
|
||||
unlocked = true,
|
||||
});
|
||||
return Save();
|
||||
}
|
||||
|
||||
public SteamIntegerStatSnapshot[] CreateSteamIntegerStatSnapshot()
|
||||
{
|
||||
SteamIntegerStatSnapshot[] result =
|
||||
new SteamIntegerStatSnapshot[SteamIntegerMappings.Length];
|
||||
for (int i = 0; i < SteamIntegerMappings.Length; i++)
|
||||
{
|
||||
(string missionId, string apiName) = SteamIntegerMappings[i];
|
||||
result[i] = new SteamIntegerStatSnapshot(
|
||||
apiName,
|
||||
Mathf.Clamp(GetMissionCompletions(missionId), 0, MaximumSteamIntegerStat));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public string[] CreateSteamUnlockedAchievementApiNameSnapshot(
|
||||
IReadOnlyList<SteamAchievementDefinition> definitions)
|
||||
{
|
||||
if (definitions == null || Profile?.achievements == null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
List<string> result = new();
|
||||
for (int i = 0; i < definitions.Count; i++)
|
||||
{
|
||||
SteamAchievementDefinition definition = definitions[i];
|
||||
if (string.IsNullOrWhiteSpace(definition.AchievementId)
|
||||
|| string.IsNullOrWhiteSpace(definition.SteamApiName)
|
||||
|| !IsAchievementUnlocked(definition.AchievementId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(definition.SteamApiName);
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
Profile = new UserProfile();
|
||||
Normalize(Profile);
|
||||
CanSave = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(filePath);
|
||||
Match schemaMatch = SchemaVersionPattern.Match(json ?? string.Empty);
|
||||
if (!schemaMatch.Success
|
||||
|| !int.TryParse(schemaMatch.Groups[1].Value, out int schemaVersion))
|
||||
{
|
||||
Profile = new UserProfile();
|
||||
Normalize(Profile);
|
||||
CanSave = false;
|
||||
LoadIssue = "malformed";
|
||||
return;
|
||||
}
|
||||
|
||||
UserProfile loaded = JsonUtility.FromJson<UserProfile>(json);
|
||||
if (loaded == null
|
||||
|| loaded.schemaVersion != schemaVersion
|
||||
|| schemaVersion != CurrentSchemaVersion)
|
||||
{
|
||||
Profile = new UserProfile();
|
||||
Normalize(Profile);
|
||||
CanSave = false;
|
||||
LoadIssue = loaded == null
|
||||
? "malformed"
|
||||
: loaded.schemaVersion > CurrentSchemaVersion
|
||||
? "newer-schema"
|
||||
: "unsupported-schema";
|
||||
return;
|
||||
}
|
||||
|
||||
Profile = loaded;
|
||||
Normalize(Profile);
|
||||
CanSave = true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException
|
||||
|| exception is UnauthorizedAccessException
|
||||
|| exception is ArgumentException
|
||||
|| exception is FormatException)
|
||||
{
|
||||
Profile = new UserProfile();
|
||||
Normalize(Profile);
|
||||
CanSave = false;
|
||||
LoadIssue = "malformed";
|
||||
}
|
||||
}
|
||||
|
||||
private bool Save()
|
||||
{
|
||||
if (!CanSave || Profile == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string temporaryPath = filePath + ".tmp";
|
||||
try
|
||||
{
|
||||
string directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(temporaryPath, JsonUtility.ToJson(Profile, true));
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Replace(temporaryPath, filePath, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
LoadIssue = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException
|
||||
|| exception is UnauthorizedAccessException
|
||||
|| exception is ArgumentException)
|
||||
{
|
||||
TryDeleteTemporaryFile(temporaryPath);
|
||||
LoadIssue = "save-failed";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Normalize(UserProfile profile)
|
||||
{
|
||||
profile.missionTotals ??= new List<UserProfileMissionTotal>();
|
||||
profile.achievements ??= new List<UserProfileAchievementState>();
|
||||
profile.unlockedSkinIds ??= new List<string>();
|
||||
profile.selectedSkinId ??= string.Empty;
|
||||
for (int i = profile.missionTotals.Count - 1; i >= 0; i--)
|
||||
{
|
||||
UserProfileMissionTotal entry = profile.missionTotals[i];
|
||||
if (entry == null || string.IsNullOrWhiteSpace(entry.missionId))
|
||||
{
|
||||
profile.missionTotals.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.completions = Mathf.Max(0, entry.completions);
|
||||
}
|
||||
|
||||
for (int i = profile.achievements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (profile.achievements[i] == null
|
||||
|| string.IsNullOrWhiteSpace(profile.achievements[i].achievementId))
|
||||
{
|
||||
profile.achievements.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = profile.unlockedSkinIds.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile.unlockedSkinIds[i]))
|
||||
{
|
||||
profile.unlockedSkinIds.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int SaturatingAdd(int value, int amount)
|
||||
{
|
||||
long result = (long)Mathf.Max(0, value) + Mathf.Max(0, amount);
|
||||
return result >= int.MaxValue ? int.MaxValue : (int)result;
|
||||
}
|
||||
|
||||
private static void TryDeleteTemporaryFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A failed temporary cleanup does not affect the original profile.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// A failed temporary cleanup does not affect the original profile.
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsAchievementUnlocked(string achievementId)
|
||||
{
|
||||
for (int i = 0; i < Profile.achievements.Count; i++)
|
||||
{
|
||||
UserProfileAchievementState state = Profile.achievements[i];
|
||||
if (state != null
|
||||
&& state.achievementId == achievementId
|
||||
&& state.unlocked)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2cf245ffdab14d6b881058c5d5755d15
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user