有人用 Three.js 做了个在魔方上开车的游戏,六个面全能跑

Cube World 是开发者 Paul Robello 开源的一个 Three.js 项目:一辆车在魔方的表面行驶,路网覆盖全部六个面,面与面之间无缝衔接。更有意思的是,魔方会周期性地发生切片旋转,把正在行驶的路面和其他面重新拼在一起——车还在跑,世界已经变了。 在线 demo:https://paulrobello.github.io/cube-world/ (opens in a new tab)

难在哪里

在平面上生成路网不复杂,但在立方体六个面上做这件事,有两个核心问题需要解决:

第一,路怎么从一个面延伸到另一个面?

每个面的坐标系是独立的,车辆从 Top 面走到边缘,下一步踏入 Front 面,行和列的对应关系并不直接。

第二,魔方转动时,地块、建筑、车辆的状态怎么同步?

切片旋转会把一整行或一整列的地块搬到另一个面,每个地块的朝向要跟着调整,车辆的位置和行进方向也要同步更新,否则就乱了。

跨面衔接:邻接表 + step 函数

项目用一张静态的邻接表(ADJACENCY)描述六个面之间的拓扑关系,每条记录包含目标面、坐标变换函数和穿越后的行进方向:

interface AdjEntry {
  face: FaceId;
  transform: (row: number, col: number) => [number, number];
  continueDir: Dir;
}
 
const ADJACENCY: Record<FaceId, Record<Dir, AdjEntry>> = {
  top: {
    north: { face: "back",  transform: (_, c) => [2, c], continueDir: "north" },
    south: { face: "front", transform: (_, c) => [0, c], continueDir: "south" },
    east:  { face: "right", transform: (r, _) => [r, 0], continueDir: "east"  },
    west:  { face: "left",  transform: (r, _) => [r, 2], continueDir: "west"  },
  },
  // ...其余五个面
};

transform 是关键——它把当前面边缘的坐标映射到相邻面对应的入口坐标。例如从 Top 面向北走出去,进入 Back 面时行坐标固定为 2,列坐标不变。

实际移动通过 step() 函数完成,每走一格先计算面内新坐标,越界时调用邻接表完成跨面转移:

function step(face: FaceId, row: number, col: number, dir: Dir) {
  const [nr, nc] = moveInDir(row, col, dir);
 
  // 面内移动
  if (nr >= 0 && nr < 3 && nc >= 0 && nc < 3) {
    return { face, row: nr, col: nc, dir };
  }
 
  // 跨面移动:查邻接表,做坐标变换
  const adj = ADJACENCY[face]?.[dir];
  if (!adj) return null;
  const [newRow, newCol] = adj.transform(row, col);
  return { face: adj.face, row: newRow, col: newCol, dir: adj.continueDir };
}

程序化路网生成

路网由 generateRoadPath() 在游戏启动时随机生成,每次结果不同。生成逻辑基于 step() 迭代延伸,同时控制单面内的路块密度,避免路段集中在某一面:

export function generateRoadPath(): PathNode[] {
  const path: PathNode[] = [];
  const visited = new Set<string>();
 
  // 随机选起始面和边缘入口点
  let face: FaceId = allFaces[Math.floor(Math.random() * allFaces.length)];
  const startEdge = edgeOptions[Math.floor(Math.random() * edgeOptions.length)];
 
  // 最多延伸 22 步,倾向于在同一面不超过 3 个地块后跨面
  while (path.length < 22) {
    const key = `${face},${row},${col}`;
    if (visited.has(key)) break;
    visited.add(key);
    path.push({ face, row, col });
 
    const next = step(face, row, col, dir);
    if (!next) break;
    ({ face, row, col, dir } = next);
  }
 
  return path;
}

路径生成完成后,每个路块根据前后节点的进出方向被分类为直道、弯道、T 形路口或十字路口,非路块地块则随机放置建筑、公园等环境元素,并自动朝向相邻的道路。

切片旋转时的状态同步

切片旋转是难度最高的部分。rotateSlice() 接收轴向、层级和方向三个参数,把相关联的几个面的地块循环交换。以 Y 轴旋转为例,前→右→后→左四个面的对应行依次交换,每次跨面都调整地块旋转角度:

// Y 轴旋转:前 → 右 → 后 → 左
function rotateSliceY(index: number, dir: number) {
  const faces = ["front", "right", "back", "left"] as FaceId[];
  const saved = getRow(state[faces[0]], index);
 
  for (let i = 0; i < 3; i++) {
    const from = faces[i];
    const to = faces[(i + 1) % 4];
    const row = getRow(state[from], index);
    // 每次跨面旋转 90°
    adjustTiles(row, dir * 90);
    setRow(state[to], index, row);
  }
  setRow(state[faces[0]], index, adjustTiles(saved, dir * 90 * 3));
}

当旋转发生在边缘层(index 为 0 或 2)时,对应的整个面也要跟着旋转 90°,通过 rotateFace() 处理,坐标变换遵循标准的矩阵旋转公式:

function rotateFace(faceId: FaceId, dir: number) {
  const original = deepCopy(state[faceId]);
  for (let r = 0; r < 3; r++) {
    for (let c = 0; c < 3; c++) {
      const tile = original[r][c];
      tile.rotation = (tile.rotation + dir * 90 + 360) % 360;
      // 顺时针:新位置 (c, 2-r);逆时针:新位置 (2-c, r)
      if (dir === 1) state[faceId][c][2 - r] = tile;
      else           state[faceId][2 - c][r] = tile;
    }
  }
}

旋转完成后,系统检测车辆是否还在道路上,如果脱轨则触发粒子效果并重新生成路网。

本地运行

需要提前安装 Node.js。

git clone https://github.com/paulrobello/cube-world.git
cd cube-world
npm install
npm run dev

启动后访问本地地址即可进入游戏。控制面板支持切换昼夜、相机视角(跟随 / 俯视 / 电影感 / 车内)、音效开关和手动重新生成世界。

写在最后

Cube World 在技术上有两个值得关注的地方:一是用邻接表统一描述六面拓扑,把"跨面移动"这件事收敛到一个可复用的 step() 函数里,路网生成和车辆移动都建立在同一套抽象上;二是切片旋转时多个对象的状态同步,地块坐标、朝向、路面角度、车辆位置一次性全部对齐,没有遗漏。

它是一个完整可运行的游戏 demo,不是库,用法是克隆后直接跑,或者拆取其中的邻接表 + step 寻路逻辑用在自己的项目里。对想研究 Three.js 在非平面空间下做路网生成或状态同步的开发者,源码值得翻一翻。

GitHub 地址:https://github.com/paulrobello/cube-world (opens in a new tab)