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

【更新履歴】

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

画像
ゲーム画面


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

《操作説明》

・方向キーで移動。

・Zキーでショットを撃つ。(押しっぱなしだと、貯め撃ち)

・Xキーまたは上キーで通常ジャンプ。
 (地面が点滅している時に押すと雲まで届く)

・下キーで急降下して敵を踏み潰し、
 地面を突き破って下の階層へ行ける。

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

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

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

・ロックオンされていない時にAキーを押すと、
 進行方向にダッシュする。(物にぶつかると止まる)

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

・地面が跳ね上がった瞬間にSキーを押すと
 ハイジャンプして、上の階層へ行ける。
 (※途中で別のアクションのキーを押すと中断する。)

・escキーでゲームを一時停止。もう一度押すと再開。

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Wave Ground 1.7</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #000;
            font-family: 'Arial Black', Impact, sans-serif;
            height: 100vh;
            width: 100vw;
            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.85);
            padding: 40px;
            border-radius: 10px;
            border: 2px solid #00BFFF;
            min-width: 450px;
            box-shadow: 0 0 20px rgba(0, 191, 255, 0.5);
        }
        canvas {
            display: none;
            width: 100%;
            height: 100%;
        }
        .btn {
            padding: 15px 40px;
            font-size: 20px;
            cursor: pointer;
            background: linear-gradient(to bottom, #00BFFF, #0080FF);
            color: white;
            border: none;
            border-radius: 5px;
            margin-top: 15px;
            font-weight: bold;
            text-shadow: 1px 1px 2px black;
        }
        .btn:disabled { background: #555; color: #999; cursor: not-allowed; }
        .key-instruction { font-size: 14px; color: #ccc; margin-top: 10px; line-height: 1.6; }
        .highlight { color: #FF0; font-weight: bold; }
        h1 { margin: 0 0 10px 0; color: #00BFFF; text-shadow: 0 0 10px #00BFFF; }
        .tag { display:inline-block; background:#333; padding:2px 6px; border-radius:4px; font-size:0.9em; margin:2px; }
    </style>
</head>
<body>

<div id="ui-layer">
    <h1>Wave Ground 1.7</h1>
    <p style="font-size: 14px; color:#aaa;"></p>
    <div class="key-instruction">
        <span class="tag">移動: ←→</span> <span class="tag">通常ジャンプ: ↑ or X</span><br>
        <span class="tag highlight">ハイジャンプ: S (地面点滅時)</span> <span class="tag">ショット: Z (長押しチャージ)</span><br>
        <span class="tag">ダッシュ: A</span> <span class="tag">踏み潰し: ↓</span><br><br>
        <span class="highlight">【修正完了】</span><br>
        <span style="color:#F0F">完全版</span>: 全クラスを実装済み。エラー修正。<br>
        <span style="color:#FF0">爆発音</span>: 「ドドン!」という重低音。<br>
        <span style="color:#0FF">操作</span>: A/Z/Xでハイジャンプ中断。<br>
    </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>
// === 設定・定数 ===
let CANVAS_WIDTH = window.innerWidth;
let CANVAS_HEIGHT = window.innerHeight;
const BLOCK_SIZE = 25; 
const BLOCK_THICKNESS = 10; 
const MAX_HP = 12; 
const INITIAL_HP = 6;
const LAYER_HEIGHT = 1800; 
const LAYER_KEEP_RANGE = 3; 

const ROTATION_CYCLE = 1800; 
const ROTATION_DURATION = 600; 

const COLOR = {
    BG: "#000000", 
    BLOCK_OUTLINE: "rgba(0,0,0,0.2)",
    PLAYER: {R:50, G:100, B:255}, 
    ENEMY:  {R:200, G:50, B:200}, 
    BOSS:   {R:255, G:50, B:255},
    CYAN:   {R:0, G:255, B:255}, 
    CYAN_HEX: "#00FFFF",
    CLOUD:  "#FFFFFF", 
    LOCKON: "#FF0000",
    SPARK:  "#FFFF00"
};
const TERRAIN_PALETTE = ["#4682B4", "#20B2AA", "#87CEEB", "#5F9EA0", "#B0C4DE"];

// === グローバル変数 ===
let canvas, ctx;
let gameState = 'loading'; 
let cameraX = 0;
let cameraY = 0; 
let keys = {};
let score = 0;
let frameCount = 0;
let animationFrameId;
let currentAudioLevel = 0;
let screenShake = 0;
let timeScale = 1.0; 

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

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;

window.addEventListener('resize', () => {
    CANVAS_WIDTH = window.innerWidth;
    CANVAS_HEIGHT = window.innerHeight;
    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)(); 
        this.noiseBuffer = null;
    }
    
    async loadFile(file) {
        if(this.ctx.state === 'suspended') await this.ctx.resume();
        if (!this.noiseBuffer) {
            const bufferSize = this.ctx.sampleRate * 2; 
            this.noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
            const data = this.noiseBuffer.getChannelData(0);
            for (let i = 0; i < bufferSize; i++) {
                data[i] = Math.random() * 2 - 1;
            }
        }
        return await this.ctx.decodeAudioData(await file.arrayBuffer());
    }

    playBGM(buffer) {
        this.stopBGM();
        try {
            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);
        } catch(e) { console.error("BGM Play Error:", e); }
    }
    stopBGM() { if (soundSource) { try { soundSource.stop(); } catch(e){} soundSource = null; } }
    suspend() { if(this.ctx.state === 'running') this.ctx.suspend(); }
    resume() { if(this.ctx.state === 'suspended') this.ctx.resume(); }

    playOsc(freq, type, dur, vol, slideTo=null) {
        if(this.ctx.state === 'suspended') return;
        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);
    }

    playNoise(dur, vol, filterType = 'lowpass', filterFreq = 1000) {
        if(this.ctx.state === 'suspended' || !this.noiseBuffer) return;
        const src = this.ctx.createBufferSource();
        src.buffer = this.noiseBuffer;
        const gain = this.ctx.createGain();
        const filter = this.ctx.createBiquadFilter();
        
        filter.type = filterType;
        filter.frequency.value = filterFreq;
        
        src.connect(filter);
        filter.connect(gain);
        gain.connect(this.ctx.destination);
        
        gain.gain.setValueAtTime(vol, this.ctx.currentTime);
        gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
        
        src.start();
        src.stop(this.ctx.currentTime + dur);
    }

    playExplosion() {
        if(this.ctx.state === 'suspended' || !this.noiseBuffer) return;
        const osc = this.ctx.createOscillator();
        const oscGain = this.ctx.createGain();
        osc.type = 'square';
        osc.frequency.setValueAtTime(150, this.ctx.currentTime);
        osc.frequency.exponentialRampToValueAtTime(10, this.ctx.currentTime + 0.3);
        oscGain.gain.setValueAtTime(0.5, this.ctx.currentTime);
        oscGain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3);
        osc.connect(oscGain);
        oscGain.connect(this.ctx.destination);
        osc.start();
        osc.stop(this.ctx.currentTime + 0.3);
        this.playNoise(0.4, 0.8, 'lowpass', 500);
    }

    playSprayShot() { this.playNoise(0.2, 0.2, 'highpass', 2000); }
    playHeavySprayShot() {
        this.playNoise(0.5, 0.4, 'bandpass', 1000);
        this.playOsc(200, 'sawtooth', 0.2, 0.1, 50);
    }

    playJump() { this.playOsc(150, 'sawtooth', 0.3, 0.2, 600); }
    playSuperJump() { 
        this.playOsc(300, 'square', 0.5, 0.3, 1500); 
        this.playNoise(0.4, 0.1, 'lowpass', 1000);
    } 
    playBoost() { 
        this.playOsc(600, 'sawtooth', 0.2, 0.2, 100); 
        this.playNoise(0.2, 0.1, 'bandpass', 5000);
    } 
    playStomp() { 
        this.playOsc(800, 'square', 0.3, 0.3, 50); 
        this.playNoise(0.3, 0.2, 800);
    } 
    playImpact() { this.playOsc(100, 'sawtooth', 0.4, 0.5, 10); }
    playBreak() { this.playExplosion(); } 
    playEnemyHit() { this.playExplosion(); } 
    playBossHit() {
        this.playNoise(0.8, 0.6, 'lowpass', 500);
        this.playOsc(100, 'square', 0.6, 0.5, 10);
    }
    playChargeShot() { this.playHeavySprayShot(); } // エイリアス
    playShot() { this.playSprayShot(); } // エイリアス
    playCrit() { this.playOsc(1500, 'square', 0.15, 0.15, 2000); }
    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); }
    playDamage() { 
        this.playOsc(100, 'sawtooth', 0.3, 0.4, 20); 
        this.playOsc(80, 'square', 0.3, 0.4, 10);
        this.playNoise(0.3, 0.3, 'lowpass', 500);
    } 
    playStompStart() { this.playOsc(800, 'square', 0.3, 0.3, 50); }
}();

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;
        const startY = cameraY;
        this.x = randomX ? startX + Math.random() * CANVAS_WIDTH : startX + CANVAS_WIDTH;
        this.y = startY + (Math.random() * CANVAS_HEIGHT * 3) - CANVAS_HEIGHT * 1.5; 
        this.speed = (2 + Math.random() * 5) * 10;
        this.size = 1 + Math.random() * 2;
    }
    update() { 
        this.x -= this.speed * timeScale; 
        if (this.x < cameraX - 100) this.reset(); 
    }
    draw(ctx, camX, camY) { 
        ctx.fillStyle = "#FFF"; 
        ctx.fillRect(this.x - camX, this.y - camY, 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 ? 15 : 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.8 : 1.0);
        this.decay = Math.random() * 0.02 + (isSpark ? 0.04 : 0.03);
        if(isFirework) this.vy -= 3;
    }
    update() {
        this.x += this.vx * timeScale; 
        this.y += this.vy * timeScale;
        this.vy += (this.isFirework ? 0.1 : 0.3) * timeScale; 
        this.life -= this.decay * timeScale;
    }
    draw(ctx, camX, camY) {
        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 - camY, 5, 5);
        else { ctx.arc(this.x - camX, this.y - camY, 3, 0, Math.PI * 2); ctx.fill(); }
        ctx.globalAlpha = 1.0;
    }
}
function spawnParticles(x, y, color) { for(let i=0; i<8; i++) particles.push(new Particle(x, y, color)); }
function spawnBlockDebris(x, y, color) { for(let i=0; i<8; i++) particles.push(new Particle(x, y, color, false, true)); }
function spawnSparks(x, y) { for(let i=0; i<15; 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 = 40;
    }
    update() { this.y -= 1 * timeScale; this.life -= 1 * timeScale; }
    draw(ctx, camX, camY) {
        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 - camY);
    }
}

