Files
2026-07-30 23:15:58 +08:00

1280 lines
42 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AllyLevelInfo> levelStats = new List<AllyLevelInfo>();
[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<AllyBehaviourAxis> behaviourAxes = new List<AllyBehaviourAxis>();
[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>();
}
int currentLevelId = AllyHeroLevelRuntimeResolver.GetCurrentLevelId(this);
int maxSkillSlots = Mathf.Max(0, AllyHeroLevelRuntimeResolver.GetEffectiveSkillSlotLimit(this));
List<int> validatedGroupIds = new List<int>(Mathf.Min(equippedSkillGroupIDs.Length, maxSkillSlots));
HashSet<int> seenGroupIds = new HashSet<int>();
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<int>();
var dedupe = new HashSet<int>();
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<AllyHero_SO>(guids.Length);
for (int i = 0; i < guids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
AllyHero_SO hero = AssetDatabase.LoadAssetAtPath<AllyHero_SO>(path);
if (hero != null)
{
heroes.Add(hero);
}
}
return heroes.ToArray();
}
#endif
return RuntimeResourcesCache.LoadAll<AllyHero_SO>("so/ally");
}
private static void TryAddEquipmentSkillGroupId(List<int> result, HashSet<int> 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<AllyLevelInfo> sorted = BuildSortedLevelStats();
return ResolveUnlockedLevelIndex(sorted);
}
public int GetExpQualifiedLevelIndex()
{
List<AllyLevelInfo> sorted = BuildSortedLevelStats();
return ResolveExpQualifiedLevelIndex(sorted);
}
public int GetDisplayLevelIndex()
{
List<AllyLevelInfo> 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<AllyLevelInfo> 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<AllyLevelInfo> 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<AllyLevelInfo> _cachedSortedLevelStats;
[System.NonSerialized] private bool _cachedSortedLevelStatsIsNull;
[System.NonSerialized] private object _snapLevelStatsRef;
[System.NonSerialized] private int _snapLevelStatsCount = -1;
private List<AllyLevelInfo> BuildSortedLevelStats()
{
if (_cachedSortedLevelStats != null
&& ReferenceEquals(_snapLevelStatsRef, levelStats)
&& _snapLevelStatsCount == (levelStats != null ? levelStats.Count : 0))
{
return _cachedSortedLevelStatsIsNull ? null : _cachedSortedLevelStats;
}
var sorted = new List<AllyLevelInfo>();
_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<AllyLevelInfo> sorted)
{
if (sorted == null || sorted.Count == 0)
{
return -1;
}
return Mathf.Clamp(ally_growthUnlockedTierIndex, 0, sorted.Count - 1);
}
private int ResolveExpQualifiedLevelIndex(List<AllyLevelInfo> 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<AllyLevelInfo> 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<EquippedSkillGroupIDsPayload>(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<EquippedEquipmentPayload>(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<equipmentSO>(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<equipmentSO>("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<int>();
PlayerPrefs.DeleteKey(GetEquippedSkillsPrefsKey());
PlayerPrefs.Save();
#if UNITY_EDITOR
if (!Application.isPlaying)
{
UnityEditor.EditorUtility.SetDirty(this);
}
#endif
}
public static void ClearAllEquippedSkills()
{
HashSet<int> clearedHeroIds = new HashSet<int>();
#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<AllyHero_SO>(path);
if (hero == null)
{
continue;
}
hero.ClearEquippedSkills();
clearedHeroIds.Add(hero.ally_heroID);
}
UnityEditor.AssetDatabase.SaveAssets();
#endif
AllyHero_SO[] runtimeHeroes = RuntimeResourcesCache.LoadAll<AllyHero_SO>("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<HeroSkinResolvedData> 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<AllyBehaviourAxis>();
}
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
}
}