音で作る2Dシューティング「WaveGround」
【更新履歴】
・2026/2/1 バージョン1.1公開。
・2026/2/2 バージョン1.3公開。
・ダウンロードされる方はこちら。↓
《操作説明》
・方向キーで移動。
・Zキーでショットを撃つ。(押しっぱなしだと、貯め撃ち)
・Xキーでジャンプ。
・下キーで急降下して敵を踏み潰す。
・貯め撃ちすると、ショットサイズが大きくなり、
当たりやすくなる。(ダメージも増えていく。)
(※チャージ中は、自機が膨らむので、当たりやすくなる。)
・HPが減ると、ショットのダメージが減る。
・敵を倒すと、ロックオンされるので、
そこでZキーを押すと、ダッシュで敵に衝突し、
敵を吸収してHPが1回復する。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Wave Ground 1.3</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000;
font-family: 'Arial Black', Impact, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
color: #fff;
}
#ui-layer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 20;
background: rgba(0, 0, 0, 0.8);
padding: 40px;
border-radius: 10px;
border: 2px solid #555;
min-width: 320px;
}
canvas {
display: none;
box-shadow: 0 0 30px rgba(0, 191, 255, 0.3);
background-color: #000;
}
.btn {
padding: 10px 30px;
font-size: 18px;
cursor: pointer;
background: #00BFFF;
color: white;
border: none;
border-radius: 5px;
margin-top: 10px;
font-weight: bold;
}
.btn:disabled { background: #555; color: #999; cursor: not-allowed; }
.key-instruction { font-size: 14px; color: #ccc; margin-top: 5px; line-height: 1.6; }
.highlight { color: #FF0; font-weight: bold; }
</style>
</head>
<body>
<div id="ui-layer">
<h1>Wave Ground</h1>
<p>無限に続く音の波を駆け抜けろ!</p>
<div class="key-instruction">
移動: ←→ | ジャンプ: X | 急降下: 空中で ↓<br>
ショット: Z (長押しチャージ)<br>
<span class="highlight">敵撃破後、Zキーで追撃&回復!</span>
</div>
<br>
<input type="file" id="fileInput" accept="audio/*" style="color:white;">
<br><br>
<button id="startBtn" class="btn" disabled>Loading...</button>
<p id="status">ファイルを選択してください</p>
</div>
<canvas id="gameCanvas"></canvas>
<script>
// === 設定・定数 ===
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const BLOCK_SIZE = 20;
const COLOR = {
BG: "#000000", BLOCK_OUTLINE: "#333",
PLAYER: {R:0, G:0, B:255},
ENEMY: {R:128, G:0, B:128},
BOSS: {R:255, G:0, B:255},
CYAN: {R:0, G:255, B:255},
LOCKON: "#FF0000"
};
const TERRAIN_PALETTE = ["#8B4513", "#2E8B57", "#4682B4", "#DAA520", "#800000"];
// === グローバル変数 ===
let canvas, ctx;
let gameState = 'loading';
let gameResultText = '';
let cameraX = 0;
let keys = {};
let score = 0;
let frameCount = 0;
let animationFrameId;
let currentAudioLevel = 0;
let globalBlockIndex = 0;
let lastGeneratedX = 0;
let nextBossScore = 5000;
let player = null;
let enemies = [], bullets = [], blocks = [], stars = [], particles = [], popups = [];
const uiLayer = document.getElementById('ui-layer');
const fileInput = document.getElementById('fileInput');
const startBtn = document.getElementById('startBtn');
const statusText = document.getElementById('status');
canvas = document.getElementById('gameCanvas');
ctx = canvas.getContext('2d');
canvas.width = CANVAS_WIDTH; canvas.height = CANVAS_HEIGHT;
// オーディオ系
let audioBuffer, analyser, dataArray, soundSource;
const soundManager = new class {
constructor() { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); }
async loadFile(file) {
if(this.ctx.state === 'suspended') await this.ctx.resume();
return await this.ctx.decodeAudioData(await file.arrayBuffer());
}
playBGM(buffer) {
this.stopBGM();
soundSource = this.ctx.createBufferSource();
soundSource.buffer = buffer;
soundSource.loop = false;
soundSource.onended = () => { if (gameState === 'playing') endGame(true); };
analyser = this.ctx.createAnalyser();
analyser.fftSize = 2048;
dataArray = new Uint8Array(analyser.frequencyBinCount);
soundSource.connect(analyser);
analyser.connect(this.ctx.destination);
soundSource.start(0);
}
stopBGM() { if (soundSource) { try { soundSource.stop(); } catch(e){} soundSource = null; } }
playOsc(freq, type, dur, vol, slideTo=null) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(vol, this.ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, this.ctx.currentTime + dur);
o.connect(g); g.connect(this.ctx.destination);
o.start(); o.stop(this.ctx.currentTime + dur);
}
playShot() { this.playOsc(1200, 'square', 0.1, 0.1); }
playChargeShot() { this.playOsc(600, 'sawtooth', 0.3, 0.2, 100); }
playJump() { this.playOsc(300, 'triangle', 0.15, 0.2, 600); }
playStomp() { this.playOsc(150, 'square', 0.3, 0.3, 50); }
playEnemyHit() { this.playOsc(800, 'sawtooth', 0.1, 0.3, 50); }
playEnemyChargeHit() { this.playOsc(400, 'sawtooth', 0.2, 0.4, 50); }
playPlayerDamage() { this.playOsc(100, 'sawtooth', 0.4, 0.4, 50); }
playCrit() { this.playOsc(1500, 'square', 0.15, 0.15, 2000); }
playRecover() { this.playOsc(400, 'sine', 0.3, 0.3, 1200); }
playClear() { this.playOsc(523, 'sine', 0.1, 0.1); setTimeout(()=>this.playOsc(783, 'sine', 0.8, 0.1), 200); }
}();
const rgbToHex = (c) => `rgb(${Math.floor(c.R)},${Math.floor(c.G)},${Math.floor(c.B)})`;
const blendColor = (t, s, w) => ({R:t.R+(s.R-t.R)*w, G:t.G+(s.G-t.G)*w, B:t.B+(s.B-t.B)*w});
// === クラス定義 ===
class Star {
constructor() { this.reset(true); }
reset(randomX = false) {
// カメラ位置を基準に配置
const startX = cameraX;
this.x = randomX ? startX + Math.random() * CANVAS_WIDTH : startX + CANVAS_WIDTH;
this.y = Math.random() * CANVAS_HEIGHT;
// 高速移動
this.speed = 4 + Math.random() * 8;
this.size = 1 + Math.random() * 2;
}
update() {
this.x -= this.speed;
// 画面左端(カメラ基準)より消えたらリセット
if (this.x < cameraX - 100) this.reset();
}
draw(ctx, camX) {
ctx.fillStyle = "#FFF";
// 視差効果(0.5倍)などを無くし、そのまま描画して相対速度を上げる
ctx.fillRect(this.x - camX, this.y, this.size, this.size);
}
}
class Particle {
constructor(x, y, color, isFirework = false) {
this.x = x; this.y = y;
this.color = color || COLOR.CYAN;
this.isFirework = isFirework;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * (isFirework ? 10 : 5) + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.life = isFirework ? 2.0 : 1.0;
this.decay = Math.random() * 0.02 + 0.01;
if(isFirework) this.vy -= 3;
}
update() {
this.x += this.vx; this.y += this.vy;
this.vy += this.isFirework ? 0.1 : 0.2;
this.life -= this.decay;
}
draw(ctx, camX) {
if(this.life <= 0) return;
ctx.globalAlpha = this.life;
ctx.fillStyle = typeof this.color === 'string' ? this.color : rgbToHex(this.color);
ctx.beginPath();
ctx.arc(this.x - camX, this.y, this.isFirework ? 4 : 3, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
}
}
function spawnParticles(x, y, color) { for(let i=0; i<10; i++) particles.push(new Particle(x, y, color)); }
function spawnFirework(x, y) {
const colors = ["#F00", "#0F0", "#00F", "#FF0", "#F0F", "#0FF"];
const c = colors[Math.floor(Math.random()*colors.length)];
for(let i=0; i<50; i++) particles.push(new Particle(x, y, c, true));
}
class PopupText {
constructor(x, y, text, color="#FFF", size=20) {
this.x = x; this.y = y; this.text = text; this.color = color; this.size = size; this.life = 30;
}
update() { this.y -= 1; this.life--; }
draw(ctx, camX) {
if(this.life<=0) return;
ctx.fillStyle = this.color; ctx.font = `bold ${this.size}px Arial`;
ctx.fillText(this.text, this.x - camX, this.y);
}
}
class Block {
constructor(x, baseHeight, index) {
this.x = x; this.baseHeight = baseHeight; this.y = CANVAS_HEIGHT; this.vy = 0;
this.color = TERRAIN_PALETTE[Math.floor(index / 20) % TERRAIN_PALETTE.length];
}
update(targetY) {
this.vy += (targetY - this.y) * 0.1; this.vy *= 0.85; this.y += this.vy;
}
draw(ctx, camX) {
if (this.x + BLOCK_SIZE < camX || this.x > camX + CANVAS_WIDTH) return;
ctx.fillStyle = this.color; ctx.strokeStyle = COLOR.BLOCK_OUTLINE; ctx.lineWidth = 1;
ctx.beginPath(); ctx.rect(this.x - camX, this.y, BLOCK_SIZE, CANVAS_HEIGHT - this.y + BLOCK_SIZE);
ctx.fill(); ctx.stroke();
}
}
function getTargetHeight(screenX) {
if (!dataArray) return 450;
const dataIdx = Math.floor((screenX % CANVAS_WIDTH) / CANVAS_WIDTH * dataArray.length);
const val = dataArray[dataIdx] || 128;
return 450 - (val - 128) * 3.5;
}
class Entity {
constructor(x, y, size, rgb) {
this.x = x; this.y = y; this.baseSize = size; this.currentSize = size;
this.rgb = {...rgb}; this.vx = 0; this.vy = 0; this.hp = 10;
this.dead = false; this.grounded = false; this.groundBlock = null;
}
draw(ctx, camX) {
if (this.dead) return;
ctx.fillStyle = rgbToHex(this.rgb);
ctx.beginPath();
ctx.arc(this.x - camX, this.y - this.currentSize/2, this.currentSize, Math.PI, 0);
ctx.fillRect(this.x - camX - this.currentSize, this.y - this.currentSize/2, this.currentSize*2, this.currentSize/2);
ctx.fill();
}
update() {
this.vy += 0.6; this.x += this.vx; this.y += this.vy;
this.grounded = false; this.groundBlock = null;
const blockIdx = blocks.findIndex(b => this.x >= b.x && this.x < b.x + BLOCK_SIZE);
if (blockIdx !== -1) {
const block = blocks[blockIdx];
if (this.y >= block.y - this.currentSize/2) {
this.y = block.y;
this.vy = block.vy < 0 ? block.vy * 0.25 : 0;
this.grounded = true;
this.groundBlock = block;
}
}
if (!this.grounded && this.y >= CANVAS_HEIGHT - 5) {
this.y = CANVAS_HEIGHT - 5; this.vy = 0; this.grounded = true;
}
}
}
class Player extends Entity {
constructor() {
super(100, 200, 15, COLOR.PLAYER);
this.maxHp = 10; this.invincible = 0; this.scale = 1.0; this.isStomping = false;
this.state = 'normal';
this.dashTarget = null;
this.dashOrigin = {x:0, y:0};
}
update() {
if (this.dead && gameState === 'playing') endGame(false);
if (this.invincible > 0) this.invincible--;
if (this.state === 'normal') this.updateNormal();
else if (this.state === 'dashing') this.updateDashing();
else if (this.state === 'returning') this.updateReturning();
this.x = Math.max(cameraX, this.x);
this.currentSize = this.baseSize * this.scale;
}
updateNormal() {
this.vx = keys['ArrowLeft'] ? -5 : (keys['ArrowRight'] ? 5 : 0);
if (keys['KeyX'] && this.grounded) {
this.vy = -14;
if (this.groundBlock) this.vy += this.groundBlock.vy * 0.2;
soundManager.playJump();
this.grounded = false;
}
if (keys['KeyZ']) {
if (this.scale === 1.0) {
const target = enemies.find(e => e.isLockedOn && !e.dead);
if (target) { this.startDash(target); return; }
}
this.scale = Math.min(5.0, this.scale + 0.1);
} else {
if (this.scale > 1.0) {
const dmg = this.hp * this.scale;
const shotSize = this.scale * 4;
bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 10, 0, true, this.rgb, dmg, shotSize));
if (this.scale > 2.0) soundManager.playChargeShot();
else soundManager.playShot();
this.scale = 1.0;
}
}
if (keys['ArrowDown'] && !this.grounded && !this.isStomping) {
soundManager.playStomp();
this.scale = 5.0; this.vy += 3; this.isStomping = true;
} else if (this.grounded && this.isStomping) {
this.scale = 1.0; this.isStomping = false;
}
super.update();
}
startDash(target) {
if (target.x < cameraX) return;
this.state = 'dashing';
this.dashTarget = target;
this.dashOrigin = {x: this.x, y: this.y};
this.invincible = 999;
this.isStomping = false; this.scale = 1.0;
soundManager.playJump();
}
updateDashing() {
if (!this.dashTarget || this.dashTarget.dead || this.dashTarget.x < cameraX) {
this.state = 'returning'; return;
}
const dx = this.dashTarget.x - this.x;
const dy = this.dashTarget.y - this.y;
const dist = Math.hypot(dx, dy);
if (dist < this.currentSize + this.dashTarget.currentSize + 20) {
this.heal();
popups.push(new PopupText(this.x, this.y-40, "HP +1", "#0F0", 25));
spawnParticles(this.dashTarget.x, this.dashTarget.y, COLOR.CYAN);
this.dashTarget.dead = true;
const tIdx = enemies.indexOf(this.dashTarget);
if(tIdx > -1) enemies.splice(tIdx, 1);
score += this.dashTarget.isBoss ? 5000 : 500;
this.dashTarget = null;
this.state = 'returning';
soundManager.playRecover();
} else {
this.x += (dx / dist) * 40;
this.y += (dy / dist) * 40;
}
}
updateReturning() {
const targetX = Math.max(cameraX + 20, this.dashOrigin.x);
const targetY = this.dashOrigin.y;
const dx = targetX - this.x;
const dy = targetY - this.y;
const dist = Math.hypot(dx, dy);
if (dist < 20) {
this.x = targetX; this.y = targetY;
this.state = 'normal';
this.invincible = 60;
this.vx = 0; this.vy = 0;
} else {
this.x += (dx / dist) * 40;
this.y += (dy / dist) * 40;
}
}
heal() { if(this.hp < this.maxHp) this.hp++; }
}
class Enemy extends Entity {
constructor(x, y, isBoss = false) {
super(x, y, isBoss ? 60 : 15, isBoss ? COLOR.BOSS : COLOR.ENEMY);
this.isBoss = isBoss;
this.shootTimer = Math.random() * 200;
this.isLockedOn = false;
}
update() {
if (this.dead) return;
super.update();
if (this.isLockedOn) return;
if (this.x > cameraX - 100 && this.x < cameraX + CANVAS_WIDTH + 100) {
if (!this.isBoss && this.grounded && Math.random()<0.01) this.vy = -10;
this.shootTimer--;
if (this.shootTimer <= 0) {
const dir = player.x < this.x ? -1 : 1;
bullets.push(new Bullet(this.x, this.y - 10, 6 * dir, 0, false, this.rgb, this.hp, 5));
soundManager.playShot();
this.shootTimer = this.isBoss ? 60 : 200 + Math.random()*200;
}
}
}
draw(ctx, camX) {
if(this.dead) return;
super.draw(ctx, camX);
if (this.isLockedOn) {
ctx.strokeStyle = COLOR.LOCKON;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(this.x - camX, this.y - this.currentSize/2, this.currentSize + 5, 0, Math.PI*2);
ctx.stroke();
}
}
hit(damage, shooterRgb, isCharge) {
if (this.isLockedOn) return false;
this.hp -= damage;
this.rgb = blendColor(this.rgb, COLOR.CYAN, 0.4);
if(isCharge) soundManager.playEnemyChargeHit();
else soundManager.playEnemyHit();
if (this.hp <= 0) {
this.hp = 0;
this.isLockedOn = true;
return true;
}
return false;
}
}
class Bullet {
constructor(x, y, vx, vy, isPlayerShot, rgb, damage, size) {
this.x = x; this.y = y; this.vx = vx; this.vy = vy;
this.isPlayerShot = isPlayerShot;
this.rgb = {...rgb}; this.damage = damage; this.size = size; this.active = true;
}
update() {
this.x += this.vx; this.y += this.vy;
if (this.x < cameraX - 100 || this.x > cameraX + CANVAS_WIDTH + 100) this.active = false;
}
draw(ctx, camX) {
ctx.fillStyle = rgbToHex(this.rgb);
ctx.beginPath(); ctx.arc(this.x - camX, this.y, this.size, 0, Math.PI * 2); ctx.fill();
if(this.size > 8) { ctx.strokeStyle = "#FFF"; ctx.lineWidth = 2; ctx.stroke(); }
}
}
// === 無限生成とゲーム管理 ===
function updateLevelGeneration() {
const rightEdge = cameraX + CANVAS_WIDTH + 200;
while (lastGeneratedX < rightEdge) {
blocks.push(new Block(lastGeneratedX, 500, globalBlockIndex));
globalBlockIndex++;
lastGeneratedX += BLOCK_SIZE;
if (Math.random() < 0.05) {
enemies.push(new Enemy(lastGeneratedX, 0));
}
if (lastGeneratedX > nextBossScore) {
enemies.push(new Enemy(lastGeneratedX + 200, 0, true));
nextBossScore += 4000;
}
}
const deleteThreshold = cameraX - 200;
blocks = blocks.filter(b => b.x > deleteThreshold);
enemies = enemies.filter(e => e.x > deleteThreshold && !e.dead);
}
function initGame() {
player = new Player();
bullets = [], enemies = [], blocks = [], stars = [], particles = [], popups = [];
score = 0; frameCount = 0; keys = {};
globalBlockIndex = 0;
lastGeneratedX = 0;
nextBossScore = 4000;
for(let i=0; i<100; i++) stars.push(new Star());
updateLevelGeneration();
gameState = 'playing';
uiLayer.style.display = 'none'; canvas.style.display = 'block';
soundManager.playBGM(audioBuffer);
if (animationFrameId) cancelAnimationFrame(animationFrameId);
gameLoop();
}
function update() {
if (gameState !== 'playing') return;
frameCount++;
if (analyser) {
analyser.getByteTimeDomainData(dataArray);
let sum = 0; for(let i=0; i<dataArray.length; i+=10) sum += Math.abs(dataArray[i] - 128);
currentAudioLevel = sum / (dataArray.length / 10) / 128.0;
}
updateLevelGeneration();
stars.forEach(s => s.update());
blocks.forEach(b => {
let targetY = b.baseHeight;
if (b.x >= cameraX - BLOCK_SIZE && b.x <= cameraX + CANVAS_WIDTH) {
targetY = getTargetHeight(b.x);
}
b.update(targetY);
});
if(player) player.update();
enemies.forEach(e => e.update());
bullets.forEach(b => b.update());
bullets = bullets.filter(b => b.active);
particles.forEach(p => p.update());
particles = particles.filter(p => p.life > 0);
popups.forEach(p => p.update());
popups = popups.filter(p => p.life > 0);
// 弾判定
bullets.forEach(b => {
if (!b.active) return;
const targets = b.isPlayerShot ? enemies : [player];
targets.forEach(t => {
if (!b.active || t.dead || (t === player && player.invincible > 0)) return;
const dist = Math.hypot(b.x - t.x, b.y - (t.y - t.currentSize/2));
if (dist < t.currentSize + b.size) {
b.active = false;
if (t === player) {
t.hit(b.damage/10, b.rgb);
player.invincible = 60;
soundManager.playPlayerDamage();
} else {
let dmg = b.damage;
if (currentAudioLevel > 0.15) { dmg *= 2; soundManager.playCrit(); popups.push(new PopupText(t.x, t.y-20,"CRITICAL!","#FF0")); }
const isCharge = b.size > 8;
t.hit(dmg, b.rgb, isCharge);
}
}
});
});
// 体当たり判定
if(player.state === 'normal') {
enemies.forEach(e => {
if(e.dead || e.isLockedOn || player.dead) return;
const dist = Math.hypot(player.x - e.x, (player.y - player.currentSize/2) - (e.y - e.currentSize/2));
if(dist < player.currentSize + e.currentSize) {
if (player.isStomping && player.y < e.y + e.currentSize) {
e.hit(9999, player.rgb, true);
spawnParticles(e.x, e.y, COLOR.CYAN);
} else if (player.invincible <= 0) {
player.hit(0.1, e.rgb);
player.invincible = 60;
soundManager.playPlayerDamage();
}
}
});
}
const targetCamX = player.x - CANVAS_WIDTH * 0.3;
if (targetCamX > cameraX) cameraX = targetCamX;
}
function draw() {
ctx.fillStyle = COLOR.BG; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.save();
const baseSway = Math.sin(frameCount * 0.005) * 5;
const volumeKick = currentAudioLevel * 20;
const kickDir = Math.sin(frameCount * 0.1) > 0 ? 1 : -1;
const angle = (baseSway + (volumeKick * kickDir)) * (Math.PI / 180);
ctx.translate(CANVAS_WIDTH/2, CANVAS_HEIGHT/2); ctx.rotate(angle); ctx.translate(-CANVAS_WIDTH/2, -CANVAS_HEIGHT/2);
stars.forEach(s => s.draw(ctx, cameraX)); // 修正: 星の描画位置修正
blocks.forEach(b => b.draw(ctx, cameraX));
particles.forEach(p => p.draw(ctx, cameraX));
enemies.forEach(e => e.draw(ctx, cameraX));
if(player) player.draw(ctx, cameraX);
bullets.forEach(b => b.draw(ctx, cameraX));
popups.forEach(p => p.draw(ctx, cameraX));
ctx.restore();
if (gameState === 'playing') drawUI();
else if (gameState === 'gameover' || gameState === 'gameclear') drawEndScreen();
}
function drawUI() {
if(!player) return;
const barW = 200, barH = 25, barX = 20, barY = 20;
ctx.fillStyle = "red"; ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = "lime"; ctx.fillRect(barX, barY, barW * (player.hp/player.maxHp), barH);
ctx.strokeStyle = "white"; ctx.lineWidth = 2; ctx.strokeRect(barX, barY, barW, barH);
ctx.fillStyle = "white"; ctx.font = "bold 16px Arial";
ctx.fillText(`HP: ${Math.ceil(Math.max(0, player.hp))}`, barX + 10, barY + 18);
ctx.textAlign = "right"; ctx.font = "bold 24px Arial";
ctx.fillText(`SCORE: ${score}`, CANVAS_WIDTH - 20, 45);
ctx.textAlign = "left";
if(currentAudioLevel > 0.15) { ctx.fillStyle = "yellow"; ctx.font = "bold 14px Arial"; ctx.fillText("BEAT BONUS!", CANVAS_WIDTH - 20, 70); }
}
function drawEndScreen() {
ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0,0,CANVAS_WIDTH,CANVAS_HEIGHT);
if (gameState === 'gameclear') {
if(Math.random() < 0.1) spawnFirework(cameraX + Math.random()*CANVAS_WIDTH, Math.random()*CANVAS_HEIGHT/2);
ctx.fillStyle = "white"; ctx.textAlign = "center";
ctx.font = "bold 80px Arial Black";
ctx.fillText("GAME CLEAR!!", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
} else {
ctx.fillStyle = "white"; ctx.textAlign = "center";
ctx.font = "bold 80px Arial Black";
ctx.fillText("GAME OVER", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
}
ctx.font = "bold 40px Arial";
ctx.fillText(`Final Score: ${score}`, CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 100);
ctx.font = "20px Arial";
ctx.fillText("Restarting...", CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 160);
}
function endGame(win) {
if(gameState !== 'playing') return;
gameState = win ? 'gameclear' : 'gameover';
soundManager.stopBGM();
if(win) soundManager.playClear();
setTimeout(initGame, 5000);
}
function gameLoop() { try { update(); draw(); } catch(e){ console.error(e); } animationFrameId = requestAnimationFrame(gameLoop); }
fileInput.addEventListener('change', async (e) => {
if (!e.target.files.length) return;
statusText.innerText = "読み込み中..."; startBtn.disabled = true;
try {
audioBuffer = await soundManager.loadFile(e.target.files[0]);
statusText.innerText = "準備完了!"; startBtn.disabled = false; startBtn.innerText = "GAME START";
} catch (e) { console.error(e); statusText.innerText = "エラー"; }
});
startBtn.addEventListener('click', () => { if (audioBuffer) initGame(); });
window.addEventListener('keydown', e => keys[e.code] = true);
window.addEventListener('keyup', e => keys[e.code] = false);
</script>
</body>
</html>《似ているものコーナー》
・なんか見た覚えがあったんですが、
例によってAIくんに聞いてみたところ、
これが一番似ているらしいです。↓
「ビブリボン vib-ribbon」(1999)SONY
・プレイステーションに好きな音楽CDを入れると、
その曲に合わせてステージ(白いライン)が生成される。
・障害物を避けて進むタイプのゲーム。
「Audiosurf」 (2008)
・音声ファイルを読み込んで、
発音タイミングの位置にアイテムや障害物を配置し、
当たったり避けたりして進むゲーム。
・波形によってコースが起伏したりする。
「Melody's Escape」(2016)
・これも音声ファイルを読み込んでステージを作るもの。
Beat Hazard (2009)
・これも音声ファイルから作るタイプですが、
全方位シューティングということで、
敵の出現タイミングとかだと思いますが、
あまり音と連動してる感じはしないですね。
・あとは、手持ちの音声ファイルを指定できないものでいえば、
音声データとステージがリアルタイムに連動するものは
いくつかあるみたいですね。↓
「Sound Shapes」 (2012)
・足場や敵、コインなどがすべて「楽器」になっており、
ステージを進めることで曲を演奏していく。
「140」(2021)
・登場するほぼ全てのギミックが
音楽のBPM140の全音符4つ分リズムで動き、
ゲーム内の音楽と連動するように動くそうです。
・ノイズブロックに当たると、最初からやり直し。
「Geometry Dash」(2013)
技術的な「祖先」
Web Audio API のデモ (Chrome Experimentsなど)
2010年代中盤、ブラウザで高度な音声処理ができるようになった頃、今回のように「波形(スペクトラム)を可視化して、その上を走る」という技術デモがWeb上にいくつか作られました。
《このゲームの独自性》
・「音楽からステージを作る」ゲームはありますが、
多くは「事前に解析して固定のコースを作る」もので、
今回のように「リアルタイムに波形そのものが物理的な足場として
暴れまわる」というアプローチは、製品レベルのゲームでは
意外と珍しい。
・多くの音ゲーは、音を「譜面(タイミング)」として扱いますが、
このツールは音を「物理エネルギー(高さとバネ)」として扱っている。
・音が大きくなると地面が物理的にプレイヤーを跳ね飛ばす、
という「音の暴力性」をアクションに転化している点がユニーク。


コメント