☰
Egret贪吃蛇H5源码解析:帧循环、对象池与合批优化
2026/10/26 2:07:35 网站建设 项目流程

简介:这是一个基于Egret引擎开发的贪吃蛇小游戏完整项目源码,面向刚接触HTML5游戏开发、希望从零理解小游戏运行机制的初学者与实训学生。项目围绕蛇的移动、食物生成、碰撞判定与游戏循环等基础规则展开,涉及坐标系统、事件监听、精灵与舞台、帧序列动画、矩形碰撞检测及状态切换等核心知识点,可用于课程练习、毕业设计参考或Egret入门实践。压缩包共118个文件,约1.07MB,以30个png界面与素材图片、24个ts源码和24个js编译文件为主,另含15个exml皮肤布局、11个json配置及map等资源,结构覆盖视图、皮肤与逻辑代码,可据此梳理界面拆分与逻辑实现的对应关系,便于对照阅读和二次修改。目前已有417人学习,适合希望通过一个体量轻巧、逻辑完整的案例熟悉Egret工作流、资源管理与用户交互实现的开发者参考。

1. Egret 贪吃蛇小游戏源码:一个被低估的 H5 手感训练场

很多人第一次碰 H5 游戏引擎,都是从贪吃蛇起步——逻辑简单、边界清楚,一周内能把资源加载、帧循环、打包发布整条链路跑通。这份 Egret 贪吃蛇小游戏源码走的就是这条最经典的路线:TypeScript 写游戏逻辑、RES 管资源、网格化碰撞判定,外加一套可以直接对着改的难度与计分模块。它不是跑完就丢的玩具 demo,蛇身推进、转向缓冲、食物刷新、对象池复用这几处都留了可调的参数口子。适合想拿它当第一块 H5 小游戏跳板的新手,也适合需要一份干净骨架来验证引擎理解的老手。

2. Egret 工程骨架:先把「谁在驱动谁」搞明白

拿到一份陌生引擎的源码,先跑起来不是第一位的,先搞清楚哪个文件在驱动哪个文件才是。Egret 这套工程结构不复杂,但有几个约定是硬性的,破了就白屏或者编译不过。这一章从目录认到帧循环,把整条链路走一遍。

2.1 先认目录:能改的文件其实只有四类

先把源码包解开,根目录大致长这样:src/放 TypeScript 源码,resource/放资源和资源表,egretProperties.json是工程配置,index.html是浏览器入口,tsconfig.json管编译选项。新手最容易犯的错是到处改,改到最后自己都忘了动了哪。我的习惯是先分类:

路径作用能不能动
egretProperties.json引擎模块列表、发布配置只在加模块时改,改完必须重编
index.html画布尺寸、缩放模式、入口类名一般不动,缩放和朝向在这里调
src/Main.ts启动入口,挂加载界面可以改,但别删ADDED_TO_STAGE监听
src/game/*.ts蛇、食物、场景、配置主战场,大部分改动在这里
resource/default.res.json资源清单与分组加任何图片都必须同步改这里
resource/assets/*图、音、图集 json可以替换,文件名大小写要和清单一致

这张表里最容易被低估的是default.res.json。它不是自动生成的(除非你用资源管理面板重新导出),手写的清单和物理文件对不上,运行时就是一句RES.getRes返回null,然后一路空指针。

2.2 入口链路:从 index.html 到 Main 的那一跳

浏览器打开页面后发生了什么?index.html里的egret.runEgret({...})启动引擎,引擎去读><!-- index.html 关键属性,改完记得同步入口类名 --> <div id="gameDiv" style="width:100%;height:100%"></div> <script> egret.runEgret({ renderMode: "webgl", // 低端机黑屏时改成 "canvas" 兜底 audioType: 0, calculateCanvasSize: true }); </script>

renderMode定成webgl是现在的主流选择,合批(后面第 4 章会讲)只有在 WebGL 下才生效。但如果目标机型里有老设备,留一个canvas降级分支不吃亏——判断方式是启动后检测egret.Capabilities.renderMode,不是webgl就把一些依赖特效的表现砍掉。

接着看Main.ts。它的职责就一件事:在游戏场景准备好之前,给用户一个能看的画面,同时把资源配置表拉下来。

class Main extends egret.DisplayObjectContainer { private loadingView: LoadingUI; public constructor() { super(); // 必须等自己挂到 stage 上,才能拿 stage 引用 this.addEventListener(egret.Event.ADDED_TO_STAGE, this.onAddToStage, this); } private async onAddToStage(): Promise<void> { // 1. 先挂加载界面,避免用户对着黑屏等资源 this.loadingView = new LoadingUI(); this.stage.addChild(this.loadingView); try { // 2. 第二个参数是资源根目录,结尾的斜杠不能省 await RES.loadConfig("resource/default.res.json", "resource/"); // 3. 按组加载,分组在 default.res.json 里定义 await RES.loadGroup("preload", 0, this.loadingView); this.startGame(); } catch (e) { console.error("[boot] 资源加载失败", e); // 这里别只打日志,给个重试按钮比白屏强 } } private startGame(): void { this.removeChild(this.loadingView); const scene = new GameScene(); this.addChild(scene); scene.start(); } }

这段代码里有三个参数值得单独说。第一,RES.loadConfig的第二个参数是资源根目录,default.res.json里写的assets/xxx.png是相对这个目录解析的,少个斜杠就全 404。第二,loadGroup的第二个参数是优先级,传 0 表示默认,多个组同时加载时数字大的先走。第三,loadGroup的第三个参数是进度回调对象,传进去的对象如果有onProgress方法,加载过程会回调它,加载界面就是这么转起来的。

RES.loadConfig的返回值在不同引擎版本里不一样,老版本走事件(RES.addEventListener(RES.ResourceEvent.CONFIG_COMPLETE, ...)),新版本返回 Promise。源码里如果用的是await写法,说明配套的引擎版本较新,别拿老版本引擎去编,会直接语法错误。

2.3 egretProperties.json:模块没开,编译期就是一堆 undefined

Egret 是模块化的,egretProperties.json的modules数组决定哪些库参与编译。默认模板通常只带了egret核心,你一旦在代码里写eui.Button或者egret.Tween.get,编译器就会告诉你找不到。

{ "modules": [ { "name": "egret" }, { "name": "eui" }, { "name": "tween" }, { "name": "game" } ], "publish": { "web": { "outputDir": "bin-release/web" } } }

改完这个文件,必须重新执行一次引擎编译(命令行里的egret build -e,或者 IDE 里的「重新构建」),否则改动不生效——它影响的是引擎库的生成,不是业务代码。血泪经验:改了modules只点「运行」不点「重新构建」,报的还是老的错,能让人怀疑人生十几分钟。

2.4 帧循环:把「逻辑步」和「渲染帧」拆开

这是整份源码里最值得抄的一段设计。新手写贪吃蛇,最常见的写法是在ENTER_FRAME回调里直接让蛇走一格:

// 反例:逻辑和渲染绑死,换台高刷屏速度直接翻倍 this.addEventListener(egret.Event.ENTER_FRAME, () => { this.snake.moveOneCell(); this.syncView(); }, this);

问题在于ENTER_FRAME的触发频率跟着显示器走。60Hz 屏一秒 60 次,120Hz 屏一秒 120 次,同一份代码在两台设备上游戏速度差一倍——这种 bug 在开发机上永远复现不了,属于典型的玄学现场。

正确做法是做时间累积,逻辑按固定步长推进,渲染每帧最多同步一次:

private lastTick: number = 0; private acc: number = 0; // 累积的毫秒数 private stepMs: number = 220; // 走一格需要的毫秒,越小蛇越快 private onEnterFrame(): void { const now = egret.getTimer(); const dt = now - this.lastTick; this.lastTick = now; // 切后台再回来会有一个巨大的 dt,直接丢弃,不然蛇会瞬移 if (dt > 200) return; this.acc += dt; // while 而不是 if:一帧内可能要补多步,保证逻辑总时长和真实时间对齐 while (this.acc >= this.stepMs) { this.acc -= this.stepMs; this.stepLogic(); // 只改数据,不碰显示对象 } this.syncView(); // 每帧最多刷一次画面 }

egret.getTimer()返回的是毫秒级时间戳,比Date.now()更适合做帧间隔计算,因为它不受系统时间调整影响。dt > 200那个阈值是防瞬移的后悔药,切标签页、锁屏、来电都会触发一次大间隔,不拦住的话蛇会连走十几格然后撞墙。

stepMs就是难度参数的入口,后面第 6 章讲难度曲线时,改的就是它。

3. 蛇的移动与碰撞:20×20 网格上的三个核心函数

贪吃蛇的逻辑部分其实只有三个函数:走一步、吃一口、转个向。但每个函数都有边界条件,写漏一个就是穿模或者自杀。这一章把这三个函数拆开看。

3.1 为什么用格子坐标而不是像素坐标

先说选型。蛇身每个节点可以存像素坐标(x: 137.5),也可以存格子索引(x: 7)。这份源码用的是后者,配置里定死一套网格:

// GameConfig.ts export const CFG = { cols: 20, // 横向格数 rows: 20, // 纵向格数 cell: 24, // 每格像素边长,最终画布宽度 = cols * cell stepMs: 220, // 初始速度 minStepMs: 90, // 速度上限,再快就没法玩了 initLen: 3 // 初始蛇长 };

格子坐标的好处很直接:碰撞判定从「矩形相交」退化成「整数相等比较」,没有浮点误差,也不会出现「看着没撞上但判定了」的争议。代价是移动不平滑,蛇是一格一格跳的——想要顺滑就得在syncView里做插值,那是表现层的事,不该污染逻辑层。

这套 20×20 的网格,单格 24 像素,画布逻辑宽度就是 480。index.html里的>public step(): StepResult { // 1. 消费一个转向指令;队列空了就保持原方向 const d = this.dirQueue.shift(); if (d) this.dir = d; const head = this.body[0]; const next: Cell = { x: head.x + this.dir.x, y: head.y + this.dir.y }; // 2. 撞墙:四个方向都要判,别只判左上 if (next.x < 0 || next.y < 0 || next.x >= CFG.cols || next.y >= CFG.rows) { return StepResult.HitWall; } // 3. 撞自己:尾巴那一格在本步结束时会腾出来,所以不参与判定 const tailIdx = this.body.length - 1; for (let i = 0; i < this.body.length; i++) { if (i === tailIdx && this.growPending === 0) continue; const c = this.body[i]; if (c.x === next.x && c.y === next.y) return StepResult.HitSelf; } // 4. 生长用「预支」而不是「立即插入」 this.body.unshift(next); if (this.growPending > 0) { this.growPending--; // 这一步不删尾巴,长度 +1 } else { this.body.pop(); } return StepResult.Move; }

第 3 步那个tailIdx的跳过条件是全篇最容易被忽略的一处。蛇尾在当前这一步结束后会离开原位置,所以头进到尾格是合法的。如果把尾巴也纳入自撞判定,蛇长度到 4、围成一个小圈时就会莫名死亡,玩家会认为你判定穿模。

第 4 步的growPending是个计数器,吃到食物时growPending++,而不是立刻往body里塞一格。原因在于时序:吃食物的判定发生在「头进入食物格」的那一刻,此时尾巴还没处理。如果既插入新头又立刻加长度,蛇会一步长两格。用预支计数器能保证「一步只长一格」,视觉上也对得上。

3.3 转向缓冲队列:一帧内连按两次为什么必死

原始输入直接改this.dir是新手最容易踩的坑。设想蛇头朝右,玩家在 16 毫秒内快速按了「上」再按「左」。第一次把方向改成上,第二次改成左——从「右」到「左」是一次 180 度反向,蛇头直接怼进自己第二节,当场自爆。玩家会很愤怒:我明明按的是上再左,凭什么死?

解法是队列 + 两道过滤:

public pushDir(dx: number, dy: number): void { // 取队列里最后一个方向;队列空就用当前方向做基准 const last = this.dirQueue.length > 0 ? this.dirQueue[this.dirQueue.length - 1] : this.dir; // 180 度反向直接丢弃,塞进去就是自杀 if (dx === -last.x && dy === -last.y) return; // 同方向重复按,无意义,丢弃 if (dx === last.x && dy === last.y) return; // 缓冲上限 2,防止一帧内狂刷十几个方向 if (this.dirQueue.length >= 2) return; this.dirQueue.push({ x: dx, y: dy }); }

队列上限设 2 是权衡过的。设 1 的话,玩家快速「上、左」两连击会丢掉第二个指令,手感发涩;设 3 以上,指令会积压,玩家松开手后蛇还在按老指令拐弯,同样难受。2 刚好覆盖「拐弯紧接着拐弯」的常见操作。

还有一个细节:step()每次只shift()一个方向。如果一帧内累积了两步逻辑(低端机掉帧时会发生),队列会被消费两次,蛇在一帧内拐两次弯——这是正确的,因为时间上确实过了两格的时间。反过来说,如果把队列清空式消费,掉帧时就会丢操作。

3.4 食物刷新:别在蛇身上长蘑菇

食物生成看着简单,Math.random()两个数就完事,但漏了去重就会长出「蛇身里的食物」,玩家永远吃不到,还会以为是显示错位。

private spawnFood(): void { const total = CFG.cols * CFG.rows; // 蛇占满全屏就是通关,直接返回,别进死循环 if (this.body.length >= total) { this.food = null; return; } // 占用表法:先标记所有蛇身格,再在剩余空间里随机 const occupied = new Set<number>(); for (const c of this.body) occupied.add(c.y * CFG.cols + c.x); const free: number[] = []; for (let i = 0; i < total; i++) { if (!occupied.has(i)) free.push(i); } const pick = free[Math.floor(Math.random() * free.length)]; this.food = { x: pick % CFG.cols, y: Math.floor(pick / CFG.cols) }; }

用「先收集空闲格再随机挑」的写法,最坏情况是 O(n),但绝不会进入死循环。反过来写while(true) { 随机取一格; 如果不在蛇身上就 break; },在蛇快占满屏时随机命中率趋近于零,表现就是游戏卡死——这个坑我在别的项目里真实踩过,蛇长到 380 格的时候整个画面冻住,查了半天才定位到随机循环。

this.body.length >= total这个分支也别省。不处理的话,蛇占满全屏后free是空数组,Math.floor(Math.random() * 0)取到undefined,后面一路崩。

4. 资源与渲染:RES、对象池、合批三件套

逻辑写对只是及格,跑起来掉帧、切场景卡顿、重开一局越来越慢,问题基本都出在资源和渲染上。这一章讲三件事:资源表怎么配、节点怎么复用、drawcall 怎么压。

4.1 default.res.json:name 后缀这个约定必须遵守

Egret 的资源表有两个字段最关键:groups定义分组,resources定义单个资源。分组的意义在于按需加载——首屏只拉preload,UI 图和音效放到后面按场景加载,冷启动能快一倍。

{ "groups": [ { "name": "preload", "keys": "snake_head_png,snake_body_png,food_png,bg_png" }, { "name": "audio", "keys": "eat_mp3,die_mp3" } ], "resources": [ { "name": "snake_head_png", "type": "image", "url": "assets/snake_head.png" }, { "name": "snake_body_png", "type": "image", "url": "assets/snake_body.png" }, { "name": "food_png", "type": "image", "url": "assets/food.png" }, { "name": "bg_png", "type": "image", "url": "assets/bg.png" }, { "name": "eat_mp3", "type": "sound", "url": "assets/eat.mp3" } ] }

注意name字段的命名约定:图片资源统一加_png后缀,声音加_mp3。这不是强迫症,是 Egret 资源管理面板导出时的默认规则,手写清单时不遵循也能跑,但一旦用面板重新导出,全表的 name 会变,代码里的RES.getRes("snakeHead")就全部返回null。老老实实按约定来,省的是将来的自己。

取资源的时候要判空:

const tex: egret.Texture = RES.getRes("snake_head_png"); if (!tex) { console.warn("[res] snake_head_png 缺失,检查 default.res.json 与文件大小写"); return; } const bmp = new egret.Bitmap(tex);

大小写在 Linux 服务器上是致命的:本地 Windows 开发时Snake_Head.png和snake_head.png不分,传到服务器上就 404。这个坑没有技术解法,只有约定——资源文件名全小写下划线。

4.2 对象池:省的不只是 GC

贪吃蛇里需要频繁创建销毁的对象只有食物(每吃一个换一个位置)。但如果你的版本带粒子特效、飘分文字、蛇身闪光,那每吃一口就是十几个节点进出场景。直接new再removeChild,几百次之后 GC 压力上来了,表现是每隔几秒卡一下。

对象池的写法不复杂,但有两个清理动作不能省:

class NodePool { private pools: { [key: string]: egret.DisplayObject[] } = {}; public get<T extends egret.DisplayObject>(key: string, factory: () => T): T { const list = this.pools[key] || (this.pools[key] = []); const node = list.pop() as T; return node || factory(); } public put(key: string, node: egret.DisplayObject): void { // 1. 摘掉挂在节点上的业务监听,不然复用时会被旧逻辑接管 node.removeEventListener(egret.TouchEvent.TOUCH_TAP, node["onTap"], node); // 2. 停掉残留的缓动,否则回收的节点还在跑上一轮的动画 egret.Tween.removeTweens(node); // 3. 复位可视属性,避免带着上一轮的状态出场 node.alpha = 1; node.scaleX = 1; node.scaleY = 1; (this.pools[key] || (this.pools[key] = [])).push(node); } }

egret.Tween.removeTweens(node)这行是后悔药级别的存在。少了它,一个被回收的飘分文字如果还有个 800 毫秒的缓动在跑,它会在池子里继续改y和alpha,下次取出来时位置是歪的、透明度是 0——现象诡异到像灵异事件,排查成本极高。

4.3 合批:drawcall 从 40 降到 3

Egret 在 WebGL 模式下会把「相同纹理 + 相同混合模式 + 相邻渲染顺序」的位图合并成一次 drawcall。理解这条规则,就知道优化方向在哪。

假设蛇长 40 节,每节一个Bitmap,都用snake_body_png这张图,且中间不夹别的东西,那 40 个节点合成 1 次 drawcall。但如果你在蛇身中间插了一个TextField显示分数,批次就被打断了:文字前一批、文字后一批,drawcall 翻倍。文字越多、插得越碎,批次数越高。

两个实操建议。第一,把所有 HUD 元素(分数、暂停按钮、最高分)集中放在显示列表的同一层,且放在蛇身之后或之前,别穿插。第二,分数文本只在数值变化时更新:

private setScore(v: number): void { if (v === this.lastScore) return; // 值没变就不碰 TextField this.lastScore = v; this.scoreText.text = String(v); }

TextField.text的 setter 会触发文本重新排版、重新生成纹理、重新上传 GPU。每帧无条件赋值,等于每帧做一次纹理上传,在低端机上就是稳定掉帧的来源。源码里如果有this.scoreText.text = String(this.score)写在onEnterFrame里,直接改成上面这样,帧率立刻不一样。

还有一条:把蛇头、蛇身、食物三张小图合成一张纹理集,中间不插入任何单图节点,理论上整场游戏 3 次 drawcall 就能画完。图集文件(.json+.png)用纹理打包工具生成,然后在代码里通过egret.MovieClipDataFactory或直接RES.getRes("atlas_json")取子纹理名。

5. 排查清单:跑不起来、蛇穿模、越玩越卡的五个真实现场

下面五条都是我在拆这类源码时真实碰到过的,按「现象 → 原因 → 解决」写,遇到同类问题可以直接对号。

5.1 编译期与启动期:三条先查这里

现象一:满屏黑,控制台无报错。原因通常是index.html里的入口类名和实际类名对不上,或者入口类没有继承egret.DisplayObjectContainer。Egret 在这种情况下不会抛异常,只是不显示任何东西。解决:先确认>// 长度每 +1 减 4ms,但永远不低于 minStepMs private refreshSpeed(): void { const grown = this.body.length - CFG.initLen; this.stepMs = Math.max(CFG.minStepMs, CFG.stepMs - grown * 4); }

minStepMs定在 90 左右是手感阈值,再低就不是技巧问题是手速问题了。注意这里改的是stepMs,而acc累积值不要清——清了会在加速瞬间产生一帧的卡顿感。

最高分用egret.localStorage存,它在 H5 端落到浏览器本地存储,在小游戏容器里走平台适配层,一份代码两端都能用:

private static readonly BEST_KEY = "snake_best_v1"; public loadBest(): number { // 读出来是字符串,可能是 null,转换时要兜底 return Number(egret.localStorage.getItem(GameScene.BEST_KEY) || "0"); } public saveBest(v: number): void { if (v <= this.loadBest()) return; // 只写更大的,减少写入次数 egret.localStorage.setItem(GameScene.BEST_KEY, String(v)); }

key 后面带v1是个便宜但管用的习惯:将来存档结构变了,换个 key 就等价于清档,不用写迁移逻辑。

包体这块,给一份我常用的对照:

项常见做法收益
小图合并蛇头/蛇身/食物/UI 图打进一张图集drawcall 降低,请求数减少
音频压缩mp3 转低码率,短音效用单声道音效往往占包体一半
删未引用资源按default.res.json反查,清单里没有的直接删换个皮就忘了删旧图是常态
分组懒加载音效和结算界面图单独成组,进游戏后再拉首屏体积能砍掉三分之一

验证手段别靠感觉。跑起来之后执行一次egret.Profiler.getInstance().run(),左上角会出现实时面板,帧率、drawcall、显示对象数量都能看到。开局时 drawcall 应该是个位数,如果你看到三四十,回第 4 章查批次被打断的问题;蛇长到 100 格时 drawcall 不应该增长,增长了说明蛇身节点没走图集。

我自己的习惯是:拿到任何一份别人的小游戏源码,先把 Profiler 打开跑三分钟,看帧率曲线和 drawcall 变化,再动第一行逻辑代码。这个顺序反过来,后面会花十倍时间分辨「这个卡是我改出来的还是它本来就卡」。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询