Unity画线蚂蚁寻迹:路径重采样与匀速移动实现详解
2026/9/14 7:34:31 网站建设 项目流程

简介:这是基于 Unity 引擎开发的休闲益智画线游戏完整项目源码,采用 C# 编写,适合有一定基础、希望学习商业游戏项目结构和移动端发布的开发者。游戏以手指画线引导蚂蚁找到食物并安全归巢为核心,融合路径规划、障碍规避和策略思考,覆盖主要休闲玩法。项目适配 Unity2022.3.50f1 或更高版本,已集成 Google 移动广告与 GameAnalytics 分析,具备直接继续开发或上架二次改造的条件。压缩包共 2000 个文件,约 98.05MB,目录按 Assets、ProjectSettings、Packages 组织,检索方便。内含 167 个 C# 脚本、78 个预制体、45 个材质、23 个着色器、18 个动画、12 个模型、22 个音频,并有 APK 安装包和 SDK 库,便于安装体验与广告接入;从场景搭建、角色动画、UI 交互到广告调用均有可运行与二次开发的落脚点。已有 72 人学习/下载,适合作为提升 Unity 工程组织、移动端适配与休闲游戏开发能力的完整参考项目。

1. 画线玩法与蚂蚁寻迹:核心问题在路径数据而不在画线

拿到 Ant Flow 蚂蚁寻迹这个标题时,第一反应通常是去翻 LineRenderer 的调用,毕竟画线游戏一眼看过去的视觉核心是那根线。但真正把“画线”和“寻迹”串起来的关键,是源码里对路径点做重采样、建立距离表、再按弧长驱动蚂蚁移动的那一小段 C# 逻辑。它决定了线画得再好,蚂蚁走起来是匀速的,转环不会卡顿,还是根本走不到终点。它并不复杂,却贯穿了输入、数据结构和移动代理三层。这里就按“画线输入 → 路径处理 → 蚂蚁代理 → 规则判定 → 性能收尾”的顺序,把一套可直接落地的方案拆开讲。

2. 用 Unity 的 LineRenderer 实现画线输入与轨迹渲染

2.1 从鼠标触摸到世界坐标:Plane 投射与 UI 拦截

在 Ant Flow 这类 2D 画线玩法里,最常见的错误是直接把 Input.mousePosition 当成世界坐标写进去,结果屏幕坐标套上世界矩阵,画出来的线和手指位置差一截。标准做法是用 Camera.ScreenPointToRay 打一条射线,再和游戏平面求交。项目里如果使用 Orthographic 相机,也可以直接 ScreenToWorldPoint,但源码里如果用了 Perspective 相机做轻微透视,Plane.Raycast 就是通用解法。平面用一个 Vector3.forward 朝向、过原点的 Plane 即可,2D 游戏的所有绘制点都落在 z=0 平面上。

另一个必须处理的是 UI 拦截。如果界面里有开始按钮、撤销或暂停按钮,画线输入会穿透 UGUI。所以在 Update 入口先判断 EventSystem.current.IsPointerOverGameObject(),为 true 时直接 return,避免在按钮上画出一笔。这个细节在休闲游戏里很容易被忽略,但它直接关系到玩家误触的概率。

