想用纯CSS做个拼图游戏,拖拽功能咋实现,这波操作太骚了

3,768字
16–24 分钟
in

纯CSS还能做拼图?刚看到这玩法的时候,整个人都惊了。没有JavaScript,光靠HTML和CSS就搞定了图片拖拽、随机打乱、拼图形状这些逻辑。核心全靠:active伪类、过渡动画的大延迟套路,再加上兄弟选择器的一顿配合。下面就把这整套玩法掰开揉碎,从拖拽模拟到网格布局再到拼图形状,一步步盘清楚。

目录

拖拽模拟,全靠延时过渡来骗眼睛

先看最棘手的部分——怎么让图片从一个格子“拖”到另一个格子并且留在那儿。正常拖拽需要JS监听事件,但这里用了一个障眼法:鼠标在目标格子上松开时,图片瞬间位移进去,而鼠标移开后,图片要花极长时间(比如999秒)才能回到原位。这样普通玩家根本等不到它回去,效果就跟永久移动一样。

实现这个需要两个关键状态:悬停时图片瞬间归位移出后图片慢吞吞返回。代码大概长这样:

img {
  transform: translate(200%);
  transition: 999s 999s; /* 移出后超慢动作 */
}
.box:hover img {
  transform: translate(0);
  transition: 0s; /* 悬停时秒切 */
}

但是直接这么写有个大坑——鼠标悬停在图片本身上也会触发移动。因为图片是.box的子元素,冒泡上去就把效果激活了。解决办法是塞一个额外的占位元素(比如.a),让它跟图片叠在同一个网格区域里,然后只对.a做悬停检测。兄弟选择器+就派上用场了:

.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上面?等等,需要确认层级。实际上.aimg同层,但.a在HTML中写在img前面,兄弟选择器+选择紧邻的下一个兄弟。那么鼠标悬浮在img上时,并不会触发.a:hover,所以图片不会乱动。完美解决。

接下来要模拟“拖拽后才允许移动”的效果,而不是随便一碰格子就移动。这里用:active伪类来控制.a的尺寸:点击图片时(即:active激活)让.a铺满整个格子,松开鼠标时.a缩回0宽,但在缩回的一瞬间如果鼠标还在格子内,就会触发.a:hover,从而移动图片。代码调整如下:

.a {
  width: 0%;
  transition: 0s 0.2s; /* 小延迟确保能抓到悬停 */
}
.box:active .a {
  width: 100%;
  transition: 0s; /* 点击瞬间撑满 */
}
img {
  transform: translate(200%);
  transition: 999s 999s;
}
.a:hover + img {
  transform: translate(0);
  transition: 0s;
}

但是又冒出新问题——直接点击空白的.box区域,也会触发:active,导致.a撑满并且悬停,图片照样移动。这不符合预期,只有从图片开始拖拽才应该生效。修复方案是用pointer-events掐断对.box的点击,但保留子元素的交互:

.box {
  pointer-events: none;
}
.box * {
  pointer-events: initial;
}

现在整个拖拽逻辑就稳了:只有抓着图片(<b>元素)拖进目标格子再松开,图片才会乖乖跳进去。其他任何方式的点击或悬停都不会触发移动。

拼图网格,用Grid和随机背景定位打乱顺序

拖拽功能搞定了,接下来搭拼图盘子。用CSS Grid做4×4网格,每个格子就是一个独立容器。HTML结构用自定义标签(比如<g>代表网格,<z>代表格子,<a>是触发器,<b>是可拖拽的图片碎片),这样写起来比一堆div清爽很多。

- let n = 4
- 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后就是一组<z><a></a><b draggable="true"></b></z>的循环。样式上,<g>设置为Grid容器,宽高由变量--s控制,格子数量由Sass的$n决定:

$n: 4;
g {
  --s: 300px;
  display: grid;
  max-width: var(--s);
  grid-template-columns: repeat($n, 1fr);
}
z {
  aspect-ratio: 1;
  display: grid;
  outline: 1px dashed;
}
a, b {
  grid-area: 1/1;
}

每个<b>的背景是同一张大图,但通过background-position只显示一小块。同时用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);
  }
}

注意这里用了random()函数,每次刷新位置都不一样,可玩性拉满。而悬停移动的逻辑跟前面拖拽部分完全一致:每个格子的<a>触发对应<b>的位移复位。

拼图形状,用mask和径向渐变造出咬合齿

普通的方格子太素了,给碎片加上凸起和凹陷的“拼图耳朵”才够味。这活儿交给CSS的mask属性,配合多个径向渐变来抠出圆形凸起和凹槽。先看中间部位的碎片(既不是边角也不是边缘),需要四个圆弧:

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);

简单拆解:第一行在顶部中间挖一个半圆缺口(透明区域),第二行在右侧中间挖缺口,第三行和第四行分别在左侧和底部生成凸起的半圆(黑色区域保留)。通过调整--r变量控制圆弧半径。

边缘的碎片少一些圆弧。比如顶部边缘的碎片只需要三个圆弧(顶部平直,左右和底部有齿):

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);

为了区分不同位置的碎片,用一组选择器给每个位置的格子套上对应的mask样式。比如:

选择器对应位置
z:first-child左上角
z:nth-child(-n+4):not(:first-child)顶部中间
z:nth-child(5)左边缘第二行
z:last-child右下角

由于每个碎片的mask会抠出圆弧,碎片实际需要比格子稍大一点才能互相咬合。所以给z里面的b增加宽高溢出:

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

这样相邻碎片的凸起和凹陷就能完美穿插在一起,视觉效果瞬间拉满。


整套代码跑起来之后,一个完全不用JS的拼图游戏就出炉了。从拖拽模拟到随机打乱再到异形拼块,每个环节都是纯CSS的骚操作。想调整难度改改$n的值就行,换成3×3或者5×5也就是改个数字的事。如果还想加个完成动画或者计时器,那又是另一波折腾了,但这基础已经够硬,接着往上叠功能完全不虚。