音で作る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公開。(誘導弾追加)
・2026/2/5 バージョン1.8公開。(効果音追加)

画像
ゲーム画面


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


バージョン1.8以降は、Soundフォルダが必要です。
 必ずhtmlファイルと同じフォルダに配置して下さい。

※また、サーバー上ではなく、
 ご家庭のPCなどのローカル環境で実行される場合は、
 ローカル環境でのテスト専用のブラウザを使用する必要があります。↓


《操作説明》


・方向キーで移動。

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

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

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

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

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

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

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

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

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

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

【1.9の新要素】

  • 低音と高音の使い分け(周波数解析)

    • 低音(Bass): 上記の「ハードブロック」を作ったり、画面を揺らして(シェイク演出)迫力を出します。

    • 高音(Treble): ハイハットやシンバルのような高い音が鳴ると、**「空を飛ぶ黄色い敵」**が出現します。

  • 曲に合わせたスピード変化

    • 静かなバラードパートではゲームスピードがゆっくりになり、精密な操作が求められます。

    • 激しいロックやEDMのパートではスクロールが高速になり、反射神経が試されます。

  • 気持ちいい「音」の演出

    • コンボ音階: ダッシュで敵を連続で倒すと、効果音の音程が「ド・ミ・ソ…」と上がっていき、演奏しているような気分になれます。

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Wave Ground 1.9</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.9);
            padding: 40px;
            border-radius: 10px;
            border: 2px solid #00BFFF;
            min-width: 500px;
            box-shadow: 0 0 30px rgba(0, 191, 255, 0.6);
        }
        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;
            transition: transform 0.1s;
        }
        .btn:active { transform: scale(0.95); }
        .btn:disabled { background: #555; color: #999; cursor: not-allowed; transform: none; }
        .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; letter-spacing: 2px; }
        .tag { display:inline-block; background:#333; padding:2px 6px; border-radius:4px; font-size:0.9em; margin:2px; }
        #fever-bar-container {
            display: none;
            position: absolute;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            width: 60%;
            height: 20px;
            background: #333;
            border: 2px solid #FFF;
            border-radius: 10px;
            overflow: hidden;
        }
        #fever-bar {
            width: 0%;
            height: 100%;
            background: linear-gradient(90deg, #FF0, #F0F);
            transition: width 0.1s;
        }
        #fever-text {
            position: absolute;
            width: 100%;
            text-align: center;
            top: -25px;
            font-size: 20px;
            color: #FF0;
            text-shadow: 0 0 5px #F00;
            display: none;
        }
    </style>
</head>
<body>

<div id="ui-layer">
    <h1>Wave Ground 1.9</h1>
    <p style="font-size: 14px; color:#aaa;">Beat Edition (Frequency Analysis)</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>
        低音(Bass)で<span style="color:#F55">赤い壁</span>が出現。<br>
        高音(Treble)で<span style="color:#AAF">飛行敵</span>が出現。<br>
        サビの盛り上がりで<span style="color:#FF0">FEVER MODE</span>突入!<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>

<div id="fever-bar-container">
    <div id="fever-text">FEVER MODE!! SCORE x2</div>
    <div id="fever-bar"></div>
</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}, 
    FLY_ENEMY: {R:200, G:200, B:50}, // 新しい敵の色
    BOSS:   {R:255, G:50, B:255},
    CYAN:   {R:0, G:255, B:255}, 
    CYAN_HEX: "#00FFFF",
    CLOUD:  "#FFFFFF", 
    LOCKON: "#FF0000",
    SPARK:  "#FFFF00",
    HARD_BLOCK: "#AA3333" // 硬いブロック
};
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;

// Audio Analysis Variables
let currentAudioLevel = 0;
let bassEnergy = 0;
let trebleEnergy = 0;
let isFever = false;
let feverGauge = 0;
const FEVER_THRESHOLD = 80; // フィーバー発動閾値(蓄積)
const FEVER_MAX = 100;
let audioEnergyHistory = []; // 平均エネルギー計算用

let screenShake = 0;
let zoomLevel = 1.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 feverBarContainer = document.getElementById('fever-bar-container');
const feverBar = document.getElementById('fever-bar');
const feverText = document.getElementById('fever-text');
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;
});

