lcy
2026-06-23 9eb225e0b1297fdc47b558c922c6ed5248dfd95d
662 武将试炼 重构红点系统,精简本地存储内容
1个文件已修改
1177 ■■■■ 已修改文件
Main/System/HeroTrial/HeroTrialManager.cs 1177 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Main/System/HeroTrial/HeroTrialManager.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using LitJson;
public class HeroTrialManager : GameSystemManager<HeroTrialManager>
{
@@ -9,45 +10,22 @@
    /// <summary>武将试炼进度字典 <HeroID, PassLevelNum></summary>
    public Dictionary<uint, byte> heroTrialDict = new Dictionary<uint, byte>();
    int challengeRecordDay;
    // 当天发起过挑战的武将不再显示红点,胜负无关。
    HashSet<int> challengedHeroIDsToday = new HashSet<int>();
    // 当天新开放过的武将副本。这里保存“发生过新开放”的事实,待显示队列每次刷新时从它派生,避免持久化当前红点/队列进度。
    List<int> newOpenedHeroIDsToday = new List<int>();
    // 历史上客户端已经见过的可挑战武将副本,用于识别当天因等级/官职达成而新解锁的副本。
    HashSet<int> seenUnlockedHeroIDs = new HashSet<int>();
    int currentRedHeroID;
    bool isChallengeRecordLoaded;
    bool needSeedSeenUnlockedHeroIDs;
    bool isRefreshingByPlayerProgress;
    int lastObservedPlayerLV = -1;
    int lastObservedRealmLevel = -1;
    string challengeRecordDayKey { get { return StringUtility.Concat("HeroTrial_ChallengeRecordDay_", PlayerDatas.Instance.PlayerId.ToString()); } }
    string lastChallengeHeroIDTodayKey { get { return StringUtility.Concat("HeroTrial_LastChallengeHeroIDToday_", PlayerDatas.Instance.PlayerId.ToString()); } }
    string challengedHeroIDsTodayKey { get { return StringUtility.Concat("HeroTrial_ChallengedHeroIDsToday_", PlayerDatas.Instance.PlayerId.ToString()); } }
    string normalRedConsumedTodayKey { get { return StringUtility.Concat("HeroTrial_NormalRedConsumedToday_", PlayerDatas.Instance.PlayerId.ToString()); } }
    string newOpenedHeroIDsTodayKey { get { return StringUtility.Concat("HeroTrial_NewOpenedHeroIDsToday_", PlayerDatas.Instance.PlayerId.ToString()); } }
    string seenUnlockedHeroIDsKey { get { return StringUtility.Concat("HeroTrial_SeenUnlockedHeroIDs_", PlayerDatas.Instance.PlayerId.ToString()); } }
    /// <summary>数据更新事件,UI订阅刷新</summary>
    public event Action OnUpdateHeroTrialInfoEvent;
    /// <summary>主挑战红点</summary>
    public Redpoint parentRedpoint = new Redpoint(MainRedDot.MainChallengeRedpoint, MainRedDot.HeroTrialRepoint);
    // 当天最后一次发起挑战的武将副本,用于界面首次打开时的默认跳转。
    int lastChallengeHeroIDToday;
    // 普通红点当天只提示一次;当天新开放武将副本仍可独立排队提示。
    bool normalRedConsumedToday;
    // 本地只保存 day/new/consumed 事实;最终红点目标始终由当前配置、进度和消费状态实时计算。
    bool hasLoadedLocalRecord;
    // 首次快照只建立基线,避免登录时把历史已开放副本都识别成“今日新开放”。
    bool hasAvailableSnapshot;
    int todayKey;
    readonly HashSet<int> todayNewOpenHeroIDs = new HashSet<int>();
    readonly HashSet<int> consumedRedHeroIDsToday = new HashSet<int>();
    readonly HashSet<int> knownAvailableHeroIDs = new HashSet<int>();
    public override void Init()
    {
        DTC0102_tagCDBPlayer.beforePlayerDataInitializeEventOnRelogin += OnBeforePlayerDataInitializeEventOnRelogin;
        FuncOpen.Instance.OnFuncStateChangeEvent += OnFuncStateChangeEvent;
        PlayerDatas.Instance.playerDataRefreshEvent += OnPlayerDataRefreshEvent;
        TimeMgr.Instance.OnDayEvent += OnDayEvent;
        TimeUtility.OnServerOpenDayRefresh += OnServerOpenDayRefresh;
        TimeUtility.OnServerTimeRefresh += OnServerTimeRefresh;
    }
    public override void Release()
@@ -55,22 +33,29 @@
        DTC0102_tagCDBPlayer.beforePlayerDataInitializeEventOnRelogin -= OnBeforePlayerDataInitializeEventOnRelogin;
        FuncOpen.Instance.OnFuncStateChangeEvent -= OnFuncStateChangeEvent;
        PlayerDatas.Instance.playerDataRefreshEvent -= OnPlayerDataRefreshEvent;
        TimeMgr.Instance.OnDayEvent -= OnDayEvent;
        TimeUtility.OnServerOpenDayRefresh -= OnServerOpenDayRefresh;
        TimeUtility.OnServerTimeRefresh -= OnServerTimeRefresh;
    }
    void OnBeforePlayerDataInitializeEventOnRelogin()
    {
        heroTrialDict.Clear();
        ResetTodayChallengeRecordCache();
        parentRedpoint.state = RedPointState.None;
        hasLoadedLocalRecord = false;
        hasAvailableSnapshot = false;
        todayKey = 0;
        lastChallengeHeroIDToday = 0;
        todayNewOpenHeroIDs.Clear();
        consumedRedHeroIDsToday.Clear();
        knownAvailableHeroIDs.Clear();
        ClearRedpoint();
    }
    void OnFuncStateChangeEvent(int obj)
    {
        if (obj != funcId)
            return;
        RefreshHeroTrialState();
        RefreshRedpointAndNotify();
    }
    void OnPlayerDataRefreshEvent(PlayerDataType type)
