让所有客户端在同一帧施放同一技能:《混沌与秩序之英雄战歌》战斗系统技术设计

下文把《混沌与秩序之英雄战歌》简称为 HOC1。一场移动 MOBA 战斗看起来很直观:我们拖动摇杆、点下技能,角色移动,目标掉血,屏幕上出现动画和特效。

引擎真正要处理的事情多得多。同一条输入需要在多台设备上落到相同的模拟时刻;技能要经历目标选择、施法校验、前摇、事件触发、伤害、Buff 和表现;渲染帧率与网络抖动还要被隔离在战斗结果之外。

这篇文章把视角锁定在战斗运行时,重点回答三个问题:

  1. HOC1 怎样用帧同步把多名玩家的输入组织成一条统一时间线?
  2. 一条施法输入进入这条时间线后,技能系统怎样把它变成完整的战斗结算?
  3. Buff、攻击、阵容部署以及表现层怎样接在同一套 Core 框架上?

我们会沿一条战斗命令,依次讲清帧同步、技能、Buff、攻击、阵容部署和表现层怎样协作。有普通游戏逻辑经验的朋友,可以顺着这条数据流理解整套设计。

先看全景:四个阶段,一条战斗真值链

整套战斗可以先压缩成四个阶段:输入、同步、模拟、表现。

flowchart LR
    I[输入阶段<br/>触摸 技能提示 目标选择] --> C[战斗命令<br/>UnitAction]
    C --> S[同步阶段<br/>统一帧与全局顺序]
    S --> Q[客户端命令队列]
    Q --> M[模拟阶段<br/>Unit 技能 Buff 弹道]
    M --> W[战斗真值<br/>位置 HP 状态 冷却]
    W --> P[表现阶段<br/>模型 粒子 声音 HUD]

    D[技能表 Buff 表 Lua] --> M
    R[同步随机流] --> M

这张图里最重要的是中间那条真值链:

  • 输入层表达玩家意图。
  • 同步层决定意图在哪个模拟帧执行,以及多个意图之间的先后顺序。
  • 模拟层执行完整战斗规则,产出位置、生命、状态和冷却等真值。
  • 表现层读取这些状态,并接收动画、特效、声音和 UI 请求。

HOC1 的逻辑与表现属于“真值归属和更新时间分层”。代码对象保持老式 C++ 游戏常见的重对象风格:Unit、攻击和 Buff 直接调用表现服务,真值与表现共享对象图,再用更新阶段和写权限划出边界。

帧同步正好位于这套架构的腰部。上面是玩家意图,下面是确定性战斗模拟。先把这一层讲清楚,技能系统的很多设计就会自然起来。

第一部分:帧同步怎样让所有客户端走在同一条时间线上

帧同步的核心是命令顺序

网络游戏常见两类同步思路。

模型 网络主要传什么 战斗结果在哪里计算 典型特点
状态同步 位置、速度、HP、状态快照 服务端为主 权威性强,状态带宽与插值逻辑更重
输入帧同步 移动、攻击、施法等命令 各客户端执行同一套模拟 命令很小,对确定性要求高

HOC1 采用服务端权威调度的输入帧同步。客户端上传“我要移动到哪里”“我要攻击谁”“我要向这个位置施法”;同步服务给这些命令分配统一的帧号和顺序;每个客户端再执行自己的 Unit、技能、Buff、攻击和弹道逻辑。

sequenceDiagram
    participant A as 客户端 A
    participant S as 同步服务
    participant B as 客户端 B

    A->>S: 移动命令
    B->>S: 施法命令
    S->>S: 分配 server frame 与 sequence
    S-->>A: 同一份有序命令帧
    S-->>B: 同一份有序命令帧
    A->>A: 执行移动和施法逻辑
    B->>B: 执行移动和施法逻辑
    Note over A,B: 初始状态、命令顺序、时间步和随机流一致

服务端在这里更像裁判与节拍器。它决定:

  • 哪个参与者有权提交这条命令。
  • 命令属于哪个模拟帧。
  • 同帧多条命令按什么顺序执行。
  • 客户端当前应该推进到哪里。
  • 命令流是否出现缺失、重复或乱序。

客户端同时运行完整战斗内核。理解这套系统时,可以把主线记成“服务端安排输入、客户端执行模拟”;服务端还可以继续附加同步检查与安全校验。

确定性模拟需要四个共同输入

帧同步能成立,依赖的内容比命令流多。每个客户端至少要共享四类输入:

flowchart TB
    A[相同的 BattleStartSnapshot] --> SIM[确定性战斗模拟]
    B[相同的有序 UnitAction] --> SIM
    C[相同的离散模拟步] --> SIM
    D[相同的同步随机流] --> SIM

    SIM --> X[客户端 A 的战斗状态]
    SIM --> Y[客户端 B 的战斗状态]
    X --> H{状态摘要一致}
    Y --> H

1. BattleStartSnapshot:相同的起跑线

战斗开始前需要冻结一份技术快照。它通常包含:

  • 地图与玩法参数。
  • 参与者的逻辑 ID 与阵营。
  • 英雄、皮肤和战斗配置。
  • 初始等级、技能、属性和状态效果。
  • 出生位置或出生点索引。
  • 随机种子。
  • 数据表与脚本版本。

这份快照的职责很单纯:让每台设备创建出相同的对象图。某台设备少一个初始 Buff,或者使用另一版技能表,后面的命令完全一致也会很快分叉。

2. UnitAction:相同的操作命令

UnitAction 只记录足以重放玩家意图的参数。移动需要目标位置,普通攻击需要目标 GUID,单位指向技能需要技能 ID 和目标 GUID,位置技能需要技能 ID 与坐标。

派生结果不放进命令。一次 AOE 命中谁、造成多少伤害、附加几层 Buff,由各客户端在统一帧上计算。

3. 离散模拟步:相同的时间增量

客户端按服务端帧推进离散战斗步,常见步长约为 33ms。Buff 持续时间、技能冷却、攻击前摇、弹道飞行和 AI 状态都使用这条模拟时间。

4. 同步随机流:相同的概率分支

暴击、概率触发和随机目标使用专门的同步随机源。初始种子相同还不够,每个客户端还要在同一逻辑节点、以同样次数消费随机数。

三套时钟:渲染时间、模拟时间和网络游标

帧同步最容易混淆的地方,是项目里同时存在三套“时间”。

flowchart TB
    W[Wall Clock<br/>设备外层帧 delta] --> PRE[输入 UI 镜头 特效 音频 绘制]

    F[Server Frame] --> PENDING[pendingFrames]
    PENDING --> STEP[Simulation Step<br/>约 33ms]
    STEP --> CORE[Unit 技能 Buff 弹道]

    Q[Sequence] --> ORDER[命令全局顺序与缺口检测]
    ORDER --> CORE

    CORE --> STATE[共享战斗状态]
    STATE --> PRE

Wall Clock:设备多久画一帧

外层帧使用设备时间 delta。它负责输入采样、UI 动画、镜头、粒子、音频和最终绘制。设备可以运行在 30 FPS、60 FPS 或更高帧率,外层 delta 也会随卡顿变化。

Server Frame:服务端批准到了哪个同步前沿

server_frame 表示服务端给出的权威同步前沿。客户端收到它时,自己的 cframe 可能还落后若干步;解包器会把时间推进记录展开到 pendingFrames,客户端每消费一个待执行步,才真正让 Unit、状态机、冷却、Buff、弹道与技能事件向前推进一次。

Sequence:命令流消费到了哪里

sequence 是同步记录的严格游标。客户端保存 expectedSequence,它表示“下一条期望消费的同步记录”。向前出现缺口时,客户端就知道自己的命令流已经不完整;重复前缀则可以按 sequence 幂等跳过。

server_framesequence 需要分开理解:

  • 一个待执行逻辑帧允许没有玩家命令,时间依然向前推进。
  • 一个模拟帧也可以包含多条命令,sequence 会连续消耗多次。
  • 空帧压缩可以一次推进多个模拟帧,却只占一个同步记录序号。
flowchart LR
    F100[同步前沿 100<br/>无输入] --> F101[同步前沿 101<br/>移动 + 施法]
    F101 --> F102[同步前沿 102<br/>无输入]

    S50[Seq 50<br/>时钟记录] --> S51[Seq 51<br/>移动]
    S51 --> S52[Seq 52<br/>施法]
    S52 --> S53[Seq 53<br/>帧推进标记]
    S53 --> S54[Seq 54<br/>时钟记录]

这两个游标一起存在,能分别检查“时间是否向前”和“命令是否完整”。

外层帧与同步帧怎样嵌套

Gameplay 更新被组织为 Pre、Core、Post 三段,每段使用的时钟域不同。

flowchart LR
    PRE[UpdatePlayPre<br/>外层帧] --> INPUT[UI 与输入采样]
    INPUT --> LOOP{pendingFrames 数量}
    LOOP -->|每个同步帧| SYN[同步动作处理]
    SYN --> OBJ[对象与 Unit 更新]
    OBJ --> BULLET[逻辑投射物更新]
    BULLET --> SPELL[技能事件更新]
    SPELL --> WORLD[区域与战争迷雾]
    WORLD --> LOOP
    LOOP -->|同步帧消费完| POST[UpdatePlayPost<br/>外层帧]
    POST --> VIEW[镜头 场景 UI 与绘制]

一帧画面可以消费零个、一个或多个同步模拟步:

  • 网络帧暂时没到时,本次外层帧可以只更新表现。
  • 节奏正常时,一帧画面通常消费一个或与时间匹配的同步步。
  • 客户端落后时,一帧画面可以连续执行多个同步步追赶。

Core 阶段的顶层顺序大致是:

  1. 同步动作阶段把本帧命令交给统一动作处理器。
  2. 对象与单位阶段推进移动、攻击、状态机、冷却与 Buff。
  3. 逻辑投射物阶段推进会影响命中时序的弹道对象。
  4. 技能事件阶段处理本帧到期的触发上下文。
  5. 区域与战争迷雾等世界服务更新。

