Hướng dẫn tạo game Tic-Tac-Toe 20x20 bằng ReactJS

09 tháng 03, 2023 - 3585 lượt xem

Giới thiệu

Xin chào các bạn!
Trong bài viết này mình sẽ hướng dẫn các bạn tạo game Tic-Tac-Toe (tên dân dã là XO đó) với độ rộng 20x20.

Lý do vì sao mình viết bài này?
Đây là một trong những ví dụ điển hình của ReactJS, các bạn có thể tham khảo phần basic tại đây nha:
Tutorial

Tuy nhiên phần tutorial trên chỉ với game 3x3 đơn giản. Mình sẽ hướng dẫn các bạn tạo game với độ rộng 20x20 ngay sau đây.

Cùng tìm hiểu và thử làm theo nha!

Lưu ý là nên đọc, hiểu và tự viết code thay vì copy paste code nhé các bạn!

Tôi sẽ bỏ qua phần tạo ReactJS project, các bạn tìm hiểu và tự tạo bằng các câu lệnh nha!

Các bước tạo game

Như các bạn đã biết, React là một thư viện JavaScript, dùng để xây dựng giao diện người dùng một cách linh hoạt. Nó cho phép bạn soạn các giao diện người dùng phức tạp từ các đoạn mã nhỏ và biệt lập được gọi là “components”. Chúng ta sử dụng các components để khai báo cho React biết những gì hiển thị trên màn hình (buttons, forms,…) . Khi dữ liệu thay đổi, React sẽ cập nhật và re-render các components đó.

Vậy, khi chúng ta xây dựng nên Tic-Tac-Toe game, hình dung trong đầu sẽ có 3 components chính:

  • Square: là component hiển thị một button duy nhất.
  • Board: hiển thị 20 ô vuông.
  • Game: hiển thị một bảng tổng thể mà người dùng tương tác.

Khai báo các components

Đầu tiên, chúng ta khai báo component Square.jsx:

const Square = (props) => {
  return (
  <button
      className={props.value == "X" ? "square styleX" : "square styleO"}
      onClick={() => props.onClick()}
    >
      {props.value}
    </button>
  );
};

CSS cho square nha (App.css):

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.styleX {
  color: #345594;
  font-weight: 700;
}

.styleO {
  color: red;
  font-weight: 700;
}

.square:focus {
  outline: none;
}

Cùng tạo component Board.jsx nào:

Vì game có độ dài lớn, nên ta cần phải biểu thị vị trí bằng (i: tương ứng với trục hoành, j: tương ứng với trục tung) nhé.

Cùng theo dõi code cùng comment nha:

const Board = () => {
//Khai báo số ô vuông cần render. Ở đây, tôi mặc định độ rộng của game là 20, bạn cũng có thể tùy chỉnh theo ý mình nhé.
   const CONFIG = {
     DIMENSION: 20,
   };
// Set số square trong game
  const [squares, setSquares] = useState(
    Array(CONFIG.DIMENSION).fill(Array(CONFIG.DIMENSION).fill(null))
  );
// Định vị đến lượt X hay O 
  const [xIsNext, setXIsNext] = useState(true);

// Hàm xử lý sự kiện click vào ô
  const handleClick = (i, j) => {
  //Nếu đã có giá trị tại vị trí [i,j] tương ứng, return
    if (squares[i][j]) {
      return;
    }
   // Nếu ô còn trống, điền giá trị phụ thuộc vào lượt isNext
    let newSquares = squares.map((r) => [...r]);
    newSquares[i][j] = xIsNext ? "X" : "O";
    // Gán giá trị cho ô chỉ định
    setSquares(newSquares);
    //Chỉ định người tiếp theo
    setXIsNext(!xIsNext);
  };
  
  const renderSquare = (i, j) => {
    return <Square value={squares[i][j]} onClick={() => handleClick(i, j)} />;
  };

   // Render
  const renderTwoDimensionSquare = (i, j) => {
  //Đảm bảo việc render không bị bug hiện thêm ô trống
    let arrForLoopRow = Array(i).fill(null);
    let arrForLoopCol = Array(j).fill(null);

    return arrForLoopRow.map((e1, idx) => (
      <div className="board-row">
        {arrForLoopCol.map((e2, jdx) => renderSquare(idx, jdx))}
      </div>
    ));
  };
// Để trực quan  hơn, ta sẽ thêm text để thông báo cho người chơi biết lượt đi tiếp theo là của ai:
   let status = "Next player: " + (xIsNext ? "X" : "O");

//Return phần board game
  return (
    <div className="container">
      <div className="status">{status}</div>
      {renderTwoDimensionSquare(CONFIG.DIMENSION, CONFIG.DIMENSION)}
    </div>
  );
};

