---
title: 让动作序列帧在战斗里准时命中
url: https://doc.liz6.com/game-development/battle-animation-event-clock
locale: zh
area: game-development
tags:
- game-development
date: 2026-08-27
modified: 2026-08-27
description: 动作图集进入游戏以后，如何让攻击、受击、特效、浮字与最终状态共享同一个接触时钟，并处理资源迟到、低动态偏好和解码内存。
---

# 让动作序列帧在战斗里准时命中

> 动画播放流畅，不等于战斗演出成立。真正决定一次攻击是否有力量感的，是所有可见结果是否在同一个因果时刻发生。

[从角色原画到动作帧序列](./character-art-to-animation-frames.md)解决了素材生产问题：怎样得到待机、攻击、受击和倒地四类可寻址图集。资源入库以后，另一个更隐蔽的问题才会出现：攻击者已经挥到接触帧，目标还没有受击；数字先跳出来，特效晚半拍；图片加载得稍慢，角色又从第零帧重新开始。

这些现象通常不是某一段 CSS 动画写错了，而是系统里同时存在多只钟：图集按自己的 FPS 播放，位移按 CSS 时长播放，特效靠延时器出现，战报则在状态更新时立即渲染。每一项单独看都合理，合起来却没有共同的“现在”。

本文记录的是一套事件驱动的战斗演出方法。它不要求所有动作资源拥有相同帧数或时长，而是要求它们共同服从一份很小的时间合同。

```mermaid
flowchart LR
    A[已成立的战斗事件] --> B[演出导演帧]
    B --> C[统一动作拍]
    C --> D[攻击图集]
    C --> E[目标反应]
    C --> F[位移与镜头]
    C --> G[特效、浮字与战报]
    D --> H[稳定最终状态]
    E --> H
    F --> H
    G --> H
```

## 先把战斗结果与战斗演出分开

战斗内核应先算出事实，再由表现层解释这些事实。一个最小事件序列可以是：

```text
Action  { at, actor, source }
Damage  { at, target, amount, critical }
Guard   { at, target, amount }
Recover { at, target, amount }
Defeat  { at, actor }
```

同一个 `at` 下的事件构成一拍。表现层从这一拍推导：

- 谁是当前行动者；
- 哪些人物是目标，谁是主要目标；
- 结果属于伤害、格挡、恢复、免疫还是位置变化；
- 应使用哪类动作、命中特效和镜头预设；
- 哪些文字与数值要在接触点出现；
- 这一拍结束后应回到待机、停在倒地终帧，还是进入其它稳定状态。

这里最重要的不变量是：**表现层只消费已经成立的结果，不在动画结束时反向提交伤害，也不从角色名、图片路径或 CSS 类猜测玩法结论。**

这样做有两个直接收益。第一，跳过动画、切换低动态偏好或页面失焦，都不会改变战斗结果。第二，同一类事件在不同战斗场景中可以复用同一套演出映射，只替换背景、站位和镜头。

## 一拍只定义两个关键时间

不必先设计一条复杂的电影时间线。大多数短促战斗动作只需要两个全局量：

```text
T_beat     一整拍的墙钟时长
T_contact  这一拍的统一接触时刻，0 < T_contact < T_beat
```

例如，一拍约 `900 ms`，接触点约在 `420 ms`。这只是便于理解的取整示例，不是通用参数。它可以被分成：

| 阶段 | 可见内容 | 不应提前出现的内容 |
|---|---|---|
| 蓄势 | 镜头轻推、攻击预备、目标聚焦 | 伤害数字、命中印、结果战报 |
| 提交 | 攻击者沿战斗轴接近目标 | 接触后的受击姿态 |
| 接触 | 攻击帧、受击帧、特效、浮字同时成立 | 无 |
| 落定 | 收势、倒地、镜头归位 | 过早回到待机 |

统一时长不是要求所有素材都做成 `900 ms`。真正需要统一的是语义接触点。

## 动作清单保存语义帧，而不是猜一个时长

每份非循环图集除了帧数和 FPS，还应声明几个语义 cue：

```json
{
  "playback": {
    "frames": 24,
    "fps": 30,
    "loop": false,
    "end": "return-idle"
  },
  "cues": {
    "anticipationEnd": 7,
    "contact": 13,
    "recoverStart": 18
  }
}
```

`contact` 是其中最关键的一项。它应由看过动作的人标注，而不是一律取总帧数的中点。挥刀、突刺、投射、受击和倒地的有效接触位置并不相同；视频抽帧以后，几何中点更没有可靠语义。

清单还应校验：

- cue 均落在合法帧范围内；
- `anticipationEnd <= contact <= recoverStart`；
- 循环待机不声明一次性接触 cue；
- `end` 明确是回待机、停留还是隐藏；
- 图集实际尺寸与单元格、行列完全一致。

没有清单校验时，一张损坏或旧格式图集很容易表现成“偶发节奏问题”，排查成本远高于加载时直接拒绝。