攻击、Buff 和弹道可以立即触发新的技能事件,所以这是一组带回边的阶段,顶层执行顺序依然固定。

输入先变成意图,在线模式等待权威回灌

玩家操作先被整理成 UnitAction,战斗真值只在统一执行器中修改。不同运行模式共用同一条命令入口。

flowchart TB
    INPUT[触摸或按键] --> ACTION[构造 UnitAction]

    ACTION -->|在线| UP[发送到同步服务]
    UP --> ORDER[服务端排序与组帧]
    ORDER --> DOWN[权威帧回灌]

    ACTION -->|单机| SOLO[solo command queue]
    REPLAY[录像命令流] --> REPLAYQ[replay command queue]

    DOWN --> EXEC[统一动作执行器]
    SOLO --> EXEC
    REPLAYQ --> EXEC

在线路径尤其值得注意:本机触摸只会产生预施法提示、选中描边等即时反馈;移动、攻击、扣血和正式施法要等命令从权威帧回灌后再执行。这样,本机输入与远端输入最终会走同一个动作处理函数。

单机和录像也复用命令队列。这个设计让一套战斗逻辑同时服务在线、离线与回放,调试时还能直接保存命令流复现问题。

上行 UnitAction:客户端时间只是参考

一条上行 UnitAction 可以分成两段:

部分 字段 作用
本地时序前缀 client_frame 发送时客户端看到的帧
本地时序前缀 client_sequence / reserved 本地顺序或保留信息
本地时序前缀 client_timestamp 本地发送时间参考
动作体 action_type 移动、攻击、单位施法、位置施法等
动作体 action_payload 位置、GUID、技能 ID 与特殊参数

前三个字段合计 12 字节。同步服务读取它们作为客户端提交时的时序信息,然后从权威下行记录中移除这段本地前缀。各客户端的本地时钟彼此不同,全局执行时间统一由服务端重新分配。

flowchart LR
    U[上行 UnitAction] --> H[12 字节本地时序前缀]
    U --> B[action body]
    H --> OBS[延迟与异常观测]
    B --> V[身份 长度 频率与类型校验]
    V --> Q[当前服务端帧的待组帧队列]

动作体需要保持紧凑。常见命令可以这样理解:

动作 最小语义参数
Move 目标平面坐标或方向
StopMove 无动作体或极短标志
Attack 目标 Unit GUID
SpellToUnit 技能 ID、目标 GUID、附加参数
SpellToPosition 技能 ID、目标位置、附加参数
UpgradeSpell 技能槽与升级信息

服务端还要用连接上下文覆盖命令里的发送者 ID。客户端负责声明“我要做什么”,逻辑参与者归属由服务端连接上下文决定。

服务端怎样把 33ms 内的输入装进一个帧

同步服务维护一个离散帧调度器,tick 固定为 33ms,约 30.3Hz。每个 tick 做四件事:

  1. server_frame 加一。
  2. 取出这个时间窗口内到达的全部合法 UnitAction。
  3. 按权威接收顺序写入动作记录。
  4. 发送且只发送一个能推进本帧时钟的下行包。
flowchart TB
    T[33ms tick] --> F[server_frame + 1]
    F --> D[Drain pending actions]
    D --> V[校验来源 类型 长度 速率]
    V --> O[保持权威顺序]
    O --> C{本帧有动作}
    C -->|有| A[动作记录 + EmptyFrameRun 1]
    C -->|无| E[纯时钟帧]
    A --> B[广播一个下行帧包]
    E --> B
    B --> H[写入短期同步历史]

“一个 tick 只有一个时钟载体”是这里的关键不变量。若同一个 server_frame 先发纯时钟包,再额外发动作聚合,客户端会看到两次相同帧号;第二批动作可能被当成过期数据。

动作聚合包的结构

动作聚合可以抽象成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FrameBundle
start_sequence : uint32
server_frame : uint32
records[]

ActionRecord
player_id : uint8
action_type : uint8
body_length : uint8
action_body : bytes

EmptyFrameRun
marker : 0xFF
count : uint8

start_sequence 告诉客户端第一条展开记录应该使用哪个同步序号,server_frame 给出这批记录关联的权威同步前沿。普通记录携带来源、动作类型和最多 255 字节的动作体。

为什么动作后面还要有 EmptyFrameRun(1)

HOC1 的普通动作记录只负责把 UnitAction 放进当前待执行逻辑帧的动作队列,本身不等价于“模拟时间向前一步”。因此,有输入的聚合包会在动作记录后追加 EmptyFrameRun(1)。为了方便阅读,下文把这个用法简称为 FrameAdvance:

flowchart LR
    A[FrameBundle<br/>同步前沿 800] --> R1[Seq 1200<br/>玩家 1 移动]
    R1 --> R2[Seq 1201<br/>玩家 2 施法]
    R2 --> ADV[Seq 1202<br/>EmptyFrameRun 1]
    ADV --> SIM[在当前逻辑帧执行两条动作<br/>随后推进一次 33ms 模拟]

如果本帧完全没有动作,服务端可以发送纯时钟帧。连续安静期也可以使用 EmptyFrameRun(count) 压缩多个模拟步,安全计数范围是 1 到 127。

空帧压缩还有一个容易写错的细节:count 表示模拟帧推进量,这条压缩记录本身只消耗一个 sequence。帧游标与记录游标仍然保持两套语义。历史上分别占用 sequence 的多条 EmptyFrameRun(1) 在恢复重放时要保持原样,重新合并会改变记录序列。

这里还要分清包头 server_frame 与动作的执行逻辑帧。包头描述这一批记录最终批准到的同步前沿;动作先挂到当前待执行逻辑帧,尾部 EmptyFrameRun(1) 随后才让客户端推进到新的前沿。把包头帧号直接写成动作执行帧,会产生一帧偏移。

客户端怎样展开聚合帧

客户端收到下行包后,先完成一条严格的数据整理链,整理完成的动作才会进入技能或移动逻辑:

flowchart TB
    P[收到 FrameBundle] --> CHECK[校验包头 CRC 与 sequence 覆盖范围]
    CHECK --> EXPAND[按顺序展开 records]
    EXPAND --> KIND{记录类型}
    KIND -->|普通动作| INJECT[注入执行逻辑帧<br/>sequence receive time]
    INJECT --> SYNQ[同步消息队列]
    SYNQ --> AIQ[AI / UnitAction 队列]
    KIND -->|EmptyFrameRun| PENDING[pendingFrames<br/>追加 count 个 33ms 步]
    PENDING --> STEP[PlayCoreAIFrameOnline<br/>消费一个 pending dt]
    AIQ --> SYN[同步动作处理<br/>取 actionFrame 等于 cframe 的动作]
    STEP --> SYN
    SYN --> HANDLE[统一动作处理器]
    HANDLE --> CORE[完整 Core 更新]

普通动作记录会被重新包装成完整 UnitAction。服务端已经剥离的 12 字节本地时序前缀,此时由客户端使用所属执行逻辑帧、严格 sequence 和接收时间重新注入。

这一步决定了所有客户端最终看到的命令上下文相同。若服务端把原始 12 字节前缀也塞进下行记录,解码器会看到两套时序字段,后面的技能 ID、坐标或目标 GUID 就会整体错位。

sequence 缺口怎样处理

客户端维护期望的下一条 sequence:

flowchart TD
    R[收到记录覆盖范围] --> C{startSequence 与 expectedSequence 比较}
    C -->|相等| X[从第一条记录开始消费]
    C -->|小于| OVERLAP{包尾仍覆盖 expectedSequence}
    OVERLAP -->|否| OLD[整包已消费 直接丢弃]
    OVERLAP -->|是| SKIP[跳过重复前缀<br/>从 expectedSequence 继续]
    C -->|大于| GAP[出现向前缺口]
    X --> N[expectedSequence 前进]
    SKIP --> N
    GAP --> STOP[停止继续模拟并进入恢复]

严格序号让错误尽早暴露。重复记录通过幂等跳过解决,向前缺口代表至少丢了一条待执行同步记录。此时停止继续模拟并进入恢复流程,可以避免错误时间线继续扩散。

pendingFrames:网络前沿与本机执行前沿

客户端可以用两个水位理解自己的同步状态:

  • sframe:网络已经批准,可以执行到的最远模拟帧。
  • cframe:客户端已经实际执行完成的模拟帧。

两者之差就是待执行帧数:

1
backlog = sframe - cframe
flowchart LR
    C[cframe<br/>本机已执行前沿] --> P[pendingFrames<br/>待执行离散步]
    P --> S[sframe<br/>网络批准前沿]

每消费一个 pending frame,客户端按固定顺序工作:

  1. 找出 action_frame == cframe 的全部动作。
  2. 按 sequence 执行这些动作。
  3. 写入本帧模拟 dt。
  4. 推进一次完整 Core 战斗更新。
  5. 弹出一个 pending frame,cframe 加一。

未来帧动作继续等待。已经落在 cframe 之前的动作说明执行点被错过,应当直接触发时间线错误。

正常运行时可以持续检查三个不变量:

  • cframe <= sframe
  • pendingFrames.size == sframe - cframe
  • 每个 sequence 最多执行一次。

这些不变量比画面截图更适合做同步健康检查。它们一旦破坏,后面的瞬移、技能失效和 Buff 漂移往往只是结果。

延迟、抖动与追帧

输入帧同步会引入可解释的输入延迟。一次操作从触摸到正式执行,可以粗略分成:

1
Tinput = Tuplink + TtickAlign + Tdownlink + Tbuffer + Texecute
部分 含义
Tuplink 命令发到同步服务的时间
TtickAlign 等待下一个服务端离散帧的时间
Tdownlink 权威聚合帧返回设备的时间
Tbuffer 客户端为吸收抖动保留的缓冲
Texecute 本机执行器走到目标帧的时间

