Lirb 的头像

Lirb.

Unity Gameplaying
P1 / 游戏策划

“我们的旅程是星辰大海”

“一个简单普通的男孩”

站点信息

文章数目: 8 篇

最近动态: 2月前

上线时间: 1690天

当前版本: v1.0.2

READ / JOURNAL▪

Unity2D俯视角敌人巡逻以及追击玩家

效果展示:

xg

敌人自动寻路AI:

自动寻路其实就是以敌人为中心,向四周限定范围和角度寻找随机位置,最后让敌人移动到该位置;以此为循环,直到此状态结束。

1.制定随机点

1
2
3
4
5
6
7
8
9
void ChooseEndPoinnt()
{
//随机角度
float randomAngle = Random.Range(0,360) * Mathf.Deg2Rad;//注意要转为弧度制
//随机半径
float randomRadius = Random.Range(2,5);
//进行三角函数变换
endPosition = rb.position + new Vector(Mathf.Cos(randomAngle), Mathf.Sin(randomAngle)) * randomRadius
}

原理

2.移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void MoveTowardsEndPosition()
{
Vector2 direction = (endPosition - rb.position).normalized;
float distance = Vector2.Distance(rb.position, endPosition);

// 如果距离小于停止阈值,则停止移动
if (distance < stopThreshold)
{
rb.velocity = Vector2.zero;
rb.position = endPosition; // 可选:确保位置精确
isMoving = false;
}
else
{
// 否则,设置速度以朝向目标位置移动
rb.velocity = direction * wanderSpeed;
}
}

人物移动这里是使用了velocity,主要是为了之后能检测敌人的运动方向等数值。

移动还可以使用MovePosition()或者是AddForce()

为了节省游戏性能,不能一直调用ChooseEndPoinnt()方法,所以在FixedUpdate中加载一个计时器去定时调用此函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected void FixedUpdate()
{
// 更新计时器
if (isWandering)
{
timer += Time.deltaTime;

// 检查是否到达移动时间间隔
if (timer >= wanderIntervalTime && !isMoving)
{
timer = 0f; // 重置计时器
ChooseEndPoint(); // 重新选择结束点
isMoving = true; // 标记为正在移动
MoveTowardsEndPosition(); // 开始移动
}

// 如果正在移动,则更新移动逻辑
if (isMoving)
{
MoveTowardsEndPosition();
}
}
}

此时,敌人巡逻逻辑基本上实现。

优化:

有时,地图中会有一些障碍物,而如果随机位置刚好落在障碍物上,敌人会一直往障碍物上撞,导致无法退出MoveTowardsEndPosition()函数。

此时,我们就需要一个射线检测,看我们随机出来的位置是否会在障碍物上,并且,由于是射线检测,还能检测出移动的方向是否会经过障碍物

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ChooseEndPoint()
{
float wanderAngle = Random.Range(0, 360) * Mathf.Deg2Rad;
float wanderRadius = Random.Range(2, 5);
Vector2 tempVecter = rb.position + new Vector2(Mathf.Cos(wanderAngle), Mathf.Sin(wanderAngle)) * wanderRadius;
RaycastHit2D wall = Physics2D.Raycast((Vector2)transform.position, tempVecter - rb.position, (tempVecter - rb.position).magnitude, GameStaticData.wallLayer);
if (wall)
{
Debug.DrawLine(transform.position, wall.point, Color.blue);
tempVecter = rb.position;
ChooseEndPoint(); // 如果撞到墙,重新选择
return;
}
endPosition = tempVecter;
}
1
RaycastHit2D wall = Physics2D.Raycast((Vector2)transform.position, tempVecter - rb.position, (tempVecter - rb.position).magnitude, GameStaticData.wallLayer);

这是一个射线检测方法,当wall有经过一个Layer为wallLayer的物体时,会返回此函数,重新随机位置,直到找到正确的位置。

敌人追击玩家:

1.检测玩家

首先,需要一个方法去检测附近是否有玩家需要追击。

这里,我使用Physics2D.OverlapCircleAll去检测玩家

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void DetectPlayer()
{
//获取物体
List<Collider2D> obj = Physics2D.OverlapCircleAll(center.position, range).ToList();
//判断是否是Player
List<Collider2D> player = obj.Where(x => x.gameObject.layer == GameStaticData.playerLayer).ToList();

if (player.Count > 0)
{
for (int i = 0; i < player.Count; i++)
{
if (player[i].gameObject.layer == GameStaticData.playerLayer)
{
isWandering = false;
timer = 0f;
endPosition = transform.position;
StartPath(target);
Move();
}
}
}
else
{
//rb.velocity = Vector2.zero;
//isWandering = true;
}
}

