簡単な対戦ゲーム「MagicalMaze」
【更新履歴】
・2026/5/18 バージョン1.0公開。
・2026/5/18 バージョン1.1公開。
・2026/5/18 バージョン1.2公開。
・2026/5/18 バージョン1.3公開。(マルチプレイモード追加)
・2026/5/20 バージョン1.4公開。(マップチップを刷新し拡大)
・ダウンロードされる方はこちら。↓
【操作説明】
・方向キーで移動する。
・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 1.4</title>
<style>
body {
margin: 0;
padding: 0;
background-color: #000;
color: #fff;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
#gameContainer {
position: absolute;
top: 0; left: 0;
width: 100vw;
height: 100vh;
}
canvas {
display: block;
width: 100%;
height: 100%;
background-color: #222;
outline: none;
}
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas" 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 ---
let TS = 40;
const COLS = 15; // マップサイズを半分に
const ROWS = 10;
const TIME_LIMIT = 180;
const MAX_STAGES = 8;
const UI_HEIGHT = 160;
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// UI領域を除いた画面にマップ全体が収まるようにTSを動的計算(COLS/ROWSが半分になったのでTSは2倍になる)
TS = Math.floor(Math.min(canvas.width / COLS, (canvas.height - UI_HEIGHT) / ROWS));
if (TS < 10) TS = 10;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// --- 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);
});
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'); }
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 = [];
let p1Wins = 0;
let p2Wins = 0;
let lastStageWinner = 0;
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.yPos = y - (ROWS / 2); // グリッドベース
this.speed = 12; // grids per second
this.type = type; this.data = data; this.hit = false;
}
update(dt) {
if (this.hit) return;
this.yPos += this.speed * dt;
if (this.yPos >= this.targetY) {
this.yPos = this.targetY; this.hit = true;
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;
let px = this.targetX * TS + TS / 2;
let py = this.yPos * TS + TS / 2;
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, px - TS/2, py - TS/2, TS, TS);
} else {
ctx.beginPath(); ctx.arc(px, py, 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;
this.baseTS = TS;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * (TS * 3) + TS;
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() * (TS * 0.2) + (TS * 0.1);
}
update(dt) {
this.vy += (this.baseTS * 12) * 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.yPos = ay - (ROWS / 2);
this.speed = 25 + Math.random() * 8;
this.magic = magic; this.owner = owner; this.hit = false;
}
update(dt) {
if (this.hit) return;
this.yPos += this.speed * dt;
if (this.yPos >= this.targetY) {
this.yPos = this.targetY; this.hit = true;
handleMeteorHit(this);
}
}
draw(ctx) {
if (this.hit) return;
let px = this.targetX * TS + TS / 2;
let py = this.yPos * TS + TS / 2;
const path = `Magic/${this.magic.name}.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, px - TS/2, py - TS/2, TS, TS);
} else {
ctx.beginPath(); ctx.arc(px, py, TS/4, 0, Math.PI*2);
ctx.fillStyle = this.magic.color; ctx.fill(); ctx.closePath();
}
ctx.beginPath(); ctx.moveTo(px, py - TS/4);
ctx.lineTo(px, py - 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.attackArea = [];
this.steps = 0; this.speedTimer = 0; this.hesitateTimer = 0;
this.moving = false; this.moveTimer = 0; this.dir = 'down';
this.isAiming = false; // CPU専用フラグ
}
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) * (TS / 2.6);
}
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.magic) {
ctx.beginPath(); ctx.arc(drawX + TS/2, drawY - TS/4, 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)) {
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;
}
// 魔法入手時に常時レンジを展開する
player.attackArea = generateAttackArea(player.magicLevel);
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.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 && gameState === 'PLAY') {
if (mode === 1) {
if (target.isPlayer1) {
setGameOver();
} else {
lastStageWinner = 1; p1Wins++;
setStageClear();
}
} else {
if (target.isPlayer1) {
lastStageWinner = 2; p2Wins++;
} else {
lastStageWinner = 1; p1Wins++;
}
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 (gameState === 'PLAY') {
if (mode === 1) {
if (players[0].score > players[1].score) {
lastStageWinner = 1; p1Wins++; setStageClear();
} else {
setGameOver();
}
} else {
if (players[0].score > players[1].score) { lastStageWinner = 1; p1Wins++; }
else if (players[1].score > players[0].score) { lastStageWinner = 2; p2Wins++; }
else { lastStageWinner = 0; }
setStageClear();
}
}
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);
// Aキーで即時攻撃
if (keys['a'] || keys['A']) {
if (!keys['a_handled']) {
if (p1.magic) executeMagic(p1);
keys['a_handled'] = true;
}
} else keys['a_handled'] = false;
let p2 = players[1];
if (mode === 2) {
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);
// Spaceキーで即時攻撃
if (keys[' '] || keys['Spacebar']) {
if (!keys['space_handled']) {
if (p2.magic) executeMagic(p2);
keys['space_handled'] = true;
}
} else keys['space_handled'] = false;
} else {
// CPUの行動ロジック
if (p2.isAiming) {
if (p2.hesitateTimer > 0) p2.hesitateTimer -= dt;
else {
executeMagic(p2);
p2.isAiming = false;
}
} else if (!p2.moving && Math.random() < 0.15 + (stage * 0.05)) {
let actionTaken = false;
if (p2.magic) {
// 既にレンジは展開されているため、その中にP1がいるかチェック
let inRange = p2.attackArea.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.isAiming = true;
playSound('Sound', 'magic_cast', 'magic_cast'); // 威嚇音として鳴らす
if (Math.random() < 0.5) p2.hesitateTimer = 1.0 + Math.random();
else p2.hesitateTimer = 0.1;
actionTaken = true;
}
}
if (!actionTaken && !p2.isAiming) {
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 renderGameElements() {
// Map
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') {
const path = (cell.name === 'normal') ? 'Road/default.png' : `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 = 4; ctx.strokeStyle = cell.color;
ctx.strokeRect(x * TS + 2, y * TS + 2, TS - 4, TS - 4);
} else {
ctx.fillStyle = cell.color; ctx.fillRect(x*TS, y*TS, TS, TS);
}
}
}
}
// Items
items.forEach(item => {
if (['food', 'heal', 'speed'].includes(item.type)) {
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();
}
}
});
// Falling Items
fallingItems.forEach(f => f.draw(ctx));
// Attack Areas (常時表示)
players.forEach(p => {
if (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 = 4;
ctx.strokeRect(ax * TS + 2, ay * TS + 2, TS - 4, TS - 4);
}
});
ctx.globalAlpha = 1.0; ctx.setLineDash([]);
}
});
// Entities
players.forEach(p => p.draw(ctx));
meteors.forEach(m => m.draw(ctx));
particles.forEach(p => p.draw(ctx));
}
function drawHpBar(x, y, hp, maxHp, color, bgColor, w, h) {
ctx.fillStyle = bgColor; ctx.fillRect(x, y, w, h);
ctx.fillStyle = color;
const hpWidth = Math.max(0, (hp / maxHp) * w);
ctx.fillRect(x, y, hpWidth, h);
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.strokeRect(x, y, w, h);
}
function drawPlayerUI(player, isP1, startX, startY) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(startX, startY, 320, 150);
ctx.strokeStyle = isP1 ? 'cyan' : 'pink';
ctx.lineWidth = 3;
ctx.strokeRect(startX, startY, 320, 150);
ctx.fillStyle = '#fff';
ctx.font = '22px sans-serif';
ctx.textAlign = 'left';
const px = startX + 15;
let py = startY + 30;
const lh = 30;
// 行1: 名前と勝ち点分の星
const name = isP1 ? "Player1" : (mode === 1 ? "CPU" : "Player2");
const wins = isP1 ? p1Wins : p2Wins;
let stars = "";
for (let i = 0; i < wins; i++) stars += "★";
ctx.fillText(`${name} ${stars}`, px, py);
py += lh;
// 行2: スコア
ctx.fillText(`Score ${player.score}`, px, py);
py += lh;
// 行3: HPとバー
ctx.fillText(`HP ${Math.floor(player.hp)}`, px, py);
drawHpBar(px + 80, py - 18, player.hp, player.maxHp, isP1 ? 'cyan' : 'pink', 'navy', 200, 20);
py += lh;
// 行4: 魔法アイコンをレベル分並べ、属性名
if (player.magic) {
const m = player.magic;
const path = `Magic/${m.name}.png`;
let iconX = px;
for(let i=0; i<player.magicLevel; i++) {
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, iconX, py - 24, 30, 30);
} else {
ctx.beginPath();
ctx.arc(iconX + 15, py - 9, 12, 0, Math.PI * 2);
ctx.fillStyle = m.color;
ctx.fill();
ctx.closePath();
}
iconX += 35;
}
ctx.fillStyle = '#fff';
ctx.fillText(`${m.name.toUpperCase()}`, iconX, py);
} else {
ctx.fillText(`NONE`, px, py);
}
}
function drawCenterUI() {
const cx = canvas.width / 2;
const cy = canvas.height - UI_HEIGHT + 40;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(cx - 120, cy, 240, 80);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(cx - 120, cy, 240, 80);
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.font = 'bold 24px sans-serif';
ctx.fillText(`Stage: ${stage} / ${MAX_STAGES}`, cx, cy + 30);
const min = Math.floor(timeLeft / 60);
const sec = Math.floor(timeLeft % 60).toString().padStart(2, '0');
ctx.fillText(`Time: ${min}:${sec}`, cx, cy + 65);
}
function draw() {
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'TITLE') {
const path = `Screen/title.png`;
if (ASSETS.images[path] && ASSETS.images[path].loaded) {
ctx.drawImage(ASSETS.images[path].img, 0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#111'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff'; ctx.textAlign = 'center';
ctx.font = '50px sans-serif'; ctx.fillText('Magical Maze', canvas.width/2, canvas.height/2 - 100);
}
let boxW = 500, boxH = 120;
let boxX = (canvas.width - boxW) / 2, boxY = canvas.height/2 + 50;
ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH);
ctx.fillStyle = '#fff'; ctx.textAlign = 'left'; ctx.font = '24px sans-serif';
ctx.fillText("Select Mode:", boxX + 20, boxY + 35);
ctx.fillText(mode === 1 ? "▶ SINGLE MODE (CPU対戦)" : " SINGLE MODE (CPU対戦)", boxX + 40, boxY + 70);
ctx.fillText(mode === 2 ? "▶ MULTI MODE (人間同士で対戦)" : " MULTI MODE (人間同士で対戦)", boxX + 40, boxY + 100);
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';
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);
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#111'; ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.fillStyle = '#fff'; ctx.textAlign = 'center';
if (gameState === 'GAME_CLEAR') {
ctx.font = 'bold 40px sans-serif';
let winnerTxt = "MATCH DRAW!";
if (p1Wins > p2Wins) winnerTxt = mode === 2 ? "P1 WINS THE MATCH!" : "YOU WIN THE MATCH!";
else if (p2Wins > p1Wins) winnerTxt = (mode === 1 ? "CPU" : "P2") + " WINS THE MATCH!";
ctx.fillStyle = p1Wins > p2Wins ? '#00e5ff' : (p2Wins > p1Wins ? '#ff00ff' : '#ffeb3b');
ctx.fillText(winnerTxt, canvas.width / 2, canvas.height / 2 - 40);
ctx.font = 'bold 30px sans-serif';
ctx.fillStyle = '#fff';
ctx.fillText(`FINAL SCORE: P1 [ ${p1Wins} ] - [ ${p2Wins} ] ${mode === 1 ? 'CPU' : 'P2'}`, canvas.width / 2, canvas.height / 2 + 20);
ctx.font = '20px sans-serif';
ctx.fillStyle = '#aaa';
ctx.fillText('Press Any Key...', canvas.width / 2, canvas.height / 2 + 80);
} else {
ctx.font = 'bold 40px sans-serif';
let txt = gameState === 'STAGE_START' ? `Stage ${stage} Start` :
gameState === 'STAGE_CLEAR' ? `Stage ${stage} Clear!` :
`Game Over`;
ctx.fillText(txt, canvas.width / 2, canvas.height / 2 - 20);
if (gameState === 'STAGE_CLEAR') {
ctx.font = 'bold 24px sans-serif';
ctx.fillStyle = '#00e5ff';
let stgWinner = "DRAW!";
if (lastStageWinner === 1) stgWinner = "P1 WINS THE STAGE!";
else if (lastStageWinner === 2) {
stgWinner = (mode === 1 ? "CPU" : "P2") + " WINS THE STAGE!";
ctx.fillStyle = '#ff00ff';
} else {
ctx.fillStyle = '#ffeb3b';
}
ctx.fillText(stgWinner, canvas.width / 2, canvas.height / 2 + 30);
ctx.fillStyle = '#fff';
}
ctx.font = '20px sans-serif';
ctx.fillStyle = '#aaa';
ctx.fillText('Press Any Key...', canvas.width / 2, canvas.height / 2 + (gameState === 'STAGE_CLEAR' ? 80 : 40));
}
return;
}
if (gameState === 'PLAY') {
// スクロールなし・分割なしでマップ全体を描画
const mapOffsetX = (canvas.width - COLS * TS) / 2;
const mapOffsetY = Math.max(0, (canvas.height - UI_HEIGHT - ROWS * TS) / 2);
ctx.save();
ctx.translate(mapOffsetX, mapOffsetY);
renderGameElements();
ctx.restore();
// UIオーバーレイ描画を下部に配置
const uiY = canvas.height - UI_HEIGHT + 5;
drawPlayerUI(players[0], true, 20, uiY);
drawPlayerUI(players[1], false, canvas.width - 340, uiY);
drawCenterUI();
}
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', canvas.width / 2, canvas.height / 2);
}
}
function handleScreenTransition() {
playSound('Sound', 'pageup', 'pageup');
if (gameState === 'TITLE') {
stage = 1; gameState = 'STAGE_START';
p1Wins = 0; p2Wins = 0; lastStageWinner = 0;
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>


コメント