音で作る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公開。

画像
ゲーム画面


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

《操作説明》

・方向キーで移動。
・Zキーでショットを撃つ。(押しっぱなしだと、貯め撃ち)
・Xキーでジャンプ。
・下キーで急降下して敵を踏み潰し、
 地面を突き破って下の階層へ行ける。

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

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

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

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

・地面が跳ね上がった瞬間に上キーを押し続けると
 ハイジャンプして、上の階層へ行ける。

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Wave Ground 1.6</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #000;
            font-family: 'Arial Black', Impact, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
            color: #fff;
        }
        #ui-layer {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            text-align: center;
            z-index: 20;
            background: rgba(0, 0, 0, 0.85);
            padding: 40px;
            border-radius: 10px;
            border: 2px solid #00BFFF;
            min-width: 450px;
            box-shadow: 0 0 20px rgba(0, 191, 255, 0.5);
        }
        canvas {
            display: none;
            box-shadow: 0 0 30px rgba(0, 191, 255, 0.3);
            background-color: #000; 
        }
        .btn {
            padding: 15px 40px;
            font-size: 20px;
            cursor: pointer;
            background: linear-gradient(to bottom, #00BFFF, #0080FF);
            color: white;
            border: none;
            border-radius: 5px;
            margin-top: 15px;
            font-weight: bold;
            text-shadow: 1px 1px 2px black;
        }
        .btn:disabled { background: #555; color: #999; cursor: not-allowed; }
        .key-instruction { font-size: 14px; color: #ccc; margin-top: 10px; line-height: 1.6; }
        .highlight { color: #FF0; font-weight: bold; }
        h1 { margin: 0 0 10px 0; color: #00BFFF; text-shadow: 0 0 10px #00BFFF; }
        .tag { display:inline-block; background:#333; padding:2px 6px; border-radius:4px; font-size:0.9em; margin:2px; }
    </style>
</head>
<body>

<div id="ui-layer">
    <h1>Wave Ground 2.7</h1>
    <p style="font-size: 14px; color:#aaa;">Wave Ground</p>
    <div class="key-instruction">
        <span class="tag">移動: ←→</span> <span class="tag">通常ジャンプ: X</span><br>
        <span class="tag">ショット: Z (長押し0.5秒でチャージ)</span> <span class="tag">スローダッシュ: A</span><br>
        <span class="tag">ポーズ: ESC</span><br><br>
        <span class="highlight">【アクション】</span><br>
        ハイジャンプ: 地面隆起時に <span class="tag" style="color:#0FF">↑ 長押し</span> (天井を突き破る)<br>
        踏み潰し: 空中で <span class="tag" style="color:#F0F">↓</span> (床を破壊)<br><br>
        <span style="font-size:0.9em; color:#aaa;">スネーク隊列の敵に注意!ボスは高耐久です。</span>
    </div>
    <br>
    <input type="file" id="fileInput" accept="audio/*" style="color:white;">
    <br><br>
    <button id="startBtn" class="btn" disabled>Loading...</button>
    <p id="status">音楽ファイルを選択してください</p>
</div>

<canvas id="gameCanvas"></canvas>

<script>
// === 設定・定数 ===
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const BLOCK_SIZE = 25; 
const BLOCK_THICKNESS = 10; 
const MAX_HP = 20;
const LAYER_HEIGHT = 1800; 
const LAYER_KEEP_RANGE = 3; 

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

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

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

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

const uiLayer = document.getElementById('ui-layer');
const fileInput = document.getElementById('fileInput');
const startBtn = document.getElementById('startBtn');
const statusText = document.getElementById('status');

canvas = document.getElementById('gameCanvas');
ctx = canvas.getContext('2d');
canvas.width = CANVAS_WIDTH; canvas.height = CANVAS_HEIGHT;

// オーディオ系
let audioBuffer, analyser, dataArray, soundSource;
const soundManager = new class {
    constructor() { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); }
    async loadFile(file) {
        if(this.ctx.state === 'suspended') await this.ctx.resume();
        return await this.ctx.decodeAudioData(await file.arrayBuffer());
    }
    playBGM(buffer) {
        this.stopBGM();
        try {
            soundSource = this.ctx.createBufferSource();
            soundSource.buffer = buffer;
            soundSource.loop = false; 
            soundSource.onended = () => { if (gameState === 'playing') endGame(true); };
            analyser = this.ctx.createAnalyser();
            analyser.fftSize = 2048;
            dataArray = new Uint8Array(analyser.frequencyBinCount);
            soundSource.connect(analyser);
            analyser.connect(this.ctx.destination);
            soundSource.start(0);
        } catch(e) { console.error("BGM Play Error:", e); }
    }
    stopBGM() { if (soundSource) { try { soundSource.stop(); } catch(e){} soundSource = null; } }
    suspend() { if(this.ctx.state === 'running') this.ctx.suspend(); }
    resume() { if(this.ctx.state === 'suspended') this.ctx.resume(); }

    playOsc(freq, type, dur, vol, slideTo=null) {
        if(this.ctx.state === 'suspended') return;
        const o = this.ctx.createOscillator();
        const g = this.ctx.createGain();
        o.type = type; o.frequency.value = freq;
        g.gain.setValueAtTime(vol, this.ctx.currentTime);
        g.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
        if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, this.ctx.currentTime + dur);
        o.connect(g); g.connect(this.ctx.destination);
        o.start(); o.stop(this.ctx.currentTime + dur);
    }
    playShot() { this.playOsc(1200, 'square', 0.1, 0.1); }
    playChargeShot() { this.playOsc(600, 'sawtooth', 0.3, 0.2, 100); }
    playJump() { this.playOsc(300, 'triangle', 0.15, 0.2, 600); }
    playSuperJump() { this.playOsc(800, 'square', 0.3, 0.3, 1500); } 
    playBoost() { this.playOsc(400, 'sawtooth', 0.1, 0.1, 800); } 
    playStomp() { this.playOsc(150, 'square', 0.3, 0.3, 50); } 
    playImpact() { this.playOsc(100, 'sawtooth', 0.4, 0.5, 10); }
    // 破壊音:短く切れの良い音に
    playBreak() { this.playOsc(100, 'noise', 0.05, 0.3); } 
    playEnemyHit() { this.playOsc(800, 'sawtooth', 0.1, 0.3, 50); } 
    playCrit() { this.playOsc(1500, 'square', 0.15, 0.15, 2000); }
    playCombo() { this.playOsc(800, 'sine', 0.1, 0.2, 1600); } 
    playItemGet() { this.playOsc(1200, 'sine', 0.2, 0.2, 2000); } 
    playClear() { this.playOsc(523, 'sine', 0.1, 0.1); setTimeout(()=>this.playOsc(783, 'sine', 0.8, 0.1), 200); }
    playDamage() { this.playOsc(100, 'sawtooth', 0.3, 0.3, 50); }
}();

const rgbToHex = (c) => `rgb(${Math.floor(c.R)},${Math.floor(c.G)},${Math.floor(c.B)})`;
const blendColor = (t, s, w) => ({R:t.R+(s.R-t.R)*w, G:t.G+(s.G-t.G)*w, B:t.B+(s.B-t.B)*w});

// === クラス定義 ===

class Star {
    constructor() { this.reset(true); }
    reset(randomX = false) {
        const startX = cameraX;
        const startY = cameraY;
        this.x = randomX ? startX + Math.random() * CANVAS_WIDTH : startX + CANVAS_WIDTH;
        this.y = startY + (Math.random() * CANVAS_HEIGHT * 3) - CANVAS_HEIGHT * 1.5; 
        this.speed = 2 + Math.random() * 5;
        this.size = 1 + Math.random() * 2;
    }
    update() { 
        this.x -= this.speed * timeScale; 
        if (this.x < cameraX - 100) this.reset(); 
    }
    draw(ctx, camX, camY) { 
        ctx.fillStyle = "#FFF"; 
        ctx.fillRect(this.x - camX, this.y - camY, this.size, this.size); 
    }
}

class Particle {
    constructor(x, y, color, isFirework = false, isSpark = false) {
        this.x = x; this.y = y;
        this.color = color || COLOR.CYAN_HEX;
        this.isFirework = isFirework;
        this.isSpark = isSpark;
        const angle = Math.random() * Math.PI * 2;
        const speed = Math.random() * (isFirework ? 10 : (isSpark ? 12 : 5)) + 2;
        this.vx = Math.cos(angle) * speed;
        this.vy = Math.sin(angle) * speed;
        if(isSpark && this.vy > 0) this.vy *= -0.5;
        this.life = isFirework ? 2.0 : (isSpark ? 0.5 : 1.0);
        this.decay = Math.random() * 0.02 + (isSpark ? 0.05 : 0.03);
        if(isFirework) this.vy -= 3;
    }
    update() {
        this.x += this.vx * timeScale; 
        this.y += this.vy * timeScale;
        this.vy += (this.isFirework ? 0.1 : 0.2) * 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, 4, 4);
        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<5; i++) particles.push(new Particle(x, y, color, false, true)); }
function spawnSparks(x, y) { for(let i=0; i<15; i++) particles.push(new Particle(x, y, COLOR.SPARK, false, true)); }
function spawnFirework(x, y) {
    const colors = ["#F00", "#0F0", "#00F", "#FF0", "#F0F", "#0FF"];
    const c = colors[Math.floor(Math.random()*colors.length)];
    for(let i=0; i<50; i++) particles.push(new Particle(x, y, c, true));
}

class PopupText {
    constructor(x, y, text, color="#FFF", size=20) {
        this.x = x; this.y = y; this.text = text; this.color = color; this.size = size; this.life = 40;
    }
    update() { this.y -= 1 * timeScale; this.life -= 1 * timeScale; }
    draw(ctx, camX, camY) {
        if(this.life<=0) return;
        ctx.fillStyle = this.color; ctx.font = `bold ${this.size}px Arial`;
        ctx.fillText(this.text, this.x - camX, this.y - camY);
    }
}

class Block {
    constructor(x, layerIndex, index) {
        this.x = x; 
        this.layerIndex = layerIndex;
        this.baseHeight = 500 - (layerIndex * LAYER_HEIGHT); 
        this.y = this.baseHeight; 
        this.vy = 0;
        const paletteIdx = Math.abs(layerIndex) % TERRAIN_PALETTE.length;
        this.color = TERRAIN_PALETTE[paletteIdx];
        this.destroyed = false; 
    }
    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;

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

class CloudItem {
    constructor(x, y) {
        this.x = x; this.y = y; this.active = true;
        this.parts = [{ox:0, oy:0, r:15}, {ox:-10, oy:5, r:12}, {ox:10, oy:5, r:12}];
    }
    update() {
        if(this.x < cameraX - 100) this.active = false;
        if(player && this.active) {
            const dist = Math.hypot(player.x - this.x, player.y - this.y);
            if (dist < 40) {
                this.active = false; player.heal();
                soundManager.playItemGet();
                spawnParticles(this.x, this.y, "#FFF");
                popups.push(new PopupText(player.x, player.y - 40, "HP +1", "#FFF", 20));
            }
        }
    }
    draw(ctx, camX, 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;
    }
    draw(ctx, camX, camY) {
        if (this.dead) return;
        ctx.fillStyle = rgbToHex(this.rgb);
        ctx.beginPath();
        ctx.arc(this.x - camX, this.y - this.currentSize/2 - camY, this.currentSize, Math.PI, 0);
        ctx.fillRect(this.x - camX - this.currentSize, this.y - this.currentSize/2 - camY, this.currentSize*2, this.currentSize/2);
        ctx.fill();
    }
    update() {
        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;
                // X軸判定
                if (checkX >= b.x && checkX < b.x + BLOCK_SIZE) {
                    // Y軸判定(ブロックの下から厚み全体)
                    if (checkY >= b.y && checkY <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS) + 20) {
                        b.destroyed = true;
                        spawnBlockDebris(b.x + BLOCK_SIZE/2, b.y + BLOCK_SIZE/2, b.color);
                        soundManager.playBreak();
                    }
                }
            });
        }

        // 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.y - 15 && this.y <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS)
            );

            if (hitBlocks.length > 0) {
                // 一番上(Yが小さい)のブロックを取得
                hitBlocks.sort((a,b) => a.y - b.y); 
                const block = hitBlocks[0];

                if (this instanceof Player && this.isStomping) {
                    // ストンプ時:触れたブロックのみ破壊して跳ね返る
                    block.destroyed = true; 
                    spawnBlockDebris(block.x + BLOCK_SIZE/2, block.y, block.color);
                    soundManager.playBreak();
                    
                    this.isStomping = false;
                    this.vy = -12; // 破壊後に強めにバウンドして落下防止
                    this.scale = 1.0;
                    popups.push(new PopupText(this.x, this.y, "CRASH!", "#F00", 25));

                } else {
                    // 通常着地
                    this.y = block.y;
                    // 反発力を半分に抑制 (0.5 -> 0.1)
                    this.vy = block.vy < 0 ? block.vy * 0.1 : 0; 
                    this.grounded = true;
                    this.groundBlock = block;
                }
            }
        }
    }
}

