分页: 1 / 1

將製作趣味遊戲「炸彈超人」

发表于 : 2026年 7月 26日 08:02
ejsoon
用three.js做一個遊戲「炸彈超人」:
一,遊戲的道具有:16根10*10*50的長方體木條,兩個邊長為30的正三角形薄木片,一個邊長為10的正六邊形薄木片。

在木條中有14根的三個側面(10*50的面)分別塗上楬色、深藍色和紫色,稱作「地板」。

另兩根木條分別在四個側面的中間寫上數字「0,1,2,3」(楬色)和「1,2,3,4」(藍色),稱作「數字木條」。

把正三角形朝向右放置,在它們的兩個面都從中心向右角畫一條線(將是人物朝向),再在其中一個面的左邊和中心之間的地方畫一條平行於左邊的線,該線的兩個端點與上邊和下邊的距離跟它離左邊的距離相等。線粗為2。其中一個三角形用楬色畫,另一個用深藍色畫。稱作「人物」。

正六邊形稱作「炸彈」,它的上下兩個邊應始終水平放置。

二,遊戲開始時的物件擺放

把14個長木條分成上下兩排,每排7個,頭尾相接(留有2的空隙),横著放至離視圖中心25的位置,木色(未塗色的面)朝上。

楬色人物將放在最左邊最上面的木條的正上方,朝向右,距離下方的木條15。藍色人物將放在最右邊最下面的木條的正下方,朝向左,距離上方的木條15。把只有一條線的面朝上。

正六邊形木片放在視圖中心。

三,遊戲環節

楬色方先行動,之後雙方輪流行動。

當楬色方行動時,視圖將把中心往上移,但仍保持藍色方在視圖內的最下方。同時屏幕中心將出現一個半徑為240的圓(不屬於3d物件,用svg實現),底色為綠色與黄色之間,半透明,沒有邊。當點擊至圓內,按住,再拖至圓外,則將在圓內到圓的邊界畫出一條線,這時圓形消失,並以這條線為方向拋出兩個數字木條。經過物理碰撞,將有兩個數字朝上。可能會出現其中一個數字木條斜靠在另一個數字木條上,但仍可得到其朝上的數字。之後視圖中心將移回來,楬方將按這兩個數字木條朝上的數字加起來的和行動。

當輪到藍方,視圖將把中心往下移,但仍保證楬方不超出視圖邊界。用相同的方式投擲數字木條並行動。

當一方行動時,它將朝向所指方向平行移動,每個地板上下方為一個棋位,則每方一共有七個棋位,當走到盡頭時將反轉方向。

具體遊戲規則將在以後提供。

四,鏡頭說明

手機端需要提示「横放以獲得最佳遊戲體驗」。

鏡頭角度只能在正上方俯視到水平之間,不能再到水平以下,不能平移,左右旋轉最多只能到90度,可以縮放。拋數字木條時出現的圓形不受縮放和視角影響。

代码: 全选

用three.js做一個遊戲「炸彈超人」:
一,遊戲的道具有:16根10*10*50的長方體木條,兩個邊長為30的正三角形薄木片,一個邊長為10的正六邊形薄木片。

在木條中有14根的三個側面(10*50的面)分別塗上楬色、深藍色和紫色,稱作「地板」。

另兩根木條分別在四個側面的中間寫上數字「0,1,2,3」(楬色)和「1,2,3,4」(藍色),稱作「數字木條」。

把正三角形朝向右放置,在它們的兩個面都從中心向右角畫一條線(將是人物朝向),再在其中一個面的左邊和中心之間的地方畫一條平行於左邊的線,該線的兩個端點與上邊和下邊的距離跟它離左邊的距離相等。線粗為2。其中一個三角形用楬色畫,另一個用深藍色畫。稱作「人物」。

正六邊形稱作「炸彈」,它的上下兩個邊應始終水平放置。

二,遊戲開始時的物件擺放

把14個長木條分成上下兩排,每排7個,頭尾相接(留有2的空隙),横著放至離視圖中心25的位置,木色(未塗色的面)朝上。

楬色人物將放在最左邊最上面的木條的正上方,朝向右,距離下方的木條15。藍色人物將放在最右邊最下面的木條的正下方,朝向左,距離上方的木條15。把只有一條線的面朝上。

正六邊形木片放在視圖中心。

三,遊戲環節

