あめがえるのITブログ

頑張りすぎない。ほどほどに頑張るブログ。

【AI】cursor+gpt-4.1でブラウザで動作するオセロゲームを作ってみた


cursor+gpt-4.1でオセロゲームを作ってみた

準備

下記からcursorをダウンロードしインストール
www.cursor.com

実践!

cursorの右ウインドウがAIとの会話窓のため、ここからいろいろ指示をする。
「ブラウザで動作するオセロゲームを作りたい」
「CPU対戦機能を加えて」
などなど


ものの15分程度で下記が完成!
※いっさいコードには触っていない・・・



コードはこちら
※コードを同じフォルダに保存しindex.htmlをブラウザで表示すれば遊べます!
index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>オセロゲーム</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>オセロゲーム</h1>
  <div id="choose-side">
    <div style="margin-bottom:10px;">
      <span>CPUの強さ:</span>
      <label><input type="radio" name="cpu-level" value="strong" checked><img src="dragon.svg" alt="竜" style="height:1.5em;vertical-align:middle;"></label>
      <label><input type="radio" name="cpu-level" value="normal"><img src="dog.svg" alt="犬" style="height:1.5em;vertical-align:middle;"></label>
      <label><input type="radio" name="cpu-level" value="weak"><img src="mouse.svg" alt="ねずみ" style="height:1.5em;vertical-align:middle;">ねずみ</label>
    </div>
    <button id="choose-black">先手(黒)で始める</button>
    <button id="choose-white">後手(白)で始める</button>
  </div>
  <div id="cpu-image-container" style="display:none; text-align:center; margin-bottom:10px;"></div>
  <div id="board" style="display:none;"></div>
  <div style="text-align:center;">
    <button id="back-to-title" style="display:none; margin-top:20px;">最初の画面に戻る</button>
    <button id="reset-game" style="display:none; margin-top:20px; margin-left:10px;">リセット</button>
  </div>
  <script src="main.js"></script>
</body>
</html> 

main.js

const BOARD_SIZE = 8;
const boardElement = document.getElementById('board');
const chooseSideDiv = document.getElementById('choose-side');
const chooseBlackBtn = document.getElementById('choose-black');
const chooseWhiteBtn = document.getElementById('choose-white');
const backToTitleBtn = document.getElementById('back-to-title');
const resetGameBtn = document.getElementById('reset-game');
const cpuLevelRadios = document.getElementsByName('cpu-level');
const cpuImageContainer = document.getElementById('cpu-image-container');

// メッセージ表示用divを追加
let messageDiv = document.getElementById('message');
if (!messageDiv) {
  messageDiv = document.createElement('div');
  messageDiv.id = 'message';
  messageDiv.style.marginTop = '20px';
  messageDiv.style.fontWeight = 'bold';
  boardElement.parentNode.appendChild(messageDiv);
}

// 0:空, 1:黒, 2:白
let board;
let currentPlayer; // 1:黒(人/CPU), 2:白(人/CPU)
let gameOver;
let playerColor = 1; // 1:黒, 2:白
let cpuLevel = 'strong'; // 'strong', 'normal', 'weak'

function getSelectedCpuLevel() {
  for (const radio of cpuLevelRadios) {
    if (radio.checked) return radio.value;
  }
  return 'strong';
}

function initGame() {
  board = Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(0));
  board[3][3] = 2;
  board[3][4] = 1;
  board[4][3] = 1;
  board[4][4] = 2;
  currentPlayer = 1;
  gameOver = false;
}

chooseBlackBtn.onclick = () => {
  playerColor = 1;
  cpuLevel = getSelectedCpuLevel();
  chooseSideDiv.style.display = 'none';
  boardElement.style.display = '';
  backToTitleBtn.style.display = '';
  resetGameBtn.style.display = '';
  showCpuImage();
  initGame();
  renderBoard();
  if (currentPlayer === 2 && !gameOver) setTimeout(cpuMove, 700);
};
chooseWhiteBtn.onclick = () => {
  playerColor = 2;
  cpuLevel = getSelectedCpuLevel();
  chooseSideDiv.style.display = 'none';
  boardElement.style.display = '';
  backToTitleBtn.style.display = '';
  resetGameBtn.style.display = '';
  showCpuImage();
  initGame();
  renderBoard();
  if (currentPlayer === 1 && !gameOver) setTimeout(cpuMove, 700);
};

backToTitleBtn.onclick = () => {
  chooseSideDiv.style.display = '';
  boardElement.style.display = 'none';
  backToTitleBtn.style.display = 'none';
  resetGameBtn.style.display = 'none';
  cpuImageContainer.style.display = 'none';
  cpuImageContainer.innerHTML = '';
  messageDiv.textContent = '';
};

