第05章 · Enhanced Input:从按键到动作语义

📑 目录

一个运营两年的老项目,需求只有四个字:加手柄支持。我打开 Project Settings 的 Input 页,Action Mapping 和 Axis Mapping 一百多条挤在一起,"Jump" 右边焊着 Space Bar,蓝图里还散落着几十个直接读键盘的散兵游勇。评估完改动量,我报了三周。同样的需求放到用 Enhanced Input 的新项目上,答案是半天——键位从代码里搬出来,变成了可以增删的资产。三十天和半天的差距,就是本章要讲的解耦。

本章地图

mindmap
  root((Enhanced Input
从按键到动作语义)) 为什么换代 老 Action Mapping 焊死设备与意图 四个真实的痛 三件套 Input Action 动作语义 Mapping Context 情境键位表 Modifier 值的加工管道 Trigger 状态机 None Ongoing Triggered 三态 五个对外事件 长按蓄力时序解剖 组合键与双击 多设备统一 键鼠 手柄 触屏一份回调 运行时改键 感知延迟公式 回响峡谷实操 探索 战斗 菜单三套情境 优先级压栈 补课·手感设计 输入缓冲 土狼时间

老 Action Mapping 输在哪:设备和意图焊死了

UE4 时代的输入系统只有一张表:Action Mapping 管离散按键,Axis Mapping 管连续轴。一行配置 = 一个动作名 + 一个具体键位 + 一个缩放系数。Jump = Space BarMoveForward = W, Scale 1.0MoveForward = S, Scale -1.0。写起来直观,跑起来也直观,直到项目活过第一年。

这套设计的根本问题,是把设备(哪个物理键)和意图(玩家想干什么)焊死在同一行里。焊死的代价会在四个时刻找上门。

第一个时刻是加设备。手柄的跳跃是 Gamepad Face Button Bottom,得再加一行映射;触屏没有物理键,老系统直接抓瞎,项目组只能手写一套虚拟按键层,再把虚拟键"翻译"回键盘事件骗过输入系统——我在两个项目上见过这种套娃,层一层地糊。

第二个时刻是做改键功能。键位写在 DefaultInput.ini 里,运行时改键要自己读配置、写配置、做 UI、处理冲突。很多小团队算完工期直接放弃,于是玩家社区里"不能改键"常年挂在差评理由前三。

第三个时刻是语义泄漏。蓝图里 Is Key Down (E) 遍地都是,"E 是交互"这条知识分散在四十个蓝图里。策划说交互键改成 F,程序得考古一周,还不保证找全。

第四个时刻是情境切换。菜单打开了,WASD 照样驱动角色在背景里跑路;瞄准状态下移动该变慢,得在每个输入回调里手动 if。老系统没有"当前处于什么情境"的概念,状态全散在业务代码里。

用程序员熟悉的概念类比:老 Action Mapping 像把 IP 地址硬编码进源码,Enhanced Input 则是 DNS 加配置中心——名字解析独立成层,后端换机器,调用方无感。

维度老 Action MappingEnhanced Input
键与动作的关系一行配置焊死资产间引用,随时换绑
多设备支持每个设备平行加映射同一 Action 挂任意多行键位
运行时改键自己造轮子引擎内置键位重绑定机制
情境切换回调里手动 ifMapping Context 优先级栈
输入值加工裸值直送回调Modifier 管道逐级变换
触发时机Pressed/Released 两种完整 Trigger 状态机

Enhanced Input 在 UE5 早期作为插件转正,到 5.8 已是默认输入方案,新模板工程全部自带,老系统只留作兼容。没理由再开历史的倒车。

顺带把迁移的账也算了:老项目迁 Enhanced Input,真正的成本不在建资产——Action 和 IMC 对着旧映射表抄,一个下午能抄完——而在清理语义泄漏。每个 Is Key Down 节点都要追到业务含义,替换成对应 Action,这一步省不掉。可行的节奏是按情境分批迁:先迁移动和跳跃(收益最大、风险最小),稳定一周再迁战斗,UI 输入最后动。一次全迁的团队我见过两家,都滚回了旧系统,测试覆盖跟不上才是真凶。

换代后的完整链路长这样,后面三节逐个拆开:

flowchart LR
    subgraph HW[硬件层]
        KB[键盘 / 鼠标]
        GP[手柄]
        TS[触摸屏]
    end
    HW --> PI[UPlayerInput
原始键值采集] PI --> LU{激活的 Mapping Context 栈
按优先级查键位映射} subgraph PER[每个 Input Action 运行时实例] MOD[Modifier 管道
死区 → 缩放 → 取反] TRG[Trigger 评估
状态机推进] MOD --> TRG end LU --> MOD TRG --> EVT[ETriggerEvent 派发
Started / Ongoing / Triggered
Canceled / Completed] EVT --> CB[游戏回调
Move / Fire / Aim]

三件套各管一件事:动作、情境、值的加工

Enhanced Input 的解耦靠三个资产类型落地。记住一句话分工:Input Action 管"做什么",Mapping Context 管"按哪个键",Modifier 和 Trigger 管"值怎么加工、何时算触发"。

Input Action(UInputAction)是一个纯语义资产,名字叫"向前移动""开火",它自己不知道任何键的存在。它身上最重要的属性是 ValueType:Boolean 给跳跃这类离散动作,Axis1D 给油门这类单轴,Axis2D 给移动和视角,Axis3D 极少用,留给需要三维输入的特殊玩法。它像接口定义:只声明签名,不关心实现。

Input Mapping Context(UInputMappingContext,下称 IMC)是一张"键 → Action"映射表,每一行写着:哪个键、触发哪个 Action、挂哪些 Modifier 和 Trigger。一个工程可以有任意多张表,运行时通过 UEnhancedInputLocalPlayerSubsystem::AddMappingContext(Context, Priority) 压进栈里,优先级高的先匹配。瞄准状态压一张"战斗表",打开背包压一张"菜单表",关掉就弹栈——情境切换从 if 森林变成了栈操作。

Modifier(UInputModifier)是输入值的加工厂。原始值从键位映射里出来,先流过一条 Modifier 管道,逐级变换后才交给 Trigger 评估。内置的够用:DeadZone 治摇杆漂移,Scalar 调灵敏度,Negate 反转轴,ResponseCurveExponential 做手柄的非线性转向(轻推微调准星、推满快速转身,一条曲线两种性格),Smooth 给粗糙的输入源做平滑。也可以继承 UInputModifier 写自定义的,重写一个 ModifyRaw 就行——我们给「回响峡谷」写过一个"瞄准减速"Modifier,开镜状态把视角输入乘以 0.4,三行代码,策划在资产里调系数。

管道顺序是要负责的:先 DeadZone 再 Scalar 和先 Scalar 再 DeadZone,结果不同——后者会把死区阈值也放大,轻推摇杆的可用行程白白缩水。编辑器里可以拖拽调整 Modifier 的排列,调错顺序的手感问题往往被误报成"摇杆不灵敏",排查时先看管道再看硬件。

classDiagram
    class UInputAction {
        +ValueType: EInputActionValueType
        +bConsumeInput: bool
        +Triggers: UInputTrigger[]
        +Modifiers: UInputModifier[]
    }
    class UInputMappingContext {
        +Mappings: FEnhancedActionKeyMapping[]
    }
    class FEnhancedActionKeyMapping {
        +Key: FKey
        +Triggers: UInputTrigger[]
        +Modifiers: UInputModifier[]
    }
    class UInputModifier {
        <>
        +ModifyRaw() FInputActionValue
    }
    class UInputTrigger {
        <>
        +UpdateState() ETriggerState
    }
    class UEnhancedInputComponent {
        +BindAction(Action, Event, Obj, Func)
    }
    class UEnhancedInputLocalPlayerSubsystem {
        +AddMappingContext(Ctx, Priority)
        +RemoveMappingContext(Ctx)
    }
    UInputMappingContext "1" *-- "n" FEnhancedActionKeyMapping
    FEnhancedActionKeyMapping --> UInputAction : 引用
    FEnhancedActionKeyMapping --> UInputModifier
    FEnhancedActionKeyMapping --> UInputTrigger
    UInputAction --> UInputModifier
    UInputAction --> UInputTrigger
    UEnhancedInputComponent ..> UInputAction : BindAction
    UEnhancedInputLocalPlayerSubsystem ..> UInputMappingContext : 压栈 / 出栈

图里有个细节值得停下来看:Modifier 和 Trigger 能挂在两个地方——IMC 的映射行上,或 Action 自己身上。挂在映射行上,加工只对这个键生效;挂在 Action 上,对所有来源生效。经验法则:设备差异放映射行(手柄摇杆的死区、鼠标灵敏度),语义规则放 Action(蓄力时长、连发间隔)。放反了的典型症状是:给 Action 挂了死区,结果键盘 WASD 也被削掉一小块输入。

