前言

最近学习数据结构与算法,但是平常自己要么刷题,要么看书,很是无聊。就想着游戏中一般都会运用大量的算法,所以就开始写游戏来锻炼自己算法这方面的小能力。后面还会持续做其他小游戏,并发布说说自己做的过程中的心得体会。

一、游戏介绍

游戏采用熟悉的回合制斗兽棋,由玩家和电脑进行对战,双方各执16颗动物棋子。其中包括1个龙、2个虎、2个象、2个熊、2个狐、2个鹰、5个猫。
基本游戏规则如下:

在这里插入图片描述
在这里插入图片描述

我们小时候玩的斗兽棋,就是一般的象、狮、虎、豹、狼、狗、猫、鼠这种,我当时感觉儿时的比较简单一点,就没用此套卡牌和规则。而布局跟游戏规则是参考他人的,逻辑方面处理都是自己跟着规则慢慢啃出来的,其他优化的地方在下面实现里我也会标出来,整体游戏是可以完美运行的。
游戏的基本玩法就是,玩家先手,棋牌中分为红蓝两方,玩家第一手翻开什么牌,就会固定双方阵容。后面就是把对方的子吃完,即可获取胜利。

在这里插入图片描述

二、游戏实现

1. 基本游戏布局(都注释了,不细说)
<template>
	<view class="beast">
		<!-- 电脑 -->
		<view class="player player-1" :class="[player1.camp]">
			<!-- 电脑头像 -->
			<view class="photo">
				<image src="../../static/player-ai.png"></image>
			</view>
			<!-- 电脑血量 -->
			<view class="HP">
				<progress :percent="player1.score" stroke-width="44" :activeColor="player1.camp == 'red' ? '#f56c6c': player1.camp == 'blue' ? '#94bcff' : ''"/>
				<text class="hp-num">{{ player1.score }}/100</text>
			</view>
		</view>
		
		<!-- 主游戏区 -->
		<view class="map">
			<view class="map-box">
				<!-- 电脑的棋子 -->
				<view class="chess-player chess-player1">
					<template v-for="item in player1.list">
						<uni-badge class="uni-badge-left-margin" :text="isPlay != 0 ? item.count: ''" absolute="leftTop" size="small">
							<view class="chess-item" :class="[item.count == 0 ? 'die': '', player1.camp]">
								<image :src="`../../static/${item.name}.png`" mode=""></image>
							</view>
						</uni-badge>
					</template>
				</view>
				<!-- 电脑 VS 玩家 -->
				<view class="chess-play">
					<view class="chess-play-row" v-for="item1,index1 in mapList" :key="index1">
						<view class="chess-block" v-for="item2,index2 in item1" :key="index2" @click="onPlayer(item2, index1, index2)">
							<view 
								class="chess-item" 
								:class="[!item2.isFilp ? 'no' : item2.camp, item2.isSelected ? 'selected' : '']" 
								v-if="item2 && item2.isShow"
							>
								<image :src="`../../static/${item2.name}.png`" mode="" v-show="item2.isFilp"></image>
							</view>
						</view>
					</view>
				</view>
				<!-- 玩家棋子 -->
				<view class="chess-player chess-player2">
					<template v-for="item in player2.list">
						<uni-badge class="uni-badge-left-margin" :text="isPlay != 0 ? item.count: ''" absolute="rightTop" size="small">
							<view class="chess-item" :class="[item.count == 0 ? 'die': '', player2.camp]">
								<image :src="`../../static/${item.name}.png`" mode=""></image>
							</view>
						</uni-badge>
					</template>
				</view>
			</view>
		</view>
		
		<!-- 玩家 -->
		<view class="player player-2" :class="[player2.camp]">
			<!-- 玩家血量 -->
			<view class="HP">
				<progress :percent="player2.score" stroke-width="44" :activeColor="player2.camp == 'red' ? '#f56c6c': player2.camp == 'blue' ? '#94bcff' : ''"/>
				<text class="hp-num">{{ player2.score }}/100</text>
			</view>
			<!-- 玩家头像 -->
			<view class="photo">
				<image src="../../static/player-1.png"></image>
			</view>
		</view>
		
		<!-- 规则提示框 -->
		<uni-popup ref="popupRule" :isMaskClick="false" :maskBackgroundColor="'rgba(0,0,0,.6)'">
			<view class="win">
				<view class="win-box">
					<view class="win-title">
						<text>游戏规则</text>
					</view>
					<view class="win-content">
						<view class="rule">
							<view class="rule-item">1、龙 > 虎 > 象 > 熊 > 狐 > 鹰 > 猫</view>
							<view class="rule-item">2、猫可以吃龙</view>
							<view class="rule-item">3、鹰可以吃全部,前提是需要隔一颗棋子才能吃,不分距离。</view>
							<view class="rule-item">4、所有动物只能上下左右移动,每次最多可移动一格(鹰狩猎的时候除外)。</view>
							<view class="rule-item">5、任意一方血量为0时,游戏结束。</view>
						</view>
					</view>
				</view>
			</view>
			<view class="win-btns">
				<button class="win-btn" @click="onKnow()">我知道了</button>
			</view>
		</uni-popup>
		
		<!-- 输赢提示框 -->
		<uni-popup ref="popupWin" :isMaskClick="false" :maskBackgroundColor="'rgba(0,0,0,.6)'">
			<view class="win">
				<view class="win-box">
					<view class="win-title">
						<text v-if="isWin == 1">&nbsp;&nbsp;&nbsp;</text>
						<text v-if="isWin == 2">&nbsp;&nbsp;</text>
					</view>
					<view class="win-content">
						<text v-if="isWin == 1">{{ aiLose ? '电脑认输了' : '你 赢 了' }}</text>
						<text v-if="isWin == 2">你 输 了</text>
					</view>
				</view>
			</view>
			<view class="win-btns">
				<button class="win-btn" @click="onExitGame()">退出游戏</button>
				<button class="win-btn" @click="onAgainGame()">再来一局</button>
			</view>
		</uni-popup>
	</view>
