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

【更新履歴】

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


画像

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

《操作説明》

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

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

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Wave Ground 1.2</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #000;
            font-family: 'Arial Black', Impact, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
            color: #fff;
        }
        #ui-layer {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            text-align: center;
            z-index: 20;
            background: rgba(0, 0, 0, 0.8);
            padding: 40px;
            border-radius: 10px;
            border: 2px solid #555;
            min-width: 320px;
        }
        canvas {
            display: none;
            box-shadow: 0 0 30px rgba(0, 191, 255, 0.3);
            background-color: #000; 
        }
        .btn {
            padding: 10px 30px;
            font-size: 18px;
            cursor: pointer;
            background: #00BFFF;
            color: white;
            border: none;
            border-radius: 5px;
            margin-top: 10px;
            font-weight: bold;
        }
        .btn:disabled { background: #555; color: #999; cursor: not-allowed; }
        .key-instruction { font-size: 14px; color: #ccc; margin-top: 5px; }
    </style>
</head>
<body>

<div id="ui-layer">
    <h1>Wave Ground</h1>
    <p>音楽ファイルを選択してゲームスタート</p>
    <div class="key-instruction">
        移動: ←→ | ジャンプ: X<br>
        ショット: Z (長押しで巨大化チャージ)<br>
        急降下攻撃: 空中で ↓
    </div>
    <br>
    <input type="file" id="fileInput" accept="audio/*" style="color:white;">
    <br><br>
    <button id="startBtn" class="btn" disabled>Loading...</button>
    <p id="status">ファイルを選択してください</p>
</div>

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

<script>
// === 設定・定数 ===
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const WORLD_WIDTH = CANVAS_WIDTH * 5;
const BLOCK_SIZE = 20;

const COLOR = {
    BG: "#000000",
    BLOCK_OUTLINE: "#333",
    PLAYER: {R:0, G:0, B:255},
    ENEMY:  {R:128, G:0, B:128},
    BOSS:   {R:255, G:0, B:255},
    CYAN:   {R:0, G:255, B:255}, // ヒット時の変化色&パーティクル
    CHARGE_BG: "#000080", CHARGE_FG: "#00BFFF"
};

// 地面のカラーパレット(20ブロックごとに切り替え)
const TERRAIN_PALETTE = [
    "#8B4513", // 茶
    "#2E8B57", // 緑(シーグリーン)
    "#4682B4", // 青(スチールブルー)
    "#DAA520", // 金(ゴールデンロッド)
    "#800000"  // 濃い赤(マルーン)
];

// === グローバル変数 ===
let canvas, ctx;
let gameState = 'loading'; 
let gameResultText = '';
let cameraX = 0;
let keys = {};
let score = 0;
let frameCount = 0;
let animationFrameId;
let currentAudioLevel = 0; // 現在の音量レベル(0.0~1.0)

// エンティティ管理
let player = null;
let enemies = [];
let bullets = [];
let blocks = [];
let stars = [];
let particles = [];
let popups = []; // ダメージやCRITICAL表示用

// DOM要素
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();
        const buffer = await file.arrayBuffer();
        return await this.ctx.decodeAudioData(buffer);
    }
    playBGM(buffer) {
        this.stopBGM();
        soundSource = this.ctx.createBufferSource();
        soundSource.buffer = buffer;
        soundSource.loop = true;
        analyser = this.ctx.createAnalyser();
        analyser.fftSize = 2048;
        dataArray = new Uint8Array(analyser.frequencyBinCount);
        soundSource.connect(analyser);
        analyser.connect(this.ctx.destination);
        soundSource.start(0);
    }
    stopBGM() { 
        if (soundSource) {
            try { soundSource.stop(); } catch(e){}
            soundSource = null;
        }
    }
    playBeep(f, t, d, v=0.1) {
        const o = this.ctx.createOscillator(), g = this.ctx.createGain();
        o.type = t; o.frequency.value = f;
        g.gain.setValueAtTime(v, this.ctx.currentTime);
        g.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + d);
        o.connect(g); g.connect(this.ctx.destination);
        o.start(); o.stop(this.ctx.currentTime + d);
    }
    playShot() { this.playBeep(1200, 'square', 0.08, 0.05); }
    playChargeShot() { this.playBeep(400, 'sawtooth', 0.3, 0.2); }
    playJump() { this.playBeep(440, 'triangle', 0.15); }
    playDamage() { this.playBeep(150, 'sawtooth', 0.3); }
    playHit() { this.playBeep(800, 'sine', 0.05); }
    playCrit() { this.playBeep(1500, 'square', 0.1, 0.1); }
    playStomp() { this.playBeep(100, 'square', 0.2, 0.3); } 
    playClear() { this.playBeep(523,'sine',0.1); setTimeout(()=>this.playBeep(783,'sine',0.4),200); }
}();

