using System; using UnityEngine; using System.Collections.Generic; using Spine.Unity; #if UNITY_EDITOR using UnityEditor; #endif [CreateAssetMenu(fileName = "NewAllyHero", menuName = "SO_Data/AllyHero")] public class AllyHero_SO : ScriptableObject { public enum AllyRolePosition { 攻击, 回血, 回蓝, 加分, 综合 } private const string EquippedEquipmentSaveCategory = "ally_equipped_equipment"; private const string EquippedEquipmentRecoverySlotPrefix = "ally_equipped_equipment_"; public static readonly string[] BehaviourAxisNames = { "题海战术", "诛锄异己", "逸乐共谋", "完美至上", "涵养心境", "众志成城", "过激行为", "二次形变", "自有主见", "迷茫或凡想" }; [Header("Inspector")] public string ally_heroName; public string ally_heroDesignation; public int ally_heroID; public bool isUnlocked; [Header("Role Setup")] [InspectorName("所属阵营")] [Tooltip("Uses the same category list as equipment skill types.")] public equipmentSO.EquipmentSkillType allyType; [InspectorName("角色定位")] [Tooltip("Primary combat role used for configuration and display.")] public AllyRolePosition allyRolePosition = AllyRolePosition.综合; [Tooltip("Optional obsession tag used by memory skills such as 30011012. Leave empty to ignore mismatch checks.")] public string obsessionTag; [Header("DLC")] [Tooltip("DLC key that owns this hero. Leave empty for base-game heroes that are always available. When set, the hero is only accessible when the matching DLC is owned.")] public string sourceDlcId; [Header("Inspector")] public Sprite ally_heroImage; [Header("Inspector")] public Sprite ally_heroProfile; [Header("Inspector")] public Sprite ally_heroSelectIcon; [Header("Inspector")] public Sprite ally_heroIcon; [Header("Inspector")] public Sprite ally_heroPoster; [Header("Inspector")] public Sprite ally_hero_HD_image; [Header("Inspector")] public Sprite ally_hero_squareProfile; [Header("Inspector")] public Sprite ally_hero_settleDisplay; [Header("spine")] public SkeletonDataAsset ally_heroSpineData; [Header("Skins")] public string selectedSkinId; [Header("Inspector")] public Color ally_heroThemeColor; [Header("Inspector")] [TextArea(3, 10)] [Tooltip("Documentation text normalized.")] public string ally_heroDescription; [Header("Inspector")] public List levelStats = new List(); [System.Serializable] public class AllyLevelInfo { [Tooltip("Documentation text normalized.")] public string levelName; public int levelID; [Tooltip("Documentation text normalized.")] public int attack = 0; [Tooltip("Documentation text normalized.")] public int maxHP = 100; [Tooltip("Documentation text normalized.")] public float damageResistance = 0f; [Tooltip("Documentation text normalized.")] public int maxMana = 100; [Tooltip("Documentation text normalized.")] public float scoreEfficiency = 1f; [Header("Inspector")] [Tooltip("Documentation text normalized.")] public int requiredEXP; [Header("Inspector")] [Tooltip("Documentation text normalized.")] public int skill_slot_limited; [Header("Note-hit shared parameters")] [Tooltip("Documentation text normalized.")] public int manaGainGood = 1; [Tooltip("Documentation text normalized.")] public int manaGainGreat = 2; [Tooltip("Documentation text normalized.")] public int manaGainPerfect = 3; [Tooltip("Documentation text normalized.")] public int manaGainOnMiss = 5; [Tooltip("Documentation text normalized.")] public float damageMultiplierGood = 0.5f; [Tooltip("Documentation text normalized.")] public float damageMultiplierGreat = 0.75f; [Tooltip("Documentation text normalized.")] public float damageMultiplierPerfect = 1f; [Tooltip("Documentation text normalized.")] public float missHpLossBase = 10f; } [System.Serializable] public class AllyBehaviourAxis { public string axisName; [Range(0, 100)] public int value; } [Header("Inspector")] public int ally_currentEXP; public int ally_growthUnlockedTierIndex; [Tooltip("When enabled, level display/effective level resolution is clamped to the previous tier while this hero is breakthrough-locked, even if EXP has already reached the next tier threshold.")] public bool level_lock; public bool ally_autoBreakthroughEnabled; public int ally_battleDeployCount; public int ally_finishCount; public int ally_mvpCount; public long ally_joinDateUtcTicks; [Header("Behaviour Radar")] public List behaviourAxes = new List(); [Header("Skills")] public SkillDefinition[] availableSkills; [Tooltip("Indexes into availableSkills for preselected skills; empty means none selected")] public int[] selectedSkillIndices = new int[0]; [Tooltip("Primary selected skill index into availableSkills (-1 = none)")] public int primarySkillIndex = -1; [Tooltip("Skill groups defined on this hero. Each SkillGroup has a numeric skillGroupID which can be equipped into slots to allow activation of the entire group via that ID.")] public SkillGroup[] skillGroups = new SkillGroup[0]; [Tooltip("Equipped skill group IDs for this ally. Each int references a skillGroupID defined in skillGroups. Use 0 to indicate empty.")] public int[] equippedSkillGroupIDs = new int[0]; [Header("Equipments")] public equipmentSO equippedEquipment; public string equippedEquipmentId; // 【性能】装备存档已从磁盘水合过一次的标记。水合后内存即权威来源: // SetEquippedEquipment / ApplyEquippedEquipmentPayload / ClearEquippedEquipment 都会同步更新内存与磁盘, // 因此无需反复解密读盘。idols 界面每次切换角色时 RebuildBag 会对"每件装备 × 每个英雄"调用 // LoadEquippedEquipmentFromLocal 做占用检查,若每次都真正解密 SecureSaveVault 会造成严重卡顿。 // 该标记确保每个英雄整个会话只读盘一次,之后走内存,行为完全不变。 [System.NonSerialized] private bool equippedEquipmentHydrated; public SkillDefinition GetPrimarySkill() { if (availableSkills == null || primarySkillIndex < 0 || primarySkillIndex >= availableSkills.Length) return null; return availableSkills[primarySkillIndex]; } public SkillGroup GetPrimarySkillGroup() { int[] runtimeGroupIds = GetEffectiveEquippedSkillGroupIDs(); if (runtimeGroupIds != null && runtimeGroupIds.Length > 0) { foreach (int gid in runtimeGroupIds) { if (gid == 0) continue; SkillGroup group = GetSkillGroupByID(gid); if (group != null) return group; } } return null; } public SkillGroup GetSkillGroupByID(int groupID) { if (skillGroups != null && skillGroups.Length > 0) { foreach (SkillGroup group in skillGroups) { if (group != null && group.skillGroupID == groupID) return group; } } return EquipmentSkillGroupLibrary.ResolveSkillGroup(groupID); } public bool ValidateEquippedSkillSelections(bool persistIfChanged = true) { LoadEquippedSkillsFromLocal(); LoadEquippedEquipmentFromLocal(); if (equippedSkillGroupIDs == null) { equippedSkillGroupIDs = Array.Empty(); } int currentLevelId = AllyHeroLevelRuntimeResolver.GetCurrentLevelId(this); int maxSkillSlots = Mathf.Max(0, AllyHeroLevelRuntimeResolver.GetEffectiveSkillSlotLimit(this)); List validatedGroupIds = new List(Mathf.Min(equippedSkillGroupIDs.Length, maxSkillSlots)); HashSet seenGroupIds = new HashSet(); for (int i = 0; i < equippedSkillGroupIDs.Length; i++) { int groupId = equippedSkillGroupIDs[i]; if (groupId <= 0 || !seenGroupIds.Add(groupId)) { continue; } if (validatedGroupIds.Count >= maxSkillSlots) { continue; } SkillGroup group = GetOwnedSkillGroupByID(groupId); if (group == null) { continue; } int requiredLevelId = Mathf.Clamp(group.thisSkill_levelLimit, 1, 4); if (currentLevelId < requiredLevelId) { continue; } validatedGroupIds.Add(groupId); } int[] validatedArray = validatedGroupIds.ToArray(); bool changed = !AreSkillGroupIdArraysEqual(equippedSkillGroupIDs, validatedArray); if (!changed) { return false; } equippedSkillGroupIDs = validatedArray; if (persistIfChanged) { SaveEquippedSkillsToLocal(); } #if UNITY_EDITOR if (!Application.isPlaying) { EditorUtility.SetDirty(this); if (persistIfChanged) { AssetDatabase.SaveAssets(); } } #endif return true; } // 性能:GetEffectiveEquippedSkillGroupIDs 在单次掉血/事件中会被多入口重复调用(每次都 new List+HashSet+ToArray)。 // 用"输入快照比对"缓存返回数组:仅当影响返回值的可变输入变化时才重建。所有调用点均只读遍历返回数组, // 故返回共享缓存安全。EXP/tier 由外部 AllyHeroDeployLedger 直接写公有字段(无法用脏标记拦截), // 故此处按值/引用快照比对——这些比较都不分配,命中缓存时零 GC,且结果与每次重算完全一致(等效)。 // 全局装备数据版本号。装备的换装/卸下(影响跨英雄幻象额外槽)以及对已装备装备 // sa_skillID/ia_skillID 的“原地修改”(引用不变,单靠 _snapEquipment 引用比对无法察觉) // 都会自增此计数,从而让所有英雄的 GetEffectiveEquippedSkillGroupIDs 缓存失效重建。 // 这些操作都发生在菜单/养成界面,战斗中不变,故战斗热路径仍是零 GC 命中缓存。 private static int s_equipmentDataRevision; // 装备数据发生“引用不变的原地变更”或“可能影响跨英雄派生结果”的变更时调用, // 使所有英雄的装备派生缓存(有效技能组 ID)在下次读取时重算。 public static void InvalidateEquipmentDerivedCaches() { s_equipmentDataRevision++; } [System.NonSerialized] private int[] _cachedEffectiveGroupIds; [System.NonSerialized] private bool _cachedGroupIdsValid; [System.NonSerialized] private int[] _snapEquippedGroupIdsRef; // equippedSkillGroupIDs 引用(整体替换才变) [System.NonSerialized] private int _snapCurrentEXP; [System.NonSerialized] private int _snapUnlockedTierIndex; [System.NonSerialized] private equipmentSO _snapEquipment; [System.NonSerialized] private int _snapEquipmentDataRevision = -1; public int[] GetEffectiveEquippedSkillGroupIDs() { // 快照比对:任一影响返回值的输入变化则失效重建。 equipmentSO currentEquipment = GetEquippedEquipmentResolved(); if (_cachedGroupIdsValid && _snapEquipmentDataRevision == s_equipmentDataRevision && ReferenceEquals(_snapEquippedGroupIdsRef, equippedSkillGroupIDs) && _snapCurrentEXP == ally_currentEXP && _snapUnlockedTierIndex == ally_growthUnlockedTierIndex && ReferenceEquals(_snapEquipment, currentEquipment)) { return _cachedEffectiveGroupIds; } var result = new List(); var dedupe = new HashSet(); int heroSkillSlotsRemaining = GetEffectiveSkillSlotLimit(); if (equippedSkillGroupIDs != null) { for (int i = 0; i < equippedSkillGroupIDs.Length; i++) { if (heroSkillSlotsRemaining <= 0) { break; } int groupId = equippedSkillGroupIDs[i]; if (groupId <= 0 || !dedupe.Add(groupId)) { continue; } result.Add(groupId); heroSkillSlotsRemaining--; } } if (currentEquipment != null) { TryAddEquipmentSkillGroupId(result, dedupe, currentEquipment.sa_skillID); TryAddEquipmentSkillGroupId(result, dedupe, currentEquipment.ia_skillID); } // 写入缓存并记录本次输入快照(与上面失效判断的字段一一对应)。 _cachedEffectiveGroupIds = result.ToArray(); _snapEquippedGroupIdsRef = equippedSkillGroupIDs; _snapCurrentEXP = ally_currentEXP; _snapUnlockedTierIndex = ally_growthUnlockedTierIndex; _snapEquipment = currentEquipment; _snapEquipmentDataRevision = s_equipmentDataRevision; _cachedGroupIdsValid = true; return _cachedEffectiveGroupIds; } public int GetEffectiveSkillSlotLimit() { AllyLevelInfo currentLevelInfo = GetEffectiveLevelForCurrentEXP(); int slotLimit = currentLevelInfo != null ? Mathf.Max(0, currentLevelInfo.skill_slot_limited) : 0; if (HasExclusiveIllusionExtraSlotBonus()) { slotLimit += 1; } return slotLimit; } public AllyLevelInfo GetEffectiveLevelInfoWithEquipment(AllyLevelInfo baseInfo) { if (baseInfo == null) { return null; } AllyLevelInfo resolved = CloneLevelInfo(baseInfo); resolved.skill_slot_limited = GetEffectiveSkillSlotLimit(); equipmentSO equipment = GetEquippedEquipmentResolved(); if (equipment == null) { return resolved; } resolved.maxHP = ApplyIntEquipmentBonus( resolved.maxHP, equipment.maxHp, equipment, equipmentSO.EquipmentSpecialEffectType.MaxHp); resolved.attack = ApplyIntEquipmentBonus( resolved.attack, equipment.attack, equipment, equipmentSO.EquipmentSpecialEffectType.Attack); resolved.maxMana = ApplyIntEquipmentBonus( resolved.maxMana, equipment.maxMana, equipment, equipmentSO.EquipmentSpecialEffectType.MaxMana); resolved.damageResistance = ApplyFloatEquipmentBonus( resolved.damageResistance, equipment.damageResistance, equipment, equipmentSO.EquipmentSpecialEffectType.DamageResistance); resolved.scoreEfficiency = ApplyFloatEquipmentBonus( resolved.scoreEfficiency, equipment.scoreEfficiency, equipment, equipmentSO.EquipmentSpecialEffectType.ScoreEfficiency); return resolved; } private bool HasExclusiveIllusionExtraSlotBonus() { equipmentSO equipment = GetEquippedEquipmentResolved(); if (equipment == null || equipment.ia_skillID != 30021007) { return false; } AllyHero_SO winner = ResolveExclusiveIllusionExtraSlotOwner(); return winner != null && winner.ally_heroID == ally_heroID; } private static AllyHero_SO ResolveExclusiveIllusionExtraSlotOwner() { AllyHero_SO[] heroes = LoadAllHeroAssetsForEquipmentChecks(); if (heroes == null || heroes.Length == 0) { return null; } AllyHero_SO selected = null; for (int i = 0; i < heroes.Length; i++) { AllyHero_SO hero = heroes[i]; if (hero == null) { continue; } hero.LoadEquippedEquipmentFromLocal(); equipmentSO equipped = hero.GetEquippedEquipmentResolved(); if (equipped == null || equipped.ia_skillID != 30021007) { continue; } if (selected == null || hero.ally_heroID < selected.ally_heroID || (hero.ally_heroID == selected.ally_heroID && string.CompareOrdinal(hero.ally_heroName ?? string.Empty, selected.ally_heroName ?? string.Empty) < 0)) { selected = hero; } } return selected; } private static AllyHero_SO[] LoadAllHeroAssetsForEquipmentChecks() { #if UNITY_EDITOR if (!Application.isPlaying) { string[] guids = AssetDatabase.FindAssets("t:AllyHero_SO", new[] { "Assets/Resources/so/ally" }); var heroes = new List(guids.Length); for (int i = 0; i < guids.Length; i++) { string path = AssetDatabase.GUIDToAssetPath(guids[i]); AllyHero_SO hero = AssetDatabase.LoadAssetAtPath(path); if (hero != null) { heroes.Add(hero); } } return heroes.ToArray(); } #endif return RuntimeResourcesCache.LoadAll("so/ally"); } private static void TryAddEquipmentSkillGroupId(List result, HashSet dedupe, int groupId) { if (groupId <= 0 || result == null || dedupe == null || !dedupe.Add(groupId)) { return; } result.Add(groupId); } private static bool AreSkillGroupIdArraysEqual(int[] left, int[] right) { int leftLength = left != null ? left.Length : 0; int rightLength = right != null ? right.Length : 0; if (leftLength != rightLength) { return false; } for (int i = 0; i < leftLength; i++) { if (left[i] != right[i]) { return false; } } return true; } private SkillGroup GetOwnedSkillGroupByID(int groupId) { if (skillGroups == null || skillGroups.Length == 0) { return null; } for (int i = 0; i < skillGroups.Length; i++) { SkillGroup group = skillGroups[i]; if (group != null && group.skillGroupID == groupId) { return group; } } return null; } public int GetUnlockedLevelIndex() { List sorted = BuildSortedLevelStats(); return ResolveUnlockedLevelIndex(sorted); } public int GetExpQualifiedLevelIndex() { List sorted = BuildSortedLevelStats(); return ResolveExpQualifiedLevelIndex(sorted); } public int GetDisplayLevelIndex() { List sorted = BuildSortedLevelStats(); return ResolveDisplayLevelIndex(sorted); } public string GetDisplayLevelRatingKey() { int displayIndex = GetDisplayLevelIndex(); return DisplayLevelIndexToRatingKey(displayIndex); } // 用外部传入的成长值(来自持久化账本 AllyHeroDeployLedger)计算等级评级, // 而不是读取 SO 上被打包烘焙的镜像字段。用于 teamSelector 等直接依赖账本真相的界面, // 避免"账本已加载但尚未回写 SO 镜像"窗口内读到过期的烘焙值。判级规则与 GetDisplayLevelRatingKey 完全一致。 public string GetDisplayLevelRatingKeyFromGrowth(int currentExp, int unlockedTierIndex, bool levelLocked) { int displayIndex = ResolveDisplayLevelIndexFromGrowth(currentExp, unlockedTierIndex, levelLocked); return DisplayLevelIndexToRatingKey(displayIndex); } private static string DisplayLevelIndexToRatingKey(int displayIndex) { if (displayIndex <= 0) return "C"; if (displayIndex == 1) return "B"; if (displayIndex == 2) return "A"; return "S"; } private int ResolveDisplayLevelIndexFromGrowth(int currentExp, int unlockedTierIndex, bool levelLocked) { List sorted = BuildSortedLevelStats(); if (sorted == null || sorted.Count == 0) { return -1; } int safeExp = Mathf.Max(0, currentExp); int expQualifiedIndex = 0; for (int i = 0; i < sorted.Count; i++) { if (safeExp >= sorted[i].requiredEXP) { expQualifiedIndex = i; } else { break; } } if (!levelLocked) { return expQualifiedIndex; } int unlockedIndex = Mathf.Clamp(unlockedTierIndex, 0, sorted.Count - 1); return Mathf.Clamp(Mathf.Min(expQualifiedIndex, unlockedIndex), 0, sorted.Count - 1); } public AllyLevelInfo GetEffectiveLevelForCurrentEXP() { List sorted = BuildSortedLevelStats(); int unlockedIndex = ResolveUnlockedLevelIndex(sorted); int expQualifiedIndex = ResolveExpQualifiedLevelIndex(sorted); if (unlockedIndex < 0 || expQualifiedIndex < 0 || sorted == null || sorted.Count == 0) { return null; } int effectiveIndex = Mathf.Clamp(Mathf.Min(expQualifiedIndex, unlockedIndex), 0, sorted.Count - 1); return sorted[effectiveIndex]; } // 性能:levelStats 是序列化配置,运行时不变,故排序结果可缓存,避免每次事件 new List + Sort(闭包+排序)。 // 用 (引用, Count) 快照判失效,防止极少数运行时改配置的情况漏更新。返回缓存列表,调用方只读(仅索引读取)。 [System.NonSerialized] private List _cachedSortedLevelStats; [System.NonSerialized] private bool _cachedSortedLevelStatsIsNull; [System.NonSerialized] private object _snapLevelStatsRef; [System.NonSerialized] private int _snapLevelStatsCount = -1; private List BuildSortedLevelStats() { if (_cachedSortedLevelStats != null && ReferenceEquals(_snapLevelStatsRef, levelStats) && _snapLevelStatsCount == (levelStats != null ? levelStats.Count : 0)) { return _cachedSortedLevelStatsIsNull ? null : _cachedSortedLevelStats; } var sorted = new List(); _snapLevelStatsRef = levelStats; _snapLevelStatsCount = levelStats != null ? levelStats.Count : 0; if (levelStats == null || levelStats.Count == 0) { // 空配置:原逻辑返回空列表(非 null)。缓存该空列表。 _cachedSortedLevelStats = sorted; _cachedSortedLevelStatsIsNull = false; return sorted; } for (int i = 0; i < levelStats.Count; i++) { if (levelStats[i] != null) { sorted.Add(levelStats[i]); } } if (sorted.Count == 0) { // 原逻辑:全为 null 时返回 null。缓存该 null 语义(仍记录快照避免每次重建)。 _cachedSortedLevelStats = sorted; // 存空列表占位,使缓存有效 _cachedSortedLevelStatsIsNull = true; return null; } sorted.Sort((left, right) => left.requiredEXP.CompareTo(right.requiredEXP)); _cachedSortedLevelStats = sorted; _cachedSortedLevelStatsIsNull = false; return sorted; } private int ResolveUnlockedLevelIndex(List sorted) { if (sorted == null || sorted.Count == 0) { return -1; } return Mathf.Clamp(ally_growthUnlockedTierIndex, 0, sorted.Count - 1); } private int ResolveExpQualifiedLevelIndex(List sorted) { if (sorted == null || sorted.Count == 0) { return -1; } int expQualifiedIndex = 0; for (int i = 0; i < sorted.Count; i++) { if (ally_currentEXP >= sorted[i].requiredEXP) { expQualifiedIndex = i; } else { break; } } return Mathf.Clamp(expQualifiedIndex, 0, sorted.Count - 1); } private int ResolveDisplayLevelIndex(List sorted) { if (sorted == null || sorted.Count == 0) { return -1; } int expQualifiedIndex = ResolveExpQualifiedLevelIndex(sorted); if (!level_lock) { return expQualifiedIndex; } int unlockedIndex = ResolveUnlockedLevelIndex(sorted); return Mathf.Clamp(Mathf.Min(expQualifiedIndex, unlockedIndex), 0, sorted.Count - 1); } private AllyLevelInfo CloneLevelInfo(AllyLevelInfo source) { return new AllyLevelInfo { levelName = source.levelName, levelID = source.levelID, attack = source.attack, maxHP = source.maxHP, damageResistance = source.damageResistance, maxMana = source.maxMana, scoreEfficiency = source.scoreEfficiency, requiredEXP = source.requiredEXP, skill_slot_limited = source.skill_slot_limited, manaGainGood = source.manaGainGood, manaGainGreat = source.manaGainGreat, manaGainPerfect = source.manaGainPerfect, manaGainOnMiss = source.manaGainOnMiss, damageMultiplierGood = source.damageMultiplierGood, damageMultiplierGreat = source.damageMultiplierGreat, damageMultiplierPerfect = source.damageMultiplierPerfect, missHpLossBase = source.missHpLossBase }; } private int ApplyIntEquipmentBonus(int baseValue, equipmentSO.EquipmentStatTuning tuning, equipmentSO equipment, equipmentSO.EquipmentSpecialEffectType effectType) { float percent = GetEquipmentPercentBonus(tuning, equipment, effectType); float flat = GetEquipmentFlatBonus(equipment, effectType); return Mathf.Max(0, Mathf.FloorToInt(baseValue * (1f + percent) + flat)); } private float ApplyFloatEquipmentBonus(float baseValue, equipmentSO.EquipmentStatTuning tuning, equipmentSO equipment, equipmentSO.EquipmentSpecialEffectType effectType) { float percent = GetEquipmentPercentBonus(tuning, equipment, effectType); float flat = GetEquipmentFlatBonus(equipment, effectType); return Mathf.Max(0f, (baseValue * (1f + percent)) + flat); } private float GetEquipmentPercentBonus(equipmentSO.EquipmentStatTuning tuning, equipmentSO equipment, equipmentSO.EquipmentSpecialEffectType effectType) { float total = 0f; if (tuning != null) { if (!Mathf.Approximately(tuning.basicGain, 0f)) { total += tuning.basicGain; total += Mathf.Max(0, equipment.level) * tuning.cultivationInterval; } } if (equipment.enableTypeSameEffects && equipment.skillType == allyType) { total += SumEffectValues(equipment.typeSameEffects, effectType); } if (equipment.IsMaxLevelEffectActive()) { total += SumEffectValues(equipment.maxLevelEffects, effectType); } return total; } private float GetEquipmentFlatBonus(equipmentSO equipment, equipmentSO.EquipmentSpecialEffectType effectType) { float total = 0f; total += SumEffectValues(equipment.specialEffects, effectType); equipmentSO.EquipmentSpecialEffect[] normalizedIllusionEffects = equipment.GetNormalizedIllusionEffects(); total += SumEffectValues(normalizedIllusionEffects, effectType); return total; } private static float SumEffectValues(equipmentSO.EquipmentSpecialEffect[] effects, equipmentSO.EquipmentSpecialEffectType effectType) { if (effects == null || effects.Length == 0) { return 0f; } float total = 0f; for (int i = 0; i < effects.Length; i++) { equipmentSO.EquipmentSpecialEffect effect = effects[i]; if (effect == null || effect.effectType != effectType) { continue; } total += effect.value; } return total; } [System.Serializable] private class EquippedSkillGroupIDsPayload { public int[] equippedSkillGroupIDs; } [System.Serializable] private class EquippedEquipmentPayload { public string equippedEquipmentId; } private string GetEquippedSkillsPrefsKey() { return "ally_equippedSkillGroupIDs_" + ally_heroID; } private string GetEquippedEquipmentPrefsKey() { return "ally_equippedEquipment_" + ally_heroID; } private string GetEquippedEquipmentSaveKey() { return ally_heroID.ToString(); } private string GetEquippedEquipmentRecoverySlotKey() { return EquippedEquipmentRecoverySlotPrefix + ally_heroID; } private string GetSelectedSkinPrefsKey() { return "ally_selectedSkin_" + ally_heroID; } public void SaveEquippedSkillsToLocal() { var payload = new EquippedSkillGroupIDsPayload { equippedSkillGroupIDs = equippedSkillGroupIDs ?? new int[0] }; string json = JsonUtility.ToJson(payload); PlayerPrefs.SetString(GetEquippedSkillsPrefsKey(), json); PlayerPrefs.Save(); } public void LoadEquippedSkillsFromLocal() { string key = GetEquippedSkillsPrefsKey(); if (!PlayerPrefs.HasKey(key)) return; string json = PlayerPrefs.GetString(key, ""); if (string.IsNullOrEmpty(json)) return; var payload = JsonUtility.FromJson(json); if (payload == null) return; equippedSkillGroupIDs = payload.equippedSkillGroupIDs ?? new int[0]; } public void SaveEquippedEquipmentToLocal() { equippedEquipmentId = equippedEquipment != null ? equippedEquipment.name : string.Empty; var payload = new EquippedEquipmentPayload { equippedEquipmentId = equippedEquipmentId ?? string.Empty }; string json = JsonUtility.ToJson(payload); SecureSaveVault.SaveJson(EquippedEquipmentSaveCategory, GetEquippedEquipmentSaveKey(), payload); LocalRecoveryMirror.SaveJson(GetEquippedEquipmentRecoverySlotKey(), payload); PlayerPrefs.SetString(GetEquippedEquipmentPrefsKey(), json); PlayerPrefs.Save(); } public void LoadEquippedEquipmentFromLocal(bool forceReload = false) { // 已水合且非强制刷新时直接返回,避免重复解密读盘(见 equippedEquipmentHydrated 说明)。 if (equippedEquipmentHydrated && !forceReload) { return; } // 无论后续走哪条分支,本次调用都视为已完成水合: // 命中存档→内存已填充;无存档→内存保持当前值,也不必反复重试读盘。 equippedEquipmentHydrated = true; string key = GetEquippedEquipmentPrefsKey(); EquippedEquipmentPayload payload; if (SecureSaveVault.TryLoadJson(EquippedEquipmentSaveCategory, GetEquippedEquipmentSaveKey(), out payload) && payload != null) { ApplyEquippedEquipmentPayload(payload); return; } if (LocalRecoveryMirror.TryLoadJson(GetEquippedEquipmentRecoverySlotKey(), out payload) && payload != null) { ApplyEquippedEquipmentPayload(payload); SecureSaveVault.SaveJson(EquippedEquipmentSaveCategory, GetEquippedEquipmentSaveKey(), payload); return; } if (!PlayerPrefs.HasKey(key)) { return; } string json = PlayerPrefs.GetString(key, string.Empty); if (string.IsNullOrEmpty(json)) { return; } payload = JsonUtility.FromJson(json); if (payload == null) { return; } ApplyEquippedEquipmentPayload(payload); SecureSaveVault.SaveJson(EquippedEquipmentSaveCategory, GetEquippedEquipmentSaveKey(), payload); LocalRecoveryMirror.SaveJson(GetEquippedEquipmentRecoverySlotKey(), payload); } private void ApplyEquippedEquipmentPayload(EquippedEquipmentPayload payload) { if (payload == null) { return; } equippedEquipmentId = payload.equippedEquipmentId ?? string.Empty; equippedEquipment = ResolveEquippedEquipmentById(equippedEquipmentId); } public void SetEquippedEquipment(equipmentSO equipment, bool persist = true) { equippedEquipment = equipment; equippedEquipmentId = equipment != null ? equipment.name : string.Empty; equippedEquipmentHydrated = true; // 换装可能改变本英雄及其它英雄的跨英雄幻象额外槽归属,使全体派生缓存失效。 InvalidateEquipmentDerivedCaches(); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); if (persist) { UnityEditor.AssetDatabase.SaveAssets(); } } #endif if (persist) { SaveEquippedEquipmentToLocal(); } } public equipmentSO GetEquippedEquipmentResolved() { if (equippedEquipment != null) { return equippedEquipment; } if (string.IsNullOrWhiteSpace(equippedEquipmentId)) { return null; } equippedEquipment = ResolveEquippedEquipmentById(equippedEquipmentId); return equippedEquipment; } public void ClearEquippedEquipment() { equippedEquipment = null; equippedEquipmentId = string.Empty; equippedEquipmentHydrated = true; // 卸下装备同样可能改变跨英雄幻象额外槽归属,使全体派生缓存失效。 InvalidateEquipmentDerivedCaches(); SecureSaveVault.Delete(EquippedEquipmentSaveCategory, GetEquippedEquipmentSaveKey()); LocalRecoveryMirror.DeleteSlot(GetEquippedEquipmentRecoverySlotKey()); PlayerPrefs.DeleteKey(GetEquippedEquipmentPrefsKey()); PlayerPrefs.Save(); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); UnityEditor.AssetDatabase.SaveAssets(); } #endif } private equipmentSO ResolveEquippedEquipmentById(string equipmentId) { if (string.IsNullOrWhiteSpace(equipmentId)) { return null; } #if UNITY_EDITOR if (!Application.isPlaying) { string[] guids = UnityEditor.AssetDatabase.FindAssets("t:equipmentSO", new[] { "Assets/Resources/so/uEquip" }); for (int i = 0; i < guids.Length; i++) { string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); equipmentSO equipment = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (equipment != null && equipment.name == equipmentId) { return equipment; } } } #endif var generatedEquipments = Bansonic.equipmentGenerator.GetRuntimeGeneratedEquipments(); for (int i = 0; i < generatedEquipments.Count; i++) { if (generatedEquipments[i] != null && generatedEquipments[i].name == equipmentId) { return generatedEquipments[i]; } } equipmentSO[] runtimeEquipments = Resources.LoadAll("so/uEquip"); for (int i = 0; i < runtimeEquipments.Length; i++) { if (runtimeEquipments[i] != null && runtimeEquipments[i].name == equipmentId) { return runtimeEquipments[i]; } } return null; } public void ClearEquippedSkills() { equippedSkillGroupIDs = Array.Empty(); PlayerPrefs.DeleteKey(GetEquippedSkillsPrefsKey()); PlayerPrefs.Save(); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); } #endif } public static void ClearAllEquippedSkills() { HashSet clearedHeroIds = new HashSet(); #if UNITY_EDITOR string[] guids = UnityEditor.AssetDatabase.FindAssets("t:AllyHero_SO", new[] { "Assets/Resources/so/ally" }); for (int i = 0; i < guids.Length; i++) { string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); AllyHero_SO hero = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (hero == null) { continue; } hero.ClearEquippedSkills(); clearedHeroIds.Add(hero.ally_heroID); } UnityEditor.AssetDatabase.SaveAssets(); #endif AllyHero_SO[] runtimeHeroes = RuntimeResourcesCache.LoadAll("so/ally"); for (int i = 0; i < runtimeHeroes.Length; i++) { AllyHero_SO hero = runtimeHeroes[i]; if (hero == null || clearedHeroIds.Contains(hero.ally_heroID)) { continue; } hero.ClearEquippedSkills(); } PlayerPrefs.Save(); } public List GetResolvedSkinSet(bool includeBaseSkin = true) { return HeroSkinResolver.GetSkinSet(this, includeBaseSkin); } public HeroSkinResolvedData GetResolvedSelectedSkin(bool fallbackToBase = true) { return HeroSkinResolver.GetSkinById(this, selectedSkinId, fallbackToBase); } public void SetSelectedSkin(string skinId, bool persist = true) { selectedSkinId = string.IsNullOrWhiteSpace(skinId) ? HeroSkinResolver.BuildBaseSkinId(ally_heroID) : skinId.Trim(); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); if (persist) { UnityEditor.AssetDatabase.SaveAssets(); } } #endif if (persist) { SaveSelectedSkinToLocal(); } } public void SaveSelectedSkinToLocal() { string safeSkinId = string.IsNullOrWhiteSpace(selectedSkinId) ? HeroSkinResolver.BuildBaseSkinId(ally_heroID) : selectedSkinId.Trim(); PlayerPrefs.SetString(GetSelectedSkinPrefsKey(), safeSkinId); PlayerPrefs.Save(); } public void LoadSelectedSkinFromLocal() { string key = GetSelectedSkinPrefsKey(); if (!PlayerPrefs.HasKey(key)) { return; } selectedSkinId = PlayerPrefs.GetString(key, HeroSkinResolver.BuildBaseSkinId(ally_heroID)); if (string.IsNullOrWhiteSpace(selectedSkinId)) { selectedSkinId = HeroSkinResolver.BuildBaseSkinId(ally_heroID); } } public void ClearSelectedSkin() { selectedSkinId = HeroSkinResolver.BuildBaseSkinId(ally_heroID); PlayerPrefs.DeleteKey(GetSelectedSkinPrefsKey()); PlayerPrefs.Save(); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); UnityEditor.AssetDatabase.SaveAssets(); } #endif } public void SetUnlocked(bool value) { if (isUnlocked == value) return; isUnlocked = value; #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); } #endif } private void OnValidate() { EnsureBehaviourAxes(); } public void EnsureBehaviourAxes() { if (behaviourAxes == null) { behaviourAxes = new List(); } while (behaviourAxes.Count < BehaviourAxisNames.Length) { behaviourAxes.Add(new AllyBehaviourAxis()); } if (behaviourAxes.Count > BehaviourAxisNames.Length) { behaviourAxes.RemoveRange(BehaviourAxisNames.Length, behaviourAxes.Count - BehaviourAxisNames.Length); } for (int i = 0; i < BehaviourAxisNames.Length; i++) { if (behaviourAxes[i] == null) { behaviourAxes[i] = new AllyBehaviourAxis(); } behaviourAxes[i].axisName = BehaviourAxisNames[i]; behaviourAxes[i].value = Mathf.Clamp(behaviourAxes[i].value, 0, 100); } } public void IncrementBattleDeployCount(int amount = 1) { if (amount <= 0) return; AllyHeroDeployLedger.EnsureInstance().IncrementDeployCount(this, amount); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); } #endif } public void LoadBattleDeployCountFromLocal() { ally_battleDeployCount = AllyHeroDeployLedger.EnsureInstance().GetDeployCount(ally_heroID); } public void SaveBattleDeployCountToLocal() { AllyHeroDeployLedger.EnsureInstance().SaveNow(); } public void IncrementFinishCount(int amount = 1) { if (amount <= 0) return; AllyHeroDeployLedger.EnsureInstance().IncrementFinishCount(this, amount); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); } #endif } public void IncrementMvpCount(int amount = 1) { if (amount <= 0) return; AllyHeroDeployLedger.EnsureInstance().IncrementMvpCount(this, amount); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); } #endif } public void SetJoinDateIfMissing(System.DateTime? utcTime = null) { AllyHeroDeployLedger.EnsureInstance().SetJoinDateIfMissing(this, utcTime); #if UNITY_EDITOR if (!Application.isPlaying) { UnityEditor.EditorUtility.SetDirty(this); } #endif } }