☰
用TypeScript类组织贪吃蛇:面向对象游戏开发实战
2026/10/17 12:35:31 网站建设 项目流程

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,这些魔法数字如果你在每个方法里重复写,后期想要调整的时候得满文件去搜。像我之前做某个项目时就吃过这种亏,改一个参数要按三遍 Ctrl+F。

我建议的做法是直接在 type.ts 里定一个配置对象:

/** 游戏参数配置 */ export const CONFIG = { canvasWidth: 600, canvasHeight: 600, gridSize: 20, snakeInitLength: 4, snakeSpeed: 150, // 单位毫秒,数值越大越慢 colorSnakeHead: '#2d5016', colorSnakeBody: '#4e8d3f', colorFood: '#d1483b', colorBg: '#f5f2e6' } as const

as const会让这些值的类型变成字面量类型,比如canvasWidth的类型就是600而不是number。这有什么好处?好处是如果你后续不小心写了一个CONFIG.canvasWidth = 800,TypeScript 会直接在编译阶段报错,提醒你正在篡改一个只读配置。对游戏这类运行时状态变化频繁的程序来说,这个保护很值。

还有一个细节:蛇的移动速度我用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: Record<string, 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 变成 over,loop 里走的是 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 这两个文件,会发现所有的复杂性都被类的边界切成了小块。这个感觉,就是面向对象设计在实战项目中最直接的回报。

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

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

立即咨询