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>