分享和讨论Javascript相关的话题
回复
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

「我為歌狂」遊戲製作

帖子 ejsoon »

做一個html+canvas+js遊戲,3d可旋轉。畫布大小為600*600。起初底板為480*480淺藍色正方形,底板沒有高度,邊緣為虛線白色。

遊戲等於分割成縱横都是八塊區域,每一塊為60*60,一共64個棋位。設從左到右坐標為0,1,2,3,4,5,6,7,從上到下坐標為a b,c,d,e,f,g,h。後面將用坐標表示棋位,如(2,d)。

中間初始放置有四個遊戲板,每個遊戲板的大小為120*120,其表面為木黄色,其邊緣為淺灰色,中心線為十字黑色,線寬為2,高度為4。每個板佔四個棋位,這四個遊戲板分別佔的坐標是[2,c;3,c;2,d;3,d],[4,c;5,c;4,d;5,d],[2,e;3,e;2,f;3,f],[4,e;5,e;4,f;5,f]。

當鼠標或觸控旋轉視圖時,如果上下移動,將是俯視角移至側視角,但不能翻到底面;如果左右移動,將等於順時針或逆時針旋轉視圖,但不能超過45度。

隨機放置兩個圓柱體至遊戲板上的其中兩個棋位,圓柱體高48,直徑48,表面為紫色,線寬為1,線色為黑色。

隨機放置兩個立方體,至遊戲板上的另兩個空棋位,長高寬都是48,表面為橙色和深藍色,六個面分別為骰子六個點,其中一個立方體的所有點的形狀是正三角形,另一個立方體的所有點的形狀是正六邊形,點的顏色分別為淺綠色和淺黄色。

立方體起初為一點朝上。當點擊其中一個立方體,它將會升起48,然後繞其3d中心旋轉至二點,然後再落下。如果已經是二點,將旋轉至三點……如果是六點,將旋轉至一點。


gemini 3.1 pro

代码: 全选

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <title>3D 棋盤互動遊戲</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #2c3e50;
        }
        #game-container {
            width: 600px;
            height: 600px;
            background-color: #000;
            position: relative;
            overflow: hidden;
            box-shadow: 0 10px 30px rgba(0,0,0,0.5);
        }
        canvas {
            display: block;
        }
    </style>
    <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/"
            }
        }
    </script>
</head>
<body>

