Published on
约 19686 字

QQ飞车 3D 赛车游戏 · 源码解读

Authors
  • avatar
    Name
    小辉辉
    Twitter

第 0 章 导读:这个项目是什么

0.1 项目概览

qq-speed/ 是一个纯 Three.js 实现的 3D 竞速游戏,模仿《QQ飞车》的核心玩法:

项目地址:https://github.com/riba2534/claude-opus-5-5-demo/tree/main/qq-speed

能力实现
4 张地图城市环岛(跨海大桥)、爱琴海(海滨)、法老金字塔(沙漠)、冰雪城堡(雪原)
6 车竞速1 名玩家 + 5 名 AI,三档难度(新手/熟练/车神),带橡皮筋机制
2 种模式竞速模式(漂移集气→氮气)、道具模式(导弹/香蕉/磁铁/护盾/随机道具箱)
漂移体系漂移集气、小喷、落地喷、完美喷、双喷(W C W 连招)
全程序化零外部资源:没有 .glb 模型、没有图片、没有音频文件——所有车、楼、贴图、音乐全部用代码生成
部署Cloudflare Workers(wrangler deploy),单页面应用

技术栈只有两个依赖(package.json):

  • three ^0.169.0 —— 渲染
  • esbuild ^0.28.2 —— 打包

0.2 文件清单与依赖分层

qq-speed/
├── src/
│   ├── util.js        (69 行)   数学工具 ← 最底层,谁都可以 import
│   ├── track.js       (625 行)  赛道几何与"一维化" ← 只依赖 util/textures
│   ├── vehicle.js     (369 行)  玩家车物理 ← 只依赖 util
│   ├── main.js        (1073 行) Game 主类:状态机、主循环、粘合 ← 依赖上面全部
│   ├── ai.js          (138 行)  AI 车 ← 依赖 vehicle 的 TUNE
│   ├── items.js       (228 行)  道具系统 ← 被 main 调用
│   ├── world.js       (278 行)  天空/地面/水/山/合批 ← 只依赖 util
│   ├── textures.js    (610 行)  Canvas 程序化贴图 ← 只依赖 util
│   ├── effects.js     (187 行)  粒子/胎痕 ← 只依赖 util
│   ├── audio.js       (297 行)  WebAudio 合成音效/BGM ← 零依赖
│   ├── hud.js         (290 行)  仪表盘/小地图 ← 零依赖
│   ├── input.js       (79 行)   键盘/触摸输入 ← 零依赖
│   ├── carModel.js    车模型程序化构建
│   ├── layouts.js     4 张地图的赛道控制点
│   ├── maps.js        地图主题配置(颜色/雾/道具点位)
│   ├── props.js       路边物件(楼、棕榈树、金字塔、路灯…)
│   └── index.html     唯一的 HTML
└── package.json / build.mjs / wrangler.jsonc

依赖方向图(箭头 = import):

util.js ──→ track.js ──┐
   │                   ├──→ main.js ──→ ai.js / items.js
   ├──→ vehicle.js ────┤
   ├──→ world.js ──────┤        (main 也 import 它们)
   ├──→ textures.js ──→│
   ├──→ effects.js ────┤
   │                   │
audio.js ──→ main.js   │        audio / hud / input 互不依赖,只被 main 调用
hud.js   ──→ main.js   │
input.js ──→ main.js ──┘

关键性质:箭头全部朝一个方向,没有环。track.js 不知道 vehicle.js 的存在,vehicle.js 不知道谁在渲染它。这是单人能把 3000+ 行项目写完并维护住的根基(详见第 8 章)。

0.3 怎么跑起来

npm install
npx wrangler dev     # 或 npm run preview —— 本地开发服务器
npx wrangler deploy  # 部署到 Cloudflare

操作(input.js:1-2 的注释就是官方说明):

↑↓←→ 驾驶 · Shift 漂移 · Ctrl 氮气/道具 · ↑(出弯点按) 或 W 小喷
Alt 道具换位 · R 复位 · C 视角 · Esc 暂停 · M 音乐

0.4 给 Three.js 新手的三个心智模型

如果你写过前端 DOM,可以这样映射:

① 场景图 ≈ DOM 树。 THREE.Scene 是 <html>,THREE.Group 是 <div>(纯容器),THREE.Mesh 是带样式的元素。parent.add(child) 等于 parent.appendChild(child),子对象继承父对象的位移/旋转/缩放。本项目 main.js:286 建一个 root = new THREE.Group(),然后把地面、赛道、天空、水、山全部 root.add(...) 进去——换地图时 this.scene.remove(this.level) 一行就能整体卸载(main.js:349),像摘掉一个 DOM 子树。

② Mesh = Geometry + Material。 Geometry 回答"顶点在哪"(位置数组 + 每个三角形用哪三个顶点),Material 回答"表面长什么样"(颜色/贴图/粗糙度/是否发光)。对应 CSS 里"结构 + 样式"的分离。本项目最核心的渲染技巧之一(world.js:21 的 Batch)就是把上百个 Mesh 合并成一个大 Geometry——相当于把 100 个 DOM 节点合成一张雪碧图。

③ 游戏循环 ≈ requestAnimationFrame + 状态。 前端的 rAF 通常只做渲染;游戏循环里它还要驱动逻辑:每帧读输入 → 更新物理 → 更新 AI/道具 → 更新相机 → 渲染。本项目的循环在 main.js:468-477,一帧内用累加器把物理切成固定 1/120 秒的小步(第 4 章详解为什么)。

还有一个游戏开发特有的观念:逻辑和表现分离。车的"真实状态"只是几个数字(坐标 x/z、朝向 h、速度 s…),3D 模型只是每帧读取这些数字去摆放自己(vehicle.js:326 的 syncModel)。物理代码完全不碰 Three.js 的 Mesh(除了 syncModel 阶段),所以跑起来又快又稳。

0.5 一局游戏的数据流(总览)

键盘/触摸 (input.js)
    ↓  每帧 frame() 返回 {up,down,left,right,shift,...}
main.js step()  ── 状态机 'menu'/'countdown'/'race'/'finish'/'paused'
    ↓  固定步长 1/120s
PlayerCar.update()   ← 车辆物理,产出 events 事件队列
AICar.update() ×5    ← AI 决策 + 同一套简化物理
    ↓
track.project()      ← 世界坐标 → 赛道坐标 (d, lat),查碰撞/进度/腾空
    ↓
syncModel()          ← 把数字状态同步到 3D 模型(纯视觉)
    ↓
handleEvents()       ← 事件 → HUD 提示 / 音效 / 粒子
render(dt)           ← 相机、太阳、辉光后期 → WebGL 绘制

下面按依赖顺序逐层拆开。


第 1 章 util.js —— 69 行的地基

文件:src/util.js(全 69 行)。这是整个项目被引用最多的文件,导出 10 个纯函数,全部无副作用。先读懂它们,后面每一章的公式都不再神秘。

1.1 区间与插值:clamp / lerp / damp / smoothstep

export const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);   // util.js:1
export const lerp = (a, b, t) => a + (b - a) * t;                // util.js:2
export const damp = (a, b, lambda, dt) => lerp(a, b, 1 - Math.exp(-lambda * dt));  // util.js:3
export const smoothstep = (a, b, x) => {                          // util.js:4-7
  const t = clamp((x - a) / (b - a), 0, 1);
  return t * t * (3 - 2 * t);
};
  • clamp(v,a,b):把 v 夹进 [a,b]。游戏里到处是"速度不能为负""角度不能超过 65°"。
  • lerp(a,b,t):线性插值,t=0 得 a,t=1 得 b。这是游戏开发的第一公式——所有"平滑过渡"的本质。
  • smoothstep(a,b,x):也是插值,但曲线是 S 形(两端斜率为 0)。用于地形过渡:lerp(-6, 0, smoothstep(44, 64, r))(main.js:44,河岸到平地的过渡)在 44 米处开始、64 米处结束,且两端是"缓入缓出"的,不会出现生硬折角。
  • damp(a, b, lambda, dt):帧率无关的指数平滑,本项目最核心的工具(后面出现几十次)。

为什么不用 lerp(a, b, k*dt) 直接插值? 因为帧率会变。60fps 时 dt≈1/60,144fps 时 dt≈1/144,同一个 k 画出来的平滑速度差一倍多。damp 的推导:

设每帧把 a 向 b 移动剩余距离的固定比例,且该比例随 dt 指数衰减,解微分方程 da/dt = -λ(a-b),得通解:

a(t) = b + (a₀ - b)·e^(−λt)

离散化到每帧:a ← b + (a-b)·e^(−λ·dt),整理即 lerp(a,b, 1-e^(−λ·dt))。λ 是"速率"——λ=10 大约意味着 0.1 秒走完 63% 的路程、0.3 秒走完 95%。无论 60fps 还是 144fps,到达时间相同。项目里的用法:转向平滑 damp(this.steer, steerT, 12, dt)(vehicle.js:120)、相机跟随 camPos.lerp(want, 1-Math.exp(-14*dt))(main.js:1019)、车身侧倾 damp(u.root.rotation.z, lean, 8, dt)(vehicle.js:342)。

1.2 角度的回绕:wrapAngle / dampAngle

export function wrapAngle(a) {          // util.js:9-13
  while (a > Math.PI) a -= Math.PI * 2;
  while (a < -Math.PI) a += Math.PI * 2;
  return a;
}
export function dampAngle(a, b, lambda, dt) {   // util.js:15-17
  return a + wrapAngle(b - a) * (1 - Math.exp(-lambda * dt));
}

游戏里朝向用弧度(一圈 = 2π ≈ 6.28)。问题:朝向 179° 和 -179° 其实只差 2°,但数值差 358°——直接 lerp 会让车"绕远路"转一整圈。wrapAngle 把任意角度折回 (-π, π],保证差值永远取最短的那条弧。所以凡是角度插值都要先 wrap 差值,dampAngle 就是干这个的(相机朝向 main.js:1011、AI 车头 ai.js:113)。只要看到角度参与运算,就找 wrapAngle,这是本项目的铁律。

1.3 可复现的随机数:mulberry32

