一、界面布局 软件采用左右两栏布局,左侧为棋盘及信息区,右侧为垂直排列的功能按钮,整体风格古典雅致。
- 棋盘区:19×19 标准棋盘,采用立体感棋子、木质底色,并带有星标(天元、小目等)。棋盘支持点击落子,并有落点偏离提示。
- 计时器:上方显示黑方(⚫)与白方(⚪)的剩余时间,默认每方 30 分钟(可修改源码中的 INIT_TIME 常量)。当前行棋方的计时器会有高亮边框。
- 统计栏:下方显示黑方提子数、白方提子数以及当前落子手数。
- 按钮组:右侧竖向排列八个功能按钮,涵盖对局控制、棋谱管理、界面个性化等。
二、核心对局功能 1. 落子规则
- 点击棋盘交叉点即可落子,程序自动检测该位置是否为空。
- 提子:落子后自动移除被包围且无气的敌方棋子,并累加提子计数。
- 禁着点检测:
- 自杀禁止:落子后若己方棋子无气则判为非法。
- 劫争检测:禁止立即重复上一回合的全局局面(即"劫")。
- 落子合法后,切换玩家并启动对应方计时器。
2. 虚一手(Pass) 点击"虚一手"按钮表示当前玩家放弃落子,轮由对方行棋。虚手也会记录在历史中,便于连续虚手后的协商终局。3. 悔棋 点击"悔棋"可逐步回退至上一手棋前的状态(包括提子数、手数、计时器均回退)。程序内部维护完整的历史栈,确保悔棋逻辑准确。4. 认输 点击"认输"立即结束对局,弹出胜负提示,计时停止,棋盘锁定。5. 新局 重置所有状态:棋盘清空、计时重置为 30 分钟、提子与手数归零、历史清空,并由黑方先手开始计时。
三、计时规则
- 采用倒计时制,每方独立计时,当前行棋方计时递减。
- 计时器每秒更新一次,时间耗尽时自动判负(超时方输)。
- 切换玩家时计时器自动切换,游戏结束后计时停止。
四、棋谱管理
1. 导出棋谱 点击"导出棋谱"可将当前对局的落子序列保存为 JSON 文件。文件格式如下:
json复制下载{ "format": "qingstone-go", "boardSize": 19, "moves": [ { "color": "B", "row": 3, "col": 16 }, { "color": "W", "pass": true }, ... ]}每步棋记录颜色(B/W)、坐标(row, col)或虚手(pass: true)。文件名自动包含时间戳。
2. 导入棋谱 点击"导入棋谱"选择本地 JSON 文件,程序会按顺序自动落子,并实时校验每一步的合法性(如颜色顺序、禁着点等)。若棋谱非法,会提示错误并重置棋盘。
五、个性化设置
1. 棋盘颜色 点击"棋盘颜色"按钮,通过颜色选择器修改棋盘底色。线条与星标的颜色会根据底色自动加深(保持视觉对比度),实现一键换肤。
2. 背景颜色 点击"背景颜色"按钮可修改页面背景的径向渐变主色,程序自动生成对应的深色渐变,营造不同的对局氛围。
六、技术特色
-
纯前端实现:HTML + CSS + JavaScript,无任何外部依赖,可离线运行。
-
精确的围棋规则:实现了连通块气数计算、提子、劫争、自杀检测等核心算法。
-
历史与动作双记录:既支持悔棋所需的完整状态快照(history),也保留了轻量的动作序列(moveHistory)用于导入导出。
-
视觉细节:棋子使用径向渐变模拟立体感,棋盘线条粗细适中,点击时有轻微反馈(亮度变化)。
-
响应式布局:棋盘尺寸基于 Canvas 固定 900×900 像素,但通过 CSS 限制最大宽度,在不同屏幕下均可正常显示。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>围棋 · 清石</title> <style> * { box-sizing: border-box; user-select: none; } body { background: #2b5d3b; background: radial-gradient(circle at 20% 30%, #3f8654, #1e4a2f); min-height: 100vh; margin: 0; display: flex; justify-content: center; align-items: center; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; padding: 20px; transition: background 0.2s; } .go-container { background: transparent; padding: 0; border: none; box-shadow: none; width: fit-content; margin: 0 auto; } .main-layout { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; } .board-area { display: flex; flex-direction: column; align-items: center; } canvas { display: block; width: 100%; height: auto; max-width: 900px; aspect-ratio: 1 / 1; border-radius: 24px; background: #e5c8a3; box-shadow: inset 0 0 0 2px #9b7e5f, 0 20px 25px rgba(0,0,0,0.6); cursor: pointer; transition: filter 0.1s; } canvas:active { filter: brightness(0.97); } .timer-row { display: flex; justify-content: space-between; gap: 30px; width: 100%; margin-bottom: 15px; font-size: 1.4rem; font-weight: 600; color: #2d1f13; } .timer { display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 4px 12px; border-radius: 40px; transition: all 0.2s; } .timer.black-timer-active { outline: 3px solid #ffd966; background: rgba(255, 217, 102, 0.15); } .timer.white-timer-active { outline: 3px solid #ffd966; background: rgba(255, 217, 102, 0.15); } .timer span { background: #f0e0d0; padding: 4px 15px; border-radius: 40px; color: #3d2b1b; font-size: 1.3rem; font-weight: 600; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); } .stats { display: flex; gap: 25px; font-size: 1.3rem; color: #2d1f13; text-shadow: 0 1px 0 #eeddbb; margin-top: 15px; } .stats div { display: flex; align-items: center; gap: 8px; white-space: nowrap; } .stats span { background: #f0e0d0; padding: 4px 12px; border-radius: 30px; font-weight: 700; color: #3d2b1b; min-width: 45px; text-align: center; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); } .button-group-vertical { display: flex; flex-direction: column; gap: 8px; min-width: 90px; /* 稍微加宽以适应中文 */ } .go-button { background: #efe0c9; border: none; padding: 6px 0; font-size: 0.9rem; font-weight: bold; border-radius: 30px; color: #3d2b1b; box-shadow: 0 3px 0 #7a5f45, 0 4px 6px black; cursor: pointer; transition: 0.07s linear; border: 1px solid #ffefd1; letter-spacing: 0.5px; width: 100%; text-align: center; white-space: normal; line-height: 1.2; word-break: keep-all; } .go-button:hover { background: #f5ead7; } .go-button:active { transform: translateY(3px); box-shadow: 0 1px 0 #7a5f45, 0 4px 6px black; } /* 隐藏的原生文件上传按钮 + 颜色选择器 */ #importFileInput, #boardColorPicker, #bgColorPicker { display: none; } </style> </head> <body><script> (function(){ // ----- 常量 ----- const BOARD_SIZE = 19; const EMPTY = 0; const BLACK = 1; const WHITE = 2; const MARGIN = 55; const CANVAS_SIZE = 900; const INIT_TIME = 1800; // ----- 全局状态 ----- let board = []; let currentPlayer = BLACK; let prevBoard = []; // 用于劫检测 let gameOver = false; // 统计 let blackCaptures = 0; let whiteCaptures = 0; let moveCount = 0; // 计时器相关 let blackTime = INIT_TIME; let whiteTime = INIT_TIME; let timerInterval = null; // 历史记录:存储每一步之后的状态 { board, blackCaptures, whiteCaptures, moveCount, currentPlayer } let history = []; // 落子动作序列 (用于导入/导出棋谱) let moveHistory = []; // 每个元素: { color: BLACK/WHITE, row, col } 或 { color: BLACK/WHITE, pass: true } // ---------- 新增:自定义颜色变量 ---------- let boardBgColor = '#e5c8a3'; // 棋盘底色 let boardLineColor = '#5d3f28'; // 线条、星标颜色 (默认深棕) // DOM 元素 const canvas = document.getElementById('goBoard'); const ctx = canvas.getContext('2d'); const blackCapturesSpan = document.getElementById('blackCaptures'); const whiteCapturesSpan = document.getElementById('whiteCaptures'); const moveCountSpan = document.getElementById('moveCount'); const blackTimerDisplay = document.getElementById('blackTimerDisplay'); const whiteTimerDisplay = document.getElementById('whiteTimerDisplay'); // 新增:导入文件输入 & 颜色选择器 const importFileInput = document.getElementById('importFileInput'); const boardColorPicker = document.getElementById('boardColorPicker'); const bgColorPicker = document.getElementById('bgColorPicker'); // 提示函数 function setMessage(msg) { alert(msg); } // ----- 辅助函数 ----- function copyBoard(src) { return src.map(row => [...row]); } function boardsEqual(b1, b2) { for (let i = 0; i < BOARD_SIZE; i++) { for (let j = 0; j < BOARD_SIZE; j++) { if (b1[i][j] !== b2[i][j]) return false; } } return true; } // ----- 获取连通块信息 ----- function getGroupInfo(boardState, row, col, color) { if (boardState[row][col] !== color) return { points: [], libertyCount: 0 }; const visited = Array(BOARD_SIZE).fill().map(() => Array(BOARD_SIZE).fill(false)); const queue = [[row, col]]; visited[row][col] = true; const points = []; const libertySet = new Set(); while (queue.length) { const [r, c] = queue.shift(); points.push([r, c]); const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]]; for (let [dr, dc] of dirs) { const nr = r + dr, nc = c + dc; if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE) { if (boardState[nr][nc] === EMPTY) { libertySet.add(`${nr},${nc}`); } else if (boardState[nr][nc] === color && !visited[nr][nc]) { visited[nr][nc] = true; queue.push([nr, nc]); } } } } return { points, libertyCount: libertySet.size }; } // 移除无气棋子并返回移除数量 function removeDeadGroups(boardState, color) { const toRemove = []; const visited = Array(BOARD_SIZE).fill().map(() => Array(BOARD_SIZE).fill(false)); for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { if (boardState[r][c] === color && !visited[r][c]) { const { points, libertyCount } = getGroupInfo(boardState, r, c, color); for (let [pr, pc] of points) { visited[pr][pc] = true; } if (libertyCount === 0) { toRemove.push(...points); } } } } for (let [r, c] of toRemove) { boardState[r][c] = EMPTY; } return toRemove.length; } // 检查自杀 function hasSelfDestruct(boardState, color) { const visited = Array(BOARD_SIZE).fill().map(() => Array(BOARD_SIZE).fill(false)); for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { if (boardState[r][c] === color && !visited[r][c]) { const { points, libertyCount } = getGroupInfo(boardState, r, c, color); for (let [pr, pc] of points) visited[pr][pc] = true; if (libertyCount === 0) return true; } } } return false; } // ----- 计时器函数 ----- function formatTime(seconds) { if (seconds < 0) seconds = 0; const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } function updateTimerDisplay() { blackTimerDisplay.innerHTML = `⚫ ${formatTime(blackTime)}`; whiteTimerDisplay.innerHTML = `⚪ ${formatTime(whiteTime)}`; if (!gameOver) { if (currentPlayer === BLACK) { blackTimerDisplay.classList.add('black-timer-active'); whiteTimerDisplay.classList.remove('white-timer-active'); } else { whiteTimerDisplay.classList.add('white-timer-active'); blackTimerDisplay.classList.remove('black-timer-active'); } } else { blackTimerDisplay.classList.remove('black-timer-active'); whiteTimerDisplay.classList.remove('white-timer-active'); } } function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } } function timeLoss(player) { if (gameOver) return; gameOver = true; stopTimer(); const loser = (player === BLACK) ? '黑棋' : '白棋'; const winner = (player === BLACK) ? '白棋' : '黑棋'; alert(`⏰ ${loser} 超时 · ${winner} 获胜!`); updateTimerDisplay(); drawBoard(); } function startTimer(player) { if (gameOver) return; stopTimer(); timerInterval = setInterval(() => { if (gameOver) { stopTimer(); return; } if (currentPlayer === BLACK) { blackTime--; if (blackTime <= 0) { blackTime = 0; timeLoss(BLACK); } } else { whiteTime--; if (whiteTime <= 0) { whiteTime = 0; timeLoss(WHITE); } } updateTimerDisplay(); }, 1000); } // 切换玩家 function switchPlayerAndTimer(newPlayer) { currentPlayer = newPlayer; stopTimer(); if (!gameOver) { startTimer(currentPlayer); } updateStats(); updateTimerDisplay(); } // 保存当前状态到历史 (落子后调用) function pushHistory() { history.push({ board: copyBoard(board), blackCaptures: blackCaptures, whiteCaptures: whiteCaptures, moveCount: moveCount, currentPlayer: currentPlayer }); } // 从历史恢复状态 (用于悔棋) function restoreFromHistory(index) { const state = history[index]; board = copyBoard(state.board); blackCaptures = state.blackCaptures; whiteCaptures = state.whiteCaptures; moveCount = state.moveCount; currentPlayer = state.currentPlayer; if (index > 0) { prevBoard = copyBoard(history[index-1].board); } else { prevBoard = Array(BOARD_SIZE).fill().map(() => Array(BOARD_SIZE).fill(EMPTY)); } gameOver = false; stopTimer(); startTimer(currentPlayer); updateStats(); updateTimerDisplay(); drawBoard(); } // ----- 落子逻辑 ----- function tryMove(row, col) { if (gameOver) { alert('🏁 游戏已结束,请按【新局】'); return false; } if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return false; if (board[row][col] !== EMPTY) { alert('❌ 此处已有棋子'); return false; } const opponent = currentPlayer === BLACK ? WHITE : BLACK; const newBoard = copyBoard(board); newBoard[row][col] = currentPlayer; const captured = removeDeadGroups(newBoard, opponent); if (hasSelfDestruct(newBoard, currentPlayer)) { alert('⛔ 自杀禁止'); return false; } if (boardsEqual(newBoard, prevBoard)) { alert('🔄 劫争 --- 不能立即重复局面'); return false; } if (currentPlayer === BLACK) { blackCaptures += captured; } else { whiteCaptures += captured; } prevBoard = copyBoard(board); board = newBoard; moveCount++; pushHistory(); // 保存新状态到历史 // 记录动作到 moveHistory moveHistory.push({ color: currentPlayer, row: row, col: col }); const nextPlayer = opponent; switchPlayerAndTimer(nextPlayer); updateStats(); drawBoard(); return true; } // ----- 虚一手 ----- function pass() { if (gameOver) { alert('游戏已结束,请按新局'); return; } const nextPlayer = (currentPlayer === BLACK) ? WHITE : BLACK; prevBoard = copyBoard(board); pushHistory(); // 虚手也视为一步历史 (棋盘不变) moveHistory.push({ color: currentPlayer, pass: true }); switchPlayerAndTimer(nextPlayer); drawBoard(); } // ----- 悔棋 (同步moveHistory) ----- function undo() { if (gameOver) { alert('游戏已结束,无法悔棋'); return; } if (history.length < 2) { alert('无法继续悔棋'); return; } history.pop(); if (moveHistory.length > 0) { moveHistory.pop(); } const lastIndex = history.length - 1; restoreFromHistory(lastIndex); } // ----- 认输 ----- function resign() { if (gameOver) return; gameOver = true; stopTimer(); const loser = (currentPlayer === BLACK) ? '黑棋' : '白棋'; const winner = (currentPlayer === BLACK) ? '白棋' : '黑棋'; alert(`🏳️ ${loser} 认输 · ${winner} 获胜!`); updateTimerDisplay(); drawBoard(); } function resetGame() { stopTimer(); board = Array(BOARD_SIZE).fill().map(() => Array(BOARD_SIZE).fill(EMPTY)); prevBoard = copyBoard(board); currentPlayer = BLACK; gameOver = false; blackCaptures = 0; whiteCaptures = 0; moveCount = 0; blackTime = INIT_TIME; whiteTime = INIT_TIME; history = []; moveHistory = []; pushHistory(); // 初始空棋盘状态 updateStats(); drawBoard(); updateTimerDisplay(); startTimer(BLACK); } // ----- 绘制棋盘 (使用自定义颜色) ----- function drawBoard() { ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); // 使用自定义棋盘底色 ctx.fillStyle = boardBgColor; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); const step = (CANVAS_SIZE - 2 * MARGIN) / (BOARD_SIZE - 1); ctx.lineWidth = 2.2; ctx.strokeStyle = boardLineColor; // 线条颜色 for (let i = 0; i < BOARD_SIZE; i++) { const x = MARGIN + i * step; ctx.beginPath(); ctx.moveTo(x, MARGIN); ctx.lineTo(x, CANVAS_SIZE - MARGIN); ctx.stroke(); const y = MARGIN + i * step; ctx.beginPath(); ctx.moveTo(MARGIN, y); ctx.lineTo(CANVAS_SIZE - MARGIN, y); ctx.stroke(); } const stars = [3, 9, 15]; ctx.fillStyle = boardLineColor; // 星标颜色与线条一致 for (let r of stars) { for (let c of stars) { const x = MARGIN + c * step; const y = MARGIN + r * step; ctx.beginPath(); ctx.arc(x, y, step * 0.25, 0, 2 * Math.PI); ctx.fill(); } } for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { if (board[r][c] === EMPTY) continue; const x = MARGIN + c * step; const y = MARGIN + r * step; const radius = step * 0.44; ctx.shadowColor = 'rgba(0,0,0,0.6)'; ctx.shadowBlur = 12; ctx.shadowOffsetX = 4; ctx.shadowOffsetY = 4; if (board[r][c] === BLACK) { const gradient = ctx.createRadialGradient(x-6, y-6, radius*0.2, x, y, radius*1.5); gradient.addColorStop(0, '#333'); gradient.addColorStop(0.7, '#111'); gradient.addColorStop(1, '#000'); ctx.fillStyle = gradient; } else { const gradient = ctx.createRadialGradient(x-6, y-6, radius*0.3, x, y, radius*1.5); gradient.addColorStop(0, '#fefefe'); gradient.addColorStop(0.6, '#dddddd'); gradient.addColorStop(1, '#aaaaaa'); ctx.fillStyle = gradient; } ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.fill(); ctx.shadowBlur = 6; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; ctx.strokeStyle = board[r][c] === BLACK ? '#2f2f2f' : '#f0f0f0'; ctx.lineWidth = 2.2; ctx.stroke(); } } ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; } function updateStats() { blackCapturesSpan.innerText = blackCaptures; whiteCapturesSpan.innerText = whiteCaptures; moveCountSpan.innerText = moveCount; } // ----- 鼠标点击处理 ----- function handleCanvasClick(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const mouseX = (e.clientX - rect.left) * scaleX; const mouseY = (e.clientY - rect.top) * scaleY; const step = (CANVAS_SIZE - 2 * MARGIN) / (BOARD_SIZE - 1); const gridCol = Math.round((mouseX - MARGIN) / step); const gridRow = Math.round((mouseY - MARGIN) / step); if (gridRow >= 0 && gridRow < BOARD_SIZE && gridCol >= 0 && gridCol < BOARD_SIZE) { const crossX = MARGIN + gridCol * step; const crossY = MARGIN + gridRow * step; const dist = Math.hypot(mouseX - crossX, mouseY - crossY); if (dist < step * 0.6) { tryMove(gridRow, gridCol); } else { alert('⛔ 点击位置偏离交叉点'); } } else { alert('⛔ 棋盘外'); } } // ---------- 导出棋谱 ---------- function exportGame() { if (moveHistory.length === 0) { alert('没有落子记录,无法导出空棋谱'); return; } const exportMoves = moveHistory.map(m => { if (m.pass) { return { color: m.color === BLACK ? 'B' : 'W', pass: true }; } else { return { color: m.color === BLACK ? 'B' : 'W', row: m.row, col: m.col }; } }); const gameData = { format: 'qingstone-go', boardSize: BOARD_SIZE, moves: exportMoves }; const jsonStr = JSON.stringify(gameData, null, 2); const blob = new Blob([jsonStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `围棋棋谱_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.json`; a.click(); URL.revokeObjectURL(url); } // ---------- 导入棋谱 ---------- function importGameFromFile(file) { const reader = new FileReader(); reader.onload = (e) => { try { const content = e.target.result; const gameData = JSON.parse(content); if (!gameData.moves || !Array.isArray(gameData.moves) || (gameData.boardSize && gameData.boardSize !== BOARD_SIZE)) { throw new Error('棋谱格式不符或棋盘大小不为19'); } stopTimer(); resetGame(); stopTimer(); const originalAlert = window.alert; window.alert = function(){}; for (const m of gameData.moves) { const color = m.color === 'B' ? BLACK : WHITE; if (currentPlayer !== color) { throw new Error(`棋谱顺序错误:期待${currentPlayer===BLACK?'黑':'白'},但动作是${m.color}`); } if (m.pass) { pass(); } else { if (m.row === undefined || m.col === undefined) throw new Error('缺少坐标'); const success = tryMove(m.row, m.col); if (!success) throw new Error(`落子 (${m.row},${m.col}) 非法`); } } window.alert = originalAlert; startTimer(currentPlayer); updateTimerDisplay(); drawBoard(); alert('✅ 棋谱导入成功'); } catch (err) { window.alert = originalAlert || alert; alert('❌ 导入失败:' + err.message); resetGame(); } finally { importFileInput.value = ''; } }; reader.readAsText(file); } // 导入按钮:触发隐藏file input function onImportClick() { importFileInput.click(); } // ---------- 颜色工具函数:hex变暗 ---------- function darkenColor(hex, factor) { // 去除 #,解析rgb let r = parseInt(hex.slice(1,3), 16); let g = parseInt(hex.slice(3,5), 16); let b = parseInt(hex.slice(5,7), 16); r = Math.min(255, Math.max(0, Math.floor(r * factor))); g = Math.min(255, Math.max(0, Math.floor(g * factor))); b = Math.min(255, Math.max(0, Math.floor(b * factor))); return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } // 设置背景渐变 (基于选中的底色) function setBodyGradient(baseColor) { const dark = darkenColor(baseColor, 0.5); // 变暗作为渐变终点 document.body.style.background = `radial-gradient(circle at 20% 30%, ${baseColor}, ${dark})`; } // 监听文件选择 importFileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { importGameFromFile(file); } }); // ----- 新增:颜色自定义逻辑 ----- // 棋盘颜色按钮:触发颜色选择器 document.getElementById('boardColorBtn').addEventListener('click', () => { boardColorPicker.click(); }); // 棋盘颜色选择变化 boardColorPicker.addEventListener('change', (e) => { const newBase = e.target.value; boardBgColor = newBase; // 线条颜色自动变暗,保持对比 (因子0.45 接近原始对比度) boardLineColor = darkenColor(newBase, 0.4); drawBoard(); // 重绘棋盘 }); // 背景颜色按钮 document.getElementById('bgColorBtn').addEventListener('click', () => { bgColorPicker.click(); }); bgColorPicker.addEventListener('change', (e) => { const newBg = e.target.value; setBodyGradient(newBg); }); // ----- 事件绑定 ----- canvas.addEventListener('click', handleCanvasClick); document.getElementById('passBtn').addEventListener('click', pass); document.getElementById('undoBtn').addEventListener('click', undo); document.getElementById('resignBtn').addEventListener('click', resign); document.getElementById('resetBtn').addEventListener('click', resetGame); document.getElementById('exportBtn').addEventListener('click', exportGame); document.getElementById('importBtn').addEventListener('click', onImportClick); // 启动游戏 resetGame(); // 初始化背景渐变 (使用默认颜色) setBodyGradient(bgColorPicker.value); // 如果希望初始线条也由底色自动生成,可以打开下面注释: // boardLineColor = darkenColor(boardColorPicker.value, 0.4); // drawBoard(); })(); </script> </body> </html><canvas id="goBoard" width="900" height="900"></canvas>⚫ 30:00⚪ 30:00⚫ 0⚪ 0👆0<div class="button-group-vertical"> <button class="go-button" id="passBtn">虚一手</button> <button class="go-button" id="undoBtn">悔棋</button> <button class="go-button" id="resetBtn">新局</button> <button class="go-button" id="resignBtn">认输</button> <button class="go-button" id="exportBtn">导出棋谱</button> <button class="go-button" id="importBtn">导入棋谱</button> <!-- 新增两个自定义颜色按钮 --> <button class="go-button" id="boardColorBtn">棋盘颜色</button> <button class="go-button" id="bgColorBtn">背景颜色</button> </div> </div>