※落ち物パズルゲーがブログで遊べる!!
ブログの更新が止まってしまっている間、何か新しい試みをしたいと考え、ブラウザだけで動く「落ち物パズルゲーム」を自作してみました。
↓今回作ってみたのはこんな見た目のゲームで、しっかりとゲーム要素を入れてみました。
更新履歴・おしらせ
2026/02/17 🔴🔵🟡 パズルゲームに「L字消し」と「BEEP音」を実装しました!
2026/02/17 タイトルの視認性を向上させ、スタートボタンを設置しました。
2026/02/17 落ち物パズルゲームの基本プロトタイプを公開。
🔴🔵🟡 COMBO PUZZLE
SCORE: 0
←→ 移動 / Z 回転 / X 即着地
一見難しそうに見えるゲーム制作ですが、一歩ずつロジックを積み上げていく過程は非常に論理的で、プログラミングの楽しさが詰まっています。今回はその制作の流れを解説します。
なお、私はプログラムの知識はBASIC等を遊び程度にしか使った事がなく、はっきり言って知識ゼロに近いです。
今回はプログラムの知識なんて全く無い!という私と同じような人でも簡単に作れてしまいます(≧∇≦)
では解説等を記述します。
追記
なお、手伝ってもらったのはGoogleのAI Geminiを使いました。
それではGeminiを使いながらの解説を始めます。
1. 描画領域(Canvas)の設定と試行錯誤
まずはHTML5の canvas 要素を使い、ゲーム画面を作成しました。
最初は100×100マスという広大なフィールドを想定していましたが、実際に描画してみると1マスが小さくなりすぎ、視認性に課題が出ました。
そこでゲームとしての遊びやすさを重視し、最終的に 20×20マス という「密度」に落ち着かせました。
1マスを20pxに設定することで、モバイルでもPCでも見やすいサイズ感を実現しています。
2. 2個1組のブロックと「4方向回転」のロジック
このゲームの核心は、2つのブロックがペアで降ってくる点です。
中心となるブロック(軸)に対して、もう一つのブロックがどの位置にあるかを 0(右), 1(下), 2(左), 3(上) という数値で管理しています。
「Zキー」を押すごとにこの数値を更新し、相対的な座標を計算し直すことで、軸を中心に円を描くような滑らかな回転を実装しました。
3. 「連鎖」を生む消去アルゴリズム
パズルの醍醐味である「消去」と「連鎖」は、以下の3つのステップを繰り返すことで実現しています。
・走査: 縦・横に同じ色が3つ以上並んでいる場所を特定する。
・重力: 消えたマスの空いた空間に、上にあるブロックを詰める。
・再帰判定: ブロックが落ちた後、再び3つ並んでいないかチェックする。
この判定を自動で繰り返すことで、1つのアクションが大きな連鎖を生む爽快なシステムが出来上がりました。
4. ユーザー体験(UX)を支える操作性
ただ動くだけでなく、「心地よさ」を感じてもらうために以下の機能を追加しました。
・ハードドロップ: 「Xキー」で一気に着地させ、ゲームのテンポを向上。
・壁蹴り機能: 壁際で回転させた際、自動で1マス内側に移動させることで「回転できないストレス」を解消。
・スタートボタン: ページを開いていきなり始まらないよう、明示的な開始トリガーを設置。
5. スコアシステムと視認性の向上
最後に、連鎖数に応じたスコア倍率を設定し、ゲーム性を高めました。
デザイン面では、どんなブログ背景でも文字がはっきり読めるよう、CSSの text-shadow を活用してタイトルに強力な縁取りを施しています。
まとめ
「何を書くか」に迷った時、自分の手で何かを「作る」過程そのものが、何よりのコンテンツになります。
このゲームは、HTML/JSという身近な技術だけで構築されています。コードの詳細は以下に掲載しますので、興味のある方はぜひ自分の環境でも動かしてみてください。
※注意としてはBloggerでは動くのですが、別のブログサイトでは動かない可能性もあります。
なのでもし他のブログサイトで動かなかった場合、試しにBloggerでやってみてください。
なお、自分のブログでもこのゲームを設置したい!という人がいるかもしれませんので、コードを記述致しますが、私のブログのコードを使ったという記述をお願いします。
例:
ゲームコードサイト:Gスカの適当に遊べ
https://skgura.blogspot.com/2026/02/blog-post.html
改造は自由にどうぞ!また、コメントにてこういう改造をしてみたよ!とかってありましたら、コメントにあなたブログのURLつきでしていただけると訪れてくれた人が遊びに行くかもしれません(≧∇≦)
ちなみにURLだけの記述だと弾かれてしまうかもしれませんので、日本語の文章の記述もお願いします。
動画視聴、もしくはOFUSEをくれるとやる気が上がりますヽ(T∇T )ノ
(この記事の解説動画は記事の最後に貼り付けます)
以下 ゲームコード
<div class="game-wrapper">
<style>
/* ... 前回のスタイルをそのまま使用 ... */
.game-wrapper { background: #1a1a1a; color: #fff; text-align: center; font-family: 'Courier New', Courier, monospace; padding: 20px 0; border-radius: 10px; }
.game-wrapper .game-title { font-size: 28px; font-weight: bold; color: #ffffff; text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 0 10px rgba(0,212,255,0.8); background: rgba(0, 0, 0, 0.8); display: inline-block; padding: 10px 25px; border-radius: 50px; margin: 20px 0; border: 2px solid #00d4ff; }
.game-wrapper #game-container { position: relative; display: inline-block; }
.game-wrapper canvas { border: 5px solid #00d4ff; background: #000; box-shadow: 0 0 25px rgba(0,212,255,0.4); display: block; margin: 0 auto; }
.game-wrapper #overlay { position: absolute; top: 0; left: 0; width: 410px; height: 410px; background: rgba(0, 0, 0, 0.85); display: flex; flex-direction: column; justify-content: center; align-items: center; border-radius: 5px; z-index: 10; }
.game-wrapper #start-btn { padding: 15px 40px; font-size: 24px; font-family: inherit; font-weight: bold; color: #00d4ff; background: transparent; border: 3px solid #00d4ff; cursor: pointer; transition: 0.3s; border-radius: 10px; box-shadow: 0 0 10px #00d4ff; }
.game-wrapper .ui { background: rgba(40, 40, 40, 0.9); padding: 15px; border-radius: 8px; margin: 15px auto; border: 1px solid #555; width: 380px; }
.game-wrapper .score-board { font-size: 24px; color: #00ff41; text-shadow: 0 0 5px #00ff41; margin-bottom: 5px; }
.game-wrapper .combo-board { font-size: 20px; color: #ff3e3e; font-weight: bold; min-height: 1.2em; }
.game-wrapper kbd { background: #eee; color: #000; padding: 2px 5px; border-radius: 3px; font-weight: bold; }
</style>
<h2 class="game-title">🔴🔵🟡 COMBO PUZZLE</h2>
<div id="game-container">
<canvas id="gameCanvas" width="400" height="400"></canvas>
<div id="overlay"><button id="start-btn">GAME START</button></div>
</div>
<div class="ui">
<div class="score-board">SCORE: <span id="score">0</span></div>
<div class="combo-board" id="combo"></div>
<div style="font-size: 14px; color: #ccc; margin-top: 10px;">
<kbd>←</kbd><kbd>→</kbd> 移動 / <kbd>Z</kbd> 回転 / <kbd>X</kbd> 即着地
</div>
</div>
<script>
(function() {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// 音を鳴らす魔法の関数(波形から生成します)
function playSound(type) {
if (audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (type === 'land') { // 着地音
osc.type = 'triangle';
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.1);
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0, now + 0.1);
osc.start(); osc.stop(now + 0.1);
} else if (type === 'match') { // 消去音
osc.type = 'square';
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(1000, now + 0.15);
gain.gain.setValueAtTime(0.1, now);
gain.gain.linearRampToValueAtTime(0, now + 0.15);
osc.start(); osc.stop(now + 0.15);
} else if (type === 'rotate') { // 回転音
osc.type = 'sine';
osc.frequency.setValueAtTime(400, now);
osc.frequency.linearRampToValueAtTime(500, now + 0.05);
gain.gain.setValueAtTime(0.1, now);
gain.gain.linearRampToValueAtTime(0, now + 0.05);
osc.start(); osc.stop(now + 0.05);
}
}
// --- 以下、ゲームロジック(音を鳴らすタイミングを各所に追加) ---
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const comboEl = document.getElementById('combo');
const overlay = document.getElementById('overlay');
const startBtn = document.getElementById('start-btn');
const ROWS = 20, COLS = 20, BLOCK_SIZE = 20;
const COLORS = ['🔴', '🔵', '🟡'];
const COLOR_MAP = { '🔴': '#ff3e3e', '🔵': '#3e3eff', '🟡': '#ffff3e' };
let field = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
let score = 0, current = null, gameRunning = false, gameInterval = null;
function spawn() { return { x: 9, y: 0, t1: COLORS[Math.floor(Math.random()*3)], t2: COLORS[Math.floor(Math.random()*3)], rot: 0 }; }
function getPos2(x, y, rot) {
const offsets = [{dx:1, dy:0}, {dx:0, dy:1}, {dx:-1, dy:0}, {dx:0, dy:-1}];
return { x2: x + offsets[rot].dx, y2: y + offsets[rot].dy };
}
startBtn.addEventListener('click', () => {
overlay.style.display = 'none';
field = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
score = 0; scoreEl.innerText = "0"; comboEl.innerText = "";
gameRunning = true; current = spawn();
if(gameInterval) clearInterval(gameInterval);
gameInterval = setInterval(gameLoop, 1000);
draw();
});
function gameLoop() {
if(!gameRunning) return;
if (!checkCollision(current.x, current.y + 1, current.rot)) { current.y++; } else { lock(); }
draw();
}
function drawBall(x, y, color, active=false) {
ctx.fillStyle = COLOR_MAP[color];
ctx.beginPath(); ctx.arc(x*BLOCK_SIZE + 10, y*BLOCK_SIZE + 10, active ? 9 : 8, 0, Math.PI*2); ctx.fill();
if(active) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 2; ctx.stroke(); }
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#111';
for(let i=0; i<=400; i+=20) {
ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,400); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,i); ctx.lineTo(400,i); ctx.stroke();
}
for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { if (field[r][c]) drawBall(c, r, field[r][c]); } }
if(current && gameRunning) {
drawBall(current.x, current.y, current.t1, true);
let p2 = getPos2(current.x, current.y, current.rot);
if (p2.y2 >= 0) drawBall(p2.x2, p2.y2, current.t2, true);
}
}
function checkCollision(nx, ny, nrot) {
let p2 = getPos2(nx, ny, nrot);
if (nx < 0 || nx >= COLS || p2.x2 < 0 || p2.x2 >= COLS || ny >= ROWS || p2.y2 >= ROWS) return true;
if (ny >= 0 && field[ny][nx]) return true;
if (p2.y2 >= 0 && field[p2.y2][p2.x2]) return true;
return false;
}
function applyGravity() {
let moved = false;
for (let c = 0; c < COLS; c++) {
for (let r = ROWS - 2; r >= 0; r--) {
if (field[r][c] && !field[r+1][c]) { field[r+1][c] = field[r][c]; field[r][c] = null; moved = true; }
}
}
if (moved) applyGravity();
}
function checkMatch(comboCount = 0) {
let visited = Array.from({ length: ROWS }, () => Array(COLS).fill(false));
let totalDeleted = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (field[r][c] && !visited[r][c]) {
let group = []; let color = field[r][c]; let queue = [{r, c}]; visited[r][c] = true;
while (queue.length > 0) {
let curr = queue.shift(); group.push(curr);
let dirs = [{dr:-1, dc:0}, {dr:1, dc:0}, {dr:0, dc:-1}, {dr:0, dc:1}];
for (let d of dirs) {
let nr = curr.r + d.dr, nc = curr.c + d.dc;
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && !visited[nr][nc] && field[nr][nc] === color) {
visited[nr][nc] = true; queue.push({r: nr, c: nc});
}
}
}
if (group.length >= 3) { totalDeleted = totalDeleted.concat(group); }
}
}
}
if (totalDeleted.length > 0) {
playSound('match'); // 消去時に音を鳴らす!
let bonus = comboCount > 0 ? comboCount * 2 : 1;
score += totalDeleted.length * 10 * bonus;
scoreEl.innerText = score;
if(comboCount > 0) comboEl.innerText = `${comboCount} COMBO! (x${bonus})`;
totalDeleted.forEach(p => field[p.r][p.c] = null);
applyGravity();
setTimeout(() => { checkMatch(comboCount + 1); draw(); }, 250);
} else if(comboCount > 0) {
setTimeout(() => { if(comboEl.innerText.includes("COMBO")) comboEl.innerText = ""; }, 1000);
}
}
function lock() {
playSound('land'); // 着地音!
field[current.y][current.x] = current.t1;
let p2 = getPos2(current.x, current.y, current.rot);
if (p2.y2 >= 0) field[p2.y2][p2.x2] = current.t2;
applyGravity();
checkMatch(0);
current = spawn();
if (checkCollision(current.x, current.y, current.rot)) {
gameRunning = false; clearInterval(gameInterval);
overlay.style.display = 'flex'; startBtn.innerText = "RETRY?";
alert("GAME OVER! SCORE: " + score);
}
}
window.addEventListener('keydown', e => {
if(!gameRunning) return;
let k = e.key.toLowerCase();
if (e.key === 'ArrowLeft' && !checkCollision(current.x - 1, current.y, current.rot)) current.x--;
if (e.key === 'ArrowRight' && !checkCollision(current.x + 1, current.y, current.rot)) current.x++;
if (k === 'z') {
let nRot = (current.rot + 1) % 4;
if (!checkCollision(current.x, current.y, nRot)) {
current.rot = nRot; playSound('rotate'); // 回転音!
}
else if (!checkCollision(current.x - 1, current.y, nRot)) { current.x--; current.rot = nRot; playSound('rotate'); }
else if (!checkCollision(current.x + 1, current.y, nRot)) { current.x++; current.rot = nRot; playSound('rotate'); }
}
if (k === 'x') { while (!checkCollision(current.x, current.y + 1, current.rot)) current.y++; lock(); }
draw();
});
draw();
})();
</script>
</div>
追記
記事公開した当初は縦か横に3つ繋がってないと●が消えない仕組みだったのですが、2月17日の12時手前に3つ以上繋がってれば下記のような状態でも消えるように変えました。
今まで→縦か横に3
新→縦2横1でも消える これによって連鎖もしやすく。
0 件のコメント:
コメントを投稿
I'm sorry, Japanese text only.
荒らし目的と思われるコメントは気づき次第対処します。