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

在线试玩:https://yangdinghui.github.io/neon-runner-3d/

霓虹奔跑者开始界面

为什么做一个跑酷游戏

想做的游戏类型其实换过好几轮。横版平台跳跃要考虑关卡设计,开放世界更不现实,最后选跑酷,是因为它的核心循环足够简单:

目标也定得很克制:零依赖、零构建,双击 index.html 就能玩。没有后端、没有数据库、没有登录,所有贴图和音效都由代码生成,整个项目就是一个普通文件夹。

技术选型:能少一层就少一层

项目没有用 React、Vite、Webpack,也没有 node_modules。页面就是原生 HTML + CSS + JavaScript,3D 部分用 Three.js:

代码按职责拆成了几个文件:

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:035cc80de第一版:4,079 行,核心玩法、场景、音效、排行榜
16:254984669移动端适配:触屏、窄屏视角、画质分级(+652 / -76)
18:09837dd5b触屏改版:从固定按钮改成全屏手势(+274 / -245)
18:1582f8422真机修复: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;

这样做的好处很直接:

障碍物生成也有一个细节:生成线放在雾外。雾的范围是 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)在白底上等于消失。黑色背景上发光的粒子,叠到白色背景上几乎看不见。所以粒子、拖尾和光带都改成了高饱和颜色的普通透明混合,靠颜色对比而不是亮度来制造「发光」的感觉。

游戏画面

场景里的东西基本都是程序化生成的:

音效:没有音频文件,全部现场合成

项目里没有任何 .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 都有独立的手势状态,所以多指操作不会互相覆盖。手指落下处还会出现一个光环,转向时变青色,触发跳跃或下趴时变粉色并闪出「跳 / 趴」,几秒后有一条手势图例提示,降低第一次上手的门槛。

移动端还有两个容易被忽略的适配:

画质档位大致是这样:

档位像素比阴影粒子上限
high210241400
mobile1.5512700
low1.25关闭420

真机踩坑:pointerup 丢了,手势会卡死

最后一版提交 82f8422 只有 26 行改动,但它是整个项目里最值得记录的一个 bug。

在真机上,如果手指从屏幕边缘或系统手势区抬起,pointerup 事件有时会被系统吞掉。结果就是:这个 pointerId 的手势状态一直留在 Map 里,角色会保持转向或下趴,后续手势也会被这个「幽灵手指」干扰。

修复分三层:

  1. 同一个 pointerId 再次按下时,先清掉上一次残留的状态;
  2. 同时监听 pointercancel,系统取消手势时也走结束逻辑;
  3. 在 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

项目里还留了几个调试开关,方便继续改:

这次开发下来,最大的几个感受是:

  1. 先让核心循环跑起来。跑酷的规则简单,第一版只要能跑、能跳、能撞、能计分,就已经可以判断好不好玩了;
  2. 参数集中管理。config.js 让调手感变成改几个数字,而不是在几千行代码里翻找;
  3. 程序化资产很划算。没有美术和音频素材,也能靠几何体、Canvas 纹理和 WebAudio 做出完整的视听效果;
  4. 真机测试不可替代。pointerup 丢失、竖屏视野、移动端填充率,这些问题在桌面浏览器里都看不到;
  5. 限制范围。没有排行榜服务器、没有账号、没有关卡编辑器,反而让项目能在一个下午内形成完整闭环。

后面如果继续做,可能会加更多障碍组合、角色皮肤、成就系统,或者把本地排行榜换成云端排行榜。不过在那之前,先让「打开就能跑一局」这个体验保持简单,可能更重要。

如果你也在本地写小游戏,建议从一个能玩的最小版本开始,别急着搭框架。很多时候,一个 index.html 加几个 JS 文件,就已经足够把想法验证出来了。