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

準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

製作一個基於svg的html5弈棋遊戲:
一,《三菱棋(Trirhombus)》是一款二人抽象策略遊戲,雙方所用的棋子相同。

本棋的棋子有三種,在所上傳的svg的defs中,其名稱和id對應關係為:藍棋id=tile0,紅棋id=tile1,橙棋id=tile2。

每個玩家每種棋子都持有6個,則一方一共持有18個棋子。

二,棋盤的網格為豎放的正三角形,即把所有密鋪的正三角形的邊連起來會得到豎線。正三角形的高為18,以兩個豎邊貼合的三角形作為棋盤中心。不用真的把網格畫出來,這裡只是在介紹規則。

每個棋子都是一個相當於由兩個正三角形組合而成的菱形。藍棋是兩個正三角形横向貼合,所貼合的邊是豎邊。紅棋是斜邊對斜邊上下貼合,上面的三角形朝左,下面朝右。橙棋也是斜邊對斜邊上下貼合,但跟紅棋相反,上面的三角形朝右,下面朝左。

三,起初一個藍棋落在棋盤中心,這是先手方的第一手棋。往後所有的棋子都要與場上已有的棋子邊與邊貼合,唯一禁止的是形成空洞。後手方的第二手棋一定要用紅棋,往後雙方可以任意選擇棋子類型。

横坐標:棋盤中心往左的每個縱列的三角形的横坐標為「abcdefg...」,往右為「ABCDEFG...」。

縱坐標:横向中線一排三角形是第0排,往下將是第1、2、3…正排,往上則是第-1、-2、-3…負排。

程式中記三角形坐標時先寫縱坐標再寫横坐標,如(a,-1)。

一個棋子由兩個正三角形構成,則要有兩組坐標,比如[(a,-1),(a,-2)]。

記譜簡寫:
1.開局第一手藍棋置於棋盤中心,記作「0」。
2.如果藍棋的兩個三角形都位於0排,如果在左方則標為「一個小寫字母」,右方則標為「一個大寫字母」,取最靠近中心的三角形横坐標。
3.如果藍棋的兩個三角形分別位於a列和A列,如果在下方(正排)則標作「a$A」,上方(負排)則標作「A$a」,$指無符號數字。
4.如果藍棋位於其它地方,則標作「前字母$後字母」。如果在下方(正排),則前字母與後字母按字母順序排,上方(負排)則前字母與後字母按字母倒序排,
5.紅棋和橙棋標作「$字母$」,$指無符號數字,分別為前數字和後數字。當兩個三角形中至少有一個是正數時,則前數字小於後數字,否則前數字大於後數字。

四,頁面功能
頁面背景為淺黄色。

svg直接放進html,垂直居中並且水平居中,作為棋盤。如果是電腦端,其上下間距應為36px,如果是手機端,其左右間距應為0。

棋盤外為雙方分別提供三種菱形棋子按鈕(每個棋子按鈕都是一個獨立的svg),以及它的剩餘數量。如果是手機端,則我方(先手方)的藍、紅、橙棋横向一排置於svg下方,平分間隔,數量標在棋子下方。對方則用相同的擺放方式翻轉180度後置於svg的上方。

如果是電腦端,則我方置於右下角,藍、紅、橙棋縱向擺放,數字位於棋子右邊。對方則位於左上角,並且以藍、紅、橙棋的順序縱向擺放,數字位於棋子的左邊。

第一手藍棋默認是先手已下至svg棋盤中心,則輪到後手方,他將可以點擊一個棋子,當他點擊其中一個棋子後,svg中的g.etdrop內部的最後將會加上多個對應的棋子<use href="tile$">,並加上opacity使其處於半透明狀態,置於其所有能夠落子的地方。

當他點擊其中一個棋子,則它將去掉opacity屬性,同時其它透明狀態的棋子將會
移除,之後輪到另一方。

同時顯示當前玩家的分數,計分規則將在之後介紹,現在先加上計分的元素。電腦端我方分數顯示在右上角,對方在左下角。手機端我方顯示在最下方,對方翻轉180度後顯示在最上方。目前先是一方下了多少藍棋就得多少分。

把所有css和js都寫到一個html中。

代码: 全选

製作一個基於svg的html5弈棋遊戲:
一,《三菱棋(Trirhombus)》是一款二人抽象策略遊戲,雙方所用的棋子相同。

本棋的棋子有三種,在所上傳的svg的defs中,其名稱和id對應關係為:藍棋id=tile0,紅棋id=tile1,橙棋id=tile2。

