Skip to main content

主线战斗续战与波次快照

本页于 2026-10-15 整合现有功能稿、飞书内容及客户端 origin/develop@7f0636c9 的功能资料。原 draft/unknown 不因整理自动变为批准;设计规则、源配置与运行结果分别说明。本次只做文档及源码审阅,没有执行游戏功能测试。

玩家入口与预期​

只有主线关卡在主界面就绪后显示继续对局提示。确认会重新进入对应关卡并恢复波次快照,取消清除待恢复标记;材料/BOSS玩法禁用该快照续战。这里记录本地战斗快照,不代替服务端断线重连或奖励结算。

恢复的语义是从保存波次重新开始;不恢复弹体、区域、技能CD与临时召唤物。养成基础属性按本次进关冻结值重新建立,英雄词条由快照恢复一次,不能重复叠加装备/符文/里程碑。客户端读档资料§7.7将不恢复节奏类运行时明确记录为预期行为;早期关卡稿曾要求保留能量、技能CD及BOSS状态,仍需区分已删除能量体系与恢复范围的产品差异,不能只凭代码存在宣布全部旧要求已批准废除。

范围:只覆盖本地战斗快照续战(Gameplay/Features/BattleSnapshot/),不含服务端续战/断线重连。 源码基线:客户端origin/develop@7f0636c9。

一句话模型:PlayerPrefs 里的「待恢复标记」+ persistentDataPath 上的 Base64 快照文件 + 进关时的一次内存排队应用。 写盘只有一条链路(每轮变更写滚动文件),恢复只有一个入口(TryConsumeAndResume)。


1. 三个组成部分​

组成位置生命周期
待恢复标记PlayerPrefs:BattleSnapshot.LocalResume.Pending = 1、...RollingFileName跨进程持久;结算 / 取消 / 校验失败时清除
滚动快照文件{persistentDataPath}/BattleSnapshots/rolling_a{accountId}_m{missionId}.b64(Base64 文本)每轮变更覆盖写;结算不删
内存排队BattleSnapshotResume._queuedPayload进关时排队 → OnAfterSkillCardServiceInitialized 应用后 ClearQueue()

文件名同时承担账号隔离与 mission 绑定两个职责(BattleSnapshotFacade.cs:181-188)。 旧格式 rolling_m{missionId} 仍可解析(BattleSnapshotLocalResumeMarker.cs:57-62),新格式为 rolling_a{accountId}_m{missionId}。


2. 写档链路(只有一条)​

2.1 每轮变更写滚动文件​

LevelBattleControllerBase.Lifecycle.cs:493-505 OnLevelSessionLevelRoundChange(全工程唯一订阅 EvtLevelRoundChange 处)
:503 BattleSnapshotFacade.TryWriteRollingSnapshotAfterRoundChange(this, currentRound1Based)
└─ BattleSnapshotResume.cs:51-60
:53 轮次 <= 1 → 跳过(第 1 轮不写,避免刚进关就有可恢复点)
:57 IsBattleResumeDisabledByMode() → 跳过
:59 facade.TryWriteRollingSnapshotInternal(level)
└─ BattleSnapshotFacade.cs:142-165
:153 new BattleSnapshotSaveOptions { IncludeDeadActors = false }
:154 BattleSnapshotCollector.Collect(level, opts) ← 遍历全部实体 + 排序 + 查 Perk
:155 Directory.CreateDirectory(GetSnapshotDirectory())
:156 codec.Encode → Base64 → File.WriteAllText(...) ← 同步 IO

2.2 打「待恢复」标记​

LevelBattleReadyState.cs:30-48 OnCombatTick
:34 TryMarkPendingRollingSnapshotIfPastFirstRound(Owner)
:43 CurrentRound <= 1 → 跳过
:45 IsBattleResumeDisabledByMode() → 跳过
:47 Owner.BattleSnapshotFacade.MarkPendingRollingSnapshot(owner)
└─ BattleSnapshotFacade.cs:126-132 → LocalResumeMarker.MarkPendingRollingSnapshot(fileName)
写 PlayerPrefs 两个 Key + PlayerPrefs.Save()