export function mulberry32(seed) {              // util.js:19-27
  let s = seed >>> 0;
  return function () {
    s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Math.random() 每次运行结果都不同;做程序化生成(同一张地图每次长得一样、贴图每次画得一样)需要种子随机:给同一个 seed,返回同一个数列。mulberry32 是著名的 32 位 PRNG,只有 5 步位运算,快到可以每帧调用上千次。用法举例:

const rnd = mulberry32(id.length * 997 + 13);   // main.js:293 —— 地图 id 决定云/山的排布
const rnd = mulberry32((Date.now() & 0xffff) + 7);  // main.js:380 —— 每场比赛 AI 皮/名字不同

前者用固定种子(每次进同一张地图,世界一致);后者用时间种子(每局不同)——两种用法覆盖了游戏里几乎所有"随机"。

1.4 噪声:makeNoise2D

export function makeNoise2D(seed = 1) {              // util.js:30-61
  const rnd = mulberry32(seed);
  const perm = new Uint8Array(512);                  // 洗牌后的置换表
  ...
  const grad = new Float32Array(256);                // 每个格点一个 -1..1 的梯度值
  const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);   // util.js:41 五次缓动
  function noise(x, y) {                             // util.js:42-50
    const xi = Math.floor(x), yi = Math.floor(y);
    const xf = x - xi, yf = y - yi;
    ...                                              // 取周围 4 格的梯度值
    return lerp(lerp(a, b, u), lerp(c, d, u), v);    // 双线性混合
  }
  noise.fbm = (x, y, oct = 4) => {                   // util.js:51-59 分形叠加
    let s = 0, amp = 0.5, f = 1;
    for (let i = 0; i < oct; i++) {
      s += amp * noise(x * f, y * f);
      f *= 2.03;      // 故意不是 2,避免各倍频在同一坐标对齐产生纹路
      amp *= 0.5;
    }
    return s;
  };
  return noise;
}

这是 Perlin noise 的简化版(value noise)。直觉:把平面划成 1×1 格子,每个格角随机一个 -1~1 的值,查询任意点时取周围 4 个格角做双线性插值——得到的是连续、无规律的起伏场,而不是每次调用都突变的随机数。fade 用五次曲线缓动插值权重,否则插值出来的面在格线处有折角。

fbm(分形布朗运动)是把噪声叠加 4 个倍频:频率 ×2、振幅 ×0.5。只叠一层像"缓坡",叠四层就有了大起伏 + 小碎波的自然质感。整个项目的地形都靠它:

noise.fbm(x * 0.004, z * 0.004, 4) * 26   // main.js:70 雪原地形:26 米高的丘陵

1.5 formatTime

util.js:63-68,把秒数格式化成 01:23.45。唯一的小心思是 padStart(5, '0') 让 s.toFixed(2) 的 "3.5" 变 "03.50",分钟秒数对齐。

本章小结:damp(指数平滑)+ wrapAngle(最短弧)+ mulberry32(种子随机)+ fbm(分形噪声)——这四个原语分别解决了平滑、角度、随机、地形四大问题,后面所有章节都在复用它们。


第 2 章 track.js —— 把三维赛道"一维化"

文件:src/track.js(625 行)。这是整个项目最核心的架构决策,先讲清楚思想,代码就好读了。

2.1 核心思想:为什么"一维化"

想象一条闭环赛道。车的世界坐标是 (x, z),如果直接在平面直角坐标系里做碰撞(撞墙?)、进度(跑了多少米?超过谁了?)、AI 决策(前面多远有弯?),每件事都要写独立的几何算法,又慢又容易错。

本项目的做法:先把赛道铺成一条一维的"带子",再把所有问题都换到赛道坐标系里解:

  • d(distance):沿赛道中心线走了多少米。进度、名次、AI 视距、道具位置全是 d。
  • lat(lateral):偏离中心线左右多少米。撞墙就是 |lat| 超过半宽,AI 走线就是控制 lat。

于是:

三维世界的问题赛道坐标系里的问题
车撞墙了吗?|lat| > 半宽 − 1.1?
谁跑在前面?比 d(跨圈用总里程)
前方 100 米有急弯?curvAhead(d, 100)
加速带压到了吗?d 落在某区间且 |lat−lat₀| < 半宽
这段路是桥吗?查 bridge[i]

这个变换有数学保证:赛道是一条不自交的闭曲线,中心线上每个点 (d) 都唯一对应世界一个位置——所以 (d, lat) ↔ (x, z) 在赛道附近是双射。代价是两个换算函数:sample()(d → xyz)和 project()(xyz → d,lat)。写好这两个函数,整栋楼就立起来了。

2.2 构造:从控制点到采样数组

每张地图只存十几个控制点(layouts.js),构造函数把它们变成密采样:

const curve = new THREE.CatmullRomCurve3(          // track.js:12-16
  layout.points.map((p) => new THREE.Vector3(p[0], p[2] || 0, p[1])),
  true,           // closed:闭环
  'centripetal',  // 样条类型
);
curve.arcLengthDivisions = 6000;                   // track.js:17
curve.updateArcLengths();
const approxLen = curve.getLength();
const N = Math.round(approxLen / 1.5);             // track.js:20 约 1.5 米一个采样点
const pts = curve.getSpacedPoints(N).slice(0, N);

给新手的解释:CatmullRomCurve3 是"样条曲线"——你给十几个锚点,它生成一条平滑穿过所有锚点的曲线(类似 CSS 里连接多个点的 path,或贝塞尔曲线的通用版)。'centripetal' 参数化能让锚点疏密不均时曲线也不过冲。getSpacedPoints(N) 返回曲线上等弧长的 N+1 个点——注意不是"等参数",等参数在急弯处会挤成一团。最终一条约 3000 米的赛道变成 N ≈ 2000 个点,相邻两点间距恒为 1.5 米,这个数字后面到处用。

SoA(结构体数组)布局——把一个"赛道点"拆进多列平行数组:

this.px = new Float32Array(N);   // 每个采样点的 x
this.py = new Float32Array(N);   // y(高度!赛道本身有起伏)
this.pz = new Float32Array(N);   // z
this.hd = new Float32Array(N);   // heading 朝向
this.tx = new Float32Array(N);   // 切线向量 x 分量
this.tz = new Float32Array(N);   // 切线向量 z 分量
this.rx = new Float32Array(N);   // 右法线 x 分量
this.rz = new Float32Array(N);   // 右法线 z 分量
this.curv = new Float32Array(N); // 曲率
this.bank = new Float32Array(N); // 路面倾角
this.slope = new Float32Array(N);// 坡度

(track.js:23-25, 39-46)新手可能想写成 points = [{x, y, z, heading, ...}, ...]——数组套对象。Float32Array 快得多:连续内存、数字类型固定、无属性查找。游戏里每帧对 6 辆车 × 多次采样做查询,用 SoA 是标准做法。注意 slice(0, N) 丢掉重复的尾点(闭环时首尾点重合),所以索引永远 i % N 回绕。

每帧预算观念:这些数组只在加载地图时算一次(625 行里构造函数占了 100 行),之后运行期全是只读查询——"一次昂贵、终身免费",这是程序化生成能跑满帧的根本策略。

2.3 派生量:朝向、切线、坡度

for (let i = 0; i < N; i++) {
  const a = (i - 1 + N) % N, b = (i + 1) % N;                  // track.js:48
  const h = Math.atan2(this.px[b] - this.px[a], this.pz[b] - this.pz[a]);  // :49
  this.hd[i] = h;
  this.tx[i] = Math.sin(h);  this.tz[i] = Math.cos(h);          // :51-52 切线
  this.rx[i] = -Math.cos(h); this.rz[i] = Math.sin(h);          // :53-54 右法线
  this.slope[i] = (this.py[b] - this.py[a]) / (2 * this.ds);    // :55 坡度
}

朝向用前后邻居连线(中心差分)而不是相邻两点,抗锯齿噪声。atan2(dx, dz) 注意参数顺序——本项目约定"朝向 0 = 朝 +z,90° = 朝 +x",之后所有 Math.sin(h) 是 x 分量、Math.cos(h) 是 z 分量(vehicle.js:228-230 的位移积分用的同一约定)。

切线 (tx,tz) 是前进方向单位向量;右法线 (rx,rz) 是它转 90°。验证:h=0 时切线 (0,1)、右法线 (-1,0)——朝 +z 走时右手边是 −x。lat 的正负号由此定义:lat = 位置相对中心线在右法线上的投影,lat>0 在赛道右侧。

2.4 曲率 → 倾角 → 竖直曲率(三层平滑)

曲率 = 单位长度的朝向变化量(弧度/米):

const raw = new Float32Array(N);
for (let i = 0; i < N; i++) raw[i] = wrapAngle(this.hd[(i+1) % N] - this.hd[i]) / this.ds;  // track.js:57-58
const W = 7;
for (let i = 0; i < N; i++) {
  let s = 0;
  for (let k = -W; k <= W; k++) s += raw[(i + k + N) % N];
  this.curv[i] = s / (2 * W + 1);   // track.js:59-64 ±7 点滑动平均
}

直道 curv≈0;半径 50 米的圆 curv=1/50=0.02。wrapAngle 再次登场:359°→1° 的朝向跳变不能算成 -358°。然后做 ±7 点滑动平均(box blur)——因为控制点样条在锚点处会留下曲率毛刺,平滑后 AI 决策和倾角生成才稳定。

倾角 bank(弯道外侧抬高):

const bk = new Float32Array(N);
for (let i = 0; i < N; i++) bk[i] = clamp(this.curv[i] * 5.5, -0.13, 0.13);  // track.js:67
for (let i = 0; i < N; i++) {
  let s = 0;
  for (let k = -12; k <= 12; k++) s += bk[(i + k + N) % N];
  this.bank[i] = s / 25;   // track.js:68-72 再平滑一次(±12 点)
}

bank 是路面的横向倾角(弧度),curv*5.5 让越急的弯倾斜越多,clamp(±0.13)(约 7.5°)封顶防止路肩穿地。注意平滑了两层且窗口不同:曲率 ±7、倾角 ±12——倾角直接影响路面网格的 y 坐标,必须比曲率更"绵",否则桥面会出现波浪。这是"视觉量要比逻辑量更平滑"的通用经验。

左转 curv>0、左低右高 → 车向弯心"沉"。路面某点的实际高度:y = py + lat·sin(bank)(track.js:205 的 surfY 同式)。

竖直曲率 vcurv(跳台检测的关键):

// 竖直曲率(平滑后):v²·κ > g 时车辆会腾空(跳台)      track.js:73
const sy = new Float32Array(N);
for (let i = 0; i < N; i++) { let a = 0; for (let k = -3; k <= 3; k++) a += this.py[(i+k+N)%N]; sy[i] = a / 7; }
this.vcurv = new Float32Array(N);
this.sslope = new Float32Array(N);
const K = 4;
for (let i = 0; i < N; i++) {
  const s0 = (sy[i] - sy[(i - K + N) % N]) / (K * this.ds);   // 后 4 点坡度
  const s1 = (sy[(i + K) % N] - sy[i]) / (K * this.ds);       // 前 4 点坡度
  this.vcurv[i] = (s1 - s0) / (K * this.ds);                  // 坡度的变化率 = 竖直曲率
  this.sslope[i] = (sy[(i+1) % N] - sy[(i-1) % N]) / (2 * this.ds);  // 平滑坡度
}

vcurv = 纵剖面(y-d 曲线)的二阶差分。物理含义(第 3 章会用到):车以速度 v 过凸坡,需要的向心加速度是 v²·κ;超过重力 g 时,路面"追不上"车 → 车腾空。所以 v²·κ > g 就是飞跳条件,赛道设计师在 layouts.js 里放的凸点会自动变成跳台。

2.5 空间哈希:把 O(N) 查询变 O(1)

this.cell = 16;                                    // track.js:92
this.grid = new Map();
for (let i = 0; i < N; i++) {
  const k = this._key(Math.floor(this.px[i] / this.cell), Math.floor(this.pz[i] / this.cell));
  let arr = this.grid.get(k);
  if (!arr) this.grid.set(k, (arr = []));
  arr.push(i);
}
_key(cx, cz) { return cx * 73856093 ^ cz * 19349663; }   // track.js:110

把世界划成 16×16 米格子,每格记录落在其中的采样点索引。查询"离 (x,z) 最近的赛道点"只需扫周围几格(nearest(),track.js:113-128),不用遍历 2000 个点。哈希函数 cx*73856093 ^ cz*19349663 是经典的空间格子哈希(两个大质数乘+异或,把二维格子坐标揉成一个 int)。这个结构服务于非赛道物体:地形压平、路边物件摆放避让(props.js 的 isFree)、埃及绿洲定位(main.js:296)。

2.6 sample():d → 世界坐标(O(1))

sample(d, out = this._tmp) {                       // track.js:148
  const L = this.length;
  d = ((d % L) + L) % L;                           // 任意 d 回绕到 [0, L)
  const f = d / this.ds;
  const i = Math.floor(f) % this.N;
  const j = (i + 1) % this.N;
  const t = f - Math.floor(f);
  out.x = this.px[i] + (this.px[j] - this.px[i]) * t;   // 位置线性插值
  ...
  const h = this.hd[i] + wrapAngle(this.hd[j] - this.hd[i]) * t;  // track.js:161 朝向必须 wrap!
  out.hd = h;
  out.tx = Math.sin(h); out.tz = Math.cos(h);
  out.rx = -out.tz; out.rz = out.tx;               // 派生量从插值后的 h 重算
  out.bank = this.bank[i] + (this.bank[j] - this.bank[i]) * t;
  return out;
}

任意里程 d:先 ((d % L) + L) % L 三明治回绕(JS 的 % 对负数返回负值,所以要先 +L 再 %),再夹在相邻两个采样点间 lerp。唯一陷阱在 161 行:两个朝向 179° 和 -179° 插值必须先 wrapAngle 差值,否则在赛道"最北端"车头会瞬间反向。out = this._tmp 复用一个对象避免每帧产生 GC 垃圾——调用方必须立即消费结果,这个约定贯穿全项目。

2.7 project():世界坐标 → (d, lat)(本项目最精巧的函数)

project(x, y, z, hint, out) {                      // track.js:173
  const N = this.N;
  let bi = 0, best = Infinity;
  if (hint < 0) {                                  // 全局搜索(初始定位)
    for (let i = 0; i < N; i++) {
      const dx = this.px[i] - x, dz = this.pz[i] - z, dy = (this.py[i] - y) * 3;  // :178
      const d2 = dx * dx + dz * dz + dy * dy;
      if (d2 < best) { best = d2; bi = i; }
    }
  } else {                                         // 局部搜索(帧间跟踪)
    for (let k = -30; k <= 30; k++) {
      const i = (hint + k + N) % N;
      const dx = this.px[i] - x, dz = this.pz[i] - z;
      const d2 = dx * dx + dz * dz;
      if (d2 < best) { best = d2; bi = i; }
    }
  }
  // 在 bi 前后两段上精确投影                              track.js:190-202
  let i = bi, j = (i + 1) % N;
  let ex = this.px[j] - this.px[i], ez = this.pz[j] - this.pz[i];
  let t = ((x - this.px[i]) * ex + (z - this.pz[i]) * ez) / (ex * ex + ez * ez);
  if (t < 0) { /* 换到前一段重新投影 */ }
  t = clamp(t, 0, 1);
  const s = this.sample((i + t) * this.ds, out);
  s.lat = (x - s.x) * s.rx + (z - s.z) * s.rz;     // track.js:204
  s.surfY = s.y + s.lat * Math.sin(s.bank);        // track.js:205
  return s;
}

三段式,逐段加速:

  1. 粗定位。hint 是车上一帧的采样索引——车一帧最多移动 ~1 米、采样间距 1.5 米,所以只需在 hint±30(±45 米)里找最近点,61 次循环。复位/初始化时 hint=-1 才走全局 2000 次循环。这是"时间连贯性"(temporal coherence)优化的教科书案例:帧间状态不会突变,就用上帧结果缩小搜索域。
  2. 精投影(190-202):最近采样点只是 1.5 米网格上的近似。把车位置向采样点连线段做点积投影:t = P−A · B−A / |B−A|²(把车投影到线段 AB 上求参数 t∈[0,1]),得到亚采样点精度。t < 0 说明投影落在线段外,换前一段重算。
  3. 换算 lat(204):lat = (P − S)·右法线。surfY(205)顺带算出车底路面的准确高度(含倾角),供贴地/腾空判断。

178 行的 dy*3 值得注意:全局搜索时把高度差放大 3 倍再参与距离——为了立体交叉路段(城市图的跨海大桥)能正确区分"桥上"和"桥下"的车,平面近但高度远的候选点被惩罚。一个乘法解决上下层归属,很划算。

2.8 curvAhead():给 AI 的"前视雷达"

curvAhead(d, range) {                              // track.js:212-222
  const i0 = Math.floor(...);
  const steps = Math.max(1, Math.floor(range / this.ds));
  let m = 0;
  for (let k = 0; k < steps; k += 2) {             // 隔点采样省一半
    const c = this.curv[(i0 + k) % N];
    if (Math.abs(c) > Math.abs(m)) m = c;          // 绝对值最大的(带符号)
  }
  return m;
}

返回前方 range 米内绝对值最大的曲率(保留符号)。AI 刹车距离长,需要看 140 米;转方向盘只需要看 8 米——同一个函数,两种调用(ai.js:59, 75)。

2.9 build():把数组变成三角形网格

构造函数算完"数据赛道",build()(track.js:225-440)把它变成 Three.js 能画的 Mesh。核心是缎带(ribbon)网格:

// 路面:每行 2 个顶点(左边缘、右边缘),行间距 = 采样间距 1.5 米
for (let r = 0; r < rows; r++) {
  const i = r % N;
  const sb = Math.sin(this.bank[i]);
  for (let s = 0; s < 2; s++) {
    const lat = s ? hw : -hw;                      // 半宽 hw,左右边缘
    const o = (r * 2 + s) * 3;
    pos[o]     = this.px[i] + this.rx[i] * lat;    // 中心点 + 法线 × lat
    pos[o + 1] = this.py[i] + lat * sb;            // 倾角抬边            track.js:240
    pos[o + 2] = this.pz[i] + this.rz[i] * lat;
    uv[(r*2+s)*2]     = s;                         // u: 0→1 横跨路宽
    uv[(r*2+s)*2 + 1] = (r * this.ds) / TEX_LEN;   // v: 每 24 米重复贴图   track.js:243
  }
}
for (let r = 0; r < N; r++) {
  const a = r*2, b = a+1, c = a+2, d = a+3;
  idx.push(a, b, c,  b, d, c);                     // 两三角形 = 一格路面
}

新手拆解:Three.js 画的一切都是三角形。要铺一条 3000 米的路面,就生成 N 行 × 2 列顶点,每相邻两行连出 2 个三角形(索引数组 idx 每组 6 个数字 = 2 个三角形,每个 3 个顶点索引)。2000 行 = 4000 个三角形,一次 drawcall 画完整条路。

UV 坐标是贴图的"归一化寻址":u∈[0,1] 从路左缘到右缘(贴图横着贴一次),v = 里程/24——TEX_LEN=24(track.js:5)表示贴图沿路方向每 24 米重复一次。这就是"贴图跟着赛道走"的秘密:不旋转贴图,而是让几何本身自带沿路坐标。护栏同理,WALL_TEX_LEN=16(track.js:6)。

路面高度那行(240)就是倾角的落地:同一横截面上左缘 y = py − hw·sin(bank)、右缘 y = py + hw·sin(bank)——一条斜的路面。物理与渲染共用同一公式(track.js:205 的 surfY),所以车永远不会悬空或穿地。

之后的构建块(每个都值得按行号读一遍,思路同上):

  • 护栏 + 路基(track.js:268-342):内侧面从路缘往上 1.25 米(wallH),外挂 0.6 米厚、往下延伸 1.6 米的"裙板"——桥段从远处看是实心的桥体。材质 side: THREE.DoubleSide(333 行附近)因为从内外两个方向都要可见。
  • 弯心路肩(红白相间的 curb,track.js:344-398):只在弯心侧生成。判定 inner = this.curv[i] * -side > 0.011(374)——曲率超过阈值的路段才铺,直道不铺。连续段 run.length < 8 太短就丢弃(354),避免碎片。材质加 polygonOffset: -2(389-391)抗 z-fighting:路肩几何贴着路面(只抬高 0.035 米),两共面三角形深度抖动会闪烁,polygonOffset 把它在深度上整体推近一点。
  • 起跑线(400-405)与加速带(407-421):都走 roadDecal()(443-469)——把一个四边形沿赛道细分后"贴"在路面上(抬高 0.05 米 + polygonOffset)。加速带用 MeshBasicMaterial 且 toneMapped: false、color: new THREE.Color(1.8,1.8,1.8)(413-416):不受光照、不受色调映射、颜色超 1 → 自发光霓虹感,配合后期 Bloom 泛光。运行时 main.js:558 还会滚动它的贴图 offset 做出"箭头流动"动画。
  • 隧道(buildTunnel,471-529):沿赛道扫出半圆柱(lat = -cos(a)*hw,a 从 0 到 π 扫过拱顶),内面发光、外面土色,两端加 TorusGeometry 门框。
  • 桥墩(buildPillars,579-618):每 12 米一根。判断"这段是桥吗"用 bridge 数组(构造时由 cfg.isBridge(x,z,y) 回调标记,track.js:89-90);桥下地面高度到桥面不足 1.2 米的不放墩;同层重叠段(Math.min(di, N-di)>40 && py[j]<y-3,590-593)跳过避免立体交叉处穿模。最终用 InstancedMesh(一个 BoxGeometry + 每墩一个变换矩阵)画所有墩——比逐个 Mesh 省几十倍 drawcall。

本章小结:Track 类 = 一次性的几何预处理机。输入 20 个控制点,输出(a)一套平行数组供物理/AI O(1) 查询,(b)一套 BufferGeometry 供渲染。它既不懂车也不懂渲染管线——这就是"层"的含义。


第 3 章 vehicle.js —— 物理与"手感"

文件:src/vehicle.js(369 行)。玩家车的全部状态机与物理。注意它 import 了什么:

import { clamp, wrapAngle, damp } from './util.js';   // vehicle.js:1

只有 util!不知道 Three.js 的 Mesh 长什么样(模型只是挂在 this.model 上被 syncModel 摆弄),不知道键盘、不知道分数。369 行里 320 行是纯数学。先看调参表:

3.1 TUNE:所有手感的旋钮集中在一处

export const TUNE = {          // vehicle.js:4-24
  vmax: 55,          // ~198 km/h   基础极速(米/秒;×3.6 = km/h)
  vmaxNitro: 76,     // ~274 km/h   氮气极速
  accel: 24,         // 基础加速度 m/s²
  nitroAccel: 40,    // 氮气加速度
  brake: 45,         // 刹车减速度
  reverseMax: 12,    // 倒车极速
  roll: 2.2,         // 松油门滑行减速度
  turnRate: 1.9,     // 最大转向角速度(弧度/秒 ≈ 109°/s)
  grip: 12,          // 抓地(车身回正速率 λ)
  driftMinSpeed: 13, // 低于此速无法起漂
  driftYaw: 1.5,     // 漂移中的额外角速度
  driftYawAlign: 0.85, // 反打方向修正量
  driftGrip: 2.0,    // 漂移中车身回正 λ(松→5.2,抓地 12)
  maxDriftAngle: 1.15, // 最大漂移角(约 66°)
  smallWindow: 0.5,  // 出弯小喷窗口 0.5 秒
  gaugeRate: 0.36,   // 集气速率
  nitroTime: 2.8,    // 一次氮气 2.8 秒
  gravity: 30,       // 重力(游戏常用的"放大重力",30 而不是 9.8)
};

把所有魔数收进一个对象并导出(AI 也 import 它,ai.js 开头),调手感只改这一处。为什么 gravity 是 30 不是 9.8? 真实重力下 1 秒才掉 4.9 米,游戏里显得"飘"。街机赛车惯用 2~3 倍重力让跳跃干脆利落。这行注释就是"游戏感优先于物理正确"的注脚。

3.2 双角度模型:h(车头朝向)与 m(速度方向)

这是本章最重要的概念。真实世界里,车头朝向 ≠ 速度方向:冰面上猛打方向,车头转过去了、速度还沿老方向走——这就是漂移。本项目用两个角度显式建模:

  • h(heading):车头指向,由方向盘直接驱动。
  • m(motion):速度矢量方向,被 grip 拉着追 h。

抓地时 m 紧追 h(λ=12,0.1 秒追平);漂移时故意把 λ 降到 2.0,m 追不上 h,h − m 就是漂移角。看普通转弯的代码:

} else {                                              // vehicle.js:214-222 非漂移
  const turn = TUNE.turnRate * clamp(sp / 9, 0, 1)    // 低速转不动(避免原地掉头)
               * (1 - 0.55 * clamp(sp / TUNE.vmax, 0, 1.3));  // 高速衰减 55%
  this.h += this.steer * turn * dt * dir;             // 方向盘 → 车头
  if (this.s >= 0) this.m += wrapAngle(this.h - this.m) * (1 - Math.exp(-TUNE.grip * dt));  // :220
  else this.m = this.h;                               // 倒车时速度方向 = 车头
}