33ms 调度下,单看 TtickAlign,平均等待接近半个 tick。UI 可以立即显示技能范围、目标描边和按键反馈,位移、伤害、资源扣除与正式冷却仍然等待权威帧。

pendingFrames 同时也是抖动缓冲

网络包的到达间隔通常偏离 33ms,可能依次是 10ms、60ms、5ms,也可能一次到达多帧。pending 队列把这种到包节奏转换成相对平稳的模拟节奏。

我们可以把追帧行为抽象成下面的控制器。目标水位和状态阈值属于调优参数,图里表达的是控制思路。

stateDiagram-v2
    state "正常" as Normal
    state "等待网络" as WaitNetwork
    state "轻度追帧" as CatchUp
    state "高速恢复" as FastRecovery
    state "同步恢复" as SyncRecovery
    [*] --> Normal
    Normal --> WaitNetwork: backlog = 0
    WaitNetwork --> Normal: 收到新帧
    Normal --> CatchUp: backlog 高于目标水位
    CatchUp --> Normal: backlog 回落
    CatchUp --> FastRecovery: backlog 持续扩大
    FastRecovery --> Normal: 追到安全水位
    WaitNetwork --> SyncRecovery: 长时间无权威帧
    FastRecovery --> SyncRecovery: sequence 缺口超出轻量恢复能力
    SyncRecovery --> FastRecovery: 获得基线或历史帧

一帧画面可以追多个同步步

落后时,客户端可以在一次外层帧里连续执行多个 Core 模拟步。追帧阶段常见的降载手段包括:

  • 暂时少画或跳过世界渲染。
  • 抑制已经过期的一次性粒子和音效。
  • 保留必要的 UI 与连接反馈。
  • 为每个外层帧设置最大追帧预算。
  • 追到安全水位后,从当前逻辑状态恢复长期表现。

追帧循环需要上限。无限制地执行 while backlog > 0,会让低性能设备进入“越追越卡”的状态。调度器还要避免服务端追赶风暴:一次卡顿落后数个 tick 后,保持帧号单调,重置下一次调度基准,再恢复正常节拍,通常比瞬间补发一串零间隔 tick 稳定。

延迟与抖动要分开观察

延迟决定命令整体晚多久,抖动决定包到达得多不均匀。增加缓冲可以吸收抖动,也会增加固定输入延迟。监控时至少要同时看:

  • RTT 的 P50、P95 和 P99。
  • 到包间隔的方差。
  • backlog 的分布与峰值。
  • 每次外层帧追了几个同步步。
  • 等待网络的持续时间。
  • 从上行时间戳到权威回帧执行的端到端延迟。

确定性:相同命令怎样得到相同结果

输入帧同步可以写成一个很直观的状态转移式:

1
State(n + 1) = Simulate(State(n), OrderedInputs(n), SimulationDt, RandomState)

等号右侧只要有一项不同,后续状态就可能持续分叉。

数据和脚本版本必须一致

技能原型、Buff 原型、属性表、Lua 脚本和地图碰撞都会影响模拟。战斗开始前冻结版本,运行期间所有客户端统一切换逻辑配置。

对象遍历顺序必须稳定

范围技能找到五个目标后,若不同设备按不同哈希桶顺序遍历,连锁技能和随机目标就可能选中不同对象。稳定做法包括:

  • 目标集合按 GUID 排序。
  • 事件队列使用 frame + priority + sourceId + eventId 排序。
  • 指针地址不参与逻辑顺序。
  • 线程完成先后不决定战斗结果。

浮点差异需要边界

位置、方向和碰撞长时间积分时容易积累浮点误差。可以把关键逻辑值量化到固定精度,统一数学库与编译选项,并给范围边界规定明确的包含规则。动画插值继续使用本地浮点,因为它不进入战斗真值。

外层帧率与逻辑时间保持隔离

常见错误是让 Buff、冷却或弹道读取渲染 delta。60 FPS 设备会比 30 FPS 设备多更新一倍,或者因为积累误差在不同逻辑帧到期。战斗计时统一读取 SimulationDt,表现系统才读取 Wall Clock delta。

同步随机:种子相同还不够

战斗使用专门的同步 PRNG。它从 BattleStartSnapshot 的种子开始,暴击、闪避、概率 Buff 和随机目标都从这条受控随机流取值。

flowchart LR
    SEED[共同 seed] --> RNG[SynRand]
    RNG --> R1[draw 1<br/>暴击]
    R1 --> R2[draw 2<br/>随机目标]
    R2 --> R3[draw 3<br/>Buff 分支]
    R3 --> SAVE[PRNG state + usedCount]

真正决定一致性的还有调用次数和调用顺序。某台设备在“目标为空”的分支多取一次随机数,下一次暴击就会开始错位。

同步随机需要遵守几条纪律:

  • 表现层使用独立随机源。
  • Lua 只调用同步随机 API。
  • 提前返回和免疫分支明确是否消费随机数。
  • 重放和追帧按原顺序重新消费。
  • 状态摘要包含 PRNG state 或 usedCount

记录 frame、eventId、调用点、usedCount 后,随机分叉就能定位到具体技能和具体逻辑节点。

sequence 检查与状态检查解决两类问题

严格 sequence 保证所有客户端消费同一串记录,状态摘要负责检查这串记录是否算出了同一份战斗状态。完整同步需要两层检查。

第一层:时间线完整性

  • 幂等跳过重复前缀,拒绝向前缺口。
  • 维护 expectedSequence
  • 回传客户端已经消费到的 frame 与 sequence 水位。
  • 保存短期帧历史,以便重放缺失尾部。

第二层:规范化战斗摘要

状态检查层可以让客户端在固定检查帧生成 canonical state hash。摘要可以包含:

  • 当前 cframe 与同步随机状态。
  • 所有逻辑单位,按 GUID 排序。
  • 量化后的位置、方向、HP 和 MP。
  • 当前状态机状态。
  • 技能冷却与充能。
  • Buff ID、来源、层数、激活状态和剩余时间。
  • 投射物与待触发技能事件的稳定标识。

动画播放进度、粒子句柄、音频通道、镜头、内存地址和 UI 状态应排除在外。

sequenceDiagram
    participant A as 客户端 A
    participant S as 同步检查器
    participant B as 客户端 B

    A->>A: 在 frame N 生成 canonical hash
    B->>B: 在 frame N 生成 canonical hash
    A->>S: SynCheck N hash rngUsed
    B->>S: SynCheck N hash rngUsed
    S->>S: 按相同 frame 比较
    alt 一致
        S-->>A: 继续
        S-->>B: 继续
    else 分叉
        S->>S: 保存输入尾部与分段摘要
        S-->>A: 请求细粒度诊断或恢复
        S-->>B: 请求细粒度诊断或恢复
    end

发现分叉后,可以按层缩小范围:先比 RNG 游标,再比对象 GUID 集,再比 Unit 基础状态,最后比 Buff、冷却、弹道和事件队列。若系统同时保存命令流和分段摘要,我们还可以用二分重放找到第一处分叉帧。

同步恢复:基线加有序历史

短暂断线时,客户端的战斗内存通常仍然存在。它可以携带下一条期望 sequence 请求缺失记录。同步服务在同一条发送串行区间里,从有界历史找到覆盖位置,再按原始 frame 与 sequence 衔接到实时前沿。

sequenceDiagram
    participant C as 恢复客户端
    participant S as 帧服务
    participant O as 其他客户端

    C-xS: 网络中断
    O->>S: 继续提交命令
    S->>S: 推进帧并保存短期历史
    C->>S: Resume nextExpectedSequence
    S->>S: 暂停该连接的实时发送
    S-->>C: 按原 sequence 重放缺失 FrameBundle
    S-->>C: 衔接实时前沿
    C->>C: 高速消费 pendingFrames
    C->>C: 追到安全水位后恢复正常表现

轻量恢复请求有三种结果:

  • 请求序号等于服务端下一序号:已经追平。
  • 请求序号落在历史窗口:重放缺失尾部。
  • 请求序号早于历史窗口:直接转入可信基线与命令尾部恢复。

长时间断线、客户端进程重启和服务进程重启使用“可信状态基线 + 后续命令尾部”完成恢复。基线覆盖长期 Buff、冷却、Unit 状态、投射物、未到期事件和同步随机状态;已经结束的一次性粒子与声音可以跳过,追平后再从逻辑状态恢复持续表现。

历史重放与实时广播串行衔接,防止两条发送流交错。frame 与 sequence 使用 32 位模空间比较,正确处理从 0xFFFFFFFF 回到 0 的边界。

帧排序服务能防住什么

输入帧同步天然提供几层安全边界:

flowchart TB
    C[客户端命令] --> I[可信发送者映射]
    I --> P[协议形状与长度校验]
    P --> R[频率与字节预算]
    R --> T[动作类型和参数范围]
    T --> O[权威 frame 与 sequence]
    O --> H[可选的状态摘要与随机游标检查]
    H --> A[运行记录 重放与异常定位]

帧排序层负责保护输入来源和全局顺序。技能冷却、资源消耗、移动速度和目标视野属于语义合法性;服务端需要维护对应的战斗状态,才能继续校验这些规则。

按照服务端维护战斗状态的粒度,可以分成三档:

方案 服务端能力 成本与边界
帧排序 身份、格式、限流与顺序 成本低,深层语义依赖客户端一致执行;可附加状态摘要
选择性权威 再维护位置包络、资源、冷却等影子状态 能拦高风险命令,同时省去完整表现与 AI 模拟
完整权威模拟 服务端运行完整战斗世界 校验最强,计算与工程成本最高

HOC1 的客户端具备完整战斗模拟能力,服务端复算粒度可以独立扩展;权威帧序始终是所有客户端共同执行的时间线。

帧同步的测试体系

长期一致性测试同时覆盖字节、顺序、时间、确定性和恢复,画面正常运行只是最基础的连通性检查。

