CSS里藏了个AI,玩井字棋怎么总是输给样式表?

4,652字
20–30 分钟
in

听说过用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来接管。

电脑下棋的动画效果靠circleanimation属性实现:

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”,直接把链接甩过去,保证对方一脸懵。