## 用重定时把不同素材压到同一个接触点

设动作源接触帧为 `f_contact`，源 FPS 为 `fps`，则源动画到达接触帧所需的源时间是：

```text
S_contact = f_contact × 1000 / fps
```

运行时若按下面的关系推进图集：

```text
source_elapsed = wall_elapsed × playback_rate
```

攻击动作从一拍起点立即播放，它的倍率可以取：

```text
attack_rate = S_contact / T_contact
```

于是当墙钟来到 `T_contact` 时，源动画恰好走到 `S_contact`。一段接触较晚的长攻击会被适当加速；一段接触很早的短攻击则会被放慢。素材仍保留各自的帧数与节奏，只在运行时对齐共同的语义点。

受击和倒地还需要同时满足两个条件：接触帧落在 `T_contact`，动作末尾落在 `T_beat`。设源总时长为 `S_end`：

```text
reaction_rate = (S_end - S_contact) / (T_beat - T_contact)
start_delay   = T_contact - S_contact / reaction_rate
```

这两个式子把“接触后的源时长”映射到“接触后的剩余拍长”，再反推出反应动作应何时启动。目标可以在攻击真正碰到之前先播放少量预备受力帧，但不会过早进入命中姿态；倒地也能在一拍结束时抵达终帧。

实际实现还应给倍率设置合理上下界。若某段素材必须加速或减速到很夸张才能对齐，问题多半在动作窗口或 cue 标注，继续拉伸只会把坏素材伪装成运行时问题。超界时应回到资产审核，或降级为静态姿态。

## 一拍需要一个导演，而不是多个组件各自猜目标

攻击者、目标、镜头、特效与战报都应从同一个“导演帧”读取。导演帧是事件序列的纯派生结果，不保存第二份战斗状态：

```text
DirectorFrame {
  beat_key,
  actor,
  targets,
  primary_target,
  source,
  motion_kind,
  impact_tone,
  critical,
  source_point,
  contact_points,
  camera_focus
}
```

这解决了几类很难靠局部修补消除的问题：

- 多目标攻击中，镜头、特效和浮字对“主要目标”的理解不一致；
- 格挡或免疫没有正伤害数值，却被误判成“没有命中”；
- 治疗与位移动作复用了伤害姿态；
- 战报取了最新状态，角色动画却还在播放上一拍。

新拍到来时，表现层应以最新已揭示拍为准，取消旧拍演出，而不是排队补播已经过时的动画。战斗状态是权威，动画是它在当前时刻的投影；后台标签页恢复后尤其不能用几秒钟补播来阻塞最新状态。

## 最终状态不能抹掉眼前的因果动作

这是事件演出中一个很容易漏掉的边界：某人物在自己的行动中触发了反击，并在同一拍内倒下。状态快照里它的最终生命已经是零，但这一拍的因果顺序仍然是“它先行动，然后受到回应”。

如果组件只按最终生命选择姿态，人物会直接播放倒地，自己的攻击凭空消失。正确优先级应是：

```text
当前拍行动姿态 > 当前拍目标反应 > 拍后稳定状态
```

因此，行动者先完整表现本拍攻击；拍结束后，再根据最终状态转入倒地。类似原则也适用于同拍治疗、复活、变身和退场：最终 DOM 可以立即拥有正确数据，但可见姿态必须保留最小的因果顺序。

## 资源迟到时追当前帧，不从头补播

预加载可以降低延迟，却无法保证图片永远先于动画到达。网络缓存、图片解码、React 提交顺序和设备性能都可能让图集晚几十到几百毫秒。

如果图集加载完成时才记录自己的 `startedAt`，它会从第零帧播放，而镜头、位移和特效已经走到一半。更可靠的方式是让所有一次性图集读取同一只正在运行的拍时钟：

```text
elapsed = shared_beat_clock.current_time - start_delay
frame   = frame_at(elapsed, playback_rate)
```

组件挂载后的第一次绘制就计算当前帧。若资源在接触后才到，它直接显示接触后对应姿态；若整拍已经结束，它直接完成并落到终态。这里不能只“补偿一次初始偏移”，后续每帧也应继续读取共同时间源，否则浏览器调度抖动仍会让几条轨道慢慢分离。

循环待机是例外。它没有一次性拍起点，可以使用独立循环时钟，并按稳定人物 ID 给一个很小的相位与倍率差异，避免整支队伍像复制粘贴一样同步呼吸。

## 预加载下一拍，而不是挂载所有姿态

大图集的内存成本常被压缩文件大小掩盖。浏览器绘制前需要把图片解码成像素，粗略预算是：

```text
decoded_bytes = sheet_width × sheet_height × 4
```

例如，`1024×1024` 单元格组成的 `6×4` 图集，解码后约占 `96 MiB`。它在磁盘上可能只有一两 MiB，但若一个人物同时挂载待机、攻击、受击和倒地四张图集，单个演员就可能占用数百 MiB。

