前にQiitaに投稿した記事を自分のブログ用に再アップしました。

はじめに

こんにちは、皆さんはボードゲームはお好きでしょうか?
この肌寒い季節、友人と集まり室内ですることと言ったらお酒を片手に語り合ったり、映画を見たり、ボードゲームをしたり、、、、
ボードゲームの中でも皆さんがよく興じるのはオセロではないでしょうか?
僕はオセロを頻繁にするわけではないですが、ボードゲームの中ではオセロが一番好きです。

なぜならプログラミングで実装しやすいからです。
将棋やチェスを実装しようとしたら結構覚悟が必要になりそうですが、オセロは石をひっくり返せばいいだけなので簡単ですね!

……ということで実装してみることにしました。

要件定義

ボードのサイズ:
8×8。

初期配置:
ゲーム開始時、ボードの中央に2×2の四角を形成するように、黒と白の石が交互に配置される。

プレイヤーと石の色:
プレイヤーは二人で、一方は黒い石を使用し、もう一方は白い石を使用する。

ターン制:
プレイヤーは交互にターンを取る。

石を置くルール:
プレイヤーは自分のターンに、自分の色の石をボード上の空きマスに置く。ただし、置く石によって相手の石を少なくとも1つ挟むことができる場所にのみ置くことができる。

石を挟む:
石を置くことで、自分の石と別の自分の石の間にあるすべての相手の石を自分の色に変える(挟むことができるのは水平、垂直、斜めのいずれの方向も含む)。

移動の強制:
プレイヤーは自分のターンに石を置くことができる場所がある場合、必ず石を置かなければならない。

パスのルール:
石を置ける場所がない場合のみ、プレイヤーは自分のターンをパスできる。

ゲームの終了条件:
ボード上に空きマスがなくなるか、どちらのプレイヤーも石を置けなくなった場合、ゲームは終了する。

勝利条件:
ゲーム終了時に自分の色の石が最も多いプレイヤーが勝者。

技術選定

Next.jsですべてを完結させる。理由はNext.jsを使いたかったから、です。
AppRouter、tailwindCSS、Typescriptを使用していきます!

実装

上記の要件を満たすように以下で実装してみました。

"use client"
import { useState, useEffect  } from 'react';
import Board from '../components/Board';
import { initializeBoard, makeMove, checkWinner, canMakeMove, BoardState, Player } from '../utils/gameLogic';
import WinnerAnnouncement from '../components/WinnerAnnouncement';

export default function Home() {
  const [board, setBoard] = useState<BoardState>(initializeBoard());
  const [currentPlayer, setCurrentPlayer] = useState<Player>('black');
  const [winner, setWinner] = useState<Player | 'draw' | null>(null);

  // セルをクリックしたときの処理
  const handleCellClick = (row: number, col: number) => {
    // 石を置く
    const newBoard = makeMove(board, row, col, currentPlayer);
    // 石を置けた場合
    if (newBoard) {
      setBoard(newBoard);
      const winner = checkWinner(newBoard);
      if (winner) {
        // 勝者がいる場合、アニメーションでお知らせ
        setWinner(winner);
      } else {
        // 次のプレイヤーのターンに切り替え
        setCurrentPlayer(currentPlayer === 'black' ? 'white' : 'black');
      }
    }
  };

  const handleWinnerDismiss = () => {
    setWinner(null);
    setBoard(initializeBoard()); // ゲームをリセット
    setCurrentPlayer('black'); // 初期プレイヤーをセット
  };

  // 現在のプレイヤーが動けない場合に自動でターンをスキップする
  useEffect(() => {
    if (winner === null && !canMakeMove(board, currentPlayer)) {
      alert(`${currentPlayer.toUpperCase()}はパスします`);
      setCurrentPlayer(currentPlayer === 'black' ? 'white' : 'black');
    }
  }, [board, currentPlayer, winner]);

  return (
    <div className="container mx-auto p-4">
      <Board board={board} onCellClick={handleCellClick} />
      <p className="text-center text-lg font-bold mt-4">現在のプレイヤー: {currentPlayer.toUpperCase()}</p>
      {winner && <WinnerAnnouncement winner={winner} onDismiss={handleWinnerDismiss} />}
    </div>
  );
}
import Cell from './Cell';
import { BoardState } from '../utils/gameLogic';

type BoardProps = {
    board: BoardState;
    onCellClick: (row: number, col: number) => void;
};

// ボードを表すコンポーネント
const Board = ({ board, onCellClick }: BoardProps) => {
    return (
        <div className="grid grid-cols-8 grid-rows-8 mx-auto" style={{ width: '320px', height: '320px' }}>
            {board.map((row, rowIndex) =>
                row.map((cell, colIndex) => (
                    <Cell key={`${rowIndex}-${colIndex}`} value={cell} onClick={() => onCellClick(rowIndex, colIndex)} />
                ))
            )}
        </div>
    );
};

export default Board;


import { CellValue } from '../utils/gameLogic';

