簡単な対戦ゲーム「MagicalMaze」
【更新履歴】
・2026/5/18 バージョン1.0公開。
・ダウンロードされる方はこちら。↓
【操作説明】
・方向キーで移動する。
・Aキーで取得した魔法を発動、もう一度押すと発射する。
・制限時間内に、より多くのスコアを得たほうが勝ち。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Magical Maze</title>
<style>
body {
margin: 0;
padding: 0;
background-color: #111;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
}
#gameContainer {
position: relative;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
}
canvas {
display: block;
background-color: #222;
}
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas" width="800" height="600" tabindex="1"></canvas>
</div>
<script>
/**
* Magical Maze - Main Game Logic (Integrated)
*/
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
canvas.focus();
// --- Game Constants ---
const TS = 40; // Tile Size
const COLS = 20;
const ROWS = 13;
const TIME_LIMIT = 180;
const MAX_STAGES = 8;
const UI_HEIGHT = 80;
// --- Asset Management ---
const ASSETS = { images: {}, audio: {} };
const AUDIO_CTX = new (window.AudioContext || window.webkitAudioContext)();
function loadImg(folder, name) {
const path = `${folder}/${name}.png`;
if (!ASSETS.images[path]) {
const img = new Image();
img.src = path;
ASSETS.images[path] = { img, loaded: false, error: false };
img.onload = () => ASSETS.images[path].loaded = true;
img.onerror = () => ASSETS.images[path].error = true;
}
}
function playSound(folder, name, type = 'beep') {
const path = `${folder}/${name}.mp3`;
if (ASSETS.audio[path] && ASSETS.audio[path].loaded && !ASSETS.audio[path].error) {
const clone = ASSETS.audio[path].audio.cloneNode();
clone.volume = 0.5;
clone.play().catch(() => playFallbackSound(type));
return;
}
if (!ASSETS.audio[path]) {
const audio = new Audio(path);
ASSETS.audio[path] = { audio: audio, loaded: false, error: false };
audio.oncanplaythrough = () => { ASSETS.audio[path].loaded = true; };
audio.onerror = () => {
ASSETS.audio[path].error = true;
playFallbackSound(type);
};
audio.volume = 0.5;
audio.play().catch(() => {
ASSETS.audio[path].error = true;
playFallbackSound(type);
});
return;
}
if (ASSETS.audio[path].error) {
playFallbackSound(type);
}
}
function playFallbackSound(type) {
if (AUDIO_CTX.state === 'suspended') {
AUDIO_CTX.resume().catch(() => {});
}
const gain = AUDIO_CTX.createGain();
gain.connect(AUDIO_CTX.destination);
if (type === 'destroy') {
const bufferSize = Math.floor(AUDIO_CTX.sampleRate * 0.2);
const buffer = AUDIO_CTX.createBuffer(1, bufferSize, AUDIO_CTX.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = AUDIO_CTX.createBufferSource();
noise.buffer = buffer;
const filter = AUDIO_CTX.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 800;
noise.connect(filter);
filter.connect(gain);
gain.gain.setValueAtTime(0.3, AUDIO_CTX.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, AUDIO_CTX.currentTime + 0.2);
noise.start();
return;
}
const osc = AUDIO_CTX.createOscillator();
osc.connect(gain);
if (type === 'magic') {
osc.type = 'sine';
osc.frequency.setValueAtTime(600, AUDIO_CTX.currentTime);
osc.frequency.exponentialRampToValueAtTime(1200, AUDIO_CTX.currentTime + 0.1);
gain.gain.setValueAtTime(0.1, AUDIO_CTX.currentTime);
} else if (type === 'hit') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, AUDIO_CTX.currentTime);
osc.frequency.exponentialRampToValueAtTime(50, AUDIO_CTX.currentTime + 0.2);
gain.gain.setValueAtTime(0.1, AUDIO_CTX.currentTime);
} else if (type === 'get') {
osc.type = 'square';
osc.frequency.setValueAtTime(800, AUDIO_CTX.currentTime);
osc.frequency.setValueAtTime(1200, AUDIO_CTX.currentTime + 0.05);
gain.gain.setValueAtTime(0.05, AUDIO_CTX.currentTime);
} else {
osc.type = 'triangle';
osc.frequency.setValueAtTime(440, AUDIO_CTX.currentTime);
gain.gain.setValueAtTime(0.05, AUDIO_CTX.currentTime);
}
osc.start();
osc.stop(AUDIO_CTX.currentTime + 0.2);
}
const dirs = ['left', 'right', 'up', 'down'];
dirs.forEach(d => {
loadImg('Player', d); loadImg('Enemy', d);
});
loadImg('Player', 'Player'); loadImg('Enemy', 'Enemy');
// --- Input Handling ---
// 連続遷移を防ぐため、画面遷移は keyup で発火させる
const keys = {};
document.addEventListener('keydown', e => {
keys[e.key] = true;
if (e.key === 'Escape') togglePause();
});
document.addEventListener('keyup', e => {
keys[e.key] = false;
// プレイ中・ポーズ中以外はキーを離した瞬間に遷移判定
if (gameState !== 'PLAY' && gameState !== 'PAUSE') {
handleScreenTransition(e.key);
}
});
canvas.addEventListener('mouseup', () => {
if (gameState !== 'PLAY' && gameState !== 'PAUSE') {
handleScreenTransition(null);
}
});
// --- State Variables ---
let gameState = 'TITLE';
let mode = 1;
let stage = 1;
let timeLeft = TIME_LIMIT;
let lastTime = 0;
let map = [];
let items = [];
let players = [];
let particles = [];
let meteors = [];
let soundTypeToPlay = null;
const MAGIC_TYPES = [
{ name: 'ice', color: 'cyan' },
{ name: 'fire', color: 'red' },
{ name: 'thunder', color: 'yellow' }
];
const HURDLE_TYPES = [
{ name: 'water', color: 'blue' },
{ name: 'rock', color: 'gray' },
{ name: 'ice', color: 'lightblue' },
{ name: 'fire', color: 'darkred' }
];
// --- Classes ---
class Particle {
constructor(x, y, color) {
this.x = x;
this.y = y;
this.color = color;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 150 + 50; // より速く散らす
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.life = 0.5 + Math.random() * 0.3;
this.maxLife = this.life;
this.size = Math.random() * 6 + 3;
}
update(dt) {
this.vy += 400 * dt; // 重力を強めに加えてパラボラ軌道に
this.x += this.vx * dt;
this.y += this.vy * dt;
this.life -= dt;
}
draw(ctx) {
const alpha = Math.max(0, this.life / this.maxLife);
ctx.globalAlpha = alpha;
ctx.fillStyle = this.color;
ctx.fillRect(this.x - this.size/2, this.y - this.size/2, this.size, this.size);
ctx.globalAlpha = 1.0;
}
}
class Meteor {
constructor(ax, ay, magic, owner) {
this.targetX = ax;
this.targetY = ay;
this.pixelX = ax * TS + TS / 2;
this.pixelY = ay * TS + TS / 2;
this.currentX = this.pixelX;
this.currentY = -TS; // 画面上部から
this.speed = 900 + Math.random() * 200; // 高速落下
this.magic = magic;
this.owner = owner;
this.hit = false;
}
update(dt) {
if (this.hit) return;
this.currentY += this.speed * dt;
if (this.currentY >= this.pixelY) {
this.currentY = this.pixelY;
this.hit = true;
handleMeteorHit(this); // 着弾時に処理
}
}
draw(ctx) {
if (this.hit) return;
const path = `Magic/${this.magic.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, this.currentX - TS/2, this.currentY - TS/2, TS, TS);
} else {
ctx.beginPath();
ctx.arc(this.currentX, this.currentY, TS/4, 0, Math.PI*2);
ctx.fillStyle = this.magic.color;
ctx.fill();
ctx.closePath();
}
// メテオの軌跡
ctx.beginPath();
ctx.moveTo(this.currentX, this.currentY - TS/4);
ctx.lineTo(this.currentX, this.currentY - TS*1.5);
ctx.strokeStyle = this.magic.color;
ctx.lineWidth = 4;
ctx.stroke();
ctx.closePath();
}
}
class Entity {
constructor(x, y, isPlayer1) {
this.x = x;
this.y = y;
this.targetX = x;
this.targetY = y;
this.isPlayer1 = isPlayer1;
this.hp = 100;
this.maxHp = 100;
this.score = 0;
this.magic = null;
this.lifting = false;
this.attackArea = [];
this.steps = 0;
this.moving = false;
this.moveTimer = 0;
this.dir = 'down';
}
update(dt) {
if (this.moving) {
this.moveTimer += dt;
if (this.moveTimer >= 0.2) {
this.x = this.targetX;
this.y = this.targetY;
this.moving = false;
this.moveTimer = 0;
this.steps++;
checkItemPickup(this);
}
}
}
move(dx, dy) {
// this.lifting による移動制限を削除。持ち上げながら移動可能に。
if (this.moving) return;
const nx = this.x + dx;
const ny = this.y + dy;
if (dx > 0) this.dir = 'right';
if (dx < 0) this.dir = 'left';
if (dy > 0) this.dir = 'down';
if (dy < 0) this.dir = 'up';
if (nx >= 0 && nx < COLS && ny >= 0 && ny < ROWS) {
const cell = map[ny][nx];
if (cell.type === 'road' || cell.type === 'start') {
const other = players.find(p => p !== this && p.targetX === nx && p.targetY === ny);
if (!other) {
this.targetX = nx;
this.targetY = ny;
this.moving = true;
}
}
}
}
draw(ctx) {
let drawX = this.x * TS;
let drawY = this.y * TS;
let jumpOffset = 0;
if (this.moving) {
const progress = this.moveTimer / 0.2;
drawX += (this.targetX - this.x) * TS * progress;
drawY += (this.targetY - this.y) * TS * progress;
jumpOffset = Math.sin(progress * Math.PI) * 15;
}
const folder = this.isPlayer1 ? 'Player' : 'Enemy';
const color = this.isPlayer1 ? 'lightblue' : 'purple';
const dirPath = `${folder}/${this.dir}.png`;
const defPath = `${folder}/${folder}.png`;
let imgToDraw = null;
if (ASSETS.images[dirPath] && ASSETS.images[dirPath].loaded) {
imgToDraw = ASSETS.images[dirPath].img;
} else if (ASSETS.images[defPath] && ASSETS.images[defPath].loaded) {
imgToDraw = ASSETS.images[defPath].img;
}
drawY -= jumpOffset;
if (imgToDraw) {
ctx.drawImage(imgToDraw, drawX, drawY, TS, TS);
} else {
ctx.beginPath();
ctx.arc(drawX + TS/2, drawY + TS/2, TS/2.2, 0, Math.PI*2);
ctx.fillStyle = color;
ctx.fill();
ctx.closePath();
}
if (this.lifting && this.magic) {
ctx.beginPath();
ctx.arc(drawX + TS/2, drawY - 15, TS/5, 0, Math.PI*2);
ctx.fillStyle = this.magic.color;
ctx.fill();
ctx.closePath();
}
}
}
// --- Map & Logic ---
function initGame() {
map = [];
items = [];
particles = [];
meteors = [];
players = [
new Entity(0, 0, true),
new Entity(COLS - 1, ROWS - 1, false)
];
timeLeft = TIME_LIMIT;
for (let y = 0; y < ROWS; y++) {
let row = [];
for (let x = 0; x < COLS; x++) {
row.push({ type: 'road', name: 'normal', color: 'lightgreen' });
}
map.push(row);
}
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (Math.random() < 0.25) {
const h = HURDLE_TYPES[Math.floor(Math.random() * HURDLE_TYPES.length)];
map[y][x] = { type: 'hurdle', name: h.name, color: h.color };
loadImg('Hurdle', h.name);
}
}
}
map[0][0] = { type: 'start' };
map[0][1] = { type: 'road', name: 'normal', color: 'lightgreen' };
map[1][0] = { type: 'road', name: 'normal', color: 'lightgreen' };
map[ROWS-1][COLS-1] = { type: 'start' };
map[ROWS-1][COLS-2] = { type: 'road', name: 'normal', color: 'lightgreen' };
map[ROWS-2][COLS-1] = { type: 'road', name: 'normal', color: 'lightgreen' };
let cx = 0, cy = 0;
while (cx < COLS - 1 || cy < ROWS - 1) {
if (!(cx === 0 && cy === 0)) {
map[cy][cx] = { type: 'road', name: 'normal', color: 'lightgreen' };
}
if (cx === COLS - 1) cy++;
else if (cy === ROWS - 1) cx++;
else Math.random() < 0.5 ? cx++ : cy++;
}
spawnItems();
}
function spawnItems() {
items = [];
for(let i=0; i<5; i++) spawnRandomItem('food');
for(let i=0; i<3; i++) spawnRandomItem('magic');
}
function spawnRandomItem(type) {
let rx, ry;
let attempts = 0;
do {
rx = Math.floor(Math.random() * COLS);
ry = Math.floor(Math.random() * ROWS);
attempts++;
if (attempts > 500) break;
} while (map[ry][rx].type === 'hurdle' || items.some(i => i.x === rx && i.y === ry) || map[ry][rx].type === 'start');
if (attempts > 500) return;
if (type === 'food') {
items.push({ x: rx, y: ry, type: 'food' });
loadImg('Food', 'food');
} else {
const m = MAGIC_TYPES[Math.floor(Math.random() * MAGIC_TYPES.length)];
items.push({ x: rx, y: ry, type: 'magic', magicData: m });
loadImg('Magic', m.name);
}
}
function checkItemPickup(player) {
const idx = items.findIndex(i => i.x === player.x && i.y === player.y);
if (idx !== -1) {
const item = items[idx];
if (item.type === 'food') {
player.score += 100 + (player.steps * 10);
player.steps = 0;
playSound('Sound', 'get', 'get');
items.splice(idx, 1);
spawnRandomItem('food');
} else if (item.type === 'magic') {
player.magic = item.magicData;
playSound('Sound', 'magic_get', 'get');
items.splice(idx, 1);
spawnRandomItem('magic');
}
}
}
function generateAttackArea() {
const area = [];
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
if (Math.abs(dx) + Math.abs(dy) <= 3 && Math.random() < 0.7) {
area.push({ dx: dx, dy: dy });
}
}
}
return area;
}
function executeMagic(player) {
playSound('Sound', 'attack', 'magic'); // 発動音
const magic = player.magic;
player.attackArea.forEach(cell => {
const ax = player.targetX + cell.dx;
const ay = player.targetY + cell.dy;
if (ax >= 0 && ax < COLS && ay >= 0 && ay < ROWS) {
// 対象のマスにメテオを降らせる
meteors.push(new Meteor(ax, ay, magic, player));
}
});
player.magic = null;
player.lifting = false;
player.attackArea = [];
}
function handleMeteorHit(m) {
const ax = m.targetX;
const ay = m.targetY;
const magic = m.magic;
const player = m.owner;
// 着弾時にパーティクルを散らす
for (let i = 0; i < 15; i++) {
particles.push(new Particle(ax * TS + TS/2, ay * TS + TS/2, magic.color));
}
let mapCell = map[ay][ax];
// 地形破壊
if (mapCell.type === 'hurdle') {
if ((mapCell.name === 'water' && magic.name === 'ice') || mapCell.name === magic.name) {
map[ay][ax] = { type: 'road', name: magic.name, color: magic.color };
loadImg('Road', magic.name);
if (soundTypeToPlay !== 'hit') soundTypeToPlay = 'destroy';
}
}
// 確実なダメージ判定 (現在座標と移動先座標の双方でチェック)
players.forEach(target => {
const tx1 = Math.round(target.x);
const ty1 = Math.round(target.y);
const tx2 = target.targetX;
const ty2 = target.targetY;
if (target !== player && ((tx1 === ax && ty1 === ay) || (tx2 === ax && ty2 === ay))) {
if (!target.magic || target.magic.name !== magic.name) {
target.hp -= 30;
soundTypeToPlay = 'hit'; // ヒット音を優先
if (target.hp <= 0) {
if (target.isPlayer1) setGameOver();
else setStageClear();
}
}
}
});
}
// --- Main Loop ---
function update(dt) {
if (gameState !== 'PLAY') return;
timeLeft -= dt;
if (timeLeft <= 0) {
if (players[0].score >= players[1].score) setStageClear();
else setGameOver();
return;
}
soundTypeToPlay = null;
// Input P1
let p1 = players[0];
if (keys['ArrowUp']) p1.move(0, -1);
else if (keys['ArrowDown']) p1.move(0, 1);
else if (keys['ArrowLeft']) p1.move(-1, 0);
else if (keys['ArrowRight']) p1.move(1, 0);
if (keys['a'] || keys['A']) {
if (!keys['a_handled']) {
if (!p1.lifting && p1.magic) {
p1.lifting = true;
p1.attackArea = generateAttackArea();
} else if (p1.lifting) {
executeMagic(p1);
}
keys['a_handled'] = true;
}
} else {
keys['a_handled'] = false;
}
// Input P2 / CPU
let p2 = players[1];
if (mode === 2) {
if (keys['i'] || keys['I']) p2.move(0, -1);
else if (keys['k'] || keys['K']) p2.move(0, 1);
else if (keys['j'] || keys['J']) p2.move(-1, 0);
else if (keys['l'] || keys['L']) p2.move(1, 0);
if (keys['u'] || keys['U']) {
if (!keys['u_handled']) {
if (!p2.lifting && p2.magic) {
p2.lifting = true;
p2.attackArea = generateAttackArea();
} else if (p2.lifting) {
executeMagic(p2);
}
keys['u_handled'] = true;
}
} else {
keys['u_handled'] = false;
}
} else {
if (!p2.moving && Math.random() < 0.05 + (stage * 0.02)) {
const dx = Math.sign(p1.targetX - p2.targetX);
const dy = Math.sign(p1.targetY - p2.targetY);
if (p2.magic && Math.abs(dx) <= 3 && Math.abs(dy) <= 3) {
if (!p2.lifting) {
p2.lifting = true;
p2.attackArea = generateAttackArea();
} else {
executeMagic(p2);
}
} else {
if (Math.random() < 0.5 && dx !== 0) p2.move(dx, 0);
else if (dy !== 0) p2.move(0, dy);
else p2.move(Math.random() > 0.5 ? 1 : -1, 0);
}
}
}
players.forEach(p => p.update(dt));
meteors.forEach(m => m.update(dt));
meteors = meteors.filter(m => !m.hit); // 着弾したものは削除
particles.forEach(p => p.update(dt));
particles = particles.filter(p => p.life > 0);
// 1フレームで複数着弾しても音は代表して1回鳴らす(ノイズ防止)
if (soundTypeToPlay) {
playSound('Sound', soundTypeToPlay === 'hit' ? 'damage' : 'attack', soundTypeToPlay);
}
}
function drawFallbackSquare(x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(x * TS, y * TS, TS, TS);
}
function drawImageOrFallback(folder, name, x, y, fallbackColor) {
const path = `${folder}/${name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, x * TS, y * TS, TS, TS);
} else {
drawFallbackSquare(x, y, fallbackColor);
}
}
function draw() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'TITLE') {
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.font = '50px sans-serif';
ctx.fillText('Magical Maze', 400, 250);
ctx.font = '24px sans-serif';
ctx.fillText(`Mode: ${mode === 1 ? '1P vs CPU' : '2P Local'} (Press M to switch)`, 400, 350);
ctx.fillText('Click or Press Any Key to Start', 400, 450);
return;
}
if (gameState === 'START' || gameState === 'CLEAR' || gameState === 'OVER' || gameState === 'ALLCLEAR') {
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.font = '40px sans-serif';
let txt = '';
if (gameState === 'START') txt = `Stage ${stage} Start`;
if (gameState === 'CLEAR') txt = `Stage ${stage} Clear!`;
if (gameState === 'OVER') txt = `Game Over`;
if (gameState === 'ALLCLEAR') txt = `Game Clear! Congratulations!`;
ctx.fillText(txt, 400, 300);
ctx.font = '20px sans-serif';
ctx.fillText('Press Any Key...', 400, 400);
return;
}
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const cell = map[y][x];
if (cell.type === 'road' || cell.type === 'start') {
drawImageOrFallback('Road', cell.name || 'normal', x, y, 'lightgreen');
} else if (cell.type === 'hurdle') {
drawImageOrFallback('Hurdle', cell.name, x, y, cell.color);
}
}
}
items.forEach(item => {
if (item.type === 'food') {
const path = `Food/food.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, item.x * TS, item.y * TS, TS, TS);
} else {
ctx.fillStyle = 'yellow';
ctx.fillRect(item.x * TS + TS/4, item.y * TS + TS/4, TS/2, TS/2);
}
} else if (item.type === 'magic') {
const path = `Magic/${item.magicData.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, item.x * TS, item.y * TS, TS, TS);
} else {
ctx.beginPath();
ctx.arc(item.x * TS + TS/2, item.y * TS + TS/2, TS/4, 0, Math.PI*2);
ctx.fillStyle = item.magicData.color;
ctx.fill();
ctx.closePath();
}
}
});
// 攻撃範囲のカーソル (自キャラの移動に追従)
players.forEach(p => {
if (p.lifting && p.attackArea.length > 0 && p.magic) {
ctx.setLineDash([5, 5]);
ctx.lineDashOffset = -performance.now() / 50;
ctx.strokeStyle = p.magic.color;
ctx.lineWidth = 3;
p.attackArea.forEach(cell => {
const ax = p.targetX + cell.dx;
const ay = p.targetY + cell.dy;
if(ax >= 0 && ax < COLS && ay >= 0 && ay < ROWS) {
ctx.strokeRect(ax * TS + 2, ay * TS + 2, TS - 4, TS - 4);
}
});
ctx.setLineDash([]);
}
});
players.forEach(p => p.draw(ctx));
meteors.forEach(m => m.draw(ctx));
particles.forEach(p => p.draw(ctx));
const uiY = ROWS * TS;
ctx.fillStyle = '#333';
ctx.fillRect(0, uiY, canvas.width, UI_HEIGHT);
ctx.font = '16px sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'left';
ctx.fillText(`P1 Score: ${players[0].score}`, 20, uiY + 20);
drawHpBar(20, uiY + 30, players[0].hp, players[0].maxHp, 'lightblue');
if (players[0].magic) {
ctx.fillStyle = players[0].magic.color;
ctx.fillText(`Magic: ${players[0].magic.name}`, 20, uiY + 70);
}
ctx.textAlign = 'right';
ctx.fillStyle = '#fff';
ctx.fillText(`${mode === 1 ? 'CPU' : 'P2'} Score: ${players[1].score}`, canvas.width - 20, uiY + 20);
drawHpBar(canvas.width - 170, uiY + 30, players[1].hp, players[1].maxHp, 'purple');
if (players[1].magic) {
ctx.fillStyle = players[1].magic.color;
ctx.fillText(`Magic: ${players[1].magic.name}`, canvas.width - 20, uiY + 70);
}
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.font = '20px sans-serif';
ctx.fillText(`Stage: ${stage}`, canvas.width / 2, uiY + 30);
const min = Math.floor(timeLeft / 60);
const sec = Math.floor(timeLeft % 60).toString().padStart(2, '0');
ctx.fillText(`Time: ${min}:${sec}`, canvas.width / 2, uiY + 60);
if (gameState === 'PAUSE') {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.font = '40px sans-serif';
ctx.fillText('PAUSE', 400, 300);
}
}
function drawHpBar(x, y, hp, maxHp, color) {
const width = 150;
const height = 15;
ctx.fillStyle = 'red';
ctx.fillRect(x, y, width, height);
ctx.fillStyle = color;
const hpWidth = Math.max(0, (hp / maxHp) * width);
ctx.fillRect(x, y, hpWidth, height);
ctx.strokeStyle = '#fff';
ctx.strokeRect(x, y, width, height);
}
function handleScreenTransition(key) {
if (gameState === 'TITLE') {
if (key === 'm' || key === 'M') {
mode = mode === 1 ? 2 : 1;
} else {
stage = 1;
gameState = 'START';
}
} else if (gameState === 'START') {
initGame();
gameState = 'PLAY';
} else if (gameState === 'CLEAR') {
if (stage >= MAX_STAGES) {
gameState = 'ALLCLEAR';
} else {
stage++;
gameState = 'START';
}
} else if (gameState === 'OVER' || gameState === 'ALLCLEAR') {
gameState = 'TITLE';
}
}
function setStageClear() { gameState = 'CLEAR'; }
function setGameOver() { gameState = 'OVER'; }
function togglePause() {
if (gameState === 'PLAY') gameState = 'PAUSE';
else if (gameState === 'PAUSE') gameState = 'PLAY';
}
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
update(dt);
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>

コメント