如何制作一个纯CSS的益智游戏(附代码)

433 阅读11分钟

我最近发现了创建纯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是否是一种编程语言,都不会改变我们总是通过构建和创造创新的东西来学习的事实。