问题不是玩家按慢了,而是客户端没有记住他按过
动作游戏里最容易吵起来的反馈之一,是玩家说“我明明按了”,程序看日志却说“这一帧状态不允许”。两边都没错。玩家按下攻击键时,角色可能正在落地硬直、上一段普攻的收招、翻滚的无敌后摇,或者网络模式下等待本地预测确认。客户端如果只在“完全可行动”的那一帧检查输入,手感就会显得很挑剔;如果什么输入都先排队,又会出现技能乱放、翻滚之后自动砍空气、Boss 转场期间把玩家缓存的技能释放到空目标上。
我在 Godot 项目里更倾向把输入缓冲看成一个“有时效、有语义、有拒绝理由”的小系统,而不是在每个角色脚本里写几个 if Input.is_action_just_pressed()。它需要知道输入来自键鼠、手柄还是触屏,需要知道动作能否被当前状态消费,也要能在调试层告诉设计师:这次攻击没有放出来,是因为缓冲过期、取消窗口未打开,还是目标锁定已经失效。只有拒绝原因可见,手感调参才不会变成反复试错。
先定义动作语义,不要直接缓存按键名
Godot 的 Input Map 很方便,但战斗系统不应该直接保存 attack_light、skill_1 这种按键字符串。按键是设备层语义,战斗要消费的是动作语义。比如键盘的左键、手柄的 X、触屏按钮都可能映射为 CombatIntent.light_attack。同一个按钮在不同模式下也可能含义不同:锁定状态下右摇杆是切目标,非锁定状态下右摇杆是镜头;长按技能键可能是蓄力,轻点是瞬发。
一个实用做法是准备 InputMapper 节点,把原始事件转成结构化对象:intent 表示想做什么,pressed_at_msec 表示发生时间,strength 表示摇杆或扳机强度,source 用于提示图标和手柄震动策略,context 用于记录当时是否锁定目标、是否在 UI 模式。然后再写入 CombatInputBuffer。这样后续即使调整按键、增加触屏方案、接入辅助瞄准,也不会影响战斗状态机的核心判断。
缓冲窗口要按动作和状态分别设计
不要用一个全局 buffer_time = 0.2 解决所有动作。轻攻击、翻滚、格挡、处决、喝药、切武器的期望完全不一样。轻攻击可以容忍玩家早按 150 到 220 毫秒,连段会更顺;翻滚缓冲太长会让玩家在受击硬直后自动翻出去,反而失控;格挡一般希望尽快响应,但在某些攻击承诺动作里不能取消,否则动作代价不存在。
我通常把缓冲规则写成数据表,而不是散在状态脚本里。每个 intent 有 ttl_msec、consume_policy、can_replace、requires_target、priority。例如轻攻击允许被翻滚替换,翻滚不允许被普通攻击替换;处决需要目标仍处于可处决状态;喝药可以在普通移动中排队,但在空中和击飞中直接拒绝。状态机只暴露几个查询:当前能否消费某意图、何时打开取消窗口、是否允许清空旧缓冲。这样的边界会让技能策划也能读懂,不必每次找程序解释某个按键为什么吃掉了另一个按键。
架构图:输入、状态机和动画事件之间的责任
输入缓冲最怕职责混乱。动画事件说可以取消,状态机说还在硬直,技能执行器说冷却好了,UI 又显示按钮亮了。如果没有统一消费点,Bug 会表现得像随机发生。下面这个结构把输入记录、资格判断、动作执行和反馈拆开:
flowchart TD
A["InputEvent 到达"] --> B["InputMapper 归一化"]
B --> C["Buffer 写入动作与时间戳"]
C --> D{"当前状态可消费?"}
D -- "可以" --> E["AbilityExecutor 执行动作"]
D -- "不可以" --> F["等待取消窗口或过期"]
F --> G{"窗口打开?"}
G -- "是" --> E
G -- "否且超时" --> H["丢弃并记录原因"]
E --> I["动画事件与冷却同步"]
在 Godot 里可以让角色节点持有 CombatController,内部组合 InputMapper、InputBuffer、AbilityExecutor。动画播放器只通过事件发出 cancel_window_opened("light_chain_2"),不直接读取输入;状态机只回答 can_consume(intent),不负责取数组头;执行器只处理真正被消费的意图,并把结果通知 UI、音效、特效。这样每次输入只有一个地方决定命运,排查时不会在五个脚本之间来回跳。
GDScript 里的最小可用实现
可以先写一个非常朴素的缓冲队列。注意队列里不要只存 intent,至少要带时间戳和上下文快照。时间戳建议用 Time.get_ticks_msec(),避免受 Engine.time_scale 影响。战斗 HitStop 或慢动作时,输入缓冲多数情况下仍按真实时间过期,否则暂停 200 毫秒可能让玩家的旧输入跨过一大段演出。
示例结构可以是:class_name BufferedIntent,字段包括 intent: StringName、pressed_at: int、source: StringName、target_id: int、context_flags: int。InputBuffer.push(intent) 时根据规则决定是否替换旧输入;InputBuffer.try_consume(controller) 每帧或在取消窗口事件到来时检查队首。过期不是简单 pop_front(),而是要发出 intent_dropped(intent, "expired"),调试层和埋点都能用。真实项目中,很多手感争议就是靠这些 drop reason 解决的。
取消窗口不要只靠动画帧号
在 Godot 里用 AnimationPlayer 的 Call Method Track 标记取消窗口很直观,但不要把所有逻辑绑死在帧号上。帧号会随着动画重定时、混合、根运动调整而变化。更稳的做法是动画负责发出“窗口开始”和“窗口结束”事件,状态机同时检查角色真实条件:是否着地、是否被打断、是否仍持有武器、是否处于服务器禁止操作的短暂阶段。
例如普攻第三段的取消窗口可能从动画 0.42 秒开始,但如果这一帧角色刚被击退,窗口事件仍会触发。状态机必须回答“当前状态已经从 Attack 切到 HitStun,因此拒绝消费”。反过来,某些技能允许在命中确认后提前取消,就可以由命中盒事件打开一个额外窗口,而不必等待固定动画帧。这样的设计能让设计师做更细的手感,也避免程序为了某个 Boss 战临时写特判。
把调试界面做出来,才知道手感哪里坏
输入缓冲没有可视化时,团队只会用主观词描述:“粘”“飘”“不跟手”“误触多”。我建议在开发包里做一个小面板,显示最近 20 次意图、按下时间、消费时间、延迟、拒绝原因、当前状态、窗口名。再配一个角色头顶小标签,取消窗口打开时显示一条细线,窗口关闭时变灰。这个调试层对程序、美术和策划都有用,尤其是联机模式下,能区分本地输入已经消费但服务器回滚,还是输入根本没进入缓冲。
Godot 的 CanvasLayer 很适合做这类工具。为了不影响正式包,可以用项目设置或启动参数控制。日志量也要节制,默认只记录拒绝和异常,开启详细模式才记录所有输入。否则一场 30 分钟测试会写出巨大的日志文件,反而没人愿意打开。
测试清单和常见坑
测试输入缓冲不要只在训练场原地按普攻。至少要覆盖:落地前 100 毫秒按跳跃是否能起跳;翻滚结束前按攻击是否只触发一次;连段中连打是否不会把后续所有攻击排满;打开背包时战斗输入是否被 UI 上下文隔离;手柄断开重连后 source 是否更新;慢动作和 HitStop 中输入是否按真实时间过期;死亡、过场、Boss 转阶段是否清空危险缓冲。
最常见的坑是“消费成功但执行失败”。例如缓冲里有技能意图,窗口打开后被消费,结果执行器发现蓝量不够。此时不能默默丢掉,应该返回 failed_resource_not_enough,UI 轻轻闪一下蓝条,缓冲也应按规则决定是否保留短暂重试。另一个坑是 UI 按钮和物理按键走两套路径,触屏按钮绕过了 InputMapper,导致手柄图标、震动和调试日志都对不上。把所有输入收口到同一个 mapper,前期麻烦一点,后期会省很多解释成本。
落地建议
如果项目已经有一堆角色脚本直接读输入,不必一次重构完。可以先从一个角色或一个核心动作开始,把输入转成 CombatIntent,做一个 200 行以内的缓冲组件,再把拒绝原因打到屏幕上。等团队开始用这套面板讨论问题,再逐步把技能、翻滚、格挡接进来。输入缓冲的目标不是让所有按键都生效,而是让每个生效和不生效都有一致规则。玩家不一定能说出窗口时间,但能感受到客户端是否尊重了他的操作。
一个真实排查案例:翻滚后自动普攻
项目里曾经出现过一个看似很小、但玩家非常敏感的问题:玩家在 Boss 横扫前连续按翻滚和普攻,角色成功翻滚躲过攻击后,会在落地瞬间自动打出一刀。策划最初认为这是“连招手感顺”,但 QA 在高压战斗里反馈它会害玩家,因为玩家翻滚后想调整方向,不想原地出刀。查日志发现,普攻输入在翻滚开始前 30 毫秒进入缓冲,翻滚状态不消费普攻,落地取消窗口打开后它仍未过期,于是被执行器消费。
解决方式不是把所有普攻缓冲缩短。那会破坏正常连段。我们给不同状态增加了 clear_on_enter 和 clear_on_exit 规则:进入 Dodge 时,如果普攻缓冲不是从 Dodge 期间产生,而是 Dodge 前遗留,就标记为 unsafe_before_dodge;Dodge 结束时默认丢弃这类旧普攻,除非玩家在 Dodge 期间重新按过攻击。这样玩家主动翻滚后再按攻击仍然能接刀,翻滚前误留下的攻击不会自动释放。这个例子说明输入缓冲不能只看时间,还要看输入发生时的上下文。
取消优先级表
实际项目里可以给设计师一张表,让他们不用看代码也能调整手感。表里每行是当前状态,每列是输入意图,单元格写 立即消费、进入缓冲、替换旧输入、拒绝并反馈。例如 Idle + LightAttack 是立即消费,AttackRecovery + Dodge 是进入缓冲并高优先级替换,HitStun + Potion 是拒绝并提示,JumpFall + LightAttack 可能是进入空中攻击缓冲。表格旁边再写窗口时间,例如轻攻击 180ms、翻滚 120ms、格挡 90ms、处决 250ms。
这张表最有价值的地方,是把“为什么我按了没反应”变成具体规则讨论。设计师可以说“受击硬直最后 80ms 允许缓冲翻滚”,程序就知道要在 HitStun 状态开放一个 late buffer window;如果测试说“喝药总是误触”,可以把 Potion 的 can_replace 设为 false,要求玩家松手后重新确认。规则表跟随版本进仓库,比口头约定可靠。
和动画树的同步细节
Godot 的 AnimationTree 在混合时可能让某个动画事件触发时间看起来不稳定。比如普攻从跑步过渡进入时,前 0.1 秒被混合,命中帧和取消帧都受到影响。输入缓冲系统不应该读取动画树当前播放名称后自己猜窗口,而是由战斗动作定义给出窗口,并由动画事件确认。动作开始时记录 action_start_msec,窗口可以按动作时间计算;动画事件作为校准,如果事件晚到或没到,调试层报警。
这种双保险对内容量大的项目很有用。美术改动画长度后,如果忘了移动取消事件,程序能从动作定义发现窗口不合理;如果策划改窗口时间但动画事件没跟上,也能在开发包显示“窗口定义和事件差 90ms”。不要等玩家觉得手感变了才回头对帧。
埋点应该记录什么
输入系统的线上埋点要克制,但关键数据值得采样。比如某技能在 10 秒内的按下次数、消费次数、拒绝原因分布、平均缓冲等待时间、过期比例。若某个技能 30% 输入都因为资源不足被拒绝,可能是 UI 提示不够明显;若某段 Boss 战里翻滚缓冲过期比例高,可能是窗口太短或攻击节奏太密。采样时不要上传完整按键序列,也不要记录玩家聊天等无关信息。
最终,输入缓冲不是为了让操作更宽松,而是为了让客户端在玩家意图和角色规则之间做稳定翻译。这个翻译越透明,团队越能调出明确的手感。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。