聚合包性质测试

  • 任意分片与粘包后仍能增量解析。
  • 一帧包含 0、1 或多条命令。
  • 多名参与者同帧输入。
  • 动作体长度 0、正常值和最大值。
  • EmptyFrameRun 的正常值、边界值和非法值。
  • 重复、缺口、乱序和校验损坏。
  • frame 与 sequence 的 32 位回绕。
  • 伪造发送者 ID。
  • 一个模拟 tick 只有一个时间推进令牌。

最重要的性质是:K 条动作加一个 EmptyFrameRun(1),sequence 前进 K + 1,模拟时间只前进一帧,每条动作恰好执行一次。

pending frame 模型测试

用一个小型参考模型随机生成帧包,持续断言:

  • cframe <= sframe
  • pending.size == sframe - cframe
  • expectedSequence 单调前进。
  • 未来动作等待、当前动作执行、过去动作报错。
  • backlog 为零时停止推进 Core 模拟。
  • 追帧预算保持有界,主线程始终给输入与表现留出时间。

网络故障矩阵

场景 观察点
固定延迟 输入端到端延迟和稳定 backlog
抖动 pending 水位与追帧频率
丢包或中断 sequence 缺口与恢复成功率
重复和乱序 幂等与严格拒绝策略
突发阻塞 高速追帧及表现降载
服务调度卡顿 是否出现 tick 追赶风暴
长时间运行 队列上界、内存、回绕与状态 hash

确定性黄金重放

固定初始快照、命令日志、模拟步和随机种子,在不同帧率与设备上重放。每隔固定帧比较 canonical hash,并记录第一处分叉。30 FPS 与 120 FPS 设备应该得到相同的战斗摘要。

flowchart TD
    CASE[快照 + 命令流 + dt + seed] --> A[模拟实例 A]
    CASE --> B[模拟实例 B]
    A --> HA[逐帧摘要 A]
    B --> HB[逐帧摘要 B]
    HA --> D{frame by frame 相等}
    HB --> D
    D -->|是| NEXT[继续]
    D -->|否| LOCATE[输出第一处分叉<br/>Unit RNG Buff Event]

到这里,帧同步可以被概括成一条可重放的战斗时间线:每个输入有唯一位置,每个逻辑帧只推进一次,每次随机消费可以追踪,各客户端也能持续核对自己的模拟状态。

第二部分:技能系统怎样消费一条同步命令

帧同步解决了“什么时候执行、按什么顺序执行”。技能系统接过这条命令后,还要回答另外几件事:施法条件是否成立,技能在哪个逻辑帧提交,目标怎样筛选,效果怎样排队,伤害和 Buff 怎样落地,最后又怎样驱动画面。

我习惯从三个层次讲这套技能框架:

  1. 命令层保存施法意图,并把意图放到权威时间线上。
  2. 施法层负责校验、施法状态机、资源与冷却。
  3. 效果层负责事件、选目标、脚本、投射物、伤害和 Buff。
flowchart TD
    INPUT[输入采样] --> PARAM[SpellCastingParams<br/>施法意图]
    PARAM --> PREVIEW[UI 瞄准预览<br/>范围与指示器]
    PARAM --> CMD[UnitAction<br/>单位目标或位置目标]
    CMD --> FRAME[权威同步帧]
    FRAME --> EXEC[Unit 动作执行器]
    EXEC --> CHECK[施法准入校验<br/>等级 资源 占用 状态]
    CHECK --> PRECAST[Core 预施法状态<br/>追击与目标复核]
    CHECK --> QUICK[快速施法路径]
    PRECAST --> SM[Cast Channel ChannelLock AftCast]
    SM --> COMMIT[普通施法生效点]
    QUICK --> QUICKFLOW[专用快速处理<br/>成本与触发顺序由技能配置决定]
    COMMIT --> COST[资源与冷却处理]
    COMMIT --> EVENT[技能触发上下文<br/>立即处理或排队]
    EVENT --> EFFECT[技能效果与 Lua 触发]
    QUICKFLOW --> EFFECT
    EFFECT --> TARGET[几何查询与条件过滤]
    TARGET --> RULE[伤害 Buff 位移 召唤]
    EFFECT --> PROJECTILE[投射物管理<br/>Bullet 更新]
    PROJECTILE --> RULE
    SM --> VIEW[动画与音效请求]
    PROJECTILE --> VIEW
    RULE --> VIEW

这张图里有两种容易重名的“预施法”。UI 预施法负责即时更新准星、范围圈和目标高亮;Core 预施法状态只在权威命令进入执行帧后运行,它可以追击到施法距离,并继续复核目标、迷雾和距离。生命、法力、冷却、Buff 与位移也都在 Core 路径里修改。

SpellCastingParams:先保存“想怎么放”

玩家按下技能按钮时,输入层先整理施法上下文,再交给 Unit 的施法入口与状态对象。在架构层,我们用 SpellCastingParams 统称这组上下文,内容按技能信息、目标和空间参数组织。

字段类别 典型内容 作用
技能信息 spell ID 找到技能原型
目标类型 单位目标、位置目标或技能自身规则 决定后续参数形态
显式目标 target GUID 传递单位目标
空间参数 目标位置 传递位置型施法意图
附加上下文 由具体施法入口解释 承载技能专用参数

这份参数只表达意图,不直接缓存最终命中列表。命中对象取决于执行帧里的世界状态:单位可能已经移动,Buff 可能改变了阵营关系或可选中性,目标也可能在命令往返期间死亡。所有客户端都在相同执行帧重新查询,从而得到相同的候选集合。

flowchart LR
    PRESS[按下技能] --> MODE{施法模式}
    MODE -->|点目标| PICK[候选 target GUID]
    MODE -->|点地面| POINT[世界坐标]
    MODE -->|无显式目标| SELF[spell ID 与附加上下文]
    PICK --> PARAM[SpellCastingParams]
    POINT --> PARAM
    SELF --> PARAM
    PARAM --> AIM[UI 瞄准预览<br/>范围圈 准星 高亮]
    PARAM --> RELEASE[松开后生成 UnitAction]
    PARAM --> CANCEL[取消后丢弃意图]

输入层还会做一次轻量预检,例如按钮是否可按、当前资源是否明显不足。预检追求响应速度,Core 里的施法准入校验与预施法状态负责最终战斗结果。网络往返期间状态发生变化时,执行帧里的 Core 状态拥有最终效力。

UnitAction:让施法意图进入同步时间线

技能相关同步动作包括三类:

  • SpellToUnit:技能标识 + 目标 GUID。
  • SpellToPosition:技能标识 + 世界坐标。
  • UpgradeSpell:技能槽与升级信息。

动作类型本身属于协议契约。点目标命令只携带技能标识与目标 GUID,点地面命令只携带技能标识与世界坐标;距离和命中列表在执行帧内重新计算。方向施法在 Unit 内部转换成位置或方向参数,同步层继续使用上面的三类动作。

sequenceDiagram
    participant I as 输入层
    participant P as UI 瞄准预览
    participant N as 帧同步通道
    participant Q as 同步命令队列
    participant U as Unit 执行器

    I->>P: 更新 SpellCastingParams
    P-->>I: 准星 范围圈 候选高亮
    I->>N: UnitAction SpellToPosition
    N-->>Q: 执行逻辑帧 + sequence + action
    Q->>U: 在对应模拟帧执行
    U->>U: 重新校验并进入施法状态机

在线、离线和重放都可以复用这份命令契约。在线模式经由服务端排序后回灌;离线模式由本地命令源分配帧号;重放模式从日志读取。三条入口最终都落到同一个 UnitAction 执行器,因此技能逻辑只维护一套。

两层施法校验:基础准入与状态内复核

命令到达执行帧后,施法准入层先检查技能等级、资源、技能占用和 Unit 当前状态。需要接近目标的技能随后进入 Core 预施法状态,状态对象在每个固定逻辑步里继续检查目标有效性、迷雾和距离,并在条件满足后转入 Cast。

flowchart TD
    START[收到技能 UnitAction] --> PARAM[解析 SpellCastingParams]
    PARAM --> BASE[施法准入校验<br/>等级 资源 占用 当前状态]
    BASE -->|失败| REJECT[终止本次施法并反馈]
    BASE -->|通过| PATH{是否需要追击与持续复核}
    PATH -->|需要| PRE[Core 预施法状态]
    PRE --> UPDATE[每个同步 dt 检查<br/>目标 迷雾 距离]
    UPDATE -->|继续接近| PRE
    UPDATE -->|条件失效| REJECT
    UPDATE -->|满足| CAST[进入 Cast]
    PATH -->|直接满足| CAST

从职责上看,两层校验关注的内容不同:

层次 职责重点 运行时作用
施法准入层 等级、资源、技能占用、Unit 状态 决定能否接受这次施法请求
Core 预施法状态 目标、迷雾、距离与追击过程 在固定逻辑步中等待条件成立或结束施法
Cast 及后续状态 持续时间、触发时机、引导和后摇 推进效果、消耗、冷却与表现请求

失败原因统一收敛为稳定结果码,供 UI、AI 和重放共同消费;相同状态始终产生相同结果。

同一个权威帧里若有多条命令,校验必须服从 sequence 顺序。前一条命令可能已经消耗资源、施加沉默或击杀目标,后一条命令读取到的就是更新后的世界。稳定顺序让“同帧互相施法”拥有唯一结果。

施法状态机:用同步 timeSpan 推进规则时间

通过校验后,单位进入技能状态机。状态机包含 PreCastCastChannelChannelLockAftCast 等阶段。ChannelLockChannel 都可以作为 Cast 之后的分支。

这些状态在每个同步步接收固定的 timeSpan。内部可以累计已用时,也可以递减剩余时长;确定性边界落在同一点:只有消费一个 Core 同步步时,规则时间才增加 33ms。