Modifier 管道是连在一起的,顺序就是资产里的排列顺序。下图剖开一条真实管道——手柄左摇杆的 X 值依次经过 DeadZone、Scalar、Negate 三个 Modifier:

Modifier 管道剖面:摇杆 X 值的三步变身(DeadZone → Scalar → Negate)原始输入摇杆左推 35%(带漂移)-0.350① DeadZone阈值 0.2,区外归一重映射-0.188② Scalar× 2.0 提灵敏度-0.375③ Negate(X)X 轴取反修正方向+0.375-1-0.50+0.5+1死区 ±0.2:范围内输出归零① 原始 -0.350② 死区后 -0.188③ 缩放后 -0.375④ 取反后 +0.375 → 送入游戏

同一物理量,语义在管道里逐步成形;键盘 W 走另一行映射(bool → (0,1) 向量),殊途同归

注意 ① 到 ② 的那一步:DeadZone 不只是"小于阈值归零",区外的值还会做归一化重映射——(0.35 − 0.2) / (1 − 0.2) ≈ 0.188。没有这个重映射,摇杆推过死区的瞬间输入会从 0 直接跳到 0.2,手感上是一个突兀的台阶。这个细节在老系统里得自己写代码处理,现在是资产里勾一个阈值的事。

Trigger 是状态机,别拿 if 来想

老系统对"按键时机"的全部理解是两个事件:Pressed 和 Released。想区分点按与长按?自己开定时器。想做连发?Tick 里手写间隔判断。每个项目都把这些轮子重造一遍,造得各有脾气。

Enhanced Input 把时机判断收编进 Trigger(UInputTrigger):每一行键位映射背后都跑着一台小型状态机,输入值流进来,状态机逐步推进,条件满足时对外发事件。这是声明式的——你在资产里写"按住 0.8 秒算蓄满",引擎替你看表。

状态机内部只有三态(ETriggerState):None(无输入)、Ongoing(输入作用中,条件未达成)、Triggered(条件达成)。对外派发的事件是五种(ETriggerEvent),BindAction 时挑你关心的接:

事件何时派发典型用途
Started输入开始作用的这一帧开蓄力条、播起手动画
Ongoing条件未达成期间,每帧更新进度、累积计时
TriggeredTrigger 条件达成时绝大多数动作的执行入口
Canceled条件未达成就中断蓄力失败的兜底结算
Completed条件达成之后输入结束蓄满松手的结算
stateDiagram-v2
    [*] --> None : 无输入
    None --> Ongoing : 输入开始作用
派发 Started Ongoing --> Ongoing : 每帧评估,条件未达成
派发 Ongoing Ongoing --> Triggered : Trigger 条件达成
派发 Triggered Ongoing --> None : 输入中断(条件未达成)
派发 Canceled Triggered --> None : 输入结束
派发 Completed Triggered --> Triggered : 非 one-shot 时按住期间每帧
派发 Triggered

光看状态迁移图不过瘾,拿一个真实例子把时序钉死。「回响峡谷」里主角有一把猎弓:按住右键蓄力,0.8 秒蓄满,蓄满后松开射满蓄箭;没蓄满就松手,射一支伤害打折的弱箭。给 IA_AimCharge 挂上 Hold Trigger(HoldTimeThreshold = 0.8,one-shot),60fps 下整段旅程是这样的:

sequenceDiagram
    autonumber
    participant P as 玩家 / 鼠标
    participant EI as "Trigger 状态机
Hold 0.8s, one-shot" participant C as 角色逻辑 participant FX as 表现层 UI / 音效 P->>EI: f0 按下右键 EI->>C: Started C->>FX: 播拉弓动画,蓄力条出现 loop f1 – f47 每帧 EI->>C: Ongoing C->>FX: 蓄力条进度 = t / 0.8 end EI->>C: f48 累计 0.8s → Triggered C->>FX: 满蓄音效 + 弓弦光效 P->>EI: f60 松开右键 EI->>C: Completed C->>C: 生成满蓄箭矢,伤害 ×1.0 Note over P,EI: 分支:f20 就松开(不到 0.8s) EI->>C: Canceled C->>C: 生成弱箭,伤害 × t/0.8

