最近我折腾了一个挺有意思的小东西——网页版MC.html。这个名字说白了就是“用HTML写的《我的世界》网页版”,一个单文件就能跑起来的体素小游戏:随机生成一片方块地形,用鼠标控制视角、WASD移动,左键敲方块,右键放方块,还能存档读档。整个项目没有后端、没有框架、没有Node环境,就一个HTML文件,双击打开浏览器就能玩。
写这个项目倒不是因为要挑战什么高难度,主要是想验证一个想法:在不动用WebGL、不依赖Three.js这类库的前提下,纯靠Canvas 2D加上一点数学,到底能不能搭出一个能玩的第一人称方块世界。折腾完之后的结论是:能,而且比想象中简单,也比想象中容易踩坑。这篇文章我把整个项目的技术选型、核心架构、关键实现、性能调优过程都拆开聊一遍,前半部分是原理和思路,后面是代码和实测数据,最后是一份我很想早点拿到的避坑清单。
这期内容适合这些人:对Minecraft类游戏原理感兴趣的前端开发者,想用原生JS写小游戏练手的同学,以及那种“不想装环境、就想写个能双击运行的小项目”的懒人党。如果你只是想找个现成的完整代码去复制,这篇文章也给到了足够多的核心片段,拼起来就是一个能跑的demo。
1. 项目定位与技术选型
1.1 为什么想用HTML做MC
体素游戏听起来很高大上,但核心概念几句话就能讲清楚:把游戏世界切成一个个小格子,每个格子里存一个方块类型,游戏引擎负责把这些方块渲染出来,再让玩家以“格子”为单位进行交互。我的世界玩起来让人觉得自由,本质上是因为它给每个方块都赋予了“可破坏、可放置、可合成”的规则,而底层数据结构并不复杂。
网页技术做体素游戏的难点主要在三块:第一,三维空间怎么在二维屏幕上表达;第二,方块数量大了以后怎么保证帧率;第三,第一人称视角的操作映射怎么做得顺手。这三块没有一块是必须靠神器才能解决的,Canvas 2D配合透视投影就能做,所以我决定用最朴素的方式写一版。
这么做还有一个现实原因:我经常想给别人展示一个好玩的小项目,但让对方去装Python、跑npm实在不现实。单HTML文件双击即开,是传播成本最低的形态。写完这个文件之后,我把它发给同事,对方用手机浏览器打开也能玩,这种“零依赖”带来的爽快感,只有经历过装环境痛苦的人才能懂。
1.2 Canvas 2D实现的可行性分析
做之前我其实纠结过选型,列了一张表对比过几条路线:
| 方案 | 性能 | 开发成本 | 单文件可行性 | 适合场景 |
|---|---|---|---|---|
| Canvas 2D + 手写3D投影 | 中等 | 较低 | 完美 | 中小世界、学习体素原理 |
| WebGL原生编写 | 很高 | 很高 | 困难 | 大世界、商业级渲染 |
| Three.js等库 | 较高 | 中等 | 一般 | 追求效果、可接受依赖 |
| CSS 3D Transform | 中等 | 中高 | 勉强 | 展示型项目、非游戏 |
最终我选了第一项,理由一句话就能概括:用最基础的API把游戏原理跑通,不让渲染库把核心逻辑给“包办”了。写渲染库的人已经把矩阵、贴图、深度缓冲全部封装好,开发者只需要调API,确实很爽,但体素游戏真正的难点在数据组织和空间算法上,这些恰恰不应该被库藏起来。
Canvas 2D的画画家算法、透视投影,虽然性能天花板不高,但对一个32x32x24的小世界来说,帧率完全可以接受。我的定位是做一个“能玩能看能展示原理”的项目,不是去跟商业游戏比画质。如果你以后真想做大世界,直接把渲染层换成WebGL就行,数据结构和算法逻辑可以原封不动搬过去。
2. 体素世界的架构拆解
2.1 世界数据:三维数组与方块ID
体素世界的数据结构我选择了最短平快的方式——三维数组。坐标系这样定:x和z是水平方向,y是垂直向上。世界大小是32x24x32,也就是32格长、32格宽、24格高,正好凑出一个适合小范围探索的“孤岛”。
每个格子用整数表示方块类型:0是空气,1是草方块,2是泥土,3是石头。世界初始化的时候就往数组里填值,运行中破坏方块就是把这个格子的值改成0,放置方块就是改成对应方块ID。这种直接到格子的存储方式,配合Minecraft同款设计,空间上就是教科书式的二维数组扩展成三维,没有任何花哨操作。
const WORLD_W = 32; const WORLD_H = 24; const WORLD_D = 32; // world[x][y][z] = blockId const world = []; for (let x = 0; x < WORLD_W; x++) { world[x] = []; for (let y = 0; y < WORLD_H; y++) { world[x][y] = []; for (let z = 0; z < WORLD_D; z++) { world[x][y][z] = 0; } } }这段代码看着简单,但它会带来一个容易忽视的性能问题:每初始化一个格子,就要创建三个嵌套的数组引用。32x24x32一共不到25000个格子,数据量很小,但如果以后把世界扩大到512x256x512,嵌套数组的开销和垃圾回收压力就会明显变大。到那时可以考虑用“一维数组+索引计算”的紧凑存储,形如world[x + y * WORLD_W + z * WORLD_W * WORLD_H]。做项目时先求简单,等性能瓶颈真的来了再优化,才是务实的做法。
2.2 渲染核心:相机、投影与画家算法
3D场景显示在2D屏幕上的核心是“投影”。我这里的相机模型非常经典:相机有三个位置属性(camera.x, camera.y, camera.z),代表玩家眼睛在三维空间中的位置;还有两个旋转属性(yaw偏航角和pitch俯仰角),代表玩家往哪儿看。yaw控制左右转头,pitch控制上下低头,这两个值由鼠标移动来控制。
投影计算分三步走:把世界坐标平移到相机坐标系、绕Y轴旋转适配yaw、绕X轴旋转适配pitch,最后用透视公式把三维坐标映射成屏幕上的二维坐标与缩放比例。透视效果的关键就在于“近大远小”——用z2(深度)做分母,离相机越近的方块,投影后越大。
function worldToScreen(x, y, z) { const dx = x - camera.x; const dy = y - camera.y; const dz = z - camera.z; // 绕Y轴旋转(左右转头) const cosY = Math.cos(camera.yaw); const sinY = Math.sin(camera.yaw); const x1 = dx * cosY - dz * sinY; const z1 = dx * sinY + dz * cosY; // 绕X轴旋转(上下低头) const cosX = Math.cos(camera.pitch); const sinX = Math.sin(camera.pitch); const y1 = dy * cosX - z1 * sinX; const z2 = dy * sinX + z1 * cosX; if (z2 < 0.1) return null; // 在相机后方,不渲染 const fov = 1.2; const projectedX = canvas.width / 2 + (x1 * fov * canvas.width) / z2; const projectedY = canvas.height / 2 - (y1 * fov * canvas.height) / z2; const scale = (fov * canvas.width) / z2; return { x: projectedX, y: projectedY, scale }; }理解这个函数,整个3D实验的档位就打通了。每次你想渲染一个方块,就把它的八个顶点坐标传入,得到八个屏幕坐标,然后用Canvas绘制多边形。但是注意:直接画所有方块会乱套,因为后画的会把先画的盖住。这时候就需要画家算法:把世界里的所有方块按“到相机的距离从远到近排序”,远的先画,进的自然而然覆盖在远处上面。这个规则和真实画家画油画时先涂背景再画前景完全一样,故得此名。
2.3 可见面剔除:渲染性能的第一道闸门
如果所有方块都把六个面画出来,一个方块要画六个四边形,一个30x30的小世界即使只算表面方块也轻松上万面,Canvas 2D绘制这么多路径会直接卡成PPT。所以必须做可见面剔除,只画玩家当前能看到的面。
判断一个面是否可见,方法非常简单:看这个面的外侧邻居方块是不是空气。如果是空气,说明这个面暴露在外面,需要绘制;如果邻居是实体方块,这个面被完全挡住,不画也不会有视觉差异。
function isFaceVisible(x, y, z, face) { const nx = x + face[0]; const ny = y + face[1]; const nz = z + face[2]; if (nx < 0 || nx >= WORLD_W || ny < 0 || ny >= WORLD_H || nz < 0 || nz >= WORLD_D) { return true; // 世界边界视为可见 } return world[nx][ny][nz] === 0; // 邻居是空气则可见 } const FACES = [ [0, 1, 0], // 顶面 [0, -1, 0], // 底面 [1, 0, 0], // 右面 [-1, 0, 0], // 左面 [0, 0, 1], // 前面 [0, 0, -1] // 后面 ];这套剔除逻辑配合方块光照模拟效果很好。我在绘制时给不同面加不同的亮度系数:顶面最亮乘1.0,侧面乘0.8,底面乘0.5。这样即使是纯色方块,也能看出上下左右的立体感,视觉效果立刻上一个档次,代码量多不到十行,强烈建议加上。
3. 核心玩法功能的代码实现
3.1 第一人称控制与碰撞检测
操作手感是游戏demo的命门。第一人称控制分两块:视角控制和移动控制。视角控制很简单,监听鼠标移动事件,把mouseX的增量加到yaw上,把mouseY的增量加到pitch上,同时限制pitch在-1.5到1.5之间,防止玩家把头“转穿”过去。移动用WASD键,按下时根据yaw方向计算前进向量,移动速度用deltaTime修正,这样不同帧率下速度保持一致。
碰撞检测是这块最核心也最容易漏掉的东西。玩家不能穿墙,最简单的做法是把玩家当成一个轴对齐的盒子,移动前先计算目标位置,检测盒子所占的格子有没有方块,如果有就阻止对应轴向上的移动。
function collides(px, py, pz) { const minX = Math.floor(px - 0.3); const maxX = Math.floor(px + 0.3); const minY = Math.floor(py - 1.6); const maxY = Math.floor(py); const minZ = Math.floor(pz - 0.3); const maxZ = Math.floor(pz + 0.3); for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { for (let z = minZ; z <= maxZ; z++) { if (getBlock(x, y, z) !== 0) return true; } } } return false; }这里我踩过一个坑:玩家碰撞盒子的宽度设为0.6,也就是横向半宽0.3,高度设为1.6。如果高度设得太高,走楼梯和上坡会特别难受;如果太矮,又能从一格高的缝隙里挤过去。0.6x1.6不算精确复刻MC的0.6x1.8,但实测走起来更舒服。跌落重力我用简单的速度累加,每帧给vy减一个重力系数,碰到地面就归零。
3.2 射线拾取:砸方块和放方块
方块破坏和放置的逻辑,本质上都是“玩家视线先撞到哪个格子”。这个操作在三维图形学中叫射线与格子的求交。我不推荐用暴力遍历所有方块的方式去检测,虽然小世界也能跑,但代码逻辑不优雅,大世界就废了。
更标准的做法是DDA算法,即数字微分分析。核心思路是从相机位置沿视线方向一步一步往前走,每一步检测当前所在的格子是不是实体方块,如果是就停下来返回该方块坐标;同时记录上一个空格的位置,这样放置方块时可以直接放到空格上。
function raycast() { let x = camera.x; let y = camera.y; let z = camera.z; const stepX = Math.sign(Math.sin(camera.yaw)); const stepY = Math.sign(Math.sin(-camera.pitch)); const stepZ = Math.sign(Math.cos(camera.yaw)); for (let i = 0; i < 8; i++) { const bx = Math.floor(x); const by = Math.floor(y); const bz = Math.floor(z); if (getBlock(bx, by, bz) !== 0) { return { bx, by, bz, px: Math.floor(lastX), py: Math.floor(lastY), pz: Math.floor(lastZ) }; } lastX = x; lastY = y; lastZ = z; // 沿视线方向前进0.1格,然后继续检测 x += Math.sin(camera.yaw) * 0.1; y += Math.sin(-camera.pitch) * 0.1; z += Math.cos(camera.yaw) * 0.1; } return null; }这段代码用固定步长0.1格前进,最多检测8格距离。8格虽然不算远,但对一个做“周边交互”的demo来说足够了。真实游戏里可以用更精确的DDA,但这里固定步长足够直观、够用。左键调用raycast,把命中的方块ID改成0;右键在空格坐标放进当前选中的方块,就完成了两种最核心的交互。
3.3 地形生成与存档
地形生成我用了最简单的“正弦叠加法”。原理是,用多个不同频率的正弦函数相加,能得到看似不规则、实际却平滑起伏的曲线。对每个x和z坐标,算出一个高度值,然后从下往上填充方块:最底下三层放石头,中间放泥土,最顶层放草方块。这样生成的山丘远看有点自然味道,近看又能看出规律性的波浪,不至于杂乱。
function getHeight(x, z) { return Math.floor( 5 + Math.sin(x * 0.2) * 2 + Math.sin(z * 0.25) * 3 + Math.cos((x + z) * 0.15) * 2 ); }存档模块我给了两个方案。第一个是用localStorage,直接把三维数组压成字符串存进去,好处是不需要手动操作;缺点是localStorage容量只有5MB左右,如果是大世界很容易撑爆,而且换个浏览器或清了缓存就没了。第二个方案是做两个按钮:一键导出存档为JSON文件,一键导入。这样玩家可以把存档文件保存到本地,分享给别人时甚至能把世界一起发过去。我最终两个都做了:默认进入自动读localStorage,同时在设置界面提供导出导入,用户体验和功能完整性兼顾。
4. 实操过程中的坑和调优记录
4.1 从0到可玩:我的开发顺序
很多人拿到一个完整的项目不知道从哪儿下手,我自己写的时候总结了有一条比较顺的路线。第一步先搭好页面框架和Canvas,不做任何3D,先画一个简单的直角坐标系验证坐标方向有没有反。第二步实现相机旋转与投影,跑起来以后用鼠标左右转,屏幕上能看到一个模拟方块在转动,这一步会特别有成就感,也是整个项目的“心脏”。第三步把三维数组加进来,渲染地形,这时候注意跑一遍画家算法和可见面剔除,FPS会肉眼可见地变化。第四步做玩家移动和碰撞,此时世界已经能“走”进去。最后才加射线交互、地形生成、存档UI这些外围功能。
这个顺序的妙处在于:每一步都有可验证的结果,不会攒了三百行代码然后一次debug找不出问题。我自己第一次就把投影和坐标轴搞反了,屏幕上的方块转得跟喝醉一样,后来老老实实先画坐标轴才定位到问题。
4.2 性能实测:什么规模会卡
不同世界规模下的帧率表现,我直接搭了个简单场景测了几组数据。机器是普通的Windows笔记本,Chrome浏览器开启硬件加速,分辨率1920x1080。注意这只是单机参考,不同的浏览器、屏幕尺寸会略有差异,但相对趋势很稳定。
| 世界尺寸 | 是否开启可见面剔除 | 平均帧率 | 体验感受 |
|---|---|---|---|
| 16x16x16 | 否 | 55-60 | 勉强流畅,偶见卡顿 |
| 16x16x16 | 是 | 60+ | 丝滑 |
| 32x24x32 | 是 | 45-60 | 流畅,地形复杂时略降 |
| 64x32x64 | 是 | 20-30 | 能玩,但不畅快 |
| 64x32x64 | 否 | 8-12 | 几乎不可玩 |
数据很直观:可见面剔除是性价比最高的优化手段,没有任何理由不做。第二个高性价比优化是限制渲染距离。把世界到相机中心超过一定距离的方块跳过不画,能非常有效地保护帧率。我做了一个可视距离滑块,从4格到16格可调,实测在64x64的世界中,把可视距离拉到16格还能保持45帧以上,调到8格就稳在60帧。Canvas 2D的顶点填充始终顶不过WebGL的GPU渲染,但配合这些削减渲染量的方法,做中小型场景完全够用。
4.3 移动端适配
做完PC版以后我顺手做了移动端适配,这一块比想象中更重要。因为浏览器直接打开一个HTML文件的场景里,手机用户占了很大一部分。移动端没有键盘和鼠标,所以需要两部分改动:虚拟摇杆控制移动、滑动屏幕控制视角。
虚拟摇杆我实现得很朴素:屏幕左下角一个圆形区域,触摸按下后记录圆心,手指滑动时计算偏移量,映射成前后左右移动速度。右下角放两个按钮,一个破坏方块,一个放置方块。视角控制则是单指滑动屏幕时改变yaw和pitch。这套方案做不出原版MC手游的精致手感,但作为网页demo已经足够顺滑。
还有一个容易踩的坑是移动端的视口配置。如果少了<meta name="viewport" content="width=device-width, initial-scale=1.0">这句话,手机上打开页面会先按980px宽度渲染再缩放,导致画布模糊、触摸坐标错位。加上之后,还要在CSS里设置touch-action: none,不然手指滑动时浏览器会默认滚动或缩放页面,游戏视角就会跳。
5. 常见问题速查表
我把开发过程中遇到过的问题和排查思路整成了一张表格,后面照做就行。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 画面卡成PPT | 世界太大或没有做可见面剔除 | 开启剔除,限制可视距离,缩小世界尺寸 |
| 方块闪烁,前后互相遮挡 | 画家算法排序不稳定 | 渲染前按方块到相机距离排序,距离相同加深比较 |
| 鼠标一转视角就乱飞 | yaw或pitch累加错误,或没阻止默认事件 | 给canvas加pointer lock,或调用preventDefault |
| 移动直接穿墙 | 碰撞检测只做了单轴检测 | 分别在x和z方向检测目标位置,不要同时检测 |
| 存档读出来是空的 | localStorage被浏览器清掉 | 提示玩家使用导出JSON功能备份 |
| 手机上看不到画面 | 视口标签缺失或canvas尺寸固定为PC尺寸 | 加上viewport,用window.innerWidth动态设置canvas大小 |
| 方块面颜色发黑 | 不同面的亮度未区分 | 为顶面、侧面、底面分别乘不同亮度系数 |
| 放置方块总是放在自己身上 | 射线检测忽略了自己所在的格子 | 放置前检查目标格和玩家包围盒是否重叠 |
5.1 画面类问题
画面类问题里最坑的是“方块闪烁”。这个问题表面看是画出来的顺序不稳定,实际是排序算法不稳定。我在对方块进行距离排序时,用到了sort,但比较函数只返回远近差值,当两个方块距离完全一样时,sort结果可能不稳定,导致两个本来应该一前一后覆盖的方块互相穿插。解决办法是给比较函数加一个严格区分,例如先比较距离,再比较y值,再比较x值。虽然方块数量多了以后这点开销可以忽略,但排序的稳定性却直接影响画面。
另一个高频问题是Canvas在高分屏下模糊。很多人发现字和方块边缘发虚,原因是没有处理devicePixelRatio。Canvas的CSS尺寸是100%宽度,但实际像素达不到物理像素密度。解决方法是把canvas.width设成clientWidth * devicePixelRatio,canvas.height以此类推,再调用ctx.scale(devicePixelRatio, devicePixelRatio)。这样画面会变得锐利。加完这段代码,整个项目给人的观感立刻提升一个档次。
5.2 逻辑与操作类问题
逻辑类问题里经常踩的是碰撞检测不彻底。移动检测如果只对“目标位置”格做一次检测,角色高速移动时会直接穿过薄墙。稳妥的做法是每一帧把目标位置拆成x、y、z三个方向分别检测,看到哪一个方向撞了方块就停住哪个轴的移动,这样不仅防穿墙,还能让角色沿着墙滑行,手感自然很多。一次性检测三维目标点,会让角色撞墙后直接卡住不动,体验极差。
射线拾取还有一个细节:破坏方块和放置方块需要互斥触发。移动端尤其要注意,手指点击放置的位置如果正好也在破坏按钮的感应区内,会同时触发两个操作。我加了一个简单的机制:每一次触摸事件只允许执行一种操作,比如检测到按压时间小于200毫秒视为点击,否则是拖动视角。这个细节对手机端的操作手感提升非常明显。
写在最后
折腾完这个网页版MC项目,最大的收获反而不是一个“能玩的demo”,而是把体素游戏从“看着神秘”变成了“就这回事”。三维数组存世界,投影公式画场景,射线算法做交互,思想都很朴素,组合起来却能产生很大的自由度和乐趣。如果你也想动手试试,我建议千万不要照抄别人的完整代码,而是先跑通相机旋转,再看画面慢慢变得像MC,那个过程特别上瘾。
最后分享一个小技巧:开发期间把世界的地形种子值做成可调试的全局变量,每次刷新页面之前改一下seed,就能不重启服务地测试不同地形引起的渲染性能问题。这个小习惯帮我省了很多反复关开页面的时间。如果你也写出了一个版本的网页MC,记得在存档按钮旁边留一个“随机世界”入口,那是玩家一定会按的按钮。