音で作る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公開。(効果音追加)
・2026/2/5 バージョン1.9公開。(成分ごとにシンクロ)
・2026/2/6 バージョン2.0公開。(低音ブロック、左移動)

画像
ゲーム画面


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


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

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


《操作説明》


・方向キーで移動。

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

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

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

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

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

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

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

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

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

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Wave Ground 2.0</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #000;
            font-family: 'Arial Black', Impact, sans-serif;
            height: 100vh;
            width: 100vw;
            color: #fff;
            user-select: none;
        }
        #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; }
        
        #exciting-bar-container {
            display: none;
            position: absolute;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            width: 60%;
            height: 20px;
            background: #222;
            border: 2px solid #FFF;
            border-radius: 4px; 
            overflow: visible; 
        }
        #exciting-bar {
            width: 0%;
            height: 100%;
            background: linear-gradient(90deg, #FF4500, #FFD700); 
            transition: width 0.1s;
            box-shadow: 0 0 10px #FF4500;
        }
        #exciting-text {
            position: absolute;
            width: 100%;
            text-align: center;
            top: -30px;
            font-size: 24px;
            color: #FFD700;
            font-style: italic;
            text-shadow: 2px 2px 0px #F00, 0 0 10px #FF4500;
            display: none;
            letter-spacing: 3px;
        }
        #cors-warning {
            color: #ff4444;
            font-size: 12px;
            margin-top: 10px;
            display: none;
            border: 1px solid #ff4444;
            padding: 5px;
            background: rgba(50,0,0,0.5);
        }
        #fps-counter {
            position: absolute;
            bottom: 10px;
            right: 10px;
            color: #00FF00;
            font-family: monospace;
            font-size: 16px;
            background: rgba(0,0,0,0.5);
            padding: 2px 5px;
            pointer-events: none;
            z-index: 100;
        }
    </style>
</head>
<body>

<div id="ui-layer">
    <h1>Wave Ground 2.0</h1>
    <p style="font-size: 14px; color:#aaa;">- Sound Sync -</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>
    </div>
    <div id="cors-warning">
        ※注意: ローカルサーバーを使用していないため、SE(効果音)が読み込めません。<br>
        VSCodeのLive Server等を使用すると音が鳴ります。
    </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="exciting-bar-container">
    <div id="exciting-text">EXCITING MODE!!</div>
    <div id="exciting-bar"></div>
</div>
<div id="fps-counter">FPS: 60</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"
};
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;

// FPS計測用
let lastTime = performance.now();
let fps = 0;
let fpsElement = document.getElementById('fps-counter');

// Audio Analysis Variables
let currentAudioLevel = 0;
let bassEnergy = 0;
let trebleEnergy = 0;
let isExciting = false; 
let excitingGauge = 0; 
const EXCITING_THRESHOLD = 80; 
const EXCITING_MAX = 100;
let audioEnergyHistory = []; 
let bassCooldown = 0; 

// 低音ブロック制御用変数
let fallingBlockTargetX = null;
let fallingBlockCountInTarget = 0;

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 = [];
let fallingBlocks = []; 

const uiLayer = document.getElementById('ui-layer');
const excitingBarContainer = document.getElementById('exciting-bar-container');
const excitingBar = document.getElementById('exciting-bar');
const excitingText = document.getElementById('exciting-text');
const fileInput = document.getElementById('fileInput');
const startBtn = document.getElementById('startBtn');
const statusText = document.getElementById('status');
const corsWarning = document.getElementById('cors-warning');

