ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

白鹭引擎贪吃蛇实战:TypeScript小游戏开发核心技巧

白鹭引擎贪吃蛇实战:TypeScript小游戏开发核心技巧 简介基于Egret引擎开发的贪吃蛇小游戏完整源码面向游戏开发入门者与Egret学习者可用于理解2D休闲游戏从零搭建的完整过程。资源包共118个文件压缩后仅1.07MB其中包含30个png图片素材、24个ts源码与对应24个js脚本、15个exml界面文件、11个json配置及9个map地图文件代码与资源分离清晰便于按模块查阅。目前已有417人学习下载。项目虽小但覆盖游戏开发的关键技术点坐标系统、事件监听、Sprite与Stage使用、帧序列动画、矩形碰撞检测、游戏循环、链表式蛇身数据结构、资源预加载、键盘交互和状态机管理。通过阅读和运行这套代码可以掌握Egret引擎的基本工作流并为后续开发更复杂的2D游戏打下基础。1. egret贪吃蛇小游戏为什么经典玩法反而最考验引擎功夫贪吃蛇大概是游戏开发里被写烂了的入门项目但如果你真用白鹭引擎Egret把它做到能上线小游戏平台的程度情况就变了棋盘要适配不同屏幕、蛇身和食物用矢量绘制还是位图缓存、移动节奏要不要跟帧率解耦、触屏和键盘两套输入怎么并存这些细节在中国小游戏生态里直接决定玩家是留下还是划走。很多新手拿着空项目觉得“十分钟能写完”结果卡在方向反向和定时器漂移上一整天。这篇文章从一个可跑通的最小版本讲起覆盖方向队列、碰撞判定、食物重生这三个核心模块把参数调整思路和常见翻车点也一并拆掉。适合想用 TypeScript 做小游戏开发的新手也适合正在评估白鹭引擎技术栈的从业者——贪吃蛇这个体量恰好能把引擎的优劣完整暴露出来。2. 搭建项目与游戏循环从空工程到第一步蛇动起来2.1 创建工程与入口类白鹭项目的启动方式白鹭引擎的项目骨架通过命令行工具生成这一步没什么玄学照着执行就行。我一般先建立一个空目录在终端里进入后执行创建命令然后直接打开生成的文件夹开始写代码。这里不反复强调环境安装如果你连引擎的调试工具还没装好先补上再回来。创建完项目后真正要关心的只有Main.ts这个入口类。白鹭的项目里Main继承自egret.DisplayObjectContainer它会被引擎自动实例化并添加到舞台上。启动流程大致是构造函数先注册ADDED_TO_STAGE事件等到组件真正挂到显示列表上再做初始化。为什么不直接在构造函数里干活因为构造函数执行时舞台还没准备好拿不到stage.stageWidth这类尺寸信息强行读取只会拿到 0。// Main.ts 项目入口类 class Main extends egret.DisplayObjectContainer { private gameLayer: egret.Sprite; // 游戏内容容器 private stepInterval: number 180; // 蛇每步的时间间隔单位毫秒 private lastTime: number 0; // 上一帧的时间戳 private accTime: number 0; // 累计时间 public constructor() { super(); this.addEventListener(egret.Event.ADDED_TO_STAGE, this.onAdd, this); } private onAdd(): void { this.gameLayer new egret.Sprite(); this.addChild(this.gameLayer); this.lastTime egret.getTimer(); // 初始化基准时间 this.addEventListener(egret.Event.ENTER_FRAME, this.loop, this); } }代码说明egret.getTimer()返回引擎启动到当前时刻的毫秒数每一帧调用一次。ENTER_FRAME是白鹭的帧事件回调只要不移除监听每帧都会触发。gameLayer作为独立容器后续蛇身、食物全部挂到它下面另外画一层 UI方便统一处理显示层级。这里的参数核心是stepInterval。它决定蛇的移动速度数值越小越快。180 毫秒是个人测试下来移动端比较合适的起步值玩家不会紧张到手忙脚乱也不至于等得不耐烦。建议把它单独抽成变量方便后面做难度梯度调整。2.2 帧循环与固定步长让蛇按节拍移动而不是按帧乱跑新手最容易写出「蛇一秒钟穿过整个屏幕」的代码因为他们直接在ENTER_FRAME里移动蛇身。白鹭的帧率通常锚定 60 FPS但真机跑到耗电或者复杂场景时帧率会掉到 30 以下。如果蛇每一步移动都绑在帧上掉帧时蛇就变慢渲染恢复后蛇又突然加速玩家会明显感觉到节奏混乱。解决思路是固定步长Fixed Timestep蛇的移动不跟帧走而是跟时间走。每帧只做一件事——把帧间隔累加到一个计数器里当累计时间达到stepInterval的阈值就执行一次蛇的逻辑更新。这样无论帧率是 60 还是 30蛇每秒走的步数都一致。private loop(): void { let now egret.getTimer(); let delta now - this.lastTime; this.lastTime now; // 限制单帧累加时间防止切后台回来后一次走几十步 if (delta 500) delta 0; this.accTime delta; while (this.accTime this.stepInterval) { this.accTime - this.stepInterval; this.step(); // 真正执行一步逻辑 } } private step(): void { // 蛇的移动逻辑下一章补上 console.log(tick); }代码说明while循环不是摆设。假设某帧渲染卡了 500 毫秒理论上有两三次更新的欠账用while一次性把欠账清掉保证蛇不会瞬移。但delta 500时要主动丢弃时间累积这是做小游戏必须养成的习惯——手机锁屏、来电打断了渲染循环回到游戏时时间戳跳变不拦截的话蛇已经跑出屏幕外了。参数层面stepInterval就是从「帧频驱动」切到「时间驱动」的关键闸门。想调整游戏速度只需要改这个值不需要动渲染逻辑。这一步做完你手上其实已经有了一个稳定节拍器后面所有玩法逻辑都挂在这个节拍上不要再直接碰ENTER_FRAME。3. 蛇的移动与输入控制方向队列与步进式运动的实现3.1 方向数据模型为什么用向量而不是字符串贪吃蛇的方向本质上是二维网格上的位移向量。向左是(-1, 0)向右是(1, 0)向上是(0, -1)向下是(0, 1)。用向量存方向好处是移动逻辑里直接让蛇头坐标加上向量就得到新坐标不需要再做字符串到坐标的映射转换。很多人在这个阶段选择用egret.Point存方向我个人建议直接存轻量对象{ x: number, y: number }。egret.Point本身是显示对象坐标用的它自带 x 和 y语义上没问题但里面有额外的方法和属性。贪吃蛇的 directionQueue 每秒钟可能 push 好几次用纯对象减少装箱成本且在循环中遍历更快。这个优化在这个规模下收益微乎其微但养成「不为引擎底层类型付额外成本」的习惯没有坏处。方向校验是这里最容易翻车的点。玩家按了左键下一帧又按右键蛇不能原地掉头。但嘴唇简单直到你发现飞快连按四个方向蛇依然可能反向。核心原因是反向校验的对象应该是「队列里最后一个方向」而不是「当前移动方向」。// GameLogic.ts 方向输入与队列管理 private directionQueue: { x: number, y: number }[] []; // 待处理方向队列 private curDir: { x: number, y: number } { x: 1, y: 0 }; // 当前实际方向 private onKey(e: egret.KeyboardEvent): void { let dir: { x: number, y: number } | null null; switch (e.keyCode) { case 37: dir { x: -1, y: 0 }; break; // 左 case 38: dir { x: 0, y: -1 }; break; // 上 case 39: dir { x: 1, y: 0 }; break; // 右 case 40: dir { x: 0, y: 1 }; break; // 下 default: return; } // 关键和队列中最后一个方向做反向判断 let len this.directionQueue.length; let ref len ? this.directionQueue[len - 1] : this.curDir; if (dir.x ref.x 0 dir.y ref.y 0) { return; // 反方向直接丢弃 } this.directionQueue.push(dir); }代码说明dir.x ref.x 0这个条件本质上是两个向量之和为零向量。左右向量相加x 的和为 0上下向量相加y 的和为 0反方向则是两者都成立。用这种数学判断比if (dir.x -ref.x dir.y -ref.y)更紧凑也不容易写错。参数层面要强调的是test顺序入队时校验而不是在 step 中消费时校验。玩家在同一帧内按下「上、右、下」如果入队时只拿当前方向比对第一个上方向入队右方向入队下方向入队时拿当前方向右比较发现不反向就放行。但等到蛇实际移动第三步时它的方向其实已经从上变成了右再走下的结果是向下——玩家会觉得蛇「不听话」。用「队列最后一个方向」做参照才能保证连续快速输入时蛇按玩家的意图顺序转向。3.2 蛇身跟随算法头动尾随、数组位移法蛇身的跟随是另一个经典问题。直观写法是每步移动时先把头移动到新位置再从尾巴反向往前让每一节身体去复制它前一节的位置。这种写法没问题但代码稍显臃肿而且容易写错循环顺序——如果你正向遍历第一节复制了头的旧位置下一节复制的是已经被覆盖的第二节位置数据就乱了。我习惯用数组位移法。维护一个body数组第 0 个元素是蛇头后续元素依次是蛇身各段。每步移动时计算新的蛇头坐标用unshift插到数组头部再根据是否吃到食物决定是否pop掉最后的尾巴。这一步做完蛇身自然跟随不需要逐段复制。// GameLogic.ts 蛇身移动与食物消费 private body: { x: number, y: number }[] [ { x: 5, y: 5 }, // 蛇头 { x: 4, y: 5 }, { x: 3, y: 5 } // 初始长度三段向左站立 ]; private ateFood: boolean false; // 是否吃了食物决定尾巴是否保留 private step(): void { // 从队列取方向为空的继续用当前方向 let dir this.directionQueue.length ? this.directionQueue.shift()! : this.curDir; this.curDir dir; let head this.body[0]; let newHead { x: head.x dir.x, y: head.y dir.y }; this.body.unshift(newHead); // 新头插到最前 if (this.ateFood) { this.ateFood false; // 吃了不砍尾巴长度加一 } else { this.body.pop(); // 没吃砍掉尾巴长度不变 } this.render(); // 重绘整个蛇身 }代码说明unshift和pop的组合让整个蛇身数组每步移动只涉及一次头部插入和一次尾部移除结构上完全自洽。注意shift()操作是取队列中最早的方向输入并移除它这保证玩家在一步之内连按的方向会在后续步骤中依次生效而这一步只消费一个方向。如果你用pop()或者直接读数组头部但不移除队列机制就失效了。参数层面初始蛇身的位置和方向要保持一致的规则初始方向如果向右那么蛇头 x 坐标要比蛇身第一段大 1。这个初始布局必须在同一个坐标系里定义否则第一步移动就会感觉到蛇头「滑动」而不像一节节往前拱。网格坐标和像素坐标是两套系统蛇身数组里存的一律是网格坐标只有渲染时才乘上cellSize转成像素。这是整个贪吃蛇工程的坐标铁律——一旦混用后面食物和碰撞的逻辑会全面崩溃。4. 食物生成、得分与碰撞判定游戏如何走向终点4.1 食物随机生成排除蛇身与尝试次数上限食物生成看起来简单随机一个坐标把食物放过去。但这里有个细节食物不能刷在蛇身上否则玩家根本没法按时吃到而随着蛇越来越长随机到蛇身上的概率也线性上涨。一个常见的做法是死循环里不断重新随机直到命中的位置不跟蛇身重叠。但这在蛇长到二十格以上时可能出现极端情况——理论上抽几十次还抽不中虽然概率极低但小游戏线上遇到一次就会被骂。我的做法是加上尝试次数上限200 次以内找不到合适位置就返回false在外部特殊处理通常是判定玩家胜利。同时要注意食物坐标必须是网格坐标系而不是像素坐标否则蛇头吃食物的判定会由于浮点精度问题偶尔漏判。// GameLogic.ts 食物随机生成 private spawnFood(): boolean { let maxCols Math.floor(this.areaWidth / this.cellSize); // 横向网格数 let maxRows Math.floor(this.areaHeight / this.cellSize); // 纵向网格数 let tries 0; while (tries 200) { let fx Math.floor(Math.random() * maxCols); let fy Math.floor(Math.random() * maxRows); // 检查与蛇身是否有重叠 let hit this.body.some(seg seg.x fx seg.y fy); if (!hit) { this.foodGrid { x: fx, y: fy }; return true; } tries; } return false; // 蛇身占满棋盘判定玩家胜利 }代码说明maxCols和maxRows的计算是另一个容易踩坑的地方。你应该用「游戏区域宽度 / 网格大小」而不是直接取整数。如果游戏区域是 480 像素、网格大小是 30那么480 / 30 16没问题但如果区域是 500 像素、网格 30Math.floor(500 / 30) 16这 16 个格子实际上只占用了 480 像素剩余 20 像素是安全的留白。强行让第 17 个格子塞进 500 像素里蛇往那个方向走时就会撞到视觉边界之外的区域。参数层面cellSize同时决定了蛇身宽度和食物视觉大小它必须是整数且整除游戏区域的理想边长。如果有余数留白比拉伸更稳——原因放在第六章适配细节里展开。4.2 三类碰撞判定撞墙、撞自身、吃食物碰撞判定是贪吃蛇的胜负手只需要在每一步step()中做三个判断顺序依次是墙壁、自撞、食物。墙壁判断最直观新蛇头的网格坐标是否超出游戏区域范围。自撞判断则要遍历蛇身数组。这个遍历有一个常见的性能误区每次遍历整个蛇身数组甚至把所有蛇身坐标包装成 touch 事件去检测。贪吃蛇的蛇身通常不会超过几十节线性遍历完全够用不需要用哈希表或者空间分区徒增复杂度。真正要注意的是遍历范围从第 1 节开始——第 0 节是蛇头自己把新蛇头和旧蛇头位置比较是无意义的必然碰撞。// GameLogic.ts 碰撞检测完整逻辑 private step(): void { let dir this.directionQueue.length ? this.directionQueue.shift()! : this.curDir; this.curDir dir; let head this.body[0]; let newHead { x: head.x dir.x, y: head.y dir.y }; // 1. 撞墙检测 let maxCols Math.floor(this.areaWidth / this.cellSize); let maxRows Math.floor(this.areaHeight / this.cellSize); if (newHead.x 0 || newHead.x maxCols || newHead.y 0 || newHead.y maxRows) { this.gameOver(); return; } // 2. 自撞检测循环从第1段开始跳过自身 for (let i 1; i this.body.length; i) { if (this.body[i].x newHead.x this.body[i].y newHead.y) { this.gameOver(); return; } } // 3. 食物判定 if (this.foodGrid newHead.x this.foodGrid.x newHead.y this.foodGrid.y) { this.ateFood true; this.score; if (!this.spawnFood()) { this.winGame(); // 棋盘满了玩家胜利 return; } } else { this.ateFood false; } this.body.unshift(newHead); if (!this.ateFood) { this.body.pop(); } this.render(); }代码说明这里把移动逻辑和碰撞逻辑整合到了一个step()中顺序是「先判定、再移动」。先算出newHead但不立即unshift判定通过后才真正改变数组结构。这个顺序很重要——如果你先unshift再判定撞墙后蛇头已经在棋盘外了回滚时要多做一步shift凭空多出出错机会。参数说明cellSize和areaWidth在碰撞判断里再次出现这次用在边界上限计算上。你会发现这三个参数贯穿整个游戏所以在初始化时建议集中管理不要散落各处。score的递增放在食物命中分支里避免玩家重复吃到同一颗食物时分数错乱。spawnFood()返回false说明棋盘中所有合法格子都被蛇身占据这是赢的条件不是输的条件很多新手把两者搞反。5. 避坑与常见问题排查反向穿身、帧间隔跳变与触摸失灵5.1 快速反方向操作导致蛇头直接穿身现象玩家在蛇向右移动时快速按了两下左键蛇头不做任何减速直接反向进入自己的第二节身体游戏瞬间判负。原因方向队列的反向校验参考的是队列最后一个元素但如果玩家操作间隔极短两次输入在同一个step()周期内被消费队列中没有机会对新方向做二次校验。解决消费队列时再补一次反向校验。step()中取出方向后和curDir比较如果反向则忽略本次取出的方向继续用curDir。虽然入队时已经校验过一次但那只是防止队列里的错误输入消费时还要有一层保险。双保险逻辑只多三行代码但能彻底杜绝穿身问题。5.2 手机切后台回来后蛇瞬间走完地图现象玩家在游戏中切到微信回消息几分钟后切回游戏画面里蛇已经撞墙游戏直接结束中间没有任何提示。原因loop()函数在切后台期间没有被调用但egret.getTimer()的时间戳一直在走。恢复帧循环时delta是几千甚至几万毫秒while循环会把所有欠账步全部补齐蛇就在一帧内狂奔数格。解决在loop()开头限制单帧增量我在 2.2 节中的代码已经提前埋了if (delta 500) delta 0;这道防线。小于 500 毫秒的卡顿会让蛇稍作暂停大于 500 毫秒的长时间挂起则直接放弃这段时间回到游戏后蛇停在原地玩家可以继续操作。500 这个阈值不是固定的如果你的游戏节奏更快可以降到 300但不要低于 200否则正常掉帧也会被误判为挂起。5.3 食物刷新到蛇身上游戏无法继续现象蛇长度达到十五节以上时偶尔出现吃完一颗食物后下一颗食物迟迟不出现或者食物出现的位置与蛇身重叠玩家吃不掉。原因spawnFood()里的碰撞检查用了this.body.some(...)但检查完成后、渲染开始前蛇身可能已经移动了。食物坐标合法的那一刻是生成时但渲染时它所在的位置已经被蛇身覆盖。解决把食物生成挪到蛇身unshift()之后再执行。或者更稳妥的做法是spawnFood()内部重新读一次最新的this.body不要缓存旧的蛇身引用。这类问题在模拟器上很难复现因为本地调试时蛇身短、移动慢但线上真机上蛇身长、操作快就必须在逻辑层面彻底规避。5.4 触摸事件在小游戏平台上无响应现象在 PC 浏览器里用鼠标点击屏幕控制方向一切正常发布到小游戏容器后手指点屏幕完全没有反应。原因小游戏环境没有KeyboardEvent且部分平台对TouchEvent的派发节点有严格限制。你如果只绑定了stage上的 touch 事件而当前显示的容器层级中没有实际可命中的对象事件不会冒泡到舞台。解决在gameLayer上绘制一个全屏透明的矩形或者在stage上同时监听egret.TouchEvent.TOUCH_BEGIN。监听对象用this.stage而不是某个子容器同时确保检测点击坐标时用stageX/stageY然后换算成棋盘坐标。换算公式是(stageX - boardOffsetX) / cellSize取整注意坐标系中要先减去棋盘偏移量。5.5 蛇身越走越模糊渲染残影或网格错位现象蛇移动一段时间后画面里出现多条半透明的蛇身影子或者蛇身网格与背景网格发生规律性偏移。原因渲染层没有完全清理旧帧。多数情况是render()中用了graphics.clear()但清的是错误的显示对象另一种是背景网格和蛇身用了不同大小的cellSize导致视觉上蛇身和网格线对不齐。解决每个可变的显示对象在重绘前必须调用自己的graphics.clear()然后gameLayer.graphics只负责统一的背景网格。蛇身和食物各自建一个egret.Shape实例不要混到同一个容器里。偏差检测的方法是在蛇身第一段画一个半透明调试方块与背景网格对齐后确认cellSize一致——这是最直观的调法。6. 手感优化的关键一步动态计算网格尺寸与边界留白最后一层优化我建议直接落到「棋盘尺寸计算」这个技巧上。手机屏幕长宽比从 16:9 到 19.5:9 都有如果固定写死棋盘为 480 x 800 像素在不同屏幕上要么被拉伸变形要么两侧空出一大片。常见做法是以屏幕短边为基准动态计算cellSize在确保棋盘完整展示的前提下网格尽量方正。// Adapter.ts 动态计算网格尺寸 private computeLayout(): { cellSize: number, areaWidth: number, areaHeight: number } { let sw this.stage.stageWidth; let sh this.stage.stageHeight; let shortSide Math.min(sw, sh); // 网格数量固定为纵向20行、横向按比例推导 let targetRows 20; let cellSize Math.floor(shortSide / targetRows); // 用纵向 grid 数量推导横向区域大小保证格子永远正方形 let areaHeight cellSize * targetRows; let areaWidth areaHeight * (sw / sh 1 ? 1 : 0.6); // 竖屏时控制横向格数 return { cellSize, areaWidth: Math.floor(areaWidth / cellSize) * cellSize, areaHeight }; }这个函数的关键是先把短边分成固定行数算出正方形格子尺寸再让长边方向的区域做对齐处理而不是直接让每个格子都「尽量接近正方形」。这样写的好处是蛇的移动在纵向上永远是整数格不会出现蛇头浮在两条网格线之间的情况。如果长边方向有剩余像素统一留白在右侧或下方不要试图拉伸格子填满。屏幕适配完成后所有游戏逻辑代码都不需要知道真实像素只要使用cellSize换算网格坐标和像素坐标即可。除了尺寸手感的另一半来自反馈节奏。蛇每吃一颗食物stepInterval降低 2% 到 3%分数越高蛇越快这是玩家能直观感知的难度曲线。但注意降幅要设下限比如最低 70 毫秒再快肉眼已经看不清蛇身细节体验反而变差。我个人的经验是每一颗食物降低 5 毫秒连续吃十颗后总降幅约 26%既有加速感又不会过激。这个参数需要在真机上反复调模拟器上的感受和真机差别很大。另外值得一个习惯分数和长度显示不要放在游戏区域内单独建一个 UI 容器贴在屏幕顶部。白鹭引擎的显示列表排序是按 addChild 顺序来的UI 容器最后添加、置顶显示同时用touchEnabled false让它不拦截任何触摸事件这样屏幕顶部区域玩家点击也不会影响棋盘操作。真机上触摸坐标和设备像素比有关如果你的棋盘没有铺满全屏点击判定要减去棋盘偏移量再做格子换算——这是新人在真机测试中最容易忽视的盲区我第一次做这个项目时就在这里耗了半个下午后来养成了「先在真机上打一条走直线的蛇再验证边界触碰」的测试习惯。这个顺序能帮你快速筛掉九成适配问题希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进