const rgbToHex = (c) => `rgb(${Math.floor(c.R)},${Math.floor(c.G)},${Math.floor(c.B)})`;
const blendColor = (target, source, weight = 0.1) => ({
    R: target.R + (source.R - target.R) * weight,
    G: target.G + (source.G - target.G) * weight,
    B: target.B + (source.B - target.B) * weight
});

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

class Star {
    constructor() { this.reset(true); }
    reset(randomX = false) {
        this.x = randomX ? Math.random() * CANVAS_WIDTH : CANVAS_WIDTH;
        this.y = Math.random() * CANVAS_HEIGHT;
        this.speed = 2 + Math.random() * 8;
        this.size = 1 + Math.random() * 2;
    }
    update() {
        this.x -= this.speed;
        if (this.x < 0) this.reset();
    }
    draw(ctx) {
        ctx.fillStyle = "#FFFFFF";
        ctx.fillRect(this.x, this.y, this.size, this.size);
    }
}

class Particle {
    constructor(x, y) {
        this.x = x; this.y = y;
        const angle = Math.random() * Math.PI * 2;
        const speed = Math.random() * 5 + 2;
        this.vx = Math.cos(angle) * speed;
        this.vy = Math.sin(angle) * speed;
        this.life = 1.0;
        this.decay = Math.random() * 0.05 + 0.02;
    }
    update() {
        this.x += this.vx;
        this.y += this.vy;
        this.vy += 0.2; // 重力
        this.life -= this.decay;
    }
    draw(ctx, camX) {
        if(this.life <= 0) return;
        ctx.globalAlpha = this.life;
        ctx.fillStyle = "#00FFFF"; // 水色
        ctx.beginPath();
        ctx.arc(this.x - camX, this.y, 3, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalAlpha = 1.0;
    }
}

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

class Block {
    constructor(x, baseHeight, index) {
        this.x = x;
        this.baseHeight = baseHeight;
        this.y = CANVAS_HEIGHT;
        this.vy = 0;
        // 20本ごとに色を変える
        const colorIdx = Math.floor(index / 20) % TERRAIN_PALETTE.length;
        this.color = TERRAIN_PALETTE[colorIdx];
    }
    update(targetY) {
        const force = (targetY - this.y) * 0.1;
        this.vy += force;
        this.vy *= 0.85;
        this.y += this.vy;
    }
    draw(ctx, camX) {
        if (this.x + BLOCK_SIZE < camX || this.x > camX + CANVAS_WIDTH) return;
        ctx.fillStyle = this.color;
        ctx.strokeStyle = COLOR.BLOCK_OUTLINE;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.rect(this.x - camX, this.y, BLOCK_SIZE, CANVAS_HEIGHT - this.y + BLOCK_SIZE);
        ctx.fill();
        ctx.stroke();
    }
}

class Entity {
    constructor(x, y, size, rgb) {
        this.x = x; this.y = y; 
        this.baseSize = size;
        this.currentSize = size;
        this.rgb = {...rgb};
        this.vx = 0; this.vy = 0;
        this.hp = 10;
        this.dead = false; this.grounded = false;
        this.groundBlock = null;
    }
    draw(ctx, camX) {
        if (this.dead) return;
        ctx.fillStyle = rgbToHex(this.rgb);
        ctx.beginPath();
        ctx.arc(this.x - camX, this.y - this.currentSize/2, this.currentSize, Math.PI, 0);
        ctx.fillRect(this.x - camX - this.currentSize, this.y - this.currentSize/2, this.currentSize*2, this.currentSize/2);
        ctx.fill();
    }
    update() {
        this.vy += 0.6;
        this.x += this.vx;
        this.y += this.vy;

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

        const blockIdx = Math.floor(this.x / BLOCK_SIZE);
        if (blockIdx >= 0 && blockIdx < blocks.length) {
            const block = blocks[blockIdx];
            if (this.vy >= 0 && this.y >= block.y - this.currentSize/2 && this.y <= block.y + 30) {
                if (block.y < CANVAS_HEIGHT + 20) { 
                    this.y = block.y;
                    if(block.vy < 0) this.vy = block.vy * 0.25; 
                    else this.vy = 0;
                    this.grounded = true;
                    this.groundBlock = block;
                }
            }
        }

        if (!this.grounded) {
             const floorY = CANVAS_HEIGHT - 5;
             if (this.y >= floorY) {
                 this.y = floorY;
                 this.vy = 0;
                 this.grounded = true;
             }
        }
    }
    hit(damage, shooterRgb) {
        this.hp -= damage;
        soundManager.playHit();
        
        // 色を水色(CYAN)に近づける
        this.rgb = blendColor(this.rgb, COLOR.CYAN, 0.4); 

        if (this.hp <= 0) {
            this.dead = true;
            soundManager.playDamage();
            return true;
        }
        return false;
    }
}

class Player extends Entity {
    constructor() {
        super(100, 200, 15, COLOR.PLAYER);
        this.maxHp = 10;
        this.invincible = 0;
        this.scale = 1.0;
        this.isStomping = false;
    }
    update() {
        this.vx = keys['ArrowLeft'] ? -5 : (keys['ArrowRight'] ? 5 : 0);
        
        if (keys['KeyX'] && this.grounded) {
            this.vy = -14;
            if (this.groundBlock) this.vy += this.groundBlock.vy * 0.2;
            soundManager.playJump();
            this.grounded = false;
        }

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

        if (keys['ArrowDown'] && !this.grounded) {
            this.scale = 5.0; 
            this.vy += 3;     
            this.isStomping = true;
        } else {
            if (this.grounded) {
                if (this.isStomping) {
                     soundManager.playStomp(); 
                     this.scale = 1.0; 
                }
                this.isStomping = false;
            }
        }

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

        super.update();
        this.x = Math.max(0, Math.min(WORLD_WIDTH, this.x));
        if (this.invincible > 0) this.invincible--;
        if (this.dead && gameState === 'playing') endGame(false);
    }