楬色方先行動,之後雙方輪流行動。

當楬色方行動時,視圖將把中心往上移,但仍保持藍色方在視圖內的最下方。同時屏幕中心將出現一個半徑為240的圓(不屬於3d物件,用svg實現),底色為綠色與黄色之間,半透明,沒有邊。當點擊至圓內,按住,再拖至圓外,則將在圓內到圓的邊界畫出一條線,這時圓形消失,並以這條線為方向拋出兩個數字木條。經過物理碰撞,將有兩個數字朝上。可能會出現其中一個數字木條斜靠在另一個數字木條上,但仍可得到其朝上的數字。之後視圖中心將移回來,楬方將按這兩個數字木條朝上的數字加起來的和行動。

當輪到藍方,視圖將把中心往下移,但仍保證楬方不超出視圖邊界。用相同的方式投擲數字木條並行動。

當一方行動時,它將朝向所指方向平行移動,每個地板上下方為一個棋位,則每方一共有七個棋位,當走到盡頭時將反轉方向。

具體遊戲規則將在以後提供。

四,鏡頭說明

手機端需要提示「横放以獲得最佳遊戲體驗」。

鏡頭角度只能在正上方俯視到水平之間,不能再到水平以下,不能平移,左右旋轉最多只能到90度,可以縮放。拋數字木條時出現的圓形不受縮放和視角影響。

Re: 將製作趣味遊戲「炸彈超人」

发表于 : 2026年 7月 26日 08:07
ejsoon
gemini 3.1 pro + thinking

代码: 全选

這是一個非常有趣且具備實體桌遊風格的 3D 遊戲設計!要用 Three.js 實作這個「炸彈超人」遊戲,我們需要結合 Three.js(負責 3D 渲染與鏡頭控制)、Cannon-es(負責擲骰子的物理碰撞),以及原生的 HTML/SVG/CSS 來處理 UI 互動與手機橫向提示。

以下為你提供一個完整的、可執行的單一 HTML 檔案基礎架構,包含了你所描述的場景佈置、材質生成、鏡頭限制、投擲用的 SVG 圓盤,以及物理引擎的整合。

### 遊戲核心程式碼

你可以將以下程式碼儲存為 `index.html`,並直接在現代瀏覽器中開啟(建議使用本地伺服器如 Live Server 執行以避免跨域載入限制):