class Player extends Entity {
    constructor() {
        super(100, 300, 10, COLOR.PLAYER);
        this.maxHp = MAX_HP; this.hp = 20; 
        this.invincible = 0; this.scale = 1.0; this.isStomping = false;
        this.state = 'normal'; 
        this.dashTargets = []; this.currentDashIndex = 0; this.dashOrigin = {x:0, y:0};
        this.comboCount = 0;
        this.trail = [];
        this.boostTimer = 0; 
        this.chargeFrames = 0; // 溜め時間
    }
    update() {
        if (this.dead && gameState === 'playing') endGame(false);
        if (this.invincible > 0) this.invincible--;

        if (this.state === 'normal' || this.state === 'superJumping') this.updateNormal();
        else if (this.state === 'dashing') this.updateDashing();
        else if (this.state === 'returning') this.updateReturning();
        
        this.x = Math.max(cameraX, this.x); 
        this.currentSize = this.baseSize * this.scale;

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

        if (this.state === 'dashing' || 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);
        }
    }

    draw(ctx, camX, camY) {
        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 ctx.fillStyle = `rgb(0, 0, ${val})`; 
            ctx.beginPath();
            ctx.arc(t.x - camX, t.y - t.size/2 - camY, t.size, Math.PI, 0);
            ctx.fillRect(t.x - camX - t.size, t.y - t.size/2 - camY, t.size*2, t.size/2);
            ctx.fill();
        }
        ctx.globalAlpha = 1.0;
        super.draw(ctx, camX, camY);
    }

    updateNormal() {
        this.vx = keys['ArrowLeft'] ? -5 : (keys['ArrowRight'] ? 5 : 0);
        
        // ジャンプ・ハイジャンプ
        if (this.grounded) {
            if (keys['KeyX']) {
                this.vy = -16; 
                if (this.groundBlock) this.vy += this.groundBlock.vy * 0.1; // 反発抑制
                soundManager.playJump();
                this.grounded = false;
                this.state = 'normal';
            } else if (keys['ArrowUp']) {
                if (this.groundBlock && this.groundBlock.vy < -2.0) {
                    this.vy = -20; 
                    soundManager.playSuperJump();
                    this.grounded = false;
                    this.state = 'superJumping';
                    this.boostTimer = 60; 
                    spawnParticles(this.x, this.y, "#FF0");
                }
            }
        }

        if (this.state === 'superJumping') {
            if (keys['ArrowUp'] && this.boostTimer > 0) {
                this.vy -= 1.8; 
                this.boostTimer--;
                if (frameCount % 4 === 0) {
                    spawnParticles(this.x, this.y + 10, "#FFF");
                    soundManager.playBoost();
                }
            } else {
                this.state = 'normal';
            }
        }
        
        // チャージショット処理
        if (keys['KeyZ']) {
            this.chargeFrames++;
            // 溜め演出:scaleを振動させる
            if (this.chargeFrames > 30) {
                this.scale = 1.5 + Math.sin(this.chargeFrames * 0.5) * 0.2;
                if(this.chargeFrames === 31) soundManager.playChargeShot(); // チャージ完了音的な
            } else {
                this.scale = 1.0 + (this.chargeFrames / 60);
            }
        } else {
            // 発射
            if (this.chargeFrames > 0) {
                let isCharge = (this.chargeFrames > 30); // 0.5秒以上
                
                // コスト計算などは今回は省略し撃ち放題にするか、HP消費を維持するか
                // HP消費維持なら:
                let cost = isCharge ? 1 : 0;

                if (this.hp > cost) {
                    this.hp -= cost;
                    // チャージならサイズ4倍、通常なら1倍
                    const shotSize = isCharge ? 40 : 10; 
                    const dmg = isCharge ? 50 : 10;
                    
                    bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 12, 0, true, this.rgb, dmg, shotSize));
                    if (isCharge) {
                        soundManager.playChargeShot(); 
                        screenShake = 10;
                    } else {
                        soundManager.playShot();
                    }
                }
            }
            this.chargeFrames = 0;
            this.scale = 1.0;
        }

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

        if (keys['ArrowDown'] && !this.grounded && !this.isStomping) {
            soundManager.playStomp();
            this.scale = 5.0; 
            this.vy = 15; 
            this.isStomping = true;
            this.state = 'normal';
        } else if (this.grounded && this.isStomping) {
            screenShake = 15; spawnSparks(this.x, this.y + this.currentSize/2); soundManager.playImpact();
            this.scale = 1.0; this.isStomping = false;
        }
        super.update();
    }

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

    updateDashing() {
        if (this.currentDashIndex >= this.dashTargets.length) { this.state = 'returning'; return; }
        const target = this.dashTargets[this.currentDashIndex];
        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(); this.comboCount++;
            const multiplier = Math.pow(2, this.comboCount - 1); 
            popups.push(new PopupText(this.x, this.y-40, `x${multiplier}`, "#0FF", 30));
            spawnParticles(target.x, target.y, COLOR.CYAN_HEX);
            
            if (target instanceof Enemy) {
                target.dead = true; 
                score += (target.isBoss ? 5000 : 500) * multiplier;
            } 
            soundManager.playCombo(); this.currentDashIndex++; 
        } else {
            const speed = 80 * timeScale; 
            this.x += (dx / dist) * speed; 
            this.y += (dy / dist) * speed;
        }
    }

    updateReturning() {
        const targetX = Math.max(cameraX + 20, this.dashOrigin.x);
        const targetY = this.dashOrigin.y;
        const dx = targetX - this.x; const dy = targetY - this.y;
        const dist = Math.hypot(dx, dy);
        if (dist < 30) {
            this.x = targetX; this.y = targetY; this.state = 'normal'; this.invincible = 60; this.vx = 0; this.vy = 0; this.trail = [];
        } else {
            const speed = 60 * timeScale;
            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 -= 1; this.invincible = 60; soundManager.playDamage();
        if (this.hp <= 0) { this.dead = true; return true; }
        return false;
    }
}

