本文由Paul O'Brien进行同行评审。 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态!
我一直想制作3D游戏。 我从来没有时间和精力来学习3D编程的复杂性。 然后我发现我不需要…
在修补一天的过程中,我开始思考也许可以使用CSS转换来模拟3D环境。 我偶然发现了一篇有关使用HTML和CSS创建3D世界的老文章。
我想模拟的Minecraft的世界(或至少它的一小部分)。 Minecraft是一款沙盒游戏,您可以在其中破坏并放置积木。 我想要相同的功能,但是需要HTML,JavaScript和CSS。
跟随我描述我所学到的知识,以及它如何帮助您通过CSS转换更具创造力!
注意:本教程的大多数代码都可以在Github上找到。 我已经在最新版本的Chrome中对其进行了测试。 我不能保证在其他浏览器中看起来完全一样,但是核心概念是通用的。
这只是冒险的一半。 如果您想知道如何将设计持久化到实际服务器上,请查看姐妹文章PHP Minecraft Mod 。 在那里,我们探索与Minecraft服务器进行交互,实时操作并响应用户输入的方法。
我已经编写了相当一部分的CSS,并且为了构建网站,我已经对它非常了解。 但是这种理解是基于我将要在2D空间中工作的假设而得出的。
让我们考虑一个例子:
.tools {
position: absolute;
left: 35px;
top: 25px;
width: 200px;
height: 400px;
z-index: 3;
}
.canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2;
}
这里我们有一个canvas元素,从页面的左上角开始,一直延伸到右下角。 最重要的是,我们添加了一个tools元素。 它从页面左侧开始25px
从页面顶部35px
开始,尺寸为200px
宽x 400px
高。
根据订单div.tools
和div.canvas
被添加到标记,这是完全可能的, div.canvas
可以重叠div.tools
。 那是除z-index
样式应用于每个样式之外。
您可能已经习惯了以这种方式设置样式的元素,即可能相互重叠的2D曲面。 但是这种重叠本质上是第三维。 left
, top
和z-index
也可以重命名为x
, y
和z
。 只要我们假设每个元素的固定深度为1px
,并且z-index
的px
单位为隐式,我们就已经在以3D的方式进行思考。
我们中的某些人倾向于挣扎的是在第三维中的旋转和平移概念。
CSS翻译在API中复制了这种熟悉的功能,该API超出了我们对top
, left
和z-index
的限制。 可以用翻译替换我们以前的某些样式:
.tools {
position: absolute;
background: green;
/*
left: 35px;
top: 25px;
*/
transform-origin: 0 0;
transform: translate(35px, 25px);
width: 200px;
height: 400px;
z-index: 3;
}
可以定义一个显式原点,而不是定义left
偏移量和top
偏移量(假定偏移量从左侧偏移0px
从top
偏移0px
)。 我们可以对此元素执行各种转换,以0 0
为中心。 translate(35px, 25px)
的元件移动35px
向右和25px
向下。 我们可以使用负值将元素向左和/或向上移动。
有了为转换定义原点的能力,我们也可以开始做其他有趣的事情。 例如,我们可以旋转和缩放元素:
transform-origin: center;
transform: scale(0.5) rotate(45deg);
每个元素都以50% 50% 0
的默认transform-origin
开始,但是center
的值将x
, y
和z
设置为50%
等效。 我们可以将元素缩放到0
到1
之间的值,并按度或弧度(顺时针)旋转它。 我们可以使用以下方法在两者之间进行转换:
(45 * Math.PI) / 180
45deg
= (45 * Math.PI) / 180
0.79rad
(45 * Math.PI) / 180
0.79rad
0.79rad
= (0.79 * 180) / Math.PI
45deg
要逆时针旋转元素,我们只需要使用负deg
或rad
值即可。
关于这些转换,更有趣的是,我们可以使用它们的3D版本 。
Evergreen浏览器对这些样式提供了很好的支持,尽管它们可能需要供应商前缀。 CodePen有一个简洁的“ autoprefix”选项,但是您可以将PostCSS之类的库添加到本地代码中以实现相同的目的。
让我们开始创建3D世界。 我们将从在其中放置块的空间开始。 创建一个名为index.html
的新文件:
在这里,我们将主体拉伸到整个宽度和高度,将填充重置为0px
。 然后,我们创建一个很小的div.scene
,它将用于保存各种块。 我们使用50%的left
和top
以及负的left和top margin
(等于width
和height
一半)将其水平和垂直居中。 然后,我们稍微倾斜它(使用3D旋转),以便我们可以看到这些块的位置。
注意我们如何定义
transform-style:preserve-3d
。 这样,子元素也可以在3D空间中进行操作。
结果应如下所示:
在CodePen上查看 SitePoint( @SitePoint )的“笔空”场景 。
现在,让我们开始向场景添加块形状。 我们需要创建一个名为block.js
的新JavaScript文件:
"use strict"
class Block {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
this.build();
}
build() {
// TODO: build the block
}
createFace(type, x, y, z, rx, ry, rz) {
// TODO: return a block face
}
createTexture(type) {
// TODO: get the texture
}
}
每个块都必须是6面3D形状。 我们可以将构造的不同部分分解为以下方法:(1)构造整个块,(2)构造每个表面,(3)获得每个表面的纹理。
这些行为(或方法)均包含在ES6类中 。 这是将数据结构和对其进行操作的方法组合在一起的一种巧妙方法。 您可能熟悉传统形式:
function Block(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
this.build();
}
var proto = Block.prototype;
proto.build = function() {
// TODO: build the block
};
proto.createFace = function(type, x, y, z, rx, ry, rz) {
// TODO: return a block face
}
proto.createTexture = function(type) {
// TODO: get the texture
}
这可能看起来有些不同,但是几乎相同。 除了较短的语法外,ES6类还提供了扩展原型和调用重写方法的快捷方式。 但是我离题了……
让我们从头开始工作:
createFace(type, x, y, z, rx, ry, rz) {
return $(``)
.css({
transform: `
translateX(${x}px)
translateY(${y}px)
translateZ(${z}px)
rotateX(${rx}deg)
rotateY(${ry}deg)
rotateZ(${rz}deg)
`,
background: this.createTexture(type)
});
}
createTexture(type) {
return `rgba(100, 100, 255, 0.2)`;
}
每个表面(或面)都包含一个旋转和平移的div。 我们不能使元素的厚度大于1px
,但是我们可以通过覆盖所有孔并使用多个彼此平行的元素来模拟深度。 即使它是空心的,我们也可以给它一个深度的幻觉。
为此, createFace
方法采用一组坐标: x
, y
和z
作为面部的位置。 我们还为每个轴提供旋转,以便我们可以使用任何配置调用createFace
,它将按照我们希望的方式平移和旋转面。
让我们构建基本形状:
build() {
const size = 64;
const x = this.x * size;
const y = this.y * size;
const z = this.z * size;
const block = this.block = $(``)
.css({
transform: `
translateX(${x}px)
translateY(${y}px)
translateZ(${z}px)
`
});
$(``)
.appendTo(block)
.css({
transform: `
rotateX(90deg)
rotateY(0deg)
rotateZ(0deg)
`
});
$(``)
.appendTo(block)
.css({
transform: `
rotateX(0deg)
rotateY(90deg)
rotateZ(0deg)
`
});
$(``)
.appendTo(block);
}
我们习惯于以单个像素的位置来思考,但是像Minecraft这样的游戏可以更大规模地工作。 每个块都更大,并且坐标系处理块的位置,而不是组成它的单个像素。 我想在这里传达同样的想法…
当某人以1
× 2
× 3
创建一个新块时,我想表示0px
× 64px
× 128px
。 因此,我们将每个坐标乘以默认大小(在本例中为64px
,因为这就是我们将使用的纹理包中纹理的大小)。
然后,我们创建一个容器div(我们称之为div.block
)。 在其中,我们放置了另外3个div。 这些将向我们展示块的轴–它们就像3D渲染程序中的参考线。 我们还应该为我们的代码块添加一些新的CSS:
.block {
position: absolute;
left: 0;
top: 0;
width: 64px;
height: 64px;
transform-style: preserve-3d;
transform-origin: 50% 50% 50%;
}
.x-axis,
.y-axis,
.z-axis {
position: absolute;
left: 0;
top: 0;
width: 66px;
height: 66px;
transform-origin: 50% 50% 50%;
}
.x-axis {
border: solid 2px rgba(255, 0, 0, 0.3);
}
.y-axis {
border: solid 2px rgba(0, 255, 0, 0.3);
}
.z-axis {
border: solid 2px rgba(0, 0, 255, 0.3);
}
这种样式类似于我们之前所见。 我们需要记住在.block
上设置transform-style:preserve-3d
,以便在其自己的3D空间中渲染轴。 我们给每种颜色赋予不同的颜色,并使它们比包含在其中的砌块稍大一些。这样做是为了即使在砌块有侧面的情况下也可以看到它们。
让我们创建一个新块,并将其添加到div.scene
:
let first = new Block(1, 1, 1);
$(".scene").append(first.block);
结果应如下所示:
请参见CodePen上的SitePoint ( @SitePoint )的Pen Basic 3D块 。
现在,让我们添加这些面孔:
this
.createFace("top", 0, 0, size / 2, 0, 0, 0)
.appendTo(block);
this
.createFace("side-1", 0, size / 2, 0, 270, 0, 0)
.appendTo(block);
this
.createFace("side-2", size / 2, 0, 0, 0, 90, 0)
.appendTo(block);
this
.createFace("side-3", 0, size / -2, 0, -270, 0, 0)
.appendTo(block);
this
.createFace("side-4", size / -2, 0, 0, 0, -90, 0)
.appendTo(block);
this
.createFace("bottom", 0, 0, size / -2, 0, 180, 0)
.appendTo(block);
我发现这段代码有些反复试验(由于我在3D透视方面的经验有限)。 每个元素都从与div.z-axis
元素完全相同的位置开始。 也就是说,在div.block
的垂直中心并面向顶部。
因此,对于“顶部”元素,我不得不将其“向上”转换为块大小的一半,但是我不必以任何方式旋转它。 对于“底部”元素,我必须将其旋转180度(沿x或y轴),然后将其向下移动块大小的一半。
我用类似的想法旋转并平移了其余各面。 我还必须为其添加相应的CSS:
.side {
position: absolute;
left: 0;
top: 0;
width: 64px;
height: 64px;
backface-visibility: hidden;
outline: 1px solid rgba(0, 0, 0, 0.3);
}
添加backface-visibility:hidden
可防止渲染元素的“底部”。 通常,无论它们如何旋转,它们都将看起来相同(仅镜像)。 对于隐藏的背面,仅呈现“顶”侧。 启用此功能时要当心:您的表面需要正确旋转,否则块的侧面将消失。 这就是我将侧面旋转90/270 / -90 / -270的原因。
请参阅CodePen上的SitePoint ( @SitePoint )的Pen 3D块侧面 。
让我们使该块看起来更逼真。 我们需要创建一个名为block.dirt.js
的新文件,并覆盖createTexture
方法:
"use strict"
const DIRT_TEXTURES = {
"top": [
"textures/dirt-top-1.png",
"textures/dirt-top-2.png",
"textures/dirt-top-3.png"
],
"side": [
"textures/dirt-side-1.png",
"textures/dirt-side-2.png",
"textures/dirt-side-3.png",
"textures/dirt-side-4.png",
"textures/dirt-side-5.png"
]
};
class Dirt extends Block {
createTexture(type) {
if (type === "top" || type === "bottom") {
const texture = DIRT_TEXTURES.top.random();
return `url(${texture})`;
}
const texture = DIRT_TEXTURES.side.random();
return `url(${texture})`;
}
}
Block.Dirt = Dirt;
我们将使用一种流行的纹理包,称为Sphax PureBDCraft 。 它是免费下载和使用的(前提是您不打算出售它),并且它有多种尺寸。 我正在使用
x64
版本。
我们首先为块的侧面和顶部的纹理定义一个查找表。 纹理包没有指定底部应使用哪些纹理,因此我们仅重用顶部纹理。
如果需要纹理的一面是“顶部”或“底部”,则我们从“顶部”列表中获取随机纹理。 在定义之前,不存在随机方法:
Array.prototype.random = function() {
return this[Math.floor(Math.random() * this.length)];
};
同样,如果我们需要一个侧面的纹理,则可以获取一个随机的纹理。 这些纹理是无缝的,因此随机化对我们有利。
结果应如下所示:
请参阅CodePen上的SitePoint ( @SitePoint )提供的Pen 3D块纹理 。
我们如何使之互动? 好吧,一个好的起点是场景。 我们已经在场景中放置了块,所以现在我们只需要启用动态放置!
首先,我们可以渲染块的平面:
const $scene = $(".scene");
for (var x = 0; x < 6; x++) {
for (var y = 0; y < 6; y++) {
let next = new Block.Dirt(x, y, 0);
next.block.appendTo($scene);
}
}
太好了,这为我们提供了一个开始添加块的平坦表面。 现在,当我们将光标悬停在表面上时,让我们突出显示表面:
.block:hover .side {
outline: 1px solid rgba(0, 255, 0, 0.5);
}
但是,发生了一些奇怪的事情:
这是因为表面随机地相互夹住。 没有解决此问题的好方法,但是我们可以通过稍微扩展这些块来防止它发生:
const block = this.block = $(``)
.css({
transform: `
translateX(${x}px)
translateY(${y}px)
translateZ(${z}px)
scale(0.99)
`
});
尽管这确实使外观看起来更好,但场景中存在的块越多,它将影响性能。 一次缩放多个元素时,请稍稍踩一下…
让我们用属于它的块和类型标记每个表面:
createFace(type, x, y, z, rx, ry, rz) {
return $(``)
.css({
transform: `
translateX(${x}px)
translateY(${y}px)
translateZ(${z}px)
rotateX(${rx}deg)
rotateY(${ry}deg)
rotateZ(${rz}deg)
`,
background: this.createTexture(type)
})
.data("block", this)
.data("type", type);
}
然后,当我们单击一个表面时,我们可以导出一组新的坐标并创建一个新块:
function createCoordinatesFrom(side, x, y, z) {
if (side == "top") {
z += 1;
}
if (side == "side-1") {
y += 1;
}
if (side == "side-2") {
x += 1;
}
if (side == "side-3") {
y -= 1;
}
if (side == "side-4") {
x -= 1;
}
if (side == "bottom") {
z -= 1;
}
return [x, y, z];
}
const $body = $("body");
$body.on("click", ".side", function(e) {
const $this = $(this);
const previous = $this.data("block");
const coordinates = createCoordinatesFrom(
$this.data("type"),
previous.x,
previous.y,
previous.z
);
const next = new Block.Dirt(...coordinates);
next.block.appendTo($scene);
});
createCoordinatesFrom
有一个简单但重要的任务。 给定边的类型以及它所属的块的坐标, createCoordinatesFrom
应该返回一组新的坐标。 这些是放置新块的位置。
然后,我们附加了一个事件侦听器。 将为每个被点击的div.side
触发。 发生这种情况时,我们得到边所属的块,并为下一个块导出一组新的坐标。 一旦有了这些,就创建块并将其附加到场景中。
结果是极好的交互性:
请参阅CodePen上的SitePoint ( @SitePoint ) 预先用笔填充的场景 。
在放置之前,先查看要放置的块的轮廓会很有帮助。 这有时被称为“展示我们即将要做的事情的鬼影”。
实现此目的的代码与我们已经看到的代码非常相似:
let ghost = null;
function removeGhost() {
if (ghost) {
ghost.block.remove();
ghost = null;
}
}
function createGhostAt(x, y, z) {
const next = new Block.Dirt(x, y, z);
next.block
.addClass("ghost")
.appendTo($scene);
ghost = next;
}
$body.on("mouseenter", ".side", function(e) {
removeGhost();
const $this = jQuery(this);
const previous = $this.data("block");
const coordinates = createCoordinatesFrom(
$this.data("type"),
previous.x,
previous.y,
previous.z
);
createGhostAt(...coordinates);
});
$body.on("mouseleave", ".side", function(e) {
removeGhost();
});
主要区别在于,我们只保留一个幽灵块实例。 创建每一个新的时,旧的将被删除。 这可以从一些其他样式中受益:
.ghost {
pointer-events: none;
}
.ghost .side {
opacity: 0.6;
pointer-events: none;
-webkit-filter: brightness(1.5);
}
如果保持活动状态,则与mouseleave
影元素关联的指针事件将抵消下面一侧的mouseenter
和mouseleave
事件。 由于我们不需要与ghost元素进行交互,因此可以禁用这些指针事件。
这个结果非常简洁:
请参阅CodePen上的SitePoint ( @SitePoint )的Pen 3D块鬼影 。
我们添加的交互性越多,就越难了解正在发生的事情。 似乎是时候做些什么了。 如果我们可以缩放和旋转视口,那真是太棒了,以便能够看到发生了什么更好的事情……
让我们从缩放开始。 许多界面(和游戏)都可以通过滚动鼠标滚轮来缩放视口。 不同的浏览器以不同的方式处理鼠标滚轮事件,因此使用抽象库是有意义的。
安装完成后,我们可以查看事件:
let sceneTransformScale = 1;
$body.on("mousewheel", function(event) {
if (event.originalEvent.deltaY > 0) {
sceneTransformScale -= 0.05;
} else {
sceneTransformScale += 0.05;
}
$scene.css({
"transform": `
scaleX(${sceneTransformScale})
scaleY(${sceneTransformScale})
scaleZ(${sceneTransformScale})
`
});
});
现在,我们只需滚动鼠标滚轮就可以控制整个场景的比例。 不幸的是,在我们这样做的那一刻,旋转被覆盖了。 我们需要考虑旋转,因为我们允许用鼠标拖动视口来调整它:
let sceneTransformX = 60;
let sceneTransformY = 0;
let sceneTransformZ = 60;
let sceneTransformScale = 1;
const changeViewport = function() {
$scene.css({
"transform": `
rotateX(${sceneTransformX}deg)
rotateY(${sceneTransformY}deg)
rotateZ(${sceneTransformZ}deg)
scaleX(${sceneTransformScale})
scaleY(${sceneTransformScale})
scaleZ(${sceneTransformScale})
`
});
};
此功能不仅会考虑场景的比例因子,还会考虑x,y和z旋转因子。 我们还需要更改缩放事件监听器:
$body.on("mousewheel", function(event) {
if (event.originalEvent.deltaY > 0) {
sceneTransformScale -= 0.05;
} else {
sceneTransformScale += 0.05;
}
changeViewport();
});
现在,我们可以开始旋转场景了。 我们需要:
这样的事情应该可以解决问题:
Number.prototype.toInt = String.prototype.toInt = function() {
return parseInt(this, 10);
};
let lastMouseX = null;
let lastMouseY = null;
$body.on("mousedown", function(e) {
lastMouseX = e.clientX / 10;
lastMouseY = e.clientY / 10;
});
$body.on("mousemove", function(e) {
if (!lastMouseX) {
return;
}
let nextMouseX = e.clientX / 10;
let nextMouseY = e.clientY / 10;
if (nextMouseX !== lastMouseX) {
deltaX = nextMouseX.toInt() - lastMouseX.toInt();
degrees = sceneTransformZ - deltaX;
if (degrees > 360) {
degrees -= 360;
}
if (degrees < 0) {
degrees += 360;
}
sceneTransformZ = degrees;
lastMouseX = nextMouseX;
changeViewport();
}
if (nextMouseY !== lastMouseY) {
deltaY = nextMouseY.toInt() - lastMouseY.toInt();
degrees = sceneTransformX - deltaY;
if (degrees > 360) {
degrees -= 360;
}
if (degrees < 0) {
degrees += 360;
}
sceneTransformX = degrees;
lastMouseY = nextMouseY;
changeViewport();
}
});
$body.on("mouseup", function(e) {
lastMouseX = null;
lastMouseY = null;
});
在mousedown
我们捕获了鼠标的初始x
和y
坐标。 当鼠标移动时(如果仍在按下按钮),我们sceneTransformX
比例调整sceneTransformZ
和sceneTransformX
。 让值超过360
度或低于0
度并没有什么害处,但是如果我们想在屏幕上渲染它们,这些看起来会很糟糕。
mousemove
事件侦听器内部的计算可能会在计算上昂贵,因为此均匀侦听器可能会触发多少。 屏幕上可能有数百万个像素,并且当鼠标移至每个侦听器时,可能会触发此侦听器。 这就是为什么如果不按住鼠标按钮我们会提前退出的原因。
释放鼠标按钮时,我们会取消设置lastMouseX
和lastMouseY
,以便mousemove
侦听器停止计算内容。 我们可以只清除lastMouseX
,但是清除这两个对我来说感觉更干净。
不幸的是,mousedown事件可能会干扰块侧面的click事件。 我们可以通过防止事件冒泡来解决此问题:
$scene.on("mousedown", function(e) {
e.stopPropagation();
});
旋转一下……
请参阅CodePen上的SitePoint ( @SitePoint )进行的笔缩放和旋转 。
让我们通过添加删除块的功能来完善实验。 我们需要做一些微妙但重要的事情:
只要我们有一个body类来指示我们是加法(普通)还是减法,使用CSS进行这些操作就会更容易:
$body.on("keydown", function(e) {
if (e.altKey || e.controlKey || e.metaKey) {
$body.addClass("subtraction");
}
});
$body.on("keyup", function(e) {
$body.removeClass("subtraction");
});
当按下修饰键( alt
, control
或command
)时,此代码将确保body
具有subtraction
类。 这使得使用此类更容易定位各种元素:
.subtraction .block:hover .side {
outline: 1px solid rgba(255, 0, 0, 0.5);
}
.subtraction .ghost {
display: none;
}
我们正在检查许多修饰键,因为不同的操作系统会截取不同的修饰键。 例如, altKey
和metaKey
在macOS上工作,而controlKey
在Ubuntu上工作。
如果单击块,则在减法模式下,应将其删除:
$body.on("click", ".side", function(e) {
const $this = $(this);
const previous = $this.data("block");
if ($body.hasClass("subtraction")) {
previous.block.remove();
previous = null;
} else {
const coordinates = createCoordinatesFrom(
$this.data("type"),
previous.x,
previous.y,
previous.z
);
const next = new Block.Dirt(...coordinates);
next.block.appendTo($scene);
}
});
这与我们之前使用的.side
click事件监听器相同,但是我们首先检查是否处于减法模式,而不是单击一侧时仅添加新块。 如果是,则将刚刚单击的块从场景中移除。
最终演示非常有趣:
请参阅CodePen上的SitePoint ( @SitePoint )的笔移除块 。
要支持像Minecraft一样多的块和交互,还有很长的路要走,但这是一个好的开始。 而且,我们无需研究高级3D技术就可以实现这一目标。 这是CSS转换的非常规(创造性)用途!
如果您想使用此代码做更多事情,请继续本冒险的另一部分 。 您无需成为PHP专家即可与Minecraft服务器进行交互。 并想像一下您可以利用这些知识来完成令人敬畏的事情……
而且请不要忘记:这只是冒险的一半。 如果您想知道如何将设计持久化到实际服务器上,请查看姐妹文章PHP Minecraft Mod 。 在那里,我们探索与Minecraft服务器进行交互,实时操作并响应用户输入的方法。
From: https://www.sitepoint.com/javascript-3d-minecraft-editor/