把简历做成"能走的迷宫"是什么体验?HR 点进来,WASD 一通乱窜,走到一个发光的光柱前------唰,一段职业经历弹出来。走完全程,你的履历就看完了。
这不是 PPT 动画,是一个真能跑的 Web 3D 项目:React 19 + React Three Fiber v9 + three.js + Vite 搭的 3D 迷宫,迷宫里每一根光柱对应一段真实经历,走到就解锁。
这篇文章前半部分带你俯瞰整个项目的架构,后半部分直接上第一个真·干货------迷宫生成算法。

一、为什么要选 React Three Fiber?
先说结论:对 React 开发者来说,R3F 是入坑 Web 3D 性价比最高的那条路。
原生 three.js 是命令式 API:你手写 new Scene()、scene.add(mesh)、renderer.render(),自己管渲染循环。它强大,但和 React 的"声明式 + 状态驱动 UI"格格不入------你要么把 three 对象塞进 useRef 手动同步,要么在 useEffect 里和 React 状态打游击。
R3F 把 three.js 包成了 React 组件:
tsx
// 原生 three:命令式
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// R3F:声明式,和写 <div> 一个手感
<mesh position={[0, 1, 0]}>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
好处不只是"写法顺手":
- 声明式 = 可组合。迷宫、玩家、光柱、弹窗都是组件,能像搭积木一样拼,props 变化自动重渲染。
- 能直接接 React 状态 。玩家走到了哪、该弹哪段经历,用熟悉的
useState/ 单例 store 管就行,不用另学一套状态系统。
- 生态现成 。@react-three/drei 把 OrbitControls、loader、性能面板都封装好了,省下大量样板代码。
一句话概括:React 当 UI 框架,R3F 当 3D 视图层,Three.JS 当底层渲染,Vite 当构建。
二、项目长什么样:架构鸟瞰
很多人第一次看 R3F 项目会懵:3D 的东西和普通的 React 组件怎么混在一起?先看顶层状态机(src/App.tsx):
tsx
const [status, setStatus] = useState<'menu' | 'playing' | 'won'>('menu');
整个应用就是 menu → playing → won 三个状态:
menu:欢迎页(WASD / 鼠标提示,纯 DOM + CSS 做的,不进 Canvas)
playing:进入<MazeGame>,真正的 3D 迷宫在这里
won:通关页,显示用时
关键的 3D 部分在 MazeGame 里,组件树长这样:
css
App
├─ MenuScreen 欢迎页(纯 DOM)
├─ MazeGame
│ ├─ <Canvas> ← R3F 的根,声明式场景入口
│ │ └─ SceneContent
│ │ ├─ ambientLight 环境光
│ │ ├─ MazeEnvironment 迷宫网格 + 场景模型
│ │ └─ Player 第一人称控制器
│ ├─ HUD 计时 / 小地图(DOM 浮层)
│ └─ Description 经历弹窗(DOM 浮层)
└─ WinScreen 通关页(纯 DOM)
一句话记住这个分层: <Canvas> 是 3D 世界的大门,门里是 SceneContent(场景),门外是 HUD / Description 这些 DOM 浮层 。Player 在 useFrame 里每帧更新自己的世界坐标,同时把"我在哪个格子"写进一个全局 store(后面第 4 篇专门讲),光柱和弹窗据此触发。
tsx
// src/components/Game.tsx 节选
function SceneContent({ maze, gameRef, onWin }) {
const { scene, gl, camera } = useThree();
useEffect(() => {
// 背景和清屏色都设成暖光色:避免 Canvas 首帧先闪一下黑屏
scene.background = new THREE.Color(WARM_LIGHT_BG);
gl.setClearColor(WARM_LIGHT_BG);
}, [scene, gl]);
return (
<>
<ambientLight intensity={AMBIENT_INTENSITY} color={AMBIENT_COLOR} />
<MazeEnvironment maze={maze} />
<Player maze={maze} gameRef={gameRef} onWin={onWin} />
</>
);
}
小贴士:
WARM_LIGHT_BG这个暖色背景色同时设给了scene.background和gl.setClearColor。别小看这一行------只设 background 不设 clearColor,首帧 Canvas 挂载前会先黑一下,观感很掉价。这是后面第 7 篇"氛围营造"里的一个小坑。
到这里,"是什么 + 凭什么"讲完了。接下来进入真正的硬核:迷宫是怎么生成的。
三、迷宫生成算法:递归回溯 + 2N+1 网格
这是整个项目最适合当"R3F 第一课"的部分------算法本身不难,但它把"数据 → 3D 网格"的链路讲透了。
3.1 迷宫的本质:一张 wall / path 二维网格
别被"3D"吓到。底层迷宫其实就是一张二维表,每个格子 (cell) 只有两种状态:
ts
// src/game/types.ts
export type CellType = 'wall' | 'path';
export interface Cell {
type: CellType;
visited: boolean; // 仅生成阶段 DFS 用
}
visited 是生成算法临时用的标记,迷宫生成完就没什么用了。cells[col][row] 就是这整张表。
3.2 2N+1 扩展网格:让"挖墙"变得极简
最经典的迷宫生成本质是**"在奇数坐标上走格子,偶数坐标当墙"**。具体做法:
- 把输入尺寸强制转成奇数(偶数就 +1);
- 所有格子先初始化成
wall;
- 真正的"路"只出现在奇数坐标:(1,1)、(1,3)、(3,1)、(3,3)...;
- 四周一圈永远是墙,起点
(1,1),终点(width-2, height-2)。
为什么这么设计?因为这样"路"和"路"之间天然隔着一格墙。挖通两段路时,只需把中间那一格墙也设为 path,物理上就连通了------不用处理"半格墙"这种尴尬情况。
ts
// src/game/mazeGenerator.ts · buildMaze 开头
const w = width % 2 === 0 ? width + 1 : width; // 强制奇数
const h = height % 2 === 0 ? height + 1 : height;
// 初始化:全是墙
const cells: Cell[][] = [];
for (let c = 0; c < w; c++) {
cells[c] = [];
for (let r = 0; r < h; r++) {
cells[c][r] = { type: 'wall', visited: false };
}
}
3.3 迭代 DFS 挖墙(递归回溯)
递归回溯(Recursive Backtracker)是生成"完美迷宫"(任意两点间只有唯一通路)最常用的方法。这里用的是迭代版(用栈代替函数递归),避免大迷宫爆栈。
核心四步:
- 从起点
(1,1)出发,压栈,标记为path+visited;
- 看四个方向(坐标表示顶点位置,步长 2,跳过中间那格)有没有"未访问的邻居";
- 随机挑一个邻居,把"当前格和邻居之间那一格"以及"邻居"都设为
path,邻居压栈;
- 没邻居可走就出栈(回溯),直到栈空。
ts
// src/game/mazeGenerator.ts · 挖墙核心
const dirs: Array<[number, number]> = [
[0, -2], // N
[2, 0], // E
[0, 2], // S
[-2, 0], // W
];
while (stack.length > 0) {
const [c, r] = stack[stack.length - 1];
// 收集未访问、有效的"跳两格"邻居(含中间格坐标)
const candidates: Array<[number, number, number, number]> = [];
for (const [dc, dr] of dirs) {
const nc = c + dc, nr = r + dr;
if (nc < 1 || nc >= w - 1 || nr < 1 || nr >= h - 1) continue; // 不越界
if (cells[nc][nr].visited) continue; // 不重复
candidates.push([nc, nr, c + dc / 2, r + dr / 2]); // [nc,nr,midC,midR]
}
if (candidates.length === 0) { stack.pop(); continue; } // 死路,回溯
// 随机选一个,挖通"中间墙 + 邻居"
const [nc, nr, midC, midR] = candidates[Math.floor(Math.random() * candidates.length)];
cells[midC][midR].type = 'path';
cells[nc][nr].type = 'path';
cells[nc][nr].visited = true;
stack.push([nc, nr]);
}
为什么用迭代而不是递归? 递归写法更直观,但 JS 没有尾递归优化,迷宫一大(几千格)递归深度直接爆栈。stack 模拟调用栈,深度只受内存限制,稳得多。
3.4 解路径长度约束:让难度可控
光生成"完美迷宫"还不够------纯随机的迷宫,解路径可能短得 5 步就到终点(太水),也可能绕得离谱(劝退)。这个项目约束了解路径长度在 25, 35 格:
ts
const SOLUTION_PATH_MIN = 25;
const SOLUTION_PATH_MAX = 35;
const MAX_GENERATE_ATTEMPTS = 500;
实现很巧妙:DFS 栈本身就是"起点 → 当前格"的路径 。当算法首次到达终点时,把栈还原成完整路径,立刻量长度------不达标就整局作废、重新生成:
ts
// 首次到达终点 (exitC, exitR) 时记录解路径
if (solutionPath.length === 0 && nc === exitC && nr === exitR) {
const rawPath: Array<[number, number]> = [];
for (let i = 0; i < stack.length; i++) {
const [pc, pr] = stack[i];
rawPath.push([pc, pr]);
if (i < stack.length - 1) {
const [nxc, nxr] = stack[i + 1];
rawPath.push([(pc + nxc) / 2, (pr + nxr) / 2]); // 补上中间 mid 格
}
}
solutionPath = buildPathCells(rawPath);
if (!acceptAny && (solutionPath.length < 25 || solutionPath.length > 35)) {
return null; // 不合格,直接放弃本次生成
}
}
外层用 generateMaze 包一层重试,最多 500 次;兜底第 501 次放弃长度约束强制接受,防止极端情况下死循环:
ts
export function generateMaze(width: number, height: number): MazeData {
for (let attempt = 0; attempt < 500; attempt++) {
const maze = buildMaze(width, height);
if (maze) return maze;
}
return buildMaze(width, height, true) as MazeData; // 兜底:放弃长度约束
}
这个"生成不合格就整局重来"的思路,比"生成后局部修补"简单且结果更干净。代价是平均多生成几次,但迷宫规模小(几十格),毫秒级,体感无差。
3.5 把 DFS 栈还原成"带方向"的 PathCell
解路径不只要"经过哪些格",还要知道每一步朝哪走 ------因为玩家初始面朝哪、光柱模型怎么摆,都依赖方向。所以 buildPathCells 给每个格算出 PathDirection:
ts
// src/game/types.ts
export type PathDirection = 't' | 'r' | 'b' | 'l'; // 上 / 右 / 下 / 左
export interface PathCell {
c: number; r: number;
dir: PathDirection; // 从"前一个格"到"本格"的方向
prePos?: { c: number; r: number };
nextPos?: { c: number; r: number };
}
ts
function getDirection(from: [number, number], to: [number, number]): PathDirection {
const dc = to[0] - from[0];
const dr = to[1] - from[1];
if (dc > 0) return 'r';
if (dc < 0) return 'l';
if (dr < 0) return 't';
return 'b';
}
首格没有"前一个",就反推"它到下一个格"的方向;这样整条解路径每个拐弯都带方向信息,后面做第一人称初始朝向、光柱朝向对齐时直接查表即可。
3.6 顺带:碰撞检测怎么用这张网格
迷宫数据出来了,下一步是"让人走"。这里先剧透一个关键函数 canMove------它把世界坐标转成格子坐标,用 AABB 占格检测判断是否撞墙:
ts
// src/game/mazeGenerator.ts · canMove
export function canMove(px: number, pz: number, radius: number, maze: MazeData): boolean {
const cellPx = px / CELL_SCALE; // 世界 → 格子空间
const cellPz = pz / CELL_SCALE;
const cellRadius = radius / CELL_SCALE;
const minCol = Math.floor(cellPx - cellRadius);
const maxCol = Math.floor(cellPx + cellRadius);
const minRow = Math.floor(cellPz - cellRadius);
const maxRow = Math.floor(cellPz + cellRadius);
for (let c = minCol; c <= maxCol; c++) {
for (let r = minRow; r <= maxRow; r++) {
if (c < 0 || c >= maze.width || r < 0 || r >= maze.height) return false; // 越界
if (maze.cells[c][r].type === 'wall') return false; // 撞墙
}
}
return true;
}
注意 CELL_SCALE:格子坐标乘以它才变成 three.js 世界单位。这个函数本身不管"怎么移动",只回答"站在这合法吗"------具体"怎么一步步挪还不穿墙"是下一篇(第一人称控制器)的重点。
小结
这篇文章覆盖了两块:
- 选型与架构 :R3F 把 three.js 包成声明式 React 组件,对 React 开发者最友好;项目是
menu → playing → won状态机,3D 世界在<Canvas>里,HUD / 弹窗是 DOM 浮层。
- 迷宫生成 :用「2N+1 奇数网格 + 迭代 DFS 递归回溯」生成完美迷宫,靠"首次到达终点即量解路径长度"约束难度在 25--35 格,并把 DFS 栈还原成带方向的
PathCell,顺带用格子 AABB 做碰撞检测。
源码对应:src/App.tsx、src/components/Game.tsx(架构)、src/game/mazeGenerator.ts、src/game/types.ts(算法)。
下篇预告
迷宫有了,但现在是"死的"------怎么让人走进去、用鼠标转视角、WASD 移动还不穿墙?下一篇讲第一人称控制器:Pointer Lock 锁鼠标、WASD 向量移动、越肩相机,以及"把整帧位移拆成小步 + 分轴滑墙"的防穿墙秘诀。
如果这篇对你入坑 R3F 有帮助,点个赞 / 收藏,下一篇准时更新。系列共 7 篇,追更不迷路。