diff --git a/README.md b/README.md new file mode 100644 index 0000000..dce2bb8 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# 推子棋(网页人机版) + +## 本地运行 + +1. 进入项目目录: + ```bash + cd /workspace/Script + ``` +2. 启动静态服务器(任选其一): + ```bash + python3 -m http.server 4173 + ``` +3. 浏览器打开: + ``` + http://127.0.0.1:4173 + ``` + +> 不建议直接双击 `index.html` 用 `file://` 打开,建议始终通过 `http://` 访问。 + +## 常见问题:界面能看到但不能落子 + +- 请确认状态栏显示:`落子阶段 | 当前:玩家(黑)`。 +- 你必须在**交叉点**点击(圆点位置),不是在线段中间点击。 +- 如果在某些内置预览器(例如 IDE 的只读预览)里无法交互,请改用系统浏览器打开上述 `http://127.0.0.1:4173`。 +- 本项目已为点击层加了 `pointer-events`/`z-index` 兼容处理。 + +## 规则实现说明(与你确认后的版本) + +- 棋盘 16 个落子点(4x4 交叉点)。 +- 单推 / 双推 / 阵列:若同一步出现多个可触发规则,只能选择一个执行。 +- 阵列按“每一行/列、每一方”只能触发一次记录。 +- 下满后进入双方拔子,再进入移动阶段。 +- 胜负:对方无子即胜。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..0554f9b --- /dev/null +++ b/index.html @@ -0,0 +1,39 @@ + + + + + + 推子棋(人机) + + + +
+
+

推子棋(3x3 方格 / 16 交叉点)