可以用OnDrawGizmos去可视化检测范围

1
2
3
4
5
private void OnDrawGizmos()
{
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(center.position, range);
}

效果图

2.追逐玩家寻路算法:

由于敌人在追逐玩家的时候会遇到许多障碍物,为了敌人能够避开障碍物,这里采用了A*算法。

关于A*算法:

A*算法其实是BFS(广度优先搜索)算法的升级版,是BFS加上启发式函数所得到的进阶版。这里,我们主要是讨论在网格地图中的启发式算法;一般采用曼哈顿距离:

H(n) = D * (abs ( n.x – goal.x ) + abs ( n.y – goal.y ) )

但是在Unity中,我们没必要去自己写一套A算法,现在有许多A * 算法的插件,比如此案例中使用的是一个免费A插件

下载链接:A*插件

下载其中的免费版就行

AStar

将下载好的UnityPackage传入项目中

生成寻路网格:

新建空物体:

ex3

配置参数:

ex4

将Debug中的Path Logging改为Only Error

点击Scan,就会有生成的效果图

ex5

接下来,给敌人添加寻路组件:

注意,只需添加Seeker即可

在自己的代码中使用A*算法:

官方文档中有个寻路demo可以参考

演示demo

回到我们的代码,基本上也同demo中的一样

隔一段时间计算路径

1
2
3
4
5
6
7
8
9
10
public void StartPath(Transform target)
{
if (Time.time > lastRepath + repathRate && seeker.IsDone())
{
lastRepath = Time.time;

seeker.StartPath(transform.position, target.position, OnPathComplete);
}
}

路径完成回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void OnPathComplete(Path p)
{
Debug.Log("计算出一条路径。是否出现错误?" + p.error);

// 路径池。为了避免不必要的内存分配,路径使用引用计数。
// 调用 Claim 方法将引用计数加一,调用 Release 方法将其减一,
// 当引用计数为零时,路径将被放入池中,其他脚本可以重用该路径。
// ABPath.Construct 和 Seeker.StartPath 方法会尽可能从池中获取路径。详见路径池文档页面。
p.Claim(this);
if (!p.error)
{
if (path != null) path.Release(this);
path = p;
// 重置路径点计数器,以便开始移动到路径的第一个点
currentWaypoint = 0;
}
else
{
p.Release(this);
}
}

人物移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public void Move()
{
// 还没有路径可以跟随,所以不执行任何操作
if (path == null) return;

// 循环检查是否已经接近当前路径点,可以切换到下一个点
// 使用循环是因为许多路径点可能非常接近,可能在同一帧内到达多个路径点
reachedEndOfPath = false;
// 当前路径点到代理的距离
float distanceToWaypoint;
while (true)
{
// 如果希望最大化性能,可以检查平方距离而不是实际距离,避免使用平方根计算
distanceToWaypoint = Vector3.Distance(transform.position, path.vectorPath[currentWaypoint]);
if (distanceToWaypoint < nextWaypointDistance)
{
// 检查是否还有下一个路径点,或者是否已经到达路径的末尾
if (currentWaypoint + 1 < path.vectorPath.Count)
{
currentWaypoint++;
}
else
{
// 设置一个状态变量,表示代理已经到达路径的末尾
// 如果你的游戏需要,可以使用这个变量来触发一些特殊代码
reachedEndOfPath = true;
break;
}
}
else
{
break;
}
}
// 在接近路径末尾时平滑减速
// 这个值会在代理接近路径的最后一个路径点时,从 1 平滑过渡到 0
var speedFactor = reachedEndOfPath ? Mathf.Sqrt(distanceToWaypoint / nextWaypointDistance) : 1f;
// 到下一个路径点的方向
// 归一化,使其长度为1个世界单位
Vector3 dir = (path.vectorPath[currentWaypoint] - transform.position).normalized;
// 将方向乘以我们期望的速度,得到速度向量
Vector3 velocity = dir * currentSpeed * speedFactor;
// 移动到目标点
rb.velocity = velocity;
isWandering = true;
}

