TypeScript+Canvas实战:构建完整贪吃蛇游戏及核心逻辑剖析

发布时间:2026/10/17 13:19:47
TypeScript+Canvas实战:构建完整贪吃蛇游戏及核心逻辑剖析 1. 项目概述与整体设计思路1.1 这个系列做到哪一步了先交代一下系列进度。上一篇文章里我们已经用 TypeScript 搭好了项目的整体骨架包括配置了 tsconfig、初始化了开发环境、创建了画布和基本的棋盘渲染逻辑。如果你一路跟着做过来现在应该已经有了一个能显示网格、能跑通 TypeScript 编译流水线的最小项目。这一篇我们要完成两个最关键的部分蛇类Snake和游戏控制器类GameController然后让整个游戏完整跑起来。做完这一篇你就能在浏览器里用键盘方向键控制一条小蛇在地图里吃掉食物、长大、撞墙或撞到自己时结束游戏最后看到 Game Over 和分数。这里先解释一个很多人做贪吃蛇时会困惑的问题为什么要把蛇和游戏逻辑拆成两个独立的类我见过很多新手项目把蛇的坐标数组、移动函数、碰撞检测全都塞在一个文件里前两百行还好等你要加暂停、加速、双人模式、AI 自动寻路时就开始痛苦了。蛇类只负责蛇自身的状态和数据——身体坐标、方向、生长标记游戏控制器负责游戏规则——什么时候让蛇移动、食物在哪、是否结束、分数怎么算。这样的职责拆分让后续扩展变得非常自然。你写完后会发现后面想加“穿墙模式”或者“AI模式”根本不需要动蛇类的核心逻辑。1.2 技术选型与结构规划这个项目我选的是Canvas 2D 渲染 TypeScript 类组织没有引入任何游戏引擎。原因很简单贪吃蛇的核心逻辑不涉及复杂的物理引擎和粒子特效用 Canvas 手绘每个格子完全够用而且能让你把关注点放在“游戏逻辑如何设计”而不是“引擎 API 怎么调用”上。开发环境基于浏览器原生 DOM API也不需要 WebGL 或者第三方渲染库。在动手写代码前先在脑子里过一遍整个项目最终会包含哪些模块棋盘或地图类负责网格尺寸、渲染背景和网格线食物类负责食物的随机生成和绘制蛇类负责蛇身体坐标数组、方向向量、移动与生长逻辑游戏控制器负责把上面几个模块串联起来管理游戏主循环、键盘事件、碰撞检测、分数与结束状态上一篇文章里棋盘和食物应该已经基本搞定了这篇重点落在蛇类和控制器上。不过为了完整性我会把最终需要调整的接口都梳理清楚保证前后逻辑能无缝衔接。2. 蛇类Snake设计与核心逻辑2.1 蛇的数据结构为什么用数组蛇这个对象在贪吃蛇里本质上是“一串有序的格子”。最自然、最贴合 TypeScript 风格的数据结构就是数组。我们用一个数组存储蛇身体每一个格子所在的网格坐标比如[{ row: 10, col: 12 }, { row: 10, col: 11 }, { row: 10, col: 10 }]数组第 0 项表示蛇头最后一个元素表示蛇尾。这里有个选型上的问题值得说说为什么不用链表从数据结构课的角度讲蛇吃食物后变长、移动时尾巴缩短链表确实非常适合——头插一个新节点、尾删一个旧节点都是 O(1)。但在真正的 Canvas 贪吃蛇里数组的 BCRBatch CRUD优势反而更明显你每次渲染都需要遍历整个蛇身去绘制每一个格子数组的缓存局部性好、遍历快而链表在遍历时会因为节点分散在堆内存里而频繁缓存未命中。更关键的是当我们做“移动”操作时其实不需要物理地移动每一个节点而是有更巧妙的做法。也就是说在这个场景里数组的简单直接就是优势不要为了“理论最优”去过度设计。坐标类型我建议直接定义成一个接口或类型别名export interface Position { row: number; col: number; }这样的好处是后续碰撞检测、绘制、食物生成都可以共用这个类型不会出现“这边传 row 那边传 x、y”的混乱情况。我在实际项目里吃过这个亏一开始图省事用{ x: number, y: number }结果到了碰撞检测时经常分不清 row 和 y 哪个对应纵向白白浪费了不少调试时间。名称上遵循 Canvas 原生的 x/y 当然没问题但在这个游戏里我们讨论的是网格row 和 col 的语义更清楚直接杜绝了搞混行列的可能性。2.2 方向管理与状态字段蛇类至少需要管理下面几个状态身体坐标数组body: Position[]当前移动方向direction: Direction下一个将要移动的方向nextDirection: Direction生长标记growFlag: boolean关于方向枚举我见过两种常见写法。一种是字符串联合类型export type Direction up | down | left | right;另一种是数字枚举export enum Direction { Up, Down, Left, Right, }两种都能用我个人倾向于字符串联合类型。原因在于当你打印日志调试时直接看到direction up一眼就明白当前状态数字枚举打印出来是个 0 或 1还得自己去脑海里映射一下。而且字符串联合类型在 TypeScript 里能享受到自动补全和编译期检查写错拼写会立刻报错。这个细节在大型项目里尤其重要。再解释一下为什么需要nextDirection而不是只用一个direction。这是贪吃蛇最经典的坑之一假设蛇正向右移动玩家在极短的时间内先按了“上”再按“左”如果在监听键盘事件时直接修改direction那么蛇可能在同一帧里先向上再向左直接撞上自己的身体——因为等同于做了个 180 度转弯。正确的做法是键盘事件里只把方向写入nextDirection等真正移动的那一帧里再做合法性检查如果nextDirection与当前direction相反比如当前向右下一方向向左就忽略这个输入如果合法把direction更新为nextDirection这个机制保证了“每帧最多只能转向一次”从根源上杜绝了瞬时双转向导致蛇头插进身体的问题。我见过有新手直接改direction然后疯狂按方向键结果蛇经常莫名其妙“自杀”其实就是这个问题。2.3 移动逻辑的三个核心动作蛇的移动逻辑可以拆成三个动作头前进、身体跟随、尾部缩短或保留。先说头前进。根据当前方向计算出新的头部坐标move(): void { const head this.body[0]; let newHead: Position; switch (this.direction) { case up: newHead { row: head.row - 1, col: head.col }; break; case down: newHead { row: head.row 1, col: head.col }; break; case left: newHead { row: head.row, col: head.col - 1 }; break; case right: newHead { row: head.row, col: head.col 1 }; break; } this.body.unshift(newHead); }这里unshift就是“头前进”的动作把新头插到数组最前面。然后是身体跟随有两种实现逻辑逐格前移法从尾部开始把每一节移动到前一节的位置头插尾删法头前进后如果没吃到食物就pop()删除尾部吃掉食物就保留尾部逐格前移法的代码是for (let i this.body.length - 1; i 0; i--) { this.body[i] { ...this.body[i - 1] }; } this.body[0] newHead;这个方法容易理解但每一步都涉及对象的复制和大量赋值操作代码也啰嗦。实际上由于我们只关心头尾的变化完全可以用“头插尾删”一步到位// 在 move 方法中 this.body.unshift(newHead); if (!this.growFlag) { this.body.pop(); } else { this.growFlag false; }你看三行代码搞定了“移动 生长”两个逻辑这就是数据结构和操作匹配的威力。这也是我在 2.1 提到“数组就是最优解”的底气所在。生长标记growFlag的引入还有一个额外好处把蛇类的移动逻辑和游戏控制器的食物碰撞逻辑解耦。当 Snake 检测到吃食物时不需要立刻修改蛇的身体长度只要设一个growFlag true等下一次move()时自然多保留一节尾部。这样 Snake 的公共接口会非常干净——控制器只需要调move()剩下的内部细节全由蛇自己处理。2.4 蛇类对外暴露的辅助方法一个设计良好的类对外接口应该最小化。我最后给 Snake 定义了这些公开方法get bodyPositions(): Position[]返回身体坐标数组的只读副本供渲染层使用get headPosition(): Position返回蛇头坐标方便碰撞检测move(): void执行一步移动setDirection(dir: Direction): void设置下一方向只做方向相反的检查grow(): void设置生长标记reset(): void重置蛇到初始位置和初始长度isOnPosition(pos: Position): boolean判断某个坐标是否被蛇身体占据这里我特别说一下get bodyPositions的返回设计。如果你直接返回this.body外部就能拿到内部数组的引用可以随意push、splice修改数据破坏了封装性。所以要返回一个浅拷贝this.body.map(p ({ ...p }))。这个细节在渲染时尤其重要——如果不小心在外部修改了坐标对象可能引出一堆诡异的 bug 且极难排查。按值传递而不是按引用传递是这个类设计里最容易被忽略但又最关键的一环。关于reset()方法我必须提醒重置不是只把 body 赋回初始数组就完事了。你还得把direction恢复到默认的right假设蛇初始向右走把nextDirection也同步重置最后别忘了把growFlag清掉。我就遇到过重置后蛇虽然回到了初始位置但因为growFlag没有清除新游戏第一口食物一吃就直接长两节整个游戏节奏瞬间被打乱。3. 游戏控制器GameController的搭建3.1 控制器的职责边界控制器是连接输入、逻辑、渲染的枢纽。它要管理一堆状态游戏是否结束、当前分数、当前游戏的运行速度、蛇和食物实例、上一次移动的时间戳以及渲染所需的 Canvas 上下文和网格尺寸。说白了控制器就是一个“导演”调度蛇去移动、调度食物去生成、调度 Canvas 去绘制同时监听键盘事件来影响游戏走向。这里有一个重要的设计选择controller 里不应该有绘图细节。也就是说我们不会在 GameController 里写ctx.fillRect(...)的代码而是把绘制逻辑分别放在 Snake 和 Food 的draw(ctx, cellSize)方法中或者单独抽出一个 Renderer 类。前一种做法更简单直接适合这个项目规模。export class GameController { private snake: Snake; private food: Food; private ctx: CanvasRenderingContext2D; private rows: number; private cols: number; private cellSize: number; private score: number 0; private gameOver: boolean false; private isRunning: boolean false; private lastMoveTime: number 0; private moveInterval: number 200; // 单位毫秒数值越大蛇越慢 constructor(canvas: HTMLCanvasElement, rows: number, cols: number) { this.ctx canvas.getContext(2d)!; this.rows rows; this.cols cols; const minSize Math.min(canvas.width / cols, canvas.height / rows); this.cellSize Math.floor(minSize); this.snake new Snake(rows, cols); this.food new Food(rows, cols); } }cellSize的计算逻辑我解释一下。Canvas 画布本身的像素尺寸可能不是整数倍关系我们要保证每一个格子都是正方形所以用“画布宽度除以列数”和“画布高度除以行数”中较小的那个值再取整避免绘制时出现半像素模糊和最后一个格子溢出的问题。这个细节直接影响游戏画面的干净程度。3.2 游戏循环setInterval 还是 requestAnimationFrame贪吃蛇的主循环有两种主流实现方案各有利弊这里认真对比一下。方案一是setIntervalthis.timer setInterval(() { this.update(); this.render(); }, this.moveInterval);方案二是requestAnimationFrame 时间戳判断private gameLoop (timestamp: number) { if (timestamp - this.lastMoveTime this.moveInterval) { this.update(); this.render(); this.lastMoveTime timestamp; } if (!this.gameOver) { requestAnimationFrame(this.gameLoop); } };我在贪吃蛇项目里最终选的是方案二。原因有三点setInterval在浏览器标签页切换或页面卡顿时会出现积压回调的情况导致蛇突然“瞬移”一大截requestAnimationFrame底层与浏览器渲染帧率同步即使卡了也能保证逻辑和渲染的一致性游戏速度变化时吃食物加速只要修改moveInterval即可rAF 天然支持动态变速当然 rAF 方案有个坑控制台打印的 timestamp 是你调用requestAnimationFrame那次回调的时间戳第一次调用gameLoop时会传一个很大的初始值所以lastMoveTime不能初始化为 0否则第一帧会立刻触发一次移动。正确做法是把lastMoveTime初始化为performance.now()或者第一次回调时直接赋值private gameLoop (timestamp: number) { if (this.lastMoveTime 0) { this.lastMoveTime timestamp; } // ... };另外rAF 在浏览器标签页隐藏时会自动暂停这也是我们想要的贪吃蛇行为——看不见的时候暂停移动避免后台疯狂圈性能。3.3 游戏状态机与重置逻辑游戏有几种状态待开始、运行中、暂停、结束。我建议用布尔标志配合显式的枚举来做区分。最精简的方式是enum GameState { Ready ready, Running running, Paused paused, GameOver gameover, }Ready状态处理起来非常顺畅开局时蛇在中间食物已经生成画布已经渲染但蛇不动。等玩家第一次按键时才开始Running。这样可以避免用户点开页面游戏立刻开始导致手忙脚乱的体验。resetGame()方法里我做这几件事清空 Canvas 画布重置蛇的状态和位置重新生成一个食物注意要避开蛇的初始位置分数归零移动间隔恢复默认值gameState 设为Ready取消所有 rAF 循环如果有的话这里有个容易遗漏的细节重置时要把键盘监听里的方向缓存也清掉。如果玩家在上一局结束时按了“向下”而新开局蛇默认向右我们不应该让这个方向缓存影响新游戏。我做法是每次 resetGame 时强制把nextDirection置为right然后再允许玩家按键改变。3.4 键盘监听的事件处理细节键盘监听的核心逻辑不复杂难点在各类边界情况的处理。我统一用一个方法private handleKeyDown (event: KeyboardEvent) { if (this.gameState GameState.Ready) { this.gameState GameState.Running; this.lastMoveTime 0; requestAnimationFrame(this.gameLoop); } if (this.gameState GameState.GameOver) { return; } switch (event.key) { case ArrowUp: case w: case W: this.snake.setDirection(up); event.preventDefault(); break; case ArrowDown: case s: case S: this.snake.setDirection(down); event.preventDefault(); break; case ArrowLeft: case a: case A: this.snake.setDirection(left); event.preventDefault(); break; case ArrowRight: case d: case D: this.snake.setDirection(right); event.preventDefault(); break; case : this.togglePause(); event.preventDefault(); break; } };我同时支持方向键和 WASD 键位这在写完之后实测下来有质的提升——很多人更习惯 WASD 布局不必甩手去摸方向键。另外这里我用了箭头函数绑定this确保回调里this指向 GameController 实例。如果用普通函数后面做事件解绑时就容易踩坑。event.preventDefault()的位置很讲究。如果只按方向键不阻止默认行为浏览器页面在长页面中会随方向键滚动体验极其割裂。WASD 本身不产生滚动但空格键会触发页面滚动所以空格也必须 preventDefault。这里我对所有处理过的按键都统一调用了 preventDefault一劳永逸。整体事件绑定我会放在start()方法里start(): void { document.addEventListener(keydown, this.handleKeyDown); requestAnimationFrame(this.gameLoop); }同时提供一个destroy()destroy(): void { cancelAnimationFrame(this.rafId); document.removeEventListener(keydown, this.handleKeyDown); this.isRunning false; }这个destroy()在大型项目中特别有用——单页应用切换页面时如果忘了解绑键盘监听旧实例会继续响应按键导致多个游戏实例互相干扰。我在最终实现时专门留了这个口子。4. 核心机制碰撞检测、吃食物与分数体系4.1 三种碰撞场景的判定方法游戏中所有“事件”本质上都是碰撞检测的结果。我们要检测三类碰撞撞墙蛇头坐标越界。private isWallCollision(head: Position): boolean { return head.row 0 || head.row this.rows || head.col 0 || head.col this.cols; }这个最简单一个布尔表达式就搞定。撞自己蛇头移动到新位置时检查是否与新身体的其他节重叠。private isSelfCollision(head: Position): boolean { return this.snake.bodyPositions.some((pos, index) { return index ! 0 pos.row head.row pos.col head.col; }); }注意这里把代码里的 index 参数放在第二个位置有些some回调的签名是(value, index) boolean所以必须显式写上(pos, index)。不过很多人写的时候会漏掉 index导致判断逻辑变成pos.row head.row pos.col head.col会把蛇头本身也判定为重疊。还有一个更隐蔽的坑因为移动时蛇尾会缩短一格蛇头顶到即将移走的尾巴位置是合法的。用“移动前检查”和“移动后检查”会得到不一样的结果。我最终选择的是先让 snake.move() 执行完此时蛇尾已经完成收缩再检测蛇头是否与剩余身体重叠这样避免了误判。吃食物蛇头与食物坐标相同。这个检测放哪儿我放在 update 的移动之后const head this.snake.headPosition; if (head.row this.food.position.row head.col this.food.position.col) { this.snake.grow(); this.score 10; this.food.respawn(this.snake.bodyPositions); this.increaseSpeed(); }4.2 食物生成避开蛇身体食物类的respawn方法需要接收蛇身体坐标数组作为参数保证随机生成的新食物不会落在蛇身上。实现逻辑respawn(occupiedPositions: Position[]): void { do { this.position { row: Math.floor(Math.random() * this.rows), col: Math.floor(Math.random() * this.cols), }; } while ( occupiedPositions.some( (p) p.row this.position.row p.col this.position.col ) ); }这个do...while循环在蛇比较短时非常快几乎零开销。但如果蛇很长比如占了一半的棋盘理论上存在无限循环的风险。所以在最终实现里我给 Food 的构造方法传入了行数和列数并用一个最大尝试次数做了保护let attempts 0; do { // 随机生成 attempts; } while (occupied attempts 1000); if (occupied) { // 此时棋盘可能已满强制结束游戏 }实战中棋盘如果接近满了游戏基本也到了极限这时候直接结束反而是合理的游戏规则。体现在最终代码里还有一个关键点Food 的position应该用 getter 暴露同时把rows和cols作为构造参数存下来。这样 respawn 不需要每次从外部传入行列范围代码更内聚。4.3 得分与加速曲线设计得分规则简单每吃一个食物固定加 10 分。这个分值没有太多讲究重要的是加速曲线。加速曲线直接决定游戏的难度爬升是否平滑。我采用的方案是蛇的移动间隔毫秒数从 200 开始每吃 5 个食物减少 10 毫秒最低不低于 80 毫秒private increaseSpeed(): void { const decreaseAmount Math.floor(this.score / 50) * 10; this.moveInterval Math.max(200 - decreaseAmount, 80); }这个方案的好处是前期提速明显玩家很快能感到节奏加快刺激但可控后期速度慢慢逼近极限不会再断崖式变快。80 毫秒的极限速度在我的实测里普通玩家基本就是极限了再快会出现“看不清蛇头在哪”的体验问题。这里再分享一个小技巧游戏速度变化时如果你直接修改 moveInterval 但 rAF 循环里的 lastMoveTime 还在参考旧值可能出现刚吃完食物立刻又动了一格感觉像“卡了一下”。我的处理是在increaseSpeed()里顺便把lastMoveTime重置为 0强制下一帧重新计时private increaseSpeed(): void { this.moveInterval Math.max(200 - Math.floor(this.score / 50) * 10, 80); this.lastMoveTime 0; }这样整个速度切换就是平滑过渡没有任何跳变感。5. 完整渲染流程与画布绘制细节5.1 棋盘网格与背景绘制渲染时顺序很重要先背景再食物再蛇身最后蛇头这样能保证视觉层级合理。具体绘制private render(): void { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.drawBackground(); this.food.draw(this.ctx, this.cellSize); this.snake.draw(this.ctx, this.cellSize); this.drawScore(); }drawBackground我用了两种颜色交替绘制网格这是贪吃蛇经典棋盘风格的实现private drawBackground(): void { for (let row 0; row this.rows; row) { for (let col 0; col this.cols; col) { this.ctx.fillStyle (row col) % 2 0 ? #e8f4e8 : #d4ecd4; this.ctx.fillRect(col * this.cellSize, row * this.cellSize, this.cellSize, this.cellSize); } } }这里用(row col) % 2判断是不是偶数格产生经典的棋盘格效果。虽然每帧都要循环填充几十上百个方格但 Canvas 对这些简单的 fillRect 操作开销极小实测 100 帧以上完全没问题不需要做复杂优化。5.2 蛇的视觉分层绘制蛇的draw方法在渲染时应该分层处理蛇身和蛇头。蛇头可以作为视觉重点用不同颜色和突出的圆角来强调蛇身则用渐变色或者统一色块。draw(ctx: CanvasRenderingContext2D, cellSize: number): void { this.body.forEach((pos, index) { const x pos.col * cellSize; const y pos.row * cellSize; if (index 0) { ctx.fillStyle #2e7d32; ctx.fillRect(x 2, y 2, cellSize - 4, cellSize - 4); } else { ctx.fillStyle #66bb6a; ctx.fillRect(x 2, y 2, cellSize - 4, cellSize - 4); } }); }加入 2 像素的内边距后蛇身之间会有细小的缝隙视觉上能明显看出蛇是一节一节的而不是糊成一团这一版画出来的质感明显好于全满格填充。如果你想做更酷的效果蛇头可以加两个小白点当眼睛if (index 0) { ctx.fillStyle #ffffff; ctx.beginPath(); ctx.arc(x cellSize * 0.35, y cellSize * 0.35, 3, 0, Math.PI * 2); ctx.arc(x cellSize * 0.65, y cellSize * 0.65, 3, 0, Math.PI * 2); ctx.fill(); }眼睛位置我取了蛇头格子的两个对角位置这样蛇有“朝向感”玩家也能更直观地判断蛇头方向。5.3 分数、暂停与游戏结束提示状态提示我直接在 Canvas 上绘制文字不额外引入 DOM 元素private drawScore(): void { this.ctx.fillStyle #333333; this.ctx.font bold 20px monospace; this.ctx.textAlign left; this.ctx.fillText(分数: ${this.score}, 10, 28); }游戏结束时覆盖一层半透明遮罩private drawGameOver(): void { this.ctx.fillStyle rgba(0, 0, 0, 0.6); this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.fillStyle #ffffff; this.ctx.font bold 32px monospace; this.ctx.textAlign center; this.ctx.fillText(游戏结束, this.canvas.width / 2, this.canvas.height / 2 - 10); this.ctx.font 16px monospace; this.ctx.fillText(得分: ${this.score}按空格重新开始, this.canvas.width / 2, this.canvas.height / 2 30); }最后这个名字叫“按空格重新开始”的提示和键盘监听里对空格的处理逻辑衔接上GameOver 状态下按空格就调用 resetGame 并进入 Ready 状态。交互上形成了一个完整的闭环。这里我强调一个排期细节drawGameOver()是独立方法不要把它塞进render()里无条件执行。正确逻辑是private update(): void { if (this.gameState ! GameState.Running) { return; } this.snake.move(); // 碰撞检测 // ... } private gameLoop (timestamp: number) { this.render(); if (this.gameState GameState.Running) { // 更新逻辑 } if (this.gameState GameState.GameOver) { this.drawGameOver(); } requestAnimationFrame(this.gameLoop); };这样避免在暂停或结束时仍然执行移动逻辑也避免给玩家留下“蛇还在动”的错觉。6. 常见问题与实战避坑记录6.1 问题速查表整理一下我在写这个项目期间遇到的典型问题附带解决方案让大家直接避坑问题现象根本原因解决方案蛇偶尔穿过了自己的身体但没判定死亡移动后立刻检测时误把尾巴收缩前的位置算作重叠先执行 move()再检测蛇头与剩余身体的重叠疯狂按方向键时蛇出现瞬时 180 度转弯键盘事件里直接修改 direction引入 nextDirection移动帧做合法性检查游戏重置后第一口食物吃下长两节reset 时没有同步清除 growFlag把 growFlag 清除加进 reset()空格键让页面滚动画面被顶走没有对空格调用 preventDefault键盘处理里对空格和方向键统一 preventDefault切换浏览器标签页回来蛇瞬移一大截setInterval 回调积压改用 requestAnimationFrame 时间戳判断快速吃两个食物时加速曲线突兀只改了 moveInterval没有重置 lastMoveTime加速时把 lastMoveTime 置 0食物偶尔生成在蛇头上respawn 里没有排除蛇头坐标占用坐标判断包含头部即可6.2 排查思路和方法论如果你遇到上面没有列到的问题我的建议是先用console.log把关键状态打出来蛇的坐标数组、方向、分数、游戏状态。贪吃蛇这种逻辑型游戏95% 的问题靠打日志都能定位到。打个比方如果你发现蛇头画出去了但游戏没结束优先怀疑碰撞检测的时机是先检测了再移动还是移动了再检测建议在这个位置打日志update(): void { this.snake.move(); const head this.snake.headPosition; console.log(head:, head, direction:, this.snake.direction); if (this.isWallCollision(head) || this.isSelfCollision(head)) { this.gameState GameState.GameOver; } // ... }看到日志后一眼就能判断是头部坐标算错了还是碰撞条件写反了。还有一个小观测技巧网页游戏出现“画面没更新但分数变了”的情况时多半是渲染流程里调用了绘制方法但明明应该先 clearRect 再绘制——顺序搞反导致画面残留而视觉上看起来没变。遇到这种先检查 render 方法里第一件事是不是 clearRect这是非常容易踩的坑。6.3 性能优化经验虽然这游戏基本用不上必须坦白讲贪吃蛇在普通棋盘上根本不存在性能瓶颈几十个格子每帧重绘一次绰绰有余。但如果你将来要做更大的棋盘比如 100x100可以考虑只重绘“脏区域”记录蛇上一次的尾部位置渲染时把它擦掉只重绘蛇头和食物附近区域。不过这是典型的提早优化现在没必要写等你实际遇到性能问题时再动手。另一件真正值得做的事情是让渲染流程保持数据与画面的一致性。不要在 render 方法里去修改蛇的状态所有状态变化都发生在 update 阶段。这条准则看着简单但实际项目里很容易违反比如在绘制食物时顺手调用 respawn就会出现“食物看起来生成在A处蛇撞到B处却加分了”的错位问题。6.4 代码组织的最终形态最终整个项目目录结构大致是src/ index.ts // 入口实例化 GameController types.ts // Position、Direction 等公共类型 snake.ts // Snake 类 food.ts // Food 类 game-controller.ts// GameController 类每一类一个文件职责单一类型定义独立放一个文件方便多处引用。入口文件三行代码启动游戏const canvas document.getElementById(game-canvas) as HTMLCanvasElement; const controller new GameController(canvas, 20, 20); controller.start();这种组织方式在公司实际前端项目的代码审查里也说得过去——类型清晰、类职责明确、可扩展性好。后面你想加“AI 自动寻路的演示模式”、加“音效”、加“多级关卡”这几个类的接口基本不用大改最多新加几个类和模块就行。7. 从完成到可玩打磨与扩展方向如果你坚持看到了这里那我不妨把这个项目后续还能怎么玩透说几句。做完基础版后我实际打磨时做了三件事都很有意思一是给游戏加了加速徽章提示。当 score 每跨过 50 的整数倍时在分数旁边闪一下“加速”字样持续三秒。这个纯属体验优化但玩家反馈“知道自己变快了”比无声无息加速效果好很多。二是实现了暂停菜单。按空格切换暂停后画面中间显示“暂停中”同时把蛇的剩余倒数三秒后继续做成进度条。很多人玩到后期会需要暂停去回消息这个功能非常实在。三是尝试做寻路 AI。给 Snake 类加一个enableAutoPilot开关控制器在 update 时用一个简单的 BFS 计算下一步去食物的方向然后调用setDirection。这个功能写出来后你用来自娱自乐或者做演示素材都绰绰有余。BFS 在这个场景就是洪水填充几行代码的事但做出来的演示效果十分唬人。如果你本身是学生或者刚参加工作不久我强烈建议把这个项目在你的简历或作品集里留一个位置。它虽然简单但完美覆盖了 TypeScript 类封装、事件处理、Canvas 渲染、游戏循环、碰撞检测和状态管理这些基础命题。面试时聊这个项目你能顺畅地讲出为什么用nextDirection而不是直接改方向、为什么选 rAF 而不是 setInterval这就比背八股文有说服力得多。我个人实际写过很多次贪吃蛇每次重构都能发现新问题。这一版用 TypeScript 写完后最大的感受是类型系统真的能在编译期抓住很多低级疏忽——比如把 row 和 col 写反、调用不存在的枚举值、传错坐标系这些都是 JS 时代要等到运行时靠肉眼去发现的问题。所以如果你还没接触过 TypeScript从这种小型游戏项目入手是成本低、收获大的练手方式。最后留一个小彩蛋跑起来之后试试连续快速按“上、左、下、右”四个方向键看看蛇有没有机会绕成一个小圈而不死。我调试过程中反复在这个测试上验证转向机制的健壮性也推荐你也玩一下。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询