用TypeScript类组织贪吃蛇:面向对象游戏开发实战

发布时间:2026/10/17 12:35:39
用TypeScript类组织贪吃蛇:面向对象游戏开发实战 1. 整体设计思路为什么用类来组织贪吃蛇我见过不少初学者写贪吃蛇习惯把所有逻辑塞进一个 main 函数或者全用全局变量堆。全局变量一多代码就成了一锅粥。这次我们用 TypeScript 的类来组织但很多人会问一句贪吃蛇这么小有必要搞类吗我觉得有必要。不是小题大做而是贪吃蛇这个项目其实覆盖了面向对象设计里最典型的几个问题谁负责数据、谁负责行为、谁负责协调、谁负责渲染。如果一开始就把职责划清楚后面加计分、加速、障碍物这些功能都会顺手很多。整款游戏的核心逻辑可以拆成三大块。第一块是蛇类负责蛇的状态、移动规则、碰撞自检、以及自身的绘制。第二块是食物类负责食物坐标的随机生成与绘制。第三块是游戏控制器类负责把上面两块组合起来再承担游戏循环、分数结算、键盘监听、开始结束状态的切换。project-root/ ├─ src/ │ ├─ type.ts # 共享类型定义 │ ├─ food.ts # 食物类 │ ├─ snake.ts # 蛇类 │ ├─ game.ts # 游戏控制器类 │ └─ index.ts # 入口文件 ├─ index.html └─ tsconfig.json这个拆分有一个好处每个文件只做一件事。type.ts 里没有逻辑只放类型snake.ts 里不碰键盘事件game.ts 里不关心蛇身格子到底是什么颜色。将来你如果想换成贪吃蛇双人对战版基本只需要创建两个 Snake 实例再改一下控制器——这就是低耦合带来的实际收益。1.1 类型先行先定义数据形状再写逻辑写 TypeScript 项目我习惯的第一步永远是定义类型。别看类型定义只是几行 interface它相当于整个项目的地基。地基歪了上面盖什么都是歪的。贪吃蛇这个项目我认为最少需要这三样类型/** 方向枚举 */ export enum Direction { Up Up, Down Down, Left Left, Right Right } /** 坐标结构 */ export interface Point { x: number y: number } /** 蛇身和食物的通用格子结构 */ export interface Cell { x: number y: number }方向用枚举还是用字符串字面量联合类型都可以但我个人更推荐枚举。原因很实在枚举给每个方向一个语义名字代码里不会出现裸字符串。你想想如果有人写snake.faceTo(left)一旦字母拼错整个方向判断就废了。用Direction.Left就完全没这个问题编辑器还能给你直接提示省去翻代码确认的麻烦。坐标为什么要分开 Point 和 Cell严格来说这两个都是{ x: number; y: number }但语义完全不同。Point 强调空间上的一个点Cell 强调的是画布上的一个格子。如果你在代码里直接混用看moveTo(point)和moveTo(cell)的时候你很难一眼判断参数到底是什么意思。TypeScript 是结构化类型系统两个形状相同的接口是可以互相赋值的但它们的存在本身就在提醒你——这里的坐标代表什么含义。1.2 配置参数集中管理别写死魔法数字写游戏最忌讳的就是各种数字散落在代码的各个角落比如画布宽度 400、格子大小 20、蛇初始长度 3这些魔法数字如果你在每个方法里重复写后期想要调整的时候得满文件去搜。像我之前做某个项目时就吃过这种亏改一个参数要按三遍 CtrlF。我建议的做法是直接在 type.ts 里定一个配置对象/** 游戏参数配置 */ export const CONFIG { canvasWidth: 600, canvasHeight: 600, gridSize: 20, snakeInitLength: 4, snakeSpeed: 150, // 单位毫秒数值越大越慢 colorSnakeHead: #2d5016, colorSnakeBody: #4e8d3f, colorFood: #d1483b, colorBg: #f5f2e6 } as constas const会让这些值的类型变成字面量类型比如canvasWidth的类型就是600而不是number。这有什么好处好处是如果你后续不小心写了一个CONFIG.canvasWidth 800TypeScript 会直接在编译阶段报错提醒你正在篡改一个只读配置。对游戏这类运行时状态变化频繁的程序来说这个保护很值。还有一个细节蛇的移动速度我用snakeSpeed: 150表示每 150 毫秒走一格。这个值直接决定了游戏的难度。100 毫秒手感偏快200 毫秒手感偏慢150 毫秒是我调试下来比较舒服的档位。如果你的玩家群体是小朋友建议放宽到 200。2. 食物类把地基打扎实很多人觉得食物类太简单了就是把一个点随机放在画布上然后画个圆。但食类有几个细节值得琢磨比如随机生成的坐标必须对齐网格否则蛇头永远吃不到食物食物不能生成在蛇身上否则一开局就穿模食物被吃掉后要能重新生成。2.1 食物类的职责范围食物类的职责我限定为两件生成一个当前场景内有效的坐标以及把自己画在画布上。它不关心蛇在哪里也不关心分数怎么加。那我怎么保证食物不生成在蛇身上答案是通过生成方法接收一个不可用区域参数由调用方来告诉它哪些坐标不能选。这个设计叫做依赖倒置。食物类并不直接引用 Snake 类型而是接收一个坐标数组。这样食物类就不依赖蛇类将来如果棋盘上有障碍物也只需要把障碍物的坐标一并传进来食物类不需要改一个字符。import { CONFIG } from ./type /** 食物类 */ export class Food { position: { x: number; y: number } constructor() { this.position { x: 0, y: 0 } } /** * 生成一个不包含在 forbiddenCells 里的随机坐标 * param forbiddenCells 不可选的坐标蛇身等 */ generate(forbiddenCells: { x: number; y: number }[] []): void { // 计算行列数避免坐标越界 const rowNum CONFIG.canvasHeight / CONFIG.gridSize const colNum CONFIG.canvasWidth / CONFIG.gridSize while (true) { // 随机取 0 ~ rowNum-1 之间的整数 const newX Math.floor(Math.random() * colNum) const newY Math.floor(Math.random() * rowNum) // 如果这个坐标没人占用则确定位置 const conflict forbiddenCells.some( cell cell.x newX cell.y newY ) if (!conflict) { this.position { x: newX, y: newY } return } } } /** 绘制食物 */ draw(ctx: CanvasRenderingContext2D): void { const { x, y } this.position // 给食物留一点内边距不然看起来像粘在一起 const radius CONFIG.gridSize / 2 - 3 ctx.save() ctx.shadowColor CONFIG.colorFood ctx.shadowBlur 6 ctx.fillStyle CONFIG.colorFood ctx.beginPath() // 注意这里换算的是像素中心点 ctx.arc( x * CONFIG.gridSize CONFIG.gridSize / 2, y * CONFIG.gridSize CONFIG.gridSize / 2, radius, 0, Math.PI * 2 ) ctx.fill() ctx.restore() } }2.2 随机生成算法为什么用 while(true)你看那个while (true)可能会有点慌这玩意儿不会死循环吗理论上如果棋盘被蛇占满了确实会死循环。但贪吃蛇的棋盘大小时 30 × 30一共 900 个格子蛇身要达到 900 格这个可能性在实际游戏中几乎为零。就算真的发生了那也是胜利的场面——棋盘都被蛇占满了游戏早该结束了。不过从工程严谨的角度来说我会给这个循环加一个最大尝试次数。这个习惯是我自己后期养成的因为哪怕概率再低一个无法终止的循环在代码评审里都是个隐患。比如可以改成尝试 1000 次还找不到空位就抛异常。但在教学项目里while(true)配合占位判断已经完全够用注释里写清楚前提就行。2.3 食物的绘制技巧食物的绘制我用的是shadowBlur加光晕效果这是让食物看起来有质感的低成本方式。不透明度和阴影的配合让它像一颗水果糖而不是一个干巴巴的色块。这里有个绘制坐标系容易混的地方。游戏逻辑里的坐标是格子坐标一格是 20 像素。比如{ x: 3, y: 5 }表示从左上角开始横着数第 4 格、竖着数第 6 格。但 canvas 绘图用的是像素坐标所以绘制的时候必须做一个换算像素X 格子X × 格子大小 格子大小 / 2 像素Y 格子Y × 格子大小 格子大小 / 2加一半格子大小的原因是为了让圆心落在格子的正中心。如果不加图案会偏在格子的左上角视觉上看起来很别扭。这个换算方法在蛇类绘制里也会用到建议你把它当成一个固定的心法记下来。3. 蛇类实现游戏的核心到重点了。蛇类是贪吃蛇整个代码里最复杂的类核心就是那几件事数据怎么存、移动怎么做、碰撞怎么检测、自己会不会咬到自己、吃到食物怎么变长、以及怎么把自己画出来。3.1 蛇的数据结构怎么选蛇在棋盘上的形态是一连串连续格子而且随时会在头部新增一格尾部可能减少一格。这种头部增、尾部减的操作典型的先进先出逻辑天然适合用队列。在 JavaScript 里数组的unshift在头部插入、pop在尾部删除刚好模拟了最自然的思路。但这里有一个性能上的点在教学里我会特意提一下unshift的时间复杂度是 O(n)因为数组头部插入会引发后续所有元素的内存移动。贪吃蛇的蛇身长度通常在几十格这个量级这一点性能损失完全可忽略。如果你贪吃蛇以后要做到 500 格以上可以考虑用双向链表存储蛇身。在教学项目里用数组就行容易理解也容易调试。蛇类还用一个direction字段记录当前朝向一个pendingDirection记录下一次移动的方向。为什么要两个方向因为玩家在一帧之内可能连续按两个方向键如果不做缓冲第二个按键会覆盖第一个按键导致蛇没有按玩家的意图转向。而如果按下即生效又会出现一个经典的 bug蛇正在向左走玩家按了向上再快速按了向右蛇会直接掉头——蛇不允许直接 180 度转向这个约束后面细说。3.2 蛇类的 TypeScript 实现import { CONFIG, Direction } from ./type interface Cell { x: number y: number } /** 蛇类管理蛇身状态、移动规则与碰撞检测 */ export class Snake { /** 蛇身第 0 项为蛇头 */ body: Cell[] /** 当前移动方向 */ direction: Direction /** 缓冲方向本轮移动尚未消费的最新转向指令 */ private pendingDirection: Direction /** 本轮是否已经移动过防止一帧内多次移动 */ private movedThisTurn: boolean constructor() { // 初始蛇身从中间向左延伸 const gridX CONFIG.canvasWidth / CONFIG.gridSize const gridY CONFIG.canvasHeight / CONFIG.gridSize const startX Math.floor(gridX / 2) const startY Math.floor(gridY / 2) this.body [] for (let i 0; i CONFIG.snakeInitLength; i) { this.body.push({ x: startX - i, y: startY }) } this.direction Direction.Right this.pendingDirection Direction.Right this.movedThisTurn false } /** 获取蛇头坐标 */ get head(): Cell { return this.body[0] } /** 设置下一次移动的方向带转向合法性校验 */ setDirection(newDir: Direction): void { // 左、右是横向上、下是纵向 const isOpposite (this.direction Direction.Up newDir Direction.Down) || (this.direction Direction.Down newDir Direction.Up) || (this.direction Direction.Left newDir Direction.Right) || (this.direction Direction.Right newDir Direction.Left) // 如果是相反方向忽略 if (isOpposite) return // 如果相同方向忽略 if (this.direction newDir) return // 如果这一轮已经移动过只更新缓冲方向下一轮再消费 if (this.movedThisTurn) { this.pendingDirection newDir } else { // 这一轮还没移动直接生效 this.direction newDir this.pendingDirection newDir } } /** 蛇移动一步 */ move(grow: boolean): void { const head this.body[0] let newHead: Cell { x: head.x, y: head.y } switch (this.direction) { case Direction.Up: newHead.y - 1 break case Direction.Down: newHead.y 1 break case Direction.Left: newHead.x - 1 break case Direction.Right: newHead.x 1 break } // 头部插入新格子 this.body.unshift(newHead) // 只有吃到食物才保留尾部否则移除尾部 if (!grow) { this.body.pop() } // 修正蛇头坐标到棋盘范围内撞墙的场景留给控制器处理 const rowNum CONFIG.canvasHeight / CONFIG.gridSize const colNum CONFIG.canvasWidth / CONFIG.gridSize newHead.x (newHead.x colNum) % colNum newHead.y (newHead.y rowNum) % rowNum this.body[0] newHead // 移动完成消费缓冲方向 this.direction this.pendingDirection this.movedThisTurn true } /** 是否与自身发生碰撞 */ isHitSelf(): boolean { const head this.body[0] // 从索引 1 开始检查因为索引 0 就是头本身 return this.body.slice(1).some(segment segment.x head.x segment.y head.y) } /** 蛇头是否撞到边界 */ isHitWall(): boolean { const head this.body[0] const rowNum CONFIG.canvasHeight / CONFIG.gridSize const colNum CONFIG.canvasWidth / CONFIG.gridSize return head.x 0 || head.x colNum || head.y 0 || head.y rowNum } /** 判断某个坐标是否与蛇身重合 */ isOnSnake(x: number, y: number): boolean { return this.body.some(segment segment.x x segment.y y) } /** 重置蛇状态重新开局用 */ reset(): void { const gridX CONFIG.canvasWidth / CONFIG.gridSize const gridY CONFIG.canvasHeight / CONFIG.gridSize const startX Math.floor(gridX / 2) const startY Math.floor(gridY / 2) this.body [] for (let i 0; i CONFIG.snakeInitLength; i) { this.body.push({ x: startX - i, y: startY }) } this.direction Direction.Right this.pendingDirection Direction.Right this.movedThisTurn false } /** 绘制蛇 */ draw(ctx: CanvasRenderingContext2D): void { const size CONFIG.gridSize this.body.forEach((segment, index) { const px segment.x * size const py segment.y * size // 是否蛇头 const isHead index 0 // 内边距让蛇身看起来是一节一节的 const padding isHead ? 1 : 3 ctx.fillStyle isHead ? CONFIG.colorSnakeHead : CONFIG.colorSnakeBody // 用圆角矩形或正方形绘制蛇身 ctx.fillRect(px padding, py padding, size - padding * 2, size - padding * 2) // 给蛇头画出两个眼睛增强辨识度 if (isHead) { ctx.fillStyle #fff const eyeSize 3 if (this.direction Direction.Right) { ctx.fillRect(px size - 8, py 4, eyeSize, eyeSize) ctx.fillRect(px size - 8, py size - 8, eyeSize, eyeSize) } else if (this.direction Direction.Left) { ctx.fillRect(px 5, py 4, eyeSize, eyeSize) ctx.fillRect(px 5, py size - 8, eyeSize, eyeSize) } else if (this.direction Direction.Up) { ctx.fillRect(px 4, py 5, eyeSize, eyeSize) ctx.fillRect(px size - 8, py 5, eyeSize, eyeSize) } else { // Down ctx.fillRect(px 4, py size - 8, eyeSize, eyeSize) ctx.fillRect(px size - 8, py size - 8, eyeSize, eyeSize) } } }) } }3.3 移动逻辑中的关键设计我在这里用了movedThisTurn这个标志位它在游戏循环里承担着重要的作用。requestAnimationFrame 是每帧都会回调的但贪吃蛇不需要每帧都移动——它应该按照snakeSpeed的节奏来走。于是我们只会每隔一段时间调用一次move()。但在一次移动完成之后如果用户在这一帧里连续按了两次方向键第一次的转向已经生效第二次的转向只能进入 pendingDirection 缓冲区等下一轮移动时再消费。这个机制看起来简单实际解决了两个大问题。第一个问题是反向瞬移蛇头正在向右走玩家在下一次移动前按了上又按了左如果没有缓冲左方向会覆盖上方向而左恰好是右的反方向蛇会瞬间穿过自己的身体。第二个问题是输入丢失如果只用一个 direction而且每次键盘事件都直接覆盖 direction那么玩家在短时间内按的上和左只有最后一次生效看起来像是按键失灵。有了 pendingDirection按键就变成了队列的感觉虽然只缓冲一个但手感已经完全不同。还有就是move(grow: boolean)这个参数设计。grow 为 false 时移动完要把尾巴 pop 掉这样蛇身长度保持不变。grow 为 true 时不 pop蛇身长度加一。这比吃到食物后再调一个专门的方法来加一节更符合直觉因为增长本来就应该发生在移动这个瞬间。3.4 撞墙策略不穿墙还是穿墙我在move里做了一个边界取模的兜底newHead.x (newHead.x colNum) % colNum newHead.y (newHead.y rowNum) % rowNum这个东西的意思是如果你的坐标到了 -1就把它修正成 colNum - 1如果到了 colNum就回到 0。也就是说蛇头会从左边穿出去、右边钻出来类似环屏效果。这在早期贪吃蛇里是一种常见玩法。但你可能想问既然蛇类里已经做了边界修正为什么还有一个isHitWall方法两种玩法都存在经典模式下蛇撞到边界游戏结束穿墙模式则不然。把这个选择交给控制器更合理——控制器知道当前游戏该用哪种模式。在实际项目中我通常建议默认使用撞墙结束因为穿墙模式会让玩家的策略变得完全不同难度把控也更含糊。在这个项目里我选择在控制器里检查撞墙结束游戏move里的取模只是防御性代码确保极端情况下蛇头始终在画布内。4. 游戏控制器类把一切串起来控制器类的名字我起的是 Game整个游戏的主循环、状态管理、事件监听、Canvas 上下文管理都集中在它身上。它操作蛇和食物这两个业务类但自身不关心蛇的数据结构也不关心食物怎么画它的工作是决策。4.1 游戏状态机贪吃蛇看起来是实时游戏但逻辑上是一个状态机至少包含这几种状态等待开始、运行中、暂停、结束。状态不同事件处理和渲染行为完全不同。type GameStatus idle | running | paused | over export class Game { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D private snake: Snake private food: Food private status: GameStatus idle private score 0 private lastMoveTime 0 private animationId 0 constructor(canvas: HTMLCanvasElement) { this.canvas canvas // 这里在正式项目中应该做空值处理但教学环节先保证存在 this.ctx canvas.getContext(2d)! this.snake new Snake() this.food new Food() this.food.generate() this.bindEvents() } }状态机的好处在于任何外部输入进来先看状态再决定要不要响应。比如游戏结束状态下按方向键没有任何意义暂停状态下按空格应该恢复运行中按空格应该暂停。这个东西用 if 堆也能写但状态一多就会乱。我在项目里用状态字符串加 switch 来处理。4.2 事件绑定与防止方向键滚动页面键盘事件绑在 document 上而不是 canvas 上。为什么因为玩家按键盘时焦点不一定在 canvas 上如果绑在 canvas 上很可能出现按了没反应。而绑 document 能保证无论焦点在哪都能响应。方向键的默认行为会让页面滚动必须preventDefault()。这是新手最容易忽略的问题不处理的话会出现按一下方向键蛇没动页面先滚了。private bindEvents(): void { document.addEventListener(keydown, (e) { const keyMap: Recordstring, Direction { ArrowUp: Direction.Up, ArrowDown: Direction.Down, ArrowLeft: Direction.Left, ArrowRight: Direction.Right, w: Direction.Up, s: Direction.Down, a: Direction.Left, d: Direction.Right } const dir keyMap[e.key] if (dir) { // 防止方向键滚动页面 e.preventDefault() this.handleDirection(dir) } if (e.key || e.key Space) { e.preventDefault() this.handleSpace() } if (e.key Enter) { e.preventDefault() this.handleEnter() } }) }同时支持字母键和方向键这个体验细节很加分。很多玩家习惯左手控制 WASD右手可能还握着鼠标。如果你只做了方向键会损失一部分玩家的舒适度。4.3 按键响应与状态流转private handleDirection(dir: Direction): void { if (this.status ! running) return this.snake.setDirection(dir) } private handleSpace(): void { if (this.status running) { this.status paused } else if (this.status paused) { this.status running // 恢复时重置计时避免瞬间移动一次 this.lastMoveTime performance.now() } } private handleEnter(): void { if (this.status idle || this.status over) { this.start() } }这里有一个细节暂停恢复后lastMoveTime要重置为当前时间。如果不重置暂停期间过去的时间会计入移动间隔恢复后瞬间就走一步玩家会感觉蛇跳了一下。类似的坑还有浏览器标签页切换回来后的大间隔问题也靠这个办法解决。4.4 游戏循环用 requestAnimationFrame 加时间差控制游戏循环是控制器的灵魂。我用 requestAnimationFrame 驱动渲染但移动的节拍单独用时间差控制。private loop (timestamp: number): void { // 记录动画帧ID便于取消 this.animationId requestAnimationFrame(this.loop) if (this.status ! running) { // 不管暂停还是结束画面不动但为了方便显示暂停文字还是重绘一次 this.draw() return } // 距离上次移动是否达到设定间隔 if (timestamp - this.lastMoveTime CONFIG.snakeSpeed) { this.update() this.lastMoveTime timestamp } this.draw() }为什么不直接setInterval来控制蛇移动诚实地讲setInterval在这个场景其实也能用但有个一致性问题setInterval的回调可能会因为主线程忙而出现积压导致多次快速连续执行而且它和渲染不同步可能出现蛇移动了画面却没更新。requestAnimationFrame 天然和浏览器渲染帧率同步在 60Hz 的屏幕上是 16.67ms 触发一次移动节奏通过时间差来卡稳定且不掉帧。loop定义为箭头函数的原因是requestAnimationFrame(this.loop)里的 this 上下文如果换成一个普通函数在回调执行时 this 会指向 window。箭头函数则自动绑定当前实例。4.5 更新逻辑碰撞检测的先后顺序update()是整个单帧决策的集中地。我处理碰撞的顺序是先吃食物再撞自己再撞墙。private update(): void { const head this.snake.body[0] const foodPos this.food.position // 吃了食物吗 const isEating head.x foodPos.x head.y foodPos.y // 让蛇移动吃到食物就增长 this.snake.move(isEating) if (isEating) { this.score 10 this.food.generate(this.snake.body) } if (this.snake.isHitSelf()) { this.over() return } if (this.snake.isHitWall()) { this.over() return } }先判断吃再调用 move。这里有个潜在的问题如果先调用 move 再判断吃此时蛇头已经移动了就必须用移动前的坐标做判断逻辑上更绕。先判断再移动代码读起来顺畅得多。isHitWall之所以放在isHitSelf之后是因为如果蛇头和墙壁重合时同时也和自己的身体重合报哪个错都合理。但实际游戏里蛇头撞墙后不再移动此时蛇身不可能超过边界所以先后顺序影响不大保持这个顺序只是因为读代码时先近后远更自然。4.6 开始、结束、重绘private start(): void { this.score 0 this.snake.reset() this.food.generate(this.snake.body) this.status running this.lastMoveTime performance.now() } private over(): void { this.status over } private draw(): void { const { ctx } this ctx.clearRect(0, 0, CONFIG.canvasWidth, CONFIG.canvasHeight) ctx.fillStyle CONFIG.colorBg ctx.fillRect(0, 0, CONFIG.canvasWidth, CONFIG.canvasHeight) this.food.draw(ctx) this.snake.draw(ctx) // 游戏状态文字 if (this.status idle) { this.drawCenterText(按 Enter 开始游戏) } else if (this.status paused) { this.drawCenterText(暂停中按空格继续) } else if (this.status over) { this.drawCenterText(游戏结束得分 ${this.score}按 Enter 重来) } } private drawCenterText(text: string): void { const { ctx } this ctx.save() ctx.fillStyle #333 ctx.font bold 24px sans-serif ctx.textAlign center ctx.textBaseline middle ctx.fillText(text, CONFIG.canvasWidth / 2, CONFIG.canvasHeight / 2) ctx.restore() }draw 里的一个细节是 clearRect 之后用背景色铺底。如果不铺底背景默认是透明的看起来还行但如果你在 draw 里用了部分透明度的颜色比如半透明的蛇身下一帧绘制时会和上一帧残留叠加形成拖影。先铺一层不透明底色能彻底避免这个视觉错误。还有一个容易忽略的点drawCenterText 里的ctx.save()和ctx.restore()。save 保存当前的绘图状态到栈里restore 恢复。这里之所以必须用是因为我改了textAlign和textBaseline。如果不恢复下一次绘制几何图形时这些文字对齐属性会一直生效可能导致后续绘制出现莫名其妙的结果。但凡你在绘制过程中改了 Canvas 的全局状态画完就恢复是一个好习惯。5. 常见问题与排查技巧实录写贪吃蛇的过程当中有几类问题出现频率极高。我把自己调试过程中踩过的坑整理一下按症状、原因、解法的方式列出来方便你对照排查。5.1 症状一蛇身显示方向相反现象是蛇头在最右但身体往右排看起来蛇在倒着走。排查后发现是初始化时 push 的顺序导致的。如果你的代码写成this.body.push({ x: startX i, y: startY })i 从 0 递增那么第 0 项是 startX第 1 项是 startX 1第 2 项是 startX 2以此类推。但我们的设计是 body[0] 永远是蛇头而蛇向右走时头必须在最右边。所以 body[0] 的 x 必须最大。正确的写法是this.body.push({ x: startX - i, y: startY })这样第 0 项 x 最大向右走时头部插入的新位置永远比原来大 1视觉和逻辑都是正确的。这个坑本质上是数组第 0 项是蛇头这个约定没有贯彻到底。我建议在代码注释里写上第 0 项为蛇头以后自己看代码也不会犯迷糊。5.2 症状二快速连按方向键导致蛇反向瞬移假设蛇向右移动玩家按了一下上紧接着按了一下左。如果没有 pendingDirection 缓冲第二次按键后 direction 变成左但蛇头还在向右移动下一次 move 时 head.x - 1 就会让蛇头直接往左而蛇身第二节还在原来的位置蛇头就直接撞进了第二节的位置游戏可能瞬间判负。玩家会觉得莫名其妙。解法就是我在 Snake 类里实现的 setDirection 加 pendingDirection 机制。在设置方向时检查新方向是否为当前方向的反向或同向如果是就直接丢弃。然后再利用 movedThisTurn 做缓冲只允许方向变化一次多余的进入末位等待。这是最稳妥的做法。5.3 症状三方向键按下时页面跟着滚动如果你没在 keydown 里调 preventDefault方向键默认会让浏览器滚动页面尤其是你没有给页面设置 overflow hidden 的时候。用户按下方向键游戏画面没反应滚动条倒是动了。解决方式是 keydown 回调里对方向键统一调用 e.preventDefault()这在 bindEvents里已经处理了。5.4 症状四游戏结束后蛇还在画布上画面有残留游戏结束后 status 变成 overloop 里走的是 draw 分支draw 会先清空画布再重绘食物和蛇所以画面理论上不会残留旧帧。如果你发现残留多半是清屏写错了比如ctx.clearRect(0, 0, 100, 100)clearRect 的区域小于画布实际尺寸导致边缘没清掉。用 CONFIG.canvasWidth 和 CONFIG.canvasHeight 代替魔法数字可以解决。5.5 症状五食物生成到蛇身上食物重新生成时应该把蛇身的所有坐标传给 generate 方法作为禁止区域。留意这个调用时机应该在蛇移动并且长大完成之后调用否则传的蛇身还是旧的缺少刚长出来的那一节。这个顺序我一般在 update 里固定为move - 分数 - 重新生成食物。5.6 症状六requestAnimationFrame 没有启动或者被浏览器节流浏览器为了省电会对后台标签页的 requestAnimationFrame 做节流长时间挂后台可能完全停止回调。这会导致什么你切出去一分钟再切回来发现蛇没有按照时间戳累计跳跃移动而是重新在这个位置继续走。这不是 bug而是浏览器行为。如果你希望切回来时游戏立即补上这一分钟的移动可以在 loop 里检测 timestamp - lastMoveTime 如果大于某个阈值就只更新一次避免蛇一步跨出十万八千里。我试过这个方案效果不错带来的问题是游戏难度会在切回来瞬间拔高所以也可以选择接受浏览器节流不做额外处理。5.7 关于获取 canvas 上下文时的感叹号代码里出现了这样的写法this.ctx canvas.getContext(2d)!TypeScript 里getContext的返回值类型是CanvasRenderingContext2D | null。当你非常确定 canvas 元素存在且能拿到 2d 上下文时可以用非空断言!来消除 null。但如果 canvas 的 id 写错了或者 canvas 元素还没渲染到 DOM 就拿它这里会抛出运行时错误而不是编译错误。比较稳妥的写法是判空后抛出明确错误const ctx canvas.getContext(2d) if (!ctx) throw new Error(无法获取 canvas 上下文)这个写法更安全错误信息也更明确。教学项目里直接用非空断言省事但心里要清楚它到底在做什么别在团队项目里随手用感叹号。5.8 关于分数和速度的平衡最后聊一个很多人做完游戏后都会问的问题怎么让游戏越来越难最常用的方法是分数越高速度越快。我之前试过在 update 里动态计算const BASE_SCORE 10 const EXTRA_SCORE this.score * 0.5 const LIMIT_SPEED 60 // 最快间隔毫秒 CONFIG.snakeSpeed Math.max(LIMIT_SPEED, BASE_SPEED - EXTRA_SCORE)但这么做有个副作用蛇速变快后玩家的反应时间变短如果速度增长过于陡峭游戏很快就变得不可玩。建议每次只减少 5 到 10 毫秒并且设置一个下限比如 60 毫秒保证游戏的可行性。具体阈值建议你在真机上调因为不同人的反应速度和显示器刷新率会有差异。我自己的实测经验60 毫秒是最快的极限再快人眼已经很难判断蛇头位置了。80 毫秒到 100 毫秒是高手还能玩普通玩家勉强跟得上的甜点区。你也可以用累加得分的方式来分段提速比如每吃 5 个食物提升一档速度这样玩家有明显的节奏感而不是每吃一个就变快一点那种体验反而很累。还有一个值得尝试的扩展方向食物得分可以多样化。给普通食物 10 分偶尔出现一个特殊食物 50 分限时出现几秒后消失。这会迫使玩家在追高分的风险和稳定推进之间做取舍。当然这是进阶玩法了基础版先把状态机跑通再往上叠加复杂度。蛇向前的方向判断我最后再强调一遍在 move 里方向的计算用 this.direction而不是 this.pendingDirection。this.pendingDirection 只是缓冲真正的移动方向是在每次移动完成时从 pendingDirection 同步过来的。如果搞反了会出现按一下方向键蛇立刻转向但画面还没移动视觉上非常割裂。这个顺序我在调试时栽过跟头所以拿出来单独提醒你。代码写到这里贪吃蛇的核心类就全部完成了。你可以打开浏览器按 Enter 开始用方向键或 WASD 控制空格暂停死了按 Enter 重来。整个过程跑通之后再回头去读 snake.ts 和 game.ts 这两个文件会发现所有的复杂性都被类的边界切成了小块。这个感觉就是面向对象设计在实战项目中最直接的回报。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询