最近把脑子里放了很久的一个小游戏真正做完了:霓虹奔跑者 · NEON RUNNER 3D。这是一个第三人称 3D 霓虹跑酷游戏,打开浏览器就能玩,电脑用键盘,手机用触屏,断网也能跑。
在线试玩:https://yangdinghui.github.io/neon-runner-3d/

为什么做一个跑酷游戏
想做的游戏类型其实换过好几轮。横版平台跳跃要考虑关卡设计,开放世界更不现实,最后选跑酷,是因为它的核心循环足够简单:
- 规则一句话能说清:躲障碍、吃能量球、跑得越远分数越高;
- 不需要关卡编辑器,程序可以不断生成新内容;
- 3D 霓虹风格有辨识度,哪怕只有基础几何体也能做出效果;
- 一局几分钟,适合在浏览器里随手打开玩。
目标也定得很克制:零依赖、零构建,双击 index.html 就能玩。没有后端、没有数据库、没有登录,所有贴图和音效都由代码生成,整个项目就是一个普通文件夹。
技术选型:能少一层就少一层
项目没有用 React、Vite、Webpack,也没有 node_modules。页面就是原生 HTML + CSS + JavaScript,3D 部分用 Three.js:
- Three.js 锁在 r157,使用 UMD 版本,直接放在
vendor/three.min.js; - 官方从 r160 开始移除了 UMD 构建,所以这里不追新,稳定和离线可用更重要;
- 贴图用 Canvas 2D 程序化生成,音效用 WebAudio 合成,项目里没有一张图片、没有一个音频文件;
- 排行榜用
localStorage,成绩只保存在当前浏览器。
代码按职责拆成了几个文件:
js/
├── config.js # 所有可调参数、配色、画质档位
├── game.js # 主循环、镜头、碰撞、计分、状态机
├── world.js # 无尽赛道、障碍和能量球生成
├── player.js # 主角模型、跑跳滑铲动画和物理
├── touch.js # 触屏手势
├── audio.js # WebAudio 合成音效和 BGM
├── particles.js # 粒子、拖尾、冲击波
├── art.js # 程序化贴图
├── input.js # 键盘输入
└── leaderboard.js # 本地排行榜
把所有可调参数集中到 config.js 是这次做得最值的一件事。速度、重力、跳跃高度、障碍间隔、配色、粒子数量、触屏灵敏度都在一个文件里,调试时不用满项目找常量。
时间线:四个提交,一个下午
真正落到 Git 上的提交集中在 10 月 12 日下午,从第一版到真机修复一共四次:
| 时间 | 提交 | 做了什么 |
|---|---|---|
| 14:03 | 5cc80de | 第一版:4,079 行,核心玩法、场景、音效、排行榜 |
| 16:25 | 4984669 | 移动端适配:触屏、窄屏视角、画质分级(+652 / -76) |
| 18:09 | 837dd5b | 触屏改版:从固定按钮改成全屏手势(+274 / -245) |
| 18:15 | 82f8422 | 真机修复:pointerup 丢失导致手势卡死(+26 / -12) |
第一版提交时项目已经有 4,000 多行,说明大部分时间其实花在写代码之前的构思和边写边调上。后面三次提交则很典型:先让它能在手机上跑,再让手机操作顺手,最后处理真机上才会出现的边界问题。
核心设计:玩家没有动,是世界在后退
跑酷游戏最容易写歪的地方是坐标系统。如果让玩家一直向 -z 跑,跑得越远,坐标绝对值越大,浮点数精度会越来越差,障碍回收也要处理一堆相对位置。
这次换了一个思路:玩家 z 坐标恒为 0,世界向 +z 滚动。所有内容只记录一个「赛道里程 s」,渲染时用已跑里程 D 换算:
// 世界向 +z 滚动,玩家 z 恒为 0
// 所有内容用「赛道里程 s」记录位置,渲染时 z = D - s
seg.group.position.z = D - s;
o.mesh.position.z = D - o.s;
这样做的好处很直接:
- 玩家永远在原点附近,不会出现浮点漂移;
- 障碍物跑过回收线后,只需要把它的
s重置到远处,不用重新创建对象; - 赛道段、云海、远景楼群都可以用同一套里程逻辑循环复用。
障碍物生成也有一个细节:生成线放在雾外。雾的范围是 60 到 230,生成线在 240,玩家永远看不到障碍突然弹出来。开局还会先 prefill() 铺好前 150 米,让起跑阶段有一个缓冲,不会一上来就撞墙。
碰撞:低帧率下不能只测当前位置
高速跑酷的碰撞有一个坑:障碍物在一帧内会向后滚动 speed × dt。在 30fps 的手机上,速度 47 u/s 时一帧能移动 2.4 个单位,如果只判断「当前帧障碍物的位置和玩家是否重叠」,障碍物会直接从玩家身上穿过去。
所以碰撞用的是扫掠区间:把这一帧障碍物扫过的最前位置也算进去,形成一个覆盖整段位移的 z 区间,再和玩家包围盒做重叠检测。
const zPad = Math.abs(this.speed * this.speedMul * dt) + 0.06;
const hz0 = hz - d.halfD - zPad; // 本帧它扫过的最前位置
const hz1 = hz + d.halfD; // 它现在的位置
if (hz1 < box.z0 || hz0 > box.z1) continue;
if (box.x1 < hx - d.halfW || box.x0 > hx + d.halfW) continue;
if (box.y1 < d.yMin || box.y0 > d.yMax) continue;
这套判定在 60fps 和 30fps 下表现一致,不需要给低端机单独降速。所有运动也都用 dt 做指数平滑和积分,掉帧不会改变跳跃高度和手感。
视觉:白底上的霓虹,不能靠加法混合
一开始想的是常见的赛博朋克夜景:黑底、霓虹、辉光。但做着做着觉得黑底太常见,于是换成了「浅色小清新卡通 × 赛博朋克霓虹」——底色是白净柔和的天空和云海,霓虹只出现在赛道边缘、招牌、粒子和描边上。
这个方向带来一个技术问题:加法混合(additive blending)在白底上等于消失。黑色背景上发光的粒子,叠到白色背景上几乎看不见。所以粒子、拖尾和光带都改成了高饱和颜色的普通透明混合,靠颜色对比而不是亮度来制造「发光」的感觉。

