游戏输入系统与打击感:帧缓冲、输入队列与连招

深入游戏输入系统与打击感:输入采样与帧缓冲、输入队列与缓冲窗口、连招与招式判定、手柄/触屏适配、顿帧与打击反馈,帮助开发者把「手感」做成可调试的系统。

「这游戏手感很烂」是玩家最容易感知、也最难量化的评价。手感的底层其实是一套可以拆解的系统:输入怎么被采样(帧缓冲)、输入怎么被消化(输入队列与缓冲窗口)、连招怎么判定(招式帧表)、不同设备怎么适配(手柄/触屏)、以及命中那一刻的反馈(顿帧、震屏、特效)。任何一个环节的延迟超过玩家预期,「手感」就崩了。本文剥开输入系统与打击感外壳,聚焦五个核心模块:输入系统分层架构、帧缓冲与输入队列、连招与招式判定、手柄与触屏适配、打击感反馈,并给出可调试的输入管线设计。

建议先读 游戏网络同步:延迟补偿与回滚 理解输入在联机下的时序问题;游戏性能剖析与优化 理解输入采样不该成为帧预算的黑洞。

1. 输入系统的分层架构

1.1 输入的三层模型

输入系统三层:
  采样层(Input Sampling):读取物理设备(键盘/鼠标/手柄/触屏)
  抽象层(Input Abstraction):映射成「语义动作」(Jump/Attack/Move)
  消费层(Action Consumption):游戏逻辑消费动作(判定/连招/移动)

  设备    →  语义动作   →  游戏逻辑
  A键  ──┐
  X键  ──┼→  Attack  ──→  招式判定
  触摸 ──┘
好处:换设备不改逻辑,只改映射表
// 语义动作:设备无关的动作枚举
public enum GameAction { Move, Jump, Attack, Dodge, Interact }

// 采样层:把物理按键映射到语义动作
// 手柄 A、键盘空格、触屏按钮都映射到 Jump
public void Bind(GameAction action, params InputBinding[] bindings) { ... }

1.2 输入与逻辑的时序

输入在帧循环里的位置(关键):
  1. 本帧开始:采样输入(记录按下/松开/时长)
  2. 输入事件入队(带时间戳/帧号)
  3. 逻辑更新(FixedUpdate/Update):消费输入
  4. 渲染输出

原则:采样在每帧最前,且「本帧的输入本帧用」
不要在下游逻辑里直接调用 Input.GetKey——那会丢失帧边界
// 帧开头统一采样一次,供本帧逻辑消费
public void OnFrameStart() {
    _frameInput = new FrameInput {
        jumpPressed = Input.GetKeyDown(KeyCode.Space) || gamepad.ButtonA.Pressed,
        moveAxis   = Input.GetAxis("Horizontal"),
    };
}
// 逻辑层只读 _frameInput,不再碰 Input API

2. 帧缓冲与输入队列

2.1 帧缓冲:允许「提前输入」

玩家在招式结束前 0.1s 按了下一招——这个输入不能丢,要缓冲起来等招式结束再消费。

帧缓冲窗口(Buffer Window):
  按下的输入进入队列,保留 N 帧(通常 6~10 帧 @60fps ≈ 0.1~0.16s)
  到窗口过期还没被消费 → 丢弃
作用:提升操作容错,让连招「按了就有响应」
// 输入缓冲:按下即入队,带帧号,过期自动丢弃
public class InputBuffer {
    private readonly Queue<(GameAction action, int frame)> _q = new();
    private int _frame;
    private const int BUFFER_FRAMES = 8;

    public void Push(GameAction a) => _q.Enqueue((a, _frame));
    public bool TryConsume(GameAction a) {
        while (_q.Count > 0) {
            var (act, f) = _q.Peek();
            if (_frame - f > BUFFER_FRAMES) { _q.Dequeue(); continue; } // 过期丢弃
            if (act == a) { _q.Dequeue(); return true; }                // 消费
            // 不匹配 → 检查下一个(但要防「提前消费掉关键输入」)
            return false;
        }
        return false;
    }
}

2.2 输入队列:连招指令的排队

连招的本质是指令序列的匹配:
  玩家输入 → 队列 [Kick, Punch, Kick] → 与招式表匹配 → 触发连招
队列 vs 缓冲:
  缓冲:单一动作的「提前量」
  队列:一串动作的「顺序匹配」
// 连招队列:维护最近 N 个输入,滑动窗口匹配招式
public class ComboInputQueue {
    private readonly Queue<GameAction> _inputs = new();
    private const int MAX_HISTORY = 10;

    public void Enqueue(GameAction a) {
        _inputs.Enqueue(a);
        while (_inputs.Count > MAX_HISTORY) _inputs.Dequeue();
    }