三个细节决定这套实现好不好用。一,StartedTriggered 分开,起手表现和蓄满判定互不干扰——动画在按下瞬间就播,不用等 0.8 秒。二,CanceledCompleted 分开,"提前松手"和"蓄满松手"是两条结算路径,策划改规则(比如提前松手不射箭而是取消)只动一个槽位。三,时序逻辑全在资产里,角色代码只剩五个小函数,每个不超过三行。

# IA_AimCharge 资产配置(声明式,编辑器里勾选,非运行时代码)
value_type = Boolean
triggers   = [ InputTriggerHold(HoldTimeThreshold=0.8, bIsOneShot=True) ]

# 角色侧的五个事件槽(伪代码,BindAction 接到对应 ETriggerEvent)
def on_started():    charge_t = 0;  show_charge_ui(); play_anim("bow_draw")
def on_ongoing(dt):  charge_t += dt; ui.progress = charge_t / 0.8
def on_triggered():  full = True;   play_sound("charge_full")
def on_canceled():   fire_arrow(power = charge_t / 0.8)   # 提前松手
def on_completed():  fire_arrow(power = 1.0)              # 蓄满松手

对照老系统的写法:Pressed 里记时间戳、Released 里算差值、Tick 里更新蓄力条,三个地方维护同一份隐式状态,改需求必出 bug。Trigger 把这份状态显式化了——这就是"状态机不是 if"的实际含义。

有人担心每条映射都跑状态机会不会拖性能。真相是:Trigger 只在对应键有输入作用时才被评估,静止状态下整台状态机睡眠,开销约等于零;就算几十个映射同时活跃,评估逻辑也只是几次浮点比较。输入系统从来不是帧时间的大头,把心放回肚子里。

组合键、长按、双击:时序写进资产,别写进 Tick

内置 Trigger 覆盖了九成以上的输入花样,先把速查表放这:

Trigger触发条件典型用途
Down值过阈值即触发,按住期间持续移动、视角(最常用)
Pressed / Released按下瞬间 / 松开瞬间跳跃、松手结算
Hold按住超阈值后触发蓄力、长按交互
HoldAndRelease按住超时后,松开那一刻触发长按开门、持续施法
Tap阈值时间内按下并松开区分点按与长按
Pulse按住期间按固定间隔连发机枪扫射、连续拾取
ChordedAction指定的另一 Action 处于触发态才评估Shift+方向冲刺、瞄准+射击
Combo按序匹配一串键输入双击方向闪避、连招

组合键用 ChordedAction。「回响峡谷」的冲刺是"按住 Shift + 方向键":给 IA_Sprint 的映射行挂 ChordedAction,Chord 目标指向 IA_Move。只有 IA_Move 处于触发态时,IA_Sprint 的评估才放行——单独按 Shift 不会原地启动。这条规则写在资产里,而不是 if (bShiftDown && bMoving) 写在 Tick 里。

双击用 Combo:把"同一方向键在 0.3 秒内两次 Pressed"配成一个序列,触发 IA_Dodge。手柄上同一个 IA_Dodge 再配一行 B 键双击,两个设备共享一份闪避逻辑。

长按类需求有个高频选型纠结:Hold 还是 HoldAndRelease。判断标准就一条——"蓄满这件事本身要不要立刻产生效果"。猎弓蓄力要立刻有满蓄反馈(音效、光效),用 Hold,Triggered 在 0.8 秒那一刻就发;长按开门是"持续按住、松手才算办完手续",门开得再久也不该提前触发,用 HoldAndRelease,事件压在松开那一帧。配错了的症状很典型:玩家手一抖提前松键,Hold 配错的门照样开,HoldAndRelease 配错的弓永远蓄不满。

工程上的取舍要说透:Trigger 组合能表达的,终究是"声明式时序"。真遇到跨动作的复杂窗口(比如"跳跃落地后 0.2 秒内接重击触发派生技"),那是连招系统的活,卷三的 GAS 章节(见第 23 章)有专门的 Cancel 窗口机制,别硬塞进 Trigger。Trigger 的职责边界是"这个键算不算、何时算",跨动作的编排往上走一层。

触发逻辑下沉成资产后,新问题是排查。"我按了为什么没触发"在老系统里看代码就行,现在得看状态机:编辑器里用 Enhanced Input 的调试显示(5.8 中对应官方文档 Enhanced Input 词条下的调试命令,使用前以文档为准),能实时看到每个映射行的当前状态与事件流。把它加进团队的排障手册,比口头传经验靠谱。

