# GameBase Lesson 01 · Entity 的真实职责

## 插值专题：从源码值到手感（2026-10-29）

新增四个**同步运动角色场景**：不插值、原版 Deepnight Lerp、Smoothstep、基于本次速度的外推。四个 Canvas 共用同一份 30Hz 物理数据，只改变 60Hz 的 Sprite 绘制结果；可选撞墙急停、反向、匀速、跳跃、瞬移。手机为上下排列的完整场景，而不是进度条。主流程保留播放/暂停、单步物理、重新播放、查看当前计算；旧的六算法数值推演改为默认折叠的进阶实验。

### Deepnight 精确计算（固定源文件）

原始源码 gameBase/src/game/Entity.hx：

* 每次 Entity.fixedUpdate() **开始时**调用 updateLastFixedUpdatePos()：lastFixedUpdateX = attachX，lastFixedUpdateY = attachY（物理更新前的旧坐标）。
* 当前物理位置 attachX = (cx + xr) * Const.GRID，Y 同理。物理本身会用速度合成和格子子步推进，并不依赖 Sprite 插值。
* get_sprX() 返回 M.lerp(lastFixedUpdateX, attachX, game.getFixedUpdateAccuRatio())，若 interpolateSprPos=false 则直接返回 attachX。
* dn.Process.getFixedUpdateAccuRatio() = _fixedUpdateAccu / (getDefaultFrameRate() / FIXED_UPDATE_FPS)；默认 FIXED_UPDATE_FPS=30。累积量在固定步更新后减去一个周期，再随运行帧增长，所以插值 α 是剩余固定步时间占周期的比例。
* Entity.postUpdate() 将 spr.x = sprX、spr.y = sprY。手动瞬移的逻辑还会重设 lastFixedUpdateX/Y，防止长距离错误滑动。

数学：RenderX = PreviousX + (CurrentX - PreviousX)*α。以 PreviousX=100、CurrentX=110、α=0.5 为例，RenderX=105。这里的 PreviousX 是**物理更新开始前**而不是前一个渲染帧 Sprite.x；CurrentX 是权威物理结果而不是画面显示位置。固定 30Hz 周期约为 33.33ms，插值视觉在物理真实状态后方约一个物理步，但不让碰撞/命中逻辑延后。

### 四种算法比较与推荐

| 方法 | 计算和特点 | 推荐场景 |
|---|---|---|
| None | Render=currentPose；每次固定更新位置跳到新端点 | 调试与极端追求贴近当前物理结果 |
| Deepnight Lerp | Render=lerp(previous,current,alpha)；恒速段平滑、不超过已知端点，但有视觉延迟 | 自写 2D 固定物理主角、NPC、需要屏幕平滑的目标 |
| Smoothstep | Render=lerp(previous,current,3α²−2α³)；每个物理步两端“停一下”，会改变匀速运动的视觉速度感 | 明确需要事件动画缓动时；**不建议每物理步用于玩家持续位移** |
| Extrapolation | Render=current+estimatedVelocity*elapsed；减少滞后，但碰撞/反向/突然受力可能穿墙、跳回 | 可预测且容错的视觉对象；不推荐权威碰撞或默认主角 |

Unity 的 Rigidbody.interpolate Interpolate 与 Deepnight 在**使用过去两次物理结果平滑当前显示**上原理相同。区别在于 Unity 物理组件处理位置与旋转、插值生命周期，而 GameBase 自定义 Entity 只负责自身 Sprite 的 X/Y 绘制坐标；Unity 官方同样指出 Interpolate 常滞后一个物理更新，Extrapolate 可能因无法预测未来碰撞而穿透边界再纠正。

建议：先选**30Hz 固定物理 + Sprite 线性插值**作为基准，用 A/B 实际测主角转向、急停、起跳、落地和摄像机跟随。如果视觉延迟不可接受，先做输入到动作的端到端延迟测量，再考虑提高物理频率（如 60Hz）或针对个别对象关闭插值；不要用更花哨的 Easing 假装解决输入延迟。Camera 应使用与其追踪对象一致的展示时基，避免“角色平滑但镜头每物理步抽动”。碰撞、游戏录像和网络同步要坚持使用权威物理坐标，Sprite 不能回写逻辑坐标。

> **2026-10-28 稳定阅读修复**：小游戏的 60Hz 自动更新只影响 Canvas 游戏画面，不再驱动页面的数值和说明文字每帧变化。下面“刚才那一帧”显示的是**捕获快照**。自动运行时可以按“捕获当前帧”；按“暂停自动运行”会记录当前帧，然后每次“单步”刷新一次快照。fixedUpdate 即使本帧没有执行也保留固定占位，仅切换灰色/高亮，页面不再因为阶段标签消失而变化高度。创建、销毁、Bump、输入源切换等主动操作仍即时更新有关数据。坐标栏同样显示捕获时刻，而非持续跳动的直播数字。

