看到“纯CSS贪吃蛇游戏无JavaScript实现完整逻辑”这个标题时我第一反应是“又来标题党”。以前见过不少纯CSS作品时钟、弹球、开关按钮但贪吃蛇不一样它需要位置记忆、方向输入、随机食物、碰撞判定这几乎全是JavaScript的基本盘。可当我真的坐下来拆了一阵子之后发现这个项目不仅能做而且做完之后会对CSS的“状态机”理解拔高一截。这篇文章会把我的完整思路、踩坑过程和可复现的落地方式都写出来尤其会讲清楚一个关键前提纯CSS不是没有逻辑而是把逻辑从“运行时执行”换成了“编译期展开”。1. 拆需求贪吃蛇在CSS里到底缺什么1.1 四个核心逻辑的CSS映射先别急着写代码先看游戏本身。贪吃蛇拆开看只有四件事游戏需求JavaScript做法纯CSS的替代方案记录当前位置和方向变量保存坐标用radio的checked状态表示读取玩家的输入监听keydown/click用label触发隐藏radio蛇身跟随头部移动数组shift/unshift每个状态给每节身体写transform随机生成食物并判定吃掉Math.random预生成食物序列状态表里决定显示/隐藏撞墙或撞到自己if判断生成状态时预判转跳到死亡状态这里面的核心不是“不用JS”而是把JS在运行时算的东西提前变成一张张静态状态表。CSS只是个查表工具它不聪明但胜在稳定只要输入一个状态输出就完全确定。1.2 必须接受的三条边界真正开始之前我先给自己划了三道线否则这个项目永远做不出来。第一没有循环。CSS的animation可以无限循环但那只是视觉上重复跑同一组keyframes不是程序上的while。所以我不会试图用animation模拟一个永动的“游戏主循环”。第二没有随机数。食物位置不可能真的随机出现我会在一开始就定好一颗接一颗的食物序列。玩家感觉是随机的其实只是生成器在编译期按规则挑了一个合理位置。第三没有实时时钟。JS可以用setInterval驱动游戏一帧一帧走CSS做不到主动计时。我最后的方案是把它做成“回合制”玩家每点一次方向键游戏状态就往前推一格。这不是退步反而是纯CSS能守住“无JS”底线的最直接方式。想明白这三条标题里的“完整逻辑”就不再是忽悠我是一个完整的状态机只是触发下一帧的动作交给了玩家。2. 状态表把运行内存搬到样式表里2.1 一个状态到底记录了什么我先把整个游戏看作有限状态机。游戏里的任意一个瞬间可以被一个状态唯一描述蛇头在哪一个格子蛇身一共有几节每一节分别在哪一个格子当前食物的位置已经吃掉了多少分举个简单例子在一个4x4棋盘上初始状态可能是蛇头: (1, 0) 第一节身体: (0, 0) 食物: (2, 1) 分数: 0我给它一个状态ID比如S_000。这个ID不需要包含所有信息它只是一个唯一编号真正的位置信息都写进CSS规则里。生成器会为每一个状态生成一条类似这样的规则#S_000:checked ~ .board .head { transform: translate(36px, 0); opacity: 1; }你可能会说这不就是把变量写死在CSS里吗对就是写死。纯CSS没有内存但选择器可以充当“记忆”玩家选中哪个radio就代表当前处于哪个状态。这个状态对应的所有画面细节由样式表全量给出。2.2 状态怎么跳转一张方向键跳转表玩家按方向键本质上是让状态从A跳转到B。在HTML里我生成的不是按钮而是指向目标radio的label。假设当前状态是S_000玩家按“上”生成器算出下一状态是S_015于是生成label classkey up>#S_000:checked ~ .board .key.up[data-fromS_000] { opacity: 1; pointer-events: auto; }点击之后浏览器自动把S_000的radio取消勾选勾上S_015。因为同一个name的radio天然互斥所以不需要任何JS状态就完成了切换。这就是radio hack我在项目里把它当成“单比特寄存器”用。2.3 状态量怎么控制小棋盘加短蛇身如果棋盘15x15、蛇最长25节理论上状态数量会爆炸生成器跑出来的CSS可能几百MB不现实。我最后选择的是4x4棋盘、蛇最长4节、食物序列10颗。这样生成器输出是几千个状态CSS文件压缩后只有几百KB浏览器能扛住。这里也顺便解释一下纯CSS贪吃蛇不是给你替代真实游戏的它更适合做教学Demo、CSS选择器极限挑战、以及面试脑洞题。你能从里面学到的不是“怎么做一个游戏”而是“怎么在极端约束下建模”。3. 关键模块实现与代码解析3.1 HTML骨架所有radio必须在画面元素之前这个方案非常依赖通用兄弟选择器~所以DOM结构有硬性要求所有radio要放在棋盘容器之前并且和棋盘容器共用一个父元素。否则选择器选不中。完整骨架长这样div classgame input typeradio namegs idS_RESET checked !-- 下面这些由生成器自动生成 -- input typeradio namegs idS_000 input typeradio namegs idS_001 input typeradio namegs idS_002 ... input typeradio namegs idS_DEAD div classboard div classsnake i classseg head/i i classseg seg1/i i classseg seg2/i i classseg seg3/i /div i classfood/i label classkey up/label label classkey down/label label classkey left/label label classkey right/label div classscore/div div classoverlay h2游戏结束/h2 label forS_RESET再来一次/label /div /div /div注意方向键label也放在.board里并且每个label都要有>.game { --cell: 36px; width: calc(var(--cell) * 4); height: calc(var(--cell) * 4); position: relative; } .board { position: absolute; inset: 0; } .seg, .food { position: absolute; left: 2px; top: 2px; width: calc(var(--cell) - 4px); height: calc(var(--cell) - 4px); transform: translate(-100px, -100px); opacity: 0; transition: transform 0.18s linear, opacity 0.12s linear; }我不用left和top来控制位置原因有两个第一left/top触发布局计算性能不好transform只触发合成动画更顺滑。第二transition: transform能让我在状态切换时自动获得平滑移动效果。状态从(0,0)切到(1,0)蛇头会自动滑过去看起来就像真的走了半步。某个状态下的蛇头和第一节身体可以这样写#S_000:checked ~ .board .head { transform: translate(36px, 0); opacity: 1; } #S_000:checked ~ .board .seg1 { transform: translate(0, 0); opacity: 1; }如果某个状态蛇还没长到第二节身体就让对应元素保持不可见#S_000:checked ~ .board .seg2 { opacity: 0; }这段逻辑看起来简单但它背后有一个非常重要的原则每一节身体在每一帧的位置都是预先计算好的不是依靠身体节点“跟随前一个节点”运行出来的。3.3 方向键覆盖层透明按钮很多纯CSS交互作品会用四个贴在棋盘四边的透明label做方向键。我这个项目也是。生成器会给每个状态生成四条label方向不同定位不同.key { position: absolute; z-index: 5; opacity: 0; pointer-events: none; } .key.up { top: 0; left: 0; right: 0; height: 25%; } .key.down { bottom: 0; left: 0; right: 0; height: 25%; } .key.left { left: 0; top: 0; bottom: 0; width: 25%; } .key.right { right: 0; top: 0; bottom: 0; width: 25%; }然后让当前状态的对应label显示出来#S_000:checked ~ .board .key.up[data-fromS_000] { opacity: 1; pointer-events: auto; }这里有个容易踩的坑opacity: 0虽然看不见但默认仍会挡住点击所以必须配合pointer-events: none。只有当这条label属于当前状态时才打开pointer-events。否则玩家会点到一个“幽灵按钮”状态永远切不过去。3.4 食物与增长状态表里的一颗“隐形苹果”在JS版里吃食物是一个运行时事件。在纯CSS版里这提前变成了两个状态之间的差异。假设S_000状态里食物在坐标(2,1)用生成器生成的规则就是#S_000:checked ~ .board .food { transform: translate(72px, 36px); opacity: 1; }当玩家按方向键走到食物格时生成器算出的目标状态是S_010。在这个状态里食物移到下一个位置蛇身多一节约出#S_010:checked ~ .board .food { transform: translate(108px, 72px); opacity: 1; } #S_010:checked ~ .board .seg2 { transform: translate(36px, 36px); opacity: 1; }从玩家视角看就是“吃到苹果、蛇变长、苹果换位置”。从CSS视角看只是换了一个radio被勾上。食物序列不是随机生成而是生成器按规则挑选新食物不能出现在蛇身上也不能超出棋盘。为了让游戏更有变化我会让食物序列尽量分散。你甚至可以写一个很简单的“种子算法”来保证每次重开都从一个固定但看起来不重复的序列开始。3.5 撞墙、自撞、得分和重开碰撞判定同样发生在生成阶段。生成器在计算方向转移时如果发现目标位置超出边界或者目标位置正好是蛇身就不生成正常状态label而是生成一个指向S_DEAD的labellabel classkey up>#S_DEAD:checked ~ .board .overlay { opacity: 1; pointer-events: auto; }点“再来一次”的label会把S_RESET勾上S_DEAD自动取消棋盘回到初始状态。得分不需要用JS实时累计因为每个状态本身知道自己有多少分。用CSS计数器就能显示.score { counter-reset: score 0; } .score::after { content: 得分 counter(score); } #S_010:checked ~ .score { counter-reset: score 1; } #S_011:checked ~ .score { counter-reset: score 2; }这里要说清楚counter-reset不能平滑过渡得分会从0直接跳到1、跳到2但对一个回合制游戏来说完全够用。4. 常见问题与排查技巧这个方案里最容易出问题的不是CSS语法而是“状态表和选择器对不上”。我把自己实操时踩过的坑整理成了一张速查表。现象可能原因排查方法点方向键没反应label的for和radio的id不匹配打开开发者工具检查label的for属性方向键能看到但点不透pointer-events没有从none切回auto检查当前状态下的label样式是否命中蛇身位置错乱某个状态没有给对应蛇节写transform用生成器重新输出不要手写状态表蛇身消失隐藏时用了display:nonetransition不生效统一改用opacity控制显示隐藏画面不滑动位置用了left/top而不是transform改成transform并保留transition所有radio一开始都是灰的没给任何radio设置checked让S_RESET默认勾选分数一直显示0状态规则优先级低于基础.score规则给状态选择器加足够权重或增加ID层级最让我头疼的一次是方向键label全部设了pointer-events: none但在某个状态里忘记给当前label设回auto结果玩家点哪里都没反应。后来我写了个小工具函数在生成状态表时同时统计每个状态的label规则数量发现某条规则缺失才定位到问题。另外如果你想让蛇头移动更“像贪吃蛇”可以把transition时长调成0.15s让移动短促一些。不要用太长的时间否则回合制游戏会显得拖泥带水。5. 这个项目的边界与下一步扩展最后说点我个人的经验。纯CSS贪吃蛇的技术上限不在于你会不会写CSS而在于你有没有能力把一个动态问题“拍平”成静态状态表。这个思维方式其实和编译原理里的状态机优化很像。如果你也想挑战我建议从3x3棋盘开始蛇最长3节先手工或脚本生成一版跑通“移动、吃、增长、死亡、重开”五个闭环。然后慢慢加大棋盘。不要一上来就追求15x15否则光生成状态表就能让你怀疑人生。这个方案还能往几个方向扩展加障碍物生成器在坐标计算时把障碍格排除掉本质上就是多一层边界判断。加皮肤不同状态下给蛇头设置不同背景色吃食物瞬间可以多一个闪烁动画。加存档你可以把某一组关键状态作为“关卡”做得像一个解谜游戏。加音效虽然CSS不能发声但可以配合Web Audio API不过那已经超出“无JS”的范畴了。如果你问我这个项目到底值不值得做我会说它不是一个适合生产落地的游戏但绝对是一个适合反复研究的前端实验。做完之后你对CSS选择器、radio hack、状态建模、生成器的理解都会上一个台阶。尤其是“把运行时逻辑提前编译成静态表”这个思路放到任何前端架构里都好用。最后分享一个小技巧当你生成了海量状态规则后不要手动去维护直接用脚本输出css和html并且把脚本和生成结果分开存储。我在实际操作中最常做的事就是改一改食物序列重新生成一次而不是去动那几千行CSS。这个习惯能帮你省下大量调试时间。
阅读完成 · 觉得有帮助?