using UnityEngine; using UnityEngine.EventSystems; public class LineDrawer : MonoBehaviour { public LineRenderer line; public Camera cam; private Plane drawPlane = new Plane(Vector3.forward, Vector3.zero); // 2D 游戏绘制平面 private bool isDrawing; void Update() { // 鼠标落在 UGUI 元素上时,不允许画线 if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return; if (Input.GetMouseButtonDown(0)) { isDrawing = true; line.positionCount = 0; // 这一笔从头开始 } else if (Input.GetMouseButton(0) && isDrawing) { if (TryGetWorldPoint(out Vector3 pos)) { line.positionCount += 1; line.SetPosition(line.positionCount - 1, pos); } } else if (Input.GetMouseButtonUp(0)) { isDrawing = false; OnDrawFinished(); // 这里在下一章接路径重采样 } } bool TryGetWorldPoint(out Vector3 worldPos) { Ray ray = cam.ScreenPointToRay(Input.mousePosition); if (drawPlane.Raycast(ray, out float dist)) { worldPos = ray.GetPoint(dist); return true; } worldPos = Vector3.zero; return false; } }

这段代码有两个值得注意的地方。drawPlane 只构造了一次,Plane 是结构体,放在字段里避免 Update 每帧 new。Raycast 返回的是射线起点到交点的距离 dist,再通过 ray.GetPoint(dist) 还原世界坐标,这样便于在需要时附加 z 偏移。positionCount 动态增减本身没有问题,但每帧调用 SetPosition 时会触发 LineRenderer 内部顶点上传,路径很长时性能会下降,这部分到后面优化章节处理。

2.2 LineRenderer 的关键参数与线型毛刺

LineRenderer 的参数对视觉和后续路径处理都有影响,参数配置通常在 Inspector 里完成,但源码阅读时要知道每一项的边界。

参数名推荐值 / 含义说明
useWorldSpacefalse使用本地坐标,配合 Canvas 或整体移动时方便整组平移,也能减少一次矩阵运算
widthMultiplier0.1 ~ 0.3线宽,按屏幕像素密度归一化,移动端建议取偏大值
numCapVertices4 ~ 8线帽圆滑度,太小会看到线头有毛刺
numCornerVertices2 ~ 4折角圆滑度,直接影响蚂蚁沿转角行走时的视觉表现
alignmentTransformZ2D 游戏用 TransformZ,避免 Billboard 效果导致线宽透视变化

这组参数里 widthMultiplier 和 numCapVertices 决定线条好不好看,useWorldSpace 和 alignment 则影响后续碰撞处理和移动端性能。如果源码里 LineRenderer 不显示线条,优先检查 useWorldSpace 是否在 transform 不在原点时设成了 true,这是最常见的“线画出来了但看不到”的原因。

2.3 撤销与清空:把“一笔”当数据结构管理

画线游戏不可能只画一笔。常见做法是维护一个最新笔迹列表,每次按下记录一个新的 Stroke,撤销时把对应的顶点数据移除。不要在 LineRenderer 里直接做裁剪,因为它不提供按笔迹删除的能力,重建整条线会触发全部顶点重新上传,数据层比渲染层便宜得多。

public class Stroke { public List<Vector3> points = new List<Vector3>(); // 这一笔的所有点 } public class StrokeManager { public List<Stroke> strokes = new List<Stroke>(); // 已完成的所有笔迹 public void BeginStroke() { strokes.Add(new Stroke()); // 压入一笔 } public void AddPoint(Vector3 p) { if (strokes.Count > 0) strokes[strokes.Count - 1].points.Add(p); } public void UndoLast() { if (strokes.Count > 0) strokes.RemoveAt(strokes.Count - 1); } }

这段代码不直接操作 LineRenderer,而是把数据层和渲染层分开。源码里如果把 Stroke 转成 Mesh 或 Texture2D 也是同样的道理,重点是撤销时只改数据,再让渲染层重建。

3. 路径重采样:把鼠标轨迹变成蚂蚁能匀速走的路线

3.1 为什么不能直接把 LineRenderer 的顶点交给蚂蚁

鼠标轨迹的问题在于采样频率不稳定:移动快时顶点间距大,移动慢时顶点几乎重合。蚂蚁如果按“每帧走到下一个点”的方式移动,会把同样的距离拆成数量不等的帧,视觉上快慢抖动,转弯时还会因为相邻点太近导致朝向乱跳。所以路径必须先归一化成“单位弧长均匀”的点列,这是画线寻迹类源码里 C# 数据处理最核心的一步。

重采样的另一个目的是为“距离表”打基础。距离表是蚂蚁沿路径位置映射的关键数据结构,它要求路径点之间的长度严格已知,原始轨迹恰好不满足这个条件。

3.2 Catmull-Rom 平滑与等距采样

常见做法是先用 Catmull-Rom 插值加密原始点,再按固定步长抽点。Catmull-Rom 的好处是插值曲线一定会经过原始控制点,不会像贝塞尔那样把线拉离用户手写的轨迹,对画线游戏来说,这直接关系到手感是否符合预期。

public static Vector3 CatmullRom(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { float t2 = t * t; float t3 = t2 * t; // Catmull-Rom 样条的矩阵展开形式 float a = 0.5f * (2f * p1); float b = 0.5f * (p2 - p0); float c = 0.5f * (2f * p0 - 5f * p1 + 4f * p2 - p3); float d = 0.5f * (p2 - p1); return a + b * t + c * t2 + d * t3; }

参数 p0 到 p3 是四个连续控制点,t 是 0 到 1 之间的插值系数。系数 0.5 对应标准 Catmull-Rom 张力,数值越小曲线越贴近折线,越大越圆滑。在密集轨迹里,遍历每个连续四点,每段内取多个 t 值,就得到平滑后的加密点列。加密后再做等距重采样,每次累加两点间距离,超过步长 step 就落一个点。

List<Vector3> Resample(List<Vector3> input, float step) { List<Vector3> result = new List<Vector3>(); if (input.Count == 0) return result; result.Add(input[0]); float carry = 0f; // 跨线段时留下的余量 for (int i = 1; i < input.Count; i++) { float segLen = Vector3.Distance(input[i - 1], input[i]); float d = carry + segLen; while (d >= step) { float t = (step - carry) / segLen; // 在本段内的比例 result.Add(Vector3.Lerp(input[i - 1], input[i], t)); carry += step; d -= step; } carry = d; } if (Vector3.Distance(result[result.Count - 1], input[input.Count - 1]) > step * 0.5f) result.Add(input[input.Count - 1]); // 保留终点,避免路径缩短 return result; }

这个实现比“每隔 n 个点取一个”多做了余量处理,carry 记录上次跨段剩余的弧长,保证采样点之间的直线距离尽可能接近 step,而不是随原始分布波动。采样点之间距离不均会让距离表计算失真,蚂蚁速度就做不到均匀。step 的建议值见下表。

采样步长 step适用场景实际效果
0.05 世界单位短线、精细转弯路径点数多,转向细致
0.1 ~ 0.15大多数关卡平衡平滑度与性能
0.3 以上长距离路径顶点少,转角可能被削平

如果蚂蚁身体的视觉半径是 0.2 世界单位,step 取 0.1 左右比较合适。step 大于蚂蚁体宽的一半时,蚂蚁在转角处可能出现“切角”现象,看起来像是直接穿过了一段路径。

3.3 路径距离表与二分查找:C# 里 List 的选择

有了等距点列后,构建一个累计距离数组,第 i 项表示从起点到第 i 个点的累计长度。蚂蚁每帧要回答的问题是:我行驶了多少距离,落在哪条线段上。顺序遍历是 O(n),当路径点有 500 个时,10 只蚂蚁每帧就是 5000 次距离比较,移动端会明显发热。用二分查找可以把单次定位降到 O(log n)。

int FindSegment(float target) { int lo = 0; int hi = distances.Count - 1; while (lo < hi - 1) // 收敛到相邻两个点之间 { int mid = (lo + hi) >> 1; // 右移一位等于除以 2 if (distances[mid] <= target) lo = mid; else hi = mid; } return lo; // 目标距离落在 [lo, lo+1] 区间 }

distances 在这个类里用 List ,如果路径点固定不变,可以换成 float[],减少一次间接寻址。右移代替除 2 在这里没有性能上的实际收益,主要是我习惯用位运算保持风格一致,避免在循环里写除法。查到 lo 之后,用线段两端点的世界坐标做 Lerp,就能精确得到蚂蚁的位置。

4. 蚂蚁代理:沿路径移动、朝向插值与多蚂蚁错峰

4.1 蚂蚁的移动驱动:距离累计而不是物理速度

Ant Flow 里的蚂蚁移动是典型的“代理沿路径行走”模式。不要在 Update 里直接用 transform.Translate 朝目标移动,因为那样无法把移动距离和弧长对应起来。正确做法是每帧给 totalDistance 累加 speed * deltaTime,再通过 FindSegment 查表得到精确位置。这样无论 fps 是 30 还是 120,蚂蚁到达终点的总时间都一致。

public class AntAgent : MonoBehaviour { public float speed = 1.2f; // 每秒走过的世界单位 public float arriveRadius = 0.1f; // 到达终点的判定范围 private float totalDistance; // 已行驶的累计弧长 private PathData path; // 重采样后的路径与距离表 void Update() { totalDistance += speed * Time.deltaTime; if (totalDistance >= path.length) { OnArrive(); return; } int seg = path.FindSegment(totalDistance); Vector3 a = path.points[seg]; Vector3 b = path.points[seg + 1]; float segLen = Vector3.Distance(a, b); float t = (segLen > 0.0001f) ? (totalDistance - path.distances[seg]) / segLen : 0f; transform.position = Vector3.Lerp(a, b, t); // 朝向使用线段方向,Slerp 平滑插值 Vector3 dir = (b - a).normalized; Quaternion target = Quaternion.LookRotation(Vector3.forward, dir); float k = 1f - Mathf.Exp(-8f * Time.deltaTime); transform.rotation = Quaternion.Slerp(transform.rotation, target, k); } }

核心在于 totalDistance 是弧长而不是位移,这保证蚂蚁在弯道和直线上消耗的时间与路径长度严格成正比。朝向插值的 k 用指数衰减公式代替固定 Slerp 系数,这个写法的好处是帧率无关,无论 30 帧还是 60 帧,转向的“黏着感”保持一致。固定 0.1 这种系数在高低帧率下差别很大,是移动端工程里常见的坑。

参数推荐值作用与影响
speed1.0 ~ 1.5 世界单位/秒越小越有“寻迹感”,越大越难控制
arriveRadius0.05 ~ 0.2小于该距离判定到达,避免浮点误差
k 指数系数4 ~ 12越大转向越生硬,越小越迟钝,8 是平衡点
错峰 delay0.5 ~ 2.0 秒多蚂蚁依次出发,避免视觉重叠

提示:指数衰减公式中的 8 表示每秒约完成 8 次时间常数,如果想让转向更跟手可以提高到 10 以上。

4.2 转角插值:避免蚂蚁在折点处瞬间转头

上一节代码用 Slerp 配合指数衰减已经能解决绝大多数折角跳变。但如果采样步长过大,折角依然突兀。另一个常见问题是 transform.LookAt 在 2D 里会造成物体绕 x/y 轴翻转,因为 LookAt 默认让物体的 z 轴指向目标点,而 2D 游戏的 z 轴本来就朝向屏幕外。所以代码里用 LookRotation(Vector3.forward, dir),把正前方固定到 z 轴,dir 作为向上参数传入,这是 Unity 2D 游戏的标准解法。

如果还需要更平滑的转向,可以对方向向量做低通滤波。维护一个 smoothedDir,每帧把当前线段方向和它按系数混合,再用 smoothedDir 做 LookRotation。这个方法在连续 S 弯里表现更稳定,但会让蚂蚁转向有轻微“滞后”,具体系数要按玩法手调。

4.3 多蚂蚁错峰出发:管理器驱动而不是各自计时

多蚂蚁同时运行时,如果每只蚂蚁在 Start 里各自延迟,后续控制会乱。常见做法是有一个 PathRunner 管理器持有所有 AntAgent,按队列下发出发事件。每只蚂蚁维护一个简单的状态就好,管理器负责节奏。

public class AntManager : MonoBehaviour { public AntAgent[] ants; // 场景中所有蚂蚁代理 public float delayBetween = 1f; // 每只蚂蚁出发间隔(秒) private float timer; private int nextIndex; void Update() { if (nextIndex >= ants.Length) return; timer += Time.deltaTime; if (timer >= delayBetween) { ants[nextIndex].StartMoving(); nextIndex++; timer = 0f; } } }

用 nextIndex 保证蚂蚁按顺序出发。如果希望所有蚂蚁统一出发,把 delayBetween 设成 0 即可,但同一条路径上多只蚂蚁会叠在同一个位置,视觉效果很差。要解决重叠可以给每只蚂蚁加一个垂直于路径方向的偏移,作为 routeOffset 字段在 AntAgent 里保存。

蚂蚁状态进入条件行为
Waiting管理器启动原地等待,可播放触角动画
Movingdelay 到点沿路径移动,检测食物和碰撞
Arrived到达终点播放到达动画,通知 GameManager 结算

5. 食物判定、画线碰撞与回合状态机

5.1 食物收集:OverlapCircle 比碰撞回调更可控

蚂蚁吃到食物的判定有两种常见方案。一种是给食物挂 Collider 并依赖 OnTriggerEnter2D,但蚂蚁移动是代码驱动的,物理回调会存在延迟,高速移动时偶尔出现“穿过去但没吃到”的观感问题。更可控的方案是蚂蚁每帧主动检测,用 Physics2D.OverlapCircle 或者直接手工计算距离。

提示:使用距离判定时,判定半径应该略小于食物视觉半径,避免玩家看到“隔空取物”。

public class AntAgent : MonoBehaviour { public LayerMask foodLayer; public float foodCheckRadius = 0.35f; void CheckFood() { Collider2D hit = Physics2D.OverlapCircle(transform.position, foodCheckRadius, foodLayer); if (hit != null) { Food f = hit.GetComponentInParent<Food>(); if (f != null && !f.picked) { f.picked = true; // 防止同一帧两只蚂蚁重复拾取 GameManager.Instance.AddScore(1); Destroy(f.gameObject); } } } }

foodCheckRadius 建议和食物视觉半径一致或略小一点。GetComponentInParent 是为了兼容食物预制体里 Sprite 在子物体的情况。picked 标志在销毁前必须置位,否则同帧内其他蚂蚁也会检测到这个食物,出现重复计分的竞态问题。

5.2 画线阻挡:把重采样点转成 EdgeCollider2D

如果玩法要求蚂蚁碰到自己画的线就停止或回退,就需要把 LineRenderer 的顶点转成物理边界。但直接拿几万个顶点交给 EdgeCollider2D 会在物理引擎内部产生极高的初始化开销,有时会卡顿数帧。常见做法是每 0.2 世界单位取一个简化点,再用 SetPoints 一次性设置。

void RebuildEdgeCollider(PathData path, EdgeCollider2D edge) { // 每 0.2 世界单位取一个点,减少物理引擎的边数 List<Vector2> pts = new List<Vector2>(); for (float d = 0; d <= path.length; d += 0.2f) { int seg = path.FindSegment(d); Vector3 a = path.points[seg]; Vector3 b = path.points[seg + 1]; float t = (d - path.distances[seg]) / (path.distances[seg + 1] - path.distances[seg]); pts.Add(Vector2.Lerp(a, b, t)); } edge.SetPoints(pts); // 一次性赋值,而不是逐点 AddPoint }

SetPoints 相比逐个 SetPosition 对物理引擎更友好,后者每调用一次都会触发内部重建。0.2 的简化步长对“碰线弹开”这类判定足够,但如果要做更精确的“蚂蚁沿路径内边界走”,还是直接基于路径数据做距离判断更划算,不需要碰物理系统。

5.3 回合状态机:用 enum 锁住画线和蚂蚁的互斥

画线阶段和蚂蚁移动阶段必须互斥。玩家在蚂蚁运行时画线,会改变路径数据,导致蚂蚁所在路径对象失效。项目里通常有一个 RoundManager 用 enum 维护当前状态,状态转换集中在同一个类里,便于排查问题。

状态进入条件主要行为退出条件
DrawPhase回合开始允许玩家画线、撤销玩家点击“出发”按钮
WaitForAnts蚂蚁开始移动禁止画线,播放等待动画所有蚂蚁到达或超时
ResultPhase回合结算打开结算界面与下一关玩家点击“下一关”
public enum RoundState { DrawPhase, WaitForAnts, ResultPhase } public class RoundManager : MonoBehaviour { public RoundState state = RoundState.DrawPhase; public void StartAnts() { if (state != RoundState.DrawPhase) return; // 状态锁,防止重复触发 state = RoundState.WaitForAnts; antManager.RunAllAnts(); } void OnAllAntsArrived() { state = RoundState.ResultPhase; } public void NextRound() { if (state == RoundState.ResultPhase) { state = RoundState.DrawPhase; // 清空画线、重置蚂蚁位置 } } }

StartAnts 里的 if 判断是必须的,否则玩家在动画期间连续点击出发按钮会导致蚂蚁重复启动。这个状态机足够应付回合制休闲游戏,如果要加入“蚂蚁运行中线条逐渐消失”的多段逻辑,再考虑嵌套状态,这里不要过度设计。

6. 源码收尾的三个优化细节:帧率、GC 与碰撞开销

6.1 关闭 LineRenderer 的 useWorldSpace 减少矩阵更新

2D 游戏里画线区域固定时,把 useWorldSpace 设为 false 可以让线条跟随 transform 统一移动,渲染时减少一次世界矩阵变化。这个优化本身收益有限,但它决定了后续碰撞和渲染是否共用一套坐标系,也方便做整体关卡位移和屏幕震动。

6.2 用缓存数组代替每帧 List 分配

private Vector3[] segBuffer = new Vector3[2]; void SetSegment(Vector3 a, Vector3 b) { segBuffer[0] = a; segBuffer[1] = b; line.SetPositions(segBuffer); // 复用数组,避免每帧分配 }

LineRenderer.SetPositions 接受数组参数,每帧 new Vector3[2] 看起来无害,但几十只蚂蚁加辅助线每秒会产生大量短生命周期对象,触发频繁 GC。缓存数组是 C# 移动端优化里最常用的一招。

6.3 用距离判定代替射线检测

优化点做法收益
线段碰撞用点到线段距离公式避免每帧多次 RaycastAll
拾取食物OverlapCircle 只检测蚂蚁周围替代全局遍历所有食物
路径查找预构建距离表 + 二分查找替代每帧 O(n) 顺序扫描

这类休闲游戏物体数量不多,最大开销往往在物理层。点到线段距离公式只有几次乘法和比较,比 Raycast 穿过物理引擎快一个数量级,实现上就是把蚂蚁位置投影到线段上,再判断投影点是否在线段范围内。优化完成后用 Unity Profiler 的 CPU Usage 模块看 AntAgent.Update,每帧耗时应该比优化前低一个数量级;如果还卡,优先检查采样步长 step 而不是纹理或粒子,因为 step 直接决定路径点数,路径点数决定 LineRenderer 顶点数、EdgeCollider 重建成本和每只蚂蚁的定位开销。

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

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

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

立即咨询