Xây dựng component cha Game.jsx:

const Game = () => {
  return (
    <div className="game">
      <div className="game-board">
        <Board />
      </div>
    </div>
  );
};

CSS một chút nhé: (App.css):

.game-board {
  margin: auto;
  margin-top: 50px;
}

.game {
  display: flex;
  flex-direction: row;
  align-items: center;
}


Giao diện hiển thị sẽ thế này:
Giờ đây, ta có thể tick vào các ô trống để hiển thị X - O

Logic hoàn thiện trò chơi

Vậy đến khi nào thì có người chiến thắng?? Cùng nhau xây dựng logic để đi tìm người chiến thắng nhé!

Ta cần xác định rằng việc tính toán để tìm người chiến thắng sẽ cho component Board.jsx đảm nhiệm, vậy nên, trong Board.jsx, ta khai báo:

//Ban đầu chưa có người chiến thắng, mặc định là null
  const [theWinner, setTheWinner] = useState(null);

// Trong hàm handleClick, ta phải sửa một chút logic: Khi đã có người chiến thắng thì sẽ không click được nữa, và setTheWinner là người chiến thắng
// Vậy nên hàm handleClick sẽ được thay thế như sau:

  const handleClick = (i, j) => {
    if (theWinner || squares[i][j]) {
      return;
    }

    let newSquares = squares.map((r) => [...r]);
    newSquares[i][j] = xIsNext ? "X" : "O";
    setSquares(newSquares);
    setXIsNext(!xIsNext);

    let whoTheWinner = //TODO: build calculateWinnerNew()
    if (whoTheWinner) {
      setTheWinner(whoTheWinner);
    }
  };

Okay, giờ việc cần làm là chúng ta sẽ phải xây dựng hàm calculateWinnerNew() để xác định người chiến thắng.

Quy tắc tìm người chiến thắng là: Người chơi nào xếp được đủ 5 ô theo chiều ngang/dọc/chéo thì người đó chiến thắng.

Vậy, trong Board.jsx, ta khai báo các hàm để tìm người chiến thắng như sau:

const isBingo = (currentPlayer, arr) => {
  let count = 0;
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] == currentPlayer) {
      count++;
      if (count == 5) {
        return true;
      }
    } else {
      count = 0;
    }
  }
  return false;
};

Để dễ hình dung, tôi sẽ đặt theo các hướng: Đông (E) - Tây (W) - Nam (S) - Bắc (N) - Đông Nam (SE) - Tây Nam (SW) - Tây Bắc (NW) - Đông Bắc (NE). Như vậy đối với mỗi vị trí được chỉ định, ta sẽ có 8 vị trí xung quanh tương ứng, biểu thị như sau:

// Nam
const directSouth = (i,j) => {
return [i+1, j]
}

// Bắc
const directNorth = (i,j) => {
return [i-1, j]
}

//Tây
const directWest = (i,j) => {
return [i, j-1]
}

//Đông
const directEast = (i,j) => {
return [i, j+1]
}

//Đông Nam
const directSouthEast = (i,j) => {
let [_i, _j] = directSouth(i,j);
return directEast(_i, _j);
}

//Tây Nam
const directSouthWest = (i,j) => {
let [_i, _j] = directSouth(i,j);
return directWest(_i, _j);
}

//Tây Bắc
const directNorthWest = (i,j) => {
let [_i, _j] = directNorth(i,j);
return directWest(_i, _j);
}

