ブラウザ版テトリスの実装を解説
テトリミノの生成、移動と回転、衝突判定、ライン消去、レベルアップ、BGMと効果音まで、ゲームページの実装を順番に紹介します。
テトリスは、落下するブロックを横一列にそろえて消していく落ちものパズルです。ルールは分かりやすい一方で、盤面と操作中のブロックを別々に管理すること、回転後の形で衝突を調べること、ライン消去の演出中に入力を止めることなど、ゲーム実装の基本要素が含まれています。
今回のゲームは、PHPで共通ヘッダーと広告を読み込み、ゲーム本体はCanvasとJavaScriptで動かしています。外部ライブラリを使わず、10列×20行の盤面を毎フレーム描画する構成です。
盤面と操作中のブロックを分けて管理する
盤面は10行ではなく、10列×20行の二次元配列です。まだブロックがないセルはnull、積み上がったセルには色を保存します。落下中のテトリミノは盤面へすぐに書き込まず、currentとして別に持ちます。
const COLS = 10, ROWS = 20, CELL = 24;
function emptyGrid(){
const grid = [];
for(let row = 0; row < ROWS; row++){
grid.push(new Array(COLS).fill(null));
}
return grid;
}
let grid = emptyGrid();
let current, next;
let score = 0, lines = 0, level = 1;gridには確定したブロックだけを置き、currentは描画時に重ねます。この分離により、移動や回転を試すときに盤面を書き換えて戻す必要がなくなります。次のブロックはnextとして先に生成し、サイドパネルへ表示します。
テトリミノを行列で表現する
7種類のテトリミノは、0と1からなる二次元配列で定義しています。1のセルだけをブロックとして描画するため、形状の追加や回転処理を共通化できます。
const COLORS = {
I: '#5ee7c9', O: '#ffd166', T: '#c77dff',
S: '#7bd88f', Z: '#ff6b9d', J: '#5b8def', L: '#ff9f5b'
};
const SHAPES = {
I: [[1,1,1,1]],
O: [[1,1],[1,1]],
T: [[0,1,0],[1,1,1]],
S: [[0,1,1],[1,1,0]],
Z: [[1,1,0],[0,1,1]],
J: [[1,0,0],[1,1,1]],
L: [[0,0,1],[1,1,1]]
};新しいピースを作るときは、形状配列をmapで複製します。元のSHAPESを直接変更しないことで、回転したピースを次回以降も正しい初期形状から生成できます。
行列を回転して壁際の移動にも対応する
時計回りの回転は、元の行列を列ごとに読み、各列を上下反転して新しい行にする処理です。回転後の行列を作ったら、そのまま適用せず、現在位置で衝突しないかを確認します。
function rotateMatrix(matrix){
const rows = matrix.length;
const cols = matrix[0].length;
const rotated = [];
for(let col = 0; col < cols; col++){
const row = [];
for(let r = rows - 1; r >= 0; r--){
row.push(matrix[r][col]);
}
rotated.push(row);
}
return rotated;
}壁際では回転後の幅が変わるため、現在位置だけでは回転できないことがあります。そこで、中央、左右1マス、左右2マスの順にずらす候補を試します。いずれかの候補で衝突しなければ、その位置と行列を採用します。
function rotate(){
const rotated = rotateMatrix(current.matrix);
const kicks = [0, -1, 1, -2, 2];
for(const offset of kicks){
if(!collide(rotated, current.x + offset, current.y)){
current.matrix = rotated;
current.x += offset;
playRotateSound();
return;
}
}
}移動前に衝突を判定する
衝突判定では、テトリミノの1セルごとに盤面上の座標へ変換します。左右の壁、床、すでに積まれたセルのいずれかに当たる場合はtrueを返します。上部から出現する際に行番号が負になるため、上端より上にあるセルは盤面の値を参照しません。
function collide(matrix, x, y){
for(let row = 0; row < matrix.length; row++){
for(let col = 0; col < matrix[row].length; col++){
if(!matrix[row][col]) continue;
const boardX = x + col;
const boardY = y + row;
if(boardX < 0 || boardX >= COLS || boardY >= ROWS){
return true;
}
if(boardY >= 0 && grid[boardY][boardX]) return true;
}
}
return false;
}移動や回転では、まず変更後の座標をcollideへ渡し、問題がなければcurrent.xやcurrent.matrixを更新します。先に盤面を変更しないことが、判定をシンプルにするポイントです。
落下、固定、次のピースをつなぐ
落下処理は、毎フレームの経過時間をdropTimerへ加算し、レベルに応じたdropIntervalを超えたら1マス下へ移動します。下へ移動できないときは、操作中のピースを盤面へ固定してから次の処理へ進みます。
function merge(){
const {matrix, x, y, color} = current;
for(let row = 0; row < matrix.length; row++){
for(let col = 0; col < matrix[row].length; col++){
if(matrix[row][col]){
const boardY = y + row;
const boardX = x + col;
if(boardY >= 0) grid[boardY][boardX] = color;
}
}
}
}
function softDrop(){
if(!collide(current.matrix, current.x, current.y + 1)){
current.y++;
} else {
lockPiece();
}
}lockPieceではmergeのあとにそろった行を調べます。行がなければすぐ次のピースを出し、行があれば消去演出用のclearing状態へ切り替えます。固定処理と次の出現処理を分けておくと、ライン消去のアニメーションを間に挟めます。
ライン消去をアニメーションと状態管理で処理する
盤面の全セルが埋まった行を探し、対象行をclearingRowsへ保存します。すぐに削除せず、380ミリ秒の間は対象行を点滅させます。この間は通常の落下や操作を行わないため、表示と内部データのずれを防げます。
function findFullRows(){
const full = [];
for(let row = 0; row < ROWS; row++){
if(grid[row].every(cell => cell !== null)){
full.push(row);
}
}
return full;
}演出が終わったら、対象行を下の行から順にspliceで削除し、同じ数だけ空行を先頭へ追加します。下から削除するのは、先に削除した行によって後続行のインデックスが変わる影響を避けるためです。
function finishClear(){
const rows = clearingRows.slice().sort((a, b) => b - a);
for(const row of rows){
grid.splice(row, 1);
grid.unshift(new Array(COLS).fill(null));
}
const points = [0, 100, 300, 500, 800];
score += (points[clearingRows.length] || clearingRows.length * 200) * level;
lines += clearingRows.length;
level = 1 + Math.floor(lines / 10);
dropInterval = Math.max(100, 800 - (level - 1) * 70);
}1行、2行、3行、4行の順に基本点を設定し、現在のレベルを掛けて得点にしています。10行消すごとにレベルを上げ、落下間隔を短くすることで、ゲームが進むほど操作の判断を速く求められるようにしています。
requestAnimationFrameでゲームループを作る
ゲームループでは、前フレームからの経過時間を使って落下タイマーとライン消去タイマーを進めます。描画は毎フレーム行いますが、状態がclearingのときはライン消去の完了だけを待ち、通常のピース操作を止めます。
function loop(time){
if(!running) return;
const delta = time - lastTime;
lastTime = time;
if(state === 'clearing'){
clearTimer += delta;
draw();
if(clearTimer >= CLEAR_DURATION) finishClear();
rafId = requestAnimationFrame(loop);
return;
}
dropTimer += delta;
if(dropTimer > dropInterval){
dropTimer = 0;
softDrop();
}
draw();
rafId = requestAnimationFrame(loop);
}フレーム数ではなくdeltaで時間を測るため、画面の更新頻度が異なる環境でも落下速度が大きく変わりません。停止中はrunningをfalseにしてループを抜け、再開時にlastTimeを現在時刻へ戻しています。
キー入力とゲーム状態を分ける
左右移動、回転、ソフトドロップ、ハードドロップはキーボードイベントから同じゲーム関数を呼び出します。入力処理の中で直接Canvasを書き換えず、状態を更新して最後にdrawを呼ぶ構成にしています。
document.addEventListener('keydown', event => {
if(event.code === 'KeyP'){
event.preventDefault();
togglePause();
return;
}
if(!running || state !== 'playing') return;
switch(event.code){
case 'ArrowLeft': move(-1); break;
case 'ArrowRight': move(1); break;
case 'ArrowUp': rotate(); break;
case 'ArrowDown': softDrop(); break;
case 'Space': hardDrop(); break;
}
event.preventDefault();
draw();
});stateがplayingでないときは、落下中のピースへの操作を受け付けません。ポーズはrunningを止め、ゲームオーバーではstateをoverに変更します。表示用のオーバーレイも同じ状態変更から更新するため、画面とゲーム内部の状態を一致させられます。
Canvasへ盤面と次のピースを描画する
描画処理では、最初にCanvasを消去し、薄いグリッド線、確定済みのセル、操作中のピースの順に描きます。ライン消去中は対象行だけ色と透明度を変え、点滅しているように見せています。
function drawCell(context, x, y, color, alpha){
const px = x * CELL;
const py = y * CELL;
context.save();
if(alpha !== undefined) context.globalAlpha = alpha;
context.fillStyle = color;
context.fillRect(px + 1, py + 1, CELL - 2, CELL - 2);
context.fillStyle = 'rgba(255,255,255,0.18)';
context.fillRect(px + 1, py + 1, CELL - 2, 4);
context.restore();
}次のピースは別Canvasの中央に配置し、プレイヤーが先の形状を確認できるようにしています。内部状態から毎回描画するため、移動後に古い位置のブロックが残ることもありません。
BGMと効果音をWeb Audio APIで作る
音声ファイルを読み込む代わりに、Web Audio APIの発振器で短い音を生成しています。BGMは16ステップの音階を220ミリ秒ごとに再生し、移動、回転、落下、ライン消去、ゲームオーバーにはそれぞれ異なる音を割り当てています。
function playTone(destination, frequency, duration, type, peak){
if(!audioCtx) return;
const oscillator = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const now = audioCtx.currentTime;
oscillator.type = type;
oscillator.frequency.value = frequency;
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(peak, now + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.connect(gain);
gain.connect(destination);
oscillator.start(now);
oscillator.stop(now + duration + 0.02);
}音量はマスター、BGM、効果音のゲインに分けています。ブラウザの自動再生制限を避けるため、AudioContextはスタートボタンのクリック時に初期化し、必要ならresumeします。BGMと効果音は別々にミュートできるため、音楽だけを止めて操作音を残すこともできます。
実装時に確認したいこと
テトリスは、通常の落下だけでなく、壁際の回転や複数行消去などの境界条件で不具合が見つかりやすいゲームです。次の操作を実際に試して確認します。
・左右の壁や床からテトリミノがはみ出さない
・壁際や積み上がったブロックの近くで回転できる
・ハードドロップで最下段まで移動してから固定される
・1行から4行まで正しく消去される
・消去中に移動や回転が実行されない
・10行消すたびにレベルと落下速度が変わる
・盤面上部まで積み上がるとゲームオーバーになる
・Pキーで停止と再開ができる
・BGMと効果音を個別にミュートできる特に、回転後の行列をそのまま盤面へ反映してしまうと、壁や他のブロックに重なることがあります。まず仮の行列と座標でcollideを呼び、成功したときだけ状態を変更する流れを守ることが重要です。
まとめ
テトリスの処理は、次のゲームサイクルに整理できます。
キーボード入力を受け取る
↓
操作中のテトリミノを移動・回転する
↓
衝突しないか確認し、落下または盤面へ固定する
↓
そろった行を演出付きで消去する
↓
スコア、ライン数、レベルを更新する
↓
Canvasへ盤面と次のピースを描画する
↓
requestAnimationFrameで次のフレームへこの構造を理解すると、落ちものパズルの盤面を広げる、特殊ブロックを追加する、ホールド機能を加えるといった改造にも取り組めます。実際に操作しながら確認したい場合は、テトリスのゲームページをご覧ください。