☰
Egret贪吃蛇小游戏源码拆解:主循环、碰撞与渲染调优
2026/10/26 2:07:31 网站建设 项目流程

简介:这是一份基于Egret引擎开发的贪吃蛇小游戏完整工程,面向刚接触Egret与HTML5游戏开发的初学者,也适合想通过小型项目理解游戏主循环与状态管理的进阶学习者。压缩包共118个文件、约1.07MB,包含24个ts脚本与配套编译后的js、30张png与jpg位图素材、15个exml皮肤布局文件,以及json配置与map地图数据,另有html入口页面,可直接对照源码梳理游戏结构。目前已有417人学习下载。工程把坐标定位、事件监听、精灵与舞台、帧序列动画、矩形碰撞检测、Ticker帧率控制、蛇身数据结构、资源预加载、键盘事件处理与游戏状态切换等要点落到可运行代码中,并借助exml拆分出开始、失败等界面皮肤,便于读者边读代码边调参数,理解一款小游戏从资源加载到状态切换的完整链路,为后续开发更复杂的项目打下基础。

1. Egret 贪吃蛇小游戏:一份能直接跑的源码,值不值得拆

上个月一个做后端的 A 同学找我,说想拿现成的 Egret 贪吃蛇小游戏改个玩法,给自己做的内部培训加个互动环节,结果卡在egret run那一晚上——引擎装好了,工程也拉下来了,跑起来却是黑屏。这类事在拆别人的 Egret 小游戏源码时特别常见:玩法代码只有两百来行,真正耗时间的是摸清目录约定、资源清单和主循环这三件事。

这份资源是一个基于 Egret 引擎的贪吃蛇小游戏完整工程,TypeScript 源码加图集资源加 EUI 主题配置,核心玩法是网格移动、吃食物增长、撞墙与自咬判负。适合两类人:想找一个体量够小、逻辑闭环完整的 Egret 入门工程,把生命周期、Ticker、RES 资源管理和 EUI 一次摸透;以及想把现成贪吃蛇改成自己的规则(穿墙、加速、双人)但懒得从零搭架子的人。

下面按工程骨架、核心逻辑、渲染手感、踩坑排查、发布调优的顺序拆,代码都是能在这个工程里对得上的写法。

2. Egret 工程骨架:从 egretProperties.json 到主循环怎么跑起来

拿到一份 Egret 源码,第一件事不是读玩法代码,而是确认工程能不能在你这台机器上跑起来。我见过的“跑不起来”有八成集中在两个地方:引擎模块声明不全、资源清单和实际文件对不上,跟贪吃蛇本身没半点关系。先把骨架理顺,后面读逻辑才不心虚。

2.1 目录结构:先认清哪几个文件在管资源

Egret 工程的目录约定比较死,认错了文件会白白浪费一晚上。把手头这份工程摊开,真正需要你先看一眼的是下面这几处,其余都是构建产物或者模板,改坏了重新egret build就会再生。

路径作用改错后的典型症状
egretProperties.json声明用到哪些引擎模块编译过,运行时抛eui is not defined
tsconfig.jsonTS 编译目标与源码根目录编辑器满屏红线,但命令行能编
src/Main.ts入口类,生命周期挂这里页面白屏、控制台无报错
src/game/玩法代码,蛇、食物、场景逻辑错乱,但画面正常
resource/default.res.json资源清单,名字到文件的映射图片 404、显示成白块
resource/default.thm.jsonEUI 皮肤主题按钮变成一块空白矩形
template/index.html页面容器与 runtime 挂载点有 DOM 没 canvas

这里有个强绑定的关系要记住:src里代码写的资源名,必须和default.res.json里的name字段一字不差。很多工程把图片叫snake_head.png,清单里却写成snakeHead,代码里又写snake_head,三层名字各说各话,运行起来就是一张白图,控制台还不一定报错,这种玄学问题最难查。

2.2 模块声明与资源清单:两份配置决定加载链路

egretProperties.json决定引擎往产物里塞哪些模块。新版 Egret 把 EUI、Tween、资源管理器都拆成了独立模块,少声明一个,编译期不吭声,运行时才炸。我一般会先确认eui、tween、assetsmanager这三个在不在,贪吃蛇用不到复杂的资源管理器,但eui和tween基本跑不掉。