class Block {
    constructor(x, layerIndex, index) {
        this.x = x; 
        this.layerIndex = layerIndex;
        this.baseHeight = 500 - (layerIndex * LAYER_HEIGHT); 
        this.y = this.baseHeight; 
        this.vy = 0;
        const paletteIdx = Math.abs(layerIndex) % TERRAIN_PALETTE.length;
        this.color = TERRAIN_PALETTE[paletteIdx];
        this.destroyed = false; 
        this.damage = 0; 
    }
    getTopY() {
        return this.y + (this.damage * BLOCK_SIZE);
    }
    update(waveOffset) {
        if (this.destroyed) return; 
        const amp = 1.5 + (Math.abs(this.layerIndex) * 0.2); 
        const targetY = this.baseHeight - (waveOffset * amp);
        this.vy += (targetY - this.y) * 0.1 * timeScale; 
        this.vy *= 0.85; 
        this.y += this.vy * timeScale;
    }
    draw(ctx, camX, camY) {
        if (this.destroyed) return;
        if (this.x + BLOCK_SIZE < camX || this.x > camX + CANVAS_WIDTH) return;
        if (this.y - camY > CANVAS_HEIGHT + 400 || this.y - camY < -400) return;

        let drawColor = this.color;
        let brightness = 0;
        
        if (this.vy < -0.1) {
             const intensity = Math.min(1.0, Math.abs(this.vy) / 2.5);
             brightness = intensity * 100;
             if (this.vy < -2.0) {
                 if (Math.floor(frameCount / 3) % 2 === 0) {
                     brightness += 150; 
                 }
             }
             drawColor = adjustBrightness(this.color, brightness);
        }

        ctx.strokeStyle = COLOR.BLOCK_OUTLINE; 
        ctx.lineWidth = 1;
        
        for(let i = this.damage; i < BLOCK_THICKNESS; i++) {
            const drawY = this.y - camY + (i * BLOCK_SIZE);
            if (drawY > CANVAS_HEIGHT + 100 || drawY < -100) continue;
            let sideColor = drawColor;
            if (i > 0) sideColor = adjustBrightness(drawColor, -8 * i); 
            ctx.fillStyle = sideColor;
            ctx.fillRect(this.x - camX, drawY, BLOCK_SIZE, BLOCK_SIZE);
            ctx.strokeRect(this.x - camX, drawY, BLOCK_SIZE, BLOCK_SIZE);
        }
    }
}
function adjustBrightness(hex, percent) {
    let r = parseInt(hex.substring(1,3),16);
    let g = parseInt(hex.substring(3,5),16);
    let b = parseInt(hex.substring(5,7),16);
    r = Math.max(0, Math.min(255, r + percent));
    g = Math.max(0, Math.min(255, g + percent));
    b = Math.max(0, Math.min(255, b + percent));
    return `rgb(${r},${g},${b})`;
}