每個玩家每種棋子都持有6個,則一方一共持有18個棋子。

二,棋盤的網格為豎放的正三角形,即把所有密鋪的正三角形的邊連起來會得到豎線。正三角形的高為18,以兩個豎邊貼合的三角形作為棋盤中心。不用真的把網格畫出來,這裡只是在介紹規則。

每個棋子都是一個相當於由兩個正三角形組合而成的菱形。藍棋是兩個正三角形横向貼合,所貼合的邊是豎邊。紅棋是斜邊對斜邊上下貼合,上面的三角形朝左,下面朝右。橙棋也是斜邊對斜邊上下貼合,但跟紅棋相反,上面的三角形朝右,下面朝左。

三,起初一個藍棋落在棋盤中心,這是先手方的第一手棋。往後所有的棋子都要與場上已有的棋子邊與邊貼合,唯一禁止的是形成空洞。後手方的第二手棋一定要用紅棋,往後雙方可以任意選擇棋子類型。

横坐標:棋盤中心往左的每個縱列的三角形的横坐標為「abcdefg...」,往右為「ABCDEFG...」。

縱坐標:横向中線一排三角形是第0排,往下將是第1、2、3…正排,往上則是第-1、-2、-3…負排。

程式中記三角形坐標時先寫縱坐標再寫横坐標,如(a,-1)。

一個棋子由兩個正三角形構成,則要有兩組坐標,比如[(a,-1),(a,-2)]。

記譜簡寫:
1.開局第一手藍棋置於棋盤中心,記作「0」。
2.如果藍棋的兩個三角形都位於0排,如果在左方則標為「一個小寫字母」,右方則標為「一個大寫字母」,取最靠近中心的三角形横坐標。
3.如果藍棋的兩個三角形分別位於a列和A列,如果在下方(正排)則標作「a$A」,上方(負排)則標作「A$a」,$指無符號數字。
4.如果藍棋位於其它地方,則標作「前字母$後字母」。如果在下方(正排),則前字母與後字母按字母順序排,上方(負排)則前字母與後字母按字母倒序排,
5.紅棋和橙棋標作「$字母$」,$指無符號數字,分別為前數字和後數字。當兩個三角形中至少有一個是正數時,則前數字小於後數字,否則前數字大於後數字。

四,頁面功能
頁面背景為淺黄色。

svg直接放進html,垂直居中並且水平居中,作為棋盤。如果是電腦端,其上下間距應為36px,如果是手機端,其左右間距應為0。

棋盤外為雙方分別提供三種菱形棋子按鈕(每個棋子按鈕都是一個獨立的svg),以及它的剩餘數量。如果是手機端,則我方(先手方)的藍、紅、橙棋横向一排置於svg下方,平分間隔,數量標在棋子下方。對方則用相同的擺放方式翻轉180度後置於svg的上方。

如果是電腦端,則我方置於右下角,藍、紅、橙棋縱向擺放,數字位於棋子右邊。對方則位於左上角,並且以藍、紅、橙棋的順序縱向擺放,數字位於棋子的左邊。

第一手藍棋默認是先手已下至svg棋盤中心,則輪到後手方,他將可以點擊一個棋子,當他點擊其中一個棋子後,svg中的g.etdrop內部的最後將會加上多個對應的棋子<use href="tile$">,並加上opacity使其處於半透明狀態,置於其所有能夠落子的地方。

當他點擊其中一個棋子,則它將去掉opacity屬性,同時其它透明狀態的棋子將會
移除,之後輪到另一方。

同時顯示當前玩家的分數,計分規則將在之後介紹,現在先加上計分的元素。電腦端我方分數顯示在右上角,對方在左下角。手機端我方顯示在最下方,對方翻轉180度後顯示在最上方。目前先是一方下了多少藍棋就得多少分。

把所有css和js都寫到一個html中。
附件
ejtrirhombus_tile.svg.7z
(638 Bytes) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6041
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini3.1pro要睡到16:52

代码: 全选

這是一個基於您提供的 SVG 和規則,使用 HTML5、CSS3 和 Vanilla JavaScript 完整編寫的《三菱棋 (Trirhombus)》網頁遊戲。

所有的樣式、邏輯與視圖都已經封裝在單一的 HTML 檔案中。您可以將以下的程式碼複製並存成 `.html` 檔案(例如 `trirhombus.html`),然後使用瀏覽器開啟即可遊玩。

