
简介这是一份基于Egret引擎开发的弹珠游戏完整源码面向HTML5游戏初学者和希望快速上手Egret的开发者核心覆盖碰撞检测、物理模拟、动画系统、用户交互与音效管理等常见模块。项目采用TypeScript编写按public_playBall主目录组织代码包含main.ts入口、PlayBall.ts游戏主体、Ball.ts弹珠实体、Stage.ts场景及Audio.ts音效管理等类目录结构清晰便于逐模块拆解学习。资源共110个文件以js与ts源码为主搭配png图片素材、map地图数据、json配置、mp3音效和html运行页面物理模拟部分引入p2Physics.js辅助实现整体仅1.06MB轻量易读可直接在Egret Wing或命令行工程中运行调试。目前已有1279人学习下载对于想掌握Egret事件机制、Tween动画及自定义碰撞算法的开发者是一份能快速落地实践的参考源码。1. 项目整体拆解Egret弹珠游戏的核心设计1.1 为什么选Egret做弹珠游戏“egret弹珠游戏源码”这个项目说白了就是用白鹭引擎写一个经典的弹珠台小游戏把引擎源码级的东西拆开揉碎做成一套可以直接跑起来、能改能扩展的代码工程。很多想做H5小游戏的朋友拿到的素材要么是PureMVC那种大而全的框架Demo要么是微信小游戏那边的原生Canvas代码想找一个贴近真实弹珠玩法、物理效果又不至于太复杂的Egret工程反而不容易。这套源码的价值就在于它把弹珠游戏里最核心的移动、碰撞、反弹、计分全部用Egret的方式实现了一遍适合刚学完TypeScript基础、想练一个完整项目的人也适合要做外包或者快速上线的团队直接改UI和玩法。为什么弹珠类游戏特别适合拿Egret来做首先弹珠台场景固定画面元素全是圆和矩形碰撞规则清晰对渲染性能要求不高Egret 2D渲染完全可以扛住。其次Egret项目可以用TypeScript写类型系统对游戏这种状态多、对象多的项目非常友好。再一个Egret支持发布到Web和各种小游戏渠道同样一套代码改改平台适配配置就能多渠道上这是很多小团队选它的直接原因。我自己用下来觉得Egret最大的好处是显示列表、事件系统、资源加载、Ticker主循环这些基础能力是现成的我不需要像用原生Canvas那样自己维护一个游戏循环和对象图层可以把精力集中在弹珠物理和玩法逻辑上。注意Egret在市面上能找到的资料版本差异很大5.x和6.x的API不完全兼容。如果你是照着老教程写编译报错时先看一下引擎版本再决定改API还是升级项目配置不要盲目重装引擎。1.2 整体功能模块怎么划分拿到一套源码先别急着跑先把结构看明白。这套弹珠源码在模块划分上是比较标准的单一职责风格核心文件拆成了这几块模块职责对应核心文件入口初始化舞台、加载资源、启动场景Main.ts场景管理创建弹珠台、管理关卡元素GameScene.ts球对象记录坐标、速度、半径、状态Ball.ts碰撞工具圆与圆、圆与矩形碰撞判定CollisionUtil.ts挡板控制响应触摸/鼠标事件计算发射角度与力度Flipper.ts计分系统命中目标、结算得分、生命值ScoreManager.ts这个划分思路核心逻辑是“互相解耦”。球只管自己怎么运动碰撞工具只负责判断“碰没碰到”挡板只管接收玩家输入并换算成角度和力度计分系统订阅碰撞事件去更新分数。这样一来调试定位问题非常快如果球穿过了挡板那问题在碰撞工具不在挡板如果挡板转动不正常那问题在Flipper跟碰撞无关。对于初学者这种结构也方便你顺着调用链去理解整个游戏从发球到计分的完整流程。2. 核心机制与关键技术点2.1 碰撞检测弹珠游戏的心脏弹珠游戏玩来玩去核心问题只有一个两个东西碰上了没有碰上了方向怎么变因为弹珠是圆形场景里的钉子也是圆形挡板是矩形所以碰撞检测可以简化为两类基础判定圆与圆、圆与矩形。圆与圆碰撞的判定很简单两圆心距离小于等于半径之和就是碰上了。代码写起来就是算一个平方根的距离function checkCircleCollision(a: Ball, b: Ball): boolean { const dx a.x - b.x; const dy a.y - b.y; const dist Math.sqrt(dx * dx dy * dy); return dist a.radius b.radius; }圆与矩形的判定稍微绕一点点核心思路是先找到矩形上离圆的圆心最近的那个点然后看圆心到该点的距离是否小于圆的半径。找最近点的方法是逐个维度做Clamp——把圆心坐标限制到矩形的坐标范围内。function checkCircleRectCollision(ball: Ball, rect: Rect): boolean { const closestX Math.max(rect.x, Math.min(ball.x, rect.x rect.width)); const closestY Math.max(rect.y, Math.min(ball.y, rect.y rect.height)); const dx ball.x - closestX; const dy ball.y - closestY; return (dx * dx dy * dy) ball.radius * ball.radius; }这里为什么可以用Clamp直接找最近点你可以把矩形想象成一个被压扁的盒子。圆心的水平坐标在盒子左边之外就取左边界的值在盒子右边之外就取右边界的值在盒子内部就保持原值。竖直方向同理。这么处理之后最近点一定在矩形边界上或者内部中心到它的距离大于等于矩形上任意一条边到圆心的最短距离。理解了这个原理后面不管是旋转的挡板、倾斜的通道你就能自己去扩展判定逻辑。注意碰撞检测频率要跟球的移动速度匹配。如果球一帧移动的距离比钉子半径还大就会直接越过钉子下一帧检测时已经完全错开视觉上就是“穿模”。这个问题在第4节我会详细说处理方法。2.2 反射方向与挡板角度控制碰上了之后球往哪走这是弹珠游戏手感好坏的关键。物理上的规律是反射定律入射角等于反射角。用向量计算非常简洁已知法线方向碰撞点指向球心的方向和球的速度向量反射向量就是// n: 法线方向单位向量 // v: 碰撞前速度向量 function reflect(vx: number, vy: number, nx: number, ny: number) { const dot vx * nx vy * ny; return { vx: vx - 2 * dot * nx, vy: vy - 2 * dot * ny }; }公式原理可以这样理解速度向量分解为“沿法线方向分量”和“切向方向分量”反射后法线分量反向乘以负一切向分量不变。代码里的2 * dot * nx就是在算两次法线分量因为一次是将法线分量归零再减一次才是反向。生活类比把球往墙上斜着扔碰墙瞬间墙只改写法线方向的速度平行于墙面的速度是不变的。挡板的操作手感在这个项目里用的是角度控制加力度条方案而不是直接给一个固定速度。玩家点击屏幕对应区域后按下时间越长力度越大抬手的瞬间挡板当前的旋转角度决定方向。为什么这么设计因为直接设速度对玩家来说不可控而角度加力度是人手操作直觉的映射——按下蓄力松开发射这跟篮球出手前压腕是一个道理。代码上挡板对象会记录currentAngle按下的时间戳跟当前时间戳做差映射到一个力度值最终发射速度用三角函数分解const rad this.currentAngle * Math.PI / 180; ball.vx Math.cos(rad) * power; ball.vy Math.sin(rad) * power;这样就保证玩家往哪个方向拨挡板球就往哪个方向飞力度条拉满也不会一下飞出屏幕因为power上限封住了。2.3 得分、生命与状态机很多新手写游戏时会忽略状态管理导致疯狂出bug球还在滚动玩家就点了一下发球又生成了一个新球或者游戏结算弹窗还没关球又碰了一下加分点。这套源码里用了一个非常轻量的状态机来管游戏流程WAITING待发球、PLAYING球运行中、GAMEOVER游戏结束。状态只能按顺序流转比如GAMEOVER状态下点击发球是无效的必须走“重新开始”流程才能回到WAITING。计分系统的设计同样简单清晰得分碰撞点上有专门的触发器命中时通过事件分发让计分系统更新分数同时播放音效。这样的好处是以后想加“连击加成”“暴击双倍”之类的玩法只需要在计分系统里扩展不需要动碰撞逻辑。生命周期管理上球一旦从底部漏洞掉出去场景管理器会先把球的显示对象从舞台上移除再把它从对象列表里删掉最后把引用置空防止内存泄漏。小游戏跑在手机上内存本来就紧张这种“掉了就回收、不够就重用”的习惯能避免很多卡顿问题。3. 实操过程从零搭建可运行的源码工程3.1 工程结构与源码文件说明如果你拿到的是完整工程用Egret工具打开就能跑。我建议你动手前先执行一次干净构建确认环境没问题。推荐用命令行方式创建项目egret create PinballGame --type eui然后把你拿到的源码按模块覆盖到对应目录。一个标准Egret弹珠项目的目录结构大致是PinballGame/ ├── src/ │ ├── Main.ts # 入口初始化舞台和资源 │ ├── GameScene.ts # 游戏主场景所有游戏元素在这里装配 │ ├── Ball.ts # 弹珠对象 │ ├── Flipper.ts # 玩家操作的挡板 │ ├── CollisionUtil.ts # 碰撞判定静态工具 │ ├── ScoreManager.ts # 计分与生命管理 │ └── assets/ # 资源文件图片、声音 ├── resource/ │ ├── default.res.json # 资源配置清单 │ └── assets/ # 图集、音频文件 ├── template/ # 发布模板各平台 ├── egretProperties.json # 引擎版本与项目配置 └── index.html # 开发期页面入口拿到源码后建议你第一件事就是打开egretProperties.json看引擎版本然后在index.html里核对>egret.Ticker.getInstance().register(this.onTick, this);onTick里做的事情非常朴素判断当前游戏状态如果是PLAYING就让球移动一段距离然后检测和所有碰撞体的关系有碰撞就更新速度没碰撞就继续下一次检测。private onTick(deltaTime: number) { if (this.gameState ! GameState.PLAYING) return; const dt deltaTime / 1000; this.ball.x this.ball.vx * dt; this.ball.y this.ball.vy * dt; this.checkAllCollisions(); this.checkOutOfBounds(); }这里有一个细节初学者很容易忽略deltaTime的单位是毫秒所以要除以1000换成秒这样速度单位才是像素/秒调整手感时数字直观。如果你直接拿毫秒去乘速度球会以1000倍的极速飞出屏幕。球的移动逻辑我放在Ball类自己身上Ball继承egret.Sprite所以ball.x和ball.y既是物理坐标又是渲染坐标直接同步不用额外维护一个“逻辑坐标”再映射到“显示坐标”这个设计大大减少了出错的概率。每个球有自己的半径属性碰撞工具不需要直接访问Ball内部的私有变量只要读x、y、radius就够了。检测到碰撞之后不要只改速度还要做一个重要的位置修正把球沿法线方向轻轻推开让球和障碍物刚好相切。这一步是防止球“抖动”的关键。如果不做位置修正球下一秒还是和障碍物重叠检测逻辑就会被反复触发视觉上球像粘在钉子上不停抖动非常影响手感。3.3 屏幕适配与手感调优Egret的适配策略一般写在index.html的scale-mode里。竖屏弹珠游戏我建议用FIXED_WIDTH它保证设计宽度严格对齐屏幕宽度高度方向做裁剪或扩展这样球台左右的比例在真机上和设计稿完全一致不会因为屏幕更宽而出现“球台变扁、球速视觉失真”的问题。另一种SHOW_ALL会完整展示整个设计画面但在宽屏手机上两边会出现大黑边弹珠台这种占满全屏的游戏不太合适。手感调优是我在这个项目里花时间最多的地方列几个关键参数可以直接改着试参数作用经验值球的阻尼系数每次碰撞后速度衰减模拟摩擦和空气阻力0.98最大速度防止球速无限增加导致穿透600像素/秒挡板发射角度范围控制可瞄准的范围太大会导致斜向乱飞30°~150°力度条蓄力时间满力需要的时间太快不好控制太慢着急1.2秒球的速度衰减用一个简单的阻尼值就能模拟得很像回事每次碰撞计算完反射速度后乘一个小于1的系数。系数越接近1球飞得越久弹珠台“停不下来”就没意思了系数太小碰两三下就软绵绵也没有弹珠的爽快感。0.98是我试过比较舒服的值你可以根据自己的玩法加子弹时间、加速道具等机制再调。经验手感调优一定要真机测试。在模拟器里你觉得舒服的速度和角度到了手机上因为屏幕尺寸和触控采样率差异体验会完全不一样。至少提前预留两个可配置参数FlipperSensitivity挡板灵敏度和MaxBallSpeed球的最大速度发布前临时调。4. 常见问题与排查技巧实录4.1 球高速穿透钉子这是弹珠游戏最经典的问题球速度起来了结果直接从钉子穿过去物理完全失效。原因是球每帧移动的步长过大一步就跨过了钉子的直径把碰撞检测的采样点跳过去了。核心解法两条路我建议两条都上第一限制最大速度。600像素/秒配60fps的帧率每帧移动10像素而钉子的半径一般15到20像素不会穿。把MAX_SPEED作为全局常量加在球上每次反射后算出新速度就clamp一次const speed Math.sqrt(vx * vx vy * vy); if (speed MAX_SPEED) { const ratio MAX_SPEED / speed; vx * ratio; vy * ratio; }第二子步长采样。如果球已经飞得很快或者你想要更快的球速可以把一次移动拆成多步每步都检测碰撞const subSteps 4; const subDt dt / subSteps; for (let i 0; i subSteps; i) { this.ball.x this.ball.vx * subDt; this.ball.y this.ball.vy * subDt; this.checkAllCollisions(); }用4步子采样就算球的速度再快每帧至少也有4次检测点穿透概率降到极低。缺点是碰撞检测调用次数变成4倍但弹珠台场景里的碰撞体数量本来就不多性能开销完全可控。4.2 挡板旋转中心不对发球方向反直觉新手最容易踩的坑是挡板锚点设置错误。Egret里Sprite的旋转是绕着锚点anchorOffsetX/anchorOffsetY转的默认锚点在(0, 0)。如果挡板的图片是一根横杆而锚点设置在了图片左上角旋转起来就是绕着杆头转而不是绕着杆的支点转看起来就像挡板在“扫荡”发射方向也完全对不上。解决方式是在加载挡板图片之后把锚点设到挡板旋转支点的位置this.anchorOffsetX this.width / 2; this.anchorOffsetY this.height / 2;还有一种情况是挡板是斜着的支点跟几何中心不重合那就要根据美术资源的具体坐标来指定锚点而不是无脑用宽高中点。调试技巧是给挡板加上DebugDraw——在支点位置画一个小红点运行时看旋转是否围绕红点转。看不见的坐标问题最快的方式就是可视化。另外发射角度也要做象限修正。因为Egret坐标系的y轴向下角度0度指向右边90度指向下所以如果你在三角函数里直接填角度很容易得到反直觉的方向。代码里做一次统一换算玩家在挡板左侧点击发射方向要朝左上角度换算成右上时还要做一次镜像。我习惯用一个函数angleToVector(angle)集中处理所有发射逻辑都走这个函数避免临时换算搞混。4.3 球黏在障碍物上抖动球碰到钉子后没有“弹开”而是钉在钉子上原地高频抖动。上一帧判定碰撞把速度反向下一帧球因为位置修正没做好仍然和钉子重叠于是再次触发碰撞速度又被反向如此反复。解决办法是碰撞处理时除了改速度还要立刻做位置修正沿法线方向把球推出到恰好相切的位置。前面提过一次这里再给一段可以直接用的代码function resolveCircleCollision(ball: Ball, obstacle: Ball) { const dx ball.x - obstacle.x; const dy ball.y - obstacle.y; const dist Math.sqrt(dx * dx dy * dy); // 法线方向单位向量 const nx dx / dist; const ny dy / dist; // 重叠深度 const overlap ball.radius obstacle.radius - dist; // 把球沿法线方向推开 ball.x nx * overlap; ball.y ny * overlap; // 再反射速度 const reflected reflect(ball.vx, ball.vy, nx, ny); ball.vx reflected.vx; ball.vy reflected.vy; }注意这里有个隐藏问题如果dist恰好是0也就是计算玩家发射角度时不小心把球放在了和钉子完全重合的位置dx和dy都是0除以0得到NaN然后球就会瞬间消失。预防的办法是在除法前判断dist 1e-6时随便给一个默认法线方向比如nx0, ny-1向上推。4.4 不同分辨率下面板和球错位这个问题多半不是代码逻辑问题而是资源适配问题。弹珠游戏的元素位置如果用绝对坐标写死在iPhone SE和iPhone Pro Max上表现必然不一样。一套可用的做法是所有布局坐标用设计稿比例来算。在设计分辨率640x1136下挡板位置是(320, 1050)那么真机运行时动态计算const scaleX this.stage.stageWidth / DESIGN_WIDTH; const scaleY this.stage.stageHeight / DESIGN_HEIGHT; this.flipper.x 320 * scaleX; this.flipper.y 1050 * scaleY;虽然Egret的适配模式已经帮忙缩放了画布但如果你在场景里用了外部加载的资源它们不会自动跟着适配需要自己手动按比例设置位置和大小。还有个小技巧把整个游戏场景打包成一个容器统一设缩放这样所有子元素自动跟着适配代码量最小const scale Math.min( this.stage.stageWidth / DESIGN_WIDTH, this.stage.stageHeight / DESIGN_HEIGHT ); this.gameContainer.scaleX scale; this.gameContainer.scaleY scale;不过这个方案的代价是可能上下或左右出现留白看起来不如FIXED_WIDTH撑满屏幕的效果好。具体用哪种看你项目的视觉定位。最后再分享一个小技巧。我在调试碰撞参数的时候会在GameScene里加一个隐藏的调试面板把球当前速度、碰撞次数、是否发生穿透这些信息实时显示在舞台上。这个面板在正式发布时直接不创建就行不影响任何核心逻辑。有这样一个面板很多“感觉不对但又说不出来哪不对”的问题都能一眼定位——比如发现某颗钉子被碰了200次你自然会去看那颗钉子是不是位置写错了。弹珠游戏看着简单真正把物理和手感调到让人愿意反复玩的程度都是靠一遍遍试出来的。这套源码的意义就是给你一个可跑可改的起点别怕改坏改坏了再跑git diff对比这也是学习源码最快的方式。本文还有配套的精品资源点击获取