class Enemy extends Entity {
    constructor(x, y, isBoss = false, leader = null) {
        super(x, y, isBoss ? 60 : 15, isBoss ? COLOR.BOSS : COLOR.ENEMY);
        this.isBoss = isBoss; 
        if(isBoss) this.hp = 600; // ボスHP強化 (10倍)
        this.leader = leader; this.followDelay = []; 
        this.shootTimer = Math.random() * 200; this.isLockedOn = false; 
    }
    update() {
        if (this.dead) return;
        
        if (this.isLockedOn) {
            if(player) {
                const dist = Math.hypot(player.x - this.x, player.y - this.y);
                if(dist < player.currentSize + this.currentSize + 20) {
                    this.dead = true; player.heal(); score += 100;
                    spawnParticles(this.x, this.y, COLOR.CYAN_HEX); soundManager.playItemGet();
                }
            }
            return;
        }

        if (this.leader && !this.leader.dead && !this.leader.isLockedOn) {
            this.leader.followDelay.push({x: this.leader.x, y: this.leader.y});
            if (this.leader.followDelay.length > 8) { // 遅延を調整
                const pos = this.leader.followDelay.shift();
                this.x = pos.x; 
                this.y = pos.y;
            }
        } else {
            // リーダーがいない(先頭)またはリーダーが死んだ場合
            super.update();
            if (!this.isBoss && this.grounded && Math.random()<0.02) this.vy = -12;
        }

        if (this.x > cameraX - 100 && this.x < cameraX + CANVAS_WIDTH + 100) {
            this.shootTimer -= 1 * timeScale;
            if (this.shootTimer <= 0) {
                const dir = player.x < this.x ? -1 : 1;
                bullets.push(new Bullet(this.x, this.y - 10, 6 * dir, 0, false, this.rgb, this.hp, 5));
                soundManager.playShot();
                this.shootTimer = this.isBoss ? 60 : 200 + Math.random()*200; 
            }
        }
    }
    draw(ctx, camX, camY) {
        if(this.dead) return;
        if (this.isLockedOn) {
            ctx.fillStyle = COLOR.CYAN_HEX; ctx.shadowBlur = 15; ctx.shadowColor = COLOR.CYAN_HEX;
            ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.currentSize, 0, Math.PI*2); ctx.fill();
            ctx.shadowBlur = 0; ctx.strokeStyle = COLOR.LOCKON; ctx.lineWidth = 3;
            ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.currentSize + 5, 0, Math.PI*2); ctx.stroke();
            return;
        }
        super.draw(ctx, camX, camY);
    }
    hit(damage, shooterRgb, isCharge) {
        if (this.isLockedOn) return false; 
        this.hp -= damage; this.rgb = blendColor(this.rgb, COLOR.CYAN, 0.4); 
        if(isCharge) soundManager.playEnemyHit(); else soundManager.playEnemyHit();
        if (this.hp <= 0) { this.hp = 0; this.isLockedOn = true; return true; }
        return false;
    }
}