type CellProps = {
    value: CellValue;
    onClick: () => void;
};

// セルを表すコンポーネント
const Cell = ({ value, onClick }: CellProps) => {
    const cellStyle = `flex justify-center items-center w-10 h-10 border border-gray-800`;
    const stoneStyle = `rounded-full ${value === 'black' ? 'bg-black' : 'bg-white'} `;
    const hasStone = value === 'black' || value === 'white';

    return (
        <div
            onClick={onClick}
            className={`${cellStyle} bg-green-400`}
            style={{ width: '40px', height: '40px' }}
        >
        {hasStone && <div className={stoneStyle} style={{ width: '30px', height: '30px' }}></div>}
        </div>
    );
};


export default Cell;

type WinnerAnnouncementProps = {
    winner: 'black' | 'white' | 'draw';
    onDismiss: () => void;
};

// 勝者が決定した時に表示させるコンポーネント
const WinnerAnnouncement = ({ winner, onDismiss }: WinnerAnnouncementProps) => {
    const winnerText = winner === 'draw' ? '引き分け!' : `${winner.toUpperCase()} の勝利!`;

    return (
        <div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center" onClick={onDismiss}>
            <div className="animate-bounce bg-white p-10 rounded-lg shadow-xl text-3xl">
            {winnerText}
            </div>
        </div>
    );
};

export default WinnerAnnouncement;

export type CellValue = 'black' | 'white' | null;
export type BoardState = CellValue[][];
export type Player = 'black' | 'white';

// 盤面の初期化を行う関数
export const initializeBoard = (): BoardState => {
  const board: BoardState = Array(8).fill(null).map(() => Array(8).fill(null));
  // オセロの初期配置
  board[3][3] = 'white';
  board[3][4] = 'black';
  board[4][3] = 'black';
  board[4][4] = 'white';
  return board;
};

// 指定された位置に石を置く処理を行う関数
export const makeMove = (board: BoardState, row: number, col: number, player: Player): BoardState | null => {
  if (board[row][col] !== null) {
    return null; // 指定されたマスにすでに石がある場合はnullを返す
  }

  let canFlip = false; // 石を裏返せるかどうかのフラグ
  const directions = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1]]; // 8方向を表す配列
  const newBoard = board.map(row => [...row]); // 盤面のコピーを作成

  // 各方向に対してループを行う
  directions.forEach(([dx, dy]) => {
    // 現在のセルの座標からスタート
    let x = row + dx;
    let y = col + dy;
    let toFlip = []; // 裏返す石を一時的に保存する配列

    // 次のセルが盤面内にあり、かつ相手の石である間、ループを続ける
    while (x >= 0 && x < 8 && y >= 0 && y < 8 && board[x][y] === (player === 'black' ? 'white' : 'black')) {
      toFlip.push([x, y]); // 相手の石の座標をtoFlipに追加
      x += dx; // 次のセルへ移動(水平方向)
      y += dy; // 次のセルへ移動(垂直方向)
    }

    // もしループの終了時点で、次のセルが盤面内にあり、かつ現在のプレイヤーの石であれば
    if (x >= 0 && x < 8 && y >= 0 && y < 8 && board[x][y] === player && toFlip.length > 0) {
      canFlip = true; // 石を裏返すことができる
      // 裏返す石をすべて現在のプレイヤーの色に変更
      toFlip.forEach(([fx, fy]) => {
        newBoard[fx][fy] = player; // 反転可能な石を反転させる
      });
    }
  });

  if (!canFlip) {
    return null; // 反転できる石がない場合はnullを返す
  }

  newBoard[row][col] = player; // 石を置く
  return newBoard;
};

// 盤面上の黒と白の石の数を数える関数
export const countStones = (board: BoardState): { black: number, white: number } => {
  let black = 0, white = 0;
  board.forEach(row => {
    row.forEach(cell => {
      if (cell === 'black') black++;
      if (cell === 'white') white++;
    });
  });
  return { black, white };
};

// ゲームの勝者をチェックする関数
export const checkWinner = (board: BoardState): Player | 'draw' | null => {
  const { black, white } = countStones(board);

  if (black + white === 64 || black === 0 || white === 0) { // 全てのマスが埋まったか、どちらかの石がなくなった場合
    if (black > white) return 'black';
    if (white > black) return 'white';
    return 'draw';
  }

  return null; // ゲームがまだ続いている場合はnullを返す
};

// 現在のプレイヤーが石を置ける場所があるかどうかをチェックする関数
export const canMakeMove = (board: BoardState, player: Player): boolean => {
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      if (makeMove(board, row, col, player)) {
        return true; // 石を置ける場所がある場合はtrueを返す
      }
    }
  }
  return false; // 石を置ける場所がない場合はfalseを返す
};

完成

Next.jsのプロジェクトをnpm run devで立ち上げると以下のようにオセロゲームができるはずです。

感想

今後発展させてオンライン対戦できるような仕組みを作りたいなあ…