前阵子我花了两天,用一台普通笔记本和一份单文件 HTML,在浏览器里重新做了一个类似《我的世界》的方块沙盒体验。没有用游戏引擎,没有写 C++ 或 Unity,所有东西都落在 HTML、CSS 和原生 JavaScript 里。画面里的方块世界可以自由探索,能爬山、跳树、挖土、放方块,地形还是程序化生成的。对前端开发者来说,这个项目最大的价值不是“做游戏”,而是把一个看起来只有游戏引擎才能搞定的 3D 体素世界,用我们每天写页面时都会用到的技术拆开揉碎,验证一遍 3D 空间、碰撞检测和射线拾取这些基础概念。如果你也想试试手搓一个“我的世界”,这篇博文应该能帮你少踩不少坑。
1. 项目整体设计与功能拆解
1.1 这个项目到底做了什么
整个项目就是一份可以双击打开的 HTML 文件,打开之后,你会在一个由方块组成的低多边形世界里醒来。地面是起伏的山丘,山丘上有草、泥土、石头,偶尔会出现一棵树。你可以按住鼠标拖拽旋转视角,使用键盘上的 WASD 前后左右移动,空格键跳跃,鼠标左键敲掉面前的方块,鼠标右键在当前看向的位置放一个新方块。
底层实现上,我没有用 <canvas> 去逐像素绘制,也没有引入任何 WebGL 框架。所有可见方块都是真正的 DOM 元素——每个立方体由 6 个 <div> 面片拼成,然后利用 CSS 3D 变换摆放到三维空间坐标上。世界数据则用 JavaScript 里的三维坐标映射表保存,运行时不断读取、更新这些数据,再对 DOM 做增删或位移。
这种方案听起来很“简陋”,但它真的能跑出第一人称 3D 效果。项目规模控制在几十米见方的区域里,方块数量大约几千个,现代浏览器可以稳定保持在 60 帧左右。对于学习目的来说,这个体量刚刚好:代码结构清晰,不会因为性能优化而淹没核心逻辑。
1.2 为什么用 CSS 3D 而不是 WebGL 或 Canvas
选渲染方案时我先排除掉了 WebGL。WebGL 确实才是《我的世界》这类体素游戏的正规道路,但它要把网格数据、顶点着色器、片元着色器、矩阵库全部串起来,对很多前端同学来说是一个巨大的知识跨度,很容易卡在 API 层,还没碰到游戏逻辑就劝退了。
Canvas 2D 也是一个选项,但 Canvas 2D 的本质是“画家算法”,需要我在每一帧里手动把 3D 坐标投影到 2D 平面,再按深度排序绘制多边形。这个工作量并不小,而且一旦方块多了,排序不对就会出现前后遮挡错误,调试体验比较折磨。
CSS 3D 的好处在于,浏览器已经把 3D 变换、透视和深度排序都做好了。我只需要告诉它“这个方块放在哪个坐标”,它就会自动处理远近关系。代码直观到几乎可以用 HTML 直接描述一个三维世界。代价是性能上限比较低,方块数量多了 DOM 会顶不住。但对个人项目、教学 Demo 和 3D 基础学习来说,CSS 3D 是性价比最高的选择。
1.3 功能清单与体验亮点
最终版本实现了这些功能:
- 程序化地形:不是手工摆方块,而是用多层噪声函数生成起伏地形,再根据高度填充草、泥土、石头等方块类型。
- 树木生成:在随机位置长出一棵由树干和树叶组成的方块树,增加世界辨识度。
- 第一人称视角:鼠标移动控制水平转向和俯仰视角,视点限制在上下 89 度以内避免翻转。
- 移动与重力:WASD 控制水平移动,空格跳跃,重力让玩家自然落地。
- 碰撞检测:玩家的脚不会陷进地面,也不能穿墙,能沿着墙壁滑动。
- 方块交互:左键挖掘,右键放方块,准星指向的目标方块会有高亮线框。
- 单文件零依赖:全部代码塞进一个 HTML 文件,方便复制和分享。
这些功能合起来,就是一个可以玩的小沙盒。它离真正的《我的世界》还差得很远,但所有经典交互的操作逻辑是一致的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:CSS 3D 视觉与体素数据模型
2.1 一个立方体怎么用 CSS 画出来
CSS 本身没有“立方体”这个元素,想在浏览器里造一个立方体,最直接的办法就是用 6 个平面拼块。你可以准备一个 <div class="block"> 作为容器,设置为 transform-style: preserve-3d,这样它的子元素在三维空间里不会塌成一个平面。然后往容器里塞 6 个 face,每个 face 都是一个带背景色的普通 div。
关键是每个面的变换。以块中心为原点,边长设为起始常量 BLOCK_SIZE = 20px,那么每个面需要先旋转到对应朝向,再沿着面法线方向向外平移半个边长:
css复制.block {
width: 20px;
height: 20px;
transform-style: preserve-3d;
}
.face {
position: absolute;
width: 20px;
height: 20px;
left: 0;
top: 0;
border: 1px solid rgba(0,0,0,0.15);
backface-visibility: hidden;
}
.face.front { transform: rotateY(0deg) translateZ(10px); }
.face.back { transform: rotateY(180deg) translateZ(10px); }
.face.right { transform: rotateY(90deg) translateZ(10px); }
.face.left { transform: rotateY(-90deg) translateZ(10px); }
.face.top { transform: rotateX(90deg) translateZ(10px); }
.face.bottom { transform: rotateX(-90deg) translateZ(10px); }
face 上还可以按方块类型设置不同背景色,比如草地顶部是绿色,泥土是棕色。为了让轮廓清晰,我保留了一个很窄的边框,这样挖开地面时能明显看到方块与方块之间的嵌合感。
如果不做 backface-visibility: hidden,由于 CSS 3D 默认会绘制背面,方块多了以后会莫名其妙看到很多互相穿插的面,视觉上就会糊成一片。加上之后,浏览器会跳过背离摄像机的面,也算是一种天然的剪裁优化。
2.2 世界地图的数据结构
一个体素世界如果没有数据层,那只是一堆静态模型。程序要判断“某个坐标有没有方块”“这个方块是什么类型”,必须设计一个足够快的存储结构。
我用了一个坐标到方块类型的映射表,替代传统游戏里的三维数组:
javascript复制const world = new Map();
const key = (x, y, z) => `${x},${y},${z}`;
function getBlock(x, y, z) {
return world.get(key(x, y, z)) || 0;
}
function setBlock(x, y, z, type) {
if (type === 0) world.delete(key(x, y, z));
else world.set(key(x, y, z), type);
}
Map 的好处是只保存“有方块”的坐标,空气方块根本不会占用空间。三维数组 world[x][y][z] 在概念上更直接,但在 JavaScript 里要提前开一个固定大小的 Array,数据量一大会很浪费。用 Map 之后,无论是地形初始化、挖掘方块还是放置方块,复杂度都很稳定,而且方便我们额外用一个同样的 Map 维护“坐标到 DOM 元素”的对应关系。
当然,Map 并不是万能解。如果以后想把世界做得足够大,更好的方向是像《我的世界》那样按区块存储,每个区块内部用紧凑的数组保存。但在一个演示项目里,Map 的清晰度远大于微小的性能差异。
2.3 相机逆变换让场景“动”起来
CSS 3D 世界里,真正的“相机”是不存在的。我们有的只是一个带 perspective 的观察容器,和一个承载全部方块的 #world 容器。
要让玩家感觉自己在世界里行走,最简单的方法是反过来移动整个世界。当玩家向左走,其实整个世界向右移动;当玩家向右转,其实整个世界向左转。用术语说,就是对场景做“相机逆变换”。
核心代码只有一行:
javascript复制world.style.transform = `
translate3d(${-cam.x * BLOCK_SIZE}px, ${cam.y * BLOCK_SIZE}px, ${-cam.z * BLOCK_SIZE}px)
rotateY(${-cam.yaw}deg)
rotateX(${-cam.pitch}deg)
`;
注意这里坐标轴的对应关系。我定义世界坐标为三维空间中的 (x, y, z),其中 y 是高度,但 CSS 的 translate3d 第二个参数是屏幕纵向位移,方向向下,所以高度方向上需要取反。最终 camera 向上移动时,世界看起来就要向下移动,因此 cam.y 前面有个负号。
变换顺序也非常重要。如果先把旋转写在前面还是后面,观感会完全不同。我自己调试时发现,应该让世界先平移到相机位置附近,再进行反向旋转。如果顺序反了,视野会在绕着一个奇怪的原点打转,而不是在原地转头。这个坑最好在项目一开始就记住。
3. 实操入口:搭建基础 HTML 与交互框架
3.1 初始化页面与三个关键容器
整个项目从下面的页面骨架开始。不用 npm,不用构建工具,直接在本地新建一个 .html 文件,把样式和脚本写在里面。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>手搓体素沙盒</title>
<style>
html, body {
margin: 0;
overflow: hidden;
background: #87ceeb;
}
#scene {
position: absolute;
inset: 0;
perspective: 700px;
overflow: hidden;
}
#world {
position: absolute;
transform-style: preserve-3d;
left: 0;
top: 0;
}
#crosshair {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: white;
font: bold 24px monospace;
pointer-events: none;
text-shadow: 0 0 4px black;
}
#highlight {
position: absolute;
width: 20px;
height: 20px;
border: 2px solid black;
background: transparent;
pointer-events: none;
display: none;
}
</style>
</head>
<body>
<div id="scene">
<div id="world"></div>
<div id="highlight"></div>
<div id="crosshair">+</div>
</div>
<script>
// 后续所有逻辑都写在这
</script>
</body>
</html>
#scene 承担两个职责:作为观察窗口,通过 perspective: 700px 提供透视效果;同时作为指针锁定的目标元素,鼠标点击后请求锁定光标。#world 是所有方块的父容器,每一帧更新它的 transform。#highlight 是加在准星指向的高亮方块上的线框,初始隐藏即可。
3.2 输入系统:WASD、鼠标拖拽与点击
想要获得《我的世界》那种“鼠标转向、准星操控”的手感,最好用 Pointer Lock API。它能把鼠标指针隐藏,并把鼠标移动事件转换成无限旋转的 movementX/movementY,而不是像普通拖拽那样受屏幕边缘限制。
点击场景后请求指针锁定,再监听鼠标移动:
javascript复制const scene = document.getElementById('scene');
const worldRoot = document.getElementById('world');
let yaw = 0;
let pitch = 0;
scene.addEventListener('click', () => {
scene.requestPointerLock();
});
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement !== scene) return;
yaw -= e.movementX * 0.12;
pitch -= e.movementY * 0.12;
pitch = Math.max(-89, Math.min(89, pitch));
});
yaw 是水平朝向,pitch 是垂直俯仰。灵敏度用 0.12 是我在 24 英寸 1080p 屏幕上试出来的,鼠标扫过显示器一周大概能转两圈。灵敏度和显示器尺寸、鼠标 DPI 有很大关系,建议读者按自己的手感调整。
键盘控制用一个状态对象记录按键,空格键容易误触浏览器滚动,需要 preventDefault:
javascript复制const keys = {};
document.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code === 'Space') e.preventDefault();
});
document.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
点击交互上,鼠标左键执行挖掘,鼠标右键执行放置。同时阻止右键菜单弹出:
javascript复制document.addEventListener('mousedown', (e) => {
if (document.pointerLockElement !== scene) return;
e.preventDefault();
if (e.button === 0) breakBlock();
if (e.button === 2) placeBlock();
});
document.addEventListener('contextmenu', (e) => e.preventDefault());
3.3 游戏主循环 requestAnimationFrame
浏览器里做游戏,最标准的循环就是 requestAnimationFrame。它在下一帧绘制前执行回调,帧率和屏幕刷新率同步。
主循环里做三件事:更新输入状态、更新玩家物理、更新相机变换。
javascript复制let lastTime = performance.now();
function gameLoop(time) {
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
updatePlayer(dt);
updateCamera();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
为什么把 dt 限制在 0.05 秒?因为如果浏览器切到后台再回来,两帧之间的时间差可能非常长,如果拿着这个巨大时间差去更新物理,玩家会瞬间飞出地图。限制最大 delta 后,即使掉帧也不会出现不可接受的跳变。
4. 核心模块实现:从地图生成到碰撞检测
4.1 程序化地形生成
《我的世界》最让我着迷的一点是世界地形不是人工摆放的,而是由算法生成的。我在这里实现了一个简化版噪声地形。
如果没有成熟噪声库,可以用一个特别土但有效的办法:把多个不同频率、不同振幅的正弦波叠加在一起,再混合随机数,也能得到比较自然的地形起伏。真正的柏林噪声效果更好,但代码也更长。为了让读者一眼看懂原理,我用“二维随机平滑噪声”的版本。
JavaScript 里写一个可复现的伪随机函数,然后通过平滑插值生成连续噪声:
javascript复制function hash(x, z) {
let n = x * 374761393 + z * 668265263;
n = (n ^ (n >> 13)) * 1274126177;
return ((n ^ (n >> 16)) >>> 0) / 4294967295;
}
function smoothNoise(x, z) {
const ix = Math.floor(x), iz = Math.floor(z);
const fx = x - ix, fz = z - iz;
const smooth = (t) => t * t * (3 - 2 * t);
const u = smooth(fx), v = smooth(fz);
const a = hash(ix, iz), b = hash(ix + 1, iz);
const c = hash(ix, iz + 1), d = hash(ix + 1, iz + 1);
const top = a + (b - a) * u;
const bottom = c + (d - c) * u;
return top + (bottom - top) * v;
}
为了表现出大尺度山脉和小尺度起伏,需要用多频率叠加的方式采样,也就是俗称的 FBM:
javascript复制function fbm(x, z) {
let value = 0;
let amp = 0.5;
let freq = 1 / 32;
let total = 0;
for (let i = 0; i < 4; i++) {
value += smoothNoise(x / 16 * freq, z / 16 * freq) * amp;
total += amp;
amp *= 0.5;
freq *= 2;
}
return value / total;
}
生成地形时,遍历世界 XZ 平面,对每个坐标采样高度,然后从最底层一层层向上填方块。接近表面的方块放草方块,往下依次是泥土和石头:
javascript复制function generateWorld() {
for (let x = -RANGE; x <= RANGE; x++) {
for (let z = -RANGE; z <= RANGE; z++) {
const h = Math.floor(fbm(x, z) * 18 + 2);
for (let y = 0; y <= h; y++) {
let type = 2; // 石头
if (y === h) type = 1; // 草
else if (y >= h - 3) type = 3; // 泥土
setBlock(x, y, z, type);
}
}
}
generateTrees();
}
树是在地形生成之后随机摆放的。做法是找到地表方块,然后在上面用竖条方块堆树干,再用交叉方块堆树叶。为了控制 DOM 数量,我只在离世界中心一定距离内生成树。
4.2 玩家移动与碰撞检测
移动本身不难,难的是不穿墙。我把玩家默认看成一个 0.6 宽、1.8 高的碰撞盒,脚下位置记录为 (px, py, pz),眼睛位置是 (px, py + 1.6, pz)。
每一帧根据按键和朝向计算水平方向,然后分解到 X 和 Z 轴分别处理。为了让玩家能贴墙滑动,我采用“分轴移动、逐轴检测”的策略:先尝试 X 轴,如果碰撞就还原,再尝试 Z 轴,最后处理 Y 轴的跳跃和重力。
核心的碰撞判断函数是检查一个玩家包围盒所占的每个格子是否有实心方块:
javascript复制function isColliding(px, py, pz) {
const hw = 0.3;
for (let bx = Math.floor(px - hw); bx <= Math.floor(px + hw); bx++) {
for (let by = Math.floor(py); by <= Math.floor(py + 1.8); by++) {
for (let bz = Math.floor(pz - hw); bz <= Math.floor(pz + hw); bz++) {
if (getBlock(bx, by, bz)) return true;
}
}
}
return false;
}
移动函数大致如下:
javascript复制function updatePlayer(dt) {
const speed = 8;
const forward = {
x: -Math.sin(yaw * Math.PI / 180),
z: -Math.cos(yaw * Math.PI / 180)
};
const moveX = 0, moveZ = 0;
if (keys['KeyW']) { moveX += forward.x; moveZ += forward.z; }
if (keys['KeyS']) { moveX -= forward.x; moveZ -= forward.z; }
if (keys['KeyA']) { moveX += -forward.z; moveZ += forward.x; }
if (keys['KeyD']) { moveX += forward.z; moveZ -= forward.x; }
const len = Math.hypot(moveX, moveZ);
const nx = len ? moveX / len : 0;
const nz = len ? moveZ / len : 0;
let newX = cam.x + nx * speed * dt;
if (!isColliding(newX, cam.y, cam.z)) cam.x = newX;
let newZ = cam.z + nz * speed * dt;
if (!isColliding(cam.x, cam.y, newZ)) cam.z = newZ;
// 重力与跳跃
velocity.y -= 25 * dt;
if (keys['Space'] && onGround) {
velocity.y = 8;
onGround = false;
}
let newY = cam.y + velocity.y * dt;
if (isColliding(cam.x, newY, cam.z)) {
if (velocity.y < 0) onGround = true;
velocity.y = 0;
cam.y = Math.ceil(cam.y) - 0.01; // 贴住地面
} else {
cam.y = newY;
onGround = false;
}
}
这里我故意把视角高度当作玩家“脚底位置”来用,其实严格来说应该用脚底坐标,但为了方便直接让 cam.y 代表脚底高度,最后把相机放到 cam.y + 1.6。需要注意 Math.ceil(cam.y) - 0.01 这个小技巧,它能把玩家在落地时“吸”到整数格平面的上方,避免反复穿透。
4.3 方块选择与射线拾取
要挖掘或放置方块,得先知道准星对住了哪个方块。我实现了一个体素射线遍历算法,本质是让一条射线从相机出发,沿着视线方向一格一格前进,直到撞到非空气方块。
首先根据朝向算出方向向量。注意 pitch 是仰角,所以方向向量的 Y 分量等于 -Math.sin(pitch):
javascript复制function getCameraDir() {
const yawRad = yaw * Math.PI / 180;
const pitchRad = pitch * Math.PI / 180;
return {
x: -Math.sin(yawRad) * Math.cos(pitchRad),
y: -Math.sin(pitchRad),
z: -Math.cos(yawRad) * Math.cos(pitchRad)
};
}
然后做 DDA 步进。每步找到射线下一个穿过的体素边界,比较哪个轴先到:
javascript复制function raycast(maxDist) {
let x = Math.floor(cam.x);
let y = Math.floor(cam.y + 1.6);
let z = Math.floor(cam.z);
const dir = getCameraDir();
const stepX = dir.x > 0 ? 1 : -1;
const stepY = dir.y > 0 ? 1 : -1;
const stepZ = dir.z > 0 ? 1 : -1;
const tDeltaX = Math.abs(1 / (dir.x || 1e-10));
const tDeltaY = Math.abs(1 / (dir.y || 1e-10));
const tDeltaZ = Math.abs(1 / (dir.z || 1e-10));
let tMaxX = ((dir.x > 0 ? x + 1 - cam.x : cam.x - x) * tDeltaX);
let tMaxY = ((dir.y > 0 ? y + 1 - (cam.y + 1.6) : (cam.y + 1.6) - y) * tDeltaY);
let tMaxZ = ((dir.z > 0 ? z + 1 - cam.z : cam.z - z) * tDeltaZ);
let hitNormal = { x: 0, y: 0, z: 0 };
let lastX = x, lastY = y, lastZ = z;
for (let i = 0; i < maxDist * 8; i++) {
const type = getBlock(x, y, z);
if (type) {
return {
x, y, z,
type,
normal: hitNormal,
previous: { x: lastX, y: lastY, z: lastZ }
};
}
lastX = x; lastY = y; lastZ = z;
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
x += stepX;
tMaxX += tDeltaX;
hitNormal = { x: -stepX, y: 0, z: 0 };
} else if (tMaxY < tMaxZ) {
y += stepY;
tMaxY += tDeltaY;
hitNormal = { x: 0, y: -stepY, z: 0 };
} else {
z += stepZ;
tMaxZ += tDeltaZ;
hitNormal = { x: 0, y: 0, z: -stepZ };
}
}
return null;
}
DDA 算法有一个容易出错的地方:当方向向量的某一分量接近 0 时,1 / dir 会变成极大的数,导致计算不稳定。我在 tDeltaX 里用了 1e-10 做保护值,这样即使方向几乎垂直朝下,也不会出现除以零。
4.4 放置/破坏方块与区块重建
拿到射线结果后,破坏方块就非常简单。射线命中方块时,得到它所在的整数坐标,直接从世界数据里删掉,同时移除对应的 DOM 元素。
javascript复制function breakBlock() {
const hit = raycast(6);
if (!hit) return;
removeBlockFromWorld(hit.x, hit.y, hit.z);
}
放置方块稍微多一步。射线命中一个方块后,如果我想往“当前看向的空白处”放一个方块,那么新方块的位置就是命中方块坐标加上命中面法线。比如射线击中方块的顶面,新方块就放在上方的空格子里。
放置前还要做两件事:确认新位置是空气,并且不会和玩家的碰撞盒重叠。否则玩家会把自己卡进方块里出不来。
javascript复制function placeBlock() {
const hit = raycast(6);
if (!hit) return;
const bx = hit.x + hit.normal.x;
const by = hit.y + hit.normal.y;
const bz = hit.z + hit.normal.z;
if (getBlock(bx, by, bz)) return;
const playerMinX = cam.x - 0.3;
const playerMaxX = cam.x + 0.3;
const playerMinY = cam.y;
const playerMaxY = cam.y + 1.8;
const playerMinZ = cam.z - 0.3;
const playerMaxZ = cam.z + 0.3;
const boxOverlap =
bx + 1 > playerMinX && bx < playerMaxX &&
by + 1 > playerMinY && by < playerMaxY &&
bz + 1 > playerMinZ && bz < playerMaxZ;
if (boxOverlap) return;
setBlock(bx, by, bz, 4);
createBlockDOM(bx, by, bz, 4);
}
破坏和放置只影响局部一两个方块,所以不需要像 Canvas 那样整帧重绘。用 DOM 方案的好处在这里体现得非常明显:改数据、删节点、插入节点,几毫秒完事。
同时,为了让准星位置有反馈,我在主循环里每帧做一次射线检测,如果命中方块,就把高亮线框移动到对应位置并显示;否则隐藏。这样玩家能清楚地知道自己正在看哪个方块。
5. 性能优化与常见问题排查
5.1 DOM 数量失控怎么办
CSS 3D 方案最大的瓶颈就是 DOM 数量。如果生成一个 128×128 的地形,再算上树木,可能有上万个 <div> 节点,页面会立刻卡到无法操作。
我的优化思路是“只渲染玩家附近可见范围的方块”。地图数据可以生成得很大,但是 DOM 方块只创建在玩家周围一小圈。范围设为 20 格左右,理论上最多可能创建 40×40 个地面的方块,也就是 1600 个方块乘以 6 个面,约 9600 个面,现代浏览器还能接受。实际因为地形起伏和背面裁剪,真正渲染的面数更少。
实现上,我每帧检查所有已创建方块的坐标,如果离玩家太远就移除 DOM,并把元素映射也删掉;如果进入范围的方块还没有 DOM,就创建出来。为了不每帧全量扫描,可以只检测一圈新增和减少的范围,但演示项目里全量扫描 1600 个 Map 键并不慢,主要成本在 DOM 创建本身。
5.2 视觉闪烁与景深问题
CSS 3D 偶尔会出现方块面片之间互相闪烁的情况,尤其是两个面贴得很近时,浏览器不知道该把哪一个画在前面。常见原因是方块之间的边框或面片发生了重合。
解决方法是不要让面片完全重叠。给每个 face 的 translateZ 加了一个极小偏移,比如 translateZ(10.1px),或者给边框设置 background-clip: padding-box 来消除边缘抖动。另外,场景透视值不能太小,我用 perspective: 700px,太小的 perspective 会让近处物体产生非常夸张的畸变,远处物体被裁剪掉;太大则显得像正交投影,缺乏 3D 感。700 到 800 是一个比较舒服的范围。
5.3 移动时画面漂移、头晕问题
第一人称游戏最怕画面乱晃。我踩过的坑主要有三个。
第一个是相机变换顺序写错。如果 world.style.transform 里的旋转和平移顺序写反,转头时整个世界会绕着某个固定点转,玩家会觉得自己不是在“原地转头”,而是在绕圈,非常晕。解决办法是先平移后旋转,把这个顺序固定好就别乱动。
第二个是鼠标灵敏度太高。直接在 mousemove 里乘的系数如果大于 0.3,鼠标稍微一动视角就飞了。建议初始值设为 0.1 到 0.15,再按自己感受调。
第三个是帧率不稳。如果 requestAnimationFrame 回调里没有用时间差,而是每帧固定移动一小段距离,帧率低的时候速度就会变慢,然后又因为卡顿突然跳一大段,视觉上就很晕。统一使用 dt 乘以速度就能基本解决。
5.4 拾取方块的常见判定错误
射线拾取是最容易出现“就差一点”的模块。常见表现是:准星明明对着方块,却挖不到;或者放置的方块总是离目标偏一格。
问题多半出在方向向量没有和 yaw/pitch 保持一致。比如我一开始写反了 sin 和 cos,导致准星对着正东,射线却往北走。调试方法很简单:在准星位置放一个测试方块,从不同角度观察射线命中的 previous 坐标和 normal 方向,多转几圈就能看出来规律。
另一个常见问题是射线从玩家眼睛位置出发时,如果眼睛刚好卡在方块边界,DDA 第一次就会判断为命中了玩家所在的方块。解决方法是射线起点从 (cam.x, cam.y + 1.6, cam.z) 往前进一点点,把起点从整数边界上“推开”:
javascript复制const ox = cam.x;
const oy = cam.y + 1.6;
const oz = cam.z;
const dir = getCameraDir();
let x = Math.floor(ox + dir.x * 0.01);
let y = Math.floor(oy + dir.y * 0.01);
let z = Math.floor(oz + dir.z * 0.01);
这个 0.01 的偏移让我少调了很久 bug。
5.5 怎么让世界看起来更“我的世界”
做完基础功能后,我加了一些小细节让世界更有气氛。天空用 CSS 线性渐变模拟,从头顶的浅蓝色渐变为地平线附近的灰白色。背景不再是一块纯色,更有立体感。方块纹理虽然只是单色,但通过给每个面加一个半透明边框,模拟了方块之间接缝,一下子就有了“积木搭成世界”的味道。
还加了一个简单的环境光模拟:把每个方块顶部面的颜色调亮一些,侧面颜色调暗一些,底面更暗。这样即使没有复杂光影,方块也能看出明暗朝向,立体感增强。实现起来很简单,给不同 face 设置不同的背景色或透明度就行。
6. 做完这个项目后的心得
真正写完这些代码之后,我对“用浏览器做游戏”这件事有了新的体会。CSS 3D 绝不是 WebGL 的替代品,但对于理解三维坐标变换、体素网格和玩家碰撞,它反而比 WebGL 更友好,因为每一步效果都能直观看到。我自己调试时,好几次因为一个负号不对而在奇怪的位置“卡在墙里”,但这也逼着我把坐标映射彻底想清楚。
这个项目后续还能扩展很多方向。比如把世界数据存到 localStorage,刷新页面后还能恢复之前的建筑;增加区块化存储,让世界可以无限延伸;加入简单的昼夜循环,通过改变背景色和方块面亮度来模拟不同时间段。如果把这一版代码推倒重写成 WebGL,同样的数据模型和射线算法可以直接复用,只是渲染那一层换掉而已。
如果你照着写了一遍还是哪里不对,大概率都出在相机变换顺序、方向向量正负号这些细节上。建议一次只改一个变量,然后在准星下放一个固定方块,前后走几步、左右转几圈,用“身体感受”来验证视野是否合理。这个调试过程本身就很有意思。
