QuanAI只读快照

共享会话

🛠️ 操作說明改進指南

分享于 2026年8月21日 00:49
提问者

改進操作說明頁面 一,界面介紹

界面介紹中的「桌面版」的寬度應縮短到一半,左右的「玩家控制區」中文應豎排,英文則轉九十度。並且不需要有一個指向「手機版」的箭頭。

二,棋盤控制

在棋盤控制的svg中,「往左」和「往右」方向按鈕應跟上方和下方的按鈕的横坐標一致。

在其下方加上工具欄中的按鈕功能介紹:

  • 回退:可對當前棋局的下法進行回退
  • 前進:可對已經回退的下法進行恢復
  • 仲裁功能:當你認為對方上回合違反規則,可點此仲裁
  • 狀態窗口:點擊將打開當前狀態窗口
  • 導入/導出:點擊將打開導入/導出窗口
  • AI設置窗口:點擊將打開AI設置窗口
  • 下載按鈕:點擊將打開下載svg窗口
  • 旋轉功能:開啟後,每當輪到玩家二,雙方控制區和工具欄位置將圍繞棋盤中心水平翻轉。

每行兩個,用美觀的外框圍住,框內左方是相應的圖標,右方是文字介紹。注意配上相應的英文。

三,計分標記

把svg中間的兩個「…」和AI圖標去掉。

四,AI控制

把svg中的「簡單」的線的顏色改為跟右邊三個一樣的白色。

五,選擇目標和落子

去掉svg,重新製作,將用js控制svg來替代所以

  1. 左方或右方為三個縱列棋子,樣式跟遊戲中的一樣,隨機選擇左方或右方,如果是右方則為玩家一的粉色棋子,左方則是玩家二的綠色棋子。
  2. 在棋子的右側或左側同時加入棋盤和一個隨機的對方棋子,旋轉角度隨機。以上這兩步是一開始就有的。
  3. 2.4s後,三個縱列棋子的其中一個被中,其樣式跟遊戲中棋子被選中完全一樣。
  4. 2.4s後,棋盤中的棋子被點擊,其中心出現藍點,同時出現預放棋子,跟遊戲時一樣。調用遊戲時出現預放棋子的相關函式。
  5. 2.4s後,其中一個預放棋子變成實際落下棋子,其它預放棋子消失。
  6. 4.8s後,棋子和棋盤消失,然後再過2.4s,另一方的三個縱列棋子和一個對方棋子再次出現,然後重新執行以上過程。

六,開局落子和旋轉

在svg中,藍色v形箭頭應左移並稍作順時針旋轉,使其成為一個表示旋轉的箭頭。

回答要求:

不要影響正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。

trigolden_game2658.htmltext/plain · 288 KB预览下载
QuanAI

我會先定位操作說明頁面的六個章節、相關 SVG/CSS/JavaScript,以及遊戲中「選中、預放、正式落子」的既有函式,確保修改只影響說明動畫而不改動正常遊戲流程。

以下修改均只針對「操作說明」頁面。正常遊戲中的選棋、落子、確認、AI、歷史記錄及仲裁流程不變。

其中「選擇目標和落子」不直接呼叫會修改全域 ghostsgenerateGhosts(),而是抽出一個純粹的共用函式 collectPlacementGhosts():正式遊戲與說明動畫共同使用同一套 attachByEdge()isValidGhost() 合法落點邏輯,但說明動畫只操作自己的示意資料。


一、補充操作說明所需 CSS

<style> 中,建議放在現有 .operation-tip 規則後面,加入:

css
      /* 操作說明:工具欄功能列表 */
      .operation-tool-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 12px;
        margin-top: 16px;
      }

      .operation-tool-item {
        min-width: 0;
        min-height: 92px;
        display: grid;
        grid-template-columns: 48px minmax(0, 1fr);
        align-items: center;
        gap: 12px;
        padding: 13px 14px;
        box-sizing: border-box;
        border: 1px solid rgba(114, 137, 218, 0.48);
        border-radius: 10px;
        background: linear-gradient(
          135deg,
          rgba(114, 137, 218, 0.12),
          rgba(255, 255, 255, 0.035)
        );
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
      }

      .operation-tool-icon {
        width: 42px;
        height: 42px;
        padding: 9px;
        box-sizing: border-box;
        border-radius: 50%;
        background: #23272a;
        color: #fff;
        fill: currentColor;
        flex-shrink: 0;
      }

      .operation-tool-copy {
        min-width: 0;
        color: #ccc;
        font-size: 0.92em;
        line-height: 1.45;
      }

      .operation-tool-copy strong {
        display: block;
        margin-bottom: 3px;
        color: #fff;
        font-size: 1.02em;
      }

      /* 操作說明:JavaScript 控制的選擇目標/落子動畫 */
      #op-placement-demo .op-placement-piece-box {
        fill: #30343a;
        stroke: transparent;
        stroke-width: 2;
      }

      #op-placement-demo .op-placement-piece-box.is-selected {
        fill: #3a3f46;
        stroke: #7289da;
        filter: url(#op-placement-selected-glow);
      }

      #op-placement-demo .op-placement-target {
        stroke: #555;
        stroke-width: 2;
        stroke-linejoin: round;
        vector-effect: non-scaling-stroke;
      }

      #op-placement-demo .op-placement-ghost {
        fill-opacity: 0.36;
        stroke: #000;
        stroke-width: 2;
        stroke-linejoin: round;
        vector-effect: non-scaling-stroke;
      }

      #op-placement-demo .op-placement-final {
        stroke: #555;
        stroke-width: 2;
        stroke-linejoin: round;
        vector-effect: non-scaling-stroke;
      }

      @media (max-width: 768px) {
        /* 依要求,即使手機版仍維持每行兩項 */
        .operation-tool-grid {
          grid-template-columns: repeat(2, minmax(0, 1fr));
          gap: 8px;
        }

        .operation-tool-item {
          min-height: 108px;
          grid-template-columns: 34px minmax(0, 1fr);
          gap: 8px;
          padding: 10px 8px;
        }

        .operation-tool-icon {
          width: 34px;
          height: 34px;
          padding: 7px;
        }

        .operation-tool-copy {
          font-size: 0.78em;
          line-height: 1.35;
        }
      }

