We Blog Weblog

セ〇アの四目並べ

Web

2025年1月5日

あけましておめでとうございます。
ケミストのWeb担当みやのです。

今年もよろしくお願いします。

「Web」記事では、これまでに得たWebに関する知識を記録として残していきたいと思います。

今回は100円ショップ「セリア」で買ったコレ!

頭脳で勝負!立体色並べ

これを作ってみたいと思います。

赤と黄色の駒を交互に置いていって、縦か横か斜めに4つ揃った方の勝ちです。

要は6行×7列のマス目を使った四目並べなのですが、盤面は垂直になっていて、駒は重力に従って一番下まで落ちるので、かなり頭を使うゲームです。

ぜひ2人で遊んでみてください。

 

red turn

 

HTMLとCSS

6行×7列のマス目を作ります。

<div id="gameBoard">
  <div class="row">
    <div class="cell" data-row="0" data-col="0"></div>
    <div class="cell" data-row="0" data-col="1"></div>
    <div class="cell" data-row="0" data-col="2"></div>
    <div class="cell" data-row="0" data-col="3"></div>
    <div class="cell" data-row="0" data-col="4"></div>
    <div class="cell" data-row="0" data-col="5"></div>
    <div class="cell" data-row="0" data-col="6"></div>
  </div>
  <div class="row">
    <div class="cell" data-row="1" data-col="0"></div>
    <div class="cell" data-row="1" data-col="1"></div>
    <div class="cell" data-row="1" data-col="2"></div>
    <div class="cell" data-row="1" data-col="3"></div>
    <div class="cell" data-row="1" data-col="4"></div>
    <div class="cell" data-row="1" data-col="5"></div>
    <div class="cell" data-row="1" data-col="6"></div>
  </div>
  <div class="row">
    <div class="cell" data-row="2" data-col="0"></div>
    <div class="cell" data-row="2" data-col="1"></div>
    <div class="cell" data-row="2" data-col="2"></div>
    <div class="cell" data-row="2" data-col="3"></div>
    <div class="cell" data-row="2" data-col="4"></div>
    <div class="cell" data-row="2" data-col="5"></div>
    <div class="cell" data-row="2" data-col="6"></div>
  </div>
  <div class="row">
    <div class="cell" data-row="3" data-col="0"></div>
    <div class="cell" data-row="3" data-col="1"></div>
    <div class="cell" data-row="3" data-col="2"></div>
    <div class="cell" data-row="3" data-col="3"></div>
    <div class="cell" data-row="3" data-col="4"></div>
    <div class="cell" data-row="3" data-col="5"></div>
    <div class="cell" data-row="3" data-col="6"></div>
  </div>
  <div class="row">
    <div class="cell" data-row="4" data-col="0"></div>
    <div class="cell" data-row="4" data-col="1"></div>
    <div class="cell" data-row="4" data-col="2"></div>
    <div class="cell" data-row="4" data-col="3"></div>
    <div class="cell" data-row="4" data-col="4"></div>
    <div class="cell" data-row="4" data-col="5"></div>
    <div class="cell" data-row="4" data-col="6"></div>
  </div>
  <div class="row">
    <div class="cell" data-row="5" data-col="0"></div>
    <div class="cell" data-row="5" data-col="1"></div>
    <div class="cell" data-row="5" data-col="2"></div>
    <div class="cell" data-row="5" data-col="3"></div>
    <div class="cell" data-row="5" data-col="4"></div>
    <div class="cell" data-row="5" data-col="5"></div>
    <div class="cell" data-row="5" data-col="6"></div>
  </div>
</div>

data-rowは行を表し、0~5の6行
data-colは列を表し、0~6の7列です。

盤面全体に#gameBoardというIDを振っておきます。


CSSは以下の通りです。

#gameBoard {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 100%;
  aspect-ratio: 7 / 6;
  position: relative;
}
.row {
  display: flex;
  flex: 1;
}
.cell {
  flex: 1;
  background-color: lightblue;
  cursor: pointer;
  aspect-ratio: 1 / 1;
  position: relative;
  border-left: 2px solid #333;
}
.circle, .piece {
  position: absolute;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  top: 0;
  left: 0;
  transition: top 0.5s ease;
}
.red, .piece.red {
  background-color: #dc3545;
}
.yellow, .piece.yellow {
  background-color: #ffc107;
}
.row:first-child .cell {
  border-top: 2px solid #333;
}
.row:last-child .cell {
  border-bottom: 2px solid #333;
}
.cell:last-child {
  border-right: 2px solid #333;
}

