罗马帝国如何让纯 CSS 实现井字棋

Avatar of Bence Szabó
Bence Szabó 发布

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

实验是学习最新技巧、思考新想法和突破极限的有趣借口。“纯 CSS” 演示已经出现了一段时间,但随着浏览器和 CSS 本身的演进,新的机会也随之出现。 CSS 和 HTML 预处理器也帮助推动了这一场景的发展。 有时,预处理器用于对所有可能的情况进行硬编码,例如,长的 :checked 和相邻兄弟选择器字符串。

在本文中,我将介绍我构建的纯 CSS 井字棋游戏的关键思想。 在我的实验中,我尽量避免硬编码,并在没有预处理器的情况下进行工作,以专注于保持生成的代码简洁。 您可以在此处查看所有代码和游戏

查看 CodePen 上 Bence Szabó (@finnhvman) 编写的 纯 CSS 井字棋

基本概念

我认为在“纯 CSS”类型中有一些概念被认为是必不可少的。 通常,表单元素用于管理状态和捕获用户操作。 当我发现人们使用 <button type="reset"> 重置或开始新游戏时,我感到很兴奋。 您只需将元素包装在 <form> 标签中并添加按钮即可。 我认为这比必须刷新页面要干净得多。

我的第一步是创建一个表单元素,然后向其中添加一堆输入作为棋盘格,并添加重置按钮。 这是一个关于 <button type="reset"> 操作的非常基本的演示

查看 CodePen 上 Bence Szabó (@finnhvman) 编写的 纯 HTML 表单重置

我希望为这个演示提供一个不错的视觉效果,以提供完整的体验。 我没有引入外部棋盘或棋子的图像,而是使用了 radial-gradient()。 我经常使用的一个不错的资源是 Lea Verou 的 CSS3 图案画廊。 它是由渐变组成的图案集合,并且也可以编辑! 我使用了 currentcolor,它对棋子图案非常有用。 我添加了一个标题并重新使用了我的 纯 CSS 波纹按钮

此时布局和棋子设计已经完成,只是游戏根本无法运行

将棋子放到棋盘上

接下来,我允许用户轮流将棋子放到井字棋棋盘上。 在井字棋中,玩家(一个红色,一个黄色)轮流将棋子放入列中。 共有 7 列和 6 行(42 个棋盘格)。 每个棋盘格可以为空或被红色或黄色棋子占用。 因此,一个棋盘格可以有三种状态(空、红色或黄色)。 放入同一列的棋子会堆叠在一起。

我首先为每个棋盘格放置两个复选框。 当它们都未选中时,该棋盘格被认为为空,当其中一个被选中时,相应的玩家在其上放置棋子。

应避免同时选中它们的状态,一旦选中其中一个,就隐藏它们。 这些复选框是直接的兄弟元素,因此当第一对复选框被选中时,您可以使用 :checked 伪类和相邻兄弟组合符(+)隐藏两者。 如果第二个被选中呢? 您可以隐藏第二个,但如何影响第一个呢? 好吧,没有前一个兄弟选择器,这根本不是 CSS 选择器的工作方式。 我不得不放弃这个想法。

实际上,复选框本身可以有三种状态,它可以处于 indeterminate 状态。 问题是您无法仅使用 HTML 将其置于不确定状态。 即使可以,下次单击复选框也会始终使其转换为选中状态。 强制第二个玩家在移动时双击是不可靠且不可接受的。

我陷入了 :indeterminate 的 MDN 文档中,并注意到单选按钮也具有不确定状态。 当所有单选按钮都未选中时,具有相同名称的单选按钮处于此状态。 哇,这实际上是一个初始状态! 真正有益的是,选中后一个兄弟元素也会影响前一个! 因此,我用 42 对单选按钮填充了棋盘。

回想起来,巧妙地排序和使用带有复选框或单选按钮的标签可以实现这一技巧,但我没有考虑将标签作为选项,以使代码更简单和更短。

我希望有较大的交互区域以获得良好的用户体验,因此我认为让玩家通过点击列来进行移动是合理的。 我通过向相应的元素添加绝对和相对定位,将同一列的控件彼此堆叠。 这样,在一列中只能选择最下面的空棋盘格。 我精心设置了每行棋子下落过渡的时间,并且它们的计时函数近似于二次曲线,以模拟真实的自由落体。 到目前为止,这些拼图碎片组合得很好,尽管下面的动画清楚地表明只有红色玩家可以进行移动。

即使所有控件都在那里,也只有红色棋子可以放到棋盘上