二、界面介紹

<!-- 一、界面介紹 --> 中,完整替換目前的:

html
<svg class="operation-demo operation-svg" viewBox="0 0 760 400" ...>
  ...
</svg>

替換為:

html
            <svg class="operation-demo operation-svg" viewBox="0 0 760 400" role="img" aria-label="Interface layout">
              <rect x="0" y="0" width="760" height="400" rx="12" fill="#e9ebee" />

              <text class="op-zh" x="380" y="28" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">
                桌面版
              </text>
              <text class="op-en" x="380" y="28" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">
                Desktop
              </text>

              <!-- 桌面版總寬度由原本 690 縮為 345,約為一半 -->
              <rect
                x="207.5"
                y="48"
                width="75"
                height="130"
                rx="10"
                fill="#96ffbe"
                stroke="#333"
                stroke-width="2"
              />
              <rect
                x="287.5"
                y="48"
                width="185"
                height="130"
                rx="10"
                fill="#fff"
                stroke="#7289da"
                stroke-width="3"
              />
              <rect
                x="477.5"
                y="48"
                width="75"
                height="130"
                rx="10"
                fill="#ffc2eb"
                stroke="#333"
                stroke-width="2"
              />

              <!-- 中文控制區名稱改為豎排 -->
              <text class="op-zh" x="245" y="77" text-anchor="middle" font-size="16" fill="#222">
                <tspan x="245" dy="0"></tspan>
                <tspan x="245" dy="19"></tspan>
                <tspan x="245" dy="19"></tspan>
                <tspan x="245" dy="19"></tspan>
                <tspan x="245" dy="19"></tspan>
              </text>

              <text class="op-zh" x="515" y="77" text-anchor="middle" font-size="16" fill="#222">
                <tspan x="515" dy="0"></tspan>
                <tspan x="515" dy="19"></tspan>
                <tspan x="515" dy="19"></tspan>
                <tspan x="515" dy="19"></tspan>
                <tspan x="515" dy="19"></tspan>
              </text>

              <!-- 英文控制區名稱旋轉九十度 -->
              <text
                class="op-en"
                x="245"
                y="118"
                text-anchor="middle"
                font-size="14"
                fill="#222"
                transform="rotate(-90 245 113)"
              >
                Player controls
              </text>

              <text
                class="op-en"
                x="515"
                y="118"
                text-anchor="middle"
                font-size="14"
                fill="#222"
                transform="rotate(90 515 113)"
              >
                Player controls
              </text>

              <text class="op-zh" x="380" y="112" text-anchor="middle" font-size="22" font-weight="bold" fill="#333">
                棋盤
              </text>
              <text class="op-en" x="380" y="112" text-anchor="middle" font-size="22" font-weight="bold" fill="#333">
                Board
              </text>

              <text class="op-zh" x="380" y="218" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">
                手機版
              </text>
              <text class="op-en" x="380" y="218" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">
                Mobile
              </text>

              <rect x="280" y="235" width="200" height="42" rx="8" fill="#96ffbe" stroke="#333" stroke-width="2" />
              <rect x="280" y="284" width="200" height="68" rx="8" fill="#fff" stroke="#7289da" stroke-width="3" />
              <rect x="280" y="359" width="200" height="32" rx="8" fill="#ffc2eb" stroke="#333" stroke-width="2" />

              <text class="op-zh" x="380" y="262" text-anchor="middle" font-size="15" fill="#222">玩家控制區</text>
              <text class="op-en" x="380" y="262" text-anchor="middle" font-size="14" fill="#222">
                Player controls
              </text>

              <text class="op-zh" x="380" y="326" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">
                棋盤
              </text>
              <text class="op-en" x="380" y="326" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">
                Board
              </text>

              <text class="op-zh" x="380" y="381" text-anchor="middle" font-size="14" fill="#222">玩家控制區</text>
              <text class="op-en" x="380" y="381" text-anchor="middle" font-size="13" fill="#222">
                Player controls
              </text>

              <!-- 不再顯示由桌面版指向手機版的箭頭 -->
            </svg>

這裡已經刪除原本最後兩個箭頭 <path>


三、棋盤控制 SVG

3.1 工具欄補上旋轉圖標

在「棋盤控制」SVG 中找到:

html
<rect x="145" y="220" width="470" height="54" rx="27" fill="#23272a" opacity="0.92" />

<g fill="#ddd">
  ...
</g>

把這一整段替換為:

html
                <rect x="112" y="220" width="536" height="54" rx="27" fill="#23272a" opacity="0.92" />

                <!-- 與正式工具欄一致,共八個按鈕 -->
                <g fill="#ddd" color="#ddd">
                  <!-- 回退 -->
                  <g transform="translate(145 247) scale(1.05) translate(-12 -12)">
                    <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-.78C21.08 11.03 17.15 8 12.5 8z"
                    />
                  </g>

                  <!-- 前進 -->
                  <g transform="translate(212 247) scale(1.05) translate(-12 -12)">
                    <path
                      transform="translate(24 0) scale(-1 1)"
                      d="M11.5 8C16.15 8 20.08 11.03 21.63 15.22l-2.37.78C18.21 12.81 15.2 10.5 11.66 10.5c-1.96 0-3.73.72-5.12 1.88L10.16 16H1.16V7l3.44 3.44C6.45 8.99 8.85 8 11.5 8z"
                    />
                  </g>

                  <!-- 仲裁 -->
                  <g transform="translate(279 247) translate(-12 -12)">
                    <path
                      d="M3.52 10.94C2.98 10.41 10.41 2.98 10.94 3.52L14.12 7.76C14.65 8.29 8.29 14.65 7.76 14.12Z M10.14 11.73L11.73 10.14L19.69 18.1L18.1 19.69Z"
                    />
                  </g>

                  <!-- 狀態窗口 -->
                  <g transform="translate(346 247) translate(-12 -12)">
                    <path d="M4 2h16v18H4V2zm2 4v12h12V6H6z" />
                    <path d="M11 15h2V9l-2 1v1l1-.5V15z" />
                  </g>

                  <!-- 導入/導出 -->
                  <g transform="translate(413 247) translate(-12 -12)">
                    <path d="M16 3l-4 4h3v7h2V7h3l-4-4zm-5 14H8v-7H6v7H3l4 4 4-4z" />
                  </g>

                  <!-- AI 設置 -->
                  <g
                    transform="translate(480 247) translate(-12 -12)"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="2"
                  >
                    <path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7z M9 21h6" />
                    <path stroke-width="1" d="M10 12v-2l2-2 2 2v2" />
                  </g>

                  <!-- 下載 -->
                  <g transform="translate(547 247) translate(-12 -12)">
                    <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
                  </g>

                  <!-- 自動旋轉 -->
                  <g transform="translate(614 247) translate(-12 -12)">
                    <path
                      d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"
                    />
                  </g>
                </g>

3.2 修正左右方向按鈕橫座標

找到方向控制中的四行:

html
<circle cx="190" cy="166" r="22" />
<circle cx="570" cy="166" r="22" />

替換為:

html
<circle cx="205" cy="166" r="22" />
<circle cx="555" cy="166" r="22" />

再找到:

html
<path d="M178 166l12-9v18z" />
<path d="M582 166l-12 9v-18z" />

替換為:

html
<path d="M193 166l12-9v18z" />
<path d="M567 166l-12 9v-18z" />

現在左側方向按鈕會與左上、左下的 x="205" 對齊,右側則與右上、右下的 x="555" 對齊。


四、在棋盤控制 SVG 下方加入工具欄說明

在「棋盤控制」的 SVG 結束標籤:

html
</svg>

後面、該 section 結束前,加入:

html
            <div class="operation-tool-grid">
              <div class="operation-tool-item">
                <svg class="operation-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
                  <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-.78C21.08 11.03 17.15 8 12.5 8z"
                  />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">回退</span>
                    <span class="en-text">Undo</span>
                  </strong>
                  <span class="zh-text">可對當前棋局的下法進行回退。</span>
                  <span class="en-text">Reverts moves made in the current game.</span>
                </div>
              </div>

              <div class="operation-tool-item">
                <svg class="operation-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
                  <path
                    transform="translate(24 0) scale(-1 1)"
                    d="M11.5 8C16.15 8 20.08 11.03 21.63 15.22l-2.37.78C18.21 12.81 15.2 10.5 11.66 10.5c-1.96 0-3.73.72-5.12 1.88L10.16 16H1.16V7l3.44 3.44C6.45 8.99 8.85 8 11.5 8z"
                  />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">前進</span>
                    <span class="en-text">Redo</span>
                  </strong>
                  <span class="zh-text">可對已經回退的下法進行恢復。</span>
                  <span class="en-text">Restores moves that have previously been undone.</span>
                </div>
              </div>

              <div class="operation-tool-item">
                <svg class="operation-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
                  <path
                    d="M3.52 10.94C2.98 10.41 10.41 2.98 10.94 3.52L14.12 7.76C14.65 8.29 8.29 14.65 7.76 14.12Z M10.14 11.73L11.73 10.14L19.69 18.1L18.1 19.69Z M2.98 11.47L3.78 10.67L7.49 14.39L6.7 15.18Z M10.67 3.78L11.47 2.98L15.18 6.7L14.39 7.49Z"
                  />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">仲裁功能</span>
                    <span class="en-text">Arbitration</span>
                  </strong>
                  <span class="zh-text">當你認為對方上回合違反規則,可點此申請仲裁。</span>
                  <span class="en-text">
                    Click here if you believe your opponent violated a rule on the previous turn.
                  </span>
                </div>
              </div>

              <div class="operation-tool-item">
                <svg class="operation-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M4 2h16v18H4V2zm2 4v12h12V6H6z" />
                  <path d="M11 15h2V9l-2 1v1l1-.5V15z" />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">狀態窗口</span>
                    <span class="en-text">Status Window</span>
                  </strong>
                  <span class="zh-text">點擊將打開當前狀態窗口。</span>
                  <span class="en-text">Click to open the current game status window.</span>
                </div>
              </div>

              <div class="operation-tool-item">
                <svg class="operation-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M16 3l-4 4h3v7h2V7h3l-4-4zm-5 14H8v-7H6v7H3l4 4 4-4z" />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">導入/導出</span>
                    <span class="en-text">Import / Export</span>
                  </strong>
                  <span class="zh-text">點擊將打開導入/導出窗口。</span>
                  <span class="en-text">Click to open the notation import/export window.</span>
                </div>
              </div>

              <div class="operation-tool-item">
                <svg
                  class="operation-tool-icon"
                  viewBox="0 0 24 24"
                  aria-hidden="true"
                  fill="none"
                  stroke="currentColor"
                  stroke-width="2"
                >
                  <path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7z M9 21h6" />
                  <path stroke-width="1" d="M10 12v-2l2-2 2 2v2" />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">AI 設置窗口</span>
                    <span class="en-text">AI Settings</span>
                  </strong>
                  <span class="zh-text">點擊將打開 AI 設置窗口。</span>
                  <span class="en-text">Click to open the AI settings window.</span>
                </div>
              </div>

              <div class="operation-tool-item">
                <svg class="operation-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">下載按鈕</span>
                    <span class="en-text">Download</span>
                  </strong>
                  <span class="zh-text">點擊將打開 SVG 下載窗口。</span>
                  <span class="en-text">Click to open the SVG download window.</span>
                </div>
              </div>

              <div class="operation-tool-item">
                <svg class="operation-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
                  <path
                    d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"
                  />
                </svg>
                <div class="operation-tool-copy">
                  <strong>
                    <span class="zh-text">旋轉功能</span>
                    <span class="en-text">Auto-Rotate</span>
                  </strong>
                  <span class="zh-text">
                    開啟後,每當輪到玩家二,雙方控制區和工具欄位置將圍繞棋盤中心水平翻轉。
                  </span>
                  <span class="en-text">
                    When enabled, the player panels and toolbar flip around the board center whenever it is Player 2’s turn.
                  </span>
                </div>
              </div>
            </div>