turn 公式的三个因子各司其职:clamp(sp/9,0,1) 让起步时方向是"渐进生效"的(防止 0 速原地旋转);(1-0.55·sp/vmax) 让高速转向变钝(不然 200km/h 还能满舵就假了)。220 行又是 damp 的手写展开——m 指数逼近 h,λ=grip=12。

速度矢量的合成(积分,vehicle.js:226-231):

const dirSign = this.s >= 0 ? 1 : -1;
const mv = this.s >= 0 ? this.m : this.h;     // 前进沿 m,倒车沿 h
let vx = Math.sin(mv) * Math.abs(this.s) * dirSign;
let vz = Math.cos(mv) * Math.abs(this.s) * dirSign;
this.x += vx * dt;
this.z += vz * dt;

s 是标量速度(带符号:正=前进),把方向角 mv 转成单位向量 (sin, cos)——注意与 track.js:51-54 同一套约定。为什么倒车用 h? 倒车是"推着车尾走",速度沿车头反方向,此时不存在漂移。这些小分支就是"手感"的砖石。

3.3 纵向:油门曲线与坡度

if (inp.up) {
  ...
  else if (this.s < vmax) this.s += acc * (1 - Math.pow(this.s / vmax, 2) * 0.85) * dt;  // vehicle.js:158
}
...
} else {  // 松油门
  const dec = (TUNE.roll + Math.abs(this.s) * 0.06) * dt;   // :163 滑行阻力随速增大
  this.s = Math.abs(this.s) <= dec ? 0 : this.s - Math.sign(this.s) * dec;
}
if (this.s > vmax) this.s -= (this.s - vmax) * 1.1 * dt;    // :166 超速回落
this.s -= tr.slope[this.hint] * Math.cos(this.h - tr.hd[this.hint]) * 6 * dt;  // :168 坡度

