簡単な対戦ゲーム「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);

いいなと思ったら応援しよう!

ピックアップされています

気楽に遊べるゲーム

  • 26本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
簡単な対戦ゲーム「MagicalMaze」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1