class CloudItem {
    constructor(x, y) {
        this.x = x; this.y = y; this.active = true;
        this.parts = [{ox:0, oy:0, r:15}, {ox:-10, oy:5, r:12}, {ox:10, oy:5, r:12}];
        this.vx = 0; 
    }
    update() {
        if(this.x < cameraX - 100) this.active = false;
    }
    draw(ctx, camX, camY) {
        if(!this.active) return;
        ctx.fillStyle = COLOR.CLOUD;
        const breath = 1.0 + Math.sin(frameCount * 0.1) * 0.1;
        for(let p of this.parts) {
            ctx.beginPath(); ctx.arc(this.x + p.ox - camX, this.y + p.oy - camY, p.r * breath, 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;
        this.blinkTimer = 0; 
        this.knockbackVx = 0; 
        this.knockbackVy = 0;
        this.animScaleX = 1.0;
        this.animScaleY = 1.0;
    }
    draw(ctx, camX, camY) {
        if (this.dead) return;
        
        ctx.save();
        ctx.translate(this.x - camX, this.y - camY);
        ctx.scale(this.animScaleX, this.animScaleY);

        if (this.blinkTimer > 0) {
            if (Math.floor(this.blinkTimer / 3) % 2 === 0) {
                ctx.fillStyle = "#FFFFFF";
            } else {
                ctx.fillStyle = rgbToHex(this.rgb);
            }
        } else {
            ctx.fillStyle = rgbToHex(this.rgb);
        }
        
        ctx.beginPath();
        ctx.arc(0, -this.currentSize/2, this.currentSize, Math.PI, 0);
        ctx.fillRect(-this.currentSize, -this.currentSize/2, this.currentSize*2, this.currentSize/2);
        ctx.fill();

        ctx.restore();
    }
    update() {
        this.x += this.knockbackVx;
        this.y += this.knockbackVy;
        this.knockbackVx *= 0.9;
        this.knockbackVy *= 0.9;
        if(this.blinkTimer > 0) this.blinkTimer--;

        this.animScaleX += (1.0 - this.animScaleX) * 0.1;
        this.animScaleY += (1.0 - this.animScaleY) * 0.1;

        this.vy += 0.6 * timeScale; 
        this.x += this.vx * timeScale; 
        this.y += this.vy * timeScale;
        
        if(this.y > cameraY + CANVAS_HEIGHT + 300) {
            this.dead = true;
        }

        this.grounded = false; 
        this.groundBlock = null;

        // 1. 上昇破壊
        if (this.vy < 0 && this instanceof Player && this.state === 'superJumping') {
            const checkX = this.x;
            const checkY = this.y - 20;
            blocks.forEach(b => {
                if (b.destroyed) return;
                const topY = b.getTopY();
                if (checkX >= b.x && checkX < b.x + BLOCK_SIZE) {
                    if (checkY >= topY && checkY <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS) + 50) {
                        spawnBlockDebris(b.x + BLOCK_SIZE/2, topY, b.color);
                        soundManager.playBreak();
                        b.destroyed = true; 
                    }
                }
            });
        }
        
        // 雲接触
        if (this.vy < 0 && this instanceof Player) {
             const hitClouds = clouds.filter(c => 
                c.active &&
                Math.abs(this.x - c.x) < 40 &&
                this.y >= c.y && this.y <= c.y + 40
            );
            if(hitClouds.length > 0) {
                const c = hitClouds[0];
                c.active = false;
                this.heal();
                score += 1; 
                popups.push(new PopupText(this.x, this.y - 40, "HP+1 SCORE+1", "#FFF", 20));
                soundManager.playItemGet();
            }
        }

        // 2. 下降着地・ストンプ
        if (this.vy >= 0) {
            const hitBlocks = blocks.filter(b => 
                !b.destroyed && 
                this.x >= b.x && this.x < b.x + BLOCK_SIZE && 
                this.y >= b.getTopY() - 15 && this.y <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS)
            );

            const hitEnemies = (this instanceof Player) ? enemies.filter(e => 
                !e.dead && !e.isLockedOn &&
                Math.abs(this.x - e.x) < (this.currentSize + e.currentSize) &&
                this.y >= e.y - 20 && this.y <= e.y + 20
            ) : [];

            let candidates = [];
            if(hitBlocks.length > 0) {
                hitBlocks.sort((a,b) => a.getTopY() - b.getTopY());
                candidates.push({type:'block', obj:hitBlocks[0], y:hitBlocks[0].getTopY()});
            }
            if(hitEnemies.length > 0) candidates.push({type:'enemy', obj:hitEnemies[0], y:hitEnemies[0].y});

            if(candidates.length > 0) {
                candidates.sort((a,b) => a.y - b.y);
                const hit = candidates[0];

                if (this instanceof Player && this.isStomping) {
                    
                    if (hit.type === 'block') {
                        hit.obj.damage++;
                        spawnBlockDebris(hit.obj.x + BLOCK_SIZE/2, hit.y, hit.obj.color);
                        soundManager.playBreak();
                        
                        if (hit.obj.damage >= BLOCK_THICKNESS) {
                            hit.obj.destroyed = true;
                            this.vy = -15; 
                            this.y = hit.y - 30; 
                            this.isStomping = false;
                            this.scale = 1.0;
                            this.state = 'normal';
                            popups.push(new PopupText(this.x, this.y, "CRUSH!", "#F0F", 25));
                            return; 
                        } else {
                            this.y = hit.obj.getTopY(); 
                            this.vy = 5; 
                        }
                    
                    } else if (hit.type === 'enemy') {
                        const enemy = hit.obj;
                        const stompDmg = 50; 
                        if (enemy.hp <= stompDmg) {
                            enemy.hit(999, null, false); 
                            this.heal();
                            soundManager.playItemGet(); 
                        } else {
                            enemy.hit(stompDmg, null, true);
                            this.hit(1); 
                            this.isStomping = false;
                            this.vy = -10; 
                            this.scale = 1.0;
                        }
                    }

                } else {
                    this.y = hit.y;
                    
                    if (hit.type === 'block') {
                        this.vy = hit.obj.vy < 0 ? hit.obj.vy * 0.1 : 0;
                        this.groundBlock = hit.obj;
                    } else {
                        this.vy = 0; 
                    }
                    this.grounded = true;
                    this.isStomping = false; 
                }
            }
        }
    }
}