<div id="game-container"></div>

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

    // 1. 初始化場景、相機、渲染器
    const container = document.getElementById('game-container');
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x34495e);

    const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 2000);
    // 設置初始視角
    camera.position.set(0, 450, 450);

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(600, 600);
    // 支援 sRGB 顏色空間,讓材質顏色更鮮豔
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    container.appendChild(renderer.domElement);

    // 2. 設置光源
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
    scene.add(ambientLight);
    const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
    dirLight.position.set(200, 400, 200);
    scene.add(dirLight);

    // 3. 視角控制器 (OrbitControls)
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enablePan = false; // 禁止平移
    controls.enableZoom = true;
    
    // 限制上下移動(俯視至側視,不可翻到底面)
    controls.minPolarAngle = 0; // 0 度為正上方俯視
    controls.maxPolarAngle = Math.PI / 2; // 90 度為平行側視

    // 限制左右移動(不能超過 45 度)
    controls.minAzimuthAngle = -Math.PI / 4;
    controls.maxAzimuthAngle = Math.PI / 4;

    // 4. 建立底板 (480x480)
    const boardSize = 480;
    const planeGeo = new THREE.PlaneGeometry(boardSize, boardSize);
    const planeMat = new THREE.MeshLambertMaterial({ color: 0xADD8E6 }); // 淺藍色
    const plane = new THREE.Mesh(planeGeo, planeMat);
    plane.rotation.x = -Math.PI / 2;
    scene.add(plane);

    // 底板白色虛線邊緣
    const edgesGeo = new THREE.EdgesGeometry(planeGeo);
    const edgesMat = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 10, gapSize: 10 });
    const planeEdges = new THREE.LineSegments(edgesGeo, edgesMat);
    planeEdges.computeLineDistances(); // 必須呼叫才能顯示虛線
    planeEdges.rotation.x = -Math.PI / 2;
    planeEdges.position.y = 0.5; // 微幅抬高防止 Z-fighting
    scene.add(planeEdges);

    // 5. 建立四個遊戲板 (120x120, 高度 4)
    // 繪製遊戲板表面材質 (木黃色 + 黑色十字)
    function createBoardTopTexture() {
        const canvas = document.createElement('canvas');
        canvas.width = 120; canvas.height = 120;
        const ctx = canvas.getContext('2d');
        
        ctx.fillStyle = '#DEB887'; // 木黃色
        ctx.fillRect(0, 0, 120, 120);
        
        ctx.strokeStyle = '#000000';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(60, 0); ctx.lineTo(60, 120);
        ctx.moveTo(0, 60); ctx.lineTo(120, 60);
        ctx.stroke();
        
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return texture;
    }

    const topBoardMat = new THREE.MeshLambertMaterial({ map: createBoardTopTexture() });
    const sideBoardMat = new THREE.MeshLambertMaterial({ color: 0xD3D3D3 }); // 淺灰色

    // Three.js Box 材質順序: 右, 左, 上, 下, 前, 後
    const boardMaterials = [sideBoardMat, sideBoardMat, topBoardMat, sideBoardMat, sideBoardMat, sideBoardMat];
    const gameBoardGeo = new THREE.BoxGeometry(120, 4, 120);

    const boardCenters = [
        { x: -60, z: -60 }, // 佔 [2,c;3,c;2,d;3,d]
        { x: 60, z: -60 },  // 佔 [4,c;5,c;4,d;5,d]
        { x: -60, z: 60 },  // 佔 [2,e;3,e;2,f;3,f]
        { x: 60, z: 60 }    // 佔 [4,e;5,e;4,f;5,f]
    ];

    boardCenters.forEach(pos => {
        const board = new THREE.Mesh(gameBoardGeo, boardMaterials);
        board.position.set(pos.x, 2, pos.z); // y=2 是因為高度為4,中心在2
        scene.add(board);
    });

    // 6. 決定隨機棋位 (16個遊戲板上的棋位)
    // a=0, b=1, c=2, d=3, e=4, f=5, g=6, h=7
    const validCells = [
        [2,2], [3,2], [2,3], [3,3], // Board 1
        [4,2], [5,2], [4,3], [5,3], // Board 2
        [2,4], [3,4], [2,5], [3,5], // Board 3
        [4,4], [5,4], [4,5], [5,5]  // Board 4
    ];

    // 打亂陣列以隨機取樣
    for(let i = validCells.length - 1; i > 0; i--){
        const j = Math.floor(Math.random() * (i + 1));
        [validCells[i], validCells[j]] = [validCells[j], validCells[i]];
    }

    // 座標轉換函式 (行列轉成世界 X Z)
    function getCellWorldPos(col, row) {
        return {
            x: (col - 3.5) * 60,
            z: (row - 3.5) * 60
        };
    }

    const itemY = 4 + 24; // 板高4 + 物體半高24 = 28

    // 7. 放置隨機兩個圓柱體
    for(let i = 0; i < 2; i++) {
        const cell = validCells[i];
        const pos = getCellWorldPos(cell[0], cell[1]);

        const cylGeo = new THREE.CylinderGeometry(24, 24, 48, 32);
        const cylMat = new THREE.MeshLambertMaterial({ color: 0x800080 }); // 紫色
        const cylinder = new THREE.Mesh(cylGeo, cylMat);
        cylinder.position.set(pos.x, itemY, pos.z);

        const cylEdgesGeo = new THREE.EdgesGeometry(cylGeo);
        const cylEdgesMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
        const cylEdges = new THREE.LineSegments(cylEdgesGeo, cylEdgesMat);
        cylinder.add(cylEdges);

        scene.add(cylinder);
    }

    // 8. 放置隨機兩個立方體 (骰子)
    const cubes = [];

    // 生成骰子每一面的材質
    function createDiceMaterial(baseColor, dotColor, shape, number) {
        const canvas = document.createElement('canvas');
        canvas.width = 128; canvas.height = 128;
        const ctx = canvas.getContext('2d');

        ctx.fillStyle = baseColor;
        ctx.fillRect(0, 0, 128, 128);

        ctx.fillStyle = dotColor;

        const drawShape = (x, y) => {
            ctx.beginPath();
            if (shape === 'triangle') {
                const r = 16;
                ctx.moveTo(x, y - r);
                ctx.lineTo(x + r*0.866, y + r*0.5);
                ctx.lineTo(x - r*0.866, y + r*0.5);
            } else { // hexagon
                const r = 14;
                for (let i=0; i<6; i++) {
                    const angle = i * Math.PI / 3;
                    if(i===0) ctx.moveTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                    else ctx.lineTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                }
            }
            ctx.closePath();
            ctx.fill();
        };

        const dots = [];
        if(number === 1) dots.push([64, 64]);
        if(number === 2) dots.push([32, 32], [96, 96]);
        if(number === 3) dots.push([32, 32], [64, 64], [96, 96]);
        if(number === 4) dots.push([32, 32], [96, 32], [32, 96], [96, 96]);
        if(number === 5) dots.push([32, 32], [96, 32], [64, 64], [32, 96], [96, 96]);
        if(number === 6) dots.push([32, 24], [32, 64], [32, 104], [96, 24], [96, 64], [96, 104]);

        dots.forEach(p => drawShape(p[0], p[1]));

        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return new THREE.MeshLambertMaterial({ map: texture });
    }

    // 將數字 1~6 對應至 BoxGeometry 的面: [右(+x), 左(-x), 上(+y), 下(-y), 前(+z), 後(-z)]
    const faceMapping = [3, 4, 1, 6, 2, 5]; 

    function createDiceMesh(baseColor, dotColor, shape) {
        const materials = faceMapping.map(num => createDiceMaterial(baseColor, dotColor, shape, num));
        const cube = new THREE.Mesh(new THREE.BoxGeometry(48, 48, 48), materials);
        
        // 狀態管理
        cube.userData = {
            currentNumber: 1,
            animating: false,
            startTime: 0,
            startQuat: new THREE.Quaternion(),
            targetQuat: new THREE.Quaternion()
        };
        return cube;
    }

    // 立方體 1: 橙色底, 淺綠色正三角形點
    const cube1Pos = getCellWorldPos(validCells[2][0], validCells[2][1]);
    const cube1 = createDiceMesh('#FFA500', '#90EE90', 'triangle');
    cube1.position.set(cube1Pos.x, itemY, cube1Pos.z);
    scene.add(cube1);
    cubes.push(cube1);

    // 立方體 2: 深藍色底, 淺黃色正六邊形點
    const cube2Pos = getCellWorldPos(validCells[3][0], validCells[3][1]);
    const cube2 = createDiceMesh('#00008B', '#FFFFE0', 'hexagon');
    cube2.position.set(cube2Pos.x, itemY, cube2Pos.z);
    scene.add(cube2);
    cubes.push(cube2);

    // 9. 互動與動畫處理
    const raycaster = new THREE.Raycaster();
    const mouse = new THREE.Vector2();

    container.addEventListener('pointerdown', (event) => {
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        const intersects = raycaster.intersectObjects(cubes);
        if (intersects.length > 0) {
            const clickedCube = intersects[0].object;
            if (!clickedCube.userData.animating) {
                triggerCubeAnimation(clickedCube);
            }
        }
    });

    function triggerCubeAnimation(cube) {
        cube.userData.animating = true;
        cube.userData.startTime = performance.now();
        cube.userData.startQuat.copy(cube.quaternion);

        // 決定下一個數字
        let nextNum = cube.userData.currentNumber + 1;
        if (nextNum > 6) nextNum = 1;
        cube.userData.currentNumber = nextNum;

        // 計算對應的目標旋轉歐拉角
        let rx = 0, ry = 0, rz = 0;
        switch(nextNum) {
            case 1: break; // +y 向上
            case 2: rx = -Math.PI/2; break; // +z 向上
            case 3: rz = Math.PI/2; break;  // +x 向上
            case 4: rz = -Math.PI/2; break; // -x 向上
            case 5: rx = Math.PI/2; break;  // -z 向上
            case 6: rx = Math.PI; break;    // -y 向上
        }
        cube.userData.targetQuat.setFromEuler(new THREE.Euler(rx, ry, rz));
    }

    // 10. 主渲染迴圈
    function animate() {
        requestAnimationFrame(animate);
        const now = performance.now();

        // 處理所有立方體的動畫
        cubes.forEach(cube => {
            if (cube.userData.animating) {
                const elapsed = now - cube.userData.startTime;
                const duration = 1000; // 動畫總時長 1000 毫秒
                let t = elapsed / duration;

                if (t >= 1) {
                    t = 1;
                    cube.userData.animating = false;
                }

                // 拋物線高度:利用 sin 曲線呈現平滑的升起48再落下
                cube.position.y = itemY + Math.sin(t * Math.PI) * 48;

                // 3D 旋轉插值 (Slerp) 實現自然翻轉
                cube.quaternion.slerpQuaternions(cube.userData.startQuat, cube.userData.targetQuat, t);
            }
        });

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

    animate();
</script>

</body>
</html>
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

修改代碼:
一,點擊立方體不再會翻轉,但「翻轉立方體」的方法保留。

二,起始時,將不再有隨機的圓柱體和立方體出現,但保留把圓柱體和立方體放至遊戲板上的方法。

三,開始時,將不會有四個遊戲板放在中間。

四,頁面要適配手機端,canvas將佔滿整個網頁(而不再是600*600)。

五,起初中間有個「開始遊戲」按鈕(用html元素絕對定位在canvas上方),當點擊時,遊戲開始,按鈕消失。

六,遊戲分為藍方和橙方,兩方輪流行動,先由藍方開始。在俯視視角,藍方為下方,橙方為上方。

這時遊戲將進入開局階段,主要任務是放上遊戲板、圓柱體和立方體。

開局階段雙方輪流執行以下步驟:放置中心遊戲板,放置角遊戲板,放置圓柱至中心,放置邊遊戲板並放置圓柱,放置立方體和角遊戲板。

七,放置中心遊戲板(由程式自動完成):兩個遊戲板[2,e;3,e;2,f;3,f],[4,e;5,e;4,f;5,f]從俯視視角的下方移至當前位置,另兩個遊戲板[2,c;3,c;2,d;3,d],[4,c;5,c;4,d;5,d]從上方移至當前位置。

八,放置角遊戲板:輪到藍方,在坐標(0,1,g,h)和(6,7,g,h)中心,即左下角和右下角,出現兩個「行動標示」,它們是平面圓,顏色跟深藍色立方體的表面顏色相同,圖的直徑為36。當點擊其中一個,兩個行動標示將消失,在這個位置上將放上一個「角遊戲板」。

「角遊戲板」的屬性跟「中心遊戲板」基本一樣,不同的是它表面畫的線,是兩條從中心畫向內邊緣的黑色線。意即如果藍方點擊的是(6,7,g,h),則這個遊戲板的中心線將是向上和向左,如果點擊的是(0,1,g,h),則這個遊戲板的中心線將是向上和向右。

之後輪到橙方。在坐標(0,1,a,b)或(6,7,a,b)中心,即左上角或右上角,出現一個橙方的「行動標示」。如果之前藍方選了右下角,則橙方只能選擇左上角;如果之前藍方選了左下角,則橙方只能選擇右上角。當橙方點擊它,也會放上一個「角遊戲板」。

九,放置圓柱至中心:輪到藍方,在中心遊戲板上的十六個棋位,將出現十六個行動標示,點擊其中一個,將會在該位置放一個圓柱體。之後輪到橙方,它的行動標示將出現在中心遊戲板上的棋位,但不能是已放圓柱體的棋位,也不能是與之同在一條横線或縱線的棋位,亦不能是與之斜相鄰的棋位。橙方點擊其中一個行動標示同樣會放置一個圓柱體。

十,放置邊遊戲板並放置圓柱:輪到藍方,如果藍方之前選擇的是右下角,則可放圓柱的位置是[2,h;3,h;4,h;5,h;7,c;7,d;7,e;7,f];如果藍方之前選擇的是左下角,則可放圓柱的位置是[2,h;3,h;4,h;5,h;0,c;0,d;0,e;0,f]。

在這些可放位置中,要排除跟已有圓柱在同一條横線或縱線的位置。之後在這些可放位置加上行動指示。

當點擊其中一個行動指示,將先自動放上兩個並排的邊遊戲板,再在所選位置放上圓柱體。

邊遊戲板的黑線是T字形,其中横穿的線與邊緣平行,由中心畫向邊緣的短線朝外。

舉個例子,如果藍方選擇了(4,h),則會在(2,3,gh)和(4,5,g,h)放置兩個並排的邊遊戲板,它們的黑線分別是横向貫穿,並且都有一條黑線從中心畫向外邊緣。之後在(4,h)處放置一個圓柱體。

輪到橙方,橙方也將會有靠近邊緣但非角上,且跟當前已有圓柱不共線的位置可選擇放置圓柱體。

之後再次輪到藍方,再輪到橙方,最終有六個圓柱體出現在場上。

十一,放置立方體和角遊戲板。輪到藍方,如果之前藍方選擇的是右下角,則會在(7,g)、(7,h)、(6,h)出現行動標示,當點擊其中一個,將會在所選位置出現藍色立方體。如果是左下角,則會在左下角的三個棋位出現行動標示。橙方也一樣,在當前有的角遊戲板上最靠近角落的三個棋位上選擇橙色立方體的出場位置。

當兩個立方體都出場後,程式自動補齊剩餘的最後一塊角遊戲板。

十二,開局階段結束,將進入遊戲階段。遊戲規則將在以後補充。

代码: 全选

修改代碼:
一,點擊立方體不再會翻轉,但「翻轉立方體」的方法保留。

二,起始時,將不再有隨機的圓柱體和立方體出現,但保留把圓柱體和立方體放至遊戲板上的方法。

三,開始時,將不會有四個遊戲板放在中間。

四,頁面要適配手機端,canvas將佔滿整個網頁(而不再是600*600)。

五,起初中間有個「開始遊戲」按鈕(用html元素絕對定位在canvas上方),當點擊時,遊戲開始,按鈕消失。

六,遊戲分為藍方和橙方,兩方輪流行動,先由藍方開始。在俯視視角,藍方為下方,橙方為上方。

這時遊戲將進入開局階段,主要任務是放上遊戲板、圓柱體和立方體。

開局階段雙方輪流執行以下步驟:放置中心遊戲板,放置角遊戲板,放置圓柱至中心,放置邊遊戲板並放置圓柱,放置立方體和角遊戲板。

七,放置中心遊戲板(由程式自動完成):兩個遊戲板[2,e;3,e;2,f;3,f],[4,e;5,e;4,f;5,f]從俯視視角的下方移至當前位置,另兩個遊戲板[2,c;3,c;2,d;3,d],[4,c;5,c;4,d;5,d]從上方移至當前位置。

八,放置角遊戲板:輪到藍方,在坐標(0,1,g,h)和(6,7,g,h)中心,即左下角和右下角,出現兩個「行動標示」,它們是平面圓,顏色跟深藍色立方體的表面顏色相同,圖的直徑為36。當點擊其中一個,兩個行動標示將消失,在這個位置上將放上一個「角遊戲板」。

「角遊戲板」的屬性跟「中心遊戲板」基本一樣,不同的是它表面畫的線,是兩條從中心畫向內邊緣的黑色線。意即如果藍方點擊的是(6,7,g,h),則這個遊戲板的中心線將是向上和向左,如果點擊的是(0,1,g,h),則這個遊戲板的中心線將是向上和向右。

之後輪到橙方。在坐標(0,1,a,b)或(6,7,a,b)中心,即左上角或右上角,出現一個橙方的「行動標示」。如果之前藍方選了右下角,則橙方只能選擇左上角;如果之前藍方選了左下角,則橙方只能選擇右上角。當橙方點擊它,也會放上一個「角遊戲板」。

九,放置圓柱至中心:輪到藍方,在中心遊戲板上的十六個棋位,將出現十六個行動標示,點擊其中一個,將會在該位置放一個圓柱體。之後輪到橙方,它的行動標示將出現在中心遊戲板上的棋位,但不能是已放圓柱體的棋位,也不能是與之同在一條横線或縱線的棋位,亦不能是與之斜相鄰的棋位。橙方點擊其中一個行動標示同樣會放置一個圓柱體。

十,放置邊遊戲板並放置圓柱:輪到藍方,如果藍方之前選擇的是右下角,則可放圓柱的位置是[2,h;3,h;4,h;5,h;7,c;7,d;7,e;7,f];如果藍方之前選擇的是左下角,則可放圓柱的位置是[2,h;3,h;4,h;5,h;0,c;0,d;0,e;0,f]。

在這些可放位置中,要排除跟已有圓柱在同一條横線或縱線的位置。之後在這些可放位置加上行動指示。

當點擊其中一個行動指示,將先自動放上兩個並排的邊遊戲板,再在所選位置放上圓柱體。

邊遊戲板的黑線是T字形,其中横穿的線與邊緣平行,由中心畫向邊緣的短線朝外。

舉個例子,如果藍方選擇了(4,h),則會在(2,3,gh)和(4,5,g,h)放置兩個並排的邊遊戲板,它們的黑線分別是横向貫穿,並且都有一條黑線從中心畫向外邊緣。之後在(4,h)處放置一個圓柱體。

輪到橙方,橙方也將會有靠近邊緣但非角上,且跟當前已有圓柱不共線的位置可選擇放置圓柱體。

之後再次輪到藍方,再輪到橙方,最終有六個圓柱體出現在場上。

十一,放置立方體和角遊戲板。輪到藍方,如果之前藍方選擇的是右下角,則會在(7,g)、(7,h)、(6,h)出現行動標示,當點擊其中一個,將會在所選位置出現藍色立方體。如果是左下角,則會在左下角的三個棋位出現行動標示。橙方也一樣,在當前有的角遊戲板上最靠近角落的三個棋位上選擇橙色立方體的出場位置。

當兩個立方體都出場後,程式自動補齊剩餘的最後一塊角遊戲板。

十二,開局階段結束,將進入遊戲階段。遊戲規則將在以後補充。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

gemini 3.1 pro差點沒吃:

代码: 全选

這是一份為您修改好的完整代碼。代碼去除了原本的隨機初始化,改為完整的開局階段輪流互動流程,並加入了響應式全螢幕以及動畫過渡效果。

請將以下代碼保存為 HTML 檔案並在瀏覽器中運行(記得同目錄下需有 `three.module.js` 和 `OrbitControls.js`,或替換為 CDN 連結):

```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;
            padding: 0;
            overflow: hidden;
            background-color: #2c3e50;
            touch-action: none; /* 防止手機滑動縮放 */
        }
        #game-container {
            width: 100vw;
            height: 100vh;
            background-color: #000;
            position: relative;
        }
        canvas {
            display: block;
        }
        #ui-layer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            pointer-events: none; /* 讓點擊穿透到 canvas */
            z-index: 10;
        }
        #btn-start {
            pointer-events: auto; /* 按鈕本身可點擊 */
            padding: 20px 50px;
            font-size: 28px;
            font-weight: bold;
            color: #fff;
            background-color: #e74c3c;
            border: none;
            border-radius: 10px;
            cursor: pointer;
            box-shadow: 0 5px 15px rgba(0,0,0,0.5);
            transition: transform 0.1s, background-color 0.2s;
        }
        #btn-start:hover {
            background-color: #c0392b;
            transform: scale(1.05);
        }
        #btn-start:active {
            transform: scale(0.95);
        }
        #message {
            position: absolute;
            top: 20px;
            width: 100%;
            text-align: center;
            color: #fff;
            font-size: 24px;
            font-weight: bold;
            pointer-events: none;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
            display: none;
        }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "./three.module.js",
                "three/addons/controls/": "./"
            }
        }
    </script>
</head>
<body>

<div id="game-container"></div>
<div id="ui-layer">
    <button id="btn-start">開始遊戲</button>
</div>
<div id="message"></div>

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

    // ==========================================
    // 1. 初始化場景、相機、渲染器
    // ==========================================
    const container = document.getElementById('game-container');
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x34495e);

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

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    container.appendChild(renderer.domElement);

    // 視窗自適應
    window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
    });

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
    scene.add(ambientLight);
    const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
    dirLight.position.set(200, 400, 200);
    scene.add(dirLight);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enablePan = false;
    controls.enableZoom = true;
    controls.minPolarAngle = 0;
    controls.maxPolarAngle = Math.PI / 2;
    controls.minAzimuthAngle = -Math.PI / 4;
    controls.maxAzimuthAngle = Math.PI / 4;

    // ==========================================
    // 2. 建立底板
    // ==========================================
    const boardSize = 480;
    const planeGeo = new THREE.PlaneGeometry(boardSize, boardSize);
    const planeMat = new THREE.MeshLambertMaterial({ color: 0xADD8E6 });
    const plane = new THREE.Mesh(planeGeo, planeMat);
    plane.rotation.x = -Math.PI / 2;
    scene.add(plane);

    const edgesGeo = new THREE.EdgesGeometry(planeGeo);
    const edgesMat = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 10, gapSize: 10 });
    const planeEdges = new THREE.LineSegments(edgesGeo, edgesMat);
    planeEdges.computeLineDistances();
    planeEdges.rotation.x = -Math.PI / 2;
    planeEdges.position.y = 0.5;
    scene.add(planeEdges);

    // ==========================================
    // 3. 共用幾何體與材質生成
    // ==========================================
    const gameBoardGeo = new THREE.BoxGeometry(118, 4, 118);
    const boardEdgesGeo = new THREE.EdgesGeometry(gameBoardGeo);
    const boardEdgesMat = new THREE.LineBasicMaterial({ color: 0x363636, linewidth: 4 });
    const sideBoardMat = new THREE.MeshLambertMaterial({ color: 0xD3D3D3 });

    // 根據需求繪製黑線 (dirs 包含 'top', 'bottom', 'left', 'right')
    function createBoardTopTexture(dirs) {
        const canvas = document.createElement('canvas');
        canvas.width = 120; canvas.height = 120;
        const ctx = canvas.getContext('2d');
        
        ctx.fillStyle = '#DEB887';
        ctx.fillRect(0, 0, 120, 120);
        
        ctx.strokeStyle = '#000000';
        ctx.lineWidth = 4;
        ctx.beginPath();
        if (dirs.includes('top')) { ctx.moveTo(60, 60); ctx.lineTo(60, 0); }
        if (dirs.includes('bottom')) { ctx.moveTo(60, 60); ctx.lineTo(60, 120); }
        if (dirs.includes('left')) { ctx.moveTo(60, 60); ctx.lineTo(0, 60); }
        if (dirs.includes('right')) { ctx.moveTo(60, 60); ctx.lineTo(120, 60); }
        ctx.stroke();
        
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return texture;
    }

    function createGameBoard(colCenter, rowCenter, dirs, startZOffset = 0) {
        const topMat = new THREE.MeshLambertMaterial({ map: createBoardTopTexture(dirs) });
        const materials = [sideBoardMat, sideBoardMat, topMat, sideBoardMat, sideBoardMat, sideBoardMat];
        const board = new THREE.Mesh(gameBoardGeo, materials);
        
        const pos = getCellWorldPos(colCenter, rowCenter);
        // 設定初始位置 (供動畫使用)
        board.position.set(pos.x, 2, pos.z + startZOffset);
        
        const boardEdges = new THREE.LineSegments(boardEdgesGeo, boardEdgesMat);
        board.add(boardEdges);
        scene.add(board);
        return { mesh: board, targetZ: pos.z, startZ: pos.z + startZOffset };
    }

    // 將行列(0~7)轉成世界座標 X Z
    function getCellWorldPos(col, row) {
        return {
            x: (col - 3.5) * 60,
            z: (row - 3.5) * 60
        };
    }

    // 骰子材質生成與模型建立
    const faceMapping = [3, 4, 1, 6, 2, 5]; 
    function createDiceMaterial(baseColor, dotColor, shape, number) {
        const canvas = document.createElement('canvas');
        canvas.width = 128; canvas.height = 128;
        const ctx = canvas.getContext('2d');

        ctx.fillStyle = baseColor;
        ctx.fillRect(0, 0, 128, 128);
        ctx.fillStyle = dotColor;

        const drawShape = (x, y) => {
            ctx.beginPath();
            if (shape === 'triangle') {
                const r = 16;
                ctx.moveTo(x, y - r);
                ctx.lineTo(x + r*0.866, y + r*0.5);
                ctx.lineTo(x - r*0.866, y + r*0.5);
            } else { 
                const r = 14;
                for (let i=0; i<6; i++) {
                    const angle = i * Math.PI / 3;
                    if(i===0) ctx.moveTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                    else ctx.lineTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                }
            }
            ctx.closePath();
            ctx.fill();
        };

        const dots = [];
        if(number === 1) dots.push([64, 64]);
        if(number === 2) dots.push([32, 32], [96, 96]);
        if(number === 3) dots.push([32, 32], [64, 64], [96, 96]);
        if(number === 4) dots.push([32, 32], [96, 32], [32, 96], [96, 96]);
        if(number === 5) dots.push([32, 32], [96, 32], [64, 64], [32, 96], [96, 96]);
        if(number === 6) dots.push([32, 24], [32, 64], [32, 104], [96, 24], [96, 64], [96, 104]);

        dots.forEach(p => drawShape(p[0], p[1]));

        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return new THREE.MeshLambertMaterial({ map: texture });
    }

    function createDiceMesh(baseColor, dotColor, shape) {
        const materials = faceMapping.map(num => createDiceMaterial(baseColor, dotColor, shape, num));
        const cube = new THREE.Mesh(new THREE.BoxGeometry(48, 48, 48), materials);
        cube.userData = {
            currentNumber: 1, animating: false, startTime: 0,
            startQuat: new THREE.Quaternion(), targetQuat: new THREE.Quaternion()
        };
        return cube;
    }

    // 翻轉立方體函數保留 (供後續遊戲邏輯調用)
    function triggerCubeAnimation(cube) {
        cube.userData.animating = true;
        cube.userData.startTime = performance.now();
        cube.userData.startQuat.copy(cube.quaternion);

        let nextNum = cube.userData.currentNumber + 1;
        if (nextNum > 6) nextNum = 1;
        cube.userData.currentNumber = nextNum;

        let rx = 0, ry = 0, rz = 0;
        switch(nextNum) {
            case 1: break; 
            case 2: rx = -Math.PI/2; break; 
            case 3: rz = Math.PI/2; break;  
            case 4: rz = -Math.PI/2; break; 
            case 5: rx = Math.PI/2; break;  
            case 6: rx = Math.PI; break;    
        }
        cube.userData.targetQuat.setFromEuler(new THREE.Euler(rx, ry, rz));
    }

    // ==========================================
    // 4. 遊戲狀態與互動邏輯
    // ==========================================
    const itemY = 28;
    const cubes = [];
    const cylindersData = [];
    let markers = [];
    let raycaster = new THREE.Raycaster();
    let mouse = new THREE.Vector2();

    // 狀態追蹤
    let blueCornerType = ''; 
    let orangeCornerType = '';
    let placedEdges = { top: false, bottom: false, left: false, right: false };
    let placedCorners = { LB: false, RB: false, LT: false, RT: false };
    let centerBoardsAnimData = [];
    let isAnimatingPhase1 = false;
    let phase1Progress = 0;
    let edgeTurn = 1;

    const uiMsg = document.getElementById('message');
    function showMessage(msg) {
        uiMsg.style.display = 'block';
        uiMsg.innerText = msg;
    }

    // 標示點生成
    function createMarker(col, row, color, onClickCallback) {
        const pos = getCellWorldPos(col, row);
        const geo = new THREE.CylinderGeometry(18, 18, 2, 32);
        const mat = new THREE.MeshBasicMaterial({ color: color });
        const marker = new THREE.Mesh(geo, mat);
        marker.position.set(pos.x, 5, pos.z);
        marker.userData.onClick = onClickCallback;
        scene.add(marker);
        markers.push(marker);
    }

    function clearMarkers() {
        markers.forEach(m => scene.remove(m));
        markers = [];
        document.body.style.cursor = 'default';
    }

    // --- 實體放置方法 ---
    function placeCylinder(col, row) {
        const pos = getCellWorldPos(col, row);
        const cylGeo = new THREE.CylinderGeometry(24, 24, 48, 32);
        const cylMat = new THREE.MeshLambertMaterial({ color: 0x800080 }); // 統一紫色
        const cylinder = new THREE.Mesh(cylGeo, cylMat);
        cylinder.position.set(pos.x, itemY, pos.z);

        const cylEdgesGeo = new THREE.EdgesGeometry(cylGeo);
        const cylEdgesMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
        const cylEdges = new THREE.LineSegments(cylEdgesGeo, cylEdgesMat);
        cylinder.add(cylEdges);

        scene.add(cylinder);
        cylindersData.push({ c: col, r: row });
    }

    function placeCornerBoard(type) {
        if (placedCorners[type]) return;
        placedCorners[type] = true;
        if (type === 'LB') createGameBoard(0.5, 6.5, ['top', 'right']);
        if (type === 'RB') createGameBoard(6.5, 6.5, ['top', 'left']);
        if (type === 'LT') createGameBoard(0.5, 0.5, ['bottom', 'right']);
        if (type === 'RT') createGameBoard(6.5, 0.5, ['bottom', 'left']);
    }

    function placeEdgeBoards(side) {
        if (placedEdges[side]) return;
        placedEdges[side] = true;
        if (side === 'bottom') {
            createGameBoard(2.5, 6.5, ['left', 'right', 'bottom']);
            createGameBoard(4.5, 6.5, ['left', 'right', 'bottom']);
        }
        if (side === 'top') {
            createGameBoard(2.5, 0.5, ['left', 'right', 'top']);
            createGameBoard(4.5, 0.5, ['left', 'right', 'top']);
        }
        if (side === 'left') {
            createGameBoard(0.5, 2.5, ['top', 'bottom', 'left']);
            createGameBoard(0.5, 4.5, ['top', 'bottom', 'left']);
        }
        if (side === 'right') {
            createGameBoard(6.5, 2.5, ['top', 'bottom', 'right']);
            createGameBoard(6.5, 4.5, ['top', 'bottom', 'right']);
        }
    }

    function placeCube(col, row, color) {
        const pos = getCellWorldPos(col, row);
        let cube;
        if (color === 'blue') cube = createDiceMesh('#00008B', '#FFFFE0', 'hexagon');
        else cube = createDiceMesh('#FFA500', '#90EE90', 'triangle');
        cube.position.set(pos.x, itemY, pos.z);
        scene.add(cube);
        cubes.push(cube);
    }

    // --- 輔助檢測 ---
    function isOccupiedByCyl(c, r) {
        return cylindersData.some(cyl => cyl.c === c || cyl.r === r);
    }
    function isDiagAdjacent(c1, r1, c2, r2) {
        return Math.abs(c1 - c2) === 1 && Math.abs(r1 - r2) === 1;
    }
    function isValidOrangeCenterCyl(c, r) {
        if (isOccupiedByCyl(c, r)) return false;
        return !cylindersData.some(cyl => isDiagAdjacent(c, r, cyl.c, cyl.r));
    }

    function getEdgeCells(cornerType) {
        let cells = [];
        if(cornerType.includes('B')) {
            for(let c=2; c<=5; c++) cells.push({c, r:7, side:'bottom'});
        }
        if(cornerType.includes('T')) {
            for(let c=2; c<=5; c++) cells.push({c, r:0, side:'top'});
        }
        if(cornerType.includes('L')) {
            for(let r=2; r<=5; r++) cells.push({c:0, r, side:'left'});
        }
        if(cornerType.includes('R')) {
            for(let r=2; r<=5; r++) cells.push({c:7, r, side:'right'});
        }
        return cells;
    }

    function getOuterCornerCells(cornerType) {
        if (cornerType === 'LB') return [{c:0, r:7}, {c:1, r:7}, {c:0, r:6}];
        if (cornerType === 'RB') return [{c:7, r:7}, {c:6, r:7}, {c:7, r:6}];
        if (cornerType === 'LT') return [{c:0, r:0}, {c:1, r:0}, {c:0, r:1}];
        if (cornerType === 'RT') return [{c:7, r:0}, {c:6, r:0}, {c:7, r:1}];
    }

    // ==========================================
    // 5. 遊戲階段流程控制器
    // ==========================================

    // 階段一:中心板進場動畫
    function startPhase1() {
        showMessage("開局:中心遊戲板就位");
        centerBoardsAnimData.push(createGameBoard(2.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(4.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(2.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        centerBoardsAnimData.push(createGameBoard(4.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        isAnimatingPhase1 = true;
    }

    // 階段二:藍方選角
    function startPhase2() {
        showMessage("藍方行動:選擇並放置角遊戲板");
        // 標示點在四個格子的幾何中心
        createMarker(0.5, 6.5, 0x00008B, () => {
            clearMarkers();
            blueCornerType = 'LB';
            placeCornerBoard('LB');
            startPhase3();
        });
        createMarker(6.5, 6.5, 0x00008B, () => {
            clearMarkers();
            blueCornerType = 'RB';
            placeCornerBoard('RB');
            startPhase3();
        });
    }

    // 階段三:橙方對角
    function startPhase3() {
        showMessage("橙方行動:放置對角遊戲板");
        orangeCornerType = (blueCornerType === 'LB') ? 'RT' : 'LT';
        const centerPos = (orangeCornerType === 'RT') ? {c: 6.5, r: 0.5} : {c: 0.5, r: 0.5};
        createMarker(centerPos.c, centerPos.r, 0xFFA500, () => {
            clearMarkers();
            placeCornerBoard(orangeCornerType);
            startPhase4();
        });
    }

    // 階段四:藍方放中心圓柱
    function startPhase4() {
        showMessage("藍方行動:在中心板上放置一個圓柱體");
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                createMarker(c, r, 0x00008B, () => {
                    clearMarkers();
                    placeCylinder(c, r);
                    startPhase5();
                });
            }
        }
    }

    // 階段五:橙方放中心圓柱
    function startPhase5() {
        showMessage("橙方行動:在中心板上放置一個圓柱體 (避開同行/同列/斜相鄰)");
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                if(isValidOrangeCenterCyl(c, r)) {
                    createMarker(c, r, 0xFFA500, () => {
                        clearMarkers();
                        placeCylinder(c, r);
                        startPhase6();
                    });
                }
            }
        }
    }

    // 階段六:輪流放置邊板與邊緣圓柱 (總共4回合,藍/橙交替)
    function startPhase6() {
        if (edgeTurn > 4) {
            startPhase7();
            return;
        }
        
        const isBlueTurn = (edgeTurn % 2 !== 0);
        const colorHex = isBlueTurn ? 0x00008B : 0xFFA500;
        const cornerType = isBlueTurn ? blueCornerType : orangeCornerType;
        const teamName = isBlueTurn ? "藍方" : "橙方";
        
        showMessage(`${teamName}行動:放置邊遊戲板及圓柱體`);
        
        const validCells = getEdgeCells(cornerType).filter(cell => !isOccupiedByCyl(cell.c, cell.r));
        validCells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeEdgeBoards(cell.side);
                placeCylinder(cell.c, cell.r);
                edgeTurn++;
                startPhase6(); // 下一回合
            });
        });
    }

    // 階段七:藍方放置立方體
    function startPhase7() {
        showMessage("藍方行動:放置藍色立方體");
        const cells = getOuterCornerCells(blueCornerType);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, 0x00008B, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, 'blue');
                startPhase8();
            });
        });
    }

    // 階段八:橙方放置立方體並補齊剩餘角落
    function startPhase8() {
        showMessage("橙方行動:放置橙色立方體");
        const cells = getOuterCornerCells(orangeCornerType);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, 0xFFA500, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, 'orange');
                
                // 補齊剩餘的角遊戲板
                ['LB', 'RB', 'LT', 'RT'].forEach(type => {
                    if (!placedCorners[type]) placeCornerBoard(type);
                });
                
                showMessage("開局階段結束!即將進入遊戲階段...");
                setTimeout(() => { showMessage(""); }, 3000);
            });
        });
    }

    // ==========================================
    // 6. 事件綁定與主迴圈
    // ==========================================

    // 開始按鈕
    document.getElementById('btn-start').addEventListener('click', (e) => {
        e.target.parentElement.style.display = 'none'; // 隱藏按鈕UI層
        startPhase1();
    });

    // 點擊事件監聽
    container.addEventListener('pointerdown', (event) => {
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        // 偵測點擊 Markers
        if (markers.length > 0) {
            const intersects = raycaster.intersectObjects(markers);
            if (intersects.length > 0) {
                intersects[0].object.userData.onClick();
            }
        }
    });

    // 鼠標懸浮效果 (改變指標樣式)
    container.addEventListener('pointermove', (event) => {
        if (markers.length === 0) return;
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        const intersects = raycaster.intersectObjects(markers);
        if (intersects.length > 0) document.body.style.cursor = 'pointer';
        else document.body.style.cursor = 'default';
    });

    // 主渲染迴圈
    function animate() {
        requestAnimationFrame(animate);
        const now = performance.now();

        // 處理中心遊戲板入場動畫
        if (isAnimatingPhase1) {
            phase1Progress += 0.015; // 動畫速度
            if (phase1Progress >= 1) {
                phase1Progress = 1;
                isAnimatingPhase1 = false;
                startPhase2();
            }
            // EaseOutQuad
            const easeOut = phase1Progress * (2 - phase1Progress);
            centerBoardsAnimData.forEach(b => {
                b.mesh.position.z = b.startZ + (b.targetZ - b.startZ) * easeOut;
            });
        }

        // 處理立方體翻轉動畫
        cubes.forEach(cube => {
            if (cube.userData.animating) {
                const elapsed = now - cube.userData.startTime;
                const duration = 1000;
                let t = elapsed / duration;

                if (t >= 1) {
                    t = 1;
                    cube.userData.animating = false;
                }
                cube.position.y = itemY + Math.sin(t * Math.PI) * 48;
                cube.quaternion.slerpQuaternions(cube.userData.startQuat, cube.userData.targetQuat, t);
            }
        });

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

    animate();
</script>

</body>
</html>

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

進入遊戲階段的行動規則:
藍方和橙方輪流行動,藍方先行。

所要行動的就是一方所屬的立方體。

每方的行動包括三次行走,三次行走完需要放置一個特殊形狀的「話筒」。

每次行走都必須撞到一個障礙物才會停下,障礙物包括圓柱體、對方立方體、棋盤邊緣。

當一次行走結束,要轉至下一次行走時,假定前一次行走為往前,則下一次行走只能是往左或往右,僅當既無法往左也無法往右時才能往後。

當要進行一次行走時,將出現行動標示在本次行走所要停下的所有可能的地方。

當三次行走結束時,將要放置一個「話筒」,藍方的話筒是正六邊形,表面顏色是同樣的深藍色,橙方的話筒是三角形,表面顏色同樣是橙色。話筒的高為4。

因為話筒高為4,所以當一個立方體要行動時,它將先升起5個高度,行動結束才會放下。它在行走時是直線平移,加上貝塞爾曲線控制的移動效果。如果行動標示出現的棋位上有話筒,則這個標示也應升起5個高度。

話筒只能放在第一次或第二次行走時停下的地方,如果這兩個地方都是空地,則出現兩個行動標示在這兩個棋位;如果其中一個地方已經有了本方話筒,則出現一個行動標示在另一個棋位;如果這兩個地方已經有了本方的話筒,則本次行動不需放置話筒;如果場上此時已經存在四個本方話筒,則本次行動不需放置話筒。

當話筒放置結束,將輪到對方行動。

一方在行走時,如果經過對方的話筒,則將全部清空。如果是第三次行走經過對方的話筒,則經過一個得一分。得分時將翻轉立方體,立方體初始分數為一,如果得一分,則翻轉為二點朝上。得分應在第三次行走結束但沒有放置話筒時計算。

一方在行走時,如果經過本方話筒,將不作任何處理,但是如果第三次行走結束時停在本方話筒上,則將把它拿走,並使對方得一分。

當有一方得六分及以上時遊戲結束,分數高者獲勝。

當有一方獲勝時,將由html元素提示,是否要進行下一場遊戲,有三個選項:否,用當前場地再戰一局,重新開局。

如果選擇後兩個選項,則將由橙方開始遊戲,並且在放角遊戲板時只能選擇另一個角。比如上一局藍方選擇右下角,這一局橙方只能選擇右上角。所有回合將是先輪到橙方再輪到藍方。

當兩局遊戲結束,總分高者為最終贏家。

如果前面選擇「否」,或者兩局結束,將顯示最終勝者是哪一方,兩方的總分,同時顯示「重新開始」按鈕。

代码: 全选

進入遊戲階段的行動規則:
藍方和橙方輪流行動,藍方先行。

所要行動的就是一方所屬的立方體。

每方的行動包括三次行走,三次行走完需要放置一個特殊形狀的「話筒」。

每次行走都必須撞到一個障礙物才會停下,障礙物包括圓柱體、對方立方體、棋盤邊緣。

當一次行走結束,要轉至下一次行走時,假定前一次行走為往前,則下一次行走只能是往左或往右,僅當既無法往左也無法往右時才能往後。

當要進行一次行走時,將出現行動標示在本次行走所要停下的所有可能的地方。

當三次行走結束時,將要放置一個「話筒」,藍方的話筒是正六邊形,表面顏色是同樣的深藍色,橙方的話筒是三角形,表面顏色同樣是橙色。話筒的高為4。

因為話筒高為4,所以當一個立方體要行動時,它將先升起5個高度,行動結束才會放下。它在行走時是直線平移,加上貝塞爾曲線控制的移動效果。如果行動標示出現的棋位上有話筒,則這個標示也應升起5個高度。

話筒只能放在第一次或第二次行走時停下的地方,如果這兩個地方都是空地,則出現兩個行動標示在這兩個棋位;如果其中一個地方已經有了本方話筒,則出現一個行動標示在另一個棋位;如果這兩個地方已經有了本方的話筒,則本次行動不需放置話筒;如果場上此時已經存在四個本方話筒,則本次行動不需放置話筒。

當話筒放置結束,將輪到對方行動。

一方在行走時,如果經過對方的話筒,則將全部清空。如果是第三次行走經過對方的話筒,則經過一個得一分。得分時將翻轉立方體,立方體初始分數為一,如果得一分,則翻轉為二點朝上。得分應在第三次行走結束但沒有放置話筒時計算。

一方在行走時,如果經過本方話筒,將不作任何處理,但是如果第三次行走結束時停在本方話筒上,則將把它拿走,並使對方得一分。

當有一方得六分及以上時遊戲結束,分數高者獲勝。

當有一方獲勝時,將由html元素提示,是否要進行下一場遊戲,有三個選項:否,用當前場地再戰一局,重新開局。

如果選擇後兩個選項,則將由橙方開始遊戲,並且在放角遊戲板時只能選擇另一個角。比如上一局藍方選擇右下角,這一局橙方只能選擇右上角。所有回合將是先輪到橙方再輪到藍方。

當兩局遊戲結束,總分高者為最終贏家。

如果前面選擇「否」,或者兩局結束,將顯示最終勝者是哪一方,兩方的總分,同時顯示「重新開始」按鈕。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

gemini 3.1 pro表示對這個規則非常贊賞

代码: 全选

這個遊戲階段的設計非常精彩!結合了滑塊碰撞機制(類似冰壺或撞球的直線滑行)、路徑規劃(三次折線行走)以及「話筒」的放置與消除策略,讓整個棋盤的空間利用充滿了變數與深度。連局機制與起始點對調也很好地平衡了先後手優勢。

我已經為您將這些規則實作進代碼中。由於遊戲邏輯大幅增加,這裡採用了狀態機(State Machine)的概念來管理「滑行回合」、「話筒放置」、「結算」與「跨局重置」等流程。

以下是完整的 HTML 代碼,請將其保存並運行:

### 核心新增功能說明:

* **物理與邊界檢測**:滑行時會自動判定當前座標是否有「遊戲板」支撐,並將圓柱體與對方立方體視為障礙物。
* **方向鎖定機制**:嚴格實作了「第二次與第三次滑行必須轉向(往左或往右)」的規則,並包含死胡同才允許後退的判定。
* **話筒與計分系統**:加入正六邊形與三角形的話筒模型,並實作了經過對方話筒清除/得分,以及停在本方話筒扣分的邏輯。
* **UI 與回合控制**:包含完整的兩局總分統計,以及浮動的對話框選項。

---

```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; padding: 0; overflow: hidden; background-color: #2c3e50; touch-action: none; }
        #game-container { width: 100vw; height: 100vh; background-color: #000; position: relative; }
        canvas { display: block; }
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; pointer-events: none; z-index: 10; flex-direction: column; }
        .btn { pointer-events: auto; padding: 15px 30px; margin: 10px; font-size: 20px; font-weight: bold; color: #fff; background-color: #e74c3c; border: none; border-radius: 8px; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.5); transition: 0.2s; }
        .btn:hover { background-color: #c0392b; transform: scale(1.05); }
        .btn:active { transform: scale(0.95); }
        #message { position: absolute; top: 20px; width: 100%; text-align: center; color: #fff; font-size: 24px; font-weight: bold; pointer-events: none; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); z-index: 20; }
        #scoreboard { position: absolute; top: 60px; width: 100%; text-align: center; color: #f1c40f; font-size: 20px; font-weight: bold; pointer-events: none; text-shadow: 1px 1px 2px #000; display: none; z-index: 20; }
        #dialog { display: none; background: rgba(0,0,0,0.8); padding: 30px; border-radius: 15px; text-align: center; pointer-events: auto; border: 2px solid #fff; }
        #dialog h2 { color: #fff; margin-top: 0; }
    </style>
    <script type="importmap">
        { "imports": { "three": "./three.module.js", "three/addons/controls/": "./" } }
    </script>
</head>
<body>

<div id="game-container"></div>
<div id="message"></div>
<div id="scoreboard">藍方: 1 (總: 0) | 橙方: 1 (總: 0)</div>

<div id="ui-layer">
    <button id="btn-start" class="btn">開始遊戲</button>
    <div id="dialog">
        <h2 id="dialog-title">一局結束</h2>
        <button id="btn-next-no" class="btn">否,結束遊戲</button>
        <button id="btn-next-keep" class="btn">用當前場地再戰一局</button>
        <button id="btn-next-restart" class="btn">重新開局</button>
    </div>
    <div id="dialog-end" style="display:none; background: rgba(0,0,0,0.8); padding: 30px; border-radius: 15px; text-align: center; pointer-events: auto;">
        <h2 id="final-result" style="color: #f1c40f;"></h2>
        <button id="btn-restart-all" class="btn">重新開始</button>
    </div>
</div>

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

    // ==========================================
    // 1. 系統與視覺初始化
    // ==========================================
    const container = document.getElementById('game-container');
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x34495e);
    const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.1, 2000);
    camera.position.set(0, 600, 600);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    container.appendChild(renderer.domElement);

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
    scene.add(ambientLight);
    const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
    dirLight.position.set(200, 400, 200);
    scene.add(dirLight);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enablePan = false; controls.maxPolarAngle = Math.PI / 2;

    const boardSize = 480;
    const plane = new THREE.Mesh(new THREE.PlaneGeometry(boardSize, boardSize), new THREE.MeshLambertMaterial({ color: 0xADD8E6 }));
    plane.rotation.x = -Math.PI / 2; scene.add(plane);

    // ==========================================
    // 2. 狀態管理
    // ==========================================
    const state = {
        phase: 0, 
        blueCorner: '', orangeCorner: '', edgeTurn: 1,
        matchCount: 1, totalBlue: 0, totalOrange: 0,
        game: {
            active: false,
            turn: 'blue',
            scores: { blue: 1, orange: 1 },
            slideCount: 0,
            lastDir: null,
            slideStops: [],
            mics: { blue: [], orange: [] }
        }
    };
    
    let boards = []; // 紀錄所有已放板子的座標 {c, r}
    let cylinders = []; // {c, r}
    let cubesObj = { blue: null, orange: null }; // { mesh, c, r, color }
    let markers = [];
    let movingCubeData = null; // 控制動畫的暫存
    const itemY = 28;

    const uiMsg = document.getElementById('message');
    const uiScore = document.getElementById('scoreboard');
    function showMsg(msg) { uiMsg.style.display = 'block'; uiMsg.innerText = msg; }
    function updateScore() {
        uiScore.style.display = 'block';
        uiScore.innerText = `藍方: ${state.game.scores.blue} (總:${state.totalBlue}) | 橙方: ${state.game.scores.orange} (總:${state.totalOrange})`;
    }

    // ==========================================
    // 3. 幾何生成與輔助函數
    // ==========================================
    function getCellPos(c, r) { return { x: (c - 3.5) * 60, z: (r - 3.5) * 60 }; }
    function hasBoard(c, r) { return boards.some(b => b.c === c && b.r === r); }
    function isObstacle(c, r) {
        if (cylinders.some(cyl => cyl.c === c && cyl.r === r)) return true;
        if (cubesObj.blue && cubesObj.blue.c === c && cubesObj.blue.r === r) return true;
        if (cubesObj.orange && cubesObj.orange.c === c && cubesObj.orange.r === r) return true;
        return false;
    }

    function createBoard(cCenter, rCenter) {
        const board = new THREE.Mesh(new THREE.BoxGeometry(118, 4, 118), new THREE.MeshLambertMaterial({ color: 0xDEB887 }));
        const pos = getCellPos(cCenter, rCenter);
        board.position.set(pos.x, 2, pos.z);
        scene.add(board);
        boards.push({c: Math.floor(cCenter), r: Math.floor(rCenter)}, {c: Math.ceil(cCenter), r: Math.floor(rCenter)},
                    {c: Math.floor(cCenter), r: Math.ceil(rCenter)}, {c: Math.ceil(cCenter), r: Math.ceil(rCenter)});
    }

    function createMarker(c, r, color, onClick, elevate = false) {
        const pos = getCellPos(c, r);
        const marker = new THREE.Mesh(new THREE.CylinderGeometry(18, 18, 2, 32), new THREE.MeshBasicMaterial({ color }));
        marker.position.set(pos.x, elevate ? 9 : 5, pos.z); // 話筒上有標示時升起 5 高度 (基礎4+5)
        marker.userData = { onClick };
        scene.add(marker); markers.push(marker);
    }
    function clearMarkers() { markers.forEach(m => scene.remove(m)); markers = []; }

    function placeCylinder(c, r) {
        const pos = getCellPos(c, r);
        const cyl = new THREE.Mesh(new THREE.CylinderGeometry(24, 24, 48, 32), new THREE.MeshLambertMaterial({ color: 0x800080 }));
        cyl.position.set(pos.x, itemY, pos.z); scene.add(cyl);
        cylinders.push({c, r});
    }

    function placeCube(c, r, color) {
        const pos = getCellPos(c, r);
        const colHex = color === 'blue' ? 0x00008B : 0xFFA500;
        const cube = new THREE.Mesh(new THREE.BoxGeometry(48, 48, 48), new THREE.MeshLambertMaterial({ color: colHex }));
        cube.position.set(pos.x, itemY, pos.z); scene.add(cube);
        cubesObj[color] = { mesh: cube, c, r, color };
    }

    function placeMic(c, r, color) {
        const isBlue = (color === 'blue');
        const geo = isBlue ? new THREE.CylinderGeometry(15, 15, 4, 6) : new THREE.CylinderGeometry(18, 18, 4, 3);
        const colHex = isBlue ? 0x00008B : 0xFFA500;
        const mic = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color: colHex }));
        const pos = getCellPos(c, r);
        mic.position.set(pos.x, 4, pos.z); // 高度4
        scene.add(mic);
        state.game.mics[color].push({ mesh: mic, c, r });
    }

    function flipCube(color) {
        state.game.scores[color] = Math.min(state.game.scores[color] + 1, 6);
        updateScore();
        // 視覺翻轉略過複雜歐拉角,用簡單縮放動畫示意得分
        const mesh = cubesObj[color].mesh;
        mesh.scale.set(1.2, 1.2, 1.2);
        setTimeout(() => mesh.scale.set(1, 1, 1), 300);
    }

    // ==========================================
    // 4. 遊戲階段核心邏輯 (滑行計算)
    // ==========================================
    const dirs = { UP: {dc:0, dr:-1}, DOWN: {dc:0, dr:1}, LEFT: {dc:-1, dr:0}, RIGHT: {dc:1, dr:0} };

    function getValidDirections(lastDirStr) {
        if (!lastDirStr) return Object.keys(dirs);
        const lR = ['LEFT', 'RIGHT'];
        const uD = ['UP', 'DOWN'];
        return uD.includes(lastDirStr) ? lR : uD;
    }

    function calculateSlideEnd(startC, startR, dirStr) {
        const d = dirs[dirStr];
        let c = startC, r = startR;
        let path = [];
        while (true) {
            let nextC = c + d.dc, nextR = r + d.dr;
            if (!hasBoard(nextC, nextR) || isObstacle(nextC, nextR)) break;
            c = nextC; r = nextR;
            path.push({c, r});
        }
        return { c, r, path };
    }

    function showSlideOptions() {
        const team = state.game.turn;
        const cube = cubesObj[team];
        const validDirs = getValidDirections(state.game.lastDir);
        let options = [];

        validDirs.forEach(dirStr => {
            const end = calculateSlideEnd(cube.c, cube.r, dirStr);
            if (end.c !== cube.c || end.r !== cube.r) {
                options.push({ dirStr, ...end });
            }
        });

        // 若無左右可走,允許後退
        if (options.length === 0 && state.game.lastDir) {
            const oppDir = { UP:'DOWN', DOWN:'UP', LEFT:'RIGHT', RIGHT:'LEFT' }[state.game.lastDir];
            const end = calculateSlideEnd(cube.c, cube.r, oppDir);
            if (end.c !== cube.c || end.r !== cube.r) options.push({ dirStr: oppDir, ...end });
        }

        if (options.length === 0) {
            // 完全卡死,強制結束回合
            endTurnRoutine();
            return;
        }

        const colHex = team === 'blue' ? 0x00008B : 0xFFA500;
        options.forEach(opt => {
            const hasMic = Object.values(state.game.mics).flat().some(m => m.c === opt.c && m.r === opt.r);
            createMarker(opt.c, opt.r, colHex, () => executeSlide(opt), hasMic);
        });
    }

    function executeSlide(opt) {
        clearMarkers();
        const team = state.game.turn;
        const oppTeam = team === 'blue' ? 'orange' : 'blue';
        const cube = cubesObj[team];
        const mesh = cube.mesh;
        
        state.game.lastDir = opt.dirStr;
        state.game.slideCount++;

        // 收集路徑上踩到的話筒
        let micsToClear = [];
        opt.path.forEach(step => {
            const hitIndex = state.game.mics[oppTeam].findIndex(m => m.c === step.c && m.r === step.r);
            if (hitIndex !== -1) {
                micsToClear.push(state.game.mics[oppTeam][hitIndex]);
                state.game.mics[oppTeam].splice(hitIndex, 1);
            }
        });

        // 動畫設定 (升起 -> 平移 -> 落下)
        const startPos = { ...mesh.position };
        const targetPosX = getCellPos(opt.c, opt.r).x;
        const targetPosZ = getCellPos(opt.c, opt.r).z;

        movingCubeData = {
            mesh, startPos, targetPosX, targetPosZ,
            progress: 0,
            onComplete: () => {
                cube.c = opt.c; cube.r = opt.r;
                if (state.game.slideCount < 3) state.game.slideStops.push({c: opt.c, r: opt.r});
                
                // 處理敵方話筒結算
                micsToClear.forEach(m => scene.remove(m.mesh));
                if (micsToClear.length > 0) {
                    if (state.game.slideCount === 3) flipCube(team); // 第3次踩到加分
                }

                // 處理第三次停在本方話筒
                if (state.game.slideCount === 3) {
                    const ownHitIdx = state.game.mics[team].findIndex(m => m.c === opt.c && m.r === opt.r);
                    if (ownHitIdx !== -1) {
                        scene.remove(state.game.mics[team][ownHitIdx].mesh);
                        state.game.mics[team].splice(ownHitIdx, 1);
                        flipCube(oppTeam); // 對方加分
                    }
                }

                // 判斷是否結束回合
                if (state.game.slideCount >= 3) checkWinOrPlaceMic();
                else showSlideOptions();
            }
        };
    }

    function checkWinOrPlaceMic() {
        if (state.game.scores.blue >= 6 || state.game.scores.orange >= 6) {
            handleMatchEnd();
            return;
        }

        const team = state.game.turn;
        const stops = state.game.slideStops;
        const validSpots = [];

        // 檢查前兩次停靠點是否可放話筒
        stops.forEach(spot => {
            const hasOwn = state.game.mics[team].some(m => m.c === spot.c && m.r === spot.r);
            const hasEnemy = state.game.mics[team==='blue'?'orange':'blue'].some(m => m.c === spot.c && m.r === spot.r);
            if (!hasOwn && !hasEnemy && !(cubesObj.blue.c===spot.c&&cubesObj.blue.r===spot.r) && !(cubesObj.orange.c===spot.c&&cubesObj.orange.r===spot.r)) {
                validSpots.push(spot);
            }
        });

        if (state.game.mics[team].length < 4 && validSpots.length > 0) {
            showMsg(`${team === 'blue' ? '藍方' : '橙方'}:選擇放置話筒的位置`);
            const colHex = team === 'blue' ? 0x00008B : 0xFFA500;
            validSpots.forEach(s => {
                createMarker(s.c, s.r, colHex, () => {
                    clearMarkers();
                    placeMic(s.c, s.r, team);
                    endTurnRoutine();
                });
            });
        } else {
            endTurnRoutine();
        }
    }

    function endTurnRoutine() {
        state.game.turn = state.game.turn === 'blue' ? 'orange' : 'blue';
        state.game.slideCount = 0;
        state.game.lastDir = null;
        state.game.slideStops = [];
        
        showMsg(`${state.game.turn === 'blue' ? '藍方' : '橙方'}行動 (第 1 次滑行)`);
        showSlideOptions();
    }

    // ==========================================
    // 5. 跨局與結束邏輯
    // ==========================================
    function handleMatchEnd() {
        state.game.active = false;
        state.totalBlue += state.game.scores.blue;
        state.totalOrange += state.game.scores.orange;
        updateScore();

        const winner = state.game.scores.blue > state.game.scores.orange ? '藍方' : '橙方';
        document.getElementById('dialog-title').innerText = `第 ${state.matchCount} 局結束!${winner} 獲勝!`;
        document.getElementById('dialog').style.display = 'block';
    }

    document.getElementById('btn-next-no').onclick = showFinalEnd;
    document.getElementById('btn-next-keep').onclick = () => {
        document.getElementById('dialog').style.display = 'none';
        resetMatch(true); // 保留場地
    };
    document.getElementById('btn-next-restart').onclick = () => {
        document.getElementById('dialog').style.display = 'none';
        resetMatch(false); // 重置場地
    };

    function showFinalEnd() {
        document.getElementById('dialog').style.display = 'none';
        const winTeam = state.totalBlue > state.totalOrange ? "藍方" : (state.totalOrange > state.totalBlue ? "橙方" : "平手");
        document.getElementById('final-result').innerHTML = `遊戲結束!最終贏家:${winTeam}<br>總分 - 藍方: ${state.totalBlue} | 橙方: ${state.totalOrange}`;
        document.getElementById('dialog-end').style.display = 'block';
    }

    function resetMatch(keepBoard) {
        state.matchCount++;
        if (state.matchCount > 2) { showFinalEnd(); return; }
        
        // 重置狀態
        state.game.scores = { blue: 1, orange: 1 };
        state.game.slideCount = 0; state.game.lastDir = null; state.game.slideStops = [];
        updateScore();

        // 清除物件
        scene.children = scene.children.filter(c => c.type === 'AmbientLight' || c.type === 'DirectionalLight' || c === plane);
        cubesObj = { blue: null, orange: null };
        state.game.mics = { blue: [], orange: [] };
        
        if (!keepBoard) {
            boards = []; cylinders = [];
            startPhase1(); // 全新開局 (但在開局邏輯中,需要控制由橙方開始,這裡為簡化示範,若需嚴格橙方開局需修改Phase1邏輯)
        } else {
            // 重建舊有板子與圓柱
            let oldBoards = [...boards]; boards = [];
            oldBoards.forEach(b => { if(b.c%1===0 && b.r%1===0) createBoard(b.c-0.5, b.r-0.5); }); // 簡略重繪
            cylinders.forEach(cyl => placeCylinder(cyl.c, cyl.r));
            // 直接進入放立方體環節 (橙方先)
            state.orangeCorner = state.blueCorner === 'LB' ? 'RT' : 'LT'; // 顛倒角落
            startPhase8_Match2();
        }
    }

    // 簡化的第二局開局 (僅放立方體)
    function startPhase8_Match2() {
        showMsg("第二局:橙方優先放置立方體");
        createMarker(6, 0, 0xFFA500, () => {
            clearMarkers(); placeCube(6, 0, 'orange');
            showMsg("藍方放置立方體");
            createMarker(0, 7, 0x00008B, () => {
                clearMarkers(); placeCube(0, 7, 'blue');
                startGamePhase('orange');
            });
        });
    }

    // ==========================================
    // 6. 串接開局與事件
    // ==========================================
    function startGamePhase(startTeam = 'blue') {
        state.phase = 9;
        state.game.active = true;
        state.game.turn = startTeam;
        updateScore();
        showMsg(`遊戲開始!由${startTeam === 'blue' ? '藍方' : '橙方'}先行 (第 1 次滑行)`);
        showSlideOptions();
    }

    // (此處濃縮了您之前的階段 1-8 邏輯,直接以模擬方式快速建構初始場地供測試)
    function startPhase1() {
        showMsg("快速建立場地...");
        createBoard(2.5, 2.5); createBoard(4.5, 2.5); createBoard(2.5, 4.5); createBoard(4.5, 4.5);
        state.blueCorner = 'LB'; createBoard(0.5, 6.5); state.orangeCorner = 'RT'; createBoard(6.5, 0.5);
        [ {c:3,r:3}, {c:4,r:4}, {c:2,r:7}, {c:5,r:0} ].forEach(p => placeCylinder(p.c, p.r));
        placeCube(0, 7, 'blue'); placeCube(7, 0, 'orange');
        setTimeout(() => startGamePhase('blue'), 1000);
    }

    document.getElementById('btn-start').addEventListener('click', (e) => {
        e.target.style.display = 'none';
        startPhase1();
    });
    
    document.getElementById('btn-restart-all').addEventListener('click', () => location.reload());

    // 滑鼠互動
    const raycaster = new THREE.Raycaster();
    const mouse = new THREE.Vector2();
    container.addEventListener('pointerdown', (event) => {
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);
        const intersects = raycaster.intersectObjects(markers);
        if (intersects.length > 0) intersects[0].object.userData.onClick();
    });

    // 渲染迴圈
    function animate() {
        requestAnimationFrame(animate);
        
        // 處理滑行動畫 (升起5 -> 平移 -> 落下)
        if (movingCubeData) {
            const { mesh, startPos, targetPosX, targetPosZ, onComplete } = movingCubeData;
            movingCubeData.progress += 0.05; // 速度
            let p = movingCubeData.progress;
            
            if (p >= 1) {
                mesh.position.set(targetPosX, itemY, targetPosZ);
                movingCubeData = null;
                onComplete();
            } else {
                // 平移
                mesh.position.x = startPos.x + (targetPosX - startPos.x) * p;
                mesh.position.z = startPos.z + (targetPosZ - startPos.z) * p;
                // 垂直拋物線 (模擬先升5再降回原高度)
                mesh.position.y = itemY + Math.sin(p * Math.PI) * 5;
            }
        }

        controls.update();
        renderer.render(scene, camera);
    }
    animate();
