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

コメント