一套语义,三种设备:手柄键鼠触摸屏怎么统一

多设备统一的秘密不在"支持了几种设备",而在 Action 只声明 ValueType、不关心来源。IA_Look 是 Axis2D,鼠标移动给 (dx, dy),右摇杆给 (x, y),触屏拖动给 (dx, dy)——同一份 Look 回调吃三种设备,游戏逻辑零改动。设备差异全部推到 IMC 的映射行里,用 Modifier 就地吸收。

关注点键鼠手柄触摸屏
IA_Move 来源WASD 四个 bool 合成 Axis2D左摇杆原生 Axis2D虚拟摇杆 Axis2D
必需 Modifier无(数字键无漂移)DeadZone 0.2 + 响应曲线DeadZone + 平滑
转向特性位移型,Scalar 调灵敏度角速度型,推杆时长决定转速拖动距离型
瞄准辅助不需要需要需要

WASD 合成 Axis2D 值得单独说一句:W 映射到 IA_Move 时挂一个把 bool 转成 (0, 1) 向量的 Modifier(内置的"数字键转轴向"用法),S 转 (0, −1),A/D 同理,四行映射的向量在 Action 处自然求和。斜向输入的长度问题——(1,1) 的模是 √2 会跑得更快——在移动代码里归一化解决,别指望输入系统替你管。

还有一个常被问到的时间粒度问题:鼠标一秒钟报几百次位移,游戏只 Tick 六十次,中间的采样去哪了?答案是被合并了——一帧内的多次硬件采样累加成这一帧的总位移,Triggered 回调每帧拿到一份合并值。对视角控制这是正确行为,甩狙的轨迹依然完整;但如果你在做"数按键次数"的玩法(比如 QTE 连打),合并会让你丢次数,这种场景要用 Pressed 事件计数,并留意低帧率下的吞并,必要时把 QTE 判定和帧率解耦,用输入事件的时间戳而不是帧号做依据。

触摸屏这边,UE 的虚拟摇杆(Touch Interface)输出的轴量同样能映射进 Enhanced Input 的 Action,移动端项目的做法是一份 IMC 配虚拟摇杆、一份配实体手柄,运行时按平台压栈。具体的虚拟按键资产与勾选入口随版本挪过位置,5.8 中以官方文档"Touch Interface"与"Enhanced Input"两个词条为准,照文档点一遍即可,原理和本节完全一样。

运行时改键是另一个白送的。给映射行勾选 Player Mappable 并起个显示名,引擎的键位重绑定机制(5.8 中对应 UEnhancedInputUserSettings 相关词条)就提供了查询当前映射、拦截下一次按键、写回映射并持久化的完整链路。改键界面只剩 UI 的活,逻辑层不用写。本地分屏时每个玩家各存各的键位——老系统时代这是个能写一个月的功能。

统一的另一面是延迟的统一预算。玩家感知到的"跟手",是按下按键到屏幕反馈的总时长,由四段拼成:

t_{\text{感知延迟}} = \underbrace{\frac{1}{2 f_{\text{poll}}}}_{\text{设备轮询均值}} + \underbrace{\frac{1}{f_{\text{frame}}}}_{\text{等下一帧 Tick}} + \underbrace{\frac{n_q}{f_{\text{frame}}}}_{\text{渲染队列深度}} + t_{\text{display}}

代入典型数字:主流游戏外设 USB 轮询率 1000Hz,平均等半周期 0.5ms;60fps 下帧时间 16.6ms,按键恰好错过本帧采样就再等一帧;UE 的渲染线程与 RHI 并行,队列深度 n_q 约 1–2 帧;显示器自身还有 5–15ms。合计约 25–50ms,人眼无明显异物感。帧率掉到 30,帧项和队列项同时翻倍,总延迟冲上 60–80ms,玩家开始说"肉"——所以手感投诉先查帧率曲线,再查输入代码,顺序别反。触屏还要再加触控采样与手势识别的 20–40ms,这是移动端动作游戏天生吃亏的地方,只能靠更宽容的判定窗口找补,也就是下一节补课的主角。

「回响峡谷」实操:三套 Mapping Context 撑起整个 demo

理论落地。「回响峡谷」是第三人称单机竖切(对标《最后生还者》的场景氛围),玩法状态粗分三种:探索(跑图、解谜、拾取)、战斗(瞄准、射击、技能)、菜单(背包、地图、设置)。每种状态一张 IMC,用优先级栈管理切换。

