ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零构建月球着陆模拟器:物理模拟与状态机实战

从零构建月球着陆模拟器:物理模拟与状态机实战

最近我把一个存了很久的想法落地了:做一个纯粹的月球着陆模拟器,项目代号就叫 Apollo-11。名字不复杂,但它把我的目标说得很清楚——让玩家用有限的燃料,手动控制一艘着陆器,在起伏的月面上找到一块平地,减速、调整姿态,最后安全着陆。这个小项目并不追求“大作”级体验,而是想做成一个能跑在浏览器里、打开就能玩的技术演示,同时把游戏开发里最核心的物理模拟、状态机、碰撞检测这些事,扎扎实实地串起来。

我自己在写的过程中最大的感受是:一个看上去“只有几个按钮”的小游戏,拆开之后全是细节。物理公式选哪一套、碰撞检测采样几个点、状态切换谁先谁后,都会直接决定最终手感。所以这篇文章想把整个项目的设计思路、关键实现和踩坑过程都摊开来讲。如果你正想做一个类似的物理小游戏,或者单纯想看看一个小型模拟器是如何从零长出来的,这篇整理应该能给你一份可以直接参考的操作方案。

1. 项目整体设计与思路拆解

1.1 为什么是浏览器里的着陆模拟器

刚动工的时候,我列过几个技术方向:用游戏引擎做一套 3D 模拟,或者用原生语言写桌面窗口程序,甚至考虑过硬件套件加传感器的方式。后来全部否掉了,最终选择了最朴素的“浏览器 + 2D Canvas”。

原因很简单:目标场景不需要复杂的 3D 渲染。月球着陆的核心体验在于“对速度和姿态的控制”,2D 平面表现这个主题完全足够,而且能大幅降低渲染成本和交互复杂度。浏览器的好处是零安装、零依赖,打开 HTML 文件就能跑。对于技术分享和改动演示来说,这是最省事的载体。再加上现代浏览器对 Canvas 2D 的支持已经非常稳定,性能足够撑起一个常驻的模拟循环,完全不需要凑热闹引游戏引擎。

另一个考虑点是学习成本。如果用重型引擎,工程脚手架会盖过核心逻辑,读者很难看清物理模拟是怎么和游戏循环耦合的。而纯 JS + Canvas 写出来的代码,状态更新、碰撞检测、UI 绘制全部摊在眼前,几乎不需要额外解释工具链。当时我给这个项目定的技术标准只有三条:逻辑层和渲染层分离,物理模拟可配置,状态切换清晰可读。

1.2 物理模型:该简化的地方绝不复杂

着陆模拟器最容易犯的错,是物理模型越做越复杂。一开始我也想过加入自转修正、多轴惯量、燃料重心偏移,甚至在飞船上模拟姿态控制力矩。后来发现这些内容在一个 2D 小游戏里不但难以被玩家感知,还会让数值调试变成一场灾难。

最后采用的物理模型是平面二维运动,只考虑三个核心作用力:垂直方向的重力、引擎产生的推力、以及由姿态决定的水平方向分力。月球重力加速度固定为 1.62 m/s²,没有空气阻力,没有风力。小游戏里玩家能感知到的就是“重力把飞船往下拉,引擎推力把飞船往上顶”,这两个力的对抗已经足够形成紧张感。

公式上,每一帧更新速度时使用经典的运动学积分:

  • 速度更新:vx += ax * dt,vy += ay * dt
  • 位置更新:x += vx * dt,y += vy * dt
  • 加速度来源:g = 1.62,引擎推力向量按照飞船当前朝向旋转。

引擎的推力方向直接由飞船的朝向决定,这个设计很直观。玩家按下“左”键时,飞船逆时针旋转,推力方向也跟着转,于是速度中会出现水平分量。如果不调整朝向,飞船就会一边飘一边落;如果调整过度,又会往反方向甩出去。物理模型越简单,玩家的“直觉”反而越可靠。

1.3 核心状态机:从待机到登月

整个项目里最容易被忽略又最值得设计的一块,是状态机。没有状态机的时候,我试过用一堆布尔变量去判断当前是否允许输入、是否显示结算面板,结果不到半小时就出现了组合爆炸。飞在天上时按“开始”键能重置飞船,失败时还能继续喷引擎,这种逻辑混乱很快逼我停下来。

