はじめに
くま、パンダ、白熊を積み上げていくタワーバトルゲームを作りました
Geminiでくま、パンダ、白熊を積み上げていくタワーバトルゲームを作りました pic.twitter.com/RuEdNEMPIV
— がちもとさん (@sotongshi) December 25, 2025
開発環境
- Gemini
導入
技術スタックの選定
本作は、ブラウザ上で滑らかな物理挙動を実現するために以下の技術を採用しています。
- Matter.js: 2D物理エンジン。重力、衝突判定、摩擦、反発などの計算を担います。
- Canvas API: Matter.jsの座標データを基に、絵文字(Emoji)やカスタムUIを描画します。
- Tailwind CSS: スコア表示や風速インジケーターなどのモダンなUIを構築。
実装のハイライト
① 形状の最適化:円形から「四角形」へ
初期は円形の当たり判定を使用していましたが、「積み上げた際の安定感」を重視し、四角形の判定(オクルージョン)に変更しました。これにより、平らな面で支え合うことが可能になり、物理パズルとしての戦略性が向上しました。
② スクロールシステムの洗練:一方向への安定上昇
「積み上げると落とす位置とスタックが近すぎる」という課題に対し、独自のスクロールロジックを構築しました。
5個目からのトリガー: 最初の5個までは固定視点で安定させ、それ以降は1個落とすごとに動物のサイズ分(50px)だけ正確に視点を上げます。
逆流防止(一方向ロック): スタックの揺れや崩れによってカメラが上下に動かないよう、Math.maxを用いて「一度上がった視点は下がらない」ように固定し、プレイの集中力を削がない設計にしました。
③ ユーザー補助:ミニマップと全体俯瞰
タワーが高くなるにつれ、「今どれくらいの高さなのか」がわからなくなる問題を解決するため、右上に全体プレビュー(ミニマップ)を実装。
動的スケーリング: タワーの高さに合わせて自動で縮尺が変わり、常に「地面」と「頂点」を把握できます。
④ 環境要素:そよ風の導入
単調な積み上げに「予測不能な楽しさ」を加えるため、ランダムな風システムを導入しました。
視覚的フィードバック: 風向きと強さを矢印と数値でリアルタイムに表示。
微細な影響: 物理演算に Body.applyForce を用いることで、落とす瞬間のわずかな「ズレ」を計算する遊びを加えました。
会話履歴
💬 くま、パンダ、白熊を積んでいくゲーム
💬 オクルージョンは四角形にした方がいいですね
💬 積み上げていくと上に移動していって
💬 5個ずつ載せたら視点をずらしましょう
💬 くまたちが移動してないよ
💬 視点が上がっても、クマが固定されたままなんですよね
💬 土台が2つありませんか?視点が上がっても土台も固定されたままのやつがありますね。
💬 クマが落ちちゃいますね、StartLINEが下がってるので、判定がおかしい気がしますね
💬 土台がなくなってしまいました
💬 カメラの視点がおかしいです。土台もクマも見えません
💬 5個載ってから1個ずつ乗っけるたびに、視点を上げていきましょうか(世界が下に下がる)
💬 右上に全体のプレビューできますか?
💬 落とす位置と一番上のクマとの間にもう少し間隔があった方がいいですね、積み上げるとだんだん落とす位置と一番上のクマとの間隔がないので確認してください
💬 上下に動いちゃいますね、毎回、スペースを確保して固定して欲しいものです。
💬 クマを一個落としたら一個分ずらすだけでいいんですよ。5個落としてから
💬 一定距離が少し大きいのでクマのサイズにピッタリ合わせないと、タワーが画面からなくなってしまいますね
💬 くま、パンダ、白熊、それぞれサイズが違いますか?
💬 サイズは全て50pxでいいです
💬 ほんの少し風を吹かせた方がいいですね
成果物
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>くまくまスタック</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #f0f9ff;
font-family: 'Helvetica Neue', Arial, sans-serif;
touch-action: manipulation;
}
canvas {
display: block;
}
#ui-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 20px;
}
.interactive {
pointer-events: auto;
}
.bear-gradient {
background: linear-gradient(135deg, #fbbf24 0%, #d97706 100%);
}
</style>
</head>
<body>
<div id="ui-layer">
<div class="w-full flex justify-between px-6 items-start">
<!-- Score UI -->
<div class="bg-white/80 backdrop-blur px-6 py-2 rounded-full shadow-lg flex gap-8 items-center border-2 border-amber-200">
<div class="text-amber-900 font-bold">
スコア: <span id="score">0</span>
</div>
<div class="text-amber-700 font-semibold text-sm">
ハイスコア: <span id="high-score">0</span>
</div>
</div>
<!-- Wind Indicator -->
<div id="wind-panel" class="bg-white/60 backdrop-blur px-4 py-2 rounded-xl shadow-sm border border-blue-200 flex flex-col items-center min-w-[100px]">
<div class="text-[10px] font-bold text-blue-600 uppercase tracking-widest">Wind</div>
<div id="wind-arrow" class="text-2xl transition-transform duration-500">→</div>
<div id="wind-text" class="text-[10px] font-mono text-blue-500">0.0 m/s</div>
</div>
<!-- Mini-map placeholder -->
<div class="w-32 h-48 bg-transparent"></div>
</div>
<div id="overlay" class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center interactive z-50">
<div class="bg-white p-8 rounded-3xl shadow-2xl text-center max-w-sm mx-4 border-4 border-amber-400">
<h1 id="title" class="text-3xl font-black text-amber-600 mb-4">くまくまスタック</h1>
<p id="message" class="text-gray-600 mb-6">
くま・パンダ・白熊を<br>バランスよく積み上げよう!<br>
<span class="text-sm font-bold text-amber-500">風に気をつけて落としてね!</span>
</p>
<div class="flex justify-center gap-4 mb-6 text-4xl">
<span>🐻</span><span>🐼</span><span>🐻❄️</span>
</div>
<button id="start-btn" class="w-full bear-gradient text-white font-bold py-4 rounded-2xl shadow-lg hover:scale-105 transition-transform active:scale-95 text-xl">
ゲームスタート
</button>
</div>
</div>
</div>
<script>
const { Engine, Render, Runner, Bodies, Composite, Events, World, Body } = Matter;
let engine, render, runner;
let score = 0;
let highScore = localStorage.getItem('kuma_high_score') || 0;
let isGameOver = true;
let nextAnimalType = 0;
let spawnX = window.innerWidth / 2;
let lastDroppedTime = 0;
const dropCooldown = 600;
// View configuration
let viewOffset = 0;
let targetOffset = 0;
let groundY = 0;
const SPAWN_Y_UI = 120;
const FIXED_SIZE = 50;
// Wind logic
let windVelocity = 0; // Current wind power (-1 to 1)
let targetWindVelocity = 0;
let windTimer = 0;
const animals = [
{ emoji: '🐻', label: 'くま', color: '#8B4513', size: FIXED_SIZE },
{ emoji: '🐼', label: 'パンダ', color: '#333333', size: FIXED_SIZE },
{ emoji: '🐻❄️', label: '白熊', color: '#E5E7EB', size: FIXED_SIZE }
];
document.getElementById('high-score').textContent = highScore;
function init() {
engine = Engine.create({
enableSleeping: false,
positionIterations: 10,
velocityIterations: 10
});
render = Render.create({
element: document.body,
engine: engine,
options: {
width: window.innerWidth,
height: window.innerHeight,
wireframes: false,
background: 'transparent'
}
});
Render.run(render);
runner = Runner.create();
Runner.run(runner, engine);
Events.on(runner, 'afterUpdate', () => {
if (isGameOver) return;
// Update wind logic
updateWind();
const allBodies = Composite.allBodies(engine.world);
allBodies.forEach(body => {
if (body.label === 'animal') {
// Apply wind force
// We use a very small factor for "a little bit of wind"
const windForce = windVelocity * 0.00015;
Body.applyForce(body, body.position, { x: windForce, y: 0 });
// Game Over check
if (body.position.y > groundY + 400) {
endGame();
}
}
});
// Camera scroll
viewOffset += (targetOffset - viewOffset) * 0.05;
});
Events.on(render, 'afterRender', () => {
const context = render.context;
const allBodies = Composite.allBodies(engine.world);
context.clearRect(0, 0, render.options.width, render.options.height);
context.save();
context.translate(0, viewOffset);
allBodies.forEach(body => {
if (body.label === 'ground') {
const w = body.groundWidth || 300;
const h = 40;
context.fillStyle = '#4ade80';
context.beginPath();
context.roundRect(body.position.x - w/2, body.position.y - h/2, w, h, 10);
context.fill();
context.fillStyle = '#166534';
context.font = 'bold 16px sans-serif';
context.textAlign = 'center';
context.fillText('START LINE', body.position.x, body.position.y + 6);
}
if (body.animalIndex !== undefined) {
const animal = animals[body.animalIndex];
context.save();
context.translate(body.position.x, body.position.y);
context.rotate(body.angle);
context.font = '56px Arial';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(animal.emoji, 0, 0);
context.restore();
}
});
context.restore();
if (!isGameOver) {
const currentAnimal = animals[nextAnimalType];
context.globalAlpha = 0.7;
context.font = '64px Arial';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(currentAnimal.emoji, spawnX, SPAWN_Y_UI);
context.globalAlpha = 1.0;
// Guide line (curved slightly by wind if you want, but straight is better for UX)
context.beginPath();
context.setLineDash([5, 5]);
context.moveTo(spawnX, SPAWN_Y_UI + 25);
context.lineTo(spawnX, window.innerHeight);
context.strokeStyle = 'rgba(0,0,0,0.1)';
context.stroke();
}
drawMiniMap(context, allBodies);
});
render.canvas.addEventListener('mousemove', (e) => {
const rect = render.canvas.getBoundingClientRect();
spawnX = e.clientX - rect.left;
});
}
function updateWind() {
windTimer--;
if (windTimer <= 0) {
// Change wind direction and strength
targetWindVelocity = (Math.random() - 0.5) * 2; // -1.0 to 1.0
windTimer = 180 + Math.random() * 240; // 3-7 seconds
}
// Interpolate wind for smoothness
windVelocity += (targetWindVelocity - windVelocity) * 0.01;
// Update UI
const arrow = document.getElementById('wind-arrow');
const text = document.getElementById('wind-text');
// Arrow rotation based on wind
const rotation = windVelocity >= 0 ? 0 : 180;
const scaleX = Math.abs(windVelocity) * 1.5 + 0.5;
arrow.style.transform = `rotate(${rotation}deg) scaleX(${scaleX})`;
arrow.style.color = Math.abs(windVelocity) > 0.6 ? '#ef4444' : '#3b82f6';
text.textContent = (Math.abs(windVelocity) * 5).toFixed(1) + " m/s";
}
function drawMiniMap(ctx, allBodies) {
const mapWidth = 100;
const mapHeight = 160;
const mapX = window.innerWidth - mapWidth - 25;
const mapY = 25;
let minY = groundY;
allBodies.forEach(b => {
if (b.position.y < minY) minY = b.position.y;
});
const worldMinY = Math.min(minY - 300, -viewOffset);
const worldMaxY = groundY + 150;
const worldHeight = worldMaxY - worldMinY;
const scale = mapHeight / Math.max(worldHeight, window.innerHeight);
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(mapX, mapY, mapWidth, mapHeight, 10);
ctx.fill();
ctx.stroke();
ctx.save();
ctx.beginPath();
ctx.rect(mapX, mapY, mapWidth, mapHeight);
ctx.clip();
const visibleYStart = -viewOffset;
ctx.fillStyle = 'rgba(191, 219, 254, 0.4)';
ctx.fillRect(mapX, mapY + (visibleYStart - worldMinY) * scale, mapWidth, window.innerHeight * scale);
const mapGroundY = mapY + (groundY - worldMinY) * scale;
ctx.fillStyle = '#4ade80';
ctx.fillRect(mapX + mapWidth * 0.1, mapGroundY - 2, mapWidth * 0.8, 4);
allBodies.forEach(body => {
if (body.animalIndex !== undefined) {
const bx = mapX + (body.position.x / window.innerWidth) * mapWidth;
const by = mapY + (body.position.y - worldMinY) * scale;
ctx.fillStyle = animals[body.animalIndex].color;
ctx.beginPath();
ctx.arc(bx, by, 3, 0, Math.PI * 2);
ctx.fill();
}
});
ctx.restore();
ctx.fillStyle = '#92400e';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('全体プレビュー', mapX + mapWidth/2, mapY + mapHeight + 15);
}
function dropAnimal() {
if (isGameOver) return;
const now = Date.now();
if (now - lastDroppedTime < dropCooldown) return;
const physicsDropY = SPAWN_Y_UI - viewOffset;
const animal = Bodies.rectangle(spawnX, physicsDropY, FIXED_SIZE, FIXED_SIZE, {
restitution: 0.1,
friction: 0.8,
frictionStatic: 1.5,
density: 0.001,
label: 'animal'
});
animal.animalIndex = nextAnimalType;
animal.render.visible = false;
Composite.add(engine.world, animal);
score++;
document.getElementById('score').textContent = score;
if (score > 5) {
targetOffset += FIXED_SIZE;
}
nextAnimalType = Math.floor(Math.random() * animals.length);
lastDroppedTime = now;
}
function startGame() {
Composite.clear(engine.world);
initWorldState();
score = 0;
viewOffset = 0;
targetOffset = 0;
windVelocity = 0;
targetWindVelocity = 0;
windTimer = 0;
document.getElementById('score').textContent = score;
isGameOver = false;
document.getElementById('overlay').classList.add('hidden');
nextAnimalType = Math.floor(Math.random() * animals.length);
}
function initWorldState() {
groundY = window.innerHeight - 100;
const w = Math.min(window.innerWidth * 0.7, 400);
const ground = Bodies.rectangle(window.innerWidth / 2, groundY, w, 40, {
isStatic: true,
label: 'ground'
});
ground.groundWidth = w;
ground.render.visible = false;
Composite.add(engine.world, [ground]);
}
function endGame() {
if (isGameOver) return;
isGameOver = true;
if (score > highScore) {
highScore = score;
localStorage.setItem('kuma_high_score', highScore);
document.getElementById('high-score').textContent = highScore;
}
document.getElementById('title').textContent = "Game Over!";
document.getElementById('message').innerHTML = `スコア: <span class="text-2xl font-bold text-amber-600">${score}</span><br>クマが落ちてしまいました...`;
document.getElementById('start-btn').textContent = "もう一度挑戦";
document.getElementById('overlay').classList.remove('hidden');
}
window.addEventListener('touchstart', (e) => {
if (!document.getElementById('overlay').classList.contains('hidden')) return;
const rect = render.canvas.getBoundingClientRect();
spawnX = e.touches[0].clientX - rect.left;
dropAnimal();
}, { passive: false });
window.addEventListener('touchmove', (e) => {
if (!document.getElementById('overlay').classList.contains('hidden')) return;
const rect = render.canvas.getBoundingClientRect();
spawnX = e.touches[0].clientX - rect.left;
e.preventDefault();
}, { passive: false });
window.addEventListener('mousedown', (e) => {
if (!document.getElementById('overlay').classList.contains('hidden')) return;
dropAnimal();
});
document.getElementById('start-btn').addEventListener('click', (e) => {
e.stopPropagation();
startGame();
});
init();
</script>
</body>
</html>
Geminiでくま、パンダ、白熊を積み上げていくタワーバトルゲームを作りました pic.twitter.com/RuEdNEMPIV
— がちもとさん (@sotongshi) December 25, 2025
お疲れ様でした