{ "modules": [ { "name": "egret" }, { "name": "eui" }, { "name": "tween" }, { "name": "assetsmanager" } ], "target": { "current": "web" } }

modules数组就是模块白名单,target.current是当前构建目标,web表示浏览器产物;切小游戏平台时这里会被 CLI 改写,不用手改。另外这个文件里还有一个engineVersion字段,由引擎自己维护,不要手改成一个和你本地引擎对不上的值,否则编译时可能出现一堆莫名其妙的类型冲突。

资源清单是第二个关键点,格式是“组 + 资源条目”:

{ "groups": [ { "name": "preload", "keys": "snake_head_png,snake_body_png,food_png,bg_png" } ], "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" } ] }

groups里那个preload就是启动时要一并加载的资源组,keys用逗号拼起所有资源名;resources是逐条的映射,name是代码里用的键,url是相对resource/目录的真实路径。改图的时候只改文件不改清单,是白块问题最常见的原因;反过来只改清单不改文件,RES.loadGroup会一直挂在那儿等,最后超时。

2.3 Main.ts 的启动链路:createChildren 里每一步在干什么

入口类的职责很单纯:注册生命周期、加载资源、挂主题、上场景。顺序错了会出问题,比如先addChild再加载资源,场景里的贴图全是空引用。