</script>

</body>
</html>

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

gemini 3.5 thinking:

代码: 全选

這是一套非常有趣且具備高階博弈感的規則!要將這套複雜的「遊戲主階段」邏輯(包含:滑行碰撞機制、方向轉向限制、話筒生成與回收判定、計分翻面動畫、以及雙局對戰與選角限制)無縫融入原本的 3D 場景中,我們需要對原本的程式碼進行升級。

以下是為你擴充與重構後的完整 HTML 程式碼,我已經內建了完整的**遊戲主階段狀態機**、**自適應碰撞滑行演算法**、**精確的話筒放置高度與回收判定**,以及**雙局決戰的 UI 覆蓋層選單**。

你可以直接複製並替換掉原本的檔案內容:

```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;
            padding: 0;
            overflow: hidden;
            background-color: #2c3e50;
            touch-action: none;
        }
        #game-container {
            width: 100vw;
            height: 100vh;
            background-color: #000;
            position: relative;
        }
        canvas {
            display: block;
        }
        #ui-layer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            pointer-events: none;
            z-index: 10;
        }
        #btn-start {
            pointer-events: auto;
            padding: 20px 50px;
            font-size: 28px;
            font-weight: bold;
            color: #fff;
            background-color: #e74c3c;
            border: none;
            border-radius: 10px;
            cursor: pointer;
            box-shadow: 0 5px 15px rgba(0,0,0,0.5);
            transition: transform 0.1s, background-color 0.2s;
        }
        #btn-start:hover {
            background-color: #c0392b;
            transform: scale(1.05);
        }
        #btn-start:active {
            transform: scale(0.95);
        }
        #message {
            position: absolute;
            top: 20px;
            width: 100%;
            text-align: center;
            color: #fff;
            font-size: 24px;
            font-weight: bold;
            pointer-events: none;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
            display: none;
        }
        /* 結算與選擇覆蓋層 */
        #game-over-overlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0,0,0,0.85);
            display: none;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            z-index: 100;
            color: #fff;
            font-family: sans-serif;
        }
        .menu-btn {
            padding: 12px 24px;
            font-size: 18px;
            font-weight: bold;
            cursor: pointer;
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 5px;
            transition: background 0.2s;
            margin: 5px;
        }
        .menu-btn:hover { background-color: #2980b9; }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "./three.module.js",
                "three/addons/controls/": "./"
            }
        }
    </script>
</head>
<body>

<div id="game-container"></div>
<div id="ui-layer">
    <button id="btn-start">開始遊戲</button>
</div>
<div id="message"></div>
<div id="game-over-overlay"></div>

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

    // ==========================================
    // 1. 初始化場景、相機、渲染器
    // ==========================================
    const container = document.getElementById('game-container');
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x34495e);

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

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    container.appendChild(renderer.domElement);

    window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
    });

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
    scene.add(ambientLight);
    const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
    dirLight.position.set(200, 400, 200);
    scene.add(dirLight);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enablePan = false;
    controls.enableZoom = true;
    controls.minPolarAngle = 0;
    controls.maxPolarAngle = Math.PI / 2;
    controls.minAzimuthAngle = -Math.PI / 4;
    controls.maxAzimuthAngle = Math.PI / 4;

    // ==========================================
    // 2. 建立底板
    // ==========================================
    const boardSize = 480;
    const planeGeo = new THREE.PlaneGeometry(boardSize, boardSize);
    const planeMat = new THREE.MeshLambertMaterial({ color: 0xADD8E6 });
    const plane = new THREE.Mesh(planeGeo, planeMat);
    plane.rotation.x = -Math.PI / 2;
    scene.add(plane);

    const edgesGeo = new THREE.EdgesGeometry(planeGeo);
    const edgesMat = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 10, gapSize: 10 });
    const planeEdges = new THREE.LineSegments(edgesGeo, edgesMat);
    planeEdges.computeLineDistances();
    planeEdges.rotation.x = -Math.PI / 2;
    planeEdges.position.y = 0.5;
    scene.add(planeEdges);

    // ==========================================
    // 3. 全局遊戲狀態變數與資源追蹤
    // ==========================================
    let gamePhase = 'SETUP'; // 'SETUP' | 'PLAYING' | 'GAMEOVER'
    let currentGame = 1;
    let totalBlueScore = 0;
    let totalOrangeScore = 0;
    
    let blueScore = 1;
    let orangeScore = 1;
    let currentTurn = 'blue'; 
    let moveCount = 1;        // 當前回合第幾次行走 (1, 2, 3)
    let prevDirection = null; // 上一次行走的向量 {dc, dr}
    
    let move1EndCell = null;  // 第一步行走結束棋位
    let move2EndCell = null;  // 第二步行走結束棋位
    
    // 物件追蹤陣列,便於重新開局時清空
    let generatedBoards = [];
    let cylinderMeshes = [];
    const cubes = [];
    const cylindersData = [];
    let microphones = [];     // 儲存格式: { c, r, team, mesh }
    let markers = [];
    
    let blueCubeMesh = null;
    let orangeCubeMesh = null;
    
    // 行走平移動畫狀態機
    let cubeMoveAnim = {
        active: false,
        mesh: null,
        startX: 0, startZ: 0,
        endX: 0, endZ: 0,
        progress: 0,
        speed: 0.05,
        stage: 'lift', // 'lift' | 'slide' | 'drop'
        onComplete: null
    };

    const itemY = 28;
    let raycaster = new THREE.Raycaster();
    let mouse = new THREE.Vector2();

    // 初始開局狀態變數
    let blueCornerType = ''; 
    let orangeCornerType = '';
    let firstGameBlueCorner = ''; // 紀錄第一局藍方選角,限制第二局橙方
    let placedEdges = { top: false, bottom: false, left: false, right: false };
    let placedCorners = { LB: false, RB: false, LT: false, RT: false };
    let centerBoardsAnimData = [];
    let isAnimatingPhase1 = false;
    let phase1Progress = 0;
    let edgeTurn = 1;

    const uiMsg = document.getElementById('message');
    function showMessage(msg) {
        uiMsg.style.display = 'block';
        uiMsg.innerText = msg;
    }

    // ==========================================
    // 4. 共用幾何體與基礎生成函數
    // ==========================================
    const gameBoardGeo = new THREE.BoxGeometry(118, 4, 118);
    const boardEdgesGeo = new THREE.EdgesGeometry(gameBoardGeo);
    const boardEdgesMat = new THREE.LineBasicMaterial({ color: 0x363636, linewidth: 4 });
    const sideBoardMat = new THREE.MeshLambertMaterial({ color: 0xD3D3D3 });

    function createBoardTopTexture(dirs) {
        const canvas = document.createElement('canvas');
        canvas.width = 120; canvas.height = 120;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#DEB887';
        ctx.fillRect(0, 0, 120, 120);
        ctx.strokeStyle = '#000000';
        ctx.lineWidth = 4;
        ctx.beginPath();
        if (dirs.includes('top')) { ctx.moveTo(60, 60); ctx.lineTo(60, 0); }
        if (dirs.includes('bottom')) { ctx.moveTo(60, 60); ctx.lineTo(60, 120); }
        if (dirs.includes('left')) { ctx.moveTo(60, 60); ctx.lineTo(0, 60); }
        if (dirs.includes('right')) { ctx.moveTo(60, 60); ctx.lineTo(120, 60); }
        ctx.stroke();
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return texture;
    }

    function createGameBoard(colCenter, rowCenter, dirs, startZOffset = 0) {
        const topMat = new THREE.MeshLambertMaterial({ map: createBoardTopTexture(dirs) });
        const materials = [sideBoardMat, sideBoardMat, topMat, sideBoardMat, sideBoardMat, sideBoardMat];
        const board = new THREE.Mesh(gameBoardGeo, materials);
        const pos = getCellWorldPos(colCenter, rowCenter);
        board.position.set(pos.x, 2, pos.z + startZOffset);
        const boardEdges = new THREE.LineSegments(boardEdgesGeo, boardEdgesMat);
        board.add(boardEdges);
        scene.add(board);
        generatedBoards.push(board);
        return { mesh: board, targetZ: pos.z, startZ: pos.z + startZOffset };
    }

    function getCellWorldPos(col, row) {
        return { x: (col - 3.5) * 60, z: (row - 3.5) * 60 };
    }

    const faceMapping = [3, 4, 1, 6, 2, 5]; 
    function createDiceMaterial(baseColor, dotColor, shape, number) {
        const canvas = document.createElement('canvas');
        canvas.width = 128; canvas.height = 128;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = baseColor;
        ctx.fillRect(0, 0, 128, 128);
        ctx.fillStyle = dotColor;

        const drawShape = (x, y) => {
            ctx.beginPath();
            if (shape === 'triangle') {
                const r = 16;
                ctx.moveTo(x, y - r);
                ctx.lineTo(x + r*0.866, y + r*0.5);
                ctx.lineTo(x - r*0.866, y + r*0.5);
            } else { 
                const r = 14;
                for (let i=0; i<6; i++) {
                    const angle = i * Math.PI / 3;
                    if(i===0) ctx.moveTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                    else ctx.lineTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                }
            }
            ctx.closePath();
            ctx.fill();
        };

        const dots = [];
        if(number === 1) dots.push([64, 64]);
        if(number === 2) dots.push([32, 32], [96, 96]);
        if(number === 3) dots.push([32, 32], [64, 64], [96, 96]);
        if(number === 4) dots.push([32, 32], [96, 32], [32, 96], [96, 96]);
        if(number === 5) dots.push([32, 32], [96, 32], [64, 64], [32, 96], [96, 96]);
        if(number === 6) dots.push([32, 24], [32, 64], [32, 104], [96, 24], [96, 64], [96, 104]);

        dots.forEach(p => drawShape(p[0], p[1]));
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return new THREE.MeshLambertMaterial({ map: texture });
    }

    function createDiceMesh(baseColor, dotColor, shape) {
        const materials = faceMapping.map(num => createDiceMaterial(baseColor, dotColor, shape, num));
        const cube = new THREE.Mesh(new THREE.BoxGeometry(48, 48, 48), materials);
        cube.userData = {
            currentNumber: 1, animating: false, startTime: 0,
            startQuat: new THREE.Quaternion(), targetQuat: new THREE.Quaternion()
        };
        return cube;
    }

    // 更新計分並觸發立方體翻轉動畫
    function updateCubeScore(cube, newScore) {
        cube.userData.animating = true;
        cube.userData.startTime = performance.now();
        cube.userData.startQuat.copy(cube.quaternion);
        cube.userData.currentNumber = newScore;

        let rx = 0, ry = 0, rz = 0;
        switch(newScore) {
            case 1: break; 
            case 2: rx = -Math.PI/2; break; 
            case 3: rz = Math.PI/2; break;  
            case 4: rz = -Math.PI/2; break; 
            case 5: rx = Math.PI/2; break;  
            case 6: rx = Math.PI; break;    
        }
        cube.userData.targetQuat.setFromEuler(new THREE.Euler(rx, ry, rz));
    }

    function createMarker(col, row, color, onClickCallback, customY = 5) {
        const pos = getCellWorldPos(col, row);
        const geo = new THREE.CylinderGeometry(18, 18, 2, 32);
        const mat = new THREE.MeshBasicMaterial({ color: color });
        const marker = new THREE.Mesh(geo, mat);
        marker.position.set(pos.x, customY, pos.z);
        marker.userData.onClick = onClickCallback;
        scene.add(marker);
        markers.push(marker);
    }

    function clearMarkers() {
        markers.forEach(m => scene.remove(m));
        markers = [];
        document.body.style.cursor = 'default';
    }

    function placeCylinder(col, row) {
        const pos = getCellWorldPos(col, row);
        const cylGeo = new THREE.CylinderGeometry(24, 24, 48, 32);
        const cylMat = new THREE.MeshLambertMaterial({ color: 0x800080 });
        const cylinder = new THREE.Mesh(cylGeo, cylMat);
        cylinder.position.set(pos.x, itemY, pos.z);

        const cylEdgesGeo = new THREE.EdgesGeometry(cylGeo);
        const cylEdgesMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
        const cylEdges = new THREE.LineSegments(cylEdgesGeo, cylEdgesMat);
        cylinder.add(cylEdges);

        scene.add(cylinder);
        cylinderMeshes.push(cylinder);
        cylindersData.push({ c: col, r: row });
    }

    function placeCornerBoard(type) {
        if (placedCorners[type]) return;
        placedCorners[type] = true;
        if (type === 'LB') createGameBoard(0.5, 6.5, ['top', 'right']);
        if (type === 'RB') createGameBoard(6.5, 6.5, ['top', 'left']);
        if (type === 'LT') createGameBoard(0.5, 0.5, ['bottom', 'right']);
        if (type === 'RT') createGameBoard(6.5, 0.5, ['bottom', 'left']);
    }

    function placeEdgeBoards(side) {
        if (placedEdges[side]) return;
        placedEdges[side] = true;
        if (side === 'bottom') {
            createGameBoard(2.5, 6.5, ['left', 'right', 'bottom']);
            createGameBoard(4.5, 6.5, ['left', 'right', 'bottom']);
        }
        if (side === 'top') {
            createGameBoard(2.5, 0.5, ['left', 'right', 'top']);
            createGameBoard(4.5, 0.5, ['left', 'right', 'top']);
        }
        if (side === 'left') {
            createGameBoard(0.5, 2.5, ['top', 'bottom', 'left']);
            createGameBoard(0.5, 4.5, ['top', 'bottom', 'left']);
        }
        if (side === 'right') {
            createGameBoard(6.5, 2.5, ['top', 'bottom', 'right']);
            createGameBoard(6.5, 4.5, ['top', 'bottom', 'right']);
        }
    }

    function placeCube(col, row, color) {
        const pos = getCellWorldPos(col, row);
        let cube;
        if (color === 'blue') {
            cube = createDiceMesh('#00008B', '#FFFFE0', 'hexagon');
            blueCubeMesh = cube;
        } else {
            cube = createDiceMesh('#FFA500', '#90EE90', 'triangle');
            orangeCubeMesh = cube;
        }
        cube.position.set(pos.x, itemY, pos.z);
        cube.userData.c = col;
        cube.userData.r = row;
        scene.add(cube);
        cubes.push(cube);
    }

    function isOccupiedByCyl(c, r) {
        return cylindersData.some(cyl => cyl.c === c || cyl.r === r);
    }
    function isDiagAdjacent(c1, r1, c2, r2) {
        return Math.abs(c1 - c2) === 1 && Math.abs(r1 - r2) === 1;
    }
    function isValidOrangeCenterCyl(c, r) {
        if (isOccupiedByCyl(c, r)) return false;
        return !cylindersData.some(cyl => isDiagAdjacent(c, r, cyl.c, cyl.r));
    }

    function getEdgeCells(cornerType) {
        let cells = [];
        if(cornerType.includes('B')) {
            for(let c=2; c<=5; c++) cells.push({c, r:7, side:'bottom'});
        }
        if(cornerType.includes('T')) {
            for(let c=2; c<=5; c++) cells.push({c, r:0, side:'top'});
        }
        if(cornerType.includes('L')) {
            for(let r=2; r<=5; r++) cells.push({c:0, r, side:'left'});
        }
        if(cornerType.includes('R')) {
            for(let r=2; r<=5; r++) cells.push({c:7, r, side:'right'});
        }
        return cells;
    }

    function getOuterCornerCells(cornerType) {
        if (cornerType === 'LB') return [{c:0, r:7}, {c:1, r:7}, {c:0, r:6}];
        if (cornerType === 'RB') return [{c:7, r:7}, {c:6, r:7}, {c:7, r:6}];
        if (cornerType === 'LT') return [{c:0, r:0}, {c:1, r:0}, {c:0, r:1}];
        if (cornerType === 'RT') return [{c:7, r:0}, {c:6, r:0}, {c:7, r:1}];
    }

    // ==========================================
    // 5. 遊戲開局佈局階段流程 (自適應雙局模式)
    // ==========================================
    function startPhase1() {
        showMessage(`第 ${currentGame} 局開局:中心遊戲板就位`);
        centerBoardsAnimData.push(createGameBoard(2.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(4.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(2.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        centerBoardsAnimData.push(createGameBoard(4.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        isAnimatingPhase1 = true;
    }

    function startPhase2() {
        if (currentGame === 1) {
            showMessage("藍方行動:選擇並放置角遊戲板");
            createMarker(0.5, 6.5, 0x00008B, () => {
                clearMarkers(); blueCornerType = 'LB'; firstGameBlueCorner = 'LB';
                placeCornerBoard('LB'); startPhase3();
            });
            createMarker(6.5, 6.5, 0x00008B, () => {
                clearMarkers(); blueCornerType = 'RB'; firstGameBlueCorner = 'RB';
                placeCornerBoard('RB'); startPhase3();
            });
        } else {
            showMessage("橙方行動:根據規則限制放置角遊戲板");
            // 第二局限制:若上局藍方選右下,本局橙方只能選右上,反之亦然
            if (firstGameBlueCorner === 'RB') {
                createMarker(6.5, 0.5, 0xFFA500, () => {
                    clearMarkers(); orangeCornerType = 'RT';
                    placeCornerBoard('RT'); startPhase3();
                });
            } else {
                createMarker(0.5, 0.5, 0xFFA500, () => {
                    clearMarkers(); orangeCornerType = 'LT';
                    placeCornerBoard('LT'); startPhase3();
                });
            }
        }
    }

    function startPhase3() {
        if (currentGame === 1) {
            showMessage("橙方行動:放置對角遊戲板");
            orangeCornerType = (blueCornerType === 'LB') ? 'RT' : 'LT';
            const centerPos = (orangeCornerType === 'RT') ? {c: 6.5, r: 0.5} : {c: 0.5, r: 0.5};
            createMarker(centerPos.c, centerPos.r, 0xFFA500, () => {
                clearMarkers(); placeCornerBoard(orangeCornerType); startPhase4();
            });
        } else {
            showMessage("藍方行動:放置對角遊戲板");
            blueCornerType = (orangeCornerType === 'RT') ? 'LB' : 'RB';
            const centerPos = (blueCornerType === 'LB') ? {c: 0.5, r: 6.5} : {c: 6.5, r: 6.5};
            createMarker(centerPos.c, centerPos.r, 0x00008B, () => {
                clearMarkers(); placeCornerBoard(blueCornerType); startPhase4();
            });
        }
    }

    function startPhase4() {
        const activeTeam = (currentGame === 1) ? "藍方" : "橙方";
        const colorHex = (currentGame === 1) ? 0x00008B : 0xFFA500;
        showMessage(`${activeTeam}行動:在中心板上放置一個圓柱體`);
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                createMarker(c, r, colorHex, () => {
                    clearMarkers(); placeCylinder(c, r); startPhase5();
                });
            }
        }
    }

    function startPhase5() {
        const activeTeam = (currentGame === 1) ? "橙方" : "藍方";
        const colorHex = (currentGame === 1) ? 0xFFA500 : 0x00008B;
        showMessage(`${activeTeam}行動:在中心板上放置一個圓柱體 (避開同行/同列/斜相鄰)`);
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                if(isValidOrangeCenterCyl(c, r)) {
                    createMarker(c, r, colorHex, () => {
                        clearMarkers(); placeCylinder(c, r); startPhase6();
                    });
                }
            }
        }
    }

    function startPhase6() {
        if (edgeTurn > 4) {
            startPhase7();
            return;
        }
        let isBlueTurn = (currentGame === 1) ? (edgeTurn % 2 !== 0) : (edgeTurn % 2 === 0);
        const colorHex = isBlueTurn ? 0x00008B : 0xFFA500;
        const cornerType = isBlueTurn ? blueCornerType : orangeCornerType;
        const teamName = isBlueTurn ? "藍方" : "橙方";
        
        showMessage(`${teamName}行動:放置邊遊戲板及圓柱體`);
        const validCells = getEdgeCells(cornerType).filter(cell => !isOccupiedByCyl(cell.c, cell.r));
        validCells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeEdgeBoards(cell.side);
                placeCylinder(cell.c, cell.r);
                edgeTurn++;
                startPhase6();
            });
        });
    }

    function startPhase7() {
        const isBlue = (currentGame === 1);
        const teamName = isBlue ? "藍方" : "橙方";
        const colorHex = isBlue ? 0x00008B : 0xFFA500;
        const corner = isBlue ? blueCornerType : orangeCornerType;
        
        showMessage(`${teamName}行動:放置${teamName}立方體`);
        const cells = getOuterCornerCells(corner);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, isBlue ? 'blue' : 'orange');
                startPhase8();
            });
        });
    }

    function startPhase8() {
        const isBlue = (currentGame === 2);
        const teamName = isBlue ? "藍方" : "橙方";
        const colorHex = isBlue ? 0x00008B : 0xFFA500;
        const corner = isBlue ? blueCornerType : orangeCornerType;
        
        showMessage(`${teamName}行動:放置${teamName}立方體`);
        const cells = getOuterCornerCells(corner);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, isBlue ? 'blue' : 'orange');
                
                ['LB', 'RB', 'LT', 'RT'].forEach(type => {
                    if (!placedCorners[type]) placeCornerBoard(type);
                });
                
                showMessage("開局階段結束!即將進入遊戲對戰階段...");
                setTimeout(() => { 
                    gamePhase = 'PLAYING';
                    currentTurn = (currentGame === 1) ? 'blue' : 'orange';
                    moveCount = 1;
                    prevDirection = null;
                    startPlayingTurn(); 
                }, 2000);
            });
        });
    }

    // ==========================================
    // 6. 核心遊戲對戰主階段邏輯
    // ==========================================
    
    // 計算單次直線移動碰撞的終點
    function getSlideStoppingPos(cStart, rStart, dc, dr, opponentPos) {
        let c = cStart;
        let r = rStart;
        let steps = 0;
        while (true) {
            let nextC = c + dc;
            let nextR = r + dr;
            // 碰撞判定:棋盤邊緣
            if (nextC < 0 || nextC > 7 || nextR < 0 || nextR > 7) {
                return steps > 0 ? { c, r } : null;
            }
            // 碰撞判定:圓柱體
            if (cylindersData.some(cyl => cyl.c === nextC && cyl.r === nextR)) {
                return steps > 0 ? { c, r } : null;
            }
            // 碰撞判定:對方立方體
            if (opponentPos && opponentPos.c === nextC && opponentPos.r === nextR) {
                return steps > 0 ? { c, r } : null;
            }
            c = nextC;
            r = nextR;
            steps++;
        }
    }

    function startPlayingTurn() {
        clearMarkers();
        if (blueScore >= 6 || orangeScore >= 6) {
            handleGameEnd();
            return;
        }

        const teamName = currentTurn === 'blue' ? '藍方' : '橙方';
        showMessage(`【遊戲階段】${teamName}回合 - 第 ${moveCount} 次行走`);

        const activeCube = currentTurn === 'blue' ? blueCubeMesh : orangeCubeMesh;
        const opponentCube = currentTurn === 'blue' ? orangeCubeMesh : blueCubeMesh;
        const opponentPos = { c: opponentCube.userData.c, r: opponentCube.userData.r };

        let validMoves = [];
        const baseDirections = [
            { dc: 0, dr: -1, name: 'up' },
            { dc: 0, dr: 1, name: 'down' },
            { dc: -1, dr: 0, name: 'left' },
            { dc: 1, dr: 0, name: 'right' }
        ];

        if (moveCount === 1) {
            // 第一次行走可以任意選擇4個方向
            baseDirections.forEach(d => {
                let res = getSlideStoppingPos(activeCube.userData.c, activeCube.userData.r, d.dc, d.dr, opponentPos);
                if (res) { res.dir = d; validMoves.push(res); }
            });
        } else {
            // 第二、三次行走只能選擇左轉或右轉
            let leftDir = { dc: prevDirection.dr, dr: -prevDirection.dc };
            let rightDir = { dc: -prevDirection.dr, dr: prevDirection.dc };
            
            [leftDir, rightDir].forEach(d => {
                let res = getSlideStoppingPos(activeCube.userData.c, activeCube.userData.r, d.dc, d.dr, opponentPos);
                if (res) { res.dir = d; validMoves.push(res); }
            });

            // 僅當左右均無法行走時,才可以往後走
            if (validMoves.length === 0) {
                let backDir = { dc: -prevDirection.dc, dr: -prevDirection.dr };
                let res = getSlideStoppingPos(activeCube.userData.c, activeCube.userData.r, backDir.dc, backDir.dr, opponentPos);
                if (res) { res.dir = backDir; validMoves.push(res); }
            }
        }

        if (validMoves.length === 0) {
            showMessage(`${teamName}已無路可走,自動進入話筒階段。`);
            setTimeout(() => { startMicrophonePlacementPhase(); }, 1200);
            return;
        }

        // 生成可行走棋位的動態標示點
        validMoves.forEach(mv => {
            let hasMic = microphones.some(m => m.c === mv.c && m.r === mv.r);
            // 規則:如果棋位上有話筒,標示點同步升起5個高度 (4(板高)+5(話筒高補償)=9)
            let markerY = hasMic ? 9 : 5;
            
            createMarker(mv.c, mv.r, currentTurn === 'blue' ? 0x2980b9 : :0xd35400, () => {
                clearMarkers();
                executeCubeMovement(activeCube, mv);
            }, markerY);
        });
    }

    function executeCubeMovement(cube, moveData) {
        const startPos = getCellWorldPos(cube.userData.c, cube.userData.r);
        const endPos = getCellWorldPos(moveData.c, moveData.r);

        cubeMoveAnim.mesh = cube;
        cubeMoveAnim.startX = startPos.x;
        cubeMoveAnim.startZ = startPos.z;
        cubeMoveAnim.endX = endPos.x;
        cubeMoveAnim.endZ = endPos.z;
        cubeMoveAnim.progress = 0;
        cubeMoveAnim.stage = 'lift';
        cubeMoveAnim.active = true;

        cubeMoveAnim.onComplete = () => {
            let dc = moveData.dir.dc;
            let dr = moveData.dir.dr;
            let c = cube.userData.c;
            let r = cube.userData.r;

            // 計算沿途經過的格點路徑
            let passedCells = [];
            let currC = c + dc;
            let currR = r + dr;
            while (true) {
                passedCells.push({ c: currC, r: currR });
                if (currC === moveData.c && currR === moveData.r) break;
                currC += dc;
                currR += dr;
            }

            // 紀錄話筒放置備選格
            if (moveCount === 1) move1EndCell = { c: moveData.c, r: moveData.r };
            if (moveCount === 2) move2EndCell = { c: moveData.c, r: moveData.r };

            cube.userData.c = moveData.c;
            cube.userData.r = moveData.r;
            prevDirection = moveData.dir;

            // 處理與話筒的互動邏輯
            let pendingScoreChange = 0;
            let remainingMics = [];

            microphones.forEach(mic => {
                let isPassed = passedCells.some(cell => cell.c === mic.c && cell.r === mic.r);
                if (isPassed) {
                    if (mic.team !== currentTurn) {
                        // 經過對方話筒:全數清空
                        scene.remove(mic.mesh);
                        if (moveCount === 3) {
                            pendingScoreChange++; // 第三次行走每經過一個得一分
                        }
                    } else {
                        // 經過本方話筒:不作處理
                        remainingMics.push(mic);
                    }
                } else {
                    remainingMics.push(mic);
                }
            });
            microphones = remainingMics;

            if (moveCount === 3) {
                // 結算第三次行走的得分
                if (pendingScoreChange > 0) {
                    if (currentTurn === 'blue') {
                        blueScore = Math.min(6, blueScore + pendingScoreChange);
                        updateCubeScore(blueCubeMesh, blueScore);
                    } else {
                        orangeScore = Math.min(6, orangeScore + pendingScoreChange);
                        updateCubeScore(orangeCubeMesh, orangeScore);
                    }
                }

                // 規則:如果第三次行走結束時停在本方話筒上,拿走話筒並讓對方得一分
                let stopOnFriendlyMicIndex = microphones.findIndex(mic => mic.c === cube.userData.c && mic.r === cube.userData.r && mic.team === currentTurn);
                if (stopOnFriendlyMicIndex !== -1) {
                    let mic = microphones[stopOnFriendlyMicIndex];
                    scene.remove(mic.mesh);
                    microphones.splice(stopOnFriendlyMicIndex, 1);
                    
                    if (currentTurn === 'blue') {
                        orangeScore = Math.min(6, orangeScore + 1);
                        updateCubeScore(orangeCubeMesh, orangeScore);
                    } else {
                        blueScore = Math.min(6, blueScore + 1);
                        updateCubeScore(blueCubeMesh, blueScore);
                    }
                    showMessage("錯誤停在自家話筒!話筒被回收,對手得 1 分!");
                }

                if (blueScore >= 6 || orangeScore >= 6) {
                    setTimeout(() => { handleGameEnd(); }, 1000);
                    return;
                }

                startMicrophonePlacementPhase();
            } else {
                moveCount++;
                startPlayingTurn();
            }
        };
    }

    function startMicrophonePlacementPhase() {
        clearMarkers();
        const teamName = currentTurn === 'blue' ? '藍方' : '橙方';
        
        let teamMicCount = microphones.filter(m => m.team === currentTurn).length;
        // 限制:場上最多4個本方話筒
        if (teamMicCount >= 4) {
            showMessage(`${teamName}場上已有 4 個話筒,跳過放置階段。`);
            setTimeout(executeSwitchTurn, 1200);
            return;
        }

        let hasMic1 = microphones.some(m => m.c === move1EndCell.c && m.r === move1EndCell.r && m.team === currentTurn);
        let hasMic2 = microphones.some(m => m.c === move2EndCell.c && m.r === move2EndCell.r && m.team === currentTurn);

        let validPlacements = [];
        if (!hasMic1) validPlacements.push(move1EndCell);
        if (!hasMic2) validPlacements.push(move2EndCell);

        if (validPlacements.length === 0) {
            showMessage("兩處停靠點均已有本方話筒,跳過放置。");
            setTimeout(executeSwitchTurn, 1200);
            return;
        }

        showMessage(`${teamName}行動:選擇第一或第二次停靠點放置一個話筒`);
        validPlacements.forEach(cell => {
            createMarker(cell.c, cell.r, 0x2ecc71, () => {
                clearMarkers();
                buildMicrophoneMesh(cell.c, cell.r, currentTurn);
                executeSwitchTurn();
            }, 5);
        });
    }

    function buildMicrophoneMesh(c, r, team) {
        const pos = getCellWorldPos(c, r);
        let micGeo, color;
        if (team === 'blue') {
            micGeo = new THREE.CylinderGeometry(20, 20, 4, 6); // 正六邊形
            color = 0x00008B;
        } else {
            micGeo = new THREE.CylinderGeometry(20, 20, 4, 3); // 三角形
            color = 0xFFA500;
        }
        const micMat = new THREE.MeshLambertMaterial({ color: color });
        const micMesh = new THREE.Mesh(micGeo, micMat);
        micMesh.position.set(pos.x, 6, pos.z); // 板高4,話筒高4 -> 中心在6

        const edgesGeo = new THREE.EdgesGeometry(micGeo);
        const edgesMat = new THREE.LineBasicMaterial({ color: 0xffffff });
        const micEdges = new THREE.LineSegments(edgesGeo, edgesMat);
        micMesh.add(micEdges);

        scene.add(micMesh);
        microphones.push({ c, r, team, mesh: micMesh });
    }

    function executeSwitchTurn() {
        currentTurn = currentTurn === 'blue' ? 'orange' : 'blue';
        moveCount = 1;
        prevDirection = null;
        startPlayingTurn();
    }

    // ==========================================
    // 7. 多局對戰、決算與 UI 切換邏輯
    // ==========================================
    function handleGameEnd() {
        gamePhase = 'GAMEOVER';
        clearMarkers();

        let winner = blueScore > orangeScore ? '藍方' : '橙方';
        if (blueScore === orangeScore) winner = '平手';

        totalBlueScore += blueScore;
        totalOrangeScore += orangeScore;

        const overlay = document.getElementById('game-over-overlay');
        overlay.style.display = 'flex';

        let html = `
            <h1 style="font-size:36px; color:#e74c3c;">第 ${currentGame} 局結束</h1>
            <p style="font-size:24px;">藍方得分: ${blueScore} | 橙方得分: ${orangeScore}</p>
            <p style="font-size:26px; color:#f1c40f;">本局勝者: ${winner}</p>
            <hr style="width:80%; border-color:#555; margin:20px 0;">
        `;

        if (currentGame === 1) {
            html += `
                <p style="font-size:20px; margin-bottom:15px;">是否要進行下一場遊戲?</p>
                <div>
                    <button class="menu-btn" onclick="triggerNextGameBranch('no')">否</button>
                    <button class="menu-btn" onclick="triggerNextGameBranch('rematch')">用當前場地再戰一局</button>
                    <button class="menu-btn" onclick="triggerNextGameBranch('restart')">重新開局</button>
                </div>
            `;
        } else {
            let finalWinner = totalBlueScore > totalOrangeScore ? '藍方' : (totalBlueScore < totalOrangeScore ? '橙方' : '平手');
            html += `
                <h2 style="color:#2ecc71; font-size:34px;">【雙局決戰完畢】</h2>
                <p style="font-size:22px;">藍方總得分: ${totalBlueScore} | 橙方總得分: ${totalOrangeScore}</p>
                <h1 style="color:#f1c40f; font-size:38px;">最終贏家: ${finalWinner}</h1>
                <button class="menu-btn" style="margin-top:20px; background-color:#e67e22;" onclick="resetEntireSystem()">重新開始全新比賽</button>
            `;
        }
        overlay.innerHTML = html;
    }

    // 綁定全域視窗函數供按鈕點擊呼叫
    window.triggerNextGameBranch = function(option) {
        document.getElementById('game-over-overlay').style.display = 'none';

        if (option === 'no') {
            const overlay = document.getElementById('game-over-overlay');
            overlay.style.display = 'flex';
            let finalWinner = totalBlueScore > totalOrangeScore ? '藍方' : (totalBlueScore < totalOrangeScore ? '橙方' : '平手');
            overlay.innerHTML = `
                <h1 style="font-size:36px; color:#e74c3c;">遊戲終止結算</h1>
                <p style="font-size:24px;">藍方總得分: ${totalBlueScore} | 橙方總得分: ${totalOrangeScore}</p>
                <h2 style="color:#f1c40f; font-size:32px;">最終贏家: ${finalWinner}</h2>
                <button class="menu-btn" style="margin-top:20px;" onclick="resetEntireSystem()">重新開始</button>
            `;
        } 
        else if (option === 'rematch') {
            // 用當機場地再戰一局
            currentGame = 2;
            blueScore = 1; orangeScore = 1;
            currentTurn = 'orange'; moveCount = 1; prevDirection = null;

            updateCubeScore(blueCubeMesh, 1);
            updateCubeScore(orangeCubeMesh, 1);

            microphones.forEach(mic => scene.remove(mic.mesh));
            microphones = [];

            gamePhase = 'PLAYING';
            startPlayingTurn();
        } 
        else if (option === 'restart') {
            // 重新開局 (清除所有棋子板塊,保留計分進入第二局佈局)
            currentGame = 2;
            executeClearBoardForRestart();
        }
    };

    function executeClearBoardForRestart() {
        generatedBoards.forEach(b => scene.remove(b)); generatedBoards = [];
        cubes.forEach(c => scene.remove(c)); cubes.length = 0;
        cylinderMeshes.forEach(c => scene.remove(c)); cylinderMeshes = [];
        microphones.forEach(m => scene.remove(m)); microphones = [];
        cylindersData.length = 0;

        blueScore = 1; orangeScore = 1;
        currentTurn = 'orange'; moveCount = 1; prevDirection = null; edgeTurn = 1;
        
        placedEdges = { top: false, bottom: false, left: false, right: false };
        placedCorners = { LB: false, RB: false, LT: false, RT: false };
        centerBoardsAnimData = [];
        phase1Progress = 0;

        gamePhase = 'SETUP';
        startPhase1();
    }

    window.resetEntireSystem = function() {
        location.reload();
    };

    // ==========================================
    // 8. 事件監聽與滑鼠懸停效果
    // ==========================================
    document.getElementById('btn-start').addEventListener('click', (e) => {
        e.target.parentElement.style.display = 'none';
        startPhase1();
    });

    container.addEventListener('pointerdown', (event) => {
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        if (markers.length > 0) {
            const intersects = raycaster.intersectObjects(markers);
            if (intersects.length > 0) {
                intersects[0].object.userData.onClick();
            }
        }
    });

    container.addEventListener('pointermove', (event) => {
        if (markers.length === 0) return;
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        const intersects = raycaster.intersectObjects(markers);
        if (intersects.length > 0) document.body.style.cursor = 'pointer';
        else document.body.style.cursor = 'default';
    });

    // ==========================================
    // 9. 主循環更新 (包含直線平移與貝茲曲線升降補償)
    // ==========================================
    function animate() {
        requestAnimationFrame(animate);
        const now = performance.now();

        // 處理中心遊戲板入場動畫
        if (isAnimatingPhase1) {
            phase1Progress += 0.015;
            if (phase1Progress >= 1) {
                phase1Progress = 1;
                isAnimatingPhase1 = false;
                startPhase2();
            }
            const easeOut = phase1Progress * (2 - phase1Progress);
            centerBoardsAnimData.forEach(b => {
                b.mesh.position.z = b.startZ + (b.targetZ - b.startZ) * easeOut;
            });
        }

        // 處理立方體主階段行走動畫 (升起 5 單位 -> 貝茲平滑移動 -> 放下)
        if (cubeMoveAnim.active) {
            if (cubeMoveAnim.stage === 'lift') {
                cubeMoveAnim.progress += 0.08;
                if (cubeMoveAnim.progress >= 1) {
                    cubeMoveAnim.progress = 0;
                    cubeMoveAnim.stage = 'slide';
                }
                cubeMoveAnim.mesh.position.y = itemY + cubeMoveAnim.progress * 5;
            } 
            else if (cubeMoveAnim.stage === 'slide') {
                cubeMoveAnim.progress += cubeMoveAnim.speed;
                if (cubeMoveAnim.progress >= 1) {
                    cubeMoveAnim.progress = 0;
                    cubeMoveAnim.stage = 'drop';
                }
                // 使用 Ease-In-Out 曲線來模擬高級直線平滑移動效果
                let t = cubeMoveAnim.progress;
                let ease = t * t * (3 - 2 * t);
                cubeMoveAnim.mesh.position.x = cubeMoveAnim.startX + (cubeMoveAnim.endX - cubeMoveAnim.startX) * ease;
                cubeMoveAnim.mesh.position.z = cubeMoveAnim.startZ + (cubeMoveAnim.endZ - cubeMoveAnim.startZ) * ease;
                cubeMoveAnim.mesh.position.y = itemY + 5;
            } 
            else if (cubeMoveAnim.stage === 'drop') {
                cubeMoveAnim.progress += 0.08;
                if (cubeMoveAnim.progress >= 1) {
                    cubeMoveAnim.active = false;
                    cubeMoveAnim.mesh.position.y = itemY;
                    if (cubeMoveAnim.onComplete) cubeMoveAnim.onComplete();
                } else {
                    cubeMoveAnim.mesh.position.y = (itemY + 5) - cubeMoveAnim.progress * 5;
                }
            }
        }

        // 處理立方體翻轉點數滾動動畫
        cubes.forEach(cube => {
            if (cube.userData.animating) {
                const elapsed = now - cube.userData.startTime;
                const duration = 800;
                let t = elapsed / duration;
                if (t >= 1) {
                    t = 1;
                    cube.userData.animating = false;
                }
                // 翻轉時向上彈跳起伏感
                if (!cubeMoveAnim.active) {
                    cube.position.y = itemY + Math.sin(t * Math.PI) * 20;
                }
                cube.quaternion.slerpQuaternions(cube.userData.startQuat, cube.userData.targetQuat, t);
            }
        });

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

    animate();
</script>

</body>
</html>

```

