音で作る2Dシューティング「WaveGround」

【更新履歴】

・2026/2/1 バージョン1.1公開。
・2026/2/2 バージョン1.3公開。
・2026/2/2 バージョン1.4公開。
・2026/2/2 バージョン1.5公開。


画像

・ダウンロードされる方はこちら。↓

《操作説明》

・方向キーで移動。
・Zキーでショットを撃つ。(押しっぱなしだと、貯め撃ち)
・Xキーでジャンプ。
・下キーで急降下して敵を踏み潰す。

・貯め撃ちすると、ショットサイズが大きくなり、
 当たりやすくなる。(ダメージも増えていく。)
(※チャージ中は、自機が膨らむので、当たりやすくなる。)

・HPが減ると、ショットのダメージが減る。

・敵を倒すと、ロックオンされるので、
 そこでAキーを押すと、ダッシュで敵に衝突し、
 敵を吸収してHPが1回復する。

・ジャンプして雲に触れると、HPが1回復する。

・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Wave Ground 1.5</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: 350px;
        }
        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 (通常弾は無料、チャージはHP消費)<br>
        <span class="highlight">敵の玉(青)は接触か <span style="font-size:1.2em; color:#0FF;">Aキー</span> で吸収!<br>白い雲に乗って回復!</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 MAX_HP = 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}, 
    CYAN_HEX: "#00FFFF",
    CLOUD:  "#FFFFFF", 
    LOCKON: "#FF0000",
    SPARK:  "#FFFF00" // 火花の色
};
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 screenShake = 0; // 画面揺れ強度

let globalBlockIndex = 0; 
let lastGeneratedX = 0;   
let nextBossScore = 5000; 

let player = null;
let enemies = [], bullets = [], blocks = [], stars = [], particles = [], popups = [], clouds = [];

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); } 
    playImpact() { this.playOsc(100, 'sawtooth', 0.4, 0.5, 10); } // 着地音
    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); } 
    playCombo() { this.playOsc(800, 'sine', 0.1, 0.2, 1600); } 
    playItemGet() { this.playOsc(1200, 'sine', 0.2, 0.2, 2000); } 
    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"; 
        ctx.fillRect(this.x - camX, this.y, this.size, this.size); 
    }
}

class Particle {
    constructor(x, y, color, isFirework = false, isSpark = false) {
        this.x = x; this.y = y;
        this.color = color || COLOR.CYAN_HEX;
        this.isFirework = isFirework;
        this.isSpark = isSpark;
        const angle = Math.random() * Math.PI * 2;
        // スパークは速く、短命
        const speed = Math.random() * (isFirework ? 10 : (isSpark ? 12 : 5)) + 2;
        this.vx = Math.cos(angle) * speed;
        this.vy = Math.sin(angle) * speed;
        
        // スパークは上方向に飛び散りやすい
        if(isSpark && this.vy > 0) this.vy *= -0.5;

        this.life = isFirework ? 2.0 : (isSpark ? 0.5 : 1.0);
        this.decay = Math.random() * 0.02 + (isSpark ? 0.05 : 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();
        // スパークは四角
        if(this.isSpark) {
            ctx.fillRect(this.x - camX, this.y, 4, 4);
        } else {
            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 spawnSparks(x, y) { for(let i=0; i<20; i++) particles.push(new Particle(x, y, COLOR.SPARK, false, true)); }
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 CloudItem {
    constructor(x, y) {
        this.x = x;
        this.y = y;
        this.width = BLOCK_SIZE * 1.5; 
        this.height = 30;
        this.active = true;
        this.parts = [];
        for(let i=0; i<3; i++) {
            this.parts.push({
                ox: Math.random() * 10 - 5, 
                oy: Math.random() * 10 - 5,
                r: 10 + Math.random() * 5
            });
        }
    }
    update() {
        if(this.x < cameraX - 100) this.active = false;
        
        if(player && this.active) {
            const dist = Math.hypot(player.x - this.x, player.y - this.y);
            if (dist < 30 + player.currentSize) {
                this.active = false;
                player.heal();
                soundManager.playItemGet();
                spawnParticles(this.x, this.y, "#FFF");
                popups.push(new PopupText(player.x, player.y - 40, "HP +1", "#FFF", 20));
            }
        }
    }
    draw(ctx, camX) {
        if(!this.active) return;
        ctx.fillStyle = COLOR.CLOUD;
        for(let p of this.parts) {
            ctx.beginPath();
            ctx.arc(this.x + p.ox - camX, this.y + p.oy, p.r, 0, Math.PI*2);
            ctx.fill();
        }
    }
}

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, 10, COLOR.PLAYER);
        this.maxHp = MAX_HP; 
        this.hp = 20; 
        this.invincible = 0; this.scale = 1.0; this.isStomping = false;
        this.state = 'normal'; 
        this.dashTargets = [];
        this.currentDashIndex = 0;
        this.dashOrigin = {x:0, y:0};
        this.comboCount = 0;
        
        // 残像用履歴
        this.trail = [];
    }
    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;

        // 残像記録 (ダッシュ中 or ストンプ中)
        if (this.state === 'dashing' || this.isStomping) {
            this.trail.push({x: this.x, y: this.y, size: this.currentSize, alpha: 0.8});
        }
        // 残像更新
        for(let i=this.trail.length-1; i>=0; i--) {
            this.trail[i].alpha -= 0.1;
            if(this.trail[i].alpha <= 0) this.trail.splice(i, 1);
        }
    }