场景里的东西基本都是程序化生成的:
- 地面条纹、云海、霓虹招牌、光点、云朵,全部用 Canvas 2D 画好再转成纹理;
- 主角「皮皮」是基础几何体拼的,没有外部模型;
- 卡通观感靠
MeshStandardMaterial的柔和漫反射、半球光、平行光软阴影,再加一个贴地的接触阴影球; - 远景楼群、云层、海面各自用不同的视差系数滚动,成本很低但层次够用。
音效:没有音频文件,全部现场合成
项目里没有任何 .mp3 / .wav。跳跃、落地、滑铲、拾取、受击、结算音效,都是 WebAudio 的振荡器和噪声加包络实时合成的。
BGM 是一个 D 大调五声音阶的 16 步循环,速度、踩镲密度和和声层会随游戏强度变化:跑得越快,节奏越密,层次越多。这样不用准备多段音频,也不会因为音频文件加载拖慢首屏。
浏览器有自动播放限制,所以第一次按键或点击之前不会出声。这不是 bug,是浏览器的策略;点击「开始奔跑」之后音频上下文才会被唤醒。
移动端:从固定按钮到全屏手势
第一版只考虑了键盘。移动端适配分了两步。
第一步是 4984669:加上触屏操作、窄屏自适应视角和画质分级。触屏最初用的是屏幕上的固定按钮,但实际在手机上玩的时候问题很明显:按钮挡住视野,拇指要来回找位置,双手操作很别扭。
第二步是 837dd5b:直接推翻固定按钮,改成全屏手势。
- 左右拨动:按住屏幕横向拖动,拨多远走多远,松手停在原地;
- 向上拨动:跳跃,快速连拨两次是二段跳;
- 向下拨动:下趴滑铲钻横杆,按住不放就一直趴着;空中下拨是快速飞坠;
- 全屏任意位置都能操作,两只手可以同时用,左手转向、右手跳跃互不干扰。
手势判定的关键是区分「横向转向」和「纵向动作」。如果只判断位移方向,斜着拨动很容易误触发跳跃,所以纵向位移必须明显大于横向位移:
if (!g.actionFired && Math.abs(dy) > CFG.touch.swipePx &&
Math.abs(dy) > Math.abs(dx) * CFG.touch.verticalBias) {
g.actionFired = true;
if (dy < 0) {
this._jumpQueue++;
} else {
this._slideQueue++;
this._holdDown++;
}
}
每个 pointerId 都有独立的手势状态,所以多指操作不会互相覆盖。手指落下处还会出现一个光环,转向时变青色,触发跳跃或下趴时变粉色并闪出「跳 / 趴」,几秒后有一条手势图例提示,降低第一次上手的门槛。
移动端还有两个容易被忽略的适配:
- 画质分级:根据机型和内存自动选择
high/mobile/low。手机端降低像素比、砍阴影分辨率、减少透明光带和远景数量。移动端的瓶颈通常是填充率,不是面数; - 窄屏视角:竖屏时按水平视角反算垂直 FOV,并压低镜头,否则竖屏只能看到赛道中间一条缝。手机建议横屏,但竖屏也能玩。
画质档位大致是这样:
| 档位 | 像素比 | 阴影 | 粒子上限 |
|---|---|---|---|
high | 2 | 1024 | 1400 |
mobile | 1.5 | 512 | 700 |
low | 1.25 | 关闭 | 420 |
真机踩坑:pointerup 丢了,手势会卡死
最后一版提交 82f8422 只有 26 行改动,但它是整个项目里最值得记录的一个 bug。
在真机上,如果手指从屏幕边缘或系统手势区抬起,pointerup 事件有时会被系统吞掉。结果就是:这个 pointerId 的手势状态一直留在 Map 里,角色会保持转向或下趴,后续手势也会被这个「幽灵手指」干扰。
修复分三层:
- 同一个
pointerId再次按下时,先清掉上一次残留的状态; - 同时监听
pointercancel,系统取消手势时也走结束逻辑; - 在
window上以捕获阶段监听pointerup/pointercancel作为兜底,保证即使操作层收不到事件,状态也能被清掉。
// 同一 pointerId 再次按下:上一次的 pointerup 可能丢了
if (this._pointers.has(e.pointerId)) this._endPointer(e.pointerId);
// 兜底:系统手势导致操作层收不到抬起事件时,也能清状态
for (const ev of ['pointerup', 'pointercancel']) {
window.addEventListener(ev, (e) => {
if (this._pointers.has(e.pointerId)) this._endPointer(e.pointerId);
}, true);
}
这个 bug 在桌面上永远复现不了,只有真机、只有手指从边缘抬起时才会出现。它也提醒我:触屏交互不能只靠模拟器验收,边界手势一定要在真手机上多试几次。
结果与复盘
现在这个游戏可以在线玩:
https://yangdinghui.github.io/neon-runner-3d/
源码在:
https://github.com/yangdinghui/neon-runner-3d
项目里还留了几个调试开关,方便继续改:
?debug:右下角显示状态、帧率、drawcall、粒子数;?q=high|mobile|low:强制指定画质档;?touch=1|0:在桌面上强制显示或隐藏触屏操作层。
这次开发下来,最大的几个感受是:
- 先让核心循环跑起来。跑酷的规则简单,第一版只要能跑、能跳、能撞、能计分,就已经可以判断好不好玩了;
- 参数集中管理。
config.js让调手感变成改几个数字,而不是在几千行代码里翻找; - 程序化资产很划算。没有美术和音频素材,也能靠几何体、Canvas 纹理和 WebAudio 做出完整的视听效果;
- 真机测试不可替代。pointerup 丢失、竖屏视野、移动端填充率,这些问题在桌面浏览器里都看不到;
- 限制范围。没有排行榜服务器、没有账号、没有关卡编辑器,反而让项目能在一个下午内形成完整闭环。
后面如果继续做,可能会加更多障碍组合、角色皮肤、成就系统,或者把本地排行榜换成云端排行榜。不过在那之前,先让「打开就能跑一局」这个体验保持简单,可能更重要。
如果你也在本地写小游戏,建议从一个能玩的最小版本开始,别急着搭框架。很多时候,一个 index.html 加几个 JS 文件,就已经足够把想法验证出来了。