物理演算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>Gem Physics 3D - Fixed</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #050510; 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; }
        .stat-box { font-size: 20px; margin-top: 10px; font-weight: bold; }
        #speed-indicator { color: #00FF00; }
        
        #game-over { 
            position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); 
            font-size: 80px; color: #FF0000; font-weight: 900; 
            text-shadow: 0 0 30px #FF0000; 
            display: none; pointer-events: auto; z-index: 100;
            text-align: center;
        }
        #restart-btn {
            display: block; margin: 20px auto; padding: 10px 30px;
            font-size: 24px; background: #333; color: white; border: 2px solid white; cursor: pointer;
        }
        #loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; }
    </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 Engine...</div>
    <div id="ui">
        <h1>Gem Physics 3D</h1>
        <p>左クリック: 消す / 右クリック: 混ぜる</p>
        <p>[+]キー: 加速 / [-]キー: 減速</p>
        <div class="stat-box">SCORE: <span id="score">0</span></div>
        <div class="stat-box">SPEED: x<span id="speed-val">1.0</span></div>
    </div>
    
    <div id="game-over">
        GAME OVER
        <button id="restart-btn" onclick="location.reload()">RETRY</button>
    </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 GAME_OVER_HEIGHT = 12; 
        const GEM_SPAWN_Y = 20;
        const COLORS = [0xFF0055, 0x00FF99, 0x00CCFF, 0xFFFF00, 0xAA00FF]; 
        const MATCH_DIST = GEM_RADIUS * 3.5; 

        let score = 0;
        let timeScale = 1.0;
        let isGameOver = false;
        let gems = []; 
        let world, scene, camera, renderer, composer;
        let raycaster, mouse;
        let gameOverLineMesh;
        let gameStartTime = 0; // ゲーム開始時刻

        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        
        function playKiranSound() {
            if (audioCtx.state === 'suspended') audioCtx.resume();
            const t = audioCtx.currentTime;
            const osc = audioCtx.createOscillator();
            const gain = audioCtx.createGain();
            osc.type = 'sine'; 
            osc.frequency.setValueAtTime(2000, t); 
            osc.frequency.exponentialRampToValueAtTime(3000, t + 0.1); 
            gain.gain.setValueAtTime(0.05, t); 
            gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15); 
            osc.connect(gain);
            gain.connect(audioCtx.destination);
            osc.start(t);
            osc.stop(t + 0.15);
        }

        function playKyuanSound(delayIndex) {
            if (audioCtx.state === 'suspended') audioCtx.resume();
            const t = audioCtx.currentTime + (delayIndex * 0.15); 
            const osc = audioCtx.createOscillator();
            const gain = audioCtx.createGain();
            osc.type = 'triangle'; 
            osc.frequency.setValueAtTime(400, t); 
            osc.frequency.linearRampToValueAtTime(800, t + 0.3);
            gain.gain.setValueAtTime(0, t);
            gain.gain.linearRampToValueAtTime(0.1, t + 0.1);
            gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
            osc.connect(gain);
            gain.connect(audioCtx.destination);
            osc.start(t);
            osc.stop(t + 0.5);
        }

        function init() {
            document.getElementById('loading').style.display = 'none';
            gameStartTime = Date.now(); // 開始時刻記録

            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.05, restitution: 0.3 });
            const gemGemMat = new CANNON.ContactMaterial(gemMaterial, gemMaterial, { friction: 0.1, restitution: 0.4 });
            world.addContactMaterial(contactMat);
            world.addContactMaterial(gemGemMat);

            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x050510);
            scene.fog = new THREE.FogExp2(0x050510, 0.02);

            camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
            camera.position.set(0, 18, 28);
            camera.lookAt(0, 6, 0);

            scene.add(new THREE.AmbientLight(0x404040));
            const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
            dirLight.position.set(10, 20, 10);
            dirLight.castShadow = true;
            scene.add(dirLight);
            scene.add(new THREE.PointLight(0xffffff, 3, 50).translateY(10));

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

            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.1; bloomPass.strength = 1.2; bloomPass.radius = 0.5;
            composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);

            createContainer(wallMaterial);
            createGameOverLine();

            // 初期ジェム生成
            for (let i = 0; i < 60; i++) {
                setTimeout(() => spawnGem(gemMaterial), i * 80);
            }

            raycaster = new THREE.Raycaster();
            mouse = new THREE.Vector2();
            
            window.addEventListener('resize', onWindowResize);
            window.addEventListener('mousedown', onMouseDown);
            window.addEventListener('contextmenu', e => e.preventDefault());
            window.addEventListener('keydown', (e) => {
                if (isGameOver) return;
                if (e.key === '+' || e.key === ';') { 
                    timeScale = Math.min(timeScale * 2, 8.0);
                } else if (e.key === '-' || e.key === '_') { 
                    timeScale = Math.max(timeScale / 2, 0.125);
                }
                document.getElementById('speed-val').innerText = timeScale.toFixed(1);
            });

            animate();
        }

        function createGameOverLine() {
            const geometry = new THREE.BoxGeometry(BOARD_WIDTH, 0.1, BOARD_WIDTH);
            const material = new THREE.MeshBasicMaterial({ 
                color: 0xFF0000, transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending, side: THREE.DoubleSide
            });
            gameOverLineMesh = new THREE.Mesh(geometry, material);
            gameOverLineMesh.position.set(0, GAME_OVER_HEIGHT, 0);
            scene.add(gameOverLineMesh);
            gameOverLineMesh.userData = { time: 0 };
        }

        function createContainer(material) {
            const floorBody = new CANNON.Body({ mass: 0, material: material });
            floorBody.addShape(new CANNON.Box(new CANNON.Vec3(BOARD_WIDTH/2, 1, BOARD_WIDTH/2)));
            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: 0x222233 }));
            floorMesh.position.copy(floorBody.position);
            scene.add(floorMesh);

            const h = BOARD_HEIGHT * 2;
            const walls = [
                { pos: [0, h/2, -BOARD_WIDTH/2-0.5], size: [BOARD_WIDTH+2, h, 1] },
                { pos: [0, h/2, BOARD_WIDTH/2+0.5], size: [BOARD_WIDTH+2, h, 1] },
                { pos: [-BOARD_WIDTH/2-0.5, h/2, 0], size: [1, h, BOARD_WIDTH] },
                { pos: [BOARD_WIDTH/2+0.5, h/2, 0], size: [1, h, BOARD_WIDTH] }
            ];
            walls.forEach(w => {
                const body = new CANNON.Body({ mass: 0, material: material });
                body.addShape(new CANNON.Box(new CANNON.Vec3(w.size[0]/2, w.size[1]/2, w.size[2]/2)));
                body.position.set(...w.pos);
                world.addBody(body);
            });
        }

        function spawnGem(material) {
            if (isGameOver) return;

            const colorIdx = Math.floor(Math.random() * COLORS.length);
            const color = COLORS[colorIdx];
            
            const body = new CANNON.Body({ mass: 10, material: material });
            body.addShape(new CANNON.Sphere(GEM_RADIUS));
            body.position.set(
                (Math.random() - 0.5) * (BOARD_WIDTH - 3),
                GEM_SPAWN_Y + Math.random() * 5,
                (Math.random() - 0.5) * (BOARD_WIDTH - 3)
            );
            body.linearDamping = 0.3; body.angularDamping = 0.3;
            world.addBody(body);

            body.addEventListener("collide", (e) => {
                const contactVelocity = e.contact.getImpactVelocityAlongNormal();
                if (Math.abs(contactVelocity) > 2.0) {
                    const otherGem = gems.find(g => g.body === e.body);
                    if (otherGem && otherGem.colorIdx === colorIdx) {
                        playKiranSound();
                    }
                }
            });

            const geometry = new THREE.IcosahedronGeometry(GEM_RADIUS, 1);
            const mesh = new THREE.Mesh(geometry, new THREE.MeshPhysicalMaterial({
                color: color, metalness: 0.1, roughness: 0.1, transmission: 0.4, thickness: 1.5, emissive: color, emissiveIntensity: 0.4
            }));
            mesh.castShadow = true; mesh.receiveShadow = true;
            scene.add(mesh);

            // spawnTimeを記録して、出現直後の判定回避に使う
            gems.push({ mesh, body, colorIdx, id: body.id, spawnTime: Date.now() });
        }

        function checkGameOver() {
            if (isGameOver) return;
            
            const now = Date.now();
            // 開始10秒間は判定しない(初期配置中の保護)
            if (now - gameStartTime < 10000) return;

            // 1. 高さオーバー
            // 2. 静止している
            // 3. 生成から3秒以上経過している(落下中の誤判定防止)
            const dangerousGems = gems.filter(g => 
                g.body.position.y > GAME_OVER_HEIGHT && 
                g.body.velocity.length() < 0.5 &&
                (now - g.spawnTime > 3000)
            );

            if (dangerousGems.length > 0) {
                triggerGameOver();
            }
        }

        function triggerGameOver() {
            isGameOver = true;
            document.getElementById('game-over').style.display = 'block';
            document.getElementById('ui').style.opacity = 0.3;
            timeScale = 0.1; 
        }

        function onMouseDown(event) {
            if (isGameOver) return;
            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 targetGem = gems.find(g => g.mesh === intersects[0].object);
                if (event.button === 2) {
                    rotateArea(targetGem.body.position);
                } else {
                    tryMatch(targetGem);
                }
            }
        }

        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) {
                removeGems(matchGroup);
            }
        }

        function removeGems(group) {
            const points = group.length * group.length * 100;
            score += points;
            document.getElementById('score').innerText = score;
            group.forEach((_, index) => { playKyuanSound(index); });
            group.forEach(gem => {
                scene.remove(gem.mesh);
                world.removeBody(gem.body);
                const idx = gems.indexOf(gem);
                if (idx > -1) gems.splice(idx, 1);
            });
            if (!isGameOver) {
                setTimeout(() => {
                    for(let i=0; i<group.length; i++) {
                        setTimeout(() => spawnGem(gems[0]?.body.material || new CANNON.Material()), i * 50);
                    }
                }, 500);
            }
        }

        function rotateArea(centerPos) {
            const RADIUS = 4.0;
            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 < RADIUS) {
                    gem.body.velocity.y += 8; 
                    const angle = Math.atan2(pos.z - centerPos.z, pos.x - centerPos.x);
                    const force = 10;
                    gem.body.velocity.x += -Math.sin(angle) * force;
                    gem.body.velocity.z += Math.cos(angle) * force;
                    gem.body.angularVelocity.set(Math.random()*10, Math.random()*10, Math.random()*10);
                }
            });
        }

        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);
            const dt = 1 / 60 * timeScale;
            world.step(dt);
            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, GEM_SPAWN_Y, 0);
                    gem.body.velocity.set(0,0,0);
                }
            });
            if (gameOverLineMesh) {
                gameOverLineMesh.userData.time += 0.05;
                gameOverLineMesh.material.opacity = 0.2 + Math.sin(gameOverLineMesh.userData.time) * 0.1;
            }
            checkGameOver();
            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