    heal() {
        if(this.hp < this.maxHp) this.hp++;
    }
}

class Enemy extends Entity {
    constructor(x, y, isBoss = false) {
        super(x, y, isBoss ? 60 : 15, isBoss ? COLOR.BOSS : COLOR.ENEMY);
        this.isBoss = isBoss;
        this.shootTimer = Math.random() * 200;
    }
    update() {
        if (this.dead) return;
        super.update();
        if (this.x > cameraX - 100 && this.x < cameraX + CANVAS_WIDTH + 100) {
            if (!this.isBoss && this.grounded && Math.random()<0.01) this.vy = -10;
            this.shootTimer--;
            if (this.shootTimer <= 0) {
                const dir = player.x < this.x ? -1 : 1;
                bullets.push(new Bullet(this.x, this.y - 10, 6 * dir, 0, false, this.rgb, this.hp, 5));
                soundManager.playShot();
                this.shootTimer = this.isBoss ? 60 : 200 + Math.random()*200; 
            }
        }
    }
}

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

// === ゲームシステム ===

function initGame() {
    player = new Player();
    bullets = []; enemies = []; blocks = []; stars = [];
    particles = []; popups = [];
    score = 0; frameCount = 0;
    keys = {};
    
    for(let i=0; i<100; i++) stars.push(new Star());
    
    const blockCount = Math.ceil(WORLD_WIDTH / BLOCK_SIZE);
    for(let i=0; i<blockCount; i++) blocks.push(new Block(i * BLOCK_SIZE, 500, i));
    
    for(let i=0; i<25; i++) enemies.push(new Enemy(800 + Math.random()*(WORLD_WIDTH-1000), 0));
    enemies.push(new Enemy(WORLD_WIDTH - 200, 0, true)); // BOSS

    gameState = 'playing';
    uiLayer.style.display = 'none';
    canvas.style.display = 'block';
    
    soundManager.playBGM(audioBuffer);
    if (animationFrameId) cancelAnimationFrame(animationFrameId);
    gameLoop();
}

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

function spawnParticles(x, y) {
    for(let i=0; i<10; i++) {
        particles.push(new Particle(x, y));
    }
}

function update() {
    if (gameState !== 'playing') return;
    frameCount++;
    
    // 音量解析
    let sum = 0;
    if (analyser) {
        analyser.getByteTimeDomainData(dataArray);
        // 平均振幅(音量)を簡易計算 128が中心
        for(let i=0; i<dataArray.length; i+=10) {
            sum += Math.abs(dataArray[i] - 128);
        }
        currentAudioLevel = sum / (dataArray.length / 10) / 128.0; // 0.0 ~ 1.0くらい
    }

    stars.forEach(s => s.update());
    
    blocks.forEach(b => {
        const screenX = b.x - cameraX;
        let targetY = b.baseHeight;
        if (screenX >= -BLOCK_SIZE && screenX <= CANVAS_WIDTH) {
             targetY = getTargetHeight(screenX);
        }
        b.update(targetY);
    });

    if(player) player.update();
    enemies.forEach(e => e.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;
                
                let finalDamage = b.damage;
                let isCritical = false;

                // クリティカル判定(プレイヤーの弾かつ、音量が一定以上の時)
                if (t !== player && currentAudioLevel > 0.15) { // 閾値調整
                    finalDamage *= 2;
                    isCritical = true;
                    soundManager.playCrit();
                    popups.push(new PopupText(t.x, t.y - 20, "CRITICAL!", "#FF0"));
                }

                if (t === player) finalDamage = b.damage / 10; 

                const dead = t.hit(finalDamage, b.rgb);
                
                if (t === player) {
                     player.invincible = 60;
                     if (dead) endGame(false);
                } else if (dead) {
                    // 敵撃破
                    let gainScore = t.isBoss ? 5000 : 500;
                    if (isCritical) gainScore *= 2;
                    score += gainScore;

                    // 飛沫エフェクト
                    spawnParticles(t.x, t.y);
                    // HP回復
                    player.heal();
                    popups.push(new PopupText(player.x, player.y - 40, "HP +1", "#0F0", 15));

                    if (t.isBoss) endGame(true);
                }
            }
        });
    });

    // 体当たり & 急降下攻撃判定
    enemies.forEach(e => {
        if(e.dead || player.dead) return;
        
        const dist = Math.hypot(player.x - e.x, (player.y - player.currentSize/2) - (e.y - e.currentSize/2));
        const collisionDist = player.currentSize + e.currentSize;

        if(dist < collisionDist) {
            if (player.isStomping && player.y < e.y + e.currentSize) {
                e.hit(9999, player.rgb); 
                spawnParticles(e.x, e.y);
                score += e.isBoss ? 5000 : 500;
                player.heal(); // 踏みつけでも回復
                soundManager.playDamage();
                if (e.isBoss && e.hp<=0) endGame(true);
            } 
            else if (player.invincible <= 0) {
                player.hit(0.1, e.rgb); 
                player.invincible = 60;
                if(player.hp<=0) endGame(false);
            }
        }
    });

    if(player) {
        cameraX = player.x - CANVAS_WIDTH * 0.4;
        cameraX = Math.max(0, Math.min(WORLD_WIDTH - CANVAS_WIDTH, cameraX));
    }
}

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; // 音が大きいと最大20度くらい追加
    // 音量の揺れは左右ランダムっぽく
    const kickDir = Math.sin(frameCount * 0.1) > 0 ? 1 : -1;
    
    const angle = (baseSway + (volumeKick * kickDir)) * (Math.PI / 180); 

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

    stars.forEach(s => s.draw(ctx));
    blocks.forEach(b => b.draw(ctx, cameraX));
    particles.forEach(p => p.draw(ctx, cameraX)); // パーティクル描画
    enemies.forEach(e => e.draw(ctx, cameraX));
    if(player) player.draw(ctx, cameraX);
    bullets.forEach(b => b.draw(ctx, cameraX));
    popups.forEach(p => p.draw(ctx, cameraX)); // ポップアップ
    
    ctx.restore(); 

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