```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>3D 炸彈超人 桌遊版</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #222; touch-action: none; }
        canvas { display: block; }
        
        /* 手機橫放提示 */
        #orientation-prompt {
            display: none;
            position: fixed;
            top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0, 0, 0, 0.9);
            color: white;
            font-size: 24px;
            font-family: sans-serif;
            align-items: center;
            justify-content: center;
            z-index: 9999;
            text-align: center;
        }
        @media screen and (orientation: portrait) {
            #orientation-prompt { display: flex; }
        }

        /* 投擲用 SVG 圓盤 */
        #throw-ui {
            position: absolute;
            top: 50%; left: 50%;
            transform: translate(-50%, -50%);
            width: 480px; height: 480px; /* 半徑 240 */
            pointer-events: none; /* 預設不可點擊,輪到回合時啟用 */
            display: none; /* 預設隱藏 */
            z-index: 10;
        }
        #throw-ui.active {
            pointer-events: auto;
            display: block;
        }
        #throw-circle {
            fill: rgba(173, 255, 47, 0.5); /* 綠色與黃色之間 (GreenYellow),半透明 */
        }
        #throw-line {
            stroke: white;
            stroke-width: 4;
            display: none;
        }
    </style>
</head>
<body>
    <div id="orientation-prompt">請將手機橫放以獲得最佳遊戲體驗</div>
    
    <svg id="throw-ui" viewBox="0 0 480 480">
        <circle id="throw-circle" cx="240" cy="240" r="240" />
        <line id="throw-line" x1="240" y1="240" x2="240" y2="240" />
    </svg>

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

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

        // ==========================================
        // 1. 基本設定 (Three.js + Cannon.js)
        // ==========================================
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x333333);

        const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.set(0, 200, 150);

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

        // 鏡頭限制
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enablePan = false; // 禁止平移
        controls.maxPolarAngle = Math.PI / 2; // 只能俯視到水平,不能到水平以下
        controls.minAzimuthAngle = -Math.PI / 2; // 左右旋轉最多 90 度
        controls.maxAzimuthAngle = Math.PI / 2;
        controls.enableDamping = true;

        // 物理引擎世界
        const world = new CANNON.World({
            gravity: new CANNON.Vec3(0, -982, 0), // 較大的重力讓木條感覺重一些
        });
        
        // 物理地面
        const groundBody = new CANNON.Body({
            type: CANNON.Body.STATIC,
            shape: new CANNON.Plane(),
        });
        groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
        world.addBody(groundBody);

        // 燈光
        const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
        scene.add(ambientLight);
        const dirLight = new THREE.DirectionalLight(0xffffff, 1);
        dirLight.position.set(50, 100, 50);
        dirLight.castShadow = true;
        scene.add(dirLight);

        // ==========================================
        // 2. 材質與貼圖生成 (Canvas Texture)
        // ==========================================
        const colorWood = 0xd2b48c; // 木色
        const colorBrown = 0x8b4513; // 楬色
        const colorDarkBlue = 0x00008b; // 深藍色
        const colorPurple = 0x800080; // 紫色

        const matWood = new THREE.MeshStandardMaterial({ color: colorWood });
        const matBrown = new THREE.MeshStandardMaterial({ color: colorBrown });
        const matDarkBlue = new THREE.MeshStandardMaterial({ color: colorDarkBlue });
        const matPurple = new THREE.MeshStandardMaterial({ color: colorPurple });

        // 地板木條材質陣列 (未塗色面朝上, 假設 Y 軸朝上)
        // BoxGeometry 面的順序: +x, -x, +y, -y, +z, -z
        const floorMaterials = [
            matWood, matWood,      // 左右端點
            matWood,               // 上面 (未塗色)
            matBrown,              // 下面
            matDarkBlue,           // 側面 1
            matPurple              // 側面 2
        ];

        // 創建帶有數字的木條材質
        function createDiceMaterial(color, numbers) {
            const mats = [matWood, matWood]; // 端點
            for(let i=0; i<4; i++){
                const canvas = document.createElement('canvas');
                canvas.width = 64; canvas.height = 256; // 比例 10x50
                const ctx = canvas.getContext('2d');
                ctx.fillStyle = color === 'brown' ? '#8b4513' : '#00008b';
                ctx.fillRect(0, 0, 64, 256);
                ctx.fillStyle = 'white';
                ctx.font = '40px Arial';
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                // 旋轉文字以符合長條方向
                ctx.save();
                ctx.translate(32, 128);
                ctx.rotate(-Math.PI / 2);
                ctx.fillText(numbers[i], 0, 0);
                ctx.restore();
                const tex = new THREE.CanvasTexture(canvas);
                mats.push(new THREE.MeshStandardMaterial({ map: tex }));
            }
            return mats; // +y, -y, +z, -z
        }
        
        const diceMatBrown = createDiceMaterial('brown', ['0', '1', '2', '3']);
        const diceMatBlue = createDiceMaterial('blue', ['1', '2', '3', '4']);

        // 創建人物三角形貼圖
        function createCharacterTexture(bgColorStr) {
            const canvas = document.createElement('canvas');
            canvas.width = 256; canvas.height = 256;
            const ctx = canvas.getContext('2d');
            ctx.fillStyle = bgColorStr;
            ctx.fillRect(0, 0, 256, 256);
            
            // 畫線 (中心到右角) - 對應 0度
            ctx.strokeStyle = 'white';
            ctx.lineWidth = 15;
            ctx.beginPath();
            ctx.moveTo(128, 128);
            ctx.lineTo(256, 128);
            ctx.stroke();

            // 畫平行於左邊的線 (用數學近似,因為只是貼圖示意)
            ctx.beginPath();
            ctx.moveTo(64, 64);
            ctx.lineTo(64, 192);
            ctx.stroke();

            return new THREE.CanvasTexture(canvas);
        }
        const charMatBrown = new THREE.MeshStandardMaterial({ map: createCharacterTexture('#8b4513') });
        const charMatBlue = new THREE.MeshStandardMaterial({ map: createCharacterTexture('#00008b') });

        // ==========================================
        // 3. 創建遊戲物件
        // ==========================================
        // 14 根地板 (10x10x50)
        const blockGeo = new THREE.BoxGeometry(50, 10, 10);
        const startX = -3 * 52; // 7根木條,每根長50+間隔2 = 52。中心為0。

        for (let row = 0; row < 2; row++) {
            const zPos = row === 0 ? -25 : 25;
            for (let col = 0; col < 7; col++) {
                const block = new THREE.Mesh(blockGeo, floorMaterials);
                block.position.set(startX + col * 52, 5, zPos); // 放在地上 Y=5 (高度10的一半)
                block.receiveShadow = true;
                block.castShadow = true;
                scene.add(block);
            }
        }

        // 人物 (邊長30的正三角形) - 使用 Cylinder 近似
        const radiusTri = 30 / Math.sqrt(3);
        const charGeo = new THREE.CylinderGeometry(radiusTri, radiusTri, 2, 3);
        
        const charBrown = new THREE.Mesh(charGeo, charMatBrown);
        charBrown.position.set(startX, 1, -25 - 15); // 上排最左上方
        charBrown.rotation.y = -Math.PI / 6; // 朝右
        scene.add(charBrown);

        const charBlue = new THREE.Mesh(charGeo, charMatBlue);
        charBlue.position.set(-startX, 1, 25 + 15); // 下排最右下方
        charBlue.rotation.y = Math.PI - Math.PI / 6; // 朝左
        scene.add(charBlue);

        // 炸彈 (邊長10的正六邊形)
        const bombGeo = new THREE.CylinderGeometry(10, 10, 2, 6);
        const bombMat = new THREE.MeshStandardMaterial({ color: 0x555555 });
        const bomb = new THREE.Mesh(bombGeo, bombMat);
        bomb.position.set(0, 1, 0); // 視圖中心
        // 確保上下兩邊水平
        bomb.rotation.y = Math.PI / 6; 
        scene.add(bomb);

        // 準備物理數字木條 (初始隱藏,投擲時生成)
        const diceMeshes = [];
        const diceBodies = [];

        function createDice(x, y, z, materialIndex) {
            const mesh = new THREE.Mesh(blockGeo, materialIndex === 0 ? diceMatBrown : diceMatBlue);
            mesh.castShadow = true;
            scene.add(mesh);
            diceMeshes.push(mesh);

            // 物理形狀 (Half extents)
            const shape = new CANNON.Box(new CANNON.Vec3(25, 5, 5));
            const body = new CANNON.Body({ mass: 5, shape: shape });
            body.position.set(x, y, z);
            world.addBody(body);
            diceBodies.push(body);
        }

        // ==========================================
        // 4. 遊戲流程與 SVG 投擲互動
        // ==========================================
        let currentTurn = 'BROWN';
        const throwUI = document.getElementById('throw-ui');
        const throwLine = document.getElementById('throw-line');
        let isDragging = false;
        let dragStart = { x: 240, y: 240 };

        // 觸發回合
        function startTurn() {
            throwUI.classList.add('active');
            
            // 鏡頭偏移邏輯 (簡化版:平滑移動 Target)
            const targetZ = currentTurn === 'BROWN' ? -20 : 20;
            controls.target.set(0, 0, targetZ);
            controls.update();
        }

        // SVG 拖曳邏輯
        throwUI.addEventListener('pointerdown', (e) => {
            const rect = throwUI.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            const dist = Math.hypot(x - 240, y - 240);
            if (dist <= 240) {
                isDragging = true;
                throwLine.style.display = 'block';
                throwLine.setAttribute('x2', x);
                throwLine.setAttribute('y2', y);
            }
        });

        window.addEventListener('pointermove', (e) => {
            if (!isDragging) return;
            const rect = throwUI.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            throwLine.setAttribute('x2', x);
            throwLine.setAttribute('y2', y);
        });

        window.addEventListener('pointerup', (e) => {
            if (!isDragging) return;
            isDragging = false;
            throwLine.style.display = 'none';

            const rect = throwUI.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            const dist = Math.hypot(x - 240, y - 240);

            // 拖曳至圓外時拋出
            if (dist > 240) {
                throwUI.classList.remove('active');
                
                // 計算方向向量
                const dx = x - 240;
                const dy = y - 240; // 螢幕 Y 向下,3D Z 向前
                const dir = new THREE.Vector3(dx, 0, dy).normalize();

                throwDice(dir);
            }
        });

        function throwDice(dir) {
            // 清理舊骰子
            diceMeshes.forEach(m => scene.remove(m));
            diceBodies.forEach(b => world.removeBody(b));
            diceMeshes.length = 0;
            diceBodies.length = 0;

            // 生成兩顆數字木條並施加衝量
            createDice(-10, 50, 0, 0); // 楬色
            createDice(10, 50, 0, 1);  // 藍色

            const force = 2000;
            diceBodies.forEach(body => {
                // 隨機初始旋轉
                body.quaternion.setFromEuler(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
                // 施加方向力與隨機扭矩
                body.applyImpulse(new CANNON.Vec3(dir.x * force, 500, dir.z * force), new CANNON.Vec3(0, 0, 0));
                body.applyTorque(new CANNON.Vec3(Math.random()*force, Math.random()*force, Math.random()*force));
            });

            // 模擬等待骰子停下後切換鏡頭與移動 (簡化,實際需監聽 velocity)
            setTimeout(() => {
                controls.target.set(0, 0, 0); // 視角移回
                controls.update();
                
                // TODO: 讀取朝上的數字並移動人物
                
                // 換下一回合
                currentTurn = currentTurn === 'BROWN' ? 'BLUE' : 'BROWN';
                setTimeout(startTurn, 2000);
            }, 3000);
        }

        // ==========================================
        // 5. 渲染迴圈
        // ==========================================
        window.addEventListener('resize', onWindowResize, false);
        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        }

        function animate() {
            requestAnimationFrame(animate);

            // 更新物理引擎
            world.step(1 / 60);

            // 同步物理數據到 3D 網格
            for (let i = 0; i < diceMeshes.length; i++) {
                diceMeshes[i].position.copy(diceBodies[i].position);
                diceMeshes[i].quaternion.copy(diceBodies[i].quaternion);
            }

            controls.update();
            renderer.render(scene, camera);
        }

        // 啟動遊戲
        startTurn();
        animate();
    </script>
</body>
</html>

```

