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


画像

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Realtime Waveform Action</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #87CEEB;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
            color: #333;
        }
        #ui-layer {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            text-align: center;
            z-index: 10;
            background: rgba(255, 255, 255, 0.9);
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 0 20px rgba(0,0,0,0.2);
        }
        canvas {
            border: 2px solid #333;
            background-color: #87CEEB;
            display: none; /* ゲーム開始まで隠す */
            box-shadow: 0 0 20px rgba(0,0,0,0.5);
        }
        .btn {
            padding: 10px 30px;
            font-size: 18px;
            cursor: pointer;
            background: #333;
            color: white;
            border: none;
            border-radius: 5px;
            margin-top: 10px;
        }
        .btn:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
    </style>
</head>
<body>

<div id="ui-layer">
    <h1>Realtime Audio Action</h1>
    <p>WAVファイルを読み込むと、リアルタイムに地面が波打ちます。</p>
    <p>移動: ← → | ジャンプ: X | ショット: Z</p>
    <input type="file" id="fileInput" accept="audio/*">
    <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 SCREEN_COUNT = 5; 
const WORLD_WIDTH = CANVAS_WIDTH * SCREEN_COUNT;

// 色設定
const COLOR_BG = "#87CEEB";
const COLOR_TERRAIN = "#8B4513";
const COLOR_PLAYER = "#0000FF";
const COLOR_ENEMY = "#800080";
const COLOR_BOSS = "#FF00FF";
const COLOR_SHOT = "#FFFFFF";

// グローバル変数
let gameRunning = false;
let cameraX = 0;
let keys = {};
let score = 0;

// オーディオ関連
let audioCtx;
let audioBuffer;
let analyser;
let dataArray; // 波形データ格納用
let soundSource;

// エンティティ
let player;
let enemies = [];
let bullets = [];
let boss;

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

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

/**
 * サウンド管理クラス
 */
class SoundManager {
    constructor() {
        this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    }

    async loadFile(file) {
        if(this.ctx.state === 'suspended') await this.ctx.resume();
        const arrayBuffer = await file.arrayBuffer();
        return await this.ctx.decodeAudioData(arrayBuffer);
    }

    playBGM(buffer) {
        if (soundSource) soundSource.stop();
        soundSource = this.ctx.createBufferSource();
        soundSource.buffer = buffer;
        soundSource.loop = true;

        // アナライザー(波形取得用)の接続
        analyser = this.ctx.createAnalyser();
        analyser.fftSize = 2048; // 解像度
        const bufferLength = analyser.frequencyBinCount;
        dataArray = new Uint8Array(bufferLength);

        soundSource.connect(analyser);
        analyser.connect(this.ctx.destination);
        
        soundSource.start(0);
    }

    stopBGM() {
        if (soundSource) soundSource.stop();
    }

    playBeep(freq, type, duration) {
        const osc = this.ctx.createOscillator();
        const gain = this.ctx.createGain();
        osc.type = type;
        osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
        gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
        gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
        osc.connect(gain);
        gain.connect(this.ctx.destination);
        osc.start();
        osc.stop(this.ctx.currentTime + duration);
    }
    
    // SFX presets
    playShot() { this.playBeep(880, 'square', 0.1); }
    playJump() { this.playBeep(440, 'triangle', 0.15); }
    playDamage() { this.playBeep(150, 'sawtooth', 0.3); }
    playClear() { 
        this.playBeep(523, 'sine', 0.1);
        setTimeout(() => this.playBeep(783, 'sine', 0.4), 200);
    }
}
const soundManager = new SoundManager();

/**
 * リアルタイム地形取得
 * 画面上のX座標(0~800)に対応する波形の高さを返す
 */
function getWaveHeightAtScreenX(screenX) {
    if (!dataArray) return 500;
    
    // dataArray(0~1024) を 画面幅(0~800) にマッピング
    // 少し拡大して表示する
    const index = Math.floor((screenX / CANVAS_WIDTH) * dataArray.length);
    const safeIndex = Math.min(Math.max(index, 0), dataArray.length - 1);
    
    // 値は 0~255 (128が中心)。これを高さ変動に変換
    // 振幅が大きいと揺れ幅も大きくする
    const value = dataArray[safeIndex];
    const offset = (value - 128) * 1.5; // 倍率調整
    
    return 500 - offset; // 基準線はy=500
}

/**
 * ゲームエンティティ
 */
class Entity {
    constructor(x, y, size, color) {
        this.x = x;
        this.y = y;
        this.size = size;
        this.color = color;
        this.vx = 0;
        this.vy = 0;
        this.hp = 1;
        this.dead = false;
        this.grounded = false;
    }

