セ〇アの四目並べ
2025年1月5日
あけましておめでとうございます。
ケミストのWeb担当みやのです。
今年もよろしくお願いします。
「Web」記事では、これまでに得たWebに関する知識を記録として残していきたいと思います。
今回は100円ショップ「セリア」で買ったコレ!

頭脳で勝負!立体色並べ
これを作ってみたいと思います。
赤と黄色の駒を交互に置いていって、縦か横か斜めに4つ揃った方の勝ちです。
要は6行×7列のマス目を使った四目並べなのですが、盤面は垂直になっていて、駒は重力に従って一番下まで落ちるので、かなり頭を使うゲームです。
ぜひ2人で遊んでみてください。
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戦
・駒や盤面の見た目をいい感じにする
・バウンドするアニメーション
・駒を置くときにダブルクリックすると...?