Tutorial / 01 Game Feel / Lesson 01

Lesson 01 · 为什么角色会“听话”?

先亲手跳一次,再直接看刚才那次操作的回放:你什么时候离开平台、什么时候按下跳跃、为什么跳成功或失败。这条时间轴记录的就是上面小游戏的真实操作,不需要额外设置。

先不必理解毫秒数:保持上面两个开关开启,试着在平台边缘按跳跃,然后直接查看下方「刚才这一跳」。我们用同一次操作理解土狼时间和输入缓存。

① 手感实验:先玩一关

📱 手机操作:在游戏画面上水平拖动并按住移动,轻点画面跳跃;移动时可用第二根手指点按跳跃。松开停止。横屏玩能看得更清楚。

电脑操作:A/D 或方向键移动 · 空格/W/↑ 跳 · R 重置。目标:从左侧平台跳到右侧平台。

准备开始
02

刚才这一跳 · 自动复盘

直接连接上面小游戏。每次按跳跃,会用游戏实际记录的时刻更新这里;不需要选实验、调整毫秒或手动播放。

还没有跳跃记录等待操作

先在上方小游戏按一次跳跃;成功、未生效或跌落后,这里会自动说明原因。

等待刚才的按键记录…

提示:在平台上直接跳、走出边缘后补跳、落地前抢先按键,得到的复盘结论会不一样。

查看这一跳的精确毫秒数据
还没有记录。

操作任务

  1. 先直接按一次跳跃,看复盘是否显示「普通起跳」。
  2. 走到左侧平台边缘,刚踩空后再按一次跳跃,看是否显示「离地后补跳」。
  3. 关掉“土狼时间”,再试一次;这次如果失败,复盘会解释为什么。
  4. 跳向右侧平台,在落地前提前轻按一次,观察它是被缓存后执行,还是过期丢失。
可选进阶:独立参数模拟器(不是刚才的操作记录)

只有想自己指定“离地后第几毫秒按键”时才需要展开。默认折叠,不影响上方真正的跳跃复盘。

资格 150ms · 缓存 600ms
按下跳跃平台边缘 / 落地 / 解锁当前观察时刻
离开平台
Press
过期
现在

模型边界:这是可确定性重演的课堂场景,沿用原版两个窗口的数值;不是对原游戏完整运动、硬直和长按输入的逐帧仿真。

可选进阶:Deepnight 的多动作输入队列实验
+

自己制造、消费和清理输入事件

这里是另一个独立实验,专门研究原版 ControllerQueue:Jump、Dash、Shoot、600ms 懒过期与事件顺序。不属于上方小游戏的操作回放。

模拟时钟 0ms
队列刚创建。试试先按 Dash,再按 Jump,然后消费 Jump。

三个小实验:

① 先 Dash 后 Jump,直接消费 Jump 会怎样?② 改用“忽略顺序消费 Jump”,Dash 还在吗?③ 在锁定期间按 Jump,500ms 后解锁再消费,结果如何?

这里是单独编写的可测试队列模型,重现原版主要事件语义。输入锁模拟的是 Hero.controlsLocked();不是输入设备本身失活。真实 ControllerQueue 还支持 Release 查询、长按、清队列与锁到松开等更多行为。

③ 对照源码与性能审计

观察完再阅读同目录 LESSON.md 解析文档,里面有作者 Haxe 源文件的具体位置、示意时序图与本课的验收题。

体验完成后展开:Deepnight 在 Haxe 里怎么实现容错?

在他原版的 Hero.hx 的 frameUpdate() 中,落地时给玩家保留了短暂的跳跃资格:

if( onGround && options.justInTimeJump )
    cd.setS("allowJitJump",0.15);

// 发起正常跳跃的判定条件:
if( ( onGround || cd.has("allowJitJump") ) && ... )

注意原版这里是 0.15 秒,本课演示用的是 0.12 秒。上面的省略号是便于课堂阅读的缩写,完整判断请打开源文件。

输入缓冲则沿着 ControllerQueue、checkControlPress()、consumePress() 追踪。你会发现“什么时候记录意图”和“什么时候准许动作”是两种职责。

进阶源码:Deepnight 的输入缓存为何不只是一个 Boolean?

源代码在 ControllerQueue.hx。Hero 为 Jump、Shoot、Dash 各注册一个动作监听器,每帧采样时只记录按下、松开两种边沿事件的时间戳。

// 核心逻辑摘取与简化,不是原文件连续代码
if (!wasDown && ca.isDown(action))
    presses.push(curTimeS);

// 保存输入最长 0.6 秒;需要的时候才检查过期
while (presses.length > 0 &&
       curTimeS - presses[0] > 0.6)
    presses.shift();

在玩家控制被短暂锁定时,输入依然可以先存入队列。等可以行动,再用 consumePress() 取走一次按下事件;consumePressOrDown() 还支持持续按住的动作。

原版为 600ms;本页面游戏的输入缓存仅为 120ms。 原版默认还会考虑不同按键之间的事件先后顺序,所以严格说这是多动作事件队列,而不是“只记住一次跳跃”。

性能观察:这里用“时间戳 + 查询时清理”做了懒过期;但每帧仍要采样被监听的动作,消费时可能扫描其他动作队列,且 Array.shift() 不是严格 O(1)。

架构审计:输入队列有什么成本?什么情况值得简化?
每帧采样

只遍历监听的动作,Hero 默认三个:Jump / Shoot / Dash。粗略复杂度 O(W),W 是监测的动作数。

缓存按需过期

保存 Press / Release 的时间戳,在查询、新事件等时机清理旧记录;不是逐帧给每条记录减剩余时间。

消费潜在开销

默认查询还检查其他动作的先后顺序,使用 Array.shift() 删除旧事件,因此不能把整个消费操作当成严格 O(1)。

什么情况不必用队列

如果只用一个 Jump,并只保存最后一次短按,一个 pressedAt 时间戳通常够用。连招、不同动作顺序和松开事件才体现完整队列的优势。

设计结论:和上一部分讨论过的 Cooldown 不一样,输入缓存已经用了时间戳及懒过期。值得再评估的是:600ms 太长会不会误触发?跨动作排序真的符合玩法吗?

04

一课三问:检验是否真的理解

直接点答案查看解释,不上传数据、不记录成绩。

本课记录