    draw(ctx, camX) {
        if (this.dead) return;
        ctx.fillStyle = this.color;
        ctx.beginPath();
        // スライム型
        ctx.arc(this.x - camX, this.y - this.size/2, this.size, Math.PI, 0);
        ctx.fillRect(this.x - camX - this.size, this.y - this.size/2, this.size*2, this.size/2);
        ctx.fill();
    }

    update() {
        this.vy += 0.5; // 重力
        this.x += this.vx;
        this.y += this.vy;

        // 地形判定 (現在の画面上のX座標から高さを計算)
        const screenX = this.x - cameraX;
        
        // 画面外の地形は平坦とみなす(処理簡略化)
        let terrainY = 500;
        if (screenX >= 0 && screenX <= CANVAS_WIDTH) {
            terrainY = getWaveHeightAtScreenX(screenX);
        }

        // 接地判定
        if (this.y >= terrainY - 5) { // 少し余裕を持たせる
            this.y = terrainY;
            this.vy = 0;
            this.grounded = true;
        } else {
            this.grounded = false;
        }

        if (this.y > CANVAS_HEIGHT + 100) this.dead = true;
    }
}

class Player extends Entity {
    constructor() {
        super(100, 300, 15, COLOR_PLAYER);
        this.maxHp = 10;
        this.hp = 10;
        this.invincible = 0;
    }

    update() {
        if (keys['ArrowLeft']) this.vx = -4;
        else if (keys['ArrowRight']) this.vx = 4;
        else this.vx = 0;

        // ジャンプ (Xキー)
        if (keys['KeyX'] && this.grounded) {
            this.vy = -12;
            soundManager.playJump();
            this.grounded = false;
        }

        super.update();

        // 壁制限
        if (this.x < 0) this.x = 0;
        if (this.x > WORLD_WIDTH) this.x = WORLD_WIDTH;

        if (this.invincible > 0) this.invincible--;
    }

    shoot() {
        bullets.push(new Bullet(this.x, this.y - 10, 8, 0, true));
        soundManager.playShot();
    }
    
    damage() {
        if (this.invincible > 0) return;
        this.hp--;
        soundManager.playDamage();
        this.invincible = 60; 
        if (this.hp <= 0) endGame(false);
    }
}

class Enemy extends Entity {
    constructor(x, y, isBoss = false) {
        super(x, y, isBoss ? 60 : 15, isBoss ? COLOR_BOSS : COLOR_ENEMY);
        this.isBoss = isBoss;
        this.hp = isBoss ? 30 : 1;
        this.shootTimer = Math.random() * 200;
    }

    update() {
        if (this.dead) return;
        
        // 常にプレイヤー方向を向くAI
        if (!this.isBoss && Math.random() < 0.01 && this.grounded) {
             this.vy = -8; // 小ジャンプ
        }
        
        super.update();

        // 画面内にいる時だけ攻撃処理
        if (this.x > cameraX - 50 && this.x < cameraX + CANVAS_WIDTH + 50) {
            this.shootTimer--;
            if (this.shootTimer <= 0) {
                const dir = player.x < this.x ? -1 : 1;
                // 敵弾生成
                bullets.push(new Bullet(this.x, this.y - 10, 4 * dir, 0, false));
                this.shootTimer = this.isBoss ? 80 : 300; 
            }
        }
    }
}

class Bullet {
    constructor(x, y, vx, vy, isPlayerShot) {
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
        this.isPlayerShot = isPlayerShot;
        this.size = 4;
        this.active = true;
    }

    update() {
        this.x += this.vx;
        this.y += this.vy;
        if (this.x < cameraX || this.x > cameraX + CANVAS_WIDTH) this.active = false;
    }

    draw(ctx, camX) {
        ctx.fillStyle = COLOR_SHOT;
        ctx.beginPath();
        ctx.arc(this.x - camX, this.y, this.size, 0, Math.PI * 2);
        ctx.fill();
    }
}

/**
 * メインループ
 */
