Áp dụng mô hình Container Component, Presenter Component và Custom Hooks để xây dựng một game cờ caro.

Đầu tiên là cấu trúc thư mục:

src/
├── components/
│ ├── presenter/
│ │ └── Board.tsx # Presenter Component (chỉ hiển thị)
│ └── container/
│ └── GameContainer.tsx # Container Component (chứa logic)
├── hooks/
│ └── useGame.ts # Custom Hook (quản  toàn bộ state & logic game)
├── types/
│ └── game.ts # Các type chung
├── App.tsx # Entry point của app
└── main.tsx # Bootstrap React

Định nghĩa kiểu dữ liệu cho game cờ caro (types/game.ts)

export type Player = 'X' | 'O' | null;

export interface GameState {
  board: Player[][];
  currentPlayer: Player;
  winner: Player;
  isDraw: boolean;
}

Tạo Custom Hook chứa logic (hooks/use-game.ts)

import { useState, useEffect } from "react";
import type { GameState, Player } from "../types/game";

const BOARD_SIZE = 15; // Kích thước bàn cờ 15x15
const WIN_COUNT = 5; // Thắng khi có 5 quân liên tiếp

const INITIAL_BOARD: Player[][] = Array(BOARD_SIZE)
  .fill(null)
  .map(() => Array(BOARD_SIZE).fill(null));

const STORAGE_KEY = "caro_game_state";

export const useGame = () => {
  const [gameState, setGameState] = useState<GameState>({
    board: INITIAL_BOARD,
    currentPlayer: "X",
    winner: null,
    isDraw: false,
  });

  const [history, setHistory] = useState<GameState[]>([]);
  const [loading, setLoading] = useState(true);

  // Load game khi mount
  useEffect(() => {
    const loadGame = async () => {
      try {
        const saved = localStorage.getItem(STORAGE_KEY);
        if (saved) {
          const parsed = JSON.parse(saved);
          
          if (
            parsed.gameState?.board &&
            parsed.gameState.board.length === BOARD_SIZE
          ) {
            setGameState(parsed.gameState);
            setHistory(parsed.history || []);
          }
        }
      } catch (error) {
        console.error("Error loading game:", error);
      } finally {
        setLoading(false);
      }
    };

    loadGame();
  }, []);

  // Lưu game mỗi khi thay đổi
  useEffect(() => {
    if (!loading) {
      localStorage.setItem(STORAGE_KEY, JSON.stringify({ gameState, history }));
    }
  }, [gameState, history, loading]);

  /**
   * Xử lý khi người chơi click vào 1 ô trên bàn cờ
   */

  const makeMove = (row: number, col: number) => {
    if (gameState.board[row][col] || gameState.winner || gameState.isDraw)
      return;

    setHistory((prev) => [...prev, gameState]);

    const newBoard: Player[][] = gameState.board.map((r) => [...r]);
    newBoard[row][col] = gameState.currentPlayer;

    const winner = checkWinner(newBoard, row, col);
    const isDraw = !winner && newBoard.flat().every((cell) => cell !== null);

    setGameState({
      board: newBoard,
      currentPlayer: gameState.currentPlayer === "X" ? "O" : "X",
      winner,
      isDraw,
    });
  };

  /**
   * Undo nước đi gần nhất
   */
  const undoMove = () => {
    if (history.length === 0) return;

    const prevState = history[history.length - 1];
    setHistory((prev) => prev.slice(0, -1));
    setGameState(prevState);
  };

  /**
   * Reset toàn bộ game về trạng thái ban đầu
   */
  const resetGame = () => {
    setGameState({
      board: INITIAL_BOARD,
      currentPlayer: "X",
      winner: null,
      isDraw: false,
    });
    setHistory([]);
  };

  return {
    ...gameState,
    loading,
    makeMove,
    resetGame,
    undoMove,
    canUndo: history.length > 0,
  };
};

/**
 * Kiểm tra người chơi hiện tại có thắng hay không
 */
function checkWinner(
  board: Player[][],
  row: number,
  col: number
): Player | null {
  const player = board[row][col];
  if (!player) return null;

  const directions = [
    [0, 1],   // ngang
    [1, 0],   // dọc
    [1, 1],   // chéo \
    [1, -1],  // chéo /
  ];

  for (const [dr, dc] of directions) {
    let count = 1;

    // Đếm về phía trước
    let r = row + dr;
    let c = col + dc;
    while (
      r >= 0 &&
      r < board.length &&
      c >= 0 &&
      c < board[0].length &&
      board[r][c] === player
    ) {
      count++;
      r += dr;
      c += dc;
    }

    // Kiểm tra đầu phía trước có bị chặn không
    const blockedForward =
      r < 0 ||
      r >= board.length ||
      c < 0 ||
      c >= board[0].length ||
      board[r][c] !== null;

    // Đếm về phía sau
    r = row - dr;
    c = col - dc;
    while (
      r >= 0 &&
      r < board.length &&
      c >= 0 &&
      c < board[0].length &&
      board[r][c] === player
    ) {
      count++;
      r -= dr;
      c -= dc;
    }

    // Kiểm tra đầu phía sau có bị chặn không
    const blockedBackward =
      r < 0 ||
      r >= board.length ||
      c < 0 ||
      c >= board[0].length ||
      board[r][c] !== null;

    if (
      count === WIN_COUNT &&
      !(blockedForward && blockedBackward)
    ) {
      return player;
    }
  }

  return null;
}

