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,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;
}
}
}