stateDiagram-v2
    [*] --> Idle
    Idle --> PreCast: 需要追击或持续复核
    Idle --> Cast: 快速或条件已满足
    PreCast --> Cast: 目标 距离与迷雾条件满足
    PreCast --> Idle: 条件失效或施法结束
    Cast --> Channel: 进入持续引导分支
    Cast --> ChannelLock: 进入锁定引导分支
    Cast --> AftCast: 普通施法段结束
    Channel --> AftCast: 引导结束或被打断
    ChannelLock --> AftCast: 引导终止
    AftCast --> Idle: 后摇完成

状态对象需要携带或引用以下运行时信息:

  • 当前 spell、施法者、目标和空间参数。
  • 当前阶段已经消费的逻辑时长。
  • 引导进度、触发次数和结束条件。
  • 状态迁移所需的控制标记。
  • 动画请求 ID,用于让表现层切换或停止对应动作。
flowchart LR
    ENTER[进入技能状态] --> REQUEST[发送一次动画请求]
    REQUEST --> MODEL[动画采样与混合]
    CORE[固定 33ms Core 步] --> UPDATE[状态逻辑更新 timeSpan]
    UPDATE --> TIMER[累计逻辑时长并检查状态条件]
    TIMER -->|继续| KEEP[保持当前状态]
    TIMER -->|完成| TRANSITION[状态机迁移]

模型系统负责采样骨骼和混合动作。状态类根据同步 timeSpan 完成逻辑计时并迁移,模型播放回调只服务视觉资源。这样即使设备掉帧、动画资源缺失或玩家关闭特效,施法状态仍沿着相同逻辑步推进。

普通施法与快速施法两条路径

一段技能会经历输入、追击或前摇、施法生效、命中和后摇。普通路径在施法生效阶段处理资源、冷却和事件;快速路径使用专用处理链,成本、冷却和触发顺序由具体技能配置决定。

flowchart TD
    VALID[Unit CanCast 通过] --> PATH{施法路径}
    PATH -->|普通路径| PRE[PreCast 或 Cast 状态]
    PATH -->|快速路径| QUICK[快速施法处理器]
    PRE --> EFFECTIVE[普通 Cast 生效阶段]
    EFFECTIVE --> COST[处理资源消耗]
    EFFECTIVE --> COOL[启动冷却或消耗充能]
    EFFECTIVE --> EVENTS[创建技能触发上下文]
    EFFECTIVE --> ANIM[发送施法表现请求]
    EVENTS --> HIT[立即处理或压入事件队列]
    QUICK --> QUICKFLOW[专用快速处理<br/>内部顺序由技能配置决定]
    QUICKFLOW --> QUICKFX[进入对应技能效果路径]

普通路径把资源消耗、冷却启动和效果触发放在施法生效阶段。普通路径与快速路径都消费同步时间,具体先后和打断后的成本处理由技能配置决定。

技能原型、技能效果与运行时事件

技能静态数据、效果定义和运行时触发上下文承担不同职责,三类对象的核心关系如下。

classDiagram
    class SkillPrototype {
        +spellId
        +castMode
        +costConfig
        +cooldownConfig
        +effectRefs
    }
    class SkillEffect {
        +effectId
        +triggerConfig
        +targetConfig
        +scriptEntry
        +effectParams
    }
    class SkillEvent {
        +casterContext
        +targetContext
        +positionContext
        +triggerParams
    }
    class SkillEventManager {
        +processNow
        +enqueue
        +processTrigger
        +updateBySyncStep
    }

    SkillPrototype "1" o-- "0..8" SkillEffect : effect refs
    SkillEvent --> SkillEventManager : trigger context
    SkillEventManager --> SkillEffect : resolve effect

技能原型是静态蓝图,一份原型可以引用最多八个技能效果。每个效果保存触发条件、目标条件、脚本入口和效果参数;运行时技能事件则把施法者、显式目标、位置和本次触发参数带进处理链。

效果槽位按配置顺序读取,空引用可以跳过。每个技能效果再根据自己的触发条件和目标规则执行,因此多个效果可以共享同一技能原型,同时保持各自的查询与脚本逻辑。

技能事件管理器:立即处理与排队处理两条路径

技能事件存在两种入口。立即入口直接处理当前触发,排队入口把上下文放入管理器,等待后续同步步消费。两条路径最终都会进入统一触发处理,解析相应技能效果,并调用引擎规则或 Lua。

flowchart TD
    CREATE[Cast 或其他规则创建技能事件] --> MODE{处理方式}
    MODE -->|立即| NOW[立即处理入口]
    MODE -->|排队| QUEUE[事件队列]
    QUEUE --> UPDATE[技能事件管理器<br/>消费固定同步 timeSpan]
    UPDATE --> DEQUEUE[消费排队的触发上下文]
    DEQUEUE --> TRIGGER[统一触发处理]
    NOW --> TRIGGER
    TRIGGER --> EFFECT[解析技能效果]
    EFFECT --> NATIVE[引擎效果规则]
    EFFECT --> LUA[Lua 入口]
    NATIVE --> NEXT[可继续产生技能事件]
    LUA --> NEXT

技能事件管理器统一承载待处理的触发上下文。同一批事件按相同创建顺序入队,管理器使用同步 dt 消费,重入触发也遵守统一次序。单步触发预算和递归深度保护可以防止脚本或反弹效果形成无限事件链。

目标解析:几何查询加条件过滤

范围技能先调用不同形状的查询器收集候选,再按技能条件过滤。查询层提供圆形、线形和扇形三类基础入口,复杂技能可以继续组合矩形、射线和碰撞半径规则。

flowchart LR
    EFFECT[技能效果 + 运行时事件] --> SHAPE{目标形状}
    SHAPE --> RANGE[圆形范围查询]
    SHAPE --> LINE[线形范围查询]
    SHAPE --> FAN[扇形范围查询]
    RANGE --> FILTER[阵营 类型 存活 可见 免疫等条件]
    LINE --> FILTER
    FAN --> FILTER
    FILTER --> LIMIT[按效果规则截取或继续处理]
    LIMIT --> TARGETS[本次效果的目标集合]

我们可以把目标规则分成两层:

  1. 几何规则回答单位是否落在形状中。圆形看距离平方,扇形看距离与夹角,矩形看局部坐标区间,射线再叠加碰撞半径。
  2. 语义规则回答候选对象是否有资格被选中。阵营、单位类型、存活状态、隐身、无敌、魔法免疫、召唤物标签都在这一层。

每个技能效果按自己的目标配置调用查询器,因此同一技能里的伤害效果和 Buff 效果可以各自收集目标。只有事件上下文显式携带并复用集合时,两段效果才共享同一批对象。

空间查询的返回顺序、浮点边界和并列裁决都会影响帧同步。查询层因此建立统一的确定性契约:关键坐标量化、边界包含规则固定、候选按稳定 GUID 排序,并列时继续用 GUID 裁决。

脚本层:让技能可扩展,同时守住确定性

大量技能会共享“找目标—循环目标—造成伤害—添加 Buff”的骨架,差异集中在少量条件和数值组合上。脚本层适合承载这部分变化。引擎把技能事件上下文显式传给 Lua,Lua 再通过受控引擎绑定直接读取和修改战斗状态。

flowchart TD
    EVENT[技能事件] --> CTX[显式上下文<br/>caster target point params]
    CTX --> SCRIPT[调用技能脚本入口]
    SCRIPT --> API{受控引擎绑定}
    API --> QUERY[查询 Unit 与属性]
    API --> DAMAGE[伤害与治疗写入]
    API --> BUFF[创建或移除 Buff]
    API --> BUFFSET[调整 Buff 数值与时长]
    API --> CD[调整技能冷却]
    API --> OTHER[位移与其他战斗操作]
    QUERY --> WORLD[读取战斗世界]
    DAMAGE --> WORLD[直接修改战斗世界]
    BUFF --> WORLD
    BUFFSET --> WORLD
    CD --> WORLD
    OTHER --> WORLD

这些绑定允许脚本直接写入 Core 状态,也把确定性约束推到了脚本边界。帧同步架构需要保证:

  • 时间只随固定同步 dt 推进。
  • 概率逻辑使用同步随机入口,并保持调用次数一致。
  • 传给 Lua 的对象集合沿用确定性的查询与迭代次序。
  • 数值转换、除零、溢出和取整策略固定。
  • 脚本版本与数据版本包含在战斗快照和回放签名中。

脚本沙箱限制文件、网络和系统时钟访问,并给每次调用设置指令预算或递归深度上限。预算耗尽时,各客户端用相同方式终止当前触发并记录异常上下文。

冷却、资源和充能都消费同步 timeSpan

冷却对象消费固定逻辑 timeSpan,并通过只读查询暴露剩余时间、当前充能和最大充能。内部可以保存剩余毫秒或等价计数,表现层只读取结果。

普通施法在生效阶段处理资源与冷却,快速施法使用自身技能配置规定的处理链。外层渲染帧率只影响 HUD 刷新频率。

flowchart LR
    CAST[施法生效流程] --> COST[资源消耗]
    CAST --> CDSTART[启动冷却或消耗充能]
    CDSTART --> CD[冷却对象]
    STEP[固定同步 timeSpan] --> UPDATE[推进冷却]
    UPDATE --> CD
    CD --> REMAIN[查询剩余时间]
    CD --> CHARGE[查询当前与最大充能]
    REMAIN --> HUD[HUD 冷却进度]
    CHARGE --> HUD

这一层的关键依旧是时钟来源:一次追帧会连续执行多个 33ms 逻辑步,等待网络时暂停 Core 冷却;HUD 在外层帧里平滑显示剩余时间,冷却对象只由同步逻辑步推进。

混合型 Effect 管理器要按对象行为划分边界

这套 Effect 管理器同时更新多类 Bullet,也管理技能特效、效果线、声音和可见性。它横跨 Core 与表现边界,其中会决定轨迹、碰撞和命中的 Bullet 状态进入确定性模拟。

