音で作る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公開。
・2026/2/4 バージョン1.7公開。
・ダウンロードされる方はこちら。↓
《操作説明》
・方向キーで移動。
・Zキーでショットを撃つ。(押しっぱなしだと、貯め撃ち)
・Xキーまたは上キーで通常ジャンプ。
(地面が点滅している時に押すと雲まで届く)
・下キーで急降下して敵を踏み潰し、
地面を突き破って下の階層へ行ける。
・貯め撃ちすると、ショットサイズが大きくなり、
当たりやすくなる。(ダメージも増えていく。)
(※チャージ中は、自機が膨らむので、当たりやすくなる。)
・HPが減ると、ショットのダメージが減る。
・敵を倒すと、ロックオンされるので、
そこでAキーを押すと、ダッシュで敵に衝突し、
敵を吸収してHPが1回復する。
・ロックオンされていない時にAキーを押すと、
進行方向にダッシュする。(物にぶつかると止まる)
・ジャンプして雲に触れると、HPが1回復する。
・地面が跳ね上がった瞬間にSキーを押すと
ハイジャンプして、上の階層へ行ける。
(※途中で別のアクションのキーを押すと中断する。)
・escキーでゲームを一時停止。もう一度押すと再開。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Wave Ground 1.7</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000;
font-family: 'Arial Black', Impact, sans-serif;
height: 100vh;
width: 100vw;
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;
width: 100%;
height: 100%;
}
.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 1.7</h1>
<p style="font-size: 14px; color:#aaa;"></p>
<div class="key-instruction">
<span class="tag">移動: ←→</span> <span class="tag">通常ジャンプ: ↑ or X</span><br>
<span class="tag highlight">ハイジャンプ: S (地面点滅時)</span> <span class="tag">ショット: Z (長押しチャージ)</span><br>
<span class="tag">ダッシュ: A</span> <span class="tag">踏み潰し: ↓</span><br><br>
<span class="highlight">【修正完了】</span><br>
<span style="color:#F0F">完全版</span>: 全クラスを実装済み。エラー修正。<br>
<span style="color:#FF0">爆発音</span>: 「ドドン!」という重低音。<br>
<span style="color:#0FF">操作</span>: A/Z/Xでハイジャンプ中断。<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>
// === 設定・定数 ===
let CANVAS_WIDTH = window.innerWidth;
let CANVAS_HEIGHT = window.innerHeight;
const BLOCK_SIZE = 25;
const BLOCK_THICKNESS = 10;
const MAX_HP = 12;
const INITIAL_HP = 6;
const LAYER_HEIGHT = 1800;
const LAYER_KEEP_RANGE = 3;
const ROTATION_CYCLE = 1800;
const ROTATION_DURATION = 600;
const COLOR = {
BG: "#000000",
BLOCK_OUTLINE: "rgba(0,0,0,0.2)",
PLAYER: {R:50, G:100, B:255},
ENEMY: {R:200, G:50, B:200},
BOSS: {R:255, G:50, 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;
window.addEventListener('resize', () => {
CANVAS_WIDTH = window.innerWidth;
CANVAS_HEIGHT = window.innerHeight;
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)();
this.noiseBuffer = null;
}
async loadFile(file) {
if(this.ctx.state === 'suspended') await this.ctx.resume();
if (!this.noiseBuffer) {
const bufferSize = this.ctx.sampleRate * 2;
this.noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
}
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);
}
playNoise(dur, vol, filterType = 'lowpass', filterFreq = 1000) {
if(this.ctx.state === 'suspended' || !this.noiseBuffer) return;
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuffer;
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
filter.type = filterType;
filter.frequency.value = filterFreq;
src.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
src.start();
src.stop(this.ctx.currentTime + dur);
}
playExplosion() {
if(this.ctx.state === 'suspended' || !this.noiseBuffer) return;
const osc = this.ctx.createOscillator();
const oscGain = this.ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(150, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(10, this.ctx.currentTime + 0.3);
oscGain.gain.setValueAtTime(0.5, this.ctx.currentTime);
oscGain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3);
osc.connect(oscGain);
oscGain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.3);
this.playNoise(0.4, 0.8, 'lowpass', 500);
}
playSprayShot() { this.playNoise(0.2, 0.2, 'highpass', 2000); }
playHeavySprayShot() {
this.playNoise(0.5, 0.4, 'bandpass', 1000);
this.playOsc(200, 'sawtooth', 0.2, 0.1, 50);
}
playJump() { this.playOsc(150, 'sawtooth', 0.3, 0.2, 600); }
playSuperJump() {
this.playOsc(300, 'square', 0.5, 0.3, 1500);
this.playNoise(0.4, 0.1, 'lowpass', 1000);
}
playBoost() {
this.playOsc(600, 'sawtooth', 0.2, 0.2, 100);
this.playNoise(0.2, 0.1, 'bandpass', 5000);
}
playStomp() {
this.playOsc(800, 'square', 0.3, 0.3, 50);
this.playNoise(0.3, 0.2, 800);
}
playImpact() { this.playOsc(100, 'sawtooth', 0.4, 0.5, 10); }
playBreak() { this.playExplosion(); }
playEnemyHit() { this.playExplosion(); }
playBossHit() {
this.playNoise(0.8, 0.6, 'lowpass', 500);
this.playOsc(100, 'square', 0.6, 0.5, 10);
}
playChargeShot() { this.playHeavySprayShot(); } // エイリアス
playShot() { this.playSprayShot(); } // エイリアス
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.4, 20);
this.playOsc(80, 'square', 0.3, 0.4, 10);
this.playNoise(0.3, 0.3, 'lowpass', 500);
}
playStompStart() { this.playOsc(800, 'square', 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) * 10;
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 ? 15 : 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.8 : 1.0);
this.decay = Math.random() * 0.02 + (isSpark ? 0.04 : 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.3) * 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, 5, 5);
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<8; 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;
this.damage = 0;
}
getTopY() {
return this.y + (this.damage * BLOCK_SIZE);
}
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;
let drawColor = this.color;
let brightness = 0;
if (this.vy < -0.1) {
const intensity = Math.min(1.0, Math.abs(this.vy) / 2.5);
brightness = intensity * 100;
if (this.vy < -2.0) {
if (Math.floor(frameCount / 3) % 2 === 0) {
brightness += 150;
}
}
drawColor = adjustBrightness(this.color, brightness);
}
ctx.strokeStyle = COLOR.BLOCK_OUTLINE;
ctx.lineWidth = 1;
for(let i = this.damage; i < BLOCK_THICKNESS; i++) {
const drawY = this.y - camY + (i * BLOCK_SIZE);
if (drawY > CANVAS_HEIGHT + 100 || drawY < -100) continue;
let sideColor = drawColor;
if (i > 0) sideColor = adjustBrightness(drawColor, -8 * i);
ctx.fillStyle = sideColor;
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}];
this.vx = 0;
}
update() {
if(this.x < cameraX - 100) this.active = false;
}
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;
this.blinkTimer = 0;
this.knockbackVx = 0;
this.knockbackVy = 0;
this.animScaleX = 1.0;
this.animScaleY = 1.0;
}
draw(ctx, camX, camY) {
if (this.dead) return;
ctx.save();
ctx.translate(this.x - camX, this.y - camY);
ctx.scale(this.animScaleX, this.animScaleY);
if (this.blinkTimer > 0) {
if (Math.floor(this.blinkTimer / 3) % 2 === 0) {
ctx.fillStyle = "#FFFFFF";
} else {
ctx.fillStyle = rgbToHex(this.rgb);
}
} else {
ctx.fillStyle = rgbToHex(this.rgb);
}
ctx.beginPath();
ctx.arc(0, -this.currentSize/2, this.currentSize, Math.PI, 0);
ctx.fillRect(-this.currentSize, -this.currentSize/2, this.currentSize*2, this.currentSize/2);
ctx.fill();
ctx.restore();
}
update() {
this.x += this.knockbackVx;
this.y += this.knockbackVy;
this.knockbackVx *= 0.9;
this.knockbackVy *= 0.9;
if(this.blinkTimer > 0) this.blinkTimer--;
this.animScaleX += (1.0 - this.animScaleX) * 0.1;
this.animScaleY += (1.0 - this.animScaleY) * 0.1;
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;
const topY = b.getTopY();
if (checkX >= b.x && checkX < b.x + BLOCK_SIZE) {
if (checkY >= topY && checkY <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS) + 50) {
spawnBlockDebris(b.x + BLOCK_SIZE/2, topY, b.color);
soundManager.playBreak();
b.destroyed = true;
}
}
});
}
// 雲接触
if (this.vy < 0 && this instanceof Player) {
const hitClouds = clouds.filter(c =>
c.active &&
Math.abs(this.x - c.x) < 40 &&
this.y >= c.y && this.y <= c.y + 40
);
if(hitClouds.length > 0) {
const c = hitClouds[0];
c.active = false;
this.heal();
score += 1;
popups.push(new PopupText(this.x, this.y - 40, "HP+1 SCORE+1", "#FFF", 20));
soundManager.playItemGet();
}
}
// 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.getTopY() - 15 && this.y <= b.y + (BLOCK_SIZE * BLOCK_THICKNESS)
);
const hitEnemies = (this instanceof Player) ? enemies.filter(e =>
!e.dead && !e.isLockedOn &&
Math.abs(this.x - e.x) < (this.currentSize + e.currentSize) &&
this.y >= e.y - 20 && this.y <= e.y + 20
) : [];
let candidates = [];
if(hitBlocks.length > 0) {
hitBlocks.sort((a,b) => a.getTopY() - b.getTopY());
candidates.push({type:'block', obj:hitBlocks[0], y:hitBlocks[0].getTopY()});
}
if(hitEnemies.length > 0) candidates.push({type:'enemy', obj:hitEnemies[0], y:hitEnemies[0].y});
if(candidates.length > 0) {
candidates.sort((a,b) => a.y - b.y);
const hit = candidates[0];
if (this instanceof Player && this.isStomping) {
if (hit.type === 'block') {
hit.obj.damage++;
spawnBlockDebris(hit.obj.x + BLOCK_SIZE/2, hit.y, hit.obj.color);
soundManager.playBreak();
if (hit.obj.damage >= BLOCK_THICKNESS) {
hit.obj.destroyed = true;
this.vy = -15;
this.y = hit.y - 30;
this.isStomping = false;
this.scale = 1.0;
this.state = 'normal';
popups.push(new PopupText(this.x, this.y, "CRUSH!", "#F0F", 25));
return;
} else {
this.y = hit.obj.getTopY();
this.vy = 5;
}
} else if (hit.type === 'enemy') {
const enemy = hit.obj;
const stompDmg = 50;
if (enemy.hp <= stompDmg) {
enemy.hit(999, null, false);
this.heal();
soundManager.playItemGet();
} else {
enemy.hit(stompDmg, null, true);
this.hit(1);
this.isStomping = false;
this.vy = -10;
this.scale = 1.0;
}
}
} else {
this.y = hit.y;
if (hit.type === 'block') {
this.vy = hit.obj.vy < 0 ? hit.obj.vy * 0.1 : 0;
this.groundBlock = hit.obj;
} else {
this.vy = 0;
}
this.grounded = true;
this.isStomping = false;
}
}
}
}
}
class Player extends Entity {
constructor() {
super(cameraX + CANVAS_WIDTH / 2, 300, 10, COLOR.PLAYER);
this.maxHp = MAX_HP; this.hp = INITIAL_HP;
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;
this.facing = 1;
this.superJumpStartLayer = 0;
}
update() {
if (this.dead && gameState === 'playing') endGame(false);
if (this.invincible > 0) this.invincible--;
if (keys['ArrowRight']) this.facing = 1;
if (keys['ArrowLeft']) this.facing = -1;
if (this.state === 'superJumping') {
if (keys['KeyA'] || keys['KeyZ'] || keys['KeyX']) {
this.state = 'normal';
this.vy *= 0.5;
}
}
if (this.isStomping) {
const keysPressed = Object.keys(keys).filter(k => keys[k]);
if (keysPressed.some(k => k !== 'ArrowDown')) {
this.state = 'normal';
this.isStomping = false;
this.scale = 1.0;
this.vy *= 0.5;
}
}
if (this.state === 'normal' || this.state === 'superJumping') this.updateNormal();
else if (this.state === 'dashing') this.updateDashing();
else if (this.state === 'freeDashing') this.updateFreeDashing();
else if (this.state === 'returning') this.updateReturning();
const margin = 20;
if (this.x < cameraX + margin) {
this.x = cameraX + margin;
if (this.vx < 0) this.vx *= -0.5;
}
if (this.x > cameraX + CANVAS_WIDTH - margin) {
this.x = cameraX + CANVAS_WIDTH - margin;
if (this.vx > 0) this.vx *= -0.5;
}
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.state === 'freeDashing' || 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);
}
if (this.state === 'superJumping') {
this.vy -= 0.3 * timeScale;
}
super.update();
}
draw(ctx, camX, camY) {
if(this.blinkTimer > 0 && Math.floor(this.blinkTimer/4)%2===0) return;
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 if (this.state === 'dashing' || this.state === 'freeDashing') ctx.fillStyle = `rgb(0, 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);
if (this.chargeFrames > 60) {
let level = Math.floor(1.0 + (this.chargeFrames / 30));
if (level > 5) level = 5;
ctx.fillStyle = COLOR.CYAN_HEX;
for(let i=0; i < level; i++) {
const shotY = this.y - camY - this.currentSize - 15 - (i * 12);
const shotX = this.x - camX;
ctx.beginPath();
ctx.arc(shotX, shotY, 5, 0, Math.PI*2);
ctx.fill();
}
}
}
updateNormal() {
if (this.blinkTimer > 0) return;
this.vx = keys['ArrowLeft'] ? -5 : (keys['ArrowRight'] ? 5 : 0);
if (this.grounded) {
if (keys['KeyS']) {
if (this.groundBlock && this.groundBlock.vy < -2.0) {
this.vy = -45;
soundManager.playSuperJump();
this.grounded = false;
this.state = 'superJumping';
this.boostTimer = 70;
this.superJumpStartLayer = playerLayerIndex;
spawnParticles(this.x, this.y, "#FF0");
this.animScaleX = 0.6;
this.animScaleY = 1.4;
return;
}
}
if (keys['KeyX'] || keys['ArrowUp']) {
this.vy = -20;
if (this.groundBlock) this.vy += this.groundBlock.vy * 0.1;
soundManager.playJump();
this.grounded = false;
this.animScaleX = 0.8;
this.animScaleY = 1.2;
}
}
if (this.state === 'superJumping') {
if (keys['ArrowUp'] && this.boostTimer > 0) {
this.boostTimer--;
if (frameCount % 4 === 0) {
spawnParticles(this.x, this.y + 10, "#FFF");
soundManager.playBoost();
}
}
}
if (this.state === 'normal' && !this.isStomping) {
if (keys['KeyZ']) {
this.chargeFrames++;
const maxCharge = 5.0;
if (this.scale < maxCharge) {
this.scale = 1.0 + (this.chargeFrames / 30);
if(this.scale > maxCharge) this.scale = maxCharge;
}
} else {
if (this.chargeFrames > 0) {
if (this.chargeFrames < 60) {
const shotSize = 8;
const dmg = 10;
bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 0, 0, true, this.rgb, dmg, shotSize, false));
soundManager.playShot();
} else {
let level = Math.floor(this.scale);
if (level < 2) level = 2;
for(let i=0; i<level; i++) {
setTimeout(() => {
if (this.hp > 0) {
const shotSize = 25 + (i * 5);
const dmg = 40;
bullets.push(new Bullet(this.x, this.y - this.currentSize/2, 10 * this.facing, 0, true, this.rgb, dmg, shotSize, true, true, i));
soundManager.playChargeShot();
screenShake = 5;
}
}, i * 80);
}
this.hp -= 1;
}
}
this.chargeFrames = 0;
this.scale = 1.0;
}
if (keys['KeyA']) {
const targets = enemies.filter(e =>
e.isLockedOn && !e.dead && e.x > cameraX &&
Math.abs(e.y - this.y) < 800
).sort((a,b) => a.x - b.x);
if (targets.length > 0) {
this.startChainDash(targets);
} else {
this.startFreeDash();
}
return;
}
}
if (keys['ArrowDown'] && !this.grounded && !this.isStomping) {
soundManager.playStompStart();
this.scale = 5.0;
this.vy = 15;
this.isStomping = true;
this.state = 'normal';
}
}
checkSuperJumpLanding() {
if (this.state === 'superJumping') {
const targetBaseY = 500 - ((this.superJumpStartLayer + 1) * LAYER_HEIGHT);
if (this.y <= targetBaseY + 100 && this.vy < 0) {
this.y = targetBaseY;
this.vy = 0;
this.state = 'normal';
this.grounded = true;
spawnParticles(this.x, this.y, "#FFF");
}
}
}
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;
this.animScaleX = 1.8;
this.animScaleY = 0.6;
soundManager.playJump();
}
updateDashing() {
this.animScaleX = 1.8;
this.animScaleY = 0.6;
if (this.currentDashIndex >= this.dashTargets.length) {
this.state = 'returning';
timeScale = 1.0;
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();
if (target.isBossOrb) {
this.hp = this.maxHp;
}
this.comboCount++;
const earnedScore = (target.isBoss ? 100 : 500) * this.comboCount;
const popupText = target.isBoss ? `+${earnedScore}` : `+${this.comboCount}`;
popups.push(new PopupText(this.x, this.y-40, popupText, "#FFF", 30));
spawnParticles(target.x, target.y, COLOR.CYAN_HEX);
if (target instanceof Enemy) {
target.dead = true;
if(target.isBoss) soundManager.playBossHit();
else soundManager.playEnemyHit();
score += earnedScore;
}
soundManager.playCombo(); this.currentDashIndex++;
} else {
const speed = 60;
this.x += (dx / dist) * speed;
this.y += (dy / dist) * speed;
}
}
startFreeDash() {
this.state = 'freeDashing';
this.invincible = 20;
this.isStomping = false; this.scale = 1.0;
this.vx = 40 * this.facing;
this.vy = 0;
this.animScaleX = 1.8;
this.animScaleY = 0.6;
soundManager.playBoost();
}
updateFreeDashing() {
this.animScaleX = 1.8;
this.animScaleY = 0.6;
this.x += this.vx * timeScale;
if (this.x <= cameraX + 20 || this.x >= cameraX + CANVAS_WIDTH - 20) {
this.state = 'normal';
this.vx = -this.vx * 0.5;
return;
}
for(let e of enemies) {
if(!e.dead && !e.isLockedOn) {
const dist = Math.hypot(this.x - e.x, this.y - e.y);
if(dist < this.currentSize + e.currentSize + 10) {
e.hit(30, null, true);
this.state = 'normal';
this.vx = -10 * this.facing;
this.vy = -10;
screenShake = 10;
return;
}
}
}
this.vx *= 0.95;
if(Math.abs(this.vx) < 5) {
this.state = 'normal';
}
}
updateReturning() {
this.animScaleX = 1.8;
this.animScaleY = 0.6;
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;
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 -= damage;
this.invincible = 30;
this.blinkTimer = 30;
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;
this.maxHp = isBoss ? 300 : 30;
this.hp = this.maxHp;
this.isBossOrb = false;
if(isBoss) {
this.vx = 5;
this.vy = 5;
}
this.leader = leader; this.followDelay = [];
this.shootTimer = Math.random() * 200; this.isLockedOn = false;
}
update() {
if (this.dead) return;
super.update();
if (this.isLockedOn) {
return;
}
if (this.blinkTimer > 0) return;
if (this.isBoss) {
this.x += this.vx * timeScale;
this.y += this.vy * timeScale;
const boundLeft = cameraX;
const boundRight = cameraX + CANVAS_WIDTH;
const boundTop = cameraY;
const boundBottom = cameraY + CANVAS_HEIGHT;
if (this.x < boundLeft && this.vx < 0) this.vx *= -1;
if (this.x > boundRight && this.vx > 0) this.vx *= -1;
if (this.y < boundTop && this.vy < 0) this.vy *= -1;
if (this.y > boundBottom && this.vy > 0) this.vy *= -1;
} else 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 {
if (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) {
if (player) {
const angle = Math.atan2(player.y - this.y, player.x - this.x);
const spd = 6;
bullets.push(new Bullet(this.x, this.y - 10, Math.cos(angle)*spd, Math.sin(angle)*spd, false, this.rgb, 1, 5));
soundManager.playShot();
}
this.shootTimer = this.isBoss ? 60 : 200 + Math.random()*200;
}
}
}
draw(ctx, camX, camY) {
if(this.dead) return;
if (this.isLockedOn) {
ctx.save();
ctx.translate(this.x - camX, this.y - camY);
ctx.fillStyle = COLOR.CYAN_HEX;
ctx.shadowBlur = 15; ctx.shadowColor = COLOR.CYAN_HEX;
ctx.beginPath(); ctx.arc(0, 0, this.currentSize, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0; ctx.strokeStyle = COLOR.LOCKON; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(0, 0, this.currentSize + 5, 0, Math.PI*2); ctx.stroke();
ctx.restore();
return;
}
const hpRatio = this.hp / this.maxHp;
const blended = blendColor(COLOR.ENEMY, COLOR.CYAN, 1.0 - hpRatio);
this.rgb = blended;
super.draw(ctx, camX, camY);
}
hit(damage, shooterRgb, isKnockback) {
if (this.isLockedOn) return false;
this.hp -= damage;
this.blinkTimer = 10;
if(isKnockback) {
const dir = (player.x < this.x) ? 1 : -1;
this.knockbackVx = 10 * dir;
this.knockbackVy = -5;
}
if (shooterRgb) soundManager.playImpact();
if (this.hp <= 0) {
this.hp = 0;
this.isLockedOn = true;
if(this.isBoss) this.isBossOrb = true;
if (player) {
const angle = Math.atan2(this.y - player.y, this.x - player.x);
const blowSpeed = 15;
this.knockbackVx = Math.cos(angle) * blowSpeed;
this.knockbackVy = Math.sin(angle) * blowSpeed;
}
if(this.isBoss) soundManager.playBossHit();
else soundManager.playEnemyHit();
return true;
}
return false;
}
}
class Bullet {
constructor(x, y, vx, vy, isPlayerShot, rgb, damage, size, penetrate=false, isSpiral=false, spiralDelay=0) {
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;
this.penetrate = penetrate;
this.isSpiral = isSpiral;
this.hitList = [];
this.lifeTime = 0;
this.spiralDelay = spiralDelay;
this.baseY = y;
this.trail = [];
}
update() {
this.lifeTime++;
if (this.isSpiral) {
this.vx *= 1.05;
const freq = 0.5;
const amp = 30;
const time = this.lifeTime * freq + this.spiralDelay;
this.y += Math.sin(time) * 5 * timeScale;
if (this.lifeTime % 2 === 0) {
this.trail.push({x:this.x, y:this.y, alpha:0.8, size:this.size});
}
}
else if (this.isPlayerShot && this.active && !this.penetrate) {
let nearest = null;
let minDst = 99999;
for(let e of enemies) {
if (e.dead || e.isLockedOn || e.x < cameraX) continue;
if (Math.abs(e.y - this.y) > 800) continue;
const dst = Math.hypot(e.x - this.x, e.y - this.y);
if (dst < minDst) { minDst = dst; nearest = e; }
}
if (nearest) {
const angle = Math.atan2(nearest.y - this.y, nearest.x - this.x);
const acc = 2.0 * timeScale;
this.vx += Math.cos(angle) * acc;
this.vy += Math.sin(angle) * acc;
const currentSpeed = Math.hypot(this.vx, this.vy);
if (currentSpeed > 20) {
const ratio = 20 / currentSpeed;
this.vx *= ratio; this.vy *= ratio;
}
}
}
this.x += this.vx * timeScale;
this.y += this.vy * timeScale;
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);
}
if (this.x < cameraX - 100 || this.x > cameraX + CANVAS_WIDTH + 100) this.active = false;
}
draw(ctx, camX, camY) {
for(let t of this.trail) {
ctx.globalAlpha = t.alpha;
ctx.fillStyle = COLOR.CYAN_HEX;
ctx.beginPath(); ctx.arc(t.x - camX, t.y - camY, t.size, 0, Math.PI*2); ctx.fill();
}
ctx.globalAlpha = 1.0;
if (this.isPlayerShot && !this.penetrate && !this.isSpiral) {
ctx.fillStyle = COLOR.CYAN_HEX;
ctx.beginPath(); ctx.arc(this.x - camX, this.y - camY, this.size, 0, Math.PI * 2); ctx.fill();
} else {
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];
sides.forEach(x => {
if(Math.random() < 0.9) {
let leader = null;
const dir = (x > cameraX) ? 1 : -1;
for(let i=0; i<5; i++) {
let ex = x + (i * 50 * dir);
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.04) {
let leader = null;
for(let k=0; k<5; k++) {
let e = new Enemy(lastGeneratedX + (k * 50), 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() {
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();
}
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.05;
}
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();
player.checkSuperJumpLanding();
}
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 && !b.penetrate && !b.isSpiral) return;
if (t.dead || (t.isLockedOn && b.isPlayerShot) || (t instanceof Player && t.invincible > 0)) return;
if (b.penetrate && b.hitList.includes(t)) return;
const dist = Math.hypot(b.x - t.x, b.y - (t.y - t.currentSize/2));
if (dist < t.currentSize + b.size) {
if (t === player) {
t.hit(1);
b.active = false;
} else {
t.hit(b.damage, b.rgb, true);
if (b.penetrate || b.isSpiral) {
b.hitList.push(t);
} else {
b.active = false;
}
}
}
});
});
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.y < e.y - 10) return;
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 cyclePos = frameCount % ROTATION_CYCLE;
let largeSway = 0;
if (cyclePos < ROTATION_DURATION) {
const progress = cyclePos / ROTATION_DURATION;
largeSway = Math.sin(progress * Math.PI) * (Math.PI / 2.2);
}
const smallSway = Math.sin(frameCount * 0.1) * (Math.PI / 36);
const angle = largeSway + smallSway;
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 <= 2) {
ctx.fillStyle = "red"; ctx.font = "bold 14px Arial";
ctx.textAlign = "left";
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);
}
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上にいくつか作られました。
《このゲームの独自性》
・「音楽からステージを作る」ゲームはありますが、
多くは「事前に解析して固定のコースを作る」もので、
今回のように「リアルタイムに波形そのものが物理的な足場として
暴れまわる」というアプローチは、製品レベルのゲームでは
意外と珍しい。
・多くの音ゲーは、音を「譜面(タイミング)」として扱いますが、
このツールは音を「物理エネルギー(高さとバネ)」として扱っている。
・音が大きくなると地面が物理的にプレイヤーを跳ね飛ばす、
という「音の暴力性」をアクションに転化している点がユニーク。


コメント