// オーディオ系 (FFT対応版)
let audioBuffer, analyser, dataArray, freqArray, soundSource;
const soundManager = new class {
    constructor() { 
        this.ctx = new (window.AudioContext || window.webkitAudioContext)(); 
        this.seBuffers = {};
        this.loadSEs();
    }
    
    async loadFile(file) {
        if(this.ctx.state === 'suspended') await this.ctx.resume();
        return await this.ctx.decodeAudioData(await file.arrayBuffer());
    }

    async loadSEs() {
        // 同じファイルパスを使用(仮定)
        const soundList = [
            'shot', 'charge', 'charge_shot', 
            'died_enemy', 'died_boss', 
            'damage_enemy', 'damage_player', 
            'break_block', 'dash', 
            'jump', 'high_jump', 
            'press', 'heal'
        ];

        for (const name of soundList) {
            try {
                const response = await fetch(`./Sound/${name}.mp3`);
                if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
                this.seBuffers[name] = await this.ctx.decodeAudioData(await response.arrayBuffer());
            } catch (e) {
                console.warn(`Failed to load ./Sound/${name}.mp3`, e);
            }
        }
    }

    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; // 分解能
            analyser.smoothingTimeConstant = 0.85;

            dataArray = new Uint8Array(analyser.fftSize); // 波形用
            freqArray = 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(); }

    // ピッチ可変再生 (Combo System)
    play(name, pitch = 1.0) {
        if (this.ctx.state === 'suspended' || !this.seBuffers[name]) return;
        const source = this.ctx.createBufferSource();
        source.buffer = this.seBuffers[name];
        source.playbackRate.value = pitch; // ピッチ変更
        const gain = this.ctx.createGain();
        gain.gain.value = 0.5; 
        source.connect(gain);
        gain.connect(this.ctx.destination);
        source.start(0);
    }

    // 各アクション
    playShot() { this.play('shot'); }
    playCharge() { this.play('charge'); }
    playChargeShot() { this.play('charge_shot'); }
    playEnemyDeath() { this.play('died_enemy'); }
    playBossDeath() { this.play('died_boss'); }
    playEnemyDamage() { this.play('damage_enemy'); }
    playPlayerDamage() { this.play('damage_player'); }
    playBreak() { this.play('break_block'); }
    playDash() { this.play('dash'); }
    playJump() { this.play('jump'); }
    playSuperJump() { this.play('high_jump'); }
    playStomp() { this.play('press'); }
    playHeal() { this.play('heal'); }
    
    // コンボ音:音階を上げる
    playCombo(count) {
        // メジャースケール的な比率 (1, 1.125, 1.25, 1.33, 1.5 ...)
        const scales = [1.0, 1.125, 1.25, 1.334, 1.5, 1.667, 1.875, 2.0];
        const pitch = scales[Math.min(count, scales.length - 1)];
        this.play('heal', pitch); 
    }
    playImpact() { this.play('press', 0.8); }
    playClear() { 
        const o = this.ctx.createOscillator();
        const g = this.ctx.createGain();
        o.type = 'triangle';
        o.frequency.setValueAtTime(523, this.ctx.currentTime);
        o.frequency.linearRampToValueAtTime(1046, this.ctx.currentTime + 2.0);
        g.gain.setValueAtTime(0.3, this.ctx.currentTime);
        g.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 3.0);
        o.connect(g); g.connect(this.ctx.destination);
        o.start(); o.stop(this.ctx.currentTime + 3.0);
    }
}();

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() { 
        // フィーバー中は星が速く流れる
        const speedMult = isFever ? 3.0 : 1.0;
        this.x -= this.speed * timeScale * speedMult; 
        if (this.x < cameraX - 100) this.reset(); 
    }
    draw(ctx, camX, camY) { 
        ctx.fillStyle = isFever ? `hsl(${frameCount % 360}, 100%, 80%)` : "#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.save();
        ctx.fillStyle = this.color; ctx.font = `bold ${this.size}px Arial`;
        ctx.shadowColor = "#000"; ctx.shadowBlur = 4;
        ctx.fillText(this.text, this.x - camX, this.y - camY);
        ctx.restore();
    }
}