```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>三菱棋 (Trirhombus)</title>
    <style>
        :root {
            --bg-color: #ffffe0;
            --panel-gap: 20px;
        }

        body {
            margin: 0;
            padding: 0;
            background-color: var(--bg-color);
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            font-family: sans-serif;
            overflow: hidden;
            user-select: none;
        }

        #game-container {
            position: relative;
            display: flex;
            flex-direction: column;
            align-items: center;
            width: 100%;
            height: 100%;
        }

        .panel {
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 10;
        }

        .score-board {
            font-size: 1.2rem;
            font-weight: bold;
            color: #333;
        }

        .pieces-container {
            display: flex;
            gap: 15px;
        }

        .piece-btn {
            display: flex;
            flex-direction: column;
            align-items: center;
            cursor: pointer;
            transition: transform 0.1s;
        }

        .piece-btn:active {
            transform: scale(0.9);
        }
        
        .piece-btn.disabled {
            opacity: 0.3;
            cursor: not-allowed;
        }

        .piece-btn svg {
            width: 50px;
            height: 50px;
        }

        .piece-count {
            font-size: 1.2rem;
            font-weight: bold;
        }

        /* Responsive Layouts */
        @media (max-width: 767px) {
            /* 手機端 */
            #game-container {
                justify-content: space-between;
                padding: 10px 0;
            }
            
            #svg-container {
                margin: 0; /* 手機端左右間距為0 */
                width: 100vw;
                display: flex;
                justify-content: center;
            }

            #svg-container svg {
                max-width: 100%;
                height: auto;
            }

            .panel {
                width: 100%;
                flex-direction: column;
                gap: 10px;
            }

            .pieces-container {
                width: 100%;
                justify-content: space-evenly;
            }

            #player1-panel { /* 對方 (上方) */
                transform: rotate(180deg);
            }
        }

        @media (min-width: 768px) {
            /* 電腦端 */
            #game-container {
                display: grid;
                grid-template-columns: 1fr auto 1fr;
                grid-template-rows: 1fr auto 1fr;
                width: 100vw;
                height: 100vh;
            }

            #svg-container {
                grid-area: 2 / 2;
                margin: 36px 0; /* 電腦端上下間距36px */
            }

            #player1-panel { /* 對方 (左上角) */
                grid-area: 1 / 1;
                transform: rotate(180deg);
                flex-direction: column;
                align-items: flex-end;
                padding: 20px;
            }

            #player0-panel { /* 我方 (右下角) */
                grid-area: 3 / 3;
                flex-direction: column;
                align-items: flex-end;
                padding: 20px;
            }

            .pieces-container {
                flex-direction: column;
                gap: 20px;
            }

            .piece-btn {
                flex-direction: row;
                gap: 10px;
            }

            /* 左上角對方文字在左邊 */
            #player1-panel .piece-btn {
                flex-direction: row-reverse; 
            }
            
            .score-board {
                margin-bottom: 20px;
            }
        }

        .ghost {
            cursor: pointer;
            transition: opacity 0.2s;
        }
        .ghost:hover {
            opacity: 0.8 !important;
        }
    </style>
</head>
<body>

<div id="game-container">
    <div id="player1-panel" class="panel">
        <div class="score-board">分數: <span id="score-1">0</span></div>
        <div class="pieces-container">
            <div class="piece-btn" onclick="selectPiece(0)">
                <svg viewBox="-40 -40 80 80"><use href="#tile0"></use></svg>
                <span class="piece-count" id="count-1-0">6</span>
            </div>
            <div class="piece-btn" onclick="selectPiece(1)">
                <svg viewBox="-40 -40 80 80"><use href="#tile1"></use></svg>
                <span class="piece-count" id="count-1-1">6</span>
            </div>
            <div class="piece-btn" onclick="selectPiece(2)">
                <svg viewBox="-40 -40 80 80"><use href="#tile2"></use></svg>
                <span class="piece-count" id="count-1-2">6</span>
            </div>
        </div>
    </div>

    <div id="svg-container">
        <svg id="etani" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="480" height="480" rfm="repeat" viewBox="0 0 480 480" preserveAspectRatio="xMidYMid meet">
            <defs mode="0" rotatestep="1" rotatestart="0" stepvalue="180" snapto="0" displaywait="1">
                <g id="tile0" frontfill="#00BFFF" backfill="#00BFFF"><path d="M 0,-18 L 31.177,0 L 0,18 L -31.177,0 Z"/></g>
                <g id="tile1" frontfill="#FF0000" backfill="#FF0000"><path d="M 15.588,9 L -15.588,27 L -15.588,-9 L 15.588,-27 Z"/></g>
                <g id="tile2" frontfill="#FF8C00" backfill="#FF8C00"><path d="M -15.588,9 L -15.588,-27 L 15.588,-9 L 15.588,27 Z"/></g>
            </defs>
            <g class="etdrop" stroke="#777" stroke-width="2" stroke-linejoin="round" transform="translate(240,240) scale(1,1)" id="etanidrop">
                <g class="etboard"><circle r="6" cx="0" cy="0" fill="purple" stroke="none" stroke-width="0" class="bcenter"/></g>
                <use href="#tile0" fill="#00BFFF" transform="translate(0,0) scale(1,1) rotate(0)" class="tiledropped"/>
            </g>
        </svg>
    </div>

    <div id="player0-panel" class="panel">
        <div class="score-board">分數: <span id="score-0">1</span></div>
        <div class="pieces-container">
            <div class="piece-btn disabled" id="btn-0-0" onclick="selectPiece(0)">
                <svg viewBox="-40 -40 80 80"><use href="#tile0"></use></svg>
                <span class="piece-count" id="count-0-0">5</span>
            </div>
            <div class="piece-btn disabled" id="btn-0-1" onclick="selectPiece(1)">
                <svg viewBox="-40 -40 80 80"><use href="#tile1"></use></svg>
                <span class="piece-count" id="count-0-1">6</span>
            </div>
            <div class="piece-btn disabled" id="btn-0-2" onclick="selectPiece(2)">
                <svg viewBox="-40 -40 80 80"><use href="#tile2"></use></svg>
                <span class="piece-count" id="count-0-2">6</span>
            </div>
        </div>
    </div>
</div>

<script>
    // === 遊戲核心邏輯與數學系統 ===
    const W = 31.1769; // 菱形半寬 (正三角形的高)
    const H = 18;      // 正三角形的半高
    
    // 棋盤狀態 - 使用 "方向_列_排" 作為唯一標識
    // 方向 L: 尖端向左的正三角形
    // 方向 R: 尖端向右的正三角形
    let board = new Set(['L_-1_0', 'R_0_0']); // 初始為先手在中心的藍棋
    
    let turn = 1; // 0 為先手(下方), 1 為後手(上方)。第一手已下,輪到後手。
    let totalMoves = 1;
    
    // 每位玩家的棋子剩餘量 [玩家0, 玩家1]
    let counts = [
        [5, 6, 6], // 藍, 紅, 橙 (玩家0已用掉1個藍)
        [6, 6, 6]
    ];
    
    let scores = [1, 0]; // 目前分數:玩家0下了1個藍棋,得1分
    
    let activeGhosts = []; // 儲存當前顯示的半透明預覽棋子

    // 取得相鄰的三個三角形位置
    function getAdjacencies(c, r, isLeft) {
        if (isLeft) return [`R_${c+1}_${r}`, `R_${c}_${r-1}`, `R_${c}_${r+1}`];
        else return [`L_${c-1}_${r}`, `L_${c}_${r-1}`, `L_${c}_${r+1}`];
    }

    // 取得給定放置點(以其左半三角形L座標為基準)的兩個三角形組合
    function getTileParts(c, r, type) {
        if (type === 0) return [`L_${c}_${r}`, `R_${c+1}_${r}`];       // 藍:左右相連
        if (type === 1) return [`L_${c}_${r}`, `R_${c}_${r+1}`];       // 紅:右上(L) 接 左下(R)
        if (type === 2) return [`L_${c}_${r}`, `R_${c}_${r-1}`];       // 橙:右下(L) 接 左上(R)
    }

    // 計算在 SVG 中的偏移轉換
    function getTileTranslation(c, r, type) {
        if (type === 0) return { x: (c + 1) * W, y: r * 18 * 2 };
        if (type === 1) return { x: (c + 0.5) * W, y: r * 18 * 2 + 18 };
        if (type === 2) return { x: (c + 0.5) * W, y: r * 18 * 2 - 18 };
    }

    // 空洞檢測 (BFS/Flood Fill)
    function isHole(tempBoard) {
        let minC = Infinity, maxC = -Infinity, minR = Infinity, maxR = -Infinity;
        for (let key of tempBoard) {
            let [dir, c, r] = key.split('_');
            c = parseInt(c); r = parseInt(r);
            minC = Math.min(minC, c); maxC = Math.max(maxC, c);
            minR = Math.min(minR, r); maxR = Math.max(maxR, r);
        }
        
        // 擴大搜索邊界以包圍外圍空間
        minC -= 2; maxC += 2; minR -= 2; maxR += 2;

        let emptySpaces = new Set();
        for (let c = minC; c <= maxC; c++) {
            for (let r = minR; r <= maxR; r++) {
                if (!tempBoard.has(`L_${c}_${r}`)) emptySpaces.add(`L_${c}_${r}`);
                if (!tempBoard.has(`R_${c}_${r}`)) emptySpaces.add(`R_${c}_${r}`);
            }
        }

        // 從邊界任一空位開始 Flood Fill
        let queue = [];
        let visited = new Set();
        let start = `L_${minC}_${minR}`;
        if (!emptySpaces.has(start)) start = `R_${minC}_${minR}`;
        
        queue.push(start);
        visited.add(start);

        while (queue.length > 0) {
            let curr = queue.shift();
            let [dir, c, r] = curr.split('_');
            c = parseInt(c); r = parseInt(r);
            
            let neighbors = getAdjacencies(c, r, dir === 'L');
            for (let n of neighbors) {
                let [nd, nc, nr] = n.split('_');
                nc = parseInt(nc); nr = parseInt(nr);
                if (nc >= minC && nc <= maxC && nr >= minR && nr <= maxR) {
                    if (emptySpaces.has(n) && !visited.has(n)) {
                        visited.add(n);
                        queue.push(n);
                    }
                }
            }
        }
        // 如果有空位沒被訪問到,就代表形成被封閉的空洞
        return visited.size !== emptySpaces.size;
    }

    // 取得當前棋子類型的所有合法落子點
    function getValidPlacements(type) {
        let valid = [];
        let candidates = new Set();
        
        // 1. 找出所有與現有棋盤相鄰的空位,作為候選起點
        for (let key of board) {
            let [dir, c, r] = key.split('_');
            let adjs = getAdjacencies(parseInt(c), parseInt(r), dir === 'L');
            for (let a of adjs) {
                if (!board.has(a)) {
                    let [ad, ac, ar] = a.split('_');
                    ac = parseInt(ac); ar = parseInt(ar);
                    if (ad === 'L') {
                        candidates.add(a);
                    } else {
                        // 推導出對應的 L 座標
                        if (type === 0) candidates.add(`L_${ac-1}_${ar}`);
                        if (type === 1) candidates.add(`L_${ac}_${ar-1}`);
                        if (type === 2) candidates.add(`L_${ac}_${ar+1}`);
                    }
                }
            }
        }

        // 2. 過濾候選名單
        for (let cand of candidates) {
            let [_, c, r] = cand.split('_');
            c = parseInt(c); r = parseInt(r);
            let parts = getTileParts(c, r, type);

            // 條件一:兩個三角形都必須是空的
            if (board.has(parts[0]) || board.has(parts[1])) continue;

            // 條件二:放置後必須與場上至少一個棋子相鄰
            let r_parts = parts[1].split('_');
            let r_c = parseInt(r_parts[1]), r_r = parseInt(r_parts[2]);
            let adj0 = getAdjacencies(c, r, true).some(a => board.has(a));
            let adj1 = getAdjacencies(r_c, r_r, false).some(a => board.has(a));
            if (!adj0 && !adj1) continue;

            // 條件三:不能形成空洞
            let tempBoard = new Set(board);
            tempBoard.add(parts[0]);
            tempBoard.add(parts[1]);
            if (isHole(tempBoard)) continue;

            valid.push({c, r, type});
        }
        return valid;
    }

    // === UI 互動 ===
    
    function updateUI() {
        // 更新數字與分數
        for (let p = 0; p < 2; p++) {
            document.getElementById(`score-${p}`).innerText = scores[p];
            for (let t = 0; t < 3; t++) {
                document.getElementById(`count-${p}-${t}`).innerText = counts[p][t];
            }
        }
        
        // 更新按鈕狀態 (只有輪到的玩家可以使用,且數量需大於0)
        document.querySelectorAll('.piece-btn').forEach(btn => {
            btn.classList.add('disabled');
        });
        
        let activePanel = turn === 0 ? document.getElementById('player0-panel') : document.getElementById('player1-panel');
        let activeBtns = activePanel.querySelectorAll('.piece-btn');
        
        activeBtns.forEach((btn, t) => {
            if (counts[turn][t] > 0) {
                btn.classList.remove('disabled');
            }
        });
    }

    function clearGhosts() {
        activeGhosts.forEach(el => el.remove());
        activeGhosts = [];
    }

    function selectPiece(type) {
        clearGhosts();
        
        // 檢查是否輪到該玩家按鈕 (透過DOM樹關係判斷,或由UI disabled狀態保護)
        if (counts[turn][type] <= 0) return;
        
        // 規則:後手方的第一步(即遊戲第二手)一定要下紅棋 (type = 1)
        if (totalMoves === 1 && type !== 1) {
            alert("後手方的第一步棋(第二手)一定要用紅棋!");
            return;
        }

        let placements = getValidPlacements(type);
        if (placements.length === 0) {
            alert("這個棋子目前沒有可以合法落子的地方。");
            return;
        }

        let etdrop = document.getElementById('etanidrop');
        
        placements.forEach(p => {
            let trans = getTileTranslation(p.c, p.r, p.type);
            let useEl = document.createElementNS('http://www.w3.org/2000/svg', 'use');
            useEl.setAttribute('href', `#tile${p.type}`);
            useEl.setAttribute('transform', `translate(${trans.x},${trans.y}) scale(1,1)`);
            useEl.setAttribute('opacity', '0.5');
            useEl.classList.add('ghost');
            
            // 點擊幽靈棋子進行實質落子
            useEl.addEventListener('click', () => {
                placePiece(p.c, p.r, p.type, trans.x, trans.y);
            });
            
            etdrop.appendChild(useEl);
            activeGhosts.push(useEl);
        });
    }

    function placePiece(c, r, type, x, y) {
        clearGhosts();
        
        // 加入物理棋盤
        let etdrop = document.getElementById('etanidrop');
        let useEl = document.createElementNS('http://www.w3.org/2000/svg', 'use');
        useEl.setAttribute('href', `#tile${type}`);
        useEl.setAttribute('transform', `translate(${x},${y}) scale(1,1)`);
        useEl.classList.add('tiledropped');
        etdrop.appendChild(useEl);

        // 更新邏輯棋盤與庫存
        let parts = getTileParts(c, r, type);
        board.add(parts[0]);
        board.add(parts[1]);
        
        counts[turn][type]--;
        
        // 計分規則:下藍棋得分
        if (type === 0) {
            scores[turn]++;
        }

        // 換手
        turn = 1 - turn;
        totalMoves++;
        updateUI();
    }

    // 初始化
    window.onload = () => {
        updateUI();
    };