    draw(ctx, camX) {
        // 残像描画
        for(let t of this.trail) {
            ctx.globalAlpha = t.alpha;
            // 青色のグラデーション(暗い青 -> 明るい青)
            // alphaが低い(古い)ほど暗くする
            const val = Math.floor(255 * t.alpha);
            ctx.fillStyle = `rgb(0, 0, ${val})`; 
            ctx.beginPath();
            ctx.arc(t.x - camX, t.y - t.size/2, t.size, Math.PI, 0);
            ctx.fillRect(t.x - camX - t.size, t.y - t.size/2, t.size*2, t.size/2);
            ctx.fill();
        }
        ctx.globalAlpha = 1.0;

        super.draw(ctx, camX);
    }

    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.hp > 1) {
                const maxCost = this.hp - 1;
                const limitScale = Math.min(5.0, maxCost);
                if (this.scale < limitScale) {
                    this.scale += 0.1;
                } else {
                    this.scale = limitScale;
                }
            } else {
                this.scale = 1.0; 
            }
        } else {
            if (keys.lastZPressed) {
                 let cost = 0;
                 let isCharge = false;
                 if (this.scale > 1.2) {
                     cost = Math.floor(this.scale);
                     isCharge = true;
                 } else {
                     this.scale = 1.0;
                     cost = 0;
                     isCharge = false;
                 }

                 if (this.hp > cost) {
                    this.hp -= cost;
                    const dmg = 10 * this.scale; 
                    const shotSize = this.scale * 6;  
                    bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 10, 0, true, this.rgb, dmg, shotSize));
                    if (isCharge) soundManager.playChargeShot();
                    else soundManager.playShot();
                 }
                 this.scale = 1.0;
            }
        }
        keys.lastZPressed = keys['KeyZ'];

        if (keys['KeyA']) {
            const targets = enemies.filter(e => e.isLockedOn && !e.dead && e.x > cameraX).sort((a,b) => a.x - b.x);
            if (targets.length > 0) {
                this.startChainDash(targets);
                return;
            }
        }

        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) {
            // 着地衝撃
            screenShake = 15;
            spawnSparks(this.x, this.y + this.currentSize/2);
            soundManager.playImpact();
            
            this.scale = 1.0; this.isStomping = false;
        }
        super.update();
    }

    startChainDash(targets) {
        this.state = 'dashing';
        this.dashTargets = targets;
        this.currentDashIndex = 0;
        this.dashOrigin = {x: this.x, y: this.y};
        this.invincible = 999; 
        this.isStomping = false; this.scale = 1.0;
        this.comboCount = 0;
        soundManager.playJump();
    }

    updateDashing() {
        if (this.currentDashIndex >= this.dashTargets.length) {
            this.state = 'returning'; return;
        }
        const target = this.dashTargets[this.currentDashIndex];
        const invalidEnemy = (target instanceof Enemy) && (target.dead || target.x < cameraX);

        if (!target || invalidEnemy) {
            this.currentDashIndex++; return;
        }
        
        const dx = target.x - this.x;
        const dy = target.y - this.y;
        const dist = Math.hypot(dx, dy);
        let absorbDist = this.currentSize + target.currentSize + 20;

        if (dist < absorbDist) { 
            this.heal();
            this.comboCount++;
            const multiplier = Math.pow(2, this.comboCount - 1); 
            popups.push(new PopupText(this.x, this.y-40, `x${multiplier}`, "#0FF", 30));
            spawnParticles(target.x, target.y, COLOR.CYAN_HEX);
            
            if (target instanceof Enemy) {
                target.dead = true; 
                const tIdx = enemies.indexOf(target);
                if(tIdx > -1) enemies.splice(tIdx, 1);
                score += (target.isBoss ? 5000 : 500) * multiplier;
            } 
            soundManager.playCombo(); 
            this.currentDashIndex++; 
        } else {
            this.x += (dx / dist) * 50; 
            this.y += (dy / dist) * 50;
        }
    }

    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 < 30) {
            this.x = targetX; this.y = targetY;
            this.state = 'normal';
            this.invincible = 60;
            this.vx = 0; this.vy = 0;
            this.trail = []; // 残像消去
        } else {
            this.x += (dx / dist) * 40;
            this.y += (dy / dist) * 40;
        }
    }
    
    heal() { if(this.hp < this.maxHp) this.hp++; }
    
    hit(damage, shooterRgb) { 
        if (this.invincible > 0) return false;
        this.hp -= 1;
        this.invincible = 60;
        soundManager.playPlayerDamage();
        if (this.hp <= 0) {
            this.dead = true;
            return true;
        }
        return false;
    }
}

