実験#49:仲間とそろって連鎖逃走(無限ハムスター)

■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点を超えるごとにレベルアップして、
落下スピードがどんどん速くなる、
ちょっとスリリングな仕掛けも入っています。(`・ω・´)✨

ぜひ、たくさんのハムスターたちを画面の外へ逃がしてあげてください!
遊んでみた感想や、どれくらい連鎖できたかなど、
コメントでお待ちしています。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<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^*)

コメントを残す

AIと作る100のゲーム実験室(*^v^*)をもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む