JS贪吃蛇自动寻路之A*算法

原版贪吃蛇
自动寻路之深度优先搜索DFS
我试了一下BFS广度优先没有太大提升,还是有局限性,所以我就直接搞A*算法,它会有更好的权重评估。

1、移动策略

移动策略没什么变化,和上次一样

1、查找当前蛇到达食物的路线
if(有到达食物的路线){
	临时计算到达食物后的蛇
	if(到达食物后的蛇可以到达当前蛇尾) return 到达食物路线的下一步
}

2、如果上面未返回,查找蛇能到达当前蛇尾的下一步空位
if(有到达蛇尾的空位) return 距离食物最远的下一步空位

3、如果上面都未返回,那就是你的寻路算法有问题,按理来说上面的步骤是应该能返回的,最后兜底返回一个有效空位

2、直接代码上解析

我没有用以前的一维数组,改成了二维数组存位置,并封装了类,大致步骤没什么变化

<!DOCTYPE html>
<html>

<head>
	<meta charset="utf-8">
	<title>贪吃蛇</title>
</head>

<body style="height: 90vh; display: flex;flex-direction: column; justify-content: center; align-items: center;">
	<canvas id="canvas" width="400" height="400" style="background-color: black">对不起,您的浏览器不支持canvas</canvas>
	<button id="btn">暂停</button>
	<button id="btn1">开始</button>

	<script>
		class SnakeGame {
			constructor(canvas, gZise = 20) {
				this.ctx = canvas.getContext('2d');//画布
				this.ctx.clearRect(0, 0, canvas.width, canvas.height);
				//例如,20X20,从0到399表示box里[0~19]*[0~19]的所有节点,每20px一个节点
				this.gZise = gZise;//方形地图边长,一维数组表示每个方格位置
				this.mLen = gZise * gZise;//总格数
				this.snake = [[0, 0]]; // 初始位置

				this.dirList = [[0, -1], [-1, 0], [0, 1], [1, 0]]; // 方向数组 左上右下
				this.direction = this.dirList[2]; // 初始方向:向右
				this.food = this.generateFood(); //生成食物
				this.stop = false;//暂停
				this.watchDirKey(); //键盘监听
				this.draw(this.food, "yellow"); //画出食物
				this.draw(this.snake[0], "blueviolet"); //画出蛇头
			}

			//根据位置画色块
			draw(pos, color) {
				if (!pos) return;
				this.ctx.fillStyle = color;
				this.ctx.fillRect(pos[0] * 20 + 1, pos[1] * 20 + 1, 18, 18);
			}

			//监听用户按键
			watchDirKey() {
				document.onkeydown = (evt) => { //监听方向
					this.direction = this.dirList[(evt || event).keyCode - 37];
				};
			}

			//生成食物
			generateFood() {
				if (this.snake.length == this.mLen) return; //已满,不生成食物
				let food;
				do {
					food = [Math.floor(Math.random() * this.gZise), Math.floor(Math.random() * this.gZise)];
				} while (this.snake.some(pos => this.equal(pos, food)));
				return food;
			}

			//位置是否合法
			isValid(pos, snake = this.snake) {
				if (this.equal(snake[snake.length - 1], pos)) return true; //可以移到蛇尾位置
				const [x, y] = pos;
				return (
					x >= 0 && x < this.gZise &&
					y >= 0 && y < this.gZise &&
					!snake.some(it => this.equal(it, pos))
				);
			}

			//两点是否相等
			equal(m, n) {
				return m && n && m[0] === n[0] && m[1] === n[1];
			}

			//移动
			move(pos = this.snake[0] + this.direction) {
				//有传位置就使用,没有就按当前方向移动
				if (!pos) pos = [this.snake[0][0] + this.direction[0], this.snake[0][1] + this.direction[1]];

				if (!this.isValid(pos)) {
					this.stop = true;
					return alert("游戏结束!得分:" + this.snake.length);
				}

				this.draw(this.snake[0], "lime"); //覆盖原来的蛇头
				this.snake.unshift(pos); //添加蛇头
				if (this.equal(pos, this.food)) {
					//如果吃到食物时,产生一个蛇身以外的随机的点,不会去掉蛇尾
					this.food = this.generateFood();
					this.draw(this.food, "yellow"); //画出食物
				} else {
					//没有吃到食物时正常移动,蛇尾出队列
					this.draw(this.snake.pop(), "black"); //覆盖原来的蛇尾
				}
				this.draw(pos, "blueviolet"); //画出新蛇头
			}

			//玩家控制
			playerMove() {
				this.move();
				this.stop || setTimeout(this.playerMove.bind(this), 400); //移动速度
			}


			//曼哈顿距离
			hDist(m, n) {
				return Math.abs(m[0] - n[0]) + Math.abs(m[1] - n[1]);
			}

			//获取周围位置
			getNeighbors(pos) {
				return this.dirList.map(dir => [pos[0] + dir[0], pos[1] + dir[1]]);
			}

			//移动一步(f是食物位置),返回新蛇数组
			moveStep(sn, pos, f) {
				let tmp = [pos, ...sn];
				if (!this.equal(pos, f)) tmp.pop();
				return tmp;
			}

			//获取start到current的路线
			reconstructPath(cameFrom, current, start) {
				const totalPath = [current];
				while (cameFrom.has(current.toString())) {
					current = cameFrom.get(current.toString());
					if (current.toString() == start.toString()) return totalPath;
					totalPath.unshift(current);
				}
				return totalPath;
			}

			//A*算法,到达goal的最优路线
			aStarSearch(sn, goal, food) {
				const openMap = new Map(); // 开放集合,存储待评估的节点
				const cameFrom = new Map(); // 路径映射,记录每个节点的前驱节点
				const gScore = new Map(); // 从起点到当前节点的实际成本(实际路线长度)
				const fScore = new Map(); // 从起点到目标的估计成本(实际路线长度+估计剩余长度)

				openMap.set(sn[0].toString(), sn);
				gScore.set(sn[0].toString(), 0); // 起点的gScore为0
				fScore.set(sn[0].toString(), this.hDist(sn[0], goal)); // 起点的 fScore 为启发式估计

				while (openMap.size > 0) {
					// 获取 fScore 最小的节点(最近的点)
					let curStr = Array.from(openMap.keys()).reduce((a, b) => fScore.get(a) < fScore.get(b) ? a : b); 
					let curSn = openMap.get(curStr);
					let current = curStr.split(',').map(Number);
					openMap.delete(curStr); // 从开放集合中移除当前节点
					if (this.equal(current, goal)) { // 如果到达目标
						let path = this.reconstructPath(cameFrom, current, sn[0]); // 重建路径并返回
						//如果食物存在,本次搜索是移向食物路线,即goal==food
						if (food) { 
							//从吃到食物状态开始搜索,是否能到达当前蛇尾
							let tmp = [...path].reverse();
							tmp = [...tmp, ...sn].splice(0, sn.length + 1);
							if (this.aStarSearch(tmp, tmp[tmp.length - 1])) return path[0]; //若能到达蛇尾,返回路线第一步
							//若不能到达蛇尾,当前路线行不通,删除当前位置(目标位置)的记录,继续搜索其他路线
							cameFrom.delete(curStr);
							gScore.delete(curStr);
							fScore.delete(curStr);
							continue;
						} else {
						//如果食物不存在,即搜索的是蛇尾路线,返回结果
							return path[0];
						}
					}

					for (const neighbor of this.getNeighbors(current)) { // 遍历当前节点的邻居
						if (!this.isValid(neighbor, curSn)) continue; // 如果邻居无效,跳过
						const nStr = neighbor.toString();
						const curNeiGScore = gScore.get(curStr) + 1; // 计算当前 gScore(路线长度加一)
						const prevGScore = gScore.get(nStr) || Infinity; //上次此位置的gScore(为空时,设置为Infinity以便比较后赋值)

						if (curNeiGScore < prevGScore) { //当前位置距离小于上次此位置(更优的位置)
							cameFrom.set(nStr, current); // 记录前驱节点
							gScore.set(nStr, curNeiGScore); // 更新 gScore
							fScore.set(nStr, curNeiGScore + this.hDist(neighbor, goal)); // 更新 fScore
							openMap.set(nStr, this.moveStep(curSn, neighbor)); // 将邻居添加到开放集合
						}
					}
				}
			}


			//查找最优下一步
			findNext(sn, food) {
				if (food === undefined) return sn[sn.length - 1]; //已满,衔尾蛇
				let lns0 = this.getNeighbors(sn[0]).filter(v => this.isValid(v, sn));//蛇头周围点
				if (lns0.length == 0) return sn[0] - sn[1] + sn[0];//当前方向下一位置
				if (lns0.length == 1) return lns0[0]; //只有一个空位

				//查找食物路线
				let f_next = this.aStarSearch(sn, food, food);
				if (f_next) return f_next;

				//查找食物路线失败,移向蛇尾
				lns0.sort((a, b) => this.hDist(b, food) - this.hDist(a, food)); //从离食物最远的位置找起
				for (const neighbor of lns0) {
					let tmp = this.moveStep(sn, neighbor, food);
					let tail_next = this.aStarSearch(tmp, tmp[tmp.length - 1]);
					if (tail_next) return neighbor;
				}

				return lns0[0];
			}
			// 自动寻路控制
			autoMove() {
				this.move(this.findNext(this.snake, this.food));
				this.stop || setTimeout(this.autoMove.bind(this), 50); //移动速度
			}
		}
	</script>

	<script>
		var game;
		document.getElementById('btn').onclick = () => {
			console.log(game.snake, game.food);
			game.stop = !game.stop;
			game.stop || game.autoMove();
		}
		document.getElementById('btn1').onclick = () => {
			game = new SnakeGame(document.getElementById('canvas'));
			game.autoMove();
		}
	</script>