</script>
</body>
</html>

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

改進:
一,棋盤外的棋子按鈕,增加fill屬性,其值為:藍棋#00BFFF,紅棋#FF0000,橙棋#FF8C00。

二,在svg棋盤的g.etboard中,增加一個path,其線為虛線,線色為灰色,以中間的藍棋子所代表的兩個正三角形為起始,畫出正三角形的密鋪。

用js計算其d的值,算法為:從某點横向0度,斜向60度,反斜向60度,線長1200。

三,在頁面最下方,新增一個絕對定位的div.teststh用於輸出測試文字。

當點擊中間svg棋盤時,teststh將展示所點擊的位置的坐標,指的是正三角形的横坐標和縱坐標。

四,點擊藍棋的事件
當點擊場上的藍棋時,teststh輸出它的坐標以及週邊與其相鄰的正三角形的坐標。

五,解釋問題
為什麼當點擊對方的紅棋時,會說沒有可以落子的地方?

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

代码: 全选

改進:
一,棋盤外的棋子按鈕,增加fill屬性,其值為:藍棋#00BFFF,紅棋#FF0000,橙棋#FF8C00。

二,在svg棋盤的g.etboard中,增加一個path,其線為虛線,線色為灰色,以中間的藍棋子所代表的兩個正三角形為起始,畫出正三角形的密鋪。

