共享会话
🛠️ 操作說明改進指南
分享于 2026年8月21日 00:49改進操作說明頁面 一,界面介紹
界面介紹中的「桌面版」的寬度應縮短到一半,左右的「玩家控制區」中文應豎排,英文則轉九十度。並且不需要有一個指向「手機版」的箭頭。
二,棋盤控制
在棋盤控制的svg中,「往左」和「往右」方向按鈕應跟上方和下方的按鈕的横坐標一致。
在其下方加上工具欄中的按鈕功能介紹:
- 回退:可對當前棋局的下法進行回退
- 前進:可對已經回退的下法進行恢復
- 仲裁功能:當你認為對方上回合違反規則,可點此仲裁
- 狀態窗口:點擊將打開當前狀態窗口
- 導入/導出:點擊將打開導入/導出窗口
- AI設置窗口:點擊將打開AI設置窗口
- 下載按鈕:點擊將打開下載svg窗口
- 旋轉功能:開啟後,每當輪到玩家二,雙方控制區和工具欄位置將圍繞棋盤中心水平翻轉。
每行兩個,用美觀的外框圍住,框內左方是相應的圖標,右方是文字介紹。注意配上相應的英文。
三,計分標記
把svg中間的兩個「…」和AI圖標去掉。
四,AI控制
把svg中的「簡單」的線的顏色改為跟右邊三個一樣的白色。
五,選擇目標和落子
去掉svg,重新製作,將用js控制svg來替代所以
- 左方或右方為三個縱列棋子,樣式跟遊戲中的一樣,隨機選擇左方或右方,如果是右方則為玩家一的粉色棋子,左方則是玩家二的綠色棋子。
- 在棋子的右側或左側同時加入棋盤和一個隨機的對方棋子,旋轉角度隨機。以上這兩步是一開始就有的。
- 2.4s後,三個縱列棋子的其中一個被中,其樣式跟遊戲中棋子被選中完全一樣。
- 2.4s後,棋盤中的棋子被點擊,其中心出現藍點,同時出現預放棋子,跟遊戲時一樣。調用遊戲時出現預放棋子的相關函式。
- 2.4s後,其中一個預放棋子變成實際落下棋子,其它預放棋子消失。
- 4.8s後,棋子和棋盤消失,然後再過2.4s,另一方的三個縱列棋子和一個對方棋子再次出現,然後重新執行以上過程。
六,開局落子和旋轉
在svg中,藍色v形箭頭應左移並稍作順時針旋轉,使其成為一個表示旋轉的箭頭。
回答要求:
不要影響正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。
QuanAI我會先定位操作說明頁面的六個章節、相關 SVG/CSS/JavaScript,以及遊戲中「選中、預放、正式落子」的既有函式,確保修改只影響說明動畫而不改動正常遊戲流程。
以下修改均只針對「操作說明」頁面。正常遊戲中的選棋、落子、確認、AI、歷史記錄及仲裁流程不變。
其中「選擇目標和落子」不直接呼叫會修改全域 ghosts 的 generateGhosts(),而是抽出一個純粹的共用函式 collectPlacementGhosts():正式遊戲與說明動畫共同使用同一套 attachByEdge()、isValidGhost() 合法落點邏輯,但說明動畫只操作自己的示意資料。
一、補充操作說明所需 CSS
在 <style> 中,建議放在現有 .operation-tip 規則後面,加入:
/* 操作說明:工具欄功能列表 */
.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;
}
}二、界面介紹
在 <!-- 一、界面介紹 --> 中,完整替換目前的:
<svg class="operation-demo operation-svg" viewBox="0 0 760 400" ...>
...
</svg>替換為:
<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 中找到:
<rect x="145" y="220" width="470" height="54" rx="27" fill="#23272a" opacity="0.92" />
<g fill="#ddd">
...
</g>把這一整段替換為:
<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 修正左右方向按鈕橫座標
找到方向控制中的四行:
<circle cx="190" cy="166" r="22" />
<circle cx="570" cy="166" r="22" />替換為:
<circle cx="205" cy="166" r="22" />
<circle cx="555" cy="166" r="22" />再找到:
<path d="M178 166l12-9v18z" />
<path d="M582 166l-12 9v-18z" />替換為:
<path d="M193 166l12-9v18z" />
<path d="M567 166l-12 9v-18z" />現在左側方向按鈕會與左上、左下的 x="205" 對齊,右側則與右上、右下的 x="555" 對齊。
四、在棋盤控制 SVG 下方加入工具欄說明
在「棋盤控制」的 SVG 結束標籤:
</svg>後面、該 section 結束前,加入:
<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 尾部刪除以下兩段:
<g transform="translate(370 126) scale(1.4) translate(-12 -12)" color="#7289da" fill="#7289da">
<use href="#op-ai-easy" />
</g>以及:
<path d="M345 116h-24M415 116h24" stroke="#7289da" stroke-width="3" stroke-dasharray="5 4" />這會同時去除中間 AI 圖標和兩側的「…」虛線。
六、AI 控制
在「AI 控制」SVG 的「簡單」按鈕中找到:
<g transform="translate(105 104) scale(1.7) translate(-12 -12)" color="#111" fill="#111">替換為:
<g transform="translate(105 104) scale(1.7) translate(-12 -12)" color="#fff" fill="#fff">op-ai-easy 中的外框和天線使用 currentColor,因此會與右側三個圖標一樣變成白色。
七、選擇目標和落子:替換為 JavaScript 動畫
7.1 替換原 SVG
在 <!-- 七、選擇目標和落子 --> 中,刪除原本整個:
<svg
class="operation-demo operation-svg"
viewBox="0 0 760 380"
...
>
...
</svg>替換成一個由 JavaScript 填入內容的空 SVG:
<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 加入說明動畫控制函式
在現有:
function setOperationFill(selector, color) {
document.querySelectorAll(selector).forEach(element => {
element.setAttribute('fill', color);
});
}後面加入以下完整程式碼:
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 修改操作說明隨機化函式
完整替換目前的:
function randomizeOperationGuideDemos() {
...
}替換為:
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 時保留瀏覽器原生行為。
}
});
});
}這裡已移除對舊元素:
.op-placement-own
#op-placement-target的操作,因為舊 SVG 已不存在。
7.4 關閉說明時清理計時器
完整替換目前的:
function closeOperationGuide() {
const modal = document.getElementById('operation-modal');
if (modal) {
modal.style.display = 'none';
}
}替換為:
function closeOperationGuide() {
const modal = document.getElementById('operation-modal');
stopOperationPlacementDemo();
if (modal) {
modal.style.display = 'none';
}
}這樣反覆開關操作說明時,不會留下多組重複計時器。
八、抽出正式遊戲和操作說明共用的預放棋子函式
8.1 在正式 generateGhosts() 前加入共用函式
在現有:
function generateGhosts() {之前加入:
/*
* 正式遊戲與操作說明共用的預放棋子產生器。
*
* 此函式只根據傳入資料回傳落點陣列,不修改:
* 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(),使用以下版本:
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 中找到箭頭尖端:
<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>替換為:
<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°。 - 箭頭尖端會更貼近上方弧線,形成清晰的旋轉箭頭。
不應修改的正式遊戲函式
為避免影響正確遊戲流程,下列函式保持原樣:
selectTile()
handleTargetClick()
commitGhost()
pickupTempPiece()
actionCross()
actionCheck()
applySearchTurnToState()
validateTurnOnState()
undo()
redo()
startAI()唯一與正式遊戲共用的改動,是把原本 generateGhosts() 備援路徑中的落點枚舉抽成 collectPlacementGhosts();枚舉內容及合法性判定沒有改變。