パズドラ的なゲーム(オーブパズル)とコード
趣旨
・教室のAIプログラミング教材用にできるだけシンプルなゲームをつくりたい
・Pythonでもいいけど、HTMLの方が公開しやすいので、HTML5でつくる
オーブパズルゲーム
・パズドラのように部品を動かして消していくゲーム。
「できるだけシンプルに」と指定すると入れ替わるエフェクトや音がないので、後で追加したバージョン
パズドラ的なゲームのコード(タップで開く/閉じる)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 オーブパズルゲーム (効果音追加版)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
<style>
/* CSSスタイルを記述します。HTML要素の見た目を定義します */
body {
/* ページ全体のスタイル */
font-family: 'Inter', sans-serif;
/* フォントをInterに設定し、それが利用できない場合は一般的なサンセリフフォントを使用します */
touch-action: none; /* タッチデバイスでのドラッグ中のスクロールを防止します */
}
canvas {
/* ゲームの描画領域であるcanvas要素のスタイル */
display: block;
/* ブロック要素として表示します */
margin: 0 auto;
/* 上下のマージンは0、左右のマージンは自動(中央揃え) */
background-color: #2c3e50; /* 背景色を濃い青灰色に設定します */
border-radius: 8px;
/* 角を丸くします */
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
/* 影を付けます */
}
.game-container {
/* ゲーム全体を囲むコンテナのスタイル */
display: flex;
/* Flexboxレイアウトを使用します */
flex-direction: column;
/* 子要素を縦方向に並べます */
align-items: center;
/* 子要素を中央揃え(交差軸方向)にします */
justify-content: center;
/* 子要素を中央揃え(主軸方向)にします */
min-height: 100vh;
/* 最小の高さをビューポートの高さの100%にします */
background-color: #34495e; /* 背景色を少し明るい青灰色にします */
padding: 16px;
/* 内側の余白を設定します */
}
.info-panel {
/* スコアやコンボ情報を表示するパネルのスタイル */
background-color: #ecf0f1; /* 背景色を明るい灰色にします */
color: #2c3e50; /* 文字色を濃い青灰色にします */
padding: 12px 20px;
/* 内側の余白を設定します */
border-radius: 8px;
/* 角を丸くします */
margin-bottom: 16px;
/* 下側の外側の余白を設定します */
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
/* 影を付けます */
text-align: center;
/* テキストを中央揃えにします */
}
.info-panel p {
/* 情報パネル内の段落のスタイル */
margin: 4px 0;
/* 上下のマージンを設定します */
font-size: 1.1rem;
/* フォントサイズを設定します */
}
.info-panel strong {
/* 情報パネル内の強調テキスト(スコアやコンボ数)のスタイル */
color: #2980b9; /* 文字色を青にします */
}
.instructions {
/* 操作説明エリアのスタイル */
background-color: #95a5a6; /* 背景色を中間的な灰色にします */
color: #ffffff;
/* 文字色を白にします */
padding: 12px;
/* 内側の余白を設定します */
border-radius: 8px;
/* 角を丸くします */
margin-top: 16px;
/* 上側の外側の余白を設定します */
max-width: 600px;
/* 最大幅を設定します */
text-align: left;
/* テキストを左揃えにします */
font-size: 0.9rem;
/* フォントサイズを設定します */
}
.instructions h3 {
/* 操作説明エリア内の見出し(h3)のスタイル */
font-size: 1.2rem;
/* フォントサイズを設定します */
margin-bottom: 8px;
/* 下側の外側の余白を設定します */
color: #ecf0f1;
/* 文字色を明るい灰色にします */
}
.instructions ul {
/* 操作説明エリア内のリスト(ul)のスタイル */
list-style-type: disc;
/* リストマーカーを円盤にします */
margin-left: 20px;
/* 左側の外側の余白を設定します */
}
.timer-bar-container {
/* タイマーバーのコンテナのスタイル */
width: 100%;
/* 幅を100%にします */
max-width: 300px; /* ボードの一般的な幅に合わせます */
height: 10px;
/* 高さを設定します */
background-color: #7f8c8d; /* 背景色を灰色にします */
border-radius: 5px;
/* 角を丸くします */
margin-bottom: 10px;
/* 下側の外側の余白を設定します */
overflow: hidden;
/* はみ出した部分を隠します */
}
.timer-bar {
/* タイマーバー本体のスタイル */
height: 100%;
/* 高さを100%にします */
width: 100%;
/* 幅を100%にします(JavaScriptで動的に変更されます) */
background-color: #2ecc71; /* 背景色を緑にします */
border-radius: 5px;
/* 角を丸くします */
transition: background-color 0.2s linear, width 0.05s linear; /* 背景色と幅の変化を滑らかにします */
}
.message-overlay {
/* メッセージ表示用のオーバーレイのスタイル */
position: fixed;
/* 位置を固定します(スクロールしても動かない) */
top: 0;
left: 0;
/* 画面の左上に配置します */
width: 100%;
height: 100%;
/* 画面全体を覆うようにします */
background-color: rgba(0,0,0,0.7);
/* 半透明の黒い背景にします */
display: flex;
/* Flexboxレイアウトを使用します */
align-items: center;
/* 子要素(メッセージボックス)を垂直方向に中央揃えにします */
justify-content: center;
/* 子要素(メッセージボックス)を水平方向に中央揃えにします */
z-index: 1000;
/* 重なり順を最前面にします */
opacity: 0;
/* 初期状態では透明にします */
visibility: hidden;
/* 初期状態では非表示にします */
transition: opacity 0.3s ease, visibility 0.3s ease;
/* 透明度と表示状態の変化を滑らかにします */
}
.message-overlay.visible {
/* メッセージオーバーレイが表示されるときのスタイル */
opacity: 1;
/* 不透明にします */
visibility: visible;
/* 表示します */
}
.message-box {
/* メッセージボックス本体のスタイル */
background-color: white;
/* 背景色を白にします */
padding: 20px 30px;
/* 内側の余白を設定します */
border-radius: 8px;
/* 角を丸くします */
text-align: center;
/* テキストを中央揃えにします */
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
/* 影を付けます */
}
.message-box h2 {
/* メッセージボックス内の見出し(h2)のスタイル */
margin-top: 0;
/* 上側のマージンを0にします */
color: #333;
/* 文字色を濃い灰色にします */
}
.message-box button {
/* メッセージボックス内のボタンのスタイル */
background-color: #3498db; /* 背景色を青にします */
color: white;
/* 文字色を白にします */
border: none;
/* 枠線を消します */
padding: 10px 20px;
/* 内側の余白を設定します */
border-radius: 5px;
/* 角を丸くします */
cursor: pointer;
/* マウスカーソルをポインターにします */
font-size: 1rem;
/* フォントサイズを設定します */
margin-top: 15px;
/* 上側の外側の余白を設定します */
transition: background-color 0.2s;
/* 背景色の変化を滑らかにします */
}
.message-box button:hover {
/* メッセージボックス内のボタンにマウスオーバーしたときのスタイル */
background-color: #2980b9; /* 背景色を少し濃い青にします */
}
</style>
</head>
<body class="bg-gray-800">
<div class="game-container">
<div class="info-panel">
<p>スコア: <strong id="score">0</strong></p>
<p>コンボ: <strong id="combo">0</strong></p>
</div>
<div class="timer-bar-container">
<div id="timerBar" class="timer-bar"></div>
</div>
<canvas id="gameCanvas"></canvas>
<div class="instructions">
<h3>遊び方</h3>
<ul>
<li>オーブをタップ(クリック)して掴みます。</li>
<li>掴んだオーブをドラッグして、盤面上で自由に動かせます。</li>
<li>制限時間内に同じ色のオーブを縦か横に3つ以上並べると消えます。</li>
<li>たくさんコンボを繋げて高得点を目指しましょう!</li>
<li>操作が終わるか時間切れになると、オーブが消えて新しいオーブが補充されます。</li>
</ul>
</div>
</div>
<div id="messageOverlay" class="message-overlay">
<div class="message-box">
<h2 id="messageTitle"></h2>
<p id="messageText"></p>
<button id="messageButton">OK</button>
</div>
</div>
<script>
// --- ゲーム設定 ---
// これらの値はゲームの基本的なルールや見た目を定義します。
const COLS = 6; // ボードの列数
const ROWS = 5; // ボードの行数
const ORB_SIZE_BASE = 50; // オーブの基本サイズ(ピクセル単位)。画面サイズに応じて調整されます。
const ORB_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FED766', '#9B59B6', '#2ECC71']; // オーブの色の配列
const MOVE_TIME_LIMIT = 5000; // オーブを動かせる制限時間(ミリ秒単位、5秒)
const EMPTY_SLOT = -1; // ボード上の空のスロットを表す値
const ORB_SWAP_ANIMATION_DURATION = 100; // オーブ交換アニメーションの時間(ミリ秒)
// --- グローバル変数 ---
// ゲーム全体で使われる変数を宣言します。
let canvas, ctx; // canvas要素とその2D描画コンテキスト
let board = []; // ゲームボードの状態を保持する2次元配列
let score = 0; // 現在のスコア
let currentCombo = 0; // 現在のコンボ数
let selectedOrb = null; // 現在選択(ドラッグ)中のオーブの情報
let isDragging = false; // オーブをドラッグ中かどうかを示すフラグ
let moveTimerActive = false; // 移動タイマーが作動中かどうかを示すフラグ
let timeLeft = MOVE_TIME_LIMIT; // 残り移動時間
let orbSize; // 実際のオーブのサイズ(画面サイズに応じて計算される)
let boardWidth, boardHeight; // ボードの実際の幅と高さ
let path = []; // ドラッグ中のオーブの軌跡
let activeAnimations = []; // 現在再生中のアニメーションのリスト
let gameLoopId; // ゲームループのID(requestAnimationFrameの戻り値)
let lastTimerUpdateTime = 0; // タイマーの前回更新時刻
let processingMatches = false; // マッチ処理中かどうかを示すフラグ
let swapSynth; // オーブ交換時の効果音を再生するためのTone.jsシンセサイザー
let audioContextStarted = false; // AudioContextが開始されたかどうかを示すフラグ
// DOM要素の参照
// HTML要素をJavaScriptから操作するために、あらかじめ取得しておきます。
let scoreDisplay, comboDisplay, timerBarDisplay, messageOverlay, messageTitle, messageText, messageButton;
// --- 初期化処理 ---
// ページの読み込みが完了したときに実行される関数です。
window.onload = () => {
// HTML要素を取得
canvas = document.getElementById('gameCanvas'); // canvas要素を取得
ctx = canvas.getContext('2d'); // 2D描画コンテキストを取得
scoreDisplay = document.getElementById('score'); // スコア表示要素を取得
comboDisplay = document.getElementById('combo'); // コンボ表示要素を取得
timerBarDisplay = document.getElementById('timerBar'); // タイマーバー表示要素を取得
// メッセージ表示関連の要素を取得し、ボタンのクリックイベントを設定
messageOverlay = document.getElementById('messageOverlay');
messageTitle = document.getElementById('messageTitle');
messageText = document.getElementById('messageText');
messageButton = document.getElementById('messageButton');
messageButton.onclick = () => hideMessage(); // OKボタンがクリックされたらhideMessage関数を呼び出す
// 効果音の初期化
try {
// Tone.jsのシンセサイザーを作成
swapSynth = new Tone.Synth({
oscillator: { type: 'sine' }, // 発振器の種類をサイン波に設定
envelope: { attack: 0.005, decay: 0.1, sustain: 0.05, release: 0.1 } // 音のエンベロープ(立ち上がり、減衰など)を設定
}).toDestination(); // 音を最終出力先(スピーカーなど)に接続
} catch (e) {
console.error("Failed to initialize Tone.js synth:", e); // エラーが発生した場合はコンソールに出力
swapSynth = null; // シンセサイザーをnullに設定し、音を無効にする
}
setupCanvas(); // canvasのサイズなどを設定する関数を呼び出す
initBoard(); // ゲームボードを初期化する関数を呼び出す
// イベントリスナーの設定
// マウス操作のイベントリスナー
canvas.addEventListener('mousedown', handlePointerDown); // マウスボタンが押されたとき
canvas.addEventListener('mousemove', handlePointerMove); // マウスが移動したとき
canvas.addEventListener('mouseup', handlePointerUp); // マウスボタンが離されたとき
canvas.addEventListener('mouseleave', handlePointerUp); // マウスカーソルがcanvas外に出たとき(mouseupと同様の処理)
// タッチ操作のイベントリスナー
canvas.addEventListener('touchstart', handlePointerDown, { passive: false }); // タッチ開始時 (passive: falseでデフォルトのスクロールなどを防ぐ)
canvas.addEventListener('touchmove', handlePointerMove, { passive: false }); // タッチしながら移動したとき (passive: falseでデフォルトのスクロールなどを防ぐ)
canvas.addEventListener('touchend', handlePointerUp); // タッチ終了時
canvas.addEventListener('touchcancel', handlePointerUp); // タッチがキャンセルされたとき(touchendと同様の処理)
// ウィンドウリサイズ時のイベントリスナー
window.addEventListener('resize', () => {
setupCanvas(); // ウィンドウサイズが変わったらcanvasを再設定
});
gameLoopId = requestAnimationFrame(gameLoop); // ゲームループを開始
};
// canvasのサイズやオーブのサイズを計算し設定する関数
function setupCanvas() {
const padding = 16; // game-containerのpadding値(左右)
// game-containerの現在の幅からpaddingを引いた値をcanvasの最大幅の候補とする
const containerWidth = document.querySelector('.game-container').clientWidth - (padding * 2);
// オーブの基本サイズと列数から計算されるボードの最大幅
const maxBoardWidth = COLS * ORB_SIZE_BASE;
// containerの幅とmaxBoardWidthのうち、小さい方を実際のボード幅とする
boardWidth = Math.min(containerWidth, maxBoardWidth);
// ボード幅と列数から、1つのオーブの実際のサイズを計算
orbSize = boardWidth / COLS;
// 行数とオーブサイズからボードの高さを計算
boardHeight = ROWS * orbSize;
// canvasの幅と高さを設定
canvas.width = boardWidth;
canvas.height = boardHeight;
// タイマーバーのコンテナの最大幅もボード幅に合わせる
const timerContainer = document.querySelector('.timer-bar-container');
if (timerContainer) {
timerContainer.style.maxWidth = `${boardWidth}px`;
}
}
// ゲームボードを初期化する関数
function initBoard() {
board = []; // ボード配列を空にする
// 行と列に従ってボードにランダムな色のオーブを配置
for (let r = 0; r < ROWS; r++) {
board[r] = []; // 各行を配列として初期化
for (let c = 0; c < COLS; c++) {
board[r][c] = getRandomOrbColorIndex(); // ランダムな色のインデックスを割り当てる
}
}
// 初期配置でマッチが発生しないようにする
// findAllMatches()でマッチが見つからなくなるまで、オーブを再配置する
while(findAllMatches().length > 0) {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
board[r][c] = getRandomOrbColorIndex();
}
}
}
score = 0; // スコアをリセット
currentCombo = 0; // コンボ数をリセット
activeAnimations = []; // アクティブなアニメーションをリセット
updateInfoPanel(); // 情報パネル(スコア、コンボ)を更新
}
// ランダムなオーブの色のインデックスを返す関数
function getRandomOrbColorIndex() {
return Math.floor(Math.random() * ORB_COLORS.length); // 0からORB_COLORSの要素数-1までのランダムな整数を返す
}
// --- メインゲームループ ---
// 毎フレーム呼び出され、ゲームの状態更新と描画を行う関数
function gameLoop(currentTime) {
gameLoopId = requestAnimationFrame(gameLoop); // 次のフレームで再度gameLoopを呼び出すように予約
updateActiveAnimations(currentTime); // アクティブなアニメーションを更新
updateMoveTimer(currentTime); // 移動タイマーを更新
renderGame(); // ゲーム画面を描画
}
// アクティブなアニメーションの状態を更新する関数
function updateActiveAnimations(currentTime) {
// 完了したアニメーションをactiveAnimations配列から取り除く
activeAnimations = activeAnimations.filter(anim => {
const elapsedTime = currentTime - anim.startTime; // アニメーション開始からの経過時間
anim.progress = Math.min(elapsedTime / ORB_SWAP_ANIMATION_DURATION, 1); // アニメーションの進捗度 (0から1)
return anim.progress < 1; // 進捗度が1未満(アニメーションが完了していない)ものだけを残す
});
}
// 移動タイマーを更新する関数
function updateMoveTimer(currentTime) {
// ドラッグ中でない、またはタイマーがアクティブでない場合は何もしない
if (!isDragging || !moveTimerActive) return;
// 前回の更新時刻がなければ、現在の時刻を設定
if (!lastTimerUpdateTime) lastTimerUpdateTime = currentTime;
const deltaTime = currentTime - lastTimerUpdateTime; // 前回更新からの経過時間
lastTimerUpdateTime = currentTime; // 今回の更新時刻を保存
timeLeft -= deltaTime; // 残り時間を減らす
const percentageLeft = Math.max(0, (timeLeft / MOVE_TIME_LIMIT) * 100); // 残り時間の割合を計算 (0%以上)
timerBarDisplay.style.width = percentageLeft + '%'; // タイマーバーの幅を更新
// 残り時間に応じてタイマーバーの色を変更
if (percentageLeft < 25) timerBarDisplay.style.backgroundColor = '#e74c3c'; // 赤色 (25%未満)
else if (percentageLeft < 50) timerBarDisplay.style.backgroundColor = '#f39c12'; // オレンジ色 (50%未満)
else timerBarDisplay.style.backgroundColor = '#2ecc71'; // 緑色 (50%以上)
// 残り時間が0以下になったら、操作を終了する
if (timeLeft <= 0) {
handlePointerUp(); // ポインターアップ処理を呼び出す (ドラッグ終了と同じ処理)
}
}
// --- 描画処理 ---
// ゲーム画面全体を描画する関数
function renderGame() {
ctx.clearRect(0, 0, canvas.width, canvas.height); // canvas全体をクリア
// レイヤー1: ボード上の静的なオーブの描画
for (let r_idx = 0; r_idx < ROWS; r_idx++) {
for (let c_idx = 0; c_idx < COLS; c_idx++) {
if (board[r_idx][c_idx] === EMPTY_SLOT) continue; // 空のスロットは描画しない
// ドラッグ中のオーブはここでは描画しない (レイヤー3で描画するため)
if (isDragging && selectedOrb && r_idx === selectedOrb.row && c_idx === selectedOrb.col) {
continue;
}
// アニメーションの移動元となるオーブはここでは描画しない (レイヤー2で描画するため)
let isAnimatingFrom = activeAnimations.some(anim => anim.fromR === r_idx && anim.fromC === c_idx);
if (isAnimatingFrom) {
continue;
}
// アニメーションの移動先となるオーブで、かつアニメーション中のものはここでは描画しない (レイヤー2で描画するため)
let isAnimatingTo = activeAnimations.some(anim =>
anim.toR === r_idx && anim.toC === c_idx &&
anim.orbColor === board[r_idx][c_idx] && anim.progress < 1
);
if (isAnimatingTo) {
continue;
}
// 通常のオーブを描画
drawOrb(c_idx * orbSize + orbSize / 2, r_idx * orbSize + orbSize / 2, ORB_COLORS[board[r_idx][c_idx]]);
}
}
// レイヤー2: アニメーション中のオーブの描画
activeAnimations.forEach(anim => {
// アニメーションの進捗に応じて現在の座標を計算
const currentX = (anim.fromC * (1 - anim.progress) + anim.toC * anim.progress) * orbSize + orbSize / 2;
const currentY = (anim.fromR * (1 - anim.progress) + anim.toR * anim.progress) * orbSize + orbSize / 2;
drawOrb(currentX, currentY, ORB_COLORS[anim.orbColor]); // アニメーション中のオーブを描画
});
// レイヤー3: ドラッグ中のオーブと軌跡の描画
if (isDragging && selectedOrb) {
drawSelectedOrb(); // 選択中のオーブを描画
drawPath(); // ドラッグの軌跡を描画
}
}
// 1つのオーブを描画する関数
function drawOrb(x, y, color, sizeFactor = 0.8) {
// x, y: オーブの中心座標
// color: オーブの色
// sizeFactor: オーブのサイズ倍率 (デフォルトは0.8)
ctx.beginPath(); // 新しいパスを開始
ctx.arc(x, y, orbSize * sizeFactor / 2, 0, Math.PI * 2); // 円を描画
ctx.fillStyle = color; // 塗りつぶしの色を設定
ctx.fill(); // パスを塗りつぶす
// オーブにハイライトを追加して立体感を出す
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; // 半透明の白色
ctx.beginPath();
ctx.arc(x - orbSize * 0.1, y - orbSize * 0.1, orbSize * sizeFactor / 4, 0, Math.PI * 2); // 少し左上に小さい円を描画
ctx.fill();
}
// 選択中の(ドラッグしている)オーブを描画する関数
function drawSelectedOrb() {
if (!selectedOrb) return; // 選択中のオーブがなければ何もしない
// 通常より少し大きく描画する (sizeFactor = 0.9)
drawOrb(selectedOrb.x, selectedOrb.y, ORB_COLORS[selectedOrb.color], 0.9);
}
// ドラッグの軌跡を描画する関数
function drawPath() {
if (path.length < 2) return; // 軌跡の点が2つ未満なら何もしない
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y); // 最初の点に移動
for (let i = 1; i < path.length; i++) {
ctx.lineTo(path[i].x, path[i].y); // 次の点へ線を引く
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; // 線の色を半透明の白に設定
ctx.lineWidth = 3; // 線の太さを設定
ctx.stroke(); // 線を描画
}
// --- 入力処理 ---
// AudioContextを開始する関数 (ブラウザのポリシーによりユーザー操作が必要)
async function startAudioContext() {
// AudioContextがまだ開始されておらず、Tone.jsが利用可能で、かつAudioContextの状態が 'running' でない場合
if (!audioContextStarted && Tone && Tone.context && Tone.context.state !== 'running') {
try {
await Tone.start(); // Tone.jsのAudioContextを開始
console.log("AudioContext started successfully."); // 成功ログ
audioContextStarted = true; // 開始フラグを立てる
} catch (e) {
console.error("Error starting AudioContext:", e); // エラーログ
}
}
}
// マウス/タッチイベントからcanvas上の座標を取得する関数
function getPointerPosition(event) {
const rect = canvas.getBoundingClientRect(); // canvasの画面上の位置とサイズを取得
let x, y;
if (event.touches && event.touches.length > 0) {
// タッチイベントの場合
x = event.touches[0].clientX - rect.left; // 最初のタッチ点のX座標 - canvasの左端X座標
y = event.touches[0].clientY - rect.top; // 最初のタッチ点のY座標 - canvasの上端Y座標
} else {
// マウスイベントの場合
x = event.clientX - rect.left; // マウスカーソルのX座標 - canvasの左端X座標
y = event.clientY - rect.top; // マウスカーソルのY座標 - canvasの上端Y座標
}
return { x, y }; // canvas内の相対座標を返す
}
// マウスボタンが押されたとき、またはタッチが開始されたときの処理
function handlePointerDown(event) {
event.preventDefault(); // デフォルトのイベント処理(スクロールなど)をキャンセル
// すでにドラッグ中、またはマッチ処理中の場合は何もしない
if (isDragging || processingMatches) return;
startAudioContext(); // ユーザー操作があったのでAudioContextを開始試行
const { x, y } = getPointerPosition(event); // ポインターのcanvas内座標を取得
const c = Math.floor(x / orbSize); // クリックされた列インデックスを計算
const r = Math.floor(y / orbSize); // クリックされた行インデックスを計算
// クリック位置がボード範囲内で、かつ空のスロットでない場合
if (r >= 0 && r < ROWS && c >= 0 && c < COLS && board[r][c] !== EMPTY_SLOT) {
// 選択されたオーブの情報を保存
selectedOrb = {
row: r, col: c, // 現在の行と列
originalRow: r, originalCol: c, // 元の行と列(操作終了時に戻すためなどに使う可能性あり)
color: board[r][c], // オーブの色
x: x, y: y // ポインターの現在の座標
};
isDragging = true; // ドラッグ開始フラグを立てる
// ドラッグ軌跡の最初の点を追加 (オーブの中心)
path = [{x: c * orbSize + orbSize / 2, y: r * orbSize + orbSize / 2}];
startMoveTimer(); // 移動タイマーを開始
}
}
// マウスが移動したとき、またはタッチしながら移動したときの処理
function handlePointerMove(event) {
event.preventDefault(); // デフォルトのイベント処理をキャンセル
// ドラッグ中でない、または選択中のオーブがない場合は何もしない
if (!isDragging || !selectedOrb) return;
const { x, y } = getPointerPosition(event); // ポインターのcanvas内座標を取得
const c_new = Math.floor(x / orbSize); // 新しい列インデックス
const r_new = Math.floor(y / orbSize); // 新しい行インデックス
// 選択中のオーブの描画位置を更新
selectedOrb.x = x;
selectedOrb.y = y;
// 新しい位置がボード範囲内の場合
if (r_new >= 0 && r_new < ROWS && c_new >= 0 && c_new < COLS) {
// オーブが別のセルに移動した場合
if (r_new !== selectedOrb.row || c_new !== selectedOrb.col) {
const colorToDisplace = board[r_new][c_new]; // 移動先のセルにあるオーブの色
// 移動先のセルが空でない場合、オーブを交換
if (colorToDisplace !== EMPTY_SLOT) {
// 移動先のオーブを元いた位置へ移動させるアニメーションを追加
activeAnimations.push({
orbColor: colorToDisplace, // 移動するオーブの色
fromR: r_new, fromC: c_new, // 移動元 (新しいセルの位置)
toR: selectedOrb.row, toC: selectedOrb.col, // 移動先 (選択中オーブの元の位置)
startTime: performance.now(), // アニメーション開始時刻
progress: 0 // 進捗度
});
// ボード上のオーブを実際に交換
board[r_new][c_new] = selectedOrb.color; // 新しいセルに選択中オーブの色を配置
board[selectedOrb.row][selectedOrb.col] = colorToDisplace; // 元のセルに移動先オーブの色を配置
// オーブ交換の効果音を再生
if (swapSynth && audioContextStarted) {
try {
// "C5"の音を非常に短い時間再生
swapSynth.triggerAttackRelease("C5", "32n", Tone.now());
} catch(e) {
console.warn("Could not play swap sound:", e); // エラー時は警告を出す
}
}
}
// 選択中のオーブのボード上の位置を更新
selectedOrb.row = r_new;
selectedOrb.col = c_new;
// ドラッグ軌跡に新しいセルの中心座標を追加 (重複しないように)
const pathX = c_new * orbSize + orbSize / 2;
const pathY = r_new * orbSize + orbSize / 2;
if (!path.find(p => p.x === pathX && p.y === pathY)) {
path.push({x: pathX, y: pathY});
}
}
}
}
// マウスボタンが離されたとき、またはタッチが終了したときの処理
function handlePointerUp() {
if (!isDragging) return; // ドラッグ中でなければ何もしない
isDragging = false; // ドラッグ終了フラグを下ろす
stopMoveTimer(); // 移動タイマーを停止
path = []; // ドラッグ軌跡をクリア
processMatchesAndRefill(); // マッチ判定とオーブ補充処理を開始
selectedOrb = null; // 選択中のオーブ情報をクリア
}
// --- タイマー関連 ---
// 移動タイマーを開始する関数
function startMoveTimer() {
timeLeft = MOVE_TIME_LIMIT; // 残り時間をリセット
timerBarDisplay.style.width = '100%'; // タイマーバーを満タンにする
timerBarDisplay.style.backgroundColor = '#2ecc71'; // タイマーバーの色を初期色(緑)に戻す
lastTimerUpdateTime = performance.now(); // タイマーの最終更新時刻を記録
moveTimerActive = true; // タイマーアクティブフラグを立てる
}
// 移動タイマーを停止する関数
function stopMoveTimer() {
moveTimerActive = false; // タイマーアクティブフラグを下ろす
timerBarDisplay.style.width = '100%'; // タイマーバーを満タン表示に戻す(見た目上)
timerBarDisplay.style.backgroundColor = '#7f8c8d'; // タイマーバーの色を非アクティブ時の色(灰色)に戻す
}
// --- ゲームロジック: マッチ判定、消去、落下、補充 ---
// マッチ判定、オーブ消去、落下、補充の一連の処理を行う非同期関数
async function processMatchesAndRefill() {
if (processingMatches) return; // すでに処理中の場合は何もしない
processingMatches = true; // 処理中フラグを立てる
currentCombo = 0; // コンボ数をリセット
// オーブ交換アニメーションが終わるのを待つ
await sleep(ORB_SWAP_ANIMATION_DURATION + 50); // アニメーション時間 + 少しのバッファ
let matchesFoundThisIteration; // このイテレーションでマッチが見つかったかどうかのフラグ
do {
matchesFoundThisIteration = false; // フラグを初期化
const matches = findAllMatches(); // 全てのマッチを探す
if (matches.length > 0) { // マッチが見つかった場合
matchesFoundThisIteration = true; // マッチが見つかったフラグを立てる
currentCombo++; // コンボ数を増やす
clearMatches(matches); // マッチしたオーブを消去
updateScore(matches); // スコアを更新
updateInfoPanel(); // 情報パネルを更新
renderGame(); // 消去後の状態を描画
await sleep(300); // 少し待って消去を視覚的に見せる
applyGravity(); // オーブを落下させる
renderGame(); // 落下後の状態を描画
await sleep(300); // 少し待って落下を視覚的に見せる
fillEmptySlots(); // 空いたスロットに新しいオーブを補充
renderGame(); // 補充後の状態を描画
await sleep(100); // 少し待つ
}
} while (matchesFoundThisIteration); // このイテレーションでマッチが見つかっている間、ループを続ける (連鎖処理)
updateInfoPanel(); // 最終的な情報パネルを更新
processingMatches = false; // 処理中フラグを下ろす
}
// ボード上の全てのマッチ(3つ以上同じ色が並んだ箇所)を見つける関数
function findAllMatches() {
const matches = []; // マッチしたオーブの座標を格納する配列
// 水平方向のマッチをチェック
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS - 2; c++) { // 右に2つオーブがないと3つ揃わないため、COLS-2までチェック
if (board[r][c] !== EMPTY_SLOT && // 空スロットでなく
board[r][c] === board[r][c+1] && // 右隣と同じ色で
board[r][c] === board[r][c+2]) { // さらにその右隣とも同じ色の場合 (3つ揃った)
let length = 3; // マッチの長さ (最低3)
// 4つ以上連続しているかチェック
while (c + length < COLS && board[r][c] === board[r][c+length]) length++;
// マッチしたオーブをmatches配列に追加
for (let i = 0; i < length; i++) matches.push({ r: r, c: c + i });
c += length -1; // チェック済みの列をスキップ (length-1だけ進める)
}
}
}
// 垂直方向のマッチをチェック
for (let c = 0; c < COLS; c++) {
for (let r = 0; r < ROWS - 2; r++) { // 下に2つオーブがないと3つ揃わないため、ROWS-2までチェック
if (board[r][c] !== EMPTY_SLOT && // 空スロットでなく
board[r][c] === board[r+1][c] && // 下隣と同じ色で
board[r][c] === board[r+2][c]) { // さらにその下隣とも同じ色の場合 (3つ揃った)
let length = 3; // マッチの長さ (最低3)
// 4つ以上連続しているかチェック
while (r + length < ROWS && board[r][c] === board[r+length][c]) length++;
// マッチしたオーブをmatches配列に追加
for (let i = 0; i < length; i++) matches.push({ r: r + i, c: c });
r += length -1; // チェック済みの行をスキップ
}
}
}
// 重複するマッチ(十字などで同じオーブが複数回カウントされる場合)を削除して返す
return [...new Set(matches.map(m => `${m.r}-${m.c}`))].map(s => {
const [r_str, c_str] = s.split('-'); // 文字列 "r-c" を r と c に分割 (変数名を変更して衝突を回避)
return { r: parseInt(r_str), c: parseInt(c_str) }; // 数値に戻してオブジェクトとして返す
});
}
// マッチしたオーブをボードから消去する(EMPTY_SLOTに置き換える)関数
function clearMatches(matches) {
matches.forEach(match => {
board[match.r][match.c] = EMPTY_SLOT; // マッチしたオーブの位置を空スロットにする
});
}
// オーブを下に落下させる(重力を適用する)関数
function applyGravity() {
for (let c = 0; c < COLS; c++) { // 各列について処理
let emptyRow = ROWS - 1; // その列で最も下にある空のスロットの行インデックス (初期値は最下行)
// 列を下から上へスキャン
for (let r = ROWS - 1; r >= 0; r--) {
if (board[r][c] !== EMPTY_SLOT) { // オーブがある場合
if (r !== emptyRow) { // オーブが現在の位置 (r) と落下先の空スロット (emptyRow) が異なる場合
board[emptyRow][c] = board[r][c]; // オーブをemptyRowに移動
board[r][c] = EMPTY_SLOT; // 元の位置を空スロットにする
}
emptyRow--; // 次の空スロットの候補を1つ上に移動
}
}
}
}
// 空いたスロットに新しいオーブを補充する関数
function fillEmptySlots() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (board[r][c] === EMPTY_SLOT) { // スロットが空の場合
board[r][c] = getRandomOrbColorIndex(); // ランダムな色の新しいオーブを配置
}
}
}
}
// --- スコアリングとUI更新 ---
// スコアを更新する関数
function updateScore(matches) {
// 基本スコア: 消したオーブの数 * 10点
// コンボボーナス: 現在のコンボ数 * 50点
score += matches.length * 10 + currentCombo * 50;
}
// 情報パネル(スコアとコンボ)の表示を更新する関数
function updateInfoPanel() {
scoreDisplay.textContent = score; // スコア表示を更新
comboDisplay.textContent = currentCombo; // コンボ表示を更新
}
// --- ユーティリティ ---
// 指定された時間(ミリ秒)だけ処理を待つ関数 (Promiseを使用)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// --- メッセージボックス ---
// メッセージボックスを表示する関数
function showMessage(title, text) {
messageTitle.textContent = title; // メッセージタイトルを設定
messageText.textContent = text; // メッセージ本文を設定
messageOverlay.classList.add('visible'); // オーバーレイを表示状態にするCSSクラスを追加
}
// メッセージボックスを非表示にする関数
function hideMessage() {
messageOverlay.classList.remove('visible'); // オーバーレイの表示状態CSSクラスを削除
}
</script>
</body>
</html>
・最初から生成するのもアリだけど、↑このコードをコピーして改造していくのもアリ
HTML5 オーブパズルゲーム コード解説
このコードは、ウェブブラウザで遊べる簡単なパズルゲームを作るためのものです。
HTML、CSS、JavaScriptという3つの言語を使って作られています。
- HTML (HyperText Markup Language): ウェブページの「骨組み」や「内容」を作ります。
見出し、段落、画像、ボタンなどがHTMLで作られます。 - CSS (Cascading Style Sheets): ウェブページの「見た目」を整えます。
色、大きさ、配置、フォントなどを指定します。 - JavaScript: ウェブページに「動き」や「機能」を追加します。
ボタンを押したときの動作、ゲームのルール、アニメーションなどがJavaScriptで作られます。
それでは、各部分を詳しく見ていきましょう。
1. HTML の部分:ゲームの「骨組み」と「部品」
まず、ウェブページの基本的な構造から見ていきます。
HTML
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 オーブパズルゲーム (効果音追加版)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
<style>
/* ここにCSSスタイルを書きます */
</style>
</head>
<body class="bg-gray-800">
</body>
</html>
<!DOCTYPE html>
: これは「このファイルはHTML5の文書ですよ」とブラウザに伝えるためのおまじないです。<html lang="ja">
: HTML文書の始まりです。lang="ja"
はこのページの言語が日本語であることを示します。<head>
: この部分には、ウェブページには直接表示されないけれど、ページ全体の設定や情報が書かれています。<meta charset="UTF-8">
: 文字コードの設定です。これで日本語の文字が正しく表示されます。<meta name="viewport" ...>
: スマートフォンやタブレットなど、様々な画面サイズに合わせて表示を最適化するための設定です。<title>...</title>
: ブラウザのタブやウィンドウのタイトルバーに表示されるページのタイトルです。<script src="https://cdn.tailwindcss.com"></script>
: これは「Tailwind CSS」という、CSSを簡単に書くための便利な道具(フレームワーク)をインターネット上から読み込んでいます。これを使うと、見た目を効率よく設定できます。<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
: これは「Tone.js」という、JavaScriptで音を出すための便利な道具(ライブラリ)をインターネット上から読み込んでいます。このゲームの効果音はこのライブラリで作られます。<style>...</style>
: ここにゲームの見た目を設定するCSSのコードを書きます。後で詳しく説明します。
<body>
: この部分に、実際にウェブページに表示される内容が書かれています。<body class="bg-gray-800">
:body
全体の背景色を少し暗い灰色に設定しています。これはTailwind CSSの機能で、bg-gray-800
という名前のスタイルを適用しています。
<body>
の中のゲーム部品
ゲーム画面に表示される具体的な要素を見ていきましょう。
HTML
<div class="game-container">
<div class="info-panel">
<p>スコア: <strong id="score">0</strong></p>
<p>コンボ: <strong id="combo">0</strong></p>
</div>
<div class="timer-bar-container">
<div id="timerBar" class="timer-bar"></div>
</div>
<canvas id="gameCanvas"></canvas>
<div class="instructions">
<h3>遊び方</h3>
<ul>
<li>オーブをタップ(クリック)して掴みます。</li>
<li>掴んだオーブをドラッグして、盤面上で自由に動かせます。</li>
<li>制限時間内に同じ色のオーブを縦か横に3つ以上並べると消えます。</li>
<li>たくさんコンボを繋げて高得点を目指しましょう!</li>
<li>操作が終わるか時間切れになると、オーブが消えて新しいオーブが補充されます。</li>
</ul>
</div>
</div>
<div id="messageOverlay" class="message-overlay">
<div class="message-box">
<h2 id="messageTitle"></h2>
<p id="messageText"></p>
<button id="messageButton">OK</button>
</div>
</div>
<script>
// ここにJavaScriptコードを書きます
</script>
<div class="game-container">
: ゲーム全体の表示エリアを囲む箱です。これによって、ゲームの各要素を中央に配置したり、背景色を設定したりしやすくなります。<div class="info-panel">
: スコアやコンボ数を表示するパネルです。<p>スコア: <strong id="score">0</strong></p>
: スコアを表示する場所です。id="score"
が付いているので、JavaScriptからこの部分の数字(初期値は0)を簡単に変更できます。<strong>
タグで太字にしています。<p>コンボ: <strong id="combo">0</strong></p>
: コンボ数を表示する場所です。id="combo"
が付いています。
<div class="timer-bar-container">
: 制限時間を表示するタイマーバーの土台です。<div id="timerBar" class="timer-bar"></div>
: 実際のタイマーバーです。時間に応じて幅が変化し、残り時間が少なくなると色も変わります。id="timerBar"
が付いているのでJavaScriptで操作します。
<canvas id="gameCanvas"></canvas>
: これがゲームの描画領域です。オーブや線などは、この「キャンバス」と呼ばれる白い板の上にJavaScriptを使って絵を描くように表示されます。id="gameCanvas"
が付いています。<div class="instructions">
: ゲームの遊び方を説明するパネルです。<h3>遊び方</h3>
: 遊び方の見出しです。<ul>...</ul>
: 箇条書きで遊び方の説明が書かれています。
<div id="messageOverlay" class="message-overlay">
: ゲームオーバー時などにメッセージを表示するための、画面全体を覆う透明なオーバーレイ(覆い)です。初期状態では見えませんが、JavaScriptで表示・非表示を切り替えます。<div class="message-box">
: オーバーレイの中央に表示されるメッセージの箱です。<h2 id="messageTitle"></h2>
: メッセージのタイトルが表示される場所です。<p id="messageText"></p>
: メッセージ本文が表示される場所です。<button id="messageButton">OK</button>
: メッセージを閉じるためのボタンです。
<script>...</script>
: ここに、このゲームの動きを制御するJavaScriptのコードをすべて書きます。
2. CSS の部分:ゲームの「見た目」をデザインする
<style>
タグの中に書かれたCSSは、HTML要素に色、形、配置などのデザインを設定します。
CSS
/* CSSスタイルを記述します。HTML要素の見た目を定義します */
body {
font-family: 'Inter', sans-serif; /* フォントの種類を設定 */
touch-action: none; /* スマホなどでドラッグ中にページがスクロールするのを防ぐ */
}
canvas {
display: block; /* ブロック要素として表示(前後に改行が入る) */
margin: 0 auto; /* 上下マージン0、左右マージン自動で中央に配置 */
background-color: #2c3e50; /* 背景色(濃い青灰色) */
border-radius: 8px; /* 角を少し丸くする */
box-shadow: 0 4px 12px rgba(0,0,0,0.2); /* 影をつけて立体感を出す */
}
/* 他の要素も同様に、色、サイズ、配置、影、角の丸みなどを細かく設定しています */
.game-container { /* ゲーム全体を囲む箱のスタイル */ }
.info-panel { /* スコアやコンボの表示パネルのスタイル */ }
.timer-bar-container { /* タイマーバーの土台のスタイル */ }
.timer-bar { /* タイマーバー本体のスタイル。幅や色はJavaScriptで変わりますが、変化を滑らかにする設定もされています */ }
.message-overlay { /* メッセージが表示される時の背景(最初は透明で非表示) */ }
.message-overlay.visible { /* JavaScriptで 'visible' クラスが付くと、不透明になって表示される */ }
.message-box { /* メッセージの箱のスタイル */ }
/* その他、見出しやボタンなどの見た目もここで定義されています */
CSSのポイントは以下の通りです。
- セレクタ:
{}
の前の部分(例:body
,canvas
,.game-container
,#score
)は、どのHTML要素にスタイルを適用するかを指定します。- タグ名 (
body
,canvas
): そのタグのすべての要素に適用。 - クラス名 (
.game-container
):class="game-container"
と書かれた要素に適用。 - ID名 (
#score
):id="score"
と書かれた特定の要素に適用。
- タグ名 (
- プロパティと値:
{}
の中にプロパティ: 値;
の形式で書きます。background-color: #2c3e50;
: 背景色を#2c3e50
(16進数カラーコード)にする。margin: 0 auto;
: 上下方向の余白は0、左右方向の余白は自動で、要素を中央に配置する。transition: opacity 0.3s ease;
:opacity
(透明度)が変化するときに、0.3秒かけてゆっくり変化させる。
このようにCSSを使うことで、単調なHTMLの部品が、デザインされたゲーム画面として表示されます。
3. JavaScript の部分:ゲームの「動き」と「ルール」
JavaScriptがこのゲームの頭脳です。非常に長いですが、一つずつ見ていきましょう。
a. ゲーム設定と「グローバル変数」(ゲーム全体で使うデータ)
JavaScript
// --- ゲーム設定 ---
const COLS = 6; // ボードの列数(横の数)
const ROWS = 5; // ボードの行数(縦の数)
const ORB_SIZE_BASE = 50; // オーブの基本的な大きさ
const ORB_COLORS = ['#FF6B6B', '#4ECDC4', /* ... */]; // オーブの色のリスト
const MOVE_TIME_LIMIT = 5000; // オーブを動かせる制限時間(5秒)
// --- グローバル変数 ---
let canvas, ctx; // ゲームを描く「キャンバス」と「絵筆」の準備
let board = []; // ゲームボードの状態を記録する場所
let score = 0; // 現在のスコア
let currentCombo = 0; // 現在のコンボ数
let selectedOrb = null; // 今つかんでいるオーブの情報
let isDragging = false; // オーブをドラッグ中かどうかの状態
let timeLeft = MOVE_TIME_LIMIT; // 残り時間
// ... 他にもたくさんの変数がゲームの状態を記録しています
const
は「定数」といって、一度設定したら変わらない値に使います。ゲームの列数やオーブの色などです。let
は「変数」といって、ゲーム中にしょっちゅう変わる値に使います。スコア、コンボ数、残り時間などです。- これらの変数は、ゲームのどこからでもアクセスできる「グローバル変数」として定義されています。
b. ゲームの始まり (window.onload
)
JavaScript
window.onload = () => {
// HTML要素を取得(JavaScriptから操作できるように、名前で呼び出せるようにする)
canvas = document.getElementById('gameCanvas'); // IDが'gameCanvas'の要素(キャンバス)を取得
ctx = canvas.getContext('2d'); // そのキャンバスに絵を描くための「2D絵筆」を取得
scoreDisplay = document.getElementById('score'); // スコアを表示する場所
comboDisplay = document.getElementById('combo'); // コンボを表示する場所
timerBarDisplay = document.getElementById('timerBar'); // タイマーバー
// メッセージボックスの要素も取得
messageOverlay = document.getElementById('messageOverlay');
messageButton.onclick = () => hideMessage(); // OKボタンを押したらメッセージを消す設定
// 効果音の準備 (Tone.js)
try {
swapSynth = new Tone.Synth({ /* ... */ }).toDestination(); // 音を出す機械を作る
} catch (e) {
console.error("Failed to initialize Tone.js synth:", e); // エラーがあったらコンソールに表示
swapSynth = null;
}
setupCanvas(); // キャンバスのサイズを画面に合わせて調整
initBoard(); // ゲームボードを最初にオーブでいっぱいに初期化
// マウスやタッチの操作を監視する設定
canvas.addEventListener('mousedown', handlePointerDown); // マウスが押されたら
canvas.addEventListener('mousemove', handlePointerMove); // マウスが動いたら
canvas.addEventListener('mouseup', handlePointerUp); // マウスが離されたら
// ... タッチ操作についても同様に設定
gameLoopId = requestAnimationFrame(gameLoop); // ゲームの動きを始める(ループ)
};
window.onload = () => { ... };
は、「ウェブページがすべて読み込まれたら、この中の処理を実行してください」という意味です。document.getElementById('ID名')
は、HTMLで設定したid
を使って、特定のHTML要素をJavaScriptで操作できるように「見つける」ための命令です。canvas.getContext('2d')
は、キャンバスに絵を描くための「絵筆」のようなものです。これを使って、オーブを描いたり、線を引いたりします。addEventListener('イベント名', 実行する関数)
は、「この要素で特定のイベント(例:mousedown
= マウスが押された)が起きたら、指定した関数を実行してください」という設定です。requestAnimationFrame(gameLoop)
は、ブラウザに「次の画面の描画準備ができたら、gameLoop
という関数を実行してね」とお願いするものです。これを繰り返すことで、ゲームは常に動き続けます。
c. ゲームの「動き」の中心:ゲームループ (gameLoop
)
JavaScript
function gameLoop(currentTime) {
gameLoopId = requestAnimationFrame(gameLoop); // 次のフレームも自分を呼び出す
updateActiveAnimations(currentTime); // 動いているオーブがあれば更新
updateMoveTimer(currentTime); // 残り時間を更新
renderGame(); // ゲーム画面を再描画
}
- この
gameLoop
関数が、ゲームの心臓部です。ブラウザは1秒間に約60回この関数を呼び出します(PCのモニターの性能によります)。 - この関数の中で、ゲームの状態を計算(更新)し、その状態を画面に描画することを繰り返します。
updateActiveAnimations()
: オーブが交換されたときなどのアニメーションの進み具合を計算します。updateMoveTimer()
: 制限時間のタイマーを減らしていきます。renderGame()
: その時点のゲームの状態(オーブの位置、色など)をキャンバスに描画します。
d. オーブの描画 (drawOrb
, drawSelectedOrb
, drawPath
)
JavaScript
function drawOrb(x, y, color, sizeFactor = 0.8) {
ctx.beginPath(); // 新しい図形を描き始めるよ
ctx.arc(x, y, orbSize * sizeFactor / 2, 0, Math.PI * 2); // 円を描く
ctx.fillStyle = color; // 塗りつぶす色
ctx.fill(); // 塗りつぶす
// オーブに光沢を付ける(少し白い円を重ねる)
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.beginPath();
ctx.arc(x - orbSize * 0.1, y - orbSize * 0.1, orbSize * sizeFactor / 4, 0, Math.PI * 2);
ctx.fill();
}
function renderGame() {
ctx.clearRect(0, 0, canvas.width, canvas.height); // キャンバス全体を消す
// レイヤー1: 動いていないオーブを描く
// レイヤー2: アニメーションで動いているオーブを描く
// レイヤー3: ドラッグ中のオーブと軌跡を描く
// このように層(レイヤー)に分けて描くことで、重なり順をきれいにできます。
}
drawOrb
は、キャンバスに一つのオーブを描くための関数です。x
(横位置)、y
(縦位置)、color
(色)を受け取って、真ん丸のオーブを描きます。少し白い円を重ねて、キラキラとした立体感を出す工夫もされています。renderGame
は、ゲーム画面全体を描画する関数で、背景を消してから、全てのオーブを描き直します。ドラッグ中のオーブやアニメーション中のオーブが他のオーブの上に表示されるように、描画の順番を工夫しています(レイヤーの概念)。
e. ユーザーの操作処理 (マウス/タッチ)
JavaScript
async function startAudioContext() {
// ほとんどのブラウザでは、ユーザーが何か操作しないと音が出せません。
// そのため、最初にユーザーが触れたときに音の準備を開始します。
if (!audioContextStarted && Tone && Tone.context && Tone.context.state !== 'running') {
try {
await Tone.start(); // Tone.jsの音の準備を開始
audioContextStarted = true;
} catch (e) {
console.error("Error starting AudioContext:", e);
}
}
}
function handlePointerDown(event) {
event.preventDefault(); // ブラウザのデフォルトの動き(スクロールなど)を止める
// ドラッグ中やマッチ処理中ではないことを確認
if (isDragging || processingMatches) return;
startAudioContext(); // 音の準備を開始
const { x, y } = getPointerPosition(event); // マウスやタッチの座標を取得
const c = Math.floor(x / orbSize); // 座標から、どの列(横)のオーブか計算
const r = Math.floor(y / orbSize); // 座標から、どの行(縦)のオーブか計算
// クリックした場所がボード内で、かつ空でないオーブなら
if (r >= 0 && r < ROWS && c >= 0 && c < COLS && board[r][c] !== EMPTY_SLOT) {
selectedOrb = { row: r, col: c, color: board[r][c], x: x, y: y }; // つかんだオーブの情報を記録
isDragging = true; // ドラッグ開始!
// 軌跡の最初を記録
path = [{x: c * orbSize + orbSize / 2, y: r * orbSize + orbSize / 2}];
startMoveTimer(); // 制限時間タイマーを開始
}
}
function handlePointerMove(event) {
event.preventDefault(); // デフォルトの動きを止める
if (!isDragging || !selectedOrb) return; // ドラッグ中でなければ何もしない
const { x, y } = getPointerPosition(event);
const c_new = Math.floor(x / orbSize); // 新しい列
const r_new = Math.floor(y / orbSize); // 新しい行
selectedOrb.x = x; // ドラッグ中のオーブの描画位置を更新
selectedOrb.y = y;
// もしオーブが別のマスに移動したら
if (r_new !== selectedOrb.row || c_new !== selectedOrb.col) {
const colorToDisplace = board[r_new][c_new]; // 移動先のマスにあるオーブの色
if (colorToDisplace !== EMPTY_SLOT) { // 移動先が空でなければ入れ替える
// 移動先オーブを元の位置に戻すアニメーションを設定
activeAnimations.push({ /* ... */ });
// 実際にボード上のオーブの場所を入れ替える
board[r_new][c_new] = selectedOrb.color;
board[selectedOrb.row][selectedOrb.col] = colorToDisplace;
// オーブ交換の効果音を鳴らす
if (swapSynth && audioContextStarted) {
swapSynth.triggerAttackRelease("C5", "32n", Tone.now());
}
}
selectedOrb.row = r_new; // つかんでいるオーブのボード上の位置を更新
selectedOrb.col = c_new;
// 軌跡に新しいマスの中心を追加
const pathX = c_new * orbSize + orbSize / 2;
const pathY = r_new * orbSize + orbSize / 2;
if (!path.find(p => p.x === pathX && p.y === pathY)) {
path.push({x: pathX, y: pathY});
}
}
}
function handlePointerUp() {
if (!isDragging) return; // ドラッグ中でなければ何もしない
isDragging = false; // ドラッグ終了
stopMoveTimer(); // タイマーを止める
path = []; // 軌跡を消す
processMatchesAndRefill(); // マッチを調べて消して、オーブを補充する処理を開始
selectedOrb = null; // つかんでいたオーブの情報を消す
}
handlePointerDown
,handlePointerMove
,handlePointerUp
は、それぞれ「マウス/タッチが押された」「動いた」「離された」ときに実行される関数です。event.preventDefault()
は、ブラウザが通常行う動作(例えば、ウェブページをドラッグするとスクロールしてしまうなど)を止めるためによく使われます。ゲームでは、ブラウザの動きではなく、ゲームの動きを優先させたいときに重要です。- オーブをドラッグして別のマスに移動させると、そこにあったオーブと入れ替えます。このとき、交換されたオーブが元の位置に戻るようなアニメーションを再生するように設定しています。
Tone.js
を使って、オーブが交換されるたびに「ピコッ」という短い効果音を鳴らしています。
f. ゲームのメインルール:マッチ判定、消去、落下、補充 (processMatchesAndRefill
など)
JavaScript
async function processMatchesAndRefill() {
if (processingMatches) return; // すでに処理中なら何もしない
processingMatches = true; // 処理中であることを記録
currentCombo = 0; // コンボ数をリセット
// オーブ交換アニメーションが終わるまで少し待つ
await sleep(ORB_SWAP_ANIMATION_DURATION + 50);
let matchesFoundThisIteration; // 今回の処理でマッチが見つかったか
do {
matchesFoundThisIteration = false; // フラグをリセット
const matches = findAllMatches(); // ボード全体でマッチを探す
if (matches.length > 0) { // マッチが見つかったら
matchesFoundThisIteration = true; // マッチが見つかった!
currentCombo++; // コンボ数を増やす
clearMatches(matches); // マッチしたオーブをボードから消す
updateScore(matches); // スコアを増やす
updateInfoPanel(); // スコア表示を更新
renderGame(); // 消した後の画面を描画
await sleep(300); // 300ミリ秒(0.3秒)待つ(消えるのを見せるため)
applyGravity(); // オーブを下に落とす
renderGame(); // 落下後の画面を描画
await sleep(300); // 0.3秒待つ(落ちるのを見せるため)
fillEmptySlots(); // 空いた場所に新しいオーブを補充
renderGame(); // 補充後の画面を描画
await sleep(100); // 少し待つ
}
} while (matchesFoundThisIteration); // マッチが見つからなくなるまで繰り返す(これが「連鎖」の仕組み!)
updateInfoPanel(); // 最終的なスコア表示を更新
processingMatches = false; // 処理終了
}
function findAllMatches() {
const matches = [];
// 横方向のマッチをチェック (forループでボードを端から端まで見ていく)
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS - 2; c++) {
// 自分と右隣と右右隣が同じ色で、かつ空でない場合
if (board[r][c] !== EMPTY_SLOT && board[r][c] === board[r][c+1] && board[r][c] === board[r][c+2]) {
let length = 3; // 3つ以上並んでるか数える
while (c + length < COLS && board[r][c] === board[r][c+length]) length++;
for (let i = 0; i < length; i++) matches.push({ r: r, c: c + i }); // マッチしたオーブの座標を記録
c += length -1; // 既にチェックした部分を飛ばす
}
}
}
// 縦方向のマッチも同様にチェック
// ...
return [...new Set(matches.map(m => `${m.r}-${m.c}`))].map(s => {
const [r_str, c_str] = s.split('-');
return { r: parseInt(r_str), c: parseInt(c_str) };
}); // 重複を削除して返す
}
function clearMatches(matches) {
matches.forEach(match => { // マッチしたオーブ一つ一つについて
board[match.r][match.c] = EMPTY_SLOT; // その場所を空っぽにする
});
}
function applyGravity() {
// 各列について、空いている場所の下にオーブがあったら落とす
for (let c = 0; c < COLS; c++) {
let emptyRow = ROWS - 1; // その列の一番下の空の場所の候補
for (let r = ROWS - 1; r >= 0; r--) { // 下から上へ見ていく
if (board[r][c] !== EMPTY_SLOT) { // オーブがあったら
if (r !== emptyRow) { // もしオーブが一番下の空の場所にないなら
board[emptyRow][c] = board[r][c]; // そのオーブを下の空の場所に移動
board[r][c] = EMPTY_SLOT; // 元の場所は空っぽにする
}
emptyRow--; // 次の空の場所の候補を一つ上にする
}
}
}
}
function fillEmptySlots() {
// ボード全体をチェックして、空いている場所があれば新しいオーブを置く
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (board[r][c] === EMPTY_SLOT) {
board[r][c] = getRandomOrbColorIndex(); // ランダムな色のオーブを置く
}
}
}
}
processMatchesAndRefill
は、このパズルゲームのメインとなるルール処理です。async
とawait
というキーワードが使われていますが、これは「非同期処理」といって、「時間がかかる処理が終わるまで、次の処理を一時停止して待つ」ためのものです。これにより、アニメーションの途中で次の処理が始まって画面が乱れるのを防ぎ、ゲームが自然に動いているように見せます。findAllMatches()
: ボード全体をスキャンして、縦や横に同じ色が3つ以上並んでいる場所を探します。見つかったオーブの座標(行と列)をリストにして返します。clearMatches()
:findAllMatches()
で見つかったオーブの場所を空っぽにします(EMPTY_SLOT
という特別な値でマークします)。applyGravity()
: 空っぽになった場所を埋めるために、その上のオーブを重力で下に落とします。fillEmptySlots()
: 落ちてきたオーブでも埋まらなかった空の場所には、新しいオーブをランダムに生成して補充します。do { ... } while (条件);
は、連鎖の仕組みを実現しています。「中の処理をまず一回実行して、もし条件が満たされたら(マッチが見つかったら)もう一度繰り返す」という流れです。これにより、オーブが消えて落下し、新しいマッチができたら、それがまた消えるという連鎖が自動的に繰り返されます。
g. スコアとUI更新 (updateScore
, updateInfoPanel
)
JavaScript
function updateScore(matches) {
// 消したオーブの数とコンボ数に応じてスコアを計算
score += matches.length * 10 + currentCombo * 50;
}
function updateInfoPanel() {
scoreDisplay.textContent = score; // HTMLのスコア表示部分のテキストを更新
comboDisplay.textContent = currentCombo; // HTMLのコンボ表示部分のテキストを更新
}
updateScore
は、消したオーブの数と、現在のコンボ数に応じてスコアを計算し、合計スコアに加算します。updateInfoPanel
は、scoreDisplay
とcomboDisplay
という、HTMLで用意したスコアとコンボを表示する場所のテキストを、最新のスコアとコンボ数に書き換えます。
h. その他便利機能 (sleep
, showMessage
, hideMessage
)
JavaScript
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); // 指定された時間だけ待つ
}
function showMessage(title, text) {
messageTitle.textContent = title; // メッセージのタイトルを設定
messageText.textContent = text; // メッセージの本文を設定
messageOverlay.classList.add('visible'); // メッセージオーバーレイを表示するCSSクラスを追加
}
function hideMessage() {
messageOverlay.classList.remove('visible'); // メッセージオーバーレイを非表示にするCSSクラスを削除
}
sleep(ms)
は、ゲーム処理を一時停止させるために作られた便利な関数です。アニメーションをじっくり見せたいときなどに使われます。showMessage
とhideMessage
は、ゲーム開始前やゲームオーバー時などに、プレイヤーにメッセージを表示したり消したりするための関数です。CSSで設定したvisible
クラスを付けたり外したりすることで、メッセージの表示・非表示を切り替えています。
まとめ
このオーブパズルゲームのコードは、
- HTMLでゲームの画面を構成する部品を配置し、
- CSSでそれらの部品の見た目を整え、
- JavaScriptでオーブを動かす、消す、補充する、スコアを計算する、タイマーを動かす、音を出すといったゲームのルールと動きを制御しています。
特にJavaScriptは、それぞれの役割を持った小さな関数(機能のまとまり)がたくさん集まって、全体として複雑なゲームの動きを作り出していることがわかると思います。
ゲームループの中で常に画面を更新し、プレイヤーの操作に応じてゲームの状態が変化していくのが特徴です。
初めは難しく感じるかもしれませんが、このように一つ一つの要素がどのような役割を果たしているのかを理解していくと、より深くプログラミングの面白さを感じられるはずです。
ではでは。
