Try   HackMD

五子棋 Gobang

前期規劃

在看完官方教學後,其實大部分都可以續用在五子棋上面,只差在難度較高的勝負邏輯判斷。

( 所以這篇筆記只是單純紀錄勝負判斷的思考過程 )

☞ Component 如何分

在正式開始寫之前,經過思考後,切 Component 的方式跟 state 儲存位置應該可以照 React 的五子棋教學:

  • Game 整個遊戲
    • Board 棋盤
      • Square 格子
    • History 側欄( 放步驟記錄 )

☞ State 資料格式

比較棘手的是牌面陣列 squares 要怎麼規劃:

// 儲存在最上層的 Component:Game
state = {
    history: [
        {
          squares: Array(19).fill(null) 
        }
      ],
      blackIsNext: true, // => 黑棋先下
      stepNumber: 0 // => 目前步數
}

要用哪種形式儲存棋面 squares?

毫無頭緒,不如先畫出 19x19 的棋面,把座標印在上面。

createArr = (num) => {
    let arr = [];
    for (let i = 0; i <= num; i += 1) {
      for (let j = 0; j <= num; j += 1) {
        arr.push([i, j]);
      }
    }
    return arr;
}

// 先試著把座標存在 state
this.state = {
  history: [
    {
      squares: this.createArr(19)
    }
  ],
  xIsNext: true,
  stepNumber: 0
};

歷經一番波折印出:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

發現這樣好像就滿 OK 的,但每個 square 應該都要有以下資訊:

  • position :[x, y]
  • value : null || black || white

產生棋面陣列

所以把剛剛產生陣列的函式 createArr 改寫,幫每個 square 加上屬性:

createArr = (num) => {
    let arr = [];
    for (let i = 0; i <= num; i += 1) {
      for (let j = 0; j <= num; j += 1) {
        arr.push({
          value: null,
          position: [i, j]
        });
      }
    }
    return arr;
}

點擊下棋

接下來就好處理了,把 UI 的 position 換成 value,加上 handleClick() 將 square 的 value 改掉


勝負邏輯判定

沒有頭緒,決定先考慮橫排就好

因為目前 state 的資料格式無法查找座標,只能找 squares[index],不能用 squares[x][y]

  • 或者可以相反? 用 [x, y] 去推導出 index ?
  • 例如:[0][0] => 0 , [0][1] => 1, [1],[0] => 20
  • 那就寫個函式可以互相轉換 toArr(), toIndex()

while ( credit >=4 || end >= 2 )

    1. 往 x 軸上找
  • 有: credit++
    • 繼續往 x 軸上找
    • 有: credit++
      • 繼續往 x 軸上找
        • 有: credit++
          • 繼續往 x 軸上找
            • 有: credit++ => 勝利
            • 沒有: end++ => 跳到步驟 2
          • 沒有: end++ => 跳到步驟 2
        • 沒有: end++ => 跳到步驟 2
    • 沒有: end++ => 跳到步驟 2
  • 沒有: end++
      1. 往 x 軸下找
    • 有: credit++
      • 沒有: end++ => 結束
    • 沒有: end++ => 結束

往橫向找

總之寫想半天,把往左右 ( 橫向 ) 的方向判定完畢:

一但碰到不符合的 value 就往反方向找:findRow()

跑 while 迴圈,直到 credit >= 4end >=2 就跳出:

  • (1)
  • 往 target 右邊找
    • 是 => credit++ => 繼續往右找
      • 是 => credit++ => 繼續往右找
      • 不是 => end++ => 跳到步驟 2
    • (2)
    • 不是 => end++ => 換往左找
      • 是 => credit++ => 繼續往左找
      • 不是 => end++ => 結束

簡單來說就是:

  • 一樣 value => credit++
  • 不一樣 => end++( 圖片上的藍、綠色塊 )

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

完整程式碼如下

  console.log('==== 棋子:', squares[i].value, squares[i].position, ' ====');

  // 計算步數 & 方向
  function countStep(n, init) {
    if (n === 0) {
      n = init ? 1 : -1;
      init = init ? !init : init; // => 一旦初始化就設成 false
    } else {
      n = n > 0 ? n + 1 : n - 1;
    }
    console.log(n >= 0 ? '往右找: ' : '往左找: ', n);
    return [n, init];
  }

  // 往橫向找
  function findRow() {
    let n = 0;
    let credit = 0;
    let end = 0;
    let init = true;
    while (end < 2 && credit < 5) {

      [n, init] = countStep(n, init);

      if (squares[i + n] && squares[i].value === squares[i + n].value) {
        credit++;
      } else {
        end++;
        console.log('碰到牆: ', squares[i+n].position);
      }

      // 碰到牆往反方向找
      if (end >= 1 && n >= 1) n = 0;
    }

    console.log('==> 跳出');
    return credit;
  }

  // 檢查結果
  function checkResult(credit) {
    if (credit < 4) return null;
    if (squares[i].value === 'X') {
      console.log('《 黑贏了 》');
    } else {
      console.log('《 白贏了 》');
    }
  };
  checkResult(findRow());