用js計算其d的值,算法為:從某點横向0度,斜向60度,反斜向60度,線長1200。

三,在頁面最下方,新增一個絕對定位的div.teststh用於輸出測試文字。

當點擊中間svg棋盤時,teststh將展示所點擊的位置的坐標,指的是正三角形的横坐標和縱坐標。

四,點擊藍棋的事件
當點擊場上的藍棋時,teststh輸出它的坐標以及週邊與其相鄰的正三角形的坐標。

五,解釋問題
為什麼當點擊對方的紅棋時,會說沒有可以落子的地方?

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

代码: 全选

這是一個非常有趣的棋盤遊戲專案!你遇到的「紅棋無法落子」問題,其實踩到了一個非常經典且隱蔽的圖論(Graph Theory)數學陷阱。

針對你的五個需求,我已經幫你整理好需要修改與替換的程式碼,並在最後為你解開紅棋無法落子的謎團。請在你的原始檔案中找到對應的位置進行覆蓋或新增。

### 一、 棋盤外的棋子按鈕增加顏色 (`fill`)

在 HTML 中找到 `<div id="player1-panel" class="panel">` 與 `<div id="player0-panel" class="panel">` 裡面的 `<use>` 標籤,直接加上 `fill` 屬性:

```html
<div class="pieces-container">
    <div class="piece-btn" onclick="selectPiece(0)">
        <svg viewBox="-40 -40 80 80"><use href="#tile0" fill="#00BFFF"></use></svg>
        <span class="piece-count" id="count-1-0">6</span>
    </div>
    <div class="piece-btn" onclick="selectPiece(1)">
        <svg viewBox="-40 -40 80 80"><use href="#tile1" fill="#FF0000"></use></svg>
        <span class="piece-count" id="count-1-1">6</span>
    </div>
    <div class="piece-btn" onclick="selectPiece(2)">
        <svg viewBox="-40 -40 80 80"><use href="#tile2" fill="#FF8C00"></use></svg>
        <span class="piece-count" id="count-1-2">6</span>
    </div>
</div>

<div class="pieces-container">
    <div class="piece-btn disabled" id="btn-0-0" onclick="selectPiece(0)">
        <svg viewBox="-40 -40 80 80"><use href="#tile0" fill="#00BFFF"></use></svg>
        <span class="piece-count" id="count-0-0">5</span>
    </div>
    <div class="piece-btn disabled" id="btn-0-1" onclick="selectPiece(1)">
        <svg viewBox="-40 -40 80 80"><use href="#tile1" fill="#FF0000"></use></svg>
        <span class="piece-count" id="count-0-1">6</span>
    </div>
    <div class="piece-btn disabled" id="btn-0-2" onclick="selectPiece(2)">
        <svg viewBox="-40 -40 80 80"><use href="#tile2" fill="#FF8C00"></use></svg>
        <span class="piece-count" id="count-0-2">6</span>
    </div>
</div>

```

