如何制作纯 CSS 拼图游戏

Avatar of Temani Afif
Temani Afif

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 价值 200 美元的免费积分!

我最近发现用纯 CSS 创建游戏的乐趣。HTML 和 CSS 能够处理整个在线游戏的逻辑,这总是令人着迷,所以我必须尝试一下!这类游戏通常依赖于老式的复选框技巧,我们将 HTML 输入的选中/未选中状态与 CSS 中的 :checked 伪类组合起来。我们可以用这种组合实现很多神奇的效果!

事实上,我挑战自己用纯 CSS 制作一个游戏。我不确定这是否可行,但它确实可行,我会向您展示如何实现。

除了我们在本文中将要学习的拼图游戏之外,我还制作了 一个纯 CSS 游戏合集,其中大多数都没有使用复选框技巧。(它们也已在 CodePen 上 提供。)

想在开始之前玩玩吗?

我个人更喜欢全屏模式玩游戏,但您也可以在下方玩,或 在 这里打开

很酷吧?我知道,它不是您见过的最好的拼图游戏™,但对于一个只使用 CSS 和几行 HTML 的游戏来说,它也不错。您可以轻松调整网格的大小,更改单元格数量来控制难度级别,并使用您想要的任何图像!

我们将一起重制那个演示,然后最后再添加一些额外的闪光点。

拖放功能

虽然拼图的结构使用 CSS 网格相当简单,但拖放拼图块的功能就有点棘手了。我不得不依靠过渡、悬停效果和兄弟选择器的组合来完成它。

如果您将鼠标悬停在那个演示中的空框上,图像会在框内移动,即使您将鼠标移出框外,图像也会停留在那里。诀窍是添加一个很长的过渡持续时间和延迟——长到图像需要很长时间才能返回其初始位置。

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 中引入另一个元素。

这个额外的 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 元素的尺寸来实现这一点。

现在,将鼠标悬停在框上没有任何作用。但是,如果您开始拖动图像,.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;
}

现在我们的拖放功能完美无缺。除非您可以找到破解方法,否则移动图像的唯一方法就是将其拖放到框中。

构建拼图网格

与我们刚刚完成的拖放功能相比,将拼图拼凑起来会感觉非常容易。我们将依靠 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)
   }
}

您可能已经注意到我正在使用 Sass 的 random() 函数。这就是我们为拼图块获取随机位置的方式。请记住,在将相应的 <b> 元素拖放到网格单元格中并将其悬停在 <a> 元素上后,我们将 禁用 该位置。

z a:hover ~ b {
  transform: translate(0);
  transition: 0s;
}

在同一个循环中,我还定义了每个拼图块的背景配置。它们在逻辑上将共享相同的图像作为背景,并且其大小应等于整个网格的大小(用 --s 变量定义)。使用相同的 background-image 和一些数学运算,我们更新 background-position 以仅显示图像的一部分。

就这样!我们的纯 CSS 拼图游戏在技术上已经完成了!

但我们总是可以做得更好,对吧?我在另一篇文章中向您展示了 如何制作拼图块形状网格。让我们采用同样的想法,并将它应用到这里,好吗?

拼图块形状

这是我们的新拼图游戏。功能相同,但形状更逼真!

这是网格上形状的示意图。

如果你仔细观察,你会发现我们有九种不同的拼图形状:四个角四条边,以及一个用于其他所有部分

我在另一篇文章中提到的拼图块网格更简单明了

我们可以使用相同的技术,将 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);

代码可能看起来很复杂,但让我们一次关注一个渐变,看看发生了什么

两个渐变创建了两个圆圈(在演示中标记为绿色和紫色),另外两个渐变创建了其他拼图块连接的插槽(标记为蓝色的插槽填充了大部分形状,而标记为红色的插槽填充了顶部部分)。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);

我们删除了第一个(顶部)渐变,并调整了第二个渐变的值,使其覆盖了留下的空间。如果你比较两个示例,你不会注意到代码有太大区别。需要注意的是,我们可以找到不同的背景配置来创建相同的形状。如果你开始玩渐变,你肯定会想出与我不同的东西。你甚至可以写出更简洁的代码——如果可以的话,请在评论区分享!

除了创建形状之外,你还会发现我像下面这样增加了元素的宽度和/或高度

height: calc(100% + var(--r));
width: calc(100% + var(--r));

拼图块需要超出其网格单元以连接。

最终演示

这是完整的演示。如果你将其与第一个版本进行比较,你会发现创建网格和拖放功能的代码结构相同,以及创建形状的代码。

可能的增强功能

文章到此结束,但我们可以通过更多功能来继续增强我们的拼图!如何添加计时器?或者玩家完成拼图时,是否可以显示一些恭喜信息?

我可能会在未来版本中考虑所有这些功能,所以请 关注我的 GitHub 仓库

总结

人们常说,CSS 不是编程语言。哈!

我并不是要通过这句话引发一些 #HotDrama。我说这句话是因为我们做了一些非常棘手的逻辑运算,并且一路覆盖了很多 CSS 属性和技术。我们玩了 CSS Grid、过渡、遮罩、渐变、选择器和背景属性。更不用说我们使用的一些 Sass 技巧来使我们的代码易于调整。

目标不是构建游戏,而是探索 CSS,并发现你可以在其他项目中使用的新的属性和技巧。在 CSS 中创建一个在线游戏是一项挑战,它促使你深入探索 CSS 功能,并学习如何使用它们。此外,当一切都结束时,我们可以得到一些可以玩的东西,这很有趣。

无论 CSS 是否是编程语言,都不会改变我们总是通过构建和创造创新事物来学习的事实。