上一篇我们把项目骨架搭起来了页面、画布和 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作为 lefty * 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 要干的第一个活是把键盘输入翻译成方向。我维护了一个映射表同时支持方向键和 WASDconst directionMap: Recordstring, 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。为什么维度setIntervalrequestAnimationFrame计时粒度离散的毫秒间隔适合回合制逻辑每帧回调适合连续动画控制速度直接改 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游戏结束且不再响应用户方向键。控制状态的入口是togglePauseprivate 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 idscore-panel spanSCORE: b idscore0/b/span spanLEVEL: b idlevel1/b/span span方向键/WASD移动空格暂停/span /div div idstage/div button idstart-btn开始 / 继续/button script typemodule src./dist/index.js/scriptCSS 部分只需要保证三件事舞台是相对定位且大小固定、每个格子是 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 Modulefile://协议下模块加载会被 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: Recordstring, Direction { ArrowUp: up, ArrowDown: down, ArrowLeft: left, ArrowRight: right, w: up, s: down, a: left, d: right, }; const oppositeMap: RecordDirection, 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 面向对象设计真正落到一个小游戏里。