    // 用状态机/前缀匹配在 _inputs 上查找招式起始位置
    public bool TryMatch(ComboMove move, out int consumeCount) {
        // 示例:在历史输入里找 [Punch, Punch, Kick]
        var seq = move.Sequence;
        for (int i = 0; i + seq.Length <= _inputs.Count; i++) {
            var ok = true;
            for (int j = 0; j < seq.Length; j++)
                if (_inputs.ElementAt(i + j) != seq[j]) { ok = false; break; }
            if (ok) { consumeCount = seq.Length; return true; }
        }
        consumeCount = 0;
        return false;
    }
}

2.3 提前量与输入延迟的权衡

缓冲区大小的权衡:
  缓冲太大(>12 帧)→ 输入「粘滞」,招式不跟手
  缓冲太小(<4 帧)→ 输入丢失,连招难按
  建议起步:普通招式 6~8 帧、必杀技 10 帧
手感调试 = 找「玩家预期的提前量」:用录屏 + 日志量每个输入到响应的时间

记忆:帧缓冲是「容错」,输入队列是「顺序」。缓冲管「多早算有效」,队列管「一串输入怎么组成招式」——两者配合才让连招「按了就有、错了不粘」。

3. 连招与招式判定

3.1 招式帧表(Frame Data)

格斗/动作游戏的招数本质是一张帧表:每个招数由「前摇、判定、后摇」三段组成。

招式帧表(以 60FPS 计):
  招式     前摇(帧)  判定(帧)  后摇(帧)  缓冲窗口(帧)
  轻拳       6        3        8        8
  重拳       10       4        12       8
  旋风腿     14       6        18       10
  必杀技     20       8        30       12
帧表是平衡性与手感的「硬数据」,调手感先调帧表
// 帧表驱动的招式状态机
public class MoveState {
    public enum Phase { Startup, Active, Recovery }
    public Phase phase;
    public int phaseFrame;   // 当前阶段第几帧

    public void Tick() {
        phaseFrame++;
        if (phase == Phase.Startup  && phaseFrame >= frameData.startup) { phase = Phase.Active;  phaseFrame = 0; }
        else if (phase == Phase.Active   && phaseFrame >= frameData.active)   { phase = Phase.Recovery; phaseFrame = 0; }
        else if (phase == Phase.Recovery && phaseFrame >= frameData.recovery) { EndMove(); }
    }
}

3.2 判定窗口与命中处理

判定窗口(Active Frames)里的每一帧都要检测命中:
  每帧检测:攻击判定盒 × 受击判定盒 相交
  命中后只结算一次(避免同一次挥击多次命中)
  命中瞬间触发:伤害、顿帧、震屏、音效、受击动画
// 判定盒相交检测(简化 AABB)
public bool Overlap(HitBox a, HurtBox b) {
    return a.min.x <= b.max.x && a.max.x >= b.min.x
        && a.min.y <= b.max.y && a.max.y >= b.min.y;
}

// 命中只结算一次:用 hitSet 记录本招式已命中的实体
if (Overlap(move.HitBox, target.HurtBox) && !move.hitSet.Contains(target)) {
    move.hitSet.Add(target);
    target.TakeDamage(ComputeDamage());   // 触发顿帧/震屏/受击反馈
}

3.3 连招确认与取消

连招的两种进阶手感:
  确认(Confirm):普通技命中才接必杀(用命中判定作为连招前提)
  取消(Cancel):后摇中提前消费缓冲输入,缩短硬直
判定时机:
  命中确认窗口通常在「判定帧的最后一帧」
  取消窗口在「后摇前 1/3 段」

记忆:手感是帧表 + 缓冲 + 取消的综合。玩家感知的「跟不跟手」= 输入→响应总延迟,把帧表、缓冲、判定三处延迟画在一张时间轴上,一眼看出瓶颈。

4. 手柄与触屏适配

4.1 手柄输入与震动

// 手柄:轴输入(摇杆)需要死区处理,按钮需要按下/按住/松开区分
public class GamepadInput {
    public const float DEAD_ZONE = 0.15f;   // 摇杆死区,防漂移

    public Vector2 MoveAxis {
        get {
            var raw = new Vector2(
                Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
            return raw.magnitude < DEAD_ZONE ? Vector2.zero
                 : raw.normalized * ((raw.magnitude - DEAD_ZONE) / (1f - DEAD_ZONE));
        }
    }
}
手柄适配清单:
  ├── 死区:轴输入小于死区置零(线性死区 + 轴向死区)
  ├── 震动:打击反馈用短震动(30~50ms),大伤害用强震
  ├── 按键重映射:允许玩家改键(无障碍需求)
  └── 触发键程:扳机键的模拟量(油门/蓄力)

4.2 触屏虚拟摇杆与按钮

触屏输入的核心问题:没有物理反馈、手指遮挡画面
  ├── 虚拟摇杆:起始点即摇杆中心,滑出区域保持方向
  ├── 按钮:命中面积要大,误触要少(热区 > 视觉尺寸)
  ├── 多点触控:移动 + 攻击 + 闪避同时进行
  └── 视角操作:滑动屏幕转视角,需与按钮区分离
// 虚拟摇杆:按下即定中心,拖动给方向
public class VirtualJoystick {
    private Vector2 _center;
    private float _radius = 60f;
    public Vector2 Direction { get; private set; }

