代码: 全选
為滿足你的需求,以下對**三菱棋 (Trirhombus)** 進行了完整的修復與優化。本次改進優化了電腦端佈局、整合了棋盤平移縮放控制、並實現了核心二人輪流對弈規則與拓撲空洞偵測算法。
你可以直接複製下方**完整更新後的單一 HTML 檔案代碼**替換原有內容,或查閱後續提供的分段替換說明:
```html
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>三菱棋 (Trirhombus)</title>
<style>
:root {
--bg-color: #f0f2f5;
--panel-bg: #ffffff;
--text-color: #333333;
--shadow-color: rgba(0, 0, 0, 0.1);
--board-shadow: rgba(0, 0, 0, 0.3);
--hover-shadow: rgba(0, 0, 0, 0.5);
--border-color: #cccccc;
--p1-color: #0056b3;
--p2-color: #a70000;
}
body,
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: var(--bg-color);
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
overflow: hidden;
display: flex;
}
#game-container {
display: flex;
width: 100%;
height: 100%;
flex-direction: row;
}
.control-area {
position: relative;
background: var(--panel-bg);
box-shadow: 0 0 15px var(--shadow-color);
z-index: 10;
box-sizing: border-box;
}
/* Desktop Layout Style Fixes */
@media (min-width: 769px) {
#game-container {
flex-direction: row;
}
.control-area {
flex: 0 0 240px;
width: 240px;
min-width: 240px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 0;
}
#opp-control { border-right: 1px solid var(--border-color); }
#my-control { border-left: 1px solid var(--border-color); }
.score-area {
position: relative;
top: auto;
left: auto;
transform: none;
opacity: 0.8;
font-size: 1.5rem;
font-weight: bold;
color: var(--text-color);
margin-bottom: 20px;
text-align: center;
pointer-events: none;
}
.piece-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: auto;
}
#opp-control .piece-area, #my-control .piece-area {
padding: 0;
}
.board-wrapper {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #e9ecef;
padding: 24px;
box-sizing: border-box;
position: relative;
}
}
/* Mobile Layout */
@media (max-width: 768px) {
#game-container {
flex-direction: column;
height: 100vh;
}
.control-area {
min-width: auto;
min-height: 160px;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
padding: 15px 0;
}
.score-area {
position: relative;
top: auto;
left: auto;
transform: none;
opacity: 0.8;
font-size: 1.5rem;
margin-bottom: 10px;
line-height: 1;
}
#opp-control { border-bottom: 1px solid var(--border-color); }
#my-control { border-top: 1px solid var(--border-color); }
.board-wrapper {
padding: 10px;
flex: 1;
width: 100%;
height: 0;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.piece-area {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
width: 100%;
flex: 1;
}
#opp-control .piece-area, #my-control .piece-area { padding: 0; }
}
svg#etani {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
aspect-ratio: 1 / 1;
background: #ffffff;
border-radius: 8px;
filter: drop-shadow(0 10px 15px var(--board-shadow));
transition: filter 0.3s ease;
cursor: crosshair;
}
svg#etani:hover {
filter: drop-shadow(0 15px 25px var(--hover-shadow));
}
.piece-box {
position: relative;
width: 70px;
height: 70px;
background: #fff;
border: 2px solid var(--border-color);
border-radius: 12px;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 4px 6px var(--shadow-color);
transition: all 0.2s ease;
cursor: pointer;
margin: 10px;
}
@media (max-width: 768px) {
.piece-box {
width: 55px;
height: 55px;
}
}
.piece-box:hover {
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
transform: translateY(-3px);
border-color: #999;
}
.piece-box.selected {
border-color: #333;
border-width: 3px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
transform: scale(1.1);
}
.mini-svg {
width: 80%;
height: 80%;
pointer-events: none;
}
.count-badge {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 11px;
font-weight: bold;
background: rgba(255, 255, 255, 0.85);
border: 1px solid #aaa;
padding: 0px 4px;
border-radius: 6px;
color: #111;
}
/* Zoom & Move Button Styles */
.nav-btn {
width: 48px;
height: 48px;
opacity: 0.7;
cursor: pointer;
z-index: 20;
display: none;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 4px;
box-sizing: border-box;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transition: opacity 0.2s, transform 0.1s;
}
.nav-btn:hover { opacity: 1; }
.nav-btn:active { transform: scale(0.9); }
.nav-btn-svg { width: 100%; height: 100%; }
#test-box {
position: absolute;
bottom: 15px;
right: 15px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 10px 15px;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
z-index: 100;
pointer-events: none;
}
</style>
</head>
<body>
<div id="game-container">
<div class="control-area" id="opp-control">
<div class="score-area">對方</div>
<div class="piece-area">
<div class="piece-box">
<svg viewBox="-40 -40 80 80" class="mini-svg"><use href="#tile0" fill="#00BFFF"></use></svg>
</div>
<div class="piece-box">
<svg viewBox="-40 -40 80 80" class="mini-svg"><use href="#tile1" fill="#FF0000"></use></svg>
</div>
<div class="piece-box">
<svg viewBox="-40 -40 80 80" class="mini-svg"><use href="#tile2" fill="#FF8C00"></use></svg>
</div>
</div>
</div>
<div class="board-wrapper">
<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"><path d="M 0,-18 L 31.177,0 L 0,18 L -31.177,0 Z" /></g>
<g id="tile1"><path d="M 15.588,9 L -15.588,27 L -15.588,-9 L 15.588,-27 Z" /></g>
<g id="tile2"><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 class="control-area" id="my-control">
<div class="score-area">我方</div>
<div class="piece-area">
<div class="piece-box" onclick="selectPiece(0, this)">
<svg viewBox="-40 -40 80 80" class="mini-svg"><use href="#tile0" fill="#00BFFF"></use></svg>
</div>
<div class="piece-box" onclick="selectPiece(1, this)">
<svg viewBox="-40 -40 80 80" class="mini-svg"><use href="#tile1" fill="#FF0000"></use></svg>
</div>
<div class="piece-box" onclick="selectPiece(2, this)">
<svg viewBox="-40 -40 80 80" class="mini-svg"><use href="#tile2" fill="#FF8C00"></use></svg>
</div>
</div>
</div>
</div>
<div id="test-box">坐標: 無</div>
<script>
const TILE_COLORS = ['#00BFFF', '#FF0000', '#FF8C00'];
let currentSelectedPiece = null;
let selectedBoxElement = null;
// 遊戲狀態追蹤
let gameState = {
currentTurn: 2, // 1 = 我方, 2 = 對方 (依要求由對方先手)
isFirstMove: true,
inventory: {
1: [5, 6, 6], // 我方剩餘棋子 (初始正中已佔用1個藍色)
2: [6, 6, 6] // 對方剩餘棋子
},
// 儲存已被佔領的三角形,格式為 "idx,N": 玩家ID
// 初始中央藍棋佔據 (A,0) -> (1,0) 以及 (a,0) -> (-1,0)
filledTriangles: {
"1,0": 1,
"-1,0": 1
}
};
// 視角控制變數
let currentPanX = 0, currentPanY = 0, currentScale = 1.0;
let targetPanX = 0, targetPanY = 0, targetScale = 1.0;
let isAnimatingTransform = false;
document.addEventListener('DOMContentLoaded', () => {
drawGrid();
initControlButtons();
setupBoardClick();
updateInventoryUI();
});
function drawGrid() {
const etboard = document.querySelector('.etboard');
const pathNode = document.createElementNS('http://www.w3.org/2000/svg', 'path');
let d = '';
const halfWidth = 31.1769;
for (let i = -24; i <= 24; i++) {
let x = i * halfWidth;
d += `M ${x.toFixed(3)},-600 L ${x.toFixed(3)},600 `;
}
for (let i = -12; i < 12; i++) {
let dx = 600;
let dy = dx / Math.sqrt(3);
let y0_left = -18 + i * 36;
d += `M -600,${(-dy + y0_left).toFixed(3)} L 600,${(dy + y0_left).toFixed(3)} `;
let y0_right = 18 + i * 36;
d += `M -600,${(dy + y0_right).toFixed(3)} L 600,${(-dy + y0_right).toFixed(3)} `;
}
pathNode.setAttribute('d', d);
pathNode.setAttribute('stroke', '#b0b0b0');
pathNode.setAttribute('stroke-width', '1');
pathNode.setAttribute('stroke-dasharray', '5 5');
pathNode.setAttribute('fill', 'none');
etboard.insertBefore(pathNode, etboard.firstChild);
}
// 初始化平移與縮放按鈕
function initControlButtons() {
const wrapper = document.querySelector('.board-wrapper');
const btnsConfig = [
{ id: 'btn-up', style: 'top: 10px; left: 50%; transform: translateX(-50%);', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><path d="M12 4l-8 8h6v8h4v-8h6z" fill="#333"/></svg>', act: () => { targetPanY -= 24; } },
{ id: 'btn-down', style: 'bottom: 10px; left: 50%; transform: translateX(-50%);', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><path d="M12 20l8-8h-6V4h-4v8H4z" fill="#333"/></svg>', act: () => { targetPanY += 24; } },
{ id: 'btn-left', style: 'left: 10px; top: 50%; transform: translateY(-50%);', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><path d="M4 12l8 8v-6h8v-4h-8V4z" fill="#333"/></svg>', act: () => { targetPanX -= 24; } },
{ id: 'btn-right', style: 'right: 10px; top: 50%; transform: translateY(-50%);', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><path d="M20 12l-8-8v6H4v4h8v6z" fill="#333"/></svg>', act: () => { targetPanX += 24; } },
{ id: 'btn-in-tl', style: 'top: 10px; left: 10px;', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#333" stroke-width="2"/><path d="M8 12h8M12 8v8" stroke="#333" stroke-width="2"/></svg>', act: () => { targetScale += 0.12; } },
{ id: 'btn-in-br', style: 'bottom: 10px; right: 10px;', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#333" stroke-width="2"/><path d="M8 12h8M12 8v8" stroke="#333" stroke-width="2"/></svg>', act: () => { targetScale += 0.12; } },
{ id: 'btn-out-bl', style: 'bottom: 10px; left: 10px;', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#333" stroke-width="2"/><path d="M8 12h8" stroke="#333" stroke-width="2"/></svg>', act: () => { targetScale = Math.max(0.4, targetScale - 0.12); } },
{ id: 'btn-out-tr', style: 'top: 10px; right: 10px;', html: '<svg viewBox="0 0 24 24" class="nav-btn-svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#333" stroke-width="2"/><path d="M8 12h8" stroke="#333" stroke-width="2"/></svg>', act: () => { targetScale = Math.max(0.4, targetScale - 0.12); } }
];
btnsConfig.forEach(cfg => {
let btn = document.createElement('div');
btn.className = 'nav-btn';
btn.id = cfg.id;
btn.style.cssText = cfg.style;
btn.innerHTML = cfg.html;
btn.addEventListener('click', (e) => {
e.stopPropagation();
cfg.act();
startTransformAnimation();
});
wrapper.appendChild(btn);
});
}
function toggleControlButtons(visible) {
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.style.display = visible ? 'block' : 'none';
});
}
// 動畫平移縮放棋盤
function startTransformAnimation() {
if (isAnimatingTransform) return;
isAnimatingTransform = true;
function step() {
let dx = targetPanX - currentPanX;
let dy = targetPanY - currentPanY;
let ds = targetScale - currentScale;
if (Math.abs(dx) < 0.05 && Math.abs(dy) < 0.05 && Math.abs(ds) < 0.002) {
currentPanX = targetPanX;
currentPanY = targetPanY;
currentScale = targetScale;
applyTransform();
document.getElementById('test-box').innerText = `transform: translate(${currentPanX.toFixed(3)}px, ${currentPanY.toFixed(3)}px) scale(${currentScale.toFixed(3)})`;
isAnimatingTransform = false;
return;
}
currentPanX += dx * 0.2;
currentPanY += dy * 0.2;
currentScale += ds * 0.2;
applyTransform();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function applyTransform() {
const etanidrop = document.getElementById('etanidrop');
etanidrop.setAttribute('transform', `translate(${(240 + currentPanX).toFixed(3)}, ${(240 + currentPanY).toFixed(3)}) scale(${currentScale.toFixed(3)})`);
}
// 選擇棋子
function selectPiece(pieceId, element) {
if (gameState.currentTurn !== 1) return; // 不是我方回合則無法選擇
if (selectedBoxElement) {
selectedBoxElement.classList.remove('selected');
}
if (currentSelectedPiece === pieceId) {
currentSelectedPiece = null;
selectedBoxElement = null;
} else {
currentSelectedPiece = pieceId;
selectedBoxElement = element;
element.classList.add('selected');
toggleControlButtons(false); // 選擇棋子時隱藏控制按鈕
}
}
function setupBoardClick() {
const svg = document.getElementById('etani');
const pt = svg.createSVGPoint();
svg.addEventListener('click', function (e) {
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
// 扣除基礎 translate(240, 240)
let tx = svgP.x - 240;
let ty = svgP.y - 240;
// 補償因平移和縮放產生的坐標漂移
tx = (tx - currentPanX) / currentScale;
ty = (ty - currentPanY) / currentScale;
processBoardClick(tx, ty);
});
}
// 獲取三角形朝向
function getTriangleOrientation(idx, n) {
let k_left = idx > 0 ? idx - 1 : idx;
let k_right = idx > 0 ? idx : idx + 1;
let mod_n = ((n % 2) + 2) % 2;
let mod_kl = ((k_left % 2) + 2) % 2;
let mod_kr = ((k_right % 2) + 2) % 2;
if (mod_n === mod_kl) return true; // 右朝向
if (mod_n === mod_kr) return false; // 左朝向
return null;
}
function getNeighborIdx(idx, dir) {
if (idx === 1 && dir === -1) return -1;
if (idx === -1 && dir === 1) return 1;
return idx + dir;
}
// 計算三角形的幾何中心與完整資訊
function completeTriInfo(tri) {
let orientation = getTriangleOrientation(tri.idx, tri.N);
if (orientation === null) return null;
tri.isRight = orientation;
tri.cy = tri.N * 18;
const halfWidth = 31.1769;
let k_left = tri.idx > 0 ? tri.idx - 1 : tri.idx;
if (tri.isRight) {
tri.cx = k_left * halfWidth + 10.392;
} else {
tri.cx = k_left * halfWidth + 20.784;
}
return tri;
}
// 取得一個三角形相鄰的三個相鄰三角形
function getTriangleNeighbors(tri) {
let t = completeTriInfo({ idx: tri.idx, N: tri.N });
if (!t) return [];
let n1 = t.isRight ? { idx: getNeighborIdx(t.idx, -1), N: t.N } : { idx: getNeighborIdx(t.idx, 1), N: t.N };
let n2 = !t.isRight ? { idx: t.idx, N: t.N + 1 } : { idx: t.idx, N: t.N - 1 };
let n3 = t.isRight ? { idx: t.idx, N: t.N + 1 } : { idx: t.idx, N: t.N - 1 };
return [n1, n2, n3].map(completeTriInfo).filter(x => x !== null);
}
// 計算特定落子型號在某起點會額外佔據的第二個三角形
function getTri2(pieceId, tri1) {
let idx = tri1.idx;
let N = tri1.N;
let isRight = tri1.isRight;
if (pieceId === 0) {
return isRight ? { idx: getNeighborIdx(idx, -1), N: N } : { idx: getNeighborIdx(idx, 1), N: N };
} else if (pieceId === 1) {
return !isRight ? { idx: idx, N: N + 1 } : { idx: idx, N: N - 1 };
} else if (pieceId === 2) {
return isRight ? { idx: idx, N: N + 1 } : { idx: idx, N: N - 1 };
}
return null;
}
// 拓撲空洞偵測演算法 (利用 BFS 尋找封閉的空三角形集合)
function checkHoleCreated(simulatedState) {
let checked = new Set();
const MAX_HOLE_CHECK_SIZE = 300;
for (let key in simulatedState) {
let [idxS, nS] = key.split(',').map(Number);
let neighbors = getTriangleNeighbors({ idx: idxS, N: nS });
for (let nb of neighbors) {
let nbKey = `${nb.idx},${nb.N}`;
if (simulatedState[nbKey] || checked.has(nbKey)) continue;
// 開始 BFS 遍歷空白連通區
let queue = [nb];
let component = [nbKey];
let compSet = new Set([nbKey]);
let isEnclosed = true;
let head = 0;
while (head < queue.length) {
let curr = queue[head++];
if (queue.length > MAX_HOLE_CHECK_SIZE || Math.abs(curr.idx) > 40 || Math.abs(curr.N) > 40) {
isEnclosed = false;
break;
}
let currNeighbors = getTriangleNeighbors(curr);
for (let nnb of currNeighbors) {
let nnbKey = `${nnb.idx},${nnb.N}`;
if (!simulatedState[nnbKey] && !compSet.has(nnbKey)) {
compSet.add(nnbKey);
component.push(nnbKey);
queue.push(nnb);
}
}
}
if (isEnclosed) {
return component; // 找到封閉空洞,回傳格子清單
}
for (let k of compSet) checked.add(k);
}
}
return null;
}
// 繪製空洞外框線條
function drawHoleOverlay(holeKeys) {
let dStr = "";
holeKeys.forEach(key => {
let [idx, n] = key.split(',').map(Number);
let tri = completeTriInfo({ idx: idx, N: n });
if (!tri) return;
let cx = tri.cx, cy = tri.cy;
if (tri.isRight) {
dStr += `M ${(cx - 10.392).toFixed(3)},${(cy - 18).toFixed(3)} L ${(cx + 20.784).toFixed(3)},${cy.toFixed(3)} L ${(cx - 10.392).toFixed(3)},${(cy + 18).toFixed(3)} Z `;
} else {
dStr += `M ${(cx + 10.392).toFixed(3)},${(cy - 18).toFixed(3)} L ${(cx - 20.784).toFixed(3)},${cy.toFixed(3)} L ${(cx + 10.392).toFixed(3)},${(cy + 18).toFixed(3)} Z `;
}
});
let pathNode = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathNode.setAttribute('d', dStr);
pathNode.setAttribute('fill', '#000000');
pathNode.setAttribute('id', 'hole-overlay');
document.querySelector('.etboard').appendChild(pathNode);
}
// 執行落棋動畫與核心判定邏輯
function executeDropPiece(pieceId, tri1, tri2, player) {
let px, py;
if (pieceId === 0) {
px = tri1.isRight ? tri1.cx - 10.392 : tri1.cx + 10.392;
py = tri1.cy;
} else if (pieceId === 1) {
px = !tri1.isRight ? tri1.cx - 5.196 : tri1.cx + 5.196;
py = !tri1.isRight ? tri1.cy + 9 : tri1.cy - 9;
} else if (pieceId === 2) {
px = tri1.isRight ? tri1.cx + 5.196 : tri1.cx - 5.196;
py = tri1.isRight ? tri1.cy + 9 : tri1.cy - 9;
}
const useNode = document.createElementNS('http://www.w3.org/2000/svg', 'use');
useNode.setAttribute('href', `#tile${pieceId}`);
useNode.setAttribute('class', 'tiledropped');
useNode.setAttribute('fill', TILE_COLORS[pieceId]);
// 計算視窗外的垂直起點位置
let startY = player === 1 ? (600 - currentPanY) / currentScale : (-600 - currentPanY) / currentScale;
useNode.setAttribute('transform', `translate(${px.toFixed(3)}, ${startY.toFixed(3)})`);
document.getElementById('etanidrop').appendChild(useNode);
let startTime = performance.now();
const duration = 777;
function animate(currentTime) {
let elapsed = currentTime - startTime;
let progress = Math.min(elapsed / duration, 1);
// 平滑緩動 (Ease out)
let easeProgress = 1 - Math.pow(1 - progress, 3);
let currentY = startY + (py - startY) * easeProgress;
useNode.setAttribute('transform', `translate(${px.toFixed(3)}, ${currentY.toFixed(3)})`);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
// 完成落子進場,模擬並檢查是否產生空洞
let simState = Object.assign({}, gameState.filledTriangles);
simState[`${tri1.idx},${tri1.N}`] = player;
simState[`${tri2.idx},${tri2.N}`] = player;
let detectedHole = checkHoleCreated(simState);
if (detectedHole) {
// 觸發違規處罰機制:生成空洞 path -> 等待360ms -> 原路退回
drawHoleOverlay(detectedHole);
setTimeout(() => {
const overlay = document.getElementById('hole-overlay');
if (overlay) overlay.remove();
let returnStartTime = performance.now();
function animateBack(t) {
let retElapsed = t - returnStartTime;
let retProgress = Math.min(retElapsed / duration, 1);
let retEase = 1 - Math.pow(1 - retProgress, 3);
let retY = py + (startY - py) * retEase;
useNode.setAttribute('transform', `translate(${px.toFixed(3)}, ${retY.toFixed(3)})`);
if (retProgress < 1) {
requestAnimationFrame(animateBack);
} else {
useNode.remove();
if (player === 2) setTimeout(triggerOpponentAI, 200); // 對方重試
}
}
requestAnimationFrame(animateBack);
}, 360);
} else {
// 合法落子,寫入實體狀態
gameState.filledTriangles[`${tri1.idx},${tri1.N}`] = player;
gameState.filledTriangles[`${tri2.idx},${tri2.N}`] = player;
gameState.inventory[player][pieceId]--;
gameState.isFirstMove = false;
// 切換回合
gameState.currentTurn = player === 1 ? 2 : 1;
updateInventoryUI();
if (gameState.currentTurn === 2) {
setTimeout(triggerOpponentAI, 600);
}
}
}
}
requestAnimationFrame(animate);
}
function processBoardClick(tx, ty) {
let approxN = Math.round(ty / 18);
let bestDist = Infinity;
let bestTri = null;
const halfWidth = 31.1769;
let colFloat = tx / halfWidth;
let k = Math.floor(colFloat);
let idx = tx > 0 ? k + 1 : k;
if (tx === 0) idx = 1;
for (let n = approxN - 2; n <= approxN + 2; n++) {
let tri = completeTriInfo({ idx: idx, N: n });
if (!tri) continue;
let dist = Math.hypot(tx - tri.cx, ty - tri.cy);
if (dist < bestDist) {
bestDist = dist;
bestTri = tri;
}
}
if (!bestTri) return;
if (currentSelectedPiece !== null) {
if (gameState.currentTurn !== 1) return;
let tri1 = bestTri;
let tri2 = getTri2(currentSelectedPiece, tri1);
if (!tri2) return;
tri2 = completeTriInfo(tri2);
// 1. 重疊檢查
if (gameState.filledTriangles[`${tri1.idx},${tri1.N}`] || gameState.filledTriangles[`${tri2.idx},${tri2.N}`]) {
alert("此位置已被棋子佔據!");
return;
}
// 2. 相鄰檢查
let isAdjacent = false;
let checkList = getTriangleNeighbors(tri1).concat(getTriangleNeighbors(tri2));
for (let nb of checkList) {
if (gameState.filledTriangles[`${nb.idx},${nb.N}`]) {
isAdjacent = true;
break;
}
}
if (!isAdjacent) {
alert("落子必須與現有的棋子相鄰!");
return;
}
executeDropPiece(currentSelectedPiece, tri1, tri2, 1);
// 清空選擇
if (selectedBoxElement) selectedBoxElement.classList.remove('selected');
currentSelectedPiece = null;
selectedBoxElement = null;
} else {
toggleControlButtons(true); // 點擊棋盤空白顯示控制按鈕
updateTestBox(bestTri, null);
}
}
// 對方行動自動決策 (簡單 AI 尋找合法走法)
function triggerOpponentAI() {
if (gameState.currentTurn !== 2) return;
let pieceOrder = [0, 1, 2];
if (gameState.isFirstMove) pieceOrder = [1]; // 對手第一步強制為紅色棋子
let legalMove = null;
// 搜尋目前盤面上所有棋子的外圍相鄰格
for (let key in gameState.filledTriangles) {
let [idxS, nS] = key.split(',').map(Number);
let activeNeighbors = getTriangleNeighbors({ idx: idxS, N: nS });
for (let tri1 of activeNeighbors) {
let t1Key = `${tri1.idx},${tri1.N}`;
if (gameState.filledTriangles[t1Key]) continue;
for (let pId of pieceOrder) {
if (gameState.inventory[2][pId] <= 0) continue;
let tri2 = getTri2(pId, tri1);
if (!tri2) continue;
tri2 = completeTriInfo(tri2);
let t2Key = `${tri2.idx},${tri2.N}`;
if (gameState.filledTriangles[t2Key]) continue;
// 虛擬測試空洞
let sim = Object.assign({}, gameState.filledTriangles);
sim[t1Key] = 2;
sim[t2Key] = 2;
if (!checkHoleCreated(sim)) {
legalMove = { pId: pId, t1: tri1, t2: tri2 };
break;
}
}
if (legalMove) break;
}
if (legalMove) break;
}
if (legalMove) {
executeDropPiece(legalMove.pId, legalMove.t1, legalMove.t2, 2);
} else {
alert("對方無可行棋步,遊戲結束!");
}
}
function updateInventoryUI() {
// 更新我方 UI
const p1Boxes = document.querySelectorAll('#my-control .piece-box');
p1Boxes.forEach((box, i) => {
let badge = box.querySelector('.count-badge') || document.createElement('span');
badge.className = 'count-badge';
badge.innerText = `x${gameState.inventory[1][i]}`;
if (!box.querySelector('.count-badge')) box.appendChild(badge);
let clickable = (gameState.currentTurn === 1 && gameState.inventory[1][i] > 0);
box.style.opacity = clickable ? '1' : '0.4';
box.style.pointerEvents = clickable ? 'auto' : 'none';
});
// 更新對方 UI
const p2Boxes = document.querySelectorAll('#opp-control .piece-box');
p2Boxes.forEach((box, i) => {
let badge = box.querySelector('.count-badge') || document.createElement('span');
badge.className = 'count-badge';
badge.innerText = `x${gameState.inventory[2][i]}`;
if (!box.querySelector('.count-badge')) box.appendChild(badge);
box.style.opacity = (gameState.currentTurn === 2) ? '1' : '0.4';
});
document.querySelector('#my-control .score-area').innerText = gameState.currentTurn === 1 ? "我方 (行動中)" : "我方";
document.querySelector('#opp-control .score-area').innerText = gameState.currentTurn === 2 ? "對方 (行動中)" : "對方";
}
function formatCoord(idx, n) {
let colStr = idx > 0 ? String.fromCharCode(64 + idx) : String.fromCharCode(96 - idx);
return `(${colStr},${n})`;
}
function updateTestBox(tri1, tri2) {
let s = formatCoord(tri1.idx, tri1.N);
if (tri2) s += ', ' + formatCoord(tri2.idx, tri2.N);
document.getElementById('test-box').innerText = '選取格子: ' + s;
}
</script>
</body>
</html>
```
---
### 💡 核心修改與細節說明
#### 一、電腦端網頁樣式修復與自適應優化
* **分數區域解除絕對定位**:在 `@media (min-width: 769px)` 下,將 `.score-area` 改為相對定位(`position: relative`),讓其穩定排列在控制區棋子的上方,並將透明度適度提升到 `0.8` 以利辨識。
* **縮放與擠壓防崩潰**:為控制面板添加了硬性寬度屬性 `flex: 0 0 240px; width: 240px; min-width: 240px;`。當瀏覽器視窗橫向變窄時,兩側面板不會再收縮變形,而是自動擠壓中間的 `.board-wrapper` 區域。
* **棋盤等比置中**:SVG 棋盤設定了 `max-width: 100%; max-height: 100%; aspect-ratio: 1 / 1;`,確保其在被左右擠壓時,始終維持正方形並在中央垂直水平置中。
#### 二、棋盤控制按鈕與視角矩陣補償
* **動態導航按鈕**:添加了 `initControlButtons()` 函數,在 `.board-wrapper` 內自動動態生成 8 個 48*48 的圓形半透明 SVG 指向按鈕。
* **平滑緩動矩陣與狀態回傳**:平移每次步進 `24px`,縮放每次步進 `0.12`。利用 `requestAnimationFrame` 配合簡易的三次緩動公式進行非同步平滑更新。動畫結束時,將最終的平移縮放資訊寫入右下角 `#test-box` 中。
* **點擊坐標漂移補償**:修正了 `setupBoardClick` 中獲取坐標的轉換方式,在點擊轉換時自動逆向運算補償當前的平移量與縮放係數:
$$\text{actualTx} = \frac{\text{tx} - \text{currentPanX}}{\text{currentScale}}$$
這保證了棋盤不論如何拖拽、放大、縮小,點擊時落子定位依舊維持絕對精確。
#### 三、遊戲對弈規則與拓撲空洞判定演算法
* **初始狀態與先手權**:全域物件 `gameState` 初始設定 `currentTurn = 2`(由電腦方先手)。我方藍色棋子減少至 `5` 個,且系統已將盤面中央 `[(a,0);(A,0)]` 的幾何格子(即 `1,0` 與 `-1,0`)標記為已被玩家 1 佔領。
* **相鄰與強制限制判定**:實現了 `getTriangleNeighbors(tri)` 拓撲函數,利用菱形互補性準確取得任一格子的三個相鄰三角形。當電腦(Player 2)執行第一步時,系統將強制過濾並使其只能選擇紅色棋子(`tile1`)。
* **拓撲空洞偵測(Hole-BFS)**:核心新增 `checkHoleCreated(simulatedState)` 函數。每當玩家或電腦意圖落子時,系統會先對其周圍的空白格子進行廣度優先搜尋(BFS)。如果某個空白區域的連通分支小於上限(300格)且邊界封閉,即判定為「產生空洞」。
* **違規退回動畫**:若偵測到空洞,落子會先正常移至目標點,接著在 `g.etboard` 中附加一個 `#000000` 填充的黑邊 path 輪廓顯示空洞位置。`360ms` 後黑框消失,棋子會執行反向緩動動畫退回螢幕邊界外,且不切換回合,要求玩家重新落子。