「这游戏手感很烂」是玩家最容易感知、也最难量化的评价。手感的底层其实是一套可以拆解的系统:输入怎么被采样(帧缓冲)、输入怎么被消化(输入队列与缓冲窗口)、连招怎么判定(招式帧表)、不同设备怎么适配(手柄/触屏)、以及命中那一刻的反馈(顿帧、震屏、特效)。任何一个环节的延迟超过玩家预期,「手感」就崩了。本文剥开输入系统与打击感外壳,聚焦五个核心模块:输入系统分层架构、帧缓冲与输入队列、连招与招式判定、手柄与触屏适配、打击感反馈,并给出可调试的输入管线设计。
建议先读 游戏网络同步:延迟补偿与回滚 理解输入在联机下的时序问题;游戏性能剖析与优化 理解输入采样不该成为帧预算的黑洞。
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. 最佳实践与总结
输入系统与打击感落地清单:
- 三层解耦:采样层 → 语义动作 → 逻辑消费,换设备不改逻辑。
- 帧缓冲容错:普通招式 6~8 帧缓冲,连招输入不丢、不粘。
- 输入队列做连招:滑动窗口匹配招式序列,命中确认 + 取消。
- 帧表是硬数据:前摇/判定/后摇先定帧表,再谈手感。
- 多设备归一:语义动作统一,手柄死区、触屏热区、键鼠灵敏度各自调。
- 打击感参数化:顿帧/震屏/特效/音效全部进配置,可回放可盲测。
最小输入管线推荐建设顺序:语义动作层 → 帧采样 → 输入缓冲 → 招式帧表 → 判定命中 → 反馈四件套。每完成一层,就用一个「录手柄按键打木桩」的 demo 验证输入到响应的延迟在预算内。
手感没有银弹:Unity 的 Input System 现成、自研输入管线可控,但帧缓冲、输入队列、帧表、打击反馈这四件事不分引擎必须做对——它们决定玩家按下去的 100ms 内,游戏给不给得出「对的反馈」。
相关阅读:游戏网络同步:延迟补偿与回滚 讲解输入在联机下的延迟补偿;游戏性能剖析与优化 讲解输入采样与帧预算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。