    public void OnTouchDown(Vector2 p) { _center = p; }
    public void OnTouchDrag(Vector2 p) {
        var delta = p - _center;
        Direction = Vector2.ClampMagnitude(delta, _radius) / _radius;
    }
    public void OnTouchUp() { Direction = Vector2.zero; }
}

4.3 多设备输入归一化

多设备手感目标:同一套帧表,所有设备「感觉一致」
  手柄:输入延迟低(硬件直连),响应最跟手
  触屏:多点触控 + 虚拟摇杆,需要输入平滑
  键鼠:高 DPI 下鼠标灵敏度要可调
归一化手段:
  统一走「语义动作」层,设备差异只在映射表与平滑参数

5. 打击感:顿帧、震屏与反馈

5.1 顿帧(Hit Stop)

顿帧是打击感最核心的一招:命中瞬间让整个画面停几帧,把「击中的重量感」放大。

顿帧设计:
  时长:2~5 帧(@60fps),轻击 2、重击 4、必杀 5
  范围:全局顿帧(画面全停)或局部(只停受击对象)
  注意:顿帧期间输入仍要采样(否则连招手感断裂)
  与音频:顿帧常配「命中音效短暂静音 + 冲击音」,强化冲击
// 顿帧:全局时间暂停,但输入仍采样、缓冲仍走帧号
public class HitStop {
    public int remaining;          // 剩余顿帧数
    public void Trigger(int frames) { remaining = Math.Max(remaining, frames); }
    public void Tick() {
        if (remaining > 0) remaining--;
        // 逻辑用 remaining>0 时跳过常规更新,但输入采样照旧
    }
}

5.2 震屏、特效与音效

命中反馈四件套(打击感的 4 层):
  顿帧:画面暂停,放大冲击
  震屏:相机/世界震动(位移量随伤害衰减)
  特效:命中闪光、火花、受击闪白、飞溅
  音效:命中音 + 受击音 + 环境音(合成打击感)
// 受击反馈:一次性触发多路反馈
public void OnHitConfirmed(float dmg) {
    hitStop.Trigger(dmg > threshold ? 4 : 2);   // 顿帧
    cameraShake.AddTrauma(Mathf.Clamp(dmg / 100f, 0f, 1f)); // 震屏
    fxPlayer.Spawn("hit_spark", hitPoint);      // 特效
    audioPlayer.Play("hit_impact", dmg);        // 音效
}

5.3 打击感的可调试化

把打击感做成「参数化 + 可回放」:
  ├── 所有反馈参数进配置表(顿帧/震屏幅度/特效/音效)
  ├── 输入回放:录制输入序列,重放对比反馈差异
  └── 帧日志:命中帧、顿帧起止、响应延迟落盘
调试手段:两台设备对比、慢放帧日志、AB 两组参数盲测

记忆:打击感是「延迟控制 + 反馈叠加」。输入→响应总延迟压到玩家感知阈值内(约 100ms 内),再用顿帧/震屏/特效把「击中」放慢放大——手感就从「玄学」变成「参数」。

6. 最佳实践与总结

输入系统与打击感落地清单:

  1. 三层解耦:采样层 → 语义动作 → 逻辑消费,换设备不改逻辑。
  2. 帧缓冲容错:普通招式 6~8 帧缓冲,连招输入不丢、不粘。
  3. 输入队列做连招:滑动窗口匹配招式序列,命中确认 + 取消。
  4. 帧表是硬数据:前摇/判定/后摇先定帧表,再谈手感。
  5. 多设备归一:语义动作统一,手柄死区、触屏热区、键鼠灵敏度各自调。
  6. 打击感参数化:顿帧/震屏/特效/音效全部进配置,可回放可盲测。

最小输入管线推荐建设顺序:语义动作层 → 帧采样 → 输入缓冲 → 招式帧表 → 判定命中 → 反馈四件套。每完成一层,就用一个「录手柄按键打木桩」的 demo 验证输入到响应的延迟在预算内。

手感没有银弹:Unity 的 Input System 现成、自研输入管线可控,但帧缓冲、输入队列、帧表、打击反馈这四件事不分引擎必须做对——它们决定玩家按下去的 100ms 内,游戏给不给得出「对的反馈」。

相关阅读:游戏网络同步:延迟补偿与回滚 讲解输入在联机下的延迟补偿;游戏性能剖析与优化 讲解输入采样与帧预算。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 游戏社区运营与 LiveOps:活动策划、版本节奏与社区治理
  2. 游戏数据埋点与分析:事件埋点、漏斗、留存与 AB 测试
  3. 游戏经济平衡设计:经济建模、通胀控制与数值调优