158 行是渐减加速曲线:速度越接近 vmax,有效加速度越小(×0.85 保底不归零)——接近极速时"推背感"消失,符合真实体验也防止数值过冲。163 行滑行阻力 = 基础滚阻 + 空气阻(近似 ∝ v)。166 行把超速部分以 1.1/s 的速率拉回——被氮气/加速带顶到 86 的速度会缓慢回落而非瞬间归位,这是"出弯残留速度"手感的来源。

168 行坡度最妙:tr.slope[hint] 是赛道坡度(第 2 章),cos(h − hd) 是车头与赛道方向的夹角余弦——上坡正对才吃满减速,横着漂过坡几乎不受影响。系数 6 把坡度放大成明显的速度变化(游戏感again)。

3.4 漂移状态机:起漂 → 维持 → 出弯

QQ飞车的灵魂。状态迁移:

正常 ──Shift+方向+速度>13──→ 漂移中 ──松Shift且回正/掉速──→ 正常
                                    │
                                    └─出弯(clean)→ smallWindow=0.5s(小喷窗口)

起漂(vehicle.js:172-180):

if (!this.drifting && inp.shift && steerT !== 0 && this.s > TUNE.driftMinSpeed && onGround && !spinning) {
  this.drifting = true;
  this.driftDir = steerT;      // 记住漂移方向(之后方向键含义改变!)
  this.driftTime = 0;
  this.h += this.driftDir * 0.07;   // :178 起漂瞬间车头猛甩一下
  this.emit('driftStart', {});
}

178 行那个 0.07 弧度的"甩头"是起漂的手感点——视觉上车身瞬间切入姿态。

漂移核心(182-213,逐行拆):

const align = steerT * this.driftDir;        // :184 方向键与漂移方向同向=+1,反打=-1
const held = inp.shift;
if (held) this.releaseTime = 0;
else this.releaseTime += dt;
const yaw = held
  ? TUNE.driftYaw + TUNE.driftYawAlign * align                     // 按住 Shift
  : (0.45 + 0.9 * align) * Math.exp(-2.2 * this.releaseTime);      // 松开后衰减
this.h += this.driftDir * yaw * clamp(this.s / 26, 0.35, 1) * dt * (onGround ? 1 : 0.4);  // :188
  • 按住 Shift:车头以 1.5 + 0.85·align 弧度/秒持续转。同向打方向转得更狠,反打拉回——这就是漂移中方向键的两义性(同键既是"加深漂移"也是"反打救车")。
  • 松开 Shift:转向量 e^(−2.2·t) 指数衰减,0.5 秒后基本归零——漂移自然收束而不是瞬间锁死。
  • clamp(s/26, 0.35, 1):速度低时漂不动;腾空时 ×0.4。
const k = held ? TUNE.driftGrip : TUNE.driftGripRelease;   // :189 松开后回正加速 5.2
this.m += wrapAngle(this.h - this.m) * (1 - Math.exp(-k * dt));    // :190
let a = wrapAngle(this.h - this.m);
if (Math.abs(a) > TUNE.maxDriftAngle) {                    // :191-195
  this.m = this.h - Math.sign(a) * TUNE.maxDriftAngle;     // 漂移角封顶 66°
  a = Math.sign(a) * TUNE.maxDriftAngle;
}
this.driftAngle = a;
const sa = Math.abs(Math.sin(a));
if (onGround) this.s -= (2.5 + 11 * sa) * dt;              // :198 漂移掉速

漂移中 grip 从 12 降到 2.0(松开瞬间跳 5.2)——m 追 h 的速度骤降,漂移角 a = h−m 张开;封顶 ±1.15 弧度防止"转圈烧胎"。198 行:漂移损失速度 ∝ sin(漂移角)——漂得越浪掉速越多,甩尾过弯不是免费的,这是玩家要权衡的核心代价。

集气与结束:

if (!itemMode && onGround) {                                              // :199-209
  this.gauge += dt * TUNE.gaugeRate * (0.3 + sa * 1.7) * clamp(this.s / 35, 0.3, 1.1);
  while (this.gauge >= 1) {
    if (this.nitroCount < 2) { this.nitroCount++; this.gauge -= 1; this.emit('gaugeFull', {}); }
    else { this.gauge = 1; break; }      // 满两罐后集气值封顶囤着
  }
}
this.driftTime += dt;
if ((!held && (Math.abs(a) < 0.1 || (align < 0 && Math.abs(a) < 0.26))) || this.s < 7)
  this.endDrift(this.s >= 7);            // :213

集气速率 = 时间 × (0.3 + 1.7·漂移烈度) × 速度系数——大角度高速长漂才充得快,浅漂几乎白漂。集满 1 格 → nitroCount++(最多囤 2 罐,多出来的集气值停在 1 等待释放槽位)。结束条件:松开 Shift 且车尾基本回正(a<0.1)或反打明显收束(align<0 且 a<0.26),或速度掉到 7 以下(速度太低漂移作废,不给小喷——endDrift(this.s >= 7) 的 clean 参数)。

endDrift(clean = true) {                    // vehicle.js:98-106
  if (!this.drifting) return;
  this.drifting = false;
  if (clean && this.driftTime > 0.22) {     // :101 漂移短于 0.22s 不算
    this.smallWindow = TUNE.smallWindow;    // 0.5s 小喷窗口开启!
    this.windowKind = 'drift';
  }
  this.emit('driftEnd', { time: this.driftTime });
}

3.5 小喷 / 落地喷 / 双喷:窗口判定

出漂/落地不直接给速度,而是开一扇时间窗,玩家在窗内点↑/W 才兑现:

if (this.smallWindow > 0) {                 // vehicle.js:135-149
  this.smallWindow -= dt;
  if (inp.upPressed || inp.wPressed) {      // 注意是"按下沿"不是"按住"
    const elapsed = (this.windowKind === 'drift' ? TUNE.smallWindow : 0.45) - this.smallWindow;
    const perfect = elapsed < 0.2;          // :139 出弯 0.2s 内点 = 完美喷
    const dbl = this.nitroTime > 0;         // :140 氮气中还接小喷 = 双喷素材
    this.smallBoost = perfect ? 1.0 : 0.75;
    this.smallBoostPower = (perfect ? 11 : 8) + (dbl ? 5 : 0);   // :142
    this.s += perfect ? 5.5 : 4;
    if (dbl) this.nitroTime += 0.3;
    this.emit(this.windowKind === 'land' ? 'landBoost' : 'smallBoost', { perfect, double: dbl });
    if (dbl) this.emit('double', {});
    this.smallWindow = 0;
  }
}

三种结果:普通小喷(+8 极限速 0.75s)、完美喷(出弯 0.2 秒内点,+11 且瞬时 +5.5 m/s)、双喷(氮气未结束时接小喷:小喷更强 + 氮气延长 0.3s + 专属 'double' 事件给特效音效)。输入判断用 upPressed/wPressed(按下沿,input.js 每帧清一次)——按住不放只能吃到一次,必须主动再点,这就是"点按"技巧的判定基础。

落地喷的窗口在落地时开(vehicle.js:285-288):

this.emit('land', { air: this.airTime, v: hard });
if (this.airTime > 0.3) {           // 腾空超过 0.3s 落地才有
  this.smallWindow = 0.45;
  this.windowKind = 'land';
}

3.6 加速叠加栈:所有加速汇成一张表

每帧把互不冲突的加速源叠起来(vehicle.js:123-132):

let vmax = TUNE.vmax, acc = TUNE.accel;
if (this.nitroTime > 0)      { vmax = TUNE.vmaxNitro; acc = TUNE.nitroAccel; this.nitroTime -= dt; }  // 氮气:换档
if (this.smallBoost > 0)     { vmax += this.smallBoostPower; acc += 14; this.smallBoost -= dt; }       // 小喷:加成
if (this.padTime > 0)        { vmax += 12; acc += 20; this.padTime -= dt; }                            // 加速带
if (this.startBoost > 0)     { vmax += 10; acc += 26; this.startBoost -= dt; }                          // 完美起步
if (this.magnet > 0)         { vmax += 10; acc += 12; this.magnet -= dt; }                              // 磁铁
vmax = Math.min(vmax, 86);    // :129 物理上限封顶
if (this.slowTime > 0)        { vmax *= 0.55; this.slowTime -= dt; }                                    // 香蕉:减速 debuff

注意设计差异:氮气是"换档"(vmax 整个换成 76),小喷/加速带是"加成"(在当前 vmax 上加)——所以氮气中接小喷可以摸到 86 上限。Math.min(vmax, 86) 是全局护栏,保证任何叠加都出不了物理失控区。boosting getter(vehicle.js:83)汇总所有状态给相机/音效/火焰判断。

triggerNitro(85-96)是氮气按钮的逻辑:nitroTime = Math.min(4.5, max(0,nitroTime) + 2.8)——连喷叠加上限 4.5 秒;s = max(s,20)+4 保证低速也能被氮气"拉起来";wasSmall 判定(松小喷瞬间接氮气)再发一次 'double' 事件。

3.7 撞墙:位置修正 + 速度反射 + 车头摆正

const lim = tr.halfW - 1.1;                 // vehicle.js:238 离墙留 1.1m 车身余量
this.impact = 0;
if (Math.abs(p.lat) > lim) {
  const sg = Math.sign(p.lat);
  const nx = p.rx * sg, nz = p.rz * sg;     // 墙的法线(指向路内)
  const over = Math.abs(p.lat) - lim;
  this.x -= nx * over;                      // :244-245 位置修正:直接推回墙内
  this.z -= nz * over;
  const vn = vx * nx + vz * nz;             // 速度在法线上的分量(>0 = 往墙里撞)
  if (vn > 0) {
    const sp = Math.max(1, Math.hypot(vx, vz));
    const impact = vn / sp;                 // 法向分量占速度的比例 ∈ (0,1]
    vx -= nx * vn * 1.25;                   // :250-251 反弹 1.25 倍法向速度
    vz -= nz * vn * 1.25;
    const keep = 1 - 0.45 * impact;         // :252-254 正撞失去 45%,蹭墙几乎不掉速
    vx *= keep; vz *= keep;
    const ns = Math.hypot(vx, vz);
    if (this.s >= 0) {
      this.s = ns;
      if (ns > 0.5) this.m = Math.atan2(vx, vz);     // :257 速度方向改为反弹后方向
      // 车头顺着墙摆正                                          :259-261
      const th = Math.abs(wrapAngle(this.h - p.hd)) < Math.PI / 2 ? p.hd : p.hd + Math.PI;
      this.h += wrapAngle(th - this.h) * Math.min(1, 0.6 * impact + 0.05);
    } else this.s = -ns;
    this.impact = impact * sp;
    if (impact > 0.3 && this.drifting) this.endDrift(false);      // :263 重撞终止漂移(无小喷)
    if (impact * sp > 6) this.emit('crash', {...});               // :265
    else if (sp > 8) this.emit('scrape', {...});
  }
  p.lat = sg * lim;
}

四步经典流程:

  1. 位置修正(推回墙内)——先修位置再修速度,防止连续多帧嵌墙越陷越深。
  2. 速度反射:vn = v·n 是点积(速度在墙法线上的投影)。反射公式 v ← v − n·vn·(1+e),e=0.25 即反弹 1.25 倍(不完全弹性)。
  3. 切向惩罚 ∝ 撞击角:keep = 1 − 0.45·impact。impact=法向速度/总速度:垂直撞墙 impact≈1(速度掉 45%),贴墙蹭 impact≈0.1(掉 4.5%)。一个系数同时实现了"撞墙重罚、蹭墙轻罚"。
  4. 车头摆正(259-261):把 h 拉向赛道方向 hd(或其反方向,看车头原本朝哪边),力度 ∝ impact——撞完墙车自动顺墙滑行,不会横在路中间。

endDrift(false) 里的 clean=false 很关键:撞墙终止的漂移不给小喷窗口——失误不受奖励。

3.8 垂直方向:贴地、腾空、落地

const groundY = p.y + p.lat * Math.sin(p.bank);   // vehicle.js:273 车底路面高度(含倾角)
if (this.airborne) {
  this.vy -= T.gravity * dt;                       // 简单重力积分
  this.y += this.vy * dt;
  this.airTime += dt;
  if (this.y <= groundY) {                         // 落地判定
    ...
    this.emit('land', { air: this.airTime, v: hard });
    if (this.airTime > 0.3) { this.smallWindow = 0.45; this.windowKind = 'land'; }   // :285-288
  }
} else {
  const va = this.s * Math.cos(this.m - p.hd);     // :293 沿赛道方向的速度分量
  if (va > 18 && va * va * -tr.vcurv[p.i] > T.gravity) {   // :294 腾空条件!
    this.airborne = true;
    this.vy = tr.sslope[p.i] * va;                 // :297 起跳初速 = 坡度×前进速度
    this.y = groundY + this.vy * dt;
    if (this.drifting) this.endDrift(false);
  } else {
    this.y = groundY;                              // 贴地
  }
}