@@ -78,331 +63,59 @@
        if (type != PlayerDataType.LV && type != PlayerDataType.RealmLevel)
            return;
        isRefreshingByPlayerProgress = true;
        try
        {
            RefreshHeroTrialState();
        }
        finally
        {
            isRefreshingByPlayerProgress = false;
        }
        // 等级/官职会影响解锁、锁定提示和挑战按钮,即使红点目标不变也要刷新界面。
        RefreshRedpointAndNotify(true);
    }
    void OnDayEvent()
    {
        // 这里只代表服务器日期变化,不保证 OpenDay 已更新。
        // 武将试炼限定副本依赖 OpenDay,必须等 OnServerOpenDayRefresh 后再重算。
    }
    void OnServerOpenDayRefresh()
    {
        RefreshHeroTrialState();
        // 限定武将开放依赖 OpenDay;这个事件在 OpenDay 更新后触发,必须再刷新一次。
        RefreshRedpointAndNotify(true);
    }
    void OnServerTimeRefresh()
    public void SendChallenge(HeroTrialConfig config)
    {
        int oldDay = challengeRecordDay;
        EnsureTodayChallengeRecord();
        if (oldDay != challengeRecordDay)
            RefreshHeroTrialState();
        if (config == null)
            return;
        if (!CanChallenge(config, out string tip))
        {
            if (!string.IsNullOrEmpty(tip))
                SysNotifyMgr.Instance.ShowStringTip(tip);
            return;
        }
        ConsumeRedpointOnChallenge(config.HeroID);
        BattleManager.Instance.SendTurnFight((uint)DataMapID, (uint)GetFuncLineID(config));
    }
    public event Action OnUpdateHeroTrialInfoEvent;
    /// <summary>
    /// 处理 B203 封包,更新武将试炼数据
    /// </summary>
    public void UpdateHeroTrialInfo(HB203_tagSCHeroTrialInfo vNetData)
    {
        // 服务器包为空时没有可更新的试炼进度。
        if (vNetData == null || vNetData.HeroTrialList == null)
            return;
        // 把服务器下发的每个武将通关进度写入字典。
        for (int i = 0; i < vNetData.HeroTrialList.Length; i++)
        {
            var item = vNetData.HeroTrialList[i];
            heroTrialDict[item.HeroID] = item.PassLevelNum;
        }
        RefreshHeroTrialState();
    }
    void RefreshHeroTrialState()
    {
        EnsureTodayChallengeRecord();
        RefreshCurrentRedHeroID();
        UpdateRedPoint();
        RecordObservedPlayerProgress();
        UpdateRedpoint();
        OnUpdateHeroTrialInfoEvent?.Invoke();
    }
    void RefreshCurrentRedHeroID()
    {
        bool isDirty = false;
        if (!FuncOpen.Instance.IsFuncOpen(funcId))
        {
            if (currentRedHeroID != 0)
            {
                currentRedHeroID = 0;
                isDirty = true;
            }
            if (isDirty)
                SaveTodayChallengeRecord();
            return;
        }
        // 红点规则:
        // 1. 当天新开放武将优先,按配置顺序逐个提示;新开放来源包含开服天、等级、官职。
        // 2. 只保存“今天出现过哪些新开放武将”,当前显示目标每次刷新实时派生,避免本地存储队列进度。
        // 3. 今天出现过新开放武将后,队列耗尽也不回流旧武将;完全没有新开放时,旧武将一天只提示一次最低战力目标。
        List<int> heroIDList = GetHeroTrialHeroIDList();
        bool onlyProgressUnlocksCanBecomeNew = needSeedSeenUnlockedHeroIDs && isRefreshingByPlayerProgress;
        if (needSeedSeenUnlockedHeroIDs && !onlyProgressUnlocksCanBecomeNew)
        {
            SeedSeenUnlockedHeroIDs(heroIDList);
            needSeedSeenUnlockedHeroIDs = false;
            isDirty = true;
        }
        if (DetectNewOpenedHeroIDsToday(heroIDList, onlyProgressUnlocksCanBecomeNew))
            isDirty = true;
        if (onlyProgressUnlocksCanBecomeNew)
        {
            needSeedSeenUnlockedHeroIDs = false;
            isDirty = true;
        }
        if (currentRedHeroID != 0 && !CanShowHeroItemRed(currentRedHeroID))
        {
            currentRedHeroID = 0;
            isDirty = true;
        }
        int nextNewOpenedHeroID = GetNextNewOpenedRedHeroID(heroIDList);
        if (nextNewOpenedHeroID != 0)
        {
            if (currentRedHeroID != nextNewOpenedHeroID)
            {
                currentRedHeroID = nextNewOpenedHeroID;
                isDirty = true;
            }
        }
        else if (newOpenedHeroIDsToday.Count > 0)
        {
            if (currentRedHeroID != 0)
            {
                currentRedHeroID = 0;
                isDirty = true;
            }
        }
        else if (!normalRedConsumedToday)
        {
            int bestHeroID = GetLowestCanChallengeHeroID(heroIDList);
            if (currentRedHeroID != bestHeroID)
            {
                currentRedHeroID = bestHeroID;
                isDirty = true;
            }
        }
        if (isDirty)
            SaveTodayChallengeRecord();
    }
    bool DetectNewOpenedHeroIDsToday(List<int> sortedHeroIDList, bool onlyProgressUnlocksCanBecomeNew)
    {
        bool isDirty = false;
        for (int i = 0; i < sortedHeroIDList.Count; i++)
        {
            int heroID = sortedHeroIDList[i];
            if (!IsVisibleUnlockedHeroTrial(heroID))
                continue;
            bool unlockedByPlayerProgressNow = isRefreshingByPlayerProgress && IsHeroTrialHeroUnlockedByPlayerProgressNow(heroID);
            // 首次见到一个已解锁且有可挑战关卡的武将,说明它刚由等级/官职条件进入可提示范围。
            if (seenUnlockedHeroIDs.Add(heroID))
            {
                if (!onlyProgressUnlocksCanBecomeNew || unlockedByPlayerProgressNow)
                    AppendNewOpenedHeroIDToday(heroID);
                isDirty = true;
            }
            else if (unlockedByPlayerProgressNow && AppendNewOpenedHeroIDToday(heroID))
            {
                // 兼容旧本地记录:若历史已见集合曾误记该武将,本次明确跨过等级/官职门槛时仍按新开放处理。
                isDirty = true;
            }
            // 开服天新开放不依赖历史已见集合;当天首次刷新就应进入新开放优先队列。
            if (IsHeroTrialHeroOpenByServerDayToday(heroID) && AppendNewOpenedHeroIDToday(heroID))
                isDirty = true;
        }
        return isDirty;
    }
    bool IsHeroTrialHeroUnlockedByPlayerProgressNow(int heroID)
    {
        if (!HeroTrialConfig.TryGetFirstLevelConfig(heroID, out var config))
            return false;
        int currentLV = PlayerDatas.Instance.baseData.LV;
        int currentRealmLevel = PlayerDatas.Instance.baseData.realmLevel;
        bool levelJustReached = config.LVLimit > 0 && currentLV >= config.LVLimit &&
            ((lastObservedPlayerLV >= 0 && lastObservedPlayerLV < config.LVLimit) ||
            (lastObservedPlayerLV < 0 && currentLV == config.LVLimit));
        bool realmJustReached = config.RealmLimit > 0 && currentRealmLevel >= config.RealmLimit &&
            ((lastObservedRealmLevel >= 0 && lastObservedRealmLevel < config.RealmLimit) ||
            (lastObservedRealmLevel < 0 && currentRealmLevel == config.RealmLimit));
        return levelJustReached || realmJustReached;
    }
    void RecordObservedPlayerProgress()
    {
        lastObservedPlayerLV = PlayerDatas.Instance.baseData.LV;
        lastObservedRealmLevel = PlayerDatas.Instance.baseData.realmLevel;
    }
    void SeedSeenUnlockedHeroIDs(List<int> sortedHeroIDList)
    {
        // 没有历史已见记录时,先把当前可见副本作为旧状态记住,避免版本升级后把所有旧副本误判成当天新开放。
        for (int i = 0; i < sortedHeroIDList.Count; i++)
        {
            int heroID = sortedHeroIDList[i];
            if (IsVisibleUnlockedHeroTrial(heroID))
                seenUnlockedHeroIDs.Add(heroID);
        }
    }
    bool IsVisibleUnlockedHeroTrial(int heroID)
    {
        return IsHeroTrialHeroUnlocked(heroID) && HasChallengeableLevel(heroID);
    }
    bool AppendNewOpenedHeroIDToday(int heroID)
    {
        if (heroID == 0 || newOpenedHeroIDsToday.Contains(heroID))
            return false;
        newOpenedHeroIDsToday.Add(heroID);
        return true;
    }
    bool IsHeroTrialHeroOpenByServerDayToday(int heroID)
    {
        HeroConfig heroConfig = HeroConfig.Get(heroID);
        return heroConfig != null &&
            heroConfig.OpenCollectionDay > 0 &&
            heroConfig.OpenCollectionDay == TimeUtility.OpenDay + 1;
    }
    int GetNextNewOpenedRedHeroID(List<int> sortedHeroIDList)
    {
        for (int i = 0; i < sortedHeroIDList.Count; i++)
        {
            int heroID = sortedHeroIDList[i];
            if (newOpenedHeroIDsToday.Contains(heroID) && CanShowHeroItemRed(heroID))
                return heroID;
        }
        return 0;
    }
    int GetLowestCanChallengeHeroID(List<int> heroIDList)
    {
        int bestHeroID = 0;
        long bestFightPower = long.MaxValue;
        int bestSortIndex = int.MaxValue;
        for (int i = 0; i < heroIDList.Count; i++)
        {
            int heroID = heroIDList[i];
            if (!CanShowHeroItemRed(heroID))
                continue;
            if (!TryGetLowestCanChallengeConfig(heroID, out var config))
                continue;
            if (config.FightPower < bestFightPower)
            {
                bestHeroID = heroID;
                bestFightPower = config.FightPower;
                bestSortIndex = i;
                continue;
            }
            if (config.FightPower == bestFightPower && i < bestSortIndex)
            {
                bestHeroID = heroID;
                bestSortIndex = i;
            }
        }
        return bestHeroID;
    }
    int GetLowestEnterHeroID(List<int> heroIDList)
    {
        int bestHeroID = 0;
        long bestFightPower = long.MaxValue;
        int bestSortIndex = int.MaxValue;
        for (int i = 0; i < heroIDList.Count; i++)
        {
            int heroID = heroIDList[i];
            if (!IsHeroTrialHeroUnlocked(heroID))
                continue;
            if (!HasChallengeableLevel(heroID))
                continue;
            if (!TryGetLowestCanChallengeConfig(heroID, out var config))
                continue;
            if (config.FightPower < bestFightPower)
            {
                bestHeroID = heroID;
                bestFightPower = config.FightPower;
                bestSortIndex = i;
                continue;
            }
            if (config.FightPower == bestFightPower && i < bestSortIndex)
            {
                bestHeroID = heroID;
                bestSortIndex = i;
            }
        }
        return bestHeroID;
    }
    bool TryGetLowestCanChallengeConfig(int heroID, out HeroTrialConfig config)
    {
        config = null;
        if (!HeroTrialConfig.TryGetHeroConfigList(heroID, out var configs))
            return false;
        for (int i = 0; i < configs.Count; i++)
        {
            if (!CanChallenge(configs[i], out _))
                continue;
            if (config == null || configs[i].FightPower < config.FightPower)
                config = configs[i];
        }
        return config != null;
    }
    /// <summary>
    /// 更新红点状态
    /// </summary>
    public void UpdateRedPoint()
    {
        parentRedpoint.state = RedPointState.None;
        if (!FuncOpen.Instance.IsFuncOpen(funcId))
            return;
        if (currentRedHeroID != 0)
        {
            parentRedpoint.state = RedPointState.Simple;
        }
    }
    public int GetFuncLineID(HeroTrialConfig config)
    {
        if (config == null)
@@ -427,108 +140,32 @@
        return HeroTrialConfig.TryGetHeroTrialConfig(heroID, levelNum, out config);
    }
    public bool CanChallenge(HeroTrialConfig config, out string tip)
    // 是不是限定武将
    bool IsHeroTrialHeroVisible(int heroID)
    {
        tip = string.Empty;
        if (config == null)
            return false;
        if (!IsHeroTrialHeroUnlocked(config.HeroID, out string heroUnlockTip))
        {
            tip = heroUnlockTip;
            return false;
        }
        if (!IsTrialLevelLimitMet(config, out tip))
            return false;
        bool hasProgress = TryGetHeroPassLevelNum((uint)config.HeroID, out byte passLevelNum);
        bool isFirstLevel = HeroTrialConfig.TryGetFirstLevelConfig(config.HeroID, out var firstConfig) && firstConfig.CfgID == config.CfgID;
        if (!hasProgress)
        {
            passLevelNum = 0;
            if (isFirstLevel && passLevelNum < config.LevelNum)
                return true;
            tip = Language.Get("HeroTrial05");
            return false;
        }
        if (isFirstLevel && passLevelNum < config.LevelNum)
        HeroConfig heroConfig = HeroConfig.Get(heroID);
        if (heroConfig == null)
            return true;
        if (config.LevelNum != passLevelNum + 1)
        return heroConfig.IsActLimit <= 0 ||
            heroConfig.OpenCollectionDay <= 0 ||
            TimeUtility.OpenDay + 1 >= heroConfig.OpenCollectionDay;
    }
    // 获取武将试炼武将列表
    public List<int> GetHeroTrialHeroIDList()
    {
        List<int> heroIDList = HeroTrialConfig.GetSortedHeroIDList();
        for (int i = heroIDList.Count - 1; i >= 0; i--)
        {
            tip = Language.Get("HeroTrial05");
            return false;
            if (!IsHeroTrialHeroVisible(heroIDList[i]))
                heroIDList.RemoveAt(i);
        }
        return true;
        return heroIDList;
    }
    bool IsTrialLevelLimitMet(HeroTrialConfig config, out string tip)
    {
        tip = string.Empty;
        if (config == null)
            return false;
        if (config.LVLimit > 0 && PlayerDatas.Instance.baseData.LV < config.LVLimit)
        {
            tip = Language.Get("HeroTrial04", config.LVLimit);
            return false;
        }
        if (config.RealmLimit > 0 && PlayerDatas.Instance.baseData.realmLevel < config.RealmLimit)
        {
            var realmConfig = RealmConfig.Get(config.RealmLimit);
            tip = Language.Get("HeroTrial03", realmConfig != null ? realmConfig.Name : string.Empty);
            return false;
        }
        return true;
    }
    public bool IsShowHeroItemRed(int heroID)
    {
        return heroID != 0 &&
            heroID == currentRedHeroID &&
            CanShowHeroItemRed(heroID) &&
            TryGetLowestCanChallengeConfig(heroID, out _);
    }
    public bool IsShowHeroTrialLevelRed(HeroTrialConfig config)
    {
        if (config == null || config.HeroID != currentRedHeroID)
            return false;
        if (!CanChallenge(config, out _))
            return false;
        return TryGetLowestCanChallengeConfig(config.HeroID, out var lowestConfig) &&
            lowestConfig != null &&
            lowestConfig.CfgID == config.CfgID;
    }
    public bool HasAnyChallengeableLevel()
    {
        List<int> heroIDList = GetHeroTrialHeroIDList();
        for (int i = 0; i < heroIDList.Count; i++)
        {
            if (HasChallengeableLevel(heroIDList[i]))
                return true;
        }
        return false;
    }
    bool CanShowHeroItemRed(int heroID)
    {
        return FuncOpen.Instance.IsFuncOpen(funcId) &&
            IsHeroTrialHeroUnlocked(heroID) &&
            !HasChallengedHeroTrialToday(heroID) &&
            HasChallengeableLevel(heroID);
    }
    // 检查武将是否有可挑战关卡
    bool HasChallengeableLevel(int heroID)
    {
        if (!HeroTrialConfig.TryGetHeroConfigList(heroID, out var configs))
@@ -543,211 +180,51 @@
        return false;
    }
    public void SendChallenge(HeroTrialConfig config)
    // 是否任意一个武将存在可挑战关卡
    public bool HasAnyChallengeableLevel()
    {
        if (!CanChallenge(config, out string tip))
        {
            if (!string.IsNullOrEmpty(tip))
            {
                SysNotifyMgr.Instance.ShowStringTip(tip);
            }
            return;
        }
        MarkHeroTrialChallengedToday(config.HeroID);
        if (lastChallengeHeroIDToday != config.HeroID)
        {
            lastChallengeHeroIDToday = config.HeroID;
            SaveTodayChallengeRecord();
        }
        bool isDirty = false;
        if (currentRedHeroID == config.HeroID)
        {
            if (!newOpenedHeroIDsToday.Contains(config.HeroID))
                normalRedConsumedToday = true;
            currentRedHeroID = 0;
            isDirty = true;
        }
        if (isDirty)
            SaveTodayChallengeRecord();
        RefreshHeroTrialState();
        BattleManager.Instance.SendTurnFight((uint)DataMapID, (uint)GetFuncLineID(config));
    }
    public bool HasChallengedHeroTrialToday(int heroID)
    {
        EnsureTodayChallengeRecord();
        return challengedHeroIDsToday.Contains(heroID);
    }
    /// <summary>
    /// 界面首次打开时的默认落点:
    /// 1. 当前红点武将;
    /// 2. 上次挑战过且当前仍可挑战的武将;
    /// 3. 所有已解锁且可挑战关卡中战力最低的武将。
    /// </summary>
    public int GetHeroTrialEnterHeroID()
    {
        EnsureTodayChallengeRecord();
        if (FuncOpen.Instance.IsFuncOpen(funcId))
        {
            if (currentRedHeroID != 0 && IsShowHeroItemRed(currentRedHeroID))
                return currentRedHeroID;
        }
        if (lastChallengeHeroIDToday != 0 && HasChallengeableLevel(lastChallengeHeroIDToday))
            return lastChallengeHeroIDToday;
        List<int> heroIDList = GetHeroTrialHeroIDList();
        return GetLowestEnterHeroID(heroIDList);
    }
    void MarkHeroTrialChallengedToday(int heroID)
    {
        EnsureTodayChallengeRecord();
        if (challengedHeroIDsToday.Add(heroID))
            SaveTodayChallengeRecord();
    }
    void EnsureTodayChallengeRecord()
    {
        if (!isChallengeRecordLoaded)
            LoadTodayChallengeRecord();
        int today = GetTodayID();
        if (challengeRecordDay == today)
            return;
        challengeRecordDay = today;
        challengedHeroIDsToday.Clear();
        newOpenedHeroIDsToday.Clear();
        lastChallengeHeroIDToday = 0;
        normalRedConsumedToday = false;
        currentRedHeroID = 0;
        SaveTodayChallengeRecord();
    }
    void ResetTodayChallengeRecordCache()
    {
        challengeRecordDay = 0;
        challengedHeroIDsToday.Clear();
        newOpenedHeroIDsToday.Clear();
        seenUnlockedHeroIDs.Clear();
        lastChallengeHeroIDToday = 0;
        normalRedConsumedToday = false;
        currentRedHeroID = 0;
        isChallengeRecordLoaded = false;
        needSeedSeenUnlockedHeroIDs = false;
        isRefreshingByPlayerProgress = false;
        lastObservedPlayerLV = -1;
        lastObservedRealmLevel = -1;
    }
    void LoadTodayChallengeRecord()
    {
        isChallengeRecordLoaded = true;
        challengeRecordDay = LocalSave.GetInt(challengeRecordDayKey);
        needSeedSeenUnlockedHeroIDs = !LoadHeroIDSet(seenUnlockedHeroIDsKey, seenUnlockedHeroIDs);
        if (challengeRecordDay != GetTodayID())
        for (int i = 0; i < heroIDList.Count; i++)
        {
            challengeRecordDay = GetTodayID();
            challengedHeroIDsToday.Clear();
            newOpenedHeroIDsToday.Clear();
            lastChallengeHeroIDToday = 0;
            normalRedConsumedToday = false;
            currentRedHeroID = 0;
            SaveTodayChallengeRecord();
            return;
            if (HasChallengeableLevel(heroIDList[i]))
                return true;
        }
        LoadHeroIDSet(challengedHeroIDsTodayKey, challengedHeroIDsToday);
        lastChallengeHeroIDToday = LocalSave.GetInt(lastChallengeHeroIDTodayKey);
        normalRedConsumedToday = LocalSave.GetInt(normalRedConsumedTodayKey) != 0;
        LoadHeroIDList(newOpenedHeroIDsTodayKey, newOpenedHeroIDsToday);
        currentRedHeroID = 0;
        return false;
    }
    void SaveTodayChallengeRecord()
    bool IsActLimitHero(HeroConfig heroConfig)
    {
        LocalSave.SetInt(challengeRecordDayKey, challengeRecordDay);
        LocalSave.SetIntArray(challengedHeroIDsTodayKey, ToArray(challengedHeroIDsToday));
        LocalSave.SetInt(lastChallengeHeroIDTodayKey, lastChallengeHeroIDToday);
        LocalSave.SetInt(normalRedConsumedTodayKey, normalRedConsumedToday ? 1 : 0);
        LocalSave.SetIntArray(newOpenedHeroIDsTodayKey, newOpenedHeroIDsToday.ToArray());
        LocalSave.SetIntArray(seenUnlockedHeroIDsKey, ToArray(seenUnlockedHeroIDs));
        return heroConfig.IsActLimit > 0;
    }
    bool LoadHeroIDSet(string key, HashSet<int> heroIDSet)
    {
        heroIDSet.Clear();
        int[] heroIDs = LocalSave.GetIntArray(key);
        if (heroIDs == null)
            return false;
        for (int i = 0; i < heroIDs.Length; i++)
        {
            if (heroIDs[i] != 0)
                heroIDSet.Add(heroIDs[i]);
        }
        return true;
    }
    void LoadHeroIDList(string key, List<int> heroIDList)
    {
        heroIDList.Clear();
        int[] heroIDs = LocalSave.GetIntArray(key);
        if (heroIDs == null)
            return;
        for (int i = 0; i < heroIDs.Length; i++)
        {
            if (heroIDs[i] != 0 && !heroIDList.Contains(heroIDs[i]))
                heroIDList.Add(heroIDs[i]);
        }
    }
    int[] ToArray(HashSet<int> heroIDSet)
    {
        int[] heroIDs = new int[heroIDSet.Count];
        heroIDSet.CopyTo(heroIDs);
        return heroIDs;
    }
    int GetTodayID()
    {
        // 武将试炼的新开放规则按开服天计算,当天记录也必须使用同一口径。
        return TimeUtility.OpenDay;
    }
    /// <summary>
    /// 尝试获取某武将的通关进度
    /// </summary>
    // 尝试获取某武将的通关进度
    public bool TryGetHeroPassLevelNum(uint heroID, out byte passLevelNum)
    {
        return heroTrialDict.TryGetValue(heroID, out passLevelNum);
    }
    bool IsActLimitHeroLocked(HeroConfig heroConfig)
    {
        return heroConfig.OpenCollectionDay <= 0 || TimeUtility.OpenDay + 1 >= heroConfig.OpenCollectionDay;
    }
    public bool IsHeroTrialHeroUnlocked(int heroID)
    {
        // 不关心锁定提示时走这个重载。
        return IsHeroTrialHeroUnlocked(heroID, out _);
    }
    //武将副本是否解锁
    public bool IsHeroTrialHeroUnlocked(int heroID, out string lockTip)
    {
        lockTip = string.Empty;
        HeroConfig heroConfig = HeroConfig.Get(heroID);
        if (heroConfig == null)
            return false;
        if (heroConfig.IsActLimit > 0)
        if (IsActLimitHero(heroConfig))
        {
            bool isOpenCollectionDayMet = heroConfig.OpenCollectionDay <= 0 ||
                TimeUtility.OpenDay + 1 >= heroConfig.OpenCollectionDay;
            if (!isOpenCollectionDayMet)
            if (!IsActLimitHeroLocked(heroConfig))
            {
                lockTip = Language.Get("HeroTrial02", heroConfig.Name);
                return false;
@@ -756,53 +233,465 @@
        else
        {
            if (HeroTrialConfig.TryGetFirstLevelConfig(heroConfig.HeroID, out var trialConfig))
            {
                if (trialConfig.LVLimit > 0)
                {
                    if (PlayerDatas.Instance.baseData.LV < trialConfig.LVLimit)
                    {
                        lockTip = Language.Get("HeroTrial04", trialConfig.LVLimit);
                        return false;
                    }
                }
                else if (trialConfig.RealmLimit > 0)
                {
                    if (PlayerDatas.Instance.baseData.realmLevel < trialConfig.RealmLimit)
                    {
                        var config = RealmConfig.Get(trialConfig.RealmLimit);
                        lockTip = Language.Get("HeroTrial03", config != null ? config.Name : string.Empty);
                        return false;
                    }
                }
            }
                return IsTrialLevelLimitMet(trialConfig, out lockTip);
        }
        return true;
    }
    /// <summary>
    /// 获取武将试炼武将列表:按配置表排序,不依赖服务器进度数据
    /// </summary>
    public List<int> GetHeroTrialHeroIDList()
    public bool CanChallenge(HeroTrialConfig config, out string tip)
    {
        List<int> heroIDList = HeroTrialConfig.GetSortedHeroIDList();
        for (int i = heroIDList.Count - 1; i >= 0; i--)
        tip = string.Empty;
        if (config == null)
            return false;
        // 武将副本整体是否解锁
        if (!IsHeroTrialHeroUnlocked(config.HeroID, out string heroUnlockTip))
        {
            if (!IsHeroTrialHeroVisible(heroIDList[i]))
                heroIDList.RemoveAt(i);
            tip = heroUnlockTip;
            return false;
        }
        return heroIDList;
    }
        // 这一关自己的等级/官职限制是否满足。
        if (!IsTrialLevelLimitMet(config, out tip))
            return false;
    bool IsHeroTrialHeroVisible(int heroID)
    {
        HeroConfig heroConfig = HeroConfig.Get(heroID);
        if (heroConfig == null)
        // 读取服务器下发的该武将已通关进度;没有记录时按 0 关处理。
        bool hasProgress = TryGetHeroPassLevelNum((uint)config.HeroID, out byte passLevelNum);
        // 判断当前关是不是该武将试炼的第一关。
        bool isFirstLevel = HeroTrialConfig.TryGetFirstLevelConfig(config.HeroID, out var firstConfig) && firstConfig.CfgID == config.CfgID;
        // 如果服务器没有该武将进度,只有第一关允许挑战。
        if (!hasProgress)
        {
            passLevelNum = 0;
            // 第一关且尚未通关,可以挑战。
            if (isFirstLevel && passLevelNum < config.LevelNum)
                return true;
            // 不是第一关就提示前置关卡未通。
            tip = Language.Get("HeroTrial05");
            return false;
        }
        // 有进度时,第一关如果还没通,也允许挑战。
        if (isFirstLevel && passLevelNum < config.LevelNum)
            return true;
        return heroConfig.IsActLimit <= 0 ||
            heroConfig.OpenCollectionDay <= 0 ||
            TimeUtility.OpenDay + 1 >= heroConfig.OpenCollectionDay;
        // 普通关卡必须等于“已通关数 + 1”,也就是只能打下一关,不能跳关。
        if (config.LevelNum != passLevelNum + 1)
        {
            tip = Language.Get("HeroTrial05");
            return false;
        }
        // 所有条件都满足,当前关卡可以挑战。
        return true;
    }
    bool IsTrialLevelLimitMet(HeroTrialConfig config, out string tip)
    {
        // tip 用来返回这关未达标原因。
        tip = string.Empty;
        // 没有配置时认为不满足。
        if (config == null)
            return false;
        // 这关配置了等级限制,且玩家等级不足时不能挑战。
        if (config.LVLimit > 0 && PlayerDatas.Instance.baseData.LV < config.LVLimit)
        {
            tip = Language.Get("HeroTrial04", config.LVLimit);
            return false;
        }
        // 这关配置了官职/境界限制,且玩家官职不足时不能挑战。
        if (config.RealmLimit > 0 && PlayerDatas.Instance.baseData.realmLevel < config.RealmLimit)
        {
            // 查配置只是为了展示官职名称;查不到时用空字符串兜底。
            var realmConfig = RealmConfig.Get(config.RealmLimit);
            tip = Language.Get("HeroTrial03", realmConfig != null ? realmConfig.Name : string.Empty);
            return false;
        }
        // 等级和官职都满足。
        return true;
    }
    #region 红点
    public Redpoint parentRedpoint = new Redpoint(MainRedDot.MainChallengeRedpoint, MainRedDot.HeroTrialRepoint);
    public int nowShowRedHeroID;//当前展示的副本红点的的武将ID
    public void ClearRedpoint()
    {
        parentRedpoint.state = RedPointState.None;
        nowShowRedHeroID = 0;
    }
    public void UpdateRedpoint()
    {
        ClearRedpoint();
        // 功能入口未开时也维护今日状态和可提示快照,保证入口开启后能识别当前登录期间的达标开放。
        EnsureTodayState();
        RefreshTodayNewOpenHeroes();
        if (!FuncOpen.Instance.IsFuncOpen(funcId))
            return;
        if (TryGetNextNewOpenRedHero(out int newHeroID))
        {
            ShowRed(newHeroID);
            return;
        }
        if (todayNewOpenHeroIDs.Count > 0)
            return;
        // 没有新开放队列时,任意一次今日挑战都视为旧副本红点已消费。
        if (consumedRedHeroIDsToday.Count > 0)
            return;
        if (TryGetOldRedHero(out int oldHeroID))
            ShowRed(oldHeroID);
    }
    void RefreshRedpointAndNotify(bool forceNotify = false)
    {
        int oldRedHeroID = nowShowRedHeroID;
        UpdateRedpoint();
        if (forceNotify || oldRedHeroID != nowShowRedHeroID)
            OnUpdateHeroTrialInfoEvent?.Invoke();
    }
    void ShowRed(int heroID)
    {
        nowShowRedHeroID = heroID;
        parentRedpoint.state = RedPointState.Simple;
    }
    bool TryGetLowestCanChallengeConfig(int heroID, out HeroTrialConfig config)
    {
        config = null;
        if (!HeroTrialConfig.TryGetHeroConfigList(heroID, out var configs))
            return false;
        for (int i = 0; i < configs.Count; i++)
        {
            if (!CanChallenge(configs[i], out _))
                continue;
            if (config == null || configs[i].FightPower < config.FightPower)
                config = configs[i];
        }
        return config != null;
    }
    public bool IsShowHeroItemRed(int heroID)
    {
        return heroID > 0 && heroID == nowShowRedHeroID;
    }
    public bool IsShowHeroTrialLevelRed(HeroTrialConfig config)
    {
        if (config == null || config.HeroID != nowShowRedHeroID)
            return false;
        return TryGetLowestCanChallengeConfig(config.HeroID, out var redConfig) &&
            redConfig != null &&
            redConfig.CfgID == config.CfgID;
    }
    void EnsureTodayState()
    {
        int currentTodayKey = GetTodayKey();
        if (!hasLoadedLocalRecord)
            LoadLocalRecord(currentTodayKey);
        if (todayKey == currentTodayKey)
            return;
        todayKey = currentTodayKey;
        hasAvailableSnapshot = false;
        lastChallengeHeroIDToday = 0;
        todayNewOpenHeroIDs.Clear();
        consumedRedHeroIDsToday.Clear();
        knownAvailableHeroIDs.Clear();
        SaveLocalRecord();
    }
    int GetTodayKey()
    {
        DateTime now = TimeUtility.ServerNow;
        return now.Year * 10000 + now.Month * 100 + now.Day;
    }
    string GetLocalRecordKey()
    {
        return StringUtility.Concat("HeroTrialRedpoint_", PlayerDatas.Instance.PlayerId.ToString());
    }
    void LoadLocalRecord(int currentTodayKey)
    {
        hasLoadedLocalRecord = true;
        todayKey = currentTodayKey;
        todayNewOpenHeroIDs.Clear();
        consumedRedHeroIDsToday.Clear();
        string json = LocalSave.GetString(GetLocalRecordKey());
        if (string.IsNullOrEmpty(json))
            return;
        JsonData data;
        try
        {
            data = JsonMapper.ToObject(json);
        }
        catch
        {
            return;
        }
        if (data == null || !data.IsObject || !data.ContainsKey("day"))
            return;
        if (!int.TryParse(data["day"].ToString(), out int recordDay) || recordDay != currentTodayKey)
        {
            // 过期记录不迁移;按今天空记录重新计算。
            SaveLocalRecord();
            return;
        }
        LoadIntSet(data, "new", todayNewOpenHeroIDs);
        LoadIntSet(data, "consumed", consumedRedHeroIDsToday);
    }
    void SaveLocalRecord()
    {
        JsonData data = new JsonData();
        data["day"] = todayKey != 0 ? todayKey : GetTodayKey();
        data["new"] = ToJsonArray(todayNewOpenHeroIDs);
        data["consumed"] = ToJsonArray(consumedRedHeroIDsToday);
        LocalSave.SetString(GetLocalRecordKey(), data.ToJson());
    }
    void LoadIntSet(JsonData data, string field, HashSet<int> heroIDSet)
    {
        if (!data.ContainsKey(field) || data[field] == null || !data[field].IsArray)
            return;
        JsonData array = data[field];
        for (int i = 0; i < array.Count; i++)
        {
            if (int.TryParse(array[i].ToString(), out int heroID) && heroID > 0)
                heroIDSet.Add(heroID);
        }
    }
    JsonData ToJsonArray(HashSet<int> heroIDSet)
    {
        JsonData array = new JsonData();
        array.SetJsonType(JsonType.Array);
        foreach (int heroID in heroIDSet)
        {
            array.Add(heroID);
        }
        return array;
    }
    void RefreshTodayNewOpenHeroes()
    {
        bool dirty = false;
        List<int> heroIDList = GetHeroTrialHeroIDList();
        HashSet<int> currentAvailableHeroIDs = new HashSet<int>();
        for (int i = 0; i < heroIDList.Count; i++)
        {
            int heroID = heroIDList[i];
            if (!IsHeroAvailableForRed(heroID))
                continue;
            currentAvailableHeroIDs.Add(heroID);
            // 等级/官职达标只靠当前登录期间的快照差异;限定武将按当天开服天反复识别,支持重登恢复。
            bool isNewBySnapshot = hasAvailableSnapshot && !knownAvailableHeroIDs.Contains(heroID);
            bool isNewByOpenDay = IsActLimitHeroOpenToday(heroID);
            if ((isNewBySnapshot || isNewByOpenDay) && todayNewOpenHeroIDs.Add(heroID))
                dirty = true;
        }
        knownAvailableHeroIDs.Clear();
        foreach (int heroID in currentAvailableHeroIDs)
        {
            knownAvailableHeroIDs.Add(heroID);
        }
        hasAvailableSnapshot = true;
        if (dirty)
            SaveLocalRecord();
    }
    bool IsHeroAvailableForRed(int heroID)
    {
        return IsHeroTrialHeroUnlocked(heroID) && HasChallengeableLevel(heroID);
    }
    bool IsActLimitHeroOpenToday(int heroID)
    {
        HeroConfig heroConfig = HeroConfig.Get(heroID);
        return heroConfig != null &&
            heroConfig.IsActLimit > 0 &&
            heroConfig.OpenCollectionDay > 0 &&
            heroConfig.OpenCollectionDay == TimeUtility.OpenDay + 1;
    }
    bool TryGetNextNewOpenRedHero(out int heroID)
    {
        heroID = 0;
        List<int> heroIDList = GetHeroTrialHeroIDList();
        for (int i = 0; i < heroIDList.Count; i++)
        {
            int candidateHeroID = heroIDList[i];
            if (!todayNewOpenHeroIDs.Contains(candidateHeroID))
                continue;
            if (consumedRedHeroIDsToday.Contains(candidateHeroID))
                continue;
            if (!IsHeroAvailableForRed(candidateHeroID))
                continue;
            heroID = candidateHeroID;
            return true;
        }
        return false;
    }
    bool TryGetOldRedHero(out int heroID)
    {
        heroID = 0;
        long bestFightPower = long.MaxValue;
        int bestSortIndex = int.MaxValue;
        List<int> heroIDList = GetHeroTrialHeroIDList();
        for (int i = 0; i < heroIDList.Count; i++)
        {
            int candidateHeroID = heroIDList[i];
            if (!IsHeroAvailableForRed(candidateHeroID))
                continue;
            if (!TryGetLowestCanChallengeConfig(candidateHeroID, out var config))
                continue;
            if (config.FightPower < bestFightPower ||
                config.FightPower == bestFightPower && i < bestSortIndex)
            {
                heroID = candidateHeroID;
                bestFightPower = config.FightPower;
                bestSortIndex = i;
            }
        }
        return heroID != 0;
    }
    void ConsumeRedpointOnChallenge(int heroID)
    {
        if (heroID <= 0)
            return;
        EnsureTodayState();
        // 点击挑战即消费红点;不等待战斗结算,避免返回界面后才改变红点状态。
        if (consumedRedHeroIDsToday.Add(heroID))
            SaveLocalRecord();
        lastChallengeHeroIDToday = heroID;
        RefreshRedpointAndNotify();
    }
    #endregion
    #region 跳转
    // 当天最后一次发起挑战的武将副本,用于界面首次打开时的默认跳转。
    int lastChallengeHeroIDToday;
    int GetLowestEnterHeroID(List<int> heroIDList)
    {
        // 这个方法用于界面默认落点,不直接决定红点;没有红点时也会找一个最适合进入的武将。
        int bestHeroID = 0;
        // 默认最大战力,方便找最低战力关卡。
        long bestFightPower = long.MaxValue;
        // 战力相同时,用配置顺序兜底。
        int bestSortIndex = int.MaxValue;
        // 遍历所有武将,找当前已解锁且有可挑战关卡的最佳进入目标。
        for (int i = 0; i < heroIDList.Count; i++)
        {
            // 当前候选武将ID。
            int heroID = heroIDList[i];
            // 没解锁的武将不能作为默认进入目标。
            if (!IsHeroTrialHeroUnlocked(heroID))
                continue;
            // 没有可挑战关卡的武将也不作为默认进入目标。
            if (!HasChallengeableLevel(heroID))
                continue;
            // 找这个武将当前可挑战关卡里战力最低的一关。
            if (!TryGetLowestCanChallengeConfig(heroID, out var config))
                continue;
            // 战力更低,则替换默认进入目标。
            if (config.FightPower < bestFightPower)
            {
                bestHeroID = heroID;
                bestFightPower = config.FightPower;
                bestSortIndex = i;
                continue;
            }
            // 战力相同,则配置顺序靠前者优先。
            if (config.FightPower == bestFightPower && i < bestSortIndex)
            {
                bestHeroID = heroID;
                bestSortIndex = i;
            }
        }
        // 返回 0 表示没有合适的默认进入武将。
        return bestHeroID;
    }
   /// <summary>
    /// 界面首次打开时的默认落点:
    /// 1. 当前红点武将;
    /// 2. 上次挑战过且当前仍可挑战的武将;
    /// 3. 所有已解锁且可挑战关卡中战力最低的武将。
    /// </summary>
    public int GetHeroTrialEnterHeroID()
    {
        UpdateRedpoint();
        // 如果功能已开,优先进入当前红点武将。
        if (FuncOpen.Instance.IsFuncOpen(funcId) && nowShowRedHeroID != 0)
        {
            // 再次校验红点武将仍然有效,避免进入一个已经不该亮红点的武将。
            if (IsHeroAvailableForRed(nowShowRedHeroID))
                return nowShowRedHeroID;
        }
        // 没有红点时,如果今天挑战过某个武将且它仍有可挑战关卡,就默认回到它。
        if (lastChallengeHeroIDToday != 0 && HasChallengeableLevel(lastChallengeHeroIDToday))
            return lastChallengeHeroIDToday;
        // 再没有的话,就从所有已解锁且有可挑战关卡的武将里选战力最低的作为默认入口。
        List<int> heroIDList = GetHeroTrialHeroIDList();
        return GetLowestEnterHeroID(heroIDList);
    }
    #endregion
}