resetGameBtn.onclick = () => {
  chooseSideDiv.style.display = 'none';
  boardElement.style.display = '';
  backToTitleBtn.style.display = '';
  resetGameBtn.style.display = '';
  showCpuImage();
  initGame();
  // 先手・後手の設定はそのまま
  if (playerColor === 2 && currentPlayer === 1) {
    renderBoard();
    setTimeout(cpuMove, 700);
  } else if (playerColor === 1 && currentPlayer === 2) {
    renderBoard();
    setTimeout(cpuMove, 700);
  } else {
    renderBoard();
  }
};

function renderBoard() {
  boardElement.innerHTML = '';
  for (let y = 0; y < BOARD_SIZE; y++) {
    const row = document.createElement('div');
    for (let x = 0; x < BOARD_SIZE; x++) {
      const cell = document.createElement('div');
      cell.className = 'cell';
      cell.dataset.x = x;
      cell.dataset.y = y;
      if (board[y][x] === 1) {
        const disk = document.createElement('div');
        disk.className = 'disk black';
        cell.appendChild(disk);
      } else if (board[y][x] === 2) {
        const disk = document.createElement('div');
        disk.className = 'disk white';
        cell.appendChild(disk);
      }
      cell.addEventListener('click', onCellClick);
      row.appendChild(cell);
    }
    boardElement.appendChild(row);
  }
  updateMessage();
}

function onCellClick(e) {
  if (gameOver) return;
  if (currentPlayer !== playerColor) return; // プレイヤーの手番のみクリック可
  const x = parseInt(e.currentTarget.dataset.x);
  const y = parseInt(e.currentTarget.dataset.y);
  if (board[y][x] !== 0) return;
  const flipped = getFlippableDisks(x, y, currentPlayer);
  if (flipped.length === 0) return;
  board[y][x] = currentPlayer;
  for (const [fx, fy] of flipped) {
    board[fy][fx] = currentPlayer;
  }
  nextTurn();
}

function nextTurn() {
  currentPlayer = currentPlayer === 1 ? 2 : 1;
  if (getAllValidMoves(currentPlayer).length === 0) {
    // パス判定
    if (getAllValidMoves(currentPlayer === 1 ? 2 : 1).length === 0) {
      // 両者とも置けない→終了
      gameOver = true;
      renderBoard();
      return;
    } else {
      // 相手も置けない→パス
      currentPlayer = currentPlayer === 1 ? 2 : 1;
      showPassMessage();
      renderBoard();
      if (currentPlayer !== playerColor && !gameOver) setTimeout(cpuMove, 700);
      return;
    }
  }
  renderBoard();
  if (currentPlayer !== playerColor && !gameOver) setTimeout(cpuMove, 700);
}

function cpuMove() {
  // CPUの手番
  const moves = getAllValidMoves(currentPlayer);
  if (moves.length === 0) return;
  let move;
  if (cpuLevel === 'weak') {
    // 弱:ランダム
    move = moves[Math.floor(Math.random() * moves.length)];
  } else if (cpuLevel === 'normal') {
    // 中:ひっくり返せる石が多い手を選ぶ
    let max = -1;
    let bestMoves = [];
    for (const [x, y] of moves) {
      const flipped = getFlippableDisks(x, y, currentPlayer).length;
      if (flipped > max) {
        max = flipped;
        bestMoves = [[x, y]];
      } else if (flipped === max) {
        bestMoves.push([x, y]);
      }
    }
    move = bestMoves[Math.floor(Math.random() * bestMoves.length)];
  } else {
    // 強:角優先、なければ中と同じ
    const corners = moves.filter(([x, y]) =>
      (x === 0 && y === 0) || (x === 0 && y === BOARD_SIZE - 1) ||
      (x === BOARD_SIZE - 1 && y === 0) || (x === BOARD_SIZE - 1 && y === BOARD_SIZE - 1)
    );
    if (corners.length > 0) {
      move = corners[Math.floor(Math.random() * corners.length)];
    } else {
      // 角がなければ中と同じ
      let max = -1;
      let bestMoves = [];
      for (const [x, y] of moves) {
        const flipped = getFlippableDisks(x, y, currentPlayer).length;
        if (flipped > max) {
          max = flipped;
          bestMoves = [[x, y]];
        } else if (flipped === max) {
          bestMoves.push([x, y]);
        }
      }
      move = bestMoves[Math.floor(Math.random() * bestMoves.length)];
    }
  }
  const [x, y] = move;
  const flipped = getFlippableDisks(x, y, currentPlayer);
  board[y][x] = currentPlayer;
  for (const [fx, fy] of flipped) {
    board[fy][fx] = currentPlayer;
  }
  nextTurn();
}

