第一次看到“纯CSS贪吃蛇”这个标题的时候,我脑子里蹦出来的第一个词就是“反语文”。CSS这东西,平时我们拿它画个按钮、排个版、做点交互动画,就已经觉得它挺能打了。结果有人告诉我,能用它写出一整个贪吃蛇游戏,而且是“无JavaScript实现完整逻辑”——移动、转向、吃食物、计分、判定结束,全部靠CSS自己扛下来。这已经不是技术方案,这是行为艺术。
但这个项目最有意思的地方在于:它逼着你把CSS的底层能力从头审视一遍。自定义属性到底能存什么?@property注册之后变量为什么就有了“动画能力”?radio按钮加:checked伪类,为什么能伪装成一个状态机?我干脆把这套玩法完整拆一遍,顺便把我踩过的坑也摆出来,免得你重新在同一个坑里翻滚。
1. 这个项目到底在挑战什么:CSS的“禁区”与突破路径
不是所有游戏都适合用来挑战CSS的极限。井字棋能写,扫雷能写,甚至俄罗斯方块也有人尝试过,但贪吃蛇的难度是另一档。为什么?
1.1 为什么说CSS写游戏是反语文的
CSS本质上是一门“描述样式”的语言,它的工作模型是:你给我一个元素,我告诉你这个元素长什么样、在什么状态下长什么样。它没有循环、没有变量计算、没有条件分支,甚至连“顺序执行”这个概念都不存在。写CSS的时候你是在声明一组规则,而不是在写一串指令。
JS写游戏、写逻辑,那是开着跑车在高速上飙;用CSS写游戏,相当于骑着自行车上高速,还要被交警拦下来问“你确定你有驾照吗”。但偏偏就有人非要在自行车上装引擎。
纯CSS游戏能成立,靠的无非是几套“民间Hack”:
- 用checkbox或radio的checked状态充当“记忆单元”
- 用CSS自定义属性充当“变量”
- 用@property + @keyframes让变量拥有动画插值的可能
- 用CSS计数器counter充当“累加器”
把这几个工具组合起来,理论上就能在样式表里构建出一个极其简陋但能跑的状态机。这个项目真正的价值,就是把这些冷门机制全部压榨一遍。
1.2 贪吃蛇比井字棋难在哪里
井字棋本质上只有两个状态:X轮到谁下、棋盘上哪些格子被占了。用checkbox + :checked完全够用,因为每一步都是用户主动触发,事件模型天然匹配。扫雷也是类似的逻辑,点击格子,显示地雷或数字,本质上是“静态状态的展示”。
贪吃蛇完全不同,它有两个致命需求:
第一,蛇必须自己动。游戏不能等着玩家点一下才挪一格,蛇要持续前进。这要求CSS里有“时间循环”的概念,而整个CSS里唯一能提供时间循环的东西就是animation,但animation的行为是预设死的,它不会因为你中途改变了某个变量的值就重新规划路径。
第二,蛇身必须跟随蛇头。蛇头走出一条路径,蛇身每一节都要沿着这条路径的“历史轨迹”前进。这玩意儿在JS里就是一个数组,每次移动头部,尾部跟着删一格,头插一格。数组操作简单,但CSS没有数组,只有一堆DOM节点。你让第五节蛇身“知道”蛇头两秒前在哪个位置,这需要记忆能力,而CSS的变量本身不具备跨时间的“历史记录”功能。
这两道坎,是纯CSS贪吃蛇真正的难点。
2. 只用CSS构建游戏的四块基石
既然要用CSS实现逻辑,就必须先把基础零件准备好。这四样东西,是整个游戏的骨架,缺一个都玩不转。
2.1 自定义属性:把状态塞进变量
CSS自定义属性,也就是--x、--y这类变量,可能是整个方案里最不稀奇的一个。它本身不能做复杂计算,但能被calc()调用,能被继承,能被animation动态修改。
比如你可以声明一个蛇头坐标:
:root { --x: 0; --y: 0; }然后把蛇头摆到网格的对应位置:
.snake-head { grid-column: calc(var(--x) + 1); grid-row: calc(var(--y) + 1); }这个写法看起来很简单,但它打通了“变量”到“布局”的通道。只要某个机制能把--x和--y的值改掉,蛇头的位置就会跟着变。这就为后面的动画驱动打下了基础。
但传统的自定义属性有个致命的局限:它不支持transition(过渡)。你给--x写transition: 1s,浏览器根本不理你,因为CSS变量在默认情况下不属于“可过渡属性”。除非你用@property把它注册成一个“真正的属性”,这正好引出了第二个基石。
2.2 @property:让变量拥有“渐变”能力
@property是CSS Houdini的一部分,作用是把一个自定义属性注册成可以被浏览器识别、参与动画插值的正式属性。语法大概长这样:
@property --x { syntax: "<integer>"; inherits: false; initial-value: 0; }注册之后,--x就是一个“合法的、可动画的整型变量”,你给它写transition或animation,它才会真的老实渐变。
这玩意儿对纯CSS游戏来说是分水岭。没有@property,你的变量只能“啪”一下跳变,有它之后,变量可以在动画的每个关键帧之间平滑变化。尤其是配上steps()这样的步进函数,你可以让变量像JS里的计数器一样,一格一格地跳:
.snake-head { transition: --x 0.2s steps(1, end); }你变更--x的值时,蛇头会以步进的方式跳着移动,而不是平滑滑动。这个“离散步进”的效果,正是贪吃蛇需要的运动视觉:一格一格地走,而不是滑过去。
2.3 radio Hack:最朴素的输入系统
纯CSS游戏没有addEventListener,没有onclick,那怎么接收玩家的操作?答案是input的radio或checkbox。
原理很简单:把一个<input type="radio">藏在label里,用户点击label,等于选中了那个radio,然后:checked伪类就会生效,你可以基于它改变页面样式,甚至改变刚才提到的自定义属性。
方向键可以做成四个radio:
<input type="radio" name="dir" id="dir-up" class="dir-up"> <input type="radio" name="dir" id="dir-down" class="dir-down"> <input type="radio" name="dir" id="dir-left" class="dir-left"> <input type="radio" name="dir" id="dir-right" class="dir-right" checked>然后在CSS里:
#dir-right:checked ~ .snake-head { --x: calc(var(--x) + 1); }这个写法把“玩家按了右键”翻译成了“蛇头横坐标加一”。虽然它只能算是个半吊子的输入系统——它不监听键盘,而是监听点击/触摸——但配合上label和隐藏input,至少在触屏上体验是能接受的。
关键提醒:radio的同名分组决定了“只能有一个方向被选中”。当玩家按下新的方向键时,旧的radio会被自动取消,所以状态永远是单一的,不会出现蛇同时朝两个方向跑的混乱。
2.4 计数器:用CSS原生方式记分
计分系统用JS处理就是一句话score++,但在CSS里你需要动用counter。
CSS计数器本身是一个很老的功能,通常用在“自动编号列表”上。比如有序列表每多一个li,counter就自动加一。纯CSS游戏里,计数器的最大用途是统计“有多少个条件被触发”。
比如游戏里有5个食物,每个食物被吃掉时有一个隐藏的checkbox变成checked状态,那就可以用一个计数器统计总分:
.game-board { counter-reset: score 0; } #food1:checked ~ .game-board { counter-increment: score 1; } .score::after { content: counter(score); }这里面的思路是:把“事件的次数”编码成“被勾选的checkbox数量”。每一个触发条件都对应一个可被勾选的input,计数器只负责数“被勾选了几个”。这是CSS里最接近score++的实现方式。
3. 动手实现:一条蛇第一次在CSS里动起来
理论知识说完了,现在开始实操。我这里直接给出一套可落地的方案,虽然它离“完美复刻JS贪吃蛇”还有距离,但它能在纯CSS框架下完成核心闭环:蛇会动、玩家能转向、能吃食物、能计分、能结束。
3.1 游戏场与蛇的骨架搭建
先搭一个8×8的网格场地,用CSS Grid实现:
<div class="game"> <input type="radio" name="food" id="food-1"> <input type="radio" name="food" id="food-2"> <input type="radio" name="food" id="food-3"> <div class="board"> <!-- 8x8 = 64个grid-cell --> </div> <div class="snake"> <span class="snake-body" style="--i: 0"></span> <span class="snake-body" style="--i: 1"></span> <span class="snake-body" style="--i: 2"></span> <span class="snake-head"></span> </div> </div>网格区域固定尺寸,蛇身和蛇头分开处理。蛇头负责承载“当前坐标”,蛇身每一节通过--i索引做错位跟随。
CSS部分:
.board { display: grid; grid-template-columns: repeat(8, 40px); grid-template-rows: repeat(8, 40px); position: relative; } .snake-head { position: absolute; width: 40px; height: 40px; left: calc(var(--x) * 40px); top: calc(var(--y) * 40px); background: #2e7d32; z-index: 2; transition: left 0.2s steps(1, end), top 0.2s steps(1, end); }蛇头的坐标由--x和--y决定,改动这两个变量,蛇头就一格一格地移动。
3.2 四方向移动的实现与转向坑
四个方向的radio控制蛇头坐标增量,方向变量用一个--dir来区分:
#dir-right:checked ~ .game .snake-head { --x: calc(var(--x) + 1); } #dir-left:checked ~ .game .snake-head { --x: calc(var(--x) - 1); } #dir-up:checked ~ .game .snake-head { --y: calc(var(--y) - 1); } #dir-down:checked ~ .game .snake-head { --y: calc(var(--y) + 1); }这里踩进去的第一个坑是:只改变量,不重启动画,蛇会在原地不动却记录了一个“未来位置”。
因为transition驱动的是“值变化”,而如果你把radio从一个方向切到另一个方向,--x的变化确实会让蛇头移动。但是当你再按下一个方向时,上一次的radio被置为未选中,它的样式规则就失效了,--x又会被跳过。我早期实现时,蛇头就像个精神分裂患者,一会儿跳一格,一会儿回退一格。
解决办法是给蛇头加上方向状态绑定:
.snake-head { --dir-x: 1; --dir-y: 0; } #dir-up:checked ~ .game .snake-head { --dir-x: 0; --dir-y: -1; } #dir-down:checked ~ .game .snake-head { --dir-x: 0; --dir-y: 1; } #dir-left:checked ~ .game .snake-head { --dir-x: -1; --dir-y: 0; } #dir-right:checked ~ .game .snake-head { --dir-x: 1; --dir-y: 0; }移动则统一由“每步自动推进”的机制触发,而不是由radio直接改动坐标。这样才能保证方向与移动解耦。自动推进用animation或者定时变化的变量来做,后面会单独说。
3.3 蛇身跟随:delay不是跟随,路径才是
蛇身跟随是整个项目里最“骗人”的实现。新手第一反应是给每一节蛇身设置不同的animation-delay,让它们延迟播放蛇头的动画。这个想法方向对,但细节坑得很。
如果你让蛇头播放“从(0,0)到(7,0)再到(0,7)”这样的路径动画,然后给第1节身体延迟0.2秒播放同一动画,第2节延迟0.4秒,确实会形成“一条蛇”的视觉。但问题在于:蛇头转向时,蛇身每一节还在沿着旧路径前进,蛇身会直接飞出去。因为蛇身根本不了解蛇头“新选择的路径”是什么。
在纯CSS框架下,最务实的做法是:蛇身不跟随蛇头的实时动画,而是跟随一条预设路径上的固定节点。
比如蛇在6×6的内圈路线里绕圈,路径上的每个关键坐标都预先写在CSS里。蛇身第--i节,它的坐标永远等于“蛇头路径上落后i个关键帧的坐标”。这个可以通过给每一节蛇身套用同一个关键帧动画,再配合负的animation-delay实现:
.snake-body { animation: moveAlongPath 4.8s linear infinite; animation-delay: calc(var(--i) * -0.3s); }@keyframes moveAlongPath { 0% { --x: 0; --y: 0; } 12.5% { --x: 7; --y: 0; } 25% { --x: 7; --y: 1; } /* ...后面的路径关键帧 */ }蛇头的当前动画进度是100%,第1节身体的动画进度是100% - 0.3秒,第2节是100% - 0.6秒,它们沿着同一条路径错开时间走。只要路径是一个闭环,并且蛇头不中途切换路径,蛇身就会完美拖尾。
这个方案的好处是:蛇身永远沿着预设路径运动,绝不出轨。代价是:你没法让蛇自由地在网格里乱窜,它只能沿着你画好的路走。这个约束,是纯CSS贪吃蛇必须接受的现实。
3.4 食物、生长与计分的折中实现
自由摆放食物是不可能做到的,除非你预设好10个食物位,并且每个食物位都对应一个专门的响应规则。
我的实现方式是:在蛇头路径上预设5个食物点,每个食物点是一个独立的radio输入与一个食物div。蛇头经过食物点时,由于网格坐标匹配,触发“食物被吃”的规则:
#food-1:checked ~ .board .snake-head { --food-eaten: 1; } #food-1:checked ~ .board .food-1 { opacity: 0; }这里的#food-1:checked并不是蛇头“撞”上去自动勾选的——而是你在蛇头动画的关键帧里,通过改变某个隐藏联动属性,让食物radio变成选中状态。
最直接的办法是预设“时间点触发”:蛇头动画播放到第2秒时位于食物1的位置,这时食物1的radio被一个独立的动画“假装选中”。
比如:
@keyframes eatFood1 { 0%, 99% { --trigger: 0; } 100% { --trigger: 1; } }然后把食物1的选中态绑定到这个变量上。虽然这不完全等于“蛇真正撞上了食物”,但在固定路径下,蛇头经过某个位置的时刻是确定的,时间点触发和位置触发在效果上等价。
得分就用计数器把这些--trigger触发次数累加,消除食物,蛇身增加一节(预设更多蛇身节数,一开始display:none,吃到食物后显示)。
注意:这个方案里,食物和蛇的互动永远需要“预先编排”,你无法在游戏进行中动态生成食物的位置。这不是懒,而是CSS语言本身的边界。
4. 边界在哪里:纯CSS无法完成的“完整逻辑”
做完上面这套,你可能已经感受到了:这个项目的一半时间花在实现功能上,另一半时间花在“想办法绕开CSS的限制”上。而有些限制,绕不开。
4.1 随机性与状态变量是两堵墙
贪吃蛇的经典玩法里,食物每次吃掉后应该重新随机出现在一个空位上。随机性需要随机数生成器,CSS没有。你能做的,就是预设6~8个固定食物点,按时间顺序依次出现。玩起来其实也像那么回事,但你骗不了自己:它不是真随机,只是看着随机。
另一个麻烦是状态变量。JS里的蛇是一个二维数组,长度可以动态变化。CSS没有数组,你只能预先写死蛇身的长度上限(比如10节、20节),然后“需要几节就显示几节”。多写几个隐藏节点不费事,但逻辑上就不够优雅了。
4.2 自撞检测为何近乎无解
自撞检测是贪吃蛇的核心规则:蛇头撞到自己的身体,游戏结束。
在JS里,这就是一个坐标比较问题:遍历蛇身数组,看有没有哪节的坐标等于蛇头坐标。但在CSS里,你没有办法写一个“遍历”,也没有办法动态比较两个变量的值。
最接近的方案是把蛇头走过的坐标记录下来,每经过一个格子,那个格子被“标记”,蛇头再经过标记过的格子时,触发结束画面。理论上可以通过radio分组实现,但实际代码量会暴涨,而且只要蛇身增长了,标记逻辑就要重新推演一遍。我做过的所有尝试里,自撞检测没有一个版本是干净清爽的,全是补丁摞补丁。这个部分我建议放弃,或者用“禁止掉头”来代替自撞检测。
4.3 我给这类项目的落地方案
如果你也想做一个能展示的“纯CSS贪吃蛇”,我的建议是:
- 场地选小一点,6×6或者8×8
- 蛇的运动路线预设成环形或回字形
- 食物固定5~8个,按顺序出现
- 死亡判定只保留“撞墙”和“掉头”,自撞暂时不做
- 所有逻辑拆成清晰的CSS区块,用注释标好“状态层”“动画层”“输入层”
这样出来的成品,作为演示和教学是完全够格的,而且代码量不至于失控。我见过很多人硬着头皮把自撞也塞进去,最后代码膨胀到上千行CSS,维护起来生不如死,展示效果反而更差。
5. 踩坑记录与排查技巧
最后分享点实战经验。这个项目我前前后后改了三版,踩过的坑比闹钟还准。
5.1 新手必踩的坑清单
最容易翻车的几个错误,我列成一张表,你写代码的时候对照着看:
| 问题 | 症状 | 原因 | 解法 |
|---|---|---|---|
| 变量不参与transition | 蛇头瞬移,不走格子 | 自定义属性默认不可过渡 | 用@property注册并声明syntax类型 |
| radio规则失效 | 方向切了但坐标不动 | 忘记使用兄弟选择器 | input与游戏容器必须是同一层级或相邻关系 |
| 蛇身乱飞 | 转向后身体脱离头部 | 蛇身跟随头部动画而没跟随路径 | 蛇身统一走预设路径,不要读取实时坐标 |
| 动画循环时卡帧 | 蛇头走一半突然回起点 | 负animation-delay计算失误 | 检查延迟值是否与单段动画时长匹配 |
| 计数器不涨 | 分数始终为0 | counter-increment作用在错误的父级 | 把counter-reset放在最外层容器,increment放在checked规则下 |
5.2 打开DevTools,给CSS变量上“监控”
排查CSS游戏问题,最痛苦的是变量状态看不见。你根本不知道当前--x是几、--y是多少。
我的办法是:在调试阶段,把变量直接打到页面上——用一个占用固定位置的div,把它的伪元素内容设成变量值:
.debug::after { content: "x=" counter(x-debug) " y=" counter(y-debug); }或者更简单,临时把蛇头背景色绑到坐标上,比如x为偶数时红色,奇数时蓝色。用颜色变化代替数值输出,虽然原始,但非常直观。
另一个技巧是在DevTools的Elements面板里,选中游戏根节点,然后在Styles面板手动修改--x和--y的值,看蛇头是否按预期移动。这比改完代码再刷新要快得多,几乎是这个项目里的“调试神器”。
5.3 代码组织:CSS游戏项目该怎么分文件
CSS代码量大起来之后,放在一个文件里会很痛苦。我的组织习惯是:
variables.css:只放@property注册和全局变量layout.css:网格、游戏区域布局input.css:radio与输入状态相关规则snake.css:蛇头、蛇身动画与路径关键帧food.css:食物位置、消失与触发逻辑hud.css:计分、结束画面的样式
没有模块系统,就用文件拆分和清晰注释来凑。CSS这门语言写小样式可以一把梭,写这种逻辑型项目,不拆文件绝对会崩溃。
还有一点经验之谈:关键帧动画里的变量变化,浏览器兼容性比想象中差。我的第一版代码在某个浏览器里能跑,换到另一个浏览器就完全不响应。如果你遇到这种“代码没问题但就是不动”的情况,先检查是不是@property语法在这个浏览器里没生效(开发者工具里看变量是否变成黄色警告)。必要时给关键属性加上标准语法的回退方案。
写在最后的几句实在话
这个项目做完,我对CSS的认知变化很大。以前觉得CSS是个“样式表”,怎么写都是被规则推着走。现在觉得它是一门有“硬件限制”的微型语言:变量有限、没有随机存取、没有运行时判断,但你可以用空间换时间,用预设换逻辑,用结构换状态。
如果你打算照着做一遍,建议先从最小规模开始,拿一个4×4的场地跑通“蛇头移动+方向切换”就够了,再加食物,再加蛇身。我试过一上来就写8×8全功能的,最后花了整整一个周末在排查方向切换导致的坐标错乱。小步迭代,反而更稳。
至于“无JavaScript实现完整逻辑”这句话,我的理解是:真正完整的贪吃蛇,CSS一个人扛不下来。但CSS能把核心循环、状态切换、计分反馈都还原出来,这种在限制里做游戏的感觉,才就是这个项目最大的乐趣。