</body>

</html>

3、算法评估

  • 1、算法的核心就是:搜索算法中通过评估权重来决定最优位置,以及移动策略
  • 2、这次的算法测试中偶尔能够铺满地图,不能100%通关,测试一次太花时间了。
  • 3、因为是最短路径,所以经常斜向移动,导致后期地图上会留下很多单独的空位,如果食物生成在这些位置,就会花费很长时间去绕路,还很有可能根本吃不到。
  • 4、如果有大佬有更优策略,欢迎留言!!!

4、无敌赖皮策略

除了老老实实搞寻路算法,还有一个不需要算法就能通关的策略,就是让蛇移动在固定路线上,循环走过地图的每个位置。
所以,最后我就再加了一个巡航路线,可以节省后期移动时间,我就只展示一下附加的一些代码。

class SnakeGame {
	constructor(canvas, gZise = 20) {
		this.ctx = canvas.getContext('2d');//画布
		this.ctx.clearRect(0, 0, canvas.width, canvas.height);
		//例如,20X20,从0到399表示box里[0~19]*[0~19]的所有节点,每20px一个节点
		this.gZise = gZise;//方形地图边长,一维数组表示每个方格位置
		this.mLen = gZise * gZise;//总格数
		this.snake = [[0, 0]]; // 初始位置

		this.cruisePath = this.getCruisePath(gZise); //生成固定巡航路线(记录每个位置要移动的下一位置)

		this.dirList = [[0, -1], [-1, 0], [0, 1], [1, 0]]; // 方向数组 左上右下
		this.direction = this.dirList[2]; // 初始方向:向右
		this.food = this.generateFood(); //生成食物
		this.stop = false;//暂停
		this.watchDirKey();
		this.draw(this.food, "yellow"); //画出食物
		this.draw(this.snake[0], "yellow"); //画出蛇头
	}
	//生成巡航路线
	getCruisePath(size) {
		let total = size * size;
		let path = new Map(); //记录当前位置的下一位置
		let cur = [0, 0], curStr; //从原点开始
		let dir = [1, 0];//初始方向,向右

		while (path.size < total) {
			curStr = cur.toString();
			let x = cur[0] + dir[0], y = cur[1] + dir[1];
			if (x == size) {
				//向右越界
				x = cur[0], y = cur[1] + 1; //向下一格
				dir = [-1, 0]; //方向改为向左
			} else if (x == 0) {
				//留出最左边为安全线
				if (dir[0] == -1 && dir[1] == 0) {
					//方向向左
					if (cur[1] + 1 == size) {
						//已到底
						x = cur[0] - 1, y = cur[1]; //向左一格
						dir = [0, -1]; //方向改为向上
					} else {
						x = cur[0], y = cur[1] + 1; //向下一格
						dir = [1, 0]; //方向改为向右
					}
				}
			}

			path.set(curStr, [x, y]); //记录
			cur = [x, y];
		}
		return path;
	}
	// ...........省略其他代码
	//查找最优下一步
	findNext(sn, food) {
		if (food === undefined) return sn[sn.length - 1];
		let lns0 = this.getNeighbors(sn[0]).filter(v => this.isValid(v, sn));//蛇头周围点
		if (lns0.length == 0) return sn[0] - sn[1] + sn[0];//当前方向下一位置
		if (lns0.length == 1) return lns0[0];

		// 3/4进度时开启优先巡航路线
		if (sn.length >= parseInt(this.mLen * 3 / 4)) {
			//在保证可以到达蛇尾的情况下,慢慢纠正路线到巡航路线上
			let c_next = this.cruisePath.get(sn[0].toString());
			if (lns0.find(p => p[0] == c_next[0] && p[1] == c_next[1])) {
				let tmp = this.moveStep(sn, c_next, food);
				let tail_next = this.aStarSearch(tmp, tmp[tmp.length - 1]);
				if (tail_next) return c_next;
			}
		}

		//查找食物路线
		let f_next = this.aStarSearch(sn, food, food);
		if (f_next) return f_next;

		//查找食物路线失败,移向蛇尾
		lns0.sort((a, b) => this.hDist(b, food) - this.hDist(a, food));
		for (const neighbor of lns0) {
			let tmp = this.moveStep(sn, neighbor, food);
			let tail_next = this.aStarSearch(tmp, tmp[tmp.length - 1]);
			if (tail_next) return neighbor;
		}

		return lns0[0];
	}
}

我在进度到达3/4时,开始巡航路线,可以保证百分百铺满通关。当然你也可以不使用寻路算法,一开始就巡航,但是这样前期就会花费太多时间。

  • 6
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值