function showPassMessage() {
  messageDiv.textContent = (currentPlayer === 1 ? '白' : '黒') + 'は置ける場所がないのでパスします';
  setTimeout(updateMessage, 1200);
}

function updateMessage() {
  if (gameOver) {
    const [black, white] = countDisks();
    let result = '';
    if (black > white) result = '黒の勝ち!';
    else if (white > black) result = '白の勝ち!';
    else result = '引き分け!';
    messageDiv.textContent = `ゲーム終了 黒:${black} 白:${white}${result}`;
  } else {
    if (currentPlayer === playerColor) {
      messageDiv.textContent = (playerColor === 1 ? '黒(あなた)' : '白(あなた)') + 'の番です';
    } else {
      messageDiv.textContent = (playerColor === 1 ? '白(CPU)' : '黒(CPU)') + 'の番です';
    }
  }
}

// 8方向のベクトル
const directions = [
  [0, -1], [1, -1], [1, 0], [1, 1],
  [0, 1], [-1, 1], [-1, 0], [-1, -1]
];

function getFlippableDisks(x, y, player) {
  const opponent = player === 1 ? 2 : 1;
  let flippable = [];
  for (const [dx, dy] of directions) {
    let nx = x + dx;
    let ny = y + dy;
    let disks = [];
    while (
      nx >= 0 && nx < BOARD_SIZE &&
      ny >= 0 && ny < BOARD_SIZE &&
      board[ny][nx] === opponent
    ) {
      disks.push([nx, ny]);
      nx += dx;
      ny += dy;
    }
    if (
      disks.length > 0 &&
      nx >= 0 && nx < BOARD_SIZE &&
      ny >= 0 && ny < BOARD_SIZE &&
      board[ny][nx] === player
    ) {
      flippable = flippable.concat(disks);
    }
  }
  return flippable;
}

function getAllValidMoves(player) {
  let moves = [];
  for (let y = 0; y < BOARD_SIZE; y++) {
    for (let x = 0; x < BOARD_SIZE; x++) {
      if (board[y][x] === 0 && getFlippableDisks(x, y, player).length > 0) {
        moves.push([x, y]);
      }
    }
  }
  return moves;
}

function countDisks() {
  let black = 0, white = 0;
  for (let y = 0; y < BOARD_SIZE; y++) {
    for (let x = 0; x < BOARD_SIZE; x++) {
      if (board[y][x] === 1) black++;
      if (board[y][x] === 2) white++;
    }
  }
  return [black, white];
}

function showCpuImage() {
  let img = '';
  if (cpuLevel === 'strong') {
    img = '<img src="dragon.svg" alt="竜" style="height:48px;vertical-align:middle;"> <span style="font-size:1.2em;">竜</span>';
  } else if (cpuLevel === 'normal') {
    img = '<img src="dog.svg" alt="犬" style="height:48px;vertical-align:middle;"> <span style="font-size:1.2em;">犬</span>';
  } else {
    img = '<img src="mouse.svg" alt="ねずみ" style="height:48px;vertical-align:middle;"> <span style="font-size:1.2em;">ねずみ</span>';
  }
  cpuImageContainer.innerHTML = img;
  cpuImageContainer.style.display = '';
}

// 初期状態は選択画面のみ表示
boardElement.style.display = 'none';
chooseSideDiv.style.display = '';
backToTitleBtn.style.display = 'none';
resetGameBtn.style.display = 'none';

renderBoard(); 

style.css

body {
  font-family: sans-serif;
  text-align: center;
  background: #f0f0f0;
}
#board {
  display: inline-block;
  margin-top: 20px;
}
.cell {
  width: 40px;
  height: 40px;
  background: #2e8b57;
  border: 2px solid #333;
  display: inline-block;
  vertical-align: top;
  position: relative;
}
.disk {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  position: absolute;
  top: 4px;
  left: 4px;
}
.disk.black {
  background: #111;
}
.disk.white {
  background: #fff;
  border: 1px solid #aaa;
} 

doragon.svg

