#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; /// /// 打包前一键初始化工具:把随包发布的玩家进度状态恢复到默认(零)值。 /// /// 只重置“玩家进度状态”,绝不改动业务/配置数据。具体范围见菜单确认框。 /// 明确不碰:isUnlocked(角色/歌曲/剧情解锁)、itemPurchaseQuota(限购配置)、 /// equippedSkillGroupIDs(技能配装)、associatedAllyHero 等引用、DLC 配置、 /// 拥有权账本 .own.dat、以及技能/羁绊/皮肤等定义资产。 /// public static class PackagingResetTool { private const string GeneratedEquipmentFolder = "Assets/Resources/so/uEquip"; private const string NextGeneratedIdPrefsKey = "bansonic_equipment_next_id_v1"; // .cache_bridge 下按固定文件名持久化的本机测试存档(不进包,但编辑器 Play 会回写 .asset)。 // 注意:故意不含 .own.dat / .own.bak(拥有权账本),以免影响解锁业务逻辑。 private static readonly string[] CacheBridgeSaveFiles = { ".eco.dat", ".eco.bak", ".eco.tmp", // PlayerEconomyStorage(货币) ".ahd.dat", ".ahd.bak", ".ahd.tmp", // AllyHeroDeployLedgerStorage(英雄成长) ".xpv.dat", ".xpv.bak", ".xpv.tmp", // ExpBottleLedgerStorage(经验瓶) ".dsm.dat", ".dsm.bak", ".dsm.tmp", // DushMaterialLedgerStorage(突破材料) ".eqc.dat", ".eqc.bak", ".eqc.tmp", // EquipmentConsumableLedgerStorage(装备消耗品) }; [MenuItem("Tools/Packaging/一键初始化打包设置(恢复默认)", false, 0)] public static void ResetForPackaging() { bool isBatchMode = Application.isBatchMode; if (!isBatchMode) { bool confirmed = EditorUtility.DisplayDialog( "一键初始化打包设置", "将执行以下重置(仅玩家进度,不改配置/解锁/技能配装):\n\n" + "1. 玩家:金币、材料、经验、等级清零\n" + "2. 英雄:经验、成长阶位、自动突破、等级锁、出战/完成/MVP 统计、入队日期清零\n" + "2.5 歌曲:游玩次数、累计时长、各难度成绩记录清零(歌曲解锁/谱面配置不动)\n" + "3. 装备:删除 " + GeneratedEquipmentFolder + " 下所有生成装备,清除英雄已装备引用\n" + "4. 商店:所有物品 purchasedCount、user_has_read 清零(限购配置 itemPurchaseQuota 不动)\n" + "5. 清理本机测试存档(.cache_bridge 货币/成长/经验瓶/突破材料/装备消耗品、player_rks、player_experience、player_skills、装备/商店存档,不含拥有权账本)与相关 PlayerPrefs\n\n" + "此操作会修改并保存资产文件,建议在版本控制下执行。是否继续?", "执行重置", "取消"); if (!confirmed) { return; } } else { Debug.Log("[PackagingReset] BatchMode 检测到,跳过确认弹窗,直接执行重置。"); } var report = new StringBuilder(); int errors = 0; // 【关键】先删除运行时存档,再清理 .asset。 // 原因:SongData.OnEnable() 在编辑器模式下会从存档回写到 .asset; // 如果先改 .asset 再删存档,.asset 被修改时触发 OnEnable() → 从存档回写 → .asset 又脏了。 errors += ClearRuntimeSaves(report); errors += ClearPlayerPrefs(report); try { AssetDatabase.StartAssetEditing(); errors += ResetPlayers(report); errors += ResetPlayerSkills(report); errors += ResetHeroes(report); errors += ResetSongs(report); errors += DeleteGeneratedEquipmentAssets(report); errors += ResetStoreItems(report); } catch (Exception ex) { errors++; report.AppendLine("资产重置阶段异常:" + ex.Message); } finally { AssetDatabase.StopAssetEditing(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } report.Insert(0, errors == 0 ? "打包初始化完成,未发现错误。\n\n" : "打包初始化完成,但有 " + errors + " 处警告/错误,请查看下方明细。\n\n"); Debug.Log("[PackagingReset]\n" + report); if (!isBatchMode) { EditorUtility.DisplayDialog( errors == 0 ? "初始化完成" : "初始化完成(有警告)", report.ToString(), "好"); } } private static int ResetPlayers(StringBuilder report) { int errors = 0; int count = 0; foreach (var guid in AssetDatabase.FindAssets("t:Player_SO")) { string path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so == null) { continue; } var serialized = new SerializedObject(so); SetIntIfPresent(serialized, "player_coins", 0); SetIntIfPresent(serialized, "player_material", 0); SetIntIfPresent(serialized, "player_currentEXP", 0); SetIntIfPresent(serialized, "player_currentLevel", 0); SetFloatIfPresent(serialized, "uRankingScore", 0f); // 经验瓶 / 突破材料等消耗品库存也一并清零(属于玩家进度,非配置)。 SetIntIfPresent(serialized, "commonExpBottle78001", 0); SetIntIfPresent(serialized, "mediumExpBottle78002", 0); SetIntIfPresent(serialized, "superiorExpBottle78003", 0); SetIntIfPresent(serialized, "supremeExpBottle78004", 0); SetIntIfPresent(serialized, "extraordinaryExpBottle78005", 0); SetIntIfPresent(serialized, "celestialExpBottle78006", 0); SetIntIfPresent(serialized, "dushMaterial78021", 0); SetIntIfPresent(serialized, "dushMaterial78022", 0); SetIntIfPresent(serialized, "dushMaterial78023", 0); SetIntIfPresent(serialized, "dushMaterial78024", 0); serialized.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(so); count++; } report.AppendLine("玩家资产已重置:" + count + " 个 Player_SO。"); try { SecureSaveVault.Delete("player_skills", "runtime"); PlayerProgressBackupService.ClearPlayerSkillBackup(); report.AppendLine("已清理 player_skills 运行时存档与备份(player_skills/runtime)。"); } catch (Exception ex) { errors++; report.AppendLine("清理 player_skills 存档失败:" + ex.Message); } return errors; } private static int ResetPlayerSkills(StringBuilder report) { int count = 0; foreach (var guid in AssetDatabase.FindAssets("t:userLevel_skills_SO")) { string path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so == null || so.skills == null || so.skills.Count == 0) { continue; } var serialized = new SerializedObject(so); var skills = serialized.FindProperty("skills"); if (skills != null && skills.isArray) { for (int i = 0; i < skills.arraySize; i++) { var entry = skills.GetArrayElementAtIndex(i); SetChildBool(entry, "isEnabled", i == 0); } } serialized.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(so); count++; } report.AppendLine("玩家技能资产已重置:" + count + " 个 userLevel_skills_SO(仅启用第 0 个技能)。"); return 0; } private static int ResetHeroes(StringBuilder report) { int count = 0; foreach (var guid in AssetDatabase.FindAssets("t:AllyHero_SO")) { string path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so == null) { continue; } var serialized = new SerializedObject(so); // 成长进度(不动 isUnlocked / equippedSkillGroupIDs)。 SetIntIfPresent(serialized, "ally_currentEXP", 0); SetIntIfPresent(serialized, "ally_growthUnlockedTierIndex", 0); SetBoolIfPresent(serialized, "ally_autoBreakthroughEnabled", false); SetBoolIfPresent(serialized, "level_lock", false); // 战斗统计。 SetIntIfPresent(serialized, "ally_battleDeployCount", 0); SetIntIfPresent(serialized, "ally_finishCount", 0); SetIntIfPresent(serialized, "ally_mvpCount", 0); SetLongIfPresent(serialized, "ally_joinDateUtcTicks", 0L); // 装备引用:因为要删除全部生成装备,这里同步清空引用避免悬空。 SetObjectIfPresent(serialized, "equippedEquipment", null); SetStringIfPresent(serialized, "equippedEquipmentId", string.Empty); serialized.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(so); count++; } report.AppendLine("英雄资产已重置:" + count + " 个 AllyHero_SO(经验/阶位/统计/装备引用)。"); return 0; } private static int ResetSongs(StringBuilder report) { int count = 0; int charts = 0; foreach (var guid in AssetDatabase.FindAssets("t:SongData")) { string path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so == null) { continue; } var serialized = new SerializedObject(so); // 顶层游玩进度(不动 songID / isUnlocked / bpm / 谱面配置等)。 SetStringIfPresent(serialized, "usernameID", string.Empty); SetIntIfPresent(serialized, "game_enterTimes", 0); SetFloatIfPresent(serialized, "time_totalPlayingTime", 0f); // 逐难度成绩记录(chartFiles 数组内),只清成绩,保留难度/敌人/谱面配置。 var chartFiles = serialized.FindProperty("chartFiles"); if (chartFiles != null && chartFiles.isArray) { for (int i = 0; i < chartFiles.arraySize; i++) { var entry = chartFiles.GetArrayElementAtIndex(i); SetChildInt(entry, "lastScoreForThisDifficulty", 0); SetChildInt(entry, "chartPersonalRecordForThisDifficulty", 0); SetChildInt(entry, "idolPersonalRecordForThisDifficulty", 0); SetChildInt(entry, "totalPersonalRecordForThisDifficulty", 0); SetChildFloat(entry, "levelProgressForThisDifficulty", 0f); charts++; } } serialized.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(so); // 【关键】清空内存中的运行时数据映射,防止 OnEnable/SavePersistent 回写脏数据。 // SongData 在编辑器模式下会从存档加载到内存 map(personalRecordMap 等), // 再通过 SyncEntriesFromMaps() 同步到 chartFiles[] 序列化字段。 // 即使磁盘上的存档和 .asset 都清零了,内存中的 map 如果还有旧值, // Unity 保存资产时会把内存脏数据写回磁盘。必须显式清空。 so.ClearSaveDataAndReload(); count++; } report.AppendLine("歌曲资产已重置:" + count + " 个 SongData(游玩次数/时长/" + charts + " 条难度成绩记录)。"); return 0; } private static void SetChildInt(SerializedProperty parent, string field, int value) { var prop = parent.FindPropertyRelative(field); if (prop != null) { prop.intValue = value; } } private static void SetChildFloat(SerializedProperty parent, string field, float value) { var prop = parent.FindPropertyRelative(field); if (prop != null) { prop.floatValue = value; } } private static void SetChildBool(SerializedProperty parent, string field, bool value) { var prop = parent.FindPropertyRelative(field); if (prop != null) { prop.boolValue = value; } } private static int DeleteGeneratedEquipmentAssets(StringBuilder report) { if (!AssetDatabase.IsValidFolder(GeneratedEquipmentFolder)) { report.AppendLine("装备目录不存在,跳过删除:" + GeneratedEquipmentFolder); return 0; } int errors = 0; int deleted = 0; foreach (var guid in AssetDatabase.FindAssets("t:equipmentSO", new[] { GeneratedEquipmentFolder })) { string path = AssetDatabase.GUIDToAssetPath(guid); if (AssetDatabase.DeleteAsset(path)) { deleted++; } else { errors++; report.AppendLine("删除装备失败:" + path); } } report.AppendLine("已删除生成装备资产:" + deleted + " 个(目录 " + GeneratedEquipmentFolder + ")。"); return errors; } private static int ResetStoreItems(StringBuilder report) { int count = 0; foreach (var guid in AssetDatabase.FindAssets("t:storeItemSO")) { string path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so == null) { continue; } var serialized = new SerializedObject(so); // 只清运行时计数,不动 itemPurchaseQuota / isOnShelf / 关联引用等配置。 SetIntIfPresent(serialized, "purchasedCount", 0); SetBoolIfPresent(serialized, "user_has_read", false); serialized.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(so); count++; } report.AppendLine("商店物品已重置:" + count + " 个 storeItemSO(purchasedCount / user_has_read 清零)。"); return 0; } private static int ClearRuntimeSaves(StringBuilder report) { int errors = 0; // 歌曲成绩存档(必须先删除,避免回写覆盖已清零的 .asset)。 int songSavesCleared = 0; foreach (var guid in AssetDatabase.FindAssets("t:SongData")) { string path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so == null || so.songID <= 0) { continue; } try { string legacySongPath = Path.Combine(Application.persistentDataPath, $"SongData_{so.songID}.json"); SecureSaveVault.Delete("song_runtime", so.songID.ToString(), legacySongPath); songSavesCleared++; } catch (Exception ex) { errors++; report.AppendLine($"清理歌曲 {so.songID} 存档失败:" + ex.Message); } } report.AppendLine("已清理歌曲运行时存档:" + songSavesCleared + " 个(song_runtime/*)。"); // 商店 purchasedCount 存档(SecureSaveVault + 旧版明文文件)。 try { string legacyStorePath = Path.Combine(Application.persistentDataPath, "storeSystem_state.json"); SecureSaveVault.Delete("store_state", "runtime", legacyStorePath); report.AppendLine("已清理商店运行时存档(store_state/runtime)。"); } catch (Exception ex) { errors++; report.AppendLine("清理商店存档失败:" + ex.Message); } // 生成装备运行时存档。 try { SecureSaveVault.Delete("runtime_equipment", "generated_list"); report.AppendLine("已清理生成装备运行时存档(runtime_equipment/generated_list)。"); } catch (Exception ex) { errors++; report.AppendLine("清理装备存档失败:" + ex.Message); } // 货币 / 英雄成长的独立加密存档(按固定文件名)。 string cacheBridge = Path.Combine(Application.persistentDataPath, ".cache_bridge"); int fileDeleted = 0; foreach (var fileName in CacheBridgeSaveFiles) { try { string full = Path.Combine(cacheBridge, fileName); if (File.Exists(full)) { File.Delete(full); fileDeleted++; } } catch (Exception ex) { errors++; report.AppendLine("删除存档文件失败(" + fileName + "):" + ex.Message); } } report.AppendLine("已清理 .cache_bridge 货币/成长存档文件:" + fileDeleted + " 个(保留拥有权账本 .own.*)。"); // player_rks / player_experience 等 SecureSaveVault 存档。 try { SecureSaveVault.Delete("player_rks", "overall"); report.AppendLine("已清理 player_rks 运行时存档(player_rks/overall)。"); } catch (Exception ex) { errors++; report.AppendLine("清理 player_rks 存档失败:" + ex.Message); } try { string legacyExpPath = Path.Combine(Application.persistentDataPath, "player_experience.json"); SecureSaveVault.Delete("player_experience", "runtime", legacyExpPath); PlayerProgressBackupService.ClearPlayerExperienceBackup(); report.AppendLine("已清理 player_experience 运行时存档(player_experience/runtime)。"); } catch (Exception ex) { errors++; report.AppendLine("清理 player_experience 存档失败:" + ex.Message); } return errors; } private static int ClearPlayerPrefs(StringBuilder report) { PlayerPrefs.DeleteKey(NextGeneratedIdPrefsKey); int equipKeysCleared = 0; int debtKeysCleared = 0; foreach (var guid in AssetDatabase.FindAssets("t:AllyHero_SO")) { string path = AssetDatabase.GUIDToAssetPath(guid); var so = AssetDatabase.LoadAssetAtPath(path); if (so == null) { continue; } var serialized = new SerializedObject(so); var idProp = serialized.FindProperty("ally_heroID"); if (idProp == null) { continue; } int heroId = idProp.intValue; // 已装备装备的本机记录会指向被删装备,需清理(不动技能配装记录)。 string equipKey = "ally_equippedEquipment_" + heroId; if (PlayerPrefs.HasKey(equipKey)) { PlayerPrefs.DeleteKey(equipKey); equipKeysCleared++; } // 升阶欠账镜像键(idolUpgrade 用):属于成长进度残留。 string debtKey = "idol_upgrade_pending_debt_" + heroId; if (PlayerPrefs.HasKey(debtKey)) { PlayerPrefs.DeleteKey(debtKey); debtKeysCleared++; } } // 队伍槽位经验镜像键(selected_heroSlot0X_exp)。不动 _heroID 槽位编队本身, // 仅把已归零英雄对应的槽位经验一并清零,避免下次启动被读回覆盖 .asset。 int slotExpCleared = 0; for (int slot = 1; slot <= 5; slot++) { string expKey = "selected_heroSlot0" + slot + "_exp"; if (PlayerPrefs.HasKey(expKey)) { PlayerPrefs.DeleteKey(expKey); slotExpCleared++; } } PlayerPrefs.Save(); report.AppendLine("已清理 PlayerPrefs:next-id 键 + " + equipKeysCleared + " 个已装备记录 + " + debtKeysCleared + " 个升阶欠账键 + " + slotExpCleared + " 个槽位经验键。"); return 0; } private static void SetIntIfPresent(SerializedObject so, string field, int value) { var prop = so.FindProperty(field); if (prop != null) { prop.intValue = value; } } private static void SetLongIfPresent(SerializedObject so, string field, long value) { var prop = so.FindProperty(field); if (prop != null) { prop.longValue = value; } } private static void SetFloatIfPresent(SerializedObject so, string field, float value) { var prop = so.FindProperty(field); if (prop != null) { prop.floatValue = value; } } private static void SetBoolIfPresent(SerializedObject so, string field, bool value) { var prop = so.FindProperty(field); if (prop != null) { prop.boolValue = value; } } private static void SetStringIfPresent(SerializedObject so, string field, string value) { var prop = so.FindProperty(field); if (prop != null) { prop.stringValue = value; } } private static void SetObjectIfPresent(SerializedObject so, string field, UnityEngine.Object value) { var prop = so.FindProperty(field); if (prop != null) { prop.objectReferenceValue = value; } } } #endif