上一篇我们把项目骨架搭起来了:页面、画布和 Food 类都做好了,食物能够随机出现在 20×20 格子里。但把页面在浏览器里打开后,蛇就像一条没有接线的机器,一动不动。本文继续这个 TypeScript 实战系列,完成核心的 Snake 类和 GameControl 类,让这条蛇真正跑起来——键盘控制、移动、吃食物、增长、撞墙判定、计分升级,最后一个可玩的贪吃蛇游戏就齐了。适合已经看完系列第一篇、正在用 TypeScript 练习面向对象写法的同学,也适合想从代码层面搞明白“游戏循环 + 输入监听 + 碰撞检测”是怎么协作的人。
1. 本节要处理的问题清单:从静态食物到完整游戏
1.1 Food 类的接口回顾与对接约定
先花两分钟把上一篇的成果对一下,因为 Snake 和 GameControl 都要围绕 Food 的坐标做文章。上一篇实现的 Food 类,对外基本上就是三个东西:
coordinategetter,返回{ x, y },注意这里存的是格子坐标,不是像素坐标;change()方法,随机刷新到新的格子;- 一个内部 div,负责把食物画到舞台上。
要做的事情很明确:整个舞台按 20 列、20 行划分,每个格子 20px,舞台总尺寸就是 400×400。所有物体定位时统一用x * 20作为 left,y * 20作为 top。这个约定不只在 Food 里成立,Snake 每一节同样遵守。凡是涉及“蛇头吃到食物”的判断,本质都是比较两个格子坐标是否相同,而不是比较两个 div 的像素位置。
1.2 为什么单独拆 Snake 和 GameControl
很多初学者写着写着会把所有逻辑堆到一个类里:蛇在 controller 里,食物也在 controller 里,键盘事件还在 controller 里,跑起来是能跑,但后期加暂停、重开、升级时会非常痛苦。
这一节我坚持拆两个类,职责边界很简单:
- Snake 类只关心“我的身体有哪些格子”“我移动到哪个格子”“我增长还是保持长度”,以及“我撞到谁了”。它不知道键盘,不知道分数,不知道食物在哪。
- GameControl 类关心游戏编排:键盘输入怎么转成方向,定时器什么时候 tick,蛇吃了食物之后分数怎么加,等级什么时候提升,游戏什么时候结束。
好处在调试阶段特别明显。比如蛇的 DOM 渲染出了问题,我直接打开页面手动改 Snake 内部的 body 数组,根本不需要碰键盘事件;比如方向队列有问题,也可以在控制台只调用 GameControl 的按键处理函数,其他逻辑完全不受影响。
1.3 游戏主循环需要回答的三个问题
任何一个网格类游戏,主循环都在反复回答三个问题:
- 蛇下一步会走到哪个格子?
- 那个格子是墙、是蛇身、是食物,还是空地?
- 对应动作是结束、增长、还是普通移动?
本节的 Snake 类和 GameControl 类,本质就是分别回答这三个问题中的一部分。Snake 提供“身体在哪”和“能不能去”的数据基础,GameControl 负责每个节拍把这三个问题串成一条完整逻辑链。理解了这个框架,后面写代码就不会迷路。
2. Snake 类的设计细节:坐标、移动、渲染与碰撞查询
2.1 坐标存储:Point 数组 vs 字符串拼接
蛇的坐标存储,我见过两派做法。一派用字符串"3_5",理由是判重和查 Set 方便;另一派用对象{ x: 3, y: 5 }。我在这个项目里选择对象数组。
export interface Point { x: number; y: number; }对象数组在 TypeScript 里类型非常干净,后续getNextHead、willCollide、食物位置比较全都复用这个Point接口。字符串方案虽然在“新头是否撞到身体”这种判重场景里可以直接把字符串扔进 Set,但这会牺牲类型表达力,而且每算一步都要拼接和拆分,反而容易出错。
如果你担心调试时看坐标不够直观,可以给 Snake 加一个日志友好的方法:
debugInfo(): string { return this.body.map(p => `${p.x},${p.y}`).join(' -> '); }需要看蛇全貌时在控制台打一行snake.debugInfo(),比用字符串存储舒服得多。这也是我最终放弃字符串方案的核心理由:类型清晰优先,调试需求可以通过工具方法解决。
2.2 移动算法:头部插入尾部删除
再来说移动。蛇的移动有两种实现思路:
- 从尾部到头部逐个格子前移,让每一个 body 节点都走到前一个节点的位置;
- 只关注头部:在新头部位置插入一个格子,如果长度不变,就把尾部弹出一个格子。
第一种思路很符合直觉,但实现繁琐,而且增长时逻辑要额外处理。第二种思路是贪吃蛇最经典的算法,因为蛇的移动本质上就是“头部带着后面走”:头部新增一格,尾部消失一格,视觉上就是整条蛇往前挪了一步。
move(nextHead: Point, grow: boolean): void { this.body.unshift(nextHead); if (!grow) { this.body.pop(); } this.render(); }如果吃到了食物,grow传true,那么头部插入后不弹尾部,蛇身长度加一;否则就保持长度。这个设计最大的价值在于,增长和普通移动共用同一个移动入口,不存在两条移动逻辑分支互相打架的情况。
2.3 DOM 渲染与增长时的节点维护
Snake 内部维护一个bodyElements: HTMLDivElement[],每个元素对应蛇身的一节。渲染时,我先把节点数量和 body 数组长度对齐,再逐节设置位置和样式。
private render(): void { while (this.bodyElements.length < this.body.length) { const el = document.createElement('div'); el.className = 'snake-body'; this.container.appendChild(el); this.bodyElements.push(el); } while (this.bodyElements.length > this.body.length) { const el = this.bodyElements.pop(); el?.remove(); } this.bodyElements.forEach((el, index) => { const point = this.body[index]; el.style.left = `${point.x * this.cellSize}px`; el.style.top = `${point.y * this.cellSize}px`; el.className = index === 0 ? 'snake-head' : 'snake-body'; }); }这里有个容易踩的坑:删除多余节点时要用pop(),不要用shift()。因为 DOM 节点数组和 body 数组是按索引对应的,多余的一定是末尾那节。如果用了shift(),会把第一节蛇头对应的 DOM 节点删掉,视觉上出现“蛇断头”的诡异效果。我在调试这个项目时真的被这个操作坑过一次,后来把删除逻辑改回pop()之后一切都正常了。
2.4 给 GameControl 曝光的查询接口
Snake 不需要把自己内部的数组直接丢给外部,而是提供一组语义明确的方法。这个项目里我暴露了这几个:
get head():返回蛇头坐标,供控制器计算下一格;get coordinates():返回整个蛇身数组,供食物重生成时做重叠检查;move(nextHead, grow):执行移动;willCollide(nextHead, willGrow):预判新蛇头是否会和身体冲突;reset():恢复初始三节身体,供重开游戏使用。
willCollide的实现我放到第 4 节专门讲,因为它的边界判断直接决定“蛇能不能追自己的尾巴”,属于这个游戏里最精细的一个逻辑点。先把接口留出来,控制器只需要知道调用它就能判断是否撞自己,不需要理解内部细节。
3. GameControl 控制器:键盘、定时器、状态机的协作
3.1 键盘方向映射与页面滚动拦截
GameControl 要干的第一个活是把键盘输入翻译成方向。我维护了一个映射表,同时支持方向键和 WASD:
const directionMap: Record<string, Direction> = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right', };这里有两个细节值得注意。
第一,event.key和event.code的选择。我用的是event.key,因为ArrowUp、w这样的字符串可读性好,而且大写W在按下 Shift 时会变成W,其实映射表里最好同时加一个W: 'up',或者统一转成小写处理。我在代码里使用的方案是检查event.key.toLowerCase(),这样大小写都不影响。
第二,方向键和空格键在浏览器里默认会滚动页面。如果游戏跑到一半按下方向键,页面突然滚起来,体验非常糟糕。所以监听回调里第一件事就是拦截这些按键的默认行为:
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) { event.preventDefault(); }3.2 setInterval 驱动的 tick 循环
游戏循环我用的是window.setInterval,而不是requestAnimationFrame。为什么?
| 维度 | setInterval | requestAnimationFrame |
|---|---|---|
| 计时粒度 | 离散的毫秒间隔,适合回合制逻辑 | 每帧回调,适合连续动画 |
| 控制速度 | 直接改 interval 毫秒数 | 需要自己记录上次更新时间 |
| 浏览器节能 | 不考虑页面是否可见 | 页面隐藏时自动暂停 |
| 典型场景 | 贪吃蛇、俄罗斯方块、回合制游戏 | 物理引擎、平台跳跃、Canvas 游戏 |
贪吃蛇的移动是格子到格子的离散跳变,天然就应该是“每 300ms 走一格”。用setInterval反而最直接:一个 tick 就是一步,不存在中间帧的概念。如果硬用requestAnimationFrame,还得自己维护“上一次移动的时间戳”,逻辑复杂度会上升不少。
我实际用的定时器初始化逻辑是这样的:
private startTimer(): void { window.clearInterval(this.timer); this.timer = window.setInterval(() => this.loop(), this.getInterval()); }注意这里的window.前缀。在浏览器项目里,setInterval返回的是number,但如果在 Node 环境里类型定义不同,直接写setInterval可能会被推断成NodeJS.Timeout。显式用window.setInterval能让 TypeScript 明确这是浏览器环境,类型更稳。
3.3 状态机:ready/running/paused/over
游戏不能永远只有一个“运行中”状态,否则空格暂停、按钮重开都无从实现。我定义了一个联合类型:
type GameStatus = 'ready' | 'running' | 'paused' | 'over';四个状态的含义:页面刚加载时是ready,玩家还没开始操作;点击开始后进入running,定时器在工作;按空格或点击按钮后进入paused,定时器被清掉;蛇撞墙或撞自己后进入over,游戏结束且不再响应用户方向键。
控制状态的入口是togglePause:
private togglePause(): void { if (this.status === 'running') { this.status = 'paused'; window.clearInterval(this.timer); } else if (this.status === 'paused' || this.status === 'ready') { this.start(); } }从over状态重新开始时,需要先重置分数和蛇身。我在start()里做了判断:如果当前状态是over,就调用snake.reset(),分数清零,等级恢复为 1,再开定时器。这样玩家不用刷新页面也能继续玩,体验会完整很多。
3.4 速度调整:clearInterval 后重建 timer
吃到食物会加分,分数达到一定值会升级,升级会让蛇跑得更快。这里最常出现的一个 bug 是:直接改一个speed变量,以为下一次 tick 就会按新速度跑。
但setInterval的间隔时间是在创建时定死的,后续修改变量不会影响已经创建的定时器。正确做法是每次都重建定时器:
private getInterval(): number { return Math.max(80, 300 - (this.level - 1) * 20); } private startTimer(): void { window.clearInterval(this.timer); this.timer = window.setInterval(() => this.loop(), this.getInterval()); }升级时只需要调用一次startTimer(),旧定时器被清掉,新定时器按新速度启动。如果不先clearInterval,就会出现两个定时器同时在跑,蛇一帧走两步,速度看起来翻倍,而且越积越多。
4. 边界条件与体验细节:判断顺序、撞自己精确性、重开逻辑
4.1 每一步 tick 里的动作顺序
很多贪吃蛇项目一开始能跑,但稍加操作就出现各种“灵异事件”:蛇穿墙之后才显示游戏结束、头已经进到身体里才判碰撞、食物卡在身体里。这些问题九成出在 tick 内的执行顺序上。
我的loop()里严格按下面顺序执行:
private loop = (): void => { if (this.directionQueue.length > 0) { this.direction = this.directionQueue.shift()!; } const head = this.snake.head; const nextHead = this.getNextHead(head, this.direction); if (this.isOutOfBounds(nextHead)) { this.gameOver(); return; } const willEat = nextHead.x === this.food.coordinate.x && nextHead.y === this.food.coordinate.y; if (this.snake.willCollide(nextHead, willEat)) { this.gameOver(); return; } this.snake.move(nextHead, willEat); if (willEat) { this.regenerateFood(); this.addScore(10); } };核心原则是:先算出“下一格”,再在“还没有真的移动”的时候做所有判断。如果先移动再检测,蛇已经踩进墙里或者身体已经互相穿插,这时候再弹 Game Over 就晚了,画面上还会留下错误的脏位置。
吃食物的检查也要放在移动之前。因为willEat这个布尔值同时决定了蛇这一帧是否增长,而增长与否又直接影响碰撞检测的范围。把这个顺序反过来,逻辑就会变得非常别扭。
4.2 撞墙与撞自己的精确判定(追尾合法性问题)
撞墙检测没什么悬念:
private isOutOfBounds(p: Point): boolean { return p.x < 0 || p.x >= this.columns || p.y < 0 || p.y >= this.rows; }碰撞检测里真正讲究的是撞自己。我先说一个我们项目里遇到的真实场景:蛇身围成一个圈,蛇头下一步要进入蛇尾即将离开的那一格。这种情况下,由于这一帧尾部会被移除,新蛇头进去之后身体并不会重叠,这其实是合法移动。但如果你直接拿“新头是否在当前整个 body 数组里”判断,就会把这种合法移动误判为游戏结束。
所以willCollide必须考虑这一帧是否会删掉尾部:
willCollide(nextHead: Point, willGrow: boolean): boolean { const bodyToCheck = willGrow ? this.body : this.body.slice(0, -1); return bodyToCheck.some(p => p.x === nextHead.x && p.y === nextHead.y); }如果这一帧吃食物,尾部保留,那么判断范围内必须包含整个 body;如果这一帧不吃食物,尾部会弹出,那么判断范围就排除最后那节。看起来只是slice(0, -1)的区别,实际上决定了玩家在蛇尾追尾时能不能安全走位。
4.3 食物重生不与蛇身重叠
吃食物后重新生成食物,最朴素的实现是food.change()随机换一个位置。但这个随机位置如果正好落在蛇身上,食物会被蛇身盖住,玩家看不到食物,也不知道该往哪走,体验极差。
我的做法是循环重新生成,直到位置不与蛇身重合:
private regenerateFood(): void { do { this.food.change(); } while (this.snake.coordinates.some( p => p.x === this.food.coordinate.x && p.y === this.food.coordinate.y )); }正常情况下这个循环最多跑几次就会结束,因为 20×20 的格子有 400 个位置。只有当蛇身几乎占满整个舞台时,才可能出现长时间循环。那种极端情况人类玩家基本玩不出来,所以代码里不做额外保护也说得过去。如果你追求稳妥,可以加一个最大重试次数,超过后把游戏直接判负,但这属于防御性编程,正常项目里很少触发。
4.4 快速连按方向键的问题与方向队列思路
玩过贪吃蛇的人都知道,快速连续按两个方向键是常规操作。比如蛇正在向右走,玩家想迅速转到上方再转向左,实际手指可能在一瞬间同时按了up和left。
如果控制器只保存“最新一次方向”,第二次按键left会直接覆盖up,蛇在下一个 tick 直接从向右变成向左。这时候如果蛇身就在下方,瞬间就会撞车。这不是玩家想要的路径,却会被判定为事故,非常打击游戏手感。
解决思路是引入方向队列:按键时不覆盖,而是把合法方向追加到队列尾部;每个 tick 只消费队头的一个方向。
private handleKeyDown = (event: KeyboardEvent): void => { // ... const last = this.directionQueue.length > 0 ? this.directionQueue[this.directionQueue.length - 1] : this.direction; if (oppositeMap[last] === dir) { return; } if (this.directionQueue.length < 3) { this.directionQueue.push(dir); } };检查“是否反向”时,参照的不是this.direction,而是队列尾部的方向。因为虽然队列里可能存了两个方向,但蛇还没有执行,真正决定当前姿态的应该是最后一个还没跑的方向。队列长度限制在 3,是为了防止玩家狂按键盘导致方向积压太久,蛇半天转不过弯。
5. 完整代码整合与 TS 严格模式实战注意
5.1 项目文件结构与 tsconfig 关键配置
到这里,核心逻辑都讲完了。把整个项目串起来时,目录结构是这样的:
snake-game/ ├── index.html ├── style.css ├── tsconfig.json └── src/ ├── index.ts ├── Food.ts ├── Snake.ts └── GameControl.tstsconfig.json我建议至少开启这些配置:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "rootDir": "src", "outDir": "dist", "strict": true, "noUnusedLocals": true, "noImplicitReturns": true }, "include": ["src"] }strict必须开,否则null检查形同虚设;noUnusedLocals和noImplicitReturns能让代码更整洁,尤其noImplicitReturns会逼你把getNextHead里的 switch 写完整,防止漏掉某个方向。
5.2 HTML 与 CSS 的连接点
HTML 里需要三个关键元素:舞台容器#stage、计分面板#score-panel、开始按钮#start-btn。
<div id="score-panel"> <span>SCORE: <b id="score">0</b></span> <span>LEVEL: <b id="level">1</b></span> <span>方向键/WASD移动,空格暂停</span> </div> <div id="stage"></div> <button id="start-btn">开始 / 继续</button> <script type="module" src="./dist/index.js"></script>CSS 部分只需要保证三件事:舞台是相对定位且大小固定、每个格子是 20px、蛇头和身体有视觉区分。
#stage { position: relative; width: 400px; height: 400px; background: #1c1c1c; border: 2px solid #333; } .snake-head, .snake-body, .food { position: absolute; width: 20px; height: 20px; border-radius: 4px; } .food { background: #ff9800; } .snake-head { background: #66bb6a; z-index: 2; } .snake-body { background: #2e7d32; }TS 编译后,直接在浏览器里打开 index.html 是不行的。因为代码使用了 ES Module,file://协议下模块加载会被 CORS 拦掉。你需要一个本地静态服务器,比如npx serve .或者python3 -m http.server 8080,然后访问http://localhost:8080。如果不想折腾服务器,也可以用 Vite 这类开发服务器,原理一样。
5.3 Food.ts 最终代码
Food 类是上一节的成果,这里直接给出可复用的完整版。注意change()里随机生成坐标后要马上更新 DOM,否则坐标和视觉位置会脱节。
export class Food { private element: HTMLDivElement; private point: { x: number; y: number }; constructor( private containerId: string, private cellSize: number, private columns: number, private rows: number, ) { const container = document.getElementById(containerId); if (!container) throw new Error(`找不到容器: ${containerId}`); this.element = document.createElement('div'); this.element.className = 'food'; container.appendChild(this.element); this.point = { x: 0, y: 0 }; this.change(); } get coordinate() { return this.point; } change(): void { this.point = { x: Math.floor(Math.random() * this.columns), y: Math.floor(Math.random() * this.rows), }; this.render(); } private render(): void { this.element.style.left = `${this.point.x * this.cellSize}px`; this.element.style.top = `${this.point.y * this.cellSize}px`; } }5.4 Snake.ts 最终代码
Snake 的完整代码里,render()的节点维护、willCollide()的尾部排除逻辑是最容易写错的地方。逐行对照注释看一下,确保你的版本和这个行为一致。
export interface Point { x: number; y: number; } export class Snake { private container: HTMLElement; private body: Point[]; private bodyElements: HTMLDivElement[] = []; constructor( private cellSize: number, private columns: number, private rows: number, ) { const container = document.getElementById('snake'); if (!container) throw new Error('找不到 id 为 snake 的容器'); this.container = container; this.body = this.createInitialBody(); this.render(); } private createInitialBody(): Point[] { const headX = Math.floor(this.columns / 2); const headY = Math.floor(this.rows / 2); return [ { x: headX, y: headY }, { x: headX - 1, y: headY }, { x: headX - 2, y: headY }, ]; } get head(): Point { return this.body[0]; } get coordinates(): readonly Point[] { return this.body; } move(nextHead: Point, grow: boolean): void { this.body.unshift(nextHead); if (!grow) { this.body.pop(); } this.render(); } reset(): void { this.body = this.createInitialBody(); this.render(); } willCollide(nextHead: Point, willGrow: boolean): boolean { const bodyToCheck = willGrow ? this.body : this.body.slice(0, -1); return bodyToCheck.some(p => p.x === nextHead.x && p.y === nextHead.y); } private render(): void { while (this.bodyElements.length < this.body.length) { const el = document.createElement('div'); el.className = 'snake-body'; this.container.appendChild(el); this.bodyElements.push(el); } while (this.bodyElements.length > this.body.length) { const el = this.bodyElements.pop(); el?.remove(); } this.bodyElements.forEach((el, index) => { const point = this.body[index]; el.style.left = `${point.x * this.cellSize}px`; el.style.top = `${point.y * this.cellSize}px`; el.className = index === 0 ? 'snake-head' : 'snake-body'; }); } }5.5 GameControl.ts 最终代码
GameControl 是整套逻辑的编排者。注意方向队列、定时器重建、碰撞预判这三个地方,是手感好坏的关键。
import { Snake, Point } from './Snake'; import { Food } from './Food'; type Direction = 'up' | 'down' | 'left' | 'right'; type GameStatus = 'ready' | 'running' | 'paused' | 'over'; const directionMap: Record<string, Direction> = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right', }; const oppositeMap: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left', }; export class GameControl { private snake: Snake; private food: Food; private status: GameStatus = 'ready'; private direction: Direction = 'right'; private directionQueue: Direction[] = []; private timer: number = 0; private score = 0; private level = 1; private readonly cellSize = 20; private readonly columns = 20; private readonly rows = 20; constructor() { this.food = new Food('stage', this.cellSize, this.columns, this.rows); this.snake = new Snake(this.cellSize, this.columns, this.rows); window.addEventListener('keydown', this.handleKeyDown); const startBtn = document.getElementById('start-btn'); startBtn?.addEventListener('click', () => this.togglePause()); this.updatePanel(); this.start(); } start(): void { if (this.status === 'over') { this.snake.reset(); this.score = 0; this.level = 1; this.updatePanel(); } this.directionQueue = []; this.direction = 'right'; this.status = 'running'; this.startTimer(); } private startTimer(): void { window.clearInterval(this.timer); this.timer = window.setInterval(() => this.loop(), this.getInterval()); } private getInterval(): number { return Math.max(80, 300 - (this.level - 1) * 20); } private loop = (): void => { if (this.directionQueue.length > 0) { this.direction = this.directionQueue.shift()!; } const head = this.snake.head; const nextHead = this.getNextHead(head, this.direction); if (this.isOutOfBounds(nextHead)) { this.gameOver(); return; } const willEat = nextHead.x === this.food.coordinate.x && nextHead.y === this.food.coordinate.y; if (this.snake.willCollide(nextHead, willEat)) { this.gameOver(); return; } this.snake.move(nextHead, willEat); if (willEat) { this.regenerateFood(); this.addScore(10); } }; private handleKeyDown = (event: KeyboardEvent): void => { if (event.key === ' ') { event.preventDefault(); this.togglePause(); return; } const dir = directionMap[event.key.toLowerCase()]; if (!dir) return; event.preventDefault(); if (this.status !== 'running') return; const last = this.directionQueue.length > 0 ? this.directionQueue[this.directionQueue.length - 1] : this.direction; if (oppositeMap[last] === dir) return; if (this.directionQueue.length < 3) { this.directionQueue.push(dir); } }; private getNextHead(head: Point, dir: Direction): Point { switch (dir) { case 'up': return { x: head.x, y: head.y - 1 }; case 'down': return { x: head.x, y: head.y + 1 }; case 'left': return { x: head.x - 1, y: head.y }; case 'right': return { x: head.x + 1, y: head.y }; } } private isOutOfBounds(p: Point): boolean { return p.x < 0 || p.x >= this.columns || p.y < 0 || p.y >= this.rows; } private regenerateFood(): void { do { this.food.change(); } while (this.snake.coordinates.some( p => p.x === this.food.coordinate.x && p.y === this.food.coordinate.y )); } private addScore(points: number): void { this.score += points; const newLevel = Math.floor(this.score / 100) + 1; if (newLevel !== this.level) { this.level = newLevel; this.startTimer(); } this.updatePanel(); } private updatePanel(): void { const scoreEl = document.getElementById('score'); const levelEl = document.getElementById('level'); if (scoreEl) scoreEl.textContent = String(this.score); if (levelEl) levelEl.textContent = String(this.level); } private gameOver(): void { this.status = 'over'; window.clearInterval(this.timer); console.log('Game Over'); } private togglePause(): void { if (this.status === 'running') { this.status = 'paused'; window.clearInterval(this.timer); } else if (this.status === 'paused' || this.status === 'ready') { this.start(); } } }5.6 this 绑定、非空断言、setInterval 类型收窄等 TS 细节
TypeScript 严格模式下,有几个地方是初学者高频翻车点。
第一个是事件回调里的this。如果你写成:
window.addEventListener('keydown', this.handleKeyDown); handleKeyDown(event: KeyboardEvent) { ... }那么回调执行时this会指向window,而不是 GameControl 实例。解决方式有三个:箭头函数属性、bind(this)、或者在外层再包一层箭头函数。我代码里用的是箭头函数属性,这是最省心、类型最干净的做法。
第二个是document.getElementById()的返回值类型。它返回HTMLElement | null,直接使用会报Object is possibly null。有些人喜欢用!非空断言快速跳过,但更稳妥的方式是先判断再抛错:
const container = document.getElementById('snake'); if (!container) throw new Error('找不到容器');这样如果 HTML 结构写错了,你能在控制台看到一个明确的错误信息,而不是让浏览器在后续使用空引用时报一个莫名其妙的错。
第三个是setInterval的返回值类型。虽然浏览器里是number,但如果你在 tsconfig 中引入了某些库类型,或者项目环境比较复杂,直接写setInterval可能被推断成NodeJS.Timeout。我的习惯是显式写window.setInterval和window.clearInterval,从一开始就锁定浏览器环境。
5.7 自测流程与两个调试技巧
代码写完,建议按下面的流程自测一遍,能覆盖这个游戏 90% 的常见问题:
- 打开页面,确认蛇自动向右移动,食物随机出现在不覆盖蛇身的位置;
- 按空格键,确认蛇停止移动,再按一次恢复;
- 在蛇向右移动时快速按
上再按左,确认蛇先向上走一格再向左,而不是直接左转; - 让蛇头吃到食物,确认蛇身长度加一、分数加 10;
- 把分数刷到 100 分,确认等级变 2 且速度明显加快;
- 故意撞墙,确认游戏结束且方向键不再生效;
- 游戏结束后点击开始按钮,确认蛇重置、分数清零、可以重新开始。
最后分享两个实用调试技巧。第一,把 GameControl 实例挂到window上,比如在入口文件里临时写一行(window as any).game = new GameControl(),这样在浏览器控制台可以直接调用game.snake.move或手动设置方向队列,比反复刷新页面快得多。第二,如果发现蛇身渲染出错,优先在控制台执行document.getElementById('snake').innerHTML,看蛇的 DOM 节点数量对不对;如果节点数正确但位置错乱,再去核对cellSize和格子坐标的换算关系。这两个方法帮我定位过好几次逻辑和渲染之间脱节的问题,很值得一试。
我在实际做这个项目时,最大的坑其实不是类型,而是把碰撞检测放在了 move 之后,导致蛇经常出现“越界后才弹结束”的诡异画面。改成先预判、再移动的顺序之后,整个逻辑一下子就顺了,手感也正常了。希望这份实现能帮你把 TypeScript 面向对象设计真正落到一个小游戏里。