单选按钮的可点击区域用彩色但半透明的矩形可视化。 黄色和红色输入在每列中彼此堆叠六次(=六行),并将最下面一行的红色输入放在堆栈的顶部。 红色和黄色的混合产生了在开始时可以在棋盘上看到的橙色。 由于在一列中可用的空棋盘格越少,这种橙色就会变得越不强烈,因为一旦单选按钮不再为 :indeterminate,它们就不会显示。 由于红色输入始终精确地位于每列黄色输入之上,因此只有红色玩家能够进行移动。

跟踪回合

我只有一个模糊的想法和很多希望,即我可以通过通用兄弟选择器以某种方式解决两个玩家之间切换回合的问题。 这个概念是让红色玩家在选中输入的数量为偶数(0、2、4 等)时轮流,让黄色玩家在该数量为奇数时轮流。 我很快意识到通用兄弟选择器并不能(也不应该!)按照我想要的方式工作。

然后一个非常明显的选择是尝试使用 nth 选择器。 然而,尽管使用 evenodd 关键字很有吸引力,但我还是走进了死胡同。:nth-child 选择器“计算”父元素内的子元素,无论类型、类、伪类是什么。:nth-of-type 选择器“计算”父元素内某种类型的子元素,无论类或伪类是什么。 所以问题在于它们无法根据 :checked 状态进行计数。

好吧,CSS 计数器 也会计数,所以为什么不试一试呢? 计数器的常见用法是在文档中对标题(甚至在多个级别中)进行编号。 它们由 CSS 规则控制,可以在任何时候任意重置,并且它们的增量(或减量!)值可以是任何整数。 计数器由 counter() 函数在 content 属性中显示。

最简单的步骤是设置一个计数器并计算井字棋网格中 :checked 的输入。 这种方法只有两个困难。 第一个是你无法对计数器执行算术运算来检测它是否为偶数或奇数。 第二个是,你无法根据计数器值将 CSS 规则应用于元素。

我设法通过使计数器二进制来克服第一个问题。 计数器的值最初为零。 当红色玩家选中他们的单选按钮时,计数器加 1。 当黄色玩家选中他们的单选按钮时,计数器减 1,依此类推。 因此,计数器的值将为 0 或 1,即偶数或奇数。

解决第二个问题需要更多的创造力(阅读:黑客)。 如上所述,计数器可以显示,但只能在 ::before::after 伪元素中。 这是显而易见的,但是它们如何影响其他元素呢? 至少计数器的值可以改变伪元素的宽度。 不同的数字具有不同的宽度。 字符 1 通常比 0 更细,但这非常难以控制。 如果字符的数量发生变化而不是字符本身发生变化,则生成的宽度变化更容易控制。 使用罗马数字作为 CSS 计数器并不少见。 罗马数字中表示的一和二在字符上相同,并且在像素宽度上也相同。

我的想法是将一个玩家(黄色)的单选按钮附加到左侧,并将另一个玩家(红色)的单选按钮附加到他们共享的父容器的右侧。 最初,红色按钮覆盖在黄色按钮上,然后容器的宽度变化会导致红色按钮“消失”并显示黄色按钮。 一个类似的现实世界概念是带有两个窗格的滑动窗口,一个窗格是固定的(黄色按钮),另一个窗格(红色按钮)可以在另一个窗格上滑动。 区别在于,在游戏中,只有窗口的一半可见。