class Player extends Entity {
    constructor() {
        super(cameraX + CANVAS_WIDTH / 2, 300, 10, COLOR.PLAYER);
        this.maxHp = MAX_HP; this.hp = INITIAL_HP; 
        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 = [];
        this.boostTimer = 0; 
        this.chargeFrames = 0; 
        this.facing = 1; 
        this.superJumpStartLayer = 0; 
    }
    update() {
        if (this.dead && gameState === 'playing') endGame(false);
        if (this.invincible > 0) this.invincible--;

        if (keys['ArrowRight']) this.facing = 1;
        if (keys['ArrowLeft']) this.facing = -1;

        if (this.state === 'superJumping') {
             if (keys['KeyA'] || keys['KeyZ'] || keys['KeyX']) {
                 this.state = 'normal';
                 this.vy *= 0.5;
             }
        }

        if (this.isStomping) {
             const keysPressed = Object.keys(keys).filter(k => keys[k]);
             if (keysPressed.some(k => k !== 'ArrowDown')) {
                 this.state = 'normal';
                 this.isStomping = false;
                 this.scale = 1.0;
                 this.vy *= 0.5;
             }
        }

        if (this.state === 'normal' || this.state === 'superJumping') this.updateNormal();
        else if (this.state === 'dashing') this.updateDashing();
        else if (this.state === 'freeDashing') this.updateFreeDashing();
        else if (this.state === 'returning') this.updateReturning();
        
        const margin = 20;
        if (this.x < cameraX + margin) {
            this.x = cameraX + margin;
            if (this.vx < 0) this.vx *= -0.5; 
        }
        if (this.x > cameraX + CANVAS_WIDTH - margin) {
            this.x = cameraX + CANVAS_WIDTH - margin;
            if (this.vx > 0) this.vx *= -0.5; 
        }

        this.currentSize = this.baseSize * this.scale;

        const newLayer = Math.round((500 - this.y) / LAYER_HEIGHT);
        if (newLayer !== playerLayerIndex) {
            playerLayerIndex = newLayer;
            regenerateVisibleLayers();
            spawnEnemiesForLayer(newLayer); 
        }

        if (this.state === 'dashing' || this.state === 'freeDashing' || this.isStomping || this.state === 'superJumping') {
            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 * timeScale;
            if(this.trail[i].alpha <= 0) this.trail.splice(i, 1);
        }
        
        if (this.state === 'superJumping') {
            this.vy -= 0.3 * timeScale; 
        }
        
        super.update(); 
    }