flowchart TD
    EVENT[技能事件管理器触发] --> MGR[混合型 Effect 管理器]
    MGR --> BULLET[Bullet 对象]
    BULLET --> STEP[固定同步 timeSpan 更新轨迹]
    STEP --> COLLIDE{碰撞或到达目标}
    COLLIDE -->|继续飞行| STEP
    COLLIDE -->|命中| HIT[生成命中上下文]
    HIT --> UNIT[Unit 伤害与 Buff 逻辑]
    HIT --> NEXT[技能事件管理器的后续事件]
    MGR --> SPELLFX[spell effect / effect line]
    MGR --> SOUND[声音与可见性控制]
    BULLET --> VISUAL[模型与粒子轨迹]
    SPELLFX --> VIEW[表现更新]
    SOUND --> VIEW
    VISUAL --> VIEW

各类 Bullet 子类会保存自身所需的轨迹、目标、碰撞和生命周期数据。它们在固定同步步里更新并产生命中;粒子尾迹、线效、声音和模型插值则可以按外层帧刷新。架构边界按对象行为判断,管理器类名只描述所属容器。

Bullet 命中后会把结果重新送回 Unit 或技能事件管理器。投射物轨迹与命中属于 Core;同一个管理器里的声音、线效、可见性与视觉对象继续按表现规则更新。

一次扇形减速技能怎样跑完整条链

下面用一份“位置施法、扇形伤害、附加减速”的技能配置把整条链串起来。成本、冷却、事件与各效果槽的相对顺序由技能配置决定。

flowchart TD
    INPUT[位置型施法上下文] --> ACTION[SpellToPosition]
    ACTION --> SYNC[权威同步记录]
    SYNC --> UNIT[施法准入校验]
    UNIT --> PRE[Core 预施法状态<br/>固定 timeSpan 更新]
    PRE --> CAST[进入普通 Cast]
    CAST --> COST[处理资源消耗]
    CAST --> CD[启动冷却]
    CAST --> EVENT[创建技能触发上下文]
    CAST --> ANIM[Cast / AftCast 表现请求]
    EVENT --> PROCESS[立即处理或进入事件队列]
    PROCESS --> E0[伤害效果]
    PROCESS --> E1[减速效果]
    E0 --> Q0[扇形范围查询]
    E1 --> Q1[按本效果配置查询]
    Q0 --> DAMAGE[引擎规则或 Lua 伤害操作]
    DAMAGE --> UNITDMG[Unit 伤害计算与应用]
    Q1 --> BUFF[创建减速 Buff 并设置时长]
    BUFF --> BUFMGR[Buff 管理器]
    UNITDMG --> VIEW[伤害数字与受击表现]
    BUFMGR --> VIEW2[Buff 图标与特效]
    ANIM --> VIEW3[模型动画]

这条链里,UI 瞄准预览只提供操作反馈,Core 的 PreCast 状态继续复核距离与目标;每个技能效果按自己的配置查询并处理对象,共享目标由事件上下文显式表达;动画请求可以晚到或被降级,资源、冷却、生命和 Buff 仍由固定同步步中的逻辑路径结算。

技能系统的测试体系

技能测试最好围绕“命令—状态—事件—结果”四层展开。

命令与校验测试

  • SpellToUnitSpellToPositionUpgradeSpell 的序列化往返。
  • 目标 GUID 缺失、坐标越界、方向向量异常。
  • 资源刚好足够与差一单位的边界。
  • 冷却剩余时间与充能数的边界。
  • 同帧多条命令按 sequence 改变校验结果。
  • PreCast、Cast、Channel 与 ChannelLock 的结束和打断路径。

状态机测试

给状态对象输入固定的 Update(timeSpan) 序列,断言状态迁移、Cast 触发次数和引导更新次数。改变渲染帧率,Core 状态序列仍应完全一致。动画资源缺失时,逻辑计时仍能结束状态。

目标与效果测试

  • 圆、扇形、矩形和射线的边界点。
  • 各几何查询函数的边界和返回集合。
  • 目标数量上限、距离并列和生命值并列。
  • 同一原型多个技能效果的读取与触发次序。
  • 立即处理与排队处理两条事件路径。
  • 投射物穿透、擦边、目标死亡和寿命到期。
  • 随机化底层查询顺序,检查 GUID 规范化排序是否稳定。

确定性技能重放

flowchart LR
    CASE[固定快照 + 单条施法命令] --> FPS30[30 FPS 外层循环]
    CASE --> FPS120[120 FPS 外层循环]
    CASE --> CATCHUP[突发追帧循环]
    FPS30 --> HASH1[逐逻辑帧技能摘要]
    FPS120 --> HASH2[逐逻辑帧技能摘要]
    CATCHUP --> HASH3[逐逻辑帧技能摘要]
    HASH1 --> SAME{状态机 事件 目标 RNG 结果相同}
    HASH2 --> SAME
    HASH3 --> SAME

摘要里至少包含施法状态、资源、冷却、事件队列、目标 GUID、投射物、随机游标、伤害和 Buff 结果。这个测试能同时覆盖技能框架与帧同步边界。

第三部分:Buff 系统怎样持续改变单位规则

技能通常只负责“在某个帧触发一次规则”,Buff 负责把规则延续到后面的许多帧。加速、减速、护盾、持续伤害、沉默、免疫和属性光环,看起来差异很大,底层都可以归纳成一份带生命周期的单位附加状态。

这套 Buff 架构的核心对象是 Buff 管理器、Buff 实例和多态属性修改器。持续时间与周期逻辑都消费固定的 33ms 同步 dt。

flowchart TD
    SOURCE[技能 脚本 攻击或 Halo] --> ADD[Buff 管理器添加请求]
    ADD --> BUF[创建或取得 Buff 实例]
    BUF --> MODERS[创建属性修改器]
    MODERS --> MOD[最多四个多态修改器]
    MOD --> DO[应用规则]
    DO --> UNIT[修改 Unit 属性与行为]
    BUF --> UPDATE[按同步 timeSpan 更新 Buff]
    UPDATE --> TIMER[周期触发与持续时间更新]
    OPS[重入技能 脚本或管理路径] --> REFRESH[刷新时长或重置实例]
    OPS --> STACK[设置层数或切换叠层策略]
    REFRESH --> BUF
    STACK --> BUF
    REMOVE[Buff 管理器移除请求] --> UNDO[撤销全部修改器]
    UNDO --> MODUNDO[还原 Unit 规则]

Buff 管理器与 Buff 实例的职责

每个单位拥有一个 Buff 管理器。管理器负责添加、移除、更新和查询;单个 Buff 实例保存来源、持续时间、层数、数值与自身更新状态,并负责创建和撤销修改器。

一份 Buff 实例最多拥有四个多态属性修改器。攻击力变化、护盾、减伤、反伤、控制等规则由不同修改器子类实现,各自提供应用、刷新和撤销能力。Buff 实例负责生命周期,属性修改器负责具体规则钩子。

classDiagram
    class Unit {
        +guid
        +attributes
    }
    class BuffManager {
        +buffCollection
        +lifecycleControl
    }
    class BuffInstance {
        +source
        +duration
        +stack
        +runtimeValues
    }
    class AttributeModifier {
        +applyRule
        +refreshValue
        +revertRule
    }

    Unit "1" *-- "1" BuffManager
    BuffManager "1" *-- "many" BuffInstance
    BuffInstance "1" *-- "0..4" AttributeModifier

添加、刷新、叠层和策略切换

Buff 再次命中目标时,生命周期层可以刷新持续时间、重置实例状态、设置层数或切换强制叠层策略。具体选择由技能数据和脚本规则决定。

flowchart TD
    ADD[Buff 添加请求] --> PATH{本次 Buff 规则}
    PATH -->|新实例| NEW[创建 Buff 与修改器]
    PATH -->|刷新时间| REFRESH[刷新持续时间]
    PATH -->|重置实例| RESET[重置运行状态]
    PATH -->|设置层数| STACK[写入新层数]
    PATH -->|切换叠层策略| FORCE[更新策略标志]
    NEW --> DO[应用属性修改器]
    REFRESH --> STATE[更新 Buff 内部状态]
    RESET --> STATE
    STACK --> STATE
    FORCE --> STATE
    VALUE[脚本或规则要求修改器数值更新] --> MODREFRESH[显式刷新修改器数值]
    DO --> RESULT[修改 Unit 规则效果]
    MODREFRESH --> RESULT

这些入口各自更新 Buff 的时间、状态、层数或策略标志。Buff 状态变更与修改器数值刷新是两条独立路径;需要改变实际规则数值时,生命周期层显式刷新已有修改器。

多态属性修改器怎样应用和撤销

Buff 创建阶段根据配置生成最多四个修改器。Buff 生效时逐个应用规则,数值变化时显式刷新修改器,移除时再按同一组实例逐个撤销。

flowchart LR
    CREATE[Buff 创建修改器] --> M1[修改器 0]
    CREATE --> M2[修改器 1]
    CREATE --> M3[修改器 2]
    CREATE --> M4[修改器 3]
    M1 --> DO[应用]
    M2 --> DO
    M3 --> DO
    M4 --> DO
    DO --> UNIT[Unit 属性 行为与事件钩子]
    CHANGE[Buff 数值或层数变化] --> REFRESH[刷新数值]
    REFRESH --> UNIT
    REMOVE[Buff 移除] --> UNDO[撤销全部修改器]
    UNDO --> REVERSE[逐项还原 Unit 规则]

多态修改器让 Buff 的能力超出简单加减属性。某个子类可以参与最终伤害计算,另一个子类可以维护护盾,还有一些子类会处理减伤、伤害转移、吸血、反伤或暴击。创建、刷新和撤销都由同一个对象完成,规则的正向与反向路径就能配对。

周期触发与到期清理

Unit 更新阶段会推进管理器里的每个 Buff 实例。Buff 内部累计持续时间和周期计时,达到周期条件后触发一次回调;回调可以执行修改器逻辑,也可以通过脚本或技能事件产生伤害、治疗和其他效果。

