主线战斗续战与波次快照
本页于 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)的校验顺序:
| 序 | 检查 | 行号 | 失败动作 |
|---|---|---|---|
| 1 | level != null | :56-60 | 返回 false(不清标记) |
| 2 | 标记 == PendingResume | :62-63 | 返回 false(不清标记) |
| 3 | 文件名非空 | :66-71 | 清标记 |
| 4 | 文件名 mission == 当前 mission | :73-78 | 清标记 |
| 5 | 文件可加载解码 | :80-85 | 清标记 |
| 6 | payload.MissionId == level.MissionId | :87-92 | 清标记 |
| 7 | 账号一致 | :94-100 | 清标记 |
| 8 | TryEnqueuePayload 成功 | :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)
| 序 | 步骤 | 行号 | 说明 |
|---|---|---|---|
| 1 | ApplyHeroesAndRemap | :54 / :150-165 | 按 UniqueId → ConfigId 匹配存活英雄,写血/坐标,建立「快照 Id → 运行时 Id」映射 |
| 2 | ApplyPlatformAndRemap | :55 / :168-183 | 按 level.PlatformUniqueId 定位 |
| 3 | ApplyMasterCasterAndRemap | :56 / :186-201 | 按 level.MasterCasterUniqueId 定位 |
| 4 | SpawnMonstersFromSnapshot | :57 / :206-233 | 逐条 CreateMonsterEntity(configId, pos, coeff) + ReplaceHp |
| 5 | RestoreHand | :58 / :236-245 | RestoreHandCardDataFromSnapshot + PublishHandVisualListenersFromBattleSnapshot |
| 6 | BuffSelectService.RestoreFromBattleSnapshot | :60-61 | Buff 池 / 已选 / RNG 状态 / 轮次幂等记录 |
| 7 | RestoreCombatStats | :63 / :70-114 | 英雄 / 怪 / Platform 的伤害统计回填 + 刷新 UI |
| 8 | RestoreActorPerks | :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。本次未执行恢复操作。