CurrentRound 来自刷怪服务:LevelBattleInitState.cs:218 SyncRoundStateToController(Owner) → LevelMonsterSpawnedService.cs:241-253 → LevelBattleControllerBase.cs:183-187 ApplyRoundStateFromSpawnService。 因此读档进关后 CurrentRound 已经是恢复到的轮次,会立刻重新打上待恢复标记 —— 恢复点可以连续接力。

2.3 清标记​

场景位置是否删文件
进入结算LevelBattleFinishState.cs:51 ClearPendingResumeMarker()否(文件残留到下次同 mission 覆盖)
玩家在恢复弹窗点取消BattleSnapshotPendingResumePrompt.cs:51 _marker.ClearPending()否
弹窗侧校验失败:39、:97(非主线)仅非主线分支调 TryDeleteSnapshotFile
进关侧校验失败BattleSnapshotFacade.cs:69/76/83/90/98/105否

3. 读档链路:发现 → 排队 → 应用​

3.1 发现(主界面就绪后弹窗)​

ViewMainOpenCoordinatorCommand.cs:398-403 TryShowBattleResumeAndWaitAsync
└─ BattleSnapshotPendingResumePrompt.RunAfterMainReadyAsync() (PendingResumePrompt.cs:32-53)
├─ TryPeekPendingResume(:55-111) 逐项校验:
│ :59 标记 == PendingResume
│ :62-67 文件名非空
│ :69-74 文件名可解析出 missionId(且不含路径段)
│ :76-81 文件存在
│ :83-87 可解码
│ :89-101 ★ LevelType != MainStory → 静默丢弃 + 清标记 + 删文件
│ :103-108 OwnerAccountId 与当前账号一致
├─ 弹窗 anginBattleTips / anginBattleContext
├─ 确认 → MainBattleEntryFlow.RunAsync(missionId, isResume: true, isStartupAutoEnter: false) :49
│ (isResume:true 只影响一件事 —— 跳过战力门槛提醒,MainBattleEntryFlow.cs:45-46)
└─ 取消 → ClearPending() :51

3.2 进关排队​

LevelBattleInitState.RunQueueBootstrapAsync LevelBattleInitState.cs:104-121
:108 if (Owner.IsBattleResumeDisabledByMode())
:110 snapshot.ClearPendingIfMissionEquals(Owner.MissionId) ← 清标记,不走恢复
:112 else if (snapshot.TryConsumeAndResume(Owner, out var resumeErr))
:116 else if (resumeErr != null) Log.Warning(...)
:121 Owner.BattleSnapshotFacade.EnsureHooksWired() ← 订阅两个 Hook

BattleSnapshotFacade.TryConsumeAndResume(:53-110)的校验顺序:

序检查行号失败动作
1level != null:56-60返回 false(不清标记)
2标记 == PendingResume:62-63返回 false(不清标记)
3文件名非空:66-71清标记
4文件名 mission == 当前 mission:73-78清标记
5文件可加载解码:80-85清标记
6payload.MissionId == level.MissionId:87-92清标记
7账号一致:94-100清标记
8TryEnqueuePayload 成功:102-107清标记

BattleSnapshotResume.TryEnqueuePayload(:16-35):格式版本必须等于 BattleSnapshotPayload.CurrentFormatVersion(当前 8,BattleSnapshotModels.cs:83),不等直接丢弃不入队。

EnsureQueueEntryHooksWired(:37-47):_pipelineWired 幂等;订阅 OnConfigureBattleEntry 与 OnAfterSkillCardServiceInitialized。 Facade 在 LevelBattleControllerBase.Lifecycle.cs:220 每局 new,所以 _pipelineWired 每局重置。


4. 进关应用的完整时序(核心)​

RunQueueBootstrapAsync() LevelBattleInitState.cs:104-249
:122-128 重置战斗态:BattleResult / PlatformUniqueId / MasterCasterUniqueId /
ResetBattleTeam / ResetHeroSummonTracking / CombatStatFacade.ResetForNewBattle
:130-131 卡牌规则 BindCardRules
:133 ctx = new LevelBattleEntryContext(Owner)
:134 RaiseOnConfigureBattleEntry(ctx)
└─ Resume.OnConfigureBattleEntry (:62-69)
ctx.QueuedBattleSnapshot = payload
ctx.MonsterSpawnStartRoundOrder = max(0, payload.SpawnRoundOrderIndex)
ctx.Level.SetGameplayRandomSeed(payload.GameplayRandomSeed)