至極どうでもいいのですが、全ての.cellにborderを設定すると、真ん中だけ線が太くなって周囲が細い、という感じになってしまってイケてなくて、かといって#gameBoardにborderを設定しても盤面の大きさと合っていなくて、最終的に上記のように全てのcellにborder-left+最初の行にborder-top+最後の行にborder-bottom+最後の列にborder-rightと設定すると全ての線の太さが均等になりました。

javascript

今回はかなり長いので、関数ごとに分けてみます。

まず冒頭部分です。こういうのを「宣言する」って言うんでしょうか。

const rows = 6;
const cols = 7;
const board = Array.from({
	length: rows
}, () => Array(cols).fill(null));
let currentPlayer = 'red';
let gameActive = true;
let gameOver = false;

盤面の描画に関する関数です。

function initializeGame() {
	renderBoard();
	const resetButton = document.getElementById('resetButton');
	resetButton.addEventListener('click', resetGame);
}
window.onload = initializeGame;
function renderBoard() {
	const boardElement = document.getElementById('gameBoard');
	boardElement.innerHTML = '';
	for (let row = 0; row < rows; row++) {
		const rowElement = document.createElement('div');
		rowElement.classList.add('row');
		for (let col = 0; col < cols; col++) {
			const cellElement = document.createElement('div');
			cellElement.classList.add('cell');
			cellElement.setAttribute('data-row', row);
			cellElement.setAttribute('data-col', col);
			if (board[row][col] === 'red') {
				const circle = document.createElement('div');
				circle.classList.add('circle', 'red');
				cellElement.appendChild(circle);
			} else if (board[row][col] === 'yellow') {
				const circle = document.createElement('div');
				circle.classList.add('circle', 'yellow');
				cellElement.appendChild(circle);
			}
			rowElement.appendChild(cellElement);
		}
		boardElement.appendChild(rowElement);
	}
	setupClickHandlers();
}
function setupClickHandlers() {
	const cells = document.querySelectorAll('.cell');
	cells.forEach((cell, index) => {
		const col = index % cols;
		cell.addEventListener('click', () => {
			placePiece(col);
		});
	});
}

結局、HTMLに直接data-rowとかdata-colを書いたので、もしかしていらなかったかも?


ゲーム終了時に勝利メッセージやリセットボタンを表示する関数です。

function endGame(winner, winningPieces) {
  const messageElement = document.getElementById('gameMessage');
  if (winner === 'Draw') {
    messageElement.innerText = 'draw...';
    messageElement.style.color = '';
  } else {
    messageElement.innerText = `${winner} wins!`;
    if (winner === 'red') {
      messageElement.style.color = '#dc3545';
    } else if (winner === 'yellow') {
      messageElement.style.color = '#ffc107';
    }
  }
  messageElement.style.display = 'block';
  if (winner !== 'Draw') {
    highlightWinningPieces(winningPieces);
    fireworks();
  }
  const resetButton = document.getElementById('resetButton');
  resetButton.style.display = 'block';
  const turnMessageElement = document.getElementById('turnMessage');
  turnMessageElement.style.display = 'none';
  gameActive = false;
  gameOver = true;
  const cells = document.querySelectorAll('.cell');
  cells.forEach(cell => {
    cell.style.pointerEvents = 'none';
  });
}

駒を置く時の関数です。

function updateTurnMessage() {
	const turnMessageElement = document.getElementById('turnMessage');
	if (currentPlayer === 'red') {
		turnMessageElement.textContent = 'red turn';
		turnMessageElement.style.color = '#dc3545';
	} else {
		turnMessageElement.textContent = 'yellow turn';
		turnMessageElement.style.color = '#ffc107';
	}
}
function placePiece(col) {
	if (!gameActive) return;
	let targetRow = null;
	for (let row = rows - 1; row >= 0; row--) {
		if (board[row][col] === null) {
			targetRow = row;
			break;
		}
	}
	if (targetRow !== null) {
		board[targetRow][col] = currentPlayer;
		const targetCell = document.querySelector(
			`.row:nth-child(${targetRow + 1}) .cell:nth-child(${col + 1})`);
		const pieceElement = document.createElement('div');
		pieceElement.classList.add('piece', currentPlayer);
		const cellWidth = targetCell.clientWidth;
		const cellHeight = targetCell.clientHeight;
		pieceElement.style.width = `${cellWidth}px`;
		pieceElement.style.height = `${cellHeight}px`;
		pieceElement.style.position = 'absolute';
		pieceElement.style.left = `${targetCell.offsetLeft}px`;
		pieceElement.style.top = `-${cellHeight}px`;
		const gameBoard = document.getElementById('gameBoard');
		gameBoard.appendChild(pieceElement);
		const targetTop = targetCell.offsetTop;
		setTimeout(() => {
			pieceElement.style.top = `${targetTop}px`;
		}, 50);
		setTimeout(() => {
			const winningCells = checkWinner(targetRow, col);
			if (winningCells) {
				endGame(currentPlayer, winningCells);
				setTimeout(() => {
					highlightWinningPieces(winningCells);
				}, 100);
			} else if (isBoardFull()) {
				endGame('Draw', []);
			} else {
				currentPlayer = currentPlayer === 'red' ? 'yellow' : 'red';
				updateTurnMessage();
			}
			renderBoard();
		}, 300);
	}
}
function setupClickHandlers() {
	const cells = document.querySelectorAll('.cell');
	cells.forEach((cell, index) => {
		const col = index % cols;
		cell.addEventListener('click', () => {
			placePiece(col);
		});
	});
}
updateTurnMessage();

