围棋专业版 – html代码

0 分享

围棋专业版 html代码 基于 HTML5 Canvas 开发的本地双人对弈围棋程序,界面精致、规则严谨,并提供丰富的对局辅助功能。它完全运行于浏览器中,无需安装,打开即可使用,适合棋友面对面切磋、棋谱记录与复盘。

一、界面布局软件采用左右两栏布局,左侧为棋盘及信息区,右侧为垂直排列的功能按钮,整体风格古典雅致。

  • 棋盘区: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 限制最大宽度,在不同屏幕下均可正常显示。

来源:https://www.52pojie.cn/thread-2097823-1-1.html

<!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>
<div class="go-container">
    <div class="main-layout">
        <div class="board-area">
            <div class="timer-row">
                <div class="timer" id="blackTimerDisplay">&#9899; <span>30:00</span></div>
                <div class="timer" id="whiteTimerDisplay">&#9898; <span>30:00</span></div>
            </div>
            <canvas id="goBoard" width="900" height="900"></canvas>
            <div class="stats">
                <div>&#9899; <span id="blackCaptures">0</span></div>
                <div>&#9898; <span id="whiteCaptures">0</span></div>
                <div>&#128070;<span id="moveCount">0</span></div>
            </div>
        </div>
 
        <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>
</div>
 
<!-- 隐藏的file input & 颜色选择器 -->
<input type="file" id="importFileInput" accept=".json,application/json">
<input type="color" id="boardColorPicker" value="#e5c8a3">
<input type="color" id="bgColorPicker" value="#2b5d3b">
 
<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 = `&#9899; <span>${formatTime(blackTime)}</span>`;
        whiteTimerDisplay.innerHTML = `&#9898; <span>${formatTime(whiteTime)}</span>`;
 
        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(`&#9200; ${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('&#127937; 游戏已结束,请按【新局】');
            return false;
        }
        if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return false;
        if (board[row][col] !== EMPTY) {
            alert('&#10060; 此处已有棋子');
            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('&#9940; 自杀禁止');
            return false;
        }
 
        if (boardsEqual(newBoard, prevBoard)) {
            alert('&#128260; 劫争 — 不能立即重复局面');
            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(`&#127987;&#65039; ${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('&#9940; 点击位置偏离交叉点');
            }
        } else {
            alert('&#9940; 棋盘外');
        }
    }
 
    // ---------- 导出棋谱 ----------
    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('&#9989; 棋谱导入成功');
 
            } catch (err) {
                window.alert = originalAlert || alert;
                alert('&#10060; 导入失败:' + 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>

围棋专业版.zip