---

### 二、 繪製 SVG 虛線棋盤密鋪

首先,在 HTML 中修改 `<g class="etboard">`,加入一個 `path`:

```html
<g class="etboard">
    <path id="grid-lines" stroke="gray" stroke-width="1" stroke-dasharray="5,5" fill="none"></path>
    <circle r="6" cx="0" cy="0" fill="purple" stroke="none" stroke-width="0" class="bcenter"/>
</g>

```

接著,在 `<script>` 內的 `window.onload` 區塊加入計算格線的 JS。
*(註:為了與你目前棋子「左右尖端」的視覺完美貼合,數學上正確的格線角度其實是垂直的90度與斜向的±30度,程式碼已自動以你的 `W` 和 `H` 參數適配這個比例!)*

```javascript
// 修改 window.onload
window.onload = () => {
    updateUI();
    
    // 生成背景虛線網格 (線長覆蓋 1200 的範圍,即 -600 到 600)
    let gridPath = "";
    let L = 600; 
    
    // 垂直線
    for (let i = -20; i <= 20; i++) {
        let x = i * W;
        gridPath += `M ${x},${-L} L ${x},${L} `;
    }
    // 斜向線 (匹配菱形邊緣角度)
    for (let i = -40; i <= 40; i++) {
        let b = i * H; 
        let x1 = -L, y1 = (H/W)*x1 + b;
        let x2 = L, y2 = (H/W)*x2 + b;
        gridPath += `M ${x1},${y1} L ${x2},${y2} `;
        
        let y3 = -(H/W)*x1 + b;
        let y4 = -(H/W)*x2 + b;
        gridPath += `M ${x1},${y3} L ${x2},${y4} `;
    }
    document.getElementById('grid-lines').setAttribute('d', gridPath);
};

```