//Đông Bắc
const directNorthEast = (i,j) => {
let [_i, _j] = directNorth(i,j);
return directEast(_i, _j);
}

Sau khi xác định được vị trí các ô xung quanh 1 vị trí được chỉ định, ta cần xác định xem những ô quanh nó có giá trị giống nó hay không.

Để tìm những ô xung quanh vị trí được chỉ định, ta có hàm:

const findValueFromSquares = (squares, arrLocation) => {
return squares[arrLocation[0]][arrLocation[1]]
}

Để kiểm tra tính từ vị trí được chỉ định đến vị trí chiến thắng có bị quá lề hay không, ta có hàm:

const isValidPosition = (arrLocation) => {
return !(arrLocation[0] >= CONFIG.DIMENSION || arrLocation[1] >= CONFIG.DIMENSION || arrLocation[0] < 0 || arrLocation[1] < 0)
}

Ta sẽ thiết lập một Chain tính toán như sau, với ví dụ người chiến chơi điền đủ 5 ô theo hướng Bắc - Nam:


const buildNorthToSouthChainValue = (squares, i ,j) => {
//Khởi tạo kết quả
let result = [];
//Khới tạo vị trí chỉ định
let currentPosition = [i, j]
let currentValue = findValueFromSquares(squares, currentPosition);

// Thêm giá trị đầu tiên vào kết quả trả ra
result.push(currentValue)
for (let x = 0; x < 4; x++) {
  currentPosition = directSouth(currentPosition[0], currentPosition[1]);
  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.push(currentValue)
}

currentPosition = [i, j];
for (let y = 0; y < 4; y++) {
  currentPosition = directNorth(currentPosition[0], currentPosition[1]);

  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.unshift(currentValue)
}
return result;
}

Tương tự, ta sẽ build cái Chain còn lại:

  • Từ Tây sang Đông (đường thẳng):
const buildWestToEastChainValue = (squares, i ,j) => {
let result = [];
let currentPosition = [i, j]
let currentValue = findValueFromSquares(squares, currentPosition);

// Thêm giá trị đầu tiên vào kết quả trả ra
result.push(currentValue)
for (let x = 0; x < 4; x++) {
  currentPosition = directEast(currentPosition[0], currentPosition[1]);
  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.push(currentValue)
}

currentPosition = [i, j];
for (let y = 0; y < 4; y++) {
  currentPosition = directWest(currentPosition[0], currentPosition[1]);
  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.unshift(currentValue)
}
return result;
}
  • Từ Tây Bắc - Đông Nam (đường chéo):
const buildNorthWestToSouthEastChainValue = (squares, i ,j) => {
let result = [];
let currentPosition = [i, j]
let currentValue = findValueFromSquares(squares, currentPosition);

// Thêm giá trị đầu tiên vào kết quả trả ra
result.push(currentValue)
for (let x = 0; x < 4; x++) {
  currentPosition = directSouthEast(currentPosition[0], currentPosition[1]);
  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.push(currentValue)
}

currentPosition = [i, j];
for (let y = 0; y < 4; y++) {
  currentPosition = directNorthWest(currentPosition[0], currentPosition[1]);
  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.unshift(currentValue)
}
return result;
}
  • Từ Đông Bắc - Tây Nam (đường chéo):
const buildNorthEastToSouthWestChainValue = (squares, i ,j) => {
let result = [];
let currentPosition = [i, j]
let currentValue = findValueFromSquares(squares, currentPosition);

// Thêm giá trị đầu tiên vào kết quả trả ra
result.push(currentValue)
for (let x = 0; x < 4; x++) {
  currentPosition = directSouthWest(currentPosition[0], currentPosition[1]);
  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.push(currentValue)
}

currentPosition = [i, j];
for (let y = 0; y < 4; y++) {
  currentPosition = directNorthEast(currentPosition[0], currentPosition[1]);
  if (!isValidPosition(currentPosition)) {
    break;
  }
  currentValue = findValueFromSquares(squares, currentPosition);
  result.unshift(currentValue)
}
return result;
}