五、計分標記

在「計分標記」SVG 尾部刪除以下兩段:

html
              <g transform="translate(370 126) scale(1.4) translate(-12 -12)" color="#7289da" fill="#7289da">
                <use href="#op-ai-easy" />
              </g>

以及:

html
              <path d="M345 116h-24M415 116h24" stroke="#7289da" stroke-width="3" stroke-dasharray="5 4" />

這會同時去除中間 AI 圖標和兩側的「…」虛線。


六、AI 控制

在「AI 控制」SVG 的「簡單」按鈕中找到:

html
<g transform="translate(105 104) scale(1.7) translate(-12 -12)" color="#111" fill="#111">

替換為:

html
<g transform="translate(105 104) scale(1.7) translate(-12 -12)" color="#fff" fill="#fff">

op-ai-easy 中的外框和天線使用 currentColor,因此會與右側三個圖標一樣變成白色。


七、選擇目標和落子:替換為 JavaScript 動畫

7.1 替換原 SVG

<!-- 七、選擇目標和落子 --> 中,刪除原本整個:

html
<svg
  class="operation-demo operation-svg"
  viewBox="0 0 760 380"
  ...
>
  ...
</svg>

替換成一個由 JavaScript 填入內容的空 SVG:

html
            <svg
              id="op-placement-demo"
              class="operation-demo operation-svg"
              viewBox="0 0 760 400"
              role="img"
              aria-label="Legal placement animation"
            ></svg>

7.2 加入說明動畫控制函式

在現有:

js
      function setOperationFill(selector, color) {
        document.querySelectorAll(selector).forEach(element => {
          element.setAttribute('fill', color);
        });
      }

後面加入以下完整程式碼:

js
      const OP_SVG_NS = 'http://www.w3.org/2000/svg';

      let operationPlacementTimers = [];
      let operationPlacementToken = 0;

      function createOperationSvgElement(tagName, attributes = {}) {
        const element = document.createElementNS(OP_SVG_NS, tagName);

        Object.entries(attributes).forEach(([name, value]) => {
          if (value !== undefined && value !== null) {
            element.setAttribute(name, String(value));
          }
        });

        return element;
      }

      function clearOperationPlacementTimers() {
        operationPlacementTimers.forEach(timer => clearTimeout(timer));
        operationPlacementTimers = [];
      }

      function stopOperationPlacementDemo() {
        clearOperationPlacementTimers();
        operationPlacementToken++;

        const svg = document.getElementById('op-placement-demo');

        if (svg) {
          svg.replaceChildren();
        }
      }

      function scheduleOperationPlacementStep(token, delay, callback) {
        const timer = setTimeout(() => {
          operationPlacementTimers = operationPlacementTimers.filter(item => item !== timer);

          if (token !== operationPlacementToken) {
            return;
          }

          callback();
        }, delay);

        operationPlacementTimers.push(timer);
      }

      function getOperationPieceCenter(tileId) {
        if (tileId === 'tile1' || tileId === 'tile4') {
          return { x: -18, y: -2.924 };
        }

        if (tileId === 'tile0' || tileId === 'tile3') {
          return { x: -14.562, y: 0 };
        }

        return { x: -11.781, y: -2.02 };
      }

      /*
       * 使用正式棋盤的 SVG_BASE_POINTS 和 exactToScreenRaw 計算棋子變換,
       * 但不讀寫 pieces、tempPieces、ghosts 或任何正式棋局狀態。
       */
      function getOperationRawPieceTransform(svgId, exactVertices) {
        const source = SVG_BASE_POINTS[svgId];
        const target = exactVertices.map(exactToScreenRaw);

        const dxS1 = source[1].x - source[0].x;
        const dyS1 = source[1].y - source[0].y;
        const dxS2 = source[2].x - source[0].x;
        const dyS2 = source[2].y - source[0].y;

        const dxT1 = target[1].x - target[0].x;
        const dyT1 = target[1].y - target[0].y;
        const dxT2 = target[2].x - target[0].x;
        const dyT2 = target[2].y - target[0].y;

        const det = dxS1 * dyS2 - dyS1 * dxS2;

        const a = (dxT1 * dyS2 - dxT2 * dyS1) / det;
        const b = (dyT1 * dyS2 - dyT2 * dyS1) / det;
        const c = (dxS1 * dxT2 - dxS2 * dxT1) / det;
        const d = (dxS1 * dyT2 - dxS2 * dyT1) / det;

        const tx = target[0].x - a * source[0].x - c * source[0].y;
        const ty = target[0].y - b * source[0].x - d * source[0].y;
        const sx = a * d - b * c < 0 ? -1 : 1;
        const angle = (Math.atan2(b, d) * 180) / Math.PI;

        const round = value => parseFloat(value.toFixed(3));

        return `translate(${round(tx)} ${round(ty)}) scale(${sx} 1) rotate(${round(angle)})`;
      }

      function getOperationExactCenter(vertices) {
        const points = vertices.map(exactToScreenRaw);

        const total = points.reduce(
          (sum, point) => ({
            x: sum.x + point.x,
            y: sum.y + point.y
          }),
          { x: 0, y: 0 }
        );

        return {
          x: total.x / points.length,
          y: total.y / points.length
        };
      }

      function createOperationPlacementState(side) {
        const ownTileIds =
          side === 'right'
            ? ['tile0', 'tile1', 'tile2']
            : ['tile3', 'tile4', 'tile5'];

        const opponentTileIds =
          side === 'right'
            ? ['tile3', 'tile4', 'tile5']
            : ['tile0', 'tile1', 'tile2'];

        const owner = side === 'right' ? 1 : 2;
        const opponentOwner = owner === 1 ? 2 : 1;

        const selectedIndex = Math.floor(Math.random() * ownTileIds.length);
        const selectedTileId = ownTileIds[selectedIndex];

        /*
         * 與正式遊戲初始狀態一致:
         * 玩家一粉色棋子 isFlipped=false;
         * 玩家二綠色棋子 isFlipped=true。
         */
        const selectedFlipped = owner === 2;

        const targetTileId = operationRandomItem(opponentTileIds);
        const targetFlipped = opponentOwner === 2;

        const targetVertices = buildShapeVertices(
          ZERO,
          0,
          SHAPES[SHAPE_MAP[targetTileId]],
          targetFlipped
        ).vertices;

        const target = {
          id: 'op-placement-target-piece',
          owner: opponentOwner,
          type: SHAPE_MAP[targetTileId],
          svgId: targetTileId,
          isFlipped: targetFlipped,
          vertices: targetVertices,
          parentId: null,
          level: 0
        };

        preparePieceSAT(target);

        return {
          side,
          owner,
          ownTileIds,
          selectedIndex,
          selectedTileId,
          selectedFlipped,
          target,
          targetAngle: Math.floor(Math.random() * 360) + 1,
          ghosts: [],
          placedGhostIndex: -1,
          stage: 'initial'
        };
      }

      function appendOperationPlacementStatus(svg, zhText, enText) {
        const zh = createOperationSvgElement('text', {
          class: 'op-zh',
          x: 380,
          y: 380,
          'text-anchor': 'middle',
          'font-size': 16,
          'font-weight': 'bold',
          fill: '#333'
        });

        zh.textContent = zhText;
        svg.appendChild(zh);

        const en = createOperationSvgElement('text', {
          class: 'op-en',
          x: 380,
          y: 380,
          'text-anchor': 'middle',
          'font-size': 15,
          'font-weight': 'bold',
          fill: '#333'
        });

        en.textContent = enText;
        svg.appendChild(en);
      }

      function renderOperationPlacementDemo(state) {
        const svg = document.getElementById('op-placement-demo');

        if (!svg) {
          return;
        }

        svg.replaceChildren();

        const defs = createOperationSvgElement('defs');
        defs.innerHTML = `
          <filter
            id="op-placement-selected-glow"
            x="-60%"
            y="-60%"
            width="220%"
            height="220%"
          >
            <feDropShadow
              dx="0"
              dy="0"
              stdDeviation="7"
              flood-color="#7289da"
              flood-opacity="0.95"
            />
          </filter>

          <filter
            id="op-placement-box-shadow"
            x="-40%"
            y="-40%"
            width="180%"
            height="180%"
          >
            <feDropShadow
              dx="0"
              dy="4"
              stdDeviation="4"
              flood-color="#000"
              flood-opacity="0.35"
            />
          </filter>
        `;

        svg.appendChild(defs);

        svg.appendChild(
          createOperationSvgElement('rect', {
            x: 0,
            y: 0,
            width: 760,
            height: 400,
            rx: 12,
            fill: '#f7f7f7'
          })
        );

        /*
         * 12 秒時只保留底色,棋盤、棋子和文字全部消失;
         * 2.4 秒後由另一方重新出現。
         */
        if (!state || state.stage === 'hidden') {
          return;
        }

        const controlsOnLeft = state.side === 'left';

        const panelX = controlsOnLeft ? 25 : 590;
        const boardX = controlsOnLeft ? 185 : 25;
        const boardWidth = 550;

        svg.appendChild(
          createOperationSvgElement('rect', {
            x: panelX,
            y: 25,
            width: 145,
            height: 320,
            rx: 12,
            fill: '#23272a',
            stroke: '#111',
            'stroke-width': 2
          })
        );

        svg.appendChild(
          createOperationSvgElement('rect', {
            x: boardX,
            y: 25,
            width: boardWidth,
            height: 320,
            rx: 12,
            fill: '#fff',
            stroke: '#7289da',
            'stroke-width': 3
          })
        );

        state.ownTileIds.forEach((tileId, index) => {
          const boxY = 47 + index * 99;
          const boxX = controlsOnLeft ? panelX + 58 : panelX + 15;
          const boxCenterX = boxX + 36;
          const boxCenterY = boxY + 36;

          const selected =
            index === state.selectedIndex &&
            state.stage !== 'initial';

          const box = createOperationSvgElement('rect', {
            class:
              'op-placement-piece-box' +
              (selected ? ' is-selected' : ''),
            x: boxX,
            y: boxY,
            width: 72,
            height: 72,
            rx: 8,
            filter: selected ? null : 'url(#op-placement-box-shadow)'
          });

          svg.appendChild(box);

          const count = createOperationSvgElement('text', {
            x: controlsOnLeft ? panelX + 31 : panelX + 114,
            y: boxY + 44,
            'text-anchor': 'middle',
            'font-size': 22,
            'font-weight': 'bold',
            fill: '#ddd'
          });

          count.textContent = '9';
          svg.appendChild(count);

          const center = getOperationPieceCenter(tileId);

          const tileFlipped =
            tileId === state.selectedTileId
              ? state.selectedFlipped
              : state.owner === 2;

          const flipX = tileFlipped ? 1 : -1;

          const pieceUse = createOperationSvgElement('use', {
            href: '#' + tileId,
            transform:
              `translate(${boxCenterX} ${boxCenterY}) ` +
              `scale(0.52) ` +
              `scale(${flipX} 1) ` +
              `translate(${-center.x} ${-center.y})`
          });

          svg.appendChild(pieceUse);
        });

        const boardCenterX = boardX + boardWidth / 2;
        const boardCenterY = 185;

        const targetCenter = getOperationExactCenter(state.target.vertices);

        const boardPiecesGroup = createOperationSvgElement('g', {
          transform:
            `translate(${boardCenterX} ${boardCenterY}) ` +
            `rotate(${state.targetAngle}) ` +
            `scale(0.86) ` +
            `translate(${-targetCenter.x} ${-targetCenter.y})`
        });

        const targetUse = createOperationSvgElement('use', {
          class: 'op-placement-target',
          href: '#' + state.target.svgId,
          transform: getOperationRawPieceTransform(
            state.target.svgId,
            state.target.vertices
          )
        });

        boardPiecesGroup.appendChild(targetUse);

        if (state.stage === 'ghosts') {
          state.ghosts.forEach(ghost => {
            boardPiecesGroup.appendChild(
              createOperationSvgElement('use', {
                class: 'op-placement-ghost',
                href: '#' + ghost.svgId,
                transform: getOperationRawPieceTransform(
                  ghost.svgId,
                  ghost.vertices
                )
              })
            );
          });
        }

        if (
          state.stage === 'placed' &&
          state.placedGhostIndex >= 0 &&
          state.ghosts[state.placedGhostIndex]
        ) {
          const placedGhost = state.ghosts[state.placedGhostIndex];

          boardPiecesGroup.appendChild(
            createOperationSvgElement('use', {
              class: 'op-placement-final',
              href: '#' + placedGhost.svgId,
              transform: getOperationRawPieceTransform(
                placedGhost.svgId,
                placedGhost.vertices
              )
            })
          );

          /*
           * 正式遊戲中的暫時落子會在中心顯示白底紅框圓點,
           * 這裡同步顯示相同標記。
           */
          const placedCenter = getOperationExactCenter(placedGhost.vertices);

          boardPiecesGroup.appendChild(
            createOperationSvgElement('circle', {
              cx: placedCenter.x,
              cy: placedCenter.y,
              r: 5,
              fill: '#fff',
              stroke: '#f04747',
              'stroke-width': 1.5,
              'vector-effect': 'non-scaling-stroke'
            })
          );
        }

        if (state.stage === 'ghosts' || state.stage === 'placed') {
          boardPiecesGroup.appendChild(
            createOperationSvgElement('circle', {
              cx: targetCenter.x,
              cy: targetCenter.y,
              r: 7,
              fill: '#00bfff',
              stroke: 'none'
            })
          );
        }

        svg.appendChild(boardPiecesGroup);

        if (state.stage === 'initial') {
          appendOperationPlacementStatus(
            svg,
            '棋子控制區、棋盤和對方棋子同時出現',
            'The piece controls, board, and opponent piece appear together'
          );
        } else if (state.stage === 'selected') {
          appendOperationPlacementStatus(
            svg,
            '① 選中一個棋子',
            '① Select a piece'
          );
        } else if (state.stage === 'ghosts') {
          appendOperationPlacementStatus(
            svg,
            '② 點擊對方棋子,顯示所有預放位置',
            '② Click the opponent piece to show all legal placements'
          );
        } else if (state.stage === 'placed') {
          appendOperationPlacementStatus(
            svg,
            '③ 點擊一個預放棋子完成暫時落子',
            '③ Click one translucent piece to place it temporarily'
          );
        }
      }

      function runOperationPlacementCycle(side, token) {
        const modal = document.getElementById('operation-modal');

        if (
          token !== operationPlacementToken ||
          !modal ||
          modal.style.display !== 'flex'
        ) {
          return;
        }

        const state = createOperationPlacementState(side);

        renderOperationPlacementDemo(state);

        /*
         * 0 秒:三個棋子、棋盤和對方棋子出現。
         * 2.4 秒:選中其中一個棋子。
         */
        scheduleOperationPlacementStep(token, 2400, () => {
          state.stage = 'selected';
          renderOperationPlacementDemo(state);
        });

        /*
         * 4.8 秒:點擊對方棋子。
         * 使用正式遊戲共用的 collectPlacementGhosts() 產生預放棋子。
         */
        scheduleOperationPlacementStep(token, 4800, () => {
          state.ghosts = collectPlacementGhosts({
            selectedTileId: state.selectedTileId,
            selectedFlipped: state.selectedFlipped,
            target: state.target,
            owner: state.owner,
            existingPieces: [state.target],
            idFactory: index => 'op-placement-ghost-' + index
          });

          state.placedGhostIndex =
            state.ghosts.length > 0
              ? Math.floor(Math.random() * state.ghosts.length)
              : -1;

          state.stage = 'ghosts';
          renderOperationPlacementDemo(state);
        });

        /*
         * 7.2 秒:保留一個落點,轉為實際暫時落下的棋子。
         */
        scheduleOperationPlacementStep(token, 7200, () => {
          state.stage = 'placed';
          renderOperationPlacementDemo(state);
        });

        /*
         * 12 秒:距離落子再過 4.8 秒,棋盤和棋子消失。
         */
        scheduleOperationPlacementStep(token, 12000, () => {
          state.stage = 'hidden';
          renderOperationPlacementDemo(state);
        });

        /*
         * 14.4 秒:消失後再過 2.4 秒,切換另一方並重新開始。
         */
        scheduleOperationPlacementStep(token, 14400, () => {
          runOperationPlacementCycle(
            side === 'right' ? 'left' : 'right',
            token
          );
        });
      }

      function startOperationPlacementDemo() {
        stopOperationPlacementDemo();

        const token = operationPlacementToken;
        const initialSide = Math.random() < 0.5 ? 'left' : 'right';

        runOperationPlacementCycle(initialSide, token);
      }