:141 Owner.FreezeSpecialEnhancements() ← 【局外养成】冻结本局养成快照
:144 RaiseOnConfigurePreMatchPerkGrants(ctx) ← 【局外养成】装备/符文词条进 PreMatchPerkGrantRegistry
:152 CreateMasterEntity ← BuildMasterComplete(冻结快照)
:160-171 主角词条(Ownership=Player)施加
:183 CreatePlatformEntity(复制主角属性表)
:191-199 城墙词条(Ownership=Wall)施加
:203 ApplyPreMatchWallAttributes

:208 heroModel.ResetBattleSessionSlotsToEmpty() ← 战斗会话阵容清空,英雄全靠读档/召唤重建
:210-217 MonsterSpawnedService.Init(..., ctx.MonsterSpawnStartRoundOrder, ...)
:218 SyncRoundStateToController → CurrentRound = 恢复轮次
:220 q = ctx.QueuedBattleSnapshot
:224 若 q != null → GameplayClockService.PushCombatPause()(避免恢复出的怪提前移动)
:228 InitializeBattleSkillCards(q != null, q)
:231 BuildPhasePlan()
:234 若 q == null → Fire(EvtRebuildBuffPool)
:238-239 若 q != null → CardSelectService.RestoreFromSnapshot(q) ← 重建召唤英雄
:241 RaiseOnAfterSkillCardServiceInitialized(ctx)
└─ Resume.OnAfterSkillCardInitialized (:71-89)
coeff = ResolveMonsterCoeff(level)
WorldApplier.TryApply(payload, CharacterFacade, coeff, level, out err) ← 写回 ECS
ClearQueue()
:244-245 若 q != null → BuffSelectService.RestoreNpcTargetedPerksFromHistory()
:247 SkillFacade.SetProtagonistActorIds(PlatformUniqueId, MasterCasterUniqueId)
:248 Fire(EvtUpdateCombatStat.CreateRoleRefresh())

4.1 英雄重建(LevelCardSelectService.RestoreFromSnapshot :464-504)​

:470-476 _heroEvolutionLevelByHeroId ← payload.HeroEvolutionLevels
:478-488 summonRecords ← payload.SummonedHeroes(允许同 RoleId 出现多次?见 §7.4)
:491-496 _evolvedHeroIds:等级 > 1,或等级 == 1 且非召唤英雄 → 视为进化过
:498 level.RestoreSummonedHeroesFromRecords(summonRecords)
└─ LevelBattleControllerBase.HeroSummon.cs:119-160
:121 ResetHeroSummonTracking()
:148 CreateBattleHeroAt(roleId, level, pos, spawnPointIndex, applyMilestonePerks: false(预注册词条同时使用DeferToSnapshot,避免二次施加))
└─ CharacterFacade.CreateHeroEntity
:217 if (applyMilestonePerks) ApplyHeroMilestonePerks(...) ← ★ 被跳过
:221 ApplyPreMatchPerkGrants(uid, roleId, elementType) ← 读档模式延后至快照恢复,不再次授予
:501-503 SyncHeroSkillProgression(actorId, 进化等级)

4.2 世界写回(BattleSnapshotWorldApplier.TryApply :29-67)​

序步骤行号说明
1ApplyHeroesAndRemap:54 / :150-165按 UniqueId → ConfigId 匹配存活英雄,写血/坐标,建立「快照 Id → 运行时 Id」映射
2ApplyPlatformAndRemap:55 / :168-183按 level.PlatformUniqueId 定位
3ApplyMasterCasterAndRemap:56 / :186-201按 level.MasterCasterUniqueId 定位
4SpawnMonstersFromSnapshot:57 / :206-233逐条 CreateMonsterEntity(configId, pos, coeff) + ReplaceHp
5RestoreHand:58 / :236-245RestoreHandCardDataFromSnapshot + PublishHandVisualListenersFromBattleSnapshot
6BuffSelectService.RestoreFromBattleSnapshot:60-61Buff 池 / 已选 / RNG 状态 / 轮次幂等记录
7RestoreCombatStats:63 / :70-114英雄 / 怪 / Platform 的伤害统计回填 + 刷新 UI
8RestoreActorPerks:64 / :120-147遍历 payload.Heroes 与 payload.Monsters,按 ActivePerkIds 逐个 AddPerkToActor