function drawUI() {
    if(!player) return;
    const barW = 200, barH = 25, barX = 20, barY = 20;
    ctx.fillStyle = "red"; ctx.fillRect(barX, barY, barW, barH);
    ctx.fillStyle = "lime"; ctx.fillRect(barX, barY, barW * (player.hp/player.maxHp), barH);
    ctx.strokeStyle = "white"; ctx.lineWidth = 2; ctx.strokeRect(barX, barY, barW, barH);
    ctx.fillStyle = "white"; ctx.font = "bold 16px Arial";
    ctx.fillText(`HP: ${Math.ceil(Math.max(0, player.hp))}`, barX + 10, barY + 18);

    ctx.textAlign = "right";
    ctx.font = "bold 24px Arial";
    ctx.fillText(`SCORE: ${score}`, CANVAS_WIDTH - 20, 45);
    ctx.textAlign = "left";
    
    // CRITICAL CHANCE 表示 (デバッグ用兼ねて)
    if(currentAudioLevel > 0.15) {
        ctx.fillStyle = "yellow";
        ctx.font = "bold 14px Arial";
        ctx.fillText("BEAT BONUS!", CANVAS_WIDTH - 20, 70);
    }
}

function drawEndScreen() {
    ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
    ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    ctx.fillStyle = "white";
    ctx.textAlign = "center";
    ctx.font = "bold 80px Arial Black";
    ctx.fillText(gameResultText, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2);
    ctx.font = "bold 40px Arial";
    ctx.fillText(`Final Score: ${score}`, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 60);
    ctx.font = "20px Arial";
    ctx.fillText("Restarting in 3 seconds...", CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 120);
}