sequenceDiagram
    participant Core as Core 模拟
    participant Mgr as Buff 管理器
    participant Buf as Buff 实例
    participant Moder as 属性修改器
    participant Trigger as 脚本或效果触发

    Core->>Mgr: Update 33ms
    Mgr->>Buf: 推进 timeSpan
    Buf->>Buf: 推进持续时间和内部计时
    opt 周期条件满足
        Buf->>Trigger: 周期触发
    end
    opt Buff 结束或被移除
        Mgr->>Buf: Remove
        Buf->>Moder: 撤销全部修改器
    end

追帧时,Buff 会随每个补执行的 Core 步连续推进 33ms,所以持续时间和周期触发仍沿用同一条逻辑时间线。表现层只读取剩余时间和层数,或接收添加、刷新、移除带来的视觉请求。

控制、护盾与伤害钩子由属性修改器表达

沉默、眩晕、定身等控制会影响施法、移动和攻击状态;护盾、减伤、伤害转移、吸血、反伤与暴击修改器会挂到伤害路径。Buff 实例负责拥有这些对象和推进生命周期,具体规则由相应属性修改器子类执行。

flowchart TD
    BUF[Buff 实例] --> CONTROL[控制类修改器]
    BUF --> SHIELD[护盾]
    BUF --> REDUCE[减伤与伤害转移]
    BUF --> REACT[吸血 反伤与暴击]
    CONTROL --> STATE[施法 移动与攻击状态]
    SHIELD --> DAMAGE[伤害计算路径]
    REDUCE --> DAMAGE
    REACT --> DAMAGE
    REMOVE[Buff 管理器移除] --> UNDO[撤销全部修改器]
    UNDO --> CONTROL
    UNDO --> SHIELD
    UNDO --> REDUCE
    UNDO --> REACT

Buff 被移除时,管理器调用同一实例的撤销路径。需要驱散或清除特定 Buff 时,管理器先按技能规则选出实例,再逐个移除;筛选优先级和数量由技能配置决定。

Halo:光环发射器和接收 Buff

Halo 由“范围更新 + Buff 管理操作”组成。发射器在固定同步步中查询范围内对象,为命中的单位添加或刷新对应 Buff;目标离开范围或来源失效时,再移除这条 Halo 带来的效果。

flowchart LR
    AURA[Halo Update timeSpan] --> QUERY[范围查询]
    QUERY --> INSIDE[当前范围内对象]
    INSIDE --> ADD[Buff 管理器添加]
    INSIDE --> REFRESH[按 Halo 规则刷新已有 Buff]
    PREV[此前受影响对象] --> CHECK[检查离开范围或来源失效]
    CHECK --> REMOVE[Buff 管理器移除]
    REMOVE --> UNDO[撤销属性修改器]

Halo 的查询与 Buff 更新都处在 Core 阶段,扫描周期由同步 timeSpan 推进。光圈粒子和范围提示可以每个外层帧刷新,视觉频率只影响平滑度。

第四部分:伤害与攻击怎样接入技能和 Buff

普通攻击和技能伤害最终都会进入 Unit 的伤害结算管线。基础伤害、技能附加伤害和属性修改器在固定阶段协作,再把计算结果交给统一的伤害应用层。

flowchart TD
    SOURCE[普通攻击 技能 Bullet 或 Buff 周期触发] --> UNIT[Unit 伤害计算入口]
    UNIT --> REAL[基础伤害计算]
    UNIT --> SPELL[技能附加伤害计算]
    REAL <--> MOD[属性修改器钩子集合]
    SPELL <--> MOD
    MOD --> SHIELD[护盾]
    MOD --> REDUCE[减伤与伤害转移]
    MOD --> REACT[吸血 反伤与暴击]
    REAL --> VALUE[伤害计算结果]
    SPELL --> VALUE
    MOD --> VALUE
    VALUE --> APPLY[Damage Apply<br/>应用伤害结果]
    APPLY --> HP[生命与死亡状态]
    HP --> VIEW[数字 音效 动画 镜头]

暴击、减伤、护盾、转移、吸血与反伤通过固定调用阶段接入结算管线,所有客户端沿用同一顺序。把顺序固化为规则契约后,多个修改器叠加时仍能得到唯一结果。

普通攻击状态机

普通攻击可以复用技能状态机的很多思想:选目标、接近、前摇、攻击生效、弹道或即时命中、后摇。攻击间隔和前摇都由固定同步 timeSpan 推进。

flowchart TD
    IDLE[AttackIdle] --> APPROACH[Approach]
    APPROACH --> WINDUP[Windup]
    WINDUP -->|目标失效| IDLE
    WINDUP --> COMMIT[Attack Commit]
    COMMIT --> BACKSWING[立即进入 Backswing]
    COMMIT --> TYPE{攻击类型}
    TYPE -->|近战| HIT[即时进入伤害路径]
    TYPE -->|远程| PROJECTILE[创建独立 Bullet]
    PROJECTILE -->|后续同步步命中| HIT
    BACKSWING -->|攻击间隔结束并继续| WINDUP
    BACKSWING -->|停止或换目标| IDLE

攻击生效时,近战路径直接进入伤害计算,远程路径创建独立 Bullet。攻击者同时进入 Backswing,因此下一次攻击节奏由攻击状态计时决定,Bullet 飞行时间只决定这次远程伤害何时命中。动画中的武器接触画面可以在表现层微调,伤害时机继续受 Core 时间线控制。

触发器要防止无限回环

伤害可能触发吸血,受击也可能触发反伤,修改器之间因此存在回边。每次结算携带父触发上下文、递归深度和终止标签,方便约束递归并定位触发来源。

flowchart LR
    D1[一次伤害计算] --> LIFESTEAL[SuckBlood 修改器]
    D1 --> REFLECT[Rebound 修改器]
    LIFESTEAL --> HEAL[生命恢复路径]
    REFLECT --> GUARD{增强的深度与终止标签检查}
    GUARD -->|允许| D2[再次进入 Unit 伤害计算]
    GUARD -->|终止| STOP[记录触发链结束]

单步预算、最大触发深度和反弹终止标签一起守住这条回边。结算记录保存父触发标识后,我们可以从一笔最终伤害回溯到最初的技能或攻击。

第五部分:阵容部署怎样构造确定性的战斗世界

阵容部署把一组静态参战记录转换成可确定性复现的战斗世界,核心环节包括数据归一化、槽位映射、坐标变换、对象创建和初始校验。

flowchart TD
    INPUT[参战记录集合] --> NORMALIZE[字段归一化与版本校验]
    NORMALIZE --> SORT[按 campId slotId 稳定排序]
    SORT --> SNAPSHOT[BattleStartSnapshot]
    SNAPSHOT --> GUID[确定性 GUID 分配]
    SNAPSHOT --> FORMATION[阵型槽位解析]
    FORMATION --> TRANSFORM[阵营局部坐标转世界坐标]
    GUID --> FACTORY[UnitFactory]
    TRANSFORM --> FACTORY
    FACTORY --> COMPONENT[属性 技能 Buff AI 与表现绑定信息]
    COMPONENT --> OBJECT[对象管理器注册]
    OBJECT --> HASH[初始 canonical hash]

BattleStartSnapshot 是战斗的共同起点

每个参战槽位至少需要这些稳定字段:

字段 用途
campId / slotId 决定阵营与阵型位置
unitTemplateId 选择单位原型
appearanceId 选择外观资源,不参与核心属性裁决
level / growth data 构造初始属性
spell loadout 装配技能原型
passive or rune data 创建初始被动和 Buff
controller type 绑定输入、AI 或重放控制器

快照还包含地图规则版本、技能与 Buff 数据版本、同步步长、随机种子和初始模拟帧。所有客户端在创建第一个单位前比对这份签名,可以更早发现数据版本差异。

阵型坐标使用两级变换

阵型配置保存相对槽位,地图保存每个阵营的锚点和朝向。局部槽位通过阵营变换得到世界坐标:

1
2
worldPosition = campOrigin + campRotation * localSlotOffset
worldFacing = campRotation * localSlotFacing
flowchart LR
    SLOT[FormationSlot<br/>localOffset localFacing] --> ROTATE[应用 campRotation]
    CAMP[CampAnchor<br/>origin rotation] --> ROTATE
    ROTATE --> WORLD[worldPosition worldFacing]
    WORLD --> NAV[导航落点与碰撞校验]
    NAV --> SPAWN[生成 Unit]

这套表达让同一份阵型可以镜像到不同阵营,也方便地图只维护少量锚点。旋转与坐标量化采用固定数学规则,导航落点若需要修正,也要使用确定性搜索顺序。

对象创建顺序决定初始 GUID

创建前先按 campId → slotId → unitTemplateId 排序,再分配 GUID 和注册组件。GUID 提供稳定对象身份,也可以作为目标与事件并列时的裁决键。初始化阶段的一次顺序差异会扩散到后续对象引用和状态摘要。

sequenceDiagram
    participant S as SnapshotBuilder
    participant F as FormationResolver
    participant U as UnitFactory
    participant O as 对象管理器
    participant H as StateHasher

    S->>S: 规范化并稳定排序槽位
    loop 每个槽位
        S->>F: 解析世界出生变换
        F-->>U: template + GUID + transform + loadout
        U->>U: 构建属性 技能 Buff AI
        U->>O: 注册 Unit
    end
    O->>H: 构建初始规范化摘要
    H-->>S: snapshot hash

表现资源可以异步加载,战斗对象的逻辑组件必须在第一个同步帧前全部就绪。逻辑单位先完成创建,表现层在模型资源就绪后再绑定,并追上当前状态。

第六部分:逻辑层与表现层怎样分离

这套系统采用“阶段分离 + 真值所有权分离”。战斗对象仍然可以直接向动画、粒子、音频和 HUD 服务发送请求。结果型表现消费 Core 真值,UI 瞄准预览消费输入意图,战斗真值的裁决始终留在 Core。

一次外层帧里的三个阶段