class Bullet {
    constructor(x, y, vx, vy, isPlayerShot, rgb, damage, size) {
        this.x = x; this.y = y; this.vx = vx; this.vy = vy;
        this.isPlayerShot = isPlayerShot;
        this.rgb = {...rgb}; this.damage = damage; this.size = size; this.active = true;
    }
    update() {
        this.x += this.vx * timeScale; 
        this.y += this.vy * timeScale;
        if (this.x < cameraX - 100 || this.x > cameraX + CANVAS_WIDTH + 100) this.active = false;
    }
    draw(ctx, camX, camY) {
        ctx.fillStyle = rgbToHex(this.rgb);
        ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.size, 0, Math.PI * 2); ctx.fill();
        if(this.size > 8) { ctx.strokeStyle = "#FFF"; ctx.lineWidth = 2; ctx.stroke(); }
    }
}

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

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

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

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

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

function spawnEnemiesForLayer(layerIdx) {
    const baseY = 500 - (layerIdx * LAYER_HEIGHT);
    const sides = [cameraX + CANVAS_WIDTH + 100, cameraX - 100];
    
    // スネーク敵の生成 (5匹セット)
    sides.forEach(x => {
        if(Math.random() < 0.6) { 
             let leader = null;
             // 5匹生成してリーダーに追従させる
             for(let i=0; i<5; i++) {
                 // 少しずつ位置をずらす
                 let ex = x + (i * 40 * (x > cameraX ? 1 : -1));
                 let e = new Enemy(ex, baseY, false, leader);
                 enemies.push(e);
                 leader = e;
             }
        }
    });
    clouds.push(new CloudItem(cameraX + CANVAS_WIDTH + 50, baseY - 400));
}

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

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

    while (lastGeneratedX < rightEdge) {
        for (let l = minLayer; l <= maxLayer; l++) {
            const b = new Block(lastGeneratedX, l, globalBlockIndex);
            
            if (Math.random() < 0.3) {
                clouds.push(new CloudItem(lastGeneratedX, b.baseHeight - 400));
            }
            if (Math.random() < 0.01) {
                 // 通常生成時もスネーク
                 let leader = null;
                 for(let k=0; k<5; k++) {
                     let e = new Enemy(lastGeneratedX + (k * 40), b.baseHeight, false, leader);
                     enemies.push(e);
                     leader = e;
                 }
            }
            blocks.push(b);
        }

        globalBlockIndex++;
        lastGeneratedX += BLOCK_SIZE;

        if (lastGeneratedX > nextBossScore) {
             const baseBossY = 500 - (playerLayerIndex * LAYER_HEIGHT);
             enemies.push(new Enemy(lastGeneratedX + 200, baseBossY, true));
             nextBossScore += 4000;
        }
    }
}

