為什突然做這個,因為這是個筆試題,拖了一個月才寫(最近終于閑了O(∩_∩)O)穗慕,廢話不多說,說說這個題吧
題目要求
編寫一個單機【五子棋】游戲妻导,要求如下:
1.使用原生技術(shù)實現(xiàn)逛绵,兼容 Chrome 瀏覽器即可。
2.實現(xiàn)勝負判斷倔韭,并給出贏棋提示术浪;任意玩家贏得棋局,鎖定棋盤寿酌。
3.請盡可能的考慮游戲的擴展性胰苏,界面可以使用 DOM / Canvas 實現(xiàn)》菝考慮后續(xù)切換界面實現(xiàn)的方式成本最低碟联。(比如選擇使用 DOM 實現(xiàn)界面妓美,需求改變?yōu)槭褂?Canvas 實現(xiàn)時盡可能少的改動代碼)。
4.實現(xiàn)一個悔棋功能
5.實現(xiàn)一個撤銷悔棋功能鲤孵。
6.人機對戰(zhàn)部分可選壶栋。
7.盡可能的考慮實現(xiàn)的靈活性和擴展性。
UI部分
棋盤
五子棋普监,首先我們會想到棋盤贵试、棋子,這是棋類游戲的基本元素凯正,實現(xiàn)棋盤毙玻、棋子基本的兩種思路,一種使用html標(biāo)簽?zāi)M廊散,另外一種就是使用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();
}
主要的思路就是畫等距離的直線允睹,包活橫向和縱向运准,用一個循環(huán)即可。
棋子
棋子的注意點就是位置缭受,圓心和棋盤的交叉點要對齊胁澳,當(dāng)鼠標(biāo)點擊某個范圍的時候要在對應(yīng)的交叉點為圓心處畫圓。
oChess.addEventListener('click', (event) => {
let x = Math.floor(event.offsetX/40),
y = Math.floor(event.offsetY/40);
}, false)
我們以交叉點為中心的正方形(變長為棋格邊長)為范圍米者,也就是說每當(dāng)鼠標(biāo)點擊到紅色區(qū)域就在該中心點畫棋子韭畸。這里獲得中心點坐標(biāo)之后就可以在對應(yīng)點畫棋子
//畫棋子
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用來畫棋譜酿愧,并將其背景色設(shè)置為透明沥潭,這樣清除棋子就不會影響到棋盤。
算法
界面是有了嬉挡,但是玩的的時候怎么確定誰贏了钝鸽?人機的時候電腦該怎么走汇恤?
贏法
這里使用一種最簡單的算法,那就是先枚舉出所有的贏法拔恰,然后再每走一步的時候都去判斷下棋人贏的權(quán)重因谎。那么有多少種贏的方式呢,當(dāng)然是跟棋盤大小有關(guān)(可不是棋盤的寬高哦??)颜懊,格子有多少決定這有多少贏法财岔。我們都知道只要五個棋子連成一條線即可,這條線可以橫著可以豎著也可以斜著河爹,所以我們就按照這幾個方向來計算即可匠璧,橫、豎咸这、斜夷恍、反斜。
//可以贏的方式
let canWin = [];
//可以贏的種類數(shù)量
let winCount = 0;
//這橫線豎線都是15個點媳维,初始化每個點的贏法酿雪,每個點都有可能在任意方向與其他棋子連成一條線(五子),所以每個點贏的方式構(gòu)成一個數(shù)組
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++){
//橫線有十一種不重復(fù)的五個點侨艾,下面同理
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++;
}
}
所有的贏法都已經(jīng)統(tǒng)計出來执虹,那么對于電腦下棋來說肯定是要走最有可能贏的位置,那么哪個位置最有可能贏唠梨,當(dāng)然是最有可能連成五個棋子的地方袋励,因此,我們需要計算所有的沒有落子的地方如果落子的話贏得概率大小当叭,我們只需要加個權(quán)重即可
//電腦下棋
const computerStep = () => {
//人贏得期望值
let peopleScore = [];
//電腦贏得期望值
let computerScore = [];
let maxScore = 0;
//電腦落子坐標(biāo)
let currentX = 0;
let currentY = 0;
//每個坐標(biāo)點落子勝利的期望值(初始化)
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每種贏法人或者電腦落子數(shù)量,如果有別的落子則加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;
}
}
}
判斷輸贏就很簡單了间螟,走完一步就判斷人或者電腦是否在某一種贏法已經(jīng)落子5顆,當(dāng)然,如果已經(jīng)贏了就不能再落子了厢破。
人對人
人對人的時候就簡單很多了荣瑟,不需要對電腦判斷,只需要切換人即可摩泪,并且要注意一些判斷條件不再是人機笆焰。
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];
//只有當(dāng)前是人對人而且不是第一個人下棋才賦值
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表示是否是人在下棋(這個值還代表著棋子顏色的變化加勤,互不影響的)仙辟,注意點就是一個人下完之后不再是電腦下
悔棋與取消悔棋
之前我們在每次下棋的時候都記錄了雙方的落子位置,所以只需要將最后一個落子位置的棋子清除掉鳄梅,并將落子記錄刪除叠国,而且包括得分等都需要重置,只要按照落子的規(guī)則反著來即可戴尸。
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.沒有悔棋就沒有取消悔棋香追。這不是廢話嗎?這里要注意的有兩點:第一坦胶,所有悔棋記錄全都取消悔棋就不再悔棋透典;第二:每次下棋之后之前的悔棋就不再算數(shù),需要制空顿苇。因此在取消悔棋完要將變量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;
}
})
重置
這個就很簡單了,我們只需要把棋盤清空纪岁,然后把所有參數(shù)初始化原始值即可凑队,主要用于重新開始游戲以及第一次進入的時候重置棋盤。
洋洋灑灑寫了這么多幔翰,大家慢慢看漩氨,有些地方有點繞,但是沒有很難的算法遗增,所以不會很費勁的才菠。
源碼地址:https://github.com/Stevenzwzhai/stevenzwzhai.github.com/tree/master/FE/src/chess
在線演示:https://stevenzwzhai.github.io/FE/src/chess