代码: 全选
這是一個基於您提供的 SVG 和規則,使用 HTML5、CSS3 和 Vanilla JavaScript 完整編寫的《三菱棋 (Trirhombus)》網頁遊戲。
所有的樣式、邏輯與視圖都已經封裝在單一的 HTML 檔案中。您可以將以下的程式碼複製並存成 `.html` 檔案(例如 `trirhombus.html`),然後使用瀏覽器開啟即可遊玩。
```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>三菱棋 (Trirhombus)</title>
<style>
:root {
--bg-color: #ffffe0;
--panel-gap: 20px;
}
body {
margin: 0;
padding: 0;
background-color: var(--bg-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: sans-serif;
overflow: hidden;
user-select: none;
}
#game-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
}
.panel {
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.score-board {
font-size: 1.2rem;
font-weight: bold;
color: #333;
}
.pieces-container {
display: flex;
gap: 15px;
}
.piece-btn {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.1s;
}
.piece-btn:active {
transform: scale(0.9);
}
.piece-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
}
.piece-btn svg {
width: 50px;
height: 50px;
}
.piece-count {
font-size: 1.2rem;
font-weight: bold;
}
/* Responsive Layouts */
@media (max-width: 767px) {
/* 手機端 */
#game-container {
justify-content: space-between;
padding: 10px 0;
}
#svg-container {
margin: 0; /* 手機端左右間距為0 */
width: 100vw;
display: flex;
justify-content: center;
}
#svg-container svg {
max-width: 100%;
height: auto;
}
.panel {
width: 100%;
flex-direction: column;
gap: 10px;
}
.pieces-container {
width: 100%;
justify-content: space-evenly;
}
#player1-panel { /* 對方 (上方) */
transform: rotate(180deg);
}
}
@media (min-width: 768px) {
/* 電腦端 */
#game-container {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: 1fr auto 1fr;
width: 100vw;
height: 100vh;
}
#svg-container {
grid-area: 2 / 2;
margin: 36px 0; /* 電腦端上下間距36px */
}
#player1-panel { /* 對方 (左上角) */
grid-area: 1 / 1;
transform: rotate(180deg);
flex-direction: column;
align-items: flex-end;
padding: 20px;
}
#player0-panel { /* 我方 (右下角) */
grid-area: 3 / 3;
flex-direction: column;
align-items: flex-end;
padding: 20px;
}
.pieces-container {
flex-direction: column;
gap: 20px;
}
.piece-btn {
flex-direction: row;
gap: 10px;
}
/* 左上角對方文字在左邊 */
#player1-panel .piece-btn {
flex-direction: row-reverse;
}
.score-board {
margin-bottom: 20px;
}
}
.ghost {
cursor: pointer;
transition: opacity 0.2s;
}
.ghost:hover {
opacity: 0.8 !important;
}
</style>
</head>
<body>
<div id="game-container">
<div id="player1-panel" class="panel">
<div class="score-board">分數: <span id="score-1">0</span></div>
<div class="pieces-container">
<div class="piece-btn" onclick="selectPiece(0)">
<svg viewBox="-40 -40 80 80"><use href="#tile0"></use></svg>
<span class="piece-count" id="count-1-0">6</span>
</div>
<div class="piece-btn" onclick="selectPiece(1)">
<svg viewBox="-40 -40 80 80"><use href="#tile1"></use></svg>
<span class="piece-count" id="count-1-1">6</span>
</div>
<div class="piece-btn" onclick="selectPiece(2)">
<svg viewBox="-40 -40 80 80"><use href="#tile2"></use></svg>
<span class="piece-count" id="count-1-2">6</span>
</div>
</div>
</div>
<div id="svg-container">
<svg id="etani" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="480" height="480" rfm="repeat" viewBox="0 0 480 480" preserveAspectRatio="xMidYMid meet">
<defs mode="0" rotatestep="1" rotatestart="0" stepvalue="180" snapto="0" displaywait="1">
<g id="tile0" frontfill="#00BFFF" backfill="#00BFFF"><path d="M 0,-18 L 31.177,0 L 0,18 L -31.177,0 Z"/></g>
<g id="tile1" frontfill="#FF0000" backfill="#FF0000"><path d="M 15.588,9 L -15.588,27 L -15.588,-9 L 15.588,-27 Z"/></g>
<g id="tile2" frontfill="#FF8C00" backfill="#FF8C00"><path d="M -15.588,9 L -15.588,-27 L 15.588,-9 L 15.588,27 Z"/></g>
</defs>
<g class="etdrop" stroke="#777" stroke-width="2" stroke-linejoin="round" transform="translate(240,240) scale(1,1)" id="etanidrop">
<g class="etboard"><circle r="6" cx="0" cy="0" fill="purple" stroke="none" stroke-width="0" class="bcenter"/></g>
<use href="#tile0" fill="#00BFFF" transform="translate(0,0) scale(1,1) rotate(0)" class="tiledropped"/>
</g>
</svg>
</div>
<div id="player0-panel" class="panel">
<div class="score-board">分數: <span id="score-0">1</span></div>
<div class="pieces-container">
<div class="piece-btn disabled" id="btn-0-0" onclick="selectPiece(0)">
<svg viewBox="-40 -40 80 80"><use href="#tile0"></use></svg>
<span class="piece-count" id="count-0-0">5</span>
</div>
<div class="piece-btn disabled" id="btn-0-1" onclick="selectPiece(1)">
<svg viewBox="-40 -40 80 80"><use href="#tile1"></use></svg>
<span class="piece-count" id="count-0-1">6</span>
</div>
<div class="piece-btn disabled" id="btn-0-2" onclick="selectPiece(2)">
<svg viewBox="-40 -40 80 80"><use href="#tile2"></use></svg>
<span class="piece-count" id="count-0-2">6</span>
</div>
</div>
</div>
</div>
<script>
// === 遊戲核心邏輯與數學系統 ===
const W = 31.1769; // 菱形半寬 (正三角形的高)
const H = 18; // 正三角形的半高
// 棋盤狀態 - 使用 "方向_列_排" 作為唯一標識
// 方向 L: 尖端向左的正三角形
// 方向 R: 尖端向右的正三角形
let board = new Set(['L_-1_0', 'R_0_0']); // 初始為先手在中心的藍棋
let turn = 1; // 0 為先手(下方), 1 為後手(上方)。第一手已下,輪到後手。
let totalMoves = 1;
// 每位玩家的棋子剩餘量 [玩家0, 玩家1]
let counts = [
[5, 6, 6], // 藍, 紅, 橙 (玩家0已用掉1個藍)
[6, 6, 6]
];
let scores = [1, 0]; // 目前分數:玩家0下了1個藍棋,得1分
let activeGhosts = []; // 儲存當前顯示的半透明預覽棋子
// 取得相鄰的三個三角形位置
function getAdjacencies(c, r, isLeft) {
if (isLeft) return [`R_${c+1}_${r}`, `R_${c}_${r-1}`, `R_${c}_${r+1}`];
else return [`L_${c-1}_${r}`, `L_${c}_${r-1}`, `L_${c}_${r+1}`];
}
// 取得給定放置點(以其左半三角形L座標為基準)的兩個三角形組合
function getTileParts(c, r, type) {
if (type === 0) return [`L_${c}_${r}`, `R_${c+1}_${r}`]; // 藍:左右相連
if (type === 1) return [`L_${c}_${r}`, `R_${c}_${r+1}`]; // 紅:右上(L) 接 左下(R)
if (type === 2) return [`L_${c}_${r}`, `R_${c}_${r-1}`]; // 橙:右下(L) 接 左上(R)
}
// 計算在 SVG 中的偏移轉換
function getTileTranslation(c, r, type) {
if (type === 0) return { x: (c + 1) * W, y: r * 18 * 2 };
if (type === 1) return { x: (c + 0.5) * W, y: r * 18 * 2 + 18 };
if (type === 2) return { x: (c + 0.5) * W, y: r * 18 * 2 - 18 };
}
// 空洞檢測 (BFS/Flood Fill)
function isHole(tempBoard) {
let minC = Infinity, maxC = -Infinity, minR = Infinity, maxR = -Infinity;
for (let key of tempBoard) {
let [dir, c, r] = key.split('_');
c = parseInt(c); r = parseInt(r);
minC = Math.min(minC, c); maxC = Math.max(maxC, c);
minR = Math.min(minR, r); maxR = Math.max(maxR, r);
}
// 擴大搜索邊界以包圍外圍空間
minC -= 2; maxC += 2; minR -= 2; maxR += 2;
let emptySpaces = new Set();
for (let c = minC; c <= maxC; c++) {
for (let r = minR; r <= maxR; r++) {
if (!tempBoard.has(`L_${c}_${r}`)) emptySpaces.add(`L_${c}_${r}`);
if (!tempBoard.has(`R_${c}_${r}`)) emptySpaces.add(`R_${c}_${r}`);
}
}
// 從邊界任一空位開始 Flood Fill
let queue = [];
let visited = new Set();
let start = `L_${minC}_${minR}`;
if (!emptySpaces.has(start)) start = `R_${minC}_${minR}`;
queue.push(start);
visited.add(start);
while (queue.length > 0) {
let curr = queue.shift();
let [dir, c, r] = curr.split('_');
c = parseInt(c); r = parseInt(r);
let neighbors = getAdjacencies(c, r, dir === 'L');
for (let n of neighbors) {
let [nd, nc, nr] = n.split('_');
nc = parseInt(nc); nr = parseInt(nr);
if (nc >= minC && nc <= maxC && nr >= minR && nr <= maxR) {
if (emptySpaces.has(n) && !visited.has(n)) {
visited.add(n);
queue.push(n);
}
}
}
}
// 如果有空位沒被訪問到,就代表形成被封閉的空洞
return visited.size !== emptySpaces.size;
}
// 取得當前棋子類型的所有合法落子點
function getValidPlacements(type) {
let valid = [];
let candidates = new Set();
// 1. 找出所有與現有棋盤相鄰的空位,作為候選起點
for (let key of board) {
let [dir, c, r] = key.split('_');
let adjs = getAdjacencies(parseInt(c), parseInt(r), dir === 'L');
for (let a of adjs) {
if (!board.has(a)) {
let [ad, ac, ar] = a.split('_');
ac = parseInt(ac); ar = parseInt(ar);
if (ad === 'L') {
candidates.add(a);
} else {
// 推導出對應的 L 座標
if (type === 0) candidates.add(`L_${ac-1}_${ar}`);
if (type === 1) candidates.add(`L_${ac}_${ar-1}`);
if (type === 2) candidates.add(`L_${ac}_${ar+1}`);
}
}
}
}
// 2. 過濾候選名單
for (let cand of candidates) {
let [_, c, r] = cand.split('_');
c = parseInt(c); r = parseInt(r);
let parts = getTileParts(c, r, type);
// 條件一:兩個三角形都必須是空的
if (board.has(parts[0]) || board.has(parts[1])) continue;
// 條件二:放置後必須與場上至少一個棋子相鄰
let r_parts = parts[1].split('_');
let r_c = parseInt(r_parts[1]), r_r = parseInt(r_parts[2]);
let adj0 = getAdjacencies(c, r, true).some(a => board.has(a));
let adj1 = getAdjacencies(r_c, r_r, false).some(a => board.has(a));
if (!adj0 && !adj1) continue;
// 條件三:不能形成空洞
let tempBoard = new Set(board);
tempBoard.add(parts[0]);
tempBoard.add(parts[1]);
if (isHole(tempBoard)) continue;
valid.push({c, r, type});
}
return valid;
}
// === UI 互動 ===
function updateUI() {
// 更新數字與分數
for (let p = 0; p < 2; p++) {
document.getElementById(`score-${p}`).innerText = scores[p];
for (let t = 0; t < 3; t++) {
document.getElementById(`count-${p}-${t}`).innerText = counts[p][t];
}
}
// 更新按鈕狀態 (只有輪到的玩家可以使用,且數量需大於0)
document.querySelectorAll('.piece-btn').forEach(btn => {
btn.classList.add('disabled');
});
let activePanel = turn === 0 ? document.getElementById('player0-panel') : document.getElementById('player1-panel');
let activeBtns = activePanel.querySelectorAll('.piece-btn');
activeBtns.forEach((btn, t) => {
if (counts[turn][t] > 0) {
btn.classList.remove('disabled');
}
});
}
function clearGhosts() {
activeGhosts.forEach(el => el.remove());
activeGhosts = [];
}
function selectPiece(type) {
clearGhosts();
// 檢查是否輪到該玩家按鈕 (透過DOM樹關係判斷,或由UI disabled狀態保護)
if (counts[turn][type] <= 0) return;
// 規則:後手方的第一步(即遊戲第二手)一定要下紅棋 (type = 1)
if (totalMoves === 1 && type !== 1) {
alert("後手方的第一步棋(第二手)一定要用紅棋!");
return;
}
let placements = getValidPlacements(type);
if (placements.length === 0) {
alert("這個棋子目前沒有可以合法落子的地方。");
return;
}
let etdrop = document.getElementById('etanidrop');
placements.forEach(p => {
let trans = getTileTranslation(p.c, p.r, p.type);
let useEl = document.createElementNS('http://www.w3.org/2000/svg', 'use');
useEl.setAttribute('href', `#tile${p.type}`);
useEl.setAttribute('transform', `translate(${trans.x},${trans.y}) scale(1,1)`);
useEl.setAttribute('opacity', '0.5');
useEl.classList.add('ghost');
// 點擊幽靈棋子進行實質落子
useEl.addEventListener('click', () => {
placePiece(p.c, p.r, p.type, trans.x, trans.y);
});
etdrop.appendChild(useEl);
activeGhosts.push(useEl);
});
}
function placePiece(c, r, type, x, y) {
clearGhosts();
// 加入物理棋盤
let etdrop = document.getElementById('etanidrop');
let useEl = document.createElementNS('http://www.w3.org/2000/svg', 'use');
useEl.setAttribute('href', `#tile${type}`);
useEl.setAttribute('transform', `translate(${x},${y}) scale(1,1)`);
useEl.classList.add('tiledropped');
etdrop.appendChild(useEl);
// 更新邏輯棋盤與庫存
let parts = getTileParts(c, r, type);
board.add(parts[0]);
board.add(parts[1]);
counts[turn][type]--;
// 計分規則:下藍棋得分
if (type === 0) {
scores[turn]++;
}
// 換手
turn = 1 - turn;
totalMoves++;
updateUI();
}
// 初始化
window.onload = () => {
updateUI();
};
</script>
</body>
</html>
```