物理演算3マッチゲーム


画像
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>3D Physics Gem Puzzle</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #111; font-family: 'Segoe UI', sans-serif; }
        #ui { position: absolute; top: 20px; left: 20px; color: white; pointer-events: none; z-index: 10; text-shadow: 0 0 5px #000; }
        h1 { margin: 0; font-size: 24px; color: #FFD700; }
        p { margin: 5px 0; font-size: 14px; color: #ccc; }
        #score-box { font-size: 32px; font-weight: bold; margin-top: 10px; }
        #combo-msg { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 40px; color: #fff; font-weight: bold; opacity: 0; transition: opacity 0.5s; pointer-events: none; text-shadow: 0 0 20px #FF00FF; white-space: nowrap; }
        
        /* 読み込み中の表示 */
        #loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-size: 20px; }
    </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 3D Engine...</div>
    <div id="ui">
        <h1>Gem Physics 3D</h1>
        <p>左クリック: 同色を消す (3つ以上)</p>
        <p>右クリック: 宝石をかき混ぜる (回転)</p>
        <div id="score-box">SCORE: <span id="score">0</span></div>
    </div>
    <div id="combo-msg">COMBO!</div>

    <script type="module">
        import * as THREE from 'three';
        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 = 16; // 盤面の高さ(壁)
        const GEM_COUNT = 100; // 宝石の数
        const COLORS = [0xFF0055, 0x00FF99, 0x00CCFF, 0xFFFF00, 0xAA00FF]; // 5色
        const ROTATE_RADIUS = 3.5; // 右クリックで回転させる範囲

        let score = 0;
        let gems = []; // { mesh, body, colorIdx, id }
        let world, scene, camera, renderer, composer;
        let raycaster, mouse;

        // --- 初期化 ---
        function init() {
            document.getElementById('loading').style.display = 'none';

            // 1. 物理ワールド (Cannon.js)
            world = new CANNON.World();
            world.gravity.set(0, -20, 0); // 重力を強めに設定して落下をキビキビさせる
            world.broadphase = new CANNON.NaiveBroadphase();
            world.solver.iterations = 10;

            // マテリアル(滑り具合と跳ね返り)
            const wallMaterial = new CANNON.Material();
            const gemMaterial = new CANNON.Material();
            const contactMat = new CANNON.ContactMaterial(wallMaterial, gemMaterial, {
                friction: 0.1, // よく滑るように
                restitution: 0.3 // 少し跳ねる
            });
            world.addContactMaterial(contactMat);

            // 2. 描画シーン (Three.js)
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x111122);
            scene.fog = new THREE.FogExp2(0x111122, 0.02);

            camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
            camera.position.set(0, 20, 25);
            camera.lookAt(0, 5, 0);

            // ライト
            const ambientLight = new THREE.AmbientLight(0x404040);
            scene.add(ambientLight);
            
            const dirLight = new THREE.DirectionalLight(0xffffff, 2);
            dirLight.position.set(10, 20, 10);
            dirLight.castShadow = true;
            scene.add(dirLight);

            const pointLight = new THREE.PointLight(0xffffff, 5, 50);
            pointLight.position.set(0, 10, 5);
            scene.add(pointLight);

            renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.shadowMap.enabled = true;
            document.body.appendChild(renderer.domElement);

            // ポストプロセス(Bloom効果で宝石を光らせる)
            const renderScene = new RenderPass(scene, camera);
            const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
            bloomPass.threshold = 0.2;
            bloomPass.strength = 0.8;
            bloomPass.radius = 0.5;
            composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);

            // 3. 壁と床の作成
            createContainer(wallMaterial);

            // 4. 宝石の生成
            for (let i = 0; i < GEM_COUNT; i++) {
                setTimeout(() => spawnGem(gemMaterial), i * 50); // 少しずつ降らせる
            }

            // 5. 入力設定
            raycaster = new THREE.Raycaster();
            mouse = new THREE.Vector2();
            window.addEventListener('resize', onWindowResize);
            window.addEventListener('mousedown', onMouseDown);
            // 右クリックメニュー禁止
            window.addEventListener('contextmenu', e => e.preventDefault());

            animate();
        }

        // --- オブジェクト生成関数 ---

        function createContainer(material) {
            // 床
            const floorShape = new CANNON.Box(new CANNON.Vec3(BOARD_WIDTH/2, 1, BOARD_WIDTH/2));
            const floorBody = new CANNON.Body({ mass: 0, material: material });
            floorBody.addShape(floorShape);
            floorBody.position.set(0, -1, 0);
            world.addBody(floorBody);

            const floorMesh = new THREE.Mesh(
                new THREE.BoxGeometry(BOARD_WIDTH, 2, BOARD_WIDTH),
                new THREE.MeshStandardMaterial({ color: 0x333344, roughness: 0.8 })
            );
            floorMesh.position.copy(floorBody.position);
            floorMesh.receiveShadow = true;
            scene.add(floorMesh);

            // 透明な壁(前左右奥)
            const wallThickness = 1;
            const h = BOARD_HEIGHT;
            const positions = [
                { x: 0, z: -BOARD_WIDTH/2 - wallThickness/2, w: BOARD_WIDTH + wallThickness*2, d: wallThickness }, // 奥
                { x: 0, z: BOARD_WIDTH/2 + wallThickness/2, w: BOARD_WIDTH + wallThickness*2, d: wallThickness }, // 手前
                { x: -BOARD_WIDTH/2 - wallThickness/2, z: 0, w: wallThickness, d: BOARD_WIDTH }, // 左
                { x: BOARD_WIDTH/2 + wallThickness/2, z: 0, w: wallThickness, d: BOARD_WIDTH }   // 右
            ];

            positions.forEach(pos => {
                const shape = new CANNON.Box(new CANNON.Vec3(pos.w/2, h/2, pos.d/2));
                const body = new CANNON.Body({ mass: 0, material: material });
                body.addShape(shape);
                body.position.set(pos.x, h/2, pos.z);
                world.addBody(body);

                // 壁の見た目(ワイヤーフレームでガラスっぽく)
                const mesh = new THREE.Mesh(
                    new THREE.BoxGeometry(pos.w, h, pos.d),
                    new THREE.MeshPhysicalMaterial({ 
                        color: 0x88ccff, 
                        transmission: 0.9, 
                        opacity: 0.3, 
                        transparent: true,
                        roughness: 0.1
                    })
                );
                mesh.position.copy(body.position);
                scene.add(mesh);
            });
        }

        function spawnGem(material) {
            const colorIdx = Math.floor(Math.random() * COLORS.length);
            const color = COLORS[colorIdx];
            
            // 物理ボディ (球体近似)
            const shape = new CANNON.Sphere(GEM_RADIUS);
            const body = new CANNON.Body({ mass: 5, material: material });
            body.addShape(shape);
            // ランダムな位置から落とす
            body.position.set(
                (Math.random() - 0.5) * (BOARD_WIDTH - 2),
                BOARD_HEIGHT + Math.random() * 5,
                (Math.random() - 0.5) * (BOARD_WIDTH - 2)
            );
            body.linearDamping = 0.5; // 空気抵抗
            body.angularDamping = 0.5;
            world.addBody(body);

            // 見た目 (20面体で宝石っぽく)
            const geometry = new THREE.IcosahedronGeometry(GEM_RADIUS, 0);
            const mesh = new THREE.Mesh(geometry, new THREE.MeshPhysicalMaterial({
                color: color,
                metalness: 0.1,
                roughness: 0.1,
                transmission: 0.2, // 半透明
                thickness: 1.0,
                emissive: color,
                emissiveIntensity: 0.2
            }));
            mesh.castShadow = true;
            mesh.receiveShadow = true;
            scene.add(mesh);

            gems.push({ mesh, body, colorIdx, id: body.id });
        }

        // --- ゲームロジック ---

        function onMouseDown(event) {
            event.preventDefault();
            
            // マウス座標を正規化
            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 targetMesh = intersects[0].object;
                const targetGem = gems.find(g => g.mesh === targetMesh);

                if (event.button === 2) {
                    // 右クリック: 回転アクション
                    rotateArea(targetGem.body.position);
                } else {
                    // 左クリック: 消去アクション
                    tryMatch(targetGem);
                }
            }
        }

        // 3D空間でのマッチング判定(距離ベース)
        function tryMatch(startGem) {
            const matchGroup = [startGem];
            const checkQueue = [startGem];
            const checkedIds = new Set([startGem.id]);
            const MAX_DIST = GEM_RADIUS * 2.5; // 接触判定距離

            while(checkQueue.length > 0) {
                const current = checkQueue.shift();
                
                // 全宝石との距離をチェック(最適化のため本来は空間分割が必要だが、100個程度なら全探索でもOK)
                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 < MAX_DIST) {
                        matchGroup.push(other);
                        checkQueue.push(other);
                        checkedIds.add(other.id);
                    }
                }
            }

            if (matchGroup.length >= 3) {
                removeGems(matchGroup);
            }
        }

        function removeGems(group) {
            // スコア計算 (数×数×100 = 指数関数的に増える)
            const points = group.length * group.length * 100;
            score += points;
            updateUI(points, group.length);

            // エフェクト & 削除
            group.forEach(gem => {
                // パーティクル的なものを出したかったが、シンプルに縮小して消す
                scene.remove(gem.mesh);
                world.removeBody(gem.body);
                
                // 配列から削除
                const idx = gems.indexOf(gem);
                if (idx > -1) gems.splice(idx, 1);
            });

            // 減った分を補充
            setTimeout(() => {
                for(let i=0; i<group.length; i++) {
                    spawnGem(gems[0]?.body.material || new CANNON.Material());
                }
            }, 500);
        }

        // 指定座標を中心に宝石を回転させる(竜巻のように)
        function rotateArea(centerPos) {
            gems.forEach(gem => {
                const pos = gem.body.position;
                const dist = Math.sqrt(Math.pow(pos.x - centerPos.x, 2) + Math.pow(pos.z - centerPos.z, 2));

                if (dist < ROTATE_RADIUS) {
                    // 中心へ向かう力 + 回転する力 を加える
                    // 物理演算ボディに速度を与えることで、ガラガラと動かす
                    
                    // 上方向への突き上げ(少し浮かせる)
                    gem.body.velocity.y += 5;

                    // 回転方向ベクトル (接線)
                    const angle = Math.atan2(pos.z - centerPos.z, pos.x - centerPos.x);
                    const force = 8;
                    gem.body.velocity.x += -Math.sin(angle) * force;
                    gem.body.velocity.z += Math.cos(angle) * force;

                    // 中心に少し引き寄せる(散らばりすぎ防止)
                    gem.body.velocity.x += (centerPos.x - pos.x) * 1;
                    gem.body.velocity.z += (centerPos.z - pos.z) * 1;

                    // ランダムな回転トルクも与える
                    gem.body.angularVelocity.set(Math.random()*10, Math.random()*10, Math.random()*10);
                }
            });
        }

        function updateUI(addedScore, count) {
            const scoreEl = document.getElementById('score');
            // カウントアップアニメーション
            let current = parseInt(scoreEl.innerText);
            const step = Math.ceil((score - current) / 10);
            const timer = setInterval(() => {
                current += step;
                if (current >= score) {
                    current = score;
                    clearInterval(timer);
                }
                scoreEl.innerText = current;
            }, 30);

            // コンボ表示
            if (count > 0) {
                const msg = document.getElementById('combo-msg');
                msg.innerText = count + " CHAIN!\n+" + addedScore;
                msg.style.opacity = 1;
                msg.style.transform = "translate(-50%, -50%) scale(1.5)";
                setTimeout(() => {
                    msg.style.opacity = 0;
                    msg.style.transform = "translate(-50%, -50%) scale(1)";
                }, 1000);
            }
        }

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

        function animate() {
            requestAnimationFrame(animate);

            // 物理エンジンの更新
            world.step(1 / 60);

            // Three.jsのメッシュ位置を物理ボディに同期
            gems.forEach(gem => {
                gem.mesh.position.copy(gem.body.position);
                gem.mesh.quaternion.copy(gem.body.quaternion);
                
                // 落下しすぎた場合の復帰処理(念の為)
                if (gem.body.position.y < -10) {
                    gem.body.position.set(0, 20, 0);
                    gem.body.velocity.set(0,0,0);
                }
            });

            // レンダリング(Bloom適用)
            composer.render();
        }

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

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

コメント

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

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1