簡単な対戦ゲーム「MagicalMaze」
【更新履歴】
・2026/5/18 バージョン1.0公開。
・2026/5/18 バージョン1.1公開。
・2026/5/18 バージョン1.2公開。
・2026/5/18 バージョン1.3公開。
・ダウンロードされる方はこちら。↓
【操作説明】
・方向キーで移動する。
・Aキーで取得した魔法を発動、もう一度押すと発射する。
・制限時間内に、より多くのスコアを得たほうが勝ち。
・あるいは、相手のHPを、先にゼロにしたほうが勝ち。
・魔法が当たった障害物が同じ属性なら破壊される。
・同じ属性の魔法を続けて取得すると、攻撃範囲が広くなる。(最大3回)
・相手の攻撃が当たっても、同じ魔法を持っていたらノーダメージ。
・または、同じ属性の障害物の跡に乗っている時は、
1/2の確率でノーダメージ。
【マルチプレイ時の操作】
・プレイヤー2は、テンキーで移動。スペースキーで魔法発動。
・このゲームで遊ぶには、いつものテスト用ブラウザが必要です。↓
・ソースコードはこちら。↓
<!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="680" 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;
const COLS = 20;
const ROWS = 13;
const TIME_LIMIT = 180;
const MAX_STAGES = 8;
const UI_HEIGHT = 160;
// --- Asset Management ---
const ASSETS = { images: {}, audio: {} };
const AUDIO_CTX = new (window.AudioContext || window.webkitAudioContext)();
let currentBgmAudio = null;
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 playBGM(folder, name) {
if (currentBgmAudio) {
currentBgmAudio.pause();
currentBgmAudio = null;
}
const path = `${folder}/${name}.mp3`;
if (!ASSETS.audio[path]) {
const audio = new Audio(path);
audio.loop = true;
audio.volume = 0.4;
ASSETS.audio[path] = { audio: audio, loaded: false, error: false };
audio.oncanplaythrough = () => { ASSETS.audio[path].loaded = true; };
audio.onerror = () => { ASSETS.audio[path].error = true; };
}
if (!ASSETS.audio[path].error) {
currentBgmAudio = ASSETS.audio[path].audio;
currentBgmAudio.currentTime = 0;
currentBgmAudio.play().catch(() => {});
}
}
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.6;
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.6;
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 === 'break') {
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_cast') {
osc.type = 'sine'; osc.frequency.setValueAtTime(400, AUDIO_CTX.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, AUDIO_CTX.currentTime + 0.1);
gain.gain.setValueAtTime(0.1, AUDIO_CTX.currentTime);
} else if (type === 'damage') {
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_food' || type === 'heal' || type === 'speedup') {
osc.type = 'square';
let freq = type === 'heal' ? 1000 : (type === 'speedup' ? 1200 : 800);
osc.frequency.setValueAtTime(freq, AUDIO_CTX.currentTime);
osc.frequency.setValueAtTime(freq + 400, AUDIO_CTX.currentTime + 0.05);
gain.gain.setValueAtTime(0.05, AUDIO_CTX.currentTime);
} else if (type === 'move') {
osc.type = 'triangle'; osc.frequency.setValueAtTime(300, AUDIO_CTX.currentTime);
gain.gain.setValueAtTime(0.02, AUDIO_CTX.currentTime);
osc.start(); osc.stop(AUDIO_CTX.currentTime + 0.05); return;
} else if (type === 'dont_move') {
osc.type = 'square'; osc.frequency.setValueAtTime(100, AUDIO_CTX.currentTime);
gain.gain.setValueAtTime(0.05, AUDIO_CTX.currentTime);
osc.start(); osc.stop(AUDIO_CTX.currentTime + 0.1); return;
} else if (type === 'pageup') {
osc.type = 'sine'; osc.frequency.setValueAtTime(880, AUDIO_CTX.currentTime);
gain.gain.setValueAtTime(0.1, 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);
}
// --- Pre-load Assets ---
const dirs = ['left', 'right', 'up', 'down'];
dirs.forEach(d => { loadImg('Player', d); loadImg('Enemy', d); });
loadImg('Player', 'Player'); loadImg('Enemy', 'Enemy');
loadImg('Road', 'default');
['title', 'stage_start', 'stage_clear', 'game_clear', 'game_over'].forEach(s => {
loadImg('Screen', s);
});
// --- C#風:有効なアイテム画像パスをリスト化する ---
// 読込成功した画像パスだけを保持し、後でランダムなindexで抽出する
const VALID_ITEM_PATHS = { food: [], heal: [], speed: [] };
function loadItemImages() {
const types = ['food', 'heal', 'speed'];
types.forEach(type => {
const folder = type === 'food' ? 'Food' : type === 'heal' ? 'Heal' : 'SpeedUp';
const namesToCheck = [type, type.charAt(0).toUpperCase() + type.slice(1)];
if (type === 'speed') { namesToCheck.push('speedup', 'SpeedUp'); }
// 1から始まる通し番号(1.png, 2.png)や、food1.png のようなパターンを予測してロード試行
for (let i = 1; i <= 20; i++) {
namesToCheck.push(`${i}`);
namesToCheck.push(`${type}${i}`);
namesToCheck.push(`${type.charAt(0).toUpperCase() + type.slice(1)}${i}`);
}
namesToCheck.forEach(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;
// 読み込み成功したパスを有効リストに追加
if (!VALID_ITEM_PATHS[type].includes(path)) {
VALID_ITEM_PATHS[type].push(path);
}
};
img.onerror = () => { ASSETS.images[path].error = true; };
}
});
});
}
loadItemImages();
// --- Input Handling ---
const keys = {};
document.addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) {
e.preventDefault();
}
keys[e.key] = true;
if (e.key === 'Escape') togglePause();
if (gameState === 'TITLE') {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
mode = mode === 1 ? 2 : 1;
playSound('Sound', 'move', 'move');
}
}
});
document.addEventListener('keyup', e => {
keys[e.key] = false;
if (gameState !== 'PLAY' && gameState !== 'PAUSE') {
if (gameState === 'TITLE' && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key.toLowerCase() === 'm')) {
return;
}
handleScreenTransition();
}
});
canvas.addEventListener('mouseup', () => {
if (gameState !== 'PLAY' && gameState !== 'PAUSE') {
handleScreenTransition();
}
});
// --- State Variables ---
let gameState = 'STARTUP';
let mode = 1;
let stage = 1;
let timeLeft = TIME_LIMIT;
let lastTime = 0;
let map = [];
let items = [];
let players = [];
let particles = [];
let meteors = [];
let fallingItems = [];
const MAGIC_TYPES = [
{ name: 'water', color: 'cyan' },
{ name: 'fire', color: 'red' },
{ name: 'earth', color: 'saddlebrown' },
{ name: 'ice', color: 'blue' },
{ name: 'wind', color: 'green' },
{ name: 'thunder', color: 'yellow' }
];
const HURDLE_TYPES = [...MAGIC_TYPES];
// --- Classes ---
class FallingItem {
constructor(x, y, type, data) {
this.targetX = x; this.targetY = y;
this.pixelX = x * TS + TS / 2; this.pixelY = y * TS + TS / 2;
this.currentY = -TS; this.speed = 400;
this.type = type; this.data = data; 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;
// 落下のアイテムもランダムなIDを持たせる(画像抽選用)
items.push({ x: this.targetX, y: this.targetY, type: this.type, magicData: this.data, rndIdx: Math.floor(Math.random() * 10000) });
}
}
draw(ctx) {
if (this.hit) return;
if (this.type === 'magic') {
const path = `Magic/${this.data.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, this.pixelX - TS/2, this.currentY - TS/2, TS, TS);
} else {
ctx.beginPath(); ctx.arc(this.pixelX, this.currentY, TS/4, 0, Math.PI*2);
ctx.fillStyle = this.data.color; ctx.fill(); ctx.closePath();
}
}
}
}
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.magicLevel = 1;
this.lifting = false; this.attackArea = [];
this.steps = 0; this.speedTimer = 0; this.hesitateTimer = 0;
this.moving = false; this.moveTimer = 0; this.dir = 'down';
}
update(dt) {
if (this.speedTimer > 0) this.speedTimer -= dt;
if (this.moving) {
this.moveTimer += dt;
let speedThreshold = this.speedTimer > 0 ? 0.1 : 0.2;
if (this.moveTimer >= speedThreshold) {
this.x = this.targetX; this.y = this.targetY;
this.moving = false; this.moveTimer = 0; this.steps++;
checkItemPickup(this);
}
}
}
move(dx, dy) {
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;
playSound('Sound', 'move', 'move');
} else playSound('Sound', 'dont_move', 'dont_move');
} else playSound('Sound', 'dont_move', 'dont_move');
} else playSound('Sound', 'dont_move', 'dont_move');
}
draw(ctx) {
let drawX = this.x * TS; let drawY = this.y * TS; let jumpOffset = 0;
if (this.moving) {
let speedThreshold = this.speedTimer > 0 ? 0.1 : 0.2;
const progress = this.moveTimer / speedThreshold;
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 ? (mode === 2 ? 'blue' : 'cyan') : (mode === 2 ? 'red' : '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 = []; fallingItems = [];
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', name: 'normal', color: 'lightgreen' };
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', name: 'normal', color: 'lightgreen' };
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<4; i++) spawnRandomItem('food');
for(let i=0; i<3; i++) spawnRandomItem('magic');
for(let i=0; i<2; i++) spawnRandomItem('heal');
for(let i=0; i<2; i++) spawnRandomItem('speed');
}
function spawnRandomItem(type) {
let rx, ry, 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 (['food', 'heal', 'speed'].includes(type)) {
// rndIdxを割り当てておき、描画時にリストから抽出する
items.push({ x: rx, y: ry, type: type, rndIdx: Math.floor(Math.random() * 10000) });
} else if (type === 'magic') {
const m = MAGIC_TYPES[Math.floor(Math.random() * MAGIC_TYPES.length)];
items.push({ x: rx, y: ry, type: 'magic', magicData: m, rndIdx: Math.floor(Math.random() * 10000) });
loadImg('Magic', m.name);
}
}
function spawnSpecificMagic(magicData) {
if (items.some(i => i.type === 'magic' && i.magicData.name === magicData.name)) return;
let rx, ry, 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) {
items.push({ x: rx, y: ry, type: 'magic', magicData: magicData, rndIdx: Math.floor(Math.random() * 10000) });
loadImg('Magic', magicData.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_food', 'get_food');
items.splice(idx, 1); spawnRandomItem('food');
} else if (item.type === 'magic') {
if (player.magic && player.magic.name === item.magicData.name) {
player.magicLevel = Math.min(3, player.magicLevel + 1);
} else {
player.magic = item.magicData;
player.magicLevel = 1;
}
playSound('Sound', 'magic_get', 'get_food');
items.splice(idx, 1); spawnSpecificMagic(item.magicData); spawnRandomItem('magic');
} else if (item.type === 'heal') {
player.hp = Math.min(player.maxHp, player.hp + 30);
playSound('Sound', 'heal', 'heal');
items.splice(idx, 1); spawnRandomItem('heal');
} else if (item.type === 'speed') {
player.speedTimer = 10;
playSound('Sound', 'speedup', 'speedup');
items.splice(idx, 1); spawnRandomItem('speed');
}
}
}
function generateAttackArea(level) {
const area = [];
for (let dy = -level; dy <= level; dy++) {
for (let dx = -level; dx <= level; dx++) {
if (Math.abs(dx) + Math.abs(dy) <= level && Math.random() < 0.7) area.push({ dx: dx, dy: dy });
}
}
return area;
}
function executeMagic(player) {
playSound('Sound', 'magic_cast', 'magic_cast');
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.magicLevel = 1; player.lifting = false; player.attackArea = [];
}
function setStageClear() {
if (stage >= MAX_STAGES) {
gameState = 'GAME_CLEAR'; playBGM('ScreenBgm', 'game_clear');
} else {
gameState = 'STAGE_CLEAR'; playBGM('ScreenBgm', 'stage_clear');
}
}
function setGameOver() {
gameState = 'GAME_OVER'; playBGM('ScreenBgm', 'game_over');
}
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];
let hitSomething = false;
if (mapCell.type === 'hurdle') {
hitSomething = true;
let isDestructible = (mapCell.name === 'water' && magic.name === 'ice') || mapCell.name === magic.name;
if (isDestructible) {
map[ay][ax] = { type: 'road', name: magic.name, color: magic.color };
loadImg('Road', magic.name);
playSound('Sound', 'break', 'break');
for (let i = 0; i < 15; i++) particles.push(new Particle(ax * TS + TS/2, ay * TS + TS/2, mapCell.color));
} else playSound('Sound', 'damage', 'damage');
}
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))) {
hitSomething = true;
let tCell1 = map[ty1][tx1]; let tCell2 = map[ty2][tx2];
let onSameAttribute = (tCell1.type === 'road' && tCell1.name === magic.name) ||
(tCell2.type === 'road' && tCell2.name === magic.name);
if (onSameAttribute && Math.random() < 0.5) {
// Dodge
} else if (!target.magic || target.magic.name !== magic.name) {
target.hp -= 30;
playSound('Sound', 'damage', 'damage');
for (let i = 0; i < 15; i++) {
const pColor = target.isPlayer1 ? (mode === 2 ? 'blue' : 'cyan') : (mode === 2 ? 'red' : 'purple');
particles.push(new Particle(ax * TS + TS/2, ay * TS + TS/2, pColor));
}
if (target.hp <= 0) {
if (mode === 2) {
setStageClear();
} else {
if (target.isPlayer1) setGameOver();
else setStageClear();
}
}
}
}
});
if(!hitSomething) playSound('Sound', 'damage', 'damage');
}
function findPath(startX, startY, targetType, targetEntity) {
let queue = [{x: startX, y: startY, path: []}];
let visited = Array.from({length: ROWS}, () => Array(COLS).fill(false));
visited[startY][startX] = true;
while(queue.length > 0) {
let curr = queue.shift();
let found = false;
if (['magic', 'food', 'heal', 'speed'].includes(targetType)) {
if (items.some(i => i.type === targetType && i.x === curr.x && i.y === curr.y)) found = true;
} else if (targetType === 'player') {
if (curr.x === targetEntity.targetX && curr.y === targetEntity.targetY) found = true;
}
if (found) return curr.path.length > 0 ? curr.path[0] : null;
const dirs = [{dx: 0, dy: -1}, {dx: 0, dy: 1}, {dx: -1, dy: 0}, {dx: 1, dy: 0}];
for (let d of dirs) {
let nx = curr.x + d.dx; let ny = curr.y + d.dy;
if (nx >= 0 && nx < COLS && ny >= 0 && ny < ROWS) {
if (!visited[ny][nx] && (map[ny][nx].type === 'road' || map[ny][nx].type === 'start')) {
visited[ny][nx] = true;
queue.push({x: nx, y: ny, path: [...curr.path, d]});
}
}
}
}
return null;
}
function checkTrapped(player) {
if (player.moving) return;
const dirs = [{dx: 0, dy: -1}, {dx: 0, dy: 1}, {dx: -1, dy: 0}, {dx: 1, dy: 0}];
let trapped = true; let adjacentHurdles = [];
for (let d of dirs) {
let nx = player.targetX + d.dx; let ny = player.targetY + d.dy;
if (nx >= 0 && nx < COLS && ny >= 0 && ny < ROWS) {
let cell = map[ny][nx];
if (cell.type !== 'hurdle') { trapped = false; break; }
else adjacentHurdles.push(cell);
}
}
if (trapped && adjacentHurdles.length > 0) {
if (player.magic) {
let canBreak = adjacentHurdles.some(h => (h.name === 'water' && player.magic.name === 'ice') || h.name === player.magic.name);
if (canBreak) return;
}
if (items.some(i => i.x === player.targetX && i.y === player.targetY && i.type === 'magic')) return;
if (fallingItems.some(f => f.targetX === player.targetX && f.targetY === player.targetY)) return;
let targetHurdle = adjacentHurdles[Math.floor(Math.random() * adjacentHurdles.length)];
let requiredMagicName = targetHurdle.name === 'water' ? 'ice' : targetHurdle.name;
let mData = MAGIC_TYPES.find(m => m.name === requiredMagicName);
if (mData) {
fallingItems.push(new FallingItem(player.targetX, player.targetY, 'magic', mData));
loadImg('Magic', mData.name);
}
}
}
// --- Main Loop ---
function update(dt) {
if (gameState === 'STARTUP') {
gameState = 'TITLE'; playBGM('ScreenBgm', 'title'); return;
}
if (gameState !== 'PLAY') return;
timeLeft -= dt;
if (timeLeft <= 0) {
if (mode === 2) {
setStageClear();
} else {
if (players[0].score > players[1].score) setStageClear();
else setGameOver();
}
return;
}
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(p1.magicLevel);
playSound('Sound', 'magic_cast', 'magic_cast');
} else if (p1.lifting) executeMagic(p1);
keys['a_handled'] = true;
}
} else keys['a_handled'] = false;
let p2 = players[1];
if (mode === 2) {
// Numpad movement + 4,8,2,6 fallback
if (keys['8'] || keys['Numpad8']) p2.move(0, -1);
else if (keys['2'] || keys['Numpad2']) p2.move(0, 1);
else if (keys['4'] || keys['Numpad4']) p2.move(-1, 0);
else if (keys['6'] || keys['Numpad6']) p2.move(1, 0);
if (keys[' '] || keys['Spacebar']) {
if (!keys['space_handled']) {
if (!p2.lifting && p2.magic) {
p2.lifting = true; p2.attackArea = generateAttackArea(p2.magicLevel);
playSound('Sound', 'magic_cast', 'magic_cast');
} else if (p2.lifting) executeMagic(p2);
keys['space_handled'] = true;
}
} else keys['space_handled'] = false;
} else {
if (p2.lifting) {
if (p2.hesitateTimer > 0) p2.hesitateTimer -= dt;
else executeMagic(p2);
} else if (!p2.moving && Math.random() < 0.15 + (stage * 0.05)) {
let actionTaken = false;
if (p2.magic && !p2.lifting) {
let tempArea = generateAttackArea(p2.magicLevel);
let inRange = tempArea.some(cell => {
let ax = p2.targetX + cell.dx; let ay = p2.targetY + cell.dy;
return (ax === p1.targetX && ay === p1.targetY) || (ax === p1.x && ay === p1.y);
});
if (inRange) {
p2.lifting = true; p2.attackArea = tempArea;
playSound('Sound', 'magic_cast', 'magic_cast');
if (Math.random() < 0.5) p2.hesitateTimer = 1.0 + Math.random();
else p2.hesitateTimer = 0;
actionTaken = true;
}
}
if (!actionTaken && !p2.lifting) {
let nextMove = null;
if (!p2.magic) nextMove = findPath(p2.targetX, p2.targetY, 'magic', null);
if (!nextMove && p2.hp < 70) nextMove = findPath(p2.targetX, p2.targetY, 'heal', null);
if (!nextMove) nextMove = findPath(p2.targetX, p2.targetY, 'food', null);
if (!nextMove) nextMove = findPath(p2.targetX, p2.targetY, 'player', p1);
if (nextMove) p2.move(nextMove.dx, nextMove.dy);
else {
const dirs = [{dx: 0, dy: -1}, {dx: 0, dy: 1}, {dx: -1, dy: 0}, {dx: 1, dy: 0}];
let rm = dirs[Math.floor(Math.random()*dirs.length)];
p2.move(rm.dx, rm.dy);
}
}
}
}
players.forEach(p => p.update(dt));
players.forEach(p => checkTrapped(p));
meteors.forEach(m => m.update(dt));
meteors = meteors.filter(m => !m.hit);
fallingItems.forEach(f => f.update(dt));
fallingItems = fallingItems.filter(f => !f.hit);
particles.forEach(p => p.update(dt));
particles = particles.filter(p => p.life > 0);
}
function drawImageOrFallbackScreen(bgName, textRenderer) {
const path = `Screen/${bgName}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, 0, 0, canvas.width, canvas.height);
} else textRenderer();
}
function draw() {
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'TITLE') {
drawImageOrFallbackScreen('title', () => {
ctx.fillStyle = '#fff'; ctx.textAlign = 'center';
ctx.font = '50px sans-serif'; ctx.fillText('Magical Maze', 400, 250);
});
ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
ctx.fillRect(250, 400, 300, 120); ctx.strokeRect(250, 400, 300, 120);
ctx.fillStyle = '#fff'; ctx.textAlign = 'left'; ctx.font = '24px sans-serif';
ctx.fillText("Select Mode:", 270, 435);
ctx.fillText(mode === 1 ? "▶ 1P vs CPU" : " 1P vs CPU", 290, 470);
ctx.fillText(mode === 2 ? "▶ 2P Local" : " 2P Local", 290, 500);
return;
}
if (gameState === 'STAGE_START' || gameState === 'STAGE_CLEAR' || gameState === 'GAME_OVER' || gameState === 'GAME_CLEAR') {
let bgName = gameState === 'STAGE_START' ? 'stage_start' :
gameState === 'STAGE_CLEAR' ? 'stage_clear' :
gameState === 'GAME_CLEAR' ? 'game_clear' : 'game_over';
drawImageOrFallbackScreen(bgName, () => {
ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.font = '40px sans-serif';
let txt = gameState === 'STAGE_START' ? `Stage ${stage} Start` :
gameState === 'STAGE_CLEAR' ? `Stage ${stage} Clear!` :
gameState === 'GAME_OVER' ? `Game Over` : `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') {
if (cell.name === 'normal') {
const path = 'Road/default.png';
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, x*TS, y*TS, TS, TS);
} else {
ctx.fillStyle = 'lightgreen'; ctx.fillRect(x*TS, y*TS, TS, TS);
}
} else {
const path = `Road/${cell.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, x*TS, y*TS, TS, TS);
} else {
ctx.fillStyle = 'lightgreen'; ctx.fillRect(x*TS, y*TS, TS, TS);
}
}
} else if (cell.type === 'hurdle') {
const path = `Hurdle/${cell.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, x*TS, y*TS, TS, TS);
ctx.lineWidth = 2; ctx.strokeStyle = cell.color;
ctx.strokeRect(x * TS + 1, y * TS + 1, TS - 2, TS - 2);
} else {
ctx.fillStyle = cell.color; ctx.fillRect(x*TS, y*TS, TS, TS);
}
}
}
}
items.forEach(item => {
if (['food', 'heal', 'speed'].includes(item.type)) {
// 有効なパスリストからランダムに1つ選んで描画
const validPaths = VALID_ITEM_PATHS[item.type];
if (validPaths.length > 0) {
const path = validPaths[item.rndIdx % validPaths.length];
ctx.drawImage(ASSETS.images[path].img, item.x * TS, item.y * TS, TS, TS);
} else {
// 読み込めた画像がない場合のフォールバック
if (item.type === 'food') {
ctx.fillStyle = 'yellow'; ctx.fillRect(item.x * TS + TS/4, item.y * TS + TS/4, TS/2, TS/2);
} else if (item.type === 'heal') {
ctx.fillStyle = 'yellowgreen'; ctx.fillRect(item.x * TS + TS/3, item.y * TS + TS/3, TS/3, TS/3);
} else {
ctx.fillStyle = 'cyan'; ctx.fillRect(item.x * TS + TS/3, item.y * TS + TS/3, TS/3, TS/3);
}
}
} 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();
}
}
});
fallingItems.forEach(f => f.draw(ctx));
players.forEach(p => {
if (p.lifting && p.attackArea.length > 0 && p.magic) {
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) {
let mapCell = map[ay][ax];
let isDestructible = mapCell.type === 'hurdle' &&
((mapCell.name === 'water' && p.magic.name === 'ice') || mapCell.name === p.magic.name);
if (isDestructible) {
ctx.setLineDash([]);
ctx.globalAlpha = 0.5 + 0.5 * Math.sin(performance.now() / 150);
ctx.strokeStyle = 'white';
} else {
ctx.setLineDash([5, 5]);
ctx.lineDashOffset = -performance.now() / 50;
ctx.globalAlpha = 1.0;
ctx.strokeStyle = p.magic.color;
}
ctx.lineWidth = 3;
ctx.strokeRect(ax * TS + 2, ay * TS + 2, TS - 4, TS - 4);
}
});
ctx.globalAlpha = 1.0; ctx.setLineDash([]);
}
});
players.forEach(p => p.draw(ctx));
meteors.forEach(m => m.draw(ctx));
particles.forEach(p => p.draw(ctx));
// --- UI 領域の描画 ---
const uiY = ROWS * TS;
ctx.fillStyle = '#333'; ctx.fillRect(0, uiY, canvas.width, UI_HEIGHT);
ctx.font = '24px sans-serif';
ctx.fillStyle = '#fff';
// P1
ctx.textAlign = 'left';
ctx.fillText(`P1 Score: ${players[0].score}`, 20, uiY + 30);
drawHpBar(20, uiY + 40, players[0].hp, players[0].maxHp, 'cyan', 'navy');
if (players[0].magic) {
const m = players[0].magic;
const path = `Magic/${m.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, 20, uiY + 80, 40, 40);
} else {
ctx.beginPath(); ctx.arc(40, uiY + 100, 15, 0, Math.PI*2);
ctx.fillStyle = m.color; ctx.fill(); ctx.closePath();
}
ctx.fillText(`: ${m.name.toUpperCase()} (Lv.${players[0].magicLevel})`, 70, uiY + 110);
}
// P2
ctx.textAlign = 'right';
ctx.fillText(`${mode === 1 ? 'CPU' : 'P2'} Score: ${players[1].score}`, canvas.width - 20, uiY + 30);
drawHpBar(canvas.width - 270, uiY + 40, players[1].hp, players[1].maxHp, 'pink', 'saddlebrown');
if (players[1].magic) {
const m = players[1].magic;
ctx.fillText(`(Lv.${players[1].magicLevel}) ${m.name.toUpperCase()} :`, canvas.width - 70, uiY + 110);
const path = `Magic/${m.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, canvas.width - 60, uiY + 80, 40, 40);
} else {
ctx.beginPath(); ctx.arc(canvas.width - 40, uiY + 100, 15, 0, Math.PI*2);
ctx.fillStyle = m.color; ctx.fill(); ctx.closePath();
}
}
// 中央タイマーとステージ
ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.font = '30px sans-serif';
ctx.fillText(`Stage: ${stage}`, canvas.width / 2, uiY + 50);
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 + 100);
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 = '50px sans-serif';
ctx.fillText('PAUSE', 400, 300);
}
}
function drawHpBar(x, y, hp, maxHp, color, bgColor) {
const width = 250; const height = 25;
ctx.fillStyle = bgColor; 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.lineWidth = 2; ctx.strokeRect(x, y, width, height);
}
function handleScreenTransition() {
playSound('Sound', 'pageup', 'pageup');
if (gameState === 'TITLE') {
stage = 1; gameState = 'STAGE_START';
playBGM('ScreenBgm', 'stage_state');
} else if (gameState === 'STAGE_START') {
initGame(); gameState = 'PLAY';
playBGM('StageBgm', 'bgm' + (Math.floor(Math.random() * 3) + 1));
} else if (gameState === 'STAGE_CLEAR') {
stage++; gameState = 'STAGE_START'; playBGM('ScreenBgm', 'stage_state');
} else if (gameState === 'GAME_OVER') {
gameState = 'STAGE_START'; playBGM('ScreenBgm', 'stage_state');
} else if (gameState === 'GAME_CLEAR') {
gameState = 'TITLE'; playBGM('ScreenBgm', 'title');
}
}
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>

コメント