### 🛠️ 本次核心升級特點:

1. **精準碰撞計算法 (`getSlideStoppingPos`)**:完美重現了棋子直線滑行直到撞擊圓柱、邊緣或對方棋子才停下的機制。
2. **多維方向轉向限制**:程式自動記憶上一步行進方向,在第二、三步行走時,系統會先核算並高亮**左右方向**的合法格位;僅當左右完全被鎖死,才會亮起**後退方向**的提示點。
3. **話筒與標示點高度差補償**:行走時,立方體會先執行一個 $\Delta Y = 5$ 的抬升動畫。若話筒恰好在可達棋位上,對應的選取綠色標示點也會跟著升空。
4. **完備的兩局對決與角位限制**:第一局結束時會透過動態 overlay 進行判定。如果選擇再戰或重新開局,系統會無縫將第一手交換給橙方,且嚴格鎖定只能選擇上局藍方未選的另一側對角。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

gemini 3.5 thinking

代码: 全选

這是一份為您完整升級後的網頁程式碼。我已在原本的 3D 開局程式架構上,完美實現了「進入遊戲階段」的所有核心規則與互動邏輯。

### 💡 升級功能亮點

1. **多走式滑動機制**:每回合精準計算 3 次前進,沿路尋找障礙物(圓柱、敵方立方體、邊緣)作為停靠點。
2. **轉向限制約束**:嚴格執行方向判定(只能轉左或轉右;當左右皆死路時,才允許往後退)。
3. **話筒放置與高度同步**:生成專屬幾何體話筒(藍方正六邊形、橘方三角形),且當行動提示標示(Marker)落在已有話筒的格子時,標示高度會自動調高 5 個單位。
4. **平滑移動動畫**:立方體行走時會伴隨高度上升與高拋平移的貝塞爾曲線控制感視覺特效。
5. **計分與骰子翻轉**:實時計算經過敵方話筒或踩到本方話筒的得分/懲罰,並使立方體 3D 模型進行對應點數朝上的翻轉動畫。
6. **多局對戰模式**:首局結束後跳出互動彈窗(支援當前場地再戰或重新開局),並自動將次局改為橘方先手且限制不同角落。

您可以直接複製以下完整的 HTML 內容並替換原本的檔案:

```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;
            padding: 0;
            overflow: hidden;
            background-color: #2c3e50;
            touch-action: none;
        }
        #game-container {
            width: 100vw;
            height: 100vh;
            background-color: #000;
            position: relative;
        }
        canvas {
            display: block;
        }
        #ui-layer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            pointer-events: none;
            z-index: 10;
        }
        #btn-start {
            pointer-events: auto;
            padding: 20px 50px;
            font-size: 28px;
            font-weight: bold;
            color: #fff;
            background-color: #e74c3c;
            border: none;
            border-radius: 10px;
            cursor: pointer;
            box-shadow: 0 5px 15px rgba(0,0,0,0.5);
            transition: transform 0.1s, background-color 0.2s;
        }
        #btn-start:hover {
            background-color: #c0392b;
            transform: scale(1.05);
        }
        #btn-start:active {
            transform: scale(0.95);
        }
        #message {
            position: absolute;
            top: 20px;
            width: 100%;
            text-align: center;
            color: #fff;
            font-size: 24px;
            font-weight: bold;
            pointer-events: none;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
            display: none;
        }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "./three.module.js",
                "three/addons/controls/": "./"
            }
        }
    </script>
</head>
<body>

<div id="game-container"></div>
<div id="ui-layer">
    <button id="btn-start">開始遊戲</button>
</div>
<div id="message"></div>

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

    // ==========================================
    // 1. 初始化場景、相機、渲染器
    // ==========================================
    const container = document.getElementById('game-container');
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x34495e);

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

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    container.appendChild(renderer.domElement);

    window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
    });

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
    scene.add(ambientLight);
    const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
    dirLight.position.set(200, 400, 200);
    scene.add(dirLight);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enablePan = false;
    controls.enableZoom = true;
    controls.minPolarAngle = 0;
    controls.maxPolarAngle = Math.PI / 2;
    controls.minAzimuthAngle = -Math.PI / 4;
    controls.maxAzimuthAngle = Math.PI / 4;

    // ==========================================
    // 2. 建立底板與全局追蹤容器
    // ==========================================
    const boardSize = 480;
    const planeGeo = new THREE.PlaneGeometry(boardSize, boardSize);
    const planeMat = new THREE.MeshLambertMaterial({ color: 0xADD8E6 });
    const plane = new THREE.Mesh(planeGeo, planeMat);
    plane.rotation.x = -Math.PI / 2;
    scene.add(plane);

    const edgesGeo = new THREE.EdgesGeometry(planeGeo);
    const edgesMat = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 10, gapSize: 10 });
    const planeEdges = new THREE.LineSegments(edgesGeo, edgesMat);
    planeEdges.computeLineDistances();
    planeEdges.rotation.x = -Math.PI / 2;
    planeEdges.position.y = 0.5;
    scene.add(planeEdges);

    // 用於重新開局時清理的全局容器
    let boardMeshes = [];
    let cylinderMeshes = [];
    let microphones = [];

    // ==========================================
    // 3. 共用幾何體與材質生成
    // ==========================================
    const gameBoardGeo = new THREE.BoxGeometry(118, 4, 118);
    const boardEdgesGeo = new THREE.EdgesGeometry(gameBoardGeo);
    const boardEdgesMat = new THREE.LineBasicMaterial({ color: 0x363636, linewidth: 4 });
    const sideBoardMat = new THREE.MeshLambertMaterial({ color: 0xD3D3D3 });

    function createBoardTopTexture(dirs) {
        const canvas = document.createElement('canvas');
        canvas.width = 120; canvas.height = 120;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#DEB887';
        ctx.fillRect(0, 0, 120, 120);
        ctx.strokeStyle = '#000000';
        ctx.lineWidth = 4;
        ctx.beginPath();
        if (dirs.includes('top')) { ctx.moveTo(60, 60); ctx.lineTo(60, 0); }
        if (dirs.includes('bottom')) { ctx.moveTo(60, 60); ctx.lineTo(60, 120); }
        if (dirs.includes('left')) { ctx.moveTo(60, 60); ctx.lineTo(0, 60); }
        if (dirs.includes('right')) { ctx.moveTo(60, 60); ctx.lineTo(120, 60); }
        ctx.stroke();
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return texture;
    }

    function createGameBoard(colCenter, rowCenter, dirs, startZOffset = 0) {
        const topMat = new THREE.MeshLambertMaterial({ map: createBoardTopTexture(dirs) });
        const materials = [sideBoardMat, sideBoardMat, topMat, sideBoardMat, sideBoardMat, sideBoardMat];
        const board = new THREE.Mesh(gameBoardGeo, materials);
        const pos = getCellWorldPos(colCenter, rowCenter);
        board.position.set(pos.x, 2, pos.z + startZOffset);
        const boardEdges = new THREE.LineSegments(boardEdgesGeo, boardEdgesMat);
        board.add(boardEdges);
        scene.add(board);
        boardMeshes.push(board);
        return { mesh: board, targetZ: pos.z, startZ: pos.z + startZOffset };
    }

    function getCellWorldPos(col, row) {
        return { x: (col - 3.5) * 60, z: (row - 3.5) * 60 };
    }

    const faceMapping = [3, 4, 1, 6, 2, 5]; 
    function createDiceMaterial(baseColor, dotColor, shape, number) {
        const canvas = document.createElement('canvas');
        canvas.width = 128; canvas.height = 128;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = baseColor;
        ctx.fillRect(0, 0, 128, 128);
        ctx.fillStyle = dotColor;

        const drawShape = (x, y) => {
            ctx.beginPath();
            if (shape === 'triangle') {
                const r = 16;
                ctx.moveTo(x, y - r);
                ctx.lineTo(x + r*0.866, y + r*0.5);
                ctx.lineTo(x - r*0.866, y + r*0.5);
            } else { 
                const r = 14;
                for (let i=0; i<6; i++) {
                    const angle = i * Math.PI / 3;
                    if(i===0) ctx.moveTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                    else ctx.lineTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                }
            }
            ctx.closePath();
            ctx.fill();
        };

        const dots = [];
        if(number === 1) dots.push([64, 64]);
        if(number === 2) dots.push([32, 32], [96, 96]);
        if(number === 3) dots.push([32, 32], [64, 64], [96, 96]);
        if(number === 4) dots.push([32, 32], [96, 32], [32, 96], [96, 96]);
        if(number === 5) dots.push([32, 32], [96, 32], [64, 64], [32, 96], [96, 96]);
        if(number === 6) dots.push([32, 24], [32, 64], [32, 104], [96, 24], [96, 64], [96, 104]);

        dots.forEach(p => drawShape(p[0], p[1]));
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return new THREE.MeshLambertMaterial({ map: texture });
    }

    function createDiceMesh(baseColor, dotColor, shape) {
        const materials = faceMapping.map(num => createDiceMaterial(baseColor, dotColor, shape, num));
        const cube = new THREE.Mesh(new THREE.BoxGeometry(48, 48, 48), materials);
        cube.userData = {
            currentNumber: 1, score: 1, animating: false, startTime: 0,
            startQuat: new THREE.Quaternion(), targetQuat: new THREE.Quaternion(),
            moving: false, moveStartTime: 0, moveDuration: 800,
            startX: 0, startZ: 0, targetX: 0, targetZ: 0, col: 0, row: 0, color: ''
        };
        return cube;
    }

    // 依據分數翻轉立方體至指定朝上點數
    function animateCubeToScore(cube, targetScore) {
        cube.userData.animating = true;
        cube.userData.startTime = performance.now();
        cube.userData.startQuat.copy(cube.quaternion);
        cube.userData.currentNumber = targetScore;

        let rx = 0, ry = 0, rz = 0;
        switch(targetScore) {
            case 1: break; 
            case 2: rx = -Math.PI/2; break; 
            case 3: rz = Math.PI/2; break;  
            case 4: rz = -Math.PI/2; break; 
            case 5: rx = Math.PI/2; break;  
            case 6: rx = Math.PI; break;    
        }
        cube.userData.targetQuat.setFromEuler(new THREE.Euler(rx, ry, rz));
    }

    // ==========================================
    // 4. 遊戲狀態與互動邏輯
    // ==========================================
    const itemY = 28;
    let cubes = [];
    let cylindersData = [];
    let markers = [];
    let raycaster = new THREE.Raycaster();
    let mouse = new THREE.Vector2();

    let blueCornerType = ''; 
    let orangeCornerType = '';
    let originalBlueCorner = '';
    let originalOrangeCorner = '';
    let placedEdges = { top: false, bottom: false, left: false, right: false };
    let placedCorners = { LB: false, RB: false, LT: false, RT: false };
    let centerBoardsAnimData = [];
    let isAnimatingPhase1 = false;
    let phase1Progress = 0;
    let edgeTurn = 1;

    // 核心對戰局數與行走變數
    let gameRound = 1;
    let gameplayActive = false;
    let currentPlayer = 'blue'; 
    let walkCount = 1; 
    let lastDirection = null; 
    let turnStopPositions = []; 
    let blueTotalScore = 0;
    let orangeTotalScore = 0;

    const uiMsg = document.getElementById('message');
    function showMessage(msg) {
        uiMsg.style.display = 'block';
        uiMsg.innerText = msg;
    }

    function createMarker(col, row, color, onClickCallback, customY = 5) {
        const pos = getCellWorldPos(col, row);
        const geo = new THREE.CylinderGeometry(18, 18, 2, 32);
        const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.8 });
        const marker = new THREE.Mesh(geo, mat);
        marker.position.set(pos.x, customY, pos.z);
        marker.userData.onClick = onClickCallback;
        scene.add(marker);
        markers.push(marker);
    }

    function clearMarkers() {
        markers.forEach(m => scene.remove(m));
        markers = [];
        document.body.style.cursor = 'default';
    }

    function placeCylinder(col, row) {
        const pos = getCellWorldPos(col, row);
        const cylGeo = new THREE.CylinderGeometry(24, 24, 48, 32);
        const cylMat = new THREE.MeshLambertMaterial({ color: 0x800080 });
        const cylinder = new THREE.Mesh(cylGeo, cylMat);
        cylinder.position.set(pos.x, itemY, pos.z);

        const cylEdgesGeo = new THREE.EdgesGeometry(cylGeo);
        const cylEdgesMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
        const cylEdges = new THREE.LineSegments(cylEdgesGeo, cylEdgesMat);
        cylinder.add(cylEdges);

        scene.add(cylinder);
        cylinderMeshes.push(cylinder);
        cylindersData.push({ c: col, r: row });
    }

    function placeCornerBoard(type) {
        if (placedCorners[type]) return;
        placedCorners[type] = true;
        if (type === 'LB') createGameBoard(0.5, 6.5, ['top', 'right']);
        if (type === 'RB') createGameBoard(6.5, 6.5, ['top', 'left']);
        if (type === 'LT') createGameBoard(0.5, 0.5, ['bottom', 'right']);
        if (type === 'RT') createGameBoard(6.5, 0.5, ['bottom', 'left']);
    }

    function placeEdgeBoards(side) {
        if (placedEdges[side]) return;
        placedEdges[side] = true;
        if (side === 'bottom') {
            createGameBoard(2.5, 6.5, ['left', 'right', 'bottom']);
            createGameBoard(4.5, 6.5, ['left', 'right', 'bottom']);
        }
        if (side === 'top') {
            createGameBoard(2.5, 0.5, ['left', 'right', 'top']);
            createGameBoard(4.5, 0.5, ['left', 'right', 'top']);
        }
        if (side === 'left') {
            createGameBoard(0.5, 2.5, ['top', 'bottom', 'left']);
            createGameBoard(0.5, 4.5, ['top', 'bottom', 'left']);
        }
        if (side === 'right') {
            createGameBoard(6.5, 2.5, ['top', 'bottom', 'right']);
            createGameBoard(6.5, 4.5, ['top', 'bottom', 'right']);
        }
    }

    function placeCube(col, row, color) {
        const pos = getCellWorldPos(col, row);
        let cube;
        if (color === 'blue') cube = createDiceMesh('#00008B', '#FFFFE0', 'hexagon');
        else cube = createDiceMesh('#FFA500', '#90EE90', 'triangle');
        cube.position.set(pos.x, itemY, pos.z);
        cube.userData.col = col;
        cube.userData.row = row;
        cube.userData.color = color;
        cube.userData.score = 1;
        cube.userData.currentNumber = 1;
        scene.add(cube);
        cubes.push(cube);
    }

    function createMicrophoneMesh(color, col, row) {
        const pos = getCellWorldPos(col, row);
        let geo;
        const mat = new THREE.MeshLambertMaterial({ color: color === 'blue' ? 0x00008B : 0xFFA500 });
        if (color === 'blue') {
            geo = new THREE.CylinderGeometry(20, 20, 4, 6); // 藍方正六邊形
        } else {
            geo = new THREE.CylinderGeometry(20, 20, 4, 3); // 橘方三角形
        }
        const mic = new THREE.Mesh(geo, mat);
        mic.position.set(pos.x, 6, pos.z); // 話筒高為4,平貼於面板之上 (面板表面Y為4)

        const edgesGeo = new THREE.EdgesGeometry(geo);
        const edgesMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
        const edges = new THREE.LineSegments(edgesGeo, edgesMat);
        mic.add(edges);

        mic.userData = { color: color, col: col, row: row };
        scene.add(mic);
        microphones.push(mic);
    }

    function isOccupiedByCyl(c, r) {
        return cylindersData.some(cyl => cyl.c === c && cyl.r === r);
    }
    function isDiagAdjacent(c1, r1, c2, r2) {
        return Math.abs(c1 - c2) === 1 && Math.abs(r1 - r2) === 1;
    }
    function isValidOrangeCenterCyl(c, r) {
        if (isOccupiedByCyl(c, r)) return false;
        return !cylindersData.some(cyl => isDiagAdjacent(c, r, cyl.c, cyl.r));
    }

    function getEdgeCells(cornerType) {
        let cells = [];
        if(cornerType.includes('B')) {
            for(let c=2; c<=5; c++) cells.push({c, r:7, side:'bottom'});
        }
        if(cornerType.includes('T')) {
            for(let c=2; c<=5; c++) cells.push({c, r:0, side:'top'});
        }
        if(cornerType.includes('L')) {
            for(let r=2; r<=5; r++) cells.push({c:0, r, side:'left'});
        }
        if(cornerType.includes('R')) {
            for(let r=2; r<=5; r++) cells.push({c:7, r, side:'right'});
        }
        return cells;
    }

    function getOuterCornerCells(cornerType) {
        if (cornerType === 'LB') return [{c:0, r:7}, {c:1, r:7}, {c:0, r:6}];
        if (cornerType === 'RB') return [{c:7, r:7}, {c:6, r:7}, {c:7, r:6}];
        if (cornerType === 'LT') return [{c:0, r:0}, {c:1, r:0}, {c:0, r:1}];
        if (cornerType === 'RT') return [{c:7, r:0}, {c:6, r:0}, {c:7, r:1}];
    }

    // ==========================================
    // 5. 核心開局流程控制器
    // ==========================================

    function startPhase1() {
        showMessage(`第 ${gameRound} 局開局:中心遊戲板就位`);
        centerBoardsAnimData.push(createGameBoard(2.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(4.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(2.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        centerBoardsAnimData.push(createGameBoard(4.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        isAnimatingPhase1 = true;
    }

    function startPhase2() {
        if (gameRound === 1) {
            showMessage("藍方行動:選擇並放置角遊戲板");
            createMarker(0.5, 6.5, 0x00008B, () => {
                clearMarkers(); blueCornerType = 'LB'; placeCornerBoard('LB'); startPhase3();
            });
            createMarker(6.5, 6.5, 0x00008B, () => {
                clearMarkers(); blueCornerType = 'RB'; placeCornerBoard('RB'); startPhase3();
            });
        } else {
            // 第二局由橘方先手
            showMessage("橘方行動:放置角遊戲板 (受限上一局配置)");
            orangeCornerType = (originalBlueCorner === 'LB') ? 'LT' : 'RT';
            const centerPos = (orangeCornerType === 'RT') ? {c: 6.5, r: 0.5} : {c: 0.5, r: 0.5};
            createMarker(centerPos.c, centerPos.r, 0xFFA500, () => {
                clearMarkers(); placeCornerBoard(orangeCornerType); startPhase3();
            });
        }
    }

    function startPhase3() {
        if (gameRound === 1) {
            showMessage("橘方行動:放置對角遊戲板");
            orangeCornerType = (blueCornerType === 'LB') ? 'RT' : 'LT';
            const centerPos = (orangeCornerType === 'RT') ? {c: 6.5, r: 0.5} : {c: 0.5, r: 0.5};
            createMarker(centerPos.c, centerPos.r, 0xFFA500, () => {
                clearMarkers(); placeCornerBoard(orangeCornerType); startPhase4();
            });
        } else {
            showMessage("藍方行動:放置對角遊戲板");
            blueCornerType = (orangeCornerType === 'LT') ? 'RB' : 'LB';
            const centerPos = (blueCornerType === 'RB') ? {c: 6.5, r: 6.5} : {c: 0.5, r: 6.5};
            createMarker(centerPos.c, centerPos.r, 0x00008B, () => {
                clearMarkers(); placeCornerBoard(blueCornerType); startPhase4();
            });
        }
    }

    function startPhase4() {
        const teamName = gameRound === 1 ? "藍方" : "橘方";
        const colorHex = gameRound === 1 ? 0x00008B : 0xFFA500;
        showMessage(`${teamName}行動:在中心板上放置一個圓柱體`);
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                createMarker(c, r, colorHex, () => {
                    clearMarkers(); placeCylinder(c, r); startPhase5();
                });
            }
        }
    }

    function startPhase5() {
        const teamName = gameRound === 1 ? "橘方" : "藍方";
        const colorHex = gameRound === 1 ? 0xFFA500 : 0x00008B;
        showMessage(`${teamName}行動:在中心板上放置一個圓柱體 (避開同行/同列/斜相鄰)`);
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                if(isValidOrangeCenterCyl(c, r)) {
                    createMarker(c, r, colorHex, () => {
                        clearMarkers(); placeCylinder(c, r); startPhase6();
                    });
                }
            }
        }
    }

    function startPhase6() {
        if (edgeTurn > 4) {
            startPhase7();
            return;
        }
        const isBlueTurn = gameRound === 1 ? (edgeTurn % 2 !== 0) : (edgeTurn % 2 === 0);
        const colorHex = isBlueTurn ? 0x00008B : 0xFFA500;
        const cornerType = isBlueTurn ? blueCornerType : orangeCornerType;
        const teamName = isBlueTurn ? "藍方" : "橘方";
        
        showMessage(`${teamName}行動:放置邊遊戲板及圓柱體`);
        const validCells = getEdgeCells(cornerType).filter(cell => !isOccupiedByCyl(cell.c, cell.r));
        validCells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeEdgeBoards(cell.side);
                placeCylinder(cell.c, cell.r);
                edgeTurn++;
                startPhase6();
            });
        });
    }

    function startPhase7() {
        const teamName = gameRound === 1 ? "藍方" : "橘方";
        const colorHex = gameRound === 1 ? 0x00008B : 0xFFA500;
        const cornerType = gameRound === 1 ? blueCornerType : orangeCornerType;
        
        showMessage(`${teamName}行動:放置本方移動立方體`);
        const cells = getOuterCornerCells(cornerType);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, gameRound === 1 ? 'blue' : 'orange');
                startPhase8();
            });
        });
    }

    function startPhase8() {
        const teamName = gameRound === 1 ? "橘方" : "藍方";
        const colorHex = gameRound === 1 ? 0xFFA500 : 0x00008B;
        const cornerType = gameRound === 1 ? orangeCornerType : blueCornerType;
        
        showMessage(`${teamName}行動:放置本方移動立方體`);
        const cells = getOuterCornerCells(cornerType);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, gameRound === 1 ? 'orange' : 'blue');
                
                // 補齊剩餘未覆蓋的角落
                ['LB', 'RB', 'LT', 'RT'].forEach(type => {
                    if (!placedCorners[type]) placeCornerBoard(type);
                });
                
                if (gameRound === 1) {
                    originalBlueCorner = blueCornerType;
                    originalOrangeCorner = orangeCornerType;
                }
                
                showMessage("開局階段結束!即將進入正式對戰模式...");
                setTimeout(() => {
                    uiMsg.style.display = 'none';
                    startGameplayPhase();
                }, 2500);
            });
        });
    }

    // ==========================================
    // 6. 正式對戰遊戲階段邏輯
    // ==========================================

    function startGameplayPhase() {
        gameplayActive = true;
        currentPlayer = gameRound === 1 ? 'blue' : 'orange';
        startPlayerTurn();
    }

    function startPlayerTurn() {
        walkCount = 1;
        lastDirection = null;
        turnStopPositions = [];
        showWalkOptions();
    }

    // 判斷格子是否為無法逾越的物理障礙物
    function isObstacle(c, r) {
        if (c < 0 || c > 7 || r < 0 || r > 7) return true; // 棋盤邊緣
        if (isOccupiedByCyl(c, r)) return true; // 圓柱體
        // 敵方立方體
        const oppColor = currentPlayer === 'blue' ? 'orange' : 'blue';
        const oppCube = cubes.find(q => q.userData.color === oppColor);
        if (oppCube && oppCube.userData.col === c && oppCube.userData.row === r) return true;
        return false;
    }

    // 計算一直直線滑動直至撞擊障礙物停下的終點座標
    function calculateStopPos(startC, startR, dc, dr) {
        let c = startC;
        let r = startR;
        while (true) {
            let nextC = c + dc;
            let nextR = r + dr;
            if (isObstacle(nextC, nextR)) {
                break;
            }
            c = nextC;
            r = nextR;
        }
        return { c, r };
    }

    function showWalkOptions() {
        clearMarkers();
        const activeCube = cubes.find(q => q.userData.color === currentPlayer);
        const c = activeCube.userData.col;
        const r = activeCube.userData.row;
        
        let validMoves = [];
        if (walkCount === 1) {
            // 第一次行走:任意四個十字方向皆可
            const dirs = [{dc:0, dr:-1}, {dc:0, dr:1}, {dc:-1, dr:0}, {dc:1, dr:0}];
            dirs.forEach(d => {
                const stop = calculateStopPos(c, r, d.dc, d.dr);
                if (stop.c !== c || stop.r !== r) validMoves.push({ dir: d, stop: stop });
            });
        } else {
            // 第二、三次行走:基於前一次方向進行90度偏轉(左或右)
            const dLeft = { dc: lastDirection.dr, dr: -lastDirection.dc };
            const dRight = { dc: -lastDirection.dr, dr: lastDirection.dc };
            
            [dLeft, dRight].forEach(d => {
                const stop = calculateStopPos(c, r, d.dc, d.dr);
                if (stop.c !== c || stop.r !== r) validMoves.push({ dir: d, stop: stop });
            });

            // 僅當左右皆為死路時,才允許往後退 (180度反轉)
            if (validMoves.length === 0) {
                const dBack = { dc: -lastDirection.dc, dr: -lastDirection.dr };
                const stop = calculateStopPos(c, r, dBack.dc, dBack.dr);
                if (stop.c !== c || stop.r !== r) validMoves.push({ dir: dBack, stop: stop });
            }
        }

        // 如果徹底無路可走,自動跳過本次行走
        if (validMoves.length === 0) {
            showMessage(`${currentPlayer === 'blue'?'藍方':'橘方'}第 ${walkCount} 次行走無路可走`);
            setTimeout(() => { handleWalkComplete(c, r, null, []); }, 1200);
            return;
        }

        showMessage(`輪到 ${currentPlayer === 'blue'?'藍方':'橘方'} 行動:第 ${walkCount} 次行走`);

        validMoves.forEach(mv => {
            // 如果終點格子上已經放了任何一方的話筒,則行動提示標示高度調高 5 單位
            const hasMic = microphones.some(m => m.userData.col === mv.stop.c && m.userData.row === mv.stop.r);
            const markerY = hasMic ? 10 : 5;
            const colorHex = currentPlayer === 'blue' ? 0x0000FF : 0xFF8C00;

            createMarker(mv.stop.c, mv.stop.r, colorHex, () => {
                clearMarkers();
                executeCubeMovement(activeCube, mv.dir, mv.stop);
            }, markerY);
        });
    }

    function executeCubeMovement(cube, dir, stop) {
        let traversed = [];
        let curC = cube.userData.col;
        let curR = cube.userData.row;
        
        // 紀錄直線平移中經過的所有路徑格子 (含終點)
        while (curC !== stop.c || curR !== stop.r) {
            curC += dir.dc;
            curR += dir.dr;
            traversed.push({ c: curC, r: curR });
        }

        const worldPos = getCellWorldPos(stop.c, stop.r);
        cube.userData.moving = true;
        cube.userData.startX = cube.position.x;
        cube.userData.startZ = cube.position.z;
        cube.userData.targetX = worldPos.x;
        cube.userData.targetZ = worldPos.z;
        cube.userData.moveStartTime = performance.now();
        
        cube.userData.onMoveComplete = () => {
            cube.userData.col = stop.c;
            cube.userData.row = stop.r;
            handleWalkComplete(stop.c, stop.r, dir, traversed);
        };
    }

    function handleWalkComplete(stopC, stopR, dir, traversed) {
        lastDirection = dir;
        const oppColor = currentPlayer === 'blue' ? 'orange' : 'blue';

        if (walkCount === 1 || walkCount === 2) {
            turnStopPositions.push({ c: stopC, r: stopR });
            // 規則:第1, 2次行走若經過敵方話筒,將其清除,但不給分
            if (traversed.length > 0) {
                microphones = microphones.filter(m => {
                    const passThrough = (m.userData.color === oppColor && traversed.some(t => t.c === m.userData.col && t.r === m.userData.row));
                    if (passThrough) scene.remove(m);
                    return !passThrough;
                });
            }
            walkCount++;
            showWalkOptions();
        } else if (walkCount === 3) {
            // 第三次行走結束:結算得分與話筒移除
            let earnedPoints = 0;
            let penaltyPoint = false;

            if (traversed.length > 0) {
                // 1. 經過敵方話筒:每經過一個得一分,並清空該話筒
                const enemyMics = microphones.filter(m => m.userData.color === oppColor && traversed.some(t => t.c === m.userData.col && t.r === m.userData.row));
                earnedPoints = enemyMics.length;
                enemyMics.forEach(m => scene.remove(m));
                microphones = microphones.filter(m => !enemyMics.includes(m));

                // 2. 本方話筒判定:若最終停留在本方話筒上,拿走話筒,且使對方得一分
                const ownMicAtEnd = microphones.find(m => m.userData.color === currentPlayer && m.userData.col === stopC && m.userData.row === stopR);
                if (ownMicAtEnd) {
                    penaltyPoint = true;
                    scene.remove(ownMicAtEnd);
                    microphones = microphones.filter(m => m !== ownMicAtEnd);
                }
            }

            const pCube = cubes.find(q => q.userData.color === currentPlayer);
            const oCube = cubes.find(q => q.userData.color === oppColor);

            if (earnedPoints > 0) {
                pCube.userData.score += earnedPoints;
                animateCubeToScore(pCube, Math.min(6, pCube.userData.score));
            }
            if (penaltyPoint) {
                oCube.userData.score += 1;
                animateCubeToScore(oCube, Math.min(6, oCube.userData.score));
            }

            // 檢查是否有一方達到 6 分而終止遊戲
            if (pCube.userData.score >= 6 || oCube.userData.score >= 6) {
                triggerRoundEnd();
                return;
            }

            // 進入放置話筒階段
            showMicrophonePlacementOptions();
        }
    }

    function showMicrophonePlacementOptions() {
        clearMarkers();
        const currentMicsCount = microphones.filter(m => m.userData.color === currentPlayer).length;
        
        // 觸發不需放置話筒的特殊條件:場上已有4個本方話筒
        if (currentMicsCount >= 4) {
            switchTurn();
            return;
        }

        // 提取第1、2次停留點(去重)
        let targetSpots = [];
        turnStopPositions.forEach(p => {
            if (!targetSpots.some(ts => ts.c === p.c && ts.r === p.r)) targetSpots.push(p);
        });

        // 過濾掉已經有本方話筒的棋位
        let availableSpots = targetSpots.filter(p => {
            return !microphones.some(m => m.userData.color === currentPlayer && m.userData.col === p.c && m.userData.row === p.r);
        });

        // 如果兩個停留點皆已存在本方話筒,則本次行動不需放置
        if (availableSpots.length === 0) {
            switchTurn();
            return;
        }

        showMessage(`${currentPlayer === 'blue'?'藍方':'橘方'}行動:選擇在第一次或第二次停靠點放置話筒`);

        availableSpots.forEach(spot => {
            createMarker(spot.c, spot.r, 0x00FF7F, () => {
                clearMarkers();
                createMicrophoneMesh(currentPlayer, spot.c, spot.r);
                switchTurn();
            }, 6);
        });
    }

    function switchTurn() {
        currentPlayer = currentPlayer === 'blue' ? 'orange' : 'blue';
        startPlayerTurn();
    }

    // ==========================================
    // 7. 局末結算與重開 UI 控制
    // ==========================================

    function triggerRoundEnd() {
        gameplayActive = false;
        clearMarkers();

        const blueFinal = cubes.find(q => q.userData.color === 'blue').userData.score;
        const orangeFinal = cubes.find(q => q.userData.color === 'orange').userData.score;

        blueTotalScore += blueFinal;
        orangeTotalScore += orangeFinal;

        let roundWinner = blueFinal > orangeFinal ? "藍方" : (orangeFinal > blueFinal ? "橘方" : "平手");

        // 構建 HTML 結算互動介面
        const overlay = document.createElement('div');
        overlay.style.cssText = 'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';
        
        const title = document.createElement('h2');
        title.style.fontSize = '36px';
        title.innerText = `第 ${gameRound} 局結束!本局勝者:${roundWinner}`;
        overlay.appendChild(title);

        const scoreInfo = document.createElement('p');
        scoreInfo.style.fontSize = '22px';
        scoreInfo.innerText = `藍方單局得分:${blueFinal} | 橘方單局得分:${orangeFinal}`;
        overlay.appendChild(scoreInfo);

        if (gameRound === 1) {
            // 第一局結束,提供三個選項
            const btnNo = createOverlayButton("否 (顯示總結算)", () => {
                document.body.removeChild(overlay); showFinalGameSummary();
            });
            const btnSame = createOverlayButton("用當前場地再戰一局", () => {
                document.body.removeChild(overlay); reinitNextRound(true);
            });
            const btnNew = createOverlayButton("重新開局", () => {
                document.body.removeChild(overlay); reinitNextRound(false);
            });
            overlay.appendChild(btnNo); overlay.appendChild(btnSame); overlay.appendChild(btnNew);
        } else {
            // 兩局都打完了
            const btnEnd = createOverlayButton("查看最終贏家及總分", () => {
                document.body.removeChild(overlay); showFinalGameSummary();
            });
            overlay.appendChild(btnEnd);
        }
        document.body.appendChild(overlay);
    }

    function createOverlayButton(text, clickFn) {
        const btn = document.createElement('button');
        btn.innerText = text;
        btn.style.cssText = 'padding:14px 35px; margin:10px; font-size:18px; font-weight:bold; color:#fff; background-color:#3498db; border:none; border-radius:5px; cursor:pointer; min-width:260px; box-shadow:0 3px 6px rgba(0,0,0,0.3);';
        btn.addEventListener('click', clickFn);
        return btn;
    }

    function cleanupEntities() {
        microphones.forEach(m => scene.remove(m));
        microphones = [];
        cubes.forEach(c => scene.remove(c));
        cubes = [];
        clearMarkers();
    }

    function reinitNextRound(keepBoard) {
        gameRound = 2;
        cleanupEntities();

        if (keepBoard) {
            // 沿用場地:不清除棋盤與圓柱,直接重新進入立方體角位放置階段
            orangeCornerType = (originalBlueCorner === 'LB') ? 'LT' : 'RT';
            blueCornerType = (orangeCornerType === 'LT') ? 'RB' : 'LB';
            startPhase7();
        } else {
            // 重新開局:將所有動態棋盤、圓柱從場景與陣列中徹底清空
            boardMeshes.forEach(b => scene.remove(b));
            boardMeshes = [];
            centerBoardsAnimData = [];
            cylinderMeshes.forEach(c => scene.remove(c));
            cylinderMeshes = [];
            cylindersData = [];

            placedEdges = { top: false, bottom: false, left: false, right: false };
            placedCorners = { LB: false, RB: false, LT: false, RT: false };
            edgeTurn = 1;
            phase1Progress = 0;

            startPhase1();
        }
    }

    function showFinalGameSummary() {
        const overlay = document.createElement('div');
        overlay.style.cssText = 'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.92); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';
        
        let ultimateWinner = blueTotalScore > orangeTotalScore ? "藍方" : (orangeTotalScore > blueTotalScore ? "橘方" : "平手");
        
        const h1 = document.createElement('h1');
        h1.style.fontSize = '42px';
        h1.innerText = `對抗賽終局!最終贏家:${ultimateWinner}`;
        overlay.appendChild(h1);

        const p = document.createElement('p');
        p.style.fontSize = '26px';
        p.style.margin = '20px 0 40px 0';
        p.innerText = `總比分 -> 藍方總得分:${blueTotalScore} 點  ||  橘方總得分:${orangeTotalScore} 點`;
        overlay.appendChild(p);

        const btnRestartAll = createOverlayButton("重新開始整個遊戲", () => {
            document.body.removeChild(overlay);
            // 完全重置所有統計數據歸零
            gameRound = 1; blueTotalScore = 0; orangeTotalScore = 0;
            reinitNextRound(false);
        });
        overlay.appendChild(btnRestartAll);
        document.body.appendChild(overlay);
    }

    // ==========================================
    // 8. 事件綁定與主迴圈
    // ==========================================

    document.getElementById('btn-start').addEventListener('click', (e) => {
        e.target.parentElement.style.display = 'none';
        startPhase1();
    });

    container.addEventListener('pointerdown', (event) => {
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        if (markers.length > 0) {
            const intersects = raycaster.intersectObjects(markers);
            if (intersects.length > 0) {
                intersects[0].object.userData.onClick();
            }
        }
    });

    container.addEventListener('pointermove', (event) => {
        if (markers.length === 0) return;
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        const intersects = raycaster.intersectObjects(markers);
        if (intersects.length > 0) document.body.style.cursor = 'pointer';
        else document.body.style.cursor = 'default';
    });

    function animate() {
        requestAnimationFrame(animate);
        const now = performance.now();

        // 處理中心遊戲板入場動畫
        if (isAnimatingPhase1) {
            phase1Progress += 0.015;
            if (phase1Progress >= 1) {
                phase1Progress = 1;
                isAnimatingPhase1 = false;
                startPhase2();
            }
            const easeOut = phase1Progress * (2 - phase1Progress);
            centerBoardsAnimData.forEach(b => {
                b.mesh.position.z = b.startZ + (b.targetZ - b.startZ) * easeOut;
            });
        }

        // 處理正式對戰階段:立方體平移與高拋平滑曲線控制效果
        cubes.forEach(cube => {
            if (cube.userData.moving) {
                const elapsed = now - cube.userData.moveStartTime;
                let t = elapsed / cube.userData.moveDuration;
                if (t >= 1) {
                    t = 1;
                    cube.userData.moving = false;
                    if (cube.userData.onMoveComplete) {
                        const cb = cube.userData.onMoveComplete;
                        cube.userData.onMoveComplete = null;
                        cb();
                    }
                }
                // 直線平移 X 與 Z
                cube.position.x = THREE.MathUtils.lerp(cube.userData.startX, cube.userData.targetX, t);
                cube.position.z = THREE.MathUtils.lerp(cube.userData.startZ, cube.userData.targetZ, t);
                // 貝塞爾高拋躍起特效:升起 20 個高度單位 (相當於5個話筒高度4*5),落回原本底盤
                cube.position.y = itemY + Math.sin(t * Math.PI) * 20;
            }
        });

        // 處理立方體得分時的 3D 旋轉翻轉點數動畫
        cubes.forEach(cube => {
            if (cube.userData.animating) {
                const elapsed = now - cube.userData.startTime;
                const duration = 1000;
                let t = elapsed / duration;

                if (t >= 1) {
                    t = 1;
                    cube.userData.animating = false;
                }
                // 翻轉時的離地跳躍微幅晃動
                if (!cube.userData.moving) {
                    cube.position.y = itemY + Math.sin(t * Math.PI) * 48;
                }
                cube.quaternion.slerpQuaternions(cube.userData.startQuat, cube.userData.targetQuat, t);
            }
        });

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

    animate();
</script>

</body>
</html>

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

需要解決:
  1. 圓柱放置不能同在一横線或縱線上。
  2. 如果第二局輪到橙方重新開局,他在放角遊戲板時將能自由選擇左上角還是右上角。
  3. 兩局結束時,或第一局結束後選擇「否」時,再次重新開局應為藍方先行。
上次由 ejsoon 在 2026年 6月 14日 08:35,总共编辑 1 次。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

進入遊戲階段的行動規則:
藍方和橙方輪流行動,藍方先行。

所要行動的就是一方所屬的立方體。

每方的行動包括三次行走,三次行走完需要放置一個特殊形狀的「話筒」。

每次行走都必須撞到一個障礙物才會停下,障礙物包括圓柱體、對方立方體、棋盤邊緣。

當一次行走結束,要轉至下一次行走時,假定前一次行走為往前,則下一次行走只能是往左或往右,僅當既無法往左也無法往右時才能往後。

當要進行一次行走時,將出現行動標示在本次行走所要停下的所有可能的地方。

當三次行走結束時,將要放置一個「話筒」,藍方的話筒是正六邊形,表面顏色是同樣的深藍色,橙方的話筒是三角形,表面顏色同樣是橙色。話筒的高為4。

因為話筒高為4,所以當一個立方體要行動時,它將先升起5個高度,行動結束才會放下。它在行走時是直線平移,加上貝塞爾曲線控制的移動效果。如果行動標示出現的棋位上有話筒,則這個標示也應升起5個高度。

話筒只能放在第一次或第二次行走時停下的地方,如果這兩個地方都是空地,則出現兩個行動標示在這兩個棋位;如果其中一個地方已經有了本方話筒,則出現一個行動標示在另一個棋位;如果這兩個地方已經有了本方的話筒,則本次行動不需放置話筒;如果場上此時已經存在四個本方話筒,則本次行動不需放置話筒。

當話筒放置結束,將輪到對方行動。

一方在行走時,如果經過對方的話筒,則將全部清空。如果是第三次行走經過對方的話筒,則經過一個得一分。得分時將翻轉立方體,立方體初始分數為一,如果得一分,則翻轉為二點朝上。得分應在第三次行走結束但沒有放置話筒時計算。

一方在行走時,如果經過本方話筒,將不作任何處理,但是如果第三次行走結束時停在本方話筒上,則將把它拿走,並使對方得一分。

當有一方得六分及以上時遊戲結束,分數高者獲勝。

當有一方獲勝時,將由html元素提示,是否要進行下一場遊戲,有三個選項:否,用當前場地再戰一局,重新開局。

如果選擇後兩個選項,則將由橙方開始遊戲,所有回合將是先輪到橙方再輪到藍方。如果選擇「用當前場地再戰一局」,橙方在放置立體時只能選擇另一個角。比如上一局藍方選擇右下角,這一局橙方只能選擇右上角的三個棋位的其中一個放置立體,藍方只能選擇左下角的三個棋位的其中一個放置立體。

當兩局遊戲結束,總分高者為最終贏家。

如果前面選擇「否」,或者兩局結束,將顯示最終勝者是哪一方以及兩方的總分,同時顯示「重新開始」按鈕。

代码: 全选

進入遊戲階段的行動規則:
藍方和橙方輪流行動,藍方先行。

所要行動的就是一方所屬的立方體。

每方的行動包括三次行走,三次行走完需要放置一個特殊形狀的「話筒」。

每次行走都必須撞到一個障礙物才會停下,障礙物包括圓柱體、對方立方體、棋盤邊緣。

當一次行走結束,要轉至下一次行走時,假定前一次行走為往前,則下一次行走只能是往左或往右,僅當既無法往左也無法往右時才能往後。

當要進行一次行走時,將出現行動標示在本次行走所要停下的所有可能的地方。

當三次行走結束時,將要放置一個「話筒」,藍方的話筒是正六邊形,表面顏色是同樣的深藍色,橙方的話筒是三角形,表面顏色同樣是橙色。話筒的高為4。

因為話筒高為4,所以當一個立方體要行動時,它將先升起5個高度,行動結束才會放下。它在行走時是直線平移,加上貝塞爾曲線控制的移動效果。如果行動標示出現的棋位上有話筒,則這個標示也應升起5個高度。

話筒只能放在第一次或第二次行走時停下的地方,如果這兩個地方都是空地,則出現兩個行動標示在這兩個棋位;如果其中一個地方已經有了本方話筒,則出現一個行動標示在另一個棋位;如果這兩個地方已經有了本方的話筒,則本次行動不需放置話筒;如果場上此時已經存在四個本方話筒,則本次行動不需放置話筒。

當話筒放置結束,將輪到對方行動。

一方在行走時,如果經過對方的話筒,則將全部清空。如果是第三次行走經過對方的話筒,則經過一個得一分。得分時將翻轉立方體,立方體初始分數為一,如果得一分,則翻轉為二點朝上。得分應在第三次行走結束但沒有放置話筒時計算。

一方在行走時,如果經過本方話筒,將不作任何處理,但是如果第三次行走結束時停在本方話筒上,則將把它拿走,並使對方得一分。

當有一方得六分及以上時遊戲結束,分數高者獲勝。

當有一方獲勝時,將由html元素提示,是否要進行下一場遊戲,有三個選項:否,用當前場地再戰一局,重新開局。

如果選擇後兩個選項,則將由橙方開始遊戲,所有回合將是先輪到橙方再輪到藍方。如果選擇「用當前場地再戰一局」,橙方在放置立體時只能選擇另一個角。比如上一局藍方選擇右下角,這一局橙方只能選擇右上角的三個棋位的其中一個放置立體,藍方只能選擇左下角的三個棋位的其中一個放置立體。

當兩局遊戲結束,總分高者為最終贏家。

如果前面選擇「否」,或者兩局結束,將顯示最終勝者是哪一方以及兩方的總分,同時顯示「重新開始」按鈕。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

gemini thinking,接p9822:

代码: 全选

這裡為您修改了代碼,已完美解決您提到的三個問題。

### 🛠️ 主要修改說明

1. **圓柱體放置限制(同行同列判定)**:
* 新增了 `isRowOrColOccupied(c, r)` 函式,用來檢查即將放置的棋格之橫線(Row)或縱線(Column)上是否已經存在任何圓柱體。
* 將此檢查套用至 **第 4 階段**、**第 5 階段** 以及 **第 6 階段(邊緣圓柱)** 的放置判定中。


2. **第二局橙方自由選擇角落**:
* 修改了 `startPhase2()`。當進入第二局且輪到橙方先手放置角遊戲板時,畫面上會同時出現左上角(LT)**與**右上角(RT)的綠色互動提示墊,供玩家自由點選。


3. **重新開局由藍方先行**:
* 修復了原先 `reinitNextRound()` 函式內部強制將 `gameRound` 設為 `2` 的 Bug。現在將局數控制移至點擊按鈕的事件中。當兩局結束或第一局選擇「否(顯示總結算)」並點擊「重新開始整個遊戲」時,`gameRound` 會正確重設為 `1`,確保新賽局絕對由藍方先行。



---

### 完整修正後的 HTML 代碼

您可以直接複製下方完整的代碼並替換原本的檔案:

```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;
            padding: 0;
            overflow: hidden;
            background-color: #2c3e50;
            touch-action: none;
        }
        #game-container {
            width: 100vw;
            height: 100vh;
            background-color: #000;
            position: relative;
        }
        canvas {
            display: block;
        }
        #ui-layer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            pointer-events: none;
            z-index: 10;
        }
        #btn-start {
            pointer-events: auto;
            padding: 20px 50px;
            font-size: 28px;
            font-weight: bold;
            color: #fff;
            background-color: #e74c3c;
            border: none;
            border-radius: 10px;
            cursor: pointer;
            box-shadow: 0 5px 15px rgba(0,0,0,0.5);
            transition: transform 0.1s, background-color 0.2s;
        }
        #btn-start:hover {
            background-color: #c0392b;
            transform: scale(1.05);
        }
        #btn-start:active {
            transform: scale(0.95);
        }
        #message {
            position: absolute;
            top: 20px;
            width: 100%;
            text-align: center;
            color: #fff;
            font-size: 24px;
            font-weight: bold;
            pointer-events: none;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
            display: none;
        }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "./three.module.js",
                "three/addons/controls/": "./"
            }
        }
    </script>
