最近我把一个拖了很久的个人项目重新翻了出来代号就叫Apollo-11。名字取自人类第一次登月任务但内容跟航天工程没有一毛钱关系——它是我用浏览器技术写的一个登月舱着陆模拟器玩家需要操控一台虚构的登月舱在燃料有限的情况下从高空缓缓降落到一片程序生成的地形上同时保证船体姿态不失控、速度不超标、燃料不耗尽。项目全部跑在前端不依赖任何后端服务打开网页就能玩核心代码也就几千行。断断续续写了大概一个多月踩了不少物理模拟和交互设计的坑最后产出的效果我很满意。这篇文章就把整个项目从设计到实现的完整思路、关键代码、调试过程和踩坑记录都拆开讲一遍希望能给正在做类似交互模拟或者浏览器端物理仿真的朋友一些参考。我当时做这个项目的初衷很简单想验证一下“纯前端能不能做出手感还不错的实时物理模拟”顺便把 Three.js 的渲染、自研的数值积分、以及 UI 反馈这几块完整地串起来。市面上有很多现成的物理引擎比如 Cannon.js、Ammo.js但登月着陆这个场景的关键不在刚体碰撞而在于持续受力下的姿态控制——反推力、重力、水平速度、角速度之间的博弈。用通用物理引擎反而很难调出手感所以我决定自己写一个轻量的 2D 平面物理模型。没错我最终选择的是 2D 侧视角而不是 3D 自由视角。原因后面会细说先说结论2D 物理模拟是把握手感的捷径3D 会带来大量与玩法无关的技术负担。整个项目用到的技术大概这么几块Three.js 做渲染自己写的半隐式欧拉积分做物理推进地形用一维噪声函数生成高度剖面UI 层直接用原生 DOM 叠加输入处理同时支持键盘和触摸。代码组织上分成了五个模块场景初始化、地形生成、飞船状态、物理积分、控制与 UI。下面我从设计思路开始一步一步往下拆。1. 项目设计与整体思路为什么要做为什么这么做1.1 这个项目解决的核心问题是什么登月舱着陆这个玩法核心体验可以浓缩成三个字稳、准、省。稳是姿态稳定不能翻准是落点要落在安全区省是燃料要精打细算。这三者互相牵制想落得准就要频繁调整推力方向就会消耗燃料想省燃料就要降低下降速度但下降太慢会让水平漂移时间变长落点更难控制姿态一旦倾斜就必须用侧推修正侧推又消耗燃料……这个互相牵制的循环就是游戏性的核心来源。它跟开车很像油门给多了速度快但难转弯给少了又上不了坡。我的目标就是让这个博弈在所有玩家手里都能产生正向反馈——新手能感受到“我好险但成功了”老手能追求“全程不废一滴燃料”的极限操作。这个项目解决的第二个问题是怎么在浏览器里做出有即时反馈的物理手感。很多前端模拟项目做出来像PPT——物体动了但完全没有重量感。重量感来自加速度的累积和迟滞你不能让飞船立即响应按键也不能让它在放手后立即定住。要让玩家感觉到“这是一个几百公斤的金属物体在发动机作用下缓慢移动”必须在物理模型中加入时间积分和惯性延迟。这靠的是模拟本身不是渲染技巧。1.2 为什么选 2D 而不是 3D这里要说一个我一开始没想明白、后来才彻底想透的问题玩法需要什么样维度的物理空间。我最初是想做 3D 的毕竟 Three.js 渲染一个飞船太容易了。但做完基础 demo 之后发现问题3D 模式下玩家需要同时控制俯仰、滚转、偏航、主推、侧推键位极其复杂而且“下方”这个方向感在自由相机里很混乱。绝大多数玩家很快会失去耐心。后来我换了个思路这个玩法本质上是个2D 平面问题——飞船在竖直平面内运动只需要控制水平力、垂直力和俯仰角就够了。只要把视角固定为侧视把飞行问题简化为“x-y 平面内运动 旋转”玩家就能把全部注意力放在“怎么协调推力”上而不是“我该按哪个键”。于是最终方案是Three.js 依然使用 3D 渲染所有对象只在一个平面上运动相机固定为正交侧视。这样既保留了 Three.js 的着色、光照、粒子效果又避开了 3D 操作复杂性。实际测试下来这个取舍非常正确。有几个朋友试玩几分钟内就能掌握操作而之前 3D 版本他们连降落都做不到。1.3 功能范围和预期体验项目最终交付的功能是这些一个由噪声生成的横向滚动地形带安全着陆区和危险区域标记可操控的登月舱主推向上、左右侧推调整位置、姿态角控制物理参数重力加速度、推力加速度、燃料消耗率、最高速度限制HUD高度、垂直速度、水平速度、燃料剩余、姿态角碰撞判定安全落地、硬着陆损坏、坠毁难度分级基于燃料量和地形平坦度的参数调整触摸设备兼容虚拟按键这个范围控制在一个人两三周能完成的量级不贪多。做完后我对“浏览器内实时物理模拟的项目边界”有了更清晰的认识它完全可以承载有意思的玩法但前提是计算量必须小、物理模型必须克制、渲染必须在 60fps 内稳得住。2. 技术选型为什么用 Three.js 而不自己写渲染2.1 Three.js 是一层很合适的“皮”很多做前端模拟的同学纠结“到底要不要为了一个简单项目引入 Three.js”。我的观点是如果你的场景里有大量几何体、需要光照雾效、需要相机控制那用 Three.js 是合理的。月球表面需要凹凸起伏的地形、着陆时的粒子尾焰、飞船的 3D 造型这些自己用 Canvas 2D 画会很痛苦。Three.js 在这个项目里承担的角色是纯粹的展示层它不参与任何物理计算所有物体的位置、旋转都交由物理模块输出渲染层只做三件事——同步实体坐标、处理光照材质、生成粒子效果。这样职责分离后调试某一边的问题不会污染另一边。我建议所有做模拟项目的人都遵循这个模式物理是一层渲染是另一层中间靠一个单向数据流连接。千万不要让渲染代码里冒出“根据帧率推算速度”的逻辑那会让问题排查变成灾难。Three.js 的加载方式我选的是 npm 包形式这样能方便地使用 ES6 模块和 tree-shaking。只引入了需要的部分Scene、PerspectiveCamera、正交相机、Geometry、MeshStandardMaterial、Points 等。这里有个细节Three.js 从 r125 之后改动比较频繁我建议锁定版本号不要用 latest否则某天 API 变了你的代码可能一夜之间全部跑不起来。我在项目里锁定的是某个稳定版本开发过程中没有遇到一次奇怪的兼容性问题。2.2 自研物理模型而不是用物理引擎前面说了不用物理引擎的原因是手感难调这里展开讲。通用物理引擎擅长的是多刚体碰撞模拟——桌球、箱子、车辆核心逻辑是“物体接触并产生反作用力”。但登月着陆的重点不在碰撞而在持续受力的积分过程重力永久作用、发动机推力由玩家控制、燃料质量变化影响加速度、角速度的阻尼。引擎里的 At 公式是死的但它要求你以固定步长驱动 world.step()还要处理摩擦系数、恢复系数等一大堆在这个场景里根本用不到的参数。更麻烦的是引擎的数值稳定性建立在合适的质量和尺度上你的游戏世界尺度稍有不匹配就会出现物体穿模或者爆炸式弹跳。自己写物理模型的好处是每个参数对应一个直观的语义调参就是调手感。比如“主推力加速度 22m/s²”在游戏世界里这个参数调整时你直接知道是“更猛”还是“更肉”。“燃料消耗率”也是一个常数直接和“总燃料量”合算出最大推喷时间。这种透明性是引擎给不了你的。当然自研物理有一个前提要求你必须理解牛顿运动定律在时间离散化下如何表达。这个我在下一节详细讲。2.3 渲染层和物理层的数据约定在我的项目里物理世界用的是米渲染世界用的是Three.js 单位。为了方便我直接让 Three.js 的单位就是米这样省去了换算。飞船模型长宽大约 4 米 × 4 米地形高度范围在 0 到 30 米之间相机视野宽度约 120 米。这个比例会让飞船在屏幕上显得大小适中。坐标系约定Three.js 的 Y 轴向上我的物理世界也沿用 Y 向上X 向右Z 固定为 0。物理模块输出pos.x, pos.y, angle渲染模块直接把这些值赋给 mesh 的position和rotation.z。就是这么简单。每帧流程是这样requestAnimationFrame └─ 计算当前帧与上帧的间隔 dt └─ 物理模块更新(dt, 控制输入) └─ 渲染模块同步位置/旋转 └─ HUD更新和碰撞检查物理模块的内部状态只包含位置向量、速度向量、角度、角速度、燃料质量。不需要维护历史数据。输入被抽象成一个controls对象{ mainThrottle: 0或1, leftThrust: 0或1, rightThrust: 0或1, angleControl: -1/0/1 }。HUD 实时读取这些数值显示给玩家。3. 地形生成与场景搭建月面是怎么来的3.1 一维噪声生成地形剖面月球表面最重要的视觉特征是起伏和环形山。我的实现方案是用一维的 Value Noise叠加两个不同频率的层生成一条高度剖面线height(x)。大致思路是对整数点做随机高度赋值然后用线性插值或平滑插值获得连续点的高度。具体代码如下// 生成地形高度数组 function generateTerrain(width, segmentCount) { const heights []; for (let x 0; x segmentCount; x) { const h1 valueNoise(x * 0.01) * 18; // 大起伏 const h2 valueNoise(x * 0.05 100) * 6; // 小起伏 heights.push(baseHeight h1 h2); } return heights; } // 一维 Value Noise function valueNoise(x) { const xi Math.floor(x); const xf x - xi; const t smoothstep(xf); // 平滑插值 return lerp(randomValue(xi), randomValue(xi 1), t); }这里randomValue是一个基于整数索引产生确定性伪随机数的函数返回值在 0~1 之间。为了让同一地形每次加载都一样我用了一个自定义的哈希函数而不是 Math.random 直接取值。这样可以保证测试时可复现也方便后续加“种子关卡”功能。地形渲染上我在地形剖面上等距放置顶点构建一个长条形的三角形网格贴上一张简单的月球表面纹理。纹理是用 Canvas 2D 动态生成的——随机取一些灰白色噪点、暗色斑点再加上几次模糊处理。全部运行时生成不需要加载外部图片资源。这样项目的部署就是一个纯静态文件挪到哪都能跑。3.2 安全区与危险区的判定逻辑游戏需要告诉玩家“哪里可以着陆”。我在地形生成后做一次扫描对每一小段地形计算坡度。如果某段区域内的高度差小于一个阈值比如每 10 米水平距离高差不超过 1.5 米就标记为可着陆区。可着陆区在地面上用半透明的绿色标线绘制危险区用红色标线。这里有一个值得注意的细节飞船碰撞判定用的不是直线距离而是飞船底部到地形高度数组的采样。我在每次碰撞检测时都会把飞船的 x 坐标换算成地形顶点索引范围然后在飞船底部范围内线性插值出地形高度。如果飞船底部低于地形高度就认为接触到了地面。这个方案比多边形的逐三角形碰撞检测快得多而且在这个玩法里完全够用。function getTerrainHeightAt(x) { const idx (x - worldLeft) / segmentWidth; const i0 Math.floor(idx); const i1 Math.min(i0 1, heights.length - 1); const t idx - i0; return lerp(heights[i0], heights[i1], t); }地形网格有几千个顶点但碰撞检测只在飞船局部做采样所以运行期开销可以忽略。我只在飞船接近地面一定距离内启用碰撞检测超过这个距离直接跳过。3.3 星空背景与视觉氛围星空背景我用了 Three.js 的 Points 系统生成 800 颗随机分布在大球面上的星星点颜色白蓝相间亮度随机。相机不动背景球跟随相机位置但旋转固定这样视差效果正确且实现简单。尾焰粒子是另一组 Points存放飞船底部的推进器位置。当主推开启时每个粒子以随机初速度向下方发射同时受到“模拟重力”下坠并在 0.4 秒后消失。粒子位置更新在渲染循环里完成没有单独物理系统。这段代码只有大概 20 行但视觉上对“推力感”的提升是决定性的。灯光方面场景用一个方向光模拟太阳补一个比较弱的半球光保证飞船背光面不至于全黑。材质我用了 MeshStandardMaterial飞船主体是银灰色氧化层部分是亚光的深灰色着陆架用泛着一点点橙色的金属感。整体环境色调偏灰蓝符合月面氛围。4. 物理模型与运动控制手感的灵魂在哪里4.1 半隐式欧拉积分短时间步长下的稳定性选择物理积分的核心是运动方程。每个时间步 dt 内加速度 a (推力合力 重力) / 当前质量速度 v a * dt位置 p v * dt如果严格按这个顺序执行就是显式欧拉。但显式欧拉有个致命问题步长偏大时能量可能不守恒系统逐渐“膨胀”或“颤抖”。而半隐式欧拉有时也叫 semi-implicit Euler把速度更新放在位置更新之前顺序变成velocity.x acceleration.x * dt; velocity.y acceleration.y * dt; position.x velocity.x * dt; position.y velocity.y * dt;这个简单的顺序变化能显著提高稳定性特别适合我这种帧率波动大的浏览器场景。因为生成环境里 dt 是(当前帧时间 - 上一帧时间) / 1000如果掉帧到 30fpsdt 就变成 0.033 秒。显式欧拉在这种步长下飞船会明显飘半隐式欧拉的误差依然可控。旋转运动的处理方法相同角加速度由侧推和姿态控制产生角速度 角加速度 * dt角度 角速度 * dt。这里我额外加了一个很小的角速度阻尼系数模拟“稀薄大气和喷气式姿态控制”的稳定效果让飞船不会转个不停操作手感更收敛。4.2 飞船力学模型主推、侧推、姿态控制飞船的力学模型是这样的质量初始为 800 kg燃料 120 单位主推推力 20000 N方向沿飞船本体的“上方”消耗燃料 1.2 单位/秒侧推推力 4000 N分别配置在左侧和右侧方向沿飞船本体的“左右”消耗燃料 0.15 单位/秒姿态控制角加速度 0.8 rad/s²不消耗燃料理由用压缩空气实现简化处理重力加速度取 1.62 m/s²月面换算成加速度就是主推提供 20000 / 800 25 m/s²重力提供了 1.62 m/s² 的向下加速度。所以主推开启时垂直方向的净加速度大约是 23.4 m/s² 向上。从静止开始一秒内就能把速度拉回到 23 m/s。显然这个推力太猛了不适合精细操作。所以我在参数调优时把推力调到了 12000 N这样净加速度约 13.4 m/s²可控性大幅提升。这里有个细节燃料消耗导致质量变化。当燃料减少时同样推力产生的加速度变大。物理模块里用currentMass baseMass fuelRemaining更准确地可以写成 800 120 约等于 920 kg。我保留了质量随燃料减少的动态变化这样尾段燃料快用完时飞船会变得轻稍加推力就会飞起来这个微妙的非线性变化给操作增加了深度。4.3 输入映射与控制手感优化键盘布局W主推A左推D右推← / →逆时针 / 顺时针偏转相对屏幕注意主推的方向不是“向上”而是飞船本体的上方。当飞船旋转了 30 度主推产生的力就包含垂直和水平分量。这是整个操作中最难理解但也最有趣的地方你必须像驾驶一艘真正的飞船一样先调整姿态再打开推力推力才会把你推向你想去的地方。这个机制让游戏学会了“事前的姿态管理”而不是“事后的方向调整”。手感调优方面我做了三件事第一键盘输入有轻微延迟缓冲。按键不是直接作用于推力的瞬间而是以每秒 10 次的节拍平滑过渡到目标状态这样玩家看到的不是突兀的加速跳跃而是力逐渐施加的渐进感。第二侧推的响应速度比主推快因为侧推的用途是微调需要精确方向。第三姿态控制的角速度做了限幅最大角速度不能超过 0.6 rad/s避免玩家疯狂按方向键导致飞船变成陀螺。// 输入到推力的平滑过渡 controls.mainThrottle lerp(controls.mainThrottle, targetMainThrottle, 0.15); controls.leftThrust lerp(controls.leftThrust, targetLeftThrust, 0.3); controls.rightThrust lerp(controls.rightThrust, targetRightThrust, 0.3);这些参数都是反复调试后的结果。物理模拟项目里 80% 的灵性来自调参不是来自公式。公式只决定“能不能动”参数决定“好不好玩”。4.4 燃料管理和推力方向的可视化提示燃料管理是本作的核心限制。燃料条在 HUD 上很显眼剩余燃料不到 20% 时 UI 会从绿色变黄色再变红色伴随低频闪烁。推力方向的可视化很有意思我在飞船周围画了一个“力矢量指示器”——从飞船质心引出一根箭头显示主推力的方向和大小。这极大降低了新手的学习成本——他们能看到“我把飞船转了个方向所以推力也指向那里”而不是苦恼“为什么按了上飞船却斜着飞”。箭头颜色随推力大小从淡蓝变成亮橙这个反馈很直观。5. 着陆判定与任务目标怎么定义“成功”5.1 安全着陆、勉强着陆、坠毁三档判定着陆判定不是简单看“接触地面”而是看接触瞬间的状态。降落的评判逻辑是飞船底部与地形的距离小于 0.02 米时触发接地判定垂直速度小于 2.5 m/s 且水平速度小于 1.0 m/s姿态角小于 10 度 →安全着陆垂直速度 2.5~4.0 m/s或水平速度 1.0~2.0 m/s或姿态角 10~20 度 →勉强着陆着陆架受损但任务算完成垂直速度超过 4.0 m/s、水平速度超过 2.0 m/s、姿态角超过 20 度 →坠毁这个阈值我调了很多版。最初垂直接地阈值是 1.5 m/s但测试时玩家普遍会觉得太苛刻因为最后阶段的微调来不及。后来放宽到 2.5 m/s过率大大提高同时配合燃料的限制仍然有挑战。还有一个隐蔽但重要的细节接触判定后还要检查飞船姿态是否稳定。如果飞船以 25 度倾斜角触地即使速度很低也会因为重心偏移而继续翻转所以我加了一段二次判定接地后如果姿态角大于阈值会在 0.5 秒内触发“失稳坠毁”玩家看到的是飞船缓慢倾倒并爆发粒子效果。这段二次判定让“看起来落地了但其实没站稳”的情况变得可感知增加了一种紧张的层次感。5.2 评分体系与复盘数据每次降落结束后界面展示一张统计卡片包括落点偏差距离、垂直速度、水平速度、燃料剩余、判定等级。然后基于这些给一个星级评分5 星要求安全着陆、燃料剩余超过 40%、落点方差小于 5 米3 星要求安全着陆其他为 1 星。这个算法我用一个简单的加权公式计算function calculateScore(landing) { let score 0; if (landing.level perfect) score 60; else if (landing.level ok) score 30; else score 0; score Math.max(0, 40 * (remainingFuel / maxFuel)); score - Math.min(15, Math.abs(landing.offset) * 0.5); return Math.max(1, Math.round(score)); }评分的意义不只是玩家的成就感也是我调整参数的重要依据。我之后的测试都是记录几十次降落的数据分布看哪些参数导致玩家整体评分偏低或偏高做针对性调整。5.3 难度递进与关卡化设计为了不让人玩两分钟就腻我做了三个难度档位简单、普通、困难。差异表现在海平面高度越高相对下降距离越长安全区数量简单有 3 个困难只有 1 个燃料量简单是 150困难是 90地形起伏困难模式的地形噪声频率更高更颠簸关卡不是线性解锁而是玩家可以自由选择。每次进入会重新生成地形但种子可设置——如果你想和朋友比拼同一张地形的成绩可以用相同的种子生成完全相同的地形。这个功能用起来很有意思朋友之间互相挑战的成绩就变成了“硬件条件相同、技术决定胜负”的公平比拼。6. 实现细节与代码架构把模块组织成能维护的样子6.1 模块划分与单向数据流项目源码结构大致这样src/ ├─ main.js // 入口和游戏循环 ├─ scene.js // Three.js 场景创建 ├─ terrain.js // 地形生成 ├─ ship.js // 飞船模型 ├─ physics.js // 物理积分 ├─ input.js // 键盘触摸输入 ├─ ui.js // HUD和结果页 └─ particles.js // 尾焰粒子模块划分的核心准则是一个模块只知道自己该做什么不知道别人怎么实现。比如 physics.js 完全不碰 Three.js 的代码它只是接收输入参数和状态对象返回更新后的状态。input.js 不关心物理细节它只负责把按键翻译成控制指令。ui.js 用回调注册的方式接收“着陆事件”而不是主动轮询物理状态。这个设计让我在调试时幸福感很高如果飞船没有反应我先检查 input再检查 physics再检查 scene 的同步逻辑每一步都能独立验证不用在几个文件之间跳来跳去。// 物理模块对外 API export function createPhysicsWorld(params) { return { reset(startState), update(controls, dt), getState(), checkLanding(), }; }6.2 游戏循环与帧率波动处理浏览器游戏循环的基本套路就是 requestAnimationFrame但有一个坑拷贝 iMac两帧之间的间隔可能不是固定 16.67ms。如果碰到 120Hz 显示器间隔变成 8.33ms如果碰到页面卡顿间隔可能超过 50ms。所以我必须用真实时间差 dt并且给 dt 设置一个上限比如最大 0.05 秒防止物理模拟在长时间掉帧后产生“爆炸式补偿”。let lastTime performance.now(); function loop(now) { let dt (now - lastTime) / 1000; lastTime now; dt Math.min(dt, 0.05); // 防止掉帧后的突跳 physics.update(controls, dt); syncRender(physics.getState()); ui.update(physics.getState()); requestAnimationFrame(loop); } requestAnimationFrame(loop);还有一个心得不要在渲染循环里直接读写 localStorage 或做任何可能阻塞的操作。某些浏览器在低端设备上会卡顿如果卡顿发生在读取存储的地方整个游戏就越来越卡。我的解决方式是只在着陆判定完成后一次性写入成绩其他时候不访问存储。6.3 面向性能的优化减少无谓计算Three.js 场景中如果模型面数不高、顶点数不多基本不会成为瓶颈。真正消耗性能的是粒子、阴影和纹理更新。为了稳定 60fps我做了几项优化关闭不必要的阴影映射只用方向光加高光不用阴影粒子的 Points 大小固定不使用逐粒子纹理变化地形网格不动态重建只在生成时初始化一次每个粒子更新时复用数组对象不创建新对象这些优化做完后即使在低端 Android 手机上也能跑出 50fps 以上。粒子创建时我本来用new THREE.Vector3()生成每个粒子位置这会导致大量 GC 压力。改成预分配Float32Array并直接修改数值后GC 明显减少帧率也稳定了很多。这种细节在短期 demo 里看不出差别但如果你要做的模拟器要反复玩很久堆内存就会成为隐形杀手。7. 调试与调优实录那些一夜白头的经历7.1 速度数值与视觉不匹配初版物理模型调完我信心满满地跑了一遍结果发现飞船降落速度感完全不对——明明状态栏显示垂直速度只有 2m/s但看起来像流星坠地。原因很简单Theta dev2D 平面里的尺度“米”和视觉尺度“像素”不是一回事。我用 3D 场景表示 120 米宽的地形屏幕上显示的是约 800 像素宽那么 2m/s 的垂直速度对应的视觉速度是每秒移动约 13 个像素对肉眼的冲击力远小于真实感受。这个问题的解决方式是采用“速度感标尺”——在山体侧面添加一些可辨识的凹槽和凸起提供空间参照物同时把 HUD 里的速度数字放大加粗配合颜色变化红色警告。玩家看到颜色警示比直接看清数字更快。后来进一步优化是加入“速度条纹”飞船下降速度超过 5m/s 时屏幕边缘会出现几道白色拖影类似电影中的风线效果完全由 HUD 层绘制不涉及 3D 场景。它能有效传达“你现在往下坠得很快”的紧急感而不需要玩家盯着数字分析。7.2 坐标系混淆导致莫名翻车调试中碰到一次非常诡异的 bug飞船明明水平飞行姿态角却持续增大最后在空中翻转。排查了半天发现是旋转同步代码里写成了mesh.rotation.y state.angle而 Three.js 默认围绕 Y 轴旋转时物体看起来像绕竖轴旋转但我的物理模型里“角度”定义的是绕 Z 轴的旋转。结果就是画面和物理状态永远对不上——飞船“歪”的只是视觉物理世界却是水平的。修好后我是这么处理的物体绕 Z 轴旋转mesh.rotation.z state.angle相机朝 Z 轴负方向方向键 ← 让角度增加→ 让角度减小注意是顺时针 vs 逆时针这种坐标混淆问题对于初次写 2D 物理加 3D 渲染的人来说几乎必踩。我的经验是在做物理前先写一份坐标系约定文档包括哪个轴是“上”、哪个轴是“旋转轴”、正方向是顺时针还是逆时针。写文档这步只要花 10 分钟但在后续调试中能节省至少两个小时。7.3 手感从“飘”到“实”的调参历程“飘”是我收到最多的反馈飞船像在水里而不是在月球上。这个问题的根源是推力和质量的比值太高——飞船太容易加速太容易被改变速度。我当时把主推力设成了 20000N导致按一下 W 飞船就窜上天松开后减速又很慢操作非常不线性。我花了整整一个下午做参数扫描把推力从 20000N 一路降到 8000N每 500N 跑一次测试记录“完成一次成功着陆所需燃料”和“着陆后剩余燃料”两个指标。最终在 12000N 左右找到了最好的平衡点按住 W 约 3 秒能抵消从最高点掉落的初速度松开后重力又不会过快地拖拽飞船控制起来有“迟滞感”但不至于迟钝。另一个被忽视的参数是姿态角速度限制。初版允许角速度最大到 2 rad/s飞船转得飞快玩家根本来不及停住。后来我改成 0.6 rad/s相当于每秒转 34 度玩家即使按住方向键也能有充分时间反应过来松开。这个对比表明限制操作上限往往比增加操作自由度更让人感到舒服。人不是机器能精确控制的范围比想象中窄得多。7.4 粒子系统的一次性能事故尾焰粒子初版写得极其随意粒子数量 200每个粒子每帧都做随机位移、随机缩放、随机透明度变化。跑起来后掉帧严重手机尤其明显。查了下性能分析粒子系统的 CPU 占用率占到整个帧时间的近 40%。我把思路完全换掉粒子不是独立个体而是整体上的一个“粒子流”。每个粒子只在生成时确定初速度和生命周期之后按照简单的抛物运动更新位置不做任何随机变化。数量从 200 降到 60 个。效果反而更好——因为太多粒子看着一团浆糊反而看不清推力方向。视觉上少即是多性能上少绝对是快。8. 常见问题与排查技巧一份可以直接抄的速查表我在开发和测试过程中积累了一些常见问题的排查思路整理成表格方便大家对照。现象可能原因排查方法解决措施飞船完全不响应按键事件监听未绑定或焦点丢失在 keydown 打印日志确认触发监听全局window而非局部元素点击游戏区域后自动聚焦飞船缓慢漂移有未清零的侧推力打印 controls 对象各字段在 input 模块中每次松开按键时显式设置对应推力为 0飞船突然穿入地面碰撞检测的采样点太少打印飞船底部位置和地形高度扩大采样宽度从取 1 个点改为取 3 个点取最低值速度表数值跳变dt 过大未限制打印每帧 dt给 dt 设置上限 0.05s画面卡顿持续粒子数量过多或 GC用性能面板监测内存增长改用预分配数组降低粒子数量燃料用不完燃料消耗率太低计算最大能喷多少秒设置为 20~30 秒最大全推时间与任务时长匹配着陆判定太苛刻阈值设置过小统计多次测试数据分布把阈值放宽到 2.5m/s 左右并同时调节水平速度阈值除了表格里的那些“边开发边测试”是我最后要强调的一件事。这个项目最深的教训来自一次集中测试我连续调了两个小时参数以为手感已经很好了但找朋友试玩后才发现自己已经习惯了那些隐藏的物理特性而对方第一次接触满是困惑。开发者是游戏里最容易自欺欺人的人——因为你已经知道所有机制会下意识地弥补设计中不合理的地方。所以我后来每调完一轮参数都会让至少两个从没玩过的人立刻试玩观察他们哪里会卡住、哪里会乱按然后根据真实反馈再调。这种方式比任何自动化测试都更能发现体验层的问题。9. 项目之外的收获这类模拟项目的通用方法论做完这个项目我最大的体会是模拟类项目的核心不是“像不像”而是“手感顺不顺”。物理引擎可以给你精确的牛顿力学但精确不等于好玩。真正让玩家感到“真实”的往往是那些看起来不够物理、但恰好符合直觉的细节——比如输入延迟的平滑处理、速度警告的视觉刺激、落地瞬间的粒子爆发和画面震动。这些东西加进来之后玩家才会说“哇这落地太真实了”。如果你也想做一个类似的模拟小项目我建议你按这个顺序来推进先做出最简陋的物理模型和线框渲染能跑通了再考虑视觉然后花大力气调手感请外部玩家试玩、记录反馈最后再填充视觉细节和关卡内容。很多人一上来就做炫酷视觉结果物理手感完全不对后面想改推力和重力渲染又要跟着调整个返工成本极高。顺便分享一个实用的最后一步我给项目加了一个“回放模式”物理状态按固定步长记录到环形数组中着陆后可以回放刚才的全过程。这个功能调试时几乎没用但录屏做宣传素材时非常好用——可以把镜头固定在飞船的近距离视角看着尾焰喷射、姿态微调、最后稳稳落地配上几段数据统计整个观感完全不像是业余项目。以后如果继续扩展我会考虑给登月舱加入温度传感器、太阳能板朝向、不同落点的资源价值……但那些都属于玩法层的加法了。整个工程能在一周内从零搭到可以玩的状态也证明了浏览器端的物理模拟并没有门槛那么高关键是别让视觉和架构的复杂度吞噬掉调手感的时间。希望这篇文章的思路和代码片段能成为你下一个模拟项目的起点。