前端仔教你一步步實現人人對戰五子棋小遊戲【canvas詳細版】
線上地址--gobang online pc上使用谷歌瀏覽器比較友好@~@
程式碼倉庫--gobang tutorial 歡迎對此倉庫進行擴充套件或star啦 @~@
前置知識點: 阮生的es6教程和MDN的canvas教程
以上,兵馬未動,糧草先行。看官可以先體驗下小遊戲並且粗略瞭解下相關的知識點後(熟悉者可跳過,歡迎留言改進哈),再往下讀。
前言
本來是沒打算在掘金上再寫關於canvas版本的五子棋小遊戲文章的,因為之前已經在掘金上發表過類似的文章--談談前端實現五子棋遊戲。最近團隊輪到自己分享,然而,在短短的一個星期的時間內沒有想到比較實際可行的知識點或者專案拿來分享,畢竟工作日還得搬磚。於是乎,自己利用週末的時間將五子棋小遊戲重新梳理了一波,整理成一個教程,使它成為自己在一個小時的分享會上面分享的乾貨。
秉承著會就分享,不會就折騰的宗旨。竟然已經整理了一波的教程,那就放出來給大夥指點指點。下面進入正題:
五子棋規則
五子棋的規則有點點複雜,我這裡就簡化並改寫成下面這幾條:
- 對局雙方各執一色棋子。
- 空棋盤開局。
- 黑先、白後或者白先、黑後,交替下子,每次只能下一子。
- 橫線、豎線或者斜線上有連續五個同一色的棋子,則遊戲結束。
正式比賽的規則,看官可以到五子棋_百度百科這裡瞭解。本博文的案例是以上面列出來的四條規則為基礎,來實現五子棋小遊戲的。
專案骨架
為了方便管理、擴充套件功能和編寫程式碼,我這裡使用了es6的class語法,面向物件的思想來實現。首先,自己定義一個類Gobang
class Gobang { // 這裡設定一個五子棋的類,統一管理程式碼
// Gobang這個類的建構函式,options是在例項化的時候要傳過來的值
constructor(options={}){ // 設定引數的預設值,es6之前不允許這樣設定
this.options = options;
// 初始化
this.init();
}
// 初始化
init() {
const { options } = this;// 結構賦值
console.log(options); // 打印出傳入的例項的配置選項
}
}
// 例項化物件
let gobangInstance1 = new Gobang(); // 沒有傳配置項的時候
let gobangInstance2 = new Gobang({
canvas: 'chess'
}); // 傳配置項的時候
複製程式碼
上面的Gobang
類中,包含了一個constructor
和init
方法。其中constructor
方法是類預設的方法,通過new
命令生成物件例項時候,自動呼叫該方法。一個類必須有一個constructor
方法,如果沒有顯式定義,一個空的constructor
方法會預設新增。然後就是init
方法了,這裡我是整個類的初始化的入口方法。
專案骨架
程式碼在倉庫中對應的位置是skeleton。
繪製棋盤
棋盤,我們可以分為兩種,一種是視覺上的棋盤,另外一個是邏輯上的棋盤,你是看不見的。如下截圖:
首先,我們實現20*20
的物理上的棋盤,並且配上一些樣式。當然,為了高可配置,我們使用上面程式碼骨架上的options
進行傳值:
// 例項化物件
let gobang = new Gobang({
canvas: 'chess', // html中設定的畫布的id
gobangStyle: { // 五子棋的一些樣式
padding: 30, // 邊和邊之間的距離
count: 20, // 棋盤的邊數,整數
borderColor: '#bfbfbf', // 描邊的顏色
}
});
複製程式碼
然後就進行物理棋盤的繪製了,這裡是使用canvas
的相關知識點,控制畫筆更改著筆點並畫線條:
// 繪製出物理棋盤
drawChessBoard() {
const context = this.chessboard.getContext('2d');// 獲取繪製上下文
const {padding, count, borderColor} = this.options.gobangStyle;
// 設定棋盤的寬高
this.chessboard.width = this.chessboard.height = padding * count;
// 設定畫筆的顏色
context.strokeStyle = borderColor;
let half_padding = padding/2;// 考慮繪製的棋子展示的位置,所以要預留一些邊距,可以審查元素看下
// 畫棋盤
for(var i = 0; i < count; i++){
context.moveTo(half_padding+i*padding, half_padding);
context.lineTo(half_padding+i*padding, padding*count-half_padding);
context.stroke(); // 這裡繪製出的是豎軸
context.moveTo(half_padding, half_padding+i*padding);
context.lineTo(count*padding-half_padding, half_padding+i*padding);
context.stroke(); // 這裡繪製出的是橫軸
}
}
複製程式碼
接著就是邏輯的棋盤的記錄了。這裡我使用了二維陣列去記錄棋盤點的位置,比如(0,0)
點對應的陣列下標是[0][0]
;然後(1,2)
點對應的下標是[1][2]
...以此類推。這裡在記錄好點之後,也為他們進行賦值為0,表示此處沒有落子,如果有落子,記錄為1(黑子)或2(白子)。具體邏輯棋盤程式碼如下:
// 繪製邏輯矩陣棋盤
initChessboardMatrix(){
const {count} = this.options.gobangStyle;
const checkerboard = [];
// 存在(x,y)矩陣點
for(let x = 0; x < count; x++){
checkerboard[x] = [];
for(let y = 0; y < count; y++){
checkerboard[x][y] = 0; // 全部賦值為0,表示此座標是沒有棋子的
}
}
}
複製程式碼
繪製棋盤
程式碼在倉庫中對應的位置是chess_board。
繪製棋子
繪製棋子這個簡單。在標題中表明瞭是使用canvas的相關知識點,棋子是使用canvas來繪製的。具體用的canvas的知識點有arc和createRadialGradient
方法。前者是繪製一個圓,後者是為這個圓新增顏色漸變效果,使得棋子看起來更加有質感。當然,這裡需要繪製黑白兩種顏色的棋子,需要有個flag來進行標識是否是黑色/白色,程式碼中有介紹。
drawChessman(x , y, isBlack){// 繪製的(x,y)座標,isBlack判斷是黑棋子還是白色棋子
const context = this.chessboard.getContext('2d');
context.beginPath();
context.arc(x, y, 10, 0, 2 * Math.PI);// 畫圓,半徑這裡設定為10px
context.closePath();
// 為棋子新增漸變顏色
let gradient = context.createRadialGradient(x, y, 10, x-5, y-5, 0);// createRadialGradient(x1,y1,r1,x2,y2,r2)建立放射狀/圓形漸變物件。
if(isBlack){ // 黑子
gradient.addColorStop(0,'#0a0a0a'); // 開始的顏色
gradient.addColorStop(1,'#636766'); // 結束的顏色
}else{ // 白子
gradient.addColorStop(0,'#d1d1d1');
gradient.addColorStop(1,'#f9f9f9');
}
context.fillStyle = gradient;
context.fill();
}
複製程式碼
對應的效果圖如下:
繪製棋子
程式碼在倉庫中對應的位置是chessman。
落子實現人人對戰
在上一節中,只是講解了怎麼去繪製棋子。接下來我們要將繪製好的棋子放到要下在棋盤的相關點選位置,並且實現黑白兩棋的交替下棋,也就是實現人人對戰啦。
首先,我們在初始化入口那裡先初始化下棋子的角色(是黑棋還是白棋),獲取單元格的寬度。
init() {
// 角色,1是黑色棋子,2是白色棋子
this.role = options.role || 1;
// 單個格子的寬高
this.lattice = {
width: options.gobangStyle.padding,
height: options.gobangStyle.padding
};
}
複製程式碼
接下來就可以實行點選棋盤位置的計算了,獲取相關的邏輯棋盤的座標點,之後在這個座標點進行棋子的繪製:
// 監聽落子
listenDownChessman() {
// 監聽點選棋盤物件事件
this.chessboard.onclick = event => {
let {padding} = this.options.gobangStyle;
// 獲取棋子的位置(x,y)座標,如(0,0),(0,2)
let {
offsetX: x,
offsetY: y,
} = event; // 解構賦值
// console.log(x,y);
x = Math.abs(Math.round((x-padding/2)/this.lattice.width));// 防止邊界的為負數,故取絕對值
y = Math.abs(Math.round((y-padding/2)/this.lattice.height));
// console.log(x,y);
// 點選的是棋盤,並且是空位置才可以落子
if(this.checkerboard[x][y] !== undefined && Object.is(this.checkerboard[x][y],0)){
// 更新矩陣值
this.checkerboard[x][y] = this.role;
// 刻畫棋子
this.drawChessman(x,y,Object.is(this.role , 1));
// 切換棋子的角色
this.role = Object.is(this.role , 1) ? 2 : 1;
}
}
}
// 刻畫棋子
drawChessman(x,y,isBlack) {
const context = this.chessboard.getContext('2d');
const {padding} = this.options.gobangStyle;
let half_padding = padding/2;
context.beginPath();
context.arc(half_padding+x*padding,half_padding+y*padding,half_padding-2,0,2*Math.PI);
let gradient = context.createRadialGradient(half_padding+x*padding+2,half_padding+y*padding-2,half_padding-2,half_padding+x*padding+2,half_padding+y*padding-2,0);
if(isBlack){
gradient.addColorStop(0,'#0a0a0a');
gradient.addColorStop(1,'#636766');
}else{
gradient.addColorStop(0,'#d1d1d1');
gradient.addColorStop(1,'#f9f9f9');
}
context.fillStyle = gradient;
context.fill();
}
複製程式碼
落子實現人人對戰
程式碼在倉庫中對應的位置是listen_chessman。
實現悔棋
在雙方下棋中,允許對方或者自己對已經下的棋子進行調整,也就是悔棋,恢復上一步的操作,然後再重新下棋。實現悔棋功能的時候,需要知道下棋的歷史記錄和當前的落子步數和角色。
對於歷史的記錄,這裡對每一步的落子都使用一個物件進行儲存,並放到一個history
的數組裡面進行儲存:
init() {
// 走棋的歷史記錄
this.history = [];
// 當前步
this.currentStep = 0;
}
listenDownChessman() {
...
// 落子之後有可能悔棋之後落子,這種情況下應該重置歷史記錄
this.history.length = this.currentStep;
this.history.push({// 儲存座標和角色快照
x,
y,
role: this.role
});
this.currentStep++; // 當前步驟自加
...
}
複製程式碼
然後在執行悔棋的時候,將前一個記錄的棋子的在棋盤上對應的ui給抹除掉就行了,不能將history
中對應的位置移除哦,因為是要用到撤銷悔棋的啊。銷燬完棋子後,要對物理棋盤上的ui進行修補,修補的情況一共有九種:
- 左上角棋盤
- 左邊緣棋盤
- 左下角棋盤
- 下邊緣棋盤
- 右下角棋盤
- 右邊緣棋盤
- 右上角棋盤
- 上邊緣棋盤
- 中間(非邊界)棋盤
// 悔棋
regretChess() {
// 找到最後一次記錄,回滾到上一次的ui狀態
if(this.history.length){
const prev = this.history[this.currentStep - 1];
if(prev){
const {
x,
y,
role
} = prev;
// 銷燬棋子
this.minusStep(x,y);
this.checkerboard[prev.x][prev.y] = 0; // 置空操作
this.currentStep--; // 步數自減
// 角色發生改變,下一步的下棋是該撤銷棋子的角色
this.role = Object.is(role,1) ? 1 : 2;
}
}
}
// 銷燬棋子
minusStep(x, y) {
const context = this.chessboard.getContext('2d');
const {padding, count} = this.options.gobangStyle;
context.clearRect(x*padding, y*padding, padding,padding);
// 修補刪除的棋盤位置
// 重畫該圓周圍的格子,對邊角的格式進行特殊的處理
let half_padding = padding/2; // 棋盤單元格的一半
if(x<=0 && y <=0){ // 情況比較多,一共九種情況
this.fixchessboard(half_padding,half_padding,half_padding,padding,half_padding,half_padding,padding,half_padding);
}else if(x>=count-1 && y<=0){
this.fixchessboard(count*padding-half_padding,half_padding,count*padding-padding,half_padding,count*padding-half_padding,half_padding,count*padding-half_padding,padding);
}else if(y>=count-1 && x <=0){
this.fixchessboard(15,count*padding-half_padding,half_padding,count*padding-padding,half_padding,count*padding-half_padding,padding,count*padding-half_padding);
}else if(x>=count-1 && y >= count-1){
this.fixchessboard(count*padding-half_padding,count*padding-half_padding,count*padding-padding,count*padding-half_padding,count*padding-half_padding,count*padding-half_padding,count*padding-half_padding,count*padding-padding);
}else if(x <=0 && y >0 && y <count-1){
this.fixchessboard(half_padding,padding*y+half_padding,padding,padding*y+half_padding,half_padding,padding*y,half_padding,padding*y+padding);
}else if(y <= 0 && x > 0 && x < count-1){
this.fixchessboard(x*padding+half_padding,half_padding,x*padding+half_padding,padding,x*padding,half_padding,x*padding+padding,half_padding);
}else if(x>=count-1 && y >0 && y < count-1){
this.fixchessboard(count*padding-half_padding,y*padding+half_padding,count*padding-padding,y*padding+half_padding,count*padding-half_padding,y*padding,count*padding-half_padding,y*padding+padding);
}else if(y>=count-1 && x > 0 && x < count-1){
this.fixchessboard(x*padding+half_padding,count*padding-half_padding,x*padding+half_padding,count*padding-padding,x*padding,count*padding-half_padding,x*padding+padding,count*padding-half_padding);
}else{
this.fixchessboard(half_padding+x*padding,y*padding,half_padding+x*padding,y*padding + padding,x*padding,y*padding+half_padding,(x+1)*padding,y*padding+half_padding)
}
}
// 修補刪除後的棋盤
fixchessboard (a , b, c , d , e , f , g , h){
const context = this.chessboard.getContext('2d');
const {borderColor, lineWidth} = this.options.gobangStyle;
context.strokeStyle = borderColor;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(a , b);
context.lineTo(c , d);
context.moveTo(e, f);
context.lineTo(g , h);
context.stroke();
}
複製程式碼
實現悔棋
程式碼在倉庫中對應的位置是regret_chess。
實現撤銷悔棋
有允許悔棋,那麼就有允許撤銷悔棋這樣子才合理。同悔棋功能,撤銷悔棋是需要知道下棋的歷史記錄和當前的步驟和棋子角色的。如下:
// 撤銷悔棋
revokedRegretChess(){
const next = this.history[this.currentStep]; // 撤銷的點的下一個
if(next) {
this.drawChessman(next.x, next.y, next.role === 1); // 在上次撤銷的點上畫棋
this.checkerboard[next.x][next.y] = next.role;
this.currentStep++; // 當前步驟自加
this.role = Object.is(this.role, 1) ? 2 : 1; // 角色的切換
}
}
複製程式碼
實現撤銷悔棋
程式碼在倉庫中對應的位置是revoked_regret_chess。
勝利提示/遊戲結束
五子棋的的結束也就是必須要決出勝利者,或者是棋盤沒有位置可以下棋了。這裡考慮決出勝利為遊戲結束的切入點,上面也說到了如何才算是一方獲勝--橫線、豎線或者斜線上有連續五個同一色的棋子
。那麼我們就對這四種情況進行處理了,我們在矩陣中記錄當前點選的陣列點中是否有連續的五個1(黑子)或者連續的五個2(白子)即可。如下截圖的x軸獲勝,注意gif圖右側打印出來的陣列內容:
四種獲勝的情況和或者的提示相關的程式碼如下:
// 裁判觀察棋子,判斷獲勝一方
checkReferee(x , y , role) {
if((x == undefined)||(y == undefined)||(role==undefined)) return;
// 連殺的分數,五個同一色的棋子連成一條直線就是勝利
let countContinuous = 0;
const XContinuous = this.checkerboard.map(x => x[y]); // x軸上連殺
const YContinuous = this.checkerboard[x]; // y軸上連殺
const S1Continuous = []; // 儲存左斜線連殺
const S2Continuous = []; // 儲存右斜線連殺
this.checkerboard.forEach((_y,i) => {
// 左斜線
const S1Item = _y[y - (x - i)];
if(S1Item !== undefined){
S1Continuous.push(S1Item);
}
// 右斜線
const S2Item = _y[y + (x - i)];
if(S2Item !== undefined) {
S2Continuous.push(S2Item);
}
});
// 當前落棋點所在的X軸/Y軸/交叉斜軸,只要有能連起來的5個子的角色即有勝者
[XContinuous, YContinuous, S1Continuous, S2Continuous].forEach(axis => {
if(axis.some((x, i) => axis[i] !== 0 &&
axis[i - 2] === axis[i - 1] &&
axis[i - 1] === axis[i] &&
axis[i] === axis[i + 1] &&
axis[i + 1] === axis[i + 2])) {
countContinuous++
}
});
// 如果贏了就給出提示
if(countContinuous){
this.win = true;
let msg = (role == 1 ? '黑' : '白') + '子勝利✌️';
// 提示資訊
this.result.innerText = msg;
// 不允許再操作
this.chessboard.onclick = null;
}
}
複製程式碼
勝利提示/遊戲結束
程式碼在倉庫中對應的位置是winner_hint。
嗯~至此,已經一步步講解完如何開發一個能夠在pc上愉快玩耍的休閒小遊戲-五子棋了。當然,很多的引數我都是設定在options
這裡,其實為了更好的使用者體驗,你可以將這些設定在ui層面供使用者自行調節的;再者你可以在專案基礎上實現其他功能,比如人機對戰等。如果有什麼想法的話,歡迎下方留言或者前往此程式碼倉庫gobang-tutorial進行相關動能補充或者完善@~@