完整代码展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
using Pathfinding;
using System.Collections;
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using UnityEngine;
public enum EnemyType
{
little,middle,big
}
public abstract class EnemyFather: MonoBehaviour
{
[HideInInspector]public Rigidbody2D rb;
[Header("敌人类型")]
public EnemyType enemyType;
[Header("漫游")]
public float wanderIntervalTime = 3.0f;
public float wanderSpeed = 2.0f;
private float timer = 0f;
private Vector2 endPosition;
private bool isMoving = false;
public float stopThreshold = 0.01f; // 停止阈值
[Header("检测玩家")]
private bool isWandering;
public Transform center;
public float range;
[Header("目标")]
public Transform target;
[Header("A*寻路")]
[HideInInspector] public Seeker seeker;// 用于处理路径计算的 Seeker 组件。
[HideInInspector] public Path path; // Seeker 计算出的路径
[HideInInspector] public int currentWaypoint = 0; // 当前路径点的索引。
[HideInInspector] public bool reachedEndOfPath; // 标志位,指示 AI 是否到达路径的末尾。
[HideInInspector] public float nextWaypointDistance = 3f; // 到达路径点之前的距离。减速距离
[HideInInspector] public float repathRate = 0.5f; // 重新计算路径的频率(秒)。
[HideInInspector] public float lastRepath = float.NegativeInfinity; // 上次计算路径的时间。
[HideInInspector] public bool isPathRefresh;//是否刷新
public float currentSpeed = 3.0f; // 当前速度
protected void Start()
{
rb = GetComponent<Rigidbody2D>();
seeker = GetComponent<Seeker>();
isWandering = true;
ChooseEndPoint(); // 初始选择一个结束点
}
protected void Update()
{
DetectPlayer();

}
protected void FixedUpdate()
{
Debug.DrawLine(transform.position, endPosition, Color.red);

// 更新计时器
if (isWandering)
{
timer += Time.deltaTime;

// 检查是否到达移动时间间隔
if (timer >= wanderIntervalTime && !isMoving)
{
timer = 0f; // 重置计时器
ChooseEndPoint(); // 重新选择结束点
isMoving = true; // 标记为正在移动
MoveTowardsEndPosition(); // 开始移动
}

// 如果正在移动,则更新移动逻辑
if (isMoving)
{
MoveTowardsEndPosition();
}
}
}
#region 敌人检测
void DetectPlayer()
{
List<Collider2D> obj = Physics2D.OverlapCircleAll(center.position, range).ToList();
List<Collider2D> player = obj.Where(x => x.gameObject.layer == GameStaticData.playerLayer).ToList();

if (player.Count > 0)
{
for (int i = 0; i < player.Count; i++)
{
if (player[i].gameObject.layer == GameStaticData.playerLayer)
{
isWandering = false;
timer = 0f;
endPosition = transform.position;
StartPath(target);
Move();
}

}
}
else
{
//rb.velocity = Vector2.zero;
//isWandering = true;
}


}
#endregion

#region 敌人巡逻
void ChooseEndPoint()
{
float wanderAngle = Random.Range(0, 360) * Mathf.Deg2Rad;
float wanderRadius = Random.Range(2, 5);
Vector2 tempVecter = rb.position + new Vector2(Mathf.Cos(wanderAngle), Mathf.Sin(wanderAngle)) * wanderRadius;
RaycastHit2D wall = Physics2D.Raycast((Vector2)transform.position, tempVecter - rb.position, (tempVecter - rb.position).magnitude, GameStaticData.wallLayer);
if (wall)
{
Debug.DrawLine(transform.position, wall.point, Color.blue);
tempVecter = rb.position;
ChooseEndPoint(); // 如果撞到墙,重新选择
return;
}
endPosition = tempVecter;
}

void MoveTowardsEndPosition()
{
Vector2 direction = (endPosition - rb.position).normalized;
float distance = Vector2.Distance(rb.position, endPosition);

// 如果距离小于停止阈值,则停止移动
if (distance < stopThreshold)
{
rb.velocity = Vector2.zero;
rb.position = endPosition; // 可选:确保位置精确
isMoving = false;
}
else
{
// 否则,设置速度以朝向目标位置移动
rb.velocity = direction * wanderSpeed;
}
}
#endregion

#region A*寻路方法
public void StartPath(Transform target)
{
if (Time.time > lastRepath + repathRate && seeker.IsDone())
{
lastRepath = Time.time;

seeker.StartPath(transform.position, target.position, OnPathComplete);
}
}


public void Move()
{
// 还没有路径可以跟随,所以不执行任何操作
if (path == null) return;

// 循环检查是否已经接近当前路径点,可以切换到下一个点
// 使用循环是因为许多路径点可能非常接近,可能在同一帧内到达多个路径点
reachedEndOfPath = false;
// 当前路径点到代理的距离
float distanceToWaypoint;
while (true)
{
// 如果希望最大化性能,可以检查平方距离而不是实际距离
distanceToWaypoint = Vector3.Distance(transform.position, path.vectorPath[currentWaypoint]);
if (distanceToWaypoint < nextWaypointDistance)
{
// 检查是否还有下一个路径点,或者是否已经到达路径的末尾
if (currentWaypoint + 1 < path.vectorPath.Count)
{
currentWaypoint++;
}
else
{
// 设置一个状态变量,表示代理已经到达路径的末尾
// 如果你的游戏需要,可以使用这个变量来触发一些特殊代码
reachedEndOfPath = true;
break;
}
}
else
{
break;
}
}
// 在接近路径末尾时平滑减速
// 这个值会在代理接近路径的最后一个路径点时,从 1 平滑过渡到 0
var speedFactor = reachedEndOfPath ? Mathf.Sqrt(distanceToWaypoint / nextWaypointDistance) : 1f;
// 到下一个路径点的方向
// 归一化,使其长度为1个世界单位
Vector3 dir = (path.vectorPath[currentWaypoint] - transform.position).normalized;
// 将方向乘以我们期望的速度,得到速度向量
Vector3 velocity = dir * currentSpeed * speedFactor;
// 移动到目标点
rb.velocity = velocity;
isWandering = true;
}

public void OnPathComplete(Path p)
{
Debug.Log("计算出一条路径。是否出现错误?" + p.error);

// 路径池。为了避免不必要的内存分配,路径使用引用计数。
// 调用 Claim 方法将引用计数加一,调用 Release 方法将其减一,
// 当引用计数为零时,路径将被放入池中,其他脚本可以重用该路径。
// ABPath.Construct 和 Seeker.StartPath 方法会尽可能从池中获取路径。详见路径池文档页面。
p.Claim(this);
if (!p.error)
{
if (path != null) path.Release(this);
path = p;
// 重置路径点计数器,以便开始移动到路径的第一个点
currentWaypoint = 0;
}
else
{
p.Release(this);
}
}
#endregion
private void OnDrawGizmos()
{
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(center.position, range);
}
}