function gameLoop() {
    if (!gameRunning) return;

    // --- データ更新 ---
    if (analyser) analyser.getByteTimeDomainData(dataArray); // 波形取得

    player.update();
    enemies.forEach(e => e.update());
    bullets.forEach(b => b.update());
    bullets = bullets.filter(b => b.active);

    // 当たり判定
    bullets.forEach(b => {
        if (!b.active) return;
        if (b.isPlayerShot) {
            enemies.forEach(e => {
                if (e.dead) return;
                const dist = Math.hypot(b.x - e.x, b.y - (e.y - e.size/2));
                if (dist < e.size + b.size) {
                    e.hp--;
                    b.active = false;
                    if (e.hp <= 0) {
                        e.dead = true;
                        score += e.isBoss ? 1000 : 100; // スコア加算
                        if (e.isBoss) endGame(true);
                        else soundManager.playDamage(); // 撃破音代用
                    } else {
                        soundManager.playDamage();
                    }
                }
            });
        } else {
            // 敵弾 vs プレイヤー
            const dist = Math.hypot(b.x - player.x, b.y - (player.y - player.size/2));
            if (dist < player.size + b.size) {
                b.active = false;
                player.damage();
            }
        }
    });

    // 敵との接触
    enemies.forEach(e => {
        if (e.dead) return;
        const dist = Math.hypot(player.x - e.x, (player.y - player.size/2) - (e.y - e.size/2));
        if (dist < player.size + e.size) player.damage();
    });

    // カメラ
    cameraX = player.x - CANVAS_WIDTH / 3;
    if (cameraX < 0) cameraX = 0;
    if (cameraX > WORLD_WIDTH - CANVAS_WIDTH) cameraX = WORLD_WIDTH - CANVAS_WIDTH;

    // --- 描画 ---
    ctx.fillStyle = COLOR_BG;
    ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    // リアルタイム波形地面の描画
    ctx.strokeStyle = COLOR_TERRAIN;
    ctx.fillStyle = COLOR_TERRAIN;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0, CANVAS_HEIGHT);
    
    // 画面幅分だけループして波形を描く
    for (let x = 0; x <= CANVAS_WIDTH; x += 5) {
        const y = getWaveHeightAtScreenX(x);
        ctx.lineTo(x, y);
    }
    ctx.lineTo(CANVAS_WIDTH, CANVAS_HEIGHT);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();

    // キャラクター
    player.draw(ctx, cameraX);
    enemies.forEach(e => e.draw(ctx, cameraX));
    bullets.forEach(b => b.draw(ctx, cameraX));

    // --- UI描画 ---
    drawUI();

    requestAnimationFrame(gameLoop);
}

function drawUI() {
    // HP Bar (左上)
    const barX = 20;
    const barY = 20;
    const barW = 200;
    const barH = 20;

    // 背景(赤)
    ctx.fillStyle = "red";
    ctx.fillRect(barX, barY, barW, barH);

    // 現在HP(黄緑)
    const hpRatio = Math.max(0, player.hp / player.maxHp);
    ctx.fillStyle = "lime";
    ctx.fillRect(barX, barY, barW * hpRatio, barH);
    
    // 枠線
    ctx.strokeStyle = "black";
    ctx.lineWidth = 2;
    ctx.strokeRect(barX, barY, barW, barH);
    
    // 文字
    ctx.fillStyle = "white";
    ctx.font = "14px Arial";
    ctx.fillText("HP", barX + 5, barY + 15);

    // スコア (右上)
    ctx.fillStyle = "black";
    ctx.font = "24px Arial";
    ctx.textAlign = "right";
    ctx.fillText(`SCORE: ${score}`, CANVAS_WIDTH - 20, 40);
    ctx.textAlign = "left"; // 戻す
}

/**
 * 初期化処理
 */
function initGame() {
    player = new Player();
    bullets = [];
    enemies = [];
    score = 0;
    
    // 敵配置
    for(let i=0; i<20; i++) {
        const x = 600 + Math.random() * (WORLD_WIDTH - 800);
        enemies.push(new Enemy(x, 0));
    }
    // ボス
    boss = new Enemy(WORLD_WIDTH - 150, 0, true);
    enemies.push(boss);

    gameRunning = true;
    uiLayer.style.display = 'none';
    canvas.style.display = 'block';

    soundManager.playBGM(audioBuffer);
    gameLoop();
}

function endGame(win) {
    gameRunning = false;
    soundManager.stopBGM();
    if(win) soundManager.playClear();
    
    setTimeout(() => {
        alert(win ? `GAME CLEAR!! Score: ${score}` : `GAME OVER... Score: ${score}`);
        location.reload();
    }, 500);
}

// イベントリスナー
fileInput.addEventListener('change', async (e) => {
    if (e.target.files.length === 0) 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 (err) {
        console.error(err);
        statusText.innerText = "読み込みエラー";
    }
});

startBtn.addEventListener('click', () => {
    if (audioBuffer) initGame();
});

// 操作キーイベント
window.addEventListener('keydown', (e) => {
    keys[e.code] = true;
    if (!gameRunning) return;
    
    if (e.code === 'KeyZ') { // ショット
        if (player && !player.dead) player.shoot();
    }
});
window.addEventListener('keyup', (e) => keys[e.code] = false);

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

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
音で作る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