class Block {
    constructor(x, layerIndex, index, isHard = false) {
        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 = isHard ? COLOR.HARD_BLOCK : TERRAIN_PALETTE[paletteIdx];
        this.destroyed = false; 
        this.damage = 0;
        this.isHard = isHard; // 耐久力が高いブロック
        this.maxDamage = isHard ? BLOCK_THICKNESS * 3 : BLOCK_THICKNESS;
    }
    getTopY() {
        return this.y + ((this.damage % BLOCK_THICKNESS) * 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;
        
        // FEVER時はブロックが七色に光る
        if (isFever && !this.isHard) {
            drawColor = `hsl(${(this.x/10 + frameCount*5)%360}, 70%, 50%)`;
        }

        ctx.strokeStyle = COLOR.BLOCK_OUTLINE; 
        ctx.lineWidth = 1;
        
        // ハードブロックの描画(少しゴツく)
        if(this.isHard) {
            ctx.strokeStyle = "#000";
            ctx.lineWidth = 2;
        }

        const visibleThickness = Math.min(BLOCK_THICKNESS, Math.ceil((this.maxDamage - this.damage) / (this.isHard ? 3 : 1)));

        for(let i = 0; i < visibleThickness; i++) {
            const drawY = this.getTopY() - camY + (i * BLOCK_SIZE);
            if (drawY > CANVAS_HEIGHT + 100 || drawY < -100) continue;
            let sideColor = drawColor;
            
            // 擬似的な陰影
            if (!isFever) {
                const brightnessAdj = -8 * i + (this.vy < -2 ? 30 : 0);
                sideColor = adjustBrightness(drawColor, brightnessAdj);
            }
            
            ctx.fillStyle = sideColor;
            ctx.fillRect(this.x - camX, drawY, BLOCK_SIZE, BLOCK_SIZE);
            ctx.strokeRect(this.x - camX, drawY, BLOCK_SIZE, BLOCK_SIZE);
            
            // ハードブロックの模様
            if (this.isHard) {
                ctx.beginPath();
                ctx.moveTo(this.x - camX, drawY);
                ctx.lineTo(this.x - camX + BLOCK_SIZE, drawY + BLOCK_SIZE);
                ctx.stroke();
            }
        }
    }
}
function adjustBrightness(hex, percent) {
    if(hex.startsWith("hsl")) return hex; // HSLは簡易的にそのまま
    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}];
    }
    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 && Math.floor(this.blinkTimer / 3) % 2 === 0) {
            ctx.fillStyle = "#FFFFFF";
        } 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);
                        
                        if (b.isHard) {
                            soundManager.playImpact();
                            screenShake = 3;
                            b.damage++; // ハードブロックは一撃では壊れない
                            this.vy = 5; // 跳ね返される
                            this.state = 'normal'; // ジャンプ中断
                        } else {
                            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 += 100 * (isFever ? 2 : 1); 
                popups.push(new PopupText(this.x, this.y - 40, "HP+1", "#FFF", 20));
                soundManager.playHeal();
            }
        }

        // 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') {
                        // ハードブロックはストンプでないと壊しにくい
                        const dmg = this.isStomping ? 5 : 1;
                        hit.obj.damage += dmg;
                        spawnBlockDebris(hit.obj.x + BLOCK_SIZE/2, hit.y, hit.obj.color);
                        soundManager.playBreak();
                        
                        if (hit.obj.damage >= hit.obj.maxDamage) {
                            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));
                            screenShake = hit.obj.isHard ? 10 : 2;
                        } else {
                            this.y = hit.obj.getTopY(); 
                            this.vy = 5; 
                        }
                    
                    } else if (hit.type === 'enemy') {
                        const enemy = hit.obj;
                        const stompDmg = isFever ? 999 : 50; 
                        if (enemy.hp <= stompDmg) {
                            enemy.hit(999, null, false); 
                            this.heal();
                            soundManager.playHeal(); 
                        } 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') 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;
        
        // FEVER中はプレイヤーも光る
        if(isFever) {
            ctx.shadowBlur = 10;
            ctx.shadowColor = "#FF0";
        }
        super.draw(ctx, camX, camY);
        ctx.shadowBlur = 0;

        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);
                ctx.beginPath(); ctx.arc(this.x - camX, shotY, 5, 0, Math.PI*2); ctx.fill();
            }
        }
    }

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

        this.vx = keys['ArrowLeft'] ? -5 : (keys['ArrowRight'] ? 5 : 0);
        // FEVER中は少し速い
        if (isFever) this.vx *= 1.2;
        
        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 === 'normal' && !this.isStomping) {
            if (keys['KeyZ']) {
                this.chargeFrames++;
                if (this.chargeFrames === 1) soundManager.playCharge();
                const maxCharge = 5.0;
                if (this.scale < maxCharge) {
                    this.scale = 1.0 + (this.chargeFrames / 30); 
                    if(this.scale > maxCharge) this.scale = maxCharge;
                }
                if (this.chargeFrames === 60) soundManager.playCharge();
            } else {
                if (this.chargeFrames > 0) {
                    if (this.chargeFrames < 60) {
                        const shotSize = 8;
                        const dmg = 10 * (isFever ? 2 : 1);
                        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 * (isFever ? 2 : 1);
                                    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.playStomp();
            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.playDash();
    }

    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 mult = isFever ? 2 : 1;
            const earnedScore = (target.isBoss ? 100 : 500) * this.comboCount * mult;
            const popupText = target.isBoss ? `+${earnedScore}` : `+${this.comboCount} COMBO`;
            popups.push(new PopupText(this.x, this.y-40, popupText, isFever ? "#FF0" : "#FFF", 30));
            
            spawnParticles(target.x, target.y, COLOR.CYAN_HEX);
            
            if (target instanceof Enemy) {
                target.dead = true; 
                if(target.isBoss) soundManager.playBossDeath();
                else soundManager.playEnemyDeath();
                score += earnedScore;
            } 
            soundManager.playCombo(this.comboCount); // 音階付きコンボ音
            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.playDash();
    }

    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 * (isFever ? 2 : 1), 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.playPlayerDamage();
        if (this.hp <= 0) { this.dead = true; return true; }
        return false;
    }
}

