■49個目「無限ハムスター」
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/
今回は、ハムスターが画面を駆け回る“癒し系落ち物パズル”を作ってみました。
4匹ずつくっついたハムスターたちが、テトリスみたいに上からトコトコ落ちてきます。
今回こだわったのは、パズルが揃ったときの「演出」です。(=v=*)
「ただ消えるんじゃなくて、生き物らしく画面の端へ逃げていったら可愛いんじゃないかな?」
そんな遊び心から生まれたゲームになっています。
登場するハムスターは全部で5種類。
- ロボロフスキー(小さくてふかふかベージュ)
- キンクマ(大きくてのんびり黄色)
- ジャンガリアン(定番の灰色)
- ジャンガリアン白×赤目(ちょっと珍しいタイプ)
- ブルーサファイア(涼しげな青灰色)
同じ種類を3匹以上くっつけると、ポンッとばらけて下に落ちてから、
画面の外へ向かって「ツツツーッ!」と一目散に走り去っていきます。
大連鎖が起きると、画面の下がハムスターの運動会みたいになって、
とっても癒されますよ。
たくさん逃がしてあげると制限時間も増えるので、
ハイスコアを目指して可愛がってあげてくださいね。
■ AIとのやりとりで面白かった部分
■ゲーム仕様の相談からスタート
みー:(^v^) 今日もゲームを作ります。html形式で動かせるゲーム。
今日のゲームは「無限ハムスター」。 登場するのはこの4種類。
- ロボロフスキー(小型・ふかふかベージュ)
- キンクマ(大きい・黄色)
- ジャンガリアン(中型・灰色)
- ジャンガリアン(中型・白赤目)
4匹ずつテトリス風の形でつながって落ちてくる落ち物パズルです。 スペースキーで回転、矢印キーで左右移動、↓で高速落下、↑で瞬間着地ができます。
同じ種類が3匹以上つながると、ハムスターたちはばらけて下に落ち、画面の外へ「トコトコ〜ッ」と走り去っていきます。 連鎖が起きると、画面下がハムスターの大行列に……!
AI: 今日もゲーム制作ですね!素晴らしいです。 「無限ハムスター」、とても可愛らしくて中毒性のありそうなパズルゲームですね。
みー:(^_^;) ばらけて走り去る描写、再現難しかったかな。
AI: ……あいた、見透かされてましたか。 実は「スッと消えるだけでもいいかな」と思ってたんですが……さすが常連さん(笑)。 ちゃんと“ばらけて落ちて走り去る”アニメーションを組み込みましたよ。
みー:(つ_=*)制限時間制にしましょう。大連鎖で時間が増える感じ。
AI: 名案です。 スピード感のあるパズルには「連鎖で寿命を延ばす」仕組みがよく合いますからね。
みー:(*^w^) 難易度向上に、ブルーサファイアも追加しよう。
AI: 5種類目の追加、鋭いです。 色(種類)が増えると難易度が一気に跳ね上がりますからね。 ちなみにブルーサファイアはジャンガリアンのカラーバリエーションの一つなんですよ。
■ 実際の動作
最初にAIと相談して作ってもらった初期バージョンです。
今回もほぼイメージ通りに動いてくれましたが、この段階では ハムスターが揃っても走り去らず、ただ消えるだけ になっていました。

(画像:初期のゲーム画面)
こちらが仕上がったバージョンのゲーム画面です。
ハムスターの種類も増え、残り時間制+連鎖で時間が回復するシステム を導入しています。
連鎖が続くほど長く遊べますが、スコアが1000点ごとにレベルアップしてスピードが上がるので油断できません。

(画像:ゲーム画面)
ゲームオーバーは2種類あります。
・上の端まで積み上がった場合(詰み)

(画像:ゲームオーバー)
・残り時間が0になった場合(タイムアップ)