ここはハマリポイントその①でした。本当は上から降ってきて着地する時にバウンドするアニメーションも入れたかったのですが、上手くいかなかったのであきらめました。


勝敗を判定する関数です。

function checkDirection(row, col, rowIncrement, colIncrement) {
	let count = 1;
	const winningPieces = [
		[row, col]
	];
	for (let i = 1; i <= 3; i++) {
		const newRow = row - i * rowIncrement;
		const newCol = col - i * colIncrement;
		if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols && board[
				newRow][newCol] === currentPlayer) {
			count++;
			winningPieces.push([newRow, newCol]);
		} else {
			break;
		}
	}
	for (let i = 1; i <= 3; i++) {
		const newRow = row + i * rowIncrement;
		const newCol = col + i * colIncrement;
		if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols && board[
				newRow][newCol] === currentPlayer) {
			count++;
			winningPieces.push([newRow, newCol]);
		} else {
			break;
		}
	}
	return count >= 4 ? winningPieces : null;
}
function checkWinner(row, col) {
	return (
		checkDirection(row, col, 1, 0) ||
		checkDirection(row, col, 0, 1) ||
		checkDirection(row, col, 1, 1) ||
		checkDirection(row, col, 1, -1)
	);
}

ハマリポイントその②です。斜めに4つ揃った時だけ勝利判定をしてくれなくて苦労しました。


どこが4つ揃ったのか、色を変えて分かるようにするための関数です。

let blinkingIntervals = [];
function highlightWinningPieces(winningCells) {
	winningCells.forEach(cell => {
		const [row, col] = cell;
		const targetCell = document.querySelector(
			`.cell[data-row="${row}"][data-col="${col}"]`);
		console.log(`Target cell: `, targetCell);
		if (targetCell) {
			targetCell.style.backgroundColor = 'lime';
		}
	});
}

ハマリポイントその③です。何度やっても「上から2行目だけ色が変わらない」という状態でした。

「nth-childは1から始まるが、colやrowは0から始まる」というのが原因だったみたいです。

最初は「揃った4つの駒を明滅させる」という風にしていたのですが何かぎこちない感じだったので「4つのセルの背景色を変更する」という感じにしました。


最後は、盤面をリセットする関数です。

function resetGame() {
  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < cols; col++) {
      board[row][col] = null;
    }
  }
  blinkingIntervals.forEach(intervalId => clearInterval(intervalId));
  blinkingIntervals = [];
  stopFireworks();
  currentPlayer = 'red';
  gameActive = true;
  gameOver = false;
  renderBoard();
  const messageElement = document.getElementById('gameMessage');
  messageElement.innerText = '';
  messageElement.style.display = 'none';
  const turnMessageElement = document.getElementById('turnMessage');
  turnMessageElement.style.display = 'block';
  updateTurnMessage();
  const resetButton = document.getElementById('resetButton');
  resetButton.style.display = 'none';
  const cells = document.querySelectorAll('.cell');
  cells.forEach(cell => {
    cell.style.pointerEvents = 'auto';
    cell.style.backgroundColor = '';
  });
}

今後の課題

CPU戦

・駒や盤面の見た目をいい感じにする

・バウンドするアニメーション

・駒を置くときにダブルクリックすると...?

この記事を書いた人
みやの
Web・DTP担当

Contact Us

ご意見、ご相談、料金のお見積もりなど、お気軽にお問い合わせください。

お問い合わせはこちら

TOP