到目前为止,一切顺利,但我仍然对font-size(以及其他font属性)间接控制宽度不满意。我认为letter-spacing非常适合这里,因为它只在一个维度上增加尺寸。出乎意料的是,即使是一个字母也有字间距(在字母渲染后渲染),两个字母会渲染两次字间距。可预测的宽度对于保证可靠性至关重要。零宽度字符以及单字符和双字符间距可以解决问题,但将font-size设置为零是危险的。定义较大的letter-spacing(以像素为单位)和微小的(1pxfont-size使其在所有浏览器中几乎保持一致,是的,我指的是亚像素。

我需要容器宽度在初始大小(=w)和至少初始大小的两倍(>=2w)之间交替变化,以便能够完全隐藏和显示黄色按钮。假设v是“i”字符的渲染宽度(小写罗马表示法,在不同浏览器中有所不同),cletter-spacing的渲染宽度(常数)。我需要v + c = w为真,但它不可能为真,因为cw是整数,但v是非整数。我最终使用了min-widthmax-width属性来约束可能的宽度值,因此我还将可能的计数器值更改为“i”和“iii”,以确保文本宽度低于和超过约束。在方程中,这看起来像v + c < w3v + 3c > 2wv << c,这给出了2/3w < c < w。结论是,letter-spacing必须略小于初始宽度。

到目前为止,我的推理都是假设显示计数器值的伪元素是单选按钮的父元素,但事实并非如此。但是,我注意到伪元素的宽度会改变其父元素的宽度,在本例中,父元素是单选按钮的容器。

如果你在想,这能不能用阿拉伯数字解决?你说得对,在“1”和“111”之间交替切换计数器值也可以。然而,罗马数字首先给了我这个想法,它们也是标题吸引眼球的一个好借口,所以我保留了它们。

玩家轮流进行游戏,红色玩家先开始。

应用上面讨论的技术,当选中红色输入时,单选输入的父容器宽度会加倍,当选中黄色输入时,宽度会恢复到原始宽度。在原始宽度容器中,红色输入覆盖在黄色输入上,但在双宽度容器中,红色输入被移开了。

识别模式

在现实生活中,四子棋棋盘不会告诉你是否赢了或输了,但在任何软件中,提供适当的反馈都是良好用户体验的一部分。下一个目标是检测玩家是否赢得了游戏。要赢得游戏,玩家必须在同一列、行或对角线上拥有四个棋子。在许多编程语言中,这是一个非常简单的任务,但在纯 CSS 世界中,这是一个巨大的挑战。将其分解成子任务是系统地解决此问题的方法。

我使用了一个弹性容器作为单选按钮和棋子的父元素。一个黄色单选按钮、一个红色单选按钮和一个棋子div属于一个插槽。这样的插槽重复了42次,并以列的形式排列,并进行换行。因此,同一列中的插槽是相邻的,这使得使用相邻选择器识别同一列中的四个棋子成为最简单的一部分。

<div class="grid">
  <input type="radio" name="slot11">
  <input type="radio" name="slot11">
  <div class="disc"></div>
  <input type="radio" name="slot12">
  <input type="radio" name="slot12">
  <div class="disc"></div>
  ...
  <input type="radio" name="slot16">
  <input type="radio" name="slot16">
  <div class="disc"></div>

  <input type="radio" name="slot21">
  <input type="radio" name="slot21">
  <div class="disc"></div>
  ...
</div>
/* Red four in a column selector */
input:checked + .disc + input + input:checked + .disc + input + input:checked + .disc + input + input:checked ~ .outcome

/* Yellow four in a column selector */
input:checked + input + .disc + input:checked + input + .disc + input:checked + input + .disc + input:checked ~ .outcome

这是一个简单但丑陋的解决方案。每个玩家有11个类型和类选择器链接在一起,以涵盖同一列中四个棋子的情况。在插槽元素之后添加一个具有.outcome类的div,可以有条件地显示结果消息。在列换行的情况下,还存在错误检测同一列中四个棋子的问题,但我们先把这个问题放在一边。

对于检测同一行中四个棋子的类似方法,将是一个非常糟糕的主意。每个玩家将有56个选择器链接在一起(如果我的计算正确),更不用说它们也会有类似的错误检测缺陷。在这种情况下,:nth-child(An+B [of S])列组合器将来会派上用场。

为了更好的语义,可以为每一列添加一个新的div,并将插槽元素排列在其中。此修改还将消除上面提到的错误检测的可能性。然后检测同一行中的四个棋子可以这样进行:选择第一行红色单选输入被选中的列,然后选择第一行红色单选输入被选中的相邻同级列,依此类推再选择两次。这听起来非常麻烦,并且需要“父”选择器

选择父元素是不可行的,但选择子元素是可行的。使用可用的组合器和选择器,如何检测同一行中的四个棋子?选择一列,然后选择其第一个红色单选输入(如果选中),然后选择相邻列,然后选择其第一个红色单选输入(如果选中),依此类推再选择两次。这听起来仍然很麻烦,但却是可能的。技巧不仅在于 CSS,还在于 HTML,下一列必须是前一列中单选按钮的同级元素,从而创建一个嵌套结构。

<div class="grid column">
  <input type="radio" name="slot11">
  <input type="radio" name="slot11">
  <div class="disc"></div>
  ...
  <input type="radio" name="slot16">
  <input type="radio" name="slot16">
  <div class="disc"></div>

  <div class="column">
    <input type="radio" name="slot21">
    <input type="radio" name="slot21">
    <div class="disc"></div>
    ...
    <input type="radio" name="slot26">
    <input type="radio" name="slot26">
    <div class="disc"></div>

    <div class="column">
      ...
    </div>
  </div>
</div>
/* Red four in a row selectors */
input:nth-of-type(2):checked ~ .column > input:nth-of-type(2):checked ~ .column > input:nth-of-type(2):checked ~ .column > input:nth-of-type(2):checked ~ .column::after,
input:nth-of-type(4):checked ~ .column > input:nth-of-type(4):checked ~ .column > input:nth-of-type(4):checked ~ .column > input:nth-of-type(4):checked ~ .column::after,
...
input:nth-of-type(12):checked ~ .column > input:nth-of-type(12):checked ~ .column > input:nth-of-type(12):checked ~ .column > input:nth-of-type(12):checked ~ .column::after

好吧,语义搞砸了,这些选择器仅适用于红色玩家(另一轮是黄色玩家),另一方面,它确实有效。一个小好处是没有错误检测的列或行。结果的显示机制也必须修改,使用任何匹配列的::after伪元素,在应用适当的样式时是一个一致的解决方案。因此,必须在最后一个插槽之后添加一个假的第八列。

如上所示的代码片段,列中的特定位置与检测同一行中的四个棋子相匹配。通过调整这些位置,完全相同的技术可用于检测对角线上的四个棋子。请注意,对角线有两个方向。

input:nth-of-type(2):checked ~ .column > input:nth-of-type(4):checked ~ .column > input:nth-of-type(6):checked ~ .column > input:nth-of-type(8):checked ~ .column::after,
input:nth-of-type(4):checked ~ .column > input:nth-of-type(6):checked ~ .column > input:nth-of-type(8):checked ~ .column > input:nth-of-type(10):checked ~ .column::after,
...
input:nth-of-type(12):checked ~ .column > input:nth-of-type(10):checked ~ .column > input:nth-of-type(8):checked ~ .column > input:nth-of-type(6):checked ~ .column::after

在最终运行中,选择器的数量大大增加了,这绝对是一个 CSS 预处理器可以减少声明长度的地方。尽管如此,我认为演示仍然适度简短。它应该位于从为每个可能的获胜模式硬编码选择器到使用 4 个神奇选择器(列、行、两个对角线)的规模的中间位置。

当玩家获胜时,会显示一条消息。

关闭漏洞

任何软件都有边缘情况,需要处理。四子棋游戏的可能结果不仅是红色或黄色玩家获胜,还有两个玩家都没有获胜,棋盘填满称为平局。从技术上讲,这种情况不会破坏游戏或产生任何错误,缺少的是对玩家的反馈。

目标是检测棋盘上是否有 42 个:checked单选按钮。这也意味着它们都不处于:indeterminate状态。也就是说要求对每个单选组进行选择。当单选按钮处于:indeterminate状态时,它们是无效的,否则它们是有效的。因此,我为每个输入添加了required属性,然后在表单上使用了:valid伪类来检测平局。

当棋盘填满时,会显示平局结果消息。

覆盖平局结果引入了一个错误。在黄色玩家在最后一轮获胜的极少数情况下,会同时显示获胜和平局消息。这是因为这些结果的检测和显示方法是正交的。我通过确保获胜消息具有白色背景并覆盖在平局消息上解决了这个问题。我还必须延迟平局消息的淡入过渡,这样它就不会与获胜消息的过渡混合。

黄色获胜消息覆盖在平局结果上,阻止其显示。

虽然许多单选按钮通过绝对定位彼此隐藏,但所有处于不确定状态的单选按钮仍然可以通过按 Tab 键遍历控件来访问。这使玩家能够将棋子放到任意插槽中。处理此问题的一种方法是简单地通过tabindex属性禁止键盘交互:将其设置为-1表示它不应通过顺序键盘导航访问。我必须为每个单选输入添加此属性以消除此漏洞。

<input type="radio" name="slot11" tabindex="-1" required>
<input type="radio" name="slot11" tabindex="-1" required>
<div class="disc"></div>
...

局限性

最主要的缺点是棋盘没有响应性,并且由于跟踪回合的不可靠解决方案,它可能在小型视口中出现故障。由于实现的性质,我不敢冒险重构为响应式解决方案,使用硬编码尺寸感觉安全得多。

另一个问题是触摸设备上的粘性悬停。在正确的位置添加一些交互媒体查询是解决此问题的最简单方法,尽管它会消除自由落体动画。

有人可能会认为:indeterminate伪类已经得到广泛支持,确实如此。问题在于它在某些浏览器中仅部分支持。请查看兼容性表中的注释 1:MS IE 和 Edge 不支持单选按钮上的它。如果您在这些浏览器中查看演示,则光标将在棋盘上变成not-allowed光标,这是一个意外但有点优雅的降级。

并非所有浏览器都支持单选按钮上的:indeterminate。

结论

感谢你看到最后一节!让我们看看一些数字。

  • 140 个 HTML 元素
  • 350 行(合理的)CSS
  • 0 个 JavaScript
  • 0 个外部资源

总的来说,我对结果和反馈感到满意。在制作这个演示的过程中,我确实学到了很多东西,我希望我能够通过撰写这篇文章分享很多东西!