腾空条件的推导(呼应 2.4 节):车过凸坡顶时做圆周运动,需要的向心加速度 a = v²·κ(κ=vcurv,凸坡为负所以 -vcurv 为正)。这个需求只能由"重力压着车"提供;当 v²κ > g,重力不够 → 路面开始"离开"车 → 腾空。va > 18 加一道最低速度门槛(慢速过坡不跳)。起跳初速 vy = 坡度 × 前进速度——把斜面上的速度矢量分解出竖直分量,物理正确且免费。

腾空中的转向/漂移衰减 ×0.4/×0.35(188、219 行)——空中操作近似无效,落地喷才是跳台的回报。

3.9 其他细节:逆行、加速带、起步

  • 逆行检测(305-308):cos(h − hd) < −0.35 且 s > 4 持续计时,HUD 会弹"逆行"警告。
  • 加速带(311-322):纯 d/lat 区间判断(第 2 章的承诺兑现)——dd = this.d − (bp.d − bp.len/2) 回绕后 < bp.len 且 |lat − bp.lat| < halfW+0.6。padTime=1.1 是持续加速;if (this.padTime < 0.6) 保证一次踩带只发一次 'pad' 事件(0.6~1.1 之间重复踩不重复给瞬时速度)。
  • 起步喷射:倒计时结束前 0.28 秒(cdT > 2.72,main.js:493)按住油门 → startBoost。applyStartBoost(main.js:577-583)给 startBoost=1.4, s=max(s,16)。

3.10 syncModel:数字 → 3D 姿态(唯一的渲染代码)

syncModel(dt) {                                    // vehicle.js:326-351
  const mdl = this.model;
  mdl.position.set(this.x, this.y, this.z);
  const rel = this.h - tr.hd[i];                   // 车头相对赛道夹角
  const slopeAlong = tr.slope[i] * Math.cos(rel);  // 坡度在车头方向的分量
  const bankAlong = tr.bank[i] * Math.cos(rel);
  const pitchT = this.airborne ? clamp(-this.vy * 0.012, -0.3, 0.3) : -Math.atan(slopeAlong);
  this.pitchVis = damp(this.pitchVis, pitchT, 10, dt);   // 视觉俯仰也做平滑!
  this.rollVis = damp(this.rollVis, -bankAlong, 10, dt);
  mdl.rotation.set(this.pitchVis, this.h, this.rollVis, 'YXZ');   // :338
  ...
  u.root.rotation.z = damp(u.root.rotation.z, lean, 8, dt);        // 车身侧倾(子节点)
  u.root.rotation.x = damp(u.root.rotation.x, pitchDyn - (nitro ? 0.02 : 0), 6, dt);  // 加减速点头
  for (const w of u.wheels) {
    w.spin.rotation.x += (this.s * dt) / 0.47;      // :346 轮子滚动(0.47=轮半径)
    if (w.front) w.steer.rotation.y = this.drifting ? -this.driftDir * 0.3 : this.steer * 0.38;
  }
  updateFlames(u, this.nitroTime > 0, ...);
}

三个要点:

  1. rotation.set(pitch, yaw, roll, 'YXZ'):Three.js 的欧拉角默认按 XYZ 顺序应用。赛车必须先偏航(y,车头朝向)再俯仰/侧倾——顺序错了,朝北时的俯仰会变成侧倾。'YXZ' 是载具的标配顺序。新手常见 bug 就在这里。
  2. 视觉量全部再过一层 damp(pitchVis/rollVis λ=10):物理值是瞬间跳变的(撞墙 h 立刻变),视觉上平滑 100ms 才自然——"逻辑可以硬,眼睛要顺滑"。
  3. 姿态分两层:外层 Mesh 管物理姿态(位置+朝向+坡度俯仰),内层 u.root(车壳子节点)管动态姿态(转弯侧倾、油门点头、氮气下压)。两层独立阻尼,撞墙时车身"晃"而朝向不乱。

updateFlames(354-366)是尾焰动画:sin(t*60 + position.x*10) + random() 高频抖动 + 氮气蓝/小喷橙两种颜色 multiplyScalar(2.2) 超亮(配合 Bloom)。

本章小结:PlayerCar 是一台纯函数状态机——输入 (dt, input) + 当前状态 → 新状态 + 事件列表(this.events)。渲染、音效、HUD 全部由 main.js 消费事件驱动。369 行里只有 25 行碰了 this.model,且全部集中在 syncModel。这就是"物理层"的完整形态。


第 4 章 main.js 的 step() —— 粘合层

文件:src/main.js(1073 行)。Game 类是唯一的"总指挥",本章聚焦它最核心的 90 行:主循环 loop()(468-477)和物理调度 step()(479-567)。其余(菜单、选图、结算)是常规 UI 代码,读起来没有门槛。

4.1 状态机:游戏的一切状态只是一个字符串

this.state = 'menu';     // main.js:106
// 取值:'menu' | 'countdown' | 'race' | 'finish' | 'paused'

所有分支都问这一个字符串(step() 开头 const st = this.state)。暂停时 state='paused' 且记住 pausedFrom 以便恢复(togglePause,main.js:221-233)。对比新手常见的做法(一堆 boolean:isRacing/isPaused/isMenu…组合爆炸),单字符串状态机非法状态不可表示,这是最省心的写法。

4.2 主循环与固定步长积分器

loop() {                                          // main.js:468-477
  requestAnimationFrame(() => this.loop());       // 自我排程,60/120/144fps 由显示器决定
  const now = performance.now();
  const dt = Math.min(0.05, (now - this.last) / 1000);   // :471 帧间隔,上限 50ms
  this.last = now;
  this.time += dt;
  const inp = this.input.frame();                 // 本帧输入快照
  if (this.state !== 'paused') this.step(dt, inp);
  this.render(dt);
}

471 行的 Math.min(0.05, …) 是防切页炸弹:切走标签页 10 秒再回来,rAF 暂停后恢复的第一帧 dt=10——若不钳制,物理会把车瞬移 550 米穿墙。钳到 50ms 相当于"最多快进 5 帧物理"。

物理为什么不能直接用 dt?因为 vehicle.js 的数值积分(速度×dt 累加进位置)对步长敏感:60fps 和 144fps 跑出的轨迹会不一样,while (gauge>=1)、碰撞修正这类逻辑在超大步长下还会穿透。解法是经典的累加器模式:

// main.js:527-536(step() 内)
const DT = 1 / 120;                              // 物理步长固定 1/120 秒(main.js:37)
this.acc += dt;
let first = true;
const noEdge = { ...pin, upPressed: false, wPressed: false, nitroPressed: false };
while (this.acc >= DT) {
  this.acc -= DT;
  if (P) P.update(DT, first ? pin : noEdge, active, this.itemMode);
  first = false;
  for (const r of this.racers) if (!r.isPlayer) r.update(DT, aiActive, this.raceTime, this.rubber(r));
  this.collide();                                 // 车与车的碰撞也按物理步算
}

每帧把真实 dt 存进累加器 acc,凑够一个 1/120 秒就跑一轮完整物理(玩家 + 5 AI + 碰撞)。144Hz 屏幕多数帧跑 0~1 轮,60Hz 屏幕每帧跑 2 轮——物理速率与显示帧率解耦,任何设备上手感一致。

noEdge 是本章最精妙的一行。"按下沿"类输入(upPressed:本帧刚按下)只在物理第一子步有效——否则一帧内跑 2 个子步,小喷窗口会被同一个按键触发两次。把边缘输入在后续子步强制置 false,而"按住"类(up/left/shift)保持原样。这就是事件语义在固定步长下的正确处理,很多业余赛车游戏的小喷"偶尔双倍触发"就是栽在这里。

4.3 step() 的完整骨架

step(dt, inp)
 ├─ ① countdown 阶段(482-502):3-2-1 倒计时动画;cdT>2.72 检测起步喷射时机;
 │      cdT>=3 → state='race'(起跑门 gate.set(0) 打开)
 ├─ ② 玩家输入组装(511-523):菜单里代跑用 autopilot、触屏自动油门、
 │      Ctrl=道具、Alt=换位、R=复位(resetPlayer)
 ├─ ③ active 判定(524-525):玩家物理只在 'race'/'finish' 跑;AI 额外在 'menu' 也跑(演示模式)
 ├─ ④ 固定步长物理(527-536)↑
 ├─ ⑤ syncModel + updateProgress(537-540):视觉同步、计圈
 ├─ ⑥ standings 排序(541-546):名次
 ├─ ⑦ 道具系统 update + aiThink(547-550)
 ├─ ⑧ handleEvents(551):消费玩家车事件队列
 ├─ ⑨ emitEffects(552):烟雾/火花/胎痕
 ├─ ⑩ 杂项:加速带贴图滚动(558)、HUD(560-561)、引擎音(563-566)

4.4 事件队列:物理层与表现层的单向管道

vehicle.js 里你看到的所有 this.emit('driftStart', {}) 推进了 this.events 数组。谁消费?handleEvents(main.js:793-852):

handleEvents() {
  const P = this.player;
  if (!P || !P.events.length) return;
  for (const ev of P.events.splice(0)) {        // splice(0):取出并清空
    const d = ev.data || {};
    switch (ev.type) {
      case 'driftStart': /* 音效+开始胎痕 */ break;
      case 'gaugeFull':  this.hud.message('集气完成!'); this.audio.play('gauge'); break;
      case 'smallBoost': P.stats.small++; ... break;
      case 'double':     P.stats.double++; this.hud.message('双喷!'); this.audio.play('double'); break;
      case 'crash':      this.shake = Math.min(1, this.shake + d.power * 0.05); ... break;
      ...
    }
  }
}

为什么这么设计? 物理一帧可能跑 2 个子步、每步多个事件;如果物理直接调 hud.message(),1) 物理层就依赖了 HUD(破坏分层),2) 暂停/重放/倍速时表现层的时序没法控制。事件队列把"发生了什么"和"如何呈现"解耦——物理只负责陈述事实。游戏引擎里这就是 ECS 的 Observer 模式缩影。消费端处理完 splice(0) 清空,队列每帧归零。

4.5 计圈与名次:wraparound 难题

进度追踪(updateProgress,main.js:604-626)要解决一个纯一维问题:d 是回绕的。起点线在 d=0,车从 d=2995 跨到 d=5——是前进了一圈还是倒退了一圈?

// 玩家:用"欠账"标记法(main.js:608-617)
const L = this.track.length;
let dist = r.d;
if (r.owed) {                                    // 还没过第一圈起点
  if (r.half && dist > L * 0.6 && r.lastD < L * 0.4) {  // 跨过 0 线(2995→5 的特征)
    dist -= L; r.owed = false;                   // 补上 -L:真实进度是负的(起点后退车)
  }
  else if (dist < L * 0.5) r.half = true;        // 记录"到过后半圈"
} else if (r.lastD > L * 0.6 && dist < L * 0.4) {  // 正常跨线
  dist += r.lapsDone * L + L; ...                // 总里程 = 圈数×L + d
}
r.lastD = r.d;

玩家从起点线后方发车(负里程起步,makeRacers 里 d = -14 - floor(k/2)*11,main.js:390),所以需要 owed/half 两个标记把"发车时往后退着跨过起点线"的情形折算成负进度。dist 一旦折算成连续总里程(不打回绕),一切比较都变成普通数字比较:

// AI 更简单(621-624):直接 floor
r.totalD = r.d + r.lapsDone * L;
// 名次排序(541-546):按 totalD 降序排,完赛者按 finishTime
this.standings = [...this.racers].sort((a, b) =>
  b.finished !== a.finished ? (a.finished ? -1 : 1) : (b.finished ? b.finishTime - a.finishTime : 0) || b.totalD - a.totalD);

4.6 橡皮筋:让比赛永远胶着

rubber(r) {                                       // main.js:569-575
  const P = this.player;
  const gap = (r.totalD || 0) - (P ? P.totalD : 0);
  return gap > 0
    ? Math.max(diff.rubber[0], 1 - gap * 0.00045)   // AI 领先越多,被压越狠
    : Math.min(diff.rubber[1], 1 + (-gap) * 0.0004); // AI 落后越多,buff 越强
}

