用 JavaScript 事件驱动贪吃蛇:一个 setTimeout 游戏循环怎么跑起来
2026/9/19 19:43:55 网站建设 项目流程

用 JavaScript 事件驱动贪吃蛇:一个 setTimeout 游戏循环怎么跑起来

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

把一条蛇放进 40×40 的网格里,让它跟着方向键走、吃到食物就变长、撞上自己就出局——一个能玩的贪吃蛇就成形了。这篇文章只写三样东西:键盘事件监听怎么接住输入、setTimeout 回调函数怎么撑起游戏循环、网格与蛇身这类状态对象怎么建模。写完之后你会同时带走两样东西,一份可以直接在浏览器里跑的代码,和一套事件驱动编程的思维模型。

🧠 底层逻辑:游戏开始前,脑子里先摆好三张桌子

别急着写代码,先想清楚谁负责什么。

第一张桌子:等单的人。把浏览器想象成一家餐厅。服务员不会每隔半秒冲到每张桌子问一遍"要不要加水",那样整家餐厅会瘫痪。他站在过道里等,哪桌铃响了,他就去处理哪桌。键盘事件就是这个铃。你写下的监听回调就是那个"去处理"的动作,而事件对象里装着本次响铃的全部信息,比如按下的是哪个键。

第二张桌子:定时闹钟。蛇要自己往前走,但不能用死循环把页面卡死。闹钟每次只响一次,响完你得决定要不要再设下一个。setTimeout干的就是这件事:每回合只调度一次移动,回合结束后由你自己决定下一步的节奏。

第三张桌子:账本。你不需要记住蛇走过的每一步,只需要知道它现在长什么样。把整条蛇存成一串坐标对,移动就是"头部加一格、尾部减一格",吃食物就是"这一回合尾部不减少"。

想清楚这三件事之后,代码其实很薄。

把模型落进代码

蛇对象长这样,先放在这里,后面的代码都围着它转:

var snake = { position: [20, 20], direction: "r", body: [[20, 20]] };

positiondirection是蛇头的快照,body才是真正的蛇身,数组第一个元素永远是头。

40×40 的棋盘在内存里长什么样

这一步解决"数据从哪来"。实现思路是这样的——

var grid = []; for (var row = 0; row < 40; row++) { grid[row] = []; for (var col = 0; col < 40; col++) { grid[row][col] = " "; } }

网格就是一个二维数组,每个格子初始只放一个空格。后面蛇身用"O"、食物用"F"占位,判断谁在哪个格子全靠查它。

屏幕上的 1600 个格子谁来画

这一步解决"数据怎么变成画面"。把上面的模型落到代码里,核心就这几行:

function render() { var container = document.getElementById("grid"); container.innerHTML = ""; // 整块重画,旧的 div 全部清掉 for (var row = 0; row < 40; row++) { for (var col = 0; col < 40; col++) { var cell = document.createElement("div"); cell.className = "cell"; if (grid[row][col] === "O") { cell.classList.add("snake"); } else if (grid[row][col] === "F") { cell.classList.add("food"); } container.appendChild(cell); } } }

这段在做"网格转 DOM":每回合把 1600 个 div 整个重画一遍。注意innerHTML = ""是刻意的,不清空旧格子就会越叠越多。HTML 侧只需要一个空的 div 容器和三行 CSS 控制格子尺寸,全部渲染压力都在这个函数里。

方向键从键盘到蛇身的路上

这一步解决"玩家怎么控制蛇"。真正在跑的逻辑其实很短:

document.addEventListener("keydown", function (event) { if (event.key === "ArrowUp") snake.direction = "u"; if (event.key === "ArrowDown") snake.direction = "d"; if (event.key === "ArrowLeft") snake.direction = "l"; if (event.key === "ArrowRight") snake.direction = "r"; });

这段在做键盘事件监听:回调被注册后一直"待命",按键真正发生才由浏览器调它,event.key里读得到键名。注意监听挂在document上而不是某个具体元素,为什么这样也行,下面单独说。

谁在按固定节奏推蛇走

这一步解决"蛇自己怎么动起来"。实现思路是这样的——

var gameOver = false; var speed = 120; function gameLoop() { move(); render(); if (isOutOfBoard(snake.position) || hitsItself(snake.position)) { gameOver = true; alert("Game Over!"); return; } setTimeout(gameLoop, speed); }

这段在做回合调度:移动一格、重画一遍,然后才决定要不要预约下一回合。注意调度永远放在最后,撞墙或自撞的那一帧依然会渲染出来,玩家能看到蛇最后停在哪儿。

function move() { var head = snake.body[0].slice(); if (snake.direction === "u") head[0]--; if (snake.direction === "d") head[0]++; if (snake.direction === "l") head[1]--; if (snake.direction === "r") head[1]++; snake.body.unshift(head); if (!eatFood(head)) { snake.body.pop(); } snake.position = head; }

这段在做坐标数组建模里最核心的一手:头插新格子、尾弹旧格子,蛇就"走"了。注意slice()是复制而不是引用,否则新蛇头和旧蛇头会指向同一份数据。

食物放在哪,下一颗又怎么来

这一步解决"变长的燃料"。把上面的模型落到代码里,核心就这几行:

var food = { position: [0, 0] }; function placeFood() { food.position = [ Math.floor(Math.random() * 40), Math.floor(Math.random() * 40) ]; grid[food.position[0]][food.position[1]] = "F"; } function eatFood(head) { if (head[0] === food.position[0] && head[1] === food.position[1]) { placeFood(); return true; } return false; }

这段在做"进食协议":蛇头坐标对上食物坐标,就重放一颗食物并放行"不弹尾"。注意grid里同步写入"F",下一轮render()才能把红色格子画出来。

撞墙和撞自己怎么判

这一步解决"什么时候该停"。真正在跑的逻辑其实很短:

function isOutOfBoard(pos) { return pos[0] < 0 || pos[0] >= 40 || pos[1] < 0 || pos[1] >= 40; } function hitsItself(pos) { for (var i = 0; i < snake.body.length - 1; i++) { var seg = snake.body[i]; if (seg[0] === pos[0] && seg[1] === pos[1]) { return true; } } return false; }

这段在做两种出局判定,都只查蛇头一个点。注意hitsItself的循环故意停在倒数第二个元素:尾巴这一回合本来就要弹走,不算在碰撞范围内。

🩹 容易卡住的点

  • 你看到:格子排到第 20 个就开始换行,整盘棋歪了。其实是:默认盒模型下边框会加在width之外,20px 的格子实际占了 22px。改法:给格子加box-sizing: border-box,让边框吃进 20px 里。
  • 你看到:keydown明明挂在document上,却总能收到方向键。其实是:按键先落在焦点元素上,再沿 DOM 一层层冒泡到顶层,这就是事件冒泡。改法:不用改,知道它能收到就行;DOM_manipulation_and_events.md 对这条链路有完整讲解。
  • 你看到:蛇回头时明明没压到身体,程序却报 Game Over。其实是:自撞检测把"即将弹走的尾巴"也算进了身体。改法:判断时跳过最后一个坐标对,只比对前面每一节。
  • 你看到:想用setIntervalwhile循环,写完才发现没法优雅地停。其实是:固定间隔的定时器不关心游戏是否已经结束。改法:每回合末尾才调setTimeoutgameOver置位后自然不再预约下一回合;event_driven.md 讲过回调就是"把信息抛向未来"。
  • 你看到:蛇撞墙的瞬间页面还是白的,最后的位置没显示出来。其实是:把gameOver检查放在了render()之前,最后一帧没画。改法:顺序固定为move()render()→ 判定,停循环只影响下一回合。

🚀 再玩两笔

跑通之后别急着收工,有两笔很值得玩。

  • eatFood里的计分规则,让蛇越长、单颗食物的分值越高,体验"滚雪球"的爽感。
  • gameLoop末尾的speed,每吃一颗食物就缩短下一回合的延迟,体验逐渐失控的紧张感。
  • render()里的"O"换成动态插入的img元素,体验 DOM 渲染的另一种用法。

等这条蛇彻底跑顺了,下一步很自然:把"随机放食物"换成异步请求与 API 交互,让棋盘从网络里长出来。

到这里,键盘事件监听、setTimeout 游戏循环、坐标数组建模这三块就拼完整了:事件管输入,定时回调管节奏,网格和蛇对象管状态。仓库里这份作业出自 project_events_effects.md,事件与冒泡的细节在 events_effects.md 里,脚本加载时机可以参考 js_to_browser.md。照上面几段代码自己敲一遍,不翻文章也能拼出一个能玩的原型。写完这个,可以接着看看异步请求与 API 交互那一块。

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询