class Enemy extends Entity {
    constructor(x, y, isBoss = false, leader = null, isFlyer = false) {
        // 飛行敵は色が違う
        const color = isBoss ? COLOR.BOSS : (isFlyer ? COLOR.FLY_ENEMY : COLOR.ENEMY);
        super(x, y, isBoss ? 60 : 15, color);
        this.isBoss = isBoss; 
        this.isFlyer = isFlyer; // 飛行タイプ
        this.maxHp = isBoss ? 300 : (isFlyer ? 10 : 30); 
        this.hp = this.maxHp;
        this.isBossOrb = false; 

        if(isBoss) { this.vx = 5; this.vy = 5; }
        else if (isFlyer) { this.vx = -3; this.vy = 0; } // 飛行敵は左へ飛ぶ

        this.leader = leader; this.followDelay = []; 
        this.shootTimer = Math.random() * 200; this.isLockedOn = false; 
        
        // 飛行敵の動き用
        this.startY = y;
        this.timeOffset = Math.random() * 100;
    }
    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 margin = 50;
            if (this.x < cameraX + margin && this.vx < 0) this.vx *= -1;
            if (this.x > cameraX + CANVAS_WIDTH - margin && this.vx > 0) this.vx *= -1;
            if (this.y < cameraY + margin && this.vy < 0) this.vy *= -1;
            if (this.y > cameraY + CANVAS_HEIGHT - margin && this.vy > 0) this.vy *= -1;
        } else if (this.isFlyer) {
            // 波状に飛ぶ
            this.x += this.vx * timeScale;
            this.y = this.startY + Math.sin((frameCount + this.timeOffset) * 0.05) * 50;
        } 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 * (isFever ? 1.5 : 1.0); // FEVER時は攻撃激化
            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 baseColor = this.isFlyer ? COLOR.FLY_ENEMY : COLOR.ENEMY;
        const blended = blendColor(baseColor, 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.playBossDeath();
            else soundManager.playEnemyDeath();
            return true; 
        } else {
            soundManager.playEnemyDamage();
        }
        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)) {
                blocks.push(new Block(x, layerIdx, x / BLOCK_SIZE));
            }
        }
    });
}

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);

    // Audio Spawn Logic
    // 低音が強い場合は「ハードブロック」を生成しやすくする
    const spawnHardBlock = bassEnergy > 200; // 閾値(要調整)
    // 高音が強い場合は「飛行敵」を生成しやすくする
    const spawnFlyer = trebleEnergy > 100;

    while (lastGeneratedX < rightEdge) {
        for (let l = minLayer; l <= maxLayer; l++) {
            // Bass Kickの時はハードブロック
            const isHard = spawnHardBlock && (Math.random() < 0.4);
            const b = new Block(lastGeneratedX, l, globalBlockIndex, isHard);
            
            if (Math.random() < 0.3) {
                clouds.push(new CloudItem(lastGeneratedX, b.baseHeight - 400));
            }

            // Treble Highの時は飛行敵
            if (spawnFlyer && Math.random() < 0.05) {
                 const flyY = b.baseHeight - 200 - Math.random() * 300;
                 enemies.push(new Enemy(lastGeneratedX, flyY, false, null, true));
            }
            
            // 通常敵
            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 updateAudioAnalysis() {
    if (!analyser) return;

    analyser.getByteFrequencyData(freqArray);
    analyser.getByteTimeDomainData(dataArray);

    // Bass (Low Frequency): index 0-10 approx
    let bSum = 0;
    for(let i=0; i<10; i++) bSum += freqArray[i];
    bassEnergy = bSum / 10;

    // Treble (High Frequency): index 100+
    let tSum = 0;
    let tCount = 0;
    for(let i=100; i<Math.min(freqArray.length, 300); i++) {
        tSum += freqArray[i]; tCount++;
    }
    trebleEnergy = tCount > 0 ? tSum / tCount : 0;

    // Overall Volume for Wave
    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;

    // Fever / Energy Logic
    audioEnergyHistory.push(currentAudioLevel);
    if(audioEnergyHistory.length > 60) audioEnergyHistory.shift();
    const avgEnergy = audioEnergyHistory.reduce((a,b)=>a+b,0) / audioEnergyHistory.length;

    // 盛り上がり判定 (平均より著しく大きい & 絶対値も大きい)
    if (currentAudioLevel > 0.4 && currentAudioLevel > avgEnergy * 1.1) {
        feverGauge += 0.5;
    } else {
        feverGauge -= 0.2;
    }
    feverGauge = Math.max(0, Math.min(FEVER_MAX, feverGauge));

    if (!isFever && feverGauge >= FEVER_THRESHOLD) {
        isFever = true;
        feverBarContainer.style.borderColor = "#FF0";
        feverText.style.display = "block";
        soundManager.playHeal(); // フィーバー開始音代わり
    } else if (isFever && feverGauge <= 0) {
        isFever = false;
        feverBarContainer.style.borderColor = "#FFF";
        feverText.style.display = "none";
    }

    feverBar.style.width = `${(feverGauge / FEVER_MAX) * 100}%`;

    // Bass Kick Shake
    if (bassEnergy > 230 && screenShake < 5) {
        screenShake = 15; // ドンッ!と揺らす
        zoomLevel = 1.05; // 瞬間ズーム
    }
}

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;
    feverGauge = 0; isFever = false;
    feverBarContainer.style.display = "block";

    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++;
    
    updateAudioAnalysis();

    if(screenShake > 0) screenShake *= 0.9;
    if(screenShake < 0.5) screenShake = 0;
    
    // Zoom復帰
    zoomLevel += (1.0 - zoomLevel) * 0.1;

    // TimeScale制御 (曲の激しさで速度変化)
    let targetTimeScale = 1.0;
    if (player.state === 'dashing' || player.state === 'returning') {
        targetTimeScale = 0.05; 
    } else {
        // 音が静かなら遅く、激しければ速く (0.5 ~ 1.5倍速)
        targetTimeScale = 0.5 + (currentAudioLevel * 2.5);
    }
    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 = isFever ? "#200020" : 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;

    // Zoom Center
    ctx.translate(CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
    ctx.scale(zoomLevel, zoomLevel);
    ctx.translate(-CANVAS_WIDTH/2, -CANVAS_HEIGHT/2);

    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 = isFever ? "#FF0" : "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 = "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();
    feverBarContainer.style.display = "none";
    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