+
+
+
+ + +
+
+ + +
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..a048650 --- /dev/null +++ b/script.js @@ -0,0 +1,842 @@ +const SIZE = 4; +const EMPTY = 0; +const HUMAN = 1; +const AI = 2; +const AI_TURN_DELAY = 900; +const AI_STEP_DELAY = 850; + +const state = { + board: Array.from({ length: SIZE }, () => Array(SIZE).fill(EMPTY)), + current: HUMAN, + phase: "placement", // placement | preMoveRemoval | movement + preMoveRemovalsLeft: 0, + preMoveRemover: null, + lastPlacementPlayer: null, + winner: null, + pendingChoices: null, + selectedPiece: null, + usedArrays: { + [HUMAN]: new Set(), + [AI]: new Set(), + }, + log: [], + history: [], + hadPiece: { [HUMAN]: false, [AI]: false }, + pendingAction: null, +}; + +const boardEl = document.getElementById("board"); +const statusEl = document.getElementById("status"); +const logEl = document.getElementById("log"); +const choiceBox = document.getElementById("choiceBox"); +const choicesEl = document.getElementById("choices"); + +document.getElementById("restartBtn").addEventListener("click", resetGame); +document.getElementById("undoBtn").addEventListener("click", undo); + +function cloneState() { + return { + board: state.board.map((r) => [...r]), + current: state.current, + phase: state.phase, + preMoveRemovalsLeft: state.preMoveRemovalsLeft, + preMoveRemover: state.preMoveRemover, + lastPlacementPlayer: state.lastPlacementPlayer, + winner: state.winner, + selectedPiece: state.selectedPiece ? [...state.selectedPiece] : null, + pendingChoices: null, + usedArrays: { + [HUMAN]: new Set([...state.usedArrays[HUMAN]]), + [AI]: new Set([...state.usedArrays[AI]]), + }, + log: [...state.log], + hadPiece: { [HUMAN]: state.hadPiece[HUMAN], [AI]: state.hadPiece[AI] }, + pendingAction: null, + }; +} + +function saveHistory() { + state.history.push(cloneState()); + if (state.history.length > 120) state.history.shift(); +} + +function undo() { + if (!state.history.length || state.winner) return; + const prev = state.history.pop(); + state.board = prev.board; + state.current = prev.current; + state.phase = prev.phase; + state.preMoveRemovalsLeft = prev.preMoveRemovalsLeft; + state.preMoveRemover = prev.preMoveRemover; + state.lastPlacementPlayer = prev.lastPlacementPlayer; + state.winner = prev.winner; + state.usedArrays = prev.usedArrays; + state.log = prev.log; + state.hadPiece = prev.hadPiece; + state.pendingChoices = null; + state.pendingAction = null; + state.selectedPiece = null; + render(); +} + +function resetGame() { + state.board = Array.from({ length: SIZE }, () => Array(SIZE).fill(EMPTY)); + state.current = HUMAN; + state.phase = "placement"; + state.preMoveRemovalsLeft = 0; + state.preMoveRemover = null; + state.lastPlacementPlayer = null; + state.winner = null; + state.pendingChoices = null; + state.pendingAction = null; + state.selectedPiece = null; + state.hadPiece[HUMAN] = false; + state.hadPiece[AI] = false; + state.usedArrays[HUMAN].clear(); + state.usedArrays[AI].clear(); + state.log = ["新对局开始,黑先。"]; + state.history = []; + render(); +} + +function createBoard() { + boardEl.innerHTML = ""; + for (let i = 0; i < SIZE; i++) { + const h = document.createElement("div"); + h.className = "grid-line h"; + h.style.top = `${(i * 100) / (SIZE - 1)}%`; + boardEl.appendChild(h); + + const v = document.createElement("div"); + v.className = "grid-line v"; + v.style.left = `${(i * 100) / (SIZE - 1)}%`; + boardEl.appendChild(v); + } + + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + const btn = document.createElement("button"); + btn.className = "node"; + btn.dataset.r = r; + btn.dataset.c = c; + btn.style.left = `${(c * 100) / (SIZE - 1)}%`; + btn.style.top = `${(r * 100) / (SIZE - 1)}%`; + btn.addEventListener("click", () => handleNodeClick(r, c)); + boardEl.appendChild(btn); + } + } +} + +function inRange(r, c) { + return r >= 0 && r < SIZE && c >= 0 && c < SIZE; +} +function other(p) { return p === HUMAN ? AI : HUMAN; } + +function countPieces(player, board = state.board) { + let n = 0; + for (const row of board) for (const v of row) if (v === player) n++; + return n; +} + +function boardFull(board = state.board) { + return board.every((row) => row.every((v) => v !== EMPTY)); +} + +function movablePieces(player, board = state.board) { + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + const res = []; + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + if (board[r][c] !== player) continue; + for (const [dr, dc] of dirs) { + const nr = r + dr, nc = c + dc; + if (inRange(nr, nc) && board[nr][nc] === EMPTY) { + res.push([r, c]); + break; + } + } + } + } + return res; +} + +function evaluateWin() { + const humanCount = countPieces(HUMAN); + const aiCount = countPieces(AI); + + if (state.hadPiece[HUMAN] && (humanCount === 0 || (state.phase === "movement" && humanCount === 1))) state.winner = AI; + if (state.hadPiece[AI] && (aiCount === 0 || (state.phase === "movement" && aiCount === 1))) state.winner = HUMAN; +} + +function getLine(pos, horizontal) { + if (horizontal) { + const r = pos[0]; + return Array.from({ length: SIZE }, (_, c) => [r, c]); + } + const c = pos[1]; + return Array.from({ length: SIZE }, (_, r) => [r, c]); +} + +function lineKey(horizontal, idx) { + return `${horizontal ? "R" : "C"}${idx}`; +} + +function detectRulesAt(board, usedArrays, player, pos) { + const enemy = other(player); + const choices = []; + + for (const horizontal of [true, false]) { + const line = getLine(pos, horizontal); + const vals = line.map(([r, c]) => board[r][c]); + const idx = horizontal ? pos[0] : pos[1]; + + // 阵列: 4子同色且该线尚未触发 + if (vals.every((v) => v === player) && !usedArrays[player].has(lineKey(horizontal, idx))) { + const enemies = []; + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + if (board[r][c] === enemy) enemies.push([r, c]); + } + } + if (enemies.length >= 2) { + choices.push({ type: "array", horizontal, idx, take: 2, candidates: enemies }); + } + } + + // 单推: 线内仅3子,己方两子相邻且敌方一子紧邻,另外一格为空 + const occupied = vals.filter((v) => v !== EMPTY).length; + if (occupied === 3) { + const patterns = [ + [enemy, player, player, EMPTY], + [EMPTY, player, player, enemy], + [player, player, enemy, EMPTY], + [EMPTY, enemy, player, player], + ]; + for (const ptn of patterns) { + if (vals.every((v, i) => v === ptn[i])) { + const enemyIdx = ptn.indexOf(enemy); + choices.push({ + type: "single", + horizontal, + idx, + targets: [line[enemyIdx]], + }); + } + } + } + + // 双推: [player, player, enemy, enemy] / reverse + const doubles = [ + [player, player, enemy, enemy], + [enemy, enemy, player, player], + ]; + for (const ptn of doubles) { + if (vals.every((v, i) => v === ptn[i])) { + const targets = []; + ptn.forEach((v, i) => { if (v === enemy) targets.push(line[i]); }); + choices.push({ type: "double", horizontal, idx, targets }); + } + } + } + + // 同类可能重复,去重 + const uniq = []; + const seen = new Set(); + for (const ch of choices) { + const k = `${ch.type}-${ch.horizontal}-${ch.idx}-${(ch.targets || []).flat().join(",")}`; + if (!seen.has(k)) { + seen.add(k); + uniq.push(ch); + } + } + return uniq; +} + +function removePieces(board, positions) { + for (const [r, c] of positions) board[r][c] = EMPTY; +} + +function applyRuleChoice(chosen, actor, board = state.board, used = state.usedArrays) { + if (!chosen) return; + if (chosen.type === "array") { + used[actor].add(lineKey(chosen.horizontal, chosen.idx)); + removePieces(board, chosen.selectedTargets); + log(`${nameOf(actor)}发动阵列,吃掉2子。`); + } else if (chosen.type === "double") { + removePieces(board, chosen.targets); + log(`${nameOf(actor)}发动双推,吃掉2子。`); + } else if (chosen.type === "single") { + removePieces(board, chosen.targets); + log(`${nameOf(actor)}发动单推,吃掉1子。`); + } +} + +function nameOf(player) { return player === HUMAN ? "玩家(黑)" : "电脑(白)"; } + +function log(msg) { + state.log.unshift(`• ${msg}`); + state.log = state.log.slice(0, 80); +} + +function showRuleChoices() { + choiceBox.classList.remove("hidden"); + choicesEl.innerHTML = ""; + + const info = document.createElement("div"); + info.className = "choice-info"; + if (!state.pendingAction) { + info.textContent = ""; + choicesEl.appendChild(info); + return; + } + + if (state.pendingAction.mode === "rule-select") { + info.textContent = "检测到多条规则:请点击棋盘上闪烁提示(可吃对方子或阵列己方线)后再确认。"; + const confirmBtn = document.createElement("button"); + confirmBtn.className = "choice"; + confirmBtn.textContent = "确认执行所选规则"; + confirmBtn.disabled = !state.pendingAction.selectedOption; + confirmBtn.onclick = () => confirmPendingRule(); + + const skipBtn = document.createElement("button"); + skipBtn.className = "choice"; + skipBtn.textContent = "不发动吃子"; + skipBtn.onclick = () => { + const after = state.pendingAction.after; + state.pendingAction = null; + state.pendingChoices = null; + hideChoices(); + after(); + render(); + }; + choicesEl.append(info, confirmBtn, skipBtn); + return; + } + + if (state.pendingAction.mode === "array-target") { + info.textContent = "阵列:请点击闪烁的敌方棋子选择 2 枚,再确认。"; + const picked = document.createElement("div"); + picked.className = "choice-picked"; + picked.textContent = `已选择:${state.pendingAction.selectedTargets.length}/2`; + + const confirmBtn = document.createElement("button"); + confirmBtn.className = "choice"; + confirmBtn.textContent = "确认吃子"; + confirmBtn.disabled = state.pendingAction.selectedTargets.length !== 2; + confirmBtn.onclick = () => finalizeArraySelection(); + + choicesEl.append(info, picked, confirmBtn); + } +} + +function hideChoices() { + choiceBox.classList.add("hidden"); + choicesEl.innerHTML = ""; +} + +function describeRule(op) { + if (op.type === "array") return `阵列(本线首次)并任意吃2子`; + if (op.type === "double") return `双推吃2子`; + return "单推吃1子"; +} + +function posEq(a, b) { + return a[0] === b[0] && a[1] === b[1]; +} + +function optionHotspots(op) { + if (op.type === "array") { + const line = op.horizontal + ? Array.from({ length: SIZE }, (_, c) => [op.idx, c]) + : Array.from({ length: SIZE }, (_, r) => [r, op.idx]); + return line; + } + return op.targets; +} + +function optionForClick(r, c) { + if (!state.pendingAction || state.pendingAction.mode !== "rule-select") return null; + for (const op of state.pendingAction.options) { + if (optionHotspots(op).some((p) => p[0] === r && p[1] === c)) return op; + } + return null; +} + +function confirmPendingRule() { + const op = state.pendingAction?.selectedOption; + if (!op) return; + if (op.type === "array") { + state.pendingAction = { + mode: "array-target", + option: op, + selectedTargets: [], + after: state.pendingAction.after, + }; + showRuleChoices(); + render(); + return; + } + const after = state.pendingAction.after; + applyRuleChoice(op, HUMAN); + evaluateWin(); + state.pendingAction = null; + state.pendingChoices = null; + hideChoices(); + after(); + render(); +} + +function finalizeArraySelection() { + const ctx = state.pendingAction; + if (!ctx || ctx.mode !== "array-target" || ctx.selectedTargets.length !== 2) return; + const chosen = JSON.parse(JSON.stringify(ctx.option)); + chosen.selectedTargets = ctx.selectedTargets; + const after = ctx.after; + applyRuleChoice(chosen, HUMAN); + evaluateWin(); + state.pendingAction = null; + state.pendingChoices = null; + hideChoices(); + after(); + render(); +} + +function handlePendingActionClick(r, c) { + const pa = state.pendingAction; + if (!pa) return false; + + if (pa.mode === "rule-select") { + const op = optionForClick(r, c); + if (!op) return true; + pa.selectedOption = op; + log(`已选择规则:${describeRule(op)}`); + showRuleChoices(); + render(); + return true; + } + + if (pa.mode === "array-target") { + if (!pa.option.candidates.some((p) => p[0] === r && p[1] === c)) return true; + const idx = pa.selectedTargets.findIndex((p) => p[0] === r && p[1] === c); + if (idx >= 0) { + pa.selectedTargets.splice(idx, 1); + } else if (pa.selectedTargets.length < 2) { + pa.selectedTargets.push([r, c]); + } + showRuleChoices(); + render(); + return true; + } + + return false; +} + + +function handleNodeClick(r, c) { + if (state.winner || state.current !== HUMAN) return; + if (state.pendingAction) { + handlePendingActionClick(r, c); + return; + } + if (state.pendingChoices) return; + if (state.phase === "placement") handlePlacement(r, c, HUMAN); + else if (state.phase === "preMoveRemoval") handlePreMoveRemoval(r, c, HUMAN); + else handleMovementClick(r, c, HUMAN); +} + +function handlePlacement(r, c, player) { + if (state.board[r][c] !== EMPTY) return; + saveHistory(); + state.board[r][c] = player; + state.hadPiece[player] = true; + state.lastPlacementPlayer = player; + log(`${nameOf(player)}落子 (${r + 1},${c + 1})。`); + postActionRules(player, [r, c], () => { + evaluateWin(); + if (state.winner) return endTurn(); + + if (boardFull()) { + state.phase = "preMoveRemoval"; + state.preMoveRemovalsLeft = 2; + state.preMoveRemover = player; + state.current = player; + log("棋盘下满,进入拔子阶段。最后落子方先拔。"); + render(); + if (state.current === AI) setTimeout(aiTurn, AI_TURN_DELAY); + return; + } + state.current = other(player); + render(); + if (state.current === AI) setTimeout(aiTurn, AI_TURN_DELAY); + }); +} + +function postActionRules(player, pos, after) { + const options = detectRulesAt(state.board, state.usedArrays, player, pos); + if (!options.length) return after(); + + if (player === HUMAN) { + // 仅一条规则时直接执行,不再二次确认 + if (options.length === 1) { + const op = options[0]; + if (op.type === "array") { + state.pendingChoices = options; + state.pendingAction = { mode: "array-target", option: op, selectedTargets: [], after }; + showRuleChoices(); + render(); + return; + } + applyRuleChoice(op, player); + evaluateWin(); + after(); + render(); + return; + } + + state.pendingChoices = options; + state.pendingAction = { + mode: "rule-select", + options, + selectedOption: null, + after, + }; + showRuleChoices(); + render(); + return; + } + + const pick = chooseBestRuleOption(options, player); + if (pick?.type === "array") { + pick.selectedTargets = pick.candidates + .sort((a, b) => pieceValuePos(other(player), b) - pieceValuePos(other(player), a)) + .slice(0, 2); + } + applyRuleChoice(pick, player); + evaluateWin(); + after(); +} + +function chooseBestRuleOption(options, player) { + if (!options?.length) return null; + let best = null; + let bestScore = -1e9; + for (const op of options) { + let score = 0; + if (op.type === "double") score += 8; + if (op.type === "single") score += 4; + if (op.type === "array") score += 7; + const targets = op.targets || op.candidates || []; + score += targets.reduce((s, p) => s + pieceValuePos(other(player), p), 0); + if (score > bestScore) { + bestScore = score; + best = JSON.parse(JSON.stringify(op)); + } + } + return best; +} + +function pieceValuePos(player, pos) { + const [r, c] = pos; + const center = Math.abs(r - 1.5) + Math.abs(c - 1.5); + return 5 - center + (player === HUMAN ? 0.2 : 0.1); +} + +function handlePreMoveRemoval(r, c, player) { + const enemy = other(player); + if (state.board[r][c] !== enemy) return; + saveHistory(); + state.board[r][c] = EMPTY; + state.preMoveRemovalsLeft -= 1; + log(`${nameOf(player)}在拔子阶段移除敌子 (${r + 1},${c + 1})。`); + evaluateWin(); + if (state.winner) return endTurn(); + + if (state.preMoveRemovalsLeft > 0) { + state.current = other(player); + } else { + state.phase = "movement"; + state.current = state.preMoveRemover; + log("进入移动阶段。可上下左右一步移动到空位。"); + } + render(); + if (state.current === AI && !state.winner) setTimeout(aiTurn, AI_TURN_DELAY); +} + +function handleMovementClick(r, c, player) { + if (!state.selectedPiece) { + if (state.board[r][c] !== player) return; + state.selectedPiece = [r, c]; + render(); + return; + } + const [sr, sc] = state.selectedPiece; + if (sr === r && sc === c) { + state.selectedPiece = null; + render(); + return; + } + if (!isAdjacent(sr, sc, r, c) || state.board[r][c] !== EMPTY) return; + doMove(player, [sr, sc], [r, c]); +} + +function isAdjacent(r1, c1, r2, c2) { + return Math.abs(r1 - r2) + Math.abs(c1 - c2) === 1; +} + +function doMove(player, from, to) { + saveHistory(); + state.selectedPiece = null; + state.board[from[0]][from[1]] = EMPTY; + state.board[to[0]][to[1]] = player; + log(`${nameOf(player)}移动 ${fmt(from)} -> ${fmt(to)}。`); + postActionRules(player, to, () => { + evaluateWin(); + if (state.winner) return endTurn(); + state.current = other(player); + render(); + if (state.current === AI) setTimeout(aiTurn, AI_TURN_DELAY); + }); +} + +function fmt([r, c]) { return `(${r + 1},${c + 1})`; } + +function aiTurn() { + if (state.winner || state.current !== AI || state.pendingChoices) return; + if (state.phase === "placement") return aiPlacement(); + if (state.phase === "preMoveRemoval") return aiRemove(); + return aiMovement(); +} + +function aiPlacement() { + const moves = []; + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + if (state.board[r][c] === EMPTY) moves.push([r, c]); + } + } + const best = searchBestAction(state, 2, moves.map((p) => ({ kind: "place", pos: p }))); + handlePlacement(best.pos[0], best.pos[1], AI); +} + +function aiRemove() { + const enemyPos = []; + for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) if (state.board[r][c] === HUMAN) enemyPos.push([r, c]); + const best = enemyPos.sort((a, b) => pieceValuePos(HUMAN, b) - pieceValuePos(HUMAN, a))[0]; + handlePreMoveRemoval(best[0], best[1], AI); +} + +function aiMovement() { + const mineMovable = movablePieces(AI); + if (!mineMovable.length) { + // 堵住时:先移除任意敌子,再延迟移动一子,方便观察步骤 + const enemyPos = []; + for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) if (state.board[r][c] === HUMAN) enemyPos.push([r, c]); + if (!enemyPos.length) return; + const victim = enemyPos.sort((a, b) => pieceValuePos(HUMAN, b) - pieceValuePos(HUMAN, a))[0]; + saveHistory(); + state.board[victim[0]][victim[1]] = EMPTY; + log(`电脑被堵住,移除敌子 ${fmt(victim)} 后继续走。`); + evaluateWin(); + render(); + if (state.winner) return; + + setTimeout(() => { + if (state.winner || state.current !== AI || state.phase !== "movement") return; + const delayedActions = generateMoveActions(state.board, AI); + if (!delayedActions.length) { + state.current = HUMAN; + render(); + return; + } + const delayedBest = searchBestAction(state, 2, delayedActions); + doMove(AI, delayedBest.from, delayedBest.to); + }, AI_STEP_DELAY); + return; + } + + const actions = generateMoveActions(state.board, AI); + if (!actions.length) { + state.current = HUMAN; + render(); + return; + } + const best = searchBestAction(state, 2, actions); + setTimeout(() => { + if (state.winner || state.current !== AI || state.phase !== "movement") return; + doMove(AI, best.from, best.to); + }, AI_STEP_DELAY); +} + +function generateMoveActions(board, player) { + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + const out = []; + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + if (board[r][c] !== player) continue; + for (const [dr, dc] of dirs) { + const nr = r + dr, nc = c + dc; + if (inRange(nr, nc) && board[nr][nc] === EMPTY) out.push({ kind: "move", from: [r, c], to: [nr, nc] }); + } + } + } + return out; +} + +function searchBestAction(rootState, depth, actions) { + let best = actions[0]; + let bestScore = -1e9; + for (const action of actions) { + const sim = simulateAction(rootState, AI, action); + const score = minimax(sim, depth - 1, false, -1e9, 1e9); + if (score > bestScore) { + bestScore = score; + best = action; + } + } + return best; +} + +function minimax(sim, depth, maximizing, alpha, beta) { + if (depth === 0 || sim.winner) return evaluateSim(sim); + const player = maximizing ? AI : HUMAN; + const actions = sim.phase === "placement" + ? generatePlacementActions(sim.board) + : generateMoveActions(sim.board, player); + if (!actions.length) return evaluateSim(sim); + + if (maximizing) { + let v = -1e9; + for (const a of actions) { + const s2 = simulateAction(sim, player, a); + v = Math.max(v, minimax(s2, depth - 1, false, alpha, beta)); + alpha = Math.max(alpha, v); + if (beta <= alpha) break; + } + return v; + } + let v = 1e9; + for (const a of actions) { + const s2 = simulateAction(sim, player, a); + v = Math.min(v, minimax(s2, depth - 1, true, alpha, beta)); + beta = Math.min(beta, v); + if (beta <= alpha) break; + } + return v; +} + +function generatePlacementActions(board) { + const out = []; + for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) if (board[r][c] === EMPTY) out.push({ kind: "place", pos: [r, c] }); + return out; +} + +function simulateAction(src, player, action) { + const sim = { + board: src.board.map((r) => [...r]), + usedArrays: { + [HUMAN]: new Set([...src.usedArrays[HUMAN]]), + [AI]: new Set([...src.usedArrays[AI]]), + }, + phase: src.phase, + winner: null, + }; + + let pos; + if (action.kind === "place") { + sim.board[action.pos[0]][action.pos[1]] = player; + pos = action.pos; + } else { + sim.board[action.from[0]][action.from[1]] = EMPTY; + sim.board[action.to[0]][action.to[1]] = player; + pos = action.to; + } + + const opts = detectRulesAt(sim.board, sim.usedArrays, player, pos); + const best = chooseBestRuleOption(opts, player); + if (best?.type === "array") best.selectedTargets = best.candidates.slice(0, 2); + if (best) { + if (best.type === "array") sim.usedArrays[player].add(lineKey(best.horizontal, best.idx)); + removePieces(sim.board, best.selectedTargets || best.targets); + } + const enemyCount = countPieces(other(player), sim.board); + if (enemyCount === 0 || (sim.phase === "movement" && enemyCount === 1)) sim.winner = player; + return sim; +} + +function evaluateSim(sim) { + if (sim.winner === AI) return 999; + if (sim.winner === HUMAN) return -999; + const my = countPieces(AI, sim.board); + const op = countPieces(HUMAN, sim.board); + const mobility = generateMoveActions(sim.board, AI).length - generateMoveActions(sim.board, HUMAN).length; + return (my - op) * 18 + mobility * 1.2; +} + +function endTurn() { + render(); +} + +function render() { + hideChoicesIfInactive(); + renderNodes(); + renderStatus(); + logEl.innerHTML = state.log.join("
"); +} + +function hideChoicesIfInactive() { + if (!state.pendingChoices && !state.pendingAction) hideChoices(); +} + +function renderStatus() { + if (state.winner) { + statusEl.textContent = state.winner === HUMAN ? "你赢了!" : "电脑获胜!"; + return; + } + const phaseText = { + placement: "落子阶段", + preMoveRemoval: "拔子阶段", + movement: "移动阶段", + }[state.phase]; + statusEl.textContent = `${phaseText} | 当前:${state.current === HUMAN ? "玩家(黑)" : "电脑(白)"}`; +} + +function renderNodes() { + for (const btn of boardEl.querySelectorAll(".node")) { + const r = Number(btn.dataset.r), c = Number(btn.dataset.c); + btn.innerHTML = ""; + if (state.selectedPiece && state.selectedPiece[0] === r && state.selectedPiece[1] === c) { + btn.style.outline = "3px solid #d97706"; + } else { + btn.style.outline = "none"; + } + const v = state.board[r][c]; + if (v !== EMPTY) { + const piece = document.createElement("div"); + piece.className = `piece ${v === HUMAN ? "black" : "white"}`; + + if (state.pendingAction?.mode === "rule-select") { + const isHot = state.pendingAction.options.some((op) => optionHotspots(op).some((p) => p[0] === r && p[1] === c)); + if (isHot) piece.classList.add("hint"); + + const selected = state.pendingAction.selectedOption; + if (selected && optionHotspots(selected).some((p) => p[0] === r && p[1] === c)) { + piece.classList.add("hint-selected"); + } + } + + if (state.pendingAction?.mode === "array-target") { + if (state.pendingAction.option.candidates.some((p) => p[0] === r && p[1] === c)) { + piece.classList.add("hint"); + } + if (state.pendingAction.selectedTargets.some((p) => p[0] === r && p[1] === c)) { + piece.classList.add("hint-selected"); + } + } + + btn.appendChild(piece); + } + } +} + +createBoard(); +resetGame(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f4eeb3f --- /dev/null +++ b/styles.css @@ -0,0 +1,169 @@ +:root { + --size: min(72vmin, 620px); +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + color: #1f2937; + background: radial-gradient(circle at 22% 8%, #f5f2eb 0%, #ebe5d9 58%, #e2d9c9 100%); +} +.layout { + display: grid; + grid-template-columns: minmax(360px, 1fr) 340px; + gap: 18px; + max-width: 1220px; + margin: 0 auto; + padding: 16px; +} +.board-wrap { + background: #f7f5ef; + border-radius: 14px; + box-shadow: 0 8px 26px rgba(0,0,0,.12); + padding: 14px; +} +.status { min-height: 28px; font-weight: 700; } +.board { + width: var(--size); + height: var(--size); + margin: 8px auto; + position: relative; + border-radius: 12px; + border: 3px solid #7d5a2d; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.35), + inset 0 -8px 18px rgba(93, 61, 27, .22), + 0 12px 24px rgba(67, 45, 20, .25); + background: + linear-gradient(145deg, rgba(255,255,255,.22), rgba(0,0,0,.06)), + repeating-linear-gradient(102deg, + rgba(122, 82, 40, .10) 0px, + rgba(122, 82, 40, .10) 2px, + rgba(206, 164, 109, .06) 2px, + rgba(206, 164, 109, .06) 8px), + linear-gradient(135deg, #eed6ab 0%, #e2c290 48%, #d4a76e 100%); +} +.grid-line { + position: absolute; + background: #5a3f21; + opacity: 0.75; +} + +.grid-line { + pointer-events: none; +} +.node::after { + pointer-events: none; +} +.node { + z-index: 2; + touch-action: manipulation; +} +.grid-line.h { height: 2px; width: calc(100% - 72px); left: 36px; } +.grid-line.v { width: 2px; height: calc(100% - 72px); top: 36px; } +.node { + position: absolute; + width: 52px; + height: 52px; + border-radius: 50%; + transform: translate(-50%, -50%); + border: none; + background: transparent; + cursor: pointer; +} +.node::after { + content: ""; + position: absolute; + inset: 22px; + border-radius: 50%; + background: rgba(40,30,20,.2); +} +.piece { + position: absolute; + inset: 7px; + border-radius: 50%; + box-shadow: + inset -6px -8px 12px rgba(0,0,0,.32), + inset 8px 10px 14px rgba(255,255,255,.18), + 0 4px 8px rgba(0,0,0,.35); +} +.piece.black { + background: + radial-gradient(circle at 30% 24%, rgba(255,255,255,.34) 0 18%, transparent 36%), + radial-gradient(circle at 72% 78%, rgba(0,0,0,.35) 0 26%, transparent 44%), + repeating-linear-gradient(32deg, #252525 0 5px, #151515 5px 10px), + linear-gradient(150deg, #3a3a3a, #0c0c0c); +} +.piece.white { + background: + radial-gradient(circle at 30% 24%, rgba(255,255,255,.98) 0 22%, transparent 40%), + radial-gradient(circle at 72% 78%, rgba(115,115,115,.18) 0 30%, transparent 48%), + repeating-linear-gradient(34deg, #f5f5f5 0 6px, #dcdcdc 6px 12px), + linear-gradient(150deg, #ffffff, #c7c7c7); +} +.piece.removing { animation: fadePop .45s ease forwards; } +@keyframes fadePop { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(.1); } +} +.controls { display: flex; gap: 10px; justify-content: center; } +button { + padding: 9px 16px; + border: 1px solid #7c5f36; + border-radius: 8px; + cursor: pointer; + background: linear-gradient(180deg, #f7edd8, #e2c595); +} +.panel { + background: #faf8f1; + border-radius: 14px; + box-shadow: 0 8px 24px rgba(0,0,0,.08); + padding: 14px; +} +.choice-box.hidden { display: none; } +.choice { + width: 100%; + margin: 6px 0; + text-align: left; + animation: blink 1s infinite; +} +@keyframes blink { 50% { filter: brightness(1.14); } } +.log { + margin-top: 12px; + height: 300px; + overflow: auto; + font-size: 14px; + line-height: 1.35; + background: #fff; + border: 1px solid #e0d8cb; + border-radius: 8px; + padding: 8px; +} +@media (max-width: 980px) { + .layout { grid-template-columns: 1fr; } + .panel { order: -1; } +} + + +.choice-info { + font-size: 14px; + color: #4b5563; + margin: 6px 0; +} +.choice-picked { + font-weight: 700; + margin: 6px 0; +} +.piece.hint { + animation: pulseHint 0.9s infinite; + outline: 2px solid rgba(250, 204, 21, 0.9); + outline-offset: -2px; +} +.piece.hint-selected { + animation: none; + outline: 3px solid #f97316; + box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.25), inset -6px -8px 12px rgba(0,0,0,.32), inset 8px 10px 14px rgba(255,255,255,.18), 0 4px 8px rgba(0,0,0,.35); +} +@keyframes pulseHint { + 50% { transform: scale(1.06); filter: brightness(1.15); } +}