    draw(ctx, camX, camY) {
        if(this.blinkTimer > 0 && Math.floor(this.blinkTimer/4)%2===0) return;
        
        for(let t of this.trail) {
            ctx.globalAlpha = t.alpha;
            const val = Math.floor(255 * t.alpha);
            if (this.state === 'superJumping') ctx.fillStyle = `rgb(255, 255, ${val})`; 
            else if (this.state === 'dashing' || this.state === 'freeDashing') ctx.fillStyle = `rgb(0, 255, ${val})`;
            else ctx.fillStyle = `rgb(0, 0, ${val})`; 
            ctx.beginPath();
            ctx.arc(t.x - camX, t.y - t.size/2 - camY, t.size, Math.PI, 0);
            ctx.fillRect(t.x - camX - t.size, t.y - t.size/2 - camY, t.size*2, t.size/2);
            ctx.fill();
        }
        ctx.globalAlpha = 1.0;
        
        super.draw(ctx, camX, camY);

        if (this.chargeFrames > 60) {
            let level = Math.floor(1.0 + (this.chargeFrames / 30));
            if (level > 5) level = 5;
            
            ctx.fillStyle = COLOR.CYAN_HEX;
            for(let i=0; i < level; i++) {
                const shotY = this.y - camY - this.currentSize - 15 - (i * 12);
                const shotX = this.x - camX;
                ctx.beginPath();
                ctx.arc(shotX, shotY, 5, 0, Math.PI*2);
                ctx.fill();
            }
        }
    }

