
用 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 charsetutf-8 titleSnake/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 pScore: span idscore0/span/p div idgrid/div script srcsnake.js/script /body /html这段骨架做了三件事用box-sizing: border-box钉死格子尺寸、留出网格容器#grid和计分牌#score、把脚本放在/body前加载。⚠️坑一边框会把盒子撑大。默认情况下width不含边框20px 的格子实际占 22px40 列就是 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.mdcreateElement/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),仅供参考