接著把橫向判斷改成直向

直覺應該不會太難,因為我的棋面的陣列只有一維,所以往左右找只要 +1-1 比較簡單,改成垂直查找只要再乘上 20 就好:

  • 往右找: +1
  • 往左找: -1
  • 往上找: -20
  • 往下找: +20

把 findRow 變成可以用參數改變方向:

checkResult(findStraight('row')); // => 往左右檢查
checkResult(findStraight('column')); // => 往上下檢查

目前完整程式碼

其實最大的修改是計算步數的函式 countStep

( 程式碼還很亂,因為放了很多方便 debug 的程式碼,但目前算堪用 )

  console.log('==== 棋子:', squares[i].value, squares[i].position, ' ====');

  // 計算步數 & 方向
  function countStep(n, init, type) {
    if (n === 0) {
      if (type === 'row') n = init ? 1 : -1;
      if (type === 'column') n = init ? 20 : -20;
      init = init ? !init : init; // => 一旦初始化就設成 false
      return [n, init];
    }
    if (type === 'column') {
      n = n > 0 ? n / 20 + 1 : n / 20 - 1;
      n *= 20;
      console.log(n > 0 ? '往下找: ' : '往上找: ', n);
    } else {
      n = n > 0 ? n + 1 : n - 1;
      console.log(n > 0 ? '往右找: ' : '往左找: ', n);
    }
    return [n, init];
  }

  // 往橫、直向找
  function findStraight(type) {
    let n = 0;
    let credit = 0;
    let end = 0;
    let init = true;
    while (end < 2 && credit < 5) {

      [n, init] = countStep(n, init, type);

      const targetSquare = squares[i + n];

      if (targetSquare && squares[i].value === targetSquare.value) {
        credit++;
      } else {
        end++;
        if (targetSquare) {
          console.log('碰到牆: ', targetSquare && targetSquare.position);
        } else {
          console.log('碰到不在目標上的牆');
        }
      }

      // 碰到牆往反方向找
      if (end >= 1 && n >= 1) n = 0;
    }

    console.log('==> 跳出,結束', type);
    return credit;
  }

  // 檢查結果
  function checkResult(credit) {
    if (credit < 4) return null;
    if (squares[i].value === 'X') {
      console.log('《 黑贏了 》');
    } else {
      console.log('《 白贏了 》');
    }
  };
  
  checkResult(findStraight('row'));
  checkResult(findStraight('column'));

最後再來處理交叉斜線

處理最棘手的交叉線,先來看 [index] 要怎麼換算,不知道怎麼算就列出來:

  • 右斜線 /
    • 往右上找:-19-38
      • 往右找: +1 + 往上找: -20
    • 往左下找:+19+38
      • 往左找: -1 + 往下找: +20
  • 左斜線 \
    • 往左上找:-21-42
      • 往左找: -1 + 往上找: -20
    • 往右下找:+21+42
      • 往右找: +1 + 往下找: +20

寫出來感覺就不難了,規則跟剛剛的「 垂直查找 」類似,只要在剛剛的基礎再做變化就行:

  • 垂直線 | => 右斜線 /
    • 往上找: -20
      • +1 => 變右上角 -19
    • 往下找: +20
      • -1 => 變左下角 +19
  • 垂直線 | => 左斜線 \
    • 往上找: -20
      • -1 => 變左上角 -21
    • 往下找: +20
      • +1 => 變右下角 +21

我解題的想法是,問題越簡單越好,所以先土法煉鋼的寫完兩條斜線,一樣是改 countStep() 函式

function countStep(n, init, type) {
    if (n === 0) {
      if (type === 'row') n = init ? 1 : -1;
      if (type === 'column') n = init ? 20 : -20;
      if (type === 'slash') n = init ? 19 : -19;
      if (type === 'backslash') n = init ? 21 : -21;
      init = init ? !init : init; // => 一旦初始化就設成 false
      return [n, init];
    }
    if (type === 'column') {
      n = n > 0 ? n + 20 : n - 20;
      console.log(n > 0 ? '往下找: ' : '往上找: ', n);
    } else if (type === 'row') {
      n = n > 0 ? n + 1 : n - 1;
      console.log(n > 0 ? '往右找: ' : '往左找: ', n);
    } else if (type === 'slash') {
      n = n > 0 ? n + 19 : n - 19;
      console.log(n > 0 ? '往右下找: ' : '往左上找: ', n);
    } else if (type === 'backslash') {
      n = n > 0 ? n + 21 : n - 21;
      console.log(n > 0 ? '往右下找: ' : '往左上找: ', n);
    }
    return [n, init];
  }
  