rubber 是乘在 AI 极速上的系数(ai.js:53 的 vmaxBase × rubber)。玩家落后 100 米 → AI 极速 ×0.955(不知不觉等你);玩家领先 100 米 → AI ×1.04(悄悄追上来)。三档难度只是数值不同(DIFFS,main.js:25-29:新手 0.881.03、熟练 0.931.06、车神 0.97~1.10)。系数 0.00045/米意味着 300 米差距约 ±13%——够胶着又察觉不到。单人游戏 AI 的"公平"是设计出来的,不是算出来的。

4.7 车与车碰撞:为什么放在物理步内

collide()(main.js:702-730):

const R = 3.1;                                    // :704 两车碰撞半径之和
for (let i = 0; i < this.racers.length; i++)
  for (let j = i + 1; j < this.racers.length; j++) {
    const a = this.racers[i], b = this.racers[j];
    if (Math.abs(a.y - b.y) > 2.5) continue;      // :710 立体交叉上下层不撞
    const dx = b.x - a.x, dz = b.z - a.z;
    const d2 = dx * dx + dz * dz;
    if (d2 > R * R || d2 < 1e-6) continue;
    const d = Math.sqrt(d2);
    // 推开 + 交换动量
    ...
    const imp = -rel * 0.5;                       // :720 相对速度的 50% 转为冲量
    ...
  }

6 车 15 对两两检测(无 broadphase,15 对 × 240 次/秒 = 3600 次距离平方比较,毫无压力)。这个函数被放在固定步长循环里(535 行 this.collide())——碰撞和物理同步积分,否则 120Hz 物理 + 60Hz 碰撞会让"推开"的速度修正隔帧生效,表现为贴着抖动。推开后的速度以赛道坐标系分解回各车(pushRacer,738-743:r.lat += px·s.rx + pz·s.rz; r.pushD += px·s.tx + pz·s.tz)——AI 的位置状态就是 (dist, lat),直接加在这两个量上才不会和它的世界坐标脱节。

4.8 相机:第三人称跟随的全部技巧

updateCamera(main.js:978-1039):

const MODES = [
  { dist: 8.2, h: 3.0, look: 5, lookH: 1.3 },     // :1003 近景
  { dist: 12.5, h: 4.6, look: 6, lookH: 1.5 },    // 远景
  { dist: 5.2, h: 1.9, look: 8, lookH: 1.1 },     // 车头视角
];
const yawT = P.s >= 0 ? P.m + wrapAngle(P.h - P.m) * 0.35 : P.h;   // :1010
this.camYaw = dampAngle(this.camYaw, yawT, P.drifting ? 4.5 : 7, dt);  // :1011

1010 行是相机对准哪里的学问:不是车头 h,也不全是速度方向 m,而是 m + (h−m)×0.35——车头与速度方向之间取 35% 偏向车头。漂移时相机主要跟速度方向(看到的是"甩过去的路径"),但略带车头指向(保留姿态感)。λ 也切换:漂移 4.5(松)、抓地 7(紧)——漂移时相机"甩"得开,视觉冲击强。

this.camPos.lerp(want, 1 - Math.exp(-14 * dt));  // :1019 位置强跟随 λ=14

朝向跟随 λ=7 但位置跟随 λ=14——位置贴得紧、朝向有惰性,这是第三人称赛车相机的黄金配比,反了就会晕。

速度反馈三件套(1025-1036):撞击 shake(衰减 dt*1.8,按平方施加随机偏移)、FOV 拉伸 fovT = 66 + |s|·0.12 + nitro?9 : 0 + small?4 : 0(速度感不是加动效,而是拉广角——生理级错觉)、render() 里还有速度线(main.js:1056)。

4.9 render():帧内最后的渲染编排

render(dt) {                                      // main.js:1041-1062
  if (this.skyMesh) this.skyMesh.position.copy(this.camera.position);   // :1045
  // 太阳阴影相机对齐到纹素网格,消除阴影边缘闪烁       :1049-1052
  const texel = 150 / this.sun.shadow.mapSize.x;
  sun.position.set(...Math.round(v / texel) * texel...);
  this.hud.speedLines(k, dt);                     // :1056 速度线(高速时)
  if (this.bloom) this.bloom.strength = lerp(0.55, 1.05, ...nitro/speed);   // :1059
  this.composer ? this.composer.render() : this.renderer.render(this.scene, this.camera);
}
  • 天空球钉在相机上(1045):天空球半径 4000,相机移动时它跟着——永远走不到"天边"。
  • 纹素对齐(1049-1052):DirectionalLight 的阴影是正交相机,若其位置在纹素网格上不对齐,光源每帧微动会导致阴边缘"沸腾"。把太阳位置量化到 150/mapSize 的网格上是标准修复。
  • Bloom 强度动态(1059):平时 0.55,氮气中拉到 1.05——泛光本身就是速度反馈的一部分。

本章小结:step() 只做四件事——分步调度(固定步长)、状态路由(字符串状态机)、结果消费(事件队列)、表现编排(相机/HUD/音效的参数喂给)。它不包含任何一条物理公式,也不画任何像素——它是层与层之间的"总装车间"。读懂本章,你就知道往这个项目里加一个新玩法该动哪里:改 vehicle 的状态机和事件,加一个 handleEvents 分支,完事。


第 5 章 玩法层:ai.js 与 items.js

这两章是"游戏规则"所在的层。它们读下层(track/vehicle)的数据结构,被上层(main)调度,彼此互不认识。

5.1 ai.js —— 一台 PD 控制器 + 一张弯速表

文件:src/ai.js(138 行)。先纠正一个预期:AI 不是和玩家跑同一套物理。它复用 TUNE 参数和 (dist, lat) 坐标系,但更新逻辑是自己的(AICar.update(dt, active, raceTime, rubber))——没有漂移集气、没有小喷、没有撞墙反弹,取而代之是三个决策:目标速度、目标走线、何时用氮气。AI 漂移是纯视觉的。这是刻意的工程取舍:AI 的任务是"可信的对手",不是"第二套物理模拟"。

目标速度:弯速表

const cNow = this.track.curvAhead(this.dist, 8);        // ai.js:59 眼前 8 米曲率
const look = 18 + this.s * 1.25;                        // :60 视距随速度拉长
const cAhead = this.track.curvAhead(this.dist, look);
const A = 16 + this.skill;                              // 等等,实际是 16 + 30*skill
const vCorner = Math.sqrt(A / Math.max(Math.abs(cAhead), 1e-4));  // :64 弯速公式!
let vt = Math.min(vmaxBase, vCorner);                   // 目标速度 = min(极速, 弯速)

(原文 A = 16 + 30 * skill,ai.js:63。)弯速公式就是第 2/3 章推导的向心加速度公式反解:

向心加速度 a = v²·κ ≤ A(轮胎抓地上限)
        ⇒  v ≤ √(A/κ)

κ 是前方 look 米内最大曲率。A 是"AI 的抓地等级":新手 16+30×0.55≈32.5,车神 16+30×1.38≈57.4——同一个弯(κ=0.02,半径 50 米),新手过弯 40 m/s、车神 53 m/s。难度不是"AI 更快",而是"AI 敢更快地过弯",其余速度都由同一张表统一约束,观感自然。

look = 18 + s·1.25(60 行)是刹车距离的廉价近似:速度越快看得越远,保证进弯前已经减速。要减速时:

if (this.s > vt + 1) this.s -= (16 + this.skill * 8) * dt;   // 刹车
else this.s += (nitro ? 34 : 20 + 4*skill) * (1 - Math.pow(this.s / Math.max(vt,1), 2) * 0.7) * dt;  // :69 加速曲线同款

走线:PD 控制器(这一节值得慢慢读)

AI 不操作"方向盘",直接控制横向位置 lat 逼近目标线:

const hw = this.track.halfW - 2.2;                       // ai.js:81 安全半宽
// 弯心偏移:进弯切弯心,越急切得越深
const inside = -Math.sign(cAhead) * clamp(Math.abs(cAhead) * 45, 0, 1) * hw * 0.8;   // :82
const target = clamp(inside + this.personal * hw * 0.8, -hw, hw);                     // :83 个人习惯偏移
// PD 控制器
this.latV += clamp((target - this.lat) * 2.2 - this.latV * 2.4, -14, 14) * dt;       // :84
this.lat += this.latV * dt;
if (Math.abs(this.lat) > hw) { this.lat = clamp(this.lat, -hw, hw); this.latV *= -0.3; }  // :87 撞墙轻弹

什么是 PD 控制器(Proportional-Derivative,比例-微分)?只有 P(比例)项:误差大→修正大。但纯 P 会过冲振荡:到 target 时还有速度,冲过去,再修回来,蛇形前进。D 项(微分,即当前横向速度 latV)提供阻尼——"离目标越近且冲得越快,越提前刹车"。

输出 = Kp·(target − lat) − Kd·latV
      = 2.2 × 位置误差     − 2.4 × 当前横向速度

两个系数同量级(2.2/2.4)≈ 临界阻尼附近,AI 走线是"画着圆滑过去"而不是"之字形逼近"。82 行的 inside 是走线几何:−sign(κ)·min(|κ|·45,1)·hw·0.8——左转(κ>0)目标线向左偏,急弯偏到离墙 20% 处(顶点切弯)。personal(构造时 (rnd()−0.5)·0.6,ai.js:14)给每台 AI ±0.3 的走线习惯,六台车不会叠在一条线上。

进弯/出弯时 target 平滑变化(因为 cAhead 随 dist 滚动),走线自动是"外-内-外"——不是AI 懂赛车,是几何自然涌现。

氮气决策与视觉漂移

if (Math.abs(this.track.curvAhead(this.dist, 140)) < 0.006   // ai.js:74 前方 140 米无弯
    && this.s > 35 && this.nitroCd <= 0) { ... }             // :75 且速度够
this.nitroCd = (6 + Math.random() * 8) / this.skill;          // :77 冷却随难度缩短

直道(140 米曲率 < 0.006)+ 速度 > 35 → 放氮气,随后 6~14 秒冷却。ai.js:89 this.dist += this.s * dt + this.pushD——AI 的推进是 dist 直接积分(被撞的冲量 pushD 也汇入),世界坐标只用于渲染同步。

// 视觉漂移(ai.js:109-113):不改变运动,只摆姿态
const wantDrift = Math.abs(cNow) > 0.012 && this.s > 26;
this.drift = damp(this.drift, wantDrift ? 1 : 0, 5, dt);
const yawT = Math.sign(cNow) * Math.min(0.62, Math.abs(cNow) * 22);
this.yawOff = dampAngle(this.yawOff, this.drift * yawT, 5, dt);
// :115 车头 = 赛道朝向 + 横向速度角 + 漂移甩尾角
this.h = s.hd + Math.atan2(this.latV, Math.max(this.s, 3)) + this.yawOff;

115 行是 AI 姿态的合成公式:赛道朝向 + atan2(横向速度/前进速度)(车身自动指向合速度方向,天然带一点姿态)+ 人为的甩尾角。观感上 AI 在漂移,物理上它稳如轨道车。玩家看到的世界和模拟的世界是两回事——这是表现层的特权。

5.2 items.js —— 加权随机 + 简单追踪

文件:src/items.js(228 行)。

按名次加权的道具表

const WEIGHTS = {                                  // items.js:4-8
  lead:  { banana: 35, shield: 30, nitro: 20, missile: 10, magnet: 5 },   // 领头:防
  mid:   { missile: 25, nitro: 25, banana: 20, magnet: 15, shield: 15 },  // 中游:均衡
  back:  { missile: 32, nitro: 30, magnet: 25, shield: 10, banana: 3 },   // 落后:追
};

经典"橡皮筋道具"设计:名次决定池子。挑选用累计权重随机(pick(),10-19)。这就是马里奥赛车"红甲虫只在末位出现"的同款机制——游戏平衡从来不是对称的。

道具箱生成与拾取

20 个箱子的位置(ItemSystem 构造,items.js:22-60):里程 f∈{0.1, 0.35, 0.6, 0.85} × 横向 lat∈{−0.6,−0.3,0,0.3,0.6}·半宽 的网格取 20 个,外观是旋转的问号方块(悬浮 y+1.6)。拾取判定(update 内):

if (dx*dx + dz*dz < 6.5 && Math.abs(dy) < 3) { giveRandom(r); box.t = 2.5; }   // 2.5s 后重生

又是 (d/lat) 坐标系里两三个乘法的事——第 2 章的承诺第二次兑现(加速带、道具箱、香蕉命中、导弹命中,四类判定全部是区间/距离查询)。