class Main extends eui.UILayer { protected createChildren(): void { super.createChildren(); // 切后台时暂停 Ticker,切回来恢复,否则回到前台画面停在最后一帧 egret.lifecycle.addLifecycleListener((context) => { context.onPause = () => { egret.ticker.pause(); }; context.onResume = () => { egret.ticker.resume(); }; }); // EUI 的皮肤解析器和主题适配器,缺这两行 Button 会渲染成空白块 egret.registerImplementation("eui.IAssetAdapter", new AssetAdapter()); egret.registerImplementation("eui.IThemeAdapter", new ThemeAdapter()); this.runGame().catch((e) => { console.error("启动失败", e); }); } private async runGame(): Promise<void> { await this.loadResource(); // 主题必须在资源加载完之后挂,皮肤里的贴图才找得到 const theme = new eui.Theme("resource/default.thm.json", this.stage); await theme.$promise; this.addChild(new GameScene()); } private async loadResource(): Promise<void> { // 第一个参数是清单路径,第二个是资源根目录,两个都相对 bin 目录 await RES.loadConfig("resource/default.res.json", "resource/"); await RES.loadGroup("preload"); } }

RES.loadConfig的第二个参数很容易写错。它表示后续所有url的前缀,写成"resource/"之后,清单里的assets/snake_head.png会被拼成resource/assets/snake_head.png,这是对的;如果多写一层变成"resource/assets/",就会拼成resource/assets/assets/snake_head.png,直接 404。

生命周期那两行也别省。移动端切后台再回来,Ticker 不恢复的话,蛇就定格不动了,玩家会以为游戏卡死。egret.ticker.pause()和resume()是配套的,只写一个反而更糟。

2.4 Ticker 与时间累积:移动单位不能是“帧”

这是贪吃蛇最容易埋雷的地方。新手常见的写法是在ENTER_FRAME里每帧往前走一格,结果是:电脑上 60 帧,蛇飞快;手机上掉到 40 帧,蛇慢一半;开个省电模式直接变成幻灯片。正确的做法是把移动单位定成毫秒,用时间累积来触发。

private lastMoveTime: number = 0; private moveInterval: number = 160; // 走一格需要的毫秒数 protected startTick(): void { this.lastMoveTime = egret.getTimer(); egret.startTick(this.onTick, this); } private onTick(timeStamp: number): boolean { // timeStamp 与 egret.getTimer() 同源,都是毫秒 if (timeStamp - this.lastMoveTime >= this.moveInterval) { // 用 while 而不是 if,低帧率时能补上落下的步数 while (timeStamp - this.lastMoveTime >= this.moveInterval) { this.lastMoveTime += this.moveInterval; this.step(); } } return false; // 返回 true 会强制每帧重绘,贪吃蛇不需要 }

onTick的返回值是“这一帧是否需要重绘”,返回false交给引擎自己判断,返回true会强制刷屏,白白吃性能。lastMoveTime += this.moveInterval而不是直接赋值成timeStamp,是为了不让误差累积——直接赋值的话,每次都会多丢几十毫秒,玩久了蛇会越走越慢。

还有一点:不要用egret.setTimeout递归去驱动移动。定时器精度和帧不同步,两段定时器叠加时会抖,暂停恢复也麻烦。Ticker 是唯一入口,所有时间相关的逻辑都挂在上面。

3. 贪吃蛇核心逻辑:网格坐标、蛇身队列与三处碰撞判定

骨架跑起来之后,才轮到玩法。贪吃蛇的逻辑量不大,但细节密度高:坐标系选错了,后面每一步都在做多余的换算;蛇身的增长时机错一帧,就会出现“吃到了但没变长”或者“没吃到却长了”的灵异现象。这一章把四个关键点拆开写。

3.1 网格与像素的换算:把常量收在一处

我的习惯是逻辑层只认网格坐标,渲染层才换成像素,两层之间只留一个换算函数。这样蛇身数组里存的全是{col, row},跟屏幕分辨率、缩放比例完全解耦,后面改棋盘大小只动常量。

const CELL: number = 32; // 每格边长(像素) const COLS: number = 15; // 横向格数 const ROWS: number = 15; // 纵向格数 const ORIGIN_X: number = 40; // 棋盘左上角在舞台上的偏移 const ORIGIN_Y: number = 120; function gridToPixel(col: number, row: number): { x: number; y: number } { return { x: ORIGIN_X + col * CELL, y: ORIGIN_Y + row * CELL }; } function pixelToGrid(x: number, y: number): { col: number; row: number } { return { col: Math.floor((x - ORIGIN_X) / CELL), row: Math.floor((y - ORIGIN_Y) / CELL) }; }

ORIGIN_X/Y这两个偏移是给顶部分数栏和底部按钮留的位置,不加的话棋盘会顶到屏幕上边,第一行被 HUD 盖住。pixelToGrid用Math.floor而不是Math.round,因为点击落在格子内部任意位置都该算这一格,四舍五入会把右侧半格判到下一列去。

把像素坐标存进蛇身数组看着省事,实际上会害了自己:Tween 插值的时候坐标是浮点数,插值中途拿去做碰撞判定就会出现 31.7 不等于 32 的经典 bug,蛇从自己身上穿过去。逻辑和渲染分开,这类问题直接消失。

3.2 蛇身用数组当队列:头插尾删与增长的那一帧

蛇身的本质是一个队列:新头进队首,没吃到食物就弹出队尾。用数组的unshift/pop足够,几十节的规模完全不需要链表,链表反而让遍历和渲染变麻烦。

interface Cell { col: number; row: number; } class Snake { body: Cell[] = []; // index 0 是蛇头 private pendingGrow: number = 0; // 待增长节数 reset(col: number, row: number): void { this.body = [ { col: col, row: row }, { col: col - 1, row: row }, { col: col - 2, row: row } ]; this.pendingGrow = 0; } head(): Cell { return this.body[0]; } // 前进一格,返回新的蛇头坐标 move(dir: Cell, grow: boolean): Cell { const h = this.body[0]; const next: Cell = { col: h.col + dir.col, row: h.row + dir.row }; this.body.unshift(next); if (grow) { this.pendingGrow++; // 先把增长记账,下一帧再补 } if (this.pendingGrow > 0 && !grow) { this.pendingGrow--; } else if (!grow) { this.body.pop(); } return next; } }

这里用一个pendingGrow计数,而不是“吃到就unshift两次”。原因是渲染层是按body长度来同步节点数量的,一帧内长度突增两节会让节点池对不上号,出现一帧闪烁。改成记账之后,长度每帧只涨一节,视觉上是“慢慢变长”,也符合玩家预期。

unshift在数组很大时是 O(n),但蛇长到 225 节(15×15 满屏)也就到头了,这点开销可以忽略。真要抠性能,用环形缓冲或者双端队列,但对贪吃蛇属于过度设计。

3.3 碰撞判定:撞墙、自咬,还有那个容易漏的“尾巴让位”

三处判定里最容易被写错的是自咬。蛇头撞到自己的身体算死,但尾巴那一节在下一次移动时会移走,所以蛇头进入“当前尾巴所在格”是合法的。很多实现把这格也算成死,玩家贴着尾巴走就会被冤枉判负。

type CollisionResult = 'ok' | 'wall' | 'self'; private checkCollision(next: Cell, willGrow: boolean): CollisionResult { // 1. 撞墙:索引越界 if (next.col < 0 || next.col >= COLS || next.row < 0 || next.row >= ROWS) { return 'wall'; } // 2. 自咬:最后一节这帧会让位,除非正在增长 const limit = willGrow ? this.snake.body.length : this.snake.body.length - 1; for (let i = 0; i < limit; i++) { const c = this.snake.body[i]; if (c.col === next.col && c.row === next.row) return 'self'; } return 'ok'; }

willGrow这个参数不能省。吃到食物的那一帧,尾巴不会弹出,所以整条身体都是实心的,包括最后一节。用limit把两种情况合并,比在外面写两个分支干净。

顺序也有讲究:先判墙再判身体。边界之外的坐标去查身体数组虽然不会崩,但一旦棋盘外恰好有历史残留节点,就会出现“撞墙被判成自咬”的错乱提示。

3.4 食物生成与输入缓冲:两个最容易被写脏的地方

食物生成的经典错误是直接random两个数然后不管重复。运气差的时候食物落在蛇身上,玩家永远吃不到,游戏卡死。

private spawnFood(): boolean { const occupied = new Set<string>(); this.snake.body.forEach(c => occupied.add(`${c.col},${c.row}`)); const free: string[] = []; for (let c = 0; c < COLS; c++) { for (let r = 0; r < ROWS; r++) { if (!occupied.has(`${c},${r}`)) free.push(`${c},${r}`); } } if (free.length === 0) return false; // 满屏,判定通关 const pick = free[Math.floor(Math.random() * free.length)]; const parts = pick.split(','); this.food = { col: +parts[0], row: +parts[1] }; return true; }

先枚举空格再随机取值,是 O(COLS×ROWS) 的,225 格实测完全可以每帧调。返回值用来区分“正常生成”和“满屏通关”,比在外面再算一次空格数省事。

输入缓冲是另一个坑。玩家在两次移动之间连按两次方向,比如“上”然后立刻“左”,如果直接把currentDir覆盖成“左”,蛇会在还没走完上一格的时候就拐弯,看起来像瞬移。正确做法是排一个最多两格的队列。

private inputQueue: Cell[] = []; private pushDir(dir: Cell): void { if (this.inputQueue.length >= 2) return; // 最多缓冲两次转向 const last = this.inputQueue.length ? this.inputQueue[this.inputQueue.length - 1] : this.currentDir; // 反向:直接丢弃,否则必定自咬 if (dir.col === -last.col && dir.row === -last.row) return; // 同向:重复指令,没意义 if (dir.col === last.col && dir.row === last.row) return; this.inputQueue.push(dir); }

last取的是“队列尾”或者“当前方向”,这样两次连按的合法性是链式判断的,不会出现“上→左→下”里那个“下”被误判成反向。每次step()开头从队列里shift()一个出来当方向,队列空了就沿用上一帧的方向。

4. 渲染与手感:EUI 面板、Tween 插值与对象池

逻辑正确和玩起来舒服是两件事。我这几年改过的小游戏,逻辑一模一样的版本,只把“跳格”改成“滑格”,试玩的人就愿意多玩两把。这一章讲渲染层的三件事:HUD 怎么拼、位移动画怎么给、节点怎么复用。

4.1 用 EUI 拼一个最小 HUD:分数、速度与重开按钮

HUD 只有三样东西:分数文本、当前速度提示、重开按钮。用 EUI 是为了皮肤好统一换,不用自己算坐标拼贴图。

class Hud extends eui.Component implements eui.UIComponent { public lblScore: eui.Label; public lblSpeed: eui.Label; public btnRestart: eui.Button; public constructor() { super(); // 皮肤文件放在 resource/skin/HudSkin.exml,构建时会自动生成对应主题 this.skinName = "resource/skin/HudSkin.exml"; } protected partAdded(partName: string, instance: any): void { super.partAdded(partName, instance); if (instance === this.btnRestart) { this.btnRestart.addEventListener(egret.TouchEvent.TOUCH_TAP, () => { this.dispatchEvent(new egret.Event("game_restart")); }, this); } } public update(score: number, interval: number): void { this.lblScore.text = `分数 ${score}`; this.lblSpeed.text = `速度 ${(1000 / interval).toFixed(1)} 格/秒`; } }

partAdded是 EUI 里拿子组件引用的正确时机,写在构造函数里会拿到null,因为此时皮肤还没解析完。这一点跟原生 Egret 容器完全不同,是很多人第一次用 EUI 时踩的坑。

皮肤文件里的组件id必须和类里的属性名完全一致,id="lblScore"对应public lblScore,差一个字母就是undefined,运行起来报空指针,控制台还不告诉你是哪个控件。

4.2 Tween 插值:把“跳格”变成“滑格”

原生做法是每次step()直接把节点坐标设成新格子的像素位置,视觉上是一跳一跳的,很硬。用 Tween 在两次逻辑移动之间做线性插值,观感立刻不一样。

private moveNodeTo(node: egret.Bitmap, cell: Cell, duration: number): void { const p = gridToPixel(cell.col, cell.row); // 先掐掉这个节点上残留的补间,否则连续移动会叠速 egret.Tween.removeTweens(node); egret.Tween.get(node) .to({ x: p.x, y: p.y }, duration, egret.Ease.quadOut); }

duration要跟着moveInterval走,一般取moveInterval * 0.9,留一点空隙让“到位”和“下一步”之间有节奏感。取满会显得黏,取一半会显得飘。Ease.quadOut是缓出,起步快收尾慢,蛇的移动看起来更“肉”。

egret.Tween.removeTweens(node)这行绝对不能省。对象池复用节点时,上一个节点上可能还挂着一个没跑完的补间,直接Tween.get再叠一个,两次插值同时改x/y,节点会左右抽搐。我最早的一版就是这么翻车的,排查了两个小时才定位到池子。

还有一个细节:Tween 只改渲染节点,不改body里的网格数据。碰撞判定读的是网格数组,跟动画进度无关,所以即便画面还在滑动,逻辑上蛇已经在新格子里了。这个“逻辑先行、渲染追赶”的模型是有意的,玩家操作响应才不会有延迟感。

4.3 对象池:吃二十个食物之后的节点数量

蛇每长一节就要多一个贴图节点。新手写法是每次增长都new egret.Bitmap(),吃满屏就是 225 个节点,加上重开一局再建一遍,显示列表里堆着几百个不可见节点,掉帧是迟早的事。

class BitmapPool { private pool: egret.Bitmap[] = []; public constructor(private texture: egret.Texture) {} public get(): egret.Bitmap { const bmp = this.pool.pop() || new egret.Bitmap(this.texture); bmp.visible = true; bmp.alpha = 1; bmp.scaleX = 1; bmp.scaleY = 1; return bmp; } public put(bmp: egret.Bitmap): void { // 关键:先清补间,再复位,最后隐藏 egret.Tween.removeTweens(bmp); bmp.visible = false; this.pool.push(bmp); } }

put里三个步骤的顺序不能换。先removeTweens是因为补间会继续改属性;复位alpha/scale是因为吃食物的那一帧可能有缩放反馈动画;最后visible = false而不是removeChild,因为从显示列表移除再添加的开销反而比改属性大。

顺带说一句:食物节点也可以用池子,但食物同一时间只有一个,意义不大。真正值得池化的是“吃到食物时的粒子/飘字反馈”,那玩意儿一秒能建十几个。

4.4 双端输入:键盘和手势,一套缓冲两套来源

桌面端用方向键,移动端用手势滑,两套输入最后都进同一个inputQueue,逻辑层完全不用关心输入从哪来。

// 键盘:37/38/39/40 分别对应左下上右 this.stage.addEventListener(egret.KeyboardEvent.KEY_DOWN, (e: egret.KeyboardEvent) => { const map: { [code: number]: Cell } = { 38: { col: 0, row: -1 }, 40: { col: 0, row: 1 }, 37: { col: -1, row: 0 }, 39: { col: 1, row: 0 } }; const dir = map[e.keyCode]; if (dir) this.pushDir(dir); }, this); // 手势:记录起点,抬起时算位移,小于阈值视为点击不转向 private touchX: number = 0; private touchY: number = 0; this.stage.addEventListener(egret.TouchEvent.TOUCH_BEGIN, (e: egret.TouchEvent) => { this.touchX = e.stageX; this.touchY = e.stageY; }, this); this.stage.addEventListener(egret.TouchEvent.TOUCH_END, (e: egret.TouchEvent) => { const dx = e.stageX - this.touchX; const dy = e.stageY - this.touchY; if (Math.abs(dx) < 24 && Math.abs(dy) < 24) return; // 点击,不当作滑动 if (Math.abs(dx) > Math.abs(dy)) { this.pushDir({ col: dx > 0 ? 1 : -1, row: 0 }); } else { this.pushDir({ col: 0, row: dy > 0 ? 1 : -1 }); } }, this);

stageX/stageY是相对舞台的坐标,不受父容器变换影响,比localX/localY稳。24 像素的阈值是经验值,手指轻点也会产生几像素的抖动,阈值太小会误判成滑动。

键盘事件挂在stage上,要求stage已经获得焦点。如果挂在document上,某些容器里嵌页面时按键会被外层吃掉,这也是我后来统一改成挂stage的原因。

5. 避坑排查:五个让 Egret 贪吃蛇跑不起来的坑

下面这五条是我和身边人真实撞过的,每条都按“现象、原因、解决”写,遇到问题时可以直接对照。

5.1 启动黑屏,控制台看不到报错

现象:egret run之后页面打开是纯色背景,没有棋盘,控制台没有红色报错,只有一个 404。

原因:RES.loadConfig的第二个参数写错,清单路径拼成了resource/resource/default.res.json。404 之后loadGroup的 Promise 一直处于 pending 状态,后面的new GameScene()永远不执行,catch也捕获不到,因为 Promise 根本没 reject。

解决:先看 Network 面板里default.res.json的实际请求路径,再回头对loadConfig的两个参数。同时给加载链路加上超时保护,别让它无限等待。

const timeout = new Promise<void>((_, reject) => egret.setTimeout(() => reject(new Error("资源加载超时")), this, 8000) ); await Promise.race([this.loadResource(), timeout]);

5.2 蛇身显示成白块,或者整体偏移半格

现象:游戏能跑,蛇能动能吃,但蛇身是纯白矩形,或者整体往右下偏了半个格子的距离。

原因:白块是资源名对不上,清单里的name和代码里的字符串不一致,RES.getRes返回null,new egret.Bitmap(null)会画成一个默认白块而不报错。偏移则是锚点没设,Egret 的锚点默认在左上角,而网格换算算的是左上角,如果你按中心点去算就会差半格。

解决:白块问题逐个核对default.res.json的name和代码里的字符串,我一般会把资源名抽成常量集中放一个文件,只写一遍。偏移问题把锚点统一设成左上角,anchorOffsetX/Y保持 0,换算函数就不用做额外补偿。

// 统一用左上角对齐,锚点偏移保持 0 const bmp = new egret.Bitmap(RES.getRes("snake_body_png")); bmp.anchorOffsetX = 0; bmp.anchorOffsetY = 0;

5.3 手机上手势一滑,蛇直接反向自咬

现象:桌面端键盘玩没问题,手机上向下滑想加速,结果蛇头直接撞到自己脖子上。

原因:手势里只判断了滑动方向,没做反向校验。玩家向右滑的时候手指可能先产生一个向左的小位移,TOUCH_END读到的终点相对起点是往左的,于是被判成向左。

解决:手势方向判断前先做一次“主方向”判定,横纵比较取绝对值大的那个,同时复用pushDir里已有的反向过滤逻辑,不要在两处各写一份规则。

// 取主轴方向,避免斜滑时被次要轴带偏 if (Math.abs(dx) > Math.abs(dy) * 1.5) { this.pushDir({ col: dx > 0 ? 1 : -1, row: 0 }); } else if (Math.abs(dy) > Math.abs(dx) * 1.5) { this.pushDir({ col: 0, row: dy > 0 ? 1 : -1 }); } // 两个方向接近的斜滑直接丢弃,让玩家重滑

5.4 玩到三四十分开始掉帧,越玩越卡

现象:开局很顺,蛇长到二十节之后明显发涩,手机上尤其明显,能感觉到操作延迟。

原因:两个叠加问题。一是每次增长都new egret.Bitmap而不复用,重开一局旧节点没清理,显示列表越堆越大;二是每次step()都对整个body做一次gridToPixel全量重排,225 个节点每个都设一次坐标,触发大量属性变更。

解决:上对象池,复用节点;重开时把池子里的节点全部put回去而不是removeChild;渲染只在“蛇头新增”和“蛇尾移除”时改对应节点,中间节点不动。

// 只更新头尾,中间节点保持原有坐标不变 private syncRender(headCell: Cell, tailNode: egret.Bitmap | null): void { const p = gridToPixel(headCell.col, headCell.row); this.moveNodeTo(this.headNode, headCell, this.moveInterval * 0.9); if (tailNode) { this.pool.put(tailNode); // 尾节点回收,不再改坐标 } }

5.5 本地能跑,publish 之后资源全 404

现象:egret run一切正常,egret publish出来的产物丢到服务器上,页面能开,但所有贴图 404,控制台一片红。

原因:发布产物的目录结构和开发时不一样。开发时资源在resource/下按原路径访问,发布后会被打进resource/的打包目录,文件名可能带 hash,路径由清单驱动。如果代码里写死了"resource/assets/snake_head.png"这种字符串去直接加载,发布后就找不到。

解决:所有资源一律走RES.getRes(键名),不写死路径;发布前用egret publish的产物目录自己起个静态服务验一遍,别只信本地开发服务器。

# 发布并生成带版本号的产物 egret publish --version 1.0.0 # 到产物目录起个静态服务做验收,端口随意 cd bin-release/web/1.0.0 python3 -m http.server 8080

6. 速度曲线与真机验收:把 demo 调到能玩的那个版本

逻辑跑通、画面顺滑之后,剩下的就是手感。固定速度的贪吃蛇是练手 Demo,速度随长度变化的才是能让人玩下去的东西。我的做法是把moveInterval做成蛇长的函数,前期慢、后期快,但留一个下限。

private speedByLength(len: number): number { // 初始 3 节,每吃一节减 3ms,最快到 80ms 封顶 const base = 160; const step = 3; const min = 80; return Math.max(min, base - (len - 3) * step); }

min这个下限不是随便定的。低于 80ms 一格,人的反应时间已经跟不上了,玩家会开始觉得“我明明按了”,体验反而更差。这个值我调过三次,最后定在 80ms,再快就得加“穿墙”或者“减速道具”来平衡。

速度变化的那一帧要顺手同步渲染时长,否则逻辑变快了、动画还是老时长,蛇会像在冰上滑:

private onEat(): void { this.score += 1; this.moveInterval = this.speedByLength(this.snake.body.length + 1); this.hud.update(this.score, this.moveInterval); // 渲染时长跟着逻辑时长走,两者不能脱钩 this.renderDuration = this.moveInterval * 0.9; }

发布前我会强制走一遍真机验收清单,浏览器跑得再顺,真机上照样能翻车:

验收项观察点不合格的表现
首屏加载从打开到能操作超过 3 秒,或白屏一闪
手指滑动手感连续快速滑动十次有两次以上方向没响应
切后台再回来切出去 10 秒再切回蛇停住不动,或时间跳变走一大步
长时间运行连续玩 5 分钟明显发热,帧率肉眼可见下滑
重开一局连点重开五次分数没清零,或旧蛇残留

发布命令按目标平台切换,浏览器产物和小游戏产物的目录结构不一样,别拿一份产物到处塞:

# 浏览器产物,输出到 bin-release/web/<version> egret publish --version 1.0.0 # 小游戏平台产物,目录结构会按平台规范重组 egret publish --target wxgame --version 1.0.0

那次真机验收给我的教训挺深:本地浏览器上帧率稳得像一条直线,装到手机上玩了四分钟开始发烫,蛇的移动肉眼可见地一顿一顿。查了半天,发现是每一帧都在给 225 个节点做visible判断,明明池子里只有十几个是活的。从那以后我每次改完渲染层,都会先在真机上连玩五分钟再收工,浏览器上的一次“感觉还行”,什么都证明不了。

希望帮到你。

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

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

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

立即咨询