flowchart TD
    PRE[Pre 阶段<br/>采样输入 UI 预览 网络收包] --> COUNT[计算本次可消费的同步帧数]
    COUNT --> LOOP{还有同步帧预算}
    LOOP -->|是| ACTION[同步动作处理]
    ACTION --> UNIT[对象与 Unit 更新<br/>状态 冷却 Buff]
    UNIT --> BULLET[逻辑投射物<br/>Core 轨迹与命中]
    BULLET --> SPELL[技能事件管理器]
    SPELL --> WORLD[Zone / WarFog 等世界规则]
    WORLD --> LOOP
    LOOP -->|否| POST[Post 阶段<br/>场景 相机 音频 HUD]
    POST --> RENDER[提交渲染]

Pre 使用外层时间采样输入和处理界面;Core 每次只吃一个固定逻辑步;Post 使用外层时间更新相机、场景和视觉插值。网络顺畅时一帧画面可能消费一个同步帧,等待数据时消费零个,追赶 backlog 时消费多个。

Core 的顺序需要固定。同步动作阶段先让当前帧命令落地,Unit 更新再推进状态机、冷却和 Buff,逻辑投射物随后移动并产生命中,技能事件队列继续处理到期规则,最后更新区域与战争迷雾。模块之间允许产生回边,例如 Bullet 命中创建技能事件,Buff 周期回调触发伤害;同帧重入规则和预算必须统一。

真值归属比类名归属更重要

数据或行为 真值拥有者 表现层读取方式
生命、资源、属性 Unit 与属性系统 HUD 查询或接收变更通知
冷却、充能 冷却对象与管理路径 换算为按钮进度
Buff 实例与层数 Buff 管理器 图标、计时与特效订阅
施法与攻击状态 Unit 状态机 动画请求与当前状态查询
投射物命中 Core 中的 Bullet 更新 外层帧插值轨迹
技能事件顺序 技能事件管理器 特效、音效和提示事件
模型骨骼与粒子 模型与视觉效果管理器 自己维护播放状态
镜头、震动、声音 Camera 与 Audio 消费战斗表现请求

逻辑对象可以调用类似 PlayAnimationSpawnEffectPlaySound 的服务接口,直接发送表现意图。表现服务可以选择低配资源或延迟加载,Core 结果保持不变。

flowchart LR
    LOGIC[Unit Buff Attack Spell] --> REQ[表现请求<br/>type sourceGuid targetGuid frame params]
    REQ --> ANIM[Model / Animation]
    REQ --> FX[视觉效果管理器]
    REQ --> AUDIO[Audio]
    REQ --> HUD[HUD]
    LOGIC --> TRUTH[战斗真值<br/>HP CD Buff State]
    TRUTH --> QUERY[只读查询与通知]
    QUERY --> HUD
    QUERY --> ANIM

表现请求携带来源 GUID 和稳定触发标识,表现层据此做幂等处理。追帧期间同一事件被批量送达,粒子管理器可以省略已经过时的起始段,HUD 仍显示最终值;重放跳转时也能先清空表现句柄,再从当前逻辑状态恢复。

动画完成由逻辑计时器决定

施法和攻击进入状态时,逻辑层发送一次动画请求;状态机随后消费固定同步时长,并根据逻辑条件决定状态何时完成。外层渲染独立负责动作混合、速度调节和骨骼采样。

sequenceDiagram
    participant State as Unit 状态机
    participant Core as Core 时钟
    participant Model as 模型动画

    State->>State: enter Cast
    State->>Model: Play CastAnimation(eventId)
    loop 每个外层帧
        Model->>Model: 采样 混合 插值
    end
    loop 每个同步帧
        Core->>State: Update timeSpan = 33ms
    end
    State->>State: 逻辑时长或状态条件满足后迁移
    State->>Model: 切换 AftCast 动画

为动画、粒子、音频和 HUD 接口提供空实现或适配桩后,这套设计可以运行无渲染仿真和加速重放。模型回调提供视觉播放结束信号,用于回收资源;战斗状态迁移由同步逻辑步和状态条件决定。

逻辑投射物与视觉效果管理器的边界

两个名字都带 Effect,很容易混在一起。边界需要细化到对象行为:

  • Bullet 的轨迹、碰撞和命中位于 Core。
  • 同一管理器里的 spell effect、effect line、声音与可见性包含表现职责。
  • 视觉效果管理器继续管理粒子、贴花、光带等视觉对象。
  • Bullet 命中时创建伤害或技能触发,同时发送视觉请求。
flowchart LR
    SPELL[技能事件管理器] --> SEM[混合型 Effect 管理器]
    SEM --> BULLET[Bullet<br/>Core]
    BULLET --> HIT[命中事件]
    HIT --> UNIT[Unit / Damage / Buff]
    HIT --> SPELL
    SEM --> LINE[spell effect / effect line / sound<br/>表现职责]
    SEM --> VIS[视觉效果管理器<br/>Post]
    LINE --> GPU[渲染与音频提交]
    VIS --> GPU
    UNIT --> VIS

这条边界也适用于区域效果。毒雾的范围、周期和目标列表属于 Core;雾的粒子密度、颜色和消散曲线属于 Post。

UI 采用查询与通知的混合方式

HUD 持续显示生命、资源和冷却,适合每个外层帧通过只读接口查询 Unit、冷却对象等逻辑状态;击杀、升级、Buff 添加等离散变化适合事件通知。混合方式可以减少重复事件,也能避免 HUD 长期缓存一份战斗真值副本。

flowchart TD
    UNIT[Unit 与各逻辑管理器] --> CHANGE[离散变更通知]
    CHANGE --> HUD[HUD Controller]
    VIEW[Unit / 冷却对象等逻辑状态<br/>只读查询接口] --> HUD
    HUD --> FRAME[外层帧 UI 模型]
    FRAME --> WIDGET[血条 技能按钮 Buff 图标]
    WIDGET --> INTENT[输入意图]
    INTENT --> ACTION[UnitAction 命令入口]

UI 输入重新回到命令入口,HUD 保持只读消费。按钮显示为可用只是一份预览,执行帧里的施法准入层与 Core 预施法状态继续完成正式校验。

把整套架构重新串起来

现在我们可以从一条技能输入出发,看清各模块怎样合作:

flowchart TD
    A[输入意图 SpellCastingParams] --> B[UnitAction]
    B --> C[权威同步前沿 + sequence]
    C --> AQ[AI / UnitAction 队列]
    C --> D[pendingFrames<br/>时间推进记录]
    AQ --> E[同步动作处理]
    D --> E
    E --> F[施法准入校验]
    F --> PRE[Core 预施法状态]
    F --> QUICK[快速施法路径]
    PRE --> G[Cast Channel ChannelLock AftCast]
    QUICK --> QFLOW[专用快速处理<br/>内部顺序由技能配置决定]
    G --> I[资源与冷却处理]
    G --> J[技能事件<br/>立即处理或排队]
    J --> K[技能效果 / 几何查询 / Lua]
    QFLOW --> K
    K --> L[伤害管线 / Buff 管理器]
    J --> M[逻辑投射物 Bullet]
    M --> L
    L --> N[规范化战斗真值]
    N --> O[重放与可选状态 hash]
    G --> P[动画请求]
    M --> Q[粒子轨迹请求]
    L --> R[HUD 音效 镜头请求]
    P --> S[Post 表现阶段]
    Q --> S
    R --> S

这套架构有一条贯穿始终的原则:命令拥有唯一顺序,时间只由固定模拟步推进,随机数来自受控同步流,几何查询和脚本迭代保持全端一致。结果型表现消费 Core 真值,UI 瞄准预览消费输入意图,战斗裁决留在 Core。

我们落地时可以按这份清单自检

帧同步

  • 初始快照、命令、步长和随机种子是否足以重放。
  • server frame 与 sequence 是否分别表达同步前沿和记录位置。
  • 一帧多命令是否只有一次时间推进。
  • 动作队列与 pendingFrames 是否在执行逻辑帧汇合。
  • pendingFrames、缺口、追帧和恢复是否有明确上界。
  • canonical hash 能否定位第一处分叉。

技能

  • 输入意图与战斗提交是否分开。
  • 施法准入层与 Core 预施法状态是否各守住自己的校验职责。
  • 状态类是否只通过同步逻辑步推进规则时间。
  • 普通施法与快速施法路径是否区分清楚。
  • 资源、冷却与技能事件触发是否沿用同步时间。
  • 技能效果的查询、Lua 绑定和随机消费是否确定。
  • Bullet 的命中逻辑是否留在 Core。

Buff 与伤害

  • 添加、移除、刷新时间、重置与叠层入口是否职责清楚。
  • 修改器的创建、应用、刷新与撤销是否闭环。
  • Buff 生命周期与周期触发是否只消费同步时间。
  • Unit 伤害阶段与各属性修改器钩子是否共享一条明确、确定的执行顺序。
  • 状态摘要是否覆盖影响后续规则的全部字段。

初始化与表现

  • 槽位排序、GUID、坐标变换和对象创建是否可重现。
  • Core 各管理器的更新顺序是否固定。
  • 模型播放完成回调是否只影响表现资源,施法与攻击状态是否由逻辑 timeSpan 决定。
  • HUD 是否通过查询与通知消费真值,并把操作送回命令入口。

结语

从架构视角看,帧同步和技能系统是一件事的两面。帧同步提供一条全员共享、可以校验和重放的时间线;技能系统把时间线上的一条意图展开成状态机、事件、目标、伤害与 Buff;表现层再把这些确定的结果翻译成动作、粒子、声音和界面反馈。

保持命令、时钟、表现接口与脚本绑定契约后,我们可以逐步替换输入源、网络传输、表现实现和脚本内容;直接依赖处通过兼容接口或适配层衔接。战斗真值始终沿同一条 Core 管线前进,问题也能定位到具体的 frame、sequence、事件、随机游标或状态字段。这套可解释、可重放、可定位的结构,给多人战斗系统提供了长期演进的稳定底座。