IMC_Explore(优先级 0,常驻底层)

ActionValueType键鼠手柄映射行备注
IA_MoveAxis2DWASD左摇杆手柄行挂 DeadZone(0.2)
IA_LookAxis2D鼠标 XY右摇杆手柄行加指数响应曲线
IA_JumpBooleanSpaceA / CrossPressed 触发
IA_SprintBooleanLeft ShiftL3Hold(0.3s) 防误触
IA_InteractBooleanEX / SquareTap 拾取;另起一行配 Hold(0.8s) 开门
IA_CrouchBooleanLeft CtrlB / Circle切换式,按一次蹲再按一次起
IA_OpenMenuBooleanEscStart压入菜单情境

IMC_Combat(优先级 10,进入战斗压栈)

ActionValueType键鼠手柄映射行备注
IA_AimBoolean右键LTHold 触发开镜,松开收镜
IA_FireBoolean左键RT瞄准时 Pulse(0.12s) 连发
IA_ReloadBooleanRX / Square 长按与 IA_Interact 的冲突见下文
IA_Skill_EchoBooleanQRB「回响」探测波,点按即放
IA_DodgeBoolean方向键双击 / AltB 双击Combo Trigger

IMC_Menu(优先级 100,打开菜单压栈)

ActionValueType键鼠手柄映射行备注
IA_UI_NavigateAxis2D方向键十字键 / 左摇杆UI 导航专用
IA_UI_ConfirmBooleanEnter / 左键A / Cross
IA_UI_CancelBooleanEscB / Circle与 IA_OpenMenu 配合出栈

两个设计点解释清楚。

一是优先级与吞键。IMC 栈从高优先级往低逐张评估,同一物理键被高优先级表命中后,映射行上的 bConsumeInput 决定是否继续往下传。菜单打开时 IMC_Menu(100) 在最顶,WASD 没配映射,直接落空——角色自然就停了,不用任何 if。而 X 键在手柄上同时是交互(Explore)和换弹(Combat 长按):战斗表优先级高,长按被 Reload 吃掉;Explore 的 Interact 用 Tap,时序不同不打架。真出现同键同时序的冲突,那是设计事故,不是系统问题,改键位表就行。

二是压栈时机。进入战斗(敌人索敌成功、玩家开火)由 Gameplay 逻辑调 AddMappingContext(IMC_Combat, 10);脱战计时 5 秒后 RemoveMappingContext 弹出。这个状态判断放 PlayerController 或角色基类里,一份代码全项目通用。

C++ 侧的核心绑定代码如下,解决"语义动作 → 成员函数"的最后一公里:

void AEchoCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    // 1) 给本地玩家压入常驻的探索情境
    if (APlayerController* PC = Cast<APlayerController>(GetController()))
    {
        if (UEnhancedInputLocalPlayerSubsystem* Subsys =
            ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PC->GetLocalPlayer()))
        {
            Subsys->AddMappingContext(IMC_Explore, /*Priority=*/0);
        }
    }

    // 2) 绑定语义动作到成员函数
    if (UEnhancedInputComponent* Input = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        Input->BindAction(IA_Move,  ETriggerEvent::Triggered, this, &AEchoCharacter::Move);
        Input->BindAction(IA_Look,  ETriggerEvent::Triggered, this, &AEchoCharacter::Look);
        Input->BindAction(IA_Jump,  ETriggerEvent::Started,   this, &ACharacter::Jump);
        Input->BindAction(IA_Jump,  ETriggerEvent::Completed, this, &ACharacter::StopJumping);
        Input->BindAction(IA_Skill_Echo, ETriggerEvent::Triggered, this, &AEchoCharacter::CastEcho);
    }
}

void AEchoCharacter::Move(const FInputActionValue& Value)
{
    const FVector2D Axis = Value.Get<FVector2D>();   // 已过完 Modifier 管道的干净值
    if (Controller && !Axis.IsNearlyZero())
    {
        const FRotator YawRot(0.f, Controller->GetControlRotation().Yaw, 0.f);
        AddMovementInput(FRotationMatrix(YawRot).GetUnitAxis(EAxis::Y), Axis.Y);
        AddMovementInput(FRotationMatrix(YawRot).GetUnitAxis(EAxis::X), Axis.X);
    }
}

