听说过用CSS写游戏吗?不是那种简单的点击换色,而是真刀真枪的AI对战。最近网上冲浪发现个硬核玩法,利用:target伪类搞了个不可战胜的井字棋,棋盘上每个格子点下去,样式表就跟开了挂一样堵死所有赢棋路线。这波操作直接把CSS从“装修工”变成了“战术大师”,今天就来扒一扒这个骚套路是怎么整出来的。
啥是:target
:target这玩意儿平时用得少,但它其实是浏览器自带的“路由神器”。当页面URL后面跟个#xxx(比如index.html#step1),:target就能选中那个id为step1的元素,然后给丫加上样式。这就像给每个锚点链接配了个聚光灯,点到哪儿就亮哪儿。很多老式网站用这个做单页切换,但拿来写游戏逻辑?脑洞属实大。
一个典型的:target玩法长这样:
/* 默认隐藏所有面板 */
.panel {
display: none;
}
/* 只有被锚点选中的面板才显示 */
.panel:target {
display: block;
}页面里放几个<div id="p1" class="panel">,再配上带#p1的链接,点击链接就能切换内容,全程不用JS。这算是最原始的客户端路由,但用来做游戏状态管理,就得把棋盘上每一种可能的落子结果都提前写进HTML里。
游戏状态的平行宇宙
井字棋总共只有9个格子,先手(X)走第一步后,理论上最多产生五千多个合法局面。这数量说大不大,说小不小,但完全能塞进一个HTML文件里。每个局面用一个唯一的id标识,比如#--OOX----代表第3、4格是O,第5格是X的棋盘。整个HTML就像一本“选择你自己的冒险”小说,每个格子对应一个超链接,点下去就跳到下一个局面。
生成这堆东西靠的是改造版的minimax算法,用Ruby跑出来。CodePen支持HAML,而HAML里能直接嵌入Ruby代码块,这就等于白嫖了一个Ruby后端。算法会遍历所有合法状态,算出电脑(O方)的最优回应,然后把每个状态转成一段HTML结构。
每个状态在HTML里长这样:
<div class="b" id="--OOX----">
<svg class="o s">
<circle></circle>
</svg>
<a class="s" href="#OXOOX----">
<div></div>
</a>
<svg class="o s">
<circle class="c"></circle>
</svg>
<svg class="o s">
<circle class="c"></circle>
</svg>
<div class="x"></div>
<a class="s" href="#O-OOXX---">
<div></div>
</a>
<a class="s" href="#O-OOX-X--">
<div></div>
</a>
<a class="s" href="#O-OOX--X-">
<div></div>
</a>
<a class="s" href="#O-OOX---X">
<div></div>
</a>
</div>看不懂没关系,简单解释:div.b就是整个棋盘容器,id是当前局面的编码。里面混着svg(画O的图形)、div.x(画X的图形),以及一堆a标签——每个a对应一个空格子,点击后会跳转到新局面。那些带class="c"的circle,是电脑刚下的那一步,专门用来触发动画,制造“正在落子”的假象。
CSS怎么假装有智商
核心CSS分两块:第一块负责隐藏所有未激活的局面,第二块通过:target把当前选中的局面显示出来。
/* 默认隐藏所有格子元素 */
.b .s {
display: none;
}
/* 当某个局面被锚点选中时,显示它里面的棋盘格 */
:target .s {
display: inline-block;
z-index: 1;
}
/* 玩家下的X */
:target .x {
display: inline-block;
background-image: url("data:image/svg+xml,..."); /* 省略具体编码 */
width: 100px;
height: 100px;
}注意一个细节:游戏刚开始时URL没有#xxx,没有:target被激活,那初始空棋盘怎么显示?用了一个阴间写法body:has(:target) #---------。:has()是CSS新出的伪类,意思是“如果body里面有被选中的:target,就把初始状态(id为---------的空棋盘)隐藏掉”。这样没点任何格子时显示空棋盘,点完第一下之后空棋盘就消失了,之后全靠:target来接管。
电脑下棋的动画效果靠circle的animation属性实现:
circle {
animation-name: draw;
animation-duration: 1s;
animation-fill-mode: forwards;
}
/* 只有最新一步才播放动画 */
circle.c {
animation-play-state: paused;
animation-delay: -1s;
}动画是从无到有画出一个圆圈,而加.c类的那一步会反向偏移延迟,相当于只显示画完后的最终状态。这样每次跳转到新状态,电脑刚下的那个O就会“唰”地画出来,而之前的历史O保持不动。
手把手复刻一个简易版
如果想自己整一个类似的玩具,不需要五千个状态,可以从最简单的“二步棋”开始练手。假设一个2×2的棋盘,先手玩家点一个格子,电脑立刻堵另一个格子,总共只有几个状态。
第一步:穷举所有局面
用纸笔或脚本列出所有可能的id编码。比如空棋盘id为#----,四个格子分别用位置0-3表示。玩家下在位置0后,局面编码变成#X---。电脑需要在剩余空位里选一个最优回应,比如堵在位置1,生成#XO--。每个局面都写成一个<div id="编码">。
第二步:生成链接和棋子
在#----这个容器里,四个格子位置分别放四个<a>,href指向#X---、#-X--等。注意每个<a>里面要有一个<div>作为点击区域,同时还要有代表棋子图形的元素(比如.x或.o)。但初始棋盘不能显示任何棋子,所以这些棋子元素默认隐藏,只有通过:target显示出来时才会出现。
第三步:写CSS控制显隐
/* 所有局面默认不可见 */
[id^=""] {
display: none;
position: absolute;
}
/* 被选中的局面显示 */
:target {
display: block;
}
/* 初始空棋盘,没有任何锚点时显示 */
body:not(:has(:target)) #---- {
display: block;
}注意:has()兼容性不太行,老浏览器可能会翻车。稳妥方案是用一个隐藏的radio或者checkbox来做初始显示切换,但那就又绕回老套路了。追求完美可以用JS补丁,不过既然是纯CSS整活,就默认用现代浏览器。
第四步:加一点动效让反馈更带感
给.x和.o加上淡入或缩放动画:
.x {
animation: pop 0.2s ease-out;
}
@keyframes pop {
0% { transform: scale(0); }
100% { transform: scale(1); }
}这样每点一下格子,棋子不是突然冒出来,而是“啪”地弹一下,体感舒服不少。
第五步:处理平局和终局
当所有格子下满或者没有空位可下时,需要显示“平局”或“赢家”信息。可以在终局局面里放一个固定文本,并且把所有的<a>(空格子链接)全部替换成普通<div>,让玩家无法再点。比如id为#XOXO的终局状态里,不生成任何href,只显示一行<span>平局了,老弟</span>。
为啥这路子比复选框骚
以前用checkbox或radio做游戏,得把input隐藏起来,再用label模拟点击,然后通过:checked选择器来切换状态。这招虽然经典,但有几个硬伤:首先,URL不会变化,没法分享战局;其次,浏览器的后退前进按钮完全废掉,想悔棋?门都没有。而:target方案直接利用了锚点跳转,每次下棋都变一次URL,这就解锁了两个神技:
- 存盘分享:打到一半把链接发给朋友,打开就是同样的棋盘状态,堪比云存档。
- 时间回溯:点浏览器后退按钮,棋盘会回到上一步,相当于内置了悔棋功能。如果再把前进按钮绑上,甚至能做“时间旅行”玩法,像《时空幻境》那样倒退重来。
另外从语义角度看,:target用的就是普通的<a>和<div>,没有隐藏input那种“为了让CSS生效而强行扭曲HTML”的感觉。屏幕阅读器至少能读出来这是个可点击的链接,比一堆aria-label硬补丁要体面那么一丢丢。当然离真正的无障碍还有距离,但至少方向对了。
跑一个最小可玩Demo
光说不练假把式,下面是一个只有9个状态的极简井字棋骨架,只演示玩家第一步和电脑堵中间的逻辑。完整版需要生成所有状态,但理解这个就能举一反三。
HTML部分:
<div id="start" class="board">
<a href="#move_x0" class="cell">左上</a>
<a href="#move_x1" class="cell">中上</a>
<a href="#move_x2" class="cell">右上</a>
<!-- 省略另外6个格子 -->
</div>
<div id="move_x0" class="board">
<!-- 玩家在左上下了X -->
<div class="piece x"></div>
<!-- 电脑自动在中间下O -->
<div class="piece o"></div>
<!-- 其余格子仍是可点链接 -->
<a href="#move_x0_o1" class="cell">中上</a>
<a href="#move_x0_o2" class="cell">右上</a>
<!-- 等等 -->
</div>CSS部分:
.board {
display: none;
}
:target, body:not(:has(:target)) #start {
display: grid;
grid-template-columns: repeat(3, 100px);
}
.piece {
width: 100px;
height: 100px;
}
.x {
background: radial-gradient(circle at 30% 30%, #000 8%, transparent 8%);
}
.o {
border-radius: 50%;
border: 4px solid #f00;
}这里用了:has()做初始显示,如果浏览器不认,可以改成用:target配合一个假的空锚点。比如页面加载后自动执行location.replace('#start'),但这需要一行JS。纯CSS硬扛的话,只能接受首屏显示空白或者用checkbox兜底。不过整活嘛,兼容性靠嘴炮。
代码跑起来后,点左上角格子,URL会变成xxx.html#move_x0,棋盘上立刻出现一个X,同时中间格子冒出一个O。再点中上格子,会跳到#move_x0_o1,电脑继续堵。整个过程没有任何JS,全靠CSS切换显示区块。这感觉就像在玩一本立体书,翻到哪页就看到哪页的内容,而书里的每一页都是提前写死的结局。
最后说句大实话,这个套路真正费劲的不是CSS,而是用脚本生成那几千个HTML片段。但只要生成一次,丢到服务器上就能永久使用,每个不同开局都对应一个唯一链接。下次再跟朋友吹牛说“我用CSS写了个AI”,直接把链接甩过去,保证对方一脸懵。