</template>
<style lang="scss" scoped>
	image { width: 100%; height: 100%; }
	/deep/.uni-badge { z-index: 9; }
	.beast {
		width: 100%; height: 100vh; display: flex; flex-direction: column;justify-content: space-between;
	}
	.player {
		padding: 40rpx 20rpx;	display: flex;align-items: center;
		.photo {
			width: 88rpx; height: 88rpx; border-radius: 10rpx; overflow: hidden;display: flex;align-items: center;justify-content: center;
			background-color: #ebebeb;
			image { width: 80%; height: 80%; }
		}
		.HP {
			flex: 1; position: relative;
			.uni-progress { border-radius: 10rpx; overflow: hidden; }
			.hp-num { position: absolute; left: 50%; top: 44rpx; transform: translate(-50%, -50%); font-weight: bold; }
		}
		&.player-1 .photo { margin-right: 20rpx; }
		&.player-2 .photo { margin-left: 20rpx; }
		&.player-1
		&.player-2 .HP .uni-progress { transform: rotateY(180deg); }
		&.red .photo { background-color: #f56c6c; }
		&.blue .photo { background-color: #94bcff; }
		/deep/.uni-progress-inner-bar { transition: all .2s; }
	}
	.chess-item {
		width: 88rpx; height: 88rpx;border-radius: 10rpx;overflow: hidden;display: flex;align-items: center;justify-content: center;
		background-color: #ebebeb;
		image { width: 80%; height: 80%; }
	}
	.map {
		padding: 0 20rpx;
		.map-box { display: flex; justify-content: center; }
		.chess-player {
			width: 108rpx;
			&.chess-player1 { 
				padding-right: 20rpx; 
			}
			&.chess-player2 { 
				padding-left: 20rpx; display: flex; flex-direction: column; justify-content: flex-end; 
			}
			.red { background-color: #f56c6c; }
			.blue { background-color: #94bcff; }
			.die { filter: grayscale(1); }
			.uni-badge--x { display: block; }
			.uni-badge--x + .uni-badge--x { margin-top: 20rpx; }
		}
		.chess-play {
			flex: 1; height: 1020rpx; overflow: hidden;background-color: #ab865a;border-radius: 10rpx;
			display: flex;flex-direction: column;justify-content: space-evenly;padding: 6rpx;
			.chess-play-row {
				display: flex;
				align-items: center;
				justify-content: space-evenly;
			}
			.chess-block {
				padding: 10rpx 10rpx 18rpx;border-radius: 10rpx;overflow: hidden;display: flex;align-items: center;justify-content: center;
				background-color: #f0cc92; width: 108rpx; height: 116rpx;
			}
			.chess-item {
				user-select: none; transition: all .1s;
				&.no { background-color: #1aaf5d; box-shadow: 0 4px 0 #0a7237; }
				&.red { background-color: #f56c6c; box-shadow: 0 4px 0 #b34e4e; }
				&.blue { background-color: #94bcff; box-shadow: 0 4px 0 #6c8aba; }
				&.selected { transform: translateY(-8rpx); }
			}
		}
	}
	.win {
		width: 540rpx;border-radius: 20rpx;background-color: rgb(119,212,168);margin: 0 auto;padding: 20rpx;
		.win-box {
			width: 100%; background-color: rgb(254,255,241);border-radius: 10px;padding: 70rpx 30rpx 30rpx;
		}
		.win-title {
			width: 60%;padding: 20rpx;text-align: center;background-color: rgb(255,107,103);clip-path: polygon(0 0, 100% 0, 95% 50%, 100% 100%, 0 100%, 5% 50%);
			position: absolute;color: #fff;font-weight: bold;font-size: 40rpx;left: 50%;transform: translateX(-50%);top: -30rpx;
		}
		.win-content {
			width: 100%;min-height: 200rpx; border-radius: 20rpx;border: 1px solid rgb(251,206,87); background-color: rgb(255,243,205); box-shadow: inset 0 0 20rpx 0rpx rgba(251,206,87, .8);
			display: flex;align-items: center;justify-content: center;color: rgb(183,92,77); font-weight: bold; font-size: 40rpx;
		}
		.rule {
			width: 100%;padding: 30rpx;text-align: justify;
			.rule-item { font-size: 28rpx; }
			.rule-item + .rule-item { margin-top: 20rpx; }
		}
	}
	.win-btns {
		display: flex;align-items: center;justify-content: space-between;padding: 40rpx 20rpx;
		.win-btn {
			flex: 1;font-size: 34rpx;color: white;background: linear-gradient(to bottom, rgb(244,200,77), rgb(248,234,111));font-weight: bold;
			box-shadow: 0 6rpx 0 0 rgb(168,134,45); -webkit-text-stroke: 1rpx rgb(168,134,45);
		}
		.win-btn + .win-btn { margin-left: 40rpx; }
	}
</style>
2. 定义需要的基本参数
// 电脑
player1: { score: 100, camp: '', list: [] }
// 玩家
player2: { score: 100, camp: '', list: [] }

// 棋子信息
// weight: 权重
// count: 数量
// score: 分数
// camp: 所属阵营(同上)  'red' or 'blue' 
// isFilp: 是否翻开
// isShow: 是否显示
// isSelected: 是否选中
// location: 所在二维数组的位置
// 空地的话,我直接用null表示
chessList: [
  	{name: '龙', weight: 7, count: 1, score: 19, camp: null, isFilp: false, isShow: true, isSelected: false, location: []},
	{name: '虎', weight: 6, count: 2, score: 12, camp: null, isFilp: false, isShow: true, isSelected: false, location: []},
	{name: '象', weight: 5, count: 2, score: 10, camp: null, isFilp: false, isShow: true, isSelected: false, location: []},
	{name: '熊', weight: 4, count: 2, score: 8, camp: null, isFilp: false, isShow: true, isSelected: false, location: []},
	{name: '狐', weight: 3, count: 2, score: 5, camp: null, isFilp: false, isShow: true, isSelected: false, location: []},
	{name: '鹰', weight: 2, count: 2, score: 3, camp: null, isFilp: false, isShow: true, isSelected: false, location: []},
	{name: '猫', weight: 1, count: 5, score: 1, camp: null, isFilp: false, isShow: true, isSelected: false, location: []},
],

// 地图二维数组
mapList: [],		

// 选中的棋子				
selectChess: null,			

// 是否开始了,分配阵营
isStart: false,				
	
// 当前轮到谁了  0=无  1=玩家  2=电脑
isWho: 0,					
		
// 游戏是否开始  0=未开始  1=进行中  2=已结束	
isPlay: 0,	
		 				
// 当前游戏谁赢了  0=无  1=玩家  2=电脑
isWin: 0, 	
						
// 副本数组(相当于复制一份当时棋盘的数据),用来进行广度优先算法
bfsList: [],					
// 算法每次计算前,都把当前地图重新绘制一遍,0不能走 1可以走	
bfsLoadList: [],	
			
// AI是否投降
aiLose: false,					
3. 定义需要用到的公共方法

我们一般游戏中都要用到深克隆,防止操作数据时,导致视图也会做变化。
还有数组转换,比如一维数组转为多维数组,再多维转一维数组等。
其他还有打乱数组,比如数字华容道、斗兽棋等游戏

/**
 * @description 打乱数组
 * @param arr
 */ 
export const arrayRandom = function(arr = []) {
	return arr.sort(() => Math.random() - 0.5);
}

/**
 * @description 深度克隆多维数组
 * @param {Array} arr 需要深度克隆的数组
 * @returns {*} 克隆后的数组
 */
export const deepcopyArray = function(arr) {
	var out = [], i = 0, len = arr.length;
	for (; i < len; i++) {
		if (arr[i] instanceof Array){
			out[i] = deepcopyArray(arr[i]);
		}
		else out[i] = arr[i];
	}
	return out;
}

/**
 * @description 一维数组转二维数组
 * @param arr
 * @param num
 */
export const arrayChange = function(arr, num) {
	if(!Array.isArray(arr)) {
		return [];
	}
	if(Array.isArray(arr) && arr.length <= 1) {
		return arr;
	}
	let a = [];
	while(arr.length > 0) {
		a.push(arr.splice(0, num))
	}
	return a;
}

/**
 * @description 二维数组转一维数组
 * @param 
 * @param 
 */
export const arrTwoToOne = function(arr) {
	if(!Array.isArray(arr)) { 
		return [];
	}
	return [].concat.apply([], arr)
}

4. 动态生成双方所拥有棋子16颗、棋盘总棋子32颗

这里我用了4个list,因为初始化数据包含双方所拥有的棋子,还有棋盘的32颗(16红+16蓝)

createPlayChess() {
	let arr1 = [];
	let arr2 = [];
	let list1 = JSON.parse(JSON.stringify(this.chessList));
	let list2 = JSON.parse(JSON.stringify(this.chessList));
	let list3 = JSON.parse(JSON.stringify(this.chessList));
	let list4 = JSON.parse(JSON.stringify(this.chessList));
	// 电脑的棋子
	for (let i = 0; i < list1.length; i++) {
		this.player1.list.push(list1[i]);
	}
	// 玩家的棋子
	for (let i = 0; i < list2.length; i++) {
		this.player2.list.push(list2[i]);
	}
	// 棋盘中红色方子
	for (let i = 0; i < list3.length; i++) {
		for(let j = 0; j < list3[i].count; j++) {
			list3[i].camp = 'red'
			arr1.push(list3[i]);
		}
	}
	// 棋盘中蓝色方子
	for (let i = 0; i < list4.length; i++) {
		for(let j = 0; j < list4[i].count; j++) {
			list4[i].camp = 'blue'
			arr2.push(list4[i]);
		}
	}
	// 因为玩家在界面下面,他的棋子在右边,所以要把数组倒过来,让龙在最下面
	this.player2.list.reverse();
	// 处理总数据,把一维数组打乱,再转成二维数组
	let maplist = JSON.parse(JSON.stringify(this.arrayChange(this.arrayRandom([...arr1, ...arr2]), 4)))
	// 给每个棋子加上自己所在位置
	maplist.forEach((item1, index1) => {
		item1.forEach((item2, index2) => {
			item2.location = [index1, index2];
		})
	})
	this.mapList = JSON.parse(JSON.stringify(maplist))
},
5. 定义其他操作(规则、再来一局、游戏输赢等)
// 按钮 - 我知道了
// 隐藏弹框,开始游戏
onKnow() {
	this.isPlay = 1;
	this.isWho = 1;
	this.$refs.popupRule.close()
	uni.showToast({title: '玩家先手', icon: 'none'})
},
// 再来一局
// 其实就是把所有初始数据初始化,然后重新生成地图数据
onAgainGame() {
	this.player1.camp = '';
	this.player1.score = 100;
	this.player1.list = [];
	this.player2.camp = '';
	this.player2.score = 100;
	this.player2.list = [];
	this.mapList = [];
	this.selectChess = null;
	this.isStart = false;
	this.isWho = 1;
	this.isPlay = 1;
	this.isWin = 0;
	this.aiLose = false;
	this.createPlayChess();
	this.$refs.popupWin.close();
},

我们还可以根据每次吃完子,无论是电脑还是玩家,来判断双方剩余棋子和当前血量,再判断输赢。
当一方的卡牌都没有了,也就是血量为0时,输赢判定。

// 处理hp和剩余棋子  chess被吃的那个子 
handlePlayer(chess) {
	if(!chess) return;
	// 如果chess是电脑的子
	if(chess.camp == this.player1.camp) {
		this.player1.list.forEach(item => {
			if(item.name == chess.name) {
				item.count -= 1;
				this.player1.score -= chess.score;
			}
		})
	}
	// 如果chess是玩家的子
	if(chess.camp == this.player2.camp) {
		this.player2.list.forEach(item => {
			if(item.name == chess.name) {
				item.count -= 1;
				this.player2.score -= chess.score;
			}
		})
	}
	// 判断输赢
	if(this.player1.score <= 0) {
		this.isPlay = 2;
		this.isWin = 1;
		this.$refs.popupWin.open()
	}
	if(this.player2.score <= 0) {
		this.isPlay = 2;
		this.isWin = 2;
		this.$refs.popupWin.open()
	}
}

我们在游戏中可以进行吃子,移动到空地的操作,其实就是把指定子的信息赋值给被吃子或者空地的信息,此时需要注意,信息换过去,当前子的location也需要更换,不然表面虽然移动了,实际后续操作,那个子还在原来的地方。

// 交换位置 - 移动或者吃子 
// position1:需要移动的棋子的位置  position2:目标位置
// 如果是吃子操作,则需要传入第三个参数用来判断是否输赢
handlePosition(position1, position2, whoChess) {
	let [x1, y1] = position1;
	let [x2, y2] = position2;
	let chess = JSON.parse(JSON.stringify(this.mapList[x1][y1]));
	this.mapList[x2][y2] = chess;
	this.mapList[x2][y2].location = [x2, y2]
	this.mapList[x1][y1] = null;
	this.$forceUpdate()
	this.handlePlayer(whoChess); // 传入被吃的那个子  判断输赢
}
6. 玩家操作

对于玩家,我们只需要翻牌,然后吃子,移动等操作,比较简单。
我们每次点击牌都会把当前牌的信息以及坐标传递过来

onPlayer( item, x, y )

(1)如果游戏没开始或者还没轮到玩家,则不能进行操作

if(this.isWho != 1 || this.isPlay != 1) return;

(2)我们开始前,需要先初始化我们需要的操作数据,我们不能直接在主数据上直接判断修改某些子,不然容易导致视图发生改变

let list = [].concat.apply([], this.mapList); // 二维数组转一维数组
let list_some = list.some(i => i && i.isSelected);	// 有没有选择棋子
let selectChess = list.filter(i => i && i.isSelected)[0];	// 获取选中的棋子信息

(3)没选棋子之前点击空地,是没用的

if(!list_some && item == null) return;

(4)如果我选了棋子,再去点空地

if(list_some && item == null) {
	let bool = false;  // 每次只能移动一格
	let x1 = selectChess.location[0];
	let y1 = selectChess.location[1];
	if(
		(y1 == y && x1 - 1 == x) || 
		(y1 == y && x1 + 1 == x) ||
		(x1 == x && y1 - 1 == y) || 
		(x1 == x && y1 + 1 == y)
	) {
		bool = true;
	}
	if(bool) {
		// 查看二 -- 5中的方法,交换位置
		this.handlePosition(selectChess.location, [x, y])
	} else {
		uni.showToast({title: '不能走!', icon: 'none', duration: 500})
		// 如果不能走,则把选择的棋子取消掉
		this.mapList.forEach(item1 => {
			item1.forEach(item2 => item2 ? item2.isSelected = false : '')
		})
		this.$forceUpdate()
		return;
	}
	// 移动完之后,要把选中的棋子初始化
	this.mapList.forEach(item1 => {
		item1.forEach(item2 => item2 ? item2.isSelected = false : '')
	})
	// 移动完轮到电脑
	this.onAi()
	return;
}

(5)如果我们还没选择自己棋子,是不能点击电脑的棋子的

// 如果点的棋子翻开了,并且点击的是对方的牌,并且还没有选择自己的牌时
if(item.isFilp && item.camp != this.player2.camp && !list_some) {
	uni.showToast({title: '这不是你的棋子', icon: 'none', duration: 500})
	return;
}

(6)这里得记住,我们无论做了什么操作,都要在操作之后清除一下选中状态

// 每次点击牌之前 清除一下选中状态
this.mapList.forEach(item1 => {
	item1.forEach(item2 => item2 ? item2.isSelected = false : '')
})

(7)如果点的棋子是没翻开的状态,则需要把这个棋子翻开。还要注意游戏刚开始需要区分阵营,也是这时候做的操作

// 如果棋子处于没翻转的状态
if(!item.isFilp) {
	item.isFilp = true
	// 这里玩家第一次翻开棋子,是为了区分阵营
	if(!this.isStart) {
		this.isStart = true;
		this.player2.camp = item.camp
		this.player1.camp = item.camp == 'red' ? 'blue' : 'red'
	}
	this.onAi()
	return;
}

(8)如果我们点击的牌是自己的话,则选中这个牌

// 如果点击的是自己的牌 则选中当前牌
if(item.camp == this.player2.camp) {
	item.isSelected = !item.isSelected;
	this.$forceUpdate()
	return;
}

(9)最后一步,吃子,这时候做吃子操作。如果我选中棋子,并且我又点了电脑的棋子,这时候就需要判断能不能吃了。

// 判断选中自己的牌之后,再去选择别人的牌, 说明有吃的意向了
if(selectChess) {
	// 公共方法,判断两个子是否存在吃与被吃的关系
	if(!this.onIsEat(selectChess, item)) {
		uni.showToast({title: '吃不了!', icon: 'none', duration: 500})
		this.selectChess = null;
		return;
	} else {
		// 吃
		this.handlePosition(selectChess.location, item.location, item);
	}
	this.onAi()
	return;
}

(10) 如何去判断一颗子是否可以吃另一颗子

// 判断animal_1能不能吃animal_2  true=能吃  false=不能吃
onIsEat(animal_1, animal_2) {
	// 如果传入的两个牌一样或者没翻开,则不能吃
	if(!animal_1 || !animal_2 || animal_1.camp == animal_2.camp || !animal_1.isFilp || !animal_2.isFilp) {
		return false;
	}
	// 获取它们的权重和坐标
	let aniWeight_1 = animal_1.weight
	let aniWeight_2 = animal_2.weight
	let x1 = animal_1.location[0];
	let y1 = animal_1.location[1];
	let x2 = animal_2.location[0];
	let y2 = animal_2.location[1];
	// 如果捕食者是 鹰, 则需要判断上下左右的子中,鹰与被吃者中间是否有一颗子
	if(aniWeight_1 == 2) {
		let bool = false;
		if((y1 == y2 && x1 - 2 >= x2)) {
			let num = 0;
			for (let i = x1 - 1; i > x2; i--) {
				if(this.mapList[i][y1] != null) num++;
			}
			bool = num == 1 ? true : false;
		}
		if((y1 == y2 && x1 + 2 <= x2)) {
			let num = 0;
			for (let i = x1 + 1; i < x2; i++) {
				if(this.mapList[i][y1] != null) num++;
			}
			bool = num == 1 ? true : false;
		}
		if((x1 == x2 && y1 - 2 >= y2)) {
			let num = 0;
			for (let i = y1 - 1; i > y2; i--) {
				if(this.mapList[x1][i] != null) num++;
			}
			bool = num == 1 ? true : false;
		}
		if((x1 == x2 && y1 + 2 <= y2)) {
			let num = 0;
			for (let i = y1 + 1; i < y2; i++) {
				if(this.mapList[x1][i] != null) num++;
			}
			bool = num == 1 ? true : false;
		}
		return bool;
	}
	// 其他动物,不但要判断权重能否吃,还要判断是否在上下左右方向上
	if(
		((aniWeight_1 == 7 && [7,6,5,4,3,2].includes(aniWeight_2)) ||
		(aniWeight_1 == 6 && [6,5,4,3,2,1].includes(aniWeight_2)) ||
		(aniWeight_1 == 5 && [5,4,3,2,1].includes(aniWeight_2)) ||
		(aniWeight_1 == 4 && [4,3,2,1].includes(aniWeight_2)) ||
		(aniWeight_1 == 3 && [3,2,1].includes(aniWeight_2)) ||
		(aniWeight_1 == 1 && [1,7].includes(aniWeight_2))) &&
		((y1 == y2 && x1 - 1 == x2) || (y1 == y2 && x1 + 1 == x2) ||(x1 == x2 && y1 - 1 == y2) || (x1 == x2 && y1 + 1 == y2))
	) {
		return true;
	}
	return false;
},

此时玩家的操作基本完成。可以正常翻牌、走动、吃子。

7. 电脑操作

基本上就是根据优先级来处理操作:
首先作为斗兽棋,最重要的就是吃对方子来达到胜利,
其次是别人吃我时,我要逃跑,
然后追击对方,
最后是翻牌操作,
最后的最后就是投降。

(1)不该电脑的时候,电脑不能动,并且刚开始要定义我们需要用到的基本数据

if(this.isPlay != 1) return;
// 把当前地图数据变成一维数组
let list = [].concat.apply([], this.mapList);
// 玩家翻开的棋子
let playerFilpList = list.filter(item => item && item.isFilp && item.camp != this.player1.camp); 
// 电脑翻开的棋子
let aiFilpList = list.filter(item => item && item.isFilp && item.camp == this.player1.camp);	

(2)电脑吃子,判断电脑翻开的每个子的周围有没有能吃的棋子

let aiEatOwn = [];		// 我自己有哪些能吃别人的子
let aiEatList = [];		// 我能吃哪些玩家的子
// 判断ai的每个子的周围有没有能吃的棋子
for(let i = 0; i < aiFilpList.length; i++) {
	let [x, y] = aiFilpList[i].location;
	// 上
	if(x - 1 >= 0 && this.onIsEat(aiFilpList[i], this.mapList[x - 1][y])) {
		aiEatOwn.push(aiFilpList[i])
		aiEatList.push(this.mapList[x - 1][y])
	}
	// 下
	if(x + 1 < 8 && this.onIsEat(aiFilpList[i], this.mapList[x + 1][y])) {
		aiEatOwn.push(aiFilpList[i])
		aiEatList.push(this.mapList[x + 1][y])
	}
	// 左
	if(y - 1 >= 0 && this.onIsEat(aiFilpList[i], this.mapList[x][y - 1])) {
		aiEatOwn.push(aiFilpList[i])
		aiEatList.push(this.mapList[x][y - 1])
	}
	// 右
	if(y + 1 < 4 && this.onIsEat(aiFilpList[i], this.mapList[x][y + 1])) {
		aiEatOwn.push(aiFilpList[i])
		aiEatList.push(this.mapList[x][y + 1])
	}
	// 如果是鹰的情况
	for(let a = x - 2; a >= 0; a--) {
		if(this.onIsEat(aiFilpList[i], this.mapList[a][y])) {
			aiEatOwn.push(aiFilpList[i])
			aiEatList.push(this.mapList[a][y])
		}
	}
	for(let a = x + 2; a < 8; a++) {
		if(this.onIsEat(aiFilpList[i], this.mapList[a][y])) {
			aiEatOwn.push(aiFilpList[i])
			aiEatList.push(this.mapList[a][y])
		}
	}
	for(let a = y - 2; a >= 0; a--) {
		if(this.onIsEat(aiFilpList[i], this.mapList[x][a])) {
			aiEatOwn.push(aiFilpList[i])
			aiEatList.push(this.mapList[x][a])
		}
	}
	for(let a = y + 2; a < 4; a++) {
		if(this.onIsEat(aiFilpList[i], this.mapList[x][a])) {
			aiEatOwn.push(aiFilpList[i])
			aiEatList.push(this.mapList[x][a])
		}
	}
}

(3)这里我们已经获取到了我们的棋子(捕食者)和玩家的棋子(被捕食者)

我们每次获取到一个可以吃的子,就会把相应的子加入到数组队列中,就会形成两个数组,他们的值是一 一对应的,比如【被吃1,被吃2,被吃3,】 =》 【吃1,吃1,吃2】,这时候我们可以在第二个数组中看到,吃1出现两次,说明它可以吃两个,这样我们后面会根据被捕食者的分数排列,这个过程中,他们所对应的数据都需要跟着变,以便后面我们根据分数找到对应的捕食者坐标。

电脑吃子的时候还不能乱吃,因为每个子都有分数,我们不能亏。
电脑首先要吃分数最高的子,以此类推
电脑吃之前还要判断一下,吃完后自己会不会被吃,不然胡乱吃,自己就亏了,比如我的虎吃了玩家的熊,可是玩家的龙给吃了,这就亏了。
如果是相同的棋子,则直接吃,不用考虑
如果是猫吃龙的情况下,则直接吃,不用考虑

if(aiEatList.length > 0) {
	let scoreList = aiEatList.map(item => item.score); 										// 提取分数数组
	// 冒泡排序,根据分数从大到小排列,再把分数对应的敌方子和对应的我方子进行排列
	for (let i = 0; i < scoreList.length; i++) {
		for (let j = 0; j < scoreList.length - i - 1; j++) {
			if (scoreList[j] < scoreList[j + 1]) {
				// 交换分数
				let temp1 = scoreList[j];
				scoreList[j] = scoreList[j + 1];
				scoreList[j + 1] = temp1;
				// 交换被捕食者
				let temp2 = aiEatList[j];
				aiEatList[j] = aiEatList[j + 1];
				aiEatList[j + 1] = temp2;
				// 交换捕食者
				let temp3 = aiEatOwn[j];
				aiEatOwn[j] = aiEatOwn[j + 1];
				aiEatOwn[j + 1] = temp3;
			}
		}
	}
	// 这里我们去循环捕食者数组,从大到小吃查看,是否可以吃对方子,
	let bool = false;
	for (let i = 0; i < aiEatOwn.length; i++) {
		// 这个是我们如果吃过之后的那个棋子的数据,因为我们需要判断吃完以后自己会不会被吃的情况
		const afterChess = JSON.parse(JSON.stringify(aiEatOwn[i]));
		afterChess.location = aiEatList[i].location; // 坐标不需要变化,用吃完之后的棋子坐标
		// 这里我们需要判断吃完以后不会被吃,或者自己跟对方的子相等  或者猫吃龙的情况,
		// 则说明可以直接吃
		// isDotEat() 方法在下面
		if((!this.isDotEat(afterChess)) || (aiEatOwn[i].weight <= aiEatList[i].weight)) {
			bool = true;
			this.handlePosition(aiEatOwn[i].location, aiEatList[i].location, aiEatList[i]);
			break;
		}
	}
	if(bool) {
		return;
	}
}

这里我们用了一个isDotEat方法,主要是电脑用来判断某个点是否有被吃的可能
true=会被吃 false=不会被吃

isDotEat(animal) {
	// 克隆一个副本备用
	let list = JSON.parse(JSON.stringify(this.mapList));
	// 如果传过来的不是一个动物,则直接会被吃,多一事不如少一事
	if(!animal) { return true; }
	// 拿到当前棋子的坐标
	let [x, y] = animal.location;
	// 判断当前棋子四周相邻有没有能吃自己的
	// onIsEat方法判断两个动物是否有吃与被吃的关系,具体请查看【玩家操作】的第10步
	if(
		(x - 1 >= 0 && this.onIsEat(list[x - 1][y], animal)) ||
		(x + 1 <= 7 && this.onIsEat(list[x + 1][y], animal)) || 
		(y - 1 >= 0 && this.onIsEat(list[x][y - 1], animal)) || 
		(y + 1 <= 3 && this.onIsEat(list[x][y + 1], animal))
	) {
		return true;
	}
	// 判断每个子上下左右有没有鹰要吃自己
	// 有鹰并且之间有一颗子  
	// num = 两子之间存在的棋子数
	if(x - 2 >= 0) {
		let num = 0, isHawk = false;
		for(let a = x - 1; a >= 0; a--) {
			if(list[a][y] && list[a][y].isFilp && list[a][y].name == '鹰' && list[a][y].camp == this.player2.camp) { isHawk = true; break; }
			if(list[a][y] != null) { num++; }
		}
		if(num == 1 && isHawk) {
			return  true;
		}
	}
	if(x + 2 <= 7) {
		let num = 0, isHawk = false;
		for(let a = x + 1; a <= 7; a++) {
			if(list[a][y] && list[a][y].isFilp && list[a][y].name == '鹰' && list[a][y].camp == this.player2.camp) { isHawk = true; break; }
			if(list[a][y] != null) { num++; }
		}
		if(num == 1 && isHawk) {
			return  true;
		}
	}
	if(y - 2 >= 0) {
		let num = 0, isHawk = false;
		for(let a = y - 1; a >= 0; a--) {
			if(list[x][a] && list[x][a].isFilp && list[x][a].name == '鹰' && list[x][a].camp == this.player2.camp) { isHawk = true; break; }
			if(list[x][a] != null) { num++; }
		}
		if(num == 1 && isHawk) {
			return  true;
		}
	}
	if(y + 2 <= 3) {
		let num = 0, isHawk = false;
		for(let a = y + 1; a <= 3; a++) {
			if(list[x][a] && list[x][a].isFilp && list[x][a].name == '鹰' && list[x][a].camp == this.player2.camp) { isHawk = true; break; }
			if(list[x][a] != null) { num++; }
		}
		if(num == 1 && isHawk) {
			return  true;
		}
	}
	return false;
},

(4)逃跑

判断每个子被追杀的情况下,周围有没有空地,有则逃跑
逃跑的时候,判断空地的周围有没有能吃我的,如果有,则不用跑,如果没有则跑
如果自身旁边有比狩猎者等级的我方子,则不用跑,达到一换一的效果

首先我们需要找出我们自己的棋子有哪些能被玩家吃

for(let i = 0; i < aiFilpList.length; i++) {
	let a = JSON.parse(JSON.stringify(aiFilpList[i]))
	if(this.isDotEat(a)) {
		ownPassList.push(aiFilpList[i])
	}
}

其次我们需要知道被吃的子周围的空地

for(let i = 0; i < ownPassList.length; i++) {
	let [x, y] = ownPassList[i].location;
	let arr = []
	if(x - 1 >= 0 && this.mapList[x - 1][y] == null) {
		arr.push([x - 1, y]);
	}
	if(x + 1 < 8 && this.mapList[x + 1][y] == null) {
		arr.push([x + 1, y])
	}
	if(y - 1 >= 0 && this.mapList[x][y - 1] == null){
		arr.push([x, y - 1])
	}
	if(y + 1 < 4 && this.mapList[x][y + 1] == null) {
		arr.push([x, y + 1])
	}
	nullList.push({ parent: [x, y], child: [...arr] })
}

然后让最大权重的棋子先跑,我们需要先获取最大权重的棋子


let maxWeightChess = []; // 最大权重棋子的坐标
let maxWeight = 0;	// 权重值
let maxChild = [];	// 空地
for (let i = 0; i < nullList.length; i++) {
	let [x, y] = nullList[i].parent
	if(this.mapList[x][y].weight > maxWeight && nullList[i].child.length > 0) {
		maxWeight = this.mapList[x][y].weight
		maxWeightChess = [x, y]
		maxChild = [...nullList[i].child]
	}
}

最后我们需要判断,如果我们到达周围的空地上,此时有没有能吃我的。如果有,则不用跑,因为跑了也死,不如去翻牌或者追杀玩家的子,如果没有则跑。

if(maxWeightChess.length > 0 && maxChild.length > 0) { 
	let [x, y] = maxWeightChess;
	let goodspace = [];	// 确定下来的正确的坐标
	for(let i = 0; i < maxChild.length; i++) {
		let [x1, y1] = maxChild[i];
		let a = JSON.parse(JSON.stringify(this.mapList[x][y]));
		a.location = [x1, y1];
		// 在某个空地上能否被吃
		if(!this.isDotEat(a)) {
			goodspace = [...maxChild[i]]
			break;
		}
	}
	if(goodspace.length > 0) {
		this.handlePosition(maxWeightChess, goodspace);
		return;
	}
}

(5)乘胜追击

查找场上自己的子和对方子,每两个子之间是否能连成一条路线,按照权重从大到小排列。
首先用一颗棋子不远万里去吃玩家的子,肯定要找到一条最短并且分值最高的路。
而有些情况下每个子肯定有吃多个,这时我们就要记录所有路线。

我们在开始之前,先看拿一下BFS(广度优先搜索)算法,它的特点就是可以找出两点之间最短的路径。

  1. 我们要想从起点到终点,肯定要先从起点开始搜索周围相邻的其他坐标,并且每次搜索一个点之后,都要记录当前点是否被搜索过,每个点只搜索一次,以此类推,找到终点。
  2. 我们刚开始需要把每个点的信息都初始化,比如第一次搜索(0,0)点,它附近节点nearNodes有两个,而自身是起点,所有自身是没有父节点的。
  3. 再往下搜索(0,1)点,它的附近节点有两个,一个是起点,一个是(1,1)点,记住它的父节点是起点(0,0),又因为起点是第一个点,是搜索过的,这时候我们只需要去搜索(1,1)点。
  4. 继续搜索(1,1)点,它有三个邻节点,(0,1)(1,0)因为被搜索过,所以直接搜索(2,1)点,记住此时父节点是(0,1)。
  5. 继续搜索(2,1),它的父节点(1,1),邻节点(3,1)
  6. 以此类推,最后除了起点和终点,它们的父节点连起来就是一条完整的路径。
  7. 注:这个算法的特点就是地毯式搜索,不放过任何一个点
    在这里插入图片描述
    在这里插入图片描述
    以上就是我对这个算法的理解,可能不太准确,如更深了解,请自行百度。
// 基本实现方法
function breadthFirstSearch(root) {
  // 队列第一个元素就是起点
  let queue = [root];
  let visited = new Set();
  // 从起点开始搜索
  while (queue.length > 0) {
    let node = queue.shift();
    visited.add(node);
    for (let neighbor of node.nearNodes) {
      if (!visited.has(neighbor)) {
        queue.push(neighbor);
      }
    }
  }
}

接下来我们再看游戏中如何运用:
首先我们需要先选出拿出当前场上自己的子和玩家的子(均翻开),再两两搜索路线,取得一个总的路线

let allChessLoad = [];
if(aiFilpList.length > 0 && playerFilpList.length > 0) {
	// 我们先用两个for循环来取出每个自己的子和每个玩家的子
	for(let i = 0; i < aiFilpList.length; i++) {
		let aiChess = aiFilpList[i]; // 电脑的子
		for(let j = 0; j < playerFilpList.length; j++) {
			let playerChess = playerFilpList[j]; // 玩家的子
			
			// 注意这里主要判断,电脑的子能不能去吃玩家的子,只有能吃才去搜索路线
			if(this.getAiIsEatPlayer(aiChess, playerChess)) {
				// 此时要注意,如果ai的子能吃玩家的子,此时的以0、1为代表二维数组是需要重新绘制的,
				// 因为每走一步,棋盘都会变化,就要重新绘制
				// 因为此时除了捕食子、被捕食子、空地以外所以节点都为0
				// 1=空地/自身子和玩家子  0=未翻开的卡/其他无关的子
				this.bfsLoadList = [];	// 刚开始定义的副本数组,形式 [ [0,1,0,0], [1,1,0,0]...[] ]
				for(let a = 0; a < this.mapList.length; a++) {
					this.bfsLoadList[a] = []
					for(let b = 0; b < this.mapList[0].length; b++) {
						let [x1, y1] = aiChess.location;
						let [x2, y2] = playerChess.location;
						if(this.mapList[a][b] === null || (x1 == a && y1 == b) || (x2 == a && y2 == b)) {
							this.bfsLoadList[a][b] = 1;
						} else {
							this.bfsLoadList[a][b] = 0;
						}
					}
				}
				// 处理一下克隆数组,给每个节点加上我们需要的属性值nearNodes、parent、value等
				// nearNodes=邻节点  
				// parent=父节点
				// value = 0 / 1
				this.bfsList = [];
				for(let c = 0; c < this.bfsLoadList.length; c++) {
					this.bfsList[c] = []
					for(let d = 0; d < this.bfsLoadList[0].length; d++) {
						this.bfsList[c][d] = null;
					}
				}
				// 这一步是初始化每个节点的信息,包括父节点以及邻节点信息
				for(let e = 0; e < this.bfsLoadList.length; e++) {
					for(let f = 0; f < this.bfsLoadList[0].length; f++) {
						let node = this.getPointNode(e, f);
						let up = this.getPointNode(e - 1, f)
						let down = this.getPointNode(e + 1, f)
						let left = this.getPointNode(e, f - 1)
						let right = this.getPointNode(e, f + 1)
						node.nearNodes = [up, down, left, right].filter(item => item && item.value == 1)
						this.bfsList[e][f] = node
					}
				}
				// 再去计算捕食的最短路径
				let arr = this.searchLoad(aiChess.location, playerChess.location);
				if(arr.length > 1) {
					// 如果有路,则存下,继续查找下一条路
					allChessLoad.push(arr); 
				}
			}
			
		}
	}
}

以上用到的方法 包括 getAiIsEatPlayer()、getPointNode()、searchLoad(),下面我们一 一讲解

  • getAiIsEatPlayer —— 判断电脑的棋子能不能吃玩家的
getAiIsEatPlayer(animal_ai, animal_player) {
	if(!animal_ai || !animal_player || animal_ai.camp == animal_player.camp || !animal_ai.isFilp || !animal_player.isFilp) {
		return false;
	}
	let aniWeight_1 = animal_ai.weight
	let aniWeight_2 = animal_player.weight
	if(
		(aniWeight_1 == 7 && [6,5,4,3,2].includes(aniWeight_2)) ||
		(aniWeight_1 == 6 && [5,4,3,2,1].includes(aniWeight_2)) ||
		(aniWeight_1 == 5 && [4,3,2,1].includes(aniWeight_2)) ||
		(aniWeight_1 == 4 && [3,2,1].includes(aniWeight_2)) ||
		(aniWeight_1 == 3 && [2,1].includes(aniWeight_2)) ||
		// (aniWeight_1 == 2 && [1].includes(aniWeight_2)) ||
		(aniWeight_1 == 1 && [7].includes(aniWeight_2))
	) {
		return true;
	}
	return false;
}
// 注意上面我忽略了鹰,因为鹰的路径搜索算法过于复杂,它捕食是需要跳子的,所以暂时去除它
  • getPointNode —— 给每个点设置我们需要的数据结构
getPointNode(x, y) {
  if (x >= 0 && y >= 0 && x < this.bfsList.length && y < this.bfsList[0].length) {
    let node = this.bfsList[x] && this.bfsList[x][y];
	if(!node) {
		let val = this.bfsLoadList[x][y];
		if(val !== undefined) {
			node = {
				x,	// 坐标
				y,	// 坐标
				value: val,  // 值 1 / 0
				checked: false,	// 是否搜索过了
				parent: null,	// 父节点
				nearNodes: []	// 邻节点
			}
			this.bfsList[x][y] = node;
		}
	}
	return node;
  } 
  return null;
}
  • searchLoad —— 主要搜索算法
// mapList地图二维数组   aiChess起点  playerChess终点
searchLoad(aiChess, playerChess) {
	let begin = this.getPointNode(aiChess[0], aiChess[1]);	// 起点信息
	let end =  this.getPointNode(playerChess[0], playerChess[1]); // 目标点信息
	let path = [];	// 最终路线
	let quere = [begin]; // 搜索队列
	while(quere.length > 0) {
		let current = quere.shift()
		current.checked = true
		for(let item of current.nearNodes) {
			if(!item.checked) {
				item.parent = current;
				quere.push(item)
			}
		}
		// 把节点以及它的父节点都存在路径里,说明这些节点可用
		let patharr = [];
		let pathNode = current;
		while(pathNode) {
			patharr.push(pathNode)
			pathNode = pathNode.parent
		}
		path = patharr;
		// 如果当前节点就是终点,就跳出
		if(current.x == end.x && current.y == end.y) {
			break;
		}
	}
	// 如果路径数组的第一个元素不是终点坐标,则返回空
	// 为什么是第一个元素呢,因为我们取是从队列头部取,然后查找,存是从尾部存,最后终点就会出现在第一个
	if(path.length && path[0].x == playerChess[0] && path[0].y == playerChess[1]) {
		return path;
	}
	return [];
}

最后一步,就是拿到所有路线后,我们需要从路线中搜索最完美的一条,进行追击。
首先我们需要保证路线的路途中是不会被玩家其他子吃掉的
然后再把剩余的路线按照分值从大到小排列,我们就取第一条路线去走

// 如果有能行动的路线
if(allChessLoad.length > 0) {
	let lastPoint = [];		// 最终的路线数组
	let roadFirst = null;	// 
	let handleAllRoad = [];	// 根据线路对比出,哪些路能要,哪些不能要
	let arrCopy = deepcopyArray(allChessLoad);	// 克隆路线数组用来处理路线
	
	// 首先需要判断能吃玩家子的多条路线中,哪些路线半路上就会被玩家吃掉,这样的路线剔除
	for(let a = 0; a < arrCopy.length; a++) {
		// 因为所有路线中是包含起点和终点的,我们得判断这条路是大于2
		if(arrCopy[a].length > 2) {
			let item = arrCopy[a];
			let {x, y} = arrCopy[a][item.length - 1];
			roadFirst = JSON.parse(JSON.stringify(this.mapList[x][y]))
			// 这里需要跳过每条路线的头尾,也就是捕食者与被捕食者,只计算路程中
			for (let b = item.length - 2; b >= 0; b--) {
				roadFirst.location = [arrCopy[a][b].x, arrCopy[a][b].y];
				// 如果这条路途中会被吃,则设为null
				if(this.isDotEat(roadFirst)) {
					arrCopy[a] = null;
					roadFirst.location = [];
					break;
				}
			}
		} else {
			// 如果这条路只包含起、终点,则设为null
			arrCopy[a] = null;
		}
	}
	// 过滤空数据
	handleAllRoad = arrCopy.filter(item => item != null);
	if(handleAllRoad.length > 0) {
		// 按照最短路排序 	短 > 长
		handleAllRoad.sort((a, b) => a.length - b.length)
		// 拿到第一条最短的路径
		lastPoint = handleAllRoad[0];
		// 让最后第一个坐标走到倒数第二个坐标,则完成追击
		this.handlePosition(
			[lastPoint[lastPoint.length - 1].x, lastPoint[lastPoint.length - 1].y], 
			[lastPoint[lastPoint.length - 2].x, lastPoint[lastPoint.length - 2].y],
		)
		return;
	}
}

(6)翻牌

到这里说明电脑不能吃,不能跑,不能追,只能翻牌操作
注:翻牌这里有点小技巧,就是如果当前场上有自己的鹰的话,就不要随机翻牌,而是翻鹰上下左右隔一颗子的牌,很大几率前中期就能靠鹰吃不少子
这个我没有做,以后再说

// 过滤还没翻的牌,从没翻开的随机翻一张
let noFlipList = list.filter(item => item && !item.isFilp);
if(noFlipList.length > 0) {
	let randomChess = noFlipList[Math.floor(Math.random() * noFlipList.length)];
	let [x, y] = randomChess.location;
	this.mapList[x][y].isFilp = true;
	return;
}

(7)投降 —— 不用说了,自己什么都没有啥可干的了,只能投降了,这一步一般是到最后的最后了

this.aiLose = true;
this.isPlay = 2;
this.isWin = 1;
this.$refs.popupWin.open()

附上完整代码地址(https://gitee.com/zhang_meng_lei/uniapp-game-beast

三、总结

整个游戏做下来,还是学到很多的,尤其是广度优先搜索。本想着把这个游戏中的电脑做的更聪明,最起码能跟人不相上下,但是牌类游戏中最难的就是,考虑不到很全面,永远不知道每盘游戏排列组合,以及牌与牌之间的联系,就像中国象棋一样,每一盘都是新的,每一盘都是不固定的走法。所以这方面还是比较费事,其他的慢慢的还是可以啃下来的。
最后游戏中更多优化还会留着以后返回来慢慢琢磨,以便把这个电脑算法打造的更厉害一点。
最后的最后 —— 谢谢观看!

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