要点三行:MoveTriggered 而非 Pressed,因为移动是持续轴量,按住期间每帧都要有值;JumpStarted/Completed 对应按下与松开;所有 Action 都是 UPROPERTY 资产指针,在角色蓝图的默认值里指派,策划换资产不碰代码。

蓝图党走完全等价的路:事件图里拖出 EnhancedInputAction IA_Move 节点,Triggered 引脚接移动逻辑;压栈用 Add Mapping Context 节点(Target 是 Enhanced Input Local Player Subsystem)。同一套资产、同一套状态机,只是换了个接线方式。等价的节点配置展开是这样:

# 蓝图节点配置(伪代码,与上面 C++ 版完全同效)
# BeginPlay:
EnhancedInputLocalPlayerSubsystem.AddMappingContext(IMC_Explore, Priority=0)

# Event Graph,每个 Action 一个事件节点:
IA_Move.Triggered   → 取 ActionValue(Axis2D) → AddMovementInput(前向, Y) + AddMovementInput(右向, X)
IA_Jump.Started     → Jump
IA_Jump.Completed   → StopJumping
IA_Interact.Triggered(Tap)   → 射线检测 → 拾取
IA_Interact.Triggered(Hold)  → 播开门动画 → 解锁门

注意同一个 IA_Interact 能拖出多个事件节点,Tap 和 Hold 两行映射各自触发自己的节点——这就是蓝图侧的"点按拾取、长按开门",不用写一行计时代码。项目的规矩建议照搬第 4 章的分工协议(见第 4 章):移动、视角这类每帧的高频回调留 C++,交互、开门这类低频离散动作放蓝图,策划改起来不求人。

映射是资产这件事,团队意义上比技术意义更大:策划调键位、调蓄力时长、调死区,全程在编辑器里点,不触发任何编译。对照第 4 章算过的编译账,一个输入参数的修改从"动代码 → 编译 → 重启编辑器"的一分钟级,变成改资产即时生效的秒级。一天几十次迭代下来,省的是心气。电费只是顺手。

最后埋个伏笔:这套"语义先行"的设计在联机时代会翻倍回报。「远港 Online」的技能释放走的是 Input Action → GAS 技能激活的链路(见第 22 章),客户端按的是 IA_Skill_1,服务器收到的是"激活技能槽 1"这条语义 RPC——键位是客户端的私事,协议里传的是意图。键位进不了网络包,这就是解耦的终极红利,卷三会把它吃干榨净。

补课:手感设计第一块砖——输入缓冲与土狼时间

补课:本节补策划/手感设计基础,程序出身可直接抄结论;后续第 9 章调参还会回来用。

手感这个词听起来玄,拆开了就两件事:物理延迟(上一节的公式)和心理延迟。心理延迟的意思是:玩家的意图和机器的判定之间存在误差,好游戏用"宽恕窗口"把这个误差吃掉。两个最经典的宽恕窗口,是输入缓冲和土狼时间。它们是策划补课的第一块砖,因为不需要任何引擎知识就能理解,却直接决定玩家说"这游戏跟手"还是"这游戏不听使唤"。

输入缓冲(Input Buffering):落地前一小段时间内按过跳,落地瞬间自动补跳。实现就是一个时间戳——IA_Jump 的 Started 事件记下时刻,落地的这一帧检查差值,在 150ms 内就执行起跳。

没有它是什么体验?玩家跑图连跳,眼睛看着地面提前按跳,手比落地快了两帧——60fps 下就是 33ms——跳跃指令被当场吞掉,角色落地傻站,节奏断半拍。玩家不会分析帧数,他只会觉得"我明明按了"。有它之后,操作被"记住",落地即跳,行云流水,连续跳跃能踩出音乐感。物理规则一个字没改,改的只是判定的宽容度。

土狼时间(Coyote Time):走出平台边缘后的一小段窗口内,仍视为"站在地面上",允许起跳。名字来自《乐一通》里那只跑出悬崖还能悬空跑两步的歪心狼。业界常用值 100–150ms。

没有它是什么体验?玩家冲到平台边按跳,其实脚已经离开碰撞体一帧了,跳跃不响应,人直挺挺掉下去。键盘玩家尚可精确控制身位,手柄玩家转向精度低,边缘判断误差天然大,同一个悬崖摔十次,摔到删游戏。有它之后,在边缘多宽恕七帧(120ms / 16.6ms),起跳成功率肉眼可见地涨,评测里就会出现"跳跃很跟手"——玩家夸的其实是这个 120ms。

