Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
@@ -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: