# Lesson 01 · Input Forgiveness：玩家意图与物理判定

> **2026-10-28 联动复盘版**：第一课现在以 **真实小游戏操作 → 自动事件复盘 → 对照源码** 为主流程；独立参数模拟器和多动作输入队列模拟器降为可选进阶，默认折叠。手机或电脑打开：<https://deepnight.47-103-217-57.nip.io/01-GameFeel/Lesson-01-Input-Forgiveness/>。

### 为什么改变主流程？

旧版的“毫秒时间轴”是独立的假设模拟器，不接收小游戏按键，因此容易被误认为正在展示刚才那一跳。新版直接记录小游戏运行中的按键、离地、着地、真正起跳、按键过期和跌落事件；每次更新复盘时只显示这一次的实际情况，没有额外的情景选择器和滑块。

### 新的四个学习步骤

1. **亲手跳跃**：先在地面按跳跃，再尝试踩空后补跳、落地前短按，最后单独关闭一项容错。
2. **自动复盘**：直接看最近一次按键何时发生、真正起跳是否成功及原因；有需要再展开精确毫秒。
3. **源码与性能**：阅读 Hero、Cooldown、ControllerQueue 的状态管理、时间戳、内存分配与使用边界。
4. **互动自测**：回答三道题。

原来独立的参数时序模拟和多动作 FIFO 实验仍在页面下方以“可选进阶”形式折叠，只有主动想研究时再打开。它们不代表小游戏真实的操作记录。

**本次还修正一处分类误差**：判断 Jump Buffer 不再仅凭按键到起跳相差是否大于 1/60 秒，而是根据按键时是否在空中、真正执行时是否已落地，避免浮点误差导致普通起跳被误归类。

记录来自 60Hz 的教学逻辑循环；毫秒显示仅为换算后的取整值，不代表硬件达到 1ms 的输入采样精度。

## 五段式课程路线（建议按顺序）

1. **01 手感实验**：左侧平台起跳，依次关掉 Coyote / Jump Buffer，观察手感变化；原教学游戏各为 120ms。
2. **02 毫秒时间轴**：精准控制何时按 Jump。选择“严格”“120ms 教学”“150/600ms 原版参数”三个模型；改变按键发生的时刻，再用滑块或自动播放观察结果。试试“离地后 140ms”，教学失败但原版参数成功。
3. **03 输入队列实验台**：先按 Dash 后按 Jump，直接消费 Jump 看结果，再点击“忽略顺序消费 Jump”；接着模拟锁定期间 Jump 与解锁后的消费。观察 Press/Release 时间戳、懒过期及跨动作时序。
4. **04 源码与性能**：打开网页可折叠段落，对照 Hero.hx、ControllerQueue.hx。区分“按键事件过期时间”和“起跳资格过期时间”。
5. **05 三道自测**：在网页直接选答案并得到解释，无需登录或上传数据；完成后自行标记进度。

**非常重要**：时间轴是为教学创建的确定性简化模型，只借用了原版 150ms / 600ms 参数，并不完整模拟 Haxe 游戏里的所有物理、长按与控制锁状态；真实输入队列实验台也只还原主要的事件采集、消费、跨动作排序和过期语义，而不是逐行复刻库代码。

## 一、先玩（建议至少 5 分钟）

1. 打开本目录的 index.html，体验两个开关都开、然后单独关掉。
2. 快走出左侧平台后才按跳跃：关掉 Coyote Time 后更容易失败。
3. 在落地前 50~100ms 短促按空格（按了就松）：关闭 Jump Buffer 后输入会丢掉。
4. 相同动作各重复 5 次，记录“原本想跳、却没有成功”的次数。

本演示以 120ms 容错窗口、60Hz 逻辑帧为教学参数，**不是作者原版参数**；
键盘、显示器和人的反应也会影响你的 A/B 结果。

## 二、解释两种完全不同的“宽容”

~~~text
Coyote Time（离地后容错）
────接地──── | 离开边缘 | ← 仍可起跳 120ms → | 正常下落
                           ↑ 在这里按键仍可跳

Jump Buffer（提前输入缓存）
按跳跃键 → [保存 120ms 的输入] → 着地 → 自动执行跳跃
             ↑ 先有输入            ↑ 后具备起跳资格
~~~

Coyote Time 解决“资格晚了一点”的问题；Jump Buffer 解决“输入早了一点”的问题。
两者都不是增加跳跃高度，也不意味着无限二段跳。

## 三、我们的最小教学代码

- rules.js：纯判定函数 canJump，不依赖浏览器或 Canvas，适合单独测试。
- game.js：输入采集、逻辑循环、地面检测、实体渲染。
- game.js 的 tryJump() 仅在满足判定规则时，消耗一次 press 事件。
- 落地判定后**再次**调用 tryJump()，让“先按、后落地”成立。
- 这是一套自己编写的课程示范，不是对 Deepnight 源代码逐行照搬。

## 四、对照 Deepnight 的原始 Haxe 源码

**文件：** third_party/gamefeel/src/game/en/Hero.hx

| 本课问题 | 原作者源码入口 | 读什么 |
|---|---|---|
| 输入是立即丢弃还是留到后面？ | 构造函数 ControllerQueue / ctrlQueue.watch | 观察 A_Jump、A_Dash、A_Shoot 的输入监听 |
| 一次动作怎样消耗队列？ | checkControlPress() / checkControlPressOrDown() | 根据 options.ctrlQueue 选择 consumePress 或直接 isPressed |
| 刚踩空为什么还能跳？ | frameUpdate() 中 allowJitJump | 在地面短暂记住跳跃资格（源码里是 0.15 秒） |
| 着地和跳跃是否互相干扰？ | frameUpdate() 的跳跃分支 | 条件把 onGround 与 allowJitJump 组合起来 |
| 什么时候控制被锁住？ | lockControlS() / controlsLocked() | 伤害、冲刺及行动限制与输入缓冲关系 |

**关联基础设施：** third_party/deepnightLibs（\`dn.Cooldown\` 与 ControllerQueue 的实际实现）。

注意：原作者的 \`ControllerQueue\` 是更通用的输入队列，与本课只缓存 Jump 的规则不完全等价。
因此不能直接将我们的 \`rules.js\` 当作原版算法。

### 真正的 ControllerQueue 机制：先保存输入意图，条件满足时再消费

在 \`Hero.new()\` 中，\`ctrlQueue.watch(A_Jump)\`、\`watch(A_Shoot)\`、\`watch(A_Dash)\` 监测三个 Action。队列由
\`third_party/deepnightLibs/src/dn/heaps/input/ControllerQueue.hx\` 实现，而非本课 \`rules.js\`。

1. **每帧尽早采样**：\`Hero.preUpdate()\` 调用 \`ctrlQueue.earlyFrameUpdate(game.stime)\`。每个被监听的动作检查 \`ca.isDown(a)\`。
2. **只记录边沿，不每帧重复插入**：由 \`QueueEventStacks.wasDown\` 判断“刚刚按下”和“刚刚松开”，分别将 \`curTimeS\` 追加进 \`presses:Array<Float>\`、\`releases:Array<Float>\`。
3. **缓存窗口**：\`ControllerQueue\` 构造默认 \`defaultMaxKeepDurationS=0.6\`，每个 \`watch()\` 也可单独传 \`maxKeepDurationS\`。Hero 没覆盖默认值，所以原版 **Jump/Shoot/Dash 都保留最多 600ms 的输入事件**。
4. **按需读取并消费**：\`consumePress(a)\` 先 \`peekPress(a)\` 判断，再 \`popPress(curTimeS)\` 取走队首事件。过期事件由 \`gc()\` 清理（\`curTimeS - t > maxKeepDurationS\`），队列保留多次按下的先后顺序，而不只是一个布尔值。
5. **跨动作先后次序**：默认 \`ignoreChronologicalOrder=false\`，因此如果 Shoot 事件排在 Jump 前面，直接 \`consumePress(Jump)\` 可能被更早的 Shoot 阻挡；\`consumePress(Jump,true)\` 则忽略顺序并丢弃各动作中不晚于当前 Jump 的旧输入。调用点要考虑这个设计取舍。
6. **按住与短按的区别**：\`consumePressOrDown(a)\` 在无法消费事件时，还会尝试 \`ca.isDown(a)\`。因此原版 Jump 判断支持“缓存的短按”和“现在仍然按住”两条路径；后者不保证“一次按下只执行一次动作”。
7. **禁用缓存做 A/B**：\`Hero.checkControlPress(a)\` 随 \`options.ctrlQueue\` 开关，切换 \`consumePress(a)\` / \`ca.isPressed(a)\`；\`checkControlPressOrDown(a)\` 则切换 \`consumePressOrDown(a)\` / \`ca.isDown(a)\`。
8. **锁控制时依然可收集输入**：\`earlyFrameUpdate()\` 在 \`preUpdate()\` 执行，而 Hero 的移动、跳跃和冲刺判断在后续 \`if (!controlsLocked())\` 里面。只要 ControllerAccess 处于 active，玩家锁控制期间的短按就有机会进入队列，解锁后在窗口内被消费。

### 时间轴：锁控制时按下 Jump（原版机制示意）

~~~text
游戏时间     0ms       200ms           450ms           800ms
控制状态   [------ 锁定 ------]      [----可操作----]
键盘                     ↑按一下，松开
ControllerQueue          ↑记录一个 Press 时间戳
动作判断                                   ↑读取 / 消费 Jump
缓存截止                 200ms + 600ms = 800ms
~~~

这个时间轴只是演示“输入早于动作资格”；原版控制锁、土狼时间、跳跃条件仍会决定最终是否起跳。
**原版缓存 600ms，土狼时间 150ms；本教程最小跳跃游戏分别使用 120ms，不是同一套参数。**

### 本课性能审计

- 输入队列 **不是逐帧给每个 Press/Release 事件减时间**：记录的是时间戳；\`gc()\` 在注册新边沿、查询、消费或调试绘制时按需清理，这一点接近懒过期。
- 每帧 \`earlyFrameUpdate()\` 遍历被监测的动作：开销 O(W)，这里 W=3。
- 默认 \`peekPress()\` 需要检查其他动作中是否存在更早事件，通常 O(W)，并触发过期清理；\`Array.shift()\` 的成本随队列长度变化，不能认为整个 \`consumePress()\` 严格 O(1)。
- 缓存窗口较长（600ms），对受击后补偿有好处，但也可能带来“很早按的一次按键在解锁后突然执行”的意外；是否合适要用真实玩法测试，而不应盲目照搬。
- 对只有一个动作、且只需要保留最后一次输入的游戏，一个 \`pressedAt\` 时间戳就够；对需要连续技、顺序与按下/松开事件的游戏，事件队列有实际价值。

## 五、架构结论（待你体验后验证）

- **输入采集**和**动作是否有资格执行**是两个问题；
- 状态容错最好体现在独立的计时窗口，而不是零散地添加 if；
- 每种辅助功能都能够单独开关，有利于回答“它究竟改变了什么”；
- 真正优秀的手感，不需要先加大量美术或粒子。

## 验收题

1. 解释 Coyote Time 和 Jump Buffer 的时间顺序有什么不同。
2. 为什么“提前按空格之后着地”需要先记住输入，再判断动作资格？
3. 你会如何避免同一个按键被误消费两次？
4. 找出原作者 Hero.hx 中设置 allowJitJump 的那一行代码，并指出时间参数。

## 下一课

../Lesson-02-Camera-Feedback/LESSON.md：动作事件触发多层反馈，为什么能增加重量感？