簡単な対戦ゲーム「MagicalMaze」



【更新履歴】

・2026/5/18 バージョン1.0公開。

画像


・ダウンロードされる方はこちら。↓

【操作説明】

・方向キーで移動する。
・Aキーで取得した魔法を発動、もう一度押すと発射する。
・制限時間内に、より多くのスコアを得たほうが勝ち。


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Magical Maze</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            background-color: #111;
            color: #fff;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            overflow: hidden;
        }
        #gameContainer {
            position: relative;
            box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
        }
        canvas {
            display: block;
            background-color: #222;
        }
    </style>
</head>
<body>
    <div id="gameContainer">
        <canvas id="gameCanvas" width="800" height="600" tabindex="1"></canvas>
    </div>
    <script>
        /**
         * Magical Maze - Main Game Logic (Integrated)
         */

        const canvas = document.getElementById('gameCanvas');
        const ctx = canvas.getContext('2d');
        canvas.focus();

        // --- Game Constants ---
        const TS = 40; // Tile Size
        const COLS = 20;
        const ROWS = 13;
        const TIME_LIMIT = 180;
        const MAX_STAGES = 8;
        const UI_HEIGHT = 80;

        // --- Asset Management ---
        const ASSETS = { images: {}, audio: {} };
        const AUDIO_CTX = new (window.AudioContext || window.webkitAudioContext)();

        function loadImg(folder, name) {
            const path = `${folder}/${name}.png`;
            if (!ASSETS.images[path]) {
                const img = new Image();
                img.src = path;
                ASSETS.images[path] = { img, loaded: false, error: false };
                img.onload = () => ASSETS.images[path].loaded = true;
                img.onerror = () => ASSETS.images[path].error = true;
            }
        }

        function playSound(folder, name, type = 'beep') {
            const path = `${folder}/${name}.mp3`;
            
            if (ASSETS.audio[path] && ASSETS.audio[path].loaded && !ASSETS.audio[path].error) {
                const clone = ASSETS.audio[path].audio.cloneNode();
                clone.volume = 0.5;
                clone.play().catch(() => playFallbackSound(type));
                return;
            }
            
            if (!ASSETS.audio[path]) {
                const audio = new Audio(path);
                ASSETS.audio[path] = { audio: audio, loaded: false, error: false };
                audio.oncanplaythrough = () => { ASSETS.audio[path].loaded = true; };
                audio.onerror = () => {
                    ASSETS.audio[path].error = true;
                    playFallbackSound(type);
                };
                audio.volume = 0.5;
                audio.play().catch(() => {
                    ASSETS.audio[path].error = true;
                    playFallbackSound(type);
                });
                return;
            }

            if (ASSETS.audio[path].error) {
                playFallbackSound(type);
            }
        }

        function playFallbackSound(type) {
            if (AUDIO_CTX.state === 'suspended') {
                AUDIO_CTX.resume().catch(() => {});
            }
            const gain = AUDIO_CTX.createGain();
            gain.connect(AUDIO_CTX.destination);

            if (type === 'destroy') {
                const bufferSize = Math.floor(AUDIO_CTX.sampleRate * 0.2);
                const buffer = AUDIO_CTX.createBuffer(1, bufferSize, AUDIO_CTX.sampleRate);
                const data = buffer.getChannelData(0);
                for (let i = 0; i < bufferSize; i++) {
                    data[i] = Math.random() * 2 - 1;
                }
                const noise = AUDIO_CTX.createBufferSource();
                noise.buffer = buffer;
                
                const filter = AUDIO_CTX.createBiquadFilter();
                filter.type = 'lowpass';
                filter.frequency.value = 800;
                
                noise.connect(filter);
                filter.connect(gain);
                
                gain.gain.setValueAtTime(0.3, AUDIO_CTX.currentTime);
                gain.gain.exponentialRampToValueAtTime(0.01, AUDIO_CTX.currentTime + 0.2);
                noise.start();
                return;
            }

            const osc = AUDIO_CTX.createOscillator();
            osc.connect(gain);

            if (type === 'magic') {
                osc.type = 'sine';
                osc.frequency.setValueAtTime(600, AUDIO_CTX.currentTime);
                osc.frequency.exponentialRampToValueAtTime(1200, AUDIO_CTX.currentTime + 0.1);
                gain.gain.setValueAtTime(0.1, AUDIO_CTX.currentTime);
            } else if (type === 'hit') {
                osc.type = 'sawtooth';
                osc.frequency.setValueAtTime(150, AUDIO_CTX.currentTime);
                osc.frequency.exponentialRampToValueAtTime(50, AUDIO_CTX.currentTime + 0.2);
                gain.gain.setValueAtTime(0.1, AUDIO_CTX.currentTime);
            } else if (type === 'get') {
                osc.type = 'square';
                osc.frequency.setValueAtTime(800, AUDIO_CTX.currentTime);
                osc.frequency.setValueAtTime(1200, AUDIO_CTX.currentTime + 0.05);
                gain.gain.setValueAtTime(0.05, AUDIO_CTX.currentTime);
            } else {
                osc.type = 'triangle';
                osc.frequency.setValueAtTime(440, AUDIO_CTX.currentTime);
                gain.gain.setValueAtTime(0.05, AUDIO_CTX.currentTime);
            }

            osc.start();
            osc.stop(AUDIO_CTX.currentTime + 0.2);
        }

        const dirs = ['left', 'right', 'up', 'down'];
        dirs.forEach(d => {
            loadImg('Player', d); loadImg('Enemy', d);
        });
        loadImg('Player', 'Player'); loadImg('Enemy', 'Enemy');

        // --- Input Handling ---
        // 連続遷移を防ぐため、画面遷移は keyup で発火させる
        const keys = {};
        document.addEventListener('keydown', e => {
            keys[e.key] = true;
            if (e.key === 'Escape') togglePause();
        });
        document.addEventListener('keyup', e => {
            keys[e.key] = false;
            // プレイ中・ポーズ中以外はキーを離した瞬間に遷移判定
            if (gameState !== 'PLAY' && gameState !== 'PAUSE') {
                handleScreenTransition(e.key);
            }
        });
        canvas.addEventListener('mouseup', () => {
            if (gameState !== 'PLAY' && gameState !== 'PAUSE') {
                handleScreenTransition(null);
            }
        });

        // --- State Variables ---
        let gameState = 'TITLE';
        let mode = 1;
        let stage = 1;
        let timeLeft = TIME_LIMIT;
        let lastTime = 0;
        let map = [];
        let items = [];
        let players = [];
        let particles = [];
        let meteors = [];
        let soundTypeToPlay = null;

        const MAGIC_TYPES = [
            { name: 'ice', color: 'cyan' },
            { name: 'fire', color: 'red' },
            { name: 'thunder', color: 'yellow' }
        ];
        const HURDLE_TYPES = [
            { name: 'water', color: 'blue' },
            { name: 'rock', color: 'gray' },
            { name: 'ice', color: 'lightblue' },
            { name: 'fire', color: 'darkred' }
        ];

        // --- Classes ---
        class Particle {
            constructor(x, y, color) {
                this.x = x;
                this.y = y;
                this.color = color;
                const angle = Math.random() * Math.PI * 2;
                const speed = Math.random() * 150 + 50; // より速く散らす
                this.vx = Math.cos(angle) * speed;
                this.vy = Math.sin(angle) * speed;
                this.life = 0.5 + Math.random() * 0.3;
                this.maxLife = this.life;
                this.size = Math.random() * 6 + 3;
            }

            update(dt) {
                this.vy += 400 * dt; // 重力を強めに加えてパラボラ軌道に
                this.x += this.vx * dt;
                this.y += this.vy * dt;
                this.life -= dt;
            }

            draw(ctx) {
                const alpha = Math.max(0, this.life / this.maxLife);
                ctx.globalAlpha = alpha;
                ctx.fillStyle = this.color;
                ctx.fillRect(this.x - this.size/2, this.y - this.size/2, this.size, this.size);
                ctx.globalAlpha = 1.0;
            }
        }

        class Meteor {
            constructor(ax, ay, magic, owner) {
                this.targetX = ax;
                this.targetY = ay;
                this.pixelX = ax * TS + TS / 2;
                this.pixelY = ay * TS + TS / 2;
                this.currentX = this.pixelX;
                this.currentY = -TS; // 画面上部から
                this.speed = 900 + Math.random() * 200; // 高速落下
                this.magic = magic;
                this.owner = owner;
                this.hit = false;
            }

            update(dt) {
                if (this.hit) return;
                this.currentY += this.speed * dt;
                if (this.currentY >= this.pixelY) {
                    this.currentY = this.pixelY;
                    this.hit = true;
                    handleMeteorHit(this); // 着弾時に処理
                }
            }

            draw(ctx) {
                if (this.hit) return;
                const path = `Magic/${this.magic.name}.png`;
                if (ASSETS.images[path] && ASSETS.images[path].loaded) {
                    ctx.drawImage(ASSETS.images[path].img, this.currentX - TS/2, this.currentY - TS/2, TS, TS);
                } else {
                    ctx.beginPath();
                    ctx.arc(this.currentX, this.currentY, TS/4, 0, Math.PI*2);
                    ctx.fillStyle = this.magic.color;
                    ctx.fill();
                    ctx.closePath();
                }
                
                // メテオの軌跡
                ctx.beginPath();
                ctx.moveTo(this.currentX, this.currentY - TS/4);
                ctx.lineTo(this.currentX, this.currentY - TS*1.5);
                ctx.strokeStyle = this.magic.color;
                ctx.lineWidth = 4;
                ctx.stroke();
                ctx.closePath();
            }
        }

        class Entity {
            constructor(x, y, isPlayer1) {
                this.x = x;
                this.y = y;
                this.targetX = x;
                this.targetY = y;
                this.isPlayer1 = isPlayer1;
                this.hp = 100;
                this.maxHp = 100;
                this.score = 0;
                this.magic = null;
                this.lifting = false;
                this.attackArea = [];
                this.steps = 0;
                
                this.moving = false;
                this.moveTimer = 0;
                this.dir = 'down';
            }

            update(dt) {
                if (this.moving) {
                    this.moveTimer += dt;
                    if (this.moveTimer >= 0.2) {
                        this.x = this.targetX;
                        this.y = this.targetY;
                        this.moving = false;
                        this.moveTimer = 0;
                        this.steps++;
                        checkItemPickup(this);
                    }
                }
            }

            move(dx, dy) {
                // this.lifting による移動制限を削除。持ち上げながら移動可能に。
                if (this.moving) return; 
                const nx = this.x + dx;
                const ny = this.y + dy;
                
                if (dx > 0) this.dir = 'right';
                if (dx < 0) this.dir = 'left';
                if (dy > 0) this.dir = 'down';
                if (dy < 0) this.dir = 'up';

                if (nx >= 0 && nx < COLS && ny >= 0 && ny < ROWS) {
                    const cell = map[ny][nx];
                    if (cell.type === 'road' || cell.type === 'start') {
                        const other = players.find(p => p !== this && p.targetX === nx && p.targetY === ny);
                        if (!other) {
                            this.targetX = nx;
                            this.targetY = ny;
                            this.moving = true;
                        }
                    }
                }
            }

            draw(ctx) {
                let drawX = this.x * TS;
                let drawY = this.y * TS;
                let jumpOffset = 0;

                if (this.moving) {
                    const progress = this.moveTimer / 0.2;
                    drawX += (this.targetX - this.x) * TS * progress;
                    drawY += (this.targetY - this.y) * TS * progress;
                    jumpOffset = Math.sin(progress * Math.PI) * 15;
                }

                const folder = this.isPlayer1 ? 'Player' : 'Enemy';
                const color = this.isPlayer1 ? 'lightblue' : 'purple';
                
                const dirPath = `${folder}/${this.dir}.png`;
                const defPath = `${folder}/${folder}.png`;
                
                let imgToDraw = null;
                if (ASSETS.images[dirPath] && ASSETS.images[dirPath].loaded) {
                    imgToDraw = ASSETS.images[dirPath].img;
                } else if (ASSETS.images[defPath] && ASSETS.images[defPath].loaded) {
                    imgToDraw = ASSETS.images[defPath].img;
                }

                drawY -= jumpOffset;

                if (imgToDraw) {
                    ctx.drawImage(imgToDraw, drawX, drawY, TS, TS);
                } else {
                    ctx.beginPath();
                    ctx.arc(drawX + TS/2, drawY + TS/2, TS/2.2, 0, Math.PI*2);
                    ctx.fillStyle = color;
                    ctx.fill();
                    ctx.closePath();
                }

                if (this.lifting && this.magic) {
                    ctx.beginPath();
                    ctx.arc(drawX + TS/2, drawY - 15, TS/5, 0, Math.PI*2);
                    ctx.fillStyle = this.magic.color;
                    ctx.fill();
                    ctx.closePath();
                }
            }
        }

        // --- Map & Logic ---
        function initGame() {
            map = [];
            items = [];
            particles = [];
            meteors = [];
            players = [
                new Entity(0, 0, true),
                new Entity(COLS - 1, ROWS - 1, false)
            ];
            timeLeft = TIME_LIMIT;

            for (let y = 0; y < ROWS; y++) {
                let row = [];
                for (let x = 0; x < COLS; x++) {
                    row.push({ type: 'road', name: 'normal', color: 'lightgreen' });
                }
                map.push(row);
            }

            for (let y = 0; y < ROWS; y++) {
                for (let x = 0; x < COLS; x++) {
                    if (Math.random() < 0.25) {
                        const h = HURDLE_TYPES[Math.floor(Math.random() * HURDLE_TYPES.length)];
                        map[y][x] = { type: 'hurdle', name: h.name, color: h.color };
                        loadImg('Hurdle', h.name);
                    }
                }
            }

            map[0][0] = { type: 'start' };
            map[0][1] = { type: 'road', name: 'normal', color: 'lightgreen' };
            map[1][0] = { type: 'road', name: 'normal', color: 'lightgreen' };
            
            map[ROWS-1][COLS-1] = { type: 'start' };
            map[ROWS-1][COLS-2] = { type: 'road', name: 'normal', color: 'lightgreen' };
            map[ROWS-2][COLS-1] = { type: 'road', name: 'normal', color: 'lightgreen' };

            let cx = 0, cy = 0;
            while (cx < COLS - 1 || cy < ROWS - 1) {
                if (!(cx === 0 && cy === 0)) {
                    map[cy][cx] = { type: 'road', name: 'normal', color: 'lightgreen' };
                }
                if (cx === COLS - 1) cy++;
                else if (cy === ROWS - 1) cx++;
                else Math.random() < 0.5 ? cx++ : cy++;
            }

            spawnItems();
        }

        function spawnItems() {
            items = [];
            for(let i=0; i<5; i++) spawnRandomItem('food');
            for(let i=0; i<3; i++) spawnRandomItem('magic');
        }

        function spawnRandomItem(type) {
            let rx, ry;
            let attempts = 0;
            do {
                rx = Math.floor(Math.random() * COLS);
                ry = Math.floor(Math.random() * ROWS);
                attempts++;
                if (attempts > 500) break;
            } while (map[ry][rx].type === 'hurdle' || items.some(i => i.x === rx && i.y === ry) || map[ry][rx].type === 'start');

            if (attempts > 500) return;

            if (type === 'food') {
                items.push({ x: rx, y: ry, type: 'food' });
                loadImg('Food', 'food');
            } else {
                const m = MAGIC_TYPES[Math.floor(Math.random() * MAGIC_TYPES.length)];
                items.push({ x: rx, y: ry, type: 'magic', magicData: m });
                loadImg('Magic', m.name);
            }
        }

        function checkItemPickup(player) {
            const idx = items.findIndex(i => i.x === player.x && i.y === player.y);
            if (idx !== -1) {
                const item = items[idx];
                if (item.type === 'food') {
                    player.score += 100 + (player.steps * 10);
                    player.steps = 0;
                    playSound('Sound', 'get', 'get');
                    items.splice(idx, 1);
                    spawnRandomItem('food');
                } else if (item.type === 'magic') {
                    player.magic = item.magicData;
                    playSound('Sound', 'magic_get', 'get');
                    items.splice(idx, 1);
                    spawnRandomItem('magic');
                }
            }
        }

        function generateAttackArea() {
            const area = [];
            for (let dy = -3; dy <= 3; dy++) {
                for (let dx = -3; dx <= 3; dx++) {
                    if (Math.abs(dx) + Math.abs(dy) <= 3 && Math.random() < 0.7) {
                        area.push({ dx: dx, dy: dy });
                    }
                }
            }
            return area;
        }

        function executeMagic(player) {
            playSound('Sound', 'attack', 'magic'); // 発動音
            const magic = player.magic;
            
            player.attackArea.forEach(cell => {
                const ax = player.targetX + cell.dx;
                const ay = player.targetY + cell.dy;
                
                if (ax >= 0 && ax < COLS && ay >= 0 && ay < ROWS) {
                    // 対象のマスにメテオを降らせる
                    meteors.push(new Meteor(ax, ay, magic, player));
                }
            });

            player.magic = null;
            player.lifting = false;
            player.attackArea = [];
        }

        function handleMeteorHit(m) {
            const ax = m.targetX;
            const ay = m.targetY;
            const magic = m.magic;
            const player = m.owner;

            // 着弾時にパーティクルを散らす
            for (let i = 0; i < 15; i++) {
                particles.push(new Particle(ax * TS + TS/2, ay * TS + TS/2, magic.color));
            }

            let mapCell = map[ay][ax];
            
            // 地形破壊
            if (mapCell.type === 'hurdle') {
                if ((mapCell.name === 'water' && magic.name === 'ice') || mapCell.name === magic.name) {
                    map[ay][ax] = { type: 'road', name: magic.name, color: magic.color };
                    loadImg('Road', magic.name);
                    if (soundTypeToPlay !== 'hit') soundTypeToPlay = 'destroy';
                }
            }
            
            // 確実なダメージ判定 (現在座標と移動先座標の双方でチェック)
            players.forEach(target => {
                const tx1 = Math.round(target.x);
                const ty1 = Math.round(target.y);
                const tx2 = target.targetX;
                const ty2 = target.targetY;

                if (target !== player && ((tx1 === ax && ty1 === ay) || (tx2 === ax && ty2 === ay))) {
                    if (!target.magic || target.magic.name !== magic.name) {
                        target.hp -= 30;
                        soundTypeToPlay = 'hit'; // ヒット音を優先
                        if (target.hp <= 0) {
                            if (target.isPlayer1) setGameOver();
                            else setStageClear();
                        }
                    }
                }
            });
        }

        // --- Main Loop ---
        function update(dt) {
            if (gameState !== 'PLAY') return;

            timeLeft -= dt;
            if (timeLeft <= 0) {
                if (players[0].score >= players[1].score) setStageClear();
                else setGameOver();
                return;
            }

            soundTypeToPlay = null;

            // Input P1
            let p1 = players[0];
            if (keys['ArrowUp']) p1.move(0, -1);
            else if (keys['ArrowDown']) p1.move(0, 1);
            else if (keys['ArrowLeft']) p1.move(-1, 0);
            else if (keys['ArrowRight']) p1.move(1, 0);

            if (keys['a'] || keys['A']) {
                if (!keys['a_handled']) {
                    if (!p1.lifting && p1.magic) {
                        p1.lifting = true;
                        p1.attackArea = generateAttackArea();
                    } else if (p1.lifting) {
                        executeMagic(p1);
                    }
                    keys['a_handled'] = true;
                }
            } else {
                keys['a_handled'] = false;
            }

            // Input P2 / CPU
            let p2 = players[1];
            if (mode === 2) {
                if (keys['i'] || keys['I']) p2.move(0, -1);
                else if (keys['k'] || keys['K']) p2.move(0, 1);
                else if (keys['j'] || keys['J']) p2.move(-1, 0);
                else if (keys['l'] || keys['L']) p2.move(1, 0);

                if (keys['u'] || keys['U']) {
                    if (!keys['u_handled']) {
                        if (!p2.lifting && p2.magic) {
                            p2.lifting = true;
                            p2.attackArea = generateAttackArea();
                        } else if (p2.lifting) {
                            executeMagic(p2);
                        }
                        keys['u_handled'] = true;
                    }
                } else {
                    keys['u_handled'] = false;
                }
            } else {
                if (!p2.moving && Math.random() < 0.05 + (stage * 0.02)) {
                    const dx = Math.sign(p1.targetX - p2.targetX);
                    const dy = Math.sign(p1.targetY - p2.targetY);
                    
                    if (p2.magic && Math.abs(dx) <= 3 && Math.abs(dy) <= 3) {
                        if (!p2.lifting) {
                            p2.lifting = true;
                            p2.attackArea = generateAttackArea();
                        } else {
                            executeMagic(p2);
                        }
                    } else {
                        if (Math.random() < 0.5 && dx !== 0) p2.move(dx, 0);
                        else if (dy !== 0) p2.move(0, dy);
                        else p2.move(Math.random() > 0.5 ? 1 : -1, 0);
                    }
                }
            }

            players.forEach(p => p.update(dt));

            meteors.forEach(m => m.update(dt));
            meteors = meteors.filter(m => !m.hit); // 着弾したものは削除

            particles.forEach(p => p.update(dt));
            particles = particles.filter(p => p.life > 0);

            // 1フレームで複数着弾しても音は代表して1回鳴らす(ノイズ防止)
            if (soundTypeToPlay) {
                playSound('Sound', soundTypeToPlay === 'hit' ? 'damage' : 'attack', soundTypeToPlay);
            }
        }

        function drawFallbackSquare(x, y, color) {
            ctx.fillStyle = color;
            ctx.fillRect(x * TS, y * TS, TS, TS);
        }

        function drawImageOrFallback(folder, name, x, y, fallbackColor) {
            const path = `${folder}/${name}.png`;
            if (ASSETS.images[path] && ASSETS.images[path].loaded) {
                ctx.drawImage(ASSETS.images[path].img, x * TS, y * TS, TS, TS);
            } else {
                drawFallbackSquare(x, y, fallbackColor);
            }
        }

        function draw() {
            ctx.fillStyle = '#000';
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            if (gameState === 'TITLE') {
                ctx.fillStyle = '#fff';
                ctx.textAlign = 'center';
                ctx.font = '50px sans-serif';
                ctx.fillText('Magical Maze', 400, 250);
                ctx.font = '24px sans-serif';
                ctx.fillText(`Mode: ${mode === 1 ? '1P vs CPU' : '2P Local'} (Press M to switch)`, 400, 350);
                ctx.fillText('Click or Press Any Key to Start', 400, 450);
                return;
            }

            if (gameState === 'START' || gameState === 'CLEAR' || gameState === 'OVER' || gameState === 'ALLCLEAR') {
                ctx.fillStyle = '#fff';
                ctx.textAlign = 'center';
                ctx.font = '40px sans-serif';
                let txt = '';
                if (gameState === 'START') txt = `Stage ${stage} Start`;
                if (gameState === 'CLEAR') txt = `Stage ${stage} Clear!`;
                if (gameState === 'OVER') txt = `Game Over`;
                if (gameState === 'ALLCLEAR') txt = `Game Clear! Congratulations!`;
                
                ctx.fillText(txt, 400, 300);
                ctx.font = '20px sans-serif';
                ctx.fillText('Press Any Key...', 400, 400);
                return;
            }

            for (let y = 0; y < ROWS; y++) {
                for (let x = 0; x < COLS; x++) {
                    const cell = map[y][x];
                    if (cell.type === 'road' || cell.type === 'start') {
                        drawImageOrFallback('Road', cell.name || 'normal', x, y, 'lightgreen');
                    } else if (cell.type === 'hurdle') {
                        drawImageOrFallback('Hurdle', cell.name, x, y, cell.color);
                    }
                }
            }

            items.forEach(item => {
                if (item.type === 'food') {
                    const path = `Food/food.png`;
                    if (ASSETS.images[path] && ASSETS.images[path].loaded) {
                        ctx.drawImage(ASSETS.images[path].img, item.x * TS, item.y * TS, TS, TS);
                    } else {
                        ctx.fillStyle = 'yellow';
                        ctx.fillRect(item.x * TS + TS/4, item.y * TS + TS/4, TS/2, TS/2);
                    }
                } else if (item.type === 'magic') {
                    const path = `Magic/${item.magicData.name}.png`;
                    if (ASSETS.images[path] && ASSETS.images[path].loaded) {
                        ctx.drawImage(ASSETS.images[path].img, item.x * TS, item.y * TS, TS, TS);
                    } else {
                        ctx.beginPath();
                        ctx.arc(item.x * TS + TS/2, item.y * TS + TS/2, TS/4, 0, Math.PI*2);
                        ctx.fillStyle = item.magicData.color;
                        ctx.fill();
                        ctx.closePath();
                    }
                }
            });

            // 攻撃範囲のカーソル (自キャラの移動に追従)
            players.forEach(p => {
                if (p.lifting && p.attackArea.length > 0 && p.magic) {
                    ctx.setLineDash([5, 5]);
                    ctx.lineDashOffset = -performance.now() / 50; 
                    ctx.strokeStyle = p.magic.color;
                    ctx.lineWidth = 3;
                    p.attackArea.forEach(cell => {
                        const ax = p.targetX + cell.dx;
                        const ay = p.targetY + cell.dy;
                        if(ax >= 0 && ax < COLS && ay >= 0 && ay < ROWS) {
                            ctx.strokeRect(ax * TS + 2, ay * TS + 2, TS - 4, TS - 4);
                        }
                    });
                    ctx.setLineDash([]);
                }
            });

            players.forEach(p => p.draw(ctx));
            meteors.forEach(m => m.draw(ctx));
            particles.forEach(p => p.draw(ctx));

            const uiY = ROWS * TS;
            ctx.fillStyle = '#333';
            ctx.fillRect(0, uiY, canvas.width, UI_HEIGHT);

            ctx.font = '16px sans-serif';
            ctx.fillStyle = '#fff';
            ctx.textAlign = 'left';
            
            ctx.fillText(`P1 Score: ${players[0].score}`, 20, uiY + 20);
            drawHpBar(20, uiY + 30, players[0].hp, players[0].maxHp, 'lightblue');
            if (players[0].magic) {
                ctx.fillStyle = players[0].magic.color;
                ctx.fillText(`Magic: ${players[0].magic.name}`, 20, uiY + 70);
            }

            ctx.textAlign = 'right';
            ctx.fillStyle = '#fff';
            ctx.fillText(`${mode === 1 ? 'CPU' : 'P2'} Score: ${players[1].score}`, canvas.width - 20, uiY + 20);
            drawHpBar(canvas.width - 170, uiY + 30, players[1].hp, players[1].maxHp, 'purple');
            if (players[1].magic) {
                ctx.fillStyle = players[1].magic.color;
                ctx.fillText(`Magic: ${players[1].magic.name}`, canvas.width - 20, uiY + 70);
            }

            ctx.textAlign = 'center';
            ctx.fillStyle = '#fff';
            ctx.font = '20px sans-serif';
            ctx.fillText(`Stage: ${stage}`, canvas.width / 2, uiY + 30);
            
            const min = Math.floor(timeLeft / 60);
            const sec = Math.floor(timeLeft % 60).toString().padStart(2, '0');
            ctx.fillText(`Time: ${min}:${sec}`, canvas.width / 2, uiY + 60);

            if (gameState === 'PAUSE') {
                ctx.fillStyle = 'rgba(0,0,0,0.5)';
                ctx.fillRect(0, 0, canvas.width, canvas.height);
                ctx.fillStyle = '#fff';
                ctx.textAlign = 'center';
                ctx.font = '40px sans-serif';
                ctx.fillText('PAUSE', 400, 300);
            }
        }

        function drawHpBar(x, y, hp, maxHp, color) {
            const width = 150;
            const height = 15;
            ctx.fillStyle = 'red';
            ctx.fillRect(x, y, width, height);
            ctx.fillStyle = color;
            const hpWidth = Math.max(0, (hp / maxHp) * width);
            ctx.fillRect(x, y, hpWidth, height);
            ctx.strokeStyle = '#fff';
            ctx.strokeRect(x, y, width, height);
        }

        function handleScreenTransition(key) {
            if (gameState === 'TITLE') {
                if (key === 'm' || key === 'M') {
                    mode = mode === 1 ? 2 : 1;
                } else {
                    stage = 1;
                    gameState = 'START';
                }
            } else if (gameState === 'START') {
                initGame();
                gameState = 'PLAY';
            } else if (gameState === 'CLEAR') {
                if (stage >= MAX_STAGES) {
                    gameState = 'ALLCLEAR';
                } else {
                    stage++;
                    gameState = 'START';
                }
            } else if (gameState === 'OVER' || gameState === 'ALLCLEAR') {
                gameState = 'TITLE';
            }
        }

        function setStageClear() { gameState = 'CLEAR'; }
        function setGameOver() { gameState = 'OVER'; }
        function togglePause() {
            if (gameState === 'PLAY') gameState = 'PAUSE';
            else if (gameState === 'PAUSE') gameState = 'PLAY';
        }

        function loop(timestamp) {
            if (!lastTime) lastTime = timestamp;
            const dt = (timestamp - lastTime) / 1000;
            lastTime = timestamp;

            update(dt);
            draw();

            requestAnimationFrame(loop);
        }

        requestAnimationFrame(loop);
    </script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
簡単な対戦ゲーム「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