if (window.location.protocol === 'file:') {
    corsWarning.style.display = 'block';
}

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, 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(`SE Load Failed: ${name}.mp3`);
            }
        }
    }

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

    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) {
        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 = isExciting ? 3.0 : 1.0;
        this.x -= this.speed * timeScale * speedMult; 
        if (this.x < cameraX - 100) this.reset(); 
    }
    draw(ctx, camX, camY) { 
        ctx.fillStyle = isExciting ? `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 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;
    }
}

class FallingBlock extends Entity {
    constructor(x, y, color) {
        const snappedX = Math.round(x / BLOCK_SIZE) * BLOCK_SIZE;
        super(snappedX, y, BLOCK_SIZE, {R:255, G:255, B:255}); 
        this.color = color;
        this.vy = 10; 
        this.active = true;
        this.stopped = false;
    }

    break() {
        if (!this.active) return;
        this.active = false;
        spawnBlockDebris(this.x, this.y, this.color);
        soundManager.playBreak();
    }

    update() {
        if (!this.active) return;

        if (!this.stopped) {
            this.vy += 2.0 * timeScale; 
            this.y += this.vy * timeScale;
        } else {
            if(this.groundBlock) {
                 if (this.groundBlock.destroyed && this.groundBlock instanceof Block) {
                     this.stopped = false;
                     this.groundBlock = null;
                 } else {
                     this.y = this.groundBlock instanceof Block ? (this.groundBlock.getTopY() - this.baseSize) : (this.groundBlock.y - this.baseSize);
                     this.vy = 0; 
                 }
            } else {
                this.stopped = false;
            }
        }

        if (this.y > cameraY + CANVAS_HEIGHT + 200) {
            this.active = false;
        }

        if (!this.stopped) {
            const checkRange = Math.max(20, this.vy + 10); // 高速落下対応
            for(let b of blocks) {
                if (b.destroyed) continue;
                if (Math.abs(b.x - this.x) < 5) {
                    const topY = b.getTopY();
                    if (this.y + this.baseSize >= topY - 10 && this.y < topY && this.vy > 0) {
                        this.y = topY - this.baseSize; 
                        this.vy = 0;
                        this.stopped = true;
                        this.groundBlock = b;
                        soundManager.playImpact();
                        return;
                    }
                }
            }

            for(let fb of fallingBlocks) {
                if (fb === this || !fb.stopped || !fb.active) continue;
                if (Math.abs(fb.x - this.x) < 5) {
                    if (this.y + this.baseSize >= fb.y - 10 && this.y < fb.y && this.vy > 0) {
                        this.y = fb.y - this.baseSize; 
                        this.vy = 0;
                        this.stopped = true;
                        this.groundBlock = fb;
                        return;
                    }
                }
            }
        }
    }
    draw(ctx, camX, camY) {
        if (!this.active) return;
        ctx.fillStyle = this.color;
        ctx.fillRect(this.x - camX, this.y - camY, this.baseSize, this.baseSize);
        ctx.fillStyle = "rgba(0,0,0,0.2)";
        ctx.fillRect(this.x - camX, this.y - camY + this.baseSize - 5, this.baseSize, 5);
        ctx.strokeStyle = "#FFF";
        ctx.lineWidth = 1;
        ctx.strokeRect(this.x - camX, this.y - camY, this.baseSize, this.baseSize);
    }
}

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;
        this.maxDamage = BLOCK_THICKNESS;
    }
    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;
        if (isExciting) {
            drawColor = `hsl(${(this.x/10 + frameCount*5)%360}, 70%, 50%)`;
        }

        ctx.strokeStyle = COLOR.BLOCK_OUTLINE; 
        ctx.lineWidth = 1;

        const visibleThickness = BLOCK_THICKNESS - this.damage;
        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 (!isExciting) {
                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);
        }
    }
}
function adjustBrightness(hex, percent) {
    if(hex.startsWith("hsl")) return hex;
    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 - 2000) 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 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.chainCount = 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.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; 
        
        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;

        this.checkCollisions();
        this.checkSuperJumpLanding(); 
    }

    checkCollisions() {
        // 1. 上昇破壊
        if (this.vy < 0 && 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; 
                    }
                }
            });
        }

        // 2. 雲
        if (this.vy < 0) {
             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 * (isExciting ? 2 : 1); 
                popups.push(new PopupText(this.x, this.y - 40, "HP+1", "#FFF", 20));
                soundManager.playHeal();
            }
        }

        // 3. 下降着地・ストンプ
        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 hitFallingBlocks = fallingBlocks.filter(fb =>
                fb.active && fb.stopped &&
                this.x >= fb.x - 10 && this.x <= fb.x + fb.baseSize + 10 &&
                this.y >= fb.y - 15 && this.y <= fb.y + 30
            );

            // 敵
            const hitEnemies = 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(hitFallingBlocks.length > 0) {
                hitFallingBlocks.sort((a,b) => a.y - b.y);
                candidates.push({type:'fallingBlock', obj:hitFallingBlocks[0], y:hitFallingBlocks[0].y});
            }
            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.isStomping) {
                    if (hit.type === 'block') {
                        hit.obj.damage += 2;
                        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 = 5;
                        } else {
                            this.y = hit.obj.getTopY(); 
                            this.vy = 5; 
                        }
                    
                    } else if (hit.type === 'fallingBlock') {
                        hit.obj.break();
                        this.vy = -10;
                        this.isStomping = false;
                        this.scale = 1.0;
                        this.state = 'normal';
                        score += 100;

                    } else if (hit.type === 'enemy') {
                        const enemy = hit.obj;
                        const stompDmg = isExciting ? 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;
                    this.grounded = true;
                    this.isStomping = false; 
                    
                    if (hit.type === 'block') {
                        this.vy = hit.obj.vy < 0 ? hit.obj.vy * 0.1 : 0;
                        this.groundBlock = hit.obj;
                    } else if (hit.type === 'fallingBlock') {
                        this.vy = 0;
                        this.groundBlock = hit.obj; 
                    } else {
                        this.vy = 0; 
                    }
                }
            }
        }
    }

    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;
        
        if(isExciting) {
            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);
        if (isExciting) this.vx *= 1.2;
        
        if (this.grounded) {
            if (keys['KeyS']) {
                if (this.groundBlock && this.groundBlock instanceof Block && 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.groundBlock.vy) 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 * (isExciting ? 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 * (isExciting ? 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 > player.x - 1000 && 
                    Math.abs(e.y - this.y) < 800 
                ).sort((a,b) => Math.abs(a.x - this.x) - Math.abs(b.x - this.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.animScaleX = 1.8; this.animScaleY = 0.6;
        this.chainCount = 0; // リセット
        soundManager.playDash();
    }

    updateDashing() {
        this.animScaleX = 1.8; this.animScaleY = 0.6;
        
        for(let fb of fallingBlocks) {
            if(!fb.active) continue;
            const dist = Math.hypot(this.x - fb.x, this.y - fb.y);
            if(dist < this.currentSize + fb.baseSize) {
                fb.break();
                score += 100;
            }
        }

        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 - 2000) { 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.chainCount++; 
            let earnedScore = 0;

            // スコア計算修正: 基本1点、+1ずつ加算
            if (target.isBoss) {
                 earnedScore = 100 * this.chainCount;
            } else {
                 earnedScore = this.chainCount; // 1, 2, 3...
            }

            const popupText = `+${earnedScore}`; 
            popups.push(new PopupText(this.x, this.y-40, popupText, isExciting ? "#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.chainCount); 
            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;
        
        // 画面端制限は削除したので、どこまでも行ける
        // ただし速度減衰で止まる

        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 * (isExciting ? 2 : 1), null, true); 
                    this.state = 'normal'; 
                    this.chainCount = 0;
                    this.vx = -10 * this.facing;
                    this.vy = -10;
                    screenShake = 10;
                    return;
                }
            }
        }
        
        for(let fb of fallingBlocks) {
            if(!fb.active) continue;
            const dist = Math.hypot(this.x - fb.x, this.y - fb.y);
            if(dist < this.currentSize + fb.baseSize) {
                fb.break(); 
                this.state = 'normal';
                this.chainCount = 0;
                this.vx = -10 * this.facing;
                this.vy = -10;
                score += 100;
                return;
            }
        }
        
        this.vx *= 0.95;
        if(Math.abs(this.vx) < 5) {
            this.state = 'normal';
            this.chainCount = 0;
        }
    }

    updateReturning() {
        this.animScaleX = 1.8; this.animScaleY = 0.6;
        const targetX = 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.chainCount = 0; 
            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 = 60; 
        this.blinkTimer = 60; 
        soundManager.playPlayerDamage();
        if (this.hp <= 0) { this.dead = true; return true; }
        return false;
    }
}

class Enemy extends Entity {
    constructor(x, y, isBoss = false, leader = null, 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 = isBoss ? 120 : (400 + Math.random() * 400); 
        this.isLockedOn = false; 
        
        this.startY = y;
        this.timeOffset = Math.random() * 100;

        // ボス用行動パラメータ
        this.dashTimer = 0;
        this.isDashing = false;
        this.dashCoolDown = 300;
        
        // 音連動用ジャンプタイマー
        this.beatJumpTimer = 0;
    }
    
    checkGround() {
        if (this.isFlyer || this.isBoss || this.isLockedOn) return; 

        this.grounded = false;
        let groundY = 99999;
        
        // 判定範囲をさらに強化し、突き抜けを防止
        const checkRange = Math.max(40, Math.abs(this.vy) * 2 + 20);

        // 探索範囲もプレイヤー中心から広げる
        for (let b of blocks) {
            if (b.destroyed) continue;
            // X方向の範囲も少し余裕を持たせる
            if (Math.abs(b.x - this.x) < BLOCK_SIZE/2 + this.currentSize/2 + 8) {
                const top = b.getTopY();
                // 上から侵入した場合、かつ 足元より少し上 〜 落下予測位置まで
                if (this.y + this.currentSize/2 <= top + checkRange && 
                    this.y + this.currentSize/2 >= top - 20 && 
                    this.vy >= 0) { 
                     if (top < groundY) groundY = top;
                }
            }
        }
        
        for (let fb of fallingBlocks) {
            if (!fb.active || !fb.stopped) continue;
            if (Math.abs(fb.x - this.x) < fb.baseSize/2 + this.currentSize/2 + 8) {
                 const top = fb.y;
                 if (this.y + this.currentSize/2 <= top + checkRange && 
                     this.y + this.currentSize/2 >= top - 20 && 
                     this.vy >= 0) {
                     if (top < groundY) groundY = top;
                 }
            }
        }

        if (groundY < 99999) {
            this.y = groundY - this.currentSize/2;
            this.vy = 0;
            this.grounded = true;
        }
    }

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

        super.update(); 
        
        this.checkGround();

        if (this.blinkTimer > 0) return; 

        // 音連動: ビートに合わせて小ジャンプ
        if (this.grounded && bassEnergy > 200 && this.beatJumpTimer <= 0 && !this.isBoss) {
            this.vy = -5;
            this.beatJumpTimer = 10;
        }
        if (this.beatJumpTimer > 0) this.beatJumpTimer--;

        if (this.isBoss) {
            // ボス行動AI
            if (this.isDashing) {
                this.dashTimer--;
                if (player && this.dashTimer > 10) {
                     const dy = player.y - this.y;
                     this.vy += dy * 0.005;
                }
                if (this.dashTimer <= 0) {
                    this.isDashing = false;
                    this.dashCoolDown = 300 + Math.random() * 200;
                    this.vx *= 0.5; 
                    this.vy *= 0.5;
                }
            } else {
                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;
                
                this.dashCoolDown -= 1 * timeScale;
                if (this.dashCoolDown <= 0) {
                    this.isDashing = true;
                    this.dashTimer = 60;
                    if (player) {
                        const angle = Math.atan2(player.y - this.y, player.x - this.x);
                        this.vx = Math.cos(angle) * 20; 
                        this.vy = Math.sin(angle) * 20;
                        spawnParticles(this.x, this.y, "#F00");
                        soundManager.playCharge();
                    }
                }
            }

        } 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) {
                 if (Math.random() < 0.02) this.vy = -12; 
                 if (player) {
                     const dir = player.x > this.x ? 1 : -1;
                     this.vx = dir * 2;
                 }
            }
        }

        // 削除範囲を大幅に拡大 (左に戻れるように)
        if (this.x > player.x - 3000 && this.x < player.x + 3000) {
            this.shootTimer -= 1 * timeScale * (isExciting ? 1.5 : 1.0); 
            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 ? 120 : (400 + Math.random()*400); 
            }
        }
    }
    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;
        }
        
        if (this.isBoss && this.isDashing) {
             ctx.save();
             ctx.translate(this.x - camX, this.y - camY);
             ctx.fillStyle = `rgba(255, 0, 0, ${0.3 + Math.random() * 0.4})`;
             ctx.beginPath();
             ctx.arc(0, 0, this.currentSize + 10, 0, Math.PI * 2);
             ctx.fill();
             ctx.restore();
        }

        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 = 5; 
                this.vx = Math.cos(angle) * blowSpeed;
                this.vy = 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 < player.x - 3000 || this.x > player.x + 3000) this.active = false;

        if (this.isPlayerShot && this.active) {
            for(let fb of fallingBlocks) {
                if(!fb.active) continue;
                const dist = Math.hypot(this.x - fb.x, this.y - fb.y);
                if(dist < this.size + fb.baseSize) {
                    fb.break();
                    score += 50;
                    if(!this.penetrate) 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(player.x / BLOCK_SIZE - 2000 / BLOCK_SIZE) * BLOCK_SIZE;
    const endX = startX + 4000;
    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 activeEnemies = enemies.filter(e => !e.dead && !e.isBoss && !e.isFlyer);
    if (activeEnemies.length > 0) return;

    const baseY = 500 - (layerIdx * LAYER_HEIGHT);
    const sides = [player.x + CANVAS_WIDTH + 100, player.x - CANVAS_WIDTH - 100];
    sides.forEach(x => {
        if(Math.random() < 0.9) { 
             let leader = null;
             const dir = (x > player.x) ? 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 + 500;
    const minLayer = playerLayerIndex - LAYER_KEEP_RANGE;
    const maxLayer = playerLayerIndex + LAYER_KEEP_RANGE;

    // 削除判定を緩和 (左に戻れるようにプレイヤー基準)
    const deleteDist = 3000;
    blocks = blocks.filter(b => b.layerIndex >= minLayer && b.layerIndex <= maxLayer && !b.destroyed && Math.abs(b.x - player.x) < deleteDist);
    enemies = enemies.filter(e => Math.abs(e.x - player.x) < deleteDist && !e.dead);
    clouds = clouds.filter(c => c.active && Math.abs(c.x - player.x) < deleteDist);
    fallingBlocks = fallingBlocks.filter(fb => fb.active && Math.abs(fb.x - player.x) < deleteDist);

    const spawnFlyer = trebleEnergy > 100;
    const activeGroundEnemiesCount = enemies.filter(e => !e.dead && !e.isBoss && !e.isFlyer).length;

    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 (spawnFlyer && Math.random() < 0.05) {
                 const flyY = b.baseHeight - 200 - Math.random() * 300;
                 enemies.push(new Enemy(lastGeneratedX, flyY, false, null, true));
            }
            
            // 音量が高いと敵出現率アップ
            const spawnRate = 0.04 * (1 + currentAudioLevel * 2);
            if (activeGroundEnemiesCount === 0 && Math.random() < spawnRate) { 
                 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;
        
        const bossExists = enemies.some(e => e.isBoss && !e.dead);
        if (!bossExists && 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);

    let bSum = 0;
    for(let i=0; i<10; i++) bSum += freqArray[i];
    bassEnergy = bSum / 10;

    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;

    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;

    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) {
        excitingGauge += 0.5;
    } else {
        excitingGauge -= 0.2;
    }
    excitingGauge = Math.max(0, Math.min(EXCITING_MAX, excitingGauge));

    if (!isExciting && excitingGauge >= EXCITING_THRESHOLD) {
        isExciting = true;
        excitingBarContainer.style.borderColor = "#FFD700";
        excitingText.style.display = "block";
        soundManager.playHeal(); 
    } else if (isExciting && excitingGauge <= 0) {
        isExciting = false;
        excitingBarContainer.style.borderColor = "#FFF";
        excitingText.style.display = "none";
    }

    excitingBar.style.width = `${(excitingGauge / EXCITING_MAX) * 100}%`;

    // 低音ブロック生成強化
    if (bassCooldown > 0) bassCooldown--;
    // 低音の閾値を下げ、頻度を上げる
    if (bassEnergy > 180 && bassCooldown <= 0) {
        
        if (fallingBlockTargetX === null) {
            fallingBlockTargetX = player.x + (Math.random() * 600) - 300;
            fallingBlockCountInTarget = 0;
        }

        // 音量が大きいときは複数個降らせる
        const spawnCount = 1 + Math.floor(currentAudioLevel * 3);

        for(let i=0; i<spawnCount; i++) {
            const spawnX = fallingBlockTargetX + (Math.random() * 50 - 25);
            const paletteIdx = Math.abs(playerLayerIndex) % TERRAIN_PALETTE.length;
            const color = TERRAIN_PALETTE[paletteIdx];
            fallingBlocks.push(new FallingBlock(spawnX, cameraY - 100 - (i*30), color));
            fallingBlockCountInTarget++;
        }

        if (fallingBlockCountInTarget >= 10) {
            fallingBlockTargetX = null;
        }

        bassCooldown = 15; // クールダウン短縮
        
        if (screenShake < 5) screenShake = 5;
    }
}

function initGame() {
    player = new Player();
    bullets = [], enemies = [], blocks = [], stars = [], particles = [], popups = [], clouds = [];
    fallingBlocks = [];
    score = 0; frameCount = 0; keys = {};
    globalBlockIndex = 0; lastGeneratedX = 0; nextBossScore = 4000;
    playerLayerIndex = 0;
    timeScale = 1.0;
    cameraX = 0; cameraY = 0;
    excitingGauge = 0; isExciting = false;
    fallingBlockTargetX = null;
    fallingBlockCountInTarget = 0;
    excitingBarContainer.style.display = "block";
    lastTime = performance.now(); 

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

    const now = performance.now();
    const delta = now - lastTime;
    fps = Math.round(1000 / (now - (lastTime || now - 16)));
    lastTime = now;
    if (frameCount % 10 === 0) fpsElement.innerText = `FPS: ${fps}`;
    
    updateAudioAnalysis();

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

    let targetTimeScale = 1.0;
    if (player.state === 'dashing' || player.state === 'returning') {
        targetTimeScale = 0.05; 
    } else {
        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 && Math.abs(b.x - player.x) < 2000) {
            waveOffset = getWaveOffset(b.x);
        }
        b.update(waveOffset);
    });
    
    fallingBlocks.forEach(fb => fb.update());

    if(player) {
        player.update();
    }
    enemies.forEach(e => e.update());
    clouds.forEach(c => c.update());
    bullets.forEach(b => b.update());
    bullets = bullets.filter(b => b.active);
    particles.forEach(p => p.update());
    particles = particles.filter(p => p.life > 0);
    popups.forEach(p => p.update());
    popups = popups.filter(p => p.life > 0);

    fallingBlocks.forEach(fb => {
        if (!fb.active || fb.stopped) return;
        if (player.x + player.currentSize > fb.x && player.x - player.currentSize < fb.x + fb.baseSize &&
            player.y > fb.y && player.y - player.currentSize < fb.y + fb.baseSize) {
            
            if (player.state === 'dashing') {
                fb.break();
                score += 100;
            } else if (player.grounded) {
                if (player.invincible <= 0) {
                    player.hit(1); 
                    player.vy = -10;
                    player.knockbackVx = (player.x < fb.x + fb.baseSize/2) ? -30 : 30;
                    fb.active = false; 
                    spawnBlockDebris(fb.x, fb.y, fb.color);
                    screenShake = 20;
                }
            } else {
                player.y = fb.y + fb.baseSize + player.currentSize;
                player.vy = fb.vy; 
            }
        }
    });

    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)) return;
            if (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;
    cameraX += (targetCamX - cameraX) * 0.1;
    
    const targetCamY = player.y - CANVAS_HEIGHT * 0.6;
    cameraY += (targetCamY - cameraY) * 0.1;
}

function draw() {
    ctx.fillStyle = isExciting ? "#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;

    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));
    fallingBlocks.forEach(fb => fb.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 = isExciting ? "#FFD700" : "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();
    excitingBarContainer.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