在线课件：https://deepnight.47-103-217-57.nip.io/02-GameBase/Lesson-01-Entity-Lifecycle/

## 增补专题：Sprite 插值原理、Deepnight 源码与 Unity / UE 比较

课件页面已经加入紧随小游戏之后的「为什么 30Hz 物理可以显示出 60Hz 平滑运动？」专题。这个专题有独立的数值可视化：一个场景选择器和一个时间系数滑块，共用同一套物理状态比较直接显示、Deepnight Lerp、Unity 内插的概念效果、Smoothstep、Hermite 与速度外推。该独立实验专门解释公式，不是前面小游戏的自动录像。

### 作者真正使用的数学关系

Deepnight 的原始 gameBase/src/game/Entity.hx 为每个 Entity 保存上一次固定步开始时的 lastFixedUpdateX/Y；每次 fixedUpdate() 开始由 updateLastFixedUpdatePos() 更新这些字段。sprX/sprY 从旧物理位置到当前 (cx+xr) * Const.GRID 位置做 M.lerp，系数是 game.getFixedUpdateAccuRatio()；如果 interpolateSprPos 为 false 则直接取权威位置。postUpdate() 再将 sprX/sprY 写给 Heaps 的 Sprite。

冻结的 deepnightLibs/src/dn/Process.hx 实现的进度系数是 _fixedUpdateAccu / (getDefaultFrameRate() / FIXED_UPDATE_FPS)，取固定物理更新之间已经累积的时间比例。它不是「从 Update 读取一个位置，和 FixedUpdate 的位置混合」。

作者也考虑了瞬移。setPosPixel()、setPosCase() 等手动改变位置的路径会同步上一次 Fixed 位置，从而避免 Sprite 从老地点缓慢滑到传送终点。

### 方法差异

- None：直接将当前物理位置显示出来；每个固定步之间可以停住。
- 线性内插：以前后已知的物理位置按 alpha 算中间显示点；Deepnight 当前使用这个。
- Smoothstep：对系数做三次缓入缓出，单段可能很平滑，但每步重复套用容易造成速度忽快忽慢。
- Hermite：结合两端的位置与切线/速度选择曲线，处理更复杂的连续运动时有用，但参数选择不当可能过冲。
- Extrapolate：从最新位置与当前速度预测未来；急停、碰撞、转向时会偏离真实物理。
- 指数平滑：每渲染帧将显示位置向目标拉近，历史显示位置参与迭代；不等于 Deepnight 用的固定端点时间 Lerp，帧率处理也需要格外注意。

### 和 Unity 比较

Unity Rigidbody / Rigidbody2D 有 None、Interpolate、Extrapolate。Interpolate 使用最近两次物理更新姿态平滑位置和旋转，通常有一个固定步的视觉延迟；Extrapolate 利用当前速度预测。与 Deepnight 的数学意图基本一致，但实现放在 Unity 物理组件与 Transform 同步机制中，不能直接把 Deepnight 的 Sprite 属性当成 Unity 引擎级刚体插值。

Unity 官方：https://docs.unity3d.com/cn/current/Manual/rigidbody-interpolation.html

### 和 UE 比较

UE 的 Chaos Physics Sub-stepping 首先是物理积分分步，以改善物理精度稳定性，不等同于 Sprite 位置内插。UE 还有异步物理结果插值和 CharacterMovement 网络平滑，分别处理线程间的模拟状态与复制状态修正。没有必要强行用「同一个插值开关」概括这些不同机制。

UE 官方：https://dev.epicgames.com/documentation/unreal-engine/physics-sub-stepping-in-unreal-engine

UE CharacterMovement：https://dev.epicgames.com/documentation/en-us/unreal-engine/python-api/class/CharacterMovementComponent

### 工程设计解释

Deepnight 主要用自定义网格物理、单独 Sprite，自己只需保存固定步历史并在 Sprite 显示同步时作一次轻量线性 lerp，因此局部化、透明、容易调试。Unity 与 UE 则面向更广泛的物理、刚体、旋转、异步任务及网络复制，需要更深的引擎集成。它们解决的问题既有重叠，也有层次差异，不应该简单排序哪一个先进。

所有方案仍以逻辑/物理位置决定碰撞、命中与回放，显示出来的坐标可能因平滑而略滞后或预测超前，不应写回权威模拟。

原版基准：Deepnight gameBase 固定提交 a7af1d70dbbf62d7fc09cb05431ced606e0ed809；原始文件位于三方源码快照的 src/game/Entity.hx、src/game/Game.hx、src/game/sample/SamplePlayer.hx。本课 JavaScript 是独立教学实验，不是原作者的 Haxe 代码移植。