Tạo Presentational Component – Chỉ lo hiển thị (components/presenter/Board.tsx)

import type { Player } from "../../types";

interface BoardProps {
  board: Player[][];
  onCellClick: (row: number, col: number) => void;
  winner: Player;
  isDraw: boolean;
  currentPlayer: Player;
  loading: boolean;
}

export const Board = ({
  board,
  onCellClick,
  winner,
  isDraw,
  currentPlayer,
  loading,
}: BoardProps) => {
  if (loading) {
    return (
      <div className="flex items-center justify-center h-64">
        <p className="text-xl">Đang tải ván cờ...</p>
      </div>
    );
  }

  return (
    <div className="text-center">
      <div className="mb-6 text-2xl font-bold">
        {winner ? (
          <span className="text-green-600">Người thắng: {winner}</span>
        ) : isDraw ? (
          <span className="text-yellow-600">Hòa!</span>
        ) : (
          <span>Tới lượt: <span className="text-green-500">{currentPlayer}</span></span>
        )}
      </div>

      <div className="inline-block border border-gray-900 rounded-2xl shadow-2xl bg-amber-50 overflow-auto">
        <div className="flex flex-col">
          {board.map((row, rowIndex) => (
            <div
              key={rowIndex}
              className="flex flex-nowrap"
              style={{ width: "fit-content" }}
            >
              {row.map((cell, colIndex) => (
                <button
                  key={`${rowIndex}-${colIndex}`}
                  className="
                    w-10 h-10 
                    sm:w-6 sm:h-6 
                    md:w-8 md:h-8 
                    lg:w-8 lg:h-w-8  
                    border border-gray-600 
                    bg-white 
                    text-xl sm:text-2xl md:text-3xl font-bold
                    flex items-center justify-center
                    hover:bg-gray-100 active:bg-gray-200 
                    transition-all duration-150
                    disabled:cursor-not-allowed
                  "
                  onClick={() => onCellClick(rowIndex, colIndex)}
                  disabled={!!winner || isDraw || !!cell}
                >
                  {cell === "X" && (
                    <span className="text-blue-600 drop-shadow">X</span>
                  )}
                  {cell === "O" && (
                    <span className="text-red-600 drop-shadow">O</span>
                  )}
                </button>
              ))}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

Tạo Container Component – Nơi kết nối logic và UI  (components/container/GameContainer.tsx)

import { useGame } from "../../hooks/use-game";
import { Board } from "../presenter/Board";

export const GameContainer = () => {
  const { board, currentPlayer, winner, isDraw, loading, undoMove, canUndo, makeMove, resetGame} = useGame();

  return (
    <div className="bg-home min-h-screen bg-gradient-to-br from-slate-200 to-slate-300 flex items-center justify-center">
      <div className="bg-white rounded-xl shadow-2xl px-10 py-4">
        <h1 className="text-4xl font-bold text-center mb-4 bg-gradient-to-r from-orange-300 via-orange-500 to-red-700 bg-clip-text text-transparent">
          Game Cờ Caro X-O
        </h1>

        <Board
          board={board}
          currentPlayer={currentPlayer}
          winner={winner}
          isDraw={isDraw}
          loading={loading}
          onCellClick={makeMove}
        />

        <div className="mt-2 text-center flex gap-2 justify-center">
          {!winner && (
            <button
              onClick={undoMove}
              disabled={!canUndo}
              className=" px-3 py-1 rounded-lg font-semibold bg-gradient-to-r from-orange-600 to-pink-500 text-white disabled:opacity-1 disabled:cursor-not-allowed hover:scale-105 transition"
            >
              Undo
            </button>
          )}

          <button
            onClick={resetGame}
            className=" px-3 py-1 rounded-lg font-semibold bg-gradient-to-r from-emerald-400 to-teal-500 text-white disabled:opacity-40 disabled:cursor-not-allowed hover:scale-105 transition"
          >
            Chơi lại
          </button>
        </div>
      </div>
    </div>
  );
};

Kết nối vào App (App.tsx)

import { GameContainer } from './components/container/GameContainer';

function App() {
  return <GameContainer />;
}

export default App;

Và khi run game ta sẽ được game như này

Lợi ích của kiến trúc này

  • Tách biệt rõ ràng: logic tách khỏi UI
  • Dễ test: test logic và UI độc lập
  • Tái sử dụng: Presentational có thể dùng với logic khác
  • Dễ bảo trì: sửa logic không ảnh hưởng UI và ngược lại
  • Code dễ đọc: mỗi file có trách nhiệm rõ ràng

    By admin