如何使用纯 CSS 制作四子连珠游戏
序言:你是否想过单纯使用 CSS 也可以制作一款游戏?甚至可以双人对决!这是一篇非常有趣的文章,作者详细讲解了使用纯 CSS 制作四子连珠游戏的思路以及使用奇淫巧技解决困难问题的方法。因为案例本身比较复杂,而本人水平有限,翻译必有不恰当之处,欢迎留言评论。
原文:How the Roman Empire Made Pure CSS Connect 4 Possible
翻译:nzbin
实验是学习新技巧、思考新想法、并突破自身极限的有趣的方式。“纯 CSS”演示很早就有了,但是随着浏览器和CSS的发展,新的挑战又出现了。CSS 和 HTML 预处理器也促进了纯 CSS 演示的发展。有时候,预处理程序用于每个可能的硬编码场景,比如 :checked
的长字符串和相邻兄弟选择器。
在本文中,我将介绍使用纯 CSS 制作的四子连珠游戏的关键思想。在我的实验中,我尽量避免硬编码,并且不使用预处理器,专注于保持代码的简洁。以下是游戏的所有代码以及演示:
See the Pen Pure CSS Connect 4 by Bence Szabó (@finnhvman) on CodePen.
基本概念
我认为在“纯 CSS”类型中有一些概念是必不可少的。通常,表单元素用于管理状态和捕获用户操作。当我发现有人使用 <button type="reset">
重置或者重新开始新游戏时,我非常兴奋。只需要将元素包裹在 <form>
标签中并添加按钮。在我看来,这是一个比刷新页面更方便的解决方案。
第一步就是创建表单元素,再在表单中创建一些用作圆孔(the slots)的 input,然后添加重置按钮。以下是使用 <button type="reset">
的基本演示:
See the Pen Pure HTML Form Reset by Bence Szabó (@finnhvman) on CodePen.
为了让演示好看一些,我使用 radial-gradient()
,而不是在游戏板(the board)或者圆盘(the discs)上贴一张图片。我经常使用 Lea Verou 制作的 CSS3 图案库。它是使用渐变制作的图案集,而且很容易编辑。我使用了currentcolor,非常适合圆盘的图案。我添加了头部,并且复用了自己制作的纯 CSS 波纹按钮。
现在,布局和圆盘已经设计好了,只是还不能游戏
把圆盘放到游戏板上
接下来,需要让用户轮流将圆盘放到四子连珠的游戏板上。在四子连珠游戏中,玩家(一个红色,一个黄色)轮流将圆盘放置在面板的列中。游戏板有 7 列 6 行(一共有 42 个圆孔)。每一个圆孔可以为空或者被一个红色或黄色的圆盘占用。所以,一个圆孔可以有三种状态(空、红色或者黄色)。在同一列中掉落的圆盘会堆叠在一起。
首先我为每个圆孔放置了两个 checkbox 。当它们都没有被选中时,圆孔就被认为是空的,当其中一个被选中时,相应的玩家就会把他的圆盘放进去。
当其中任何一个被选中之后,应该把它隐藏起来,避免出现两者都被选中的状态。这些 checkbox 是直接的兄弟类,所以如果选中第一个之后,可以使用 :checked
伪类和相邻兄弟选择器(+
)来隐藏两个元素。但是如果选中第二个呢?你可以隐藏第二个,但是怎么才能影响第一个呢?可惜没有选择前一个的兄弟选择器,这不是 CSS 选择器的工作方式。我不得不拒绝这个想法。
实际上,一个 checkbox 本身可以有三个状态,可以使用 indeterminate
状态。问题是,仅仅使用 HTML 不能将其置于不确定状态。即使可以,当再次点击复选框时,它也会转换成选中状态。强迫第二个玩家在移动圆盘时进行双击是不现实的。
我仔细阅读了 MDN 上关于 :indeterminate
的文档后发现 radio input 通用都有 indeterminate 状态。名称相同的 radio 按钮在未选中时都处于这种状态。哇,这是一个真正的初始状态!真正有用的是,选中后一个同胞元素也会对前者产生影响!于是我在游戏板上放置了 42 对 radio input。
从以往的经历来看,使用 label ,并通过合理的顺序搭配 checkbox 或 radio 可以解决问题,但我认为 label 不能使代码更简洁。
为了获得更好的用户体验,我希望交互区域可以更大一些,所以合理的做法是让玩家点击一个列来移动圆盘。通过在合适的元素上添加绝对和相对位置,我将同一列的控件相互叠加。这样,在每一列中只能选择最下面的圆孔。我仔细地设置了每一行的圆盘下降的时间,它们的时间函数近似于一个二次曲线,与现实中的自由落体相似。到目前为止,游戏的各部分都做好了,但是下图清晰地显示出只有红色的玩家才能操作。
尽管已经设置了所有的控件,但只有红色的圆盘可以落在游戏板上
我用彩色且半透明的矩形对 Radio input 的可点击区域用进行了可视化显示。黄色和红色的 input 在每列上重叠 6 次(= 6 行),将最下面一行的红色的 input 放在顶部。红色和黄色的混合形成了橙黄色,可以在游戏板上看到。每一列中可用的圆孔越少,这种橙黄色就越不强烈,因为 radio input 只有在 :indeterminate
状态时才会显示。由于在每个圆孔上,红色 input 总是盖住黄色 input,所以只有红色的玩家能够移动。
轮流游戏
我只有一个模糊的想法,就是能不能使用普通的兄弟选择器解决玩家轮流游戏的问题。这个想法就是统计选中的 input 的数量,为偶数(0、2、4等)时红色玩家移动,为奇数时黄色玩家移动。很快我就意识到一般的兄弟选择器不能(也不应该!)按照我想要的方式工作。
还有一种方式就是使用 nth 选择器。尽管我喜欢使用even
和odd
这样的关键词,但我还是走进了死胡同。:nth-child 选择器 “统计”父类中的子元素,包括所有类型,类、伪类等等。:nth-of-type 选择器 “统计”在父类中某类型的子类,不包括类或伪类。所以问题就在于无法通过 :checked 状态去统计。
CSS counters 也可以统计,所以为什么不试试呢?计数器的一个常见用法是在文档中对标题(甚至多个级别)进行编号。它们由 CSS 规则控制,可以在任何时候被重置,其增加(或递减!)值可以是任意整数。计数器“counter()”函数显示在 content 属性中。
所以最简单的方法就是设置计数器,然后统计四子连珠游戏中 :checked
的 input 的数量。这种方法只有两个困难。首先,你不能在一个计数器上执行算术运算来检测它是偶数还是奇数。其次,你不能基于计数器的值在元素上应用 CSS 规则。
我使用二进制解决了第一个问题。计数器的初始值设为 0 。当红色玩家选中 radio 按钮时,计数器加 1。当黄色玩家选中 radio 按钮时,计数器就减 1,以此类推。因此,计数器的值始终是 0 或 1,偶数或奇数。
解决第二个问题需要更多的创造力(read: hack)。如上所述,计数器只能显示在 ::before
和 ::after
伪元素中。这是显而易见的,但它们如何影响其他元素呢?至少计数器值可以改变伪元素的宽度。不同的数有不同的宽度。字符 1
通常比 0
纤细,但这是很难控制的。如果改变的是字符的数量,而不是字符本身,那么由此产生的宽度变化就是可控的。在 CSS 计数器中使用罗马数字并不少见。用罗马数字表示的 1 和 2 与字符 1 和 2 是相同的,它们的像素宽度也是相同的。
我的想法是将一个玩家(黄色)的单选按钮贴着左边放置,并将另一个玩家(红色)的单选按钮贴着共享父容器的右边放置。最初,红色的按钮被覆盖在黄色的按钮上,然后容器的宽度变化会导致红色的按钮“消失”,显示黄色的按钮。可以将其比作现实中有两个窗格的滑动窗口,一个窗格是固定的(黄色按钮),另一个是可滑动的(红色按钮)。区别在于,在游戏中只有一半的窗口是可见的。
到目前为止,还不错,但我并不满意使用 font-size
(以及其他 font
属性)间接控制宽度。更好的方式是使用 letter-spacing
,因为它只在一个维度上改变了大小。出乎意料的是,即使是一个字母也有字母间距(在字母后面呈现),两个字母就有两个字母间距。可靠性的关键就是保证宽度是可预知的。宽度为 0 的字符加上单字母和双字母间距都可以,但是将 font-size
设置为 0 是存在风险的。为了兼容所有浏览器,可以将 letter-spacing
(以像素为单位)设置的大一些并且将 font-size
设置的小一点(1px
),是的,我说的是子像素。
我需要容器的宽度在初始大小(=w
)与至少两倍以上大小(>=2w
)之间交替变换,以便能够完全隐藏和显示黄色按钮。假设 v
是 'i' 字符的渲染宽度(小写罗马字母表示,在不同的浏览器中不同),c
是 letter-spacing
的渲染宽度(常量)。我需要 v + c = w
为真,但这是不可能的,因为 c
和 w
是整数,而 v
是非整数。最后我使用了 min-width
和 max-width
属性来约束可能的宽度值,因此我还将可能的计数器值更改为 'i' 和 'iii' ,以确保文本在流下变宽并溢出约束。通过方程 v + c < w
, 3v + 3c > 2w
,,v << c
,可以得到2/3w < c < w
。结论就是“字母间距”必须比初始宽度小一些。
我一直以为伪元素显示的计数值是 radio 按钮的父元素,可惜不是。但是,我注意到伪元素的宽度改变了其父元素的宽度,在本例中父元素是 radio 按钮的容器。
如果你在想,难道不能用阿拉伯数字来解决吗?你说得对,计数器的值在 '1' 和 '111' 之间交替变换也是可以的。尽管如此,罗马数字最先给了我启示,它们也是点击器标题的不错的方式,所以我保留了它们。
从红色玩家开始,然后轮流游戏
应用所讨论的技术使 radio input 的父容器在选中红色 input 时宽度加倍,在选中黄色 input 宽度变为原来的宽度。在原始宽度的容器中,红色 input 位于黄色 input 之上,而在双宽度容器中,红色 input 被移开。
识别模式
在现实生活中,四子连珠游戏并不会告诉你是赢了还是输了,但是提供适当的反馈是任何软件良好用户体验的一部分。下一个目标是检测玩家是否赢得了游戏。要想赢得比赛,玩家必须在一列、一行或对角线上放四个圆盘。在许多编程语言中,这是一个非常简单的任务,但是在纯 CSS 世界中,这是一个巨大的挑战。将它分解成子任务是系统地处理这个问题的方法。
我使用一个 flex 容器作为 radio 按钮和圆盘的父类。一个黄色的 radio 按钮、一个红色的 radio 按钮和一个代表圆盘并与圆孔重叠的 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]) 或者 column combinators 会派得上用场.
为了更好的语义化,可以为每个列添加一个新的 div
,并在其中排列圆孔元素。这一修改也将消除上述检测错误的情况。然后,检测一行中的有四子相连可以用以下方法:选择第一个红色 radio input 被选中的一个列,然后再选择第一个红色 radio input 被选中的相邻同胞列,重复两次。这听起来很麻烦,需要"parent"选择器。
选择父节点是不可行的,但是选择子节点是可行的。如何用选择器及其组合方式检测一行中的四子相连? 选择一个列,再选择它的第一个被选中的红色 radio input,然后选择相邻的列,再选择它的第一个被选中的红色 radio input ,以此类推,再重复两次。这听起来仍然很麻烦,但却是可行的。诀窍不仅在 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
时是 invalid ,否则是 valid 。因此,我为每个 input 添加了 required
属性,然后在表单上使用 :valid
伪类来检测平局。
当游戏板被填满时会显示平局的信息。
检测平局结果出现了一个 bug。在极少数的情况下会出现黄色玩家最终胜利的情况,胜利和平局的消息都显示出来了。这是因为这些结果的检测和显示方法是正交的。我解决了这个问题,确保获胜消息有一个白色的背景,并在平局消息之上。还必须延迟平局消息的过渡,这样它就不会与获胜消息混合出现了。
黄方胜利的信息盖住了平局结果
虽然许多单选按钮是通过绝对定位隐藏在彼此后面的,但是所有处于不确定状态的按钮仍然可以通过 tab 键来访问。这使得玩家可以将他们的圆盘放入任意的圆孔中。处理这个问题的一种方法是简单地禁止使用 tabindex
属性进行键盘交互:将其设置为 -1
意味着不应该通过连续的键盘导航来访问它。为了解决这个问题,必须在每个单选按钮上添加这一属性。
<input type="radio" name="slot11" tabindex="-1" required>
<input type="radio" name="slot11" tabindex="-1" required>
<div class="disc"></div>
...
限制
最实质性的缺点是,由于轮流游戏的解决方案不可靠,游戏板没有响应,并且可能在小的视图窗口上出现故障。我不敢冒险重构响应式的解决方案,由于实现的本质,硬编码看起来更安全。
另一个问题是触摸设备上的 sticky hover 。在正确的位置添加一些媒体查询是解决这个问题最简单的方法,但是这会消除自由落体动画。
有人可能认为 :indeterminate
伪类已经得到了广泛的支持,事实的确如此。问题是它只在一些浏览器中得到部分支持。注意[兼容性表](http://caniuse.com/ feat=css-indeterminate-pseudo)中的注释1:MS IE 和 Edge 在单选按钮上不支持它。如果您在这些浏览器中查看演示程序,您的光标将变成 not-allowed
的光标,这是无意的,但有点优雅的降级。
不是所有浏览器都支持 radio 按钮的 :indeterminate 属性。
总结
感谢阅读到最后一部分!让我们看看这个游戏的一些数据:
-
140 个 HTML 元素
-
350 行 (合理地) CSS
-
0 行 JavaScript
-
0 个外部资源
总的来说,我对结果很满意,反馈也很好。做这个演示我确实学到了很多,我希望可以分享更多这样的文章!
感谢您的阅读,如果您对我的文章感兴趣,可以关注我的博客,我是叙帝利,下篇文章再见!
开发低代码平台的必备拖拽库 https://github.com/ng-dnd/ng-dnd
低代码平台必备轻量级 GUI 库 https://github.com/acrodata/gui
适用于 Angular 的 CodeMirror 6 组件 https://github.com/acrodata/code-editor
基于 Angular Material 的中后台管理框架 https://github.com/ng-matero/ng-matero
Angular Material Extensions 扩展组件库 https://github.com/ng-matero/extensions
Unslider 轮播图插件纯 JS 实现 https://github.com/nzbin/unsliderjs
仿 Windows 照片查看器插件 https://github.com/nzbin/photoviewer
仿 Windows 照片查看器插件 jQuery 版 https://github.com/nzbin/magnify
完美替代 jQuery 的模块化 DOM 库 https://github.com/nzbin/domq
简化类名的轻量级 CSS 框架 https://github.com/nzbin/snack
与任意 UI 框架搭配使用的通用辅助类 https://github.com/nzbin/snack-helper
单元素纯 CSS 加载动画 https://github.com/nzbin/three-dots
有趣的 jQuery 卡片抽奖插件 https://github.com/nzbin/CardShow
悬疑科幻电影推荐 https://github.com/nzbin/movie-gallery
锻炼记忆力的小程序 https://github.com/nzbin/memory-stake