7.3 修改操作說明隨機化函式

完整替換目前的:

js
function randomizeOperationGuideDemos() {
  ...
}

替換為:

js
      function randomizeOperationGuideDemos() {
        const demoPieces = ['#op-piece-a', '#op-piece-b', '#op-piece-c'];
        const playerColors = ['#ffc2eb', '#96ffbe'];

        // 「棋子按鈕」示意:隨機選擇一種棋子和顏色。
        const selectedPiece = operationRandomItem(demoPieces);
        const selectedColor = operationRandomItem(playerColors);

        setOperationUseHref(
          '#op-selected-piece, #op-flipping-piece',
          selectedPiece
        );

        setOperationFill(
          '#op-selected-piece, #op-flipping-piece',
          selectedColor
        );

        /*
         * 「選擇目標和落子」已改為 JavaScript 控制的獨立動畫。
         * 初始隨機選擇左方綠色玩家二或右方粉色玩家一。
         */
        startOperationPlacementDemo();

        // 「開局落子和旋轉」示意:棋子、顏色和 1~360 度角度均隨機。
        const openingPiece = operationRandomItem(demoPieces);
        const openingColor = operationRandomItem(playerColors);
        const openingAngle = Math.floor(Math.random() * 360) + 1;

        const openingUse = document.getElementById('op-opening-piece');

        if (openingUse) {
          openingUse.setAttribute('href', openingPiece);
          openingUse.setAttribute('fill', openingColor);
        }

        const openingRotateAnimation =
          document.getElementById('op-opening-rotate');

        if (openingRotateAnimation) {
          openingRotateAnimation.setAttribute(
            'values',
            `0 0 0;0 0 0;${openingAngle} 0 0;${openingAngle} 0 0`
          );
        }

        const angleZh = document.getElementById(
          'op-opening-angle-zh'
        );

        const angleEn = document.getElementById(
          'op-opening-angle-en'
        );

        if (angleZh) {
          angleZh.textContent = `隨機角度:${openingAngle}°`;
        }

        if (angleEn) {
          angleEn.textContent = `Random angle: ${openingAngle}°`;
        }

        // 每次打開說明頁時,把 SMIL 示意動畫重新由零開始播放。
        requestAnimationFrame(() => {
          document
            .querySelectorAll('#operation-modal svg.operation-demo')
            .forEach(svg => {
              try {
                if (typeof svg.setCurrentTime === 'function') {
                  svg.setCurrentTime(0);
                }

                if (typeof svg.unpauseAnimations === 'function') {
                  svg.unpauseAnimations();
                }
              } catch (error) {
                // 不支援 SVG SMIL 控制 API 時保留瀏覽器原生行為。
              }
            });
        });
      }

