簡単な対戦ゲーム「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公開。(マップチップを刷新し拡大)
・2026/5/20 バージョン1.5公開。
・2026/6/15 バージョン1.6公開。(ストーリーモードを改善)
・ダウンロードされる方はこちら。↓
【操作説明】
・方向キーで移動する。
・Aキーで取得した魔法で攻撃。
・制限時間内に、より多くのスコアを得たほうが勝ち。
・あるいは、相手のHPを、先にゼロにしたほうが勝ち。
・魔法が当たった障害物が同じ属性なら破壊される。
・同じ属性の魔法を続けて取得すると、攻撃範囲が広くなる。(最大3回)
・相手の攻撃が当たっても、同じ魔法を持っていたらノーダメージ。
・または、同じ属性の障害物の跡に乗っている時は、
1/2の確率でノーダメージ。
【マルチプレイ時の操作】
・プレイヤー2は、テンキーで移動。スペースキーで魔法で攻撃。
・このゲームで遊ぶには、いつものテスト用ブラウザが必要です。↓
・ソースコードはこちら。↓
index.html
<!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: #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 src="js/globals_assets.js"></script>
<script src="js/entities.js"></script>
<script src="js/logic.js"></script>
<script src="js/main.js"></script>
</body>
</html>entities.js
class FloatingText {
constructor(x, y, text) {
this.x = x; this.y = y; this.text = text;
this.life = 1.0;
this.color = '#fff';
}
update(dt) {
this.y -= 1.5 * dt;
this.life -= dt;
}
draw(ctx) {
ctx.globalAlpha = Math.max(0, this.life);
ctx.fillStyle = this.color;
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.text, this.x * TS + TS / 2, this.y * TS);
ctx.globalAlpha = 1.0;
}
}
class FallingItem {
constructor(x, y, type, data) {
this.targetX = x; this.targetY = y;
this.yPos = y - (ROWS / 2);
this.speed = 12;
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.isMob = false;
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;
this.bumpTimer = 0; this.bumpMaxTimer = 0.15;
this.bumpDX = 0; this.bumpDY = 0;
this.dashDir = null;
this.lastUltimateScore = 0;
this.ultimateTimer = 0;
}
update(dt) {
if (this.speedTimer > 0) this.speedTimer -= dt;
if (this.bumpTimer > 0) this.bumpTimer = Math.max(0, this.bumpTimer - dt);
// 究極魔法のタイマー進行と、直線5マスへの発動
if (this.ultimateTimer > 0) {
this.ultimateTimer -= dt;
if (this.ultimateTimer <= 0) {
let dx = 0, dy = 0;
if (this.dir === 'up') dy = -1;
else if (this.dir === 'down') dy = 1;
else if (this.dir === 'left') dx = -1;
else if (this.dir === 'right') dx = 1;
for (let i = 1; i <= 5; i++) {
let mx = Math.round(this.x) + dx * i;
let my = Math.round(this.y) + dy * i;
if (mx >= 0 && mx < MAP_COLS && my >= 0 && my < MAP_ROWS) {
let m = new Meteor(mx, my, {name:'ultimate', color:'white'}, this);
m.hit = true;
handleMeteorHit(m);
}
}
this.ultimateTimer = 0; // 発動後は確実に0に戻す
}
}
// オートダッシュ制御
if (!this.moving && this.dashDir && mode === 3 && !this.isMob) {
let dx = this.dashDir.dx, dy = this.dashDir.dy;
let nx = this.x + dx, ny = this.y + dy;
let cell = (map[ny] && map[ny][nx]);
if (!cell || cell.type !== 'road' || !cell.isCorridor) {
const dirs = [{dx:0,dy:-1},{dx:0,dy:1},{dx:-1,dy:0},{dx:1,dy:0}];
let validDirs = dirs.filter(d => {
if (d.dx === -this.dashDir.dx && d.dy === -this.dashDir.dy) return false;
let tx = this.x + d.dx, ty = this.y + d.dy;
let c = map[ty] && map[ty][tx];
let occupied = players.some(p => Math.round(p.targetX) === tx && Math.round(p.targetY) === ty);
return c && c.type === 'road' && c.isCorridor && !occupied;
});
if (validDirs.length === 1) {
this.dashDir = {dx: validDirs[0].dx, dy: validDirs[0].dy};
} else {
this.dashDir = null;
}
}
if (this.dashDir) {
let tx = this.x + this.dashDir.dx, ty = this.y + this.dashDir.dy;
let occupied = players.some(p => Math.round(p.targetX) === tx && Math.round(p.targetY) === ty);
if (occupied) {
this.dashDir = null;
} else {
this.move(this.dashDir.dx, this.dashDir.dy);
}
}
}
if (this.moving) {
this.moveTimer += dt;
const speedThreshold = this.speedTimer > 0 ? 0.1 : (this.dashDir ? 0.1 : 0.2);
if (this.moveTimer >= speedThreshold) {
this.x = this.targetX; this.y = this.targetY;
this.moving = false; this.moveTimer = 0; this.steps++;
if (!this.isMob) {
checkItemPickup(this);
if (mode === 3 && this.isPlayer1) {
foodGauge = Math.max(0, foodGauge - 0.5);
if (foodGauge <= 0 && gameState === 'PLAY') {
this.hp = Math.max(0, this.hp - 5);
if (this.hp <= 0) setGameOver();
}
}
}
}
}
}
move(dx, dy) {
if (this.moving || this.bumpTimer > 0) 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 < MAP_COLS && ny >= 0 && ny < MAP_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;
if (!this.isMob) {
if (mode === 3 && cell.isCorridor) {
this.dashDir = {dx, dy};
playSound('Sound', 'dash', 'dash');
} else {
this.dashDir = null;
playSound('Sound', 'move', 'move');
}
}
} else {
const isEnemy = mode === 3 ? (this.isMob !== other.isMob) : (this.isPlayer1 !== other.isPlayer1);
if (isEnemy) executeMelee(this, other);
else if (!this.isMob) playSound('Sound', 'dont_move', 'dont_move');
this.dashDir = null;
}
} else {
if (!this.isMob) playSound('Sound', 'dont_move', 'dont_move');
this.dashDir = null;
}
} else if (!this.isMob) {
playSound('Sound', 'dont_move', 'dont_move');
this.dashDir = null;
}
}
draw(ctx) {
let drawX = this.x * TS; let drawY = this.y * TS; let jumpOffset = 0;
if (this.bumpTimer > 0 && this.bumpMaxTimer > 0) {
const bp = 1 - (this.bumpTimer / this.bumpMaxTimer);
const bOff = Math.sin(bp * Math.PI) * (TS * 0.38);
drawX += this.bumpDX * bOff;
drawY += this.bumpDY * bOff;
}
if (this.moving) {
const speedThreshold = this.speedTimer > 0 ? 0.1 : (this.dashDir ? 0.1 : 0.2);
const progress = this.moveTimer / speedThreshold;
drawX += (this.targetX - this.x) * TS * progress;
drawY += (this.targetY - this.y) * TS * progress;
if (!this.dashDir) {
jumpOffset = Math.sin(progress * Math.PI) * (TS / 2.6);
}
}
const folder = this.isPlayer1 ? 'Player' : 'Enemy';
const color = this.isPlayer1
? (mode === 2 ? 'blue' : 'cyan')
: (this.isMob ? 'orange' : (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 (this.isMob) ctx.globalAlpha = 0.9;
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();
}
ctx.globalAlpha = 1.0;
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();
}
if (this.isMob) {
ctx.fillStyle = '#333'; ctx.fillRect(drawX, drawY + TS + 2, TS, 8);
ctx.fillStyle = this.hp / this.maxHp > 0.5 ? '#00cc44' : '#ff4444';
ctx.fillRect(drawX, drawY + TS + 2, (this.hp / this.maxHp) * TS, 8);
}
}
}global_assets.js
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
canvas.focus();
// --- Game Constants ---
let TS = 40;
const COLS = 15;
const ROWS = 10;
let MAP_COLS = 15;
let MAP_ROWS = 10;
const TIME_LIMIT = 180;
const MAX_STAGES = 8;
const UI_HEIGHT = 160;
// --- 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 floatingTexts = [];
let p1Wins = 0;
let p2Wins = 0;
let lastStageWinner = 0;
// --- Story Mode State ---
let dungeon = [];
let currentRoomNode = null;
let magicSpawnTimer = 0;
const MAX_FOOD_GAUGE = 100;
let foodGauge = MAX_FOOD_GAUGE;
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];
// --- 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 playStageBGM(stg) {
if (stg < 1) return;
const path = `StageBgm/${stg}.mp3`;
if (currentBgmAudio) {
currentBgmAudio.pause();
currentBgmAudio = null;
}
if (ASSETS.audio[path] && ASSETS.audio[path].error) {
playStageBGM(stg - 1);
return;
}
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;
playStageBGM(stg - 1);
};
audio.play().then(() => {
currentBgmAudio = audio;
}).catch(() => {
currentBgmAudio = audio;
});
} else 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 === 'dash') {
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(250, AUDIO_CTX.currentTime);
osc.frequency.exponentialRampToValueAtTime(100, AUDIO_CTX.currentTime + 0.05);
gain.gain.setValueAtTime(0.015, AUDIO_CTX.currentTime);
osc.start(); osc.stop(AUDIO_CTX.currentTime + 0.05); return;
} 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');
loadImg('Road', 'default');
['title', 'stage_start', 'stage_clear', 'game_clear', 'game_over'].forEach(s => loadImg('Screen', s));
const VALID_ITEM_PATHS = { food: [], heal: [], speed: [] };
const keys = {};
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();logic.js
// =====================================================================
// Story-mode key-magic system state
// (これまで通ってきたフロアに「最初から」配置し、無くなったら時間経過で
// 「同じ場所」に再出現させるためのスロット情報を保持する)
// =====================================================================
let keyMagicSlots = []; // [{x, y, name, room}] 各部屋に常備するキー魔法の定位置
let keyMagicTimer = 0; // キー魔法の補充間引き用タイマー
function initGame() {
timeLeft = TIME_LIMIT;
magicSpawnTimer = 5 + Math.random() * 7;
keyMagicTimer = 0;
foodGauge = MAX_FOOD_GAUGE;
particles = []; meteors = []; fallingItems = []; floatingTexts = [];
players = [];
keyMagicSlots = [];
MAP_COLS = mode === 3 ? 60 : 15;
MAP_ROWS = mode === 3 ? 40 : 10;
if (mode === 3) {
generateDungeon();
} else {
generateStandardMap();
}
}
function generateStandardMap() {
map = []; items = [];
players = [new Entity(0, 0, true), new Entity(MAP_COLS - 1, MAP_ROWS - 1, false)];
for (let y = 0; y < MAP_ROWS; y++) {
let row = [];
for (let x = 0; x < MAP_COLS; x++) row.push({ type: 'road', name: 'normal', color: 'lightgreen' });
map.push(row);
}
for (let y = 0; y < MAP_ROWS; y++) {
for (let x = 0; x < MAP_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);
}
}
}
const safeStarts = [[0,0], [MAP_COLS-1, MAP_ROWS-1]];
safeStarts.forEach(pos => {
const px = pos[0], py = pos[1];
map[py][px] = { type: 'start', name: 'normal', color: 'lightgreen' };
const dirs = [{dx:0,dy:1}, {dx:1,dy:0}, {dx:-1,dy:0}, {dx:0,dy:-1}];
dirs.forEach(d => {
if(map[py+d.dy] && map[py+d.dy][px+d.dx]) {
map[py+d.dy][px+d.dx] = { type: 'road', name: 'normal', color: 'lightgreen' };
}
});
});
let cx = 0, cy = 0;
while (cx < MAP_COLS - 1 || cy < MAP_ROWS - 1) {
if (!(cx === 0 && cy === 0)) map[cy][cx] = { type: 'road', name: 'normal', color: 'lightgreen' };
if (cx === MAP_COLS - 1) cy++; else if (cy === MAP_ROWS - 1) cx++;
else Math.random() < 0.5 ? cx++ : cy++;
}
spawnItems();
}
function generateDungeon() {
map = []; items = [];
keyMagicSlots = [];
for (let y = 0; y < MAP_ROWS; y++) {
let row = [];
for (let x = 0; x < MAP_COLS; x++) row.push({ type: 'wall', color: '#111', isCorridor: false });
map.push(row);
}
dungeon = [];
const GRID = 4;
let grid = Array(GRID).fill(null).map(() => Array(GRID).fill(null));
let rx = Math.floor(Math.random() * GRID);
let ry = Math.floor(Math.random() * GRID);
let numRooms = 12 + Math.floor(Math.random() * 5);
let rooms = [];
for (let i = 0; i < numRooms; i++) {
if (!grid[ry][rx]) {
let r = { gx: rx, gy: ry, up: null, down: null, left: null, right: null, isStart: false, isGoal: false, visited: false, keyMagics: new Set(), corridorHurdles: [] };
grid[ry][rx] = r;
rooms.push(r);
dungeon.push(r);
}
let r = grid[ry][rx];
if (!r.w) {
r.w = 5 + Math.floor(Math.random() * 5);
r.h = 4 + Math.floor(Math.random() * 4);
r.cx = rx * 15 + 7;
r.cy = ry * 10 + 5;
r.x = r.cx - Math.floor(r.w/2);
r.y = r.cy - Math.floor(r.h/2);
for(let yy=r.y; yy<r.y+r.h; yy++){
for(let xx=r.x; xx<r.x+r.w; xx++){
map[yy][xx] = { type: 'road', name: 'normal', color: 'lightgreen', isCorridor: false };
}
}
}
let dirs = [];
if (rx > 0) dirs.push({dx: -1, dy: 0, dir: 'left', opp: 'right'});
if (rx < GRID-1) dirs.push({dx: 1, dy: 0, dir: 'right', opp: 'left'});
if (ry > 0) dirs.push({dx: 0, dy: -1, dir: 'up', opp: 'down'});
if (ry < GRID-1) dirs.push({dx: 0, dy: 1, dir: 'down', opp: 'up'});
let d = dirs[Math.floor(Math.random() * dirs.length)];
let nextRx = rx + d.dx, nextRy = ry + d.dy;
if (grid[nextRy][nextRx]) {
r[d.dir] = grid[nextRy][nextRx];
grid[nextRy][nextRx][d.opp] = r;
} else if (i < numRooms - 1) {
let nextR = { gx: nextRx, gy: nextRy, up: null, down: null, left: null, right: null, isStart: false, isGoal: false, visited: false, keyMagics: new Set(), corridorHurdles: [] };
grid[nextRy][nextRx] = nextR;
r[d.dir] = nextR;
nextR[d.opp] = r;
rooms.push(nextR);
dungeon.push(nextR);
}
rx = nextRx; ry = nextRy;
}
rooms[0].isStart = true;
rooms[rooms.length-1].isGoal = true;
// 通路を掘る。両端に障害物(ハードル)を置き、接続する「両方の部屋」に
// 必要魔法を記録しておく(どちら側からでも通り抜けに必要な魔法を入手できるように)。
function carveCorridor(roomA, roomB) {
let x1 = roomA.cx, y1 = roomA.cy, x2 = roomB.cx, y2 = roomB.cy;
let path = [];
let cx = x1, cy = y1;
while (cx !== x2) { cx += (x2 > cx ? 1 : -1); path.push({x: cx, y: cy}); }
while (cy !== y2) { cy += (y2 > cy ? 1 : -1); path.push({x: cx, y: cy}); }
path = path.filter(p => map[p.y][p.x].type === 'wall');
if (path.length === 0) return; // 部屋同士が隣接していて既に通行可能
path.forEach(p => {
map[p.y][p.x] = { type: 'road', name: 'normal', color: 'lightgreen', isCorridor: true };
});
const ends = (path.length === 1) ? [path[0]] : [path[0], path[path.length - 1]];
ends.forEach(p => {
let hType = HURDLE_TYPES[Math.floor(Math.random() * HURDLE_TYPES.length)];
map[p.y][p.x] = { type: 'hurdle', name: hType.name, color: hType.color, isCorridor: true };
loadImg('Hurdle', hType.name);
let reqName = (hType.name === 'water') ? 'ice' : hType.name;
roomA.keyMagics.add(reqName);
roomB.keyMagics.add(reqName);
roomA.corridorHurdles.push({ x: p.x, y: p.y, name: reqName });
roomB.corridorHurdles.push({ x: p.x, y: p.y, name: reqName });
});
}
rooms.forEach(r => {
if (r.right && r.gx < r.right.gx) carveCorridor(r, r.right);
if (r.down && r.gy < r.down.gy) carveCorridor(r, r.down);
// 各部屋内に障害物を多少配置
let numHurdles = Math.floor(Math.random() * 4);
for(let i=0; i<numHurdles; i++) {
let hx = r.x + Math.floor(Math.random() * r.w);
let hy = r.y + Math.floor(Math.random() * r.h);
if ((hx !== r.cx || hy !== r.cy) && map[hy][hx].type === 'road' && !map[hy][hx].isCorridor) {
let hType = HURDLE_TYPES[Math.floor(Math.random()*HURDLE_TYPES.length)];
map[hy][hx] = { type: 'hurdle', name: hType.name, color: hType.color, isCorridor: false };
loadImg('Hurdle', hType.name);
}
}
// 各部屋内に回復・食料を配置
let numItems = Math.floor(Math.random() * 3);
for(let i=0; i<numItems; i++) {
let hx = r.x + Math.floor(Math.random() * r.w);
let hy = r.y + Math.floor(Math.random() * r.h);
if ((hx !== r.cx || hy !== r.cy) && map[hy][hx].type === 'road' && !map[hy][hx].isCorridor && !items.some(it=>it.x===hx&&it.y===hy)) {
let iType = Math.random() < 0.7 ? 'food' : 'heal';
items.push({ x: hx, y: hy, type: iType, rndIdx: Math.floor(Math.random() * 10000) });
}
}
});
// 通路入口(部屋側)に矢印を付ける
rooms.forEach(r => {
for (let x = r.x; x < r.x + r.w; x++) {
if (map[r.y - 1] && map[r.y - 1][x] && map[r.y - 1][x].isCorridor) map[r.y][x].arrow = 'up';
if (map[r.y + r.h] && map[r.y + r.h][x] && map[r.y + r.h][x].isCorridor) map[r.y + r.h - 1][x].arrow = 'down';
}
for (let y = r.y; y < r.y + r.h; y++) {
if (map[y][r.x - 1] && map[y][r.x - 1].isCorridor) map[y][r.x].arrow = 'left';
if (map[y][r.x + r.w] && map[y][r.x + r.w].isCorridor) map[y][r.x + r.w - 1].arrow = 'right';
}
});
// ★ 進行に必要なキー魔法を、各部屋へ「最初から」定位置で配置する。
// これにより、どのフロアに居ても、その部屋に隣接する通路を抜けるための
// 魔法を必ずその場で入手でき、ゴールまで確実に到達できる。
rooms.forEach(r => {
r.keyMagics.forEach(name => {
const cell = findSafeRoomCell(r);
if (cell) {
const mData = MAGIC_TYPES.find(m => m.name === name);
if (!mData) return;
items.push({ x: cell.x, y: cell.y, type: 'magic', magicData: mData, rndIdx: Math.floor(Math.random() * 10000), key: true });
keyMagicSlots.push({ x: cell.x, y: cell.y, name: name, room: r });
loadImg('Magic', name);
}
});
});
players = [new Entity(rooms[0].cx, rooms[0].cy, true)];
// モンスター多めに配置
const numMobs = Math.min(60, 15 + stage * 5);
for(let i=0; i<numMobs; i++) {
let mx, my, t = 0;
do {
mx = Math.floor(Math.random() * MAP_COLS); my = Math.floor(Math.random() * MAP_ROWS);
t++;
} while (t < 500 && (map[my][mx].type !== 'road' || map[my][mx].isCorridor || (Math.abs(mx - rooms[0].cx) < 5)));
let mob = new Entity(mx, my, false);
mob.isMob = true;
mob.hp = 10 + Math.floor(Math.random() * 11);
mob.maxHp = mob.hp;
if (Math.random() < 0.3) {
mob.magic = MAGIC_TYPES[Math.floor(Math.random() * MAGIC_TYPES.length)];
mob.magicLevel = 1; mob.attackArea = generateAttackArea(1);
}
players.push(mob);
}
for(let i=0; i<8; i++) spawnRandomItem('food');
for(let i=0; i<4; i++) spawnRandomItem('heal');
for(let i=0; i<4; i++) spawnRandomItem('speed');
// 初期のアンビエント魔法(成長・究極用)も通路/入口を避けて訪問可能部屋へ
spawnAmbientMagicInVisitedRoom();
spawnAmbientMagicInVisitedRoom();
}
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() * MAP_COLS); ry = Math.floor(Math.random() * MAP_ROWS);
attempts++; if (attempts > 500) break;
} while (map[ry][rx].type === 'hurdle' || map[ry][rx].type === 'wall' || map[ry][rx].isCorridor || items.some(i => i.x === rx && i.y === ry) || map[ry][rx].type === 'start');
if (attempts > 500) return;
if (['food', 'heal', 'speed'].includes(type)) {
const currentItemsCount = items.filter(i => ['food', 'heal', 'speed'].includes(i.type)).length;
if (currentItemsCount >= 3 && mode !== 3) return;
items.push({ x: rx, y: ry, type: type, rndIdx: Math.floor(Math.random() * 10000) });
} else if (type === 'magic') {
const currentMagicCount = items.filter(i => i.type === 'magic').length;
if (currentMagicCount >= 5 && mode !== 3) return;
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) {
const currentMagicCount = items.filter(i => i.type === 'magic').length;
if (currentMagicCount >= 5 && mode !== 3) return;
if (items.some(i => i.type === 'magic' && i.magicData.name === magicData.name)) return;
let rx, ry, attempts = 0;
do {
rx = Math.floor(Math.random() * MAP_COLS); ry = Math.floor(Math.random() * MAP_ROWS);
attempts++; if (attempts > 500) break;
} while (map[ry][rx].type === 'hurdle' || map[ry][rx].type === 'wall' || map[ry][rx].isCorridor || 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);
}
}
// =====================================================================
// Story-mode magic spawn helpers
// =====================================================================
// 通路・入口・通路に隣接するセルを避け、魔法玉を置いて良いセルか判定
function isSafeMagicCell(x, y) {
if (x < 0 || y < 0 || x >= MAP_COLS || y >= MAP_ROWS) return false;
const c = map[y][x];
if (!c || c.type !== 'road') return false;
if (c.isCorridor) return false; // 通路上には置かない
if (c.arrow) return false; // 入口セルには置かない
if (items.some(i => i.x === x && i.y === y)) return false;
// 通路に隣接するセル(入口付近)も避ける
const dirs = [[0,-1],[0,1],[-1,0],[1,0]];
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < MAP_COLS && ny < MAP_ROWS && map[ny][nx].isCorridor) return false;
}
return true;
}
// 部屋内で魔法玉を置ける安全なセルを探す(部屋中心=プレイヤー定位置は除外)
function findSafeRoomCell(r) {
for (let attempt = 0; attempt < 40; attempt++) {
const x = r.x + Math.floor(Math.random() * r.w);
const y = r.y + Math.floor(Math.random() * r.h);
if (!(x === r.cx && y === r.cy) && isSafeMagicCell(x, y)) return { x, y };
}
for (let y = r.y; y < r.y + r.h; y++) {
for (let x = r.x; x < r.x + r.w; x++) {
if (!(x === r.cx && y === r.cy) && isSafeMagicCell(x, y)) return { x, y };
}
}
return null;
}
function roomContains(r, x, y) {
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
}
// 成長・究極用のアンビエント魔法を、訪問済み部屋の安全セルに少量だけ出す
function spawnAmbientMagicInVisitedRoom() {
if (mode !== 3 || dungeon.length === 0) return;
const visited = dungeon.filter(r => r.visited || r.isStart);
if (visited.length === 0) return;
const ambientCount = items.filter(i => i.type === 'magic' && !i.key).length;
if (ambientCount >= Math.min(6, visited.length + 1)) return;
const r = visited[Math.floor(Math.random() * visited.length)];
const cell = findSafeRoomCell(r);
if (!cell) return;
const mData = MAGIC_TYPES[Math.floor(Math.random() * MAGIC_TYPES.length)];
items.push({ x: cell.x, y: cell.y, type: 'magic', magicData: mData, rndIdx: Math.floor(Math.random() * 10000) });
loadImg('Magic', mData.name);
}
// 訪問済み部屋に常備すべきキー魔法を、無くなっていれば「同じ部屋・定位置」に補充する。
// その部屋に隣接する通路の障害物がすべて解除されたら、その魔法は補充しない(無限増殖防止)。
function refillKeyMagics() {
if (mode !== 3) return;
const p0 = players[0];
keyMagicSlots.forEach(slot => {
const r = slot.room;
if (!r.visited && !r.isStart) return; // 訪問済みフロアのみ
// この部屋にとって、まだその魔法が必要か(対応する通路障害物が残っているか)
const stillNeeded = r.corridorHurdles.some(h =>
h.name === slot.name && map[h.y] && map[h.y][h.x] && map[h.y][h.x].type === 'hurdle'
);
if (!stillNeeded) return;
// 既にこの部屋内にその魔法がある or プレイヤーがこの部屋で所持中なら補充不要
const inRoom = items.some(i => i.type === 'magic' && i.magicData.name === slot.name && roomContains(r, i.x, i.y));
const heldHere = p0 && p0.magic && p0.magic.name === slot.name && roomContains(r, Math.round(p0.targetX), Math.round(p0.targetY));
if (inRoom || heldHere) return;
// 同じ場所に再出現。定位置がふさがっていれば部屋内の別の安全セルへ。
let tx = slot.x, ty = slot.y;
const onPlayer = p0 && Math.round(p0.targetX) === tx && Math.round(p0.targetY) === ty;
const blocked = map[ty][tx].type !== 'road' || map[ty][tx].isCorridor || items.some(i => i.x === tx && i.y === ty);
if (onPlayer || blocked) {
const alt = findSafeRoomCell(r);
if (!alt) return;
tx = alt.x; ty = alt.y;
}
const mData = MAGIC_TYPES.find(m => m.name === slot.name);
if (!mData) return;
items.push({ x: tx, y: ty, type: 'magic', magicData: mData, rndIdx: Math.floor(Math.random() * 10000), key: true });
loadImg('Magic', slot.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') {
if (mode === 3) {
const restore = 15 + Math.floor(Math.random() * 26);
foodGauge = Math.min(MAX_FOOD_GAUGE, foodGauge + restore);
floatingTexts.push(new FloatingText(player.x, player.y, `+${restore}`));
} else {
const addScore = 10 + Math.floor(Math.random() * 41);
player.score += addScore;
floatingTexts.push(new FloatingText(player.x, player.y, `+${addScore}`));
checkUltimateMagic(player);
}
playSound('Sound', 'get_food', 'get_food');
items.splice(idx, 1);
if (mode !== 3) 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);
if (mode !== 3) {
// 対戦モードのみ:魔法在庫を維持するため補充する
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);
if (mode !== 3) spawnRandomItem('heal');
} else if (item.type === 'speed') {
player.speedTimer = 10;
playSound('Sound', 'speedup', 'speedup');
items.splice(idx, 1);
if (mode !== 3) spawnRandomItem('speed');
}
}
}
function checkUltimateMagic(player) {
// 対戦モードは100点ごと、ストーリーモードは1000点ごと(モブ討伐で加点)に発動可能
const threshold = (mode === 3) ? 1000 : 100;
if (Math.floor(player.score / threshold) > player.lastUltimateScore) {
player.lastUltimateScore = Math.floor(player.score / threshold);
player.ultimateTimer = 10.0;
playSound('Sound', 'magic_cast', 'magic_cast');
}
}
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 });
}
}
// 中心(自分の正面マス)は必ず含める:空配列で発動が無効化されるのを防ぐ
if (!area.some(c => c.dx === 0 && c.dy === 0)) area.push({ dx: 0, dy: 0 });
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 < MAP_COLS && ay >= 0 && ay < MAP_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 executeMelee(attacker, defender) {
if (!attacker || !defender || gameState !== 'PLAY') return;
const meleeDamage = 1;
attacker.bumpDX = defender.targetX - attacker.targetX;
attacker.bumpDY = defender.targetY - attacker.targetY;
attacker.bumpTimer = attacker.bumpMaxTimer;
const pColor = defender.isPlayer1 ? 'cyan' : 'pink';
for (let i = 0; i < 10; i++) {
particles.push(new Particle(defender.targetX * TS + TS / 2, defender.targetY * TS + TS / 2, pColor));
}
playSound('Sound', 'damage', 'damage');
defender.hp -= meleeDamage;
if (defender.hp <= 0 && gameState === 'PLAY') handleEntityDeath(attacker, defender);
}
function handleEntityDeath(attacker, target) {
if (target.isMob) {
players = players.filter(p => p !== target);
const p1 = players.find(p => p.isPlayer1 && !p.isMob);
if (p1) {
p1.score += 300;
checkUltimateMagic(p1);
}
const currentItemsCount = items.filter(i => ['food', 'heal', 'speed'].includes(i.type)).length;
if ((currentItemsCount < 3 || mode === 3) && !items.some(i => i.x === target.targetX && i.y === target.targetY)) {
const dropRnd = Math.random();
const dropType = dropRnd < 0.5 ? 'food' : dropRnd < 0.8 ? 'heal' : 'speed';
items.push({ x: target.targetX, y: target.targetY, type: dropType, rndIdx: Math.floor(Math.random() * 10000) });
playSound('Sound', 'get_food', 'get_food');
}
} else if (mode === 3 && target.isPlayer1) {
setGameOver();
} else if (mode === 1) {
if (target.isPlayer1) { setGameOver(); }
else { lastStageWinner = 1; p1Wins++; setStageClear(); }
} else if (mode === 2) {
if (target.isPlayer1) { lastStageWinner = 2; p2Wins++; }
else { lastStageWinner = 1; p1Wins++; }
setStageClear();
}
}
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] && map[ay][ax];
let hitSomething = false;
if (mapCell && mapCell.type === 'hurdle') {
hitSomething = true;
let isDestructible = (mapCell.name === 'water' && magic.name === 'ice') || mapCell.name === magic.name || magic.name === 'ultimate';
if (isDestructible) {
map[ay][ax] = { type: 'road', name: magic.name === 'ultimate' ? 'normal' : magic.name, color: magic.color, isCorridor: mapCell.isCorridor };
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] && map[ty1][tx1]; let tCell2 = map[ty2] && map[ty2][tx2];
let onSameAttribute = (tCell1 && tCell1.type === 'road' && tCell1.name === magic.name) || (tCell2 && tCell2.type === 'road' && tCell2.name === magic.name);
if (onSameAttribute && Math.random() < 0.5 && magic.name !== 'ultimate') {
// Dodge
} else if (!target.magic || target.magic.name !== magic.name || magic.name === 'ultimate') {
const magicDamage = magic.name === 'ultimate' ? 30 : 10;
target.hp -= magicDamage;
playSound('Sound', 'damage', 'damage');
const pColor = target.isPlayer1 ? 'cyan' : 'pink';
for (let i = 0; i < 15; i++) {
particles.push(new Particle(ax * TS + TS/2, ay * TS + TS/2, pColor));
}
if (target.hp <= 0 && gameState === 'PLAY') handleEntityDeath(player, target);
}
}
});
if(!hitSomething) playSound('Sound', 'damage', 'damage');
}
// BFS。親方向のみ記録してパス配列のコピーを排除。maxDist で探索範囲を制限可能。
function findPath(startX, startY, targetType, targetEntity, maxDist) {
const limit = (typeof maxDist === 'number') ? maxDist : 9999;
const visited = Array.from({length: MAP_ROWS}, () => Array(MAP_COLS).fill(false));
const firstDir = Array.from({length: MAP_ROWS}, () => Array(MAP_COLS).fill(null));
const dist = Array.from({length: MAP_ROWS}, () => Array(MAP_COLS).fill(0));
const queue = [{x: startX, y: startY}];
let head = 0;
visited[startY][startX] = true;
const dirs = [{dx: 0, dy: -1}, {dx: 0, dy: 1}, {dx: -1, dy: 0}, {dx: 1, dy: 0}];
while (head < queue.length) {
const curr = queue[head++];
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 firstDir[curr.y][curr.x]; // 開始地点が目標なら null(移動不要)
if (dist[curr.y][curr.x] >= limit) continue;
for (let d of dirs) {
const nx = curr.x + d.dx; const ny = curr.y + d.dy;
if (nx >= 0 && nx < MAP_COLS && ny >= 0 && ny < MAP_ROWS) {
if (!visited[ny][nx] && (map[ny][nx].type === 'road' || map[ny][nx].type === 'start')) {
visited[ny][nx] = true;
dist[ny][nx] = dist[curr.y][curr.x] + 1;
firstDir[ny][nx] = firstDir[curr.y][curr.x] || d;
queue.push({x: nx, y: ny});
}
}
}
}
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 < MAP_COLS && ny >= 0 && ny < MAP_ROWS) {
let cell = map[ny][nx];
if (cell.type !== 'hurdle' && cell.type !== 'wall') { trapped = false; break; }
if (cell.type === 'hurdle') 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;
const currentMagicCount = items.filter(i => i.type === 'magic').length + fallingItems.filter(f => f.type === 'magic').length;
if (currentMagicCount >= 5 && mode !== 3) 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);
}
}
}
function update(dt) {
if (gameState === 'STARTUP') {
gameState = 'TITLE'; playBGM('ScreenBgm', 'title'); canvas.focus(); return;
}
if (gameState !== 'PLAY') return;
if (mode === 3 && players[0] && dungeon.length > 0) {
let gx = Math.floor(players[0].x / 15);
let gy = Math.floor(players[0].y / 10);
let room = dungeon.find(r => r.gx === gx && r.gy === gy);
if (room) {
if (!room.visited) room.visited = true;
currentRoomNode = room;
if (room.isGoal) {
if (stage >= MAX_STAGES) {
gameState = 'GAME_CLEAR';
playBGM('ScreenBgm', 'game_clear');
} else {
stage++;
gameState = 'STAGE_START';
playStageBGM(stage);
}
return;
}
}
// キー魔法の補充(毎フレームではなく間引いて実行)
keyMagicTimer -= dt;
if (keyMagicTimer <= 0) { refillKeyMagics(); keyMagicTimer = 2.5; }
}
if (mode === 3) {
magicSpawnTimer -= dt;
if (magicSpawnTimer <= 0) {
spawnAmbientMagicInVisitedRoom();
magicSpawnTimer = 6 + Math.random() * 8;
}
} else {
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;
}
}
const p1 = mode === 3 ? players.find(p => p.isPlayer1 && !p.isMob) : players[0];
if (p1) {
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.magic) executeMagic(p1); keys['a_handled'] = true; }
} else keys['a_handled'] = false;
}
if (mode === 2) {
const p2 = players[1];
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.magic) executeMagic(p2); keys['space_handled'] = true; }
} else keys['space_handled'] = false;
} else if (mode === 1) {
const p2 = players[1];
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 acted = false;
if (p2.magic) {
const inRange = p2.attackArea.some(c => {
const ax = p2.targetX + c.dx, ay = p2.targetY + c.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');
p2.hesitateTimer = Math.random() < 0.5 ? 1.0 + Math.random() : 0.1;
acted = true;
}
}
if (!acted) {
let mv = null;
if (!p2.magic) mv = findPath(p2.targetX, p2.targetY, 'magic', null);
if (!mv && p2.hp < 70) mv = findPath(p2.targetX, p2.targetY, 'heal', null);
if (!mv) mv = findPath(p2.targetX, p2.targetY, 'food', null);
if (!mv) mv = findPath(p2.targetX, p2.targetY, 'player', p1);
if (mv) p2.move(mv.dx, mv.dy);
else {
const dirs = [{dx:0,dy:-1},{dx:0,dy:1},{dx:-1,dy:0},{dx:1,dy:0}];
const rm = dirs[Math.floor(Math.random() * dirs.length)];
p2.move(rm.dx, rm.dy);
}
}
}
} else if (mode === 3) {
// Mobはプレイヤーが近いときだけ追跡(性能と体感の両改善)
const ACTIVE_RADIUS = 12;
players.filter(p => p.isMob).forEach(mob => {
const dist = p1 ? Math.abs(mob.x - p1.x) + Math.abs(mob.y - p1.y) : 9999;
if (mob.isAiming) {
if (mob.hesitateTimer > 0) mob.hesitateTimer -= dt;
else { executeMagic(mob); mob.isAiming = false; }
return;
}
if (mob.moving || mob.bumpTimer > 0) return;
if (dist <= ACTIVE_RADIUS) {
if (Math.random() < 0.06 + stage * 0.01) {
let acted = false;
if (mob.magic && p1) {
const inRange = mob.attackArea.some(c => {
const ax = mob.targetX + c.dx, ay = mob.targetY + c.dy;
return (ax === p1.targetX && ay === p1.targetY) || (ax === p1.x && ay === p1.y);
});
if (inRange) {
mob.isAiming = true;
mob.hesitateTimer = 0.3 + Math.random() * 0.5;
acted = true;
}
}
if (!acted && p1) {
const mv = findPath(mob.targetX, mob.targetY, 'player', p1, ACTIVE_RADIUS + 4);
if (mv) mob.move(mv.dx, mv.dy);
else {
const dirs = [{dx:0,dy:-1},{dx:0,dy:1},{dx:-1,dy:0},{dx:1,dy:0}];
const rm = dirs[Math.floor(Math.random() * dirs.length)];
mob.move(rm.dx, rm.dy);
}
}
}
} else if (Math.random() < 0.01) {
// 遠方のMobは経路探索せず、たまにふらつくだけ(軽量)
const dirs = [{dx:0,dy:-1},{dx:0,dy:1},{dx:-1,dy:0},{dx:1,dy:0}];
const rm = dirs[Math.floor(Math.random() * dirs.length)];
mob.move(rm.dx, rm.dy);
}
});
}
players.forEach(p => p.update(dt));
players.forEach(p => { if (mode !== 3 || !p.isMob) 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);
floatingTexts.forEach(f => f.update(dt));
floatingTexts = floatingTexts.filter(f => f.life > 0);
}main.js
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
TS = Math.floor(Math.min(canvas.width / COLS, (canvas.height - UI_HEIGHT) / ROWS));
if (TS < 10) TS = 10;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
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') {
mode = mode > 1 ? mode - 1 : 3;
playSound('Sound', 'move', 'move');
} else if (e.key === 'ArrowDown') {
mode = mode < 3 ? mode + 1 : 1;
playSound('Sound', 'move', 'move');
} else {
handleScreenTransition();
}
}
});
document.addEventListener('keyup', e => {
keys[e.key] = false;
if (gameState !== 'PLAY' && gameState !== 'PAUSE' && gameState !== 'TITLE') {
handleScreenTransition();
}
});
canvas.addEventListener('mouseup', (e) => {
if (gameState === 'TITLE') {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
let boxW = 540, boxH = 155;
let boxX = (canvas.width - boxW) / 2, boxY = canvas.height / 2 + 40;
if (mouseX >= boxX && mouseX <= boxX + boxW) {
if (mouseY >= boxY + 48 && mouseY <= boxY + 78) { mode = 1; handleScreenTransition(); }
else if (mouseY >= boxY + 80 && mouseY <= boxY + 110) { mode = 2; handleScreenTransition(); }
else if (mouseY >= boxY + 112 && mouseY <= boxY + 142) { mode = 3; handleScreenTransition(); }
}
canvas.focus();
return;
}
if (gameState !== 'PLAY' && gameState !== 'PAUSE') {
handleScreenTransition();
}
});
function renderGameElements(camX, camY, viewW, viewH) {
// 画面外のタイル描画を省略(カリング)して描画負荷を大幅に削減
let minCol = 0, maxCol = MAP_COLS - 1, minRow = 0, maxRow = MAP_ROWS - 1;
if (typeof camX === 'number' && typeof viewW === 'number') {
minCol = Math.max(0, Math.floor(camX / TS));
maxCol = Math.min(MAP_COLS - 1, Math.ceil((camX + viewW) / TS));
minRow = Math.max(0, Math.floor(camY / TS));
maxRow = Math.min(MAP_ROWS - 1, Math.ceil((camY + viewH) / TS));
}
for (let y = minRow; y <= maxRow; y++) {
for (let x = minCol; x <= maxCol; 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);
}
}
if (cell.arrow && mode === 3) {
ctx.globalAlpha = 0.5 + 0.5 * Math.sin(performance.now() / 150);
ctx.fillStyle = 'white';
ctx.beginPath();
if (cell.arrow === 'up') { ctx.moveTo(x*TS+TS/2, y*TS); ctx.lineTo(x*TS+TS, y*TS+TS/2); ctx.lineTo(x*TS, y*TS+TS/2); }
else if (cell.arrow === 'down') { ctx.moveTo(x*TS+TS/2, y*TS+TS); ctx.lineTo(x*TS, y*TS+TS/2); ctx.lineTo(x*TS+TS, y*TS+TS/2); }
else if (cell.arrow === 'left') { ctx.moveTo(x*TS, y*TS+TS/2); ctx.lineTo(x*TS+TS/2, y*TS); ctx.lineTo(x*TS+TS/2, y*TS+TS); }
else if (cell.arrow === 'right') { ctx.moveTo(x*TS+TS, y*TS+TS/2); ctx.lineTo(x*TS+TS/2, y*TS); ctx.lineTo(x*TS+TS/2, y*TS+TS); }
ctx.fill();
ctx.globalAlpha = 1.0;
}
}
}
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 {
ctx.fillStyle = item.type === 'food' ? 'yellow' : item.type === 'heal' ? 'yellowgreen' : 'cyan';
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();
}
}
});
fallingItems.forEach(f => f.draw(ctx));
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 < MAP_COLS && ay >= 0 && ay < MAP_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([]);
}
// 究極魔法の直線5マスレンジ表示
if (p.ultimateTimer > 0) {
let dx = 0, dy = 0;
if (p.dir === 'up') dy = -1;
else if (p.dir === 'down') dy = 1;
else if (p.dir === 'left') dx = -1;
else if (p.dir === 'right') dx = 1;
for (let i = 1; i <= 5; i++) {
let ax = Math.round(p.x) + dx * i;
let ay = Math.round(p.y) + dy * i;
if(ax >= 0 && ax < MAP_COLS && ay >= 0 && ay < MAP_ROWS) {
ctx.setLineDash([5, 5]); ctx.lineDashOffset = -performance.now() / 30; // 激しく点滅
ctx.globalAlpha = 0.8 + 0.2 * Math.sin(performance.now() / 30);
ctx.strokeStyle = 'white'; 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));
floatingTexts.forEach(f => f.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) {
// カウントダウンが発動待機中の場合、UIの真上に表示する
if (player.ultimateTimer > 0) {
ctx.fillStyle = 'rgba(255, 255, 255, ' + (0.7 + 0.3 * Math.sin(performance.now()/100)) + ')';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`ULTIMATE READY: ${Math.ceil(player.ultimateTimer)}s`, startX, startY - 10);
}
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 + 25;
const lh = 30;
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;
ctx.fillText(`Score ${player.score}`, px, py);
py += lh;
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;
if (mode === 3 && isP1) {
const gr = foodGauge / MAX_FOOD_GAUGE;
const gc = gr > 0.5 ? '#00cc44' : gr > 0.2 ? '#ffaa00' : '#ff3333';
if (foodGauge <= 0) {
ctx.fillStyle = '#ff3333'; ctx.fillText('★ HUNGRY! ★', px, py); ctx.fillStyle = '#fff';
} else {
ctx.fillText('FOOD', px, py);
}
drawHpBar(px + 80, py - 18, foodGauge, MAX_FOOD_GAUGE, gc, '#331100', 200, 20);
ctx.font = '13px sans-serif'; ctx.textAlign = 'left'; ctx.fillStyle = '#fff';
ctx.fillText(`${Math.floor(foodGauge)}/${MAX_FOOD_GAUGE}`, px + 84, py - 3);
ctx.font = '22px sans-serif';
py += lh;
}
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);
if (mode === 3) {
ctx.fillText('Time: \u221e', cx, cy + 65);
} else {
const min = Math.floor(timeLeft / 60); const sec = Math.floor(timeLeft % 60).toString().padStart(2, '0');
ctx.fillText(`Time: ${min}:${sec}`, cx, cy + 65);
}
}
// ミニマップを2倍のサイズ(scale = 6)にして右上に配置
function drawMiniMap() {
if (!dungeon || dungeon.length === 0 || mode !== 3) return;
const scale = 6;
const margin = 20;
const startX = canvas.width - MAP_COLS * scale - margin;
const startY = margin;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(startX - 5, startY - 5, MAP_COLS * scale + 10, MAP_ROWS * scale + 10);
for (let y = 0; y < MAP_ROWS; y++) {
for (let x = 0; x < MAP_COLS; x++) {
let cell = map[y][x];
if (cell.type === 'wall') continue;
let isVisible = false;
if (cell.isCorridor) {
isVisible = true;
} else {
let gx = Math.floor(x / 15);
let gy = Math.floor(y / 10);
let r = dungeon.find(d => d.gx === gx && d.gy === gy);
if (r && r.visited) isVisible = true;
}
if (isVisible) {
if (cell.type === 'hurdle') {
ctx.fillStyle = cell.color;
} else {
ctx.fillStyle = '#778899';
}
ctx.fillRect(startX + x * scale, startY + y * scale, scale, scale);
}
}
}
// ゴール部屋の中心を金色マーカーで常時表示し、進む方向を示す
const goalRoom = dungeon.find(d => d.isGoal);
if (goalRoom) {
ctx.fillStyle = '#ffd700';
ctx.fillRect(startX + goalRoom.cx * scale - 1, startY + goalRoom.cy * scale - 1, scale + 2, scale + 2);
}
if (players[0]) {
ctx.fillStyle = 'cyan';
ctx.fillRect(startX + Math.floor(players[0].x) * scale, startY + Math.floor(players[0].y) * scale, scale, scale);
}
players.forEach(p => {
if (p.isMob) {
let gx = Math.floor(p.x / 15);
let gy = Math.floor(p.y / 10);
let r = dungeon.find(d => d.gx === gx && d.gy === gy);
if (r && r === currentRoomNode) {
ctx.fillStyle = 'red';
ctx.fillRect(startX + Math.floor(p.x) * scale, startY + Math.floor(p.y) * scale, scale, scale);
}
}
});
items.forEach(i => {
let gx = Math.floor(i.x / 15);
let gy = Math.floor(i.y / 10);
let r = dungeon.find(d => d.gx === gx && d.gy === gy);
if (r && r === currentRoomNode) {
ctx.fillStyle = 'blue';
ctx.fillRect(startX + i.x * scale, startY + i.y * scale, scale, scale);
}
});
}
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 = 540, boxH = 155;
let boxX = (canvas.width - boxW) / 2, boxY = canvas.height / 2 + 40;
ctx.fillStyle = 'rgba(0,0,0,0.85)'; 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 = '22px sans-serif';
ctx.fillText('Select Mode: \u2191\u2193 to choose, Any Key to start', boxX + 18, boxY + 30);
ctx.font = '20px sans-serif';
const sel = (n) => n === mode ? '#ffd700' : '#aaa';
ctx.fillStyle = sel(1); ctx.fillText(mode === 1 ? '\u25b6 SINGLE MODE (vs CPU)' : ' SINGLE MODE (vs CPU)', boxX + 36, boxY + 68);
ctx.fillStyle = sel(2); ctx.fillText(mode === 2 ? '\u25b6 MULTI MODE (2 Players)' : ' MULTI MODE (2 Players)', boxX + 36, boxY + 100);
ctx.fillStyle = sel(3); ctx.fillText(mode === 3 ? '\u25b6 STORY MODE (Dungeon)' : ' STORY MODE (Dungeon)', boxX + 36, boxY + 132);
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') {
if (mode === 3) {
// ストーリーモード:ダンジョン完全制覇の勝利演出
ctx.font = 'bold 44px sans-serif'; ctx.fillStyle = '#ffd700';
ctx.fillText('DUNGEON CLEARED!', canvas.width / 2, canvas.height / 2 - 40);
ctx.font = 'bold 26px sans-serif'; ctx.fillStyle = '#fff';
ctx.fillText(`All ${MAX_STAGES} floors conquered!`, canvas.width / 2, canvas.height / 2 + 18);
if (players[0]) {
ctx.font = 'bold 22px sans-serif'; ctx.fillStyle = '#00e5ff';
ctx.fillText(`Final Score: ${players[0].score}`, canvas.width / 2, canvas.height / 2 + 52);
}
ctx.font = '20px sans-serif'; ctx.fillStyle = '#aaa';
ctx.fillText('Press Any Key...', canvas.width / 2, canvas.height / 2 + 92);
} else {
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 - 30);
if (gameState === 'STAGE_START') {
const mNames = { 1:'SINGLE MODE', 2:'MULTI MODE', 3:'STORY MODE' };
const mColors = { 1:'#00e5ff', 2:'#ff00ff', 3:'#ffd700' };
ctx.font = 'bold 24px sans-serif'; ctx.fillStyle = mColors[mode] || '#fff';
ctx.fillText(mNames[mode] || '', canvas.width / 2, canvas.height / 2 + 10); ctx.fillStyle = '#fff';
}
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' ? 90 : gameState === 'STAGE_START' ? 65 : 50));
}
return;
}
if (gameState === 'PLAY') {
const viewW = COLS * TS;
const viewH = ROWS * TS;
let camX = 0, camY = 0;
if (players[0]) {
camX = players[0].x * TS + TS / 2 - viewW / 2;
camY = players[0].y * TS + TS / 2 - viewH / 2;
camX = Math.max(0, Math.min(camX, MAP_COLS * TS - viewW));
camY = Math.max(0, Math.min(camY, MAP_ROWS * TS - viewH));
}
const mapOffsetX = (canvas.width - viewW) / 2;
const mapOffsetY = Math.max(0, (canvas.height - UI_HEIGHT - viewH) / 2);
ctx.save();
ctx.beginPath(); ctx.rect(mapOffsetX, mapOffsetY, viewW, viewH); ctx.clip();
ctx.translate(mapOffsetX - camX, mapOffsetY - camY);
renderGameElements(camX, camY, viewW, viewH);
ctx.restore();
const uiY = canvas.height - UI_HEIGHT + 5;
if (mode === 3) {
const storyP1 = players.find(p => p.isPlayer1 && !p.isMob);
if (storyP1) drawPlayerUI(storyP1, true, 20, uiY);
drawMiniMap();
} else {
drawPlayerUI(players[0], true, 20, uiY);
if (players[1]) 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; p1Wins = 0; p2Wins = 0; lastStageWinner = 0;
gameState = 'STAGE_START'; playBGM('ScreenBgm', 'stage_start'); canvas.focus();
} else if (gameState === 'STAGE_START') {
initGame(); gameState = 'PLAY'; playStageBGM(stage); canvas.focus();
} else if (gameState === 'STAGE_CLEAR') {
stage++; gameState = 'STAGE_START'; playBGM('ScreenBgm', 'stage_start');
} else if (gameState === 'GAME_OVER') {
if (mode === 3) { stage = 1; p1Wins = 0; }
gameState = 'STAGE_START'; playBGM('ScreenBgm', 'stage_start');
} else if (gameState === 'GAME_CLEAR') {
gameState = 'TITLE'; playBGM('ScreenBgm', 'title'); canvas.focus();
}
}
function togglePause() {
if (gameState === 'PLAY') {
gameState = 'PAUSE';
if (currentBgmAudio) currentBgmAudio.pause();
}
else if (gameState === 'PAUSE') {
gameState = 'PLAY';
if (currentBgmAudio) currentBgmAudio.play().catch(()=>{});
}
}
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
// タブ復帰などで巨大なdtになるのを防ぐためクランプ(最大50ms)
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
update(dt); draw(); requestAnimationFrame(loop);
}
requestAnimationFrame(loop);


コメント