4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【HTML1 ファイルで完成】Copilot にオセロゲームを作ってもらって、PC でダブルクリック起動できるようにした話

4
Last updated at Posted at 2026-02-05

Copilotに「オセロゲームをHTML形式で作れる?」と相談したところ、HTML + CSS + JavaScriptが1ファイルにまとまった、完成度の高いオセロアプリをその場で生成してくれました。

そのまま othello.html として保存してダブルクリックするだけで、ブラウザ上で本格的な対戦が楽しめます。

🚀 搭載されている機能

  • 8 × 8 標準盤面: 美しいグラデーションとアニメーションを採用したUI

  • CPU対戦: 黒・白どちらでも選択可能。簡易的な評価関数による思考ルーチン付き

  • ヒント表示: 次に置ける場所を黄色いドットでガイド

  • 自動システム: パス判定、終局判定、リアルタイムのスコア計算

  • 完全スタンドアロン: 外部ライブラリ(jQuery等)に依存せず、1ファイルで完結

🕹 使い方
下記のコードをコピーし、テキストエディタに貼り付けます。

ファイル名を othello.html として保存します。

保存したファイルをブラウザ(Chrome, Edge等)にドラッグ&ドロップして起動。

🎬 動作デモ
実際の動作の様子はこちらから確認できます。

💻 コード全文

