纯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上面?等等,需要确认层级。实际上.a和img同层,但.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也就是改个数字的事。如果还想加个完成动画或者计时器,那又是另一波折腾了,但这基础已经够硬,接着往上叠功能完全不虚。