给一组起手数值:缓冲 150ms、土狼 120ms。这两个数字是从《蔚蓝(Celeste)》一代的平台跳跃游戏里传下来的业界肌肉记忆,先用它,再按自己游戏的节奏微调。调的方向也有讲究:硬核平台跳跃往小调(80ms 上下),强调容错的动作冒险往大调(200ms 也不嫌多)。

这两个机制属于角色移动层,第 9 章的移动调参会正式落地(见第 9 章)。但它们能在那一章落地的前提,是这一章的输入层给出了干净的语义:跳跃按下的精确时刻来自 Started 事件,不是轮询键盘的散兵游勇;缓冲窗口的计时起点可信,机制才可信。输入语义是手感的地基,这话不是修辞。

两个窗口叠加时还有一个顺序问题要拍死:玩家贴着边缘按跳,到底算缓冲还是算土狼?工程上的答案是起跳判定走一条固定顺序——先查"在地面上"(含土狼窗口内),成立就立即起跳并清掉缓冲;不成立再查缓冲,把意图挂起来等落地。顺序反过来会出灵异现象:人在边缘按跳被挂进缓冲,走出悬崖后土狼窗口过了,落地时才触发——玩家看到的是"跳晚了一个身位"。这个坑我踩过一次,QA 报了三天"跳跃手感飘忽",最后发现就是两行 if 的顺序。

小结

老 Action Mapping 把设备键位和动作语义焊在一行配置里,加设备、做改键、切情境都要碰代码;Enhanced Input 用三件套资产把它们拆开,这是换代的第一理由。分工记牢:Input Action 管"做什么"(语义),Mapping Context 管"按哪个键"(键位表 + 优先级栈),Modifier 管"值怎么加工",Trigger 管"何时算触发";设备差异放映射行,语义规则放 Action。Trigger 本质上是状态机:Started / Ongoing / Triggered / Canceled / Completed 五个事件覆盖了蓄力、点按、连发、组合键的全部时序,时序逻辑写进资产,别写进 Tick。三套 IMC(探索 0 / 战斗 10 / 菜单 100)加优先级压栈,是中小项目最经济的情境方案;映射是资产,策划改键零编译。手感的一半在输入层:物理延迟靠帧率和采样压,心理延迟靠 150ms 输入缓冲和 120ms 土狼时间宽;先量帧率曲线,再改输入代码。

上手任务

  1. IA_Move(Axis2D)与 IMC_Explore,WASD 和左摇杆都映射进去,手柄行加 DeadZone(0.2)。验收:键盘与手柄都能驱动角色,摇杆完全松手时角色无自走,轻推不死。
  2. 实现猎弓蓄力:IA_AimCharge 挂 Hold(0.8s, one-shot),Started 显示蓄力条,Ongoing 更新进度,Triggered 锁满蓄,Canceled 射弱箭,Completed 射满蓄箭。验收:0.8s 前后松手射出的箭伤害不同,蓄力条全程连续无跳变。
  3. IMC_Menu(优先级 100),Esc 压栈、再按出栈。验收:菜单打开期间 WASD 不再驱动角色,方向键只走 UI;关菜单后操控立即恢复。
  4. 给跳跃加 150ms 输入缓冲:Started 记时间戳,落地帧检查并补跳。验收:PIE 用 0.3 倍慢速(控制台 slomo 0.3)验证,落地前约 0.1 秒按跳,落地帧必定起跳。

下一章

输入已经变成干净的语义流,接下来轮到这些语义驱动的东西怎么被看见——第 6 章拆渲染管线,从一帧的 Game 线程跟到 GPU,看那 16.6ms 是怎么被花光的。

延伸阅读

  • 官方文档:Unreal Engine → Programming and Scripting → Enhanced Input(Input Action / Mapping Context / Trigger / Modifier 各词条)
  • 官方文档词条:Enhanced Input User Settings 与 Player Mappable Keys(运行时键位重绑定)
  • 源码:Engine/Plugins/EnhancedInput/Source/EnhancedInput/Private/InputTriggers.cpp——内置 Trigger 的状态机实现,Hold/Pulse/Combo 的判定逻辑都在这,值得通读
  • Steve Swink《Game Feel: A Game Designer’s Guide to Virtual Sensation》——"手感"作为一门学科的源头教材
  • 《蔚蓝》《TowerFall》开发者关于 Coyote Time 与 Jump Buffering 的技术分享(检索关键词 "Celeste coyote time jump buffering")