最近我把一个存了很久的想法落地了做一个纯粹的月球着陆模拟器项目代号就叫 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 * dtvy ay * dt位置更新x vx * dty 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 着陆判定速度、角度、落点模拟器真正核心的玩法点是着陆判定怎么定义“成功”。我参考了很多同类游戏的做法最终没有采用“接触到地面就算着陆”的粗暴逻辑那样玩家会很容易利用高速横擦地面蒙混过关。我的判定规则由三个条件共同构成垂直速度小于 2 m/s。垂直速度是相对月面法线方向而言在这个 2D 场景里就是 vy 的绝对值。水平速度小于 1 m/s。要求飞船横向漂移也要足够小毕竟现实中没有摩擦力让飞船在接触瞬间“刹住”。飞船倾角绝对值小于 10 度。着陆器必须保持基本上直立歪着插进地面不算成功。这三个阈值直接变成常量写在判定模块顶部。每个阈值都不是拍脑袋定的我把 g1.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调试碰撞和玩家操作时就能看清每一帧到底发生了什么。这个小功能平时不起眼但排查问题时比任何日志都管用。