---

### 三、 頁面最下方新增 `.teststh` 與點擊事件

在 `</body>` 前加入 HTML 與對應的 CSS:

```html
<div class="teststh" style="position: absolute; bottom: 10px; width: 100%; text-align: center; color: #333; font-weight: bold; pointer-events: none;">
    測試訊息將顯示於此...
</div>

```

在 `<script>` 內加入點擊事件來計算並輸出棋盤座標:

```javascript
// 加在 <script> 最下方
document.getElementById('etani').addEventListener('click', function(e) {
    let svg = document.getElementById('etani');
    let etdrop = document.getElementById('etanidrop');
    let pt = svg.createSVGPoint();
    pt.x = e.clientX;
    pt.y = e.clientY;
    // 將螢幕點擊座標轉換為 SVG 內部座標
    let svgP = pt.matrixTransform(etdrop.getScreenCTM().inverse());
    
    // 估算點擊的正三角形座標 (依據半寬 W 與半高 H 換算)
    let c = Math.floor(svgP.x / W);
    let r = Math.floor(svgP.y / H);
    
    document.querySelector('.teststh').innerText = `點擊棋盤位置: 橫座標(c) ${c}, 縱座標(r) ${r}`;
});

```

---

### 四、 點擊場上藍棋輸出相鄰座標

