物理演算3マッチゲーム


画像
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>Crystal Physics Match</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
    <style>
        body { margin: 0; padding: 0; overflow: hidden; background-color: #050510; color: #fff; font-family: 'Segoe UI', sans-serif; user-select: none; -webkit-user-select: none; }
        #score-board { position: absolute; top: 20px; left: 20px; font-size: 24px; font-weight: bold; text-shadow: 0 0 10px rgba(255,255,255,0.8); pointer-events: none; z-index: 10; }
        #combo-text { position: absolute; top: 0; left: 0; font-size: 24px; color: #fff; opacity: 0; pointer-events: none; font-weight: 800; text-shadow: 0 0 20px #fff; z-index: 10; transition: transform 0.1s; }
        #hint-text { position: absolute; bottom: 80px; width: 100%; text-align: center; color: rgba(255,255,255,0.5); pointer-events: none; font-size: 14px; }
        #ui-layer { position: absolute; bottom: 20px; width: 100%; text-align: center; pointer-events: none; z-index: 10; }
        .btn { pointer-events: auto; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.4); color: #fff; padding: 12px 40px; border-radius: 40px; cursor: pointer; font-size: 16px; transition: 0.2s; backdrop-filter: blur(10px); text-transform: uppercase; letter-spacing: 2px; }
        .btn:hover { background: rgba(255,255,255,0.4); box-shadow: 0 0 20px rgba(255,255,255,0.6); transform: scale(1.05); }
        canvas { display: block; }
    </style>
</head>
<body>
    <div id="score-board">SCORE: <span id="score">0</span></div>
    <div id="combo-text">COMBO!</div>
    <div id="hint-text">3つ以上つながったクリスタルをタップして破壊</div>
    <div id="ui-layer">
        <button class="btn" onclick="resetGame()">RESET</button>
    </div>
    
    <script>
        // --- 設定 ---
        const CONFIG = {
            ballSize: 26,
            // クリスタルっぽい寒色系カラーパレット
            colors: ['#00FFFF', '#FF00CC', '#4444FF', '#FFFF00'], 
            glowColor: ['#AAFFFF', '#FF99EE', '#AAAAFF', '#FFFFDD'],
            matchDistanceRatio: 2.3, 
            minMatch: 3
        };

        // --- エンジン初期化 ---
        const Engine = Matter.Engine,
              Render = Matter.Render,
              Runner = Matter.Runner,
              Bodies = Matter.Bodies,
              Composite = Matter.Composite,
              Events = Matter.Events,
              Query = Matter.Query,
              Vector = Matter.Vector,
              World = Matter.World;

        const engine = Engine.create();
        const world = engine.world;
        
        const render = Render.create({
            element: document.body,
            engine: engine,
            options: {
                width: window.innerWidth,
                height: window.innerHeight,
                wireframes: false,
                background: '#050510'
            }
        });

        const ctx = render.context;
        const canvas = render.canvas;

        // --- ゲーム状態 ---
        let score = 0;
        let particles = [];
        let balls = [];
        let hoverMatches = []; // ホバー中のマッチリスト

        // --- 💎 クリスタル・サウンド生成 (Web Audio API) ---
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();

        // ノイズ生成(アタック音用)
        function createNoiseBuffer() {
            const bufferSize = audioCtx.sampleRate * 0.1; // 0.1秒
            const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
            const data = buffer.getChannelData(0);
            for (let i = 0; i < bufferSize; i++) {
                data[i] = Math.random() * 2 - 1;
            }
            return buffer;
        }
        const noiseBuffer = createNoiseBuffer();

        // 衝突音(カチン!)
        function playCollisionSound(volume) {
            if (audioCtx.state === 'suspended') audioCtx.resume();
            if (volume < 0.05) return; // 小さすぎる音は鳴らさない

            const t = audioCtx.currentTime;
            const osc = audioCtx.createOscillator();
            const gain = audioCtx.createGain();

            // FMシンセシス風:金属的な響き
            osc.type = 'sine';
            osc.frequency.setValueAtTime(2000 + Math.random() * 1000, t); // 高音
            
            gain.gain.setValueAtTime(0, t);
            gain.gain.linearRampToValueAtTime(volume * 0.3, t + 0.005);
            gain.gain.exponentialRampToValueAtTime(0.001, t + 0.1); // 短い減衰

            osc.connect(gain);
            gain.connect(audioCtx.destination);
            osc.start(t);
            osc.stop(t + 0.1);
        }

        // 破壊音(パリーン!キラキラ✨)
        function playShatterSound(comboSize) {
            if (audioCtx.state === 'suspended') audioCtx.resume();
            const t = audioCtx.currentTime;

            // 1. 破壊衝撃音(ホワイトノイズ)
            const noise = audioCtx.createBufferSource();
            noise.buffer = noiseBuffer;
            const noiseGain = audioCtx.createGain();
            noiseGain.gain.setValueAtTime(0.5, t);
            noiseGain.gain.exponentialRampToValueAtTime(0.01, t + 0.1);
            noise.connect(noiseGain);
            noiseGain.connect(audioCtx.destination);
            noise.start(t);

            // 2. キラキラ音(アルペジオ)
            const count = Math.min(comboSize, 5); // 音の重なりすぎ防止
            for(let i = 0; i < count; i++) {
                const osc = audioCtx.createOscillator();
                const gain = audioCtx.createGain();
                
                osc.type = 'triangle';
                // 和音になるように周波数を設定(ド・ミ・ソ・シ...)
                const baseFreq = 880; // A5
                const ratios = [1, 1.25, 1.5, 2, 2.5]; // Major chord ratios
                osc.frequency.value = baseFreq * ratios[i % ratios.length] * (1 + Math.random()*0.02);

                const delay = i * 0.05; // 少しずらして鳴らす
                
                gain.gain.setValueAtTime(0, t + delay);
                gain.gain.linearRampToValueAtTime(0.1, t + delay + 0.01);
                gain.gain.exponentialRampToValueAtTime(0.001, t + delay + 0.5);

                osc.connect(gain);
                gain.connect(audioCtx.destination);
                osc.start(t + delay);
                osc.stop(t + delay + 0.6);
            }
        }

        // エラー音(ブブッ)
        function playErrorSound() {
            if (audioCtx.state === 'suspended') audioCtx.resume();
            const t = audioCtx.currentTime;
            const osc = audioCtx.createOscillator();
            const gain = audioCtx.createGain();
            osc.type = 'sawtooth';
            osc.frequency.setValueAtTime(150, t);
            osc.frequency.exponentialRampToValueAtTime(100, t + 0.1);
            gain.gain.setValueAtTime(0.1, t);
            gain.gain.exponentialRampToValueAtTime(0.01, t + 0.1);
            osc.connect(gain);
            gain.connect(audioCtx.destination);
            osc.start(t);
            osc.stop(t + 0.1);
        }

        // --- 物理イベント(衝突検知) ---
        Events.on(engine, 'collisionStart', function(event) {
            const pairs = event.pairs;
            // 衝突の勢いを見て音を鳴らす(負荷軽減のため間引く)
            for (let i = 0; i < pairs.length; i++) {
                const bodyA = pairs[i].bodyA;
                const bodyB = pairs[i].bodyB;
                
                // ボール同士、またはボールと壁
                if (bodyA.label === 'ball' || bodyB.label === 'ball') {
                    // 相対速度を概算
                    const speed = Vector.magnitude(Vector.sub(bodyA.velocity, bodyB.velocity));
                    if (speed > 5) { // 一定以上の強さでぶつかったら
                        playCollisionSound(Math.min(speed / 20, 1.0));
                    }
                }
            }
        });

        // --- オブジェクト生成 ---
        function createWalls() {
            const w = window.innerWidth;
            const h = window.innerHeight;
            const wallOpt = { isStatic: true, render: { visible: false } }; // 壁は透明に
            
            World.add(world, [
                Bodies.rectangle(w/2, h + 50, w, 100, wallOpt),
                Bodies.rectangle(-50, h/2, 100, h * 2, wallOpt),
                Bodies.rectangle(w + 50, h/2, 100, h * 2, wallOpt)
            ]);
        }

        function spawnBall(x, y) {
            const colorIdx = Math.floor(Math.random() * CONFIG.colors.length);
            const ball = Bodies.polygon(x, y, 6, CONFIG.ballSize, { // 六角形で宝石っぽく
                restitution: 0.4,
                friction: 0.05,
                angle: Math.random() * Math.PI,
                render: {
                    fillStyle: CONFIG.colors[colorIdx]
                },
                label: 'ball',
                customColorIdx: colorIdx
            });
            return ball;
        }

        function fillBalls() {
            const w = window.innerWidth;
            let count = 0;
            const interval = setInterval(() => {
                const x = Math.random() * (w - 100) + 50;
                const ball = spawnBall(x, -50);
                World.add(world, ball);
                balls.push(ball);
                count++;
                if (count > 60) clearInterval(interval);
            }, 60);
        }

        // --- マッチングロジック ---
        function findMatches(startBody) {
            const targetColor = startBody.customColorIdx;
            const queue = [startBody];
            const matches = new Set([startBody]);
            const searchDist = CONFIG.ballSize * CONFIG.matchDistanceRatio;

            let head = 0;
            while(head < queue.length){
                const current = queue[head++];
                for (let other of balls) {
                    if (matches.has(other)) continue;
                    if (other.customColorIdx !== targetColor) continue;
                    const d = Vector.magnitude(Vector.sub(current.position, other.position));
                    if (d < searchDist) {
                        matches.add(other);
                        queue.push(other);
                    }
                }
            }
            return Array.from(matches);
        }

        // --- エフェクト処理 ---
        function createParticles(x, y, color) {
            for (let i = 0; i < 10; i++) {
                particles.push({
                    x: x, y: y,
                    vx: (Math.random() - 0.5) * 15,
                    vy: (Math.random() - 0.5) * 15,
                    life: 1.0,
                    size: Math.random() * 4 + 2,
                    color: color
                });
            }
        }

        // --- 描画ループ ---
        function renderLoop() {
            // 背景(少し残像を残して高級感を出す)
            ctx.fillStyle = 'rgba(5, 5, 16, 0.5)';
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            // 1. ボール描画(加算合成で光らせる)
            ctx.globalCompositeOperation = 'lighter';
            
            balls.forEach(b => {
                const pos = b.position;
                const r = CONFIG.ballSize;
                const vertices = b.vertices;

                // --- ホバー時のハイライト処理 ---
                const isHovered = hoverMatches.includes(b);
                
                ctx.shadowBlur = isHovered ? 30 : 10;
                ctx.shadowColor = isHovered ? '#FFFFFF' : CONFIG.glowColor[b.customColorIdx];
                
                ctx.beginPath();
                ctx.moveTo(vertices[0].x, vertices[0].y);
                for (let j = 1; j < vertices.length; j += 1) {
                    ctx.lineTo(vertices[j].x, vertices[j].y);
                }
                ctx.lineTo(vertices[0].x, vertices[0].y);
                ctx.closePath();
                
                ctx.fillStyle = isHovered ? '#FFFFFF' : b.render.fillStyle; // ホバーなら白く
                ctx.fill();

                // 宝石のような内部の光沢線
                ctx.strokeStyle = 'rgba(255,255,255,0.5)';
                ctx.lineWidth = 1;
                ctx.stroke();
            });

            // 2. パーティクル描画
            for (let i = particles.length - 1; i >= 0; i--) {
                let p = particles[i];
                p.x += p.vx;
                p.y += p.vy;
                p.life -= 0.04;
                if (p.life <= 0) { particles.splice(i, 1); continue; }

                ctx.shadowBlur = 0;
                ctx.globalAlpha = p.life;
                ctx.fillStyle = p.color;
                ctx.beginPath();
                ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
                ctx.fill();
            }
            ctx.globalAlpha = 1.0;
            ctx.globalCompositeOperation = 'source-over';
            
            requestAnimationFrame(renderLoop);
        }

        // --- 入力ハンドリング(マウス移動&クリック) ---
        
        // マウス移動(ホバー判定)
        canvas.addEventListener('mousemove', (e) => checkHover(e.clientX, e.clientY));
        
        function checkHover(x, y) {
            const bodies = Query.point(balls, { x: x, y: y });
            if (bodies.length > 0) {
                const matches = findMatches(bodies[0]);
                if (matches.length >= CONFIG.minMatch) {
                    // 消せるなら光らせるリストに入れる
                    hoverMatches = matches;
                    canvas.style.cursor = 'pointer';
                    return;
                }
            }
            // 何もない、または消せないならリストクリア
            hoverMatches = [];
            canvas.style.cursor = 'default';
        }

        // クリック(破壊実行)
        canvas.addEventListener('mousedown', (e) => handleClick(e.clientX, e.clientY));
        canvas.addEventListener('touchstart', (e) => {
            const t = e.touches[0];
            checkHover(t.clientX, t.clientY); // タッチした瞬間も判定
            handleClick(t.clientX, t.clientY);
        }, {passive: false});

        function handleClick(x, y) {
            const bodies = Query.point(balls, { x: x, y: y });
            if (bodies.length > 0) {
                const matches = findMatches(bodies[0]);

                if (matches.length >= CONFIG.minMatch) {
                    // 成功!
                    playShatterSound(matches.length);
                    
                    // スコア演出
                    score += matches.length * 100;
                    document.getElementById('score').innerText = score;
                    
                    const comboText = document.getElementById('combo-text');
                    comboText.innerText = matches.length + " HITS!";
                    comboText.style.opacity = 1;
                    comboText.style.left = (x + 20) + 'px';
                    comboText.style.top = (y - 50) + 'px';
                    comboText.style.transform = 'scale(1.5)';
                    setTimeout(() => {
                        comboText.style.opacity = 0;
                        comboText.style.transform = 'scale(1)';
                    }, 800);

                    // 削除
                    matches.forEach(b => {
                        createParticles(b.position.x, b.position.y, CONFIG.glowColor[b.customColorIdx]);
                        World.remove(world, b);
                        const idx = balls.indexOf(b);
                        if (idx > -1) balls.splice(idx, 1);
                    });
                    hoverMatches = []; // 選択解除

                    // 補充
                    setTimeout(() => {
                        for(let i=0; i<matches.length; i++){
                            const newBall = spawnBall(Math.random() * window.innerWidth, -50 - (i*30));
                            World.add(world, newBall);
                            balls.push(newBall);
                        }
                    }, 300);
                } else {
                    // 失敗(数が足りない)
                    playErrorSound();
                }
            }
        }

        function resetGame() {
            World.clear(world);
            Engine.clear(engine);
            balls = [];
            score = 0;
            document.getElementById('score').innerText = 0;
            createWalls();
            fillBalls();
        }

        // --- 実行 ---
        createWalls();
        fillBalls();
        
        const runner = Runner.create();
        Runner.run(runner, engine);
        renderLoop();

        window.addEventListener('resize', () => {
            render.canvas.width = window.innerWidth;
            render.canvas.height = window.innerHeight;
            World.clear(world);
            balls = [];
            createWalls();
            fillBalls();
        });

    </script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
買うたび 抽選 ※条件・上限あり \note クリエイター感謝祭ポイントバックキャンペーン/最大全額もどってくる! 12.1 月〜1.14 水 まで
物理演算3マッチゲーム|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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