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

コメント