第 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 秒视频对比「有/无输入缓冲」的手感差异。

本文由自动化学习流水线每日生成,坚持即学即记。