はじめに
自分で作成した,ブラウザ上で動くオセロゲームのコードを解説します.
基本的な文法から説明するので,初心者向けの内容になっている(つもり)です.
という位置づけは形式だけのもので,本記事はただの自分用の備忘録なので悪しからず(^^")
実装環境
ディレクトリ構成図は以下の通りです.
.
└── Othello/
├── index.html
├── style.css
└── script.js
画面仕様
各コードの解説
以下,index.htmlとscript.jsについて解説していきます.
(style.cssの説明は省きます)
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="score">
<span id="black-score">● 2</span> - <span id="white-score">○ 2</span>
</div>
<div id="board"></div>
<button id="reset">リセット</button>
<div id="message"></div>
<script src="script.js"></script>
</body>
</html>
HTMLは、ウェブページの骨格を作るための言語です.各タグの役割を簡単に見ていきましょう.
<h1>オセロゲーム</h1>
一番大きな見出しで、ページのタイトルを表示しています.
<div id="score">...</div>
スコア表示部分全体を囲む箱です.
idはHTMLの要素の名前のようなもので、JavaScriptからこの要素を操作するための目印になります.
中の<span>タグで、黒と白それぞれのスコア部分をさらに細かく分けています.
これもidを付けて、後でスコアを更新しやすくしています.
<div id="board"></div>
オセロの盤面を表示するための箱です.
最初は空ですが、後ほどJavaScriptによって、この中に8x8のマス目が動的に生成されます.
<button id="reset">リセット</button>
ゲームを初期状態に戻すためのリセットボタンです.
<div id="message"></div>
「黒の番です」や「白の勝ち!」といったメッセージを表示するためのエリアです.
<script src="script.js"></script>
bodyタグの最後で、JavaScriptファイル(script.js)を読み込んでいます.
HTMLの要素がすべて読み込まれた後にJavaScriptを実行させるため、この位置に書くのが一般的です.
このように、HTMLではまず「どこに」「何を」表示させるかの「場所」だけを用意しておき、実際のゲームの動きや表示の更新はJavaScriptが担当するという役割分担になっています.
script.js
script.jsの中身はこんな感じです(長いので折りたたみ表示しています)
script.js
const SIZE = 8;
const EMPTY = 0, BLACK = 1, WHITE = 2;
let board, currentPlayer, gameOver;
const boardDiv = document.getElementById('board');
const blackScoreSpan = document.getElementById('black-score');
const whiteScoreSpan = document.getElementById('white-score');
const messageDiv = document.getElementById('message');
const resetBtn = document.getElementById('reset');
function initBoard() {
board = Array.from({ length: SIZE }, () => Array(SIZE).fill(EMPTY));
board[3][3] = WHITE;
board[3][4] = BLACK;
board[4][3] = BLACK;
board[4][4] = WHITE;
currentPlayer = BLACK;
gameOver = false;
}
function drawBoard() {
boardDiv.innerHTML = '';
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.x = x;
cell.dataset.y = y;
if (isValidMove(x, y, currentPlayer)) {
cell.classList.add('valid');
cell.addEventListener('click', () => handleMove(x, y));
}
if (board[y][x] === BLACK || board[y][x] === WHITE) {
const stone = document.createElement('div');
stone.className = 'stone ' + (board[y][x] === BLACK ? 'black' : 'white');
cell.appendChild(stone);
}
boardDiv.appendChild(cell);
}
}
}
function isValidMove(x, y, player) {
if (board[y][x] !== EMPTY) return false;
return getFlippableStones(x, y, player).length > 0;
}
function getFlippableStones(x, y, player) {
const directions = [
[0,1],[1,0],[0,-1],[-1,0],
[1,1],[1,-1],[-1,1],[-1,-1]
];
const opponent = player === BLACK ? WHITE : BLACK;
let flippable = [];
for (const [dx, dy] of directions) {
let nx = x + dx, ny = y + dy;
let stones = [];
while (nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE && board[ny][nx] === opponent) {
stones.push([nx, ny]);
nx += dx;
ny += dy;
}
if (stones.length && nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE && board[ny][nx] === player) {
flippable = flippable.concat(stones);
}
}
return flippable;
}
function handleMove(x, y) {
if (gameOver || !isValidMove(x, y, currentPlayer)) return;
const flippable = getFlippableStones(x, y, currentPlayer);
board[y][x] = currentPlayer;
for (const [fx, fy] of flippable) {
board[fy][fx] = currentPlayer;
}
switchPlayer();
update();
}
function switchPlayer() {
currentPlayer = currentPlayer === BLACK ? WHITE : BLACK;
if (!hasValidMove(currentPlayer)) {
if (!hasValidMove(currentPlayer === BLACK ? WHITE : BLACK)) {
gameOver = true;
} else {
messageDiv.textContent = (currentPlayer === BLACK ? '黒' : '白') + 'は打てる場所がありません。パスします。';
currentPlayer = currentPlayer === BLACK ? WHITE : BLACK;
}
} else {
messageDiv.textContent = '';
}
}
function hasValidMove(player) {
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (isValidMove(x, y, player)) return true;
}
}
return false;
}
function updateScore() {
let black = 0, white = 0;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (board[y][x] === BLACK) black++;
if (board[y][x] === WHITE) white++;
}
}
blackScoreSpan.textContent = `● ${black}`;
whiteScoreSpan.textContent = `○ ${white}`;
}
function update() {
drawBoard();
updateScore();
if (gameOver) {
let black = 0, white = 0;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (board[y][x] === BLACK) black++;
if (board[y][x] === WHITE) white++;
}
}
if (black > white) {
messageDiv.textContent = `黒の勝ち! (${black} - ${white})`;
} else if (white > black) {
messageDiv.textContent = `白の勝ち! (${white} - ${black})`;
} else {
messageDiv.textContent = '引き分け!';
}
} else {
messageDiv.textContent = (currentPlayer === BLACK ? '黒' : '白') + 'の番です';
}
}
resetBtn.addEventListener('click', () => {
initBoard();
update();
});
// 初期化
initBoard();
update();
ここからは、実際のゲームの動きや表示の更新を担当するscript.jsの中身を、機能ごとに見ていきましょう.
const SIZE = 8;
const EMPTY = 0, BLACK = 1, WHITE = 2;
let board, currentPlayer, gameOver;
最初に、ゲームで使う基本的な設定をしています.
盤面のサイズ(SIZE)や、マスの状態(EMPTY: 空、BLACK: 黒、WHITE: 白)を、分かりやすいように名前を付けた定数として定義しています.
board(盤面の状態)、currentPlayer(現在の手番)、gameOver(ゲーム終了フラグ)は、ゲーム中に変化していくため、letで変数として用意しています.
const boardDiv = document.getElementById('board');
const blackScoreSpan = document.getElementById('black-score');
const whiteScoreSpan = document.getElementById('white-score');
const messageDiv = document.getElementById('message');
const resetBtn = document.getElementById('reset');
index.htmlに書いた各要素を、idを目印にして取得し、変数に入れています.
これにより、JavaScriptから盤面やスコア、メッセージなどを操作できるようになります.
function initBoard() {
board = Array.from({ length: SIZE }, () => Array(SIZE).fill(EMPTY));
board[3][3] = WHITE;
board[3][4] = BLACK;
board[4][3] = BLACK;
board[4][4] = WHITE;
currentPlayer = BLACK;
gameOver = false;
}
ゲームの初期化を担当する関数です.
リセットボタンが押された時や、ゲームが始まった時に呼ばれます.
盤面をすべて空の状態にした後、中央に黒と白の石を2つずつ配置し、最初の手番を黒に設定します.
function drawBoard() {
boardDiv.innerHTML = '';
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.x = x;
cell.dataset.y = y;
if (isValidMove(x, y, currentPlayer)) {
cell.classList.add('valid');
cell.addEventListener('click', () => handleMove(x, y));
}
if (board[y][x] === BLACK || board[y][x] === WHITE) {
const stone = document.createElement('div');
stone.className = 'stone ' + (board[y][x] === BLACK ? 'black' : 'white');
cell.appendChild(stone);
}
boardDiv.appendChild(cell);
}
}
}
board配列の現在の状態をもとに、HTMLの盤面を描画する、非常に重要な関数です.
まず盤面を一旦まっさらにしてから、8x8=64個のマス(div要素)を一つずつ生成して配置していきます.
この時、石が置かれているマスには石の要素を追加したり、次に石が置けるマスには目印のクラス(valid)を付けてクリックできるようにしたりしています.
function isValidMove(x, y, player) {
if (board[y][x] !== EMPTY) return false;
return getFlippableStones(x, y, player).length > 0;
}
あるマスに石を置けるかどうかを判定する関数です.
そのマスが空であること、そしてgetFlippableStones関数を呼び出して、ひっくり返せる石が1つ以上あることを確認しています.
function getFlippableStones(x, y, player) {
const directions = [
[0,1],[1,0],[0,-1],[-1,0],
[1,1],[1,-1],[-1,1],[-1,-1]
];
const opponent = player === BLACK ? WHITE : BLACK;
let flippable = [];
for (const [dx, dy] of directions) {
let nx = x + dx, ny = y + dy;
let stones = [];
while (nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE && board[ny][nx] === opponent) {
stones.push([nx, ny]);
nx += dx;
ny += dy;
}
if (stones.length && nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE && board[ny][nx] === player) {
flippable = flippable.concat(stones);
}
}
return flippable;
}
オセロのルールで最も重要な「石をひっくり返す」ロジックを担う関数です.
指定したマスに石を置いた場合、8方向(上下左右、斜め)をチェックし、ひっくり返せる相手の石の座標をすべてリストアップして返します.
function handleMove(x, y) {
if (gameOver || !isValidMove(x, y, currentPlayer)) return;
const flippable = getFlippableStones(x, y, currentPlayer);
board[y][x] = currentPlayer;
for (const [fx, fy] of flippable) {
board[fy][fx] = currentPlayer;
}
switchPlayer();
update();
}
プレイヤーが石を置けるマスをクリックした時に実行される関数です.
実際に石を置き、getFlippableStonesで取得した相手の石をすべて自分の色に変え、次のプレイヤーの手番に移すという一連の流れを処理します.
function switchPlayer() {
currentPlayer = currentPlayer === BLACK ? WHITE : BLACK;
if (!hasValidMove(currentPlayer)) {
if (!hasValidMove(currentPlayer === BLACK ? WHITE : BLACK)) {
gameOver = true;
} else {
messageDiv.textContent = (currentPlayer === BLACK ? '黒' : '白') + 'は打てる場所がありません。パスします。';
currentPlayer = currentPlayer === BLACK ? WHITE : BLACK;
}
} else {
messageDiv.textContent = '';
}
}
手番を交代する関数です.
プレイヤーを交代させた後、次のプレイヤーの打てる場所があるかをチェックします.もしなければ「パス」となり、もう一度元のプレイヤーの手番になります.両者とも打つ場所がなくなったら、ゲーム終了(gameOver = true)とします.
function hasValidMove(player) {
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (isValidMove(x, y, player)) return true;
}
}
return false;
}
指定されたプレイヤーが、盤面のどこかに打てる手が一つでもあるかどうかを調べる関数です.
盤面の全マス(64マス)をループで一つずつチェックし、それぞれのマスでisValidMove関数を呼び出します.
一つでも打てるマスが見つかった瞬間にtrue(「手がある」という意味)を返して処理を終了します.
もし、すべてのマスを調べても一つも見つからなければ、打てる手がないということなのでfalseを返します.
この関数は主に、プレイヤーがパスをしなければならない状況かを判定するために使われます.
function updateScore() {
let black = 0, white = 0;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (board[y][x] === BLACK) black++;
if (board[y][x] === WHITE) white++;
}
}
blackScoreSpan.textContent = `● ${black}`;
whiteScoreSpan.textContent = `○ ${white}`;
}
盤上にある黒と白の石の数をそれぞれ数え、画面のスコア表示を更新するための関数です.
blackとwhiteというカウンター用の変数を0で用意し、盤面の全マスをチェックしていきます.
マスに黒石があればblackの数を、白石があればwhiteの数を1つずつ増やします.
最後に、数え終わったそれぞれの石の数を、HTMLのスコア表示部分(blackScoreSpanとwhiteScoreSpan)のテキストとして書き込んでいます.
function update() {
drawBoard();
updateScore();
if (gameOver) {
let black = 0, white = 0;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (board[y][x] === BLACK) black++;
if (board[y][x] === WHITE) white++;
}
}
if (black > white) {
messageDiv.textContent = `黒の勝ち! (${black} - ${white})`;
} else if (white > black) {
messageDiv.textContent = `白の勝ち! (${white} - ${black})`;
} else {
messageDiv.textContent = '引き分け!';
}
} else {
messageDiv.textContent = (currentPlayer === BLACK ? '黒' : '白') + 'の番です';
}
}
ゲームの状態が変わるたびに呼ばれる、全体の司令塔のような関数です.
drawBoardで盤面を最新の状態に描き直し、updateScoreでスコアを更新し、ゲームの状況に応じたメッセージ(「黒の番です」や「黒の勝ち!」など)を表示します.
resetBtn.addEventListener('click', () => {
initBoard();
update();
});
リセットボタンがクリックされた時の処理を定義しています.
initBoardでゲームを初期化し、updateで画面表示を最初の状態に戻します.
initBoard();
update();
最後に、このJavaScriptファイルが読み込まれた時に、initBoardとupdateを一度だけ実行しています.
これにより、ウェブページを開いた瞬間にゲームが開始されます.
あとがき
ここまで読んでくださり、ありがとうございました。
僕自身Qiitaで記事を書くのは初めてで(Markdown記法で何か書くのも初めてです)、ところどころ読みにくいところがあったと思います。
本記事を読んで、わからないことがあればコメントにて何でも聞いてください。
また、「ここはこうした方がいい」などのアドバイスがあれば、ぜひお願いいたします。