最终定义了几组稳定的状态:

  • READY:显示初始界面,飞船停在发射台上,等待玩家点击“起飞”。
  • FLYING:游戏主阶段,玩家可以通过键盘控制飞船的引擎开关、旋转方向和推力大小。
  • LANDED:成功着陆状态,飞船停稳,显示任务完成,终止输入响应。
  • CRASHED:失败状态,飞船已撞击地面,显示失败原因,终止输入响应。
  • OUT_OF_FUEL:燃料耗尽,进入失控滑落状态,此状态下引擎不再响应,但仍保留物理模拟。

状态之间的跃迁只允许在明确事件下发生。比如只有 FLYING 状态下燃料耗尽才进入 OUT_OF_FUEL;只有 OUT_OF_FUEL 或 FLYING 状态下发生撞击才进入 CRASHED。这样一来,任何一帧更新只需要查看当前状态决定要不要执行物理积分、要不要处理输入,逻辑路径非常干净。

2. 核心细节解析与实操要点

2.1 坐标系与渲染层分离

这个项目里我踩过最大的一个坑,是差点把物理坐标和屏幕像素坐标混在一起。早期原型里直接让飞船的 x、y 用像素值驱动,结果想改屏幕尺寸时,所有物理参数全部作废,飞船动不动就飞出了视野。

后来强行约定:物理世界使用“米”作为单位,渲染层负责把米转换成像素。转换函数集中在两个地方,一个是从世界坐标到屏幕坐标,另一个是从屏幕坐标到世界坐标。这样做的好处非常明显:重力加速度 1.62 m/s² 直接按照物理世界数值写,不需要转换成“多少像素每秒平方”;碰撞检测全部在世界坐标里做,和浏览器窗口大小无关。

渲染层在做的事是:根据视口中心点和缩放比例,把世界坐标的 (x, y) 映射到 Canvas 的 (screenX, screenY)。

function worldToScreen(worldX, worldY, camera) { const screenX = (worldX - camera.x) * camera.scale + canvas.width / 2; const screenY = (worldY - camera.y) * camera.scale + canvas.height / 2; return { x: screenX, y: screenY }; }

画面缩放到什么程度,只影响表现,不影响模拟。我在实际调参时,会直接在代码里手动改 camera.scale,让视野从“月球表面大画幅”切到“飞船特写”,物理参数完全不用动。这个设计后来帮我节省了大量调试时间。

2.2 着陆判定:速度、角度、落点

模拟器真正核心的玩法点,是着陆判定怎么定义“成功”。我参考了很多同类游戏的做法,最终没有采用“接触到地面就算着陆”的粗暴逻辑,那样玩家会很容易利用高速横擦地面蒙混过关。

我的判定规则由三个条件共同构成:

  1. 垂直速度小于 2 m/s。垂直速度是相对月面法线方向而言,在这个 2D 场景里就是 vy 的绝对值。
  2. 水平速度小于 1 m/s。要求飞船横向漂移也要足够小,毕竟现实中没有摩擦力让飞船在接触瞬间“刹住”。
  3. 飞船倾角绝对值小于 10 度。着陆器必须保持基本上直立,歪着插进地面不算成功。

这三个阈值直接变成常量写在判定模块顶部。每个阈值都不是拍脑袋定的,我把 g=1.62 代入试算过:垂直速度 2 m/s 意味着玩家在最后一秒还有约 1.2 米的减速余地;如果阈值设成 3 m/s,玩家会明显觉得“明明摔得挺重也算成功”。设为 2 之后,手感会紧张但不苛刻。

碰撞检测部分,我没有用像素级检测,而是把飞船抽象成底部两个端点的线段。每一帧取飞船底部的左右两点,检测它们和地形的交点。只要有一个点穿越到了地面高度以下,就认为发生了接触碰撞。之所以不用飞船中心点,是因为中心点离底部还有很长距离,看起来明明要触地了,中心点仍在地形上方很远处,导致撞击判定严重延迟。

2.3 燃料、重力与任务进度

燃料系统是整个游戏里“压力”的主要来源。我设计燃料总量为 100 单位,最大推力设置为 18 m/s²,燃料消耗率是推力值乘以固定系数。也就是说,玩家长时间满推飞行会快速耗尽燃料,而用低推力和滑翔策略则能飞得更久。

燃料和推力的关系,采用的是线性比例模型:

const consumptionPerSecond = currentThrust * 0.012; fuel -= consumptionPerSecond * deltaTime;

如果当前推力为 18,每秒就要消耗 0.216 单位燃料,100 单位大约只能支持 7 分多钟的全马力运行,但实际飞行中很少持续全推,所以可玩性还行。