更稳妥的资源策略是：

1. 演员只挂载当前姿态的图集；
2. 舞台查看尚未揭示的下一拍，只预热下一位行动者的攻击、一个主要目标的反应和对应特效；
3. 相同清单请求复用同一个加载 Promise；
4. 缓存记录引用计数、最后使用次序与**解码字节数**；
5. 只淘汰引用数为零的资源，并为闲置资源设置 LRU 预算；
6. 预加载器向演员交接资源时，把淘汰延迟到当前提交后的微任务，避免刚预热完就被清掉。

预算对象必须是解码内存，而不是 WebP 文件大小。后者适合评估下载，不能预测浏览器峰值内存。

## 低动态偏好是一套稳定演出，不是把时长改成零

`prefers-reduced-motion` 下若简单把所有动画时长设为零，常会留下半透明图层、错误首帧或没有命中反馈的空场。低动态模式需要明确的静态映射：

- 站立与一次性非倒地动作显示稳定首帧或等价静态姿态；
- 倒地显示并保持最后一帧；
- 人物停在编队锚点，不执行镜头推拉与冲刺；
- 命中特效使用静态定格图；
- 结果文字和最终数值仍然完整出现；
- 动画完成回调立即结算表现状态，不能让界面永远等候。

资源缺失、清单解析失败或图片解码失败也应走同一条静态降级路径。动画是增强层，不应成为战斗可读性的单点故障。

## 验收要检查语义时间点

只看一段完整录像，很容易凭整体观感放过几十毫秒的错位。更有效的回归方式是在每次测试中进入同一确定战斗拍，暂停共同时间轴，并截取几个语义时间点：

| 检查点 | 主要断言 |
|---|---|
| 初始 | 人物、站位与最终数据可读取 |
| 蓄势 | 结果文字和命中特效尚未暴露 |
| 接近 | 攻击与反应图集都未越过 contact cue |
| 接触 | 攻击、反应、特效、浮字和次级战报同时可见 |
| 落定 | 人物尚未过早跳回待机 |
| 拍尾 | 镜头归位，倒地到终帧，最终状态稳定 |

还应单独覆盖：

- 近战位移确实接近目标，但没有穿过战斗轴；
- 多目标事件的目标集合与特效位置一致；
- 行动者在同拍倒下时仍先表现自己的动作；
- 图集故意延迟加载后，首帧直接追上当前拍；
- 低动态模式下没有仍在运行的战斗动画；
- 图集失败时静态角色、数值与结果仍可理解；
- 连续多拍后，闲置解码内存回落到预算以内。

这类测试的价值不只在截图。它把“感觉命中不够实”转换成可复现的时间合同：接触前不能泄露结果，接触时必须共同成立，拍尾必须进入正确终态。

## 仍需单独处理的声音时钟

声音很容易被误认为“收到事件就播放即可”。如果战斗事件在一拍开始时已经揭示，而刀声或命中声立即播放，它仍会比约 `T_contact` 的画面早一截。

声音要真正同步，也必须读取同一个拍起点，并按接触点调度；页面恢复、音频上下文尚未解锁或已经错过接触点时，还要定义跳过还是有限追播。本文的视觉时间轴并不自动解决浏览器音频调度，因此不能把“事件来源一致”写成“声音已经同步”。

## 最小实现清单

- [ ] 战斗内核先产出 typed 事件，表现层不反向写玩法结果；
- [ ] 每拍只有一个 `T_beat` 与一个 `T_contact`；
- [ ] 非循环图集显式标注 anticipation、contact 和 recover cue；
- [ ] 攻击、受击和倒地通过 cue 重定时，不强制统一源 FPS；
- [ ] 镜头、位移、图集、特效和文字读取同一拍时钟；
- [ ] 迟到资源按当前拍追帧，不从第零帧补播；
- [ ] 当前因果动作优先于拍后的最终姿态；
- [ ] 只挂载当前图集，按解码尺寸做引用计数与 LRU；
- [ ] 下一拍只预热最可能用到的攻击、主要反应和特效；
- [ ] 低动态偏好与资源失败都有完整静态表现；
- [ ] 回归在蓄势、接近、接触、落定和终态分别断言；
- [ ] 若接入音效，声音也必须服从同一接触时钟。

## 结语：动作资源真正进入了事件系统

一组动作帧只有在运行时知道“谁在行动、何时接触、结果作用于谁、拍后留下什么”时，才从图片资源变成战斗演出。

最值得保留的不是某个 `900 ms` 常量，而是四个不变量：事件是唯一事实来源，接触点是共同语义锚，迟到资源追随当前时钟，最终状态不抹去眼前因果。只要这四点稳定，动作帧数、模型来源、特效风格和镜头都可以逐步替换，而不会重新制造多只互相漂移的钟。

*Keywords: 游戏开发, 战斗动画, 序列帧, sprite sheet, 事件时间线, contact cue, 动画同步, reduced motion, 图集预加载, 解码内存*