クリックして othello.html の全文を見る
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>オセロ(リバーシ)</title>
<style>
  :root{
    --board-green: #1b6b39;
    --board-green-dark: #11502a;
    --cell-border: #0e3e21;
    --black: #111;
    --white: #fafafa;
    --hint: rgba(255,255,0,0.65);
    --accent: #0ea5e9;
  }
  body{
    margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, "ヒラギノ角ゴ ProN", "游ゴシック体", "Yu Gothic", "メイリオ", Arial, sans-serif;
    background: #0b1220; color:#eef2ff; display:flex; min-height:100svh; align-items:center; justify-content:center;
  }
  .app{
    width:min(96vw, 920px);
    padding:16px;
  }
  .card{
    background:#0f172a; border:1px solid #1f2a44; border-radius:16px; padding:16px;
    box-shadow: 0 10px 30px rgba(0,0,0,0.3);
  }
  .row{
    display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between;
  }
  .title{
    font-size: clamp(18px, 3.2vw, 28px); font-weight:700; letter-spacing: .02em;
  }
  .controls{
    display:flex; gap:10px; flex-wrap:wrap; align-items:center;
  }
  .badge{
    padding:6px 10px; border-radius:999px; background:#111827; border:1px solid #1f2937; font-size:14px;
  }
  .button, .toggle{
    background:#111827; color:#e5e7eb; border:1px solid #1f2937; padding:8px 12px; border-radius:10px; cursor:pointer;
  }
  .button:hover{ background:#0b1220; }
  .toggle{ display:flex; gap:8px; align-items:center; user-select:none; }
  .toggle input{ accent-color: var(--accent); transform: translateY(1px); }

  .board-wrap{
    margin-top:14px; display:grid; grid-template-columns: 1fr 320px; gap:16px;
  }
  @media (max-width: 800px){
    .board-wrap{ grid-template-columns: 1fr; }
  }

  .board{
    background: radial-gradient(120% 120% at 50% 0%, #2a8d52 0%, var(--board-green) 50%, var(--board-green-dark) 100%);
    border-radius:16px; padding:10px; border:1px solid #0c2f19;
    display:grid; grid-template-columns: repeat(8, 1fr); gap:8px;
    box-shadow: inset 0 0 20px rgba(0,0,0,0.35);
  }
  .cell{
    position:relative; aspect-ratio: 1 / 1; border-radius:10px;
    background: linear-gradient(180deg, #1f7b43 0%, #146336 100%);
    border:1px solid var(--cell-border);
    display:grid; place-items:center; cursor:pointer;
    transition: transform .05s ease;
  }
  .cell:active{ transform: scale(0.98); }
  .cell.disabled{ cursor:not-allowed; filter: grayscale(.5) brightness(.9); }

  .disc{
    width:76%; aspect-ratio:1 / 1; border-radius:50%;
    box-shadow: inset 0 6px 8px rgba(255,255,255,0.2), inset 0 -8px 10px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.45);
  }
  .disc.black{ background: radial-gradient(circle at 35% 30%, #333 0%, #111 60%, #000 100%); }
  .disc.white{ background: radial-gradient(circle at 35% 30%, #fff 0%, #f2f2f2 60%, #d9d9d9 100%); }

  .hint-dot{
    width:20%; aspect-ratio:1/1; border-radius:50%; background: var(--hint);
    box-shadow: 0 0 12px 3px rgba(255,255,0,0.55);
  }

  .side{
    display:flex; flex-direction:column; gap:12px;
  }
  .panel{
    background:#0b1220; border:1px solid #1f2a44; border-radius:12px; padding:12px;
  }
  .panel h2{ margin:0 0 8px; font-size:16px; opacity:.9; }
  .status{ font-size:15px; line-height:1.6; }
  .score{
    display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:15px;
  }
  .score .pill{
    display:flex; gap:8px; align-items:center; background:#0f172a; border:1px solid #1f2937; border-radius:999px; padding:6px 10px;
  }
  .dot{
    width:14px; height:14px; border-radius:50%;
    box-shadow: inset 0 2px 4px rgba(255,255,255,0.2), inset 0 -2px 3px rgba(0,0,0,0.35);
  }
  .dot.black{ background:#111; }
  .dot.white{ background:#fafafa; }
  .turn{
    display:inline-flex; gap:8px; align-items:center; font-weight:700;
  }
  .turn .dot{ width:16px; height:16px; }

  .footer{
    display:flex; gap:8px; flex-wrap:wrap; margin-top:6px;
  }
  .small{ opacity:.8; font-size:12px; }
</style>
</head>
<body>
  <div class="app">
    <div class="card">
      <div class="row">
        <div class="title">オセロ(リバーシ)</div>
        <div class="controls">
          <span class="badge">8 × 8</span>
          <label class="toggle"><input id="hintToggle" type="checkbox" />ヒント表示</label>
          <label class="toggle"><input id="cpuToggle" type="checkbox" />CPU と対戦</label>
          <label class="toggle"><input type="radio" name="cpuColor" value="WHITE" checked />CPU は白</label>
          <label class="toggle"><input type="radio" name="cpuColor" value="BLACK" />CPU は黒</label>
          <button id="resetBtn" class="button">リセット</button>
        </div>
      </div>

      <div class="board-wrap">
        <div id="board" class="board" aria-label="オセロ盤面"></div>

        <div class="side">
          <div class="panel">
            <h2>現在の手番</h2>
            <div id="turn" class="turn"><span class="dot black"></span><span>黒(先手)</span></div>
          </div>

          <div class="panel">
            <h2>スコア</h2>
            <div id="score" class="score">
              <span class="pill"><span class="dot black"></span>黒 <strong id="blackScore">2</strong></span>
              <span class="pill"><span class="dot white"></span>白 <strong id="whiteScore">2</strong></span>
            </div>
          </div>

          <div class="panel">
            <h2>ステータス</h2>
            <div id="status" class="status">黒の番です。置ける場所を選んでください。</div>
            <div class="footer small">
              <span>・合法手が無い場合は自動で <strong>パス</strong> します。</span>
              <span>・両者が置けないか全マスが埋まると終局です。</span>
            </div>
          </div>
        </div>
      </div>

    </div><!--/card-->
  </div><!--/app-->

<script>
(() => {
  // 盤面定数
  const SIZE = 8;
  const EMPTY = 0, BLACK = 1, WHITE = 2;
  const DIRS = [
    [-1,-1],[-1,0],[-1,1],
    [ 0,-1],       [ 0,1],
    [ 1,-1],[ 1,0],[ 1,1],
  ];

  // 状態
  let board;
  let current = BLACK; // 黒先手
  let hintOn = false;
  let cpuEnabled = false;
  let cpuColor = WHITE; // デフォで CPU は白
  let gameOver = false;

  // 要素
  const elBoard = document.getElementById('board');
  const elTurn = document.getElementById('turn');
  const elScoreBlack = document.getElementById('blackScore');
  const elScoreWhite = document.getElementById('whiteScore');
  const elStatus = document.getElementById('status');
  const hintToggle = document.getElementById('hintToggle');
  const cpuToggle = document.getElementById('cpuToggle');
  const resetBtn = document.getElementById('resetBtn');
  const cpuColorRadios = [...document.querySelectorAll('input[name="cpuColor"]')];

  // 初期化
  function newBoard(){
    const b = Array.from({length: SIZE}, () => Array(SIZE).fill(EMPTY));
    const mid = SIZE/2;
    b[mid-1][mid-1] = WHITE;
    b[mid][mid]     = WHITE;
    b[mid-1][mid]   = BLACK;
    b[mid][mid-1]   = BLACK;
    return b;
  }

  function reset(){
    board = newBoard();
    current = BLACK;
    gameOver = false;
    updateUI();
    maybeCpuTurn(); // もし CPU が黒なら開幕で動く
  }

  // 合法手計算
  function inBounds(r,c){ return r>=0 && r<SIZE && c>=0 && c<SIZE; }
  function opponent(p){ return p===BLACK ? WHITE : BLACK; }

  // 置ける手一覧(キー: "r,c" -> flips 配列)
  function getValidMoves(bd, pl){
    const opp = opponent(pl);
    const result = new Map();

    for(let r=0; r<SIZE; r++){
      for(let c=0; c<SIZE; c++){
        if(bd[r][c] !== EMPTY) continue;
        let flipsAll = [];
        for(const [dr,dc] of DIRS){
          let rr=r+dr, cc=c+dc;
          const flips = [];
          while(inBounds(rr,cc) && bd[rr][cc]===opp){
            flips.push([rr,cc]);
            rr+=dr; cc+=dc;
          }
          if(flips.length>0 && inBounds(rr,cc) && bd[rr][cc]===pl){
            flipsAll = flipsAll.concat(flips);
          }
        }
        if(flipsAll.length>0){
          result.set(`${r},${c}`, flipsAll);
        }
      }
    }
    return result;
  }

  function cloneBoard(bd){ return bd.map(row => row.slice()); }

  // 着手
  function playMove(r,c, pl=current, useBoard=board){
    const moves = getValidMoves(useBoard, pl);
    const key = `${r},${c}`;
    if(!moves.has(key)) return false;
    useBoard[r][c] = pl;
    for(const [fr,fc] of moves.get(key)){
      useBoard[fr][fc] = pl;
    }
    return true;
  }

  // スコア
  function countScore(bd){
    let b=0, w=0;
    for(let r=0;r<SIZE;r++){
      for(let c=0;c<SIZE;c++){
        if(bd[r][c]===BLACK) b++;
        else if(bd[r][c]===WHITE) w++;
      }
    }
    return {b,w};
  }

  // 評価(CPU 用簡易ヒューリスティック)
  const WEIGHTS = [
    [120,-20, 20,  5,  5, 20,-20,120],
    [-20,-40,-5, -5, -5,-5,-40,-20],
    [ 20, -5, 15,  3,  3, 15, -5, 20],
    [  5, -5,  3,  3,  3,  3, -5,  5],
    [  5, -5,  3,  3,  3,  3, -5,  5],
    [ 20, -5, 15,  3,  3, 15, -5, 20],
    [-20,-40,-5, -5, -5,-5,-40,-20],
    [120,-20, 20,  5,  5, 20,-20,120],
  ];

  function evaluate(bd, pl){
    const opp = opponent(pl);
    let val = 0;
    for(let r=0;r<SIZE;r++){
      for(let c=0;c<SIZE;c++){
        if(bd[r][c]===pl) val += WEIGHTS[r][c];
        else if(bd[r][c]===opp) val -= WEIGHTS[r][c];
      }
    }
    return val;
  }

  function cpuChooseMove(pl){
    const moves = getValidMoves(board, pl);
    if(moves.size===0) return null;

    let best = null;
    let bestVal = -Infinity;

    for(const key of moves.keys()){
      const [r,c] = key.split(',').map(Number);
      const tmp = cloneBoard(board);
      playMove(r,c, pl, tmp);
      // 次手の相手の着手可否で少し減点(手数を与えにくくする簡易策)
      const oppMoves = getValidMoves(tmp, opponent(pl)).size;
      const val = evaluate(tmp, pl) - oppMoves * 2;
      if(val > bestVal){
        bestVal = val;
        best = {r,c};
      }
    }
    return best;
  }

  // UI 描画
  function renderBoard(){
    elBoard.innerHTML = '';
    const moves = getValidMoves(board, current);
    const isCpuTurn = cpuEnabled && current === cpuColor;

    for(let r=0;r<SIZE;r++){
      for(let c=0;c<SIZE;c++){
        const cell = document.createElement('button');
        cell.className = 'cell';
        cell.setAttribute('data-r', r);
        cell.setAttribute('data-c', c);
        cell.setAttribute('aria-label', `マス ${r+1}-${c+1}`);
        if(isCpuTurn) cell.classList.add('disabled');

        const v = board[r][c];
        if(v===BLACK || v===WHITE){
          const d = document.createElement('div');
          d.className = `disc ${v===BLACK?'black':'white'}`;
          cell.appendChild(d);
        }else{
          const key = `${r},${c}`;
          if(hintOn && moves.has(key)){
            const dot = document.createElement('div');
            dot.className = 'hint-dot';
            cell.appendChild(dot);
          }
        }

        cell.addEventListener('click', () => {
          if(isCpuTurn || gameOver) return;
          const ok = playMove(r,c);
          if(!ok) return;
          afterHumanMove();
        });

        elBoard.appendChild(cell);
      }
    }
  }

  function updateTurnUI(){
    const who = current===BLACK ? '黒' : '白';
    const dotClass = current===BLACK ? 'black' : 'white';
    elTurn.innerHTML = `<span class="dot ${dotClass}"></span><span>${who}(${current===BLACK?'先手':'後手'})</span>`;
  }

  function updateScoreUI(){
    const {b,w} = countScore(board);
    elScoreBlack.textContent = b;
    elScoreWhite.textContent = w;
  }

  function updateStatus(text){
    elStatus.textContent = text;
  }

  function updateUI(msg){
    renderBoard();
    updateTurnUI();
    updateScoreUI();
    if(msg) updateStatus(msg);
    else updateStatus(`${current===BLACK?'黒':'白'}の番です。`);
  }

  // 手番進行と終局チェック
  function switchTurn(){ current = opponent(current); }

  function checkProgress(){
    const myMoves = getValidMoves(board, current);
    if(myMoves.size>0){
      // 普通に続行
      return;
    }
    // 自分置けない → パスまたは終局
    const oppMoves = getValidMoves(board, opponent(current));
    if(oppMoves.size>0){
      // パス
      updateUI(`${current===BLACK?'黒':'白'}は置けないためパス。`);
      switchTurn();
    }else{
      // 両者置けない → 終局
      gameOver = true;
      const {b,w} = countScore(board);
      let result = `終局です。黒 ${b} – 白 ${w}。`;
      if(b>w) result += ' 黒の勝ち!';
      else if(w>b) result += ' 白の勝ち!';
      else result += ' 引き分け!';
      updateUI(result);
    }
  }

  function afterHumanMove(){
    switchTurn();
    updateUI();
    checkProgress();
    updateUI();

    maybeCpuTurn();
  }

  function maybeCpuTurn(){
    if(!cpuEnabled || gameOver) return;
    if(current !== cpuColor) return;

    // CPU 思考 → 少し待って着手
    const moves = getValidMoves(board, current);
    if(moves.size===0){
      // パス処理は checkProgress 側で行うのでここは UI 更新だけ
      checkProgress();
      updateUI();
      // もし連続パスで終局しなければ人間の手番へ
      if(!gameOver) maybeCpuTurn();
      return;
    }

    updateStatus('CPU が考えています…');
    renderBoard();

    setTimeout(() => {
      const choice = cpuChooseMove(current);
      if(choice){
        playMove(choice.r, choice.c);
        switchTurn();
        updateUI();
        checkProgress();
        updateUI();
        // CPU が連続になるケース(相手パス)もあるので再帰チェック
        if(!gameOver) maybeCpuTurn();
      }else{
        // 何らかの理由で選べなかった(稀)
        checkProgress();
        updateUI();
      }
    }, 350);
  }

  // イベント
  hintToggle.addEventListener('change', e => {
    hintOn = e.target.checked;
    updateUI();
  });
  cpuToggle.addEventListener('change', e => {
    cpuEnabled = e.target.checked;
    updateUI();
    maybeCpuTurn();
  });
  cpuColorRadios.forEach(r => r.addEventListener('change', e => {
    cpuColor = e.target.value === 'WHITE' ? WHITE : BLACK;
    updateUI();
    maybeCpuTurn();
  }));
  resetBtn.addEventListener('click', () => reset());

  // 起動
  reset();
})();
</script>
</body>
</html>
4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?