任务进度的显示也花了一些心思。除了高度和剩余燃料,我还增加了“当前垂直速度”“当前水平速度”“倾角”三个读数。目的很直接:让玩家在操作时能像看仪表盘一样实时调整策略,而不是凭感觉去猜。调试时这些数据也是我的重要参考,用文字印在 Canvas 右上角,比开 DevTools 去查变量直观得多。

3. 实操过程与核心环节实现

3.1 主循环:固定时间步长是底线

游戏循环的第一版我直接用 requestAnimationFrame 驱动,每帧计算 dt,然后直接更新物理。看起来理所当然,但很快就发现问题:当电脑性能波动或者浏览器标签页短暂失去焦点再回来时,dt 会突然变大。假设某一帧 dt 变成 0.2 秒,物理积分会一下子往前推进很多,飞船可能直接从天上穿到地下,判定直接失败。

这种情况在 Web 平台上几乎无法避免,最稳妥的解法是固定时间步长 + 累加器。每次 requestAnimationFrame 回调都计算真实的时间差,把它累加到 accumulator 里,然后以固定步长(比如 1/60 秒)循环消费这个时间差。这样无论帧率怎么波动,每一步物理更新所看到的时间步长都是稳定的,模拟结果可复现。

核心循环结构大致是这样:

function gameLoop(timestamp) { if (!lastTime) lastTime = timestamp; let frameTime = (timestamp - lastTime) / 1000; lastTime = timestamp; if (frameTime > 0.1) frameTime = 0.1; accumulator += frameTime; while (accumulator >= step) { update(step); accumulator -= step; } render(); requestAnimationFrame(gameLoop); }

补充说明一下:代码里把单帧最大时长限制在 0.1 秒,是为了防止标签页长时间挂起后进入“追帧地狱”。如果用户切出去几十秒再回来,累加器会积压大量步长,物理世界会以极快的速度瞬间跑完,这显然不是我们想要的。限制掉最大帧时间后,最多只补几帧,现象就会自然恢复。

3.2 键盘与触屏输入处理

输入系统要同时考虑桌面和移动端。键盘上我绑定了方向键和 WASD:上/W 增加推力,左/A 左转,右/D 右转,下/S 或空格用来点火(也可以作为刹车辅助)。触屏上则直接在画面底部绘制三个虚拟按钮:左转、点火、右转。

这里有一个很容易忽略的设计:键盘事件的按下和抬起需要跟踪,而不是用 click 一次性触发。玩家按住方向键时,飞船应该持续旋转;松开后才停止。所以我用了一个 Set 保存当前按下的键,在每帧物理更新时查询这个 Set 来决定是否施加力矩。

const keys = new Set(); window.addEventListener('keydown', (e) => { keys.add(e.code); if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) { e.preventDefault(); } }); window.addEventListener('keyup', (e) => { keys.delete(e.code); });

随后在 update 里判断:

if (keys.has('ArrowLeft') || keys.has('KeyA')) ship.angle -= turnSpeed * dt; if (keys.has('ArrowRight') || keys.has('KeyD')) ship.angle += turnSpeed * dt; if (keys.has('ArrowUp') || keys.has('KeyW') || keys.has('Space')) engineOn = true;

这样还能够方便地支持快捷键组合,尤其“按住方向键同时点火”这种常见操作,用 Set 判断比一系列布尔变量要干净得多。触屏虚拟按钮的实现则是在 pointerdown/pointerup 事件里往同一个 Set 里添加或删除对应的虚拟按键值。逻辑完全复用。

3.3 地形生成与视觉反馈

地形模块直接用高度图实现。最开始的方案是手写一段固定数组,但看起来太“假”,也没有可重复性。后来采用了简单的分段随机生成:将水平方向切成若干段,每段给出一个高度值,再用线性插值连接。为了不让地形出现直角折线,我额外做了一次平滑处理,将每个高度点与前后两点取平均。

着陆点选择也做了特殊处理。在生成地形时,我会强制在画面中间附近保留一段较平缓的高度区间,并把这段区间标记为“推荐着陆区”。实际视觉效果就是一片比较明显的平地,玩家会自然地把它当作目标。这比让玩家在随机山脉中找一条可能存在也可能不存在的平路要友好得多。

视觉反馈上,除了飞船的实时角度和推力火焰位置,我还加入了几个小细节:速度过大时,飞船周围会出现红色警示线;倾角过大时,底部会画出方向箭头;燃料低于 20% 时,燃料条会开始闪烁。这些反馈全部在渲染层完成,不干扰物理判定,属于“纯表现”功能,但很能提升游玩的紧张感。