    updateNormal() {
        if (this.blinkTimer > 0) return;

        this.vx = keys['ArrowLeft'] ? -5 : (keys['ArrowRight'] ? 5 : 0);
        
        if (this.grounded) {
            if (keys['KeyS']) {
                if (this.groundBlock && this.groundBlock.vy < -2.0) {
                    this.vy = -45; 
                    soundManager.playSuperJump();
                    this.grounded = false;
                    this.state = 'superJumping';
                    this.boostTimer = 70; 
                    this.superJumpStartLayer = playerLayerIndex; 
                    spawnParticles(this.x, this.y, "#FF0");
                    this.animScaleX = 0.6;
                    this.animScaleY = 1.4;
                    return; 
                }
            }

            if (keys['KeyX'] || keys['ArrowUp']) {
                this.vy = -20;
                if (this.groundBlock) this.vy += this.groundBlock.vy * 0.1; 
                soundManager.playJump();
                this.grounded = false;
                this.animScaleX = 0.8;
                this.animScaleY = 1.2;
            }
        }

        if (this.state === 'superJumping') {
            if (keys['ArrowUp'] && this.boostTimer > 0) {
                this.boostTimer--;
                if (frameCount % 4 === 0) {
                    spawnParticles(this.x, this.y + 10, "#FFF");
                    soundManager.playBoost();
                }
            }
        }
        
        if (this.state === 'normal' && !this.isStomping) {
            if (keys['KeyZ']) {
                this.chargeFrames++;
                const maxCharge = 5.0;
                if (this.scale < maxCharge) {
                    this.scale = 1.0 + (this.chargeFrames / 30); 
                    if(this.scale > maxCharge) this.scale = maxCharge;
                }
            } else {
                if (this.chargeFrames > 0) {
                    if (this.chargeFrames < 60) {
                        const shotSize = 8;
                        const dmg = 10;
                        bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 0, 0, true, this.rgb, dmg, shotSize, false));
                        soundManager.playShot();
                    } else {
                        let level = Math.floor(this.scale); 
                        if (level < 2) level = 2; 
                        
                        for(let i=0; i<level; i++) {
                            setTimeout(() => {
                                if (this.hp > 0) { 
                                    const shotSize = 25 + (i * 5); 
                                    const dmg = 40;
                                    bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 10 * this.facing, 0, true, this.rgb, dmg, shotSize, true, true, i));
                                    soundManager.playChargeShot();
                                    screenShake = 5;
                                }
                            }, i * 80); 
                        }
                        this.hp -= 1; 
                    }
                }
                this.chargeFrames = 0;
                this.scale = 1.0;
            }

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

        if (keys['ArrowDown'] && !this.grounded && !this.isStomping) {
            soundManager.playStompStart();
            this.scale = 5.0; 
            this.vy = 15; 
            this.isStomping = true;
            this.state = 'normal';
        }
    }
    
    checkSuperJumpLanding() {
        if (this.state === 'superJumping') {
            const targetBaseY = 500 - ((this.superJumpStartLayer + 1) * LAYER_HEIGHT);
            if (this.y <= targetBaseY + 100 && this.vy < 0) {
                 this.y = targetBaseY;
                 this.vy = 0;
                 this.state = 'normal';
                 this.grounded = true;
                 spawnParticles(this.x, this.y, "#FFF");
            }
        }
    }

    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;
        this.animScaleX = 1.8;
        this.animScaleY = 0.6;
        soundManager.playJump();
    }

    updateDashing() {
        this.animScaleX = 1.8;
        this.animScaleY = 0.6;

        if (this.currentDashIndex >= this.dashTargets.length) { 
            this.state = 'returning'; 
            timeScale = 1.0;
            return; 
        }

        const target = this.dashTargets[this.currentDashIndex];
        if (!target || target.dead || target.x < cameraX) { 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();
            if (target.isBossOrb) {
                this.hp = this.maxHp;
            }

            this.comboCount++;
            const earnedScore = (target.isBoss ? 100 : 500) * this.comboCount;
            const popupText = target.isBoss ? `+${earnedScore}` : `+${this.comboCount}`;
            popups.push(new PopupText(this.x, this.y-40, popupText, "#FFF", 30));
            
            spawnParticles(target.x, target.y, COLOR.CYAN_HEX);
            
            if (target instanceof Enemy) {
                target.dead = true; 
                if(target.isBoss) soundManager.playBossHit();
                else soundManager.playEnemyHit();
                score += earnedScore;
            } 
            soundManager.playCombo(); this.currentDashIndex++; 
        } else {
            const speed = 60; 
            this.x += (dx / dist) * speed; 
            this.y += (dy / dist) * speed;
        }
    }

    startFreeDash() {
        this.state = 'freeDashing';
        this.invincible = 20;
        this.isStomping = false; this.scale = 1.0;
        this.vx = 40 * this.facing;
        this.vy = 0;
        this.animScaleX = 1.8;
        this.animScaleY = 0.6;
        soundManager.playBoost();
    }

    updateFreeDashing() {
        this.animScaleX = 1.8;
        this.animScaleY = 0.6;
        
        this.x += this.vx * timeScale;
        
        if (this.x <= cameraX + 20 || this.x >= cameraX + CANVAS_WIDTH - 20) {
            this.state = 'normal';
            this.vx = -this.vx * 0.5; 
            return;
        }

        for(let e of enemies) {
            if(!e.dead && !e.isLockedOn) {
                const dist = Math.hypot(this.x - e.x, this.y - e.y);
                if(dist < this.currentSize + e.currentSize + 10) {
                    e.hit(30, null, true); 
                    this.state = 'normal'; 
                    this.vx = -10 * this.facing;
                    this.vy = -10;
                    screenShake = 10;
                    return;
                }
            }
        }
        
        this.vx *= 0.95;
        if(Math.abs(this.vx) < 5) {
            this.state = 'normal';
        }
    }

    updateReturning() {
        this.animScaleX = 1.8;
        this.animScaleY = 0.6;

        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 {
            const speed = 60;
            this.x += (dx / dist) * speed; 
            this.y += (dy / dist) * speed;
        }
    }
    heal() { if(this.hp < this.maxHp) this.hp++; }
    
    hit(damage) { 
        if (this.invincible > 0) return false;
        this.hp -= damage; 
        this.invincible = 30; 
        this.blinkTimer = 30; 
        soundManager.playDamage();
        
        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.maxHp = isBoss ? 300 : 30; 
        this.hp = this.maxHp;
        this.isBossOrb = false; 

        if(isBoss) {
            this.vx = 5; 
            this.vy = 5;
        }
        this.leader = leader; this.followDelay = []; 
        this.shootTimer = Math.random() * 200; this.isLockedOn = false; 
    }
    update() {
        if (this.dead) return;
        super.update(); 

        if (this.isLockedOn) {
            return;
        }
        
        if (this.blinkTimer > 0) return; 

        if (this.isBoss) {
            this.x += this.vx * timeScale;
            this.y += this.vy * timeScale;
            const boundLeft = cameraX;
            const boundRight = cameraX + CANVAS_WIDTH;
            const boundTop = cameraY;
            const boundBottom = cameraY + CANVAS_HEIGHT;
            if (this.x < boundLeft && this.vx < 0) this.vx *= -1;
            if (this.x > boundRight && this.vx > 0) this.vx *= -1;
            if (this.y < boundTop && this.vy < 0) this.vy *= -1;
            if (this.y > boundBottom && this.vy > 0) this.vy *= -1;
        } else 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 > 8) { 
                const pos = this.leader.followDelay.shift();
                this.x = pos.x; 
                this.y = pos.y;
            }
        } else {
            if (this.grounded && Math.random()<0.02) this.vy = -12;
        }

        if (this.x > cameraX - 100 && this.x < cameraX + CANVAS_WIDTH + 100) {
            this.shootTimer -= 1 * timeScale;
            if (this.shootTimer <= 0) {
                if (player) {
                    const angle = Math.atan2(player.y - this.y, player.x - this.x);
                    const spd = 6;
                    bullets.push(new Bullet(this.x, this.y - 10, Math.cos(angle)*spd, Math.sin(angle)*spd, false, this.rgb, 1, 5)); 
                    soundManager.playShot();
                }
                this.shootTimer = this.isBoss ? 60 : 200 + Math.random()*200; 
            }
        }
    }
    draw(ctx, camX, camY) {
        if(this.dead) return;

        if (this.isLockedOn) {
            ctx.save();
            ctx.translate(this.x - camX, this.y - camY);
            ctx.fillStyle = COLOR.CYAN_HEX; 
            ctx.shadowBlur = 15; ctx.shadowColor = COLOR.CYAN_HEX;
            ctx.beginPath(); ctx.arc(0, 0, this.currentSize, 0, Math.PI*2); ctx.fill();
            ctx.shadowBlur = 0; ctx.strokeStyle = COLOR.LOCKON; ctx.lineWidth = 3;
            ctx.beginPath(); ctx.arc(0, 0, this.currentSize + 5, 0, Math.PI*2); ctx.stroke();
            ctx.restore();
            return;
        }

        const hpRatio = this.hp / this.maxHp;
        const blended = blendColor(COLOR.ENEMY, COLOR.CYAN, 1.0 - hpRatio);
        this.rgb = blended;

        super.draw(ctx, camX, camY);
    }
    hit(damage, shooterRgb, isKnockback) {
        if (this.isLockedOn) return false; 
        this.hp -= damage; 
        
        this.blinkTimer = 10;
        if(isKnockback) {
            const dir = (player.x < this.x) ? 1 : -1;
            this.knockbackVx = 10 * dir;
            this.knockbackVy = -5;
        }
        
        if (shooterRgb) soundManager.playImpact(); 

        if (this.hp <= 0) { 
            this.hp = 0; 
            this.isLockedOn = true; 
            if(this.isBoss) this.isBossOrb = true;

            if (player) {
                const angle = Math.atan2(this.y - player.y, this.x - player.x);
                const blowSpeed = 15;
                this.knockbackVx = Math.cos(angle) * blowSpeed;
                this.knockbackVy = Math.sin(angle) * blowSpeed;
            }
            
            if(this.isBoss) soundManager.playBossHit();
            else soundManager.playEnemyHit();

            return true; 
        }
        return false;
    }
}

