音で作る2Dシューティング「WaveGround」
【更新履歴】
・2026/2/1 バージョン1.1公開。
・2026/2/2 バージョン1.3公開。
・2026/2/2 バージョン1.4公開。
・2026/2/2 バージョン1.5公開。
・2026/2/2 バージョン1.6公開。
・ダウンロードされる方はこちら。↓
《操作説明》
・方向キーで移動。
・Zキーでショットを撃つ。(押しっぱなしだと、貯め撃ち)
・Xキーでジャンプ。
・下キーで急降下して敵を踏み潰し、
地面を突き破って下の階層へ行ける。
・貯め撃ちすると、ショットサイズが大きくなり、
当たりやすくなる。(ダメージも増えていく。)
(※チャージ中は、自機が膨らむので、当たりやすくなる。)
・HPが減ると、ショットのダメージが減る。
・敵を倒すと、ロックオンされるので、
そこでAキーを押すと、ダッシュで敵に衝突し、
敵を吸収してHPが1回復する。
・ジャンプして雲に触れると、HPが1回復する。
・地面が跳ね上がった瞬間に上キーを押し続けると
ハイジャンプして、上の階層へ行ける。
・escキーでゲームを一時停止。もう一度押すと再開。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Wave Ground 1.6</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.85);
padding: 40px;
border-radius: 10px;
border: 2px solid #00BFFF;
min-width: 450px;
box-shadow: 0 0 20px rgba(0, 191, 255, 0.5);
}
canvas {
display: none;
box-shadow: 0 0 30px rgba(0, 191, 255, 0.3);
background-color: #000;
}
.btn {
padding: 15px 40px;
font-size: 20px;
cursor: pointer;
background: linear-gradient(to bottom, #00BFFF, #0080FF);
color: white;
border: none;
border-radius: 5px;
margin-top: 15px;
font-weight: bold;
text-shadow: 1px 1px 2px black;
}
.btn:disabled { background: #555; color: #999; cursor: not-allowed; }
.key-instruction { font-size: 14px; color: #ccc; margin-top: 10px; line-height: 1.6; }
.highlight { color: #FF0; font-weight: bold; }
h1 { margin: 0 0 10px 0; color: #00BFFF; text-shadow: 0 0 10px #00BFFF; }
.tag { display:inline-block; background:#333; padding:2px 6px; border-radius:4px; font-size:0.9em; margin:2px; }
</style>
</head>
<body>
<div id="ui-layer">
<h1>Wave Ground 2.7</h1>
<p style="font-size: 14px; color:#aaa;">Wave Ground</p>
<div class="key-instruction">
<span class="tag">移動: ←→</span> <span class="tag">通常ジャンプ: X</span><br>
<span class="tag">ショット: Z (長押し0.5秒でチャージ)</span> <span class="tag">スローダッシュ: A</span><br>
<span class="tag">ポーズ: ESC</span><br><br>
<span class="highlight">【アクション】</span><br>
ハイジャンプ: 地面隆起時に <span class="tag" style="color:#0FF">↑ 長押し</span> (天井を突き破る)<br>
踏み潰し: 空中で <span class="tag" style="color:#F0F">↓</span> (床を破壊)<br><br>
<span style="font-size:0.9em; color:#aaa;">スネーク隊列の敵に注意!ボスは高耐久です。</span>
</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 BLOCK_SIZE = 25;
const BLOCK_THICKNESS = 10;
const MAX_HP = 20;
const LAYER_HEIGHT = 1800;
const LAYER_KEEP_RANGE = 3;
const COLOR = {
BG: "#000000",
BLOCK_OUTLINE: "rgba(0,0,0,0.2)",
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},
CYAN_HEX: "#00FFFF",
CLOUD: "#FFFFFF",
LOCKON: "#FF0000",
SPARK: "#FFFF00"
};
const TERRAIN_PALETTE = ["#4682B4", "#20B2AA", "#87CEEB", "#5F9EA0", "#B0C4DE"];
// === グローバル変数 ===
let canvas, ctx;
let gameState = 'loading';
let cameraX = 0;
let cameraY = 0;
let keys = {};
let score = 0;
let frameCount = 0;
let animationFrameId;
let currentAudioLevel = 0;
let screenShake = 0;
let timeScale = 1.0;
let globalBlockIndex = 0;
let lastGeneratedX = 0;
let nextBossScore = 5000;
let playerLayerIndex = 0;
let player = null;
let enemies = [], bullets = [], blocks = [], stars = [], particles = [], popups = [], clouds = [];
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();
return await this.ctx.decodeAudioData(await file.arrayBuffer());
}
playBGM(buffer) {
this.stopBGM();
try {
soundSource = this.ctx.createBufferSource();
soundSource.buffer = buffer;
soundSource.loop = false;
soundSource.onended = () => { if (gameState === 'playing') endGame(true); };
analyser = this.ctx.createAnalyser();
analyser.fftSize = 2048;
dataArray = new Uint8Array(analyser.frequencyBinCount);
soundSource.connect(analyser);
analyser.connect(this.ctx.destination);
soundSource.start(0);
} catch(e) { console.error("BGM Play Error:", e); }
}
stopBGM() { if (soundSource) { try { soundSource.stop(); } catch(e){} soundSource = null; } }
suspend() { if(this.ctx.state === 'running') this.ctx.suspend(); }
resume() { if(this.ctx.state === 'suspended') this.ctx.resume(); }
playOsc(freq, type, dur, vol, slideTo=null) {
if(this.ctx.state === 'suspended') return;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(vol, this.ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, this.ctx.currentTime + dur);
o.connect(g); g.connect(this.ctx.destination);
o.start(); o.stop(this.ctx.currentTime + dur);
}
playShot() { this.playOsc(1200, 'square', 0.1, 0.1); }
playChargeShot() { this.playOsc(600, 'sawtooth', 0.3, 0.2, 100); }
playJump() { this.playOsc(300, 'triangle', 0.15, 0.2, 600); }
playSuperJump() { this.playOsc(800, 'square', 0.3, 0.3, 1500); }
playBoost() { this.playOsc(400, 'sawtooth', 0.1, 0.1, 800); }
playStomp() { this.playOsc(150, 'square', 0.3, 0.3, 50); }
playImpact() { this.playOsc(100, 'sawtooth', 0.4, 0.5, 10); }
// 破壊音:短く切れの良い音に
playBreak() { this.playOsc(100, 'noise', 0.05, 0.3); }
playEnemyHit() { this.playOsc(800, 'sawtooth', 0.1, 0.3, 50); }
playCrit() { this.playOsc(1500, 'square', 0.15, 0.15, 2000); }
playCombo() { this.playOsc(800, 'sine', 0.1, 0.2, 1600); }
playItemGet() { this.playOsc(1200, 'sine', 0.2, 0.2, 2000); }
playClear() { this.playOsc(523, 'sine', 0.1, 0.1); setTimeout(()=>this.playOsc(783, 'sine', 0.8, 0.1), 200); }
playDamage() { this.playOsc(100, 'sawtooth', 0.3, 0.3, 50); }
}();
const rgbToHex = (c) => `rgb(${Math.floor(c.R)},${Math.floor(c.G)},${Math.floor(c.B)})`;
const blendColor = (t, s, w) => ({R:t.R+(s.R-t.R)*w, G:t.G+(s.G-t.G)*w, B:t.B+(s.B-t.B)*w});
// === クラス定義 ===
class Star {
constructor() { this.reset(true); }
reset(randomX = false) {
const startX = cameraX;
const startY = cameraY;
this.x = randomX ? startX + Math.random() * CANVAS_WIDTH : startX + CANVAS_WIDTH;
this.y = startY + (Math.random() * CANVAS_HEIGHT * 3) - CANVAS_HEIGHT * 1.5;
this.speed = 2 + Math.random() * 5;
this.size = 1 + Math.random() * 2;
}
update() {
this.x -= this.speed * timeScale;
if (this.x < cameraX - 100) this.reset();
}
draw(ctx, camX, camY) {
ctx.fillStyle = "#FFF";
ctx.fillRect(this.x - camX, this.y - camY, this.size, this.size);
}
}
class Particle {
constructor(x, y, color, isFirework = false, isSpark = false) {
this.x = x; this.y = y;
this.color = color || COLOR.CYAN_HEX;
this.isFirework = isFirework;
this.isSpark = isSpark;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * (isFirework ? 10 : (isSpark ? 12 : 5)) + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
if(isSpark && this.vy > 0) this.vy *= -0.5;
this.life = isFirework ? 2.0 : (isSpark ? 0.5 : 1.0);
this.decay = Math.random() * 0.02 + (isSpark ? 0.05 : 0.03);
if(isFirework) this.vy -= 3;
}
update() {
this.x += this.vx * timeScale;
this.y += this.vy * timeScale;
this.vy += (this.isFirework ? 0.1 : 0.2) * timeScale;
this.life -= this.decay * timeScale;
}
draw(ctx, camX, camY) {
if(this.life <= 0) return;
ctx.globalAlpha = this.life;
ctx.fillStyle = typeof this.color === 'string' ? this.color : rgbToHex(this.color);
ctx.beginPath();
if(this.isSpark) ctx.fillRect(this.x - camX, this.y - camY, 4, 4);
else { ctx.arc(this.x - camX, this.y - camY, 3, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = 1.0;
}
}
function spawnParticles(x, y, color) { for(let i=0; i<8; i++) particles.push(new Particle(x, y, color)); }
function spawnBlockDebris(x, y, color) { for(let i=0; i<5; i++) particles.push(new Particle(x, y, color, false, true)); }
function spawnSparks(x, y) { for(let i=0; i<15; i++) particles.push(new Particle(x, y, COLOR.SPARK, false, true)); }
function spawnFirework(x, y) {
const colors = ["#F00", "#0F0", "#00F", "#FF0", "#F0F", "#0FF"];
const c = colors[Math.floor(Math.random()*colors.length)];
for(let i=0; i<50; i++) particles.push(new Particle(x, y, c, true));
}
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 = 40;
}
update() { this.y -= 1 * timeScale; this.life -= 1 * timeScale; }
draw(ctx, camX, camY) {
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 - camY);
}
}
class Block {
constructor(x, layerIndex, index) {
this.x = x;
this.layerIndex = layerIndex;
this.baseHeight = 500 - (layerIndex * LAYER_HEIGHT);
this.y = this.baseHeight;
this.vy = 0;
const paletteIdx = Math.abs(layerIndex) % TERRAIN_PALETTE.length;
this.color = TERRAIN_PALETTE[paletteIdx];
this.destroyed = false;
}
update(waveOffset) {
if (this.destroyed) return;
const amp = 1.5 + (Math.abs(this.layerIndex) * 0.2);
const targetY = this.baseHeight - (waveOffset * amp);
this.vy += (targetY - this.y) * 0.1 * timeScale;
this.vy *= 0.85;
this.y += this.vy * timeScale;
}
draw(ctx, camX, camY) {
if (this.destroyed) return;
if (this.x + BLOCK_SIZE < camX || this.x > camX + CANVAS_WIDTH) return;
if (this.y - camY > CANVAS_HEIGHT + 400 || this.y - camY < -400) return;
ctx.fillStyle = this.color;
ctx.strokeStyle = COLOR.BLOCK_OUTLINE;
ctx.lineWidth = 1;
for(let i = 0; i < BLOCK_THICKNESS; i++) {
const drawY = this.y - camY + (i * BLOCK_SIZE);
if (drawY > CANVAS_HEIGHT + 100 || drawY < -100) continue;
if (i > 0) ctx.fillStyle = adjustBrightness(this.color, -8 * i);
else ctx.fillStyle = this.color;
ctx.fillRect(this.x - camX, drawY, BLOCK_SIZE, BLOCK_SIZE);
ctx.strokeRect(this.x - camX, drawY, BLOCK_SIZE, BLOCK_SIZE);
}
}
}
function adjustBrightness(hex, percent) {
let r = parseInt(hex.substring(1,3),16);
let g = parseInt(hex.substring(3,5),16);
let b = parseInt(hex.substring(5,7),16);
r = Math.max(0, Math.min(255, r + percent));
g = Math.max(0, Math.min(255, g + percent));
b = Math.max(0, Math.min(255, b + percent));
return `rgb(${r},${g},${b})`;
}
class CloudItem {
constructor(x, y) {
this.x = x; this.y = y; this.active = true;
this.parts = [{ox:0, oy:0, r:15}, {ox:-10, oy:5, r:12}, {ox:10, oy:5, r:12}];
}
update() {
if(this.x < cameraX - 100) this.active = false;
if(player && this.active) {
const dist = Math.hypot(player.x - this.x, player.y - this.y);
if (dist < 40) {
this.active = false; player.heal();
soundManager.playItemGet();
spawnParticles(this.x, this.y, "#FFF");
popups.push(new PopupText(player.x, player.y - 40, "HP +1", "#FFF", 20));
}
}
}
draw(ctx, camX, camY) {
if(!this.active) return;
ctx.fillStyle = COLOR.CLOUD;
const breath = 1.0 + Math.sin(frameCount * 0.1) * 0.1;
for(let p of this.parts) {
ctx.beginPath(); ctx.arc(this.x + p.ox - camX, this.y + p.oy - camY, p.r * breath, 0, Math.PI*2); ctx.fill();
}
}
}
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, camY) {
if (this.dead) return;
ctx.fillStyle = rgbToHex(this.rgb);
ctx.beginPath();
ctx.arc(this.x - camX, this.y - this.currentSize/2 - camY, this.currentSize, Math.PI, 0);
ctx.fillRect(this.x - camX - this.currentSize, this.y - this.currentSize/2 - camY, this.currentSize*2, this.currentSize/2);
ctx.fill();
}
update() {
this.vy += 0.6 * timeScale;
this.x += this.vx * timeScale;
this.y += this.vy * timeScale;
if(this.y > cameraY + CANVAS_HEIGHT + 300) {
this.dead = true;
}
this.grounded = false;
this.groundBlock = null;
// --- ブロック衝突判定 ---
// 1. 上昇破壊(ハイジャンプ中)
if (this.vy < 0 && this instanceof Player && this.state === 'superJumping') {
// 当たり判定矩形(少し広めに)
const checkX = this.x;
const checkY = this.y - 20;
blocks.forEach(b => {
if (b.destroyed) return;
// X軸判定
if (checkX >= b.x && checkX < b.x + BLOCK_SIZE) {
// Y軸判定(ブロックの下から厚み全体)
if (checkY >= b.y && checkY <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS) + 20) {
b.destroyed = true;
spawnBlockDebris(b.x + BLOCK_SIZE/2, b.y + BLOCK_SIZE/2, b.color);
soundManager.playBreak();
}
}
});
}
// 2. 下降着地・ストンプ破壊
if (this.vy >= 0) {
// プレイヤー座標の真下にあるブロック候補
const hitBlocks = blocks.filter(b =>
!b.destroyed &&
this.x >= b.x && this.x < b.x + BLOCK_SIZE &&
this.y >= b.y - 15 && this.y <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS)
);
if (hitBlocks.length > 0) {
// 一番上(Yが小さい)のブロックを取得
hitBlocks.sort((a,b) => a.y - b.y);
const block = hitBlocks[0];
if (this instanceof Player && this.isStomping) {
// ストンプ時:触れたブロックのみ破壊して跳ね返る
block.destroyed = true;
spawnBlockDebris(block.x + BLOCK_SIZE/2, block.y, block.color);
soundManager.playBreak();
this.isStomping = false;
this.vy = -12; // 破壊後に強めにバウンドして落下防止
this.scale = 1.0;
popups.push(new PopupText(this.x, this.y, "CRASH!", "#F00", 25));
} else {
// 通常着地
this.y = block.y;
// 反発力を半分に抑制 (0.5 -> 0.1)
this.vy = block.vy < 0 ? block.vy * 0.1 : 0;
this.grounded = true;
this.groundBlock = block;
}
}
}
}
}
class Player extends Entity {
constructor() {
super(100, 300, 10, COLOR.PLAYER);
this.maxHp = MAX_HP; this.hp = 20;
this.invincible = 0; this.scale = 1.0; this.isStomping = false;
this.state = 'normal';
this.dashTargets = []; this.currentDashIndex = 0; this.dashOrigin = {x:0, y:0};
this.comboCount = 0;
this.trail = [];
this.boostTimer = 0;
this.chargeFrames = 0; // 溜め時間
}
update() {
if (this.dead && gameState === 'playing') endGame(false);
if (this.invincible > 0) this.invincible--;
if (this.state === 'normal' || this.state === 'superJumping') this.updateNormal();
else if (this.state === 'dashing') this.updateDashing();
else if (this.state === 'returning') this.updateReturning();
this.x = Math.max(cameraX, this.x);
this.currentSize = this.baseSize * this.scale;
const newLayer = Math.round((500 - this.y) / LAYER_HEIGHT);
if (newLayer !== playerLayerIndex) {
playerLayerIndex = newLayer;
regenerateVisibleLayers();
spawnEnemiesForLayer(newLayer);
}
if (this.state === 'dashing' || this.isStomping || this.state === 'superJumping') {
this.trail.push({x: this.x, y: this.y, size: this.currentSize, alpha: 0.8});
}
for(let i=this.trail.length-1; i>=0; i--) {
this.trail[i].alpha -= 0.1 * timeScale;
if(this.trail[i].alpha <= 0) this.trail.splice(i, 1);
}
}
draw(ctx, camX, camY) {
for(let t of this.trail) {
ctx.globalAlpha = t.alpha;
const val = Math.floor(255 * t.alpha);
if (this.state === 'superJumping') ctx.fillStyle = `rgb(255, 255, ${val})`;
else ctx.fillStyle = `rgb(0, 0, ${val})`;
ctx.beginPath();
ctx.arc(t.x - camX, t.y - t.size/2 - camY, t.size, Math.PI, 0);
ctx.fillRect(t.x - camX - t.size, t.y - t.size/2 - camY, t.size*2, t.size/2);
ctx.fill();
}
ctx.globalAlpha = 1.0;
super.draw(ctx, camX, camY);
}
updateNormal() {
this.vx = keys['ArrowLeft'] ? -5 : (keys['ArrowRight'] ? 5 : 0);
// ジャンプ・ハイジャンプ
if (this.grounded) {
if (keys['KeyX']) {
this.vy = -16;
if (this.groundBlock) this.vy += this.groundBlock.vy * 0.1; // 反発抑制
soundManager.playJump();
this.grounded = false;
this.state = 'normal';
} else if (keys['ArrowUp']) {
if (this.groundBlock && this.groundBlock.vy < -2.0) {
this.vy = -20;
soundManager.playSuperJump();
this.grounded = false;
this.state = 'superJumping';
this.boostTimer = 60;
spawnParticles(this.x, this.y, "#FF0");
}
}
}
if (this.state === 'superJumping') {
if (keys['ArrowUp'] && this.boostTimer > 0) {
this.vy -= 1.8;
this.boostTimer--;
if (frameCount % 4 === 0) {
spawnParticles(this.x, this.y + 10, "#FFF");
soundManager.playBoost();
}
} else {
this.state = 'normal';
}
}
// チャージショット処理
if (keys['KeyZ']) {
this.chargeFrames++;
// 溜め演出:scaleを振動させる
if (this.chargeFrames > 30) {
this.scale = 1.5 + Math.sin(this.chargeFrames * 0.5) * 0.2;
if(this.chargeFrames === 31) soundManager.playChargeShot(); // チャージ完了音的な
} else {
this.scale = 1.0 + (this.chargeFrames / 60);
}
} else {
// 発射
if (this.chargeFrames > 0) {
let isCharge = (this.chargeFrames > 30); // 0.5秒以上
// コスト計算などは今回は省略し撃ち放題にするか、HP消費を維持するか
// HP消費維持なら:
let cost = isCharge ? 1 : 0;
if (this.hp > cost) {
this.hp -= cost;
// チャージならサイズ4倍、通常なら1倍
const shotSize = isCharge ? 40 : 10;
const dmg = isCharge ? 50 : 10;
bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 12, 0, true, this.rgb, dmg, shotSize));
if (isCharge) {
soundManager.playChargeShot();
screenShake = 10;
} else {
soundManager.playShot();
}
}
}
this.chargeFrames = 0;
this.scale = 1.0;
}
if (keys['KeyA']) {
const targets = enemies.filter(e => e.isLockedOn && !e.dead && e.x > cameraX).sort((a,b) => a.x - b.x);
if (targets.length > 0) { this.startChainDash(targets); return; }
}
if (keys['ArrowDown'] && !this.grounded && !this.isStomping) {
soundManager.playStomp();
this.scale = 5.0;
this.vy = 15;
this.isStomping = true;
this.state = 'normal';
} else if (this.grounded && this.isStomping) {
screenShake = 15; spawnSparks(this.x, this.y + this.currentSize/2); soundManager.playImpact();
this.scale = 1.0; this.isStomping = false;
}
super.update();
}
startChainDash(targets) {
this.state = 'dashing';
this.dashTargets = targets;
this.currentDashIndex = 0;
this.dashOrigin = {x: this.x, y: this.y};
this.invincible = 999;
this.isStomping = false; this.scale = 1.0; this.comboCount = 0;
soundManager.playJump();
}
updateDashing() {
if (this.currentDashIndex >= this.dashTargets.length) { this.state = 'returning'; return; }
const target = this.dashTargets[this.currentDashIndex];
if (!target || target.dead || target.x < cameraX) { this.currentDashIndex++; return; }
const dx = target.x - this.x; const dy = target.y - this.y;
const dist = Math.hypot(dx, dy);
let absorbDist = this.currentSize + target.currentSize + 20;
if (dist < absorbDist) {
this.heal(); this.comboCount++;
const multiplier = Math.pow(2, this.comboCount - 1);
popups.push(new PopupText(this.x, this.y-40, `x${multiplier}`, "#0FF", 30));
spawnParticles(target.x, target.y, COLOR.CYAN_HEX);
if (target instanceof Enemy) {
target.dead = true;
score += (target.isBoss ? 5000 : 500) * multiplier;
}
soundManager.playCombo(); this.currentDashIndex++;
} else {
const speed = 80 * timeScale;
this.x += (dx / dist) * speed;
this.y += (dy / dist) * speed;
}
}
updateReturning() {
const targetX = Math.max(cameraX + 20, this.dashOrigin.x);
const targetY = this.dashOrigin.y;
const dx = targetX - this.x; const dy = targetY - this.y;
const dist = Math.hypot(dx, dy);
if (dist < 30) {
this.x = targetX; this.y = targetY; this.state = 'normal'; this.invincible = 60; this.vx = 0; this.vy = 0; this.trail = [];
} else {
const speed = 60 * timeScale;
this.x += (dx / dist) * speed;
this.y += (dy / dist) * speed;
}
}
heal() { if(this.hp < this.maxHp) this.hp++; }
hit(damage) {
if (this.invincible > 0) return false;
this.hp -= 1; this.invincible = 60; soundManager.playDamage();
if (this.hp <= 0) { this.dead = true; return true; }
return false;
}
}
class Enemy extends Entity {
constructor(x, y, isBoss = false, leader = null) {
super(x, y, isBoss ? 60 : 15, isBoss ? COLOR.BOSS : COLOR.ENEMY);
this.isBoss = isBoss;
if(isBoss) this.hp = 600; // ボスHP強化 (10倍)
this.leader = leader; this.followDelay = [];
this.shootTimer = Math.random() * 200; this.isLockedOn = false;
}
update() {
if (this.dead) return;
if (this.isLockedOn) {
if(player) {
const dist = Math.hypot(player.x - this.x, player.y - this.y);
if(dist < player.currentSize + this.currentSize + 20) {
this.dead = true; player.heal(); score += 100;
spawnParticles(this.x, this.y, COLOR.CYAN_HEX); soundManager.playItemGet();
}
}
return;
}
if (this.leader && !this.leader.dead && !this.leader.isLockedOn) {
this.leader.followDelay.push({x: this.leader.x, y: this.leader.y});
if (this.leader.followDelay.length > 8) { // 遅延を調整
const pos = this.leader.followDelay.shift();
this.x = pos.x;
this.y = pos.y;
}
} else {
// リーダーがいない(先頭)またはリーダーが死んだ場合
super.update();
if (!this.isBoss && this.grounded && Math.random()<0.02) this.vy = -12;
}
if (this.x > cameraX - 100 && this.x < cameraX + CANVAS_WIDTH + 100) {
this.shootTimer -= 1 * timeScale;
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;
}
}
}
draw(ctx, camX, camY) {
if(this.dead) return;
if (this.isLockedOn) {
ctx.fillStyle = COLOR.CYAN_HEX; ctx.shadowBlur = 15; ctx.shadowColor = COLOR.CYAN_HEX;
ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.currentSize, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0; ctx.strokeStyle = COLOR.LOCKON; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.currentSize + 5, 0, Math.PI*2); ctx.stroke();
return;
}
super.draw(ctx, camX, camY);
}
hit(damage, shooterRgb, isCharge) {
if (this.isLockedOn) return false;
this.hp -= damage; this.rgb = blendColor(this.rgb, COLOR.CYAN, 0.4);
if(isCharge) soundManager.playEnemyHit(); else soundManager.playEnemyHit();
if (this.hp <= 0) { this.hp = 0; this.isLockedOn = true; return true; }
return false;
}
}
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 * timeScale;
this.y += this.vy * timeScale;
if (this.x < cameraX - 100 || this.x > cameraX + CANVAS_WIDTH + 100) this.active = false;
}
draw(ctx, camX, camY) {
ctx.fillStyle = rgbToHex(this.rgb);
ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.size, 0, Math.PI * 2); ctx.fill();
if(this.size > 8) { ctx.strokeStyle = "#FFF"; ctx.lineWidth = 2; ctx.stroke(); }
}
}
// === 無限生成とゲーム管理 ===
function getWaveOffset(screenX) {
if (!dataArray) return 0;
const dataIdx = Math.floor((screenX % CANVAS_WIDTH) / CANVAS_WIDTH * dataArray.length);
const val = dataArray[dataIdx] || 128;
return val - 128;
}
function regenerateVisibleLayers() {
const startX = Math.floor(cameraX / BLOCK_SIZE) * BLOCK_SIZE;
const endX = startX + CANVAS_WIDTH + 100;
const layersNeeded = [];
for(let i = -LAYER_KEEP_RANGE; i <= LAYER_KEEP_RANGE; i++) {
layersNeeded.push(playerLayerIndex + i);
}
const existingBlockMap = new Set();
blocks.forEach(b => {
if (!b.destroyed) existingBlockMap.add(`${Math.floor(b.x)}_${b.layerIndex}`);
});
layersNeeded.forEach(layerIdx => {
for (let x = startX; x < endX; x += BLOCK_SIZE) {
const key = `${Math.floor(x)}_${layerIdx}`;
if (!existingBlockMap.has(key)) {
const approxIndex = x / BLOCK_SIZE;
blocks.push(new Block(x, layerIdx, approxIndex));
}
}
});
}
function spawnEnemiesForLayer(layerIdx) {
const baseY = 500 - (layerIdx * LAYER_HEIGHT);
const sides = [cameraX + CANVAS_WIDTH + 100, cameraX - 100];
// スネーク敵の生成 (5匹セット)
sides.forEach(x => {
if(Math.random() < 0.6) {
let leader = null;
// 5匹生成してリーダーに追従させる
for(let i=0; i<5; i++) {
// 少しずつ位置をずらす
let ex = x + (i * 40 * (x > cameraX ? 1 : -1));
let e = new Enemy(ex, baseY, false, leader);
enemies.push(e);
leader = e;
}
}
});
clouds.push(new CloudItem(cameraX + CANVAS_WIDTH + 50, baseY - 400));
}
function updateLevelGeneration() {
const rightEdge = cameraX + CANVAS_WIDTH + 200;
const minLayer = playerLayerIndex - LAYER_KEEP_RANGE;
const maxLayer = playerLayerIndex + LAYER_KEEP_RANGE;
blocks = blocks.filter(b => b.layerIndex >= minLayer && b.layerIndex <= maxLayer && !b.destroyed && b.x > cameraX - 200);
enemies = enemies.filter(e => e.x > cameraX - 200 && !e.dead);
clouds = clouds.filter(c => c.active && c.x > cameraX - 200);
while (lastGeneratedX < rightEdge) {
for (let l = minLayer; l <= maxLayer; l++) {
const b = new Block(lastGeneratedX, l, globalBlockIndex);
if (Math.random() < 0.3) {
clouds.push(new CloudItem(lastGeneratedX, b.baseHeight - 400));
}
if (Math.random() < 0.01) {
// 通常生成時もスネーク
let leader = null;
for(let k=0; k<5; k++) {
let e = new Enemy(lastGeneratedX + (k * 40), b.baseHeight, false, leader);
enemies.push(e);
leader = e;
}
}
blocks.push(b);
}
globalBlockIndex++;
lastGeneratedX += BLOCK_SIZE;
if (lastGeneratedX > nextBossScore) {
const baseBossY = 500 - (playerLayerIndex * LAYER_HEIGHT);
enemies.push(new Enemy(lastGeneratedX + 200, baseBossY, true));
nextBossScore += 4000;
}
}
}
function initGame() {
try {
player = new Player();
bullets = [], enemies = [], blocks = [], stars = [], particles = [], popups = [], clouds = [];
score = 0; frameCount = 0; keys = {};
globalBlockIndex = 0; lastGeneratedX = 0; nextBossScore = 4000;
playerLayerIndex = 0;
timeScale = 1.0;
cameraX = 0;
cameraY = 0;
for(let i=0; i<100; i++) stars.push(new Star());
updateLevelGeneration();
gameState = 'playing';
uiLayer.style.display = 'none'; canvas.style.display = 'block';
soundManager.playBGM(audioBuffer);
if (animationFrameId) cancelAnimationFrame(animationFrameId);
gameLoop();
} catch(e) {
console.error("Init Game Error:", e);
alert("ゲーム開始時にエラーが発生しました。コンソールを確認してください。");
}
}
function update() {
if (gameState !== 'playing') return;
frameCount++;
if (analyser) {
analyser.getByteTimeDomainData(dataArray);
let sum = 0; for(let i=0; i<dataArray.length; i+=10) sum += Math.abs(dataArray[i] - 128);
currentAudioLevel = sum / (dataArray.length / 10) / 128.0;
}
if(screenShake > 0) screenShake *= 0.9;
if(screenShake < 0.5) screenShake = 0;
let targetTimeScale = 1.0;
if (player.state === 'dashing' || player.state === 'returning') {
targetTimeScale = 0.2;
}
timeScale += (targetTimeScale - timeScale) * 0.1;
updateLevelGeneration();
stars.forEach(s => s.update());
blocks.forEach(b => {
let waveOffset = 0;
if (!b.destroyed && b.x >= cameraX - BLOCK_SIZE && b.x <= cameraX + CANVAS_WIDTH) {
waveOffset = getWaveOffset(b.x);
}
b.update(waveOffset);
});
if(player) player.update();
enemies.forEach(e => e.update());
clouds.forEach(c => c.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;
if (t === player) {
t.hit(1);
} else {
let dmg = b.damage;
if (currentAudioLevel > 0.15) { dmg *= 2; soundManager.playCrit(); popups.push(new PopupText(t.x, t.y-20,"CRITICAL!","#FF0")); }
const isCharge = b.size > 20;
t.hit(dmg, b.rgb, isCharge);
}
}
});
});
if(player.state === 'normal') {
enemies.forEach(e => {
if(e.dead || e.isLockedOn || player.dead) return;
const dist = Math.hypot(player.x - e.x, (player.y - player.currentSize/2) - (e.y - e.currentSize/2));
if(dist < player.currentSize + e.currentSize) {
if (player.isStomping && player.y < e.y + e.currentSize) {
e.hit(9999, player.rgb, true);
spawnParticles(e.x, e.y, COLOR.CYAN);
} else if (player.invincible <= 0) {
player.hit(1);
}
}
});
}
const targetCamX = player.x - CANVAS_WIDTH * 0.3;
if (targetCamX > cameraX) cameraX = targetCamX;
const targetCamY = player.y - CANVAS_HEIGHT * 0.6;
cameraY += (targetCamY - cameraY) * 0.1;
}
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;
const kickDir = Math.sin(frameCount * 0.1) > 0 ? 1 : -1;
const angle = (baseSway + (volumeKick * kickDir)) * (Math.PI / 180);
const shakeX = (Math.random() - 0.5) * screenShake;
const shakeY = (Math.random() - 0.5) * screenShake;
ctx.translate(CANVAS_WIDTH/2 + shakeX, CANVAS_HEIGHT/2 + shakeY);
ctx.rotate(angle);
ctx.translate(-CANVAS_WIDTH/2, -CANVAS_HEIGHT/2);
stars.forEach(s => s.draw(ctx, cameraX, cameraY));
blocks.forEach(b => b.draw(ctx, cameraX, cameraY));
particles.forEach(p => p.draw(ctx, cameraX, cameraY));
enemies.forEach(e => e.draw(ctx, cameraX, cameraY));
clouds.forEach(c => c.draw(ctx, cameraX, cameraY));
if(player) player.draw(ctx, cameraX, cameraY);
bullets.forEach(b => b.draw(ctx, cameraX, cameraY));
popups.forEach(p => p.draw(ctx, cameraX, cameraY));
ctx.restore();
if (gameState === 'playing') drawUI();
else if (gameState === 'paused') drawPauseScreen();
else if (gameState === 'gameover' || gameState === 'gameclear') drawEndScreen();
}
function drawUI() {
if(!player) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
ctx.fillRect(0, 0, CANVAS_WIDTH, 60);
const uiX = 20; const uiY = 20;
for(let i=0; i<player.hp; i++) {
ctx.fillStyle = COLOR.CYAN_HEX;
ctx.beginPath();
const px = uiX + (i * 22); const py = uiY + 10;
ctx.arc(px, py, 8, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = "#FFF"; ctx.lineWidth = 1; ctx.stroke();
}
if(player.hp <= 1) {
ctx.fillStyle = "red"; ctx.font = "bold 14px Arial";
ctx.fillText("CRITICAL CONDITION!", uiX, uiY + 32);
}
ctx.fillStyle = "white";
ctx.textAlign = "right"; ctx.font = "bold 24px Arial";
ctx.fillText(`SCORE: ${score}`, CANVAS_WIDTH - 20, 45);
ctx.font = "bold 18px Arial";
const depthText = playerLayerIndex >= 0 ? `ALTITUDE: ${playerLayerIndex}` : `DEPTH: ${Math.abs(playerLayerIndex)}`;
ctx.fillText(depthText, CANVAS_WIDTH - 200, 45);
}
function drawPauseScreen() {
drawUI();
ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(0,0,CANVAS_WIDTH,CANVAS_HEIGHT);
ctx.fillStyle = "white"; ctx.textAlign = "center";
ctx.font = "bold 60px Arial Black";
ctx.fillText("PAUSE", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
ctx.font = "20px Arial";
ctx.fillText("Press ESC to Resume", CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 50);
}
function drawEndScreen() {
ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0,0,CANVAS_WIDTH,CANVAS_HEIGHT);
if (gameState === 'gameclear') {
if(Math.random() < 0.1) spawnFirework(cameraX + Math.random()*CANVAS_WIDTH, Math.random()*CANVAS_HEIGHT/2);
ctx.fillStyle = "white"; ctx.textAlign = "center";
ctx.font = "bold 80px Arial Black";
ctx.fillText("GAME CLEAR!!", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
} else {
ctx.fillStyle = "white"; ctx.textAlign = "center";
ctx.font = "bold 80px Arial Black";
ctx.fillText("GAME OVER", CANVAS_WIDTH/2, CANVAS_HEIGHT/2);
}
ctx.font = "bold 40px Arial";
ctx.fillText(`Final Score: ${score}`, CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 100);
ctx.font = "bold 20px Arial";
ctx.fillText(`Final Layer: ${playerLayerIndex}`, CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 140);
ctx.font = "20px Arial";
ctx.fillText("Restarting...", CANVAS_WIDTH/2, CANVAS_HEIGHT/2 + 190);
}
function endGame(win) {
if(gameState !== 'playing') return;
gameState = win ? 'gameclear' : 'gameover';
soundManager.stopBGM();
if(win) soundManager.playClear();
setTimeout(initGame, 5000);
}
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', async () => {
if (!audioBuffer) return;
if (soundManager.ctx.state === 'suspended') {
try { await soundManager.ctx.resume(); } catch(e) { console.error("Audio resume failed", e); }
}
initGame();
});
window.addEventListener('keydown', e => {
if (e.code === 'Escape') {
if (gameState === 'playing') {
gameState = 'paused';
soundManager.suspend();
} else if (gameState === 'paused') {
gameState = 'playing';
soundManager.resume();
}
}
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上にいくつか作られました。
《このゲームの独自性》
・「音楽からステージを作る」ゲームはありますが、
多くは「事前に解析して固定のコースを作る」もので、
今回のように「リアルタイムに波形そのものが物理的な足場として
暴れまわる」というアプローチは、製品レベルのゲームでは
意外と珍しい。
・多くの音ゲーは、音を「譜面(タイミング)」として扱いますが、
このツールは音を「物理エネルギー(高さとバネ)」として扱っている。
・音が大きくなると地面が物理的にプレイヤーを跳ね飛ばす、
という「音の暴力性」をアクションに転化している点がユニーク。


コメント