function initGame() {
    try {
        player = new Player();
        bullets = [], enemies = [], blocks = [], stars = [], particles = [], popups = [], clouds = [];
        score = 0; frameCount = 0; keys = {};
        globalBlockIndex = 0; lastGeneratedX = 0; nextBossScore = 4000;
        playerLayerIndex = 0;
        timeScale = 1.0;
        cameraX = 0;
        cameraY = 0;

        for(let i=0; i<100; i++) stars.push(new Star());
        updateLevelGeneration();
        gameState = 'playing';
        uiLayer.style.display = 'none'; canvas.style.display = 'block';
        soundManager.playBGM(audioBuffer);
        if (animationFrameId) cancelAnimationFrame(animationFrameId);
        gameLoop();
    } catch(e) {
        console.error("Init Game Error:", e);
        alert("ゲーム開始時にエラーが発生しました。コンソールを確認してください。");
    }
}

function update() {
    if (gameState !== 'playing') return;
    frameCount++;
    if (analyser) {
        analyser.getByteTimeDomainData(dataArray);
        let sum = 0; for(let i=0; i<dataArray.length; i+=10) sum += Math.abs(dataArray[i] - 128);
        currentAudioLevel = sum / (dataArray.length / 10) / 128.0;
    }

    if(screenShake > 0) screenShake *= 0.9;
    if(screenShake < 0.5) screenShake = 0;

    let targetTimeScale = 1.0;
    if (player.state === 'dashing' || player.state === 'returning') {
        targetTimeScale = 0.2; 
    }
    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();
    enemies.forEach(e => e.update());
    clouds.forEach(c => c.update());
    bullets.forEach(b => b.update());
    bullets = bullets.filter(b => b.active);
    particles.forEach(p => p.update());
    particles = particles.filter(p => p.life > 0);
    popups.forEach(p => p.update());
    popups = popups.filter(p => p.life > 0);

    bullets.forEach(b => {
        if (!b.active) return;
        const targets = b.isPlayerShot ? enemies : [player];
        targets.forEach(t => {
            if (!b.active || t.dead || (t === player && player.invincible > 0)) return;
            const dist = Math.hypot(b.x - t.x, b.y - (t.y - t.currentSize/2)); 
            if (dist < t.currentSize + b.size) {
                b.active = false;
                if (t === player) {
                    t.hit(1); 
                } else {
                    let dmg = b.damage;
                    if (currentAudioLevel > 0.15) { dmg *= 2; soundManager.playCrit(); popups.push(new PopupText(t.x, t.y-20,"CRITICAL!","#FF0")); }
                    const isCharge = b.size > 20;
                    t.hit(dmg, b.rgb, isCharge);
                }
            }
        });
    });

    if(player.state === 'normal') {
        enemies.forEach(e => {
            if(e.dead || e.isLockedOn || player.dead) return;
            const dist = Math.hypot(player.x - e.x, (player.y - player.currentSize/2) - (e.y - e.currentSize/2));
            if(dist < player.currentSize + e.currentSize) {
                if (player.isStomping && player.y < e.y + e.currentSize) {
                    e.hit(9999, player.rgb, true);
                    spawnParticles(e.x, e.y, COLOR.CYAN);
                } else if (player.invincible <= 0) {
                    player.hit(1); 
                }
            }
        });
    }

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

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

function draw() {
    ctx.fillStyle = COLOR.BG; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    ctx.save();
    
    const baseSway = Math.sin(frameCount * 0.005) * 5; 
    const volumeKick = currentAudioLevel * 20;
    const kickDir = Math.sin(frameCount * 0.1) > 0 ? 1 : -1;
    const angle = (baseSway + (volumeKick * kickDir)) * (Math.PI / 180); 
    const shakeX = (Math.random() - 0.5) * screenShake;
    const shakeY = (Math.random() - 0.5) * screenShake;

    ctx.translate(CANVAS_WIDTH/2 + shakeX, CANVAS_HEIGHT/2 + shakeY); 
    ctx.rotate(angle); 
    ctx.translate(-CANVAS_WIDTH/2, -CANVAS_HEIGHT/2);

    stars.forEach(s => s.draw(ctx, cameraX, 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 <= 1) {
        ctx.fillStyle = "red"; ctx.font = "bold 14px Arial";
        ctx.fillText("CRITICAL CONDITION!", uiX, uiY + 32);
    }

    ctx.fillStyle = "white";
    ctx.textAlign = "right"; ctx.font = "bold 24px Arial";
    ctx.fillText(`SCORE: ${score}`, CANVAS_WIDTH - 20, 45);
    
    ctx.font = "bold 18px Arial";
    const depthText = playerLayerIndex >= 0 ? `ALTITUDE: ${playerLayerIndex}` : `DEPTH: ${Math.abs(playerLayerIndex)}`;
    ctx.fillText(depthText, CANVAS_WIDTH - 200, 45);
}

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

function drawEndScreen() {
    ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0,0,CANVAS_WIDTH,CANVAS_HEIGHT);
    if (gameState === 'gameclear') {
        if(Math.random() < 0.1) spawnFirework(cameraX + Math.random()*CANVAS_WIDTH, Math.random()*CANVAS_HEIGHT/2);
        ctx.fillStyle = "white"; ctx.textAlign = "center";
        ctx.font = "bold 80px Arial Black";
        ctx.fillText("GAME CLEAR!!", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
    } else {
        ctx.fillStyle = "white"; ctx.textAlign = "center";
        ctx.font = "bold 80px Arial Black";
        ctx.fillText("GAME OVER", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
    }
    ctx.font = "bold 40px Arial";
    ctx.fillText(`Final Score: ${score}`, CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 100);
    ctx.font = "bold 20px Arial";
    ctx.fillText(`Final Layer: ${playerLayerIndex}`, CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 140);
    ctx.font = "20px Arial";
    ctx.fillText("Restarting...", CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 190);
}

function endGame(win) {
    if(gameState !== 'playing') return;
    gameState = win ? 'gameclear' : 'gameover';
    soundManager.stopBGM();
    if(win) soundManager.playClear();
    setTimeout(initGame, 5000); 
}

function gameLoop() { try { update(); draw(); } catch(e){ console.error(e); } animationFrameId = requestAnimationFrame(gameLoop); }

fileInput.addEventListener('change', async (e) => {
    if (!e.target.files.length) return;
    statusText.innerText = "読み込み中..."; startBtn.disabled = true;
    try {
        audioBuffer = await soundManager.loadFile(e.target.files[0]);
        statusText.innerText = "準備完了!"; startBtn.disabled = false; startBtn.innerText = "GAME START";
    } catch (e) { console.error(e); statusText.innerText = "エラー"; }
});

startBtn.addEventListener('click', async () => {
    if (!audioBuffer) return;
    if (soundManager.ctx.state === 'suspended') {
        try { await soundManager.ctx.resume(); } catch(e) { console.error("Audio resume failed", e); }
    }
    initGame();
});

window.addEventListener('keydown', e => {
    if (e.code === 'Escape') {
        if (gameState === 'playing') {
            gameState = 'paused';
            soundManager.suspend();
        } else if (gameState === 'paused') {
            gameState = 'playing';
            soundManager.resume();
        }
    }
    keys[e.code] = true;
});
window.addEventListener('keyup', e => keys[e.code] = false);
</script>
</body>
</html>

《似ているものコーナー》


・なんか見た覚えがあったんですが、
 例によってAIくんに聞いてみたところ、
 これが一番似ているらしいです。↓

「ビブリボン vib-ribbon」(1999)SONY

・プレイステーションに好きな音楽CDを入れると、
 その曲に合わせてステージ(白いライン)が生成される。

・障害物を避けて進むタイプのゲーム。

「Audiosurf」 (2008)

・音声ファイルを読み込んで、
 発音タイミングの位置にアイテムや障害物を配置し、
 当たったり避けたりして進むゲーム。

・波形によってコースが起伏したりする。

「Melody's Escape」(2016)

・これも音声ファイルを読み込んでステージを作るもの。

Beat Hazard (2009)

・これも音声ファイルから作るタイプですが、
 全方位シューティングということで、
 敵の出現タイミングとかだと思いますが、
 あまり音と連動してる感じはしないですね。

・あとは、手持ちの音声ファイルを指定できないものでいえば、
 音声データとステージがリアルタイムに連動するものは
 いくつかあるみたいですね。↓

「Sound Shapes」 (2012)

・足場や敵、コインなどがすべて「楽器」になっており、
 ステージを進めることで曲を演奏していく。

「140」(2021)

・登場するほぼ全てのギミックが
 音楽のBPM140の全音符4つ分リズムで動き、
 ゲーム内の音楽と連動するように動くそうです。

・ノイズブロックに当たると、最初からやり直し。


「Geometry Dash」(2013)


技術的な「祖先」

  • Web Audio API のデモ (Chrome Experimentsなど)

    • 2010年代中盤、ブラウザで高度な音声処理ができるようになった頃、今回のように「波形(スペクトラム)を可視化して、その上を走る」という技術デモがWeb上にいくつか作られました。


《このゲームの独自性》


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

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

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

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

コメント

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

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