<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
  <circle cx="64" cy="64" r="60" fill="#aee9f7" stroke="#333" stroke-width="4"/>
  <ellipse cx="64" cy="80" rx="32" ry="24" fill="#fff" stroke="#333" stroke-width="3"/>
  <ellipse cx="64" cy="60" rx="28" ry="24" fill="#7fdc8a" stroke="#333" stroke-width="3"/>
  <ellipse cx="54" cy="60" rx="4" ry="6" fill="#333"/>
  <ellipse cx="74" cy="60" rx="4" ry="6" fill="#333"/>
  <path d="M64 84 Q64 92 72 92" stroke="#333" stroke-width="3" fill="none"/>
  <path d="M40 40 Q32 24 48 32" stroke="#333" stroke-width="3" fill="none"/>
  <path d="M88 40 Q96 24 80 32" stroke="#333" stroke-width="3" fill="none"/>
</svg> 

dog.svg

<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
  <ellipse cx="64" cy="80" rx="36" ry="32" fill="#fff" stroke="#333" stroke-width="4"/>
  <ellipse cx="44" cy="60" rx="10" ry="16" fill="#bfa77f" stroke="#333" stroke-width="3"/>
  <ellipse cx="84" cy="60" rx="10" ry="16" fill="#bfa77f" stroke="#333" stroke-width="3"/>
  <ellipse cx="64" cy="80" rx="24" ry="20" fill="#ffe4b5" stroke="#333" stroke-width="3"/>
  <ellipse cx="58" cy="80" rx="3" ry="5" fill="#333"/>
  <ellipse cx="70" cy="80" rx="3" ry="5" fill="#333"/>
  <ellipse cx="64" cy="92" rx="6" ry="3" fill="#333"/>
</svg> 

mouse.svg

<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
  <ellipse cx="64" cy="80" rx="36" ry="28" fill="#eee" stroke="#333" stroke-width="4"/>
  <ellipse cx="44" cy="60" rx="8" ry="14" fill="#ccc" stroke="#333" stroke-width="3"/>
  <ellipse cx="84" cy="60" rx="8" ry="14" fill="#ccc" stroke="#333" stroke-width="3"/>
  <ellipse cx="64" cy="80" rx="20" ry="16" fill="#fff" stroke="#333" stroke-width="3"/>
  <ellipse cx="58" cy="80" rx="2" ry="4" fill="#333"/>
  <ellipse cx="70" cy="80" rx="2" ry="4" fill="#333"/>
  <ellipse cx="64" cy="92" rx="4" ry="2" fill="#333"/>
  <path d="M100 110 Q120 120 110 100" stroke="#333" stroke-width="3" fill="none"/>
</svg> 

README.md

# オセロWebアプリ メンテナンス用マニュアル・仕様書

## 概要
- ブラウザで動作するシンプルなオセロ(リバーシ)ゲームです。
- プレイヤー vs CPU(強さ3段階)で対戦できます。
- 先手・後手、CPU強さを選択可能。
- 盤面下に「リセット」「最初の画面に戻る」ボタンあり。
- CPU強さは「竜(強)」「犬(中)」「ねずみ(弱)」のイラスト付き。

## ファイル構成
- `index.html` … 画面レイアウト・UI
- `main.js` … ゲームロジック・UI制御
- `style.css` … 盤面や石の見た目
- `dragon.svg`, `dog.svg`, `mouse.svg` … CPU強さイラスト

## 主な仕様
### ゲームルール
- 8x8の標準オセロルール
- 石を置ける場所のみクリック可能
- 挟んだ相手の石を自動でひっくり返す
- 置ける場所がなければ自動パス
- 両者とも置けなければ終了・勝敗表示

### CPUの強さ
- 竜(強):角優先、なければひっくり返せる石が多い手
- 犬(中):ひっくり返せる石が多い手
- ねずみ(弱):完全ランダム

### UI
- ゲーム開始時に「先手/後手」「CPU強さ」を選択
- 対局中は盤面上にCPU強さのイラストとラベルを表示
- 「リセット」:現在の設定のまま新規対局
- 「最初の画面に戻る」:設定選択画面に戻る

## メンテナンス・カスタマイズ
- 盤面サイズやルール変更は`main.js`の定数・ロジックを修正
- CPUの思考ルーチンは`cpuMove()`関数を編集
- イラスト差し替えはSVGファイルを入れ替え
- UI文言やデザインは`index.html`と`style.css`で調整

## 動作環境
- モダンなWebブラウザ(Chrome, Edge, Firefox, Safari等)
- サーバ不要、ローカルで`index.html`を開くだけで動作

## 注意事項
- 画像ファイル(SVG)は`index.html`と同じディレクトリに配置
- JavaScriptの仕様変更時は動作確認を十分に行うこと

---




感想

昔1週間かけて挫折したのに、、、恐ろしい時代になった、、、(;・∀・)