评论区

欢迎你留下宝贵的意见,昵称输入QQ号会显示QQ头像哦~

更新记录

CHANGELOG

22 条更新 2026 年

少多
活跃 12 天最近 2026-10-02

点击亮色方块查看当天更新

查看本年日志
  • 网站改版

    新增独立更新日志与年份切换

  • 历史部署

    历史部署:feat(site): publish pixel theme and interactive toolbox

  • 历史部署

    历史部署:Site updated: 2026-08-14 21:58:40

  • 历史部署

    历史部署:Site updated: 2026-08-14 21:49:25

  • 历史部署

    历史部署:Site updated: 2026-08-14 15:46:44

  • 历史部署

    历史部署:Site updated: 2026-08-14 15:23:26

  • 历史部署

    历史部署:Site updated: 2026-08-14 14:56:59

  • 文章发布

    让Boss与玩家真正“打起来”——《漫威蜘蛛侠2》Boss战设计分析

  • 历史部署

    历史部署:Site updated: 2026-07-29 18:34:53

  • 历史部署

    历史部署:Site updated: 2026-07-27 09:06:17

  • 历史部署

    历史部署:Merge main branch for Pages deployment

  • 历史部署

    历史部署:Site updated: 2026-07-26 15:11:12

  • 历史部署

    历史部署:Site updated: 2026-07-26 15:44:10

  • 历史部署

    历史部署:Site updated: 2026-07-04 10:02:55

  • 历史部署

    历史部署:Site updated: 2026-05-17 10:31:06

  • 历史部署

    历史部署:Site updated: 2026-05-14 09:52:35

  • 历史部署

    历史部署:Site updated: 2026-05-14 09:47:30

  • 历史部署

    历史部署:Site updated: 2026-05-13 11:10:05

  • 历史部署

    历史部署:Site updated: 2026-05-12 10:31:20

  • 历史部署

    历史部署:Site updated: 2026-05-12 10:53:10

  • 历史部署

    历史部署:Site updated: 2026-05-06 theme redesign

  • 历史部署

    历史部署:Site updated: 2026-05-06 11:31:01

构建时记录变化,上传后更新。文章同日修改、网站同日改版各合并计次;历史部署单独标注。

Built with Hexo

Lirb. / PIXEL JOURNAL