class Bullet {
    constructor(x, y, vx, vy, isPlayerShot, rgb, damage, size, penetrate=false, isSpiral=false, spiralDelay=0) {
        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;
        this.penetrate = penetrate; 
        this.isSpiral = isSpiral;
        this.hitList = []; 
        
        this.lifeTime = 0;
        this.spiralDelay = spiralDelay; 
        this.baseY = y;
        this.trail = [];
    }
    update() {
        this.lifeTime++;

        if (this.isSpiral) {
            this.vx *= 1.05; 
            const freq = 0.5;
            const amp = 30;
            const time = this.lifeTime * freq + this.spiralDelay;
            this.y += Math.sin(time) * 5 * timeScale; 
            
            if (this.lifeTime % 2 === 0) {
                this.trail.push({x:this.x, y:this.y, alpha:0.8, size:this.size});
            }
        } 
        else if (this.isPlayerShot && this.active && !this.penetrate) { 
             let nearest = null;
             let minDst = 99999;
             for(let e of enemies) {
                 if (e.dead || e.isLockedOn || e.x < cameraX) continue;
                 if (Math.abs(e.y - this.y) > 800) continue; 

                 const dst = Math.hypot(e.x - this.x, e.y - this.y);
                 if (dst < minDst) { minDst = dst; nearest = e; }
             }
             if (nearest) {
                 const angle = Math.atan2(nearest.y - this.y, nearest.x - this.x);
                 const acc = 2.0 * timeScale;
                 this.vx += Math.cos(angle) * acc;
                 this.vy += Math.sin(angle) * acc;
                 const currentSpeed = Math.hypot(this.vx, this.vy);
                 if (currentSpeed > 20) {
                     const ratio = 20 / currentSpeed;
                     this.vx *= ratio; this.vy *= ratio;
                 }
             }
        }

        this.x += this.vx * timeScale; 
        this.y += this.vy * timeScale;
        
        for(let i=this.trail.length-1; i>=0; i--) {
            this.trail[i].alpha -= 0.1 * timeScale;
            if(this.trail[i].alpha <= 0) this.trail.splice(i, 1);
        }

        if (this.x < cameraX - 100 || this.x > cameraX + CANVAS_WIDTH + 100) this.active = false;
    }
    draw(ctx, camX, camY) {
        for(let t of this.trail) {
            ctx.globalAlpha = t.alpha;
            ctx.fillStyle = COLOR.CYAN_HEX;
            ctx.beginPath(); ctx.arc(t.x - camX, t.y - camY, t.size, 0, Math.PI*2); ctx.fill();
        }
        ctx.globalAlpha = 1.0;

        if (this.isPlayerShot && !this.penetrate && !this.isSpiral) {
            ctx.fillStyle = COLOR.CYAN_HEX;
            ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.size, 0, Math.PI * 2); ctx.fill();
        } else {
            ctx.fillStyle = rgbToHex(this.rgb);
            ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.size, 0, Math.PI * 2); ctx.fill();
            if(this.size > 8) { ctx.strokeStyle = "#FFF"; ctx.lineWidth = 2; ctx.stroke(); }
        }
    }
}

// === 無限生成とゲーム管理 ===

function getWaveOffset(screenX) {
    if (!dataArray) return 0;
    const dataIdx = Math.floor((screenX % CANVAS_WIDTH) / CANVAS_WIDTH * dataArray.length);
    const val = dataArray[dataIdx] || 128;
    return val - 128; 
}

function regenerateVisibleLayers() {
    const startX = Math.floor(cameraX / BLOCK_SIZE) * BLOCK_SIZE;
    const endX = startX + CANVAS_WIDTH + 100;
    const layersNeeded = [];
    for(let i = -LAYER_KEEP_RANGE; i <= LAYER_KEEP_RANGE; i++) {
        layersNeeded.push(playerLayerIndex + i);
    }

    const existingBlockMap = new Set();
    blocks.forEach(b => {
        if (!b.destroyed) existingBlockMap.add(`${Math.floor(b.x)}_${b.layerIndex}`);
    });

    layersNeeded.forEach(layerIdx => {
        for (let x = startX; x < endX; x += BLOCK_SIZE) {
            const key = `${Math.floor(x)}_${layerIdx}`;
            if (!existingBlockMap.has(key)) {
                const approxIndex = x / BLOCK_SIZE; 
                blocks.push(new Block(x, layerIdx, approxIndex));
            }
        }
    });
}

function spawnEnemiesForLayer(layerIdx) {
    const baseY = 500 - (layerIdx * LAYER_HEIGHT);
    const sides = [cameraX + CANVAS_WIDTH + 100, cameraX - 100];
    
    sides.forEach(x => {
        if(Math.random() < 0.9) { 
             let leader = null;
             const dir = (x > cameraX) ? 1 : -1;
             for(let i=0; i<5; i++) {
                 let ex = x + (i * 50 * dir); 
                 let e = new Enemy(ex, baseY, false, leader);
                 enemies.push(e);
                 leader = e;
             }
        }
    });
    clouds.push(new CloudItem(cameraX + CANVAS_WIDTH + 50, baseY - 400));
}

function updateLevelGeneration() {
    const rightEdge = cameraX + CANVAS_WIDTH + 200;
    const minLayer = playerLayerIndex - LAYER_KEEP_RANGE;
    const maxLayer = playerLayerIndex + LAYER_KEEP_RANGE;

    blocks = blocks.filter(b => b.layerIndex >= minLayer && b.layerIndex <= maxLayer && !b.destroyed && b.x > cameraX - 200);
    enemies = enemies.filter(e => e.x > cameraX - 200 && !e.dead);
    clouds = clouds.filter(c => c.active && c.x > cameraX - 200);

    while (lastGeneratedX < rightEdge) {
        for (let l = minLayer; l <= maxLayer; l++) {
            const b = new Block(lastGeneratedX, l, globalBlockIndex);
            
            if (Math.random() < 0.3) {
                clouds.push(new CloudItem(lastGeneratedX, b.baseHeight - 400));
            }
            if (Math.random() < 0.04) { 
                 let leader = null;
                 for(let k=0; k<5; k++) {
                     let e = new Enemy(lastGeneratedX + (k * 50), b.baseHeight, false, leader);
                     enemies.push(e);
                     leader = e;
                 }
            }
            blocks.push(b);
        }
        globalBlockIndex++;
        lastGeneratedX += BLOCK_SIZE;
        if (lastGeneratedX > nextBossScore) {
             const baseBossY = 500 - (playerLayerIndex * LAYER_HEIGHT);
             enemies.push(new Enemy(lastGeneratedX + 200, baseBossY, true));
             nextBossScore += 4000;
        }
    }
}