我們需要新增一個專屬函式,並修改初始藍棋與後續生成藍棋的邏輯。

**1. 新增專屬事件函式 (加在 `<script>` 內):**

```javascript
function showBlueInfo(c, r, event) {
    if (event) event.stopPropagation(); // 阻止觸發底層棋盤的點擊事件
    let adjs = [...getAdjacencies(c, r, true), ...getAdjacencies(c+1, r, false)];
    // 過濾掉重複的內部相鄰邊,只顯示外部相鄰
    let uniqueAdjs = [...new Set(adjs)].filter(adj => adj !== `R_${c+1}_${r}` && adj !== `L_${c}_${r}`);
    document.querySelector('.teststh').innerText = `藍棋座標: L_${c}_${r} & R_${c+1}_${r} | 相鄰周邊: ${uniqueAdjs.join(', ')}`;
}

```

**2. 修改 HTML 中初始的藍棋,加上 `onclick`:**

```html
<use href="#tile0" fill="#00BFFF" transform="translate(0,0) scale(1,1) rotate(0)" class="tiledropped" onclick="showBlueInfo(-1, 0, event)" style="cursor: pointer;" />

```

**3. 修改 `placePiece` 函式,讓新下的藍棋也能點擊:**

```javascript
// 在 placePiece 函式中,找到 etdrop.appendChild(useEl); 這行,並在它前面加上:
if (type === 0) {
    useEl.style.cursor = 'pointer';
    useEl.addEventListener('click', (e) => showBlueInfo(c, r, e));
}
etdrop.appendChild(useEl);

```

---

### 五、 解釋問題:為什麼紅棋會說沒有可以合法落子的地方?

這是一個在**三角網格(Triangle Grid)中進行邊相鄰 Flood Fill(洪水填充算法)時常發生的邏輯錯誤**。

在你的 `isHole` 空洞檢測演算法中,你從一個空地出發,試圖尋訪所有的空地(`visited.size !== emptySpaces.size`)。問題在於:在由正三角形邊相鄰組成的網格中,其實**存在兩個完全獨立、互不相連的網路系統**!

1. 當一個左向三角形(L)走向右向三角形(R)時,它們的 `(c + r)` 座標奇偶性會發生改變。
2. 這導致所有的空地其實被分割成了「兩個絕對碰不到彼此的平行時空」(例如:偶數L配奇數R是一組,奇數L配偶數R是另一組)。
3. 你的 BFS(廣度優先搜尋)只從一個起點出發,所以**它最多只能拜訪到一半的空地**。
4. 程式比對後發現「走過的空地數量 < 總空地數量」,就誤判為「出現了被封閉的空洞」,進而將這個本來合法的落子給直接退回。因為紅棋的每一次嘗試都會觸發這個誤判,所以系統永遠會告訴你「沒有合法落子的地方」。

#### **修正方法 (`isHole` 的替換代碼)**

我們只要把原本的「單次起點 BFS」改成「計算連通分塊數量」。在無空洞的情況下,系統必定會產生正好 **2** 個連通區塊。若區塊數量 **大於 2**,才代表真的有空洞產生。請替換 `isHole` 下半部的迴圈:

```javascript
// 替換 isHole 函式中 let queue = []; 之後的所有代碼直到 return:
    let visited = new Set();
    let components = 0; // 計算連通區塊的數量

    for (let start of emptySpaces) {
        if (!visited.has(start)) {
            components++;
            let queue = [start];
            visited.add(start);

            while (queue.length > 0) {
                let curr = queue.shift();
                let [dir, c, r] = curr.split('_');
                let neighbors = getAdjacencies(parseInt(c), parseInt(r), dir === 'L');
                
                for (let n of neighbors) {
                    let [nd, nc, nr] = n.split('_');
                    nc = parseInt(nc); nr = parseInt(nr);
                    if (nc >= minC && nc <= maxC && nr >= minR && nr <= maxR) {
                        if (emptySpaces.has(n) && !visited.has(n)) {
                            visited.add(n);
                            queue.push(n);
                        }
                    }
                }
            }
        }
    }
    
    // 三角網格本質上分為兩個獨立子圖,所以正常的空地會有 2 個區塊。
    // 如果區塊數大於 2,才代表有棋子把空間切斷形成空洞了。
    return components > 2;
}

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: ejsoon 和 1 访客