這裡已移除對舊元素:

js
.op-placement-own
#op-placement-target

的操作,因為舊 SVG 已不存在。


7.4 關閉說明時清理計時器

完整替換目前的:

js
      function closeOperationGuide() {
        const modal = document.getElementById('operation-modal');

        if (modal) {
          modal.style.display = 'none';
        }
      }

替換為:

js
      function closeOperationGuide() {
        const modal = document.getElementById('operation-modal');

        stopOperationPlacementDemo();

        if (modal) {
          modal.style.display = 'none';
        }
      }

這樣反覆開關操作說明時,不會留下多組重複計時器。


八、抽出正式遊戲和操作說明共用的預放棋子函式

8.1 在正式 generateGhosts() 前加入共用函式

在現有:

js
      function generateGhosts() {

之前加入:

js
      /*
       * 正式遊戲與操作說明共用的預放棋子產生器。
       *
       * 此函式只根據傳入資料回傳落點陣列,不修改:
       * pieces、tempPieces、ghosts、selectedTile、
       * targetOpponentPieceId 或其它正式棋局狀態。
       */
      function collectPlacementGhosts({
        selectedTileId,
        selectedFlipped,
        target,
        owner,
        existingPieces,
        idFactory
      }) {
        if (
          !selectedTileId ||
          !target ||
          !SHAPE_MAP[selectedTileId]
        ) {
          return [];
        }

        const selectedType = SHAPE_MAP[selectedTileId];
        const boardPieces =
          existingPieces && existingPieces.length > 0
            ? existingPieces
            : [target];

        const result = [];
        let generatedIndex = 0;

        for (let targetEdge = 0; targetEdge < 4; targetEdge++) {
          for (let myEdge = 0; myEdge < 4; myEdge++) {
            const attached = attachByEdge(
              selectedType,
              selectedFlipped,
              myEdge,
              target.vertices[targetEdge],
              target.vertices[(targetEdge + 1) % 4],
              !!target.isFlipped
            );

            if (!attached) {
              continue;
            }

            const ghost = {
              id:
                typeof idFactory === 'function'
                  ? idFactory(generatedIndex++)
                  : allocateHypothesisPieceId(),
              vertices: attached.vertices,
              type: selectedType,
              owner,
              svgId: selectedTileId,
              isFlipped: selectedFlipped,
              edgeOnOpp: targetEdge,
              targetId: target.id,
              myEdge,
              targetEdge,
              parentId: target.id,
              level:
                (target.level !== undefined ? target.level : 0) + 1
            };

            preparePieceSAT(ghost);

            if (isValidGhost(ghost, boardPieces)) {
              result.push(ghost);
            }
          }
        }

        return result;
      }

8.2 替換正式遊戲的 generateGhosts()

完整替換目前的 generateGhosts(),使用以下版本:

js
      function generateGhosts() {
        ghosts = [];

        if (!selectedTile || !targetOpponentPieceId) {
          return;
        }

        const state = createSearchStateFromGlobals(currentPlayer);

        /*
         * 正式遊戲的持久增量索引快速路徑維持不變。
         */
        if (state.incremental) {
          const methodKeys =
            state.incremental.byTarget.get(targetOpponentPieceId) ||
            new Set();

          const existingPieces = pieces.concat(tempPieces);

          for (const methodKey of methodKeys) {
            const method = state.incremental.methods.get(methodKey);

            if (!method) {
              continue;
            }

            const source = method.ghost;

            if (source.owner !== currentPlayer) {
              continue;
            }

            if (source.svgId !== selectedTile) {
              continue;
            }

            if (source.isFlipped !== isFlipped[selectedTile]) {
              continue;
            }

            /*
             * 第一顆暫存棋不在 committed index 中,
             * 因此第二顆仍須檢查與 tempPieces 的相容性。
             */
            if (!isValidGhost(source, existingPieces)) {
              continue;
            }

            /*
             * 必須複製,因為 commitGhost() 會修改 id。
             */
            ghosts.push({
              ...source,
              vertices: source.vertices,
              satBounds: source.satBounds
            });
          }

          return;
        }

        /*
         * 背景索引尚未完成時的 UI 備援。
         * 原本的雙重迴圈已移入 collectPlacementGhosts(),
         * 幾何判定與迭代次序保持不變。
         */
        const target = pieces.find(
          piece => piece.id === targetOpponentPieceId
        );

        if (!target) {
          return;
        }

        ghosts = collectPlacementGhosts({
          selectedTileId: selectedTile,
          selectedFlipped: isFlipped[selectedTile],
          target,
          owner: currentPlayer,
          existingPieces: pieces.concat(tempPieces),
          idFactory: () => allocateHypothesisPieceId()
        });
      }

這個替換不改變正式遊戲的落點規則:

  • 有增量索引時仍使用原本的索引快速路徑。
  • 沒有增量索引時,仍執行原本的四條目標邊 × 四條己方邊枚舉。
  • 仍使用 attachByEdge()
  • 仍使用 preparePieceSAT()
  • 仍使用 isValidGhost()
  • 不修改 commitGhost()handleTargetClick()actionCheck()

九、開局落子和旋轉箭頭

在「開局落子和旋轉」SVG 中找到箭頭尖端:

html
              <path
                d="M542 199l8 14 8-14"
                fill="none"
                stroke="#00bfff"
                stroke-width="5"
                stroke-linejoin="round"
                opacity="0"
              >
                <animate
                  attributeName="opacity"
                  values="0;0;1;1"
                  keyTimes="0;0.42;0.52;1"
                  dur="7s"
                  repeatCount="indefinite"
                />
              </path>

替換為:

html
              <path
                d="M534 199l8 14 8-14"
                transform="rotate(15 542 207)"
                fill="none"
                stroke="#00bfff"
                stroke-width="5"
                stroke-linecap="round"
                stroke-linejoin="round"
                opacity="0"
              >
                <animate
                  attributeName="opacity"
                  values="0;0;1;1"
                  keyTimes="0;0.42;0.52;1"
                  dur="7s"
                  repeatCount="indefinite"
                />
              </path>

修改效果:

  • 箭頭由原本的 x=542~558 左移至 x=534~550
  • (542,207) 為中心順時針旋轉 15°
  • 箭頭尖端會更貼近上方弧線,形成清晰的旋轉箭頭。

不應修改的正式遊戲函式

為避免影響正確遊戲流程,下列函式保持原樣:

js
selectTile()
handleTargetClick()
commitGhost()
pickupTempPiece()
actionCross()
actionCheck()
applySearchTurnToState()
validateTurnOnState()
undo()
redo()
startAI()

唯一與正式遊戲共用的改動,是把原本 generateGhosts() 備援路徑中的落點枚舉抽成 collectPlacementGhosts();枚舉內容及合法性判定沒有改變。