### 程式碼與設計說明:

1. **道具與佈置還原:**
* **木條地板:** 使用 `THREE.BoxGeometry(50, 10, 10)` 產生長方體。透過材質陣列 (Materials Array) 分別對三個特定側面塗上楬色、深藍色與紫色,頂面保持木色。共 14 根,分為兩排(間距 2),放置在距離 Z 軸中心 25 的位置。
* **人物:** 使用 `CylinderGeometry` 近似正三角形(三邊形柱體),透過 Canvas 動態生成帶有中心畫線(方向)及側邊輔助線的貼圖,分別貼在楬色與深藍色人物上,並精準擺放在木條的上方與下方 15 的距離。
* **炸彈與數字木條:** 正六邊形設定為半徑 10 的圓柱體六邊形。數字木條一樣是 `10*10*50`,使用了 `createDiceMaterial` 動態生成含有對應數字(0-3 與 1-4)的 Canvas 貼圖。


2. **SVG 投擲互動:**
* 遊戲不採用 3D 內的 Raycaster 投擲,而是照你的要求使用原生 `<svg>` 覆蓋在畫面中央。
* 半徑 240 的無邊框半透明綠黃色圓盤,支援 Pointer Events(相容手機觸控與滑鼠)。
* 當玩家點擊圓內並拖曳至圓外時,會觸發 `throwDice` 函數,計算滑鼠的向量。


3. **物理引擎 (Cannon-es):**
* 當拖出圓外時,會呼叫 `CANNON.Body.applyImpulse` 根據 SVG 拖出的方向,對兩根「數字木條」施加拋擲方向的力,同時加上隨機扭力使其在空中旋轉翻滾,直到落至平面。


4. **鏡頭與回合邏輯:**
* 使用 `OrbitControls`,並透過 `maxPolarAngle = Math.PI / 2` 限制只能由正上方俯視至水平,`minAzimuthAngle` 與 `maxAzimuthAngle` 限制左右平移,`enablePan = false` 禁止玩家自己平移鏡頭。
* 回合切換時,`controls.target` 會往上方或下方平移(修改 Target Z 值),以達到「將中心往上/下移」的視覺效果。


5. **手機橫向提示:**
* 使用了簡單強大的 CSS 媒體查詢 `@media screen and (orientation: portrait)`。當偵測到手機處於直向時,會跳出滿版的全黑遮罩提示玩家「請將手機橫放」。