简介:这是一份基于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时随便给一个默认法线方向,比如nx=0, 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对比,这也是学习源码最快的方式。
本文还有配套的精品资源,点击获取