为什突然做这个,因为这是个笔试题,拖了一个月才写(最近终于闲了O(∩_∩)O),废话不多说,说说这个题吧
题目要求
编写一个单机【五子棋】游戏,要求如下:
1.使用原生技术实现,兼容 Chrome 浏览器即可。
2.实现胜负判断,并给出赢棋提示;任意玩家赢得棋局,锁定棋盘。
3.请尽可能的考虑游戏的扩展性,界面可以使用 DOM / Canvas 实现。考虑后续切换界面实现的方式成本最低。(比如选择使用 DOM 实现界面,需求改变为使用 Canvas 实现时尽可能少的改动代码)。
4.实现一个悔棋功能
5.实现一个撤销悔棋功能。
6.人机对战部分可选。
7.尽可能的考虑实现的灵活性和扩展性。
UI部分
棋盘
五子棋,首先我们会想到棋盘、棋子,这是棋类游戏的基本元素,实现棋盘、棋子基本的两种思路,一种使用html标签模拟,另外一种就是使用canvas去画。这里我们选择使用canvas。
canvas.strokeStyle = "#ccc";
for(let i = 0; i<=15; i++){
canvasA.moveTo(20+40*i, 20);
canvasA.lineTo(20+40*i, 620);
canvasA.stroke();
canvasA.moveTo(20, 20+40*i);
canvasA.lineTo(620, 20+40*i);
canvasA.stroke();
}
主要的思路就是画等距离的直线,包活横向和纵向,用一个循环即可。
棋子
棋子的注意点就是位置,圆心和棋盘的交叉点要对齐,当鼠标点击某个范围的时候要在对应的交叉点为圆心处画圆。
oChess.addEventListener('click', (event) => {
let x = Math.floor(event.offsetX/40),
y = Math.floor(event.offsetY/40);
}, false)
我们以交叉点为中心的正方形(变长为棋格边长)为范围,也就是说每当鼠标点击到红色区域就在该中心点画棋子。这里获得中心点坐标之后就可以在对应点画棋子
//画棋子
const drawChessman = (x, y, temp) => {
canvas.beginPath();
canvas.arc(20+x*40,20+y*40, 18, 0, 360);
canvas.closePath();
canvas.fillStyle = temp ? "#fff" : "#000";
canvas.fill();
}
其他
由于有悔棋功能,我们可以使用canvas的clearRect,但是,在清除棋子的时候不仅把棋子清除掉,而且棋子覆盖的棋盘也被清除掉,这是不被允许的,但是我们总不能把棋盘再重新画一遍,这样棋盘就会把棋子覆盖,而且总归是有性能问题。
这里耍了个小聪明,我们使用两个canvas来完成,底层的canvas用来画棋盘,上层的canvas用来画棋谱,并将其背景色设置为透明,这样清除棋子就不会影响到棋盘。
算法
界面是有了,但是玩的的时候怎么确定谁赢了?人机的时候电脑该怎么走?
赢法
这里使用一种最简单的算法,那就是先枚举出所有的赢法,然后再每走一步的时候都去判断下棋人赢的权重。那么有多少种赢的方式呢,当然是跟棋盘大小有关(可不是棋盘的宽高哦😏),格子有多少决定这有多少赢法。我们都知道只要五个棋子连成一条线即可,这条线可以横着可以竖着也可以斜着,所以我们就按照这几个方向来计算即可,横、竖、斜、反斜。
//可以赢的方式
let canWin = [];
//可以赢的种类数量
let winCount = 0;
//这横线竖线都是15个点,初始化每个点的赢法,每个点都有可能在任意方向与其他棋子连成一条线(五子),所以每个点赢的方式构成一个数组
for(let i = 0; i< 15; i++){
canWin[i] = [];
for(let j = 0; j<15; j++){
canWin[i][j] = [];
}
}
//横线赢的方式
for(let i = 0; i<15;i++){
//横线有十一种不重复的五个点,下面同理
for(let j = 0;j<11; j++){
//连成五个子
for(let k = 0; k<5; k++){
canWin[i][j+k][winCount] = true;
}
winCount++;
}
}
//竖线赢的方式
for(let i = 0; i<11;i++){
for(let j = 0;j<15; j++){
//连成五个子
for(let k = 0; k<5; k++){
canWin[i+k][j][winCount] = true;
}
winCount++;
}
}
//正斜线赢的方式
for(let i = 0; i<11;i++){
for(let j = 0;j<11; j++){
//连成五个子
for(let k = 0; k<5; k++){
canWin[i+k][j+k][winCount] = true;
}
winCount++;
}
}
//反斜线赢的方式
for(let i = 0; i<11;i++){
for(let j = 14;j>3; j--){
//连成五个子
for(let k = 0; k<5; k++){
canWin[i+k][j-k][winCount] = true;
}
winCount++;
}
}
所有的赢法都已经统计出来,那么对于电脑下棋来说肯定是要走最有可能赢的位置,那么哪个位置最有可能赢,当然是最有可能连成五个棋子的地方,因此,我们需要计算所有的没有落子的地方如果落子的话赢得概率大小,我们只需要加个权重即可
//电脑下棋
const computerStep = () => {
//人赢得期望值
let peopleScore = [];
//电脑赢得期望值
let computerScore = [];
let maxScore = 0;
//电脑落子坐标
let currentX = 0;
let currentY = 0;
//每个坐标点落子胜利的期望值(初始化)
for(let i = 0; i<15; i++){
peopleScore[i] = [];
computerScore[i] = [];
for(let j = 0; j<15; j++){
peopleScore[i][j] = 0;
computerScore[i][j] = 0;
}
}
for(let i = 0; i<15; i++){
for(let j = 0; j<15; j++){
//还未落子
if(chessArr[i][j] == 0){
for(let k = 0;k<winCount;k++){
if(canWin[i][j][k]){
//peopleWin,computerWin每种赢法人或者电脑落子数量,如果有别的落子则加10,表示这种赢法不可能赢
switch(peopleWin[k]){
case 1:peopleScore[i][j]+=100;
break;
case 2:peopleScore[i][j]+=400;
break;
case 3:peopleScore[i][j]+=800;
break;
case 4:peopleScore[i][j]+=2000;
break;
}
switch(computerWin[k]){
case 1:computerScore[i][j]+=150;
break;
case 2:computerScore[i][j]+=450;
break;
case 3:computerScore[i][j]+=850;
break;
case 4:computerScore[i][j]+=10000;
break;
}
}
}
if(peopleScore[i][j]>maxScore){
maxScore = peopleScore[i][j];
currentX = i;
currentY = j;
}else if(peopleScore[i][j] == maxScore){
if(computerScore[i][j]>computerScore[currentX][currentY]){
currentX = i;
currentY = j;
}
}
if(computerScore[i][j]>maxScore){
maxScore = computerScore[i][j];
currentX = i;
currentY = j;
}else if(computerScore[i][j] == maxScore){
if(peopleScore[i][j]>peopleScore[currentX][currentY]){
currentX = i;
currentY = j;
}
}
}
}
}
drawChessman(currentX, currentY, false);
// currentComputer = [currentX, currentY];
//记录电脑落子位置
pTwo[0].push([currentX, currentY]);
chessArr[currentX][currentY] = 2;
for(let i = 0; i<winCount; i++){
if(canWin[currentX][currentY][i]){
computerWin[i]++;
//人不可能赢
peopleWin[i] += 10;
if(computerWin[i]==5){
alert('computer win!')
gameOver = true;
}
}
}
}
主要思路就是,首先找出棋盘上所有没有落子的位置,然后计算该点电脑或人落子的话赢得期望值,分别找出最大值,那么就是我们需要落子的地方,这时有可能是两个点,如果人下一步落子的地方期望值大于电脑,说明需要堵棋,否则不用。
判断输赢
for(let i = 0; i<winCount; i++){
if(canWin[currentX][currentY][i]){
computerWin[i]++;
//人不可能赢
peopleWin[i] += 10;
if(computerWin[i]==5){
alert('computer win!')
gameOver = true;
}
}
}
判断输赢就很简单了,走完一步就判断人或者电脑是否在某一种赢法已经落子5颗,当然,如果已经赢了就不能再落子了。
人对人
人对人的时候就简单很多了,不需要对电脑判断,只需要切换人即可,并且要注意一些判断条件不再是人机。
oChess.addEventListener('click', (event) => {
if(gameOver){
return;
}
if(!isp2p){
if(!isMan){
return;
}
}
let x = Math.floor(event.offsetX/40),
y = Math.floor(event.offsetY/40);
if(chessArr[x][y] == 0){
drawChessman(x, y, isMan);
// currentPeople = [x, y];
//只有当前是人对人而且不是第一个人下棋才赋值
if(isp2p && !isMan){
pTwo[0].push([x,y]);
}else{
pOne[0].push([x, y]);
}
chessArr[x][y] = 1;
for(let i = 0; i<winCount; i++){
if(canWin[x][y][i]){
if(isp2p && !isMan){
computerWin[i]++;
//HOU
peopleWin[i] += 10;
if(computerWin[i]==5){
alert('opponent win!')
gameOver = true;
}
}else{
peopleWin[i]++;
//电脑不可能赢
computerWin[i] += 10;
if(peopleWin[i]==5){
alert('you win!')
gameOver = true;
}
}
}
}
if(!gameOver){
if(!isp2p){
computerStep();
}
//这个值还代表着棋子颜色的变化
isMan = !isMan;
}
}
})
这里用isp2p判断是否是人对人,isMan表示是否是人在下棋(这个值还代表着棋子颜色的变化,互不影响的),注意点就是一个人下完之后不再是电脑下
悔棋与取消悔棋
之前我们在每次下棋的时候都记录了双方的落子位置,所以只需要将最后一个落子位置的棋子清除掉,并将落子记录删除,而且包括得分等都需要重置,只要按照落子的规则反着来即可。
oBack.addEventListener('click', (event) => {
let currentOne = [];
let currentTwo = [];
isBack = true;
//这里悔棋会删除落子位置,但是取消悔棋还需要记录之前位置
currentOne = pOne[0].pop();
pOne[1].push(currentOne);
currentTwo = pTwo[0].pop();
pTwo[1].push(currentTwo);
if(!currentOne){
return;
}
clearChessman(currentOne[0], currentOne[1], true);
clearChessman(currentTwo[0], currentTwo[1], true);
chessArr[currentOne[0]][ currentOne[1]] = 0;
chessArr[currentTwo[0]][ currentTwo[1]] = 0;
for(let i = 0; i<winCount; i++){
if(canWin[currentOne[0]][currentOne[1]][i]){
peopleWin[i]--;
computerWin[i] -= 10;
}
}
for(let i = 0; i<winCount; i++){
if(canWin[currentTwo[0]][currentTwo[1]][i]){
computerWin[i]--;
peopleWin[i] -= 10;
}
}
gameOver = false;
})
这里悔棋的时候我们把记录的位置删除了,但是取消悔棋的时候我们还需要上一步的位置,所以每次悔棋的时候都需要把删除的位置记录下来,以便取消悔棋。取消悔棋做的呢就跟悔棋相反了。
注意点:
1.没有悔棋就没有取消悔棋。这不是废话吗?这里要注意的有两点:第一,所有悔棋记录全都取消悔棋就不再悔棋;第二:每次下棋之后之前的悔棋就不再算数,需要制空。因此在取消悔棋完要将变量isBack重置为false,还有就是每次人或电脑下棋都要重置悔棋和取消悔棋记录
//重置悔棋部分
isBack = false;
pOne = [];
pTwo = [];
2.取消悔棋也要把每一方的得分重新计算。
oReback.addEventListener('click', (event) => {
let currentOne = [];
let currentTwo = [];
//如果没有悔棋就没有取消悔棋
if(!isBack){
return;
}
console.log(pTwo[0])
currentOne = pOne[1].pop();
pOne[0].push(currentOne);
currentTwo = pTwo[1].pop();
pTwo[0].push(currentTwo);
console.log(pTwo)
if(!currentOne){
return;
}
//所有悔棋撤销之后再重制
if(!pOne[1].length){
isBack = false;
}
drawChessman(currentOne[0], currentOne[1], true);
chessArr[currentOne[0]][currentOne[1]] = 1;
for(let i = 0; i<winCount; i++){
if(canWin[currentOne[0]][currentOne[1]][i]){
peopleWin[i]++;
//电脑不可能赢
computerWin[i] = 10;
if(peopleWin[i]==5){
alert('you win!')
gameOver = true;
}
}
}
if(!gameOver){
if(!isp2p){
//为了防止二次添加到已走位置
drawChessman(currentTwo[0], currentTwo[1], false);
chessArr[currentTwo[0]][currentTwo[1]] = 2;
for(let i = 0; i<winCount; i++){
if(canWin[currentTwo[0]][currentTwo[1]][i]){
computerWin[i]++;
//电脑
peopleWin[i] += 10;
if(computerWin[i]==5){
alert('computer win!')
gameOver = true;
}
}
}
}else{
drawChessman(currentTwo[0], currentTwo[1], false);
chessArr[currentTwo[0]][currentTwo[1]] = 2;
for(let i = 0; i<winCount; i++){
if(canWin[currentTwo[0]][currentTwo[1]][i]){
computerWin[i]++;
//先手
peopleWin[i] += 10;
if(computerWin[i]==5){
alert('opponent win!')
gameOver = true;
}
}
}
}
isMan = !isMan;
}
})
重置
这个就很简单了,我们只需要把棋盘清空,然后把所有参数初始化原始值即可,主要用于重新开始游戏以及第一次进入的时候重置棋盘。
洋洋洒洒写了这么多,大家慢慢看,有些地方有点绕,但是没有很难的算法,所以不会很费劲的。
源码地址:https://github.com/Stevenzwzhai/stevenzwzhai.github.com/tree/master/FE/src/chess
在线演示:https://stevenzwzhai.github.io/FE/src/chess