function endGame(win) {
    if(gameState === 'gameover' || gameState === 'gameclear') return;
    gameState = win ? 'gameclear' : 'gameover';
    gameResultText = win ? "GAME CLEAR!!" : "GAME OVER";
    soundManager.stopBGM();
    if(win) soundManager.playClear();
    
    setTimeout(() => {
        initGame();
    }, 3000);
}

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

// イベント
fileInput.addEventListener('change', async (e) => {
    if (!e.target.files.length) return;
    statusText.innerText = "読み込み中..."; 
    startBtn.disabled = true;
    try {
        audioBuffer = await soundManager.loadFile(e.target.files[0]);
        statusText.innerText = "準備完了!"; 
        startBtn.disabled = false; 
        startBtn.innerText = "GAME START";
    } catch (e) { 
        console.error(e);
        statusText.innerText = "エラー: ファイルを読み込めません"; 
    }
});
startBtn.addEventListener('click', () => { if (audioBuffer) initGame(); });
window.addEventListener('keydown', e => keys[e.code] = true);
window.addEventListener('keyup', e => keys[e.code] = false);

</script>
</body>
</html>

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


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

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

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

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

「Audiosurf」 (2008)

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

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

「Melody's Escape」(2016)

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

Beat Hazard (2009)

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

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

「Sound Shapes」 (2012)

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

「140」(2021)

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

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


「Geometry Dash」(2013)


技術的な「祖先」

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

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


《このゲームの独自性》


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

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

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

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

コメント

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

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