在游戏开发中,路径寻找算法是实现角色自动导航(如NPC移动)的关键技术之一。A*(A-Star)算法因其高效和灵活而广泛应用于各类游戏中。本文用Cocos Creator
实现一个简单A星寻路例子,老规矩,先上效果图。
基本概念
在深入A*算法之前,首先需要理解它的三个关键概念:节点的G值、H值和F值。
- G值(G Cost) :是从起点到当前节点的实际成本。
- H值(H Cost) :是从当前节点到终点的预估成本,这部分是基于启发式方法估算的。
- F值(F Cost) :是G值和H值的总和,即
F = G + H
。算法在每一步都会尝试选择F值最小的节点继续前进,以期望找到最佳路径。
基础框架
A*算法的实现通常需要以下几个步骤:
-
初始化:将起点添加到开放列表(Open List)中。
-
循环以下步骤直到找到终点或开放列表为空:
-
从开放列表中选取F值最小的节点作为当前节点。
-
将当前节点从开放列表移除,并加入到关闭列表(Closed List)中。
-
对当前节点的每一个邻居进行以下操作:
- 如果它不可通过或者已经在关闭列表中,忽略它。
- 如果它不在开放列表中,计算它的G、H和F值,然后将其加入到开放列表中。
- 如果它已经在开放列表中,检查通过当前节点到达它的路径是否更优,如果是,则更新其G、H和F值。
-
-
重复上述过程,直到终点被加入到关闭列表(此时路径被找到),或者开放列表被清空(没有路径可以到达终点)。
这个基础框架涵盖了A*
算法的核心逻辑,但要使算法有效运行,还需要合理地实现启发式函数和邻居节点的处理逻辑。
合适的启发式函数
启发式函数(Heuristic)对A*算法的效率有重大影响。常见的启发式函数有曼哈顿距离(Manhattan Distance)、欧几里得距离(Euclidean Distance)等。选择哪一个取决于问题的具体性质。
例如,在一个允许对角移动的地图上,欧几里得距离可能是一个更合适的选择;而在只允许四方向移动的地图上,曼哈顿距离可能更适用。正确的启发式函数会使得A*算法更快地找到最终路径。
本文地图只允许上下左右
四方向移动,因此使用曼哈顿距离:
第二步:搭建A*算法的类结构
typescript
export class Point {
// 格子X坐标
x: number;
// 格子Y坐标
y: number;
// G值
G: number = 0;
// H值
H: number = 0;
// F值
F: number = 0;
// 父节点
father: Point = null;
// 是否关闭搜索
is_close: boolean = false;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
Point
代表地图上的一个节点,包含了节点的坐标(x, y),以及A*算法中的核心参数:G、H、F值,和其他辅助属性如父节点和是否已经被搜索过并被关闭搜索的标志。
typescript
@ccclass
export default class AStar extends cc.Component {
static start:Point = null; //起点
static end:Point = null; //终点
static map: Map<number, Point> = null; //地图point
static size: cc.Size = null; //地图尺寸
static arr_open:Array<Point> = []; //开放队列
static pppp:Point = null; //执行完寻路,它就有值了,除非没找到
static is_find = false; //是否已经找到路线
/**
* 获取路线 (此寻路不走斜线)
*/
static getRoute(start: Point, end: Point, map: Map<number, Point>,size: cc.Size) {
//清空上次寻路,并赋值
this.is_find = false;
this.arr_open = [];
this.pppp = null;
this.start = {...start};
this.end = {...end};
this.map = new Map<number,Point>();
map.forEach((value, key) => {
this.map.set(key,{...value}); //map 里放的是传过来的对象,使用深拷贝
});
this.size = size;
map.get(this.start.x+this.start.y*this.size.width).G = 0; //起点的G是0
//开始寻路
let route = new Array<Point>();
try {
this.search(this.start); //内存不够会报错,一般是起点或终点封闭
} catch (error) {
console.error("位置不对");
return route;
}
if(this.pppp){
this.getFather(this.pppp,route);
}
return route;
}
/**
* 寻路
*/
static search(point:Point){
if(point.x==this.end.x&&point.y==this.end.y){
this.is_find = true;
this.pppp = point;
return ;
}
let arr = this.getAround(point);
arr.forEach(p => {
this.setFather(p,point);
});
//arr按照F排序 从小到大
this.arr_open.sort(this.compare);
//递归继续找
this.arr_open.forEach((pp,index,arr)=>{
if(pp.is_close){ //删除没用的
arr.splice(index,1);
}
if(!this.is_find){
this.search(pp);
}
});
}
/**
* 获取周围上下左右4个点
*/
static getAround(point:Point){
point.is_close = true;
let arr = new Array<Point>();
let index:number;
let p:Point;
//上
if(point.y!=0){ //到顶了,没有上
index = point.x+(point.y-1)*this.size.width;
p = this.map.get(index)
if(p&&!p.is_close){
arr.push(this.map.get(index));
this.arr_open.push(this.map.get(index)); //我也要一份
}
}
//下
if(point.y+1!=this.size.height){ //到底了,没有下
index = point.x+(point.y+1)*this.size.width;
p = this.map.get(index)
if(p&&!p.is_close){
arr.push(this.map.get(index));
this.arr_open.push(this.map.get(index));
}
}
//左
if(point.x!=0){ //同理
index = point.x-1+point.y*this.size.width;
p = this.map.get(index)
if(p&&!p.is_close){
arr.push(this.map.get(index));
this.arr_open.push(this.map.get(index));
}
}
//右
if(point.x+1!=this.size.width){ //同理
index = point.x+1+point.y*this.size.width;
p = this.map.get(index)
if(p&&!p.is_close){
arr.push(this.map.get(index));
this.arr_open.push(this.map.get(index));
}
}
return arr;
}
/**
* point换父亲,并重新计算G、H、F
*/
static setFather(son:Point,father:Point){
if(!son.father||son.father.G>father.G){
son.father = father;
son.G = son.father.G+1;
son.H = Math.abs(son.x-this.end.x)+Math.abs(son.y-this.end.y);
son.F = son.G+son.H;
}
}
/**
* 比较器
*/
static compare(p1:Point,p2:Point){
if(p1.F>p2.F){
return 1;
}else{
return -1;
}
}
/**
* 递归 把祖宗放进route里面
*/
static getFather(point:Point,route:Array<Point>){
let father = point.father;
if(father){
this.getFather(father,route);
}
route.push(point);
}
}
AStar
类是算法的主体框架,包括地图(map)、起点(start)、终点(end)、以及用于控制搜索过程的开放列表(arr_open)。
getRoute()
方法是算法的入口点。它首先清空上一次搜索的结果,然后初始化算法的起点、终点和地图。接着,它调用search()
方法开始搜索。
search()
是递归搜索的核心。它不断从开放列表中选择F值最小的节点,计算其邻居节点的G、H、F值,并根据这些值更新开放列表和关闭列表。
getAround()
是一个辅助方法,用于获取当前节点周围的节点,这对于在网格中寻路尤其重要。而setFather()
则是设置节点父关系和计算G、H、F值的方法。
集成到Cocos Creator
首先通过TileMap
创建地图,并将底图导入到Cocos Creator
中:
读取地图数据:
typescript
/**
* 读取地图,得到可以走的全部地块索引 (地图的xy是从左上角到右下角)
*/
readMap(){
this.layer_road = this.Map.getLayer("road").getComponent(cc.TiledLayer);
this.layer_wall = this.Map.getLayer("wall").getComponent(cc.TiledLayer);
this.size = this.Map.getMapSize();
// 地板全部加进去
for(let x=0;x<this.size.width;x++){
for(let y=0;y<this.size.height;y++){
let tiled =this.layer_road.getTiledTileAt(x,y,true);
if(tiled.gid!=0){
let point = new Point(x,y);
this.map_point.set(x+y*this.size.width,point);
}
}
}
// 去掉墙
for(let x=0;x<this.size.width;x++){
for(let y=0;y<this.size.height;y++){
let tiled =this.layer_wall.getTiledTileAt(x,y,true);
if(tiled.gid!=0){
this.map_point.delete(x+y*this.size.width);
}
}
}
}
开始寻路
按钮触发逻辑:随机设置红块起点位置->随机设置蓝块终点位置->生成绿色方块路径->起点方块移动到终点方块位置:
typescript
/** 寻路 */
startAStar(){
// 设置起点位置
this.start_point = this.getRandomPoint();
this.Node_start.setPosition(this.layer_road.getTiledTileAt(this.start_point.x,this.start_point.y,false).node.position);
this.Node_start.zIndex = 2;
// 设置终点位置
this.end_point = this.getRandomPoint();
this.Node_end.setPosition(this.layer_road.getTiledTileAt(this.end_point.x,this.end_point.y,false).node.position);
// 重新生成路线
setTimeout(() => {
this.generateRoute();
}, 300);
// 移动
setTimeout(() => {
this.move();
}, 400);
}
生成方块路线:
typescript
/**
* 生成路线
*/
generateRoute(){
if(!this.start_point){
console.error("你还没有设置起点");
return;
}
if(!this.end_point){
console.error("你还没有设置终点");
return;
}
//调用工具类获取路线
this.route = AStar.getRoute(this.start_point,this.end_point,this.map_point,this.size);
if(this.route.length==0){
console.log("没有找到路径");
}
//黑色方块提示出来看看,先清空之前的
this.Folder_route.removeAllChildren();
this._routeLineIndex = 0;
// 创建路线方块
this.route.forEach(point=>{
let tiled = this.layer_road.getTiledTileAt(point.x,point.y,false);
let node = cc.instantiate(this.Prefab_route);
this.Folder_route.addChild(node);
node.setPosition(tiled.node.position);
});
}
起点方块按照绿色方块路径的位置移动到终点:
typescript
/**
* 按照路线移动
*/
move(){
// 走完了
if(this._routeLineIndex==this.route.length){
return;
}
let point = this.route[this._routeLineIndex++];
cc.tween(this.Node_start)
.to(.05,{ position: this.layer_road.getTiledTileAt(point.x,point.y,false).node.position })
.call(()=>{this.move();})
.start();
}
到此我们就实现开头效果图的功能。
A*算法的优化
虽然A*算法非常强大,但在某些情况下它的效率仍然可以被优化。常见的优化方法包括减少搜索空间、使用更有效的数据结构来管理开放和关闭列表、以及改进启发函数。
- 减少搜索空间:通过剪枝技术排除那些不可能是最优路径的分支。
- 数据结构优化:使用优先队列来管理开放列表可以大大提升寻找最小f(n)值的效率。
- 启发函数优化:选择或设计更适合具体问题的启发函数。
- 分帧计算优化:在多个更新周期内逐步完成,从而避免在单一帧中执行大量计算。