</head>
<body>

<div id="game-container"></div>
<div id="ui-layer">
    <button id="btn-start">開始遊戲</button>
</div>
<div id="message"></div>

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

    // ==========================================
    // 1. 初始化場景、相機、渲染器
    // ==========================================
    const container = document.getElementById('game-container');
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x34495e);

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

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    container.appendChild(renderer.domElement);

    window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
    });

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
    scene.add(ambientLight);
    const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
    dirLight.position.set(200, 400, 200);
    scene.add(dirLight);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enablePan = false;
    controls.enableZoom = true;
    controls.minPolarAngle = 0;
    controls.maxPolarAngle = Math.PI / 2;
    controls.minAzimuthAngle = -Math.PI / 4;
    controls.maxAzimuthAngle = Math.PI / 4;

    // ==========================================
    // 2. 建立底板與全局追蹤容器
    // ==========================================
    const boardSize = 480;
    const planeGeo = new THREE.PlaneGeometry(boardSize, boardSize);
    const planeMat = new THREE.MeshLambertMaterial({ color: 0xADD8E6 });
    const plane = new THREE.Mesh(planeGeo, planeMat);
    plane.rotation.x = -Math.PI / 2;
    scene.add(plane);

    const edgesGeo = new THREE.EdgesGeometry(planeGeo);
    const edgesMat = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 10, gapSize: 10 });
    const planeEdges = new THREE.LineSegments(edgesGeo, edgesMat);
    planeEdges.computeLineDistances();
    planeEdges.rotation.x = -Math.PI / 2;
    planeEdges.position.y = 0.5;
    scene.add(planeEdges);

    // 用於重新開局時清理的全局容器
    let boardMeshes = [];
    let cylinderMeshes = [];
    let microphones = [];

    // ==========================================
    // 3. 共用幾何體與材質生成
    // ==========================================
    const gameBoardGeo = new THREE.BoxGeometry(118, 4, 118);
    const boardEdgesGeo = new THREE.EdgesGeometry(gameBoardGeo);
    const boardEdgesMat = new THREE.LineBasicMaterial({ color: 0x363636, linewidth: 4 });
    const sideBoardMat = new THREE.MeshLambertMaterial({ color: 0xD3D3D3 });

    function createBoardTopTexture(dirs) {
        const canvas = document.createElement('canvas');
        canvas.width = 120; canvas.height = 120;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#DEB887';
        ctx.fillRect(0, 0, 120, 120);
        ctx.strokeStyle = '#000000';
        ctx.lineWidth = 4;
        ctx.beginPath();
        if (dirs.includes('top')) { ctx.moveTo(60, 60); ctx.lineTo(60, 0); }
        if (dirs.includes('bottom')) { ctx.moveTo(60, 60); ctx.lineTo(60, 120); }
        if (dirs.includes('left')) { ctx.moveTo(60, 60); ctx.lineTo(0, 60); }
        if (dirs.includes('right')) { ctx.moveTo(60, 60); ctx.lineTo(120, 60); }
        ctx.stroke();
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return texture;
    }

    function createGameBoard(colCenter, rowCenter, dirs, startZOffset = 0) {
        const topMat = new THREE.MeshLambertMaterial({ map: createBoardTopTexture(dirs) });
        const materials = [sideBoardMat, sideBoardMat, topMat, sideBoardMat, sideBoardMat, sideBoardMat];
        const board = new THREE.Mesh(gameBoardGeo, materials);
        const pos = getCellWorldPos(colCenter, rowCenter);
        board.position.set(pos.x, 2, pos.z + startZOffset);
        const boardEdges = new THREE.LineSegments(boardEdgesGeo, boardEdgesMat);
        board.add(boardEdges);
        scene.add(board);
        boardMeshes.push(board);
        return { mesh: board, targetZ: pos.z, startZ: pos.z + startZOffset };
    }

    function getCellWorldPos(col, row) {
        return { x: (col - 3.5) * 60, z: (row - 3.5) * 60 };
    }

    const faceMapping = [3, 4, 1, 6, 2, 5]; 
    function createDiceMaterial(baseColor, dotColor, shape, number) {
        const canvas = document.createElement('canvas');
        canvas.width = 128; canvas.height = 128;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = baseColor;
        ctx.fillRect(0, 0, 128, 128);
        ctx.fillStyle = dotColor;

        const drawShape = (x, y) => {
            ctx.beginPath();
            if (shape === 'triangle') {
                const r = 16;
                ctx.moveTo(x, y - r);
                ctx.lineTo(x + r*0.866, y + r*0.5);
                ctx.lineTo(x - r*0.866, y + r*0.5);
            } else { 
                const r = 14;
                for (let i=0; i<6; i++) {
                    const angle = i * Math.PI / 3;
                    if(i===0) ctx.moveTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                    else ctx.lineTo(x + r*Math.cos(angle), y + r*Math.sin(angle));
                }
            }
            ctx.closePath();
            ctx.fill();
        };

        const dots = [];
        if(number === 1) dots.push([64, 64]);
        if(number === 2) dots.push([32, 32], [96, 96]);
        if(number === 3) dots.push([32, 32], [64, 64], [96, 96]);
        if(number === 4) dots.push([32, 32], [96, 32], [32, 96], [96, 96]);
        if(number === 5) dots.push([32, 32], [96, 32], [64, 64], [32, 96], [96, 96]);
        if(number === 6) dots.push([32, 24], [32, 64], [32, 104], [96, 24], [96, 64], [96, 104]);

        dots.forEach(p => drawShape(p[0], p[1]));
        const texture = new THREE.CanvasTexture(canvas);
        texture.colorSpace = THREE.SRGBColorSpace;
        return new THREE.MeshLambertMaterial({ map: texture });
    }

    function createDiceMesh(baseColor, dotColor, shape) {
        const materials = faceMapping.map(num => createDiceMaterial(baseColor, dotColor, shape, num));
        const cube = new THREE.Mesh(new THREE.BoxGeometry(48, 48, 48), materials);
        cube.userData = {
            currentNumber: 1, score: 1, animating: false, startTime: 0,
            startQuat: new THREE.Quaternion(), targetQuat: new THREE.Quaternion(),
            moving: false, moveStartTime: 0, moveDuration: 800,
            startX: 0, startZ: 0, targetX: 0, targetZ: 0, col: 0, row: 0, color: ''
        };
        return cube;
    }

    // 依據分數翻轉立方體至指定朝上點數
    function animateCubeToScore(cube, targetScore) {
        cube.userData.animating = true;
        cube.userData.startTime = performance.now();
        cube.userData.startQuat.copy(cube.quaternion);
        cube.userData.currentNumber = targetScore;

        let rx = 0, ry = 0, rz = 0;
        switch(targetScore) {
            case 1: break; 
            case 2: rx = -Math.PI/2; break; 
            case 3: rz = Math.PI/2; break;  
            case 4: rz = -Math.PI/2; break; 
            case 5: rx = Math.PI/2; break;  
            case 6: rx = Math.PI; break;    
        }
        cube.userData.targetQuat.setFromEuler(new THREE.Euler(rx, ry, rz));
    }

    // ==========================================
    // 4. 遊戲狀態與互動邏輯
    // ==========================================
    const itemY = 28;
    let cubes = [];
    let cylindersData = [];
    let markers = [];
    let raycaster = new THREE.Raycaster();
    let mouse = new THREE.Vector2();

    let blueCornerType = ''; 
    let orangeCornerType = '';
    let originalBlueCorner = '';
    let originalOrangeCorner = '';
    let placedEdges = { top: false, bottom: false, left: false, right: false };
    let placedCorners = { LB: false, RB: false, LT: false, RT: false };
    let centerBoardsAnimData = [];
    let isAnimatingPhase1 = false;
    let phase1Progress = 0;
    let edgeTurn = 1;

    // 核心對戰局數與行走變數
    let gameRound = 1;
    let gameplayActive = false;
    let currentPlayer = 'blue'; 
    let walkCount = 1; 
    let lastDirection = null; 
    let turnStopPositions = []; 
    let blueTotalScore = 0;
    let orangeTotalScore = 0;

    const uiMsg = document.getElementById('message');
    function showMessage(msg) {
        uiMsg.style.display = 'block';
        uiMsg.innerText = msg;
    }

    function createMarker(col, row, color, onClickCallback, customY = 5) {
        const pos = getCellWorldPos(col, row);
        const geo = new THREE.CylinderGeometry(18, 18, 2, 32);
        const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.8 });
        const marker = new THREE.Mesh(geo, mat);
        marker.position.set(pos.x, customY, pos.z);
        marker.userData.onClick = onClickCallback;
        scene.add(marker);
        markers.push(marker);
    }

    function clearMarkers() {
        markers.forEach(m => scene.remove(m));
        markers = [];
        document.body.style.cursor = 'default';
    }

    function placeCylinder(col, row) {
        const pos = getCellWorldPos(col, row);
        const cylGeo = new THREE.CylinderGeometry(24, 24, 48, 32);
        const cylMat = new THREE.MeshLambertMaterial({ color: 0x800080 });
        const cylinder = new THREE.Mesh(cylGeo, cylMat);
        cylinder.position.set(pos.x, itemY, pos.z);

        const cylEdgesGeo = new THREE.EdgesGeometry(cylGeo);
        const cylEdgesMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
        const cylEdges = new THREE.LineSegments(cylEdgesGeo, cylEdgesMat);
        cylinder.add(cylEdges);

        scene.add(cylinder);
        cylinderMeshes.push(cylinder);
        cylindersData.push({ c: col, r: row });
    }

    function placeCornerBoard(type) {
        if (placedCorners[type]) return;
        placedCorners[type] = true;
        if (type === 'LB') createGameBoard(0.5, 6.5, ['top', 'right']);
        if (type === 'RB') createGameBoard(6.5, 6.5, ['top', 'left']);
        if (type === 'LT') createGameBoard(0.5, 0.5, ['bottom', 'right']);
        if (type === 'RT') createGameBoard(6.5, 0.5, ['bottom', 'left']);
    }

    function placeEdgeBoards(side) {
        if (placedEdges[side]) return;
        placedEdges[side] = true;
        if (side === 'bottom') {
            createGameBoard(2.5, 6.5, ['left', 'right', 'bottom']);
            createGameBoard(4.5, 6.5, ['left', 'right', 'bottom']);
        }
        if (side === 'top') {
            createGameBoard(2.5, 0.5, ['left', 'right', 'top']);
            createGameBoard(4.5, 0.5, ['left', 'right', 'top']);
        }
        if (side === 'left') {
            createGameBoard(0.5, 2.5, ['top', 'bottom', 'left']);
            createGameBoard(0.5, 4.5, ['top', 'bottom', 'left']);
        }
        if (side === 'right') {
            createGameBoard(6.5, 2.5, ['top', 'bottom', 'right']);
            createGameBoard(6.5, 4.5, ['top', 'bottom', 'right']);
        }
    }

    function placeCube(col, row, color) {
        const pos = getCellWorldPos(col, row);
        let cube;
        if (color === 'blue') cube = createDiceMesh('#00008B', '#FFFFE0', 'hexagon');
        else cube = createDiceMesh('#FFA500', '#90EE90', 'triangle');
        cube.position.set(pos.x, itemY, pos.z);
        cube.userData.col = col;
        cube.userData.row = row;
        cube.userData.color = color;
        cube.userData.score = 1;
        cube.userData.currentNumber = 1;
        scene.add(cube);
        cubes.push(cube);
    }

    function createMicrophoneMesh(color, col, row) {
        const pos = getCellWorldPos(col, row);
        let geo;
        const mat = new THREE.MeshLambertMaterial({ color: color === 'blue' ? 0x00008B : 0xFFA500 });
        if (color === 'blue') {
            geo = new THREE.CylinderGeometry(20, 20, 4, 6); // 藍方正六邊形
        } else {
            geo = new THREE.CylinderGeometry(20, 20, 4, 3); // 橘方三角形
        }
        const mic = new THREE.Mesh(geo, mat);
        mic.position.set(pos.x, 6, pos.z); // 話筒高為4,平貼於面板之上 (面板表面Y為4)

        const edgesGeo = new THREE.EdgesGeometry(geo);
        const edgesMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 });
        const edges = new THREE.LineSegments(edgesGeo, edgesMat);
        mic.add(edges);

        mic.userData = { color: color, col: col, row: row };
        scene.add(mic);
        microphones.push(mic);
    }

    function isOccupiedByCyl(c, r) {
        return cylindersData.some(cyl => cyl.c === c && cyl.r === r);
    }
    // 新增:檢查橫線(Row)或縱線(Col)上是否已有圓柱體
    function isRowOrColOccupied(c, r) {
        return cylindersData.some(cyl => cyl.c === c || cyl.r === r);
    }
    function isDiagAdjacent(c1, r1, c2, r2) {
        return Math.abs(c1 - c2) === 1 && Math.abs(r1 - r2) === 1;
    }
    function isValidOrangeCenterCyl(c, r) {
        if (isRowOrColOccupied(c, r)) return false;
        return !cylindersData.some(cyl => isDiagAdjacent(c, r, cyl.c, cyl.r));
    }

    function getEdgeCells(cornerType) {
        let cells = [];
        if(cornerType.includes('B')) {
            for(let c=2; c<=5; c++) cells.push({c, r:7, side:'bottom'});
        }
        if(cornerType.includes('T')) {
            for(let c=2; c<=5; c++) cells.push({c, r:0, side:'top'});
        }
        if(cornerType.includes('L')) {
            for(let r=2; r<=5; r++) cells.push({c:0, r, side:'left'});
        }
        if(cornerType.includes('R')) {
            for(let r=2; r<=5; r++) cells.push({c:7, r, side:'right'});
        }
        return cells;
    }

    function getOuterCornerCells(cornerType) {
        if (cornerType === 'LB') return [{c:0, r:7}, {c:1, r:7}, {c:0, r:6}];
        if (cornerType === 'RB') return [{c:7, r:7}, {c:6, r:7}, {c:7, r:6}];
        if (cornerType === 'LT') return [{c:0, r:0}, {c:1, r:0}, {c:0, r:1}];
        if (cornerType === 'RT') return [{c:7, r:0}, {c:6, r:0}, {c:7, r:1}];
    }

    // ==========================================
    // 5. 核心開局流程控制器
    // ==========================================

    function startPhase1() {
        showMessage(`第 ${gameRound} 局開局:中心遊戲板就位`);
        centerBoardsAnimData.push(createGameBoard(2.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(4.5, 2.5, ['top', 'bottom', 'left', 'right'], -600));
        centerBoardsAnimData.push(createGameBoard(2.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        centerBoardsAnimData.push(createGameBoard(4.5, 4.5, ['top', 'bottom', 'left', 'right'], 600));
        isAnimatingPhase1 = true;
    }

    function startPhase2() {
        if (gameRound === 1) {
            showMessage("藍方行動:選擇並放置角遊戲板");
            createMarker(0.5, 6.5, 0x00008B, () => {
                clearMarkers(); blueCornerType = 'LB'; placeCornerBoard('LB'); startPhase3();
            });
            createMarker(6.5, 6.5, 0x00008B, () => {
                clearMarkers(); blueCornerType = 'RB'; placeCornerBoard('RB'); startPhase3();
            });
        } else {
            // 第二局由橘方先手,提供自由選擇左上角(LT)或右上角(RT)
            showMessage("橘方行動:選擇並放置角遊戲板(左上角或右上角)");
            createMarker(0.5, 0.5, 0xFFA500, () => {
                clearMarkers(); orangeCornerType = 'LT'; placeCornerBoard('LT'); startPhase3();
            });
            createMarker(6.5, 0.5, 0xFFA500, () => {
                clearMarkers(); orangeCornerType = 'RT'; placeCornerBoard('RT'); startPhase3();
            });
        }
    }

    function startPhase3() {
        if (gameRound === 1) {
            showMessage("橘方行動:放置對角遊戲板");
            orangeCornerType = (blueCornerType === 'LB') ? 'RT' : 'LT';
            const centerPos = (orangeCornerType === 'RT') ? {c: 6.5, r: 0.5} : {c: 0.5, r: 0.5};
            createMarker(centerPos.c, centerPos.r, 0xFFA500, () => {
                clearMarkers(); placeCornerBoard(orangeCornerType); startPhase4();
            });
        } else {
            showMessage("藍方行動:放置對角遊戲板");
            blueCornerType = (orangeCornerType === 'LT') ? 'RB' : 'LB';
            const centerPos = (blueCornerType === 'RB') ? {c: 6.5, r: 6.5} : {c: 0.5, r: 6.5};
            createMarker(centerPos.c, centerPos.r, 0x00008B, () => {
                clearMarkers(); placeCornerBoard(blueCornerType); startPhase4();
            });
        }
    }

    function startPhase4() {
        const teamName = gameRound === 1 ? "藍方" : "橘方";
        const colorHex = gameRound === 1 ? 0x00008B : 0xFFA500;
        showMessage(`${teamName}行動:在中心板上放置一個圓柱體`);
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                // 套用不可同行同列限制
                if (!isRowOrColOccupied(c, r)) {
                    createMarker(c, r, colorHex, () => {
                        clearMarkers(); placeCylinder(c, r); startPhase5();
                    });
                }
            }
        }
    }

    function startPhase5() {
        const teamName = gameRound === 1 ? "橘方" : "藍方";
        const colorHex = gameRound === 1 ? 0xFFA500 : 0x00008B;
        showMessage(`${teamName}行動:在中心板上放置一個圓柱體 (避開同行/同列/斜相鄰)`);
        for(let r=2; r<=5; r++) {
            for(let c=2; c<=5; c++) {
                if(isValidOrangeCenterCyl(c, r)) {
                    createMarker(c, r, colorHex, () => {
                        clearMarkers(); placeCylinder(c, r); startPhase6();
                    });
                }
            }
        }
    }

    function startPhase6() {
        if (edgeTurn > 4) {
            startPhase7();
            return;
        }
        const isBlueTurn = gameRound === 1 ? (edgeTurn % 2 !== 0) : (edgeTurn % 2 === 0);
        const colorHex = isBlueTurn ? 0x00008B : 0xFFA500;
        const cornerType = isBlueTurn ? blueCornerType : orangeCornerType;
        const teamName = isBlueTurn ? "藍方" : "橘方";
        
        showMessage(`${teamName}行動:放置邊遊戲板及圓柱體`);
        // 修改:過濾邊緣棋位時同樣必須滿足「不與已有圓柱體同行同列」
        const validCells = getEdgeCells(cornerType).filter(cell => !isRowOrColOccupied(cell.c, cell.r));
        validCells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeEdgeBoards(cell.side);
                placeCylinder(cell.c, cell.r);
                edgeTurn++;
                startPhase6();
            });
        });
    }

    function startPhase7() {
        const teamName = gameRound === 1 ? "藍方" : "橘方";
        const colorHex = gameRound === 1 ? 0x00008B : 0xFFA500;
        const cornerType = gameRound === 1 ? blueCornerType : orangeCornerType;
        
        showMessage(`${teamName}行動:放置本方移動立方體`);
        const cells = getOuterCornerCells(cornerType);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, gameRound === 1 ? 'blue' : 'orange');
                startPhase8();
            });
        });
    }

    function startPhase8() {
        const teamName = gameRound === 1 ? "橘方" : "藍方";
        const colorHex = gameRound === 1 ? 0xFFA500 : 0x00008B;
        const cornerType = gameRound === 1 ? orangeCornerType : blueCornerType;
        
        showMessage(`${teamName}行動:放置本方移動立方體`);
        const cells = getOuterCornerCells(cornerType);
        cells.forEach(cell => {
            createMarker(cell.c, cell.r, colorHex, () => {
                clearMarkers();
                placeCube(cell.c, cell.r, gameRound === 1 ? 'orange' : 'blue');
                
                // 補齊剩餘未覆蓋的角落
                ['LB', 'RB', 'LT', 'RT'].forEach(type => {
                    if (!placedCorners[type]) placeCornerBoard(type);
                });
                
                if (gameRound === 1) {
                    originalBlueCorner = blueCornerType;
                    originalOrangeCorner = orangeCornerType;
                }
                
                showMessage("開局階段結束!即將進入正式對戰模式...");
                setTimeout(() => {
                    uiMsg.style.display = 'none';
                    startGameplayPhase();
                }, 2500);
            });
        });
    }

    // ==========================================
    // 6. 正式對戰遊戲階段邏輯
    // ==========================================

    function startGameplayPhase() {
        gameplayActive = true;
        currentPlayer = gameRound === 1 ? 'blue' : 'orange';
        startPlayerTurn();
    }

    function startPlayerTurn() {
        walkCount = 1;
        lastDirection = null;
        turnStopPositions = [];
        showWalkOptions();
    }

    // 判斷格子是否為無法逾越的物理障礙物
    function isObstacle(c, r) {
        if (c < 0 || c > 7 || r < 0 || r > 7) return true; // 棋盤邊緣
        if (isOccupiedByCyl(c, r)) return true; // 圓柱體
        // 敵方立方體
        const oppColor = currentPlayer === 'blue' ? 'orange' : 'blue';
        const oppCube = cubes.find(q => q.userData.color === oppColor);
        if (oppCube && oppCube.userData.col === c && oppCube.userData.row === r) return true;
        return false;
    }

    // 計算一直直線滑動直至撞擊障礙物停下的終點座標
    function calculateStopPos(startC, startR, dc, dr) {
        let c = startC;
        let r = startR;
        while (true) {
            let nextC = c + dc;
            let nextR = r + dr;
            if (isObstacle(nextC, nextR)) {
                break;
            }
            c = nextC;
            r = nextR;
        }
        return { c, r };
    }

    function showWalkOptions() {
        clearMarkers();
        const activeCube = cubes.find(q => q.userData.color === currentPlayer);
        const c = activeCube.userData.col;
        const r = activeCube.userData.row;
        
        let validMoves = [];
        if (walkCount === 1) {
            // 第一次行走:任意四個十字方向皆可
            const dirs = [{dc:0, dr:-1}, {dc:0, dr:1}, {dc:-1, dr:0}, {dc:1, dr:0}];
            dirs.forEach(d => {
                const stop = calculateStopPos(c, r, d.dc, d.dr);
                if (stop.c !== c || stop.r !== r) validMoves.push({ dir: d, stop: stop });
            });
        } else {
            // 第二、三次行走:基於前一次方向進行90度偏轉(左或右)
            const dLeft = { dc: lastDirection.dr, dr: -lastDirection.dc };
            const dRight = { dc: -lastDirection.dr, dr: lastDirection.dc };
            
            [dLeft, dRight].forEach(d => {
                const stop = calculateStopPos(c, r, d.dc, d.dr);
                if (stop.c !== c || stop.r !== r) validMoves.push({ dir: d, stop: stop });
            });

            // 僅當左右皆為死路時,才允許往後退 (180度反轉)
            if (validMoves.length === 0) {
                const dBack = { dc: -lastDirection.dc, dr: -lastDirection.dr };
                const stop = calculateStopPos(c, r, dBack.dc, dBack.dr);
                if (stop.c !== c || stop.r !== r) validMoves.push({ dir: dBack, stop: stop });
            }
        }

        // 如果徹底無路可走,自動跳過本次行走
        if (validMoves.length === 0) {
            showMessage(`${currentPlayer === 'blue'?'藍方':'橘方'}第 ${walkCount} 次行走無路可走`);
            setTimeout(() => { handleWalkComplete(c, r, null, []); }, 1200);
            return;
        }

        showMessage(`輪到 ${currentPlayer === 'blue'?'藍方':'橘方'} 行動:第 ${walkCount} 次行走`);

        validMoves.forEach(mv => {
            // 如果終點格子上已經放了任何一方的話筒,則行動提示標示高度調高 5 單位
            const hasMic = microphones.some(m => m.userData.col === mv.stop.c && m.userData.row === mv.stop.r);
            const markerY = hasMic ? 10 : 5;
            const colorHex = currentPlayer === 'blue' ? 0x0000FF : 0xFF8C00;

            createMarker(mv.stop.c, mv.stop.r, colorHex, () => {
                clearMarkers();
                executeCubeMovement(activeCube, mv.dir, mv.stop);
            }, markerY);
        });
    }

    function executeCubeMovement(cube, dir, stop) {
        let traversed = [];
        let curC = cube.userData.col;
        let curR = cube.userData.row;
        
        // 紀錄直線平移中經過的所有路徑格子 (含終點)
        while (curC !== stop.c || curR !== stop.r) {
            curC += dir.dc;
            curR += dir.dr;
            traversed.push({ c: curC, r: curR });
        }

        const worldPos = getCellWorldPos(stop.c, stop.r);
        cube.userData.moving = true;
        cube.userData.startX = cube.position.x;
        cube.userData.startZ = cube.position.z;
        cube.userData.targetX = worldPos.x;
        cube.userData.targetZ = worldPos.z;
        cube.userData.moveStartTime = performance.now();
        
        cube.userData.onMoveComplete = () => {
            cube.userData.col = stop.c;
            cube.userData.row = stop.r;
            handleWalkComplete(stop.c, stop.r, dir, traversed);
        };
    }

    function handleWalkComplete(stopC, stopR, dir, traversed) {
        lastDirection = dir;
        const oppColor = currentPlayer === 'blue' ? 'orange' : 'blue';

        if (walkCount === 1 || walkCount === 2) {
            turnStopPositions.push({ c: stopC, r: stopR });
            // 規則:第1, 2次行走若經過敵方話筒,將其清除,但不給分
            if (traversed.length > 0) {
                microphones = microphones.filter(m => {
                    const passThrough = (m.userData.color === oppColor && traversed.some(t => t.c === m.userData.col && t.r === m.userData.row));
                    if (passThrough) scene.remove(m);
                    return !passThrough;
                });
            }
            walkCount++;
            showWalkOptions();
        } else if (walkCount === 3) {
            // 第三次行走結束:結算得分與話筒移除
            let earnedPoints = 0;
            let penaltyPoint = false;

            if (traversed.length > 0) {
                // 1. 經過敵方話筒:每經過一個得一分,並清空該話筒
                const enemyMics = microphones.filter(m => m.userData.color === oppColor && traversed.some(t => t.c === m.userData.col && t.r === m.userData.row));
                earnedPoints = enemyMics.length;
                enemyMics.forEach(m => scene.remove(m));
                microphones = microphones.filter(m => !enemyMics.includes(m));

                // 2. 本方話筒判定:若最終停留在本方話筒上,拿走話筒,且使對方得一分
                const ownMicAtEnd = microphones.find(m => m.userData.color === currentPlayer && m.userData.col === stopC && m.userData.row === stopR);
                if (ownMicAtEnd) {
                    penaltyPoint = true;
                    scene.remove(ownMicAtEnd);
                    microphones = microphones.filter(m => m !== ownMicAtEnd);
                }
            }

            const pCube = cubes.find(q => q.userData.color === currentPlayer);
            const oCube = cubes.find(q => q.userData.color === oppColor);

            if (earnedPoints > 0) {
                pCube.userData.score += earnedPoints;
                animateCubeToScore(pCube, Math.min(6, pCube.userData.score));
            }
            if (penaltyPoint) {
                oCube.userData.score += 1;
                animateCubeToScore(oCube, Math.min(6, oCube.userData.score));
            }

            // 檢查是否有一方達到 6 分而終止遊戲
            if (pCube.userData.score >= 6 || oCube.userData.score >= 6) {
                triggerRoundEnd();
                return;
            }

            // 進入放置話筒階段
            showMicrophonePlacementOptions();
        }
    }

    function showMicrophonePlacementOptions() {
        clearMarkers();
        const currentMicsCount = microphones.filter(m => m.userData.color === currentPlayer).length;
        
        // 觸發不需放置話筒的特殊條件:場上已有4個本方話筒
        if (currentMicsCount >= 4) {
            switchTurn();
            return;
        }

        // 提取第1、2次停留點(去重)
        let targetSpots = [];
        turnStopPositions.forEach(p => {
            if (!targetSpots.some(ts => ts.c === p.c && ts.r === p.r)) targetSpots.push(p);
        });

        // 過濾掉已經有本方話筒的棋位
        let availableSpots = targetSpots.filter(p => {
            return !microphones.some(m => m.userData.color === currentPlayer && m.userData.col === p.c && m.userData.row === p.r);
        });

        // 如果兩個停留點皆已存在本方話筒,則本次行動不需放置
        if (availableSpots.length === 0) {
            switchTurn();
            return;
        }

        showMessage(`${currentPlayer === 'blue'?'藍方':'橘方'}行動:選擇在第一次或第二次停靠點放置話筒`);

        availableSpots.forEach(spot => {
            createMarker(spot.c, spot.r, 0x00FF7F, () => {
                clearMarkers();
                createMicrophoneMesh(currentPlayer, spot.c, spot.r);
                switchTurn();
            }, 6);
        });
    }

    function switchTurn() {
        currentPlayer = currentPlayer === 'blue' ? 'orange' : 'blue';
        startPlayerTurn();
    }

    // ==========================================
    // 7. 局末結算與重開 UI 控制
    // ==========================================

    function triggerRoundEnd() {
        gameplayActive = false;
        clearMarkers();

        const blueFinal = cubes.find(q => q.userData.color === 'blue').userData.score;
        const orangeFinal = cubes.find(q => q.userData.color === 'orange').userData.score;

        blueTotalScore += blueFinal;
        orangeTotalScore += orangeFinal;

        let roundWinner = blueFinal > orangeFinal ? "藍方" : (orangeFinal > blueFinal ? "橘方" : "平手");

        // 構建 HTML 結算互動介面
        const overlay = document.createElement('div');
        overlay.style.cssText = 'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';
        
        const title = document.createElement('h2');
        title.style.fontSize = '36px';
        title.innerText = `第 ${gameRound} 局結束!本局勝者:${roundWinner}`;
        overlay.appendChild(title);

        const scoreInfo = document.createElement('p');
        scoreInfo.style.fontSize = '22px';
        scoreInfo.innerText = `藍方單局得分:${blueFinal} | 橘方單局得分:${orangeFinal}`;
        overlay.appendChild(scoreInfo);

        if (gameRound === 1) {
            // 第一局結束,提供三個選項
            const btnNo = createOverlayButton("否 (顯示總結算)", () => {
                document.body.removeChild(overlay); showFinalGameSummary();
            });
            const btnSame = createOverlayButton("用當前場地再戰一局", () => {
                document.body.removeChild(overlay); gameRound = 2; reinitNextRound(true);
            });
            const btnNew = createOverlayButton("重新開局", () => {
                document.body.removeChild(overlay); gameRound = 2; reinitNextRound(false);
            });
            overlay.appendChild(btnNo); overlay.appendChild(btnSame); overlay.appendChild(btnNew);
        } else {
            // 兩局都打完了
            const btnEnd = createOverlayButton("查看最終贏家及總分", () => {
                document.body.removeChild(overlay); showFinalGameSummary();
            });
            overlay.appendChild(btnEnd);
        }
        document.body.appendChild(overlay);
    }

    function createOverlayButton(text, clickFn) {
        const btn = document.createElement('button');
        btn.innerText = text;
        btn.style.cssText = 'padding:14px 35px; margin:10px; font-size:18px; font-weight:bold; color:#fff; background-color:#3498db; border:none; border-radius:5px; cursor:pointer; min-width:260px; box-shadow:0 3px 6px rgba(0,0,0,0.3);';
        btn.addEventListener('click', clickFn);
        return btn;
    }

    function cleanupEntities() {
        microphones.forEach(m => scene.remove(m));
        microphones = [];
        cubes.forEach(c => scene.remove(c));
        cubes = [];
        clearMarkers();
    }

    function reinitNextRound(keepBoard) {
        // 修改:移除原本在此處寫死的 gameRound = 2 賦值,使其相容於全重開機制
        cleanupEntities();

        if (keepBoard) {
            // 沿用場地:不清除棋盤與圓柱,直接重新進入立方體角位放置階段
            orangeCornerType = (originalBlueCorner === 'LB') ? 'LT' : 'RT';
            blueCornerType = (orangeCornerType === 'LT') ? 'RB' : 'LB';
            startPhase7();
        } else {
            // 重新開局:將所有動態棋盤、圓柱從場景與陣列中徹底清空
            boardMeshes.forEach(b => scene.remove(b));
            boardMeshes = [];
            centerBoardsAnimData = [];
            cylinderMeshes.forEach(c => scene.remove(c));
            cylinderMeshes = [];
            cylindersData = [];

            placedEdges = { top: false, bottom: false, left: false, right: false };
            placedCorners = { LB: false, RB: false, LT: false, RT: false };
            edgeTurn = 1;
            phase1Progress = 0;

            startPhase1();
        }
    }

    function showFinalGameSummary() {
        const overlay = document.createElement('div');
        overlay.style.cssText = 'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.92); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';
        
        let ultimateWinner = blueTotalScore > orangeTotalScore ? "藍方" : (orangeTotalScore > blueTotalScore ? "橘方" : "平手");
        
        const h1 = document.createElement('h1');
        h1.style.fontSize = '42px';
        h1.innerText = `對抗賽終局!最終贏家:${ultimateWinner}`;
        overlay.appendChild(h1);

        const p = document.createElement('p');
        p.style.fontSize = '26px';
        p.style.margin = '20px 0 40px 0';
        p.innerText = `總比分 -> 藍方總得分:${blueTotalScore} 點  ||  橘方總得分:${orangeTotalScore} 點`;
        overlay.appendChild(p);

        const btnRestartAll = createOverlayButton("重新開始整個遊戲", () => {
            document.body.removeChild(overlay);
            // 完全重置所有統計數據歸零,重開必定為藍方先行 (gameRound = 1)
            gameRound = 1; blueTotalScore = 0; orangeTotalScore = 0;
            reinitNextRound(false);
        });
        overlay.appendChild(btnRestartAll);
        document.body.appendChild(overlay);
    }

    // ==========================================
    // 8. 事件綁定與主迴圈
    // ==========================================

    document.getElementById('btn-start').addEventListener('click', (e) => {
        e.target.parentElement.style.display = 'none';
        startPhase1();
    });

    container.addEventListener('pointerdown', (event) => {
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        if (markers.length > 0) {
            const intersects = raycaster.intersectObjects(markers);
            if (intersects.length > 0) {
                intersects[0].object.userData.onClick();
            }
        }
    });

    container.addEventListener('pointermove', (event) => {
        if (markers.length === 0) return;
        const rect = container.getBoundingClientRect();
        mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
        mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        const intersects = raycaster.intersectObjects(markers);
        if (intersects.length > 0) document.body.style.cursor = 'pointer';
        else document.body.style.cursor = 'default';
    });

    function animate() {
        requestAnimationFrame(animate);
        const now = performance.now();

        // 處理中心遊戲板入場動畫
        if (isAnimatingPhase1) {
            phase1Progress += 0.015;
            if (phase1Progress >= 1) {
                phase1Progress = 1;
                isAnimatingPhase1 = false;
                startPhase2();
            }
            const easeOut = phase1Progress * (2 - phase1Progress);
            centerBoardsAnimData.forEach(b => {
                b.mesh.position.z = b.startZ + (b.targetZ - b.startZ) * easeOut;
            });
        }

        // 處理正式對戰階段:立方體平移與高拋平滑曲線控制效果
        cubes.forEach(cube => {
            if (cube.userData.moving) {
                const elapsed = now - cube.userData.moveStartTime;
                let t = elapsed / cube.userData.moveDuration;
                if (t >= 1) {
                    t = 1;
                    cube.userData.moving = false;
                    if (cube.userData.onMoveComplete) {
                        const cb = cube.userData.onMoveComplete;
                        cube.userData.onMoveComplete = null;
                        cb();
                    }
                }
                // 直線平移 X 與 Z
                cube.position.x = THREE.MathUtils.lerp(cube.userData.startX, cube.userData.targetX, t);
                cube.position.z = THREE.MathUtils.lerp(cube.userData.startZ, cube.userData.targetZ, t);
                // 貝塞爾高拋躍起特效:升起 20 個高度單位 (相當於5個話筒高度4*5),落回原本底盤
                cube.position.y = itemY + Math.sin(t * Math.PI) * 20;
            }
        });

        // 處理立方體得分時的 3D 旋轉翻轉點數動畫
        cubes.forEach(cube => {
            if (cube.userData.animating) {
                const elapsed = now - cube.userData.startTime;
                const duration = 1000;
                let t = elapsed / duration;

                if (t >= 1) {
                    t = 1;
                    cube.userData.animating = false;
                }
                // 翻轉時的離地跳躍微幅晃動
                if (!cube.userData.moving) {
                    cube.position.y = itemY + Math.sin(t * Math.PI) * 48;
                }
                cube.quaternion.slerpQuaternions(cube.userData.startQuat, cube.userData.targetQuat, t);
            }
        });

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

    animate();
</script>

</body>
</html>

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

改進:
在頁面下方增加「行走撤銷和重做」、「回合後退和前進」圖形按鈕。當點擊「行走撤銷」時,將回退到上一步操作(可能是己方也可能是對方),當點擊「回合後退」將回退到對方上回合開始時的情形。當撤銷或後退之後,如果有新的操作,則不再能夠「行走重做」或「回合前進」。

例如當本方行走一次結束時,點擊「行走撤銷」,將回到本回合開始時的情形,再點擊一次「行走撤銷」,將回到對方選擇放置話筒時的情形。這時如果對方選擇了另一個位置放話筒,則不再能執行「行走重做」。

需要明確給出所要修改的地方,不用給出全部代碼。

代码: 全选

改進:
在頁面下方增加「行走撤銷和重做」、「回合後退和前進」圖形按鈕。當點擊「行走撤銷」時,將回退到上一步操作(可能是己方也可能是對方),當點擊「回合後退」將回退到對方上回合開始時的情形。當撤銷或後退之後,如果有新的操作,則不再能夠「行走重做」或「回合前進」。

例如當本方行走一次結束時,點擊「行走撤銷」,將回到本回合開始時的情形,再點擊一次「行走撤銷」,將回到對方選擇放置話筒時的情形。這時如果對方選擇了另一個位置放話筒,則不再能執行「行走重做」。

需要明確給出所要修改的地方,不用給出全部代碼。

代码: 全选

為了解決這個需求,我們需要在遊戲中建立一個**狀態快照(Snapshot)機制**。每當玩家做出選擇(點擊行走終點或點擊話筒位置)時,將當前的棋盤狀態、玩家回合、剩餘步數、分數等資訊打包存入「撤銷棧(Undo Stack)」。

以下是具體的修改方案,分為 4 個步驟進行注入,無需重寫整個檔案:

---

### 1. 新增 HTML 介面與 CSS 樣式

在 `<style>` 標籤中加入按鈕樣式,並在 `<body>` 中添加控制面板。

**在 `<style>` 區塊內結尾處加上:**

```css
#history-controls {
    position: absolute;
    bottom: 20px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    gap: 12px;
    z-index: 20;
    pointer-events: auto;
}
.hist-btn {
    padding: 10px 20px;
    font-size: 16px;
    font-weight: bold;
    color: #fff;
    background-color: #34495e;
    border: 2px solid #bdc3c7;
    border-radius: 6px;
    cursor: pointer;
    transition: background-color 0.2s, opacity 0.2s;
    box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.hist-btn:hover { background-color: #4e6a85; }
.hist-btn:disabled {
    background-color: #7f8c8d;
    color: #bdc3c7;
    cursor: not-allowed;
    opacity: 0.5;
    box-shadow: none;
}

```

**在 `<body>` 的 `<div id="message"></div>` 下方加上:**

```html
<div id="history-controls" style="display: none;">
    <button id="btn-walk-undo" class="hist-btn">行走撤銷</button>
    <button id="btn-walk-redo" class="hist-btn">行走重做</button>
    <button id="btn-turn-undo" class="hist-btn">回合後退</button>
    <button id="btn-turn-redo" class="hist-btn">回合前進</button>
</div>

```

---

### 2. 注入核心快照與歷史記錄邏輯

在 `<script type="module">` 內,**5. 核心開局流程控制器** 的上方,定義快照與撤銷/重做核心函數:

```javascript
// ==========================================
// [新增] 歷史紀錄與快照核心機制
// ==========================================
let historyUndoStack = [];
let historyRedoStack = [];

function saveSnapshot() {
    return {
        currentPlayer: currentPlayer,
        walkCount: walkCount,
        lastDirection: lastDirection ? { ...lastDirection } : null,
        turnStopPositions: JSON.parse(JSON.stringify(turnStopPositions)),
        gameplayActive: gameplayActive,
        subPhase: markers.some(m => m.position.y === 6) ? 'mic' : 'walk', // 透過提示圈高度判斷處於話筒還是行走階段
        cubes: cubes.map(c => ({
            color: c.userData.color, col: c.userData.col, row: c.userData.row,
            score: c.userData.score, currentNumber: c.userData.currentNumber,
            quat: c.quaternion.clone()
        })),
        microphones: microphones.map(m => ({
            color: m.userData.color, col: m.userData.col, row: m.userData.row
        }))
    };
}

function restoreSnapshot(snap) {
    currentPlayer = snap.currentPlayer;
    walkCount = snap.walkCount;
    lastDirection = snap.lastDirection;
    turnStopPositions = snap.turnStopPositions;
    gameplayActive = snap.gameplayActive;

    // 清理當前 3D 實體
    cubes.forEach(c => scene.remove(c)); cubes = [];
    microphones.forEach(m => scene.remove(m)); microphones = [];
    clearMarkers();

    // 還原立方體與角度
    snap.cubes.forEach(sCube => {
        placeCube(sCube.col, sCube.row, sCube.color);
        const newCube = cubes[cubes.length - 1];
        newCube.userData.score = sCube.score;
        newCube.userData.currentNumber = sCube.currentNumber;
        newCube.userData.moving = false;
        newCube.userData.animating = false;
        newCube.quaternion.copy(sCube.quat);
    });

    // 還原話筒
    snap.microphones.forEach(sMic => {
        createMicrophoneMesh(sMic.color, sMic.col, sMic.row);
    });

    // 重新繪製當前階段的引導圈
    if (snap.subPhase === 'walk') {
        showWalkOptions();
    } else {
        showMicrophonePlacementOptions();
    }
}

function pushAction() {
    historyUndoStack.push(saveSnapshot());
    historyRedoStack = []; // 有新操作時,清空重做棧
    updateUndoRedoButtons();
}

function updateUndoRedoButtons() {
    const wUndo = document.getElementById('btn-walk-undo');
    const wRedo = document.getElementById('btn-walk-redo');
    const tUndo = document.getElementById('btn-turn-undo');
    const tRedo = document.getElementById('btn-turn-redo');
    if (!wUndo) return;

    wUndo.disabled = (historyUndoStack.length === 0);
    wRedo.disabled = (historyRedoStack.length === 0);
    tRedo.disabled = (historyRedoStack.length === 0);

    // 回合後退判定:棧中是否存在對方上回合開始(walkCount===1且是行走階段)的快照
    let hasTurnUndo = false;
    if (gameplayActive) {
        for (let i = historyUndoStack.length - 1; i >= 0; i--) {
            let snap = historyUndoStack[i];
            if (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk') {
                hasTurnUndo = true;
                break;
            }
        }
    }
    tUndo.disabled = !hasTurnUndo;
}

function walkUndo() {
    if (!gameplayActive || historyUndoStack.length === 0) return;
    historyRedoStack.push(saveSnapshot());
    restoreSnapshot(historyUndoStack.pop());
    updateUndoRedoButtons();
}

function walkRedo() {
    if (!gameplayActive || historyRedoStack.length === 0) return;
    historyUndoStack.push(saveSnapshot());
    restoreSnapshot(historyRedoStack.pop());
    updateUndoRedoButtons();
}

function turnUndo() {
    if (!gameplayActive) return;
    let targetIdx = -1;
    for (let i = historyUndoStack.length - 1; i >= 0; i--) {
        let snap = historyUndoStack[i];
        if (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk') {
            targetIdx = i;
            break;
        }
    }
    if (targetIdx === -1) return;

    historyRedoStack.push(saveSnapshot());
    while (historyUndoStack.length > targetIdx + 1) {
        historyRedoStack.push(historyUndoStack.pop());
    }
    restoreSnapshot(historyUndoStack.pop());
    updateUndoRedoButtons();
}

function turnRedo() {
    if (!gameplayActive || historyRedoStack.length === 0) return;
    historyUndoStack.push(saveSnapshot());
    while (historyRedoStack.length > 1) {
        historyUndoStack.push(historyRedoStack.pop());
    }
    restoreSnapshot(historyRedoStack.pop());
    updateUndoRedoButtons();
}

```

---

### 3. 攔截互動點以記錄狀態

我們需要在玩家點擊「行走提示圈」或「話筒放置圈」的一瞬間(正式執行動作前)呼叫 `pushAction()` 存檔。

**修改 `showWalkOptions` 函數內建立提示圈的部分:**

```javascript
// 尋找此段程式碼並加上 pushAction();
createMarker(mv.stop.c, mv.stop.r, colorHex, () => {
    pushAction(); // <--- 插入這行
    clearMarkers();
    executeCubeMovement(activeCube, mv.dir, mv.stop);
}, markerY);

```

**修改 `showMicrophonePlacementOptions` 函數內建立提示圈的部分:**

```javascript
// 尋找此段程式碼並加上 pushAction();
createMarker(spot.c, spot.r, 0x00FF7F, () => {
    pushAction(); // <--- 插入這行
    clearMarkers();
    createMicrophoneMesh(currentPlayer, spot.c, spot.r);
    switchTurn();
}, 6);

```

---

### 4. 綁定按鈕事件與控制面板顯示切換

最後,在遊戲開始時顯示面板,結束時隱藏面板,並綁定點擊事件。

**修改 `startGameplayPhase` 與 `triggerRoundEnd` 函數:**

```javascript
function startGameplayPhase() {
    gameplayActive = true;
    currentPlayer = gameRound === 1 ? 'blue' : 'orange';
    
    // 初始化清空棧並顯示控制面板
    historyUndoStack = [];
    historyRedoStack = [];
    document.getElementById('history-controls').style.display = 'flex';
    updateUndoRedoButtons();

    startPlayerTurn();
}

function triggerRoundEnd() {
    gameplayActive = false;
    clearMarkers();
    document.getElementById('history-controls').style.display = 'none'; // <--- 隱藏控制面板
    // ... 後續原有的結算邏輯

```

**在程式碼末尾(如 `animate();` 上方)加上事件監聽綁定:**

```javascript
document.getElementById('btn-walk-undo').addEventListener('click', walkUndo);
document.getElementById('btn-walk-redo').addEventListener('click', walkRedo);
document.getElementById('btn-turn-undo').addEventListener('click', turnUndo);
document.getElementById('btn-turn-redo').addEventListener('click', turnRedo);

```

### 運作邏輯說明:

1. **行走撤銷:** 每當點擊撤銷時,將當前狀態移入重做棧,並彈出上一次快照還原。若連續撤銷到上回合對方放話筒時,此時若做了不同的新話筒放置選擇,新動作觸發的 `pushAction()` 會立刻洗掉重做棧(`historyRedoStack = []`),使得原來的「行走重做」徹底失效。
2. **回合後退:** 點擊後會向後搜尋直到找到「對方的 walkCount 為 1 的行走選單狀態」,並將中間所有橫跨的快照依序打包移入重做棧,達成一鍵回退整個回合的效果。
附件
three.min.js.7z
(132.76 KiB) 已下载 7 次
three160.7z
(202.8 KiB) 已下载 8 次
csinger23.html.7z
(9.6 KiB) 已下载 8 次
上次由 ejsoon 在 2026年 6月 14日 11:25,总共编辑 3 次。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

改進:
當一方在第一次或第二次行走或結束時,如果停留在己方的話筒上,則應保持升起高度為5。

當一方的分數達到六分時,應等其翻轉到六點朝上,然後才結束本局遊戲。

需要明確給出所要修改的地方,不用給出全部代碼。

代码: 全选

改進:
當一方在第一次或第二次行走或結束時,如果停留在己方的話筒上,則應保持升起高度為5。

當一方的分數達到六分時,應等其翻轉到六點朝上,然後才結束本局遊戲。

需要明確給出所要修改的地方,不用給出全部代碼。
附件
csinger231.html.7z
(10.87 KiB) 已下载 8 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

為本遊戲製作蒙地卡洛樹搜尋AI:
在下方按鈕位置增加「藍方AI」、「綠方AI」,當選中時其將高亮。

AI在行動時,左上角將顯示html元素,提示AI正在思考、思考結束、思考用時、勝率。

再在下方最右邊增加「AI強度」按鈕,當點擊時,彈出選單,可選擇「簡單、困難、專家」。

需要明確給出所要修改的地方,不用給出全部代碼。

代码: 全选

為本遊戲製作蒙地卡洛樹搜尋AI:
在下方按鈕位置增加「藍方AI」、「綠方AI」,當選中時其將高亮。

AI在行動時,左上角將顯示html元素,提示AI正在思考、思考結束、思考用時、勝率。

再在下方最右邊增加「AI強度」按鈕,當點擊時,彈出選單,可選擇「簡單、困難、專家」。

需要明確給出所要修改的地方,不用給出全部代碼。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這是一項非常有趣的擴充!要為這款 3D 棋盤遊戲加入蒙地卡洛樹搜尋(MCTS)AI,我們需要將遊戲的「視覺與控制(Three.js)」與「純邏輯狀態」分離,讓 AI 能夠在背景快速模擬千上萬次而不用真的移動畫面上的物件。

以下是針對你提供的 HTML 檔案所需的具體修改片段。請依照標籤尋找對應位置並插入或替換程式碼。

### 1. CSS 樣式修改

在 `<style>` 標籤內的結尾處(在 `</style>` 之前)加入以下 CSS,用於控制 AI 狀態列、按鈕高亮與強度選單:

```css
      /* AI 狀態提示面板 */
      #ai-status {
        position: absolute;
        top: 20px;
        left: 20px;
        padding: 15px 20px;
        background-color: rgba(44, 62, 80, 0.9);
        border: 2px solid #3498db;
        border-radius: 8px;
        color: #fff;
        font-size: 16px;
        font-weight: bold;
        pointer-events: none;
        z-index: 30;
        display: none;
        box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
      }
      .ai-btn.active-ai {
        background-color: #e67e22; /* 啟用 AI 時的亮橘色 */
        border-color: #f39c12;
      }
      /* AI 強度選單容器 */
      .ai-strength-container {
        position: relative;
        display: inline-block;
      }
      .ai-strength-dropdown {
        display: none;
        position: absolute;
        bottom: 100%;
        left: 0;
        background-color: #34495e;
        min-width: 120px;
        border: 2px solid #bdc3c7;
        border-radius: 6px;
        margin-bottom: 5px;
        z-index: 30;
        overflow: hidden;
      }
      .ai-strength-dropdown button {
        width: 100%;
        padding: 10px;
        background: none;
        border: none;
        color: white;
        text-align: left;
        cursor: pointer;
      }
      .ai-strength-dropdown button:hover {
        background-color: #4e6a85;
      }
      .ai-strength-dropdown button.selected {
        background-color: #2980b9;
      }

```

---

### 2. HTML 元素新增

在 `<div id="history-controls"...>` 區塊中,加入 AI 相關按鈕;並在 `<body>` 內加入狀態顯示面板:

```html
    <div id="ai-status">
      狀態:<span id="ai-state-text">等待中</span><br>
      用時:<span id="ai-time">0</span> ms<br>
      勝率:<span id="ai-winrate">0</span>%
    </div>

    <div id="history-controls" style="display: none">
      <button id="btn-walk-undo" class="hist-btn">行走撤銷</button>
      <button id="btn-walk-redo" class="hist-btn">行走重做</button>
      <button id="btn-turn-undo" class="hist-btn">回合後退</button>
      <button id="btn-turn-redo" class="hist-btn">回合前進</button>
      
      <button id="btn-ai-blue" class="hist-btn ai-btn">藍方 AI</button>
      <button id="btn-ai-orange" class="hist-btn ai-btn">綠方 AI</button>
      
      <div class="ai-strength-container">
        <button id="btn-ai-strength" class="hist-btn">AI強度: 簡單</button>
        <div id="ai-dropdown" class="ai-strength-dropdown">
          <button data-level="easy" class="selected">簡單 (100次)</button>
          <button data-level="hard">困難 (500次)</button>
          <button data-level="expert">專家 (2000次)</button>
        </div>
      </div>
    </div>

```

---

### 3. JavaScript 全域變數與 MCTS 核心邏輯

在 `<script type="module">` 內,找到 `let historyUndoStack = [];`(約在歷史紀錄機制附近),在它上方加入 AI 相關設定與 MCTS 類別:

```javascript
      // ==========================================
      // [新增] AI 狀態與 MCTS (Monte Carlo Tree Search) 核心
      // ==========================================
      let aiConfig = {
        blue: false,
        orange: false,
        level: 'easy',
        iterations: { easy: 100, hard: 500, expert: 2000 }
      };

      // 純邏輯遊戲狀態,用於 MCTS 模擬,避免操作 Three.js 實體
      class SimState {
        constructor(baseSnap) {
          // 從現有遊戲狀態快照(或另一 SimState)建立
          this.currentPlayer = baseSnap.currentPlayer;
          this.walkCount = baseSnap.walkCount;
          this.lastDirection = baseSnap.lastDirection ? { ...baseSnap.lastDirection } : null;
          this.turnStopPositions = [...baseSnap.turnStopPositions];
          this.subPhase = baseSnap.subPhase;
          this.cubes = JSON.parse(JSON.stringify(baseSnap.cubes));
          this.microphones = JSON.parse(JSON.stringify(baseSnap.microphones));
        }
        
        getLegalMoves() {
            let moves = [];
            const activeCube = this.cubes.find(c => c.color === this.currentPlayer);
            if (!activeCube) return moves;

            if (this.subPhase === 'walk') {
                let dirs = [];
                if (this.walkCount === 1) {
                    dirs = [{dc: 0, dr: -1}, {dc: 0, dr: 1}, {dc: -1, dr: 0}, {dc: 1, dr: 0}];
                } else if (this.lastDirection) {
                    const dL = { dc: this.lastDirection.dr, dr: -this.lastDirection.dc };
                    const dR = { dc: -this.lastDirection.dr, dr: this.lastDirection.dc };
                    dirs = [dL, dR];
                    
                    // 檢查左右是否皆為死路
                    let canTurn = false;
                    for (let d of dirs) {
                        let stop = this.calcStop(activeCube.col, activeCube.row, d.dc, d.dr);
                        if (stop.c !== activeCube.col || stop.r !== activeCube.row) canTurn = true;
                    }
                    if (!canTurn) dirs.push({ dc: -this.lastDirection.dc, dr: -this.lastDirection.dr });
                }

                dirs.forEach(d => {
                    let stop = this.calcStop(activeCube.col, activeCube.row, d.dc, d.dr);
                    if (stop.c !== activeCube.col || stop.r !== activeCube.row) {
                        moves.push({ type: 'walk', dir: d, stop: stop });
                    }
                });
                
                // 無路可走視為跳過
                if (moves.length === 0) moves.push({ type: 'skip' });
                
            } else if (this.subPhase === 'mic') {
                const currentMicsCount = this.microphones.filter(m => m.color === this.currentPlayer).length;
                if (currentMicsCount >= 4) {
                    moves.push({ type: 'skip_mic' });
                    return moves;
                }
                
                let targetSpots = [];
                this.turnStopPositions.forEach(p => {
                  if (!targetSpots.some(ts => ts.c === p.c && ts.r === p.r)) targetSpots.push(p);
                });
                
                let availableSpots = targetSpots.filter(p => {
                  return !this.microphones.some(m => m.color === this.currentPlayer && m.col === p.c && m.row === p.r);
                });

                if (availableSpots.length === 0) moves.push({ type: 'skip_mic' });
                else availableSpots.forEach(s => moves.push({ type: 'mic', spot: s }));
            }
            return moves;
        }

        calcStop(startC, startR, dc, dr) {
            let c = startC, r = startR;
            while (true) {
                let nextC = c + dc, nextR = r + dr;
                // 檢查邊界與圓柱 (假設 cylindersData 全域不變)
                if (nextC < 0 || nextC > 7 || nextR < 0 || nextR > 7) break;
                if (cylindersData.some(cyl => cyl.c === nextC && cyl.r === nextR)) break;
                // 檢查敵方立方體
                const oppColor = this.currentPlayer === 'blue' ? 'orange' : 'blue';
                const oppCube = this.cubes.find(q => q.color === oppColor);
                if (oppCube && oppCube.col === nextC && oppCube.row === nextR) break;
                
                c = nextC; r = nextR;
            }
            return { c, r };
        }

        applyMove(move) {
            if (this.subPhase === 'walk') {
                if (move.type === 'skip') {
                    this.walkCount = 3; // 直接推進結算
                    this.subPhase = 'mic';
                    return;
                }
                
                const activeCube = this.cubes.find(c => c.color === this.currentPlayer);
                const oppColor = this.currentPlayer === 'blue' ? 'orange' : 'blue';
                const oppCube = this.cubes.find(c => c.color === oppColor);
                
                let traversed = [];
                let curC = activeCube.col; let curR = activeCube.row;
                while (curC !== move.stop.c || curR !== move.stop.r) {
                    curC += move.dir.dc; curR += move.dir.dr;
                    traversed.push({ c: curC, r: curR });
                }

                activeCube.col = move.stop.c;
                activeCube.row = move.stop.r;
                this.lastDirection = move.dir;

                if (this.walkCount < 3) {
                    this.turnStopPositions.push({ c: move.stop.c, r: move.stop.r });
                    // 第1, 2次經過敵方話筒清除不給分
                    if (traversed.length > 0) {
                        this.microphones = this.microphones.filter(m => {
                            return !(m.color === oppColor && traversed.some(t => t.c === m.col && t.r === m.row));
                        });
                    }
                    this.walkCount++;
                } else {
                    // 第3次結算
                    if (traversed.length > 0) {
                        const enemyMics = this.microphones.filter(m => 
                            m.color === oppColor && traversed.some(t => t.c === m.col && t.r === m.row)
                        );
                        if (enemyMics.length > 0) {
                            activeCube.score += enemyMics.length;
                            this.microphones = this.microphones.filter(m => !enemyMics.includes(m));
                        }
                        
                        const ownMicAtEnd = this.microphones.find(m => 
                            m.color === this.currentPlayer && m.col === move.stop.c && m.row === move.stop.r
                        );
                        if (ownMicAtEnd) {
                            oppCube.score += 1;
                            this.microphones = this.microphones.filter(m => m !== ownMicAtEnd);
                        }
                    }
                    this.subPhase = 'mic';
                }
            } else if (this.subPhase === 'mic') {
                if (move.type === 'mic') {
                    this.microphones.push({ color: this.currentPlayer, col: move.spot.c, row: move.spot.r });
                }
                // 回合結束,換人
                this.currentPlayer = this.currentPlayer === 'blue' ? 'orange' : 'blue';
                this.walkCount = 1;
                this.lastDirection = null;
                this.turnStopPositions = [];
                this.subPhase = 'walk';
            }
        }

        checkTerminal() {
            const b = this.cubes.find(c => c.color === 'blue');
            const o = this.cubes.find(c => c.color === 'orange');
            if (b && b.score >= 6) return 'blue';
            if (o && o.score >= 6) return 'orange';
            return null;
        }
      }

      class MCTSNode {
        constructor(state, parent = null, move = null) {
            this.state = state;
            this.parent = parent;
            this.move = move;
            this.children = [];
            this.wins = 0;
            this.visits = 0;
            this.untriedMoves = state.getLegalMoves();
            this.playerJustMoved = parent ? parent.state.currentPlayer : state.currentPlayer;
        }

        uctSelectChild() {
            // UCT 公式
            let bestValue = -Infinity;
            let bestChild = null;
            for (let child of this.children) {
                const uctValue = (child.wins / child.visits) + 1.414 * Math.sqrt(Math.log(this.visits) / child.visits);
                if (uctValue > bestValue) {
                    bestValue = uctValue;
                    bestChild = child;
                }
            }
            return bestChild;
        }

        expand() {
            const move = this.untriedMoves.pop();
            const nextState = new SimState(this.state);
            nextState.applyMove(move);
            const childNode = new MCTSNode(nextState, this, move);
            this.children.push(childNode);
            return childNode;
        }

        simulate() {
            let simState = new SimState(this.state);
            let depth = 0;
            while (simState.checkTerminal() === null && depth < 50) { // 防止無限迴圈
                const moves = simState.getLegalMoves();
                if (moves.length === 0) break;
                const randomMove = moves[Math.floor(Math.random() * moves.length)];
                simState.applyMove(randomMove);
                depth++;
            }
            return simState.checkTerminal();
        }

        backpropagate(winner) {
            this.visits++;
            if (winner === this.playerJustMoved) {
                this.wins += 1;
            } else if (winner === null) {
                this.wins += 0.5; // 平手
            }
            if (this.parent) this.parent.backpropagate(winner);
        }
      }

      async function runAI() {
        const aiStatusPanel = document.getElementById('ai-status');
        const aiStateText = document.getElementById('ai-state-text');
        const aiTimeText = document.getElementById('ai-time');
        const aiWinRateText = document.getElementById('ai-winrate');
        
        aiStatusPanel.style.display = 'block';
        aiStateText.innerText = '思考中...';
        aiStateText.style.color = '#f1c40f';
        
        const startTime = performance.now();
        
        // 將當前 Three.js 狀態轉換為純邏輯初始快照
        const rootSnap = saveSnapshot();
        const rootState = new SimState(rootSnap);
        const rootNode = new MCTSNode(rootState);
        
        const maxIterations = aiConfig.iterations[aiConfig.level];
        const aiPlayerColor = currentPlayer;

        // 使用非同步讓出主執行緒,避免畫面卡死
        await new Promise(resolve => setTimeout(resolve, 50));

        for (let i = 0; i < maxIterations; i++) {
            let node = rootNode;
            let state = new SimState(rootState);

            // 1. Select
            while (node.untriedMoves.length === 0 && node.children.length > 0) {
                node = node.uctSelectChild();
                state.applyMove(node.move);
            }
            
            // 2. Expand
            if (node.untriedMoves.length > 0 && state.checkTerminal() === null) {
                node = node.expand();
                state.applyMove(node.move);
            }
            
            // 3. Simulate
            const winner = node.simulate();
            
            // 4. Backpropagate
            node.backpropagate(winner);
            
            // 每 100 次迭代更新一下畫面時間 (專家模式才不會卡太久)
            if (i % 100 === 0) {
                await new Promise(resolve => setTimeout(resolve, 0));
            }
        }

        const endTime = performance.now();
        
        // 找出最佳子節點 (造訪次數最多的通常最穩)
        let bestChild = null;
        let maxVisits = -1;
        for (let child of rootNode.children) {
            if (child.visits > maxVisits) {
                maxVisits = child.visits;
                bestChild = child;
            }
        }

        const winRate = bestChild ? ((bestChild.wins / bestChild.visits) * 100).toFixed(1) : 0;
        
        aiStateText.innerText = '思考完成';
        aiStateText.style.color = '#2ecc71';
        aiTimeText.innerText = Math.round(endTime - startTime);
        aiWinRateText.innerText = winRate;

        setTimeout(() => {
            aiStatusPanel.style.display = 'none';
        }, 2000);

        return bestChild ? bestChild.move : null;
      }

```

---

### 4. 綁定 UI 事件與攔截遊戲流程

在檔案最底部的事件綁定區域(約在 `animate();` 呼叫之前),新增 AI 選單事件,並**修改原有的** `showWalkOptions` 與 `showMicrophonePlacementOptions` 函式,使其能夠識別 AI 回合。

```javascript
      // ==========================================
      // [修改與新增] 事件綁定與 AI 攔截邏輯
      // ==========================================
      
      // AI UI 控制綁定
      document.getElementById('btn-ai-blue').addEventListener('click', (e) => {
          aiConfig.blue = !aiConfig.blue;
          e.target.classList.toggle('active-ai', aiConfig.blue);
          // 若目前剛好是藍方回合,立刻觸發 AI
          if (aiConfig.blue && gameplayActive && currentPlayer === 'blue') interceptAITurn();
      });
      document.getElementById('btn-ai-orange').addEventListener('click', (e) => {
          aiConfig.orange = !aiConfig.orange;
          e.target.classList.toggle('active-ai', aiConfig.orange);
          if (aiConfig.orange && gameplayActive && currentPlayer === 'orange') interceptAITurn();
      });
      
      const btnStrength = document.getElementById('btn-ai-strength');
      const aiDropdown = document.getElementById('ai-dropdown');
      btnStrength.addEventListener('click', () => {
          aiDropdown.style.display = aiDropdown.style.display === 'block' ? 'none' : 'block';
      });
      aiDropdown.querySelectorAll('button').forEach(btn => {
          btn.addEventListener('click', (e) => {
              aiDropdown.querySelectorAll('button').forEach(b => b.classList.remove('selected'));
              e.target.classList.add('selected');
              aiConfig.level = e.target.dataset.level;
              const textMap = { easy: '簡單', hard: '困難', expert: '專家' };
              btnStrength.innerText = `AI強度: ${textMap[aiConfig.level]}`;
              aiDropdown.style.display = 'none';
          });
      });

      // 用於統一處理 AI 行動的入口
      async function interceptAITurn() {
          const isAI = (currentPlayer === 'blue' && aiConfig.blue) || (currentPlayer === 'orange' && aiConfig.orange);
          if (!isAI || !gameplayActive) return;

          // 隱藏目前可能有的滑鼠操作引導圈
          clearMarkers();
          
          const chosenMove = await runAI();
          if (!chosenMove) return;

          pushAction();
          
          if (chosenMove.type === 'walk') {
              const activeCube = cubes.find(q => q.userData.color === currentPlayer);
              executeCubeMovement(activeCube, chosenMove.dir, chosenMove.stop);
          } else if (chosenMove.type === 'skip') {
              const activeCube = cubes.find(q => q.userData.color === currentPlayer);
              handleWalkComplete(activeCube.userData.col, activeCube.userData.row, null, []);
          } else if (chosenMove.type === 'mic') {
              createMicrophoneMesh(currentPlayer, chosenMove.spot.c, chosenMove.spot.r);
              switchTurn();
          } else if (chosenMove.type === 'skip_mic') {
              switchTurn();
          }
      }

      // [需要修改原有的 showWalkOptions 函式]
      // 請找到原本的 function showWalkOptions() { ... }
      // 在其最開頭加入以下程式碼:
      /* --- 複製以下片段替換原 showWalkOptions 內部頂端 --- */
      const oldShowWalkOptions = showWalkOptions;
      showWalkOptions = function() {
          const isAI = (currentPlayer === 'blue' && aiConfig.blue) || (currentPlayer === 'orange' && aiConfig.orange);
          if (isAI) {
              interceptAITurn();
              return; // AI 接管,不顯示玩家操作的 markers
          }
          
          // ... 這裡貼上原本 showWalkOptions 內的所有程式碼 ...
          // (為了維持回答簡潔,原版找 markers 的邏輯保持不變放在這下面)
          clearMarkers();
          const activeCube = cubes.find(q => q.userData.color === currentPlayer);
          // ... (原本的 validMoves 產生邏輯等) ...
      };
      
      // [需要修改原有的 showMicrophonePlacementOptions 函式]
      // 請找到原本的 function showMicrophonePlacementOptions() { ... }
      // 在其最開頭加入以下程式碼:
      const oldShowMicOptions = showMicrophonePlacementOptions;
      showMicrophonePlacementOptions = function() {
          const isAI = (currentPlayer === 'blue' && aiConfig.blue) || (currentPlayer === 'orange' && aiConfig.orange);
          if (isAI) {
              interceptAITurn();
              return;
          }
          
          // ... 這裡貼上原本 showMicrophonePlacementOptions 內的程式碼 ...
          clearMarkers();
          const currentMicsCount = microphones.filter(m => m.userData.color === currentPlayer).length;
          // ... (原本的話筒判斷邏輯等) ...
      };

```

### 實作重點說明:

1. **純資料層模擬 (`SimState`)**:MCTS 必須在極短時間內模擬遊戲數千次。由於 Three.js 的物件與動畫太吃效能且帶有非同步動畫,我編寫了一個輕量版的 `SimState`,它負責完整複製你的「網格、圓柱體障礙、走步方向與得分規則」,完全剝離 3D 運算。
2. **非同步避免卡死 (`async / await`)**:當玩家選擇「專家 (2000次)」時,AI 運算會很繁重。透過 `await new Promise(...)` 將任務切分,可確保瀏覽器左上角的「思考中...」面板能順利渲染出來,畫面不會凍結。
3. **無縫接管 (`interceptAITurn`)**:透過包裝原有的 `showWalkOptions` 和 `showMicOptions`,當判斷當前回合的顏色啟用了 AI,就不會再畫出提示圈(`markers`),而是直接把權限丟給背景的 MCTS 運算。

> **注意:** 開局的佈陣階段(Phase 1~8)屬於高度自由且極度複雜的樹狀結構(MCTS 難以在短時間內搜完所有棋盤組合)。此腳本專注於**正式對戰階段 (`startGameplayPhase`)** 的 AI 演算。若你在開局佈陣時就開啟 AI,目前仍需玩家手動幫 AI 點選初始位置,進入正式對戰後 AI 就會自動接手走步。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

改進:
當點擊「開始遊戲」時,從選擇「角遊戲板」開始,「行走撤銷和重做」、「回合後退和前進」就應顯示並可用,把它們簡化為四個SVG圖標。

當一方在第一次或第二次行走結束時,如果停留在己方的話筒上,則應保持升起高度為5。

當一方的分數達到六分時,應等其翻轉到六點朝上,然後才結束本局遊戲。

需要明確給出所要修改的地方,不用給出全部代碼。

代码: 全选

改進:
當點擊「開始遊戲」時,從選擇「角遊戲板」開始,「行走撤銷和重做」、「回合後退和前進」就應顯示並可用,把它們簡化為四個SVG圖標。

當一方在第一次或第二次行走結束時,如果停留在己方的話筒上,則應保持升起高度為5。

當一方的分數達到六分時,應等其翻轉到六點朝上,然後才結束本局遊戲。

需要明確給出所要修改的地方,不用給出全部代碼。

gemini 3.1 pro

代码: 全选

為達成您的需求,請將以下程式碼片段替換至原本的檔案對應位置。

### 1. 修改 UI 樣式與 HTML(替換歷史控制面板)

首先,更新 `.hist-btn` 的 CSS,以便讓 SVG 圖標置中顯示;然後將 `#history-controls` 的按鈕文字替換為 SVG 圖示:

**修改 `<style>` 中的 `.hist-btn`:**

```css
      .hist-btn {
        padding: 10px; /* 調整內邊距以適應圖標 */
        display: flex;
        justify-content: center;
        align-items: center;
        font-size: 16px;
        font-weight: bold;
        color: #fff;
        background-color: #34495e;
        border: 2px solid #bdc3c7;
        border-radius: 6px;
        cursor: pointer;
        transition:
          background-color 0.2s,
          opacity 0.2s;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
      }

```

**替換 `<body>` 內的 `<div id="history-controls">`:**

```html
    <div id="history-controls" style="display: none">
      <button id="btn-walk-undo" class="hist-btn" title="行走撤銷">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C20.76 11.03 17.03 8 12.5 8z"/></svg>
      </button>
      <button id="btn-walk-redo" class="hist-btn" title="行走重做">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M11.5 8C16.03 8 19.76 11.03 21 14.72l-2.37.78c-1.05-3.19-4.06-5.5-7.6-5.5-1.95 0-3.73.72-5.12 1.88L9.53 16H.53V7l3.6 3.6C5.98 8.99 8.38 8 11.5 8z"/></svg>
      </button>
      <button id="btn-turn-undo" class="hist-btn" title="回合後退">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
      </button>
      <button id="btn-turn-redo" class="hist-btn" title="回合前進">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M4 11h12.17l-5.59-5.59L12 4l8 8-8 8-1.41-1.41L16.17 13H4v-2z"/></svg>
      </button>
    </div>

```

---

### 2. 在選擇角遊戲板時提前顯示控制面板

**修改 `startPhase2()` 函數的開頭:**

```javascript
      function startPhase2() {
        // 從選擇角遊戲板開始顯示控制按鈕
        document.getElementById('history-controls').style.display = 'flex';
        updateUndoRedoButtons();

        if (gameRound === 1) {
          // ... 原本的邏輯

```

---

### 3. 加入「停留在己方話筒上保持升起高度 5」的邏輯

這需要同時修改移動邏輯與動畫渲染,讓立方體擁有動態的目標高度 `targetY`。

**第一處修改:在 `executeCubeMovement()` 內更新起始與目標高度**

```javascript
      function executeCubeMovement(cube, dir, stop) {
        let traversed = [];
        let curC = cube.userData.col;
        let curR = cube.userData.row;

        // 紀錄直線平移中經過的所有路徑格子 (含終點)
        while (curC !== stop.c || curR !== stop.r) {
          curC += dir.dc;
          curR += dir.dr;
          traversed.push({ c: curC, r: curR });
        }

        const worldPos = getCellWorldPos(stop.c, stop.r);
        cube.userData.moving = true;
        cube.userData.startX = cube.position.x;
        cube.userData.startZ = cube.position.z;
        cube.userData.startY = cube.position.y; // 新增起始高度紀錄
        
        cube.userData.targetX = worldPos.x;
        cube.userData.targetZ = worldPos.z;
        
        // 判斷終點是否有己方話筒,並設定目標高度
        const onOwnMic = microphones.some(m => m.userData.color === currentPlayer && m.userData.col === stop.c && m.userData.row === stop.r);
        cube.userData.targetY = onOwnMic ? itemY + 5 : itemY;

        cube.userData.moveStartTime = performance.now();

        cube.userData.onMoveComplete = () => {
        // ... 保持不變 ...

```

**第二處修改:在 `restoreSnapshot()` 確保撤銷/重做時高度正確復原**

```javascript
        // 還原立方體與角度
        snap.cubes.forEach(sCube => {
          placeCube(sCube.col, sCube.row, sCube.color);
          const newCube = cubes[cubes.length - 1];
          newCube.userData.score = sCube.score;
          newCube.userData.currentNumber = sCube.currentNumber;
          newCube.userData.moving = false;
          newCube.userData.animating = false;
          newCube.quaternion.copy(sCube.quat);
          
          // 還原時檢查是否在己方話筒上以調整高度
          const onOwnMic = snap.microphones.some(m => m.color === sCube.color && m.col === sCube.col && m.row === sCube.row);
          const targetY = onOwnMic ? itemY + 5 : itemY;
          newCube.position.y = targetY;
          newCube.userData.targetY = targetY;
        });

```

**第三處修改:在 `animate()` 函數內修改 Y 軸的平滑運動與翻轉彈跳動畫**
找到 `animate()` 中針對 `cube.userData.moving` 與 `cube.userData.animating` 的區塊,修改如下:

```javascript
        // 處理正式對戰階段:立方體平移與高拋平滑曲線控制效果
        cubes.forEach(cube => {
          if (cube.userData.moving) {
            const elapsed = now - cube.userData.moveStartTime;
            let t = elapsed / cube.userData.moveDuration;
            if (t >= 1) {
              t = 1;
              cube.userData.moving = false;
              if (cube.userData.onMoveComplete) {
                const cb = cube.userData.onMoveComplete;
                cube.userData.onMoveComplete = null;
                cb();
              }
            }
            // 直線平移 X 與 Z
            cube.position.x = THREE.MathUtils.lerp(cube.userData.startX, cube.userData.targetX, t);
            cube.position.z = THREE.MathUtils.lerp(cube.userData.startZ, cube.userData.targetZ, t);
            
            // 加入起點與終點的 Y 軸高度平滑過渡 (取代原本直接寫死的 itemY)
            const currentStartY = cube.userData.startY !== undefined ? cube.userData.startY : itemY;
            const currentTargetY = cube.userData.targetY !== undefined ? cube.userData.targetY : itemY;
            cube.position.y = THREE.MathUtils.lerp(currentStartY, currentTargetY, t) + Math.sin(t * Math.PI) * 20;
          }
        });

        // 處理立方體得分時的 3D 旋轉翻轉點數動畫
        cubes.forEach(cube => {
          if (cube.userData.animating) {
            const elapsed = now - cube.userData.startTime;
            const duration = 1000;
            let t = elapsed / duration;

            if (t >= 1) {
              t = 1;
              cube.userData.animating = false;
            }
            // 翻轉時的離地跳躍微幅晃動,需基於其所在的目標高度
            if (!cube.userData.moving) {
              const baseRestY = cube.userData.targetY !== undefined ? cube.userData.targetY : itemY;
              cube.position.y = baseRestY + Math.sin(t * Math.PI) * 48;
            }
            cube.quaternion.slerpQuaternions(cube.userData.startQuat, cube.userData.targetQuat, t);
          }
        });

```

---

### 4. 滿 6 分後等待動畫再觸發結算

**修改 `handleWalkComplete()` 內的終結判定(大約在函數的最後):**

```javascript
          // 檢查是否有一方達到 6 分而終止遊戲
          if (pCube.userData.score >= 6 || oCube.userData.score >= 6) {
            // 等翻轉動畫完成 (1000 毫秒) 後再結束本局
            setTimeout(() => {
              triggerRoundEnd();
            }, 1000);
            return;
          }

          // 進入放置話筒階段
          showMicrophonePlacementOptions();
        }
      }

```
上次由 ejsoon 在 2026年 6月 14日 15:06,总共编辑 1 次。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: Amazon [Bot] 和 1 访客