我最近发现了创建纯CSS游戏的乐趣。HTML和CSS能够处理整个在线游戏的逻辑,这总是令人着迷,所以我不得不尝试一下!这类游戏通常依靠老式的Checkbox Hack,我们将HTML输入的选中/未选中状态与CSS中的:checked 伪类结合起来。我们可以用这一组合做很多神奇的事情!
事实上,我向自己提出挑战,要建立一个没有Checkbox的整个游戏。我不确定这是否可行,但它肯定是可行的,而且我将向你展示如何做。
除了我们将在本文中研究的益智游戏外,我还制作了一系列纯CSS游戏,其中大部分没有使用Checkbox Hack。(它们也可以在CodePen上找到)。
在我们开始之前想玩一下吗?
我个人更喜欢在全屏模式下玩游戏,但你可以在下面玩,或在这里打开它。
CodePen嵌入回退
很酷吧?我知道,这不是你见过的最好的益智游戏™,但对于只使用CSS和几行HTML的东西来说,它也不差。你可以很容易地调整网格的大小,改变单元格的数量来控制难度,并使用你想要的任何图像
我们将一起重新制作这个演示,然后在结尾处放上一点额外的闪光点,以增加一些乐趣。
拖放功能
虽然CSS Grid的谜题结构相当简单,但拖放谜题的功能却有点棘手。我不得不依靠过渡、悬停效果和同级选择器的组合来完成它。
CodePen嵌入回退
如果你把鼠标悬停在那个演示中的空盒子上,图片就会在里面移动,即使你把光标移出盒子也会停留在那里。诀窍是添加一个大的过渡时间和延迟--大到图像需要很多时间才能返回到它的初始位置:
img {
transform: translate(200%);
transition: 999s 999s; /* very slow move on mouseout */
}
.box:hover img {
transform: translate(0);
transition: 0s; /* instant move on hover */
}
只指定 transition-delay就足够了,但是在延迟和持续时间上都使用大的数值,会减少玩家看到图像回移的机会。如果你等待999s + 999s ,也就是大约30分钟,那么你会看到图像移动。但你不会的,对吗?我的意思是,没有人会在两轮之间花那么长时间,除非他们离开游戏。所以,我认为这是一个在两种状态之间切换的好技巧。
你是否注意到,将图像悬停也会触发变化?这是因为图像是盒子元素的一部分,这对我们来说是不利的。我们可以通过给图片添加pointer-events: none 来解决这个问题,但我们以后就不能拖动它了。
这意味着我们必须在.box 里面引入另一个元素:
CodePen Embed Fallback
这个额外的div (我们使用的是.a 类)将占用与图片相同的区域(感谢CSS网格和grid-area: 1 / 1 ),并将成为触发悬停效果的元素。这就是兄弟姐妹选择器发挥作用的地方。
.a {
grid-area: 1 / 1;
}
img {
grid-area: 1 / 1;
transform: translate(200%);
transition: 999s 999s;
}
.a:hover + img {
transform: translate(0);
transition: 0s;
}
悬停在.a 元素上会移动图片,由于它占据了方框内的所有空间,就像我们在方框上悬停一样悬停图像不再是个问题了
让我们把我们的图像拖放到盒子里,看看结果如何。
你看到了吗?你首先抓住图像并把它移到盒子里,没什么花哨的。但是一旦你放开图像,你就会触发移动图像的悬停效果,然后我们就模拟了拖放功能。如果你在框外释放鼠标,什么都不会发生。
嗯,你的模拟并不完美,因为我们也可以将盒子悬停,得到同样的效果。
没错,我们将纠正这一点。我们需要禁用悬停效果,只有当我们在框内释放图像时才允许它。我们将利用我们的.a 元素的尺寸来实现这一目标。
CodePen嵌入回退
现在,悬停在盒子上没有任何作用。但如果你开始拖动图片,.a 元素就会出现,一旦在框内释放,我们就可以触发悬停效果并移动图片。
让我们来剖析一下这段代码:
.a {
width: 0%;
transition: 0s .2s; /* add a small delay to make sure we catch the hover effect */
}
.box:active .a { /* on :active increase the width */
width: 100%;
transition: 0s; /* instant change */
}
img {
transform: translate(200%);
transition: 999s 999s;
}
.a:hover + img {
transform: translate(0);
transition: 0s;
}
点击图片可以启动 :active伪类,使.a 元素变得全宽(它最初等于0 )。活动状态将一直保持,直到我们释放图像。如果我们在框内释放图像,.a 元素会回到width: 0 ,但我们会在它发生之前触发悬停效果,图像会落在框内如果你在框外释放它,什么也不会发生。
有一个小怪癖:点击空框也会移动图片,并破坏我们的功能。目前,:active 链接到.box 元素,所以点击它或它的任何一个子元素将激活它;通过这样做,我们最终显示.a 元素并触发悬停效果。
我们可以通过以下方法解决这个问题 pointer-events.它允许我们禁用与.box 的任何交互,同时保持与子元素的交互:
.box {
pointer-events: none;
}
.box * {
pointer-events: initial;
}
CodePen嵌入回退
现在我们的拖放功能是完美的。除非你能找到黑客的方法,否则移动图片的唯一方法就是拖动它并把它放到框内。
构建拼图网格
与我们刚才为拖放功能所做的相比,将拼图放在一起会感觉很容易。我们将依靠CSS网格和背景技巧来创建拼图。
这是我们的网格,为了方便,用Pug写的:
- let n = 4; /* number of columns/rows */
- let image = "https://picsum.photos/id/1015/800/800";
g(style=`--i:url(${image})`)
- for(let i = 0; i < n*n; i++)
z
a
b(draggable="true")
这些代码可能看起来很奇怪,但它可以编译成普通的HTML:
<g style="--i: url(https://picsum.photos/id/1015/800/800)">
<z>
<a></a>
<b draggable="true"></b>
</z>
<z>
<a></a>
<b draggable="true"></b>
</z>
<z>
<a></a>
<b draggable="true"></b>
</z>
<!-- etc. -->
</g>
我打赌你一定想知道这些标签是怎么回事。这些元素都没有任何特殊的含义--我只是发现,用<z> ,比用一堆<div class="z"> 或其他什么东西写代码要容易得多。
这就是我对它们的映射方式:
<g>是我们的网格容器,包含 元素。N*N<z><z>代表我们的网格项目。它扮演着我们在上一节看到的 元素的角色。.box<a>触发悬停效果。<b>代表我们图片的一部分。我们对它应用 属性,因为它默认不能被拖动。draggable
好了,让我们在<g> 上注册我们的网格容器。这是在Sass而不是CSS中:
$n : 4; /* number of columns/rows */
g {
--s: 300px; /* size of the puzzle */
display: grid;
max-width: var(--s);
border: 1px solid;
margin: auto;
grid-template-columns: repeat($n, 1fr);
}
实际上,我们要让我们的网格子元素--<z> --也成为网格,并让<a> 和<b> 都在同一个网格区域内:
z {
aspect-ratio: 1;
display: grid;
outline: 1px dashed;
}
a {
grid-area: 1/1;
}
b {
grid-area: 1/1;
}
正如你所看到的,没有什么花哨的东西--我们创建了一个有特定尺寸的网格。我们需要的其余CSS是用于拖放功能,这需要我们在棋盘周围随机放置棋子。我打算用Sass来做这件事,这也是为了方便我们用一个函数来循环并设计所有的拼图块:
b {
background: var(--i) 0/var(--s) var(--s);
}
@for $i from 1 to ($n * $n + 1) {
$r: (random(180));
$x: (($i - 1)%$n);
$y: floor(($i - 0.001) / $n);
z:nth-of-type(#{$i}) b{
background-position: ($x / ($n - 1)) * 100% ($y / ($n - 1)) * 100%;
transform:
translate((($n - 1) / 2 - $x) * 100%, (($n - 1)/2 - $y) * 100%)
rotate($r * 1deg)
translate((random(100)*1% + ($n - 1) * 100%))
rotate((random(20) - 10 - $r) * 1deg)
}
}
你可能已经注意到,我正在使用Sassrandom() 函数。这就是我们如何为拼图块获得随机位置的方法。<a> 请记住,当我们在网格单元内拖放其对应的<b> 元素后,将悬停在该位置:
z a:hover ~ b {
transform: translate(0);
transition: 0s;
}
在同一个循环中,我还为拼图的每一块定义了背景配置。从逻辑上讲,所有的拼图都将共享相同的图片作为背景,并且其大小应该等于整个网格的大小(用--s 变量定义)。使用相同的background-image 和一些数学方法,我们更新background-position ,只显示图片的一部分。
就这样了我们的纯CSS的拼图游戏在技术上已经完成了!
CodePen嵌入回退
但我们总是可以做得更好,对吗?我在另一篇文章中向你展示了如何制作一个拼图形状的网格。让我们把这个想法应用到这里,好吗?
拼图块的形状
这就是我们的新拼图游戏。同样的功能,但有更真实的形状
CodePen 嵌入回退
这是一张网格上的形状图:

如果你仔细观察,你会发现我们有九种不同的拼图形状:四个角、四条边,还有一种是其他的。
我在另一篇文章中提到的拼图块的网格就比较简单了。
CodePen嵌入回退
我们可以使用同样的技术,结合CSS掩码和梯度来创建不同的形状。如果你不熟悉mask 和渐变,我强烈建议在进入下一部分之前,先查看那个简化案例,以便更好地理解这个技术。
首先,我们需要使用特定的选择器来针对每一组共享相同形状的元素。我们有九个组,所以我们将使用八个选择器,加上一个默认的选择器,选择所有的组:
z /* 0 */
z:first-child /* 1 */
z:nth-child(-n + 4):not(:first-child) /* 2 */
z:nth-child(5) /* 3 */
z:nth-child(5n + 1):not(:first-child):not(:nth-last-child(5)) /* 4 */
z:nth-last-child(5) /* 5 */
z:nth-child(5n):not(:nth-child(5)):not(:last-child) /* 6 */
z:last-child /* 7 */
z:nth-last-child(-n + 4):not(:last-child) /* 8 */
这里有一个图,显示了如何映射到我们的网格:

现在我们来解决形状问题。让我们专注于学习其中的一两个形状,因为它们都使用同样的技术--这样,你就有一些功课要继续学习了
对于网格中心的拼图块,0:
mask:
radial-gradient(var(--r) at calc(50% - var(--r) / 2) 0, #0000 98%, #000) var(--r)
0 / 100% var(--r) no-repeat,
radial-gradient(var(--r) at calc(100% - var(--r)) calc(50% - var(--r) / 2), #0000 98%, #000)
var(--r) 50% / 100% calc(100% - 2 * var(--r)) no-repeat,
radial-gradient(var(--r) at var(--r) calc(50% - var(--r) / 2), #000 98%, #0000),
radial-gradient(var(--r) at calc(50% + var(--r) / 2) calc(100% - var(--r)), #000 98%, #0000);
代码可能看起来很复杂,但让我们一次专注于一个梯度,看看会发生什么。
CodePen 嵌入回退
两个梯度创造了两个圆(在演示中标为绿色和紫色),另外两个梯度创造了其他棋子连接的槽(标为蓝色的槽填满了大部分的形状,而标为红色的槽填满了顶部的部分)。一个CSS变量,--r ,设定了圆形的半径。

中间的拼图块(图中标记为0 )是最难制作的,因为它使用了四个梯度,有四个弧度。所有其他的棋子使用的渐变都比较少。
例如,沿着谜题顶部边缘的拼图(图中标记为2 )使用了三个梯度而不是四个。
mask:
radial-gradient(var(--r) at calc(100% - var(--r)) calc(50% + var(--r) / 2), #0000 98%, #000) var(--r) calc(-1 * var(--r)) no-repeat,
radial-gradient(var(--r) at var(--r) calc(50% - var(--r) / 2), #000 98%, #0000),
radial-gradient(var(--r) at calc(50% + var(--r) / 2) calc(100% - var(--r)), #000 98%, #0000);
CodePen 嵌入回退
我们删除了第一个(顶部)渐变,并调整了第二个渐变的值,使其覆盖了留下的空间。如果你比较这两个例子,你不会注意到代码有很大的不同。应该注意的是,我们可以找到不同的背景配置来创造相同的形状。如果你开始玩梯度,你肯定会想出一些与我不同的东西。你甚至可以写出更简洁的东西--如果是这样,在评论中分享吧
除了创建形状之外,你还会发现我在增加元素的宽度和/或高度,就像下面这样:
height: calc(100% + var(--r));
width: calc(100% + var(--r));
拼图的碎片需要溢出它们的网格单元来连接:

最后的演示
这里又是完整的演示。如果你将它与第一个版本进行比较,你会看到同样的代码结构来创建网格和拖放功能,另外还有创建形状的代码。
CodePen 嵌入回退
可能的改进
文章到此结束,但我们可以继续加强我们的谜题,使其具有更多的功能!计时器如何?或者,当玩家完成谜题时,给予某种祝贺?
我可能会在未来的版本中考虑所有这些功能,所以请关注我的GitHub repo。
总结
他们说,CSS并不是一种编程语言。哈!
我并不是想通过这句话来引发一些#热剧#。我这么说是因为我们做了一些非常棘手的逻辑性工作,并且一路走来涵盖了很多CSS属性和技术。我们使用了CSS网格、过渡、遮罩、梯度、选择器和背景属性。更不用说我们使用的一些Sass技巧,使我们的代码易于调整。
我们的目的不是为了制作游戏,而是为了探索CSS,发现你可以在其他项目中使用的新属性和技巧。用CSS创建一个网络游戏是一个挑战,它促使你非常详细地探索CSS的特性并学习如何使用它们。另外,当所有的事情都完成后,我们能得到一些东西来玩,这实在是一件非常有趣的事情。
无论CSS是否是一种编程语言,都不会改变我们总是通过构建和创造创新的东西来学习的事实。