导弹:追踪 + 命中

// items.js:186-203(节选)
const to = _v.set(target.x - m.x, target.y - m.y, target.z - m.z);
const dist = to.length();
m.dir.lerp(to.normalize(), Math.min(1, dt * 6)).normalize();   // 转向速率限制
m.v = Math.max(m.v, Math.abs(target.s) + 35);                  // 永远比目标快 35
m.x += m.dir.x * m.v * dt; ...
if (dist < 2.6) hitRacer(target, 'missile');                   // 命中
if (m.life > 6) explode(m);                                    // 6 秒寿命(防绕圈)

追踪核心一行:dir.lerp(to, min(1, dt·6))——导弹的朝向每秒最多转 6 弧度的"刚性",不是瞬间对准。这带来两个免费的好处:导弹飞出的是圆滑的追踪弧线(好看),且急转可以甩掉它(玩法深度:hitRacer 里玩家被导弹命中 spin=1.3 秒失控 + s *= 0.35——重罚但不致死)。

香蕉、磁铁、护盾与 AI 用道具

  • 香蕉:放在车主后方 3.2 米(r.x − sin(h)*3.2),踩中 slowTime 减速。宽限期 1.2 秒(age < 1.2 时主人踩自己的香蕉无效)——刚扔出去压自己线的尴尬由一行 if 兜底。90 秒未触发自动过期(防止赛道变成雷区)。
  • 磁铁:magnet=3 秒,吸向 racerAhead()(前方最近对手);main.js:960-966 还给磁铁状态画金色粒子拖尾。
  • 护盾:7 秒,hitRacer(main.js:757-774)先查 shield——吸收导弹/香蕉并弹出护盾特效,不减速。
  • AI 用道具(aiThink,items.js:217-228):每 1~4 秒决策一次(不是每帧——AI 也要"反应时间")。规则全是阈值:导弹在手且与目标 gap>350 → 先 swap() 把道具挪到一号位再用;磁铁 gap<150 才放(贴身吸);盾/氮气立即用。没有任何搜索算法,全部是 if——道具决策的本质是战术枚举,不需要聪明。

本章小结:AI = 查表(弯速 √(A/κ))+ 控制器(PD 走线)+ 阈值(氮气/道具),没有一行"学习"或"搜索",但六台车跑起来各有性格、有攻有防。这就是"够用的智能"——游戏 AI 的目标从来不是最优解,是戏剧性。


第 6 章 表现层:world.js / textures.js / effects.js

这一层不参与任何游戏规则——删掉它们游戏照样"跑"(只是黑屏)。但视觉的成本预算观念在这里最重:每帧 16 毫秒,渲染占 12+。

6.1 world.js —— 天空、地面、水、山,以及"合批"

天空:一个全屏"作弊"球

// world.js:59-89 buildSky 的 ShaderMaterial(节选)
varying vec3 vWorld;
void main() {
  vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  gl_Position = p.xyww;              // world.js:69 —— 关键技巧
  vWorld = position;
}
// 片元:按方向向量算渐变天幕 + 太阳光斑
float h = normalize(vWorld).y;
vec3 c = h > 0.0
  ? mix(horizon, top,    pow(clamp(h, 0.0, 1.0), 0.55))
  : mix(horizon, bottom, pow(clamp(-h, 0.0, 1.0), 0.4));     // :74
float s = dot(normalize(vWorld), sunDir);
vec3 sun = vec3(pow(s, 900.0) * 6.0 + pow(s, 40.0) * 0.35 + pow(s, 6.0) * 0.12);   // :76

gl_Position = p.xyww 解密:齐次裁剪空间里 w 是透视除法的分母,把 z 设成 w 之后 z/w = 1——永远落在最远的深度平面上。所以这个半径 4000 的球(其实用什么都行)画出来永远在所有物体"后面",无需关心它的实际远近。配合 renderOrder=-10(先画)+ fog:false(天空不吃雾)+ BackSide(从内往外看球壳)。太阳是三个不同锐度的 pow 光斑叠加(900=核心、40=光晕、6=大气散射),太阳方向 sunDir 同时喂给水和光照——整个世界的光来自同一个向量,这是"画面统一"的数学根基。

地面:顶点色 + 包络 + 压平

buildGround(world.js:107-174)造一张 3200×320 米的大平面网格(320×320 段),每个顶点:

h = fbm 噪声地形;                                   // world.js:113-141 三个包络函数
h = Math.max(h, lo(x, z));  h = Math.min(h, hi(x, z));   // 上下包络钳制
if (离赛道中心 < flatR) h = lerp(赛道高, h, 平滑衰减);    // 赛道周围压平
colorAt(x, z, h, col);                                // :152-165 顶点色(省一张大贴图)

包络(envelope)是程序化地形的"设计图":下包络保证海边低于海面、上包络防止山吞掉赛道。main.js:40-63 的 baseFor(id) 就是每张地图的包络函数——城市图的河道(r<44 ? -6 : …)、爱琴海的向南倾斜入海(lerp(land, -14, smoothstep(-200,-262,z)))、埃及的绿洲凹坑全写在这里。数据(layouts)给形状,包络(baseFor)给地貌,噪声(fbm)给质感——三层各司其职。

顶点直接带颜色(vertex colors)而不用贴图:320×320 = 10 万顶点的渐变地形,一张等分辨率贴图要 1000×1000 像素还带重复感;顶点色是连续的、且随光照正常着色。

水面:四个余弦波叠加出"真的水"

buildWater(world.js:177-229)的片元着色器:

// 法线 = 4 个不同频率/速度的余弦波梯度之和      world.js:201-204
// 频率 0.09/0.17/0.41/0.83,速度 1.3/1.9/2.7/3.4(大浪慢、涟漪快)
float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0);        // :207 菲涅尔
vec3 col = mix(deepColor, skyColor, fres);
vec3 spec = pow(max(dot(reflect(-sunDir, n), v), 0.0), 180.0) * 2.5;   // :211-212 高光
// sparkle:伪随机像素闪烁                       :213

菲涅尔(Fresnel):视线越掠水面,反射越强(看脚下的水是透明的深绿,看远处的水是天空色)——1−dot(n,v) 的三次幂恰好重现这个物理现象。镜面高光 pow(…,180) 是超锐光斑(太阳在波峰上碎成星星)。没有反射贴图、没有折射 pass,一个法线扰动 + 两个 pow,便宜且在 60fps 里骗过所有人的眼睛。

山:两圈"裙边"几何 + fbm 峰顶

buildMountains(world.js:232-275):围绕赛道中心两圈环形山(远圈大而高),每座山是 360 边的锥形裙边,峰顶高度由 fbm 决定,雪线以上换顶点色画雪帽(t > 0.62 && top > h*0.45,256 行附近)。放在 1400~1600 米外圈,功能是视觉围墙:远处地平线永远有轮廓,玩家不会看到"世界尽头"。

Batch:静态物体合批(表现层最重要的一招)

class Batch {                                    // world.js:21-49
  add(geo, mat, matrix, shadow) {
    const e = matrix.elements;
    const key = `${mat.uuid}|${Math.floor(e[12] / 260)}|${Math.floor(e[14] / 260)}|${shadow ? 1 : 0}`;
    ...按 key 分桶
  }
  build(parent) {
    for (每个桶) {
      const merged = mergeGeometries(桶里的几何, false);   // :37 拼成一个 BufferGeometry
      const mesh = new THREE.Mesh(merged, 桶的材质);
      mesh.matrixAutoUpdate = false;                     // :44 静态物体冻结矩阵计算
      parent.add(mesh);
    }
  }
}

问题:城市图有几百栋楼、几十盏路灯、上千个物件,每个一个 Mesh = 几百次 drawcall,帧率崩盘。 方案:把同一材质且同一 260×260 米空间分桶的几何拼成一个大 BufferGeometry,一次 drawcall 画完。材质相同才能合并(否则无法共享渲染状态);按空间分桶是因为视锥剔除以 Mesh 为单位——合成一个大 Mesh 后整桶要么全画要么全不画,分桶保证远处整桶被剔除时不会牵连近处。 代价:完全静态(一动就不能合);memory 翻倍缓存顶点。 matrixAutoUpdate=false 顺手省掉每帧矩阵乘法。这一招让整张地图静态部分压到个位数 drawcall,是"程序生成海量物件"在浏览器里跑 60fps 的前提。

6.2 textures.js —— 全部贴图来自 <canvas> 2D 绘图

文件:src/textures.js(610 行),15 个纹理生成函数。管线:

function mk(w, h) {                              // textures.js:7-12
  const c = document.createElement('canvas');
  c.width = w; c.height = h;
  return [c, c.getContext('2d')];
}
function toTex(c, { repeat = true, srgb = true } = {}) {   // :14-20
  const t = new THREE.CanvasTexture(c);
  if (repeat) t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.anisotropy = MAX_ANISO;
  if (srgb) t.colorSpace = THREE.SRGBColorSpace;
  return t;
}

CanvasTexture 直接把 canvas 位图当 GPU 纹理——你会的 Canvas 2D API(fillRect/渐变/arc/文字)就是贴图工厂。例如城市护栏(wallTexture('city'),207-299):

const cols = ['#1565c0', '#fafafa', '#e53935', '#fafafa'];   // 蓝白红白围挡
for (let i = 0; i < 4; i++) { g.fillStyle = cols[i]; g.fillRect(i * 256, 0, 256, H); }
g.font = 'italic bold 60px Arial Black, Arial';
g.fillText('SPEED', ...);                                     // 直接写广告字

1024×128 的画布 = 沿护栏每 16 米重复一次(WALL_TEX_LEN)。路面(41-177)更精细:沥青画车辙暗带、车道虚线、蓝霓虹边;石板路画错缝砖 + 每块随机亮度;法老路画紫色边线 + 沙岩裂纹。每个纹理函数都是一段小小的 Canvas 美术课,配 mulberry32 固定种子保证每次生成一致。

两个工程细节值得抄:

  1. cache Map(34-38 行):cached('road-' + style, fn)——同一风格贴图全游戏只生成一次,换图/重赛不再重复绘制。
  2. sRGB 标记(19 行):颜色类贴图必须声明 colorSpace = SRGBColorSpace,否则线性采样会把颜色"洗白"。(softDotTexture 特意 srgb:false,因为粒子亮度要走数学混合,不是美术颜色。)

为什么程序化贴图可行? 贴图信息量低(平铺、重复、无方向性)而规则性强(砖缝等距、路缘等宽)。规则的东西代码写得出来;照片级的东西才需要美术。本项目全部选了"规则风格",这是整个"零资源"决策的成立前提。

6.3 effects.js —— 粒子与胎痕:环形缓冲区的两堂课

粒子:ShaderMaterial + gl_PointSize

// effects.js:4-16 顶点着色器
attribute float aSize;
uniform float uScale;
void main() {
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  gl_PointSize = aSize * uScale / max(0.1, -mvPosition.z);   // :13 透视缩放
  gl_Position = projectionMatrix * mvPosition;
}

Point Sprites 是粒子系统的最省形态:一个顶点 = 一个方形粒子(GPU 自动光栅成方块),不需要每粒子四个顶点。gl_PointSize / -mvPosition.z——离相机越远越小,透视由除法免费获得。片元着色器(17-28)用中心距离做圆形软边渐隐(配合 softDotTexture 的径向渐变贴图)。

Particles 类(31-105)管理 1800×2 个粒子槽(IS_TOUCH 时降到 900):

// 11 列平行 Float32Array(位置/速度/寿命/大小/颜色…)  effects.js:34-45
this.pos = new Float32Array(N * 3);   // 生命周期结束的槽位 size=0 隐藏
geometry.boundingSphere = new THREE.Sphere(0, 1e7);   // :51 巨大的包围球:永不被视锥剔除
emit(x, y, z, vx, vy, vz, life, size, r, g, b, drag, grav) {   // :68-77
  // 从 this.i 环形取下一个槽,覆盖旧数据
  this.rgb[i * 3] = r * r;             // :75 sRGB→线性的廉价近似(平方)
}

环形缓冲:新粒子永远覆盖最老的槽,不需要释放/分配。r*r 平方是 sRGB→线性转换 pow(c, 2.2) 的一阶近似——粒子发光叠加在线性空间才不会"发灰"。燃烧数学都在 update(79-104):位置积分、drag 指数衰减、alpha 用 a0*(1-k)*min(1, k*8+0.2)(出生淡入 + 死亡淡出)。一个 drawcall 画所有粒子(一个 BufferGeometry,DynamicDrawUsage 每帧整包上传)。

