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; }
        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 = [];

        // 操作時刻の記録(GAMEOVER判定用)
        let lastActionTime = 0;

        // 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.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); 
            
            // ステージ進行で重力を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: 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 = (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;
            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 (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.position.y < 1.5) {
                                     gem.body.velocity.y = Math.max(gem.body.velocity.y, -5);
                                 }
                                 // 上方向へ跳ねる力を抑制
                                 if (gem.body.velocity.y > 0) {
                                     gem.body.velocity.y = 0;
                                 }
                             }
                        }
                    }
                });
            }
        }

        function onPointerUp(event) {
            if (isGameOver || isPaused) return;
            lastActionTime = Date.now(); // 操作終了を記録
            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() {
            // 落下判定の改良ロジック
            // 「操作中」または「操作直後(1秒以内)」で箱内部からの底抜けのみ救済
            const isInvincible = dragMode || (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;
                
                // 底抜け判定(y < -3.0)
                if (y < -3.0) {
                    outOfBounds.push({ gem, type: 'bottom' });
                }
                // 箱から外に溢れ出た判定(箱の外側でy > 0)
                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') {
                    // ボックス内部範囲(5.5)での底抜けかつ無敵時間中のみ救済
                    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 {
                        // 箱から外に飛び出た、または放置中の落下はGAMEOVER
                        document.getElementById('danger-warning').style.display = 'block';
                        gameOver();
                    }
                } else if (type === 'overflow') {
                    // 箱から溢れ出た場合は即GAMEOVER
                    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 => {
                // 底抜け防止: 底面より下に行った宝石を上に戻す
                if (gem.body.position.y < 0.0) {
                    gem.body.position.y = 0.0;
                    gem.body.velocity.y = Math.max(0, gem.body.velocity.y);
                }
                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);

                // 連鎖数に応じて花火を表示
                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 {
                playSound('error');
                penaltyCount += 10; // ペナルティとして10個の宝石を追加
            }
        }

        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; }
        
        #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;
        
        // 重力を標準の8倍相当(-800)に設定
        let baseGravity = -800; 

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

        let lastFrameTime = 0;

        // タッチ操作用変数
        let dragMode = false;
        let dragPivotGem = null;
        let pointerStart = new THREE.Vector2();
        let pointerCurrent = new THREE.Vector2();
        let pointerPrev = new THREE.Vector2();
        let isTouchDown = false;
        let touchStartTime = 0;
        let squeezeSoundCooldown = 0;
        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);
            } 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 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 = 10; 
            world.solver.tolerance = 0.01;

            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);
            renderer.domElement.addEventListener('pointermove', onPointerMove);
            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); 
            
            // ステージ進行で重力を1.5倍にする
            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 onPointerDown(event) {
            if (isGameOver || isPaused) return;
            isTouchDown = true;
            
            const clientX = event.clientX;
            const clientY = event.clientY;
            
            pointerStart.set(clientX, clientY);
            pointerPrev.set(clientX, clientY);
            touchStartTime = Date.now();
            lastActionTime = Date.now(); // 操作開始
            dragMode = false;
            dragPivotGem = null;

            mouse.x = (clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(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) {
                    dragPivotGem = gem;
                    gem.body.type = CANNON.Body.KINEMATIC;
                    gem.body.velocity.set(0,0,0);
                    gem.body.angularVelocity.set(0,0,0);
                }
            }
            
            if (dragPivotGem) {
                controls.enabled = false;
            }
        }

        function onPointerMove(event) {
            if (!isTouchDown || isGameOver || isPaused) return;
            
            lastActionTime = Date.now(); // 操作中も更新
            const clientX = event.clientX;
            const clientY = event.clientY;
            pointerCurrent.set(clientX, clientY);
            
            const dist = pointerCurrent.distanceTo(pointerStart);
            
            if (dist > 10 && dragPivotGem) {
                dragMode = true;
            }

            if (dragMode && dragPivotGem) {
                const deltaX = clientX - pointerPrev.x;
                const deltaY = clientY - pointerPrev.y;
                pointerPrev.set(clientX, clientY);
                
                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 d = gem.body.position.distanceTo(pivotPos);
                    if (d < 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 d = Math.sqrt(dx*dx + dz*dz);
                    
                    if (d < 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 = (Math.abs(deltaX) > Math.abs(deltaY)) ? (deltaX > 0 ? -1 : 1) : (deltaY > 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.position.y < 1.5) {
                                     gem.body.velocity.y = Math.max(gem.body.velocity.y, -5);
                                 }
                                 if (gem.body.velocity.y > 0) gem.body.velocity.y = 0;
                             }
                        }
                    }
                });
            }
        }

        function onPointerUp(event) {
            if (!isTouchDown) return;
            isTouchDown = false;
            lastActionTime = Date.now(); // 操作終了
            
            if (dragMode) {
                if (dragPivotGem) {
                    dragPivotGem.body.type = CANNON.Body.DYNAMIC;
                    dragPivotGem.body.wakeUp();
                }
            } else {
                if (Date.now() - touchStartTime < 300 && dragPivotGem) {
                    tryMatch(dragPivotGem);
                }
            }
            
            dragMode = false;
            dragPivotGem = null;
            controls.enabled = true; 
        }

        function checkGameOver() {
            // 落下判定の改良ロジック
            // 「操作中」または「操作直後(1秒以内)」で箱内部からの底抜けのみ救済
            const isInvincible = dragMode || isTouchDown || (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;
                
                // 底抜け判定(y < -3.0)
                if (y < -3.0) {
                    outOfBounds.push({ gem, type: 'bottom' });
                }
                // 箱から外に溢れ出た判定(箱の外側でy > 0)
                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') {
                    // ボックス内部範囲(5.5)での底抜けかつ無敵時間中のみ救済
                    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 {
                        // 箱から外に飛び出た、または放置中の落下はGAMEOVER
                        document.getElementById('danger-warning').style.display = 'block';
                        gameOver();
                    }
                } else if (type === 'overflow') {
                    // 箱から溢れ出た場合は即GAMEOVER
                    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 < 0.0) {
                    gem.body.position.y = 0.0;
                    gem.body.velocity.y = Math.max(0, gem.body.velocity.y);
                }
                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) {
                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 {
                playSound('error');
                penaltyCount += 10; // ペナルティとして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