// 調用時  
checkResult(findStraight('row'));
checkResult(findStraight('column'));
checkResult(findStraight('slash'));
checkResult(findStraight('backslash'));

驚人發現: 寫完就發現規則都一樣,其實只要改初始值就好!

  • 寫一個 getStartPosition() 把各方向的初始值定義好:
function getStartPosition(type) {
    switch (type) {
      case 'row':
        return [1, -1];
      case 'column':
        return [20, -20];
      case 'slash':
        return [19, -19];
      case 'backslash':
        return [21, -21];
    }
}
  • 修改土炮 countStep() 計步函式,整個簡潔許多:
function countStep(n, init, type) {
    const [startForward, startBack] = getStartPosition(type);
    // 第一次查找,不同方向用指定初始值
    if (n === 0) {
      n = init ? startForward : startBack;
      init = init ? !init : init;
      return [n, init];
    }

    // 之後的查找位置,遞增或遞減初始值
    n = n > 0 ? n + startForward : n + startBack;
    return [n, init];
  }

結束判定

再來就把檢查判定改過一輪就行了,本來是這樣的:

// 檢查結果
function checkResult(credit) {
    if (credit < 4) return null;
    if (squares[i].value === 'X') {
      console.log('《 黑贏了 》');
    } else {
      console.log('《 白贏了 》');
    }
};

checkResult(findStraight('row'));
checkResult(findStraight('column'));
checkResult(findStraight('slash'));
checkResult(findStraight('backslash'));

改成某方向出現贏家就直接 return,不用再去比較其他方向。

// 檢查結果
function checkResult(credit) {
if (credit < 4) return null;
return (target.value === 'X') ? 'X' : 'O';
};

const direction = ['row', 'column', 'slash', 'backslash'];

// 從 row 開始找,有贏家就立刻回傳
const result = direction.find(type => checkResult(findStraight(type))); 
return result;

完成勝負判斷,最後整理完的程式碼

把最難寫的勝負判斷寫完了!!

過程真是艱辛,雖然用的方法都很土炮,且還有很多優化空間,總之能完成很開心,也深深感受到寫程式要化繁為簡的必要性。

function calculateWinner(squares, i) {
  const target = squares[i];

  // => 初始查找位置
  function getStartPosition(type) {
    switch (type) {
      case 'row':
        return [1, -1];
      case 'column':
        return [20, -20];
      case 'slash':
        return [19, -19];
      case 'backslash':
        return [21, -21];
    }
  }

  // => 計算查找步數
  function countStep(n, init, type) {
    const [startForward, startBack] = getStartPosition(type);
    // 第一次查找,用初始值
    if (n === 0) {
      n = init ? startForward : startBack;
      init = init ? !init : init;
      return [n, init];
    }

    // 之後的查找位置,遞增或遞減初始值
    n > 0 ? n += startForward : n += startBack;
    return [n, init];
  }

  // => 查找該方向有多少同樣的棋子
  function countCredit(type, init, n, credit, end) {
    while (end < 2 && credit < 5) {
      [n, init] = countStep(n, init, type);
      const targetSquare = squares[i + n];

      if (targetSquare && squares[i].value === targetSquare.value) {
        credit++;
      } else {
        end++;
      }
      // 碰到牆就往反方向找
      if (end >= 1 && n >= 1) n = 0;
    }

    return credit;
  }

  // => 檢查結果
  function getResult(credit) {
    if (credit < 4) return null;
    return (target.value === 'X') ? 'X' : 'O';
  };

  const direction = ['row', 'column', 'slash', 'backslash'];
  const result = direction.find(type => (
    getResult(countCredit(type, true, 0, 0, 0)))
  );
  
  return result;
}

其他修改

☞ 簡化 state 結構

寫一寫發現 state 的 squares 根本不用存位置資訊,所以把原本的物件格式改成 value 字串

// 本來存在 state 的 squares 格式長這樣
squares : [
{
    position : [0, 0],
    value: 'X'
},
{
    position : [0, 1],
    value: null
}, ....]

// 改成這樣
squares : ['X', null, null, ...]

☞ 把寫死的資料都變成變數控制:

this.rowNum = 15; // => 幾路棋
this.rowSize = 30; // => 棋盤格尺寸 (px)

控制幾路棋的參數 this.rowNum 滿多地方會用到就不貼了,另外一個就是用 this.rowSize 控制CSS,直接寫在 inline-style 上

// => 整個盤面寬度
<div className="board-row" style={{ width: ((rowSize - 1) * rowNum) + 'px' }}>
    {
      squares.map((item, index) => {
        return this.renderSquare(item, index, rowSize);
      })
    }
</div>


// => 棋盤格
const style = {
    width: size + 'px',
    height: size + 'px',
    lineHeight: size + 'px',
}
return (
    <button className="square" onClick={onClick} style={style}>
      {value}
    </button>
);