リバーシ
2025年5月15日

みなさんこんにちは。
ケミストのWeb担当みやのです。
今回は「リバーシ」を作ってみました。
「オセロ」は登録商標のためこのページでは「リバーシ」と呼称します。
では早速2人で遊んでみてください。
今回は1人でもできる遊び方があります。
「どちらかの色を全滅させる」に挑戦してみてください。
黒の番です
HTMLとCSS
HTMLはシンプルです。
<div id="board"></div>
<p id="status">黒の番です</p>
<button id="resetButton">リセット</button>
盤面が#board
メッセージが#status
リセットボタンが#resetButtonです。
CSSは以下の通りです。
#board {
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
margin: 20px auto;
border: 1px solid #333;
width: 100%;
max-width: 100%;
aspect-ratio: 1 / 1;
}
.cell {
border: 1px solid #333;
background-color: #1E7C2A;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cell.black::before, .cell.white::before {
content: "";
width: 80%;
height: 80%;
border-radius: 50%;
display: block;
}
.cell.black::before {
background-color: black;
}
.cell.white::before {
background-color: white;
}
実際の盤面っぽく特定の線だけ太くしたり、特定の線と線の交点に小さい黒丸を配置したかったのですがうまくいきませんでした。
javascript
今回はChatGPT先生が「盤面を作りましょう」「駒を置けるようにしましょう」「有効な位置にだけ駒を置けるようにしましょう」「はさんだらひっくり返すロジックを作りましょう」「勝敗判定をしましょう」のように順序だてて提案してくれたのですごく楽でした。
そのままだと「どちらかが置けなくなったらその時点の駒の数で勝敗判定」だったので「置ける場所がなかったらパスしてもう一度相手のターン」「どちらも置けなくなったら勝敗判定」とオーダーしました。
長いので分けて見ていきましょう。
まず基本部分です。
const board = document.getElementById("board");
const status = document.getElementById("status");
const resetButton = document.getElementById("resetButton");
let currentPlayer = "black";
let fireworksInterval = null;
function initializeBoard() {
board.innerHTML = "";
for (let i = 0; i < 8; i++) {
for (let j = 0; j < 8; j++) {
const cell = document.createElement("div");
cell.classList.add("cell");
cell.dataset.row = i;
cell.dataset.col = j;
if ((i === 3 && j === 3) || (i === 4 && j === 4)) {
cell.classList.add("white");
} else if ((i === 3 && j === 4) || (i === 4 && j === 3)) {
cell.classList.add("black");
}
cell.addEventListener("click", () => handleCellClick(cell));
board.appendChild(cell);
}
}
currentPlayer = "black";
status.textContent = "黒の番です";
}
駒を置ける場所を判定する関数です。
function hasValidMove(player) {
const cells = document.querySelectorAll(".cell");
return Array.from(cells).some(cell =>
!cell.classList.contains("black") &&
!cell.classList.contains("white") &&
isValidMoveForPlayer(cell, player)
);
}
function isValidMoveForPlayer(cell, player) {
const directions = [
[-1, 0], [1, 0], [0, -1], [0, 1],
[-1, -1], [-1, 1], [1, -1], [1, 1]
];
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
for (const [dx, dy] of directions) {
let x = row + dx;
let y = col + dy;
let hasOpponentDisc = false;
while (x >= 0 && x < 8 && y >= 0 && y < 8) {
const nextCell = document.querySelector(`.cell[data-row='${x}'][data-col='${y}']`);
if (!nextCell) break;
if (nextCell.classList.contains(player === "black" ? "white" : "black")) {
hasOpponentDisc = true;
} else if (nextCell.classList.contains(player)) {
if (hasOpponentDisc) return true;
break;
} else {
break;
}
x += dx;
y += dy;
}
}
return false;
}
セルをクリックした際の処理です。
function handleCellClick(cell) {
if (cell.classList.contains("black") || cell.classList.contains("white")) {
return;
}
if (!isValidMoveForPlayer(cell, currentPlayer)) {
return;
}
cell.classList.add(currentPlayer);
flipDiscs(cell);
const opponent = currentPlayer === "black" ? "white" : "black";
if (hasValidMove(opponent)) {
currentPlayer = opponent;
status.textContent = currentPlayer === "black" ? "黒の番です" : "白の番です";
} else if (hasValidMove(currentPlayer)) {
const passMessage = `${opponent === "black" ? "黒" : "白"}が置けないためパスします。<br>${currentPlayer === "black" ? "黒" : "白"}の番です。`;
status.innerHTML = passMessage;
} else {
const winner = determineWinner();
status.textContent = winner === "draw" ? "引き分けです!" : `${winner === "black" ? "黒" : "白"}の勝利です!`;
if (winner !== "draw") fireworks();
}
}
駒をひっくり返す処理です。
function flipDiscs(cell) {
const directions = [
[-1, 0], [1, 0], [0, -1], [0, 1],
[-1, -1], [-1, 1], [1, -1], [1, 1]
];
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
directions.forEach(([dx, dy]) => {
const cellsToFlip = [];
let x = row + dx;
let y = col + dy;
while (x >= 0 && x < 8 && y >= 0 && y < 8) {
const nextCell = document.querySelector(`.cell[data-row='${x}'][data-col='${y}']`);
if (!nextCell || !nextCell.classList.contains(currentPlayer === "black" ? "white" : "black")) {
break;
}
cellsToFlip.push(nextCell);
x += dx;
y += dy;
}
if (x >= 0 && x < 8 && y >= 0 && y < 8) {
const finalCell = document.querySelector(`.cell[data-row='${x}'][data-col='${y}']`);
if (finalCell && finalCell.classList.contains(currentPlayer)) {
cellsToFlip.forEach(cellToFlip => {
cellToFlip.classList.remove("black", "white");
cellToFlip.classList.add(currentPlayer);
});
}
}
});
}
ここが一番重要な部分なんでしょうが、どういう意味なのかサッパリわかりませぬ。
勝敗判定です。
function determineWinner() {
const cells = document.querySelectorAll(".cell");
let blackCount = 0;
let whiteCount = 0;
cells.forEach(cell => {
if (cell.classList.contains("black")) blackCount++;
if (cell.classList.contains("white")) whiteCount++;
});
if (blackCount > whiteCount) return "black";
if (whiteCount > blackCount) return "white";
return "draw";
}
最後にいつもの花火とリセットボタンです。
function fireworks() {
const duration = 15 * 1000;
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0
};
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
fireworksInterval = setInterval(function() {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
clearInterval(fireworksInterval);
}
const particleCount = 50 * (timeLeft / duration);
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.1, 0.3),
y: Math.random() - 0.2
}
});
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.7, 0.9),
y: Math.random() - 0.2
}
});
}, 250);
}
function stopFireworks() {
if (fireworksInterval) {
clearInterval(fireworksInterval);
fireworksInterval = null;
}
}
resetButton.addEventListener("click", function() {
stopFireworks();
initializeBoard();
});
initializeBoard();
今後の課題
・盤面の色に 青とか橙とか黒を追加したら怒られそう
・黒の数、白の数をリアルタイムで表示
・ひっくり返す時にアニメーション