用 5 个模块拼出完整贪吃蛇:Web 开发 101 事件与效果项目完整拆解
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
几十行 JavaScript,能把一张 1600 格的白板变成一条会吃、会涨、还会越吃越快的蛇。这是 Web 开发 101 旧版课程「Events & Effects」章节里的实战作业(对应文档:project_events_events.md),它逼你把事件驱动编程的全部核心机制拼进一个可运行的游戏:键盘事件负责转向,setTimeout驱动回合循环,DOM 方法负责把状态画到屏幕上。做完这个项目,你手里就握着一个「事件改变状态、状态改变画面」的完整闭环,往后写任何交互页面都不慌。
核心概念速览:动笔前钉死三个词
- 事件:浏览器上报"有事发生了"的机制。鼠标动一下、按一个键、页面加载完,都会触发。类比前台:它不替你干活,只把消息转交给登记过的人。
- 回调:作为参数传给别的函数、由对方"到时候"再调用的函数。类比把菜谱寄存在食堂——人不用守着灶台,饭到点自然会做。
setTimeout:一次性计时器,浏览器在指定毫秒后把你给的函数执行一次。类比只能拧一次的鸡蛋煮蛋器。
三样东西串起来就是一句话:事件是扳机,回调是响应,setTimeout是时钟。贪吃蛇只是把它们排成一个转得动的轮子。
环境搭建:一个文件骨架和两个新手坑
整个项目只需要两个文件:index.html和snake.js,放在同一目录下就能双击运行,不需要任何构建工具。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Snake</title> <style> #grid { width: 800px; } /* 40 格 x 20px */ .cell { width: 20px; height: 20px; border: 1px solid #ccc; display: inline-block; box-sizing: border-box; /* 关键:边框算在 20px 内 */ } .snake { background-color: green; } .food { background-color: red; } </style> </head> <body> <p>Score: <span id="score">0</span></p> <div id="grid"></div> <script src="snake.js"></script> </body> </html>这段骨架做了三件事:用box-sizing: border-box钉死格子尺寸、留出网格容器#grid和计分牌#score、把脚本放在</body>前加载。
⚠️坑一:边框会把盒子撑大。默认情况下width不含边框,20px 的格子实际占 22px,40 列就是 880px,超出 800px 的容器后第 40 格会掉到下一行,棋盘从此"碎掉"。上图这种给每个元素都画出盒子的视角,正是排查布局错位的思路。
⚠️坑二:脚本放错位置会拿到null。如果把<script>挪到<head>里,代码执行时#grid还不存在,getElementById直接返回空。脚本贴</body>前,是最省心的做法。
分模块实现:从棋盘到完整游戏
下面所有 JavaScript 按顺序写进snake.js。每个模块解决一个独立职责,看完一个就能在浏览器里验证一个。
状态建模:蛇身为什么必须是坐标对数组
动手前先回答一个问题:蛇的"身体"该用什么数据结构存?如果只存一个坐标,你无法表达"长度",也就无法实现"吃食物变长"和"撞到自己"这两条核心规则。把蛇身存成坐标对的数组,一切就顺了:走一步 = 头部插入新坐标、尾部弹出旧坐标;变长 = 只插入、不弹出。
// 棋盘:40x40,每格默认一个空格 var SIZE = 40; var grid = []; for (var row = 0; row < SIZE; row++) { grid[row] = []; for (var col = 0; col < SIZE; col++) { grid[row][col] = " "; } } // 蛇:body 是坐标对数组,第一个元素永远是蛇头 var snake = { position: [20, 20], direction: "r", body: [[20, 20]] }; // 食物:位置可随时随机重放 var food = { position: [0, 0], place: function () { this.position = [ Math.floor(Math.random() * SIZE), Math.floor(Math.random() * SIZE) ]; } }; var score = 0; var speed = 150; // 每个回合的间隔,毫秒 food.place();这里grid是棋盘的"外观缓存"(每格谁站岗),snake.body才是事实来源——后面每回合都会从事实来源刷新一次grid。这个模块定死了全部状态:棋盘、蛇、食物、分数、速度。
渲染输出:让 render() 画出 1600 格棋盘
状态建好了,但屏幕上还是空的。渲染的思路刻意选"暴力重建":每回合把容器清空、重新生成 1600 个格子。Diff 更新更快,但对这个规模纯属过度设计——先跑通,再谈优化。
// 每回合把真实状态(蛇身、食物)写回 grid function paintGrid() { for (var row = 0; row < SIZE; row++) { for (var col = 0; col < SIZE; col++) { grid[row][col] = " "; } } for (var i = 0; i < snake.body.length; i++) { grid[snake.body[i][0]][snake.body[i][1]] = "O"; } grid[food.position[0]][food.position[1]] = "F"; } // 先清空容器,再重建全部格子 function render() { var container = document.getElementById("grid"); container.innerHTML = ""; for (var row = 0; row < SIZE; row++) { for (var col = 0; col < SIZE; col++) { var cell = document.createElement("div"); cell.className = "cell"; if (grid[row][col] === "O") cell.className += " snake"; if (grid[row][col] === "F") cell.className += " food"; container.appendChild(cell); } } } paintGrid(); render();注意innerHTML = ""这一步:它是整个渲染的前提,忘了它,画面上的格子会一轮叠一轮地往 3200、4800 格膨胀。这个模块的职责一句话:把grid里的"O"和"F"变成有颜色的div。
此刻浏览器里已经能看到棋盘中央一个绿格、随机位置一个红格了。接下来让蛇能听你的话。
输入监听:一个 keydown 接住全部方向键
事件冒泡让这里可以偷懒:按键事件会从当前聚焦元素一路冒泡到document,所以监听器直接挂在document上,不用关心焦点在哪个元素里。
var KEY_TO_DIR = { ArrowUp: "u", ArrowDown: "d", ArrowLeft: "l", ArrowRight: "r" }; var OPPOSITE = { u: "d", d: "u", l: "r", r: "l" }; document.addEventListener("keydown", function (event) { var dir = KEY_TO_DIR[event.key]; // 无关按键直接忽略;180° 掉头也忽略 if (!dir || dir === OPPOSITE[snake.direction]) return; snake.direction = dir; console.log("direction changed to:", snake.direction); });OPPOSITE这个判断不是锦上添花——蛇向右走时按左键,下一步蛇头会精确撞进第二节身体,直接判死。console.log留着调试用,确认监听真的生效再进下一步。这个模块的职责:把按键翻译成方向,并且只改snake.direction这一个状态。
核心循环:用 setTimeout 的一次性计时器驱动蛇
蛇不会自己走,需要有人在固定节奏上喊"走一格"。这里不选setInterval,也不选while死循环:后者会占死主线程,页面直接卡死;前者想停得手动clearInterval,想变速得拆掉重建。setTimeout每回合结束才调度下一回合,停下来只需return不续约,变速只需改变量——后面进阶部分会直接吃到这个红利。
function move() { var head = snake.body[0].slice(); // 复制蛇头坐标,别改到原引用 if (snake.direction === "u") head[0]--; else if (snake.direction === "d") head[0]++; else if (snake.direction === "l") head[1]--; else head[1]++; snake.body.unshift(head); // 新头入队 if (!eatFood(head)) { snake.body.pop(); // 没吃到东西就弹出尾巴,长度不变 } } function gameLoop() { move(); paintGrid(); render(); var head = snake.body[0]; if (isOutOfBoard(head) || hitsItself()) { document.getElementById("score").textContent = score; alert("Game Over! 本局得分:" + score); return; // 不再调度下一回合,循环就此自然停止 } setTimeout(gameLoop, speed); } setTimeout(gameLoop, speed);注意gameLoop的收尾:判断死亡后return且不续约setTimeout,循环就停在这里——画面定格在致死那一帧,这正是玩家想看到的。这个模块的职责:每speed毫秒推进一格,并在死亡时优雅收场。
至此蛇已经能走、能转向、能撞死。还差最后两块规则板。
规则判定:撞墙、撞自己和吃东西
三条规则全部写成纯函数式的判断:不碰 DOM、不改状态,只回答"是或不是"。把判定和副作用分开,是这类小项目里最值钱的习惯。
function isOutOfBoard(pos) { return pos[0] < 0 || pos[0] >= SIZE || pos[1] < 0 || pos[1] >= SIZE; } function hitsItself() { var head = snake.body[0]; for (var i = 1; i < snake.body.length; i++) { if (snake.body[i][0] === head[0] && snake.body[i][1] === head[1]) { return true; } } return false; } function eatFood(head) { if (head[0] === food.position[0] && head[1] === food.position[1]) { score += 1; document.getElementById("score").textContent = score; food.place(); return true; } return false; }这里藏着一个精妙的执行顺序:hitsItself()在move()之后运行,而没吃到食物时尾巴已经被弹出了,所以蛇头走进旧尾格是合法的(真实贪吃蛇里这一步恰好安全);反过来吃到食物时尾巴留着,撞尾巴就实打实判死。顺序把规则写对了,一行特判都不用加。跑起来你会发现,这五段代码拼在一起就是一个完整游戏。
想再进一步:四个改动,越改越深
- 变速加速:在
eatFood()里加一行speed = Math.max(50, speed - 5);。为什么几乎零成本?下一回合的延迟是调度时现读speed的,改变量立即生效,不用碰任何定时器——这就是"调度读状态、状态可独立演化"。 - 长度计分:把
score += 1改成score += snake.body.length。蛇越长单个食物越值钱,直接体现了"对象状态与游戏数值联动",计分规则只是读一次状态。 - 图片蛇身:在
render()里,当格子属于蛇身时改用document.createElement("img")并设置src,而不是加 CSS 类。元素类型也可以动态选择,DOM 终究只是数据的投影。 - 穿墙模式:在
move()里把越界坐标用(head[0] + SIZE) % SIZE归一化,再删掉isOutOfBoard的判死分支。同一段边界代码,从"致死"变成"环绕",能直观看到游戏规则本质上就是一组布尔判断的排列组合。
仓库知识地图:前置与延伸文档在哪
| 主题 | 仓库内文档 | 与本文关系 |
|---|---|---|
| 事件与效果的学习目标清单 | events_effects.md | 冒泡、解绑、键盘监听等目标逐条对应本项目的实现点 |
回调函数与setTimeout原理 | event_driven.md | 游戏循环和 keydown 背后都是同一套回调机制 |
| DOM 选择器与增删节点 | DOM_manipulation_and_events.md | createElement/appendChild的现代原生写法出处 |
| jQuery 时代的对照实现 | jq_basics.md | 当年用keypress()写的版本,可与原生写法对比 |
| 脚本与浏览器的加载顺序 | js_to_browser.md | 解释骨架里<script>为什么贴在</body>前 |
| Git 仓库初始化 | introduction_to_git.md | 动手前给项目建仓库的前置步骤 |
快速自检:六道题确认它真是你的
- 蛇为什么不能只用一个坐标建模?换成什么结构后,"走一步"和"长一节"都变成了数组的两个基本操作?
keydown监听器为什么敢直接挂在document上?事件模型里的哪个机制让它在任何元素聚焦时都生效?- 游戏循环为什么用自调度的
setTimeout而不是setInterval或while?想实现"吃到食物后加速"时,哪种方案改动最小? render()每回合重建 1600 个格子之前必须先做哪一步?漏掉它,屏幕上会发生什么?- 蛇头走进刚被尾巴让出的格子为什么合法?把
move()和hitsItself()的执行顺序画出来验证一下。 - 如果不挡 180° 掉头,蛇会怎么死?这个拦截是加在监听器里还是
move()里更合适?
收尾:蛇是事件驱动的最小完整样本
一条贪吃蛇 = 输入改变状态、计时器推进状态、渲染输出状态,事件驱动编程的核心思维模型全在里面。做完它,接着推进 project_ajax_forms.md 和 project_apis.md,把同一套事件思维带进异步请求与数据交互。
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考