调用方在 main.js 的 emitEffects(920-968):氮气蓝焰、漂移烟、撞墙火花、集气金粉(gauge>=1 || nitroCount>=2 时的金色拖尾)、磁铁金线、导弹尾迹……全部同走一个池。距离 250 米外的特效直接不发射(925 行)——又一处"看不见的不算钱"。

胎痕:环形四边形条带

SkidMarks(108-187):漂移时每帧往路面"贴"一小段四边形(左右轮各一)。同样是环形缓冲(2600 段,写满就覆盖最老的)。两个细节:

// 断续判定:本帧与前帧距离 < 25 米才连成条带       effects.js:154
if ((x - prev.x) ** 2 + (z - prev.z) ** 2 < 25) { /* 与上一段缝合 */ }
// 材质 polygonOffset: -4                           :139-141

漂移中断(腾空、出漂)必须断开条带,否则会拉出横穿赛道的"钢丝"。polygonOffset 把胎痕在深度上推近(胎痕贴路面只抬高几毫米,必须防 z-fighting)。胎痕 alpha 随漂移烈度变化——急漂印黑、缓漂印浅。

本章小结:表现层三板斧——合批(静态合一个大几何)、池化(动态环形缓冲零 GC)、着色器小技巧(xyww 天空、透视点精灵、菲涅尔水面)。全部技巧的共同点:用数学换内存和 drawcall,而不是用美术换。这与"零外部资源"的项目基调完全一致。


第 7 章 外围层:audio.js 与 hud.js

这两个文件零依赖(audio.js 连 util 都不 import),彼此互不知道对方存在,只被 main.js 调用。它们验证了分层架构的最后一环:最外圈的东西最容易替换——换成 WebAudio 采样器或 React HUD,内核一行不改。

7.1 audio.js —— 没有一个音频文件的"声音引擎"

文件:src/audio.js(297 行)。所有声音 = WebAudio 节点图实时合成。

音色工厂

init()(23-95)搭好常驻节点:

// 引擎音(持续存在,改参数不重建)               audio.js:47-64
const osc1 = new OscillatorNode(ctx, { type: 'sawtooth' });   // 锯齿波:粗糙的"引擎"底噪
const osc2 = new OscillatorNode(ctx, { type: 'square', detune: 1200 });  // 方波高八度
osc1.connect(lpf); osc2.connect(lpf);
const lpf = new BiquadFilterNode(ctx, { type: 'lowpass', frequency: 900, Q: 4 });
// + 打滑带通 2300Hz、风噪带通 700Hz 两个常驻通道

合成器思维:锯齿波泛音丰富像引擎、方波像涡轮;lowpass Q=4 在截止频率处共振出"呜——"的腔体感。主链 master(0.8) → DynamicsCompressor(−12dB, ratio 4)——压限器保证 18 种音效叠加不爆音,免费的混音师。

瞬时音效走两个一次性工厂:tone(freq, dur, …)(118-132,OscillatorNode 加音量包络)和 noiseBurst(dur, …)(134-150,从预生成的 2 秒噪声 buffer 里切片 + 带通滤波)。play(name)(152-191)switch 分发 18 个音效——撞墙是低频 noiseBurst,集气完成是上滑 tone,双喷是两个 tone 叠拍……每个音效就是两三个振荡器的参数配置。

引擎音:6 挡转速模拟

setEngine(speed, boosting, drifting) {          // audio.js:97-113
  const kmh = Math.abs(speed) * 3.6;
  const g = Math.min(5, Math.floor(kmh / 33));          // 挡位 = 速度阶梯
  const inGear = (kmh - g * 33) / 33;                   // 挡内进度 0..1
  const rpm = 0.3 + inGear * 0.7 * (0.75 + g * 0.05);   // :104 转速曲线
  const base = 55 + rpm * 130 + (boosting ? 35 : 0);    // :105 基频
  osc1.frequency.value = base;
  osc2.frequency.value = base * 1.5;                    // 五度泛音
  lpf.frequency.value = 400 + rpm * 1800;
}

速度直接映射基频会是一根单调的"呜——"。挡位台阶让频率呈锯齿状循环(升挡瞬间 rpm 掉回 0.3 再爬升)——光这一个函数就把街机引擎的"换挡感"做出来了。boosting 时 +35Hz、drifting 时打开打滑通道。main.js 每帧把玩家车状态喂进来(563-566 行),声音是状态的函数。

音乐:25ms 定时器 + 前瞻调度的音序器

4 首 BGM(SONGS,4-9 行:每首 = bpm + 根音 + 和弦进行 + 旋律序列)由 16 分音符步进音序器播放:

schedule() {                                    // audio.js:209-220
  const spb = 60 / this.bpm / 4;                // :212 一个 16 分音符的时长
  if (this.nextT - ctx.currentTime < 0) this.nextT = ctx.currentTime;   // :214 积压重置
  while (this.nextT < ctx.currentTime + 0.12) { // :215 只调度未来 120ms 内的音符
    this.playStep(this.step % 16, this.step);
    this.nextT += spb;
    this.step++;
  }
}
// init 里:setInterval(() => this.schedule(), 25)   // :94

为什么不能在回调里直接 play? WebAudio 的 currentTime 与主线程帧率不同步;若每 25ms 醒来播放"现在"的音符,主线程卡顿时音符全部迟到、节拍漂移。正确姿势(WebAudio 官方推荐模式):提前 120ms 把音符调度到绝对时间轴(osc.start(nextT) 接受未来时刻),主线程抖动 ±50ms 也只影响"调度"不影响"发生"——音乐节拍由音频硬件时钟保证。:214 的积压重置处理后台标签页(定时器被节流到 1Hz,醒来时积压几小节,直接放弃对齐)。

playStep(222-259)每 16 分音符发 4 个声部:kick(150→40Hz 频率下扫 + 快衰减)、bass(根音−24 + 和弦低音)、pad(长音 spb×15)、lead(根音+12 + 和弦 + 旋律偏移)。NOTE(n) = 440·2^((n−69)/12)(2 行)——MIDI 音高换算公式,一个 Math.pow 让代码里的整数变成音符。

7.2 hud.js —— Canvas 小地图 + DOM 缓存

文件:src/hud.js(290 行)。两种渲染技术的分工:HUD 的静态图形用 Canvas,文字数字用 DOM。

小地图:一次预绘 + 每帧贴图

setupMinimap(track) {                           // hud.js:68-119
  // 离屏 canvas:把赛道中心线画一遍(黑 16px 底 + 白 9px 线)
  // 桥段加蓝色覆盖、起点画格子旗                         :95-117
  const mmT = `translate(${ox}px,${oy}px) scale(${k}) rotate(0)`;   // :72 变换字符串
  this.pathEl.style.transform = mmT;
}
drawMinimap(P) {                                // hud.js:121-152
  // 玩家箭头:一个小三角 DOM 元素
  this.carEl.style.transform = `translate(${P.x * k + ox}px,${P.z * k + oy}px) rotate(${P.h + Math.PI})`;
  ...
}

赛道轮廓只在换图时画一次到离屏 canvas;每帧只做两件事:把 6 个车的位置换算成像素、挪 DOM transform。rotate(P.h + Math.PI) 那个 +π 是坐标系换手(世界朝向 0=+z,屏幕角度 0=+x)的经典 off-by-90° 补偿。AI 车箭头用 transform 直接挪——60fps 下 DOM transform 是合成器加速的,比 canvas 重绘还省。

仪表与消息:DOM 更新的"值缓存"

set(el, v) {                                    // hud.js:56-60
  if (el._v !== v) { el.textContent = v; el._v = v; }   // 值没变就不碰 DOM
}

速度、圈数、名次每帧都在算,但 textContent 只在变化时写入——el._v 一个字段挡掉 95% 的无效 DOM 更新(浏览器 layout 是隐形成本大头)。消息条(217-225)最多同时 3 条、1150ms 自动淡出;速度表(154-191)是 canvas 弧线仪表,指针角 a0 + 1.5π·speed/300。

本章小结:外围层教的两课——音频要调度到硬件时钟(schedule-ahead 模式),DOM 要值缓存(text 只写变化)。加上前面各章,你已经把这个项目从数学原语到像素和声波完整走了一遍。


第 8 章 架构总结:为什么一个人写得完、改得动

8.1 依赖分层(收束全文)

第 1 层 util.js        数学原语(damp/wrapAngle/mulberry32/fbm)
                       ↑ 被所有层使用
第 2 层 track.js       一维化:(d,lat)↔(x,z) 双射 + 曲率/坡度/倾角派生量
                       ↑ 只依赖第 1 层
第 3 层 vehicle.js     状态机物理:h/m 双角度、漂移 FSM、事件队列
                       ↑ 只依赖第 1、2 层
第 4 层 main.js        粘合:固定步长调度、状态机路由、事件消费、相机
                       ↑ 组装第 1~3 层 + 调度第 5~7 层
第 5 层 ai.js/items.js 玩法规则:弯速表/PD 控制器/加权道具
                       ↑ 读第 2 层数据 + TUNE
第 6 层 world/textures/effects  表现:合批/程序贴图/粒子池
                       ↑ 只依赖第 1 层(甚至不知道 track 的存在)
第 7 层 audio/hud/input 外围:合成音频/DOM HUD/输入
                       ↑ 零依赖,纯被调用

没有一条反向边:track 不知道车、vehicle 不知道渲染、ai 不知道 hud。main.js 是唯一同时看见所有层的文件——而它自己几乎没有算法,只有调度。

8.2 这个架构回答了三个工程问题

① 为什么一个人写得完? 因为每层心智负载独立:写 track.js 时只需要想"一维坐标变换";写 vehicle.js 时只需要想"手感";写 audio.js 时只需要想"合成器"。3000 行不是 3000 行纠缠的代码,是 7 个各自 <700 行的独立问题。循环依赖的坏处从来不是"编译报错",而是任何一行修改的波及范围变成全图——分层把这个范围压成"只往下传"。

② 为什么 bug 好找? 依赖单向 = 因果单向。小喷偶发双倍触发 → 只可能是第 4 章的 noEdge 逻辑或第 3 章的窗口判定,两个文件 20 行代码里找;车穿墙 → 只可能是 project() 精度或碰撞步长,与贴图、音频、AI 全部无关。分层是给调试划的搜索域。

③ 为什么好扩展? 每个横向功能都只碰一条竖线:加新地图 = layouts.js 加控制点 + maps.js 加主题(两文件,零逻辑);加新道具 = items.js 的 WEIGHTS 表 + use() 一个 case + handleEvents 一个分支;换渲染风格 = 重写第 6 层、内核不动。数据驱动(地图=数据、道具=表)+ 分层(每类改动有明确的家),这就是全部秘密。

8.3 贯穿全项目的十个手法(速查表)

#手法出处
1指数平滑 1−e^(−λdt),帧率无关util.js:3,全项目几十处
2角度差先 wrapAngle 再插值util.js:9,track.js:161 等
3(d,lat) 一维化:进度/碰撞/AI 全换坐标系track.js:148/173
4hint 局部搜索:时间连贯性省掉全局扫描track.js:183
5固定步长累加器 + 边沿输入只发一次main.js:527-536
6事件队列:物理陈述事实、表现自行取用vehicle.js:80,main.js:793
7环形缓冲池:粒子/胎痕零 GCeffects.js:31/108
8合批:同材质+空间分桶 merge 成单 drawcallworld.js:21
9程序化一切:Canvas 贴图、WebAudio 音色、fbm 地形textures/audio/util
10视觉与逻辑分离:syncModel 只读状态、AI 漂移纯摆姿态vehicle.js:326,ai.js:109

8.4 按本文顺序读完之后

如果你逐章读到了这里,建议的下一步实践(按难度递增):

  1. 调手感:改 TUNE(vehicle.js:4)——把 grip 从 12 调到 5,感受"冰面";把 gravity 调到 9.8,感受"月球跳台"。
  2. 加一张地图:在 layouts.js 抄一份控制点改形状,在 maps.js 配主题色——体会"数据驱动"意味着不用写逻辑。
  3. 加一个道具(如油污/烟雾):WEIGHTS 表加一行、use() 加 case、update 加判定、handleEvents 加音效——四个明确的落点。
  4. 加回放功能:固定步长 + 纯函数状态机已经为此铺好了路(每 1/120s 存输入即可重放)——这是对这个架构"可扩展性"的终极测试。