加入用户最近100场战绩记录并实现展示
This commit is contained in:
@@ -8,6 +8,7 @@ public class AllyHero_SO : ScriptableObject
|
||||
public string ally_heroName;
|
||||
public string ally_heroDesignation;
|
||||
public int ally_heroID;
|
||||
public bool isUnlocked;
|
||||
|
||||
[Header("Inspector")]
|
||||
public Sprite ally_heroImage;
|
||||
@@ -88,6 +89,7 @@ public class AllyHero_SO : ScriptableObject
|
||||
|
||||
[Header("Inspector")]
|
||||
public int ally_currentEXP;
|
||||
public int ally_battleDeployCount;
|
||||
|
||||
[Header("Skills")]
|
||||
public SkillDefinition[] availableSkills;
|
||||
@@ -201,4 +203,32 @@ public class AllyHero_SO : ScriptableObject
|
||||
if (payload == null) return;
|
||||
equippedSkillGroupIDs = payload.equippedSkillGroupIDs ?? new int[0];
|
||||
}
|
||||
|
||||
public void SetUnlocked(bool value)
|
||||
{
|
||||
if (isUnlocked == value) return;
|
||||
isUnlocked = value;
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
#endif
|
||||
}
|
||||
|
||||
public void IncrementBattleDeployCount(int amount = 1)
|
||||
{
|
||||
if (amount <= 0) return;
|
||||
AllyHeroDeployLedger.EnsureInstance().IncrementDeployCount(this, amount);
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
#endif
|
||||
}
|
||||
|
||||
public void LoadBattleDeployCountFromLocal()
|
||||
{
|
||||
ally_battleDeployCount = AllyHeroDeployLedger.EnsureInstance().GetDeployCount(ally_heroID);
|
||||
}
|
||||
|
||||
public void SaveBattleDeployCountToLocal()
|
||||
{
|
||||
AllyHeroDeployLedger.EnsureInstance().SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,23 @@ public class Player_SO : ScriptableObject
|
||||
{
|
||||
[Header("Inspector")]
|
||||
public int player_currentEXP;
|
||||
public int player_currentLevel;
|
||||
|
||||
[Header("economics")]
|
||||
[SerializeField] private int player_coins;
|
||||
[SerializeField] private int player_material;
|
||||
|
||||
[Header("exp bottles")]
|
||||
[SerializeField] private int emptyExpBottle78000;
|
||||
[SerializeField] private int commonExpBottle78001;
|
||||
[SerializeField] private int mediumExpBottle78002;
|
||||
[SerializeField] private int superiorExpBottle78003;
|
||||
[SerializeField] private int supremeExpBottle78004;
|
||||
[SerializeField] private int extraordinaryExpBottle78005;
|
||||
[SerializeField] private int celestialExpBottle78006;
|
||||
|
||||
[Header("combat analytics")]
|
||||
[SerializeField] private float uRankingScore;
|
||||
|
||||
[Header("dush materials")]
|
||||
[SerializeField] private int dushMaterial78021;
|
||||
[SerializeField] private int dushMaterial78022;
|
||||
[SerializeField] private int dushMaterial78023;
|
||||
@@ -37,6 +40,11 @@ public class Player_SO : ScriptableObject
|
||||
get { return player_material; }
|
||||
}
|
||||
|
||||
public float URankingScore
|
||||
{
|
||||
get { return uRankingScore; }
|
||||
}
|
||||
|
||||
public void SetCoins(int value)
|
||||
{
|
||||
player_coins = Mathf.Max(0, value);
|
||||
@@ -74,6 +82,22 @@ public class Player_SO : ScriptableObject
|
||||
PersistEditorChanges();
|
||||
}
|
||||
|
||||
public void SetURankingScore(float value)
|
||||
{
|
||||
uRankingScore = Mathf.Max(0f, value);
|
||||
PersistEditorChanges();
|
||||
}
|
||||
|
||||
public void AddURankingScore(float delta)
|
||||
{
|
||||
if (Mathf.Approximately(delta, 0f))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetURankingScore(uRankingScore + delta);
|
||||
}
|
||||
|
||||
public bool TrySpendCoins(int amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
@@ -96,29 +120,11 @@ public class Player_SO : ScriptableObject
|
||||
PersistEditorChanges();
|
||||
}
|
||||
|
||||
public int GetLegacyExpBottleCount(string fieldName)
|
||||
{
|
||||
switch (fieldName)
|
||||
{
|
||||
case "emptyExpBottle78000": return emptyExpBottle78000;
|
||||
case "commonExpBottle78001": return commonExpBottle78001;
|
||||
case "mediumExpBottle78002": return mediumExpBottle78002;
|
||||
case "superiorExpBottle78003": return superiorExpBottle78003;
|
||||
case "supremeExpBottle78004": return supremeExpBottle78004;
|
||||
case "extraordinaryExpBottle78005": return extraordinaryExpBottle78005;
|
||||
case "celestialExpBottle78006": return celestialExpBottle78006;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLegacyExpBottleCount(string fieldName, int value)
|
||||
{
|
||||
int safeValue = Mathf.Max(0, value);
|
||||
switch (fieldName)
|
||||
{
|
||||
case "emptyExpBottle78000":
|
||||
emptyExpBottle78000 = safeValue;
|
||||
break;
|
||||
case "commonExpBottle78001":
|
||||
commonExpBottle78001 = safeValue;
|
||||
break;
|
||||
|
||||
+43
-67
@@ -1,99 +1,75 @@
|
||||
using UnityEngine;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Bansonic.datacheck
|
||||
{
|
||||
public class DataCheck : MonoBehaviour
|
||||
{
|
||||
[Header("Settings")]
|
||||
[Tooltip("是否在游戏启动时自动执行异步数据检查")]
|
||||
[Tooltip("是否在游戏启动时自动校验歌曲运行时存档")]
|
||||
public bool checkOnStart = true;
|
||||
|
||||
private const string SALT = "bansonic2026@fudongyouxi";
|
||||
private const string Salt = "bansonic2026@fudongyouxi";
|
||||
private const string SongRuntimeCategory = "song_runtime";
|
||||
|
||||
async void Start()
|
||||
private void Start()
|
||||
{
|
||||
if (!checkOnStart)
|
||||
{
|
||||
Debug.Log("[DataCheck] 自动检查已关闭 (checkOnStart = false)");
|
||||
Debug.Log("[DataCheck] 自动校验已关闭。");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log("[DataCheck] 开始异步验证所有歌曲存档...");
|
||||
|
||||
// 记录开始时间
|
||||
float startTime = Time.realtimeSinceStartup;
|
||||
ValidateSongRuntimeSaves();
|
||||
}
|
||||
|
||||
int totalFiles = 0;
|
||||
private void ValidateSongRuntimeSaves()
|
||||
{
|
||||
float startTime = Time.realtimeSinceStartup;
|
||||
string persistentPath = Application.persistentDataPath;
|
||||
string legacyRoot = persistentPath;
|
||||
|
||||
int encryptedFileCount = SecureSaveVault.CountEncryptedFiles(SongRuntimeCategory);
|
||||
int legacyFileCount = 0;
|
||||
try
|
||||
{
|
||||
legacyFileCount = Directory.GetFiles(legacyRoot, "SongData_*.json", SearchOption.TopDirectoryOnly).Length;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[DataCheck] 统计旧版歌曲存档失败: {ex.Message}");
|
||||
}
|
||||
|
||||
List<string> loadedJsonList = SecureSaveVault.LoadAllRawJson(SongRuntimeCategory, legacyRoot, "SongData_*.json");
|
||||
|
||||
int totalFiles = Math.Max(encryptedFileCount + legacyFileCount, loadedJsonList.Count);
|
||||
int corruptedFiles = 0;
|
||||
|
||||
// 在主线程提前获取路径,避免子线程调用 Unity API 报错
|
||||
string persistentPath = Application.persistentDataPath;
|
||||
|
||||
// 执行异步检查任务
|
||||
await Task.Run(() =>
|
||||
for (int i = 0; i < loadedJsonList.Count; i++)
|
||||
{
|
||||
string path = persistentPath;
|
||||
string[] files = Directory.GetFiles(path, "SongData_*.json");
|
||||
totalFiles = files.Length;
|
||||
|
||||
foreach (string file in files)
|
||||
string json = loadedJsonList[i];
|
||||
SongDataSerializable serializable;
|
||||
if (!SongData.VerifyJsonIntegrity(json, Salt, out serializable))
|
||||
{
|
||||
try
|
||||
{
|
||||
string content = File.ReadAllText(file);
|
||||
// 1. 首先尝试完整校验(包含 Hash)
|
||||
if (!SongData.VerifyJsonIntegrity(content, SALT, out SongDataSerializable serializable))
|
||||
{
|
||||
// 2. 如果校验失败,尝试直接解析 JSON(不看 Hash)
|
||||
try
|
||||
{
|
||||
SongDataSerializable fallbackData = JsonUtility.FromJson<SongDataSerializable>(content);
|
||||
if (fallbackData != null)
|
||||
{
|
||||
// 属于“正常更新”:格式变动导致 Hash 不匹配,但数据可读
|
||||
// 重新计算 Hash 并保存以修复文件
|
||||
string jsonWithoutHash = JsonUtility.ToJson(fallbackData);
|
||||
fallbackData.dataHash = SongData.StaticCalculateMD5(jsonWithoutHash + SALT);
|
||||
string fixedJson = JsonUtility.ToJson(fallbackData, true);
|
||||
File.WriteAllText(file, fixedJson);
|
||||
|
||||
Debug.Log($"[DataCheck] 已自动修复正常更新的存档: {Path.GetFileName(file)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 彻底无法解析,视为损坏
|
||||
corruptedFiles++;
|
||||
Debug.LogError($"[DataCheck] 存档已损坏且无法修复: {Path.GetFileName(file)}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
corruptedFiles++;
|
||||
Debug.LogError($"[DataCheck] 存档严重损坏: {Path.GetFileName(file)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
corruptedFiles++;
|
||||
Debug.LogError($"[DataCheck] 读取文件失败 {file}: {e.Message}");
|
||||
}
|
||||
corruptedFiles++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int envelopeFailedFiles = Math.Max(0, encryptedFileCount - loadedJsonList.Count + legacyFileCount);
|
||||
corruptedFiles += envelopeFailedFiles;
|
||||
|
||||
float duration = Time.realtimeSinceStartup - startTime;
|
||||
Debug.Log($"[DataCheck] 验证完成。耗时: {duration:F4}s | 总文件: {totalFiles} | 损坏/篡改: {corruptedFiles}");
|
||||
|
||||
Debug.Log($"[DataCheck] 歌曲存档校验完成。耗时: {duration:F4}s | 总文件: {totalFiles} | 异常/篡改: {corruptedFiles}");
|
||||
|
||||
if (corruptedFiles > 0)
|
||||
{
|
||||
Debug.LogWarning("[DataCheck] 部分存档存在异常,SongData 加载时将自动修复。");
|
||||
Debug.LogWarning("[DataCheck] 检测到异常歌曲存档。安全存档层已阻止被破坏的数据继续参与读取。");
|
||||
}
|
||||
else if (totalFiles > 0)
|
||||
{
|
||||
Debug.Log("[DataCheck] 所有存档通过一致性校验,数据安全。");
|
||||
Debug.Log("[DataCheck] 所有歌曲运行时存档通过校验。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using UnityEngine;
|
||||
public class InGamePerformanceManager : MonoBehaviour
|
||||
{
|
||||
public static InGamePerformanceManager Instance { get; private set; }
|
||||
public int HighestComboThisRun { get; private set; }
|
||||
|
||||
[Header("Inspector")]
|
||||
[Tooltip("Achievement definitions loaded from Resources.")]
|
||||
@@ -37,6 +38,7 @@ public class InGamePerformanceManager : MonoBehaviour
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this;
|
||||
HighestComboThisRun = 0;
|
||||
LoadAchievementLibrary();
|
||||
}
|
||||
else
|
||||
@@ -98,6 +100,11 @@ public class InGamePerformanceManager : MonoBehaviour
|
||||
|
||||
public void UpdateCombo(int currentCombo)
|
||||
{
|
||||
if (currentCombo > HighestComboThisRun)
|
||||
{
|
||||
HighestComboThisRun = currentCombo;
|
||||
}
|
||||
|
||||
CheckAchievementsByCategory(AchievementCategory.Combo, currentCombo);
|
||||
}
|
||||
|
||||
|
||||
@@ -263,20 +263,21 @@ public class UI_Panel_Mail : MonoBehaviour
|
||||
isReceived = mail.isReceived
|
||||
});
|
||||
}
|
||||
var json = JsonUtility.ToJson(data, true);
|
||||
File.WriteAllText(GetSavePath(), json);
|
||||
SecureSaveVault.SaveJson("mail_state", "runtime", data, GetLegacySavePath());
|
||||
}
|
||||
|
||||
MailSaveData LoadMailState()
|
||||
{
|
||||
var path = GetSavePath();
|
||||
if (!File.Exists(path)) return null;
|
||||
var json = File.ReadAllText(path);
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
return JsonUtility.FromJson<MailSaveData>(json);
|
||||
MailSaveData data;
|
||||
if (!SecureSaveVault.TryLoadJson("mail_state", "runtime", out data, GetLegacySavePath()))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
string GetSavePath()
|
||||
string GetLegacySavePath()
|
||||
{
|
||||
return Path.Combine(Application.persistentDataPath, "mail_state.json");
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ public class UI_Panel_Main : Singleton_Mono<UI_Panel_Main>
|
||||
() =>
|
||||
{
|
||||
Try_Open_Prefab(ui_Panel_Encyclopendia, ref encyclopedia_Instance);
|
||||
DailyTaskEventHub.ReportWatchStory();
|
||||
});
|
||||
if (button_Story != null)
|
||||
button_Story.onClick.AddListener(
|
||||
|
||||
@@ -4317,6 +4317,128 @@ MonoBehaviour:
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!1 &4051389278547494887
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 5227902933222757190}
|
||||
- component: {fileID: 1537305582658420602}
|
||||
- component: {fileID: 7802510349101123134}
|
||||
- component: {fileID: 814312480188450142}
|
||||
m_Layer: 5
|
||||
m_Name: Button_bag
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &5227902933222757190
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4051389278547494887}
|
||||
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 3273746950969294248}
|
||||
- {fileID: 7720263923492919812}
|
||||
m_Father: {fileID: 3304862671657828245}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &1537305582658420602
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4051389278547494887}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &7802510349101123134
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4051389278547494887}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: -1804059736019094365, guid: 48b7354ead1c3fe49920dd1aa2340b93, type: 3}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 0
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 3
|
||||
--- !u!114 &814312480188450142
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 4051389278547494887}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Navigation:
|
||||
m_Mode: 3
|
||||
m_WrapAround: 0
|
||||
m_SelectOnUp: {fileID: 0}
|
||||
m_SelectOnDown: {fileID: 0}
|
||||
m_SelectOnLeft: {fileID: 0}
|
||||
m_SelectOnRight: {fileID: 0}
|
||||
m_Transition: 1
|
||||
m_Colors:
|
||||
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
|
||||
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
|
||||
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
|
||||
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
|
||||
m_ColorMultiplier: 1
|
||||
m_FadeDuration: 0.1
|
||||
m_SpriteState:
|
||||
m_HighlightedSprite: {fileID: 0}
|
||||
m_PressedSprite: {fileID: 0}
|
||||
m_SelectedSprite: {fileID: 0}
|
||||
m_DisabledSprite: {fileID: 0}
|
||||
m_AnimationTriggers:
|
||||
m_NormalTrigger: Normal
|
||||
m_HighlightedTrigger: Highlighted
|
||||
m_PressedTrigger: Pressed
|
||||
m_SelectedTrigger: Selected
|
||||
m_DisabledTrigger: Disabled
|
||||
m_Interactable: 1
|
||||
m_TargetGraphic: {fileID: 7802510349101123134}
|
||||
m_OnClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
--- !u!1 &4323691831252766980
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -4432,7 +4554,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 451, y: -43}
|
||||
m_AnchoredPosition: {x: 521.45, y: -43}
|
||||
m_SizeDelta: {x: 100, y: 100}
|
||||
m_Pivot: {x: 0, y: 0}
|
||||
--- !u!114 &786756074167547563
|
||||
@@ -5553,6 +5675,156 @@ CanvasRenderer:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5909026714256705340}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!1 &5953527693605903714
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 7720263923492919812}
|
||||
- component: {fileID: 6328938927363608697}
|
||||
- component: {fileID: 7955779152810041871}
|
||||
m_Layer: 5
|
||||
m_Name: Image (1)
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &7720263923492919812
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5953527693605903714}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5227902933222757190}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 25, y: 25}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &6328938927363608697
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5953527693605903714}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &7955779152810041871
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5953527693605903714}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 0.22745098, g: 0.30980393, b: 0.3372549, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 21300000, guid: c122873ea5ca2554181d0e8fdbda4ffe, type: 3}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 0
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!1 &5988478905435832207
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 3273746950969294248}
|
||||
- component: {fileID: 5212592995083749236}
|
||||
- component: {fileID: 9143070888354374478}
|
||||
m_Layer: 5
|
||||
m_Name: Image
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &3273746950969294248
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5988478905435832207}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5227902933222757190}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0.5}
|
||||
m_AnchorMax: {x: 0.5, y: 0.5}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 30, y: 30}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &5212592995083749236
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5988478905435832207}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &9143070888354374478
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5988478905435832207}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 0}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 0
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!1 &6064513226240405794
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -6540,9 +6812,10 @@ RectTransform:
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 2149937566432696527}
|
||||
- {fileID: 4827008926351111491}
|
||||
- {fileID: 5227902933222757190}
|
||||
- {fileID: 1387828356511308751}
|
||||
- {fileID: 263900830868465045}
|
||||
- {fileID: 4827008926351111491}
|
||||
m_Father: {fileID: 6434860682258445909}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
@@ -6573,7 +6846,7 @@ MonoBehaviour:
|
||||
m_CellSize: {x: 50, y: 50}
|
||||
m_Spacing: {x: 20, y: 20}
|
||||
m_Constraint: 1
|
||||
m_ConstraintCount: 4
|
||||
m_ConstraintCount: 5
|
||||
--- !u!1 &7164294745953147232
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
using UnityEngine;
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
public static class JsonDataHandler
|
||||
{
|
||||
public static string File_Path_Folder => Application.persistentDataPath + "/FILE/";
|
||||
static readonly string File_Suffix = ".json";
|
||||
private const string SaveCategory = "generic_json_data";
|
||||
private static readonly string FileSuffix = ".json";
|
||||
|
||||
public static string File_Path_Folder => Path.Combine(Application.persistentDataPath, "FILE");
|
||||
|
||||
public static string File_Path(string file_Name)
|
||||
{
|
||||
return File_Path_Folder + file_Name + File_Suffix;
|
||||
return Path.Combine(File_Path_Folder, file_Name + FileSuffix);
|
||||
}
|
||||
|
||||
public static void Save<T>(T data, string file_Name, string saver_Name)
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||||
string legacyPath = File_Path(file_Name);
|
||||
|
||||
if (!SecureSaveVault.SaveRawJson(SaveCategory, file_Name, json, legacyPath))
|
||||
{
|
||||
Debug.LogWarning($"[JsonDataHandler] Save failed: {file_Name}");
|
||||
return;
|
||||
}
|
||||
|
||||
var path = File_Path(file_Name);
|
||||
if (!File.Exists(path)) Directory.CreateDirectory(File_Path_Folder);
|
||||
|
||||
var file_Data = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||||
// file_Data = $"//SAVER:[{saver_Name}]\n" + file_Data;
|
||||
File.WriteAllText(path, file_Data);
|
||||
|
||||
Debug.Log($"_Save_[{file_Name}{File_Suffix}]_FROM_[{saver_Name}]\n" + path);
|
||||
Debug.Log($"_Save_[{file_Name}{FileSuffix}]_FROM_[{saver_Name}]\nSECURE::{SaveCategory}/{file_Name}");
|
||||
}
|
||||
|
||||
public static bool Try_Load<T>(ref T data, string file_Name)
|
||||
{
|
||||
var path = File_Path(file_Name);
|
||||
if (!File.Exists(path))
|
||||
string json;
|
||||
if (!SecureSaveVault.TryLoadRawJson(SaveCategory, file_Name, out json, File_Path(file_Name)))
|
||||
{
|
||||
Debug.Log($"Load_[{file_Name}]_NULL");
|
||||
return false;
|
||||
}
|
||||
data = JsonConvert.DeserializeObject<T>(System.IO.File.ReadAllText(path));
|
||||
|
||||
data = JsonConvert.DeserializeObject<T>(json);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static T Load<T>(string file_Name)
|
||||
{
|
||||
var path = File_Path(file_Name);
|
||||
if (!System.IO.File.Exists(path))
|
||||
string json;
|
||||
if (!SecureSaveVault.TryLoadRawJson(SaveCategory, file_Name, out json, File_Path(file_Name)))
|
||||
{
|
||||
Debug.Log($"Load_[{file_Name}]_NULL");
|
||||
return default;
|
||||
//return T;
|
||||
}
|
||||
return JsonConvert.DeserializeObject<T>(System.IO.File.ReadAllText(path));
|
||||
|
||||
return JsonConvert.DeserializeObject<T>(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
public sealed class AllyHeroDeployLedger : MonoBehaviour
|
||||
{
|
||||
public static AllyHeroDeployLedger Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<int, int> deployCountsByHeroId = new Dictionary<int, int>();
|
||||
private bool initialized;
|
||||
private bool loadedFromSave;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void Bootstrap()
|
||||
{
|
||||
EnsureInstance();
|
||||
}
|
||||
|
||||
public static AllyHeroDeployLedger EnsureInstance()
|
||||
{
|
||||
if (Instance != null)
|
||||
{
|
||||
return Instance;
|
||||
}
|
||||
|
||||
GameObject host = new GameObject("__runtime_ally_deploy_bridge");
|
||||
host.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector;
|
||||
DontDestroyOnLoad(host);
|
||||
Instance = host.AddComponent<AllyHeroDeployLedger>();
|
||||
return Instance;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
InitializeIfNeeded();
|
||||
}
|
||||
|
||||
private void OnApplicationPause(bool pauseStatus)
|
||||
{
|
||||
if (pauseStatus)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void InitializeIfNeeded()
|
||||
{
|
||||
if (initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AllyHeroDeployLedgerPayload payload;
|
||||
loadedFromSave = AllyHeroDeployLedgerStorage.TryLoad(out payload);
|
||||
RebuildFromPayload(payload);
|
||||
initialized = true;
|
||||
SeedFromHeroAssetsIfNeeded();
|
||||
SyncAllMirrorFlags();
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public int GetDeployCount(int heroId)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
int count;
|
||||
return deployCountsByHeroId.TryGetValue(heroId, out count) ? count : 0;
|
||||
}
|
||||
|
||||
public void IncrementDeployCount(AllyHero_SO hero, int amount = 1)
|
||||
{
|
||||
if (hero == null || hero.ally_heroID <= 0 || amount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InitializeIfNeeded();
|
||||
int current = GetDeployCount(hero.ally_heroID);
|
||||
long next = (long)current + amount;
|
||||
deployCountsByHeroId[hero.ally_heroID] = next > int.MaxValue ? int.MaxValue : (int)next;
|
||||
hero.ally_battleDeployCount = deployCountsByHeroId[hero.ally_heroID];
|
||||
MarkDirty(hero);
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void SaveNow()
|
||||
{
|
||||
if (!initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SyncAllMirrorFlags();
|
||||
AllyHeroDeployLedgerStorage.TrySave(BuildPayload());
|
||||
}
|
||||
|
||||
private void RebuildFromPayload(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
deployCountsByHeroId.Clear();
|
||||
if (payload == null || payload.entries == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < payload.entries.Count; i++)
|
||||
{
|
||||
AllyHeroDeployEntry entry = payload.entries[i];
|
||||
if (entry == null || entry.heroId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
deployCountsByHeroId[entry.heroId] = Mathf.Max(0, entry.deployCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedFromHeroAssetsIfNeeded()
|
||||
{
|
||||
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
|
||||
bool changed = false;
|
||||
|
||||
for (int i = 0; i < heroes.Length; i++)
|
||||
{
|
||||
AllyHero_SO hero = heroes[i];
|
||||
if (hero == null || hero.ally_heroID <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (deployCountsByHeroId.ContainsKey(hero.ally_heroID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hero.ally_battleDeployCount <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
deployCountsByHeroId[hero.ally_heroID] = Mathf.Max(0, hero.ally_battleDeployCount);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed && !loadedFromSave)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncAllMirrorFlags()
|
||||
{
|
||||
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
|
||||
for (int i = 0; i < heroes.Length; i++)
|
||||
{
|
||||
AllyHero_SO hero = heroes[i];
|
||||
if (hero == null || hero.ally_heroID <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int count;
|
||||
if (!deployCountsByHeroId.TryGetValue(hero.ally_heroID, out count))
|
||||
{
|
||||
count = 0;
|
||||
}
|
||||
|
||||
if (hero.ally_battleDeployCount == count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hero.ally_battleDeployCount = count;
|
||||
MarkDirty(hero);
|
||||
}
|
||||
}
|
||||
|
||||
private AllyHeroDeployLedgerPayload BuildPayload()
|
||||
{
|
||||
AllyHeroDeployLedgerPayload payload = AllyHeroDeployLedgerStorage.CreateDefaultPayload();
|
||||
foreach (KeyValuePair<int, int> pair in deployCountsByHeroId)
|
||||
{
|
||||
payload.entries.Add(new AllyHeroDeployEntry
|
||||
{
|
||||
heroId = pair.Key,
|
||||
deployCount = Mathf.Max(0, pair.Value)
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static void MarkDirty(AllyHero_SO hero)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (hero != null)
|
||||
{
|
||||
EditorUtility.SetDirty(hero);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4b993c80d44994489f40e5a81dda7b3
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[Serializable]
|
||||
public class AllyHeroDeployEntry
|
||||
{
|
||||
public int heroId;
|
||||
public int deployCount;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AllyHeroDeployLedgerPayload
|
||||
{
|
||||
public int version;
|
||||
public long lastUpdatedUtcTicks;
|
||||
public List<AllyHeroDeployEntry> entries = new List<AllyHeroDeployEntry>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AllyHeroDeployLedgerEnvelope
|
||||
{
|
||||
public string payload;
|
||||
public string signature;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e25750439aee8f4e93c609697703d8a
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
public static class AllyHeroDeployLedgerStorage
|
||||
{
|
||||
private const string SecretSeed = "ban_total.ally_hero_deploy_ledger.v1";
|
||||
private const string VaultDirectoryName = ".cache_bridge";
|
||||
private const string MainFileName = ".ahd.dat";
|
||||
private const string BackupFileName = ".ahd.bak";
|
||||
private const string TempFileName = ".ahd.tmp";
|
||||
|
||||
private static string VaultDirectoryPath => Path.Combine(Application.persistentDataPath, VaultDirectoryName);
|
||||
private static string MainFilePath => Path.Combine(VaultDirectoryPath, MainFileName);
|
||||
private static string BackupFilePath => Path.Combine(VaultDirectoryPath, BackupFileName);
|
||||
private static string TempFilePath => Path.Combine(VaultDirectoryPath, TempFileName);
|
||||
|
||||
public static bool TryLoad(out AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TrySave(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(VaultDirectoryPath);
|
||||
TryHidePath(VaultDirectoryPath);
|
||||
|
||||
string envelopeJson = BuildEnvelopeJson(payload);
|
||||
File.WriteAllText(TempFilePath, envelopeJson, Encoding.UTF8);
|
||||
TryHidePath(TempFilePath);
|
||||
|
||||
if (File.Exists(MainFilePath))
|
||||
{
|
||||
File.Copy(MainFilePath, BackupFilePath, true);
|
||||
TryHidePath(BackupFilePath);
|
||||
}
|
||||
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Save failed: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static AllyHeroDeployLedgerPayload CreateDefaultPayload()
|
||||
{
|
||||
return new AllyHeroDeployLedgerPayload
|
||||
{
|
||||
version = 1,
|
||||
lastUpdatedUtcTicks = DateTime.UtcNow.Ticks,
|
||||
entries = new System.Collections.Generic.List<AllyHeroDeployEntry>()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadPayload(string path, out AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string envelopeJson = File.ReadAllText(path, Encoding.UTF8);
|
||||
AllyHeroDeployLedgerEnvelope envelope = JsonUtility.FromJson<AllyHeroDeployLedgerEnvelope>(envelopeJson);
|
||||
if (envelope == null || string.IsNullOrEmpty(envelope.payload) || string.IsNullOrEmpty(envelope.signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
byte[] plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
string payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
AllyHeroDeployLedgerPayload loadedPayload = JsonUtility.FromJson<AllyHeroDeployLedgerPayload>(payloadJson);
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = loadedPayload;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Load failed from '" + path + "': " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
string payloadJson = JsonUtility.ToJson(payload, false);
|
||||
byte[] plainBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
byte[] encryptedBytes = XorTransform(plainBytes, BuildKeyBytes());
|
||||
string payloadBase64 = Convert.ToBase64String(encryptedBytes);
|
||||
|
||||
AllyHeroDeployLedgerEnvelope envelope = new AllyHeroDeployLedgerEnvelope
|
||||
{
|
||||
payload = payloadBase64,
|
||||
signature = ComputeSignature(payloadBase64)
|
||||
};
|
||||
|
||||
return JsonUtility.ToJson(envelope, false);
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(signText);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
byte[] result = new byte[source.Length];
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
result[i] = (byte)(source[i] ^ key[i % key.Length]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void TryHidePath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor && Application.platform != RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FileAttributes attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.Hidden) == 0)
|
||||
{
|
||||
File.SetAttributes(path, attributes | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb496e1becc09464eb0ca23b9a69557d
|
||||
@@ -109,6 +109,25 @@ public sealed class DushMaterialLedger : MonoBehaviour
|
||||
ChangeCount(DushMaterialCatalog.GetKey(kind), amount);
|
||||
}
|
||||
|
||||
public bool TryConsume(DushMaterialKind kind, int amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string key = DushMaterialCatalog.GetKey(kind);
|
||||
int current = GetCountByKey(key);
|
||||
if (current < amount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ChangeCount(key, -amount);
|
||||
DailyTaskEventHub.ReportUseItem(amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SeedFromPlayerSo(Player_SO playerData, bool overwriteExistingCounts)
|
||||
{
|
||||
if (playerData == null)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
public static class LegacyPlainSaveMigrator
|
||||
{
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void MigrateLegacyPlainFiles()
|
||||
{
|
||||
string root = Application.persistentDataPath;
|
||||
MigrateSingleFile("daily_task", "runtime", Path.Combine(root, "daily_tasks.json"));
|
||||
MigrateSingleFile("mail_state", "runtime", Path.Combine(root, "mail_state.json"));
|
||||
MigrateSingleFile("store_state", "runtime", Path.Combine(root, "storeSystem_state.json"));
|
||||
|
||||
MigratePattern("song_runtime", root, "SongData_*.json", fileNameWithoutExt =>
|
||||
{
|
||||
if (fileNameWithoutExt.StartsWith("SongData_"))
|
||||
{
|
||||
return fileNameWithoutExt.Substring("SongData_".Length);
|
||||
}
|
||||
|
||||
return fileNameWithoutExt;
|
||||
});
|
||||
|
||||
MigratePattern("song_export", Path.Combine(root, "SongDataJson"), "*.json", fileNameWithoutExt => fileNameWithoutExt);
|
||||
MigratePattern("song_export", Path.Combine(root, "song_json_export"), "*.json", fileNameWithoutExt => fileNameWithoutExt);
|
||||
}
|
||||
|
||||
private static void MigrateSingleFile(string category, string key, string legacyPath)
|
||||
{
|
||||
if (!File.Exists(legacyPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(legacyPath);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
File.Delete(legacyPath);
|
||||
return;
|
||||
}
|
||||
|
||||
SecureSaveVault.SaveRawJson(category, key, json, legacyPath);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[LegacyPlainSaveMigrator] Failed to migrate {legacyPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void MigratePattern(string category, string directory, string searchPattern, System.Func<string, string> keyResolver)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] files = Directory.GetFiles(directory, searchPattern, SearchOption.TopDirectoryOnly);
|
||||
for (int i = 0; i < files.Length; i++)
|
||||
{
|
||||
string legacyPath = files[i];
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(legacyPath);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
File.Delete(legacyPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = keyResolver != null ? keyResolver(Path.GetFileNameWithoutExtension(legacyPath)) : Path.GetFileNameWithoutExtension(legacyPath);
|
||||
SecureSaveVault.SaveRawJson(category, key, json, legacyPath);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[LegacyPlainSaveMigrator] Failed to migrate {legacyPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
TryDeleteDirectoryIfEmpty(directory);
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectoryIfEmpty(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Directory.GetFiles(directory).Length == 0 && Directory.GetDirectories(directory).Length == 0)
|
||||
{
|
||||
Directory.Delete(directory, false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f29bda46e3dc3ae4d9bf7aa6c11bcadd
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[Serializable]
|
||||
public class RecentPlayRecord
|
||||
{
|
||||
public string playedAt;
|
||||
public int songID;
|
||||
public string songName;
|
||||
public string difficultyDisplay;
|
||||
public float accuracy;
|
||||
public float srks;
|
||||
public int totalScore;
|
||||
public bool scoreReadable;
|
||||
public bool wasEarlySettlement;
|
||||
public bool wasAllPerfect;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class RecentPlayHistoryPayload
|
||||
{
|
||||
public List<RecentPlayRecord> records = new List<RecentPlayRecord>();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f13cbff8f75ffe64aa2aed4e7cff0609
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public static class RecentPlayHistoryStore
|
||||
{
|
||||
private const string Category = "recent_play_history";
|
||||
private const string Key = "runs_v1";
|
||||
private const int MaxRecordCount = 100;
|
||||
|
||||
public static IReadOnlyList<RecentPlayRecord> GetRecords()
|
||||
{
|
||||
return LoadPayload().records;
|
||||
}
|
||||
|
||||
public static void Push(RecentPlayRecord record)
|
||||
{
|
||||
if (record == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = LoadPayload();
|
||||
if (payload.records == null)
|
||||
{
|
||||
payload.records = new List<RecentPlayRecord>();
|
||||
}
|
||||
|
||||
payload.records.Insert(0, record);
|
||||
if (payload.records.Count > MaxRecordCount)
|
||||
{
|
||||
payload.records.RemoveRange(MaxRecordCount, payload.records.Count - MaxRecordCount);
|
||||
}
|
||||
|
||||
SecureSaveVault.SaveJson(Category, Key, payload);
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
SecureSaveVault.Delete(Category, Key);
|
||||
}
|
||||
|
||||
private static RecentPlayHistoryPayload LoadPayload()
|
||||
{
|
||||
RecentPlayHistoryPayload payload;
|
||||
if (!SecureSaveVault.TryLoadJson(Category, Key, out payload) || payload == null)
|
||||
{
|
||||
payload = new RecentPlayHistoryPayload();
|
||||
}
|
||||
|
||||
if (payload.records == null)
|
||||
{
|
||||
payload.records = new List<RecentPlayRecord>();
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68da4f91f4d88174fb8da3ea658c1672
|
||||
@@ -0,0 +1,555 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
[Serializable]
|
||||
public class SecureSaveEnvelope
|
||||
{
|
||||
public int version;
|
||||
public string payload;
|
||||
public string signature;
|
||||
public long savedUtcTicks;
|
||||
}
|
||||
|
||||
public static class SecureSaveVault
|
||||
{
|
||||
private const string SecretSeed = "ban_total.secure_save_v2";
|
||||
private const string VaultDirectoryName = ".cache_bridge";
|
||||
private static bool s_dpapiInitialized;
|
||||
private static bool s_dpapiSupported;
|
||||
private static MethodInfo s_dpapiProtectMethod;
|
||||
private static MethodInfo s_dpapiUnprotectMethod;
|
||||
private static object s_dpapiCurrentUserScope;
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
}
|
||||
|
||||
public static bool SaveJson<T>(string category, string key, T data, string legacyPlainPath = null)
|
||||
{
|
||||
if (!typeof(T).IsValueType && (object)data == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string json = JsonUtility.ToJson(data, false);
|
||||
return SaveRawJson(category, key, json, legacyPlainPath);
|
||||
}
|
||||
|
||||
public static bool TryLoadJson<T>(string category, string key, out T data, string legacyPlainPath = null)
|
||||
{
|
||||
data = default(T);
|
||||
string json;
|
||||
if (!TryLoadRawJson(category, key, out json, legacyPlainPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
data = JsonUtility.FromJson<T>(json);
|
||||
if (typeof(T).IsValueType)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return (object)data != null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] JSON parse failed: {ex.Message}");
|
||||
data = default(T);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SaveRawJson(string category, string key, string json, string legacyPlainPath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key) || json == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string mainPath = GetFilePath(category, key, ".dat");
|
||||
string backupPath = GetFilePath(category, key, ".bak");
|
||||
string tempPath = GetFilePath(category, key, ".tmp");
|
||||
|
||||
try
|
||||
{
|
||||
string directory = Path.GetDirectoryName(mainPath);
|
||||
Directory.CreateDirectory(directory);
|
||||
TryHidePath(directory);
|
||||
|
||||
string envelopeJson = BuildEnvelopeJson(category, key, json);
|
||||
File.WriteAllText(tempPath, envelopeJson, Encoding.UTF8);
|
||||
TryHidePath(tempPath);
|
||||
|
||||
if (File.Exists(mainPath))
|
||||
{
|
||||
File.Copy(mainPath, backupPath, true);
|
||||
TryHidePath(backupPath);
|
||||
}
|
||||
|
||||
File.Copy(tempPath, mainPath, true);
|
||||
TryHidePath(mainPath);
|
||||
File.Delete(tempPath);
|
||||
DeleteLegacyPlainFile(legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Save failed ({category}/{key}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryLoadRawJson(string category, string key, out string json, string legacyPlainPath = null)
|
||||
{
|
||||
json = null;
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string mainPath = GetFilePath(category, key, ".dat");
|
||||
string backupPath = GetFilePath(category, key, ".bak");
|
||||
|
||||
if (TryReadEncryptedFile(category, key, mainPath, out json))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadEncryptedFile(category, key, backupPath, out json))
|
||||
{
|
||||
SaveRawJson(category, key, json, legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(legacyPlainPath) && File.Exists(legacyPlainPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
json = File.ReadAllText(legacyPlainPath, Encoding.UTF8);
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
SaveRawJson(category, key, json, legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Legacy migration failed ({legacyPlainPath}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool Delete(string category, string key, string legacyPlainPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteIfExists(GetFilePath(category, key, ".dat"));
|
||||
DeleteIfExists(GetFilePath(category, key, ".bak"));
|
||||
DeleteIfExists(GetFilePath(category, key, ".tmp"));
|
||||
DeleteLegacyPlainFile(legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Delete failed ({category}/{key}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<string> LoadAllRawJson(string category, string legacyDirectory = null, string legacySearchPattern = "*.json")
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
string categoryDirectory = GetCategoryDirectory(category);
|
||||
if (Directory.Exists(categoryDirectory))
|
||||
{
|
||||
string[] encryptedFiles = Directory.GetFiles(categoryDirectory, "*.dat", SearchOption.TopDirectoryOnly);
|
||||
for (int i = 0; i < encryptedFiles.Length; i++)
|
||||
{
|
||||
string path = encryptedFiles[i];
|
||||
string key = ExtractKeyFromFileName(path);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string json;
|
||||
if (TryReadEncryptedFile(category, key, path, out json) && !string.IsNullOrEmpty(json))
|
||||
{
|
||||
result.Add(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(legacyDirectory) && Directory.Exists(legacyDirectory))
|
||||
{
|
||||
string[] legacyFiles = Directory.GetFiles(legacyDirectory, legacySearchPattern, SearchOption.TopDirectoryOnly);
|
||||
for (int i = 0; i < legacyFiles.Length; i++)
|
||||
{
|
||||
string legacyPath = legacyFiles[i];
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(legacyPath, Encoding.UTF8);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string legacyKey = Path.GetFileNameWithoutExtension(legacyPath);
|
||||
SaveRawJson(category, legacyKey, json, legacyPath);
|
||||
result.Add(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Legacy bulk migration failed ({legacyPath}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int CountEncryptedFiles(string category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
string categoryDirectory = GetCategoryDirectory(category);
|
||||
if (!Directory.Exists(categoryDirectory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Directory.GetFiles(categoryDirectory, "*.dat", SearchOption.TopDirectoryOnly).Length;
|
||||
}
|
||||
|
||||
private static bool TryReadEncryptedFile(string category, string key, string filePath, out string json)
|
||||
{
|
||||
json = null;
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string envelopeJson = File.ReadAllText(filePath, Encoding.UTF8);
|
||||
var envelope = JsonUtility.FromJson<SecureSaveEnvelope>(envelopeJson);
|
||||
if (envelope == null || string.IsNullOrEmpty(envelope.payload) || string.IsNullOrEmpty(envelope.signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string expectedSignature = ComputeSignature(category, envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Signature mismatch ({category}/{key}). Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] protectedBytes = Convert.FromBase64String(envelope.payload);
|
||||
byte[] plainBytes;
|
||||
if (!TryUnprotectBytes(category, key, protectedBytes, out plainBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
json = Encoding.UTF8.GetString(plainBytes);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Read failed ({category}/{key}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(string category, string key, string json)
|
||||
{
|
||||
byte[] plainBytes = Encoding.UTF8.GetBytes(json);
|
||||
byte[] protectedBytes = ProtectBytes(category, key, plainBytes);
|
||||
string payloadBase64 = Convert.ToBase64String(protectedBytes);
|
||||
|
||||
var envelope = new SecureSaveEnvelope
|
||||
{
|
||||
version = 2,
|
||||
payload = payloadBase64,
|
||||
signature = ComputeSignature(category, payloadBase64),
|
||||
savedUtcTicks = DateTime.UtcNow.Ticks
|
||||
};
|
||||
|
||||
return JsonUtility.ToJson(envelope, false);
|
||||
}
|
||||
|
||||
private static string GetCategoryDirectory(string category)
|
||||
{
|
||||
string safeCategory = ShortHash("cat|" + category);
|
||||
return Path.Combine(VaultDirectoryPath, "." + safeCategory);
|
||||
}
|
||||
|
||||
private static string GetFilePath(string category, string key, string extension)
|
||||
{
|
||||
string categoryDirectory = GetCategoryDirectory(category);
|
||||
string safeKey = ShortHash("key|" + key);
|
||||
return Path.Combine(categoryDirectory, "." + safeKey + extension);
|
||||
}
|
||||
|
||||
private static string ExtractKeyFromFileName(string path)
|
||||
{
|
||||
string fileName = Path.GetFileNameWithoutExtension(path);
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.StartsWith(".") ? fileName.Substring(1) : fileName;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string category, string payloadBase64)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + category + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(signText));
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ProtectBytes(string category, string key, byte[] plainBytes)
|
||||
{
|
||||
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
|
||||
byte[] dpapiBytes;
|
||||
if (TryProtectWithDpapi(category, plainBytes, out dpapiBytes))
|
||||
{
|
||||
return dpapiBytes;
|
||||
}
|
||||
#endif
|
||||
using (var aes = Aes.Create())
|
||||
{
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = BuildAesKey(category, key);
|
||||
aes.GenerateIV();
|
||||
using (var encryptor = aes.CreateEncryptor())
|
||||
{
|
||||
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||
byte[] result = new byte[aes.IV.Length + cipherBytes.Length];
|
||||
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
|
||||
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryUnprotectBytes(string category, string key, byte[] protectedBytes, out byte[] plainBytes)
|
||||
{
|
||||
plainBytes = null;
|
||||
try
|
||||
{
|
||||
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
|
||||
if (TryUnprotectWithDpapi(category, protectedBytes, out plainBytes))
|
||||
{
|
||||
return plainBytes != null;
|
||||
}
|
||||
#endif
|
||||
using (var aes = Aes.Create())
|
||||
{
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = BuildAesKey(category, key);
|
||||
int ivLength = aes.BlockSize / 8;
|
||||
if (protectedBytes == null || protectedBytes.Length <= ivLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] iv = new byte[ivLength];
|
||||
byte[] cipher = new byte[protectedBytes.Length - ivLength];
|
||||
Buffer.BlockCopy(protectedBytes, 0, iv, 0, ivLength);
|
||||
Buffer.BlockCopy(protectedBytes, ivLength, cipher, 0, cipher.Length);
|
||||
aes.IV = iv;
|
||||
using (var decryptor = aes.CreateDecryptor())
|
||||
{
|
||||
plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
|
||||
return plainBytes != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Decrypt failed ({category}/{key}): {ex.Message}");
|
||||
plainBytes = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
|
||||
private static bool TryProtectWithDpapi(string category, byte[] plainBytes, out byte[] protectedBytes)
|
||||
{
|
||||
protectedBytes = null;
|
||||
if (!EnsureDpapi())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
protectedBytes = s_dpapiProtectMethod.Invoke(null, new object[] { plainBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[];
|
||||
return protectedBytes != null && protectedBytes.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] DPAPI protect failed, fallback to AES: {ex.Message}");
|
||||
protectedBytes = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryUnprotectWithDpapi(string category, byte[] protectedBytes, out byte[] plainBytes)
|
||||
{
|
||||
plainBytes = null;
|
||||
if (!EnsureDpapi())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
plainBytes = s_dpapiUnprotectMethod.Invoke(null, new object[] { protectedBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[];
|
||||
return plainBytes != null && plainBytes.Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
plainBytes = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool EnsureDpapi()
|
||||
{
|
||||
if (s_dpapiInitialized)
|
||||
{
|
||||
return s_dpapiSupported;
|
||||
}
|
||||
|
||||
s_dpapiInitialized = true;
|
||||
try
|
||||
{
|
||||
Type protectedDataType =
|
||||
Type.GetType("System.Security.Cryptography.ProtectedData, System.Security.Cryptography.ProtectedData") ??
|
||||
Type.GetType("System.Security.Cryptography.ProtectedData, System.Security");
|
||||
Type scopeType =
|
||||
Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security.Cryptography.ProtectedData") ??
|
||||
Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security");
|
||||
|
||||
if (protectedDataType == null || scopeType == null)
|
||||
{
|
||||
s_dpapiSupported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
s_dpapiProtectMethod = protectedDataType.GetMethod("Protect", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(byte[]), typeof(byte[]), scopeType }, null);
|
||||
s_dpapiUnprotectMethod = protectedDataType.GetMethod("Unprotect", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(byte[]), typeof(byte[]), scopeType }, null);
|
||||
|
||||
if (s_dpapiProtectMethod == null || s_dpapiUnprotectMethod == null)
|
||||
{
|
||||
s_dpapiSupported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
s_dpapiCurrentUserScope = Enum.Parse(scopeType, "CurrentUser");
|
||||
s_dpapiSupported = s_dpapiCurrentUserScope != null;
|
||||
return s_dpapiSupported;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] DPAPI initialize failed, fallback to AES: {ex.Message}");
|
||||
s_dpapiSupported = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private static byte[] BuildEntropy(string category)
|
||||
{
|
||||
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildAesKey(string category, string key)
|
||||
{
|
||||
return BuildEntropy(category);
|
||||
}
|
||||
|
||||
private static string ShortHash(string value)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + Application.identifier + "|" + SecretSeed));
|
||||
return BitConverter.ToString(hash, 0, 12).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteLegacyPlainFile(string legacyPlainPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(legacyPlainPath) || !File.Exists(legacyPlainPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeleteIfExists(legacyPlainPath);
|
||||
}
|
||||
|
||||
private static void DeleteIfExists(string path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryHidePath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor && Application.platform != RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.Hidden) == 0)
|
||||
{
|
||||
File.SetAttributes(path, attributes | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79fb5f18bfcf1674993076d6b5e5bb9d
|
||||
@@ -32,6 +32,12 @@ public static class StoreExpBottlePurchaseService
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RequiresSinglePurchase(itemSO) && packageCount > 1)
|
||||
{
|
||||
failureMessage = "该物品仅支持单份购买";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (itemSO.costRequirements == null || itemSO.costRequirements.Count == 0)
|
||||
{
|
||||
failureMessage = "不可购买";
|
||||
@@ -45,13 +51,6 @@ public static class StoreExpBottlePurchaseService
|
||||
return false;
|
||||
}
|
||||
|
||||
ExpBottleKind kind;
|
||||
if (!StoreExpBottleGrantResolver.TryResolve(itemSO, out kind))
|
||||
{
|
||||
failureMessage = "当前仅支持经验瓶发放";
|
||||
return false;
|
||||
}
|
||||
|
||||
long longGrantedCount = (long)Mathf.Max(1, itemSO.itemSinglePurchaseQty) * packageCount;
|
||||
if (longGrantedCount > int.MaxValue)
|
||||
{
|
||||
@@ -74,9 +73,13 @@ public static class StoreExpBottlePurchaseService
|
||||
}
|
||||
|
||||
int totalCost = (int)totalCostLong;
|
||||
if (!ValidateGrantTarget(itemSO, out failureMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayerEconomyLedger.EnsureInstance().AttachPlayerData(playerData);
|
||||
ExpBottleLedger.EnsureInstance().AttachPlayerData(playerData);
|
||||
StoreOwnershipLedger.EnsureInstance().InitializeIfNeeded();
|
||||
|
||||
if (!PlayerEconomyLedger.EnsureInstance().HasEnoughCoins(totalCost))
|
||||
{
|
||||
@@ -90,14 +93,95 @@ public static class StoreExpBottlePurchaseService
|
||||
return false;
|
||||
}
|
||||
|
||||
ExpBottleLedger.EnsureInstance().Add(kind, grantedCount);
|
||||
if (!GrantPurchasedItem(playerData, itemSO, grantedCount, out failureMessage))
|
||||
{
|
||||
PlayerEconomyLedger.EnsureInstance().AddCoins(totalCost);
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugPurchaseSuccess(itemSO, totalCost, grantedCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RequiresSinglePurchase(storeItemSO itemSO)
|
||||
{
|
||||
if (itemSO == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return itemSO.itemType == storeItemSO.ItemType.character
|
||||
|| itemSO.itemType == storeItemSO.ItemType.song
|
||||
|| itemSO.itemType == storeItemSO.ItemType.storyPassage;
|
||||
}
|
||||
|
||||
private static bool ValidateGrantTarget(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
failureMessage = string.Empty;
|
||||
if (itemSO == null)
|
||||
{
|
||||
failureMessage = "商品数据丢失";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.consumable:
|
||||
ExpBottleKind bottleKind;
|
||||
if (!StoreExpBottleGrantResolver.TryResolve(itemSO, out bottleKind))
|
||||
{
|
||||
failureMessage = "当前仅支持经验瓶发放";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.character:
|
||||
case storeItemSO.ItemType.song:
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return StoreOwnershipLedger.EnsureInstance().TryGrantOwnershipPreview(itemSO, out failureMessage);
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool GrantPurchasedItem(Player_SO playerData, storeItemSO itemSO, int grantedCount, out string failureMessage)
|
||||
{
|
||||
failureMessage = string.Empty;
|
||||
if (itemSO == null)
|
||||
{
|
||||
failureMessage = "商品数据丢失";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.consumable:
|
||||
ExpBottleKind bottleKind;
|
||||
if (!StoreExpBottleGrantResolver.TryResolve(itemSO, out bottleKind))
|
||||
{
|
||||
failureMessage = "当前仅支持经验瓶发放";
|
||||
return false;
|
||||
}
|
||||
|
||||
ExpBottleLedger.EnsureInstance().AttachPlayerData(playerData);
|
||||
ExpBottleLedger.EnsureInstance().Add(bottleKind, grantedCount);
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.character:
|
||||
case storeItemSO.ItemType.song:
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return StoreOwnershipLedger.EnsureInstance().TryGrantOwnership(itemSO, out failureMessage);
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DebugPurchaseSuccess(storeItemSO itemSO, int totalCost, int grantedCount)
|
||||
{
|
||||
var snapshot = ExpBottleLedger.EnsureInstance().GetSnapshot();
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("[StorePurchase] 已成功购买:");
|
||||
builder.Append(itemSO != null ? itemSO.itemName : "未知物品");
|
||||
@@ -105,6 +189,15 @@ public static class StoreExpBottlePurchaseService
|
||||
builder.Append(totalCost);
|
||||
builder.Append(" | 发放数量=");
|
||||
builder.Append(grantedCount);
|
||||
|
||||
if (itemSO != null && itemSO.itemType != storeItemSO.ItemType.consumable)
|
||||
{
|
||||
builder.Append(" | 已写入OwnershipSave");
|
||||
Debug.Log(builder.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = ExpBottleLedger.EnsureInstance().GetSnapshot();
|
||||
builder.Append(" | 经验瓶库存:");
|
||||
|
||||
bool appendedAny = false;
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
public sealed class StoreOwnershipLedger : MonoBehaviour
|
||||
{
|
||||
private const string RuntimeStoreItemResourcesPath = "so/storeSO";
|
||||
|
||||
public static StoreOwnershipLedger Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<int, StoreOwnershipEntry> entriesByItemId = new Dictionary<int, StoreOwnershipEntry>();
|
||||
private readonly List<storeItemSO> cachedStoreItems = new List<storeItemSO>();
|
||||
private bool initialized;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void Bootstrap()
|
||||
{
|
||||
EnsureInstance();
|
||||
}
|
||||
|
||||
public static StoreOwnershipLedger EnsureInstance()
|
||||
{
|
||||
if (Instance != null)
|
||||
{
|
||||
return Instance;
|
||||
}
|
||||
|
||||
var host = new GameObject("__runtime_ownership_bridge");
|
||||
host.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector;
|
||||
DontDestroyOnLoad(host);
|
||||
Instance = host.AddComponent<StoreOwnershipLedger>();
|
||||
return Instance;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
InitializeIfNeeded();
|
||||
}
|
||||
|
||||
private void OnApplicationPause(bool pauseStatus)
|
||||
{
|
||||
if (pauseStatus)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void InitializeIfNeeded()
|
||||
{
|
||||
if (initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoreOwnershipPayload payload;
|
||||
StoreOwnershipStorage.TryLoad(out payload);
|
||||
RebuildFromPayload(payload);
|
||||
LoadStoreItems();
|
||||
SeedFromCurrentMirrorFlags();
|
||||
SyncAllMirrorFlags();
|
||||
initialized = true;
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void ForceSyncMirrorFlags()
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
LoadStoreItems();
|
||||
SyncAllMirrorFlags();
|
||||
}
|
||||
|
||||
public bool IsOwned(storeItemSO itemSO)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
if (itemSO == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
StoreOwnershipEntry entry;
|
||||
entriesByItemId.TryGetValue(itemSO.itemID, out entry);
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
return (entry != null && entry.owned) || (itemSO.associatedAllyHero != null && itemSO.associatedAllyHero.isUnlocked);
|
||||
case storeItemSO.ItemType.song:
|
||||
return (entry != null && entry.owned) || (itemSO.associatedSong != null && itemSO.associatedSong.isUnlocked);
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return IsStoryGrantOwned(itemSO, entry);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGrantOwnership(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
failureMessage = string.Empty;
|
||||
|
||||
if (!ValidateGrantTarget(itemSO, out failureMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entry = GetOrCreateEntry(itemSO.itemID);
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
case storeItemSO.ItemType.song:
|
||||
entry.owned = true;
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
GrantStoryOwnership(itemSO, entry);
|
||||
break;
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyEntryToItem(itemSO, entry);
|
||||
SaveNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGrantOwnershipPreview(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
return ValidateGrantTarget(itemSO, out failureMessage);
|
||||
}
|
||||
|
||||
public void SaveNow()
|
||||
{
|
||||
if (!initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoreOwnershipStorage.TrySave(CreatePayload());
|
||||
}
|
||||
|
||||
private void RebuildFromPayload(StoreOwnershipPayload payload)
|
||||
{
|
||||
entriesByItemId.Clear();
|
||||
if (payload == null || payload.entries == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < payload.entries.Count; i++)
|
||||
{
|
||||
var entry = payload.entries[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.unlockedStorySonIds == null)
|
||||
{
|
||||
entry.unlockedStorySonIds = new List<int>();
|
||||
}
|
||||
|
||||
entriesByItemId[entry.storeItemId] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
private StoreOwnershipPayload CreatePayload()
|
||||
{
|
||||
var payload = StoreOwnershipStorage.CreateDefaultPayload();
|
||||
foreach (var pair in entriesByItemId)
|
||||
{
|
||||
payload.entries.Add(CloneEntry(pair.Value));
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private void LoadStoreItems()
|
||||
{
|
||||
cachedStoreItems.Clear();
|
||||
var loadedItems = Resources.LoadAll<storeItemSO>(RuntimeStoreItemResourcesPath);
|
||||
var seen = new HashSet<int>();
|
||||
for (int i = 0; i < loadedItems.Length; i++)
|
||||
{
|
||||
var item = loadedItems[i];
|
||||
if (item == null || !seen.Add(item.itemID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cachedStoreItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedFromCurrentMirrorFlags()
|
||||
{
|
||||
bool changed = false;
|
||||
for (int i = 0; i < cachedStoreItems.Count; i++)
|
||||
{
|
||||
var item = cachedStoreItems[i];
|
||||
if (item == null || entriesByItemId.ContainsKey(item.itemID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
StoreOwnershipEntry seededEntry = TrySeedEntry(item);
|
||||
if (seededEntry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entriesByItemId[item.itemID] = seededEntry;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private StoreOwnershipEntry TrySeedEntry(storeItemSO itemSO)
|
||||
{
|
||||
if (itemSO == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
if (itemSO.associatedAllyHero != null && itemSO.associatedAllyHero.isUnlocked)
|
||||
{
|
||||
return new StoreOwnershipEntry { storeItemId = itemSO.itemID, owned = true };
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.song:
|
||||
if (itemSO.associatedSong != null && itemSO.associatedSong.isUnlocked)
|
||||
{
|
||||
return new StoreOwnershipEntry { storeItemId = itemSO.itemID, owned = true };
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return TrySeedStoryEntry(itemSO);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private StoreOwnershipEntry TrySeedStoryEntry(storeItemSO itemSO)
|
||||
{
|
||||
var story = itemSO != null ? itemSO.associatedStoryPassage : null;
|
||||
if (story == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entry = new StoreOwnershipEntry { storeItemId = itemSO.itemID };
|
||||
switch (itemSO.storyPassageGrantMode)
|
||||
{
|
||||
case storeItemSO.StoryPassageGrantMode.fatherOnly:
|
||||
entry.owned = story.isUnlocked;
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.specificSonOnly:
|
||||
if (HasStorySonUnlocked(story, itemSO.associatedStorySonId))
|
||||
{
|
||||
entry.unlockedStorySonIds.Add(itemSO.associatedStorySonId);
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon:
|
||||
if (story.isUnlocked || HasStorySonUnlocked(story, itemSO.associatedStorySonId))
|
||||
{
|
||||
entry.owned = true;
|
||||
if (itemSO.associatedStorySonId >= 0)
|
||||
{
|
||||
entry.unlockedStorySonIds.Add(itemSO.associatedStorySonId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.allSons:
|
||||
if (story.isUnlocked || AreAllStorySonsUnlocked(story))
|
||||
{
|
||||
entry.owned = true;
|
||||
AddAllStorySonIds(story, entry.unlockedStorySonIds);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return entry.owned || entry.unlockedStorySonIds.Count > 0 ? entry : null;
|
||||
}
|
||||
|
||||
private void SyncAllMirrorFlags()
|
||||
{
|
||||
for (int i = 0; i < cachedStoreItems.Count; i++)
|
||||
{
|
||||
var item = cachedStoreItems[i];
|
||||
if (item == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
StoreOwnershipEntry entry;
|
||||
if (!entriesByItemId.TryGetValue(item.itemID, out entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyEntryToItem(item, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateGrantTarget(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
failureMessage = string.Empty;
|
||||
if (itemSO == null)
|
||||
{
|
||||
failureMessage = "商品数据丢失";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsOwned(itemSO))
|
||||
{
|
||||
failureMessage = "物品已解锁";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
if (itemSO.associatedAllyHero == null)
|
||||
{
|
||||
failureMessage = "角色数据未配置";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.song:
|
||||
if (itemSO.associatedSong == null)
|
||||
{
|
||||
failureMessage = "歌曲数据未配置";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
if (itemSO.associatedStoryPassage == null)
|
||||
{
|
||||
failureMessage = "剧情数据未配置";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RequiresSpecificStorySon(itemSO.storyPassageGrantMode))
|
||||
{
|
||||
if (itemSO.associatedStorySonId < 0 || FindStorySonIndex(itemSO.associatedStoryPassage, itemSO.associatedStorySonId) < 0)
|
||||
{
|
||||
failureMessage = "剧情子章节未配置";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GrantStoryOwnership(storeItemSO itemSO, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (entry.unlockedStorySonIds == null)
|
||||
{
|
||||
entry.unlockedStorySonIds = new List<int>();
|
||||
}
|
||||
|
||||
switch (itemSO.storyPassageGrantMode)
|
||||
{
|
||||
case storeItemSO.StoryPassageGrantMode.fatherOnly:
|
||||
entry.owned = true;
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.specificSonOnly:
|
||||
AddStorySonId(entry.unlockedStorySonIds, itemSO.associatedStorySonId);
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon:
|
||||
entry.owned = true;
|
||||
AddStorySonId(entry.unlockedStorySonIds, itemSO.associatedStorySonId);
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.allSons:
|
||||
entry.owned = true;
|
||||
AddAllStorySonIds(itemSO.associatedStoryPassage, entry.unlockedStorySonIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyEntryToItem(storeItemSO itemSO, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (itemSO == null || entry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
if (entry.owned && itemSO.associatedAllyHero != null)
|
||||
{
|
||||
itemSO.associatedAllyHero.isUnlocked = true;
|
||||
MarkDirty(itemSO.associatedAllyHero);
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.song:
|
||||
if (entry.owned && itemSO.associatedSong != null)
|
||||
{
|
||||
itemSO.associatedSong.isUnlocked = true;
|
||||
MarkDirty(itemSO.associatedSong);
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
ApplyStoryEntry(itemSO.associatedStoryPassage, entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStoryEntry(notebook_faterType story, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (story == null || entry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.owned || (entry.unlockedStorySonIds != null && entry.unlockedStorySonIds.Count > 0))
|
||||
{
|
||||
story.isUnlocked = true;
|
||||
MarkDirty(story);
|
||||
}
|
||||
|
||||
if (entry.unlockedStorySonIds == null || story.sonList == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < entry.unlockedStorySonIds.Count; i++)
|
||||
{
|
||||
int targetSonId = entry.unlockedStorySonIds[i];
|
||||
int sonIndex = FindStorySonIndex(story, targetSonId);
|
||||
if (sonIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
story.sonList[sonIndex].son_isUnlocked = true;
|
||||
MarkDirty(story);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsStoryGrantOwned(storeItemSO itemSO, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (itemSO == null || itemSO.associatedStoryPassage == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.storyPassageGrantMode)
|
||||
{
|
||||
case storeItemSO.StoryPassageGrantMode.fatherOnly:
|
||||
return entry != null && entry.owned;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.specificSonOnly:
|
||||
return HasEntryStorySon(entry, itemSO.associatedStorySonId)
|
||||
|| HasStorySonUnlocked(itemSO.associatedStoryPassage, itemSO.associatedStorySonId);
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon:
|
||||
return (entry != null && entry.owned)
|
||||
|| HasEntryStorySon(entry, itemSO.associatedStorySonId)
|
||||
|| HasStorySonUnlocked(itemSO.associatedStoryPassage, itemSO.associatedStorySonId);
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.allSons:
|
||||
return (entry != null && entry.owned) || AreAllStorySonsUnlocked(itemSO.associatedStoryPassage);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasEntryStorySon(StoreOwnershipEntry entry, int sonId)
|
||||
{
|
||||
if (entry == null || entry.unlockedStorySonIds == null || sonId < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < entry.unlockedStorySonIds.Count; i++)
|
||||
{
|
||||
if (entry.unlockedStorySonIds[i] == sonId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasStorySonUnlocked(notebook_faterType story, int sonId)
|
||||
{
|
||||
int index = FindStorySonIndex(story, sonId);
|
||||
return index >= 0 && story.sonList[index].son_isUnlocked;
|
||||
}
|
||||
|
||||
private static bool AreAllStorySonsUnlocked(notebook_faterType story)
|
||||
{
|
||||
if (story == null || story.sonList == null || story.sonList.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < story.sonList.Count; i++)
|
||||
{
|
||||
var son = story.sonList[i];
|
||||
if (son == null || !son.son_isUnlocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int FindStorySonIndex(notebook_faterType story, int sonId)
|
||||
{
|
||||
if (story == null || story.sonList == null || sonId < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < story.sonList.Count; i++)
|
||||
{
|
||||
var son = story.sonList[i];
|
||||
if (son != null && son.son_id == sonId)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool RequiresSpecificStorySon(storeItemSO.StoryPassageGrantMode mode)
|
||||
{
|
||||
return mode == storeItemSO.StoryPassageGrantMode.specificSonOnly
|
||||
|| mode == storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon;
|
||||
}
|
||||
|
||||
private static void AddStorySonId(List<int> sonIds, int sonId)
|
||||
{
|
||||
if (sonIds == null || sonId < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < sonIds.Count; i++)
|
||||
{
|
||||
if (sonIds[i] == sonId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sonIds.Add(sonId);
|
||||
}
|
||||
|
||||
private static void AddAllStorySonIds(notebook_faterType story, List<int> sonIds)
|
||||
{
|
||||
if (story == null || story.sonList == null || sonIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < story.sonList.Count; i++)
|
||||
{
|
||||
var son = story.sonList[i];
|
||||
if (son == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddStorySonId(sonIds, son.son_id);
|
||||
}
|
||||
}
|
||||
|
||||
private StoreOwnershipEntry GetOrCreateEntry(int storeItemId)
|
||||
{
|
||||
StoreOwnershipEntry entry;
|
||||
if (entriesByItemId.TryGetValue(storeItemId, out entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
entry = new StoreOwnershipEntry
|
||||
{
|
||||
storeItemId = storeItemId,
|
||||
owned = false,
|
||||
unlockedStorySonIds = new List<int>()
|
||||
};
|
||||
entriesByItemId[storeItemId] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static StoreOwnershipEntry CloneEntry(StoreOwnershipEntry source)
|
||||
{
|
||||
return new StoreOwnershipEntry
|
||||
{
|
||||
storeItemId = source.storeItemId,
|
||||
owned = source.owned,
|
||||
unlockedStorySonIds = source.unlockedStorySonIds != null
|
||||
? new List<int>(source.unlockedStorySonIds)
|
||||
: new List<int>()
|
||||
};
|
||||
}
|
||||
|
||||
private static void MarkDirty(UnityEngine.Object target)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (target != null)
|
||||
{
|
||||
EditorUtility.SetDirty(target);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 873f2d3326b8e9c42b3ad9757ef2b4b2
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[Serializable]
|
||||
public class StoreOwnershipEntry
|
||||
{
|
||||
public int storeItemId;
|
||||
public bool owned;
|
||||
public List<int> unlockedStorySonIds = new List<int>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StoreOwnershipPayload
|
||||
{
|
||||
public int version;
|
||||
public long lastUpdatedUtcTicks;
|
||||
public List<StoreOwnershipEntry> entries = new List<StoreOwnershipEntry>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StoreOwnershipEnvelope
|
||||
{
|
||||
public string payload;
|
||||
public string signature;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df11f9fb00f4cb949ad6556e341680bc
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
public static class StoreOwnershipStorage
|
||||
{
|
||||
private const string SecretSeed = "ban_total.store_ownership.v1";
|
||||
private const string VaultDirectoryName = ".cache_bridge";
|
||||
private const string MainFileName = ".own.dat";
|
||||
private const string BackupFileName = ".own.bak";
|
||||
private const string TempFileName = ".own.tmp";
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
}
|
||||
|
||||
private static string MainFilePath
|
||||
{
|
||||
get { return Path.Combine(VaultDirectoryPath, MainFileName); }
|
||||
}
|
||||
|
||||
private static string BackupFilePath
|
||||
{
|
||||
get { return Path.Combine(VaultDirectoryPath, BackupFileName); }
|
||||
}
|
||||
|
||||
private static string TempFilePath
|
||||
{
|
||||
get { return Path.Combine(VaultDirectoryPath, TempFileName); }
|
||||
}
|
||||
|
||||
public static bool TryLoad(out StoreOwnershipPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TrySave(StoreOwnershipPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(VaultDirectoryPath);
|
||||
TryHidePath(VaultDirectoryPath);
|
||||
|
||||
var envelopeJson = BuildEnvelopeJson(payload);
|
||||
File.WriteAllText(TempFilePath, envelopeJson, Encoding.UTF8);
|
||||
TryHidePath(TempFilePath);
|
||||
|
||||
if (File.Exists(MainFilePath))
|
||||
{
|
||||
File.Copy(MainFilePath, BackupFilePath, true);
|
||||
TryHidePath(BackupFilePath);
|
||||
}
|
||||
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[StoreOwnershipStorage] Save failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static StoreOwnershipPayload CreateDefaultPayload()
|
||||
{
|
||||
return new StoreOwnershipPayload
|
||||
{
|
||||
version = 1,
|
||||
lastUpdatedUtcTicks = DateTime.UtcNow.Ticks,
|
||||
entries = new System.Collections.Generic.List<StoreOwnershipEntry>()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadPayload(string path, out StoreOwnershipPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envelopeJson = File.ReadAllText(path, Encoding.UTF8);
|
||||
var envelope = JsonUtility.FromJson<StoreOwnershipEnvelope>(envelopeJson);
|
||||
if (envelope == null || string.IsNullOrEmpty(envelope.payload) || string.IsNullOrEmpty(envelope.signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning("[StoreOwnershipStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
var loadedPayload = JsonUtility.FromJson<StoreOwnershipPayload>(payloadJson);
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = loadedPayload;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[StoreOwnershipStorage] Load failed from '{path}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(StoreOwnershipPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
var payloadJson = JsonUtility.ToJson(payload, false);
|
||||
var plainBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
var encryptedBytes = XorTransform(plainBytes, BuildKeyBytes());
|
||||
var payloadBase64 = Convert.ToBase64String(encryptedBytes);
|
||||
|
||||
var envelope = new StoreOwnershipEnvelope
|
||||
{
|
||||
payload = payloadBase64,
|
||||
signature = ComputeSignature(payloadBase64)
|
||||
};
|
||||
|
||||
return JsonUtility.ToJson(envelope, false);
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
var result = new byte[source.Length];
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
result[i] = (byte)(source[i] ^ key[i % key.Length]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void TryHidePath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor && Application.platform != RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.Hidden) == 0)
|
||||
{
|
||||
File.SetAttributes(path, attributes | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f902609758920443b9df12a43213dd6
|
||||
@@ -9,12 +9,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
public static void ReportPlaySong()
|
||||
public static void ReportPlaySong(int songID = 0)
|
||||
{
|
||||
DailyTaskService.EnsureInstance().ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.PlaySong,
|
||||
amount = 1f
|
||||
amount = 1f,
|
||||
songID = songID
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +37,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
public static void ReportWatchStory()
|
||||
{
|
||||
DailyTaskService.EnsureInstance().ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.WatchStory,
|
||||
amount = 1f
|
||||
});
|
||||
}
|
||||
|
||||
public static void ReportUseItem(int amount)
|
||||
{
|
||||
DailyTaskService.EnsureInstance().ReportEvent(new DailyTaskEventData
|
||||
@@ -54,11 +64,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
public static void ReportGameDuration(float durationSeconds)
|
||||
public static void ReportSingleRunCombo(int combo)
|
||||
{
|
||||
DailyTaskService.EnsureInstance().ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.GameDuration,
|
||||
taskType = userTasksPool.TaskType.SingleRunCombo,
|
||||
amount = combo
|
||||
});
|
||||
}
|
||||
|
||||
public static void ReportSingleRunScore(float totalScore)
|
||||
{
|
||||
DailyTaskService.EnsureInstance().ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.SingleRunScore,
|
||||
amount = totalScore
|
||||
});
|
||||
}
|
||||
|
||||
public static void ReportOnlineDuration(float durationSeconds)
|
||||
{
|
||||
DailyTaskService.EnsureInstance().ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.OnlineDuration,
|
||||
amount = durationSeconds
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,15 +14,25 @@ public class DailyTaskRuntimeEntry
|
||||
public class DailyTaskSaveData
|
||||
{
|
||||
public string dateKey;
|
||||
public int dateStamp;
|
||||
public int refreshUsedCount;
|
||||
public int trustedDateStamp;
|
||||
public int lastRefreshDateStamp;
|
||||
public long lastRefreshLocalTicks;
|
||||
public long lastRefreshUtcTicks;
|
||||
public long lastSeenLocalTicks;
|
||||
public long lastSeenUtcTicks;
|
||||
public int timeRollbackDetections;
|
||||
public List<DailyTaskRuntimeEntry> activeTasks = new List<DailyTaskRuntimeEntry>();
|
||||
public List<DailyTaskAccumulatedProgress> accumulatedProgress = new List<DailyTaskAccumulatedProgress>();
|
||||
public List<DailyTaskUniqueIntProgress> uniqueIntProgress = new List<DailyTaskUniqueIntProgress>();
|
||||
}
|
||||
|
||||
public struct DailyTaskEventData
|
||||
{
|
||||
public userTasksPool.TaskType taskType;
|
||||
public float amount;
|
||||
public int songID;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -32,6 +42,13 @@ public class DailyTaskAccumulatedProgress
|
||||
public float progress;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DailyTaskUniqueIntProgress
|
||||
{
|
||||
public int taskType;
|
||||
public List<int> values = new List<int>();
|
||||
}
|
||||
|
||||
public sealed class DailyTaskViewData
|
||||
{
|
||||
public userTasksPool.TaskDefinition definition;
|
||||
|
||||
@@ -4,23 +4,25 @@ using UnityEngine;
|
||||
|
||||
public static class DailyTaskSaveService
|
||||
{
|
||||
private static string SaveFilePath
|
||||
private const string SaveCategory = "daily_task";
|
||||
private const string SaveKey = "runtime";
|
||||
|
||||
private static string LegacySaveFilePath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, "daily_tasks.json"); }
|
||||
}
|
||||
|
||||
public static DailyTaskSaveData Load()
|
||||
{
|
||||
if (!File.Exists(SaveFilePath))
|
||||
{
|
||||
return new DailyTaskSaveData();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(SaveFilePath);
|
||||
var data = JsonUtility.FromJson<DailyTaskSaveData>(json);
|
||||
return data ?? new DailyTaskSaveData();
|
||||
DailyTaskSaveData data;
|
||||
if (SecureSaveVault.TryLoadJson(SaveCategory, SaveKey, out data, LegacySaveFilePath))
|
||||
{
|
||||
return data ?? new DailyTaskSaveData();
|
||||
}
|
||||
|
||||
return new DailyTaskSaveData();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -38,8 +40,7 @@ public static class DailyTaskSaveService
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonUtility.ToJson(data, true);
|
||||
File.WriteAllText(SaveFilePath, json);
|
||||
SecureSaveVault.SaveJson(SaveCategory, SaveKey, data, LegacySaveFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,14 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
|
||||
public event Action OnTasksChanged;
|
||||
|
||||
private enum ProgressCombineMode
|
||||
{
|
||||
Sum,
|
||||
Max
|
||||
}
|
||||
|
||||
private const float OnlineDurationFlushStepSeconds = 5f;
|
||||
|
||||
private readonly Queue<DailyTaskEventData> pendingEvents = new Queue<DailyTaskEventData>();
|
||||
private readonly Dictionary<string, userTasksPool.TaskDefinition> definitionById = new Dictionary<string, userTasksPool.TaskDefinition>(StringComparer.Ordinal);
|
||||
|
||||
@@ -18,6 +26,8 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
private int configuredTaskMaxSize = 5;
|
||||
private int configuredRefreshMaxTimes = 3;
|
||||
private bool initialized;
|
||||
private bool appFocused = true;
|
||||
private float pendingOnlineDurationSeconds;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void Bootstrap()
|
||||
@@ -54,10 +64,53 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
InitializeStorageIfNeeded();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!Application.isPlaying || !appFocused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingOnlineDurationSeconds += Time.unscaledDeltaTime;
|
||||
if (pendingOnlineDurationSeconds >= OnlineDurationFlushStepSeconds)
|
||||
{
|
||||
float flushSeconds = Mathf.Floor(pendingOnlineDurationSeconds);
|
||||
pendingOnlineDurationSeconds = Mathf.Max(0f, pendingOnlineDurationSeconds - flushSeconds);
|
||||
ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.OnlineDuration,
|
||||
amount = flushSeconds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationFocus(bool hasFocus)
|
||||
{
|
||||
appFocused = hasFocus;
|
||||
if (!hasFocus)
|
||||
{
|
||||
FlushPendingOnlineDuration();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationPause(bool pauseStatus)
|
||||
{
|
||||
if (pauseStatus)
|
||||
{
|
||||
FlushPendingOnlineDuration();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
FlushPendingOnlineDuration();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this)
|
||||
{
|
||||
FlushPendingOnlineDuration();
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
}
|
||||
@@ -117,42 +170,39 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
}
|
||||
|
||||
EnsureTodayTasks();
|
||||
float accumulatedIncrement = GetAccumulatedIncrement(eventData);
|
||||
if (accumulatedIncrement <= 0f)
|
||||
|
||||
bool changed = ApplyEventToTrackedProgress(eventData);
|
||||
|
||||
if (saveData != null && saveData.activeTasks != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float accumulatedValue = AddAccumulatedProgress(eventData.taskType, accumulatedIncrement);
|
||||
|
||||
bool changed = false;
|
||||
for (int i = 0; i < saveData.activeTasks.Count; i++)
|
||||
{
|
||||
var runtimeEntry = saveData.activeTasks[i];
|
||||
if (runtimeEntry == null || runtimeEntry.isClaimed)
|
||||
for (int i = 0; i < saveData.activeTasks.Count; i++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var runtimeEntry = saveData.activeTasks[i];
|
||||
if (runtimeEntry == null || runtimeEntry.isClaimed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
userTasksPool.TaskDefinition definition;
|
||||
if (!definitionById.TryGetValue(runtimeEntry.taskID, out definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
userTasksPool.TaskDefinition definition;
|
||||
if (!definitionById.TryGetValue(runtimeEntry.taskID, out definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsEventMatch(definition, eventData))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!IsEventMatch(definition, eventData))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float previousProgress = runtimeEntry.progress;
|
||||
bool previousCompleted = runtimeEntry.isCompleted;
|
||||
runtimeEntry.progress = GetInitialProgress(definition, accumulatedValue);
|
||||
runtimeEntry.isCompleted = runtimeEntry.progress >= Mathf.Max(0.0001f, definition.targetValue);
|
||||
float nextProgress = CalculateProgressForDefinition(definition);
|
||||
bool nextCompleted = nextProgress >= Mathf.Max(0.0001f, definition.targetValue);
|
||||
|
||||
if (!Mathf.Approximately(previousProgress, runtimeEntry.progress) || previousCompleted != runtimeEntry.isCompleted)
|
||||
{
|
||||
changed = true;
|
||||
if (!Mathf.Approximately(runtimeEntry.progress, nextProgress) || runtimeEntry.isCompleted != nextCompleted)
|
||||
{
|
||||
runtimeEntry.progress = nextProgress;
|
||||
runtimeEntry.isCompleted = nextCompleted;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +227,7 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
}
|
||||
|
||||
saveData.refreshUsedCount += 1;
|
||||
StampRefreshUsage();
|
||||
GenerateDailyTasks();
|
||||
Save();
|
||||
NotifyTasksChanged();
|
||||
@@ -278,11 +329,7 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
{
|
||||
if (string.Equals(scene.name, "UI_UI", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.Login,
|
||||
amount = 1f
|
||||
});
|
||||
DailyTaskEventHub.ReportLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +341,7 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
}
|
||||
|
||||
saveData = DailyTaskSaveService.Load() ?? new DailyTaskSaveData();
|
||||
EnsureRuntimeCollections();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
@@ -306,25 +354,36 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
return;
|
||||
}
|
||||
|
||||
string todayKey = GetTodayKey();
|
||||
bool timeStateChanged;
|
||||
int effectiveTodayStamp = GetEffectiveTodayStamp(out timeStateChanged);
|
||||
string todayKey = FormatDateKey(effectiveTodayStamp);
|
||||
if (saveData == null)
|
||||
{
|
||||
saveData = new DailyTaskSaveData();
|
||||
}
|
||||
|
||||
bool needRegenerate = !string.Equals(saveData.dateKey, todayKey, StringComparison.Ordinal)
|
||||
EnsureRuntimeCollections();
|
||||
|
||||
bool needRegenerate = saveData.dateStamp != effectiveTodayStamp
|
||||
|| !string.Equals(saveData.dateKey, todayKey, StringComparison.Ordinal)
|
||||
|| saveData.activeTasks == null
|
||||
|| saveData.activeTasks.Count == 0;
|
||||
|
||||
if (!needRegenerate)
|
||||
{
|
||||
EnsureAccumulatedProgressCompatibility();
|
||||
if (timeStateChanged)
|
||||
{
|
||||
Save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
saveData.dateKey = todayKey;
|
||||
saveData.dateStamp = effectiveTodayStamp;
|
||||
saveData.refreshUsedCount = 0;
|
||||
saveData.accumulatedProgress = new List<DailyTaskAccumulatedProgress>();
|
||||
saveData.uniqueIntProgress = new List<DailyTaskUniqueIntProgress>();
|
||||
GenerateDailyTasks();
|
||||
Save();
|
||||
}
|
||||
@@ -348,7 +407,8 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
}
|
||||
|
||||
int pickCount = Mathf.Min(configuredTaskMaxSize, candidates.Count);
|
||||
var random = new System.Random(unchecked(GetTodayKey().GetHashCode() + saveData.refreshUsedCount * 397));
|
||||
int dateSeed = saveData.dateStamp > 0 ? saveData.dateStamp : GetLocalDateStamp(DateTime.Now);
|
||||
var random = new System.Random(unchecked(dateSeed + saveData.refreshUsedCount * 397));
|
||||
var selectedDefinitions = new List<userTasksPool.TaskDefinition>();
|
||||
|
||||
while (selectedDefinitions.Count < pickCount && candidates.Count > 0)
|
||||
@@ -374,7 +434,7 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
for (int i = 0; i < selectedDefinitions.Count; i++)
|
||||
{
|
||||
var definition = selectedDefinitions[i];
|
||||
float currentProgress = GetInitialProgress(definition, GetAccumulatedProgress(definition.taskType));
|
||||
float currentProgress = CalculateProgressForDefinition(definition);
|
||||
saveData.activeTasks.Add(new DailyTaskRuntimeEntry
|
||||
{
|
||||
taskID = definition.taskID,
|
||||
@@ -415,15 +475,7 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
|
||||
private void EnsureAccumulatedProgressCompatibility()
|
||||
{
|
||||
if (saveData == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveData.accumulatedProgress == null)
|
||||
{
|
||||
saveData.accumulatedProgress = new List<DailyTaskAccumulatedProgress>();
|
||||
}
|
||||
EnsureRuntimeCollections();
|
||||
|
||||
if (saveData.accumulatedProgress.Count > 0 || saveData.activeTasks == null || saveData.activeTasks.Count == 0)
|
||||
{
|
||||
@@ -445,8 +497,12 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
continue;
|
||||
}
|
||||
|
||||
AddAccumulatedProgress(definition.taskType, Mathf.Max(0f, runtimeEntry.progress));
|
||||
changed = true;
|
||||
if (definition.taskType == userTasksPool.TaskType.DifferentSongs)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
changed |= AddOrCombineProgress(definition.taskType, Mathf.Max(0f, runtimeEntry.progress), GetCombineMode(definition.taskType));
|
||||
}
|
||||
|
||||
if (changed)
|
||||
@@ -468,23 +524,74 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsEventMatch(userTasksPool.TaskDefinition definition, DailyTaskEventData eventData)
|
||||
private bool ApplyEventToTrackedProgress(DailyTaskEventData eventData)
|
||||
{
|
||||
return definition != null && definition.taskType == eventData.taskType;
|
||||
switch (eventData.taskType)
|
||||
{
|
||||
case userTasksPool.TaskType.Login:
|
||||
case userTasksPool.TaskType.PlaySong:
|
||||
case userTasksPool.TaskType.FullCombo:
|
||||
case userTasksPool.TaskType.WatchStory:
|
||||
case userTasksPool.TaskType.UseItem:
|
||||
case userTasksPool.TaskType.ShareGame:
|
||||
{
|
||||
bool changed = AddOrCombineProgress(eventData.taskType, Mathf.Max(1f, eventData.amount), ProgressCombineMode.Sum);
|
||||
if (eventData.taskType == userTasksPool.TaskType.PlaySong && eventData.songID > 0)
|
||||
{
|
||||
changed |= AddUniqueIntValue(userTasksPool.TaskType.DifferentSongs, eventData.songID);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
case userTasksPool.TaskType.TotalScore:
|
||||
case userTasksPool.TaskType.SpendCoins:
|
||||
case userTasksPool.TaskType.GameDuration:
|
||||
case userTasksPool.TaskType.OnlineDuration:
|
||||
return AddOrCombineProgress(eventData.taskType, Mathf.Max(0f, eventData.amount), ProgressCombineMode.Sum);
|
||||
case userTasksPool.TaskType.SingleRunCombo:
|
||||
case userTasksPool.TaskType.SingleRunScore:
|
||||
return AddOrCombineProgress(eventData.taskType, Mathf.Max(0f, eventData.amount), ProgressCombineMode.Max);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private float AddAccumulatedProgress(userTasksPool.TaskType taskType, float amount)
|
||||
private static bool IsEventMatch(userTasksPool.TaskDefinition definition, DailyTaskEventData eventData)
|
||||
{
|
||||
if (saveData == null)
|
||||
if (definition == null)
|
||||
{
|
||||
saveData = new DailyTaskSaveData();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (saveData.accumulatedProgress == null)
|
||||
if (definition.taskType == eventData.taskType)
|
||||
{
|
||||
saveData.accumulatedProgress = new List<DailyTaskAccumulatedProgress>();
|
||||
return true;
|
||||
}
|
||||
|
||||
return definition.taskType == userTasksPool.TaskType.DifferentSongs && eventData.taskType == userTasksPool.TaskType.PlaySong;
|
||||
}
|
||||
|
||||
private float CalculateProgressForDefinition(userTasksPool.TaskDefinition definition)
|
||||
{
|
||||
if (definition == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float targetValue = Mathf.Max(0.0001f, definition.targetValue);
|
||||
switch (definition.taskType)
|
||||
{
|
||||
case userTasksPool.TaskType.DifferentSongs:
|
||||
return Mathf.Min(GetUniqueIntCount(userTasksPool.TaskType.DifferentSongs), targetValue);
|
||||
default:
|
||||
return Mathf.Min(Mathf.Max(0f, GetAccumulatedProgress(definition.taskType)), targetValue);
|
||||
}
|
||||
}
|
||||
|
||||
private bool AddOrCombineProgress(userTasksPool.TaskType taskType, float amount, ProgressCombineMode combineMode)
|
||||
{
|
||||
EnsureRuntimeCollections();
|
||||
|
||||
int taskTypeValue = (int)taskType;
|
||||
for (int i = 0; i < saveData.accumulatedProgress.Count; i++)
|
||||
{
|
||||
@@ -494,17 +601,25 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.progress = Mathf.Max(0f, entry.progress + amount);
|
||||
return entry.progress;
|
||||
float nextValue = combineMode == ProgressCombineMode.Max
|
||||
? Mathf.Max(entry.progress, amount)
|
||||
: Mathf.Max(0f, entry.progress + amount);
|
||||
|
||||
if (Mathf.Approximately(entry.progress, nextValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.progress = nextValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
var newEntry = new DailyTaskAccumulatedProgress
|
||||
saveData.accumulatedProgress.Add(new DailyTaskAccumulatedProgress
|
||||
{
|
||||
taskType = taskTypeValue,
|
||||
progress = Mathf.Max(0f, amount)
|
||||
};
|
||||
saveData.accumulatedProgress.Add(newEntry);
|
||||
return newEntry.progress;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private float GetAccumulatedProgress(userTasksPool.TaskType taskType)
|
||||
@@ -529,14 +644,151 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private static float GetInitialProgress(userTasksPool.TaskDefinition definition, float accumulatedValue)
|
||||
private bool AddUniqueIntValue(userTasksPool.TaskType taskType, int value)
|
||||
{
|
||||
if (definition == null)
|
||||
if (value <= 0)
|
||||
{
|
||||
return 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
return Mathf.Min(Mathf.Max(0f, accumulatedValue), Mathf.Max(0.0001f, definition.targetValue));
|
||||
EnsureRuntimeCollections();
|
||||
int taskTypeValue = (int)taskType;
|
||||
for (int i = 0; i < saveData.uniqueIntProgress.Count; i++)
|
||||
{
|
||||
var entry = saveData.uniqueIntProgress[i];
|
||||
if (entry == null || entry.taskType != taskTypeValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.values == null)
|
||||
{
|
||||
entry.values = new List<int>();
|
||||
}
|
||||
|
||||
if (entry.values.Contains(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.values.Add(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
saveData.uniqueIntProgress.Add(new DailyTaskUniqueIntProgress
|
||||
{
|
||||
taskType = taskTypeValue,
|
||||
values = new List<int> { value }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private int GetUniqueIntCount(userTasksPool.TaskType taskType)
|
||||
{
|
||||
if (saveData == null || saveData.uniqueIntProgress == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int taskTypeValue = (int)taskType;
|
||||
for (int i = 0; i < saveData.uniqueIntProgress.Count; i++)
|
||||
{
|
||||
var entry = saveData.uniqueIntProgress[i];
|
||||
if (entry == null || entry.taskType != taskTypeValue || entry.values == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return entry.values.Count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static ProgressCombineMode GetCombineMode(userTasksPool.TaskType taskType)
|
||||
{
|
||||
switch (taskType)
|
||||
{
|
||||
case userTasksPool.TaskType.SingleRunCombo:
|
||||
case userTasksPool.TaskType.SingleRunScore:
|
||||
return ProgressCombineMode.Max;
|
||||
default:
|
||||
return ProgressCombineMode.Sum;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRuntimeCollections()
|
||||
{
|
||||
if (saveData == null)
|
||||
{
|
||||
saveData = new DailyTaskSaveData();
|
||||
}
|
||||
|
||||
if (saveData.activeTasks == null)
|
||||
{
|
||||
saveData.activeTasks = new List<DailyTaskRuntimeEntry>();
|
||||
}
|
||||
|
||||
if (saveData.accumulatedProgress == null)
|
||||
{
|
||||
saveData.accumulatedProgress = new List<DailyTaskAccumulatedProgress>();
|
||||
}
|
||||
|
||||
if (saveData.uniqueIntProgress == null)
|
||||
{
|
||||
saveData.uniqueIntProgress = new List<DailyTaskUniqueIntProgress>();
|
||||
}
|
||||
}
|
||||
|
||||
private int GetEffectiveTodayStamp(out bool stateChanged)
|
||||
{
|
||||
stateChanged = false;
|
||||
if (saveData == null)
|
||||
{
|
||||
saveData = new DailyTaskSaveData();
|
||||
}
|
||||
|
||||
int currentStamp = GetLocalDateStamp(DateTime.Now);
|
||||
long nowLocalTicks = DateTime.Now.Ticks;
|
||||
long nowUtcTicks = DateTime.UtcNow.Ticks;
|
||||
|
||||
if (saveData.trustedDateStamp <= 0)
|
||||
{
|
||||
saveData.trustedDateStamp = currentStamp;
|
||||
stateChanged = true;
|
||||
}
|
||||
else if (currentStamp < saveData.trustedDateStamp)
|
||||
{
|
||||
saveData.timeRollbackDetections += 1;
|
||||
currentStamp = saveData.trustedDateStamp;
|
||||
stateChanged = true;
|
||||
}
|
||||
else if (currentStamp > saveData.trustedDateStamp)
|
||||
{
|
||||
saveData.trustedDateStamp = currentStamp;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
if (saveData.lastSeenLocalTicks != nowLocalTicks)
|
||||
{
|
||||
saveData.lastSeenLocalTicks = nowLocalTicks;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
if (saveData.lastSeenUtcTicks != nowUtcTicks)
|
||||
{
|
||||
saveData.lastSeenUtcTicks = nowUtcTicks;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
return Mathf.Max(currentStamp, saveData.trustedDateStamp);
|
||||
}
|
||||
|
||||
private void StampRefreshUsage()
|
||||
{
|
||||
saveData.lastRefreshDateStamp = saveData.dateStamp;
|
||||
saveData.lastRefreshLocalTicks = DateTime.Now.Ticks;
|
||||
saveData.lastRefreshUtcTicks = DateTime.UtcNow.Ticks;
|
||||
}
|
||||
|
||||
private bool TryGetTaskPair(string taskID, out DailyTaskRuntimeEntry runtimeEntry, out userTasksPool.TaskDefinition definition)
|
||||
@@ -627,27 +879,33 @@ public sealed class DailyTaskService : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private static float GetAccumulatedIncrement(DailyTaskEventData eventData)
|
||||
private void FlushPendingOnlineDuration()
|
||||
{
|
||||
switch (eventData.taskType)
|
||||
if (pendingOnlineDurationSeconds <= 0f)
|
||||
{
|
||||
case userTasksPool.TaskType.TotalScore:
|
||||
case userTasksPool.TaskType.SpendCoins:
|
||||
case userTasksPool.TaskType.GameDuration:
|
||||
return Mathf.Max(0f, eventData.amount);
|
||||
case userTasksPool.TaskType.Login:
|
||||
case userTasksPool.TaskType.PlaySong:
|
||||
case userTasksPool.TaskType.FullCombo:
|
||||
case userTasksPool.TaskType.UseItem:
|
||||
return Mathf.Max(1f, eventData.amount);
|
||||
default:
|
||||
return Mathf.Max(0f, eventData.amount);
|
||||
return;
|
||||
}
|
||||
|
||||
float flushSeconds = pendingOnlineDurationSeconds;
|
||||
pendingOnlineDurationSeconds = 0f;
|
||||
ReportEvent(new DailyTaskEventData
|
||||
{
|
||||
taskType = userTasksPool.TaskType.OnlineDuration,
|
||||
amount = flushSeconds
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetTodayKey()
|
||||
private static int GetLocalDateStamp(DateTime dateTime)
|
||||
{
|
||||
return DateTime.Now.ToString("yyyy-MM-dd");
|
||||
return dateTime.Year * 10000 + dateTime.Month * 100 + dateTime.Day;
|
||||
}
|
||||
|
||||
private static string FormatDateKey(int dateStamp)
|
||||
{
|
||||
int year = dateStamp / 10000;
|
||||
int month = (dateStamp / 100) % 100;
|
||||
int day = dateStamp % 100;
|
||||
return year.ToString("D4") + "-" + month.ToString("D2") + "-" + day.ToString("D2");
|
||||
}
|
||||
|
||||
private void Save()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
%YAML 1.1
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
@@ -14,78 +14,111 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
tasks:
|
||||
- taskID: 10101
|
||||
description: "\u767B\u5F55\u6E38\u620Fv50"
|
||||
description: 登录游戏 1 次
|
||||
taskType: 0
|
||||
refreshFrequency: 1
|
||||
targetValue: 1
|
||||
rewardType: 0
|
||||
rewardAmount: 50
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10102
|
||||
description: "\u5B8C\u62103\u9996\u6B4C\u66F2"
|
||||
description: 完成任意歌曲 3 首
|
||||
taskType: 1
|
||||
refreshFrequency: 1
|
||||
targetValue: 3
|
||||
rewardType: 0
|
||||
rewardAmount: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10103
|
||||
description: "\u8FBE\u62101\u6B21Full Combo"
|
||||
taskType: 2
|
||||
description: 单局达到 99 连击
|
||||
taskType: 9
|
||||
refreshFrequency: 1
|
||||
targetValue: 1
|
||||
targetValue: 99
|
||||
rewardType: 0
|
||||
rewardAmount: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10104
|
||||
description: "\u7D2F\u8BA1\u83B7\u5F971000000\u5206"
|
||||
taskType: 3
|
||||
description: 单局总分达到 1000000
|
||||
taskType: 10
|
||||
refreshFrequency: 1
|
||||
targetValue: 1000000
|
||||
rewardType: 1
|
||||
rewardAmount: 4
|
||||
rewardType: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10105
|
||||
description: "\u4F7F\u7528\u4EFB\u610F\u7C7B\u578B\u7ECF\u9A8C\u74F6\u4E00\u6B21"
|
||||
taskType: 5
|
||||
description: 今日累计分数达到 10000000
|
||||
taskType: 3
|
||||
refreshFrequency: 1
|
||||
targetValue: 1
|
||||
targetValue: 10000000
|
||||
rewardType: 0
|
||||
rewardAmount: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10106
|
||||
description: "\u82B1\u8D391000\u91D1\u5E01"
|
||||
taskType: 7
|
||||
description: 阅读剧情 1 次
|
||||
taskType: 4
|
||||
refreshFrequency: 1
|
||||
targetValue: 1000
|
||||
targetValue: 1
|
||||
rewardType: 0
|
||||
rewardAmount: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10107
|
||||
description: "\u6E38\u620F\u65F6\u957F\u8FBE\u523030\u5206\u949F"
|
||||
taskType: 8
|
||||
description: 使用任意经验提升或突破材料 1 次
|
||||
taskType: 5
|
||||
refreshFrequency: 1
|
||||
targetValue: 1800
|
||||
targetValue: 1
|
||||
rewardType: 0
|
||||
rewardAmount: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10108
|
||||
description: 在线 15 分钟
|
||||
taskType: 11
|
||||
refreshFrequency: 1
|
||||
targetValue: 900
|
||||
rewardType: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10109
|
||||
description: 消费 2000 coins
|
||||
taskType: 7
|
||||
refreshFrequency: 1
|
||||
targetValue: 2000
|
||||
rewardType: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
selectionWeight: 1
|
||||
- taskID: 10110
|
||||
description: 游玩 3 首不同的歌曲
|
||||
taskType: 12
|
||||
refreshFrequency: 1
|
||||
targetValue: 3
|
||||
rewardType: 0
|
||||
rewardAmount: 1
|
||||
rewardExpBottleKind: 0
|
||||
rewardDushMaterialKind: 0
|
||||
addToTaskPool: 1
|
||||
|
||||
@@ -55,7 +55,11 @@ public class userTasksPool : ScriptableObject
|
||||
UseItem,
|
||||
ShareGame,
|
||||
SpendCoins,
|
||||
GameDuration
|
||||
GameDuration,
|
||||
SingleRunCombo,
|
||||
SingleRunScore,
|
||||
OnlineDuration,
|
||||
DifferentSongs
|
||||
}
|
||||
|
||||
public enum RewardType
|
||||
|
||||
@@ -111,6 +111,11 @@ public class NoteSpawner : MonoBehaviour
|
||||
// Guard to avoid double-trigger / deadlock
|
||||
private bool immediateSettlementTriggered = false;
|
||||
|
||||
public bool IsImmediateSettlementTriggered
|
||||
{
|
||||
get { return immediateSettlementTriggered; }
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// Load saved visual speed multiplier before any spawning logic uses it
|
||||
|
||||
@@ -149,6 +149,7 @@ public class settlementController : MonoBehaviour
|
||||
private Transform cachedIntroMvpTransform;
|
||||
private Vector3 cachedIntroMvpBaseLocalPos;
|
||||
private bool hasCachedIntroMvpBaseLocalPos;
|
||||
private bool settlementHistoryRecorded;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -183,6 +184,9 @@ public class settlementController : MonoBehaviour
|
||||
try { gameMusicMixer.SetFloat(lowpassParamName, 22000f); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
RegisterCurrentLineupDeployCount();
|
||||
settlementHistoryRecorded = false;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -243,6 +247,62 @@ public class settlementController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterCurrentLineupDeployCount()
|
||||
{
|
||||
HashSet<int> uniqueHeroIds = new HashSet<int>();
|
||||
AllyHero_SO[] allHeroes = null;
|
||||
|
||||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
|
||||
{
|
||||
for (int i = 0; i < teamUIController.Instance.allySlotIds.Count; i++)
|
||||
{
|
||||
int heroId = teamUIController.Instance.allySlotIds[i];
|
||||
if (heroId > 0)
|
||||
{
|
||||
uniqueHeroIds.Add(heroId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueHeroIds.Count == 0)
|
||||
{
|
||||
for (int slotIndex = 1; slotIndex <= 5; slotIndex++)
|
||||
{
|
||||
int heroId = PlayerPrefs.GetInt("selected_heroSlot0" + slotIndex + "_heroID", 0);
|
||||
if (heroId > 0)
|
||||
{
|
||||
uniqueHeroIds.Add(heroId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueHeroIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
allHeroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
|
||||
if (allHeroes == null || allHeroes.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int heroId in uniqueHeroIds)
|
||||
{
|
||||
for (int i = 0; i < allHeroes.Length; i++)
|
||||
{
|
||||
AllyHero_SO hero = allHeroes[i];
|
||||
if (hero == null || hero.ally_heroID != heroId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hero.IncrementBattleDeployCount();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void startSettlement_uiUpdate()
|
||||
{
|
||||
if (settlementUiInitialized)
|
||||
@@ -251,6 +311,7 @@ public class settlementController : MonoBehaviour
|
||||
return;
|
||||
}
|
||||
settlementUiInitialized = true;
|
||||
settlementHistoryRecorded = false;
|
||||
|
||||
// Documentation text normalized.
|
||||
InitializeSettlementCanvas();
|
||||
@@ -317,19 +378,23 @@ public class settlementController : MonoBehaviour
|
||||
|
||||
if (!GameConfig.autoPlayEnabled)
|
||||
{
|
||||
DailyTaskEventHub.ReportPlaySong();
|
||||
DailyTaskEventHub.ReportPlaySong(thisSong_so != null ? thisSong_so.songID : 0);
|
||||
DailyTaskEventHub.ReportTotalScore(targetTotalScore);
|
||||
DailyTaskEventHub.ReportSingleRunScore(targetTotalScore);
|
||||
|
||||
if (InGamePerformanceManager.Instance != null && InGamePerformanceManager.Instance.HighestComboThisRun > 0)
|
||||
{
|
||||
DailyTaskEventHub.ReportSingleRunCombo(InGamePerformanceManager.Instance.HighestComboThisRun);
|
||||
}
|
||||
|
||||
if (targetMissCount <= 0 && noteCountRaw > 0)
|
||||
{
|
||||
DailyTaskEventHub.ReportFullCombo();
|
||||
}
|
||||
|
||||
if (sessionDurationForTasks > 0f)
|
||||
{
|
||||
DailyTaskEventHub.ReportGameDuration(sessionDurationForTasks);
|
||||
}
|
||||
}
|
||||
|
||||
TryRecordRecentPlayHistory(noteCountRaw);
|
||||
|
||||
perfectHitCount_Text.text = targetPerfectCount.ToString();
|
||||
greatHitCount_Text.text = targetGreatCount.ToString();
|
||||
goodHitCount_Text.text = targetGoodCount.ToString();
|
||||
@@ -460,6 +525,127 @@ public class settlementController : MonoBehaviour
|
||||
StartSettlementIntro();
|
||||
}
|
||||
|
||||
private void TryRecordRecentPlayHistory(int noteCountRaw)
|
||||
{
|
||||
if (settlementHistoryRecorded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
settlementHistoryRecorded = true;
|
||||
|
||||
var record = new RecentPlayRecord
|
||||
{
|
||||
playedAt = System.DateTime.Now.ToString("MM-dd, HH:mm"),
|
||||
songID = thisSong_so != null ? thisSong_so.songID : 0,
|
||||
songName = thisSong_so != null && !string.IsNullOrWhiteSpace(thisSong_so.songName)
|
||||
? thisSong_so.songName
|
||||
: (bmm != null ? bmm.parsedTitle : "Unknown"),
|
||||
difficultyDisplay = BuildDifficultyDisplay(),
|
||||
accuracy = targetAccuracyPercent,
|
||||
srks = CalculateSongRankingScore(noteCountRaw),
|
||||
totalScore = targetTotalScore,
|
||||
scoreReadable = true,
|
||||
wasEarlySettlement = IsEarlySettlement(noteCountRaw),
|
||||
wasAllPerfect = IsAllPerfectRun(noteCountRaw)
|
||||
};
|
||||
|
||||
RecentPlayHistoryStore.Push(record);
|
||||
}
|
||||
|
||||
private string BuildDifficultyDisplay()
|
||||
{
|
||||
int difficulty = bmm != null ? bmm.assignedDifficulty : -1;
|
||||
string suffix = GetDifficultyShortName(difficulty);
|
||||
float level = 0f;
|
||||
|
||||
if (thisSong_so != null && thisSong_so.chartFiles != null)
|
||||
{
|
||||
for (int i = 0; i < thisSong_so.chartFiles.Count; i++)
|
||||
{
|
||||
ChartFileEntry entry = thisSong_so.chartFiles[i];
|
||||
if (entry == null || entry.difficulty != difficulty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
level = entry.difficultyLEVEL;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string levelText = Mathf.Approximately(level, Mathf.Round(level))
|
||||
? Mathf.RoundToInt(level).ToString()
|
||||
: level.ToString("0.#");
|
||||
|
||||
if (string.IsNullOrEmpty(levelText) || levelText == "0")
|
||||
{
|
||||
return suffix;
|
||||
}
|
||||
|
||||
return levelText + "_" + suffix;
|
||||
}
|
||||
|
||||
private static string GetDifficultyShortName(int difficulty)
|
||||
{
|
||||
switch (difficulty)
|
||||
{
|
||||
case 0: return "EZ";
|
||||
case 1: return "HD";
|
||||
case 2: return "IN";
|
||||
case 3: return "IM";
|
||||
default: return "UN";
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateSongRankingScore(int noteCountRaw)
|
||||
{
|
||||
int totalNotes = Mathf.Max(1, noteCountRaw);
|
||||
float accuracyScore = Mathf.Clamp01(targetAccuracyPercent / 100f);
|
||||
float scoreScore = Mathf.Clamp01((float)targetTotalScore / Mathf.Max(1, _1000000));
|
||||
int highestCombo = InGamePerformanceManager.Instance != null ? InGamePerformanceManager.Instance.HighestComboThisRun : 0;
|
||||
float comboScore = Mathf.Clamp01((float)highestCombo / totalNotes);
|
||||
float judgementScore = Mathf.Clamp01(
|
||||
(targetPerfectCount + targetGreatCount * 0.7f + targetGoodCount * 0.35f) / totalNotes);
|
||||
float missPenalty = Mathf.Clamp01((float)targetMissCount / totalNotes);
|
||||
|
||||
float srks =
|
||||
accuracyScore * 42f +
|
||||
scoreScore * 28f +
|
||||
comboScore * 18f +
|
||||
judgementScore * 12f;
|
||||
|
||||
srks -= missPenalty * 18f;
|
||||
|
||||
if (targetMissCount <= 0 && totalNotes > 0)
|
||||
{
|
||||
srks += 4f;
|
||||
}
|
||||
|
||||
return Mathf.Clamp(srks, 0f, 100f);
|
||||
}
|
||||
|
||||
private bool IsEarlySettlement(int noteCountRaw)
|
||||
{
|
||||
NoteSpawner spawner = FindAnyObjectByType<NoteSpawner>();
|
||||
if (spawner != null && spawner.IsImmediateSettlementTriggered)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
int parsedNoteAmount = bmm != null ? bmm.parsedNoteAmount : 0;
|
||||
return parsedNoteAmount > 0 && noteCountRaw < parsedNoteAmount;
|
||||
}
|
||||
|
||||
private bool IsAllPerfectRun(int noteCountRaw)
|
||||
{
|
||||
return noteCountRaw > 0 &&
|
||||
targetPerfectCount >= noteCountRaw &&
|
||||
targetGreatCount == 0 &&
|
||||
targetGoodCount == 0 &&
|
||||
targetMissCount == 0;
|
||||
}
|
||||
|
||||
private void StartSettlementIntro()
|
||||
{
|
||||
skipIntroRequested = false;
|
||||
|
||||
Reference in New Issue
Block a user