如果你也喜欢那种一眼能看完整个逻辑链的小项目那 Apollo-11 应该挺合你的胃口。这不是什么大厂产品也不是真火箭任务代码而是一个我断断续续写了两个多星期的纯前端月球着陆模拟器。名字起成 Apollo-11算是致敬经典。核心玩法一句话讲清楚你操控一艘拥有有限燃料的着陆器在重力作用下不断下坠需要在跌落之前调整推力和角度稳稳停在带有坡度的月面平台上。它能解决什么问题对普通玩家来说是几分钟的挑战对开发者来说它把物理模拟、Canvas 渲染、碰撞检测、粒子系统、输入控制这些前端基础能力全部塞进了一个可以独立运行的 HTML 文件里。新手看代码不会太痛苦老手也可以拿它来验证一些优化思路。如果你正在找课程设计、面试作品集或者单纯想练手的东西这个项目很值得拆开看看。1. 项目标题与整体设计1.1 “Apollo-11”到底是个什么项目严格说Apollo-11 不是一个游戏引擎项目也不是什么高并发系统它就是一个人花两周时间做的“月球软着陆模拟器”。你有一个着陆器底部有一个向上的推进器燃料有限月面有重力你需要让着陆器垂直速度足够低、横向偏移足够小同时坡度不能太夸张否则就算落地那一刻没炸系统也会判定为失败。我在最开始其实纠结过一个问题为什么非要叫“Apollo-11”因为这个项目对标的是那种非常经典的 Lunar Lander 玩法也就是最早在图形终端上流行的着陆小游戏。那个玩法最大的魅力在于规则足够简单但物理参数稍微调一档手感就完全不一样。叫 Apollo-11一方面是为了让人一眼知道主题另一方面也是告诉自己这项目得做到“可降落”而不是简单做一个动画演示。需求拆解阶段我把功能分成四块渲染层月面背景、着陆器、尾焰、烟雾、仪表盘物理层重力加速度、推进器推力、燃料质量消耗、速度与位置积分交互层键盘按下/松开控制推力大小、左右喷口调整姿态判定层接触月面时判断是否安全着陆、是否坠毁这四块听起来很常规但实际写起来每一块都有大量细节。尤其是判定层它决定了玩家体验的“真实感”。如果判定逻辑写得太宽容玩家会觉得没挑战写得太苛刻新手一下午都过不了关又会劝退。1.2 功能需求拆解想清楚再动手做这种个人项目最大的坑就是一上来就写代码。我早期的几个小项目都吃过这个亏画面上东补一块西补一块最后逻辑自己都理不清楚。Apollo-11 这次我先把每个模块的输入输出写清楚。物理模块输入是当前时间、推力百分比、燃料量、姿态角输出是下一帧的坐标、速度、加速度。这个模块必须与渲染完全解耦不能一边算一边画否则后面做 FPS 独立或者录屏回放会很痛苦。渲染模块输入是物理模块算出来的位置和姿态输出是 Canvas 上的一帧画面。这里有个原则渲染永远只读取状态不修改状态。哪怕是尾焰的粒子位置也不应该在渲染循环里反过来影响物理运算。交互模块输入是键盘事件输出是推进器的“期望状态”。它不是直接改物理量而是修改一个中间变量比如throttle 0.1或者throttle - 0.1这样玩家手感更平滑也方便后续接入触控或手柄。判定模块输入是着陆器底部的几个触点与月面地形的碰撞结果输出是“继续飞行 / 安全着陆 / 坠毁”三选一。之所以要用多个触点而不是直接拿中心点去判断是因为着陆器有宽度一条腿先碰到月面而另一条腿悬空时情况完全不同。我把这些写在一张 A4 纸上然后才开始写第一版代码。事实证明这一步省下来的时间远远超过我提前花掉的半小时。1.3 技术选型解析为什么用 Canvas 而不是 WebGL网上这类项目很多有人用 Three.js有人用 PixiJS甚至有人直接用 CSS 动画加 SVG。我在 Apollo-11 里最终选择的是原生 Canvas 2D没有引入任何渲染库。很多人会问都 2025 年了还在手写 Canvas是不是太原始了我的考虑很简单2D 月球着陆模拟器需要的图形复杂度并不高主要就是矩形、梯形、圆形、几条线段、一些粒子点。Canvas 2D 的 API 完全够用而且没有额外的构建成本和依赖体积。WebGL 更适合需要大量三角形、顶点变换、光照阴影的场景。如果一开始上 WebGL光是矩阵运算、着色器调试就够写满一周项目重心就全跑偏了。另外原生 Canvas 在桌面端浏览器上的性能足够跑到 60 FPS尤其是只画十几个粒子的时候压根不需要 GPU 参与。Canvas 2D 还有几个隐性的好处第一代码可以直接复制到任意在线编辑器里运行第二截图和录屏非常方便第三后续如果要移植到小游戏平台Canvas 的逻辑可以原样保留只需要替换渲染适配层。对个人项目来说这已经是最优解。2. 核心模块拆解与实现思路2.1 手写物理模拟重力、推力和燃料消耗物理模拟是这个项目的心脏。我用的不是现成物理库而是自己写的一个极简 2D 动力学模型。模型的假设如下着陆器是一个质点但有朝向角推进器产生的推力方向与着陆器纵轴重合向下喷则产生向上的加速度喷口偏转可以产生水平方向的力同时改变姿态角。我们定义状态变量x, y着陆器中心在画布中的位置vx, vyx、y 方向的速度angle着陆器当前的朝向角fuel剩余燃料throttle发动机油门取值 0.0 到 1.0每个物理步长dt按以下顺序更新根据当前油门计算推力加速度accelThrust throttle * enginePower / mass叠加月球重力加速度accelGravity gravity计算总加速度ax -sin(angle) * accelThrustay accelGravity - cos(angle) * accelThrust更新速度vx ax * dt; vy ay * dt更新位置x vx * dt; y vy * dt更新燃料fuel - throttle * fuelConsumeRate * dt这里有一个新手很容易踩的坑推力方向和重力方向的关系。着陆器的推进器是向下喷的产生向上的推力但如果你把喷口向左偏转来产生横向推力实际上着陆器会受到一个反向的力矩同时推力矢量的水平分量也会改变。为了简化我没有做完整的刚体动力学而是把推力分解为两个方向上的加速度同时单独用angle的变化速率来控制角度。这样做的结果是手感比较“街机”但足够好玩。关于参数最初我把重力设成地球重力 9.8 m/s²但月球表面重力应该大约是 1.6 m/s²。如果完全模拟月球重力着陆会非常轻飘几秒钟才能看到明显下降。反而经典月球着陆游戏里的手感更像是“慢速地球重力”。我最后用了一个折中值gravity 1.8推力加速度enginePower 2.2这样玩家感觉既有月球感又不会无聊到想睡觉。2.2 月球地形生成用噪声造出起伏表面月面不能是一条平直线否则着陆难度太低了。我用了基于随机数加平滑插值的一维地形高度生成算法。简单说在画布底部沿着 x 方向每隔一段距离设置一个高度点然后在这些点之间用余弦插值生成连续高度。这样做既能保证地形不突兀又不需要引入复杂的噪声库。算法步骤确定地形分段数segments 80对每个分段点生成一个基准高度baseHeight范围在画布 40% 到 70% 高度之间对相邻分段点做余弦插值height low (high - low) * (1 - cos(pi * t)) / 2其中t是 0 到 1 的插值系数在最终高度上叠加一个小的抖动值模拟小块碎石这里有个细节余弦插值产生的曲线在连接处会有平滑的过渡但可能在视觉上缺少“坑坑洼洼”的感觉。我额外叠加了一个低频正弦分量让地形呈现大的起伏再叠加高频抖动。如果你只做随机数画面会非常噪只做平滑插值又会像一条波浪线。多尺度叠加才是关键。着陆平台的位置则需要单独定义。我在地形上预设了两个“安全区”它们的宽度比着陆器宽度宽出大约 1.5 倍坡度小于 5 度。物理上玩家只要在一个安全区里接触月面并且垂直速度低于阈值就会判定为成功。为了让玩家知道哪里能降落我在渲染时用轻微发光描边标识安全区。2.3 碰撞检测与着陆判定最难的部分碰撞检测是整个项目里最磨人的部分。着陆器不是圆形也不是单点它有一个从中心向两边扩展的“底盘宽度”。我采用的方案是将着陆器底部边缘分为两个触点左触点和右触点再在中心增加一个触点。每一帧把这三个点的世界坐标算出来然后判断它们是否穿过了地形高度线。具体做法遍历地形高度数组找到触点所在的 x 区间然后对高度做线性插值得到该 x 位置对应的月面高度groundY。如果触点的 y 坐标大于groundY说明已经穿过月面。此时要记录穿透深度并根据触点位置和速度矢量决定事件类型。着陆判定条件我用了一张表判定项安全着陆阈值说明垂直速度vy 2.5竖直下落速度不能太快水平速度abs(vx) 1.2横向漂移不能太大否则会滑翻姿态角abs(angle) 10°着陆器接近水平触地点至少两个触点同时接触地面防止单腿着陆后侧翻听起来很标准但实际调参时的坑很多。比如垂直速度阈值定成 2.5单位是每秒多少个像素我沿用的是 Canvas 坐标一帧 16ms速度单位就是 px/s。我最初把阈值设成 2.0结果连续十几次测试都不容易过关后来发现是物理步长导致瞬时速度偏大。把一个dt内的位移除以 16ms 之后数值跳动很厉害。最后我改成在碰撞发生后回退到上一帧的位置重新计算从上一帧到这一帧的速度并且用这个速度而不是瞬时速度来判定。这样判定结果稳定很多。另外一个关键点是不能只做“接触时判定一次”。如果帧率掉到 30 FPS物理步长变大着陆器可能在一帧内直接穿过整个地形出现穿模。这个问题我放在后面的常见问题部分详细说。3. 实操过程与关键代码解析3.1 游戏循环与主循环结构Apollo-11 的主循环用的是标准的requestAnimationFrame但物理更新和画布渲染不是每帧都等量执行。为了让低速设备也能有稳定物理我实现了一个固定时间步长的累加器。简单写出来是这样的let lastTime performance.now(); let accumulator 0; const fixedDt 1 / 60; function gameLoop(timestamp) { let frameDt (timestamp - lastTime) / 1000; lastTime timestamp; // 限制单帧最大时间防止切后台回来后卡死 if (frameDt 0.1) frameDt 0.1; accumulator frameDt; while (accumulator fixedDt) { updatePhysics(fixedDt); accumulator - fixedDt; } render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);固定时间步长加上累加器是一个非常重要的做法。如果你直接在requestAnimationFrame里用frameDt做物理更新那么当显示器是 120Hz 时模拟速度会比 60Hz 快一倍。很多玩家反馈“这个游戏怎么越来越难”其实就是这个原因。我在这里还做了另外一个很实用的事把物理输入和渲染输出分开了。updatePhysics永远是 60 次每秒而render会尽量跟随显示器的刷新率。这样在 144Hz 屏幕上画面看起来无比顺滑但物理难度仍然保持一致。有人可能会担心累加器循环如果多次循环会导致位置跳跃。但实际上因为每次都只累积固定fixedDt误差会被控制住。3.2 着陆器的受力模型具体到updatePhysics内部我维护一个对象lander里面装着位置、速度、角度、角速度、燃料和油门。核心更新代码如下const GRAVITY 1.8; // 竖直加速度px/ms^2 换算后的简化值 const ENGINE_POWER 2.2; // 额定推力加速度 const FUEL_RATE 0.35; // 每秒燃料消耗百分比 const MAX_ANGULAR_SPEED 2.0; // 每秒可转动的角度上限 function updatePhysics(dt) { // 油门不是立刻生效而是以每秒约2.5的速度逼近目标 const targetThrottle keys.up ? 1.0 : 0.0; const throttleDiff targetThrottle - lander.throttle; if (Math.abs(throttleDiff) 2.5 * dt) { lander.throttle Math.sign(throttleDiff) * 2.5 * dt; } else { lander.throttle targetThrottle; } // 燃料检查 if (lander.fuel 0) { lander.fuel - lander.throttle * FUEL_RATE * dt; if (lander.fuel 0) lander.fuel 0; } // 推力加速度 let thrust 0; if (lander.fuel 0) { thrust lander.throttle * ENGINE_POWER; } // 左右喷口只影响角度不影响平动简化处理 if (keys.left) lander.angle - MAX_ANGULAR_SPEED * dt; if (keys.right) lander.angle MAX_ANGULAR_SPEED * dt; lander.angle clamp(lander.angle, -30 * DEG, 30 * DEG); // 加速度分解 const sinA Math.sin(lander.angle); const cosA Math.cos(lander.angle); const ax -sinA * thrust; const ay GRAVITY - cosA * thrust; lander.vx ax * dt; lander.vy ay * dt; lander.x lander.vx * dt; lander.y lander.vy * dt; }这一段看下来可能有人会觉得“推力方向居然这么简单”确实我在真实项目中刻意省略了力矩对角度的影响。玩家按左右键时是直接改变角度而不是通过横向喷口力矩去翻转。这样做的原因只有一个降低门槛。如果做一个完整刚体模拟玩家会发现自己无论怎么按键着陆器都像醉汉一样摇晃学习成本太高了。这个取舍在个人项目里是完全合理的。3.3 粒子系统让尾焰看起来像那么回事尾焰是吸引眼球的重要部分我用一个简单的粒子数组实现。每帧根据当前throttle释放数量不等的粒子粒子从着陆器底部喷口位置生成初始速度方向向下并且带有随机扩散随后粒子在重力作用下继续下落并逐渐变透明变大最后从数组中移除。const particles []; function spawnParticles(dt) { if (lander.throttle 0.05) return; const rate lander.throttle * 60; // 每秒最多60个粒子 const count Math.floor(rate * dt) (Math.random() (rate * dt) % 1 ? 1 : 0); for (let i 0; i count; i) { particles.push({ x: lander.x Math.random() * 8 - 4, y: lander.y 12, vx: Math.random() * 0.6 - 0.3, vy: Math.random() * 1.2 0.4, life: 0.4 Math.random() * 0.2, maxLife: 0.6, size: 2 Math.random() * 2, alpha: 1 }); } }粒子更新和渲染我没有写得特别花哨只是把粒子颜色从亮黄渐变到暗红再用globalAlpha和arc()画圆点。这已经是性价比极高的方案。如果后续想优化可以把粒子换成精灵图或者加一个简单的发光合成但现阶段已经足够营造“点火推进”的感觉。还有一个容易被忽略的点尾焰的方向应该和推进器喷口方向一致。当着陆器倾斜时粒子的初始速度也要跟着倾斜否则玩家的视觉会立刻察觉虚假。我把粒子速度做了角度旋转vx * cosA这类变换保证粒子始终沿着喷口方向喷出。这样的细节很容易被人当成“手感好”的标准。3.4 HUD 与操作手感调优HUD 部分是让玩家知道“自己还有多少燃料”“当前速度是否致命”的关键。我在右上角绘制了三个数字高度、垂直速度、燃料百分比在左下角画了一个简单的姿态指示器。都比较常规这里不展开了。唯一想提的是怎么把 HUD 做得不干扰游戏画面我给了纯色背景条和 0.75 的半透明底文字用等宽字体保证即使背景复杂也能看清。操作手感上有一个我自己反复调整的细节油门响应。如果按一下方向键油门立刻从 0 跳到 1那么每次点按都会让着陆器产生剧烈颠簸玩家很难微调。我加了一阶低通滤波也就是前面代码里的throttleDiff逼近逻辑。这个逼近速度2.5/s是我反复试出来的。太快点按敏感太慢玩家会觉得延迟严重。现在这个值在键盘操作下能明显感觉到“点按微调”和“长按全力推进”的分界。4. 常见问题与排查技巧实录4.1 碰撞“穿模”搞不定项目做到一半时我遇到最崩溃的问题就是着陆器直接穿过月面掉进地形下方然后从画面底部消失。排查后确定是物理步长和碰撞检测的时序问题。我在前面用了固定时间步长 1/60 秒按理说不会穿但由于我在地形采样时用的是“当前帧位置”而不是“上一帧到当前帧的轨迹”一旦速度很大一帧内位移超过了地形高度变化的梯度就会发生穿透。解决方式有两个我都用了在碰撞检测前先判断当前帧位移dx,dy如果dy大于某个阈值就做二分插值把碰撞点收敛到更接近实际的边界。检测到穿透后不直接修正到地形表面而是把位置回退到上一帧然后按比例缩小时间步长继续模拟。第二种方式更稳妥但实现复杂。在 Apollo-11 里我采用的是第一种因为大多数情况下速度并不会快到需要完整连续碰撞检测。只要把安全着陆阈值控制好几乎不会出现高速穿模。另外还需要处理一个边界情况着陆器从地形下方月面背面向上穿出。如果只用y groundY判断它就会在地形内部被反复推挤。我的办法是记录上一次是否已经接触地面如果上一次已经在地面下方则不再重复触发碰撞逻辑而是强行把y设为groundY - 1并清除垂直速度的反方向分量。这虽然不是物理精确但对一个模拟器来说完全够用。4.2 帧率不稳定导致模拟结果不一致有段时间我发现在办公室的 144Hz 显示器上着陆器落地非常轻但在普通笔记本 60Hz 屏幕上却很容易坠毁。原因特别简单我最初把物理时间步长绑定到了requestAnimationFrame的时间戳上导致模拟速度受显示器刷新率影响。前面提到的主循环累加器就是这个问题的最佳解决方案。但使用累加器会遇到另一个坑如果玩家切换后台标签页回来时timestamp的差值可能是十几秒累加器会瞬间循环上千次模拟直接跳到不可逆的崩溃状态。所以我加了if (frameDt 0.1) frameDt 0.1;这个上限。这个 0.1 秒的数值不是我随意定的它对应的是人眼能感知的最小卡顿阈值的两倍既能兜住后台切回又不会让玩家明显感觉时间被加速。4.3 着陆判定过于严格或宽松一开始判定条件里我要求左右两个触点都必须同时接触地面结果玩家发现自己明明成功着陆但因为一点点倾斜就反复“坠毁”。后来我改成“至少两个触点接触且第三个触点离地高度小于 2px”相当于允许一点点倾斜。温和了很多。反过来水平速度阈值如果设置成 2.5 px/s着陆器会像滑冰一样在月面上漂出去好远才停下玩家也会觉得莫名其妙。经过多次测试1.2 这个值比较合理既可以允许小幅横向漂移又不会让着陆变成“漂移挑战”。这里还是想强调阈值一定要做成可配置参数放到文件顶部不要写死在碰撞函数里。否则每次调参都要翻代码特别麻烦。4.4 键盘输入和动画帧不同步还有一次诡异的现象玩家快速连按左右键时着陆器的角度响应时而灵敏时而无响应。排查后发现我没有处理键盘事件里的“重复触发”。按住左键不放时浏览器会持续产生keydown事件并且每次事件的repeat属性为 true。我原本是按下keydown就把keys.left设为 true松开时设为 false但重复触发时会不断地把keys.left强制设为 true这本来没问题。问题出现在我同时监听了keyup事件却没有过滤repeat。一旦玩家在某个很短的瞬间松开并按回事件顺序可能出现keydown(keyup)的错乱导致内部状态和物理按键实际状态不一致。解决办法很简单在keydown里判断e.repeat如果是 true 就直接忽略在keyup里则不管是否 repeat 都正常处理。这样就不会出现状态卡死的情况。5. 可以继续折腾的几个方向5.1 加入真实轨道力学现在的物理模型是平面重力场方向始终竖直向下。如果想让项目更硬核可以把重力改为从月球中心指向着陆器的向心力模型同时加入绕月飞行需要的水平初速度。这样着陆器可以从高空轨道逐渐变轨到着陆区玩法直接从“小游戏”变成“轨道交会训练器”。这个方向改动量不大但物理计算的复杂度会上一个台阶。我做 Apollo-11 的下一步计划就是把这个模型加进去。新的状态变量会多一个polarR和polarTheta用极坐标系替代直角坐标系。好处是重力场计算直观坏处是地形和着陆器的渲染都要做极坐标到屏幕坐标的转换代码量会明显增加。如果你也想做建议先把直角坐标版本稳定跑通再在分支上尝试。5.2 多语言与移动端适配目前 Apollo-11 只支持键盘控制在手机上完全没法玩。移动端适配不只是添加触屏按钮还包括处理“长按”和“快速点按”的手势差异。我准备在外面包一层虚拟摇杆左边控制角度右边控制油门。这会让交互模块从键盘事件变成抽象输入接口从而同时支持键盘、鼠标、触屏三种输入。抽象输入接口是一个值得做的重构把keys.left这种直接布尔值改成input.leftPressed()这样的方法内部再根据设备类型决定读取键盘状态还是触摸状态。这样虽然代码会多一层但后续接手柄、接语音控制都会轻松很多。测试时也更容易模拟不同输入。5.3 数据回放与自动测试个人项目如果不带任何可测试性后期改参数很可能把之前的手感弄坏。我在完成基本玩法后给 Apollo-11 加了一个“录制与回放”功能每次降落过程把物理状态按固定频率记录成一段 JSON回放时直接用录制数据驱动渲染不再实时计算物理。这套机制好不好用非常好用。调参时可以直接录制一次“成功降落”然后改完参数再播放对比两次轨迹差异。未来如果加自动测试思路也很清晰写一个 AI 控制器在无人工输入的情况下反复尝试降落用回收率评判参数是否合理。对个人项目来说这已经是“工业化”程度很高的做法了。回看整个 Apollo-11它没有多高深的技术但足够完整。一个从零开始、没有框架支撑、却能让人玩出紧张感的小项目背后全是对细节的取舍。如果你也打算做一个类似的东西我建议不要直接抄现成代码而是先把自己想要的手感参数写出来再一点点调。这个过程中踩过的每一个坑都会变成你下一版项目最好的参考。