简介这是一份基于Unity引擎实现的祖玛Zuma游戏完整示例工程面向想学习2D休闲游戏开发的初学者与进阶开发者可用于研究发射器控制、彩球碰撞检测、匹配消除与分数计算等核心机制的落地方式。资源包共399个文件约3.36MB以cs脚本、meta与asset资源文件、png贴图、mp3音效、unity场景及csproj、sln工程文件为主另含ADB_Install.bat等辅助脚本Assets与ProjectSettings目录结构完整便于直接导入Unity编辑器运行与调试。项目标签涉及CDN资源分发思路对纹理、音频等游戏资产的远程加载与优化有一定参考价值。已有1158人学习下载读者可借此掌握Unity中游戏逻辑组织、资源管理与交互界面实现并在此基础上扩展关卡与玩法适合作为课程设计或练手项目的起点。1. 从零拆解 Unity Zuma Code一条曲线、三套系统与五个必踩的坑很多人第一次看到“Unity Zuma Code”这个标题脑子里浮现的是祖玛游戏里那只吐球的青蛙但真正动手做过的人知道它背后是一套相当紧凑的工程组合样条曲线做轨道、对象池做球体管理、状态机做发射与消除判定。我最初接手一个类似玩法的原型时以为两三天就能跑通结果光是把球沿着曲线平滑排列就折腾了整整一周。这个标题适合两类人一是想用 Unity 做一个完整可玩小游戏的独立开发者二是想借这个案例吃透曲线插值、对象池和碰撞检测的 Unity 学习者。它解决的核心问题是——如何用一套可复用的代码结构把“沿路径运动 动态增删 碰撞消除”这三件事串起来而不是每换一个关卡就重写一遍逻辑。下面我按实际开发顺序把这条链路拆开讲清楚。2. 轨道曲线怎么建从 Catmull-Rom 到等距采样2.1 为什么不用 Unity 自带的 LineRenderer 直接当轨道LineRenderer 画出来的线是给人看的不是给逻辑用的。它不提供“给定弧长比例返回世界坐标”这种接口而祖玛类玩法的核心恰恰是球体要沿轨道等间距排列发射球要能插入到任意两个球之间消除后后面的球要自动补位。这些操作全都依赖一个稳定的“弧长参数化”曲线。常见做法是用 Catmull-Rom 样条因为它只需要控制点就能生成平滑曲线而且计算量小适合每帧对几十个球做位置更新。我一般会先写一个 Curve 类内部维护一组控制点对外暴露两个方法GetPointAtDistance(float distance) 和 GetTotalLength()。实现思路是先用分段线性近似算出总长度再建立一张“距离到段索引”的查找表。下面是最小可用的代码骨架。using UnityEngine; public class ZumaCurve { public Vector3[] controlPoints; // 控制点至少4个 private float[] _segmentLengths; // 每段近似长度 private float _totalLength; public ZumaCurve(Vector3[] points) { controlPoints points; BuildLengthTable(); } // 用折线逼近曲线采样精度影响性能与平滑度 private void BuildLengthTable() { int segCount controlPoints.Length - 3; // Catmull-Rom 每段用4个点 _segmentLengths new float[segCount]; _totalLength 0f; const int samplesPerSeg 20; // 每段采样20次可根据需要调整 for (int i 0; i segCount; i) { float len 0f; Vector3 prev GetCatmullRomPoint(i, 0f); for (int s 1; s samplesPerSeg; s) { float t s / (float)samplesPerSeg; Vector3 cur GetCatmullRomPoint(i, t); len Vector3.Distance(prev, cur); prev cur; } _segmentLengths[i] len; _totalLength len; } } // 标准 Catmull-Rom 插值t 在 [0,1] 之间 private Vector3 GetCatmullRomPoint(int segIndex, float t) { Vector3 p0 controlPoints[segIndex]; Vector3 p1 controlPoints[segIndex 1]; Vector3 p2 controlPoints[segIndex 2]; Vector3 p3 controlPoints[segIndex 3]; float t2 t * t; float t3 t2 * t; return 0.5f * ((2f * p1) (-p0 p2) * t (2f * p0 - 5f * p1 4f * p2 - p3) * t2 (-p0 3f * p1 - 3f * p2 p3) * t3); } public float TotalLength _totalLength; // 根据弧长距离返回世界坐标distance 超出范围会钳制到端点 public Vector3 GetPointAtDistance(float distance) { distance Mathf.Clamp(distance, 0f, _totalLength); float accum 0f; for (int i 0; i _segmentLengths.Length; i) { if (accum _segmentLengths[i] distance) { float localDist distance - accum; float t localDist / _segmentLengths[i]; return GetCatmullRomPoint(i, t); } accum _segmentLengths[i]; } return GetCatmullRomPoint(_segmentLengths.Length - 1, 1f); } }这段代码里最关键的参数是 samplesPerSeg。设得太小曲线长度估算不准球体间距会忽大忽小设得太大初始化时计算量上升但运行时因为查表所以影响不大。我一般取 20 到 30 之间视觉上已经足够平滑。另一个容易翻车的地方是控制点数量Catmull-Rom 要求至少 4 个点才能生成一段曲线如果你只给 3 个点segCount 会变成 0后面所有逻辑都会静默失效。血泪经验是在 BuildLengthTable 开头加一句 if (controlPoints.Length 4) 直接报错比后面查半天强。2.2 等距采样与球体排列的初始化有了曲线之后下一步是把球按固定间距摆上去。假设每个球的直径是 0.5 个单位那么球心间距就是 0.5。初始化时从距离 0 开始每隔 0.5 取一个点生成球体并记录它的弧长位置。这里不要用 Transform.position 直接赋值就完事因为后续插入和消除都需要知道每个球在曲线上的“序号”和“距离”。我一般会维护一个 List 每个 Ball 存一个 float distanceOnCurve并且这个列表始终按 distanceOnCurve 升序排列。这样插入时只需要找到目标距离把后面所有球的 distanceOnCurve 加上一个球径再统一刷新位置即可。public class BallSpawner : MonoBehaviour { public ZumaCurve curve; public GameObject ballPrefab; public float ballDiameter 0.5f; private ListBall _balls new ListBall(); public void InitBalls(int count) { for (int i 0; i count; i) { float dist i * ballDiameter; if (dist curve.TotalLength) break; GameObject go Instantiate(ballPrefab); Ball ball go.GetComponentBall(); ball.distanceOnCurve dist; ball.transform.position curve.GetPointAtDistance(dist); _balls.Add(ball); } } // 在指定距离处插入一个新球后面的球全部后移 public void InsertBall(float insertDistance, GameObject prefab) { GameObject go Instantiate(prefab); Ball newBall go.GetComponentBall(); newBall.distanceOnCurve insertDistance; _balls.Add(newBall); _balls.Sort((a, b) a.distanceOnCurve.CompareTo(b.distanceOnCurve)); // 从插入点之后的所有球距离增加一个球径 bool found false; foreach (var b in _balls) { if (b newBall) { found true; continue; } if (found) b.distanceOnCurve ballDiameter; } RefreshPositions(); } private void RefreshPositions() { foreach (var b in _balls) { b.transform.position curve.GetPointAtDistance(b.distanceOnCurve); } } }InsertBall 里那个 found 标记是必须的因为 List.Sort 之后新球的位置不确定不能靠索引判断。另外注意每次插入都做一次全量 RefreshPositions 在球数少时没问题球数上百时会有性能压力后面讲对象池时会提到优化方式。3. 对象池与球体管理别让 Instantiate 毁了帧率3.1 为什么祖玛类玩法必须上对象池祖玛的球是不断生成和销毁的发射一颗、消除三颗、再补位。如果每次都用 Instantiate 和 DestroyGC 会在几十秒内积累到让你看到明显的卡顿。我实测过一个中等复杂度的轨道球数峰值约 80不做池化的情况下每消除一组球就会触发一次 2 到 3 毫秒的 GC 尖峰在移动端直接表现为掉帧。对象池的思路很简单预先生成一批球不用时 SetActive(false) 并回收需要时取出来重置状态。但坑在于球的“状态”不只是位置还有颜色、是否在轨道上、是否正在飞行。回收时必须把这些全部重置否则会出现“新发射的球带着上一颗球的颜色”这种玄学 bug。3.2 一个够用的对象池实现下面这个池子不追求通用只针对 Ball 类型但足够稳定。using System.Collections.Generic; using UnityEngine; public class BallPool : MonoBehaviour { public GameObject ballPrefab; public int initialSize 100; private QueueBall _pool new QueueBall(); void Awake() { for (int i 0; i initialSize; i) { GameObject go Instantiate(ballPrefab, transform); go.SetActive(false); _pool.Enqueue(go.GetComponentBall()); } } public Ball Get() { if (_pool.Count 0) { // 池子空了就扩容避免运行时卡死 GameObject go Instantiate(ballPrefab, transform); return go.GetComponentBall(); } Ball ball _pool.Dequeue(); ball.gameObject.SetActive(true); return ball; } public void Return(Ball ball) { ball.ResetState(); // 必须重置颜色、速度、标记位 ball.gameObject.SetActive(false); _pool.Enqueue(ball); } }Ball.ResetState 里至少要清空当前颜色、是否在轨道上、飞行速度、碰撞标记。漏掉任何一个后面都会以奇怪的方式表现出来。比如忘了清速度回收后的球再被取出时会带着旧速度飞出去看起来就像“球自己跑了”。这个坑我踩过两次后来养成习惯ResetState 里把所有 public 字段都显式赋一遍默认值哪怕看起来多余。3.3 球链的插入与消除逻辑消除判定通常是发射球插入后检查同色连续数量是否达到 3 或以上。这里的关键是“连续”必须基于弧长距离判断而不是基于列表索引。因为球在曲线上是等距的索引相邻基本等价于距离相邻但插入操作会打乱索引顺序所以稳妥做法是先按 distanceOnCurve 排序然后从插入点向两侧扫描只要颜色相同且距离差在一个球径以内就计入。消除后被消除球的位置要标记为“空洞”后面的球在下一帧统一前移补位。前移的实现就是遍历剩余球重新分配 distanceOnCurve让它们从消除起点开始紧密排列。public void CheckAndEliminate(Ball inserted) { ListBall sameColor new ListBall(); sameColor.Add(inserted); // 向两侧扫描阈值用球径的1.1倍容错 float threshold ballDiameter * 1.1f; // 先按距离排序 _balls.Sort((a, b) a.distanceOnCurve.CompareTo(b.distanceOnCurve)); int idx _balls.IndexOf(inserted); // 向左 for (int i idx - 1; i 0; i--) { if (_balls[i].color inserted.color Mathf.Abs(_balls[i].distanceOnCurve - _balls[i 1].distanceOnCurve) threshold) sameColor.Add(_balls[i]); else break; } // 向右 for (int i idx 1; i _balls.Count; i) { if (_balls[i].color inserted.color Mathf.Abs(_balls[i].distanceOnCurve - _balls[i - 1].distanceOnCurve) threshold) sameColor.Add(_balls[i]); else break; } if (sameColor.Count 3) { foreach (var b in sameColor) { _balls.Remove(b); pool.Return(b); } // 补位从消除位置开始重新紧密排列 float startDist sameColor[0].distanceOnCurve; foreach (var b in _balls) { if (b.distanceOnCurve startDist) b.distanceOnCurve - ballDiameter * sameColor.Count; } RefreshPositions(); } }注意补位时减去的距离是 ballDiameter * sameColor.Count而不是逐个减否则会出现球体重叠。这个细节在球数多的时候尤其明显少减一个球径就会看到两球挤在一起。4. 发射与碰撞射线检测的精度陷阱4.1 发射球怎么沿直线飞行并检测插入点发射球从炮口出发沿一个方向飞行直到碰到轨道上的球或飞出边界。常见做法是用射线检测但直接对球体做 Physics.Raycast 会有两个问题一是球体是球型碰撞体射线打上去返回的法线方向不直观二是高速飞行时可能穿透。我一般用 SphereCast从上一帧位置到当前位置做一次球形扫描半径设为发射球半径。命中后根据命中点的弧长距离决定插入位置。这里有个精度陷阱命中点不一定正好落在两个球中间如果直接取命中点的距离插入后可能出现球体重叠。解决办法是取命中点前后两个球的 distanceOnCurve 中点作为插入距离。public void FireBall(Vector3 direction, float speed) { Ball ball pool.Get(); ball.transform.position firePoint.position; ball.isFlying true; ball.velocity direction.normalized * speed; } void Update() { if (_flyingBall ! null _flyingBall.isFlying) { Vector3 prevPos _flyingBall.transform.position; Vector3 newPos prevPos _flyingBall.velocity * Time.deltaTime; float dist Vector3.Distance(prevPos, newPos); RaycastHit hit; if (Physics.SphereCast(prevPos, ballRadius, _flyingBall.velocity.normalized, out hit, dist)) { // 命中轨道球计算插入距离 Ball hitBall hit.collider.GetComponentBall(); if (hitBall ! null) { float insertDist FindInsertDistance(hitBall, hit.point); InsertBall(insertDist, _flyingBall.gameObject); _flyingBall.isFlying false; } } else { _flyingBall.transform.position newPos; } } } float FindInsertDistance(Ball hitBall, Vector3 hitPoint) { // 取命中球前后两个球的中点距离 int idx _balls.IndexOf(hitBall); float prevDist idx 0 ? _balls[idx - 1].distanceOnCurve : hitBall.distanceOnCurve - ballDiameter; float nextDist idx _balls.Count - 1 ? _balls[idx 1].distanceOnCurve : hitBall.distanceOnCurve ballDiameter; return (prevDist nextDist) * 0.5f; }SphereCast 的半径参数要和发射球的实际半径一致否则会出现“明明看着碰到了却没检测到”的情况。另外如果轨道球本身有多个碰撞体比如子物体上挂了碰撞体GetComponent 可能返回 null稳妥做法是用 GetComponentInParent 。4.2 发射冷却与输入缓冲祖玛类玩法的手感很大程度上取决于发射冷却。冷却太短玩家会连发导致球在空中互相干扰冷却太长操作会显得粘滞。我一般把冷却设在 0.15 到 0.25 秒之间并且加一个输入缓冲如果玩家在冷却期间按了发射记录一次待发射冷却结束后立即执行。这个缓冲能显著提升手感代码上只需要一个 bool 标记。private float _cooldownTimer 0f; private bool _bufferedFire false; void Update() { _cooldownTimer - Time.deltaTime; if (Input.GetMouseButtonDown(0)) { if (_cooldownTimer 0f) { FireBall(GetDirection(), 15f); _cooldownTimer 0.2f; } else { _bufferedFire true; } } if (_bufferedFire _cooldownTimer 0f) { FireBall(GetDirection(), 15f); _cooldownTimer 0.2f; _bufferedFire false; } }速度参数 15 是我在 1080p 下比较顺手的一个值屏幕越大可以适当调高。注意 FireBall 里不要直接改 transform.position而是交给 Update 里的飞行逻辑统一处理否则会出现一帧内位置跳变导致 SphereCast 漏检。5. 避坑与排查五个让原型崩掉的常见问题5.1 球体间距忽大忽小现象是球在曲线上排列不均匀拐弯处挤在一起直道上又拉得很开。原因几乎都是曲线长度估算不准samplesPerSeg 太小或者控制点分布不均。解决方法是把 samplesPerSeg 提高到 30并且在编辑器里把控制点可视化出来确保没有两个点靠得过近。如果曲线特别长可以考虑分段建表每段单独维护长度。5.2 消除后球体不补位或补位错乱现象是消除三颗球后后面的球原地不动或者突然跳到前面去。原因通常是补位时减去的距离不对或者 _balls 列表没有重新排序。解决方法是每次消除后先 Sort 再补位并且补位距离严格等于 ballDiameter * 消除数量。另外注意如果消除的球不在列表头部补位只影响消除点之后的球不要全量减。5.3 发射球穿透轨道球现象是发射球明明从轨道球中间穿过却没有触发插入。原因是飞行速度过快单帧位移超过了球体直径SphereCast 的检测距离不够。解决方法是把速度控制在每帧位移小于球半径或者在 Update 里把飞行拆成多个子步每步做一次 SphereCast。我一般用子步方式子步数取 Mathf.CeilToInt(dist / ballRadius)。5.4 对象池回收后球体颜色错乱现象是新发射的球带着上一颗球的颜色。原因是 ResetState 没有重置颜色字段或者颜色是通过材质实例修改的回收时没有恢复。解决方法是把颜色存在 Ball 的字段里渲染时统一从字段读取并设置材质属性而不是直接改材质。如果用了 MaterialPropertyBlock记得每次取出时重新 Set。5.5 曲线端点处球体堆叠现象是球走到曲线尽头后全部挤在最后一个点上。原因是 GetPointAtDistance 对超出总长度的距离做了 Clamp所有超出范围的球都返回同一个坐标。解决方法是在球到达终点前就触发游戏结束或循环逻辑或者在 Clamp 之前判断距离是否超出超出则标记该球为“已到达终点”并停止更新位置。6. 进阶技巧用弧长缓存把每帧计算压到最低前面几章里RefreshPositions 每次都会调用 GetPointAtDistance而 GetPointAtDistance 内部是线性遍历段长度表。球数少时没问题但当球数上百、每帧都要刷新时这个遍历会成为热点。我后来改成了一个更直接的做法预计算一张“距离到坐标”的缓存表精度取球径的十分之一然后用二分查找代替线性遍历。这样每帧每个球的位置查询从 O(n) 降到 O(log n)在移动端能省出 1 到 2 毫秒。// 预计算缓存表精度为 ballDiameter / 10 private Vector3[] _posCache; private float _cacheStep; public void BuildPositionCache(float step) { _cacheStep step; int count Mathf.CeilToInt(TotalLength / step) 1; _posCache new Vector3[count]; for (int i 0; i count; i) { _posCache[i] GetPointAtDistance(i * step); } } public Vector3 GetPointAtDistanceCached(float distance) { float fIndex distance / _cacheStep; int idx Mathf.Clamp(Mathf.FloorToInt(fIndex), 0, _posCache.Length - 2); float t fIndex - idx; return Vector3.Lerp(_posCache[idx], _posCache[idx 1], t); }缓存步长取 ballDiameter / 10 是个经验值再小内存占用上升但视觉提升不明显再大球在急转弯处会看出折线感。另外如果曲线在运行时不变这张表只需要建一次如果关卡会动态改轨道记得在改完后重建缓存。验证这套方案是否跑通我一般看三个指标球在轨道上是否等距、消除后补位是否在一帧内完成、连续发射 50 颗球后帧率是否稳定。如果这三个都过了基本就可以往里面加关卡和特效了。我自己踩过最深的坑是忘了在 Return 里重置颜色结果测试时看到一颗红色球发射出去变成蓝色查了两个小时才发现是池子的问题。从那以后我养成了一个习惯任何进池子的对象ResetState 里第一行先写注释“这里必须覆盖所有可变字段”然后逐个赋值。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?