13 分钟
本页目录

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

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

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

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

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

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

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

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

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

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

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

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

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

一拍只定义两个关键时间

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

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

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

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

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

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

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

{
  "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,则源动画到达接触帧所需的源时间是:

S_contact = f_contact × 1000 / fps

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

source_elapsed = wall_elapsed × playback_rate

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

attack_rate = S_contact / T_contact

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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, 图集预加载, 解码内存