GameBase · Lesson 01
Entity 并不只是一个有坐标的对象。我们直接让角色运动、产生新实体、标记销毁,再逐帧检查:输入、物理、表现、生命周期与内存释放分别由谁管理。
① 亲手控制一个 Entity
📱 手机:按住下面的左右按钮移动;点「跳跃」或轻点游戏画面起跳;也可以切换 AI 控制。
电脑:A/D 或 ←/→ 移动;空格/W/↑ 跳跃。青色虚线和半透明影子都标记真实逻辑位置,蓝色实心角色才是 Sprite 显示位置。
为什么 30Hz 物理可以显示出 60Hz 的平滑运动?
Deepnight 的 Sprite 内插,和 Unity / UE 的做法到底有什么关系?先理解同一个位置为何能有两种“正确”的坐标,再观察外推预测错误。
先认清三个概念
FixedUpdate 已经计算出的实体位置。碰撞和玩法判断以它为准。
Sprite / Transform 最终在屏幕显示的位置。可以用不同算法计算,但不应反过来修改权威物理状态。
表示固定更新时间段内的进度,通常是 0~1。不是“Update 的位置”和“FixedUpdate 的位置”各占多少权重。
关键事实:当物理更新频率较低时,画面可能重复显示同一个物理坐标。插值只解决视觉阶梯感,并不会让碰撞检测真的变成 60 次/秒。
四个角色同时动:同一物理轨迹,四种显示方案
这里不是孤立的点或进度条。四个窗口的角色都使用同一份 30Hz 物理位置与速度,由同一个 60Hz 渲染时钟驱动;不同的只有 Sprite 的绘制坐标。先选「撞墙急停」,观察外推是否冲过障碍。
显示 = 当前物理位置
显示 = lerp(前一个, 当前, α)
显示 = lerp(前一个, 当前, 3α² − 2α³)
显示 = 当前 + 速度 × 剩余时间
青色虚线框:当前权威物理位置橙色虚线:前一个物理点蓝色角色:真正画出来的位置
注意:四个画面是共享物理轨迹的教学渲染,不是四套独立物理模拟,也不是 Deepnight 原版可执行文件。按「单步物理」时会先暂停,再推进同一个教学物理步;画面不断更新不会修改碰撞状态。
可选进阶:六种算法的精确数值计算(手动调整 α)
同一份物理结果,六种显示坐标
这是一个独立的公式讲解,并不声称在运行 Deepnight 原版游戏。它使用同样的物理样本计算多种算法,方便在手机上直接比较。
图里金色虚线是当前已经知道的真实物理位置。外推落在它前面不代表物理真的向前执行了;「未来结果」只是用于说明预测会不会出错的预设场景。初学时只选“撞墙停下”并拖 α,就够了。
四类常见方法,别把名字相似的算法混在一起
RenderX = CurrentX。无插值延迟,但物理步之间图片停住,再跳到下一位置。
RenderX = PreviousX + (CurrentX − PreviousX) × α。用两个已知端点,视觉轨迹简单、不会超过端点;代价是稍微滞后。Deepnight 选的就是它。
Smoothstep 让一段的头尾速度趋向 0;Hermite 还可使用端点切线/速度,让轨迹更符合期望运动。但参数错误可能产生过冲,逐物理步机械套 Smoothstep 也容易每步重新加减速。这两种不是作者当前 Entity 的默认位置插值。
RenderX ≈ CurrentX + Velocity × Δt。看起来领先、不必等下一次物理采样,但撞墙、急停、转向、受力都会使预测失准,并在真实更新后纠正。Deepnight 的 sprX/sprY 没使用外推。
进阶:为什么逐帧 Lerp(currentDisplay, target, 0.2) 不等于物理插值?
这种逐渲染帧“追目标”的写法属于指数平滑/缓动跟随:结果取决于历史显示值和系数(若未做帧率归一还取决于 FPS),且通常不能保证在固定时间到达目标。Deepnight 用的是确定端点和确定时间系数的 Lerp,而不是每帧做 spr.x = lerp(spr.x, attachX, 0.2)。这两者不能因为都调用 lerp() 就视为同一种调度算法。
Deepnight 的真实实现:Sprite 层自己计算
下面直接摘出冻结的 gameBase/src/game/Entity.hx、deepnightLibs/src/dn/Process.hx 关键语句。这里不是教学 JS 的伪代码。
// Entity.hx:在本 Entity 中保存上次 FixedUpdate 开始时的位置
var lastFixedUpdateX = 0.;
var interpolateSprPos = true;
// 每次 fixedUpdate() 一开始:
updateLastFixedUpdatePos(); // lastFixedUpdateX = attachX
// 读取当前“应该显示的 Sprite X”:
return interpolateSprPos
? M.lerp(lastFixedUpdateX, (cx+xr)*Const.GRID,
game.getFixedUpdateAccuRatio())
: (cx+xr)*Const.GRID;
// postUpdate():最终同步到 Heaps 的渲染对象
spr.x = sprX;
spr.y = sprY;
// dn.Process.hx:
return _fixedUpdateAccu / (getDefaultFrameRate() / FIXED_UPDATE_FPS);
具体执行顺序:物理更新前记录旧坐标 → 固定步推进碰撞、速度与坐标 → 普通画面帧按剩余累计时间算 α → 在 postUpdate() 写给 h2d Sprite。
重要边界:attachX、cx/xr 与碰撞仍是权威数据,插值只影响 spr.x/y。作者在手动瞬移的 setPosPixel()/setPosCase() 等路径也会同步 lastFixedUpdateX/Y,避免把大跨度瞬移显示成慢慢滑动。
为何这样做:GameBase 的 Entity 是作者自己的游戏对象,物理主要是自定义网格与速度积分,不依赖一个托管所有物体姿态的 Rigidbody 引擎。因此在 Entity 的渲染同步处加一个很小的 Lerp,就能以低复杂度获得平滑视觉,也方便按实体选择开关。
和 Unity / UE 的真正差别:不是数学优劣,而是管理层次
原版 Entity 自己记录旧物理位置,用 M.lerp(..., α) 求 Sprite X/Y,后处理时写到 spr.x/y。主要是自定义 2D Entity 的位置显示;不包含通用 Rigidbody 姿态/旋转/网络预测框架。
None / Interpolate / Extrapolate 是物理组件提供的配置;Interpolate 用过去的两次物理姿态(位置、旋转)来平滑显示,通常延迟一个物理更新;Extrapolate 用当前速度推测下一帧。Deepnight 的 Lerp 与 Unity Interpolate 数学思想一致,只是 Unity 在物理系统里管理这一过程。Unity 官方手册
UE 的物理 Sub-stepping 是将物理计算分成更小步以改善精度和稳定性,不能误称它就是 Sprite 插值。异步物理可以使用物理结果插值;网络角色的 CharacterMovementComponent 还会处理平滑服务器修正的位置。这是不同来源的时间差。UE Sub-stepping 官方说明 · UE CharacterMovement 文档
怎么选择:自己写小型 2D 网格物理,Deepnight 这种“物理数据与 Sprite 显示分离”的办法轻量、容易调试;Unity 有 Rigidbody 就优先研究官方插值开关;UE 要先确认你遇到的是物理子步问题、异步物理同步,还是多人网络平滑,而不是先找一个万能的 Interpolation 开关。
不要混淆另外两件事:相机跟随平滑是另一个显示层问题,可能放大或掩盖角色抖动;碰撞、射线和回放仍应依据权威物理位置,不能把预测的 Sprite 位置写回真实模拟。
进阶:为什么 Unity 官方还提醒 Physics.SyncTransforms?
启用 Rigidbody 的物理插值/外推时,物理系统会管理对应的 Transform。若你绕过物理操作直接改了 Transform,Unity 文档提醒需要注意 Physics.SyncTransforms() 的同步语义。这个“渲染姿态与物理姿态可能不同”的问题,和 Deepnight 将 spr.x 与 attachX 分开,本质上都体现了权威物理状态与显示状态不必相同。
刚才那一帧到底发生了什么?
游戏画面仍会流畅运行,但这里显示的是一次捕获的帧快照,不会每秒更新 60 次。点击「捕获当前帧」,或先「暂停自动运行」再「单步」,才能更新观察结果。
正在准备第一份快照…
这里的阶段来自刚才小游戏实际执行过的教学逻辑,不是另外启动的独立时间轴。
为什么 InputFrame 与 Entity 物理更新不一定同频?
在这个实验里,玩家/AI 输入每画面帧采样(60Hz),物理每两帧计算一次(30Hz)。这样的输入采样和物理解耦后,角色可以统一接受 AI、手柄或键盘动作,但只在物理步长中推进位置。原版 Game 同样有 fixedUpdate() 与 update() 等不同阶段;本课将具体顺序简化为可观察模型,不宣称逐条复刻 dn.Process 的内部调度。
创建、destroy 和 dispose 是三件事
创建的橙色实体会进入 ALL;请求销毁后先标记到 GC,帧末才统一释放。你可以暂停游戏观察“标记后尚未释放”的短暂状态。
销毁的性能和风险
本教学版本的 ALL/GC 是普通 JavaScript 数组;原版使用 FixedArray<Entity>,默认容量为 1024。原版 destroy() 只设 destroyed 并加入 GC;Game.garbageCollectEntities() 再遍历 GC 调用 dispose,清除资源,并由实体从 ALL 移除。延期清理减少在遍历更新对象时直接移除的干扰,但必须保证清理阶段一定执行,且重复 destroy 不要重复排入。
坐标到底为什么分成 cx 和 xr?
这里显示上面 Hero 在最近一次捕获快照时的坐标。该课程选用每格 48px 做示意;原版格子大小由 Const.GRID 配置。
x = (cx + xr) × GRID
当角色从 3 格走向 4 格时,cx 加一,而 xr 回到 0 附近;逻辑位置连续。可把它类比 Unity 的逻辑坐标与 Render 插值位置,但不是所有游戏都必须采用格内拆分。
对照真正的 GameBase 源码
以下代码与解释以固定版本的原作者仓库为依据;课程的 JS 物理、AI、按钮都只是独立教学实现。
Entity.hx(固定版本) · Game.hx(固定版本) · SamplePlayer.hx · 课程笔记
1. 原作者究竟把生命周期交给谁?
// Entity.hx:关键代码节选
public static var ALL = new FixedArray<Entity>(1024);
public static var GC = new FixedArray<Entity>(ALL.maxSize);
public final function destroy() {
if( !destroyed ) {
destroyed = true;
GC.push(this);
}
}
// Game.hx:帧末
for(e in Entity.GC)
e.dispose();
Entity.GC.empty();
这里的代码经过少量排版简化,但流程与原版一致。Entity.dispose() 会从 ALL 移除实体,并释放 Sprite、Cooldown 等持有的资源。
2. 同一个 Entity 为什么有这么多更新函数?
preUpdate() 更新游戏时间/真实时间冷却、状态影响和持续动作;fixedUpdate() 处理实体移动及速度;frameUpdate() 提供常规逐帧逻辑;postUpdate() 同步 Sprite 的显示坐标与形变;finalUpdate() 记录上一次显示位置。Game 负责调用这些阶段并在结束时清理。
3. 为什么一个 Entity 的内存成本可能比想象中大?
原版构造时同时创建 cd 和 ucd 两个独立的 Cooldown,并创建 RecyclablePool(15) 用于持续动作,还初始化速度集合、Sprite 和矩阵等。所用 deepnightLibs 的 Cooldown 默认会预先创建 512 个计时器实例:按这个实现计算,每个 Entity 会先持有两个计时器池,共计 1024 个 CdInst(不是说一定同时有 1024 个正在计时)。这是值得结合实际实体数量做内存审计的地方。
4. 玩家与 AI 共用输入应设计在哪里?
GameBase 的 SamplePlayer 在 preUpdate() 查询 ControllerAccess,把 walkSpeed 暂存起来,再在 fixedUpdate() 应用到物理。它没有统一的 AI InputSource。本课增加一个可切换的模拟输入源,只为验证我们讨论的“真人与 AI 都生成 MoveX/Jump,再进入相同运动机制”;这是我们提出的架构延伸,而非作者原版功能。
一课三问
选答案后会立即看到解释。