5. 快照内容清单(Payload v8)​

5.1 关卡级​

SavedAtUtcTicks、MissionId、OwnerAccountId、GameplayRandomSeed、LevelType、 SpawnRoundOrderIndex、SpawnRoundCount(存了但恢复时不用,见 §7.3)。

5.2 单位(BattleSnapshotActorRecord,怪 / 英雄 / Platform / Master 共用)​

UniqueId、ConfigId、Px/Py/Pz、HpCurrent、HpMax、CombatStats[]、ActivePerkIds[]。

ActivePerkIds 的来源是 SkillFacade.TryGetActorPerkIds(BattleSnapshotCollector.cs:172-190), 即该 actor 当前全部激活词条:英雄等级里程碑词条 + 预注册词条(装备/符文)+ 肉鸽 Buff 词条。 只有 payload.Heroes 与 payload.Monsters 会被填充(:72-73),Platform / Master 不填。

5.3 卡牌与 Buff​

HandCards[]、BuffSelectRngState、BuffSelectHeroRoleIds[]、BuffSelectPool[]、 BuffSelectChosenBuffIds[]、BuffSelectResumeBlockPresent、BuffSelectAdRefreshUsedCount、 BuffSelectRoundChoiceRecords[](轮次三选一幂等)。

5.4 召唤​

SummonedHeroes[](RoleId + SpawnPointIndex,按召唤顺序)、 HeroEvolutionLevels[](HeroId + Level + SuperSkillId,v8 起带进化改写的运行时大招,见 Docs/局内英雄进化.md)。

5.5 不存的东西​

不存恢复方式
技能 CD / 投射物 / 区域 / 召唤物运行时不恢复(读档从轮次起点重新开始战斗节奏)
角色属性表每次创角用 HeroAttributeAssembler + 当次冻结的养成快照重算
局外养成快照进关时重新 FreezeSpecialEnhancements()
Buff 剩余时长靠重新 AddPerkToActor(Perk 生命周期由技能核心重新起算)
Platform / Master 的 ActivePerkIds靠创角时从 PreMatchPerkGrantRegistry 重新施加

6. 隔离与防护​

防护位置
账号隔离(文件名)BattleSnapshotFacade.cs:181-188
账号隔离(载荷)Facade.cs:94-100、PendingResumePrompt.cs:103-108
mission 绑定Facade.cs:73-78(文件名)+ :87-92(载荷二次校验)
玩法隔离PendingResumePrompt.cs:89-101(非主线丢弃 + 删文件)
玩法开关LevelBattleControllerBase.cs:439 默认 false;主线按引导关 MainStoryLevelController.Model.cs:19-22;材料关恒 true MaterialLevelController.cs:32
格式版本Resume.cs:26-31(旧版本快照一律丢弃)
路径穿越Facade.cs:200-206(文件名不得含路径段)
事件幂等Resume._pipelineWired、EquipStatIntegration 的 fired 守卫、BuffSelectRoundChoiceRecords
战斗中时间冻结LevelBattleInitState.cs:224 PushCombatPause()(恢复出的怪在进 CombatState 前不动)

已知限制​

滚动快照同步写盘;结算清标记但保留文件至同关覆盖。恢复种子不保证中途弹体和临时事件重演。SpawnRoundCount存储但恢复不用。当前版本不匹配的载荷不入队。历史旧文件名解析能力属于代码现状,不要求为本次文档迁移新增任何兼容路径。ConfigId定位依赖同英雄不重复上场,若未来允许重复则必须重评恢复身份。

来源​

客户端Docs/战斗读档逻辑梳理.md、Docs/局内英雄进化.md与材料关§12.4;旧battle-mission。本次未执行恢复操作。