物理演算3マッチゲーム

【更新履歴】

・2026/1/7 バージョン1.0公開
・2026/1/7 バージョン1.1公開
・2026/1/8 バージョン1.2公開

画像
プレイ中の画面
<!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>Gem Physics 3D - High Speed & Stability</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #222233; font-family: 'Segoe UI', sans-serif; user-select: none; }
        
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        
        #header { position: absolute; top: 20px; left: 20px; color: white; text-shadow: 1px 1px 0 #000; pointer-events: none; }
        h1 { display: none; }

        .status-row { font-size: 28px; font-weight: 800; color: #fff; margin-bottom: 20px; line-height: 1.4; text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
        .stat-label { color: #aaa; font-size: 0.7em; }
        .stat-val { color: #FFD700; font-family: "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif; }

        .controls-info { margin-top: 20px; font-size: 14px; color: #ddd; font-weight: bold; text-shadow: 1px 1px 0 #000; line-height: 1.6; }

        #combo-display {
            position: absolute; top: 30px; right: 40px; text-align: right; transform: skew(-15deg);
        }
        #combo-num {
            font-size: 80px; font-weight: 900; color: #FFFFAA; text-shadow: 3px 3px 0 #000; line-height: 1;
        }
        #combo-unit {
            font-size: 40px; font-weight: 700; color: #FFFFFF; text-shadow: 2px 2px 0 #000; margin-left: 10px;
        }
        #combo-container { opacity: 0; transition: transform 0.1s; }
        .combo-anim { animation: comboPop 0.5s ease-out forwards; }
        @keyframes comboPop {
            0% { opacity: 0; transform: scale(0.5) translateX(50px); }
            20% { opacity: 1; transform: scale(1.2) translateX(0); }
            100% { opacity: 1; transform: scale(1.0); }
        }

        #timer-container { 
            position: absolute; top: 20px; left: 50%; transform: translateX(-50%); 
            width: 400px; max-width: 80%; text-align: center;
        }
        #time-text { color: white; font-size: 28px; font-weight: 900; text-shadow: 2px 2px 0 #000; margin-bottom: 5px; }
        #time-bar-bg { width: 100%; height: 10px; background: rgba(0,0,0,0.5); border-radius: 5px; overflow: hidden; border: 2px solid rgba(255,255,255,0.5); }
        #time-bar-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #88FFAA, #FFFFAA, #FFAA88); transform-origin: left; transition: transform 0.1s linear; }

        #fps-counter {
            position: absolute; bottom: 10px; right: 10px;
            color: #00FF00; font-family: monospace; font-weight: bold; font-size: 16px;
            text-shadow: 1px 1px 0 #000;
        }

        .floating-score {
            position: absolute; color: #FFFFCC; font-size: 40px; font-weight: 900;
            text-shadow: 2px 2px 0 #000; pointer-events: none;
            animation: floatUp 0.6s ease-out forwards; white-space: nowrap; z-index: 20;
        }
        @keyframes floatUp {
            0% { opacity: 1; transform: translate(-50%, -50%) scale(0.5); }
            20% { transform: translate(-50%, -150%) scale(1.3); }
            100% { opacity: 0; transform: translate(-50%, -300%) scale(1.0); }
        }

        #danger-warning {
            position: absolute; top: 15%; left: 50%; transform: translateX(-50%);
            font-size: 40px; color: #FF5555; font-weight: 900; text-shadow: 2px 2px 0 #000;
            display: none; animation: blink 0.5s infinite alternate;
        }
        @keyframes blink { from { opacity: 1; } to { opacity: 0.3; } }

        .center-screen { 
            position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); 
            text-align: center; display: none; pointer-events: auto;
            background: rgba(30,30,45,0.95); padding: 40px; border-radius: 20px; 
            border: 4px solid #AAEEFF; box-shadow: 0 0 30px rgba(170,238,255,0.3); color: #fff;
        }
        .msg-title { font-size: 50px; font-weight: 900; margin-bottom: 20px; white-space: nowrap; text-shadow: 2px 2px 0 #000; }
        .btn { 
            padding: 15px 40px; font-size: 24px; background: transparent; 
            color: white; border: 2px solid #AAEEFF; cursor: pointer; transition: 0.2s;
            font-family: inherit; letter-spacing: 2px; text-transform: uppercase; font-weight: bold;
        }
        .btn:hover { background: #AAEEFF; color: black; box-shadow: 0 0 20px #AAEEFF; }

        #loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-weight: bold; text-shadow: 1px 1px 0 #000; }
    </style>
    
    <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
                "cannon-es": "https://unpkg.com/cannon-es@0.20.0/dist/cannon-es.js"
            }
        }
    </script>