Okay, giờ chúng ta sẽ tổng hợp lại:

const builChainValue = (squares, i, j) => {
  let nToS = buildNorthToSouthChainValue(squares, i, j);
  let wToE = buildWestToEastChainValue(squares, i, j);
  let neToSw = buildNorthEastToSouthWestChainValue(squares, i, j);
  let nwToSe = buildNorthWestToSouthEastChainValue(squares, i, j);
  return [nToS, wToE, neToSw, nwToSe];
};

Kết quả return của hàm buildChainValue sẽ được sử dụng để tính toán người chiến thắng:

const calculateWinnerNew = (squares, i, j) => {
  let currentPlayer = squares[i][j];
  let chainValues = builChainValue(squares, i, j);
  for (let x = 0; x < chainValues.length; x++) {
    let arr = chainValues[x];
    if (isBingo(currentPlayer, arr)) {
      return currentPlayer;
    }
  }
  return null;
};

Vậy hàm calculateWinnerNew có thể trả ra 2 giá trị: Nếu Chain đủ 5 ô cùng giá trị, trả ra người chơi hiện tại, nếu không, kết quả trả ra null.

Quay trở lại với componet Board và hoàn thành nốt phần TODO trong hàm handleClick:

  const handleClick = (i, j) => {
    if (theWinner || squares[i][j]) {
      return;
    }

    let newSquares = squares.map((r) => [...r]);
    newSquares[i][j] = xIsNext ? "X" : "O";
    setSquares(newSquares);
    setXIsNext(!xIsNext);

    let whoTheWinner = calculateWinnerNew(newSquares, i, j);
    if (whoTheWinner) {
      setTheWinner(whoTheWinner);
    }
  };

Và lúc này, khi tìm được người chiến thắng, phần status cũng sẽ phải hiển thị thông tin người thắng cuộc, cùng chỉnh sửa một chút nha:

  let status;
  if (theWinner) {
    status = "Winner : " + theWinner;
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }

Giờ đây, giao diện sẽ thay đổi thành:

Hện giờ, status không còn là Next player nữa, thay vào đó là người thắng cuộc, đồng thời ta sẽ không thể click vào bất kỳ ô nào nữa

Restart game

Ngoài lề một chút, nếu như ta muốn restart game mà không cần F5 trang, có thể tạo 1 chiếc button và return tất cả về trạng thái mặc định.

  • Xây dựng 1 button:


  • CSS một chút:
.restart-btn button {
  background-color: #FFFFFF;
    border: 1px solid #222222;
    border-radius: 8px;
    box-sizing: border-box;
    color: #222222;
    cursor: pointer;
    display: inline-block;
    font-family: Circular,-apple-system,BlinkMacSystemFont,Roboto,"Helvetica Neue",sans-serif;
    font-size: 16px;
    font-weight: 600;
    line-height: 20px;
    margin: 0;
    outline: none;
    padding: 13px 23px;
    position: relative;
    text-align: center;
    text-decoration: none;
    touch-action: manipulation;
    transition: box-shadow .2s,-ms-transform .1s,-webkit-transform .1s,transform .1s;
    user-select: none;
    -webkit-user-select: none;
    width: auto;
}
  • Xây dựng hàm handleRestart:
const handleRestart = () => {
  setSquares(Array(CONFIG.DIMENSION).fill(Array(CONFIG.DIMENSION).fill(null)));
  setXIsNext(true)
  setTheWinner(null)
}

Kết luận

Trên đây là hướng dẫn làm Tic-Tac-Toe game với custom DIMENSION của mình. Các bạn có nhận xét hay ý kiến đóng góp gì thì hãy để lại comment cho mình nha.
Cảm ơn các bạn đã theo dõi bài viết.

Bình luận

avatar
Trịnh Minh Cường 2023-03-10 08:06:27.689636 +0000 UTC

Tác giả nên đẩy ứng dụng chạy được lên CodePen.io để người đọc có thể trải nghiệm ứng dụng thực tế luôn.

Avatar
* Vui lòng trước khi bình luận.
Ảnh đại diện
  +1 Thích
+1