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

【更新履歴】

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


画像
ゲーム画面

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Wave Ground 1.1</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",
    TERRAIN: "#8B4513", BLOCK_OUTLINE: "#5A2D0C",
    PLAYER: {R:0, G:0, B:255},
    ENEMY:  {R:128, G:0, B:128},
    BOSS:   {R:255, G:0, B:255},
};

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

// エンティティ管理
let player = null;
let enemies = [];
let bullets = [];
let blocks = [];
let stars = [];

// 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); }
    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 Block {
    constructor(x, baseHeight) {
        this.x = x;
        this.baseHeight = baseHeight;
        this.y = CANVAS_HEIGHT;
        this.vy = 0;
    }
    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 = COLOR.TERRAIN;
        ctx.strokeStyle = COLOR.BLOCK_OUTLINE;
        ctx.lineWidth = 2;
        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();
        // currentSizeを使って描画
        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;

        // 1. ブロック(波形)との当たり判定
        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;
                    
                    // 【修正点】跳ね飛ばされの高さを1/4にする
                    // ブロックが上昇中のみ、その勢いを少しもらう
                    if(block.vy < 0) {
                        this.vy = block.vy * 0.25; 
                    } else {
                        this.vy = 0; // 下降中は密着
                    }
                    
                    this.grounded = true;
                    this.groundBlock = block;
                }
            }
        }

        // 2. 画面底辺
        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();
        this.rgb = blendColor(this.rgb, shooterRgb, 0.3);
        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;
        }

        // --- チャージ & ショット処理 ---
        // Zキー押下中:膨らむ
        if (keys['KeyZ']) {
            if (this.scale < 5.0) {
                this.scale += 0.1; // 膨張スピード
            } else {
                this.scale = 5.0; // 最大5倍
            }
        } else {
            // Zキーを離した瞬間:ショット発射判定
            if (this.scale > 1.0) {
                // ダメージとサイズはスケールに比例
                const dmg = this.hp * this.scale; // HPベース × 大きさ倍率
                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);
    }
}

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 = [];
    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));
    
    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 update() {
    if (gameState !== 'playing') return;
    frameCount++;
    if (analyser) analyser.getByteTimeDomainData(dataArray);

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

    // 弾の判定
    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)); // currentSizeで判定
            if (dist < t.currentSize + b.size) {
                b.active = false;
                let finalDamage = b.damage;
                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) {
                    score += t.isBoss ? 5000 : 500;
                    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); // 即死ダメージ
                score += e.isBoss ? 5000 : 500;
                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 angle = Math.sin(frameCount * 0.005) * 15 * (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));
    enemies.forEach(e => e.draw(ctx, cameraX));
    if(player) player.draw(ctx, cameraX);
    bullets.forEach(b => b.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";
}

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>


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

コメント

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