class Enemy extends Entity {
    constructor(x, y, isBoss = false, leader = null) {
        super(x, y, isBoss ? 60 : 15, isBoss ? COLOR.BOSS : COLOR.ENEMY);
        this.isBoss = isBoss;
        this.leader = leader; 
        this.followDelay = []; 
        this.shootTimer = Math.random() * 200;
        this.isLockedOn = false; 
    }
    update() {
        if (this.dead) return;
        
        if (this.isLockedOn) {
            // 玉状態:自機接触で吸収
            if(player) {
                const dist = Math.hypot(player.x - this.x, player.y - this.y);
                if(dist < player.currentSize + this.currentSize + 20) {
                    this.dead = true;
                    player.heal();
                    score += 100;
                    spawnParticles(this.x, this.y, COLOR.CYAN_HEX);
                    soundManager.playItemGet();
                }
            }
            return;
        }

        if (this.leader && !this.leader.dead && !this.leader.isLockedOn) {
            this.leader.followDelay.push({x: this.leader.x, y: this.leader.y});
            if (this.leader.followDelay.length > 10) { 
                const pos = this.leader.followDelay.shift();
                this.x = pos.x - 30; 
                this.y = pos.y;
            } else {
                this.x = this.leader.x - 30;
                this.y = this.leader.y;
            }
        } else {
            super.update();
            if (!this.isBoss && this.grounded && Math.random()<0.01) this.vy = -10;
        }

        if (this.x > cameraX - 100 && this.x < cameraX + CANVAS_WIDTH + 100) {
            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;
        if (this.isLockedOn) {
            // 水色の玉
            ctx.fillStyle = COLOR.CYAN_HEX;
            ctx.shadowBlur = 15; ctx.shadowColor = COLOR.CYAN_HEX;
            ctx.beginPath();
            ctx.arc(this.x - camX, this.y, this.currentSize, 0, Math.PI*2);
            ctx.fill();
            ctx.shadowBlur = 0;
            
            ctx.strokeStyle = COLOR.LOCKON;
            ctx.lineWidth = 3;
            ctx.beginPath();
            ctx.arc(this.x - camX, this.y, this.currentSize + 5, 0, Math.PI*2);
            ctx.stroke();
            return;
        }
        super.draw(ctx, camX);
    }
    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));
        
        // 雲 (4割)
        if (Math.random() < 0.4) {
            // Y=200付近
            const cloudY = 200 + Math.sin(lastGeneratedX * 0.02) * 30;
            clouds.push(new CloudItem(lastGeneratedX, cloudY));
        }

        globalBlockIndex++;
        lastGeneratedX += BLOCK_SIZE;

        if (Math.random() < 0.04) {
             const chainSize = 1 + Math.floor(Math.random() * 5); 
             let leader = null;
             let startX = lastGeneratedX;
             for(let i=0; i<chainSize; i++) {
                 const enemy = new Enemy(startX + (i * 40), 0, false, leader);
                 enemies.push(enemy);
                 leader = enemy; 
             }
        }
        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);
    clouds = clouds.filter(c => c.active && c.x > deleteThreshold);
}

