简介:这是一份基于Egret引擎开发的贪吃蛇小游戏完整源码,面向想入门HTML5游戏开发的新手与在校学生,帮助读者通过一个可运行的小项目理解游戏开发的基本流程。压缩包共118个文件,约1.07MB,以png图片资源、ts与js脚本、exml皮肤布局文件为主,另含json配置、map地图数据及html入口页面,结构完整便于直接运行与二次修改。内容围绕贪吃蛇核心逻辑展开,涵盖坐标系统、事件监听、精灵与舞台、帧序列动画、矩形碰撞检测、Ticker游戏循环、蛇身数据结构、资源预加载与键盘交互、游戏状态切换等知识点,并配有开始、暂停、失败等界面皮肤,方便读者对照代码理解各模块职责。目前已有417人学习下载,适合作为Egret入门练手与课程设计参考。
1. 从一份能直接跑起来的贪吃蛇源码说起
前阵子有个做前端的朋友找我,说想找个结构干净的小游戏项目练手,最好是那种下载下来就能跑、代码量不大但五脏俱全的。我翻了一圈,最后给他推了这份基于 Egret 引擎的贪吃蛇小游戏源码。为什么推它?因为贪吃蛇这个玩法本身逻辑闭环特别清晰——移动、吃食物、变长、撞墙判定,没有多余的系统,但麻雀虽小五脏俱全,游戏循环、资源加载、碰撞检测、状态管理这些核心机制一个不少。对于想入门 Egret 或者想拿个小游戏做二次开发的人来说,这份源码的性价比很高。
Egret 是一套 HTML5 游戏开发引擎,用 TypeScript 写逻辑,最终编译成 JavaScript 跑在浏览器里。这份贪吃蛇项目就是典型的 Egret 工程结构,包含资源文件、场景代码、游戏主逻辑和配置文件。你拿到手之后,用 Egret 官方的开发工具或者命令行就能编译运行,不需要额外搭服务器,本地起个静态服务就能看到效果。接下来我会从项目结构、核心代码、运行步骤到二次开发,把这份源码拆开讲清楚,让你不仅能跑起来,还能知道每一块该往哪儿改。
2. 工程结构与环境搭建:从零把项目跑起来
2.1 目录结构逐个说清楚
拿到源码包,解压后你会看到类似下面这样的目录布局。不同版本的 Egret 工程可能略有差异,但核心结构是一致的:
snake-game/ ├── resource/ # 游戏资源 │ ├── assets/ # 图片、音效等 │ │ ├── snake.png # 蛇身素材 │ │ ├── food.png # 食物素材 │ │ └── bg.png # 背景图 │ └── default.res.json # 资源清单配置 ├── src/ # 源代码目录 │ ├── Main.ts # 入口文件 │ ├── GameScene.ts # 游戏主场景 │ ├── Snake.ts # 蛇的逻辑类 │ ├── Food.ts # 食物逻辑类 │ └── GameConfig.ts # 全局配置常量 ├── egretProperties.json # Egret 项目配置 ├── tsconfig.json # TypeScript 编译配置 └── index.html # 入口页面resource目录放的是游戏用到的图片和音效,default.res.json是资源清单,Egret 在启动时会根据这个清单去加载资源。src目录是核心,Main.ts是入口,GameScene.ts承载游戏主循环和渲染逻辑,Snake.ts和Food.ts分别封装蛇和食物的行为。egretProperties.json里配置了引擎模块和第三方库的引用,tsconfig.json控制 TypeScript 的编译选项。
注意:如果你拿到的源码里
resource目录下没有default.res.json,说明资源清单可能被合并到了别处,或者项目用的是旧版 Egret 的资源管理方式,需要手动确认一下。
2.2 环境准备与编译运行
跑这个项目需要两个东西:Node.js 和 Egret 引擎的命令行工具。Node.js 建议用 14 以上的 LTS 版本,太老的版本可能在安装 Egret 工具链时出问题。安装 Egret 命令行工具的命令如下:
# 全局安装 Egret 命令行工具 npm install -g egret # 安装完成后验证版本 egret --version如果egret命令能正常输出版本号,说明工具装好了。接下来进入项目根目录,执行编译和运行:
# 进入项目目录 cd snake-game # 编译项目,把 TypeScript 编译成 JavaScript egret build # 启动本地调试服务器,默认端口 3000 egret runegret build会读取egretProperties.json和tsconfig.json,把src下的 TypeScript 文件编译到bin-debug目录。egret run会起一个本地静态服务器,浏览器打开http://localhost:3000就能看到游戏画面。
如果你不想装全局的 Egret 工具,也可以用项目自带的package.json里的脚本。有些源码包会包含npm run build和npm run start的快捷命令,效果是一样的。编译过程中如果报模块找不到的错,大概率是egretProperties.json里引用的引擎模块没装全,用egret upgrade命令可以自动补齐依赖。
2.3 资源加载与入口初始化
Egret 游戏的启动流程是:先加载资源清单,资源加载完成后进入主场景。Main.ts里通常长这样:
class Main extends egret.DisplayObjectContainer { public constructor() { super(); // 监听资源加载完成事件 this.addEventListener(egret.Event.ADDED_TO_STAGE, this.onAddToStage, this); } private onAddToStage(event: egret.Event) { // 配置资源加载路径和清单文件 egret.runEgret({ renderMode: "webgl", audioType: 0, calculateCanvasSize: true }); } }这段代码做了两件事:一是监听自己被添加到舞台的事件,二是调用egret.runEgret启动引擎。renderMode指定渲染模式,webgl性能更好,如果设备不支持会自动降级到canvas。calculateCanvasSize设为true会让引擎根据屏幕尺寸自动计算画布大小,适配不同分辨率的设备。
资源加载完成后,引擎会触发egret.Event.COMPLETE,这时候就可以创建游戏场景了。常见做法是在Main里监听这个事件,然后new GameScene()并添加到舞台。如果你发现游戏黑屏但控制台没报错,先检查资源清单里的路径和实际文件是否对得上,这是最常见的翻车点。
3. 核心模块拆解:蛇的移动、食物生成与碰撞判定
3.1 蛇的移动逻辑与方向控制
蛇的移动本质上是维护一个坐标数组,每帧把新头部坐标插到数组最前面,然后根据是否吃到食物决定要不要删掉尾部。Snake.ts里的核心方法大概是这样:
class Snake { private body: egret.Point[] = []; // 蛇身坐标数组,索引 0 是头部 private direction: string = "right"; // 当前移动方向 private gridSize: number = 20; // 每个格子的像素大小 // 每帧移动一步 public move(): void { const head = this.body[0]; let newHead: egret.Point; // 根据当前方向计算新头部坐标 switch (this.direction) { case "up": newHead = new egret.Point(head.x, head.y - 1); break; case "down": newHead = new egret.Point(head.x, head.y + 1); break; case "left": newHead = new egret.Point(head.x - 1, head.y); break; case "right": newHead = new egret.Point(head.x + 1, head.y); break; } // 新头部插入数组最前面 this.body.unshift(newHead); // 默认删掉尾部,如果吃到食物则保留 this.body.pop(); } // 改变方向,禁止 180 度掉头 public changeDirection(newDir: string): void { const opposites = { up: "down", down: "up", left: "right", right: "left" }; if (opposites[newDir] !== this.direction) { this.direction = newDir; } } }body数组用egret.Point存每个格子的逻辑坐标,不是像素坐标,这样碰撞判定和食物生成都基于格子来算,简单可靠。move方法每帧调用一次,先算新头,插到数组头部,再删尾部。changeDirection里做了反向限制,防止蛇直接掉头撞到自己。
方向控制的输入监听一般放在GameScene.ts里,监听键盘的keydown事件或者触摸滑动事件。键盘的话用document.addEventListener("keydown", ...)或者 Egret 自己的事件系统都行。触摸滑动需要计算滑动方向,常见做法是比较touchStart和touchEnd的坐标差,哪个轴的变化大就判定为哪个方向。
3.2 食物生成与碰撞检测
食物生成的核心要求是:不能生成在蛇身上。Food.ts里的实现思路是随机一个格子坐标,然后检查这个坐标是否在蛇的body数组里,如果在就重新随机:
class Food { public position: egret.Point; // 在网格范围内随机生成食物,避开蛇身 public generate(snakeBody: egret.Point[], maxCol: number, maxRow: number): void { let valid = false; while (!valid) { const x = Math.floor(Math.random() * maxCol); const y = Math.floor(Math.random() * maxRow); // 检查是否与蛇身重叠 valid = !snakeBody.some(p => p.x === x && p.y === y); if (valid) { this.position = new egret.Point(x, y); } } } }maxCol和maxRow是网格的列数和行数,由游戏区域大小除以格子大小得到。while循环会一直随机直到找到一个不在蛇身上的位置。蛇越长,这个循环的平均次数越多,但贪吃蛇的蛇身长度有限,实际性能完全没问题。
碰撞检测分两种:蛇头撞墙和蛇头撞自己。撞墙判定是检查新头部坐标是否超出0到maxCol-1或0到maxRow-1的范围。撞自己则是检查新头部坐标是否和body数组里除头部以外的某个坐标重合。这两个判定都在GameScene的每帧更新里做,一旦触发就切换到游戏结束状态。
提示:碰撞检测的时机很关键。一定要在蛇移动之后、渲染之前做判定,否则会出现视觉上已经撞了但游戏还在继续的玄学现象。
3.3 游戏主循环与状态管理
Egret 的游戏循环基于帧事件,GameScene里通常会注册egret.Event.ENTER_FRAME来驱动每帧逻辑:
class GameScene extends egret.DisplayObjectContainer { private snake: Snake; private food: Food; private moveInterval: number = 200; // 移动间隔,单位毫秒 private lastMoveTime: number = 0; // 上次移动的时间戳 private isGameOver: boolean = false; public constructor() { super(); this.snake = new Snake(); this.food = new Food(); this.food.generate(this.snake.getBody(), 20, 20); // 注册帧循环 this.addEventListener(egret.Event.ENTER_FRAME, this.onFrame, this); } private onFrame(event: egret.Event): void { if (this.isGameOver) return; const now = egret.getTimer(); // 按固定间隔移动,而不是每帧都移动 if (now - this.lastMoveTime >= this.moveInterval) { this.lastMoveTime = now; this.snake.move(); this.checkCollision(); this.checkEatFood(); this.render(); } } private checkCollision(): void { const head = this.snake.getBody()[0]; // 撞墙判定 if (head.x < 0 || head.x >= 20 || head.y < 0 || head.y >= 20) { this.isGameOver = true; return; } // 撞自己判定 const body = this.snake.getBody(); for (let i = 1; i < body.length; i++) { if (body[i].x === head.x && body[i].y === head.y) { this.isGameOver = true; return; } } } }这里用egret.getTimer()获取当前时间戳,配合moveInterval控制移动频率。如果每帧都移动,蛇会跑得飞快根本没法玩,所以要用时间间隔来限速。moveInterval设成 200 毫秒意味着每秒移动 5 格,这个速度对新手比较友好,想加难度就把它调小。
isGameOver标志位用来暂停游戏逻辑,游戏结束后可以弹一个重新开始的按钮,点击后重置蛇和食物的状态。状态管理这块没有用复杂的状态机,就是一个布尔值加几个重置方法,对于贪吃蛇这个体量的游戏来说完全够用。
4. 避坑与常见问题排查
4.1 编译报错找不到 egret 模块
现象:执行egret build时提示Cannot find module 'egret'或者类似的模块缺失错误。
原因:egretProperties.json里引用了某个引擎模块,但本地安装的 Egret 版本里没有这个模块,或者模块路径配置不对。
解决:先执行egret upgrade让工具自动检查并补齐缺失的模块。如果还不行,打开egretProperties.json,找到modules数组,确认里面引用的模块名和本地 Egret 安装目录下的模块列表对得上。实在不行就删掉node_modules和bin-debug,重新npm install再egret build。
4.2 游戏画面黑屏但控制台无报错
现象:浏览器打开后一片黑,控制台没有红色报错信息。
原因:资源加载失败但被静默处理了,或者index.html里的入口脚本路径不对。
解决:打开浏览器开发者工具的 Network 面板,刷新页面,看default.res.json和图片资源是不是 404。如果是 404,检查resource目录的路径和index.html里配置的资源根路径是否一致。另外确认egret.runEgret的calculateCanvasSize参数,有些情况下画布尺寸算出来是 0,也会导致黑屏。
4.3 蛇移动速度忽快忽慢
现象:蛇的移动速度不稳定,有时候一顿一顿的。
原因:moveInterval的时间判定用了egret.getTimer(),但如果帧率波动大,时间戳的精度会受影响。另外如果onFrame里做了耗时操作,也会导致帧间隔不均匀。
解决:把moveInterval的判定改成累加deltaTime的方式,而不是比较绝对时间戳。Egret 的ENTER_FRAME事件里可以通过egret.getTimer()拿到当前时间,但更稳的做法是记录上一帧的时间,算出帧间隔再累加。如果还是不稳,检查render方法里有没有频繁创建对象,比如每帧都new egret.Bitmap(),这会导致 GC 频繁触发,表现为卡顿。
4.4 食物生成在蛇身上导致游戏逻辑异常
现象:食物偶尔出现在蛇身占据的格子上,蛇吃不到或者吃了之后蛇身长度不对。
原因:generate方法里的碰撞检查有漏洞,比如只检查了蛇头没检查蛇身,或者随机范围算错了。
解决:确认generate方法里传入的snakeBody是完整的蛇身数组,不是只有头部。另外检查maxCol和maxRow的计算,确保随机范围是0到maxCol-1而不是0到maxCol。如果蛇特别长导致while循环卡死,可以加一个最大尝试次数,超过就遍历所有空格子找一个可用的。
4.5 触摸滑动方向判定不灵敏
现象:手机上滑动操作时,蛇的方向变化不跟手,有时候滑了没反应。
原因:滑动阈值设得太大,或者touchStart和touchEnd的坐标差计算方式有问题。
解决:把滑动判定的最小距离阈值调小,比如从 30 像素降到 15 像素。另外确保touchStart和touchEnd的坐标是在同一个坐标系里算的,Egret 里触摸事件的stageX和stageY是相对舞台的坐标,用这两个值做差值比较靠谱。如果还是不行,可以在touchMove里实时计算方向,而不是等touchEnd才判定。
5. 二次开发改造:从能跑到好用
5.1 加一个计分和最高分记录
原版源码里可能没有计分功能,这是最值得加的改造点。在GameScene里加一个score变量,吃到食物时加 10 分,游戏结束时把最高分存到localStorage:
class GameScene extends egret.DisplayObjectContainer { private score: number = 0; private highScore: number = 0; // 在构造函数里读取历史最高分 public constructor() { super(); const saved = egret.localStorage.getItem("snake_high_score"); this.highScore = saved ? parseInt(saved) : 0; } private checkEatFood(): void { const head = this.snake.getBody()[0]; if (head.x === this.food.position.x && head.y === this.food.position.y) { this.score += 10; // 更新最高分 if (this.score > this.highScore) { this.highScore = this.score; egret.localStorage.setItem("snake_high_score", this.highScore.toString()); } // 食物重新生成,蛇身加长 this.food.generate(this.snake.getBody(), 20, 20); this.snake.grow(); } } }egret.localStorage是 Egret 封装的本地存储接口,底层就是浏览器的localStorage,存个最高分绰绰有余。grow方法需要在Snake类里实现,逻辑很简单:移动的时候不删尾部就是加长。
5.2 难度递增与速度控制
固定速度玩久了会腻,可以根据分数动态调整moveInterval。分数越高,间隔越小,蛇跑得越快:
// 根据分数动态计算移动间隔 private getMoveInterval(): number { const baseInterval = 200; const minInterval = 80; // 每 50 分减少 20 毫秒,最低不低于 80 毫秒 const interval = baseInterval - Math.floor(this.score / 50) * 20; return Math.max(interval, minInterval); }然后在onFrame里把固定的this.moveInterval换成this.getMoveInterval()的返回值。这样分数越高难度越大,但不会快到没法操作。minInterval是速度上限,防止蛇快到人类反应不过来。
5.3 用对象池优化渲染性能
原版如果每帧都创建和销毁Bitmap对象,蛇长了之后会明显卡顿。改造方案是用对象池复用蛇身的显示对象:
class SnakeRenderer { private pool: egret.Bitmap[] = []; // 空闲对象池 // 从池里取一个,没有就新建 private getFromPool(): egret.Bitmap { if (this.pool.length > 0) { return this.pool.pop(); } const bmp = new egret.Bitmap(RES.getRes("snake_png")); return bmp; } // 回收不用的对象 private returnToPool(bmp: egret.Bitmap): void { bmp.visible = false; this.pool.push(bmp); } }对象池的核心思想是:不用的对象不销毁,标记为不可见后放回池子,下次需要时直接取出来复用。这样避免了频繁的new和 GC,蛇身再长也不会卡。RES.getRes是 Egret 的资源获取接口,从资源清单里按名称拿图片。
5.4 改造验证与调试技巧
改完之后怎么验证?我一般会按这个顺序走一遍:先跑egret build确认编译通过,然后egret run打开浏览器,用开发者工具的 Performance 面板录一段游戏过程,看帧率是否稳定在 60 帧。如果帧率掉到 30 以下,大概率是渲染层有问题,重点检查对象池有没有正确回收。
计分和最高分的验证更简单:玩一局,故意撞墙,刷新页面,看最高分还在不在。如果不在,检查egret.localStorage的 key 有没有写错,或者浏览器是不是开了无痕模式。
难度递增的验证需要玩到一定分数才能看出来,可以在getMoveInterval里加一行console.log打印当前间隔,确认分数增长时间隔确实在变小。
从那以后我每次改完游戏逻辑,都强制走一遍「编译 → 跑一局 → 看帧率 → 刷新验证存档」的流程,少一步都可能漏掉问题。希望这份拆解能帮你把这份贪吃蛇源码跑起来,也改出点自己的东西。
本文还有配套的精品资源,点击获取