はじめに
10年以上前にJavaScriptで作ったブロック落下ゲームを、作り直しました。
たまにブロックがすり抜けたりといったバグがあり、まだまだ改修の余地があります。
操作方法
- 移動: 矢印キー
- 回転: スペースキー
テトリスの著作権
テトリスの著作権侵害を考慮して、ピットの縦横を変更しています。
また、テトリミノに1x1のブロックを追加しています。
コード
falling-block.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ブロック落下ゲーム</title>
<style>
/* レイアウトの中央寄せと基本的なスタイル設定 */
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f0f0f0;
background-image: linear-gradient(45deg, #f3f3f3 25%, transparent 25%, transparent 75%, #f3f3f3 75%, #f3f3f3),
linear-gradient(45deg, #f3f3f3 25%, transparent 25%, transparent 75%, #f3f3f3 75%, #f3f3f3);
background-size: 60px 60px;
background-position: 0 0, 30px 30px;
}
/* ゲームコンテナの基本スタイル設定 */
#game-container {
display: none;
text-align: center;
position: relative;
background-color: rgba(255, 255, 255, 0.8);
padding: 20px;
border-radius: 15px;
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
/* スタートボタンのスタイル */
#start-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 15px 30px;
font-size: 24px;
background-color: #3498db;
color: white;
border: none;
border-radius: 30px;
cursor: pointer;
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
/* ボタンのホバー・アクティブ時のスタイル */
#start-button:hover {
background-color: #2980b9;
transform: translate(-50%, -50%) translateY(-2px);
box-shadow: 0 7px 20px rgba(52, 152, 219, 0.6);
}
#start-button:active {
transform: translate(-50%, -50%) translateY(1px);
box-shadow: 0 3px 10px rgba(52, 152, 219, 0.4);
}
/* ゲーム情報の表示エリア */
#info {
margin-top: 15px;
font-size: 20px;
font-weight: bold;
color: #333;
display: flex;
justify-content: space-between;
align-items: center;
}
/* タイマー表示 */
#timer {
font-size: 20px;
font-weight: bold;
color: #333;
margin-left: 20px;
}
/* ゲーム盤のスタイル */
#game-board {
border-collapse: collapse;
margin: 0 auto;
background-color: #fafafa;
border: 2px solid #333;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
/* セルの基本スタイル */
#game-board td {
width: 25px;
height: 25px;
border: 1px solid #e0e0e0;
}
/* ゲームオーバー画面のスタイル */
#game-over {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(255, 255, 255, 0.95);
padding: 30px;
border-radius: 15px;
text-align: center;
display: none;
width: 280px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
/* ゲームオーバー時のメッセージ */
#game-over h2 {
margin-top: 0;
font-size: 32px;
white-space: nowrap;
color: #e74c3c;
text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
}
/* リプレイボタン */
#replay-button {
margin-top: 15px;
padding: 12px 25px;
font-size: 18px;
cursor: pointer;
white-space: nowrap;
background-color: #3498db;
color: white;
border: none;
border-radius: 25px;
transition: all 0.3s ease;
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
#replay-button:hover {
background-color: #2980b9;
transform: translateY(-2px);
box-shadow: 0 7px 20px rgba(52, 152, 219, 0.6);
}
#replay-button:active {
transform: translateY(1px);
box-shadow: 0 3px 10px rgba(52, 152, 219, 0.4);
}
</style>
</head>
<body>
<!-- スタートボタン -->
<button id="start-button">スタート</button>
<!-- ゲームコンテナ -->
<div id="game-container">
<div id="info">
<div>スコア: <span id="score">0</span></div>
<div id="timer">プレイ時間: 0秒</div>
</div>
<div id="view"></div>
<div id="game-over">
<h2>Game Over!!</h2>
<p id="final-score"></p>
<p id="final-time"></p>
<button id="replay-button">リプレイ</button>
</div>
</div>
<!-- 効果音とBGMの追加 -->
<audio id="game-over-sound" src="game-over.mp3" preload="auto"></audio>
<audio id="clear-block-sound" src="clear-block.mp3" preload="auto"></audio>
<audio id="block-fixed-sound" src="block-fixed-sound.mp3" preload="auto"></audio>
<audio id="bgm" src="bgm.mp3" preload="auto" loop></audio>
<audio id="rotate-sound" src="rotate-sound.mp3" preload="auto"></audio>
<script>
document.addEventListener('DOMContentLoaded', () => {
// ゲームの基本設定
const width = 14;
const height = 23;
let speed = 20;
let fills = {};
let cells;
let block;
let top, left, angle, score = 0, tick;
let gameLoop;
let startTime;
let playTimeInterval;
let speedIncreaseInterval;
let elapsedTime = 0;
let clearingRows = false; // 行削除フラグ
let isGameOver = false; // ゲームオーバーフラグ
// 効果音の参照
const gameOverSound = document.getElementById('game-over-sound');
const clearBlockSound = document.getElementById('clear-block-sound');
const blockFixedSound = document.getElementById('block-fixed-sound');
const bgm = document.getElementById('bgm');
const rotateSound = document.getElementById('rotate-sound');
// ブロックのデータ
const blocks = [
{ color: '#FF6B6B', angles: [[-1,1,2], [-width,width,width*2], [-2,-1,1], [-width*2,-width,width]] },
{ color: '#4ECDC4', angles: [[-width-1,-width,-1]] },
{ color: '#45B7D1', angles: [[-width,1-width,-1], [-width,1,width+1], [1,width-1,width], [-width-1,-1,width]] },
{ color: '#F7B731', angles: [[-width-1,-width,1], [1-width,1,width], [-1,width,width+1], [-width,-1,width]] },
{ color: '#7B68EE', angles: [[-width-1,-1,1], [-width,1-width,width], [-1,1,width+1], [-width,width-1,width]] },
{ color: '#5CDB95', angles: [[1-width,-1,1], [-width,width,width+1], [-1,1,width-1], [-width-1,-width,width]] },
{ color: '#FF8C94', angles: [[-width,-1,1], [-width,1,width], [-1,1,width], [-width,-1,width]] },
{ color: '#BADA55', angles: [[0]] }
];
const keys = {};
// ゲームボードの生成関数
const generateBoard = () => {
const html = ['<table id="game-board">'];
for (let y = 0; y < height; y++) {
html.push('<tr>');
for (let x = 0; x < width; x++) {
if (x === 0 || x === width - 1 || y === height - 1) {
html.push('<td style="background-color:#ddd"></td>');
fills[x + y * width] = '#ddd';
} else {
html.push('<td></td>');
}
}
html.push('</tr>');
}
html.push('</table>');
document.getElementById('view').innerHTML = html.join('');
cells = document.getElementsByTagName('td');
};
// ゲーム初期化関数
const initGame = () => {
isGameOver = false;
fills = {};
generateBoard();
top = 2;
left = Math.floor(width / 2);
block = blocks[Math.floor(Math.random() * blocks.length)];
angle = 0;
score = 0;
tick = 0;
speed = 20;
clearingRows = false;
document.getElementById('game-over').style.display = 'none';
document.getElementById('timer').textContent = "プレイ時間: 0秒";
document.getElementById('score').textContent = score;
startTime = Date.now();
elapsedTime = 0;
playTimeInterval = setInterval(updateTime, 1000);
speedIncreaseInterval = setInterval(increaseSpeed, 10000);
playBGM();
gameLoop = requestAnimationFrame(update);
};
// 時間更新
const updateTime = () => {
elapsedTime = Math.floor((Date.now() - startTime) / 1000);
document.getElementById('timer').textContent = `プレイ時間: ${elapsedTime}秒`;
};
// 一定時間ごとのスピード増加
const increaseSpeed = () => {
if (speed > 5) {
speed--;
}
};
// ゲームオーバー処理
const showGameOver = () => {
isGameOver = true;
document.getElementById('game-over').style.display = 'block';
document.getElementById('final-score').textContent = `最終スコア: ${score}`;
document.getElementById('final-time').textContent = `プレイ時間: ${elapsedTime}秒`;
cancelAnimationFrame(gameLoop);
gameOverSound.play();
bgm.pause();
bgm.currentTime = 0;
clearInterval(playTimeInterval);
clearInterval(speedIncreaseInterval);
};
// BGM再生
const playBGM = () => {
const context = new (window.AudioContext || window.webkitAudioContext)();
const unlock = () => {
if (context.state === 'suspended') {
context.resume().then(() => {
bgm.play();
});
} else {
bgm.play();
}
document.removeEventListener('keydown', unlock);
document.removeEventListener('click', unlock);
};
document.addEventListener('keydown', unlock);
document.addEventListener('click', unlock);
};
// キーボード入力の処理
document.addEventListener('keydown', (e) => {
if (isGameOver) return;
switch(e.key) {
case 'ArrowLeft': keys.left = true; break;
case 'ArrowRight': keys.right = true; break;
case 'ArrowDown': keys.down = true; break;
case ' ':
keys.rotate = true;
rotateSound.play();
setTimeout(() => {
rotateSound.pause();
rotateSound.currentTime = 0;
}, 100);
break;
}
});
// リプレイボタンの処理
document.getElementById('replay-button').addEventListener('click', initGame);
// 行削除処理
const clearRowWithEffect = (y, callback) => {
let x = 1;
clearBlockSound.play();
const clearNextCell = () => {
if (x < width - 1) {
cells[y * width + x].style.backgroundColor = '#fafafa';
fills[y * width + x] = null;
x++;
setTimeout(clearNextCell, 50);
} else {
clearBlockSound.pause();
clearBlockSound.currentTime = 0;
callback();
}
};
clearNextCell();
};
// 行を下にシフトする関数
const shiftRowsDown = (rowsToClear) => {
rowsToClear.sort((a, b) => a - b);
rowsToClear.forEach(clearedRow => {
for (let y = clearedRow; y >= 1; y--) {
for (let x = 1; x < width - 1; x++) {
fills[y * width + x] = fills[(y - 1) * width + x];
}
}
for (let x = 1; x < width - 1; x++) {
fills[x] = null;
}
});
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
cells[y * width + x].style.backgroundColor = fills[y * width + x] || '';
}
}
};
// ゲームループ
const update = () => {
tick++;
if (!clearingRows) {
let [top0, left0, angle0] = [top, left, angle];
if (tick % speed === 0 && !clearingRows) {
top++;
}
if (keys.left && !clearingRows) {
const newLeft = left - 1;
if (!checkCollision(top, newLeft, angle)) {
left = newLeft;
}
}
if (keys.right && !clearingRows) {
const newLeft = left + 1;
if (!checkCollision(top, newLeft, angle)) {
left = newLeft;
}
}
if (keys.down && !clearingRows) top++;
if (keys.rotate && !clearingRows) {
const newAngle = angle + 1;
if (!checkCollision(top, left, newAngle)) {
angle = newAngle;
}
}
keys.left = keys.right = keys.down = keys.rotate = false;
// 衝突判定関数
function checkCollision(checkTop, checkLeft, checkAngle) {
const parts = block.angles[checkAngle % block.angles.length];
for (let i = -1; i < parts.length; i++) {
const offset = parts[i] || 0;
const cellIndex = checkTop * width + checkLeft + offset;
const cellX = (cellIndex % width);
if (cellX <= 0 || cellX >= width - 1 || fills[cellIndex]) {
return true;
}
}
return false;
}
let parts = block.angles[angle % block.angles.length];
let isBlockOnGround = false;
for (let i = -1; i < parts.length; i++) {
const offset = parts[i] || 0;
const cellIndex = top * width + left + offset;
if (fills[cellIndex] && cellIndex >= top * width) {
isBlockOnGround = true;
break;
}
}
if (isBlockOnGround) {
if (tick % speed === 0 && !clearingRows) {
blockFixedSound.play();
const parts0 = block.angles[angle0 % block.angles.length];
for (let j = -1; j < parts0.length; j++) {
const offset = parts0[j] || 0;
fills[top0 * width + left0 + offset] = block.color;
}
let cleans = 0;
const rowsToClear = [];
for (let y = height - 2; y >= 0; y--) {
const filled = Array.from({length: width - 2}, (_, x) => fills[y * width + x + 1]).every(Boolean);
if (filled) {
rowsToClear.push(y);
cleans++;
}
}
if (cleans > 0) {
clearingRows = true;
let clearIndex = 0;
const clearRowsSequentially = () => {
if (clearIndex < rowsToClear.length) {
clearRowWithEffect(rowsToClear[clearIndex], () => {
clearIndex++;
clearRowsSequentially();
});
} else {
setTimeout(() => {
shiftRowsDown(rowsToClear);
clearingRows = false;
block = blocks[Math.floor(Math.random() * blocks.length)];
[left, top, angle] = [Math.floor(width / 2), 2, 0];
if (fills[top * width + left]) {
showGameOver();
return;
}
}, 500);
}
};
clearRowsSequentially();
score += Math.pow(10, cleans) * 10;
document.getElementById('score').textContent = score;
} else {
block = blocks[Math.floor(Math.random() * blocks.length)];
[left, top, angle] = [Math.floor(width / 2), 2, 0];
if (fills[top * width + left]) {
showGameOver();
return;
}
}
} else {
[left, top, angle] = [left0, top0, angle0];
}
}
}
// 描画処理
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
cells[y * width + x].style.backgroundColor = fills[y * width + x] || '';
}
}
if (!clearingRows) {
let parts = block.angles[angle % block.angles.length];
for (let i = -1; i < parts.length; i++) {
const offset = parts[i] || 0;
cells[top * width + left + offset].style.backgroundColor = block.color;
}
}
gameLoop = requestAnimationFrame(update);
};
// ゲーム開始時の処理
document.getElementById('start-button').addEventListener('click', () => {
document.getElementById('start-button').style.display = 'none';
document.getElementById('game-container').style.display = 'block';
initGame();
});
});
</script>
</body>
</html>