</head>
<body>
    <div id="loading">Loading Square Box...</div>
    
    <div id="ui-layer">
        <div id="header">
            <div class="status-row">
                <span class="stat-label">STAGE</span> <span id="stage-val" class="stat-val">1</span><br>
                <span class="stat-label">SCORE</span> <span id="score-val" class="stat-val">0</span>
            </div>
            
            <div class="controls-info">
                <p>左ドラッグ: 視点移動<br>
                左クリック: 破壊<br>
                右ドラッグ: 撹拌 (回転)<br>
                Esc: 一時停止 | SPEED: x<span id="speed-val">1.0</span></p>
            </div>
        </div>

        <div id="combo-display">
            <div id="combo-container">
                <span id="combo-num"></span><span id="combo-unit">break !</span>
            </div>
        </div>

        <div id="timer-container">
            <div id="time-text">残り 60 秒</div>
            <div id="time-bar-bg"><div id="time-bar-fill"></div></div>
        </div>
        
        <div id="fps-counter">60 FPS</div>
        
        <div id="danger-warning">FALL OUT!</div>

        <div id="pause-screen" class="center-screen">
            <div class="msg-title" style="color: #FFFFFF;">PAUSED</div>
            <button class="btn" onclick="togglePause()">RESUME</button>
        </div>

        <div id="game-over-screen" class="center-screen">
            <div class="msg-title" style="color: #FF5555;">GAME OVER</div>
            <button class="btn" onclick="location.reload()">RETRY</button>
        </div>

        <div id="stage-clear-screen" class="center-screen">
            <div class="msg-title" style="color: #AAEEFF;">STAGE CLEAR!</div>
            <p style="margin-bottom:20px; font-weight:bold;">落下速度UP & 難易度上昇</p>
            <button class="btn" onclick="window.nextStage()">NEXT STAGE</button>
        </div>
    </div>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import * as CANNON from 'cannon-es';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

        // --- 定数設定 ---
        const GEM_RADIUS = 0.8;
        const BOARD_WIDTH = 12;
        // 箱の高さ (宝石3個分 = 約4.8程度)
        const BOARD_HEIGHT = GEM_RADIUS * 6.0; 
        
        const GEM_SPAWN_Y = 18;
        const STAGE_TIME = 60;
        const BASE_SPAWN_INTERVAL = 400; 
        const MATCH_DIST = GEM_RADIUS * 3.5;
        
        const ROT_LIGHT_HEIGHT = 30; 
        const ROT_LIGHT_RADIUS = BOARD_WIDTH * 2.0; 

        // 彩度高めのパステル
        const GEM_TYPES = [
            { color: 0xFF6666 }, 
            { color: 0x44FF44 }, 
            { color: 0x44AAFF }, 
            { color: 0xFFFF44 }, 
            { color: 0xCC66FF }
        ];

        // --- グローバル変数 ---
        let score = 0;
        let stage = 1;
        let timeScale = 1.0;
        let isPlaying = false;
        let isPaused = false;
        let isGameOver = false;
        let timeLeft = STAGE_TIME;
        let spawnInterval = BASE_SPAWN_INTERVAL;
        let penaltyCount = 0;
        let lastSpawnTime = 0;
        let gameStartTime = 0;
        let dangerTimer = 0;
        let baseGravity = -800; // 初期落下速度を8倍相当に強化

        let gems = [];
        let particles = [];

        // FPS計算用
        let lastFrameTime = 0;
        let frameCount = 0;
        let lastFpsTime = 0;

        let dragMode = null; 
        let dragPivotGem = null;
        let dragStartMouse = new THREE.Vector2();
        let dragPrevMouse = new THREE.Vector2();
        let squeezeSoundCooldown = 0;

        let world, scene, camera, renderer, composer, controls;
        let raycaster, mouse, mouseDownPos;
        let wallMaterial, gemPhysMat; 
        let gemGeometry; 
        let rotatingPointLight; 

        // 音響
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        let noiseBuffer = null;

        function createNoiseBuffer() {
            const bufferSize = audioCtx.sampleRate * 2.0; 
            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;
            }
            noiseBuffer = buffer;
        }

        function resumeAudio() { if (audioCtx.state === 'suspended') audioCtx.resume(); }

        function playSound(type, delay=0) {
            resumeAudio();
            const t = audioCtx.currentTime + delay;
            const gain = audioCtx.createGain();
            gain.connect(audioCtx.destination);

            if (type === 'hit') { 
                const osc = audioCtx.createOscillator();
                osc.type = 'sine';
                osc.frequency.setValueAtTime(2000, t);
                osc.frequency.exponentialRampToValueAtTime(4000, t + 0.05);
                gain.gain.setValueAtTime(0.01, t);
                gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
                osc.connect(gain);
                osc.start(t); osc.stop(t + 0.05);
            } else if (type === 'break') { 
                const osc = audioCtx.createOscillator();
                osc.type = 'sine';
                osc.frequency.setValueAtTime(3000, t);
                osc.frequency.exponentialRampToValueAtTime(6000, t + 0.1);
                const oscGain = audioCtx.createGain();
                oscGain.gain.setValueAtTime(0.1, t);
                oscGain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
                osc.connect(oscGain);
                oscGain.connect(gain);
                osc.start(t); osc.stop(t + 0.3);
            } else if (type === 'error') {
                const osc = audioCtx.createOscillator();
                osc.type = 'triangle';
                osc.frequency.setValueAtTime(150, t);
                osc.frequency.linearRampToValueAtTime(100, t + 0.2);
                gain.gain.setValueAtTime(0.1, t);
                gain.gain.exponentialRampToValueAtTime(0.01, t + 0.2);
                osc.connect(gain);
                osc.start(t); osc.stop(t + 0.2);
            } else if (type === 'squeeze') {
                const osc = audioCtx.createOscillator();
                osc.type = 'sawtooth';
                osc.frequency.setValueAtTime(100, t);
                osc.frequency.linearRampToValueAtTime(50, t + 0.2);
                gain.gain.setValueAtTime(0.05, t); 
                gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
                const filter = audioCtx.createBiquadFilter();
                filter.type = 'lowpass';
                filter.frequency.value = 500;
                osc.connect(filter);
                filter.connect(gain);
                osc.start(t); osc.stop(t + 0.2);
            }
        }

        function formatScore(num) {
            if (num < 10000) return num.toString();
            let str = "";
            const oku = Math.floor(num / 100000000);
            const man = Math.floor((num % 100000000) / 10000);
            const rest = num % 10000;
            
            if (oku > 0) str += oku + "億";
            if (man > 0) str += man + "万";
            if (rest > 0) str += rest;
            return str;
        }

        function updateScoreDisplay() {
            document.getElementById('score-val').innerText = formatScore(score);
        }

        function init() {
            document.getElementById('loading').style.display = 'none';
            createNoiseBuffer();

            world = new CANNON.World();
            world.gravity.set(0, baseGravity, 0); 
            world.broadphase = new CANNON.NaiveBroadphase();
            world.solver.iterations = 10; 
            world.solver.tolerance = 0.01;

            wallMaterial = new CANNON.Material();
            gemPhysMat = new CANNON.Material();
            const contactMat = new CANNON.ContactMaterial(wallMaterial, gemPhysMat, { friction: 0.1, restitution: 0.1 });
            const gemGemMat = new CANNON.ContactMaterial(gemPhysMat, gemPhysMat, { friction: 0.1, restitution: 0.1 });
            world.addContactMaterial(contactMat);
            world.addContactMaterial(gemGemMat);

            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x222233); 
            scene.fog = new THREE.FogExp2(0x222233, 0.01);

            camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
            camera.position.set(0, 20, 30);
            
            const ambientLight = new THREE.AmbientLight(0xffffff, 1.2); 
            scene.add(ambientLight);

            const sunLight = new THREE.DirectionalLight(0xffffff, 1.8);
            sunLight.position.set(20, 40, 20);
            sunLight.castShadow = true;
            sunLight.shadow.mapSize.width = 1024;
            sunLight.shadow.mapSize.height = 1024;
            scene.add(sunLight);

            rotatingPointLight = new THREE.PointLight(0xffffff, 3.0, 300);
            rotatingPointLight.position.set(ROT_LIGHT_RADIUS, ROT_LIGHT_HEIGHT, 0);
            const lightMesh = new THREE.Mesh(
                new THREE.SphereGeometry(1.0, 8, 8),
                new THREE.MeshBasicMaterial({ color: 0xffffff })
            );
            rotatingPointLight.add(lightMesh);
            scene.add(rotatingPointLight);

            const fillLight = new THREE.DirectionalLight(0xaaccff, 0.6);
            fillLight.position.set(-20, 10, -10);
            scene.add(fillLight);

            renderer = new THREE.WebGLRenderer({ antialias: false }); 
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.shadowMap.enabled = true;
            renderer.shadowMap.type = THREE.PCFSoftShadowMap;
            renderer.toneMapping = THREE.ACESFilmicToneMapping;
            renderer.toneMappingExposure = 1.0;
            document.body.appendChild(renderer.domElement);

            const renderScene = new RenderPass(scene, camera);
            const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth/2, window.innerHeight/2), 1.5, 0.4, 0.85);
            bloomPass.threshold = 0.6; 
            bloomPass.strength = 0.4; 
            bloomPass.radius = 0.2;
            composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);

            controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
            controls.dampingFactor = 0.05;
            controls.target.set(0, 2, 0); 
            controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: null };

            gemGeometry = new THREE.IcosahedronGeometry(GEM_RADIUS, 0);

            createContainer();

            for(let i=0; i<30; i++) {
                setTimeout(() => spawnGem(), i*30);
            }

            raycaster = new THREE.Raycaster();
            mouse = new THREE.Vector2();
            mouseDownPos = new THREE.Vector2();
            
            window.addEventListener('resize', onWindowResize);
            renderer.domElement.addEventListener('pointerdown', onPointerDown);
            renderer.domElement.addEventListener('pointermove', onPointerMove);
            renderer.domElement.addEventListener('pointerup', onPointerUp);
            renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
            window.addEventListener('keydown', onKeyDown);

            startGame();
            requestAnimationFrame(animate);
        }

        function startGame() {
            isPlaying = true;
            isPaused = false;
            isGameOver = false;
            timeLeft = STAGE_TIME;
            gameStartTime = performance.now();
            lastFrameTime = performance.now();
            lastFpsTime = performance.now();
            document.getElementById('danger-warning').style.display = 'none';
            updateTimerDisplay();
            updateScoreDisplay();
        }

        window.togglePause = function() {
            if (!isPlaying || isGameOver) return;
            isPaused = !isPaused;
            document.getElementById('pause-screen').style.display = isPaused ? 'block' : 'none';
            if (!isPaused) lastFrameTime = performance.now();
        }

        window.nextStage = function() {
            stage++;
            score += 2000;
            spawnInterval = Math.max(100, spawnInterval * 0.9); 
            
            // ステージ進行で重力を1.5倍にする
            baseGravity *= 1.5; 
            world.gravity.set(0, baseGravity, 0);
            
            clearAllGems();
            document.getElementById('stage-clear-screen').style.display = 'none';
            document.getElementById('stage-val').innerText = stage;
            updateScoreDisplay();
            for(let i=0; i<10; i++) {
                setTimeout(() => spawnGem(), i*50);
            }
            startGame();
        }

        function clearAllGems() {
            gems.forEach(gem => {
                world.removeBody(gem.body);
                scene.remove(gem.mesh);
                createSparks(gem.body.position, 0xFFFFFF); 
            });
            gems = [];
        }

        function createContainer() {
            const h = BOARD_HEIGHT; 
            const w = BOARD_WIDTH;

            // 床 (物理ボディは非常に分厚くして突き抜け防止)
            // Y=-50 を中心に、高さ100のボックスにする
            const floorShape = new CANNON.Box(new CANNON.Vec3(w/2, 50, w/2));
            const floorBody = new CANNON.Body({ mass: 0, material: wallMaterial });
            floorBody.addShape(floorShape);
            floorBody.position.set(0, -50, 0); // 表面が Y=0 になる
            world.addBody(floorBody);
            
            // 床の描画 (見た目は薄い板)
            const floorMesh = new THREE.Mesh(
                new THREE.BoxGeometry(w, 0.2, w),
                new THREE.MeshStandardMaterial({ color: 0x444455, roughness: 0.6 })
            );
            floorMesh.position.set(0, -0.1, 0);
            floorMesh.receiveShadow = true;
            scene.add(floorMesh);

            // 壁
            const wallPositions = [
                { x: 0, z: -w/2 - 0.5, w: w + 2, h: h, d: 1 }, 
                { x: 0, z: w/2 + 0.5, w: w + 2, h: h, d: 1 },  
                { x: -w/2 - 0.5, z: 0, w: 1, h: h, d: w },     
                { x: w/2 + 0.5, z: 0, w: 1, h: h, d: w }       
            ];

            wallPositions.forEach(p => {
                const body = new CANNON.Body({ mass: 0, material: wallMaterial });
                body.addShape(new CANNON.Box(new CANNON.Vec3(p.w/2, p.h/2, p.d/2)));
                body.position.set(p.x, p.h/2, p.z);
                world.addBody(body);
                
                // 透明度 0.05 (非常に薄い)
                const mesh = new THREE.Mesh(
                    new THREE.BoxGeometry(p.w, p.h, p.d),
                    new THREE.MeshPhysicalMaterial({ 
                        color: 0xffffff, 
                        opacity: 0.05, // かなり透明
                        transparent: true, 
                        roughness: 0.1,
                        side: THREE.DoubleSide,
                        depthWrite: false 
                    })
                );
                mesh.position.copy(body.position);
                scene.add(mesh);
            });
        }

        function spawnGem() {
            if ((isGameOver && !isPlaying) || isPaused) return;

            let spawnPos = new CANNON.Vec3(0, GEM_SPAWN_Y, 0);
            let valid = false;
            let attempts = 0;

            while (!valid && attempts < 15) {
                spawnPos.x = (Math.random() - 0.5) * (BOARD_WIDTH - 3);
                spawnPos.z = (Math.random() - 0.5) * (BOARD_WIDTH - 3);
                valid = true;
                for (let gem of gems) {
                    if (gem.body.position.y > GEM_SPAWN_Y - 3.0) { 
                        const dx = gem.body.position.x - spawnPos.x;
                        const dz = gem.body.position.z - spawnPos.z;
                        if (Math.sqrt(dx*dx + dz*dz) < GEM_RADIUS * 2.2) {
                            valid = false;
                            break;
                        }
                    }
                }
                attempts++;
            }

            const typeIdx = Math.floor(Math.random() * GEM_TYPES.length);
            const gemData = GEM_TYPES[typeIdx];

            const body = new CANNON.Body({ mass: 30, material: gemPhysMat }); 
            body.addShape(new CANNON.Sphere(GEM_RADIUS));
            body.position.copy(spawnPos);
            body.linearDamping = 0.0; 
            body.angularDamping = 0.1;
            body.allowSleep = true; 
            body.sleepSpeedLimit = 0.5; 
            body.sleepTimeLimit = 0.2;
            world.addBody(body);

            body.addEventListener("collide", (e) => {
                const cv = e.contact.getImpactVelocityAlongNormal();
                if (Math.abs(cv) > 20.0) { 
                    const other = gems.find(g => g.body === e.body);
                    if (other && other.colorIdx === typeIdx) playSound('hit');
                }
            });

            let meshMat = new THREE.MeshStandardMaterial({
                color: gemData.color,
                emissive: gemData.color,
                emissiveIntensity: 0.3, 
                roughness: 0.3, 
                metalness: 0.1, 
                flatShading: true 
            });

            const mesh = new THREE.Mesh(gemGeometry, meshMat);
            mesh.castShadow = true;
            mesh.receiveShadow = true;
            scene.add(mesh);

            gems.push({ mesh, body, colorIdx: typeIdx, id: body.id, spawnTime: Date.now() });
        }

        function createSparks(pos, color) {
            const particleCount = 8;
            const geo = new THREE.BufferGeometry();
            const positions = new Float32Array(particleCount * 3);
            const velocities = [];

            for(let i=0; i<particleCount; i++) {
                positions[i*3] = pos.x; positions[i*3+1] = pos.y; positions[i*3+2] = pos.z;
                velocities.push({ x: (Math.random()-0.5)*20, y: (Math.random()-0.5)*20, z: (Math.random()-0.5)*20 });
            }
            geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
            const mat = new THREE.PointsMaterial({ color: color, size: 0.8, transparent: true, blending: THREE.AdditiveBlending });
            const points = new THREE.Points(geo, mat);
            scene.add(points);
            particles.push({ mesh: points, velocities: velocities, life: 1.0 });
        }

        function showFloatingScore(points, position) {
            const div = document.createElement('div');
            div.className = 'floating-score';
            div.innerText = '+' + points;
            document.body.appendChild(div);

            const vec = position.clone();
            vec.y += 1.5;
            vec.project(camera);
            const x = (vec.x * 0.5 + 0.5) * window.innerWidth;
            const y = (-(vec.y * 0.5) + 0.5) * window.innerHeight;

            div.style.left = `${x}px`;
            div.style.top = `${y}px`;

            setTimeout(() => { if(div.parentNode) div.parentNode.removeChild(div); }, 600);
        }

        function showComboDisplay(count) {
            const container = document.getElementById('combo-container');
            const numEl = document.getElementById('combo-num');
            container.classList.remove('combo-anim');
            void container.offsetWidth; 
            numEl.innerText = count;
            container.classList.add('combo-anim');
            if(window.comboTimer) clearTimeout(window.comboTimer);
            window.comboTimer = setTimeout(() => {
                container.classList.remove('combo-anim');
                numEl.innerText = "";
            }, 3000);
        }

        function onPointerDown(event) {
            if (isGameOver || isPaused) return;
            mouseDownPos.x = event.clientX;
            mouseDownPos.y = event.clientY;
            mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(gems.map(g => g.mesh));
            if (event.button === 2) { 
                if (intersects.length > 0) {
                    const targetGem = gems.find(g => g.mesh === intersects[0].object);
                    if (targetGem) {
                        dragMode = 'rotate';
                        dragPivotGem = targetGem;
                        dragStartMouse.set(event.clientX, event.clientY);
                        dragPrevMouse.set(event.clientX, event.clientY);
                        controls.enabled = false;
                        dragPivotGem.body.type = CANNON.Body.KINEMATIC;
                        dragPivotGem.body.velocity.set(0,0,0);
                        dragPivotGem.body.angularVelocity.set(0,0,0);
                    }
                }
            }
        }

        function onPointerMove(event) {
            if (isGameOver || isPaused) return;
            if (dragMode === 'rotate' && dragPivotGem) {
                const currentMouse = new THREE.Vector2(event.clientX, event.clientY);
                const deltaX = currentMouse.x - dragPrevMouse.x;
                const deltaY = currentMouse.y - dragPrevMouse.y;
                dragPrevMouse.copy(currentMouse);
                
                const ROTATION_STRENGTH = 20.0; 
                const EFFECT_RADIUS = GEM_RADIUS * 2.8; 
                const pivotPos = dragPivotGem.body.position;
                
                // 詰まり判定 (単純に一定距離内のジェム数で判定)
                let neighborCount = 0;
                gems.forEach(gem => {
                    if (gem === dragPivotGem) return;
                    const dist = gem.body.position.distanceTo(pivotPos);
                    if (dist < EFFECT_RADIUS) {
                        neighborCount++;
                    }
                });

                if (neighborCount >= 6) {
                    if (Date.now() - squeezeSoundCooldown > 300) {
                        playSound('squeeze');
                        squeezeSoundCooldown = Date.now();
                    }
                    return;
                }

                gems.forEach(gem => {
                    if (gem === dragPivotGem) return;
                    const dx = gem.body.position.x - pivotPos.x;
                    const dz = gem.body.position.z - pivotPos.z;
                    const dist = Math.sqrt(dx*dx + dz*dz);
                    if (dist < EFFECT_RADIUS) {
                        const speed = (Math.abs(deltaX) + Math.abs(deltaY)) * 0.5;
                        if (speed > 0.5) {
                             let tx = -dz;
                             let tz = dx;
                             const len = Math.sqrt(tx*tx + tz*tz);
                             if (len > 0.001) {
                                 tx /= len;
                                 tz /= len;
                                 const dir = deltaX > 0 ? -1 : 1; 
                                 gem.body.wakeUp();
                                 gem.body.velocity.x += tx * ROTATION_STRENGTH * speed * 0.1 * dir;
                                 gem.body.velocity.z += tz * ROTATION_STRENGTH * speed * 0.1 * dir;
                                 
                                 // 上方向へ跳ねる力を抑制
                                 if (gem.body.velocity.y > 0) {
                                     gem.body.velocity.y = 0;
                                 }
                             }
                        }
                    }
                });
            }
        }

        function onPointerUp(event) {
            if (isGameOver || isPaused) return;
            if (dragMode === 'rotate') {
                if (dragPivotGem) {
                    dragPivotGem.body.type = CANNON.Body.DYNAMIC;
                    dragPivotGem.body.wakeUp();
                }
                dragMode = null;
                dragPivotGem = null;
                controls.enabled = true;
            } else if (event.button === 0) {
                const dx = event.clientX - mouseDownPos.x;
                const dy = event.clientY - mouseDownPos.y;
                if (Math.sqrt(dx*dx + dy*dy) < 5) {
                    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
                    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
                    raycaster.setFromCamera(mouse, camera);
                    const intersects = raycaster.intersectObjects(gems.map(g => g.mesh));
                    if (intersects.length > 0) {
                        const targetGem = gems.find(g => g.mesh === intersects[0].object);
                        tryMatch(targetGem);
                    }
                }
            }
        }

        function updateTimerDisplay() {
            const seconds = Math.ceil(timeLeft);
            document.getElementById('time-text').innerText = `残り ${seconds} 秒`;
            const ratio = timeLeft / STAGE_TIME;
            document.getElementById('time-bar-fill').style.transform = `scaleX(${ratio})`;
        }

        function checkGameOver() {
            // 箱の外に溢れた玉(Y < -3.0)の処理
            const dropped = [];
            for (let i = gems.length - 1; i >= 0; i--) {
                if (gems[i].body.position.y < -3.0) {
                    dropped.push(gems[i]);
                }
            }

            dropped.forEach(gem => {
                // 箱の内部エリア(x,z < 6)なら底抜け判定 -> 消すだけ
                // 箱の外部なら溢れ判定 -> GAMEOVER
                const x = Math.abs(gem.body.position.x);
                const z = Math.abs(gem.body.position.z);
                
                if (x < 6.0 && z < 6.0) {
                    // 底抜け (バグ回避のための救済)
                    world.removeBody(gem.body);
                    scene.remove(gem.mesh);
                    const idx = gems.indexOf(gem);
                    if (idx > -1) gems.splice(idx, 1);
                } else {
                    // 箱の外へ溢れた
                    document.getElementById('danger-warning').style.display = 'block';
                    gameOver();
                }
            });
            
            if (dropped.length === 0) {
                document.getElementById('danger-warning').style.display = 'none';
            }
        }

        function animate(time) {
            requestAnimationFrame(animate);
            
            frameCount++;
            if (time - lastFpsTime >= 1000) {
                document.getElementById('fps-counter').innerText = frameCount + " FPS";
                frameCount = 0;
                lastFpsTime = time;
            }

            if (isPaused) { composer.render(); lastFrameTime = time; return; }

            let dt = (time - lastFrameTime) / 1000;
            if (dt > 0.05) dt = 0.05; 
            lastFrameTime = time;

            if (isPlaying && !isGameOver) {
                const now = Date.now();
                if (now - lastSpawnTime > spawnInterval) {
                    spawnGem(gemPhysMat);
                    while(penaltyCount > 0) {
                        setTimeout(() => spawnGem(gemPhysMat), Math.random()*200);
                        penaltyCount--;
                    }
                    lastSpawnTime = now;
                }
                
                timeLeft -= dt * timeScale;
                if (timeLeft <= 0) { timeLeft = 0; stageClear(); }
                updateTimerDisplay();
                checkGameOver();

                const t = now * 0.001;
                if (rotatingPointLight) {
                    rotatingPointLight.position.x = Math.sin(t) * ROT_LIGHT_RADIUS;
                    rotatingPointLight.position.z = Math.cos(t) * ROT_LIGHT_RADIUS;
                }
            }

            world.step(1/60, dt, 3);
            
            controls.update();

            gems.forEach(gem => {
                gem.mesh.position.copy(gem.body.position);
                gem.mesh.quaternion.copy(gem.body.quaternion);
            });

            for (let i = particles.length - 1; i >= 0; i--) {
                const p = particles[i];
                p.life -= dt * 3.0; 
                if (p.life <= 0) { scene.remove(p.mesh); particles.splice(i, 1); continue; }
                const pos = p.mesh.geometry.attributes.position.array;
                for(let j=0; j<p.velocities.length; j++) {
                    pos[j*3] += p.velocities[j].x * dt;
                    pos[j*3+1] += p.velocities[j].y * dt;
                    pos[j*3+2] += p.velocities[j].z * dt;
                }
                p.mesh.geometry.attributes.position.needsUpdate = true;
                p.mesh.material.opacity = p.life;
            }

            composer.render();
        }

        function gameOver() {
            if(isGameOver) return;
            isPlaying = false;
            isGameOver = true;
            timeScale = 0.1;
            document.getElementById('game-over-screen').style.display = 'block';
        }

        function stageClear() {
            isPlaying = false;
            timeScale = 0.5;
            document.getElementById('danger-warning').style.display = 'none';
            document.getElementById('stage-clear-screen').style.display = 'block';
        }

        function tryMatch(startGem) {
            const matchGroup = [startGem];
            const checkQueue = [startGem];
            const checkedIds = new Set([startGem.id]);

            while(checkQueue.length > 0) {
                const current = checkQueue.shift();
                for (let other of gems) {
                    if (checkedIds.has(other.id)) continue;
                    if (other.colorIdx !== current.colorIdx) continue;
                    const dist = current.body.position.distanceTo(other.body.position);
                    if (dist < MATCH_DIST) {
                        matchGroup.push(other);
                        checkQueue.push(other);
                        checkedIds.add(other.id);
                    }
                }
            }

            if (matchGroup.length >= 3) {
                // スコア計算: 基本30点 + 増えた分(個数-3) * 30点
                const baseScore = 30;
                const extraCount = Math.max(0, matchGroup.length - 3);
                const points = baseScore + (30 * extraCount);
                
                score += points;
                updateScoreDisplay();
                
                showFloatingScore(points, startGem.mesh.position);
                showComboDisplay(matchGroup.length);

                matchGroup.forEach((gem, idx) => {
                    setTimeout(() => {
                        if (gems.includes(gem)) {
                            playSound('break', 0);
                            createSparks(gem.body.position, GEM_TYPES[gem.colorIdx].color);
                            scene.remove(gem.mesh);
                            world.removeBody(gem.body);
                            const i = gems.indexOf(gem);
                            if (i > -1) gems.splice(i, 1);
                        }
                    }, idx * 60);
                });
                dangerTimer = Math.max(0, dangerTimer - 1.0);
            } else {
                playSound('error');
                penaltyCount++;
                startGem.body.velocity.y += 20; 
            }
        }

        function onKeyDown(e) {
            if (e.code === 'Escape') { togglePause(); return; }
            if (isGameOver || isPaused) return;
            if (e.key === '+' || e.key === ';') timeScale = Math.min(timeScale * 2, 8.0);
            if (e.key === '-' || e.key === '_') timeScale = Math.max(timeScale / 2, 0.125);
            document.getElementById('speed-val').innerText = timeScale.toFixed(1);
        }

        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            composer.setSize(window.innerWidth, window.innerHeight);
        }

        init();
    </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