## 先体验

1. 键盘或屏幕按钮左右移动；看 cx/xr 坐标如何随格子边界变化。本课假设 GRID=48px，原版由 Const.GRID 决定。
2. 将输入来源切为 AI。观察同一个 Hero、同一套运动更新机制，玩家和 AI 都提供 MoveX/Jump；**可替换 AI InputSource 是本课自行设计的延伸，原版 GameBase 并没有这项功能**。
3. 暂停后，连续单步两次：每个 60Hz 画面帧都有 pre/frame/post/final 等环节，只有其中一次执行 30Hz fixedUpdate。本课用明确的教学顺序展示职责，不是对 dn.Process 调度的逐行复制。
4. 开关 Sprite 插值：实体真实位置不变；画面位置会有变化，显示插值需要接受轻微的显示滞后。
   - **观察技巧**：选择 AI 输入源，让角色连续移动，看「同一帧两种画法」对照框。金黄色圆点是不插值（立刻放在当前物理位置），蓝色圆点是插值（在上一物理点与当前点之间）；二者的偏差放大 8 倍，真实物理坐标始终一样。暂停后单步两帧通常能更明显地观察差距。
   - **修复**：曾存在自动播放时每帧默认使用插值快照，导致关闭开关只维持瞬间、下一帧被恢复的问题。现在每个画面逻辑帧都会读取插值开关，并用同一份 world 数据计算显示位置。对照框计算两个显示位置但不额外推进模拟、也不逐帧创建新的状态快照。
5. 创建 3 个测试实体，暂停后点“销毁最近一个”；GC 中出现待释放项而 ALL 还保留该实体。再单步一帧才真正调用 dispose 并从 ALL 移除。标记的对象不再参与更新。
6. 开启自动运行，剩余测试实体等待 2.4 秒自动 destroy；注意：**自动 TTL 属于本课模拟机制，并非原版所有 Entity 默认自带 TTL**。

## Deepnight 真实源码

| 原版位置 | 真实职责 |
|---|---|
| Entity.ALL / GC | 两个 FixedArray，容量上限为 1024 |
| Entity.cx/cy、xr/yr、attachX | 格子与格内坐标拆分，转换到像素 |
| Entity.vBase/vBump/VelocityArray | 基础速度和外力速度合成 |
| Entity.new、destroy、dispose | 构造登记 ALL；延迟销毁标记；释放资源及从 ALL 移除 |
| Entity.preUpdate、fixedUpdate、frameUpdate、postUpdate、finalUpdate | 冷却/动作、运动、每帧逻辑、渲染参数、末尾位置信息 |
| Game.garbageCollectEntities | 从 GC 遍历执行 dispose，清空 GC |
| SamplePlayer.preUpdate/fixedUpdate | 采样玩家输入暂存 walkSpeed；在固定更新阶段应用运动 |

原版 Entity.fixedUpdate 不是本课的简单像素移动。它按 dxTotal / dyTotal 求出细分步数，推进 xr/yr 并处理网格边界；SamplePlayer 还实现了地板和墙壁碰撞。本课做了教学简化。

## 性能与分配审计

- ALL/GC 预留 1024 个容器槽位，**不代表同时创建 1024 个 Entity**。
- 构造函数每实体创建 cd、ucd 两个 dn.Cooldown，另外有 RecyclablePool(15) 的 ChargedAction 池、VelocityArray(15)、Sprite 和矩阵等。
- 捆绑 deepnightLibs 的 dn.Cooldown 默认底层 RecyclablePool 会预创建 512 个 CdInst。于是一个 Entity 的两个 Cooldown **总计预创建 1024 个 CdInst**，不代表它同时拥有 1024 个正在运行的计时器。
- 假设有 100 个 Entity，单是两个冷却池就涉及约 102400 个 CdInst；这属于对象数量的估算，不能据此直接编造字节数。对大量实体可评估更小的初始池、按需扩容、共享时间轮或对于无回调的简单延时使用时间戳，但必须验证行为语义和热路径成本。
- Game 每一阶段遍历 Entity.ALL 中的实体；destroy 标记＋帧末统一 dispose 避免在迭代过程即时从容器删减，注意容器遍历和内存分配成本都需用真实 profiler 衡量。

## 自测

能否解释为什么逻辑坐标和视觉插值坐标不同？为什么 destroy() 不等于 dispose()？为什么 30Hz 物理与 60Hz 显示能够共存？为什么真实玩家和教学 AI 可以切换而不重建 Hero？为什么“Entity 是基类”并不等于“Entity 是零开销”？