function initGame() {
    player = new Player();
    bullets = [], enemies = [], blocks = [], stars = [], particles = [], popups = [], clouds = [];
    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;
    }

    // 画面揺れ減衰
    if(screenShake > 0) screenShake *= 0.9;
    if(screenShake < 0.5) screenShake = 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());
    clouds.forEach(c => c.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(1); 
                } 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(1); 
                }
            }
        });
    }

    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); 
    
    // シェイク
    const shakeX = (Math.random() - 0.5) * screenShake;
    const shakeY = (Math.random() - 0.5) * screenShake;

    ctx.translate(CANVAS_WIDTH/2 + shakeX, CANVAS_HEIGHT/2 + shakeY); 
    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));
    clouds.forEach(c => c.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;
    
    ctx.setTransform(1, 0, 0, 1, 0, 0); // UI座標固定

    // 背景帯
    ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
    ctx.fillRect(0, 0, CANVAS_WIDTH, 60);

    const uiX = 20;
    const uiY = 20;
    
    // HP (水色の玉)
    for(let i=0; i<player.hp; i++) {
        ctx.fillStyle = COLOR.CYAN_HEX; // #00FFFF
        ctx.beginPath();
        const px = uiX + (i * 22); 
        const py = uiY + 10;
        ctx.arc(px, py, 8, 0, Math.PI*2);
        ctx.fill();
        ctx.strokeStyle = "#FFF"; // 白枠
        ctx.lineWidth = 1;
        ctx.stroke();
    }
    
    if(player.hp <= 1) {
        ctx.fillStyle = "red";
        ctx.font = "bold 14px Arial";
        ctx.fillText("CRITICAL CONDITION!", uiX, uiY + 32);
    }

    ctx.fillStyle = "white";
    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上にいくつか作られました。


《このゲームの独自性》


 ・
「音楽からステージを作る」ゲームはありますが、
 多くは「事前に解析して固定のコースを作る」もので、
 今回のように「リアルタイムに波形そのものが物理的な足場として
 暴れまわる」というアプローチは、製品レベルのゲームでは
 意外と珍しい。

・多くの音ゲーは、音を「譜面(タイミング)」として扱いますが、
 このツールは音を「物理エネルギー(高さとバネ)」として扱っている。

・音が大きくなると地面が物理的にプレイヤーを跳ね飛ばす、
 という「音の暴力性」をアクションに転化している点がユニーク。

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
音で作る2Dシューティング「WaveGround」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1