☰
Unity祖玛游戏开发实战:曲线轨道、对象池与碰撞检测全解析
2026/10/13 13:18:08 网站建设 项目流程

简介:这是一份基于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 List<Ball> _balls = new List<Ball>(); 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.GetComponent<Ball>(); 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.GetComponent<Ball>(); 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 和 Destroy,GC 会在几十秒内积累到让你看到明显的卡顿。我实测过一个中等复杂度的轨道,球数峰值约 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 Queue<Ball> _pool = new Queue<Ball>(); void Awake() { for (int i = 0; i < initialSize; i++) { GameObject go = Instantiate(ballPrefab, transform); go.SetActive(false); _pool.Enqueue(go.GetComponent<Ball>()); } } public Ball Get() { if (_pool.Count == 0) { // 池子空了就扩容,避免运行时卡死 GameObject go = Instantiate(ballPrefab, transform); return go.GetComponent<Ball>(); } 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) { List<Ball> sameColor = new List<Ball>(); 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.GetComponent<Ball>(); 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 里第一行先写注释“这里必须覆盖所有可变字段”,然后逐个赋值。希望帮到你。

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

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

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

立即咨询