3Dパズルゲーム「GemPhysics3D」

【更新履歴】

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

画像
プレイ中の画面

・宝石を消していくゲームですが、
 物理演算を使っています。

・ツムツムだとかと違うのは、
 テトリスやぷよぷよみたいに
 何もしなくてもどんどん落ちてくるので、
 ややアクション性が高いというところです。

・PCで遊ぶブラウザゲームですが、スマホ版もあります。

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

【操作説明】

・宝石は、3つ以上並んでいる宝石のうち一つを
 左クリックすると消えます。
・並んでいない宝石を左クリックしてしまうとミス。
 (宝石が10個落ちてくる。)
・宝石をマウスの右ボタンでドラッグして
 隣の宝石にドロップすると、位置が入れ替わります。
・宝石が箱の外にあふれ出るとゲームオーバー。
・制限時間内に、なるべく多くの宝石を消して
 獲得したスコアを競います。
・箱の外をマウスの左ボタンでドラッグすると、
 視点を回転させることができます。

【難易度を調整して特訓する方法】


・テンキーの+キーを押すと、玉が落ちてくる速さが上がります。
・-キーを押すと、速度が半分になります。

・パソコン版の実装はこちら。↓

<!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 1.3 for PC</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; }

        .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;
        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; 

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

        // 操作・スワップ用変数
        let dragStartGem = null; 
        let isSwapping = false;
        let lastActionTime = 0;

        // FPS計算用
        let lastFrameTime = 0;
        let frameCount = 0;
        let lastFpsTime = 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)();

        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);
            }
        }

        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';

            world = new CANNON.World();
            world.gravity.set(0, baseGravity, 0); 
            world.broadphase = new CANNON.NaiveBroadphase();
            // ソルバーの反復回数を増やして、すり抜けにくくする
            world.solver.iterations = 20; 
            world.solver.tolerance = 0.001;

            wallMaterial = new CANNON.Material();
            gemPhysMat = new CANNON.Material();
            const contactMat = new CANNON.ContactMaterial(wallMaterial, gemPhysMat, { friction: 0.2, restitution: 0.05 });
            const gemGemMat = new CANNON.ContactMaterial(gemPhysMat, gemPhysMat, { friction: 0.1, restitution: 0.05 });
            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();
            lastActionTime = Date.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); 
            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;

            // 床
            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); 
            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);
                
                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: 600, material: gemPhysMat }); 
            body.addShape(new CANNON.Sphere(GEM_RADIUS));
            body.position.copy(spawnPos);
            body.linearDamping = 0.2; 
            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 createFirework(x, y, z, color) {
            const particleCount = 20; 
            const geo = new THREE.BufferGeometry();
            const positions = new Float32Array(particleCount * 3);
            const velocities = [];

            for(let i=0; i<particleCount; i++) {
                positions[i*3] = x; 
                positions[i*3+1] = y; 
                positions[i*3+2] = z;
                const theta = Math.random() * Math.PI * 2;
                const phi = Math.acos(2 * Math.random() - 1);
                const speed = 15 + Math.random() * 10;
                velocities.push({ 
                    x: Math.sin(phi) * Math.cos(theta) * speed, 
                    y: Math.sin(phi) * Math.sin(theta) * speed, 
                    z: Math.cos(phi) * speed 
                });
            }
            geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
            const mat = new THREE.PointsMaterial({ 
                color: color, 
                size: 0.3, 
                transparent: true, 
                blending: THREE.AdditiveBlending, 
                depthWrite: false, 
                sizeAttenuation: true 
            });
            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 swapGems(gemA, gemB) {
            if (isSwapping) return;
            
            const dist = gemA.body.position.distanceTo(gemB.body.position);
            if (dist > GEM_RADIUS * 4.0) return; 

            isSwapping = true;
            lastActionTime = Date.now();

            gemA.body.type = CANNON.Body.KINEMATIC;
            gemB.body.type = CANNON.Body.KINEMATIC;
            
            gemA.body.velocity.set(0, 0, 0);
            gemA.body.angularVelocity.set(0, 0, 0);
            gemB.body.velocity.set(0, 0, 0);
            gemB.body.angularVelocity.set(0, 0, 0);

            const startPosA = gemA.body.position.clone();
            const startPosB = gemB.body.position.clone();
            
            let progress = 0;
            const speed = 0.15; 

            function animateSwap() {
                progress += speed;
                if (progress > 1.0) progress = 1.0;

                const currentPosA = new CANNON.Vec3();
                const currentPosB = new CANNON.Vec3();
                
                currentPosA.copy(startPosA).scale(1 - progress).vadd(startPosB.clone().scale(progress), currentPosA);
                currentPosB.copy(startPosB).scale(1 - progress).vadd(startPosA.clone().scale(progress), currentPosB);

                gemA.body.position.copy(currentPosA);
                gemB.body.position.copy(currentPosB);
                
                gemA.mesh.position.copy(gemA.body.position);
                gemB.mesh.position.copy(gemB.body.position);

                if (progress < 1.0) {
                    requestAnimationFrame(animateSwap);
                } else {
                    gemA.body.type = CANNON.Body.DYNAMIC;
                    gemB.body.type = CANNON.Body.DYNAMIC;
                    
                    gemA.body.wakeUp();
                    gemB.body.wakeUp();
                    
                    isSwapping = false;

                    // --- 入れ替え完了後にマッチ判定を行う ---
                    // 第二引数 true で「ペナルティなし」モードにしておく(失敗しても宝石を降らせない)
                    tryMatch(gemA, true);
                    tryMatch(gemB, true);
                }
            }
            animateSwap();
        }

        function onPointerDown(event) {
            if (isGameOver || isPaused || isSwapping) return;
            
            lastActionTime = Date.now();
            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 (intersects.length > 0) {
                const targetGem = gems.find(g => g.mesh === intersects[0].object);
                
                if (event.button === 2) { 
                    if (targetGem) {
                        dragStartGem = targetGem;
                        controls.enabled = false; 
                    }
                }
            }
        }

        function onPointerMove(event) {
        }

        function onPointerUp(event) {
            if (isGameOver || isPaused) return;
            lastActionTime = Date.now();

            if (event.button === 2) {
                if (dragStartGem) {
                    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 dragEndGem = gems.find(g => g.mesh === intersects[0].object);
                        if (dragEndGem && dragEndGem !== dragStartGem) {
                            swapGems(dragStartGem, dragEndGem);
                        }
                    }
                    dragStartGem = 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);
                        // 左クリックはペナルティあり(第2引数 false または省略)
                        tryMatch(targetGem, false);
                    }
                }
            }
        }

        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() {
            const safeTime = (Date.now() - lastActionTime < 1500) || isSwapping;

            const outOfBounds = [];
            for (let i = gems.length - 1; i >= 0; i--) {
                const gem = gems[i];
                const x = Math.abs(gem.body.position.x);
                const z = Math.abs(gem.body.position.z);
                const y = gem.body.position.y;
                
                // 床より下に完全に落ちてしまった場合の判定
                // (底抜け防止ロジックが入っても、すさまじい勢いで突き抜けた場合の最終防壁)
                if (y < -3.0) {
                    outOfBounds.push({ gem, type: 'bottom' });
                }
                // 箱から外に溢れ出た判定
                else if (y > 0 && (x > BOARD_WIDTH/2 + 2 || z > BOARD_WIDTH/2 + 2)) {
                    outOfBounds.push({ gem, type: 'overflow' });
                }
            }

            outOfBounds.forEach(({ gem, type }) => {
                if (type === 'bottom' && safeTime) {
                    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 (outOfBounds.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 => {
                // --- 修正: 底抜け防止 (Hard Constraint) ---
                // 宝石の中心座標が半径より下(=宝石の底が床より下)に行ったら
                // 強制的に床の上に戻し、下向きの速度を0にする
                if (gem.body.position.y < GEM_RADIUS) {
                    gem.body.position.y = GEM_RADIUS;
                    if (gem.body.velocity.y < 0) {
                        gem.body.velocity.y = 0;
                    }
                }

                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';
        }

        // --- 修正: ペナルティ抑制フラグ(suppressPenalty)を追加 ---
        function tryMatch(startGem, suppressPenalty = false) {
            // 既に削除予定のジェムなら無視
            if (!gems.includes(startGem)) return false;

            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) {
                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);

                for (let i = 0; i < matchGroup.length; i++) {
                    setTimeout(() => {
                        const x = (Math.random() - 0.5) * BOARD_WIDTH * 1.5;
                        const y = 10 + Math.random() * 10;
                        const z = (Math.random() - 0.5) * BOARD_WIDTH * 1.5;
                        const fireworkColor = GEM_TYPES[Math.floor(Math.random() * GEM_TYPES.length)].color;
                        createFirework(x, y, z, fireworkColor);
                    }, i * 100);
                }

                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);
                return true; // マッチ成功
            } else {
                if (!suppressPenalty) {
                    playSound('error');
                    penaltyCount += 10; 
                }
                return false; // マッチ失敗
            }
        }

        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>

・スマホ版の実装はこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0">
    <title>Gem Physics 3D 1.3 for Mobile</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #222233; font-family: 'Segoe UI', sans-serif; user-select: none; -webkit-touch-callout: none; touch-action: none; }
        
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        
        #header-bar {
            position: absolute; top: 0; left: 0; width: 100%;
            padding: 10px 0;
            background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
            display: flex; flex-direction: column; align-items: center; justify-content: start;
            color: white; text-shadow: 1px 1px 2px #000;
        }

        .status-line {
            display: flex; justify-content: space-around; width: 95%;
            font-size: 20px; font-weight: 800;
        }
        .stat-item { text-align: center; }
        .stat-label { font-size: 12px; color: #aaa; display: block; }
        .stat-val { font-size: 24px; color: #FFD700; font-family: "Hiragino Kaku Gothic ProN", sans-serif; }
        #time-val { color: #fff; }

        #combo-display {
            position: absolute; top: 15%; left: 50%; transform: translateX(-50%) skew(-15deg);
            text-align: center; width: 100%;
        }
        #combo-num {
            font-size: 60px; font-weight: 900; color: #FFFFAA; 
            text-shadow: 3px 3px 0 #000; line-height: 1;
        }
        #combo-unit {
            font-size: 30px; font-weight: 700; color: #FFFFFF; 
            text-shadow: 2px 2px 0 #000; margin-left: 5px;
        }
        #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); }
            50% { opacity: 1; transform: scale(1.3); }
            100% { opacity: 1; transform: scale(1.0); }
        }

        .floating-score {
            position: absolute; color: #FFFFCC; font-size: 32px; 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); }
            100% { opacity: 0; transform: translate(-50%, -200%) scale(1.0); }
        }

        #danger-warning {
            position: absolute; top: 25%; 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; width: 100%; text-align: center;
        }
        @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: 30px; border-radius: 20px; width: 80%;
            border: 2px solid #AAEEFF; box-shadow: 0 0 30px rgba(170,238,255,0.3); color: #fff;
        }
        .msg-title { font-size: 40px; font-weight: 900; margin-bottom: 20px; white-space: nowrap; text-shadow: 2px 2px 0 #000; }
        .btn { 
            padding: 15px 40px; font-size: 20px; background: transparent; 
            color: white; border: 2px solid #AAEEFF; cursor: pointer; 
            font-family: inherit; letter-spacing: 2px; text-transform: uppercase; font-weight: bold;
            touch-action: manipulation;
        }
        .btn:active { background: #AAEEFF; color: black; }

        #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 Final Engine...</div>
    
    <div id="ui-layer">
        <div id="header-bar">
            <div class="status-line">
                <div class="stat-item">
                    <span class="stat-label">STAGE</span>
                    <span id="stage-val" class="stat-val">1</span>
                </div>
                <div class="stat-item">
                    <span class="stat-label">TIME</span>
                    <span id="time-val" class="stat-val">60</span>
                </div>
                <div class="stat-item">
                    <span class="stat-label">SCORE</span>
                    <span id="score-val" class="stat-val">0</span>
                </div>
            </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="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;">SPEED UP!</p>
            <button class="btn" onclick="window.nextStage()">NEXT</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;
        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; 

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

        let lastFrameTime = 0;

        // タッチ・スワップ操作用変数
        let dragStartGem = null;
        let isSwapping = false;
        let touchStartTime = 0;
        let touchStartPos = new THREE.Vector2(); // タップ判定用
        let lastActionTime = 0; // 無敵時間判定用

        let world, scene, camera, renderer, composer, controls;
        let raycaster, mouse;
        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);
            }
        }

        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 updateStatusDisplay() {
            document.getElementById('stage-val').innerText = stage;
            document.getElementById('score-val').innerText = formatScore(score);
            document.getElementById('time-val').innerText = Math.ceil(timeLeft);
        }

        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 = 20; 
            world.solver.tolerance = 0.001;

            wallMaterial = new CANNON.Material();
            gemPhysMat = new CANNON.Material();
            const contactMat = new CANNON.ContactMaterial(wallMaterial, gemPhysMat, { friction: 0.2, restitution: 0.05 });
            const gemGemMat = new CANNON.ContactMaterial(gemPhysMat, gemPhysMat, { friction: 0.1, restitution: 0.05 });
            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);
            updateCameraPosition();
            
            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, 4, 0); 
            controls.enablePan = false; 
            controls.enabled = true; 

            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();
            
            window.addEventListener('resize', onWindowResize);
            
            renderer.domElement.addEventListener('pointerdown', onPointerDown);
            // pointermoveは視点操作以外には使わないため、スワップ用には不要
            renderer.domElement.addEventListener('pointerup', onPointerUp);
            renderer.domElement.addEventListener('pointercancel', onPointerUp);
            
            renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
            
            window.addEventListener('keydown', (e) => {
                if(e.code === 'Escape') togglePause();
            });

            startGame();
            requestAnimationFrame(animate);
        }

        function updateCameraPosition() {
            const aspect = window.innerWidth / window.innerHeight;
            if (aspect < 1.0) {
                camera.position.set(0, 30, 45);
            } else {
                camera.position.set(0, 20, 30);
            }
            camera.lookAt(0, 4, 0);
        }

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

        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); 
            baseGravity *= 1.5; 
            world.gravity.set(0, baseGravity, 0);
            
            clearAllGems();
            document.getElementById('stage-clear-screen').style.display = 'none';
            updateStatusDisplay();
            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;

            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); 
            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: 150, 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 createFirework(x, y, z, color) {
            const particleCount = 20; 
            const geo = new THREE.BufferGeometry();
            const positions = new Float32Array(particleCount * 3);
            const velocities = [];

            for(let i=0; i<particleCount; i++) {
                positions[i*3] = x; 
                positions[i*3+1] = y; 
                positions[i*3+2] = z;
                const theta = Math.random() * Math.PI * 2;
                const phi = Math.acos(2 * Math.random() - 1);
                const speed = 15 + Math.random() * 10;
                velocities.push({ 
                    x: Math.sin(phi) * Math.cos(theta) * speed, 
                    y: Math.sin(phi) * Math.sin(theta) * speed, 
                    z: Math.cos(phi) * speed 
                });
            }
            geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
            const mat = new THREE.PointsMaterial({ 
                color: color, 
                size: 0.3, 
                transparent: true, 
                blending: THREE.AdditiveBlending, 
                depthWrite: false, 
                sizeAttenuation: true 
            });
            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 = Math.min(Math.max((vec.x * 0.5 + 0.5) * window.innerWidth, 20), window.innerWidth - 80);
            const y = Math.min(Math.max((-(vec.y * 0.5) + 0.5) * window.innerHeight, 50), window.innerHeight - 50);

            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 swapGems(gemA, gemB) {
            if (isSwapping) return;
            
            const dist = gemA.body.position.distanceTo(gemB.body.position);
            if (dist > GEM_RADIUS * 4.0) return; 

            isSwapping = true;
            lastActionTime = Date.now();

            gemA.body.type = CANNON.Body.KINEMATIC;
            gemB.body.type = CANNON.Body.KINEMATIC;
            
            gemA.body.velocity.set(0, 0, 0);
            gemA.body.angularVelocity.set(0, 0, 0);
            gemB.body.velocity.set(0, 0, 0);
            gemB.body.angularVelocity.set(0, 0, 0);

            const startPosA = gemA.body.position.clone();
            const startPosB = gemB.body.position.clone();
            
            let progress = 0;
            const speed = 0.15; 

            function animateSwap() {
                progress += speed;
                if (progress > 1.0) progress = 1.0;

                const currentPosA = new CANNON.Vec3();
                const currentPosB = new CANNON.Vec3();
                
                currentPosA.copy(startPosA).scale(1 - progress).vadd(startPosB.clone().scale(progress), currentPosA);
                currentPosB.copy(startPosB).scale(1 - progress).vadd(startPosA.clone().scale(progress), currentPosB);

                gemA.body.position.copy(currentPosA);
                gemB.body.position.copy(currentPosB);
                
                gemA.mesh.position.copy(gemA.body.position);
                gemB.mesh.position.copy(gemB.body.position);

                if (progress < 1.0) {
                    requestAnimationFrame(animateSwap);
                } else {
                    gemA.body.type = CANNON.Body.DYNAMIC;
                    gemB.body.type = CANNON.Body.DYNAMIC;
                    
                    gemA.body.wakeUp();
                    gemB.body.wakeUp();
                    
                    isSwapping = false;

                    // 入れ替え後のマッチ判定(ペナルティなし)
                    tryMatch(gemA, true);
                    tryMatch(gemB, true);
                }
            }
            animateSwap();
        }

        function onPointerDown(event) {
            if (isGameOver || isPaused || isSwapping) return;
            
            touchStartTime = Date.now();
            touchStartPos.set(event.clientX, event.clientY);
            lastActionTime = Date.now();
            
            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 gem = gems.find(g => g.mesh === intersects[0].object);
                if (gem) {
                    dragStartGem = gem;
                    controls.enabled = false; // スワップ操作中はカメラを動かさない
                }
            } else {
                dragStartGem = null;
            }
        }

        function onPointerUp(event) {
            if (isGameOver || isPaused) return;
            lastActionTime = Date.now();
            
            if (dragStartGem) {
                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));
                
                let dragEndGem = null;
                if (intersects.length > 0) {
                    dragEndGem = gems.find(g => g.mesh === intersects[0].object);
                }

                // ドロップ先が別の宝石ならスワップ
                if (dragEndGem && dragEndGem !== dragStartGem) {
                     swapGems(dragStartGem, dragEndGem);
                } 
                // タップ判定(移動距離が短く、時間も短い場合)
                else if (Date.now() - touchStartTime < 300) {
                     const dist = touchStartPos.distanceTo(new THREE.Vector2(event.clientX, event.clientY));
                     if (dist < 20) {
                         // タップによる破壊(ペナルティあり)
                         tryMatch(dragStartGem, false);
                     }
                }

                dragStartGem = null;
                controls.enabled = true; 
            }
        }

        function checkGameOver() {
            // 操作中および直後は落下死無効
            const isInvincible = isSwapping || (Date.now() - lastActionTime < 1000);

            const outOfBounds = [];
            for (let i = gems.length - 1; i >= 0; i--) {
                const gem = gems[i];
                const x = Math.abs(gem.body.position.x);
                const z = Math.abs(gem.body.position.z);
                const y = gem.body.position.y;
                
                // 完全落下判定
                if (y < -3.0) {
                    outOfBounds.push({ gem, type: 'bottom' });
                }
                // 箱外への飛び出し判定
                else if (y > 0 && (x > BOARD_WIDTH/2 || z > BOARD_WIDTH/2)) {
                    outOfBounds.push({ gem, type: 'overflow' });
                }
            }

            outOfBounds.forEach(({ gem, type }) => {
                const x = Math.abs(gem.body.position.x);
                const z = Math.abs(gem.body.position.z);
                
                if (type === 'bottom') {
                    // ボックス内部範囲での底抜けかつ無敵時間中は救済
                    if (x < 5.5 && z < 5.5 && isInvincible) {
                        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();
                    }
                } else if (type === 'overflow') {
                    document.getElementById('danger-warning').style.display = 'block';
                    gameOver();
                }
            });
            
            if (outOfBounds.length === 0) {
                document.getElementById('danger-warning').style.display = 'none';
            }
        }

        function animate(time) {
            requestAnimationFrame(animate);
            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(); }
                updateStatusDisplay();
                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 => {
                // 底抜け防止強化(位置強制補正)
                if (gem.body.position.y < GEM_RADIUS) {
                    gem.body.position.y = GEM_RADIUS;
                    if (gem.body.velocity.y < 0) {
                        gem.body.velocity.y = 0;
                    }
                }
                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, suppressPenalty = false) {
            if (!gems.includes(startGem)) return;

            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) {
                const baseScore = 30;
                const extraCount = Math.max(0, matchGroup.length - 3);
                const points = baseScore + (30 * extraCount);
                
                score += points;
                updateStatusDisplay();
                
                showFloatingScore(points, startGem.mesh.position);
                showComboDisplay(matchGroup.length);

                for (let i = 0; i < matchGroup.length; i++) {
                    setTimeout(() => {
                        const x = (Math.random() - 0.5) * BOARD_WIDTH * 1.5;
                        const y = 10 + Math.random() * 10;
                        const z = (Math.random() - 0.5) * BOARD_WIDTH * 1.5;
                        const fireworkColor = GEM_TYPES[Math.floor(Math.random() * GEM_TYPES.length)].color;
                        createFirework(x, y, z, fireworkColor);
                    }, i * 100);
                }

                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 {
                if (!suppressPenalty) {
                    playSound('error');
                    penaltyCount += 10; 
                }
            }
        }

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

        init();
    </script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
買うたび 抽選 ※条件・上限あり \note クリエイター感謝祭ポイントバックキャンペーン/最大全額もどってくる! 12.1 月〜1.14 水 まで
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
3Dパズルゲーム「GemPhysics3D」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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