実験 #09:落ちる・加速する(FALL FIT)

■9つ目「フォール・フィット」

 「とにかく落ちていく爽快感を味わえるゲームを作りたい!」という思いから生まれた、落下アクションゲームです!

 ▼ゲームの基本ルール
・プレイヤーはひたすら下へ落下していきます。
・地面にはいくつもの“層”があり、下の層へ行くほどスコアがアップ。
・落下スピードもどんどん速くなり、スリルが増していきます。
・落ちながら宝石を取ると追加ポイントが入ります。


   |||
ヽ( ゜。゜)ノ
  (   )
   / /


▼ダメージとゲームオーバー
・層にぶつかるとダメージ。
3回ぶつかるとゲームオーバーになります。

▼操作とヒント
・層には穴が空いていて、そこを通り抜けると次の層へ進めます。
・穴の位置は、左右の矢印で「どのあたりにあるか」を教えてくれる仕組みです。

 ゲームのイメージを伝えたくて、つい長文でAIへ伝えてしまいます。(^^;)↓

■ AIとのやりとりで面白かった部分

(^^*) ゲームスタートすると自由落下が始まります。背景が流れて落下感を再現。落下地点の穴を狙って落ちていくゲームです。大きめの矢印が穴の位置をナビしてくれて、落下地点までの距離は数字で表示されます。穴を抜けるとそのまま第2ステージへ進み、さらに次の落下地点を目指します。
 こんな感じで、いくつもの層をどんどん突破していくゲームです。層を抜けるたびにボーナス点が入り、落下中にはお宝も一緒に落ちてくるので、それを取ると追加得点になります。お宝はプレイヤーより少し遅いスピードで落ちていくので、追い越しながら取るのが気持ちいいポイント。
 スコアはランキング形式で記録され、上位を目指して挑戦できます。深い層に行くほど高得点のお宝が期待できるので、つい「もう一回!」となるタイプのゲームです。お宝感のある絵があると雰囲気が出そう。プレイヤーは図形を組み合わせた人型で、頭から落ちていくイメージ。穴をすり抜けられなかったらゲームオーバーです。

(AI) 「フォールフィット」の具体的なゲームデザイン、とてもワクワクしますね! “無限に深く潜っていく爽快感”と“穴を狙い澄ます緊張感”が同居した、中毒性の高いゲームになりそうです。

(^^*) 下層になってきたときの難易度を高くしたいね。

(AI) もちろんです!下層に行くほど手に汗握る仕掛けを追加してみましょう。

  • 動く層(Moving Gates) — ステージ10以降、層が左右にゆっくり動き始めます。深くなるほど動きが速くなり、正確な狙いが必要に。
  • 特殊エフェクト(Flash & Particle) — お宝を取った瞬間に画面がキラッと光り、ドットの破片が飛び散る演出で爽快感アップ。
  • 表情システム — 通常時は「・・」、お宝ゲットで「^‿^」、ダメージで「><」と、プレイヤーの顔が状況に応じて変化。
  • 環境変化(Deep Fog) — 深いゾーンに進むほど背景に濃い霧が増え、視覚的なプレッシャーを演出。

(^^*)表情システムナイスです!

■ 実際の動作


宝石を取ると一瞬笑顔になります(^v^)

下の穴の位置は”◁”と”▷”で教えてくれます。

左上のハートがなくなるとゲームオーバー

下層に行くほどに落下スピードが早くなり、お宝はいいものになって、難易度や背景色など変化してきます。

■ コードのポイント

 今回の記事では、AI と相談しながら作った「フォール・フィット」の HTML コードを丸ごと公開します。 このゲームはブラウザだけで動く、とてもシンプルな仕組みです。

▼遊ぶための準備

  • PC の「メモ帳」を開く
  • 下のコードをすべてコピーして貼り付ける
  • 「otiru.html」など好きな名前で保存
  • 保存したファイルをブラウザで開く

これだけで、すぐにゲームが遊べます。

▼操作方法

  • マウスをクリックするとゲームスタート
  • あとは マウスを左右に動かして穴を狙い、層に当たらないように落ちていくだけ
  • シンプルだけど、スピードが上がると意外と手に汗にぎる操作感です(^^*)

▼コードについて

今回も AI が作ってくれたコードをそのまま載せています。 気になる部分を自分で改造したり、色や動きをアレンジして遊んでみてください。

「※コードが長いので、必要な方だけコピーしてください」