(画像:タイムアップ)
どちらも違った緊張感があって、最後までハラハラしますよ。
■ コードのポイント
今回の記事では、AI と相談しながら作った「無限ハムスター」の HTML コードを公開します。
このコードをそのまま保存するだけで、あなたのPCでもすぐに遊べるようになります。
ブラウザだけで動く、とてもシンプルなゲームなので、気軽に試してみてくださいね。
▼ 遊ぶまでの手順
1. PC の「メモ帳」(※Windowsの場合)や、
Mac の「テキストエディット」など、テキストエディタを開きます。
2. 下のコードをコピーして貼り付けます。
3. 「mugen.html」など、好きな名前で保存します。
(※拡張子は .html にしてください)
4. 保存したファイルをブラウザで開けば、すぐに遊べます。
操作方法はとってもシンプルです! キーボードを使って、ハムスターたちを上手に導いてあげてくださいね。(^_^)
【 遊び方 & 操作方法 】
・ ← →(左右の矢印キー) ハムスターを左右に移動させます。
トコトコ歩く姿がかわいいです。
・ スペースキー ハムスターの向きをクルッと回転。
パズルを揃えるための大事なキーです。(=v=*)
・ ↓(下の矢印キー) 落下スピードを少しだけアップ。
急いで下ろしたい時に便利。
・ ↑(上の矢印キー) 瞬間着地! 一瞬でストンと一番下まで落とします。
慣れてきたらタイム短縮に大活躍。(^^*)
最初はゆっくり考えながら、慣れてきたら大連鎖を狙ってみてくださいね。
スコアが1000点を超えるごとにレベルアップして、
落下スピードがどんどん速くなる、
ちょっとスリリングな仕掛けも入っています。(`・ω・´)✨
ぜひ、たくさんのハムスターたちを画面の外へ逃がしてあげてください!
遊んでみた感想や、どれくらい連鎖できたかなど、
コメントでお待ちしています。
▼ここからコード▼(クリックで開く)
<html lang="ja"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>無限ハムスター - Hardcore Arcade Mode</title> <style> body { background-color: #f0f8ff; display: flex; flex-direction: column; align-items: center; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; } h1 { color: #ff8c00; margin-bottom: 10px; } #game-wrapper { position: relative; } #game-container { display: flex; gap: 20px; } canvas { background-color: #fffaf0; border: 4px solid #8b4513; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); } #score-board { background-color: white; border: 4px solid #8b4513; border-radius: 10px; padding: 20px; width: 150px; text-align: center; height: fit-content; } .status-label { font-size: 14px; color: #666; margin: 5px 0 0 0; } .status-value { font-size: 24px; font-weight: bold; margin: 0 0 15px 0; } #timer { color: #e74c3c; } #score { color: #ff4500; } #level { color: #8a2be2; } #nextCanvas { background-color: #fff; border: 2px dashed #ccc; border-radius: 5px; box-shadow: none; margin-bottom: 15px; } .controls-help { margin-top: 20px; background: white; padding: 15px; border-radius: 10px; border: 2px dashed #ccc; text-align: center; } #retry-overlay { position: absolute; top: 0; left: 0; width: 308px; height: 608px; background: rgba(0, 0, 0, 0.7); border-radius: 10px; display: none; flex-direction: column; justify-content: center; align-items: center; z-index: 10; } #retry-message { color: white; font-size: 30px; font-weight: bold; margin-bottom: 20px; text-align: center; } #retry-btn { padding: 10px 30px; font-size: 20px; font-weight: bold; color: #fff; background-color: #ff8c00; border: none; border-radius: 5px; cursor: pointer; transition: background 0.2s; } #retry-btn:hover { background-color: #e67e22; } </style></head><body> <h1>🐹 無限ハムスター 🐹</h1> <div id="game-wrapper"> <div id="game-container"> <div style="position: relative;"> <canvas id="gameCanvas" width="300" height="600"></canvas> <div id="retry-overlay"> <div id="retry-message">GAME OVER</div> <button id="retry-btn" onclick="retryGame()">リトライ</button> </div> </div> <div id="score-board"> <p class="status-label">NEXT</p> <canvas id="nextCanvas" width="90" height="90"></canvas> <hr> <p class="status-label">LEVEL</p> <p id="level" class="status-value">1</p> <hr> <p class="status-label">残り時間</p> <p id="timer" class="status-value">30.0</p> <hr> <p class="status-label">スコア</p> <p id="score" class="status-value">0</p> </div> </div> </div> <div class="controls-help"> <strong>【操作方法】</strong><br> ← → : 左右移動 / スペース : 回転<br> ↓ : 加速 / ↑ : 瞬間着地<br> <strong>5種登場!スコア1000毎にレベルUP!</strong> </div> <script> const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const nextCanvas = document.getElementById('nextCanvas'); const nextCtx = nextCanvas.getContext('2d'); const scoreElement = document.getElementById('score'); const levelElement = document.getElementById('level'); const timerElement = document.getElementById('timer'); const retryOverlay = document.getElementById('retry-overlay'); const retryMessage = document.getElementById('retry-message'); const COLS = 10; const ROWS = 20; const BLOCK_SIZE = 30; let board = []; let score = 0; let level = 1; let timeLeft = 30.0; let isProcessing = false; let gameOver = false; let animationId; // ハムスターを5種類に増員 const HAMSTERS = { 1: { color: '#E8B87D', eye: '#000' }, // ロボロフスキー 2: { color: '#F0D050', eye: '#000' }, // キンクマ 3: { color: '#A0A0A0', eye: '#000' }, // ジャンガリアン(灰) 4: { color: '#FFFFFF', eye: '#FF0000' }, // ジャンガリアン(白) 5: { color: '#778899', eye: '#000' } // ブルーサファイア(青灰) }; const SHAPES = [ [[1, 1, 1, 1]], [[1, 0, 0], [1, 1, 1]], [[0, 0, 1], [1, 1, 1]], [[1, 1], [1, 1]], [[0, 1, 1], [1, 1, 0]], [[0, 1, 0], [1, 1, 1]], [[1, 1, 0], [0, 1, 1]] ]; let piece = null; let nextPiece = null; let escapingHamsters = []; function drawHamsterAbs(ctxTarget, px, py, type) { if (type === 0) return; const h = HAMSTERS[type]; ctxTarget.fillStyle = h.color; ctxTarget.beginPath(); ctxTarget.arc(px + 15, py + 15, 14, 0, Math.PI * 2); ctxTarget.fill(); ctxTarget.strokeStyle = '#333'; ctxTarget.lineWidth = 1; ctxTarget.stroke(); ctxTarget.beginPath(); ctxTarget.arc(px + 6, py + 6, 4, 0, Math.PI * 2); ctxTarget.fill(); ctxTarget.stroke(); ctxTarget.beginPath(); ctxTarget.arc(px + 24, py + 6, 4, 0, Math.PI * 2); ctxTarget.fill(); ctxTarget.stroke(); ctxTarget.fillStyle = h.eye; ctxTarget.beginPath(); ctxTarget.arc(px + 10, py + 12, 2, 0, Math.PI * 2); ctxTarget.fill(); ctxTarget.beginPath(); ctxTarget.arc(px + 20, py + 12, 2, 0, Math.PI * 2); ctxTarget.fill(); ctxTarget.fillStyle = '#FF9999'; ctxTarget.beginPath(); ctxTarget.arc(px + 15, py + 16, 2, 0, Math.PI * 2); ctxTarget.fill(); } function generateRandomPiece() { const shapeMask = SHAPES[Math.floor(Math.random() * SHAPES.length)]; // ランダム生成を1〜5に変更(5種類) const shape = shapeMask.map(row => row.map(cell => cell ? Math.floor(Math.random() * 5) + 1 : 0)); return { shape, x: Math.floor(COLS / 2) - Math.floor(shape[0].length / 2), y: 0 }; } function createPiece() { piece = nextPiece; nextPiece = generateRandomPiece(); drawNextPiece(); if (checkCollision(piece.x, piece.y, piece.shape)) { triggerGameOver('GAME OVER'); } } function drawNextPiece() { nextCtx.clearRect(0, 0, nextCanvas.width, nextCanvas.height); if (!nextPiece) return; const shapeWidth = nextPiece.shape[0].length * BLOCK_SIZE; const shapeHeight = nextPiece.shape.length * BLOCK_SIZE; const offsetX = (nextCanvas.width - shapeWidth) / 2; const offsetY = (nextCanvas.height - shapeHeight) / 2; for (let y = 0; y < nextPiece.shape.length; y++) { for (let x = 0; x < nextPiece.shape[y].length; x++) { if (nextPiece.shape[y][x]) { drawHamsterAbs(nextCtx, offsetX + x * BLOCK_SIZE, offsetY + y * BLOCK_SIZE, nextPiece.shape[y][x]); } } } } function drawBoard() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#eee'; for(let i=0; i<=COLS; i++) { ctx.beginPath(); ctx.moveTo(i*BLOCK_SIZE, 0); ctx.lineTo(i*BLOCK_SIZE, canvas.height); ctx.stroke(); } for(let i=0; i<=ROWS; i++) { ctx.beginPath(); ctx.moveTo(0, i*BLOCK_SIZE); ctx.lineTo(canvas.width, i*BLOCK_SIZE); ctx.stroke(); } for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) if(board[y][x]) drawHamsterAbs(ctx, x * BLOCK_SIZE, y * BLOCK_SIZE, board[y][x]); } function updateEscapingHamsters() { for (let i = escapingHamsters.length - 1; i >= 0; i--) { let h = escapingHamsters[i]; if (h.state === 'falling') { h.vy += 0.6; h.px += h.vx * 0.4; h.py += h.vy; if (h.py >= canvas.height - BLOCK_SIZE) { h.py = canvas.height - BLOCK_SIZE; h.state = 'running'; h.vx = (h.vx > 0 ? 1 : -1) * (3 + Math.random() * 4); } } else { h.px += h.vx; h.py = (canvas.height - BLOCK_SIZE) - Math.abs(Math.sin(h.px * 0.15) * 8); if (h.px < -BLOCK_SIZE || h.px > canvas.width) escapingHamsters.splice(i, 1); } } } function checkCollision(px, py, shape) { for (let y = 0; y < shape.length; y++) { for (let x = 0; x < shape[y].length; x++) { if (shape[y][x]) { let nx = px + x, ny = py + y; if (nx < 0 || nx >= COLS || ny >= ROWS || (ny >= 0 && board[ny][nx])) return true; } } } return false; } function applyGravity() { let moved = false; for (let x = 0; x < COLS; x++) { for (let y = ROWS - 2; y >= 0; y--) { if (board[y][x] !== 0 && board[y + 1][x] === 0) { let dropY = y; while (dropY + 1 < ROWS && board[dropY + 1][x] === 0) dropY++; board[dropY][x] = board[y][x]; board[y][x] = 0; moved = true; } } } return moved; } function findMatches() { let matched = []; let visited = Array.from({ length: ROWS }, () => Array(COLS).fill(false)); for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { if (board[y][x] !== 0 && !visited[y][x]) { let type = board[y][x], group = [], queue = [{ x, y }]; visited[y][x] = true; while (queue.length > 0) { let curr = queue.shift(); group.push(curr); [[0, 1], [1, 0], [0, -1], [-1, 0]].forEach(d => { let nx = curr.x + d[0], ny = curr.y + d[1]; if (nx >= 0 && nx < COLS && ny >= 0 && ny < ROWS && !visited[ny][nx] && board[ny][nx] === type) { visited[ny][nx] = true; queue.push({ x: nx, y: ny }); } }); } if (group.length >= 3) matched = matched.concat(group); } } } return matched; } async function processBoard() { isProcessing = true; let currentChain = 0; if (applyGravity()) await new Promise(r => setTimeout(r, 200)); while (true) { let matches = findMatches(); if (matches.length === 0) break; currentChain++; let extraHamsters = matches.length > 3 ? matches.length - 3 : 0; timeLeft += (currentChain * 0.5) + (extraHamsters * 0.5); if (timeLeft > 60.0) timeLeft = 60.0; score += matches.length * 10 * currentChain; scoreElement.innerText = score; let newLevel = Math.floor(score / 1000) + 1; if (newLevel > level) { level = newLevel; levelElement.innerText = level; } for (let p of matches) { escapingHamsters.push({ px: p.x * BLOCK_SIZE, py: p.y * BLOCK_SIZE, type: board[p.y][p.x], vx: (Math.random() - 0.5) * 6, vy: -5, state: 'falling' }); board[p.y][p.x] = 0; } await new Promise(r => setTimeout(r, 400)); applyGravity(); await new Promise(r => setTimeout(r, 200)); } isProcessing = false; if (!gameOver) createPiece(); } function hardDrop() { while (!checkCollision(piece.x, piece.y + 1, piece.shape)) piece.y++; const { shape, x, y } = piece; for (let r = 0; r < shape.length; r++) for (let c = 0; c < shape[r].length; c++) if (shape[r][c]) board[y + r][x + c] = shape[r][c]; piece = null; processBoard(); } document.addEventListener('keydown', (e) => { if (gameOver || isProcessing || !piece) return; if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) e.preventDefault(); if (e.code === 'ArrowLeft' && !checkCollision(piece.x - 1, piece.y, piece.shape)) piece.x--; if (e.code === 'ArrowRight' && !checkCollision(piece.x + 1, piece.y, piece.shape)) piece.x++; if (e.code === 'ArrowDown' && !checkCollision(piece.x, piece.y + 1, piece.shape)) piece.y++; if (e.code === 'ArrowUp') hardDrop(); if (e.code === 'Space') { const s = piece.shape, n = s[0].map((_, i) => s.map(row => row[i]).reverse()); if (!checkCollision(piece.x, piece.y, n)) piece.shape = n; } }); let lastTime = 0; let dropCounter = 0; function triggerGameOver(msg) { gameOver = true; retryMessage.innerHTML = msg + "<br><span style='font-size:20px; color:#ffdd57;'>Score: " + score + "</span>"; retryOverlay.style.display = 'flex'; } function initGame() { board = Array.from({ length: ROWS }, () => Array(COLS).fill(0)); score = 0; level = 1; timeLeft = 30.0; isProcessing = false; gameOver = false; escapingHamsters = []; scoreElement.innerText = score; levelElement.innerText = level; retryOverlay.style.display = 'none'; lastTime = performance.now(); nextPiece = generateRandomPiece(); createPiece(); } function retryGame() { initGame(); } function update(time) { const deltaTime = time - lastTime; lastTime = time; if (!gameOver) { if (!isProcessing) { timeLeft -= deltaTime / 1000; if (timeLeft <= 0) { timeLeft = 0; triggerGameOver('TIME UP!'); } } timerElement.innerText = timeLeft.toFixed(1); let currentDropInterval = Math.max(150, 1000 - ((level - 1) * 100)); if (!isProcessing && piece && !gameOver) { dropCounter += deltaTime; if (dropCounter > currentDropInterval) { if (!checkCollision(piece.x, piece.y + 1, piece.shape)) piece.y++; else { const { shape, x, y } = piece; for (let r = 0; r < shape.length; r++) for (let c = 0; c < shape[r].length; c++) if (shape[r][c]) board[y + r][x + c] = shape[r][c]; piece = null; processBoard(); } dropCounter = 0; } } } updateEscapingHamsters(); drawBoard(); if (piece && !isProcessing && !gameOver) { for (let y = 0; y < piece.shape.length; y++) for (let x = 0; x < piece.shape[y].length; x++) if (piece.shape[y][x]) drawHamsterAbs(ctx, (piece.x + x) * BLOCK_SIZE, (piece.y + y) * BLOCK_SIZE, piece.shape[y][x]); } for (let h of escapingHamsters) drawHamsterAbs(ctx, h.px, h.py, h.type); animationId = requestAnimationFrame(update); } initGame(); animationId = requestAnimationFrame(update); </script></body></html>
■ 今日の学び
今回のゲームは、#1「BREAK BLOCK」以来の久しぶりの落ちげーでした。
R8年4月29日現在で105個目のゲームになりますが、「無限ハムスター」のあとにも落ち物パズルを2つほど作っています。
もっといろいろ工夫したかったのですが……
ある程度遊べる形になってくると、
毎回のことながら
作るより遊ぶ方に夢中になってしまう のが悩みどころです(つv<`)
実はハムスターは昔から好きで、
以前には ハムスターが主人公のRPG も作っていました。
世界観の設定までは気合を入れて作ったのですが、
かなり時間がかかりそうだったので、
屋外ステージに移る手前で止まっています。
せっかくなので、当時AIに送った最初の提案だけ紹介します。
▼ ハムスターRPGの設定(当時の提案)
- 時代設定:現代
- 主人公:飼われているハムスター
- ある日突然進化し、二足歩行・道具の使用・スマホを持ち上げられる力を獲得
- この世界には存在しない「魔力」を手に入れ、全身を覆う防御膜として働く
- 魔力はMPで表現され、外の世界に出ると少しずつ消耗
- MPが0になると防御膜が消え、ダメージが大きくなる
ストーリー: 進化したことでケージから自由に出入りできるようになった主人公が、 離れ離れになった兄弟たちを探して冒険に出る。
ステージ構成: 家の中 → 倉庫 → 屋外
武器: つまようじ → 短い鉛筆 → 子供用箸 → フォーク → プラスドライバー → 移植ごて → 摘果ばさみ … (ボスを倒すと入手)
敵: なぞの虫小、アリ、ハエトリグモ、ゴキブリ小〜大、巨大家グモ ボスは色違い+取り巻き2体
戦闘: 「たたかう」「にげる」「魔法」「アイテム」のコマンド式ターンバトル
AIの反応はこんな感じでした。
AI:素晴らしい設定ですね! ハムスターが魔力を纏って家の中を大冒険する……ワクワクします。 特に「MPが防御膜になっている」という仕様が、小さなハムスターの脆さと特別さを上手く表現しています。
▼ 今日のまとめ
今回の「無限ハムスター」を作っていて、 ゲーム作りは“遊ぶ楽しさ”と“考える楽しさ”の両方がある ということを改めて感じました。
また時間ができたら、ハムスターRPGも続きを作りたいです。(^v^*)
■ 次回は、スイカが好きで作ったゲームです。
板を傾けてスイカをコロコロ転がし、ゴールまで導くゲームです。 シンプルなのに、ちょっとした角度で結果が変わる“繊細さ”がクセになります。
スピードを出しすぎると転げ落ちたり、 思わぬ方向へ転がってしまったり…… 小さなスイカを慎重に運ぶ、緊張感のあるアクションゲームになりました。
次回もお楽しみに。(*^v^)ノシ
今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)

コメントを残す