4. 常见问题与排查技巧实录

4.1 引擎没动,数值却一直在飘

项目做到中期时,我遇到一个很奇怪的现象:明明没有按任何键,飞船的 vy 却在持续增大,甚至还会横向漂移。第一反应是“是不是哪里的物理公式写错了”。检查之后发现,加速度计算没有任何问题,问题出在 requestAnimationFrame 的 dt 单位上。

在一段时间里,我将 timestamp 的单位当成了秒直接参与计算,但 timestamp 是毫秒。于是一步物理更新相当于真实时间 1000 倍的 dt,重力加速度自然看起来被放大了无数倍,飞船瞬间被“吸”向地面。

排查办法很简单:在渲染层把 dt 和当前帧序号一起显示在屏幕上,观察数值是否合理。当发现 dt 一直在 0.016 附近而物理世界却“跳秒”时,立刻就能锁定是单位换算问题。后来我也养成了一个习惯:所有物理相关参数都要用世界单位先跑一遍纯逻辑测试,再套渲染层。纯逻辑测试时只输出 x、y、vx、vy,不画任何图形,定位问题会快很多。

4.2 明明看起来没碰到,怎么判了撞击

第二次比较难缠的问题是碰撞检测“提前触发”。飞船从高空下落,底部距离地面还有一小段肉眼可见的间隙,系统却直接判定 CRASHED。

一开始我怀疑是碰撞阈值设置太严格,但我需要区分“垂直速度阈值”和“接触检测”。后来加上了调试用的线框渲染,把飞船的碰撞盒和地形高度线画出来,才看到真相:地形的高度数据是离散采样的,采样点之间用线段连接,而飞船在这个间隔内穿过了连接线,导致检测到的接触点位置比视觉表现的地表线“更早”命中。

解决方式分成两步。第一步,增加地形采样点密度,把所有点之间的距离从 1 米压缩到 0.2 米,线段长度缩短后,穿透误解明显减少。第二步,修改碰撞检测逻辑,不只检测飞船底部端点是否低于地形高度,还检测两个连续帧之间飞船底部线段和地形线段是否存在交点,用线段相交测试替代简单的点高度比较。

线段相交检测的代价虽然略高一点,但考虑到这一步不是每帧对所有线段执行,而是先查找飞船水平位置附近的地形段,再只检测相邻两段,性能完全不是问题。修复之后,“看着没碰实际碰了”的误判基本消失了。

4.3 状态机卡在“着陆成功”不可重开

还有一个很隐蔽的状态问题:成功后点击“重新开始”时,飞船又飞起来了,但 UI 上仍显示“LANDED”,按钮无响应,只能刷新页面。

检查后明确,问题出在“重置逻辑不完整”。我在 reset 函数里确实把飞船位置、速度、燃料都重置了,却没有重置状态机和输入的按键状态。典型的场景是:玩家在空中一直按着方向键,着陆成功后松手,但键盘事件可能被浏览器吞掉,导致 keys 集合里还残留一个按键;重置后 update 帧执行,检测到该按键存在,立刻给飞船加了角速度,但状态机仍在 LANDED,于是又产生了异常。

修复方法是在重置函数里执行三个步骤:第一,设置状态为 READY;第二,调用一个方法清空 keys 集合(模拟所有按键弹起);第三,重置累计时间、暂停标志等所有运行时变量。

function resetGame() { ship.reset(); keys.clear(); gameState = GameState.READY; fuel = 100; accumulator = 0; lastTime = 0; }

这个“重置时要清理输入”的教训后来也用在暂停功能上。暂停解除后如果不清空按键,就会有一飞机自己乱转的体验。多数这类问题都可以通过强制输入状态重置来避免。

做这个小项目的过程中,最大的体会是:模拟器类游戏把“确定性”看得很重。物理不能用拍脑袋的帧率来驱动,碰撞不能靠近似感觉来判定,状态切换更不能指望几个布尔变量互相配合。每一个看起来无关紧要的细节,最后都可能在某一帧变成玩家面前的 bug。如果你也打算尝试类似的物理模拟项目,我建议第一版就把固定步长、状态机和坐标转换写好,再把着陆判定阈值做成可调参数,这样后续所有手感打磨都会顺利很多。最后再分享一个小技巧:给游戏加一个“慢动作模式”,把步长从 1/60 降到 1/180,调试碰撞和玩家操作时就能看清每一帧到底发生了什么。这个小功能平时不起眼,但排查问题时比任何日志都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进