function initGame() {
    player = new Player();
    bullets = [], enemies = [], blocks = [], stars = [], particles = [], popups = [], clouds = [];
    score = 0; frameCount = 0; keys = {};
    globalBlockIndex = 0; lastGeneratedX = 0; nextBossScore = 4000;
    playerLayerIndex = 0;
    timeScale = 1.0;
    cameraX = 0;
    cameraY = 0;
    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;

    let targetTimeScale = 1.0;
    if (player.state === 'dashing' || player.state === 'returning') {
        targetTimeScale = 0.05; 
    }
    timeScale += (targetTimeScale - timeScale) * 0.1;

    updateLevelGeneration();
    stars.forEach(s => s.update());
    
    blocks.forEach(b => {
        let waveOffset = 0;
        if (!b.destroyed && b.x >= cameraX - BLOCK_SIZE && b.x <= cameraX + CANVAS_WIDTH) {
            waveOffset = getWaveOffset(b.x);
        }
        b.update(waveOffset);
    });

    if(player) {
        player.update();
        player.checkSuperJumpLanding(); 
    }
    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 && !b.penetrate && !b.isSpiral) return; 
            if (t.dead || (t.isLockedOn && b.isPlayerShot) || (t instanceof Player && t.invincible > 0)) return;
            if (b.penetrate && b.hitList.includes(t)) return; 

            const dist = Math.hypot(b.x - t.x, b.y - (t.y - t.currentSize/2)); 
            if (dist < t.currentSize + b.size) {
                if (t === player) {
                    t.hit(1); 
                    b.active = false; 
                } else {
                    t.hit(b.damage, b.rgb, true); 
                    if (b.penetrate || b.isSpiral) {
                        b.hitList.push(t);
                    } else {
                        b.active = false;
                    }
                }
            }
        });
    });

    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.y < e.y - 10) return;
                if (player.invincible <= 0) {
                    player.hit(1); 
                }
            }
        });
    }

    const targetCamX = player.x - CANVAS_WIDTH * 0.3;
    if (targetCamX > cameraX) cameraX = targetCamX;

    const targetCamY = player.y - CANVAS_HEIGHT * 0.6;
    cameraY += (targetCamY - cameraY) * 0.1;
}

function draw() {
    ctx.fillStyle = COLOR.BG; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    ctx.save();
    
    const cyclePos = frameCount % ROTATION_CYCLE;
    let largeSway = 0;
    if (cyclePos < ROTATION_DURATION) {
        const progress = cyclePos / ROTATION_DURATION;
        largeSway = Math.sin(progress * Math.PI) * (Math.PI / 2.2); 
    }
    const smallSway = Math.sin(frameCount * 0.1) * (Math.PI / 36);   
    const angle = largeSway + smallSway; 
    
    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, cameraY)); 
    blocks.forEach(b => b.draw(ctx, cameraX, cameraY));
    particles.forEach(p => p.draw(ctx, cameraX, cameraY));
    enemies.forEach(e => e.draw(ctx, cameraX, cameraY));
    clouds.forEach(c => c.draw(ctx, cameraX, cameraY));
    if(player) player.draw(ctx, cameraX, cameraY);
    bullets.forEach(b => b.draw(ctx, cameraX, cameraY));
    popups.forEach(p => p.draw(ctx, cameraX, cameraY));
    ctx.restore(); 

    if (gameState === 'playing') drawUI();
    else if (gameState === 'paused') drawPauseScreen();
    else if (gameState === 'gameover' || gameState === 'gameclear') drawEndScreen();
}

function drawUI() {
    if(!player) return;
    ctx.setTransform(1, 0, 0, 1, 0, 0); 

    ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
    ctx.fillRect(0, 0, CANVAS_WIDTH, 60);

    const uiX = 20; const uiY = 20;
    for(let i=0; i<player.hp; i++) {
        ctx.fillStyle = COLOR.CYAN_HEX; 
        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 <= 2) {
        ctx.fillStyle = "red"; ctx.font = "bold 14px Arial";
        ctx.textAlign = "left";
        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);
}

function drawPauseScreen() {
    drawUI(); 
    ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0,0,CANVAS_WIDTH,CANVAS_HEIGHT);
    ctx.fillStyle = "white"; ctx.textAlign = "center";
    ctx.font = "bold 60px Arial Black";
    ctx.fillText("PAUSE", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
    ctx.font = "20px Arial";
    ctx.fillText("Press ESC to Resume", CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 50);
}

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 = "bold 20px Arial";
    ctx.fillText(`Final Layer: ${playerLayerIndex}`, CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 140);
    ctx.font = "20px Arial";
    ctx.fillText("Restarting...", CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 190);
}

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', async () => {
    if (!audioBuffer) return;
    if (soundManager.ctx.state === 'suspended') {
        try { await soundManager.ctx.resume(); } catch(e) { console.error("Audio resume failed", e); }
    }
    initGame();
});

window.addEventListener('keydown', e => {
    if (e.code === 'Escape') {
        if (gameState === 'playing') {
            gameState = 'paused';
            soundManager.suspend();
        } else if (gameState === 'paused') {
            gameState = 'playing';
            soundManager.resume();
        }
    }
    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