第 35、47、53 天我们反复谈「游戏手感(Game Feel)」,但有一个比震屏、顿帧更底层、也更常被新手忽略的问题:玩家按了,游戏却没反应。跳跃落地前按跳没起跳、攻击后摇里按下一刀没接上、贴墙时按冲刺被吞……这些「吃输入」的瞬间,是差手感的头号元凶,比任何特效都更劝退。今天讲两个能直接「救回来」的工程手段:输入缓冲(Input Buffer)和连招系统(Combo System)——它们让游戏「记住」玩家刚刚的意图,在条件一满足的瞬间立刻兑现。
今日速览
- 每个动作都有三段窗口:启动(Startup,前摇)/ 生效(Active,判定)/ 收招(Recovery,后摇)。玩家觉得「没反应」,几乎都是因为动作处在后摇里、代码又在后摇里拒绝一切新输入。
- 输入缓冲 = 把「刚按下」存进一个小队列,保留 100–200ms;条件一旦满足就「补执行」,过期才丢弃。经典应用:落地前按跳,落地瞬间自动起跳。
- 土狼时间(Coyote Time)= 缓冲的镜像:离开平台边缘后仍给 80–120ms 的起跳宽限,专治「我明明在边缘上按了跳」。
- 连招 = 取消窗口 + 连招索引:当前段动作进入「可取消帧区间」时,若缓冲里有攻击输入,就切到下一段;超时没接上就把索引重置回第一段。
- 只缓冲「按下边沿」,不缓冲「按住」,且消费一次即删除——否则一次按键会触发两次攻击,手感反而更乱。
核心学习内容
1. 先认清「三种窗口」,才知道该在哪里开口子
任何动作(跳跃、攻击、冲刺、翻滚)在时间轴上都能切成三段:
| 窗口 | 含义 | 玩家感受 |
|---|---|---|
| Startup 启动/前摇 | 按下到判定生效之间 | 动作「重不重」 |
| Active 生效/判定 | 真正产生效果 | 命中窗口 |
| Recovery 收招/后摇 | 生效结束到可自由行动 | 动作「顺不顺」 |
新手常见错误:在 Recovery 里 return 掉所有输入,于是「攻击后立刻按跳」被吞、连招接不上。正确做法不是取消后摇(那会让动作失去重量),而是把这段时间里按下的输入存起来,等后摇一结束立刻放行——这正是输入缓冲要干的事。
两类最常用的宽限,方向正好相反:
- 输入缓冲(未来补偿):玩家提前按了 → 存住,等条件成熟再执行。(落地前按跳)
- 土狼时间(过去补偿):玩家稍晚按了 → 仍承认,条件是刚从可执行状态离开没多久。(跳出平台边缘后再按跳)
游戏手感好的作品,几乎都同时做了这两件事。
2. 输入缓冲:一个会「自动过期」的小字典
缓冲的数据结构极简:动作名 → 剩余有效毫秒。每帧把「刚按下(is_action_just_pressed)」的动作写入(或刷新)一个倒计时;每帧再统一递减、把归零的清掉;业务代码不去读「按键」,而是问缓冲「最近有没有这个意图?」——有就消费并删除它。
# input_buffer.gd —— 可复用输入缓冲(挂成 Autoload 或角色子节点都行)
extends Node
@export var watched_actions: Array[StringName] = [&"jump", &"attack", &"dash"]
@export var window_ms := 150.0 # 宽限时长,见下方调参表
var _buffer := {} # { action: 剩余毫秒 }
func _physics_process(delta: float) -> void:
# 1) 记录"刚按下"的边沿(不要记录"按住")
for a in watched_actions:
if Input.is_action_just_pressed(a):
_buffer[a] = window_ms
# 2) 统一倒计时,过期的丢弃
for a in _buffer.keys():
_buffer[a] -= delta * 1000.0
if _buffer[a] <= 0.0:
_buffer.erase(a)
# 3) 尝试"消费"一个意图:命中即返回 true 并删除(关键:消费即清)
func consume(action: StringName) -> bool:
if _buffer.get(action, 0.0) > 0.0:
_buffer.erase(action)
return true
return false
角色侧就变得极干净:只顾「现在能不能做」,能不能做交给条件,想做交给缓冲。
func _physics_process(delta: float) -> void:
if is_on_floor() and buffer.consume(&"jump"):
velocity.y = JUMP_VELOCITY
# 土狼时间:离开地面 100ms 内仍允许起跳
if not is_on_floor() and coyote_timer > 0.0 and buffer.consume(&"jump"):
velocity.y = JUMP_VELOCITY
调参参考(60fps,1 帧 ≈ 16.7ms):跳跃缓冲 100–150ms,土狼时间 80–120ms,攻击连招衔接 120–200ms。超过 250ms 会变成「自动帮你出招」,玩家会觉得角色不听话,宁可短一点。
一个容易踩的坑:边沿检测只在一个地方判。is_action_just_pressed 在 _process 和 _physics_process 里都可能读到同一次按下,两处都判会让一次按键被消费两次。游戏逻辑统一放 _physics_process(固定步长),一次按下只入队一次。
3. 连招系统:用「取消窗口 + 连招索引」把平 A 连起来
连招的本质是在时间窗口内按顺序匹配一串输入。把每段攻击定义成一条数据:
const COMBO := [
{ "anim": "attack_1", "damage": 10, "cancel_from": 0.35, "cancel_to": 0.60 },
{ "anim": "attack_2", "damage": 12, "cancel_from": 0.30, "cancel_to": 0.55 },
{ "anim": "attack_3", "damage": 20, "cancel_from": 0.45, "cancel_to": 1.00 }, # 收尾大
]
cancel_from/cancel_to 就是取消窗口:只有当前动画播到这段区间时,才允许被「下一段」取消。逻辑只有三步:
func _try_chain() -> void:
var seg := COMBO[combo_index]
var in_window := _t >= seg.cancel_from and _t <= seg.cancel_to
var has_next := combo_index + 1 < COMBO.size()
if in_window and has_next and buffer.consume(&"attack"):
_start_attack(combo_index + 1) # 消费掉缓冲,切下一段
三个细节决定手感:
- 取消窗口要开在收招早期,让「连得顺」和「动作有重量」兼得;太早取消等于没有后摇,太晚则接不上。
- 攻击中途的自由移动:很多游戏允许在生效帧前后小幅位移(如向前滑步),用
velocity.x直接给,别让玩家觉得「一刀定身」。 - 超时重置:
animation_finished时把combo_index = -1,下次重新从第一段起手;也可以加一个「连招保持窗口」让短暂停顿后仍是第二段。
Godot 里动画播放用 AnimationPlayer.play() 或 AnimationTree 的 travel();travel() 会自动在状态间做插值过渡,做连招很顺手。判定框(Hitbox)用一个带碰撞层的 Area2D,在 Active 帧开关 monitoring,命中后加一个命中暂停(Hitstop)——第 47 天的「停顿·震屏·形变·反馈」正好接在这里。
🎯 DeepSeek 提示词
① 让 DeepSeek 写一个可直接复用的 InputBuffer 类
你是 Godot 4 + GDScript 专家。请写一个通用、可复用的「输入缓冲(Input Buffer)」节点脚本。
【目标】把玩家"刚刚按下"的动作存起来,在一定时间窗内供游戏逻辑稍后消费,解决"落地前按跳没反应 / 后摇里按下一刀被吞"的问题。
【功能要求】
1. 可配置监听的动作列表(StringName 数组)与宽限时长 window_ms。
2. 在 _physics_process 里用 Input.is_action_just_pressed 记录"按下边沿"(只记按下,不记按住)。
3. 每帧倒计时递减,过期的自动清除。
4. 提供 consume(action) -> bool:命中则返回 true 并立即删除该缓冲(消费即清,避免一次按键触发两次)。
5. 提供 has(action) -> bool 只查询不消费。
6. 用中文注释写清"为什么只缓冲按下边沿""为什么消费即清"。
【输出格式】一个完整 .gd 文件,含 extends 声明与所有方法;随后给一段"角色如何用它做跳跃缓冲 + 土狼时间"的示例 _physics_process。
【约束】Godot 4 稳定版 API;不依赖任何插件;纯 GDScript;不要使用 _process。
② 让 DeepSeek 生成一套「三连击 + 取消窗口」连招系统
你是动作游戏程序员,精通 Godot 4。请为 2D 近战角色设计并实现一套「三连击」连招系统。
【核心概念】每段攻击分 启动/生效/收招 三个窗口;"取消窗口(cancel window)"指当前段收招早期允许被下一段取消的帧区间;连招用"连招索引 combo_index"推进,超时未衔接则重置回第一段。
【我的需求】
- 三段连击:attack_1(轻,10伤) → attack_2(中,12伤) → attack_3(重,20伤,收尾)。
- 每段可单独配置动画名、伤害、取消窗口的起止时间(秒)。
- 连招衔接必须走"输入缓冲",而不是直接读按键。
- 命中时播放顿帧(约 60ms)与轻微屏幕震动(只需调用占位函数,如 hitstop(0.06)、Camera.add_trauma(0.3))。
【输出格式】
1. 用 Dictionary 数组定义连招数据表(含 anim/damage/cancel_from/cancel_to)。
2. 完整的 CharacterBody2D 脚本:_physics_process、_start_attack、_try_chain、_on_animation_finished。
3. 说明在 Godot 里用 AnimationPlayer 还是 AnimationTree.travel() 更合适,为什么。
【约束】Godot 4 稳定版 API;代码带中文注释;不引入第三方插件;把"取消窗口"作为可调参数暴露出来,方便我 30 秒内改手感。
③ 让 DeepSeek 当「输入手感排错员」
你是动作游戏手感调试专家。请根据我的现象定位"输入被吃掉 / 乱出招"的根因,并给最小修复。
【常见根因参考】① 边沿检测放在了 _process 和 _physics_process 两处,导致一次按下被消费两次;② 缓冲里存了"按住"而非"按下边沿";③ 消费后没删除,同一次输入触发了两个动作;④ 宽限时长过大(>250ms),变成自动出招;⑤ 在收招(Recovery)里直接 return,没给缓冲开口子。
【我的现象】(例:连点攻击会一次性打完三下 / 落地后自动跳一下 / 冲刺偶尔没反应)
【我的相关代码】(在此粘贴 InputBuffer 与角色 _physics_process)
【输出格式】
1. 按可能性排序列出 1–3 个根因,各写明"哪一行、为什么"。
2. 每个根因给一段最小修改后的代码。
3. 给出建议的缓冲/土狼时间数值(毫秒),并说明取舍。
【约束】只基于我给的信息推断;不确定时明确还需要我提供哪段代码或哪个数值。
学习心得
「输入缓冲」是一个性价比极高的功能:改动量极小(几十行),却会让整套动作系统的体感上一个台阶。它不改变游戏规则,只是让游戏更懂玩家——而玩家对「你懂我」的感知,往往比对精美素材更强烈。对个人开发者来说,这是花小钱办大事的典型。
结合「用 DeepSeek 做一款 Steam 游戏」的目标,输入系统是最适合交给 AI 的模块:它的逻辑边界极清晰(边沿、时间窗、消费、重置),有明确的对错,非常适合用提示词描述。今天三条提示词形成一个闭环——① 造积木(InputBuffer)→ ② 组装(连招系统)→ ③ 排错(手感诊断)。先用 ① 做出通用缓冲,再让 ② 把它接进连招,遇到”按了没反应/乱出招”就用 ③ 让 DeepSeek 反查,基本能覆盖输入手感 80% 的问题。
明日关注
- 把 ① 的 InputBuffer 挂到现有角色上,先给「跳跃」加 120ms 缓冲 + 100ms 土狼时间,落地/出边缘各测一次。
- 用 ② 做一套三连击,把
cancel_from/cancel_to调三遍,感受「接得上」和「动作有重量」的平衡点。 - 给连招命中接上第 47 天的顿帧 + 震屏,录一段 5 秒视频对比「有/无输入缓冲」的手感差异。
本文由自动化学习流水线每日生成,坚持即学即记。
评论