「※スマホでは動作しない場合があります」

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>FALL FIT - Deep Survival Edition</title>
<style>
body { margin: 0; overflow: hidden; background: #0f380f; color: #fff; font-family: 'Courier New', Courier, monospace; transition: background 1.5s; }
canvas { display: block; image-rendering: pixelated; }
#ui { position: absolute; top: 10px; left: 10px; pointer-events: none; z-index: 10; }
#life-ui { font-size: 2em; color: #ff4444; margin-bottom: 5px; }
#dist-wrap { position: absolute; top: 100px; width: 100%; text-align: center; pointer-events: none; }
#dist { font-size: 80px; font-weight: bold; color: #fff; text-shadow: 4px 4px #000; }
.nav-arrow {
position: absolute; top: 50%; transform: translateY(-50%);
font-size: 140px; font-weight: bold; color: #fff;
text-shadow: 5px 5px #000, -3px -3px #000, 3px -3px #000, -3px 3px #000;
opacity: 0; transition: opacity 0.1s; pointer-events: none;
}
#arrowL { left: 40px; }
#arrowR { right: 40px; }
#msg { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; background: rgba(0,0,0,0.9); border: 4px solid #fff; padding: 30px; display: none; z-index: 20; }
</style>
</head>
<body>
<div id="ui">
<div id="life-ui">❤️❤️❤️</div>
<div id="info" style="font-size: 1.5em; font-weight: bold;">STAGE: 1 | SCORE: 0</div>
<div>HI-SCORE: <span id="hiScore">0</span></div>
</div>
<div id="dist-wrap"><div id="dist">NEXT: ---</div></div>
<div id="arrowL" class="nav-arrow">◀</div>
<div id="arrowR" class="nav-arrow">▶</div>
<div id="msg">
<h1 id="status">FALL FIT</h1>
<p>SCORE: <span id="finalScore">0</span></p>
<p>PRESS SPACE TO START</p>
</div>
<canvas id="gameCanvas"></canvas>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function beep(freq, duration, type = 'square', vol = 0.1) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + duration);
}
function playBGM() {
const notes = [130, 146, 164, 174];
let i = 0;
setInterval(() => {
if (gameState === 'playing') beep(notes[i++ % notes.length], 0.2, 'triangle', 0.04);
}, 450);
}
let gameState = 'title';
let score = 0, stage = 1, lives = 3, invincibility = 0;
let faceType = "normal"; // normal, happy, hurt
let hiScore = localStorage.getItem('fallFitHi') || 0;
document.getElementById('hiScore').innerText = hiScore;
const zoneColors = ["#306230", "#304262", "#623055", "#624030", "#403062"];
let particles = [], treasures = [], gates = [], fx = [];
const player = { x: canvas.width/2, y: 150, targetX: canvas.width/2 };
window.addEventListener('mousemove', (e) => player.targetX = e.clientX);
window.addEventListener('keydown', (e) => { if (e.code === 'Space') { if (gameState !== 'playing') resetGame(); audioCtx.resume(); } });
window.addEventListener('mousedown', () => { if (gameState !== 'playing') resetGame(); audioCtx.resume(); });
function resetGame() {
score = 0; stage = 1; lives = 3; invincibility = 0;
treasures = []; gates = []; fx = []; gameState = 'playing';
document.getElementById('msg').style.display = 'none';
updateLifeUI();
spawnGate();
beep(440, 0.1); setTimeout(() => beep(880, 0.2), 100);
}
function updateLifeUI() {
document.getElementById('life-ui').innerText = '❤️'.repeat(lives) + '🖤'.repeat(3 - lives);
}
function spawnGate() {
const holeW = Math.max(100, 280 - (stage * 5));
const holeX = Math.random() * (canvas.width - holeW);
const zoneLevel = Math.floor(stage / 5);
const distMultiplier = Math.pow(1.3, zoneLevel);
const spawnY = canvas.height + (1500 * distMultiplier);
// ステージ10から層が動き出す
const moveSpeed = stage >= 10 ? (Math.random() - 0.5) * (stage / 2) : 0;
const decorations = [];
for(let i=0; i<15; i++) {
decorations.push({ x: Math.random() * canvas.width, type: Math.floor(Math.random() * 4), offsetY: Math.random() * 40 });
}
gates.push({ y: spawnY, x: holeX, w: holeW, passed: false, decos: decorations, vx: moveSpeed });
}
function createFX(x, y, color) {
for(let i=0; i<10; i++) {
fx.push({ x, y, vx: (Math.random()-0.5)*10, vy: (Math.random()-0.5)*10, life: 20, color });
}
}
function update() {
if (gameState !== 'playing') return;
player.x += (player.targetX - player.x) * 0.15;
const currentSpeed = 16 + (Math.floor(stage / 5) * 3);
const colorIndex = Math.floor(stage / 5) % zoneColors.length;
document.body.style.background = zoneColors[colorIndex];
if (invincibility > 0) invincibility--; else faceType = "normal";
// 粒子(深くなるほど増える)
if (Math.random() < 0.4 + (stage * 0.01)) particles.push({ x: Math.random()*canvas.width, y: canvas.height, s: Math.random()*20+5 });
particles.forEach((p, i) => { p.y -= currentSpeed + 8; if (p.y < -50) particles.splice(i, 1); });
// FXの更新
fx.forEach((f, i) => { f.x += f.vx; f.y += f.vy; f.life--; if(f.life <= 0) fx.splice(i, 1); });
treasures.forEach((t, i) => {
t.y -= currentSpeed * 0.75;
const hitBox = 40 + (t.zone * 5);
if (Math.abs(t.x - player.x) < hitBox && Math.abs(t.y - player.y) < hitBox) {
score += 100 * (t.zone + 1);
createFX(t.x, t.y, "#fff");
faceType = "happy";
treasures.splice(i, 1);
beep(1500 + (t.zone * 200), 0.05, 'square', 0.05);
}
if (t.y < -50) treasures.splice(i, 1);
});
const thickness = 100;
gates.forEach((g, i) => {
g.y -= currentSpeed;
if (g.vx !== 0) {
g.x += g.vx;
if (g.x < 0 || g.x + g.w > canvas.width) g.vx *= -1; // 壁跳ね返り
}
const holeCenter = g.x + g.w / 2;
const diff = holeCenter - player.x;
if (Math.abs(diff) > g.w / 2.5) {
if (diff > 0) { document.getElementById('arrowR').style.opacity = "1"; document.getElementById('arrowL').style.opacity = "0"; }
else { document.getElementById('arrowL').style.opacity = "1"; document.getElementById('arrowR').style.opacity = "0"; }
} else {
document.getElementById('arrowL').style.opacity = "0"; document.getElementById('arrowR').style.opacity = "0";
}
if (!g.passed) {
if (g.y < player.y + 40 && g.y + thickness > player.y - 10) {
if (player.x > g.x && player.x < g.x + g.w) {
// 通過中
} else if (invincibility <= 0) {
lives--; updateLifeUI();
faceType = "hurt";
if (lives <= 0) { gameOver(); } else { invincibility = 60; beep(100, 0.3, 'sawtooth', 0.2); }
}
}
if (g.y + thickness < player.y - 10) {
g.passed = true; score += 1000; stage++;
if (stage % 5 === 0) beep(500, 0.5, 'square', 0.1); // ゾーン突入音
spawnGate();
}
}
if (g.y < -500) gates.splice(i, 1);
});
if (Math.random() < 0.05) treasures.push({ x: Math.random()*canvas.width, y: canvas.height, zone: Math.floor(stage/5) });
document.getElementById('info').innerText = `STAGE: ${stage} | SCORE: ${score}`;
const dist = gates[0] ? Math.max(0, Math.floor((gates[0].y - player.y)/10)) : 0;
document.getElementById('dist').innerText = `NEXT: ${dist}m`;
}
function gameOver() {
gameState = 'over';
beep(50, 1.0, 'sawtooth');
document.getElementById('arrowL').style.opacity = "0";
document.getElementById('arrowR').style.opacity = "0";
if (score > hiScore) { hiScore = score; localStorage.setItem('fallFitHi', hiScore); document.getElementById('hiScore').innerText = hiScore; }
document.getElementById('finalScore').innerText = score; document.getElementById('msg').style.display = 'block';
}
function drawPlayer(frame) {
ctx.save();
ctx.translate(player.x, player.y);
if (invincibility > 0 && Math.floor(frame / 2) % 2 === 0) ctx.globalAlpha = 0.3;
const armSwing = Math.sin(frame * 0.3) * 8;
ctx.fillStyle = '#8bac0f';
ctx.fillRect(-12, -10, 24, 32); // 胴
ctx.fillRect(-8, -24, 16, 16); // 頭
ctx.fillRect(-20, -35 + armSwing, 6, 30); // 腕
ctx.fillRect(14, -35 - armSwing, 6, 30);
// 顔の描画
ctx.fillStyle = '#000';
if (faceType === "normal") {
ctx.fillRect(-4, -18, 2, 2); ctx.fillRect(2, -18, 2, 2); // 目
ctx.fillRect(-2, -12, 4, 1); // 口
} else if (faceType === "happy") {
ctx.font = "bold 12px Arial"; ctx.fillText("^ ^", -8, -14); ctx.fillText("‿", -4, -10);
} else if (faceType === "hurt") {
ctx.font = "bold 12px Arial"; ctx.fillText("> <", -8, -14);
}
ctx.fillStyle = '#000000';
ctx.fillRect(-12, 22, 10, 25); ctx.fillRect(2, 22, 10, 25);
ctx.restore();
}
function drawTreasure(t) {
ctx.save();
ctx.translate(t.x, t.y);
const colors = ["#fff", "#fbd400", "#ff4444", "#44ffff", "#ffffff"];
const baseSize = 20 + (t.zone * 4);
ctx.fillStyle = "#000";
const type = t.zone % 4;
ctx.beginPath();
if (type === 0) { ctx.moveTo(0, -baseSize); ctx.lineTo(baseSize, 0); ctx.lineTo(0, baseSize); ctx.lineTo(-baseSize, 0); }
else if (type === 1) { for(let i=0; i<8; i++) { ctx.rect(-2, -baseSize, 4, baseSize*2); ctx.rotate(Math.PI/4); } }
else if (type === 2) { for(let i=0; i<6; i++) { ctx.lineTo(Math.cos(i*Math.PI/3)*baseSize, Math.sin(i*Math.PI/3)*baseSize); } }
else { for(let i=0; i<10; i++){ let r = i % 2 === 0 ? baseSize : baseSize/2; ctx.lineTo(Math.cos(i*Math.PI/5)*r, Math.sin(i*Math.PI/5)*r); } }
ctx.fill();
ctx.fillStyle = colors[t.zone % colors.length];
ctx.fillRect(-baseSize/4, -baseSize/4, baseSize/2, baseSize/2);
ctx.restore();
}
function drawDeco(ctx, x, y, type) {
ctx.fillStyle = "#222";
if (type === 0) { ctx.beginPath(); ctx.moveTo(x-20, y); ctx.lineTo(x, y-25); ctx.lineTo(x+20, y); ctx.fill(); }
else if (type === 1) { ctx.fillRect(x-10, y-5, 20, 5); ctx.fillStyle = "#ff4400"; ctx.fillRect(x-5, y-15, 10, 10); }
else if (type === 2) { ctx.fillRect(x-10, y-20, 20, 20); ctx.fillRect(x-12, y-15, 2, 10); ctx.fillRect(x+10, y-15, 2, 10); }
else { ctx.fillRect(x-5, y-15, 10, 15); ctx.fillStyle = "#fbd400"; ctx.fillRect(x-3, y-12, 6, 6); }
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const colorIndex = Math.floor(stage / 5) % zoneColors.length;
ctx.fillStyle = zoneColors[colorIndex];
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(255,255,255,0.2)';
particles.forEach(p => ctx.fillRect(p.x, p.y, 3, p.s));
treasures.forEach(t => drawTreasure(t));
fx.forEach(f => { ctx.fillStyle = f.color; ctx.fillRect(f.x, f.y, 4, 4); });
gates.forEach(g => {
ctx.fillStyle = '#000000';
ctx.fillRect(0, g.y, g.x, 100);
ctx.fillRect(g.x + g.w, g.y, canvas.width, 100);
g.decos.forEach(d => { if (d.x < g.x - 20 || d.x > g.x + g.w + 20) drawDeco(ctx, d.x, g.y + d.offsetY + 30, d.type); });
});
drawPlayer(Date.now() / 50);
update();
requestAnimationFrame(draw);
}
document.getElementById('msg').style.display = 'block';
playBGM();
draw();
</script>
</body>
</html>

■ 今日の学び

 今回のゲームは、あえて ゲームボーイ風の雰囲気 を出したくて、AI に色味や演出を相談しながら作りました。
  初代ゲームボーイのあの独特の色合いと、ピコピコした音の感じが大好きなんです。ヽ(*^o*)ノ

最終的には難易度がかなり高めのゲームになりましたが、操作はシンプルで手軽に遊べるので、ぜひ体験してみてください。

 次回は、「頭の中で繰り返す2角取り」ゲームです。
 どうぞお楽しみに(*^v^)ノシ

 次の実験(2角取り):「オノマトペ2角取り」

コメントを残す