MidiSmash
・ダウンロードされる方はこちら。↓
https://drive.google.com/drive/folders/1vL4iwMBDM9Ibu4A5FEj3T-YGG20Wcfbb?usp=drive_link
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>MIDI Smash 1.0</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
<script src="https://unpkg.com/@tonejs/midi"></script>
<style>
body { margin: 0; background: #111; color: white; font-family: 'Segoe UI', sans-serif; overflow: hidden; user-select: none; }
#gameContainer {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #000;
}
canvas {
background: #222;
box-shadow: 0 0 20px rgba(0,0,0,0.8);
}
#ui {
position: absolute; top: 0; left: 0; width: 100%;
padding: 10px; box-sizing: border-box;
background: rgba(0, 0, 0, 0.8);
display: flex; justify-content: space-between; align-items: center;
z-index: 10;
}
.hud-group { display: flex; gap: 15px; align-items: center; font-size: 14px; }
.bar-container { width: 200px; height: 10px; background: #444; border: 1px solid #666; position: relative; }
#hpBar { width: 100%; height: 100%; background: #0f0; transition: width 0.1s, background-color 0.2s; }
button, input[type=file] {
background: #333; color: white; border: 1px solid #777;
padding: 5px 10px; cursor: pointer; border-radius: 4px; font-size: 12px;
}
button:hover { background: #555; }
button:disabled { background: #222; color: #555; cursor: default; }
#overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85);
display: flex; flex-direction: column; justify-content: center; align-items: center;
z-index: 20;
display: none; /* 初期は非表示、JSで制御 */
}
#overlay h1 { font-size: 48px; color: #fff; text-shadow: 0 0 10px #f0f; margin-bottom: 20px; }
#overlay p { font-size: 18px; color: #ccc; margin-bottom: 30px; text-align: center; line-height: 1.6; }
#overlay .key { color: #ffeb3b; font-weight: bold; border: 1px solid #666; padding: 2px 6px; border-radius: 4px; }
#comboText {
position: absolute; top: 20%; left: 50%; transform: translate(-50%, -50%);
font-size: 40px; font-weight: bold; color: rgba(255, 255, 255, 0.2);
pointer-events: none; z-index: 5;
}
</style>
</head>
<body>
<div id="ui">
<div class="hud-group">
<div>HP:</div>
<div class="bar-container"><div id="hpBar"></div></div>
<div id="scoreDisplay">Score: 0</div>
</div>
<div class="hud-group">
<input type="file" id="fileInput" accept=".mid,.midi">
<button id="resetBtn">リセット</button>
</div>
</div>
<div id="gameContainer">
<canvas id="gameCanvas"></canvas>
<div id="comboText"></div>
<div id="overlay">
<h1 id="overlayTitle">MIDI SMASH</h1>
<p id="overlayMessage">
MIDIファイルをロードしてください。<br><br>
[矢印キー] 移動<br>
[Z] 誘導ショット (同じ色の敵を狙う)<br>
[X] ジャンプ (緊急回避)<br>
[ESC] ポーズ
</p>
<button id="startBtn" style="font-size: 24px; padding: 10px 40px;" disabled>START</button>
</div>
</div>
<script>
// --- 定数・設定 ---
const COLORS = ["#ff5252", "#448aff", "#69f0ae", "#ffd740", "#e040fb", "#536dfe", "#ff9800", "#00bcd4"];
const PLAYER_SPEED = 4;
const SHOT_SPEED = 12;
const ENEMY_SPEED_BASE = 1.5;
const STAR_LEAD_TIME = 1.5; // ノート発音の何秒前に星を出すか
const MAX_HP = 100;
// --- グローバル変数 ---
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// ゲーム状態
let gameState = 'init'; // init, ready, playing, paused, gameover, clear
let lastTime = 0;
let gameTime = 0;
let score = 0;
let parsedNotes = []; // MIDIデータ
let spawnIndex = 0; // 次に敵として出すノートのインデックス
let starIndex = 0; // 次に星として出すノートのインデックス
let songDuration = 0;
// キー入力管理
const keys = { ArrowUp:false, ArrowDown:false, ArrowLeft:false, ArrowRight:false, z:false, x:false };
// エンティティ
let player = { x: 0, y: 0, r: 15, color: "#fff", hp: MAX_HP, jumpHeight: 0, isJumping: false, jumpVel: 0 };
let enemies = [];
let bullets = [];
let stars = [];
let particles = [];
// 音源
let synths = [];
let isAudioInitialized = false;
// UI要素
const hpBar = document.getElementById('hpBar');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('overlayTitle');
const overlayMsg = document.getElementById('overlayMessage');
const startBtn = document.getElementById('startBtn');
const comboText = document.getElementById('comboText');
// --- 初期化 ---
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
if(gameState === 'init' || gameState === 'ready') {
player.x = canvas.width / 2;
player.y = canvas.height / 2;
}
}
window.addEventListener('resize', resize);
resize();
// --- Tone.js セットアップ ---
async function initAudio() {
if(isAudioInitialized) return;
await Tone.start();
// 簡易シンセ(トラックごとに少し音色を変える)
const synthTypes = ['triangle', 'square', 'sawtooth'];
for(let i=0; i<8; i++) {
synths.push(new Tone.PolySynth(Tone.Synth, {
volume: -8,
oscillator: { type: synthTypes[i % 3] },
envelope: { attack: 0.02, decay: 0.1, sustain: 0.1, release: 1 }
}).toDestination());
}
isAudioInitialized = true;
}
// --- ゲームループ ---
function gameLoop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
if (gameState === 'playing') {
update(dt);
draw();
} else if (gameState === 'paused') {
// 描画だけ更新(止まっている様子を見せる)
draw();
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillRect(0,0,canvas.width, canvas.height);
ctx.fillStyle = "white";
ctx.font = "30px sans-serif";
ctx.textAlign = "center";
ctx.fillText("PAUSED", canvas.width/2, canvas.height/2);
}
requestAnimationFrame(gameLoop);
}
// --- 更新処理 (Update) ---
function update(dt) {
gameTime += dt;
// 1. プレイヤー移動
if (!player.isJumping) {
if (keys.ArrowUp) player.y -= PLAYER_SPEED;
if (keys.ArrowDown) player.y += PLAYER_SPEED;
if (keys.ArrowLeft) player.x -= PLAYER_SPEED;
if (keys.ArrowRight) player.x += PLAYER_SPEED;
}
// 画面端制限
player.x = Math.max(player.r, Math.min(canvas.width - player.r, player.x));
player.y = Math.max(player.r, Math.min(canvas.height - player.r, player.y));
// ジャンプ処理
if (keys.x && !player.isJumping) {
player.isJumping = true;
player.jumpVel = 10;
}
if (player.isJumping) {
player.jumpHeight += player.jumpVel;
player.jumpVel -= 0.6; // 重力
if (player.jumpHeight <= 0) {
player.jumpHeight = 0;
player.isJumping = false;
}
}
// 2. ショット発射 (Zキー押下時)
// 押しっぱなし連射防止のため、単純なカウンタ等の制御は省略し、ここでは「押された瞬間」またはレート制御が必要
// 今回は簡易的に「毎フレーム判定」だが、実用上はクールタイムを入れる
if (keys.z && player.coolTime <= 0) {
shootHomingBullet();
player.coolTime = 0.15; // 連射速度
}
if (player.coolTime > 0) player.coolTime -= dt;
// 3. スポーン処理 (MIDIデータに基づく)
// 星の出現 (ノートより少し前)
while (starIndex < parsedNotes.length && parsedNotes[starIndex].time - STAR_LEAD_TIME <= gameTime) {
spawnStar(parsedNotes[starIndex]);
starIndex++;
}
// 敵の出現 (ノートのタイミング ※ゲーム的調整で少し早めに出して、ヒット時に音が合うようにするのは難しいので、
// 今回は「出現=ノートタイミング」とし、撃破時に「その音」を鳴らす方式にする)
while (spawnIndex < parsedNotes.length && parsedNotes[spawnIndex].time <= gameTime) {
spawnEnemy(parsedNotes[spawnIndex]);
spawnIndex++;
}
// ゲームクリア判定
if (spawnIndex >= parsedNotes.length && enemies.length === 0 && stars.length === 0) {
gameClear();
}
// 曲が終わっても敵が残っている場合があるので、時間経過でもクリアにするか判定(今回は敵全滅を待つ)
// 4. エンティティ更新 & 衝突判定
// --- 星 ---
for (let i = stars.length - 1; i >= 0; i--) {
let s = stars[i];
// プレイヤーに向かって飛ぶ
let dx = player.x - s.x;
let dy = player.y - s.y;
let dist = Math.sqrt(dx*dx + dy*dy);
s.x += (dx / dist) * s.speed;
s.y += (dy / dist) * s.speed;
// プレイヤーとの衝突 (ジャンプ中でも取れる)
if (dist < player.r + s.r) {
// 色変更
player.color = s.color;
createParticles(player.x, player.y, s.color, 5);
stars.splice(i, 1);
score += 100;
continue;
}
// 画面外等での削除は今回はしない(必ずプレイヤーに向かう)
}
// --- 弾 ---
for (let i = bullets.length - 1; i >= 0; i--) {
let b = bullets[i];
// ターゲットが生きていれば追尾
if (b.target && !b.target.dead) {
let dx = b.target.x - b.x;
let dy = b.target.y - b.y;
let angle = Math.atan2(dy, dx);
b.vx = Math.cos(angle) * SHOT_SPEED;
b.vy = Math.sin(angle) * SHOT_SPEED;
}
b.x += b.vx;
b.y += b.vy;
// 画面外削除
if (b.x < 0 || b.x > canvas.width || b.y < 0 || b.y > canvas.height) {
bullets.splice(i, 1);
continue;
}
// 敵との衝突
let hit = false;
for (let j = 0; j < enemies.length; j++) {
let e = enemies[j];
let dx = e.x - b.x;
let dy = e.y - b.y;
if (Math.sqrt(dx*dx + dy*dy) < e.r + b.r) {
// ダメージ処理
if (b.color === e.color) {
e.hp -= 5; // 同色なら即死級ダメージ
} else {
e.hp -= 1; // 異色は1ダメージ
}
if (e.hp <= 0) {
killEnemy(e, true); // プレイヤーが倒した
enemies.splice(j, 1);
} else {
// ダメージエフェクト
createParticles(e.x, e.y, "#fff", 2);
}
bullets.splice(i, 1);
hit = true;
break;
}
}
}
// --- 敵 ---
for (let i = enemies.length - 1; i >= 0; i--) {
let e = enemies[i];
// プレイヤーに向かう
let dx = player.x - e.x;
let dy = player.y - e.y;
let dist = Math.sqrt(dx*dx + dy*dy);
// 少し揺らぎながら進む
let angle = Math.atan2(dy, dx) + Math.sin(gameTime * 5 + e.id) * 0.2;
e.x += Math.cos(angle) * e.speed;
e.y += Math.sin(angle) * e.speed;
// プレイヤーとの衝突 (ジャンプ中は回避)
// ジャンプ高さ(z)を考慮していない簡易実装だが、"jumpHeight > 10"なら無敵とする
if (player.jumpHeight < 20 && dist < player.r + e.r) {
// ダメージ
player.hp -= 10;
updateHpBar();
createParticles(player.x, player.y, "#f00", 10);
// 敵消滅
killEnemy(e, false); // 衝突死(音は鳴らない or 不協和音)
enemies.splice(i, 1);
if (player.hp <= 0) {
gameOver();
}
}
}
// --- パーティクル ---
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.life -= dt;
if (p.life <= 0) particles.splice(i, 1);
}
// HP自然回復(微量)
/* if(player.hp < MAX_HP) {
player.hp += dt;
updateHpBar();
} */
}
// --- 描画処理 (Draw) ---
function draw() {
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 背景エフェクト(ビートに合わせて明滅させても面白いが今回は省略)
// 星
stars.forEach(s => {
drawStarShape(s.x, s.y, s.r, s.color, gameTime * 5);
});
// 敵 (スライム)
enemies.forEach(e => {
drawSlime(e.x, e.y, e.r, e.color, gameTime + e.id);
});
// プレイヤー
// ジャンプ中は拡大 & 影を表示
let scale = 1 + (player.jumpHeight / 50);
// 影
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.beginPath();
ctx.ellipse(player.x, player.y + 20, player.r, player.r * 0.5, 0, 0, Math.PI*2);
ctx.fill();
// 本体
drawSlime(player.x, player.y - player.jumpHeight, player.r * scale, player.color, gameTime);
// 弾
bullets.forEach(b => {
ctx.fillStyle = b.color;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1;
ctx.stroke();
});
// パーティクル
particles.forEach(p => {
ctx.fillStyle = p.color;
ctx.globalAlpha = p.life;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
});
// UI更新
document.getElementById('scoreDisplay').innerText = "Score: " + score;
}
// --- 図形描画ヘルパー ---
function drawSlime(x, y, r, color, time) {
ctx.fillStyle = color;
ctx.strokeStyle = "rgba(255,255,255,0.3)";
ctx.lineWidth = 2;
ctx.beginPath();
// 頂点を揺らしてスライムっぽく
const points = 8;
for (let i = 0; i <= points; i++) {
let angle = (i / points) * Math.PI * 2;
let wobble = Math.sin(time * 3 + i * 2) * 3; // 揺らぎ
let px = x + Math.cos(angle) * (r + wobble);
let py = y + Math.sin(angle) * (r * 0.8 + wobble); // 少し平べったく
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
// 目
ctx.fillStyle = "white";
ctx.beginPath();
ctx.arc(x - r*0.3, y - r*0.2, r*0.25, 0, Math.PI*2);
ctx.arc(x + r*0.3, y - r*0.2, r*0.25, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = "black";
ctx.beginPath();
ctx.arc(x - r*0.3, y - r*0.2, r*0.1, 0, Math.PI*2);
ctx.arc(x + r*0.3, y - r*0.2, r*0.1, 0, Math.PI*2);
ctx.fill();
}
function drawStarShape(cx, cy, r, color, angle) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.fillStyle = color;
ctx.shadowBlur = 10;
ctx.shadowColor = color;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
ctx.lineTo(Math.cos((18 + i * 72) * Math.PI / 180) * r,
Math.sin((18 + i * 72) * Math.PI / 180) * r);
ctx.lineTo(Math.cos((54 + i * 72) * Math.PI / 180) * (r / 2),
Math.sin((54 + i * 72) * Math.PI / 180) * (r / 2));
}
ctx.closePath();
ctx.fill();
ctx.restore();
}
// --- ロジック関数 ---
function spawnEnemy(note) {
// 画面外のランダムな位置から出現
const angle = Math.random() * Math.PI * 2;
const dist = Math.max(canvas.width, canvas.height) / 2 + 50;
let colorIndex = note.trackIndex % COLORS.length;
let color = COLORS[colorIndex];
enemies.push({
x: canvas.width/2 + Math.cos(angle) * dist,
y: canvas.height/2 + Math.sin(angle) * dist,
r: 20,
color: color,
hp: 5, // HP 5
speed: ENEMY_SPEED_BASE * (1.0 + Math.random() * 0.5), // 速度ゆらぎ
note: note,
id: Math.random(),
dead: false
});
}
function spawnStar(note) {
// 同じ色の星を生成。プレイヤーより少し速く移動してくる
const angle = Math.random() * Math.PI * 2;
const dist = Math.max(canvas.width, canvas.height) / 2 + 50;
let colorIndex = note.trackIndex % COLORS.length;
stars.push({
x: canvas.width/2 + Math.cos(angle) * dist,
y: canvas.height/2 + Math.sin(angle) * dist,
r: 10,
color: COLORS[colorIndex],
speed: PLAYER_SPEED * 1.5,
});
}
function shootHomingBullet() {
// 現在の色と同じ色の敵を探す
let target = null;
let minDist = Infinity;
// 同色の敵を優先検索
for (let e of enemies) {
if (e.color === player.color) {
let d = Math.pow(e.x - player.x, 2) + Math.pow(e.y - player.y, 2);
if (d < minDist) {
minDist = d;
target = e;
}
}
}
// いなければ一番近い敵(色は違う)
if (!target) {
minDist = Infinity;
for (let e of enemies) {
let d = Math.pow(e.x - player.x, 2) + Math.pow(e.y - player.y, 2);
if (d < minDist) {
minDist = d;
target = e;
}
}
}
// それでもいなければ適当な方向に撃つ
let vx = 0, vy = -SHOT_SPEED;
if (!target) {
// 移動方向へ撃つ
if(keys.ArrowUp) { vx=0; vy=-SHOT_SPEED; }
else if(keys.ArrowDown) { vx=0; vy=SHOT_SPEED; }
else if(keys.ArrowLeft) { vx=-SHOT_SPEED; vy=0; }
else if(keys.ArrowRight) { vx=SHOT_SPEED; vy=0; }
}
bullets.push({
x: player.x, y: player.y,
vx: vx, vy: vy,
r: 5,
color: player.color,
target: target
});
}
function killEnemy(enemy, killedByPlayer) {
enemy.dead = true;
if (killedByPlayer) {
// 音を鳴らす
let trackId = enemy.note.trackIndex % synths.length;
let vel = enemy.note.velocity;
// 色違いで倒した場合はベロシティ(音量・強さ)を下げる
if (player.color !== enemy.color) {
vel *= 0.3;
score += 10;
} else {
score += 500;
displayCombo("PERFECT!");
}
synths[trackId].triggerAttackRelease(enemy.note.name, enemy.note.duration, Tone.now(), vel);
createParticles(enemy.x, enemy.y, enemy.color, 10);
}
}
function createParticles(x, y, color, count) {
for(let i=0; i<count; i++) {
let angle = Math.random() * Math.PI * 2;
let speed = Math.random() * 5;
particles.push({
x: x, y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color: color,
life: 1.0,
size: Math.random() * 3 + 1
});
}
}
function updateHpBar() {
let pct = (player.hp / MAX_HP) * 100;
if(pct < 0) pct = 0;
hpBar.style.width = pct + "%";
if (pct < 30) hpBar.style.backgroundColor = "#f00";
else if (pct < 60) hpBar.style.backgroundColor = "#ff0";
else hpBar.style.backgroundColor = "#0f0";
}
function displayCombo(text) {
comboText.innerText = text;
comboText.style.opacity = 1;
setTimeout(() => { comboText.style.opacity = 0; }, 500);
}
// --- ゲーム進行管理 ---
function gameOver() {
gameState = 'gameover';
overlayTitle.innerText = "GAME OVER";
overlayMsg.innerText = "HPが尽きました...";
overlayTitle.style.color = "#f00";
overlay.style.display = "flex";
startBtn.innerText = "もう一度";
startBtn.disabled = false;
}
function gameClear() {
gameState = 'clear';
overlayTitle.innerText = "GAME CLEAR!!";
overlayMsg.innerText = `Score: ${score}`;
overlayTitle.style.color = "#0ff";
overlay.style.display = "flex";
startBtn.innerText = "もう一度";
startBtn.disabled = false;
}
function resetGame() {
gameTime = 0;
score = 0;
spawnIndex = 0;
starIndex = 0;
player.hp = MAX_HP;
player.x = canvas.width/2;
player.y = canvas.height/2;
player.jumpHeight = 0;
player.color = "#fff";
enemies = [];
bullets = [];
stars = [];
particles = [];
updateHpBar();
}
// --- イベントリスナー ---
document.getElementById('fileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
overlay.style.display = "flex";
overlayTitle.innerText = "LOADING...";
startBtn.disabled = true;
const reader = new FileReader();
reader.onload = function(ev) {
try {
const midiData = new Midi(ev.target.result);
parsedNotes = [];
midiData.tracks.forEach((track, i) => {
track.notes.forEach(n => {
parsedNotes.push({
name: n.name,
time: n.time,
duration: n.duration,
velocity: n.velocity,
trackIndex: i
});
});
});
// 時間順にソート
parsedNotes.sort((a,b) => a.time - b.time);
songDuration = midiData.duration;
overlayTitle.innerText = "READY";
overlayMsg.innerHTML = "ファイルを読み込みました。<br>音量に注意してスタートしてください。";
overlayTitle.style.color = "#fff";
startBtn.disabled = false;
gameState = 'ready';
} catch(err) {
console.error(err);
alert("MIDI解析エラー");
}
};
reader.readAsArrayBuffer(file);
});
startBtn.addEventListener('click', async () => {
await initAudio();
resetGame();
gameState = 'playing';
overlay.style.display = "none";
requestAnimationFrame(gameLoop);
});
document.getElementById('resetBtn').addEventListener('click', () => {
gameState = 'init';
overlay.style.display = "flex";
overlayTitle.innerText = "MIDI SLIME SURVIVOR";
overlayMsg.innerHTML = "MIDIファイルをロードしてください。";
startBtn.disabled = true;
});
window.addEventListener('keydown', (e) => {
if(keys.hasOwnProperty(e.key)) keys[e.key] = true;
if(e.key === 'z' || e.key === 'Z') keys.z = true;
if(e.key === 'x' || e.key === 'X') keys.x = true;
if(e.key === 'Escape') {
if(gameState === 'playing') {
gameState = 'paused';
overlay.style.display = 'flex';
overlayTitle.innerText = "PAUSED";
overlayMsg.innerText = "ESCキーで再開";
startBtn.innerText = "RESUME";
startBtn.onclick = () => {
gameState = 'playing';
overlay.style.display = 'none';
lastTime = 0; // デルタタイムのリセット
requestAnimationFrame(gameLoop);
};
} else if (gameState === 'paused') {
gameState = 'playing';
overlay.style.display = 'none';
lastTime = 0;
requestAnimationFrame(gameLoop);
}
}
});
window.addEventListener('keyup', (e) => {
if(keys.hasOwnProperty(e.key)) keys[e.key] = false;
if(e.key === 'z' || e.key === 'Z') keys.z = false;
if(e.key === 'x' || e.key === 'X') keys.x = false;
});
</script>
</body>
</html>

コメント