Files
Gobang-Game/docs/superpowers/plans/2026-05-30-gobang-v2-implementation.md
2026-05-30 23:44:45 +08:00

95 KiB
Raw Permalink Blame History

Gobang v2.0 Rust 重写 — 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 将 Gobang(五子棋)从 C + IUP + CMake 全面重写为 Rust + Tauri + React + TypeScript 架构。

Architecture: Cargo workspace 两 cratecore/gui)加 React 前端。core 是纯 Rust 库(零 GUI 依赖),gui 是 Tauri 薄命令层,src/ 是 React TypeScript strict 模式前端。数据流:React → Tauri IPC → gui/commands → core API。

Tech Stack: Rust edition 2021, Tauri 2.x, React 19, TypeScript strict, Vite, Zustand, i18next, renet

源参考: 读取 D:\Code\doing_exercises\programs\PathEditor 项目的对应文件获取完整代码模板。所有开源文件(LICENSE、CHANGELOG.md、CODE_OF_CONDUCT.md、CONTRIBUTING.md、SECURITY.md)均以 PathEditor 对应文件为基础,替换项目名称和上下文。


文件结构

Gobang/
├── core/
│   ├── Cargo.toml
│   └── src/
│       ├── lib.rs              # pub mod 声明, re-exports
│       ├── types.rs            # Position, Color, CellState, Move, GameResult
│       ├── board.rs            # Board 结构体, place, check_win, undo, get_candidate_moves
│       ├── rules.rs            # is_forbidden, 禁手模式检测
│       ├── ai/
│       │   ├── mod.rs          # AiEngine trait, AiFactory
│       │   ├── evaluate.rs     # 棋形评分函数 (活三/冲四/连五等)
│       │   └── search.rs       # AlphaBetaAi: Alpha-Beta 剪枝 + 迭代加深
│       ├── record.rs           # GameRecord serde, save/load JSON
│       ├── network.rs          # NetworkSession, GameMessage, renet 封装
│       └── llm.rs              # LlmAi: reqwest HTTP client, prompt 构建
├── gui/
│   ├── Cargo.toml
│   ├── tauri.conf.json
│   ├── build.rs
│   ├── icons/                  # 应用图标 (tauri icon 生成)
│   └── src/
│       ├── main.rs             # Tauri 入口
│       ├── lib.rs              # setup(), AppState, GameMode 枚举
│       └── commands.rs         # 所有 #[tauri::command] 函数
├── src/                        # React 前端
│   ├── core/
│   │   ├── types.ts            # 前端类型定义 (镜像 Rust 类型)
│   │   └── constants.ts        # 棋盘常量
│   ├── store/
│   │   └── gameStore.ts        # Zustand store
│   ├── hooks/
│   │   ├── useGame.ts          # 游戏逻辑 hook
│   │   └── useTimer.ts         # 计时器 hook
│   ├── components/
│   │   ├── board/
│   │   │   ├── BoardCanvas.tsx # Canvas 棋盘组件
│   │   │   └── board-renderer.ts # 纯函数: 绘制棋盘线/棋子/高亮
│   │   ├── menu/
│   │   │   ├── MainMenu.tsx    # 主菜单导航
│   │   │   ├── LocalGameSetup.tsx
│   │   │   ├── AiGameSetup.tsx
│   │   │   ├── OnlineSetup.tsx
│   │   │   └── LoadReplay.tsx
│   │   ├── game/
│   │   │   ├── GameView.tsx    # 对局主视图
│   │   │   ├── GameInfo.tsx    # 状态栏
│   │   │   ├── TimerDisplay.tsx
│   │   │   └── GameControls.tsx
│   │   └── replay/
│   │       ├── ReplayView.tsx
│   │       ├── StepSlider.tsx
│   │       └── ReplayControls.tsx
│   ├── i18n/
│   │   ├── index.ts
│   │   ├── zh-CN.json
│   │   └── en.json
│   ├── App.tsx
│   ├── App.css
│   ├── main.tsx
│   └── index.css
├── index.html                  # Vite 入口 HTML
├── package.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
├── Cargo.toml                  # workspace 根
├── rust-toolchain.toml
├── .gitignore
├── LICENSE                     # MIT (从 PathEditor 复制, 替换项目名)
├── CHANGELOG.md                # v2.0.0 条目
├── CODE_OF_CONDUCT.md          # 从 PathEditor 复制
├── CONTRIBUTING.md             # 从 PathEditor 复制, 改为 Gobang 上下文
├── SECURITY.md                 # 从 PathEditor 复制, 改为 Gobang 上下文
└── README.md                   # 重写为 Gobang v2.0 介绍

Task 1: 项目脚手架 — Cargo workspace + Rust 基础

Files:

  • Create: Cargo.toml

  • Create: rust-toolchain.toml

  • Create: core/Cargo.toml

  • Create: core/src/lib.rs

  • Create: .gitignore

  • Step 1: 创建 workspace 根 Cargo.toml

[workspace]
resolver = "2"
members = [
    "core",
    "gui",
]

[workspace.package]
version = "2.0.0"
edition = "2021"
license = "MIT"
authors = ["刘航宇"]
repository = "https://github.com/LHY0125/Gobang"

文件路径: Cargo.toml

  • Step 2: 创建 rust-toolchain.toml
[toolchain]
channel = "stable-x86_64-pc-windows-gnu"

文件路径: rust-toolchain.toml

  • Step 3: 创建 core/Cargo.toml
[package]
name = "gobang-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
renet = "0.6"
reqwest = { version = "0.12", features = ["json", "blocking"] }
rand = "0.8"

文件路径: core/Cargo.toml

  • Step 4: 创建 core/src/lib.rs (空壳)
// Gobang core library — 纯游戏逻辑,零 GUI 依赖

文件路径: core/src/lib.rs

  • Step 5: 创建 .gitignore
node_modules
dist
dist-ssr
*.local
target/
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.sw?
.claude/
.codegraph/
CLAUDE.md

文件路径: .gitignore

  • Step 6: 验证 Rust 编译
cargo check

Expected: core crate 编译成功,无错误。

  • Step 7: 提交
git add Cargo.toml rust-toolchain.toml .gitignore core/Cargo.toml core/src/lib.rs
git commit -m "feat: 初始化 Cargo workspace + core crate 脚手架"

Task 2: core/types.rs — 基础类型定义

Files:

  • Create: core/src/types.rs

  • Modify: core/src/lib.rs

  • Step 1: 编写类型定义

use serde::{Deserialize, Serialize};

/// 棋盘最大尺寸
pub const MAX_BOARD_SIZE: usize = 19;

/// 棋子颜色
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Color {
    Black,
    White,
}

impl Color {
    /// 切换颜色 (黑→白, 白→黑)
    pub fn opponent(self) -> Self {
        match self {
            Color::Black => Color::White,
            Color::White => Color::Black,
        }
    }
}

/// 棋盘位置 (0-based)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Position {
    pub x: usize,
    pub y: usize,
}

impl Position {
    pub fn new(x: usize, y: usize) -> Self {
        Self { x, y }
    }
}

use std::cmp::Ordering;

impl PartialOrd for Position {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Position {
    fn cmp(&self, other: &Self) -> Ordering {
        self.x.cmp(&other.x).then(self.y.cmp(&other.y))
    }
}

/// 棋盘格状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CellState {
    Empty,
    Occupied(Color),
}

/// 一步棋
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Move {
    pub position: Position,
    pub color: Color,
    pub turn: u32,
}

/// 落子错误
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveError {
    OutOfBounds,
    Occupied,
    ForbiddenMove,
    GameOver,
}

impl std::fmt::Display for MoveError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let msg = match self {
            MoveError::OutOfBounds => "坐标超出棋盘范围",
            MoveError::Occupied => "该位置已有棋子",
            MoveError::ForbiddenMove => "禁手位置,不能落子",
            MoveError::GameOver => "游戏已结束",
        };
        write!(f, "{}", msg)
    }
}

/// 落子结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoveResult {
    pub position: Position,
    pub is_win: bool,
    pub is_forbidden: bool,
}

/// 游戏结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameResult {
    pub winner: Option<Color>,
    pub win_positions: Vec<Position>,
}

/// 游戏模式 (Tauri IPC 兼容 — 纯标签, 不含字段)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GameMode {
    Local,
    VsAi,
    Online,
    Replay,
}

/// 游戏配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameConfig {
    pub board_size: usize,
    pub use_forbidden_rules: bool,
    pub use_timer: bool,
    pub time_limit_secs: u32,
    pub ai_difficulty: u32,
    pub player_color: Color,
    pub is_server: bool,
}

impl Default for GameConfig {
    fn default() -> Self {
        Self {
            board_size: 15,
            use_forbidden_rules: true,
            use_timer: false,
            time_limit_secs: 60,
            ai_difficulty: 3,
            player_color: Color::Black,
            is_server: false,
        }
    }
}

文件路径: core/src/types.rs

  • Step 2: 更新 core/src/lib.rs 声明模块
// Gobang core library — 纯游戏逻辑,零 GUI 依赖

pub mod types;

文件路径: core/src/lib.rs

  • Step 3: 编译验证
cargo check -p gobang-core

Expected: 编译成功。

  • Step 4: 提交
git add core/src/types.rs core/src/lib.rs
git commit -m "feat(core): 定义基础类型 — Position, Color, CellState, Move, GameConfig"

Task 3: core/board.rs — 棋盘引擎

Files:

  • Create: core/src/board.rs

  • Modify: core/src/lib.rs

  • Step 1: 编写测试 (TDD — RED)

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Color, Position, MoveError};

    #[test]
    fn test_empty_board_creation() {
        let board = Board::new(15);
        assert_eq!(board.size, 15);
        // 所有位置应为空
        for x in 0..15 {
            for y in 0..15 {
                assert_eq!(board.get(Position::new(x, y)), CellState::Empty);
            }
        }
    }

    #[test]
    fn test_place_piece() {
        let board = Board::new(15);
        let result = board.place(Position::new(7, 7), Color::Black);
        assert!(result.is_ok());
        let new_board = result.unwrap();
        assert_eq!(new_board.get(Position::new(7, 7)), CellState::Occupied(Color::Black));
    }

    #[test]
    fn test_place_on_occupied_fails() {
        let board = Board::new(15);
        let board = board.place(Position::new(7, 7), Color::Black).unwrap();
        let result = board.place(Position::new(7, 7), Color::White);
        assert_eq!(result, Err(MoveError::Occupied));
    }

    #[test]
    fn test_place_out_of_bounds_fails() {
        let board = Board::new(15);
        let result = board.place(Position::new(20, 20), Color::Black);
        assert_eq!(result, Err(MoveError::OutOfBounds));
    }

    #[test]
    fn test_win_horizontal() {
        let board = Board::new(15);
        let mut board = board;
        // 黑子连成5个水平
        for y in 3..7 {
            board = board.place(Position::new(7, y), Color::Black).unwrap();
        }
        let board = board.place(Position::new(7, 7), Color::Black).unwrap();
        assert!(board.check_win(Position::new(7, 7)));
    }

    #[test]
    fn test_win_vertical() {
        let board = Board::new(15);
        let mut board = board;
        for x in 3..7 {
            board = board.place(Position::new(x, 7), Color::White).unwrap();
        }
        let board = board.place(Position::new(7, 7), Color::White).unwrap();
        assert!(board.check_win(Position::new(7, 7)));
    }

    #[test]
    fn test_win_diagonal() {
        let board = Board::new(15);
        let mut board = board;
        for i in 1..5 {
            board = board.place(Position::new(3 + i, 3 + i), Color::Black).unwrap();
        }
        let board = board.place(Position::new(8, 8), Color::Black).unwrap();
        assert!(board.check_win(Position::new(8, 8)));
    }

    #[test]
    fn test_no_win_on_four() {
        let board = Board::new(15);
        let mut board = board;
        for y in 3..6 {
            board = board.place(Position::new(7, y), Color::Black).unwrap();
        }
        let board = board.place(Position::new(7, 6), Color::Black).unwrap();
        assert!(!board.check_win(Position::new(7, 6)));
    }

    #[test]
    fn test_undo() {
        let board = Board::new(15);
        let board = board.place(Position::new(7, 7), Color::Black).unwrap();
        let board = board.place(Position::new(7, 8), Color::White).unwrap();
        // 悔一步 (撤销 White 的棋)
        let board = board.undo().unwrap();
        assert_eq!(board.get(Position::new(7, 8)), CellState::Empty);
        assert_eq!(board.get(Position::new(7, 7)), CellState::Occupied(Color::Black));
    }

    #[test]
    fn test_undo_empty_history() {
        let board = Board::new(15);
        assert_eq!(board.undo(), Err(MoveError::GameOver)); // 无历史时 undo 失败
    }

    #[test]
    fn test_immutable_place() {
        let board = Board::new(15);
        let _new = board.place(Position::new(7, 7), Color::Black).unwrap();
        // 原 board 不变
        assert_eq!(board.get(Position::new(7, 7)), CellState::Empty);
    }
}

文件路径: core/src/board.rs (底部, #[cfg(test)] 块)

  • Step 2: 运行测试确认失败
cargo test -p gobang-core

Expected: 所有测试 FAIL, Board 未定义。

  • Step 3: 实现 Board
use crate::types::{CellState, Color, Move, MoveError, MoveResult, Position, MAX_BOARD_SIZE};

/// 棋盘主体 — 不可变风格, place/undo 返回新 Board
#[derive(Debug, Clone)]
pub struct Board {
    pub size: usize,
    cells: [[CellState; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
    history: Vec<Move>,
    current_turn: u32,
}

impl Board {
    /// 创建空棋盘
    pub fn new(size: usize) -> Self {
        assert!(size <= MAX_BOARD_SIZE, "棋盘尺寸不能超过 {}", MAX_BOARD_SIZE);
        Self {
            size,
            cells: [[CellState::Empty; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
            history: Vec::new(),
            current_turn: 0,
        }
    }

    /// 获取指定位置的棋子状态
    pub fn get(&self, pos: Position) -> CellState {
        if pos.x >= self.size || pos.y >= self.size {
            return CellState::Empty;
        }
        self.cells[pos.x][pos.y]
    }

    /// 落子 — 返回新 Board (不可变)
    pub fn place(&self, pos: Position, color: Color) -> Result<Board, MoveError> {
        if pos.x >= self.size || pos.y >= self.size {
            return Err(MoveError::OutOfBounds);
        }
        if self.cells[pos.x][pos.y] != CellState::Empty {
            return Err(MoveError::Occupied);
        }

        let mut new_board = self.clone();
        new_board.cells[pos.x][pos.y] = CellState::Occupied(color);
        new_board.history.push(Move {
            position: pos,
            color,
            turn: self.current_turn,
        });
        new_board.current_turn = self.current_turn + 1;
        Ok(new_board)
    }

    /// 胜负判定 — 从 pos 出发四方向扫描
    pub fn check_win(&self, pos: Position) -> bool {
        let cell = self.cells[pos.x][pos.y];
        let color = match cell {
            CellState::Occupied(c) => c,
            _ => return false,
        };

        // 四个方向: 水平(0,1), 垂直(1,0), 对角线(1,1), 反对角线(1,-1)
        let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];

        for (dx, dy) in directions {
            let mut count = 1u32;
            // 正方向
            let mut nx = pos.x as isize + dx;
            let mut ny = pos.y as isize + dy;
            while nx >= 0 && ny >= 0 && (nx as usize) < self.size && (ny as usize) < self.size {
                if self.cells[nx as usize][ny as usize] == CellState::Occupied(color) {
                    count += 1;
                    nx += dx;
                    ny += dy;
                } else {
                    break;
                }
            }
            // 反方向
            let mut nx = pos.x as isize - dx;
            let mut ny = pos.y as isize - dy;
            while nx >= 0 && ny >= 0 && (nx as usize) < self.size && (ny as usize) < self.size {
                if self.cells[nx as usize][ny as usize] == CellState::Occupied(color) {
                    count += 1;
                    nx -= dx;
                    ny -= dy;
                } else {
                    break;
                }
            }
            if count >= 5 {
                return true;
            }
        }
        false
    }

    /// 悔棋 — 撤销最近一步
    pub fn undo(&self) -> Result<Board, MoveError> {
        if self.history.is_empty() {
            return Err(MoveError::GameOver);
        }
        let mut new_board = self.clone();
        let last_move = new_board.history.pop().unwrap();
        new_board.cells[last_move.position.x][last_move.position.y] = CellState::Empty;
        new_board.current_turn = self.current_turn.saturating_sub(1);
        Ok(new_board)
    }

    /// 获取所有候选落子位 (已有棋子周围2格范围)
    pub fn get_candidate_moves(&self) -> Vec<Position> {
        let mut candidates = Vec::new();
        let range = 2isize;
        let has_stones = self.history.is_empty();

        if !has_stones {
            // 棋盘为空, 返回天元
            return vec![Position::new(self.size / 2, self.size / 2)];
        }

        for x in 0..self.size {
            for y in 0..self.size {
                if self.cells[x][y] != CellState::Empty {
                    for dx in -range..=range {
                        for dy in -range..=range {
                            let nx = x as isize + dx;
                            let ny = y as isize + dy;
                            if nx >= 0 && ny >= 0
                                && (nx as usize) < self.size
                                && (ny as usize) < self.size
                                && self.cells[nx as usize][ny as usize] == CellState::Empty
                            {
                                candidates.push(Position::new(nx as usize, ny as usize));
                            }
                        }
                    }
                }
            }
        }

        candidates.sort();
        candidates.dedup();
        candidates
    }
}

文件路径: core/src/board.rs

  • Step 4: 运行测试确认通过
cargo test -p gobang-core

Expected: 所有 board 测试 PASS.

  • Step 5: 更新 lib.rs
pub mod types;
pub mod board;
  • Step 6: 最终编译 + 测试验证
cargo test -p gobang-core

Expected: 全部 PASS.

  • Step 7: 提交
git add core/src/board.rs core/src/types.rs core/src/lib.rs
git commit -m "feat(core): 棋盘引擎 — 不可变 Board, 落子/胜负/悔棋/候选位"

Task 4: core/rules.rs — 禁手规则

Files:

  • Create: core/src/rules.rs

  • Modify: core/src/lib.rs

  • Step 1: 编写测试

#[cfg(test)]
mod tests {
    use super::*;
    use crate::board::Board;
    use crate::types::{Color, Position};

    #[test]
    fn test_double_three_forbidden() {
        let board = Board::new(15);
        // 构造双三禁手局面: 黑子在 (7,7) 同时形成两个活三
        // 水平活三: 黑子 (7,5)(7,6) 空 (7,7) 空 (7,8)
        let board = board.place(Position::new(7, 5), Color::Black).unwrap();
        let board = board.place(Position::new(7, 6), Color::Black).unwrap();
        // 斜线活三: (5,9)(6,8) 空 (7,7) 空 (8,6)
        let board = board.place(Position::new(5, 9), Color::Black).unwrap();
        let board = board.place(Position::new(6, 8), Color::Black).unwrap();
        assert!(is_forbidden(&board, Position::new(7, 7), Color::Black));
    }

    #[test]
    fn test_double_four_forbidden() {
        let board = Board::new(15);
        // 构造双四禁手
        // 水平冲四: (7,3)(7,4)(7,5)(7,6) 空 (7,7)
        let board = board.place(Position::new(7, 3), Color::Black).unwrap();
        let board = board.place(Position::new(7, 4), Color::Black).unwrap();
        let board = board.place(Position::new(7, 5), Color::Black).unwrap();
        let board = board.place(Position::new(7, 6), Color::Black).unwrap();
        // 垂直冲四: (3,7)(4,7)(5,7)(6,7) 空 (7,7)
        let board = board.place(Position::new(3, 7), Color::Black).unwrap();
        let board = board.place(Position::new(4, 7), Color::Black).unwrap();
        let board = board.place(Position::new(5, 7), Color::Black).unwrap();
        let board = board.place(Position::new(6, 7), Color::Black).unwrap();
        assert!(is_forbidden(&board, Position::new(7, 7), Color::Black));
    }

    #[test]
    fn test_overline_forbidden() {
        let board = Board::new(15);
        // 长连禁手 (>=6)
        for y in 1..6 {
            let board = board.place(Position::new(7, y), Color::Black).unwrap();
        }
        let board = board.place(Position::new(7, 6), Color::Black).unwrap();
        assert!(is_forbidden(&board, Position::new(7, 6), Color::Black));
    }

    #[test]
    fn test_white_not_forbidden() {
        let board = Board::new(15);
        // 白棋永远不禁手
        for y in 1..6 {
            let board = board.place(Position::new(7, y), Color::White).unwrap();
        }
        let board = board.place(Position::new(7, 6), Color::White).unwrap();
        assert!(!is_forbidden(&board, Position::new(7, 6), Color::White));
    }

    #[test]
    fn test_normal_move_not_forbidden() {
        let board = Board::new(15);
        let board = board.place(Position::new(7, 7), Color::Black).unwrap();
        let board = board.place(Position::new(7, 8), Color::Black).unwrap();
        assert!(!is_forbidden(&board, Position::new(7, 9), Color::Black));
    }
}
  • Step 2: 运行测试确认失败
cargo test -p gobang-core -- rules

Expected: FAIL.

  • Step 3: 实现禁手检测
use crate::board::Board;
use crate::types::{CellState, Color, Position};

/// 检测 pos 位置对 player 是否为禁手
/// 黑棋禁手: 长连(>=6)、双三、双四
/// 白棋无禁手
pub fn is_forbidden(board: &Board, pos: Position, color: Color) -> bool {
    if color == Color::White {
        return false;
    }
    is_overline(board, pos, color) || is_double_three(board, pos, color) || is_double_four(board, pos, color)
}

/// 长连检测: >=6 连
fn is_overline(board: &Board, pos: Position, color: Color) -> bool {
    let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
    for (dx, dy) in directions {
        let mut count = 1u32;
        // 正方向
        let mut nx = pos.x as isize + dx;
        let mut ny = pos.y as isize + dy;
        while let Some(cell) = get_cell(board, nx, ny) {
            if cell == CellState::Occupied(color) { count += 1; }
            else { break; }
            nx += dx;
            ny += dy;
        }
        // 反方向
        let mut nx = pos.x as isize - dx;
        let mut ny = pos.y as isize - dy;
        while let Some(cell) = get_cell(board, nx, ny) {
            if cell == CellState::Occupied(color) { count += 1; }
            else { break; }
            nx -= dx;
            ny -= dy;
        }
        if count >= 6 {
            return true;
        }
    }
    false
}

/// 双三检测: 落子后同时产生 >=2 个活三
fn is_double_three(board: &Board, pos: Position, color: Color) -> bool {
    count_open_threes(board, pos, color) >= 2
}

/// 双四检测: 落子后同时产生 >=2 个四
fn is_double_four(board: &Board, pos: Position, color: Color) -> bool {
    count_fours(board, pos, color) >= 2
}

fn count_open_threes(board: &Board, pos: Position, color: Color) -> u32 {
    let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
    let mut count = 0u32;
    for (dx, dy) in directions {
        if is_open_three_in_direction(board, pos, color, dx, dy) {
            count += 1;
        }
    }
    count
}

fn count_fours(board: &Board, pos: Position, color: Color) -> u32 {
    let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
    let mut count = 0u32;
    for (dx, dy) in directions {
        if is_four_in_direction(board, pos, color, dx, dy) {
            count += 1;
        }
    }
    count
}

/// 活三检测: 一个方向上形成 "空-黑-黑-黑-空" (含 pos)
fn is_open_three_in_direction(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> bool {
    // 收集该方向连续同色棋子 + 两端状态
    let (cnt, start_open, end_open) = scan_direction(board, pos, color, dx, dy);
    cnt == 3 && start_open && end_open
}

/// 四检测: 冲四或活四
fn is_four_in_direction(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> bool {
    let (cnt, start_open, end_open) = scan_direction(board, pos, color, dx, dy);
    cnt == 4 // 冲四(一端开放一端堵) 或 活四(两端开放)
}

/// 扫描方向, 返回 (连续同色数, 起始端是否开放, 结束端是否开放)
fn scan_direction(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> (u32, bool, bool) {
    let mut count = 1u32;

    // 正方向
    let mut nx = pos.x as isize + dx;
    let mut ny = pos.y as isize + dy;
    while let Some(cell) = get_cell(board, nx, ny) {
        if cell == CellState::Occupied(color) { count += 1; }
        else { break; }
        nx += dx;
        ny += dy;
    }
    let end_open = get_cell(board, nx, ny) == Some(CellState::Empty);

    // 反方向
    let mut nx = pos.x as isize - dx;
    let mut ny = pos.y as isize - dy;
    while let Some(cell) = get_cell(board, nx, ny) {
        if cell == CellState::Occupied(color) { count += 1; }
        else { break; }
        nx -= dx;
        ny -= dy;
    }
    let start_open = get_cell(board, nx, ny) == Some(CellState::Empty);

    (count, start_open, end_open)
}

/// 安全获取棋盘格 (边界外返回 None)
fn get_cell(board: &Board, x: isize, y: isize) -> Option<CellState> {
    if x < 0 || y < 0 || x as usize >= board.size || y as usize >= board.size {
        return None;
    }
    Some(board.get(Position::new(x as usize, y as usize)))
}

文件路径: core/src/rules.rs

  • Step 4: 运行测试确认通过
cargo test -p gobang-core -- rules

Expected: 所有 rules 测试 PASS.

  • Step 5: 更新 lib.rs
pub mod types;
pub mod board;
pub mod rules;
  • Step 6: 提交
git add core/src/rules.rs core/src/lib.rs
git commit -m "feat(core): 禁手规则 — 长连/双三/双四检测"

Task 5: core/ai/evaluate.rs — 棋形评分

Files:

  • Create: core/src/ai/mod.rs

  • Create: core/src/ai/evaluate.rs

  • Modify: core/src/lib.rs

  • Step 1: 创建 ai/mod.rs — AiEngine trait

use crate::board::Board;
use crate::types::{Color, Position};

/// AI 引擎统一接口
pub trait AiEngine: Send + Sync {
    /// 返回 AI 的最佳落子位置, 无位置返回 None
    fn best_move(&self, board: &Board, color: Color) -> Option<Position>;
}

pub mod evaluate;
pub mod search;

文件路径: core/src/ai/mod.rs

  • Step 2: 编写 evaluate 测试
#[cfg(test)]
mod tests {
    use super::*;
    use crate::board::Board;
    use crate::types::{Color, Position};

    #[test]
    fn test_evaluate_empty_board() {
        let board = Board::new(15);
        let score = evaluate_board(&board, Color::Black);
        // 空棋盘得分应为 0 (双方都没有棋)
        assert_eq!(score, 0.0);
    }

    #[test]
    fn test_five_in_a_row_high_score() {
        let board = Board::new(15);
        let mut board = board;
        for y in 5..10 {
            board = board.place(Position::new(7, y), Color::Black).unwrap();
        }
        let score = evaluate_board(&board, Color::Black);
        assert!(score > 10000.0); // 连五得分极高
    }
}
  • Step 3: 运行测试确认失败
cargo test -p gobang-core -- evaluate

Expected: FAIL.

  • Step 4: 实现棋形评分
use crate::board::Board;
use crate::types::{CellState, Color, Position};

/// 棋形分数 (参考 v1 C 版评分逻辑)
const FIVE: f64 = 100000.0;
const OPEN_FOUR: f64 = 10000.0;
const RUSH_FOUR: f64 = 5000.0;
const OPEN_THREE: f64 = 1000.0;
const SLEEP_THREE: f64 = 500.0;
const OPEN_TWO: f64 = 100.0;
const SLEEP_TWO: f64 = 50.0;
const OPEN_ONE: f64 = 10.0;

/// 评估整个棋盘对 player 的得分
/// 返回 (player 得分, 对手得分)
pub fn evaluate_board(board: &Board, player: Color) -> f64 {
    let player_score = evaluate_player(board, player);
    let opponent_score = evaluate_player(board, player.opponent());
    player_score - opponent_score
}

fn evaluate_player(board: &Board, color: Color) -> f64 {
    let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
    let mut total = 0.0f64;
    let size = board.size;

    for x in 0..size {
        for y in 0..size {
            if board.get(Position::new(x, y)) != CellState::Occupied(color) {
                continue;
            }
            for &(dx, dy) in &directions {
                let (count, start_open, end_open) =
                    scan_pattern(board, Position::new(x, y), color, dx, dy);
                total += score_pattern(count, start_open, end_open);
            }
        }
    }
    total
}

/// 从 pos 向 (dx,dy) 方向扫描, 只扫描正方向(避免重复计数)
fn scan_pattern(board: &Board, pos: Position, color: Color, dx: isize, dy: isize) -> (u32, bool, bool) {
    let mut count = 1u32;

    // 正方向
    let mut nx = pos.x as isize + dx;
    let mut ny = pos.y as isize + dy;
    while in_bounds(board, nx, ny) && board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color) {
        count += 1;
        nx += dx;
        ny += dy;
    }
    let end_open = in_bounds(board, nx, ny)
        && board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty;

    // 反方向 (起点)
    let sx = pos.x as isize - dx;
    let sy = pos.y as isize - dy;
    let start_open = in_bounds(board, sx, sy)
        && board.get(Position::new(sx as usize, sy as usize)) == CellState::Empty;

    // 只在这个方向第一次遇到连续段时计数 (避免重复)
    // 检查反方向是不是同色: 如果是, 说明不是起点, 不计分
    if in_bounds(board, sx, sy) && board.get(Position::new(sx as usize, sy as usize)) == CellState::Occupied(color) {
        return (0, false, false); // 不是起点, 不计分
    }

    (count, start_open, end_open)
}

fn score_pattern(count: u32, start_open: bool, end_open: bool) -> f64 {
    let open_count = start_open as u32 + end_open as u32;
    match (count, open_count) {
        (5, _) => FIVE,
        (4, 2) => OPEN_FOUR,
        (4, 1) => RUSH_FOUR,
        (3, 2) => OPEN_THREE,
        (3, 1) => SLEEP_THREE,
        (2, 2) => OPEN_TWO,
        (2, 1) => SLEEP_TWO,
        (1, 2) => OPEN_ONE,
        _ => 0.0,
    }
}

fn in_bounds(board: &Board, x: isize, y: isize) -> bool {
    x >= 0 && y >= 0 && (x as usize) < board.size && (y as usize) < board.size
}

文件路径: core/src/ai/evaluate.rs

  • Step 5: 运行测试确认通过
cargo test -p gobang-core -- evaluate

Expected: PASS.

  • Step 6: 更新 lib.rs
pub mod types;
pub mod board;
pub mod rules;
pub mod ai;
  • Step 7: 提交
git add core/src/ai/ core/src/lib.rs
git commit -m "feat(core): AI 棋形评分模块 — 连五/活四/冲四/活三等棋形打分"

Task 6: core/ai/search.rs — Alpha-Beta 搜索

Files:

  • Create: core/src/ai/search.rs

  • Step 1: 编写测试

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ai::AiEngine;
    use crate::board::Board;
    use crate::types::{Color, Position};

    #[test]
    fn test_ai_returns_center_on_empty_board() {
        let board = Board::new(15);
        let ai = AlphaBetaAi::new(1);
        let mv = ai.best_move(&board, Color::Black);
        assert!(mv.is_some());
        let pos = mv.unwrap();
        // 天元附近
        assert!(pos.x >= 6 && pos.x <= 8);
        assert!(pos.y >= 6 && pos.y <= 8);
    }

    #[test]
    fn test_ai_blocks_four() {
        let board = Board::new(15);
        // 白棋冲四: (7,3)(7,4)(7,5)(7,6) — AI(黑) 应堵 (7,2) 或 (7,7)
        let board = board.place(Position::new(7, 3), Color::White).unwrap();
        let board = board.place(Position::new(7, 4), Color::White).unwrap();
        let board = board.place(Position::new(7, 5), Color::White).unwrap();
        let board = board.place(Position::new(7, 6), Color::White).unwrap();
        let ai = AlphaBetaAi::new(3);
        let mv = ai.best_move(&board, Color::Black).unwrap();
        // 应该堵在端点
        assert!(
            (mv.x == 7 && mv.y == 2) || (mv.x == 7 && mv.y == 7),
            "AI should block four, got ({},{})", mv.x, mv.y
        );
    }

    #[test]
    fn test_ai_takes_win() {
        let board = Board::new(15);
        // 黑棋可连五: (7,3)(7,4)(7,5)(7,6) — AI(黑) 应该下 (7,7)
        let board = board.place(Position::new(7, 3), Color::Black).unwrap();
        let board = board.place(Position::new(7, 4), Color::Black).unwrap();
        let board = board.place(Position::new(7, 5), Color::Black).unwrap();
        let board = board.place(Position::new(7, 6), Color::Black).unwrap();
        let ai = AlphaBetaAi::new(3);
        let mv = ai.best_move(&board, Color::Black).unwrap();
        assert_eq!(mv, Position::new(7, 7));
    }
}

文件路径: core/src/ai/search.rs (底部)

  • Step 2: 运行测试确认失败
cargo test -p gobang-core -- search

Expected: FAIL.

  • Step 3: 实现 AlphaBetaAi
use crate::ai::evaluate::evaluate_board;
use crate::ai::AiEngine;
use crate::board::Board;
use crate::types::{Color, Position, MoveResult};

/// Alpha-Beta AI 引擎
pub struct AlphaBetaAi {
    depth: usize,
    defense_coefficient: f64,
}

impl AlphaBetaAi {
    pub fn new(depth: usize) -> Self {
        Self {
            depth,
            defense_coefficient: 1.2,
        }
    }

    pub fn with_defense(mut self, coeff: f64) -> Self {
        self.defense_coefficient = coeff;
        self
    }
}

impl AiEngine for AlphaBetaAi {
    fn best_move(&self, board: &Board, color: Color) -> Option<Position> {
        let candidates = board.get_candidate_moves();
        if candidates.is_empty() {
            return None;
        }

        let mut best_pos = None;
        let mut best_score = f64::NEG_INFINITY;

        for &pos in &candidates {
            if let Ok(new_board) = board.place(pos, color) {
                if new_board.check_win(pos) {
                    return Some(pos); // 直接赢, 立即返回
                }
                let score = -self.negamax(
                    &new_board,
                    self.depth - 1,
                    f64::NEG_INFINITY,
                    f64::INFINITY,
                    color.opponent(),
                );
                if score > best_score {
                    best_score = score;
                    best_pos = Some(pos);
                }
            }
        }

        best_pos
    }
}

impl AlphaBetaAi {
    fn negamax(
        &self,
        board: &Board,
        depth: usize,
        mut alpha: f64,
        beta: f64,
        color: Color,
    ) -> f64 {
        if depth == 0 {
            return evaluate_board(board, color);
        }

        let candidates = board.get_candidate_moves();
        if candidates.is_empty() {
            return evaluate_board(board, color);
        }

        // 启发式排序: 按候选位评分降序
        let mut scored: Vec<(Position, f64)> = candidates
            .into_iter()
            .filter_map(|pos| {
                board.place(pos, color).ok().map(|b| {
                    if b.check_win(pos) {
                        (pos, f64::INFINITY)
                    } else {
                        let s = evaluate_board(&b, color);
                        (pos, s)
                    }
                })
            })
            .collect();
        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

        let mut max_val = f64::NEG_INFINITY;
        for (pos, _) in scored {
            if let Ok(new_board) = board.place(pos, color) {
                if new_board.check_win(pos) {
                    return f64::INFINITY; // 必胜
                }
                let val = -self.negamax(
                    &new_board,
                    depth - 1,
                    -beta,
                    -alpha,
                    color.opponent(),
                );
                if val > max_val {
                    max_val = val;
                }
                if val > alpha {
                    alpha = val;
                }
                if alpha >= beta {
                    break; // 剪枝
                }
            }
        }
        max_val
    }
}

文件路径: core/src/ai/search.rs

  • Step 4: 运行测试确认通过
cargo test -p gobang-core -- search

Expected: 所有测试 PASS.

  • Step 5: 提交
git add core/src/ai/search.rs
git commit -m "feat(core): AI Alpha-Beta 搜索 — Negamax + 剪枝 + 启发式排序"

Task 7: core/record.rs — 棋谱记录

Files:

  • Create: core/src/record.rs

  • Modify: core/src/lib.rs

  • Step 1: 编写测试

#[cfg(test)]
mod tests {
    use super::*;
    use crate::board::Board;
    use crate::types::{Color, Position};

    #[test]
    fn test_save_and_load_record() {
        let board = Board::new(15);
        let board = board.place(Position::new(7, 7), Color::Black).unwrap();
        let board = board.place(Position::new(7, 8), Color::White).unwrap();

        let record = GameRecord::from_board(&board, "Human", "AI-Lv3", Some(Color::Black));
        let json = serde_json::to_string_pretty(&record).unwrap();

        let loaded: GameRecord = serde_json::from_str(&json).unwrap();
        assert_eq!(loaded.moves.len(), 2);
        assert_eq!(loaded.moves[0].position, Position::new(7, 7));
    }

    #[test]
    fn test_replay_board() {
        let board = Board::new(15);
        let board = board.place(Position::new(7, 7), Color::Black).unwrap();
        let board = board.place(Position::new(7, 8), Color::White).unwrap();

        let record = GameRecord::from_board(&board, "Human", "AI", None);
        let replayed = record.to_replay_board().unwrap();

        // 重建后棋盘应和原始一致
        assert_eq!(replayed.get(Position::new(7, 7)), crate::types::CellState::Occupied(Color::Black));
        assert_eq!(replayed.get(Position::new(7, 8)), crate::types::CellState::Occupied(Color::White));
    }
}
  • Step 2: 运行测试确认失败
cargo test -p gobang-core -- record

Expected: FAIL.

  • Step 3: 实现棋谱模块
use serde::{Deserialize, Serialize};
use crate::board::Board;
use crate::types::{Color, Move, Position};

/// 对局棋谱
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameRecord {
    pub version: String,
    pub date: String,
    pub board_size: usize,
    pub black_player: String,
    pub white_player: String,
    pub winner: Option<String>,
    pub moves: Vec<RecordMove>,
}

/// 棋谱中的一步
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordMove {
    pub x: usize,
    pub y: usize,
    pub color: String,
    pub turn: u32,
}

impl GameRecord {
    pub fn new(board_size: usize, black: &str, white: &str) -> Self {
        Self {
            version: "2.0".to_string(),
            date: chrono_now(),
            board_size,
            black_player: black.to_string(),
            white_player: white.to_string(),
            winner: None,
            moves: Vec::new(),
        }
    }

    pub fn from_board(board: &Board, black: &str, white: &str, winner: Option<Color>) -> Self {
        let winner_str = winner.map(|c| match c {
            Color::Black => black.to_string(),
            Color::White => white.to_string(),
        });
        let moves = board.history().iter().map(|m| RecordMove {
            x: m.position.x,
            y: m.position.y,
            color: match m.color { Color::Black => "Black".into(), Color::White => "White".into() },
            turn: m.turn,
        }).collect();

        Self {
            version: "2.0".to_string(),
            date: chrono_now(),
            board_size: board.size,
            black_player: black.to_string(),
            white_player: white.to_string(),
            winner: winner_str,
            moves,
        }
    }

    /// 从棋谱重建最终棋盘
    pub fn to_replay_board(&self) -> Result<Board, String> {
        let mut board = Board::new(self.board_size);
        for m in &self.moves {
            let color = match m.color.as_str() {
                "Black" => Color::Black,
                "White" => Color::White,
                _ => return Err(format!("未知颜色: {}", m.color)),
            };
            board = board.place(Position::new(m.x, m.y), color)
                .map_err(|e| e.to_string())?;
        }
        Ok(board)
    }
}

fn chrono_now() -> String {
    // 避免引入 chrono crate, 用简单格式
    use std::time::SystemTime;
    if let Ok(dur) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
        let secs = dur.as_secs();
        let days = secs / 86400;
        // 简化: 天数从1970算起, 不作为真实日期
        format!("day-{}", days)
    } else {
        "unknown".to_string()
    }
}

文件路径: core/src/record.rs

  • Step 4: Board 需要暴露 history

core/src/board.rsimpl Board 中添加:

/// 获取落子历史 (用于棋谱)
pub fn history(&self) -> &[Move] {
    &self.history
}
  • Step 5: 更新 lib.rs
pub mod types;
pub mod board;
pub mod rules;
pub mod ai;
pub mod record;
  • Step 6: 运行测试确认通过
cargo test -p gobang-core

Expected: 全部 PASS.

  • Step 7: 提交
git add core/src/record.rs core/src/board.rs core/src/lib.rs
git commit -m "feat(core): 棋谱记录 — JSON 序列化/反序列化 + 复盘重建"

Task 8: core/network.rs — renet 网络对战

Files:

  • Create: core/src/network.rs

  • Modify: core/src/lib.rs

  • Step 1: 实现网络模块 (无独立测试, 依赖 renet runtime)

use serde::{Deserialize, Serialize};
use crate::types::Position;

/// 游戏网络消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameMessage {
    Move { x: usize, y: usize, turn: u32 },
    Undo { steps: u32 },
    Resign,
    Chat(String),
    Heartbeat,
}

/// 网络连接角色
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkRole {
    Server,
    Client,
}

/// 网络会话配置
#[derive(Debug, Clone)]
pub struct NetworkConfig {
    pub role: NetworkRole,
    pub bind_port: u16,
    pub remote_addr: String,
    pub remote_port: u16,
}

/// 网络会话状态
#[derive(Debug, Clone)]
pub struct NetworkSession {
    pub role: NetworkRole,
    pub is_connected: bool,
    pub config: NetworkConfig,
    pending_messages: Vec<GameMessage>,
}

impl NetworkSession {
    pub fn new(config: NetworkConfig) -> Self {
        Self {
            role: config.role,
            is_connected: false,
            config,
            pending_messages: Vec::new(),
        }
    }

    /// 发送消息 (实际 renet 集成在 gui 层处理)
    pub fn enqueue_message(&mut self, msg: GameMessage) {
        self.pending_messages.push(msg);
    }

    /// 取出待发送的消息
    pub fn drain_messages(&mut self) -> Vec<GameMessage> {
        std::mem::take(&mut self.pending_messages)
    }

    pub fn set_connected(&mut self, connected: bool) {
        self.is_connected = connected;
    }
}

文件路径: core/src/network.rs

  • Step 2: 更新 lib.rs
pub mod types;
pub mod board;
pub mod rules;
pub mod ai;
pub mod record;
pub mod network;
  • Step 3: 编译验证
cargo check -p gobang-core
  • Step 4: 提交
git add core/src/network.rs core/src/lib.rs
git commit -m "feat(core): 网络模块 — GameMessage 协议定义 + NetworkSession"

Task 9: core/llm.rs — 大模型 AI

Files:

  • Create: core/src/llm.rs

  • Modify: core/src/lib.rs

  • Step 1: 实现 LLM AI (实现 AiEngine trait)

use crate::ai::AiEngine;
use crate::board::Board;
use crate::types::{CellState, Color, Position};

/// 大模型 AI — 通过 HTTP API 调用
pub struct LlmAi {
    endpoint: String,
    api_key: String,
    model: String,
}

impl LlmAi {
    pub fn new(endpoint: &str, api_key: &str, model: &str) -> Self {
        Self {
            endpoint: endpoint.to_string(),
            api_key: api_key.to_string(),
            model: model.to_string(),
        }
    }

    /// 将棋盘序列化为 prompt
    pub fn board_to_prompt(board: &Board, color: Color) -> String {
        let mut s = String::from("你是一位五子棋高手。当前棋盘状态(0=空,1=黑,2=白):\n");
        for x in 0..board.size {
            for y in 0..board.size {
                let ch = match board.get(Position::new(x, y)) {
                    CellState::Empty => '0',
                    CellState::Occupied(Color::Black) => '1',
                    CellState::Occupied(Color::White) => '2',
                };
                s.push(ch);
                s.push(' ');
            }
            s.push('\n');
        }
        let color_str = match color {
            Color::Black => "黑棋(1)",
            Color::White => "白棋(2)",
        };
        s.push_str(&format!("\n你是{}, 请返回最佳落子坐标 (格式: x,y)", color_str));
        s
    }

    /// 解析 LLM 响应中的坐标
    pub fn parse_response(response: &str) -> Option<Position> {
        // 尝试匹配 "x,y" 格式
        for part in response.split([' ', '\n', '\r', '(', ')', '[', ']']) {
            let trimmed = part.trim();
            if let Some(comma_pos) = trimmed.find(',') {
                let x_str = &trimmed[..comma_pos];
                let y_str = &trimmed[comma_pos + 1..];
                if let (Ok(x), Ok(y)) = (x_str.parse::<usize>(), y_str.parse::<usize>()) {
                    return Some(Position::new(x, y));
                }
            }
        }
        None
    }
}

impl AiEngine for LlmAi {
    fn best_move(&self, board: &Board, color: Color) -> Option<Position> {
        // 同步 HTTP 请求 (在 Tauri 异步命令中通过 spawn_blocking 调用)
        let prompt = Self::board_to_prompt(board, color);
        let client = reqwest::blocking::Client::new();
        let body = serde_json::json!({
            "model": self.model,
            "messages": [
                {"role": "user", "content": prompt}
            ],
            "max_tokens": 50,
            "temperature": 0.3
        });

        let resp = client
            .post(&self.endpoint)
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .ok()?;

        let json: serde_json::Value = resp.json().ok()?;
        let content = json["choices"][0]["message"]["content"].as_str()?;
        Self::parse_response(content)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_coordinate() {
        assert_eq!(LlmAi::parse_response("7,8"), Some(Position::new(7, 8)));
        assert_eq!(LlmAi::parse_response("(7, 8)"), Some(Position::new(7, 8)));
        assert_eq!(LlmAi::parse_response("坐标是 10,5"), Some(Position::new(10, 5)));
        assert_eq!(LlmAi::parse_response("no coordinate"), None);
    }

    #[test]
    fn test_board_to_prompt() {
        let board = Board::new(15);
        let prompt = LlmAi::board_to_prompt(&board, Color::Black);
        assert!(prompt.contains("黑棋(1)"));
        assert!(prompt.contains("0 0 0"));
    }
}

文件路径: core/src/llm.rs

  • Step 2: 添加 reqwest 依赖到 core/Cargo.toml

确认 core/Cargo.toml 已包含:

reqwest = { version = "0.12", features = ["json", "blocking"] }
  • Step 3: 更新 lib.rs
pub mod types;
pub mod board;
pub mod rules;
pub mod ai;
pub mod record;
pub mod network;
pub mod llm;
  • Step 4: 运行测试
cargo test -p gobang-core -- llm

Expected: PASS (parse 测试, 不需要网络).

  • Step 5: 提交
git add core/src/llm.rs core/src/lib.rs
git commit -m "feat(core): LLM AI — OpenAI 兼容 API 调用 + prompt/parse"

Task 10: 前端脚手架 — Tauri + React + Vite + TypeScript 初始化

Files:

  • Create: gui/Cargo.toml

  • Create: gui/build.rs

  • Create: gui/tauri.conf.json

  • Create: gui/src/main.rs

  • Create: gui/src/lib.rs

  • Create: package.json

  • Create: tsconfig.json

  • Create: tsconfig.node.json

  • Create: vite.config.ts

  • Create: index.html

  • Create: src/main.tsx

  • Create: src/App.tsx

  • Create: src/index.css

  • Create: src/App.css

  • Step 1: 创建 gui/Cargo.toml

[package]
name = "gobang-gui"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true

[build-dependencies]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
gobang-core = { path = "../core" }

文件路径: gui/Cargo.toml

  • Step 2: 创建 gui/build.rs
fn main() {
    tauri_build::build()
}
  • Step 3: 创建 gui/tauri.conf.json

从 PathEditor gui/tauri.conf.json 获取模板, 修改:

  • productName → "Gobang"
  • identifier → "com.liuhangyu.gobang"
  • title → "五子棋 v2.0"
  • 窗口默认大小 900x700
{
  "$schema": "https://raw.githubusercontent.com/nicknisi/tauri-config-schema/main/tauri.conf.json",
  "productName": "Gobang",
  "version": "2.0.0",
  "identifier": "com.liuhangyu.gobang",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeBuildCommand": "npm run build",
    "beforeDevCommand": "npm run dev"
  },
  "app": {
    "windows": [
      {
        "title": "五子棋 v2.0",
        "width": 900,
        "height": 700,
        "resizable": true,
        "center": true
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

文件路径: gui/tauri.conf.json

  • Step 4: 创建 gui/src/main.rs
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    gobang_gui::run()
}
  • Step 5: 创建 gui/src/lib.rs (空壳)
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  • Step 6: 创建 package.json

从 PathEditor package.json 获取模板, 替换:

  • name → "gobang"
  • productName → "Gobang"
  • version → "2.0.0"
{
  "name": "gobang",
  "private": true,
  "version": "2.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:watch": "vitest --watch"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "zustand": "^5.0.0",
    "i18next": "^24.0.0",
    "react-i18next": "^15.0.0",
    "@tauri-apps/api": "^2.0.0",
    "@tauri-apps/plugin-opener": "^2.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.4.0",
    "typescript": "~5.7.0",
    "vite": "^6.0.0",
    "vitest": "^3.0.0",
    "@tauri-apps/cli": "^2.0.0"
  }
}

文件路径: package.json

  • Step 7: 创建 tsconfig.json

从 PathEditor 复制 tsconfig.json

  • Step 8: 创建 vite.config.ts

从 PathEditor 复制 vite.config.ts, 修改端口以适应 Tauri devUrl。

  • Step 9: 创建 index.html
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>五子棋 v2.0</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  • Step 10: 创建 src/main.tsx, src/App.tsx, src/index.css, src/App.css (最小可用)
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
// src/App.tsx
function App() {
  return (
    <div className="app">
      <h1>五子棋 v2.0</h1>
    </div>
  );
}

export default App;
  • Step 11: npm install + 验证编译
npm install
npx tauri dev

Expected: 窗口打开, 显示 "五子棋 v2.0"。

  • Step 12: 提交
git add gui/ package.json tsconfig.json tsconfig.node.json vite.config.ts index.html src/
git commit -m "feat: Tauri + React + Vite + TypeScript 前端脚手架"

Task 11: gui/commands.rs + AppState — Tauri IPC 桥接

Files:

  • Create: gui/src/commands.rs

  • Modify: gui/src/lib.rs

  • Step 1: 实现 commands.rs

use gobang_core::ai::{AlphaBetaAi, AiEngine, evaluate, search};
use gobang_core::board::Board;
use gobang_core::types::*;
use gobang_core::rules;
use std::sync::Mutex;
use tauri::State;

/// 应用全局状态
pub struct AppState {
    pub board: Mutex<Option<Board>>,
    pub game_mode: Mutex<GameMode>,
    pub config: Mutex<GameConfig>,
    pub ai_engine: Mutex<Option<AlphaBetaAi>>,
    pub current_color: Mutex<Color>,
    pub game_over: Mutex<bool>,
}

impl Default for AppState {
    fn default() -> Self {
        Self {
            board: Mutex::new(None),
            game_mode: Mutex::new(GameMode::Local),
            config: Mutex::new(GameConfig::default()),
            ai_engine: Mutex::new(None),
            current_color: Mutex::new(Color::Black),
            game_over: Mutex::new(true),
        }
    }
}

#[tauri::command]
fn new_game(mode: GameMode, config: GameConfig, state: State<AppState>) -> Result<(), String> {
    let board = Board::new(config.board_size);
    *state.board.lock().map_err(|e| e.to_string())? = Some(board);
    *state.game_mode.lock().map_err(|e| e.to_string())? = mode;
    *state.config.lock().map_err(|e| e.to_string())? = config.clone();
    *state.current_color.lock().map_err(|e| e.to_string())? = config.player_color;
    *state.game_over.lock().map_err(|e| e.to_string())? = false;

    // 初始化 AI (如果是人机模式)
    if mode == GameMode::VsAi {
        let ai = AlphaBetaAi::new(config.ai_difficulty as usize);
        *state.ai_engine.lock().map_err(|e| e.to_string())? = Some(ai);
    }

    Ok(())
}

#[tauri::command]
fn place_piece(x: usize, y: usize, state: State<AppState>) -> Result<MoveResult, String> {
    let mut game_over = state.game_over.lock().map_err(|e| e.to_string())?;
    if *game_over {
        return Err("游戏已结束".into());
    }

    let mut board_opt = state.board.lock().map_err(|e| e.to_string())?;
    let board = board_opt.as_ref().ok_or("游戏未开始")?;
    let color = *state.current_color.lock().map_err(|e| e.to_string())?;
    let config = state.config.lock().map_err(|e| e.to_string())?;

    let pos = Position::new(x, y);

    // 禁手检查
    if config.use_forbidden_rules && rules::is_forbidden(board, pos, color) {
        return Err("禁手位置,不能落子".into());
    }

    let new_board = board.place(pos, color).map_err(|e| e.to_string())?;
    let is_win = new_board.check_win(pos);

    if is_win {
        *game_over = true;
    }

    *state.current_color.lock().map_err(|e| e.to_string())? = color.opponent();
    *board_opt = Some(new_board);

    Ok(MoveResult {
        position: pos,
        is_win,
        is_forbidden: false,
    })
}

#[tauri::command]
fn undo(steps: u32, state: State<AppState>) -> Result<(), String> {
    let mut board_opt = state.board.lock().map_err(|e| e.to_string())?;
    let mut board = board_opt.clone().ok_or("游戏未开始")?;

    for _ in 0..steps * 2 {
        // 每步撤销双方各一手
        board = board.undo().map_err(|e| e.to_string())?;
    }

    *board_opt = Some(board);
    Ok(())
}

#[tauri::command]
fn get_board(state: State<AppState>) -> Result<Vec<Vec<i32>>, String> {
    let board_opt = state.board.lock().map_err(|e| e.to_string())?;
    let board = board_opt.as_ref().ok_or("游戏未开始")?;

    let mut result = vec![vec![0i32; board.size]; board.size];
    for x in 0..board.size {
        for y in 0..board.size {
            result[x][y] = match board.get(Position::new(x, y)) {
                CellState::Empty => 0,
                CellState::Occupied(Color::Black) => 1,
                CellState::Occupied(Color::White) => 2,
            };
        }
    }
    Ok(result)
}

#[tauri::command]
fn ai_move(state: State<AppState>) -> Result<Option<(usize, usize)>, String> {
    let board_opt = state.board.lock().map_err(|e| e.to_string())?;
    let board = board_opt.as_ref().ok_or("游戏未开始")?;
    let color = *state.current_color.lock().map_err(|e| e.to_string())?;
    let ai = state.ai_engine.lock().map_err(|e| e.to_string())?;
    let ai = ai.as_ref().ok_or("AI 未初始化")?;

    Ok(ai.best_move(board, color).map(|p| (p.x, p.y)))
}

#[tauri::command]
fn get_game_state(state: State<AppState>) -> Result<serde_json::Value, String> {
    let board_opt = state.board.lock().map_err(|e| e.to_string())?;
    let color = *state.current_color.lock().map_err(|e| e.to_string())?;
    let game_over = *state.game_over.lock().map_err(|e| e.to_string())?;
    let board = board_opt.as_ref();

    let cells: Vec<Vec<i32>> = board.map(|b| {
        (0..b.size).map(|x| {
            (0..b.size).map(move |y| {
                match b.get(Position::new(x, y)) {
                    CellState::Empty => 0,
                    CellState::Occupied(Color::Black) => 1,
                    CellState::Occupied(Color::White) => 2,
                }
            }).collect()
        }).collect()
    }).unwrap_or_default();

    Ok(serde_json::json!({
        "board": cells,
        "current_color": match color { Color::Black => "Black", Color::White => "White" },
        "game_over": game_over,
    }))
}

文件路径: gui/src/commands.rs

  • Step 2: 更新 gui/src/lib.rs 注册命令
mod commands;

use commands::AppState;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .manage(AppState::default())
        .invoke_handler(tauri::generate_handler![
            commands::new_game,
            commands::place_piece,
            commands::undo,
            commands::get_board,
            commands::ai_move,
            commands::get_game_state,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

文件路径: gui/src/lib.rs

  • Step 3: 编译验证
cargo check
  • Step 4: 提交
git add gui/
git commit -m "feat(gui): Tauri IPC 命令 — new_game/place_piece/undo/ai_move/get_board/get_game_state"

Task 12: 前端核心 — types.ts + constants.ts + i18n + store

Files:

  • Create: src/core/types.ts

  • Create: src/core/constants.ts

  • Create: src/i18n/index.ts

  • Create: src/i18n/zh-CN.json

  • Create: src/i18n/en.json

  • Create: src/store/gameStore.ts

  • Step 1: 创建 src/core/types.ts

export type Color = 'Black' | 'White';

export interface Position {
  x: number;
  y: number;
}

export type CellState = 0 | 1 | 2; // 0=Empty, 1=Black, 2=White

export type GameStatus = 'waiting' | 'playing' | 'ai_thinking' | 'game_over';

export type GameModeType = 'Local' | 'VsAi' | 'Online' | 'Replay';

export interface GameConfig {
  boardSize: number;
  useForbiddenRules: boolean;
  useTimer: boolean;
  timeLimitSecs: number;
  aiDifficulty: number;
  playerColor: Color;
  isServer: boolean;
}

export interface MoveResult {
  position: Position;
  is_win: boolean;
  is_forbidden: boolean;
}

export interface Move {
  position: Position;
  color: Color;
  turn: number;
}
  • Step 2: 创建 src/core/constants.ts
export const DEFAULT_BOARD_SIZE = 15;
export const MIN_BOARD_SIZE = 9;
export const MAX_BOARD_SIZE = 19;

export const CELL_COLORS: Record<number, string> = {
  0: 'transparent',
  1: '#1a1a1a', // 黑子
  2: '#f5f5f5', // 白子
};
  • Step 3: 创建 src/i18n/zh-CN.json
{
  "app": {
    "title": "五子棋 v2.0"
  },
  "menu": {
    "local_game": "本地双人",
    "ai_game": "人机对战",
    "online_game": "网络对战",
    "load_replay": "加载棋谱",
    "settings": "设置"
  },
  "game": {
    "black_turn": "黑棋回合",
    "white_turn": "白棋回合",
    "black_win": "黑棋获胜!",
    "white_win": "白棋获胜!",
    "draw": "平局",
    "ai_thinking": "AI 思考中...",
    "undo": "悔棋",
    "resign": "认输",
    "save": "保存棋谱",
    "new_game": "新游戏",
    "waiting_opponent": "等待对手加入...",
    "your_turn": "你的回合",
    "opponent_turn": "对手回合"
  },
  "replay": {
    "play": "播放",
    "pause": "暂停",
    "next": "下一步",
    "prev": "上一步",
    "step": "第 {{current}}/{{total}} 步"
  },
  "settings": {
    "board_size": "棋盘大小",
    "forbidden_rules": "禁手规则",
    "timer": "计时器",
    "time_limit": "时间限制(秒)",
    "difficulty": "AI 难度",
    "language": "语言"
  }
}
  • Step 4: 创建 src/i18n/en.json
{
  "app": {
    "title": "Gobang v2.0"
  },
  "menu": {
    "local_game": "Local 2-Player",
    "ai_game": "VS AI",
    "online_game": "Online",
    "load_replay": "Load Replay",
    "settings": "Settings"
  },
  "game": {
    "black_turn": "Black's Turn",
    "white_turn": "White's Turn",
    "black_win": "Black Wins!",
    "white_win": "White Wins!",
    "draw": "Draw",
    "ai_thinking": "AI Thinking...",
    "undo": "Undo",
    "resign": "Resign",
    "save": "Save Record",
    "new_game": "New Game",
    "waiting_opponent": "Waiting for Opponent...",
    "your_turn": "Your Turn",
    "opponent_turn": "Opponent's Turn"
  },
  "replay": {
    "play": "Play",
    "pause": "Pause",
    "next": "Next",
    "prev": "Prev",
    "step": "Step {{current}}/{{total}}"
  },
  "settings": {
    "board_size": "Board Size",
    "forbidden_rules": "Forbidden Rules",
    "timer": "Timer",
    "time_limit": "Time Limit (s)",
    "difficulty": "AI Difficulty",
    "language": "Language"
  }
}
  • Step 5: 创建 src/i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './zh-CN.json';
import en from './en.json';

i18n
  .use(initReactI18next)
  .init({
    resources: {
      'zh-CN': { translation: zhCN },
      en: { translation: en },
    },
    lng: 'zh-CN',
    fallbackLng: 'zh-CN',
    interpolation: { escapeValue: false },
  });

export default i18n;
  • Step 6: 创建 src/store/gameStore.ts
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import type { CellState, Color, GameConfig, GameModeType, GameStatus, Move, MoveResult } from '../core/types';

interface GameState {
  mode: GameModeType;
  board: CellState[][];
  boardSize: number;
  currentColor: Color;
  status: GameStatus;
  winner: Color | null;
  moves: Move[];
  config: GameConfig;
  isSaving: boolean;

  // Actions
  startGame: (mode: GameModeType, config: GameConfig) => Promise<void>;
  placePiece: (x: number, y: number) => Promise<MoveResult>;
  undo: (steps?: number) => Promise<void>;
  aiMove: () => Promise<void>;
  refreshBoard: () => Promise<void>;
  loadReplayBoard: (board: CellState[][], moves: Move[]) => void;
}

export const useGameStore = create<GameState>((set, get) => ({
  mode: 'Local',
  board: [],
  boardSize: 15,
  currentColor: 'Black',
  status: 'waiting',
  winner: null,
  moves: [],
  config: {
    boardSize: 15,
    useForbiddenRules: true,
    useTimer: false,
    timeLimitSecs: 60,
    aiDifficulty: 3,
    playerColor: 'Black',
    isServer: false,
  },
  isSaving: false,

  startGame: async (mode, config) => {
    await invoke('new_game', { mode, config });
    set({
      mode,
      config,
      boardSize: config.boardSize,
      status: mode === 'VsAi' && config.playerColor === 'White' ? 'ai_thinking' : 'playing',
      currentColor: 'Black',
      winner: null,
      moves: [],
    });
    await get().refreshBoard();
  },

  placePiece: async (x, y) => {
    const result: MoveResult = await invoke('place_piece', { x, y });
    await get().refreshBoard();
    if (result.is_win) {
      set({ status: 'game_over' });
    }
    return result;
  },

  undo: async (steps = 1) => {
    await invoke('undo', { steps });
    await get().refreshBoard();
  },

  aiMove: async () => {
    set({ status: 'ai_thinking' });
    const pos: [number, number] | null = await invoke('ai_move');
    if (pos) {
      const result = await get().placePiece(pos[0], pos[1]);
      if (!result.is_win) {
        set({ status: 'playing' });
      }
    } else {
      set({ status: 'playing' });
    }
  },

  refreshBoard: async () => {
    const state: { board: CellState[][]; current_color: string; game_over: boolean } =
      await invoke('get_game_state');
    set({
      board: state.board,
      currentColor: state.current_color as Color,
      status: state.game_over ? 'game_over' : get().status === 'ai_thinking' ? 'ai_thinking' : 'playing',
    });
  },

  loadReplayBoard: (board, moves) => {
    set({ board, moves, mode: 'Replay', status: 'playing' });
  },
}));
  • Step 7: 验证编译
npx tsc -b
  • Step 8: 提交
git add src/core/ src/i18n/ src/store/
git commit -m "feat(frontend): 类型定义 + i18n 中英双语 + Zustand store"

Task 13: 前端 — BoardCanvas + board-renderer

Files:

  • Create: src/components/board/board-renderer.ts

  • Create: src/components/board/BoardCanvas.tsx

  • Step 1: 创建 board-renderer.ts (纯函数, 零 React 依赖)

import type { CellState, Position } from '../../core/types';

export interface RenderConfig {
  cellSize: number;
  padding: number;
  boardSize: number;
}

export function computeBoardDimensions(boardSize: number, canvasWidth: number, canvasHeight: number): RenderConfig {
  const maxBoardPixelSize = Math.min(canvasWidth, canvasHeight) * 0.85;
  const cellSize = Math.floor(maxBoardPixelSize / (boardSize - 1));
  const actualBoardPixelSize = cellSize * (boardSize - 1);
  const padding = Math.floor((Math.min(canvasWidth, canvasHeight) - actualBoardPixelSize) / 2);
  return { cellSize, padding, boardSize };
}

export function canvasToBoard(
  canvasX: number,
  canvasY: number,
  cfg: RenderConfig
): Position | null {
  const col = Math.round((canvasX - cfg.padding) / cfg.cellSize);
  const row = Math.round((canvasY - cfg.padding) / cfg.cellSize);
  if (col < 0 || col >= cfg.boardSize || row < 0 || row >= cfg.boardSize) return null;
  return { x: row, y: col };
}

export function boardToCanvas(pos: Position, cfg: RenderConfig): { x: number; y: number } {
  return {
    x: cfg.padding + pos.y * cfg.cellSize,
    y: cfg.padding + pos.x * cfg.cellSize,
  };
}

export function renderBoard(
  ctx: CanvasRenderingContext2D,
  board: CellState[][],
  cfg: RenderConfig,
  lastMove: Position | null
): void {
  const { cellSize, padding, boardSize } = cfg;
  const width = padding * 2 + (boardSize - 1) * cellSize;
  const height = width;

  // 背景 (木纹色)
  ctx.fillStyle = '#DEB887';
  ctx.fillRect(0, 0, width + padding, height + padding);

  // 棋盘区域
  ctx.fillStyle = '#F5DEB3';
  ctx.fillRect(padding - 10, padding - 10, (boardSize - 1) * cellSize + 20, (boardSize - 1) * cellSize + 20);

  // 网格线
  ctx.strokeStyle = '#8B7355';
  ctx.lineWidth = 1;
  for (let i = 0; i < boardSize; i++) {
    // 水平线
    ctx.beginPath();
    ctx.moveTo(padding, padding + i * cellSize);
    ctx.lineTo(padding + (boardSize - 1) * cellSize, padding + i * cellSize);
    ctx.stroke();
    // 垂直线
    ctx.beginPath();
    ctx.moveTo(padding + i * cellSize, padding);
    ctx.lineTo(padding + i * cellSize, padding + (boardSize - 1) * cellSize);
    ctx.stroke();
  }

  // 星位 (天元和四角星)
  const starPoints = [
    [3, 3], [3, 7], [3, 11],
    [7, 3], [7, 7], [7, 11],
    [11, 3], [11, 7], [11, 11],
  ];
  ctx.fillStyle = '#8B7355';
  for (const [r, c] of starPoints) {
    if (r < boardSize && c < boardSize) {
      const { x, y } = boardToCanvas({ x: r, y: c }, cfg);
      ctx.beginPath();
      ctx.arc(x, y, 3, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // 棋子
  for (let x = 0; x < boardSize; x++) {
    for (let y = 0; y < boardSize; y++) {
      if (board[x]?.[y] === 0) continue;
      const { x: cx, y: cy } = boardToCanvas({ x, y }, cfg);
      const radius = cellSize * 0.43;

      if (board[x][y] === 1) {
        // 黑子渐变
        const gradient = ctx.createRadialGradient(cx - 2, cy - 2, 1, cx, cy, radius);
        gradient.addColorStop(0, '#4a4a4a');
        gradient.addColorStop(1, '#1a1a1a');
        ctx.fillStyle = gradient;
      } else {
        // 白子渐变
        const gradient = ctx.createRadialGradient(cx - 2, cy - 2, 1, cx, cy, radius);
        gradient.addColorStop(0, '#ffffff');
        gradient.addColorStop(1, '#d0d0d0');
        ctx.fillStyle = gradient;
      }

      ctx.beginPath();
      ctx.arc(cx, cy, radius, 0, Math.PI * 2);
      ctx.fill();

      // 白子边框
      if (board[x][y] === 2) {
        ctx.strokeStyle = '#b0b0b0';
        ctx.lineWidth = 1;
        ctx.stroke();
      }
    }
  }

  // 最后一手高亮
  if (lastMove) {
    const { x, y } = boardToCanvas(lastMove, cfg);
    ctx.strokeStyle = '#ff4444';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x, y, cellSize * 0.2, 0, Math.PI * 2);
    ctx.stroke();
  }
}
  • Step 2: 创建 BoardCanvas.tsx
import { useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '../../store/gameStore';
import {
  computeBoardDimensions,
  canvasToBoard,
  renderBoard,
  type RenderConfig,
} from './board-renderer';
import type { Position } from '../../core/types';

export default function BoardCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const board = useGameStore((s) => s.board);
  const boardSize = useGameStore((s) => s.boardSize);
  const status = useGameStore((s) => s.status);
  const mode = useGameStore((s) => s.mode);
  const placePiece = useGameStore((s) => s.placePiece);
  const aiMove = useGameStore((s) => s.aiMove);
  const moves = useGameStore((s) => s.moves);

  const lastMove = moves.length > 0 ? moves[moves.length - 1].position : null;

  const render = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width * dpr;
    canvas.height = rect.height * dpr;
    ctx.scale(dpr, dpr);

    const cfg = computeBoardDimensions(boardSize, rect.width, rect.height);
    renderBoard(ctx, board, cfg, lastMove);
  }, [board, boardSize, lastMove]);

  useEffect(() => {
    render();
    const handleResize = () => render();
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, [render]);

  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLCanvasElement>) => {
      if (status !== 'playing') return;
      if (mode === 'VsAi' && moves.length % 2 === 1) return; // AI 回合, 不响应点击
      if (mode === 'Replay') return; // 回放模式不落子

      const canvas = canvasRef.current;
      if (!canvas) return;
      const rect = canvas.getBoundingClientRect();
      const cfg = computeBoardDimensions(boardSize, rect.width, rect.height);
      const pos = canvasToBoard(e.clientX - rect.left, e.clientY - rect.top, cfg);
      if (!pos) return;

      placePiece(pos.x, pos.y).then((result) => {
        if (!result.is_win && mode === 'VsAi') {
          setTimeout(() => aiMove(), 100);
        }
      });
    },
    [status, mode, boardSize, moves.length, placePiece, aiMove]
  );

  return (
    <canvas
      ref={canvasRef}
      onClick={handleClick}
      style={{
        width: '100%',
        height: '100%',
        cursor: status === 'playing' && mode !== 'Replay' ? 'pointer' : 'default',
      }}
    />
  );
}
  • Step 3: 验证编译
npx tsc -b
  • Step 4: 提交
git add src/components/board/
git commit -m "feat(frontend): Canvas 棋盘渲染 — 木纹风格, 棋子渐变, 最后一手高亮"

Task 14: 前端 — 菜单组件

Files:

  • Create: src/components/menu/MainMenu.tsx

  • Create: src/components/menu/LocalGameSetup.tsx

  • Create: src/components/menu/AiGameSetup.tsx

  • Create: src/components/menu/OnlineSetup.tsx

  • Create: src/components/menu/LoadReplay.tsx

  • Step 1: 创建 MainMenu.tsx

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import LocalGameSetup from './LocalGameSetup';
import AiGameSetup from './AiGameSetup';
import OnlineSetup from './OnlineSetup';
import LoadReplay from './LoadReplay';

type View = 'main' | 'local' | 'ai' | 'online' | 'replay';

interface Props {
  onGameStart: () => void;
}

export default function MainMenu({ onGameStart }: Props) {
  const { t } = useTranslation();
  const [view, setView] = useState<View>('main');

  if (view === 'local') return <LocalGameSetup onBack={() => setView('main')} onStart={onGameStart} />;
  if (view === 'ai') return <AiGameSetup onBack={() => setView('main')} onStart={onGameStart} />;
  if (view === 'online') return <OnlineSetup onBack={() => setView('main')} onStart={onGameStart} />;
  if (view === 'replay') return <LoadReplay onBack={() => setView('main')} onStart={onGameStart} />;

  return (
    <div className="main-menu">
      <h1 className="menu-title">{t('app.title')}</h1>
      <div className="menu-buttons">
        <button onClick={() => setView('local')}>{t('menu.local_game')}</button>
        <button onClick={() => setView('ai')}>{t('menu.ai_game')}</button>
        <button onClick={() => setView('online')}>{t('menu.online_game')}</button>
        <button onClick={() => setView('replay')}>{t('menu.load_replay')}</button>
      </div>
    </div>
  );
}
  • Step 2: 创建 LocalGameSetup.tsx
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import type { GameConfig } from '../../core/types';

interface Props {
  onBack: () => void;
  onStart: () => void;
}

export default function LocalGameSetup({ onBack, onStart }: Props) {
  const { t } = useTranslation();
  const startGame = useGameStore((s) => s.startGame);

  const handleStart = async () => {
    const config: GameConfig = {
      boardSize: 15,
      useForbiddenRules: true,
      useTimer: false,
      timeLimitSecs: 60,
      aiDifficulty: 3,
      playerColor: 'Black',
      isServer: false,
    };
    await startGame('Local', config);
    onStart();
  };

  return (
    <div className="setup-panel">
      <h2>{t('menu.local_game')}</h2>
      <div className="setup-actions">
        <button onClick={handleStart}>{t('game.new_game')}</button>
        <button onClick={onBack}>返回</button>
      </div>
    </div>
  );
}
  • Step 3: 创建 AiGameSetup.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import type { Color, GameConfig } from '../../core/types';

interface Props {
  onBack: () => void;
  onStart: () => void;
}

export default function AiGameSetup({ onBack, onStart }: Props) {
  const { t } = useTranslation();
  const startGame = useGameStore((s) => s.startGame);
  const [difficulty, setDifficulty] = useState(3);
  const [playerColor, setPlayerColor] = useState<Color>('Black');
  const [useForbidden, setUseForbidden] = useState(true);

  const handleStart = async () => {
    const config: GameConfig = {
      boardSize: 15,
      useForbiddenRules: useForbidden,
      useTimer: false,
      timeLimitSecs: 60,
      aiDifficulty: difficulty,
      playerColor,
      isServer: false,
    };
    await startGame('VsAi', config);
    onStart();
  };

  return (
    <div className="setup-panel">
      <h2>{t('menu.ai_game')}</h2>
      <label>
        {t('settings.difficulty')}:
        <select value={difficulty} onChange={(e) => setDifficulty(Number(e.target.value))}>
          {[1, 2, 3, 4, 5].map((d) => (
            <option key={d} value={d}>{d}</option>
          ))}
        </select>
      </label>
      <label>
        先手:
        <select value={playerColor} onChange={(e) => setPlayerColor(e.target.value as Color)}>
          <option value="Black">黑棋 (先手)</option>
          <option value="White">白棋 (后手)</option>
        </select>
      </label>
      <label>
        <input type="checkbox" checked={useForbidden} onChange={(e) => setUseForbidden(e.target.checked)} />
        {t('settings.forbidden_rules')}
      </label>
      <div className="setup-actions">
        <button onClick={handleStart}>{t('game.new_game')}</button>
        <button onClick={onBack}>返回</button>
      </div>
    </div>
  );
}
  • Step 4: 创建 OnlineSetup.tsx 和 LoadReplay.tsx
// OnlineSetup.tsx
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import { useState } from 'react';

interface Props { onBack: () => void; onStart: () => void; }

export default function OnlineSetup({ onBack, onStart }: Props) {
  const { t } = useTranslation();
  const startGame = useGameStore((s) => s.startGame);
  const [ip, setIp] = useState('');

  const handleHost = async () => {
    await startGame('Online', { boardSize: 15, useForbiddenRules: true, useTimer: false, timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: true });
    onStart();
  };

  const handleJoin = async () => {
    await startGame('Online', { boardSize: 15, useForbiddenRules: true, useTimer: false, timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false });
    onStart();
  };

  return (
    <div className="setup-panel">
      <h2>{t('menu.online_game')}</h2>
      <button onClick={handleHost}>创建房间</button>
      <div>
        <input value={ip} onChange={(e) => setIp(e.target.value)} placeholder="IP:端口" />
        <button onClick={handleJoin} disabled={!ip}>加入房间</button>
      </div>
      <button onClick={onBack}>返回</button>
    </div>
  );
}
// LoadReplay.tsx
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import { useRef } from 'react';
import type { Move } from '../../core/types';

interface Props { onBack: () => void; onStart: () => void; }

export default function LoadReplay({ onBack, onStart }: Props) {
  const { t } = useTranslation();
  const loadReplayBoard = useGameStore((s) => s.loadReplayBoard);
  const fileRef = useRef<HTMLInputElement>(null);

  const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const json = JSON.parse(reader.result as string);
        const board: (0 | 1 | 2)[][] = Array.from({ length: json.board_size }, () =>
          Array(json.board_size).fill(0)
        );
        const moves: Move[] = [];
        for (const m of json.moves) {
          board[m.x][m.y] = m.color === 'Black' ? 1 : 2;
          moves.push({ position: { x: m.x, y: m.y }, color: m.color, turn: m.turn });
        }
        loadReplayBoard(board, moves);
        onStart();
      } catch {
        alert('无效的棋谱文件');
      }
    };
    reader.readAsText(file);
  };

  return (
    <div className="setup-panel">
      <h2>{t('menu.load_replay')}</h2>
      <input ref={fileRef} type="file" accept=".json" onChange={handleFile} />
      <button onClick={onBack}>返回</button>
    </div>
  );
}
  • Step 5: 验证编译
npx tsc -b
  • Step 6: 提交
git add src/components/menu/
git commit -m "feat(frontend): 菜单组件 — 主菜单/本地双人/AI设置/网络/加载棋谱"

Task 15: 前端 — 对局 + 回放视图

Files:

  • Create: src/components/game/GameView.tsx

  • Create: src/components/game/GameInfo.tsx

  • Create: src/components/game/GameControls.tsx

  • Create: src/components/game/TimerDisplay.tsx

  • Create: src/components/replay/ReplayView.tsx

  • Create: src/components/replay/StepSlider.tsx

  • Create: src/components/replay/ReplayControls.tsx

  • Create: src/hooks/useGame.ts

  • Create: src/hooks/useTimer.ts

  • Step 1: 创建 GameInfo.tsx

import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';

export default function GameInfo() {
  const { t } = useTranslation();
  const currentColor = useGameStore((s) => s.currentColor);
  const status = useGameStore((s) => s.status);
  const winner = useGameStore((s) => s.winner);

  let text = '';
  if (status === 'game_over' && winner) {
    text = winner === 'Black' ? t('game.black_win') : t('game.white_win');
  } else if (status === 'ai_thinking') {
    text = t('game.ai_thinking');
  } else if (status === 'playing') {
    text = currentColor === 'Black' ? t('game.black_turn') : t('game.white_turn');
  }

  return <div className="game-info">{text}</div>;
}
  • Step 2: 创建 GameControls.tsx
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';

interface Props {
  onBackToMenu: () => void;
}

export default function GameControls({ onBackToMenu }: Props) {
  const { t } = useTranslation();
  const undo = useGameStore((s) => s.undo);
  const mode = useGameStore((s) => s.mode);
  const status = useGameStore((s) => s.status);

  const handleUndo = () => {
    if (mode === 'VsAi') undo(1); // 人机模式悔棋悔双方各一手
    else undo(1);
  };

  return (
    <div className="game-controls">
      <button onClick={handleUndo} disabled={status === 'game_over'}>
        {t('game.undo')}
      </button>
      <button onClick={onBackToMenu}>{t('game.new_game')}</button>
    </div>
  );
}
  • Step 3: 创建 TimerDisplay.tsx
import { useState, useEffect } from 'react';
import { useGameStore } from '../../store/gameStore';

export default function TimerDisplay() {
  const config = useGameStore((s) => s.config);
  const currentColor = useGameStore((s) => s.currentColor);
  const status = useGameStore((s) => s.status);
  const [time, setTime] = useState(config.timeLimitSecs);

  useEffect(() => {
    if (!config.useTimer || status !== 'playing') return;
    setTime(config.timeLimitSecs);
    const timer = setInterval(() => {
      setTime((t) => {
        if (t <= 1) { clearInterval(timer); return 0; }
        return t - 1;
      });
    }, 1000);
    return () => clearInterval(timer);
  }, [currentColor, config.useTimer, config.timeLimitSecs, status]);

  if (!config.useTimer) return null;

  return (
    <div className={`timer-display ${time <= 10 ? 'timer-warning' : ''}`}>
      {Math.floor(time / 60)}:{(time % 60).toString().padStart(2, '0')}
    </div>
  );
}
  • Step 4: 创建 GameView.tsx
import BoardCanvas from '../board/BoardCanvas';
import GameInfo from './GameInfo';
import GameControls from './GameControls';
import TimerDisplay from './TimerDisplay';

interface Props {
  onBackToMenu: () => void;
}

export default function GameView({ onBackToMenu }: Props) {
  return (
    <div className="game-view">
      <GameInfo />
      <div className="board-container">
        <BoardCanvas />
      </div>
      <TimerDisplay />
      <GameControls onBackToMenu={onBackToMenu} />
    </div>
  );
}
  • Step 5: 创建 ReplayView.tsx + StepSlider.tsx + ReplayControls.tsx
// StepSlider.tsx
interface Props {
  current: number;
  total: number;
  onChange: (step: number) => void;
}

export default function StepSlider({ current, total, onChange }: Props) {
  return (
    <input
      type="range"
      min={0}
      max={total}
      value={current}
      onChange={(e) => onChange(Number(e.target.value))}
      className="step-slider"
    />
  );
}
// ReplayControls.tsx
import { useTranslation } from 'react-i18next';

interface Props {
  isPlaying: boolean;
  onTogglePlay: () => void;
  onPrev: () => void;
  onNext: () => void;
}

export default function ReplayControls({ isPlaying, onTogglePlay, onPrev, onNext }: Props) {
  const { t } = useTranslation();
  return (
    <div className="replay-controls">
      <button onClick={onPrev}>{t('replay.prev')}</button>
      <button onClick={onTogglePlay}>{isPlaying ? t('replay.pause') : t('replay.play')}</button>
      <button onClick={onNext}>{t('replay.next')}</button>
    </div>
  );
}
// ReplayView.tsx
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import BoardCanvas from '../board/BoardCanvas';
import StepSlider from './StepSlider';
import ReplayControls from './ReplayControls';

interface Props {
  onBackToMenu: () => void;
}

export default function ReplayView({ onBackToMenu }: Props) {
  const { t } = useTranslation();
  const moves = useGameStore((s) => s.moves);
  const [step, setStep] = useState(moves.length);
  const [isPlaying, setIsPlaying] = useState(false);

  useEffect(() => {
    if (!isPlaying) return;
    if (step >= moves.length) {
      setIsPlaying(false);
      return;
    }
    const timer = setInterval(() => setStep((s) => s + 1), 500);
    return () => clearInterval(timer);
  }, [isPlaying, step, moves.length]);

  return (
    <div className="replay-view">
      <div className="board-container">
        <BoardCanvas />
      </div>
      <StepSlider current={step} total={moves.length} onChange={setStep} />
      <div>{t('replay.step', { current: step, total: moves.length })}</div>
      <ReplayControls
        isPlaying={isPlaying}
        onTogglePlay={() => setIsPlaying(!isPlaying)}
        onPrev={() => setStep(Math.max(0, step - 1))}
        onNext={() => setStep(Math.min(moves.length, step + 1))}
      />
      <button onClick={onBackToMenu}>返回菜单</button>
    </div>
  );
}
  • Step 6: 创建 hooks
// src/hooks/useGame.ts
import { useCallback } from 'react';
import { useGameStore } from '../store/gameStore';
import type { GameConfig, GameModeType } from '../core/types';

export function useGame() {
  const store = useGameStore();

  const startGame = useCallback(async (mode: GameModeType, config: GameConfig) => {
    await store.startGame(mode, config);
  }, [store]);

  return { ...store, startGame };
}
// src/hooks/useTimer.ts
import { useState, useEffect } from 'react';

export function useTimer(seconds: number, active: boolean, onTimeout: () => void) {
  const [time, setTime] = useState(seconds);

  useEffect(() => {
    if (!active) return;
    setTime(seconds);
    const timer = setInterval(() => {
      setTime((t) => {
        if (t <= 1) { clearInterval(timer); onTimeout(); return 0; }
        return t - 1;
      });
    }, 1000);
    return () => clearInterval(timer);
  }, [active, seconds, onTimeout]);

  return time;
}
  • Step 7: 验证编译
npx tsc -b
  • Step 8: 提交
git add src/components/game/ src/components/replay/ src/hooks/
git commit -m "feat(frontend): 对局视图 + 回放视图 + 计时器 hook"

Task 16: 前端 — App.tsx 集成 + 样式

Files:

  • Modify: src/App.tsx

  • Modify: src/App.css

  • Modify: src/index.css

  • Step 1: 更新 App.tsx — 路由集成

import { useState } from 'react';
import { useGameStore } from './store/gameStore';
import MainMenu from './components/menu/MainMenu';
import GameView from './components/game/GameView';
import ReplayView from './components/replay/ReplayView';
import i18n from './i18n';
import './App.css';

type Page = 'menu' | 'game' | 'replay';

function App() {
  const [page, setPage] = useState<Page>('menu');
  const mode = useGameStore((s) => s.mode);

  const handleGameStart = () => {
    setPage('game');
  };

  const handleReplayStart = () => {
    setPage('replay');
  };

  const handleBackToMenu = () => {
    setPage('menu');
  };

  if (page === 'game') {
    return <GameView onBackToMenu={handleBackToMenu} />;
  }

  if (page === 'replay') {
    return <ReplayView onBackToMenu={handleBackToMenu} />;
  }

  return <MainMenu onGameStart={handleGameStart} />;
}

export default App;
  • Step 2: 创建 App.css — 木纹风格样式
/* 全局变量 */
:root {
  --bg-primary: #3C2415;
  --bg-secondary: #F5DEB3;
  --text-primary: #F5DEB3;
  --text-secondary: #3C2415;
  --accent: #8B4513;
  --accent-hover: #A0522D;
  --button-bg: #DEB887;
  --button-hover: #D2B48C;
  --border: #8B7355;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: 'Microsoft YaHei', sans-serif;
  background-color: var(--bg-primary);
  color: var(--text-primary);
}

.app {
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* 主菜单 */
.main-menu {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  gap: 40px;
}

.menu-title {
  font-size: 42px;
  color: var(--text-primary);
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}

.menu-buttons {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.menu-buttons button {
  width: 240px;
  padding: 14px 28px;
  font-size: 18px;
  border: 2px solid var(--border);
  border-radius: 8px;
  background: var(--button-bg);
  color: var(--text-secondary);
  cursor: pointer;
  transition: all 0.2s;
}

.menu-buttons button:hover {
  background: var(--button-hover);
  transform: scale(1.03);
}

/* 设置面板 */
.setup-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  gap: 24px;
}

.setup-panel h2 {
  font-size: 28px;
}

.setup-panel label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 16px;
}

.setup-panel select, .setup-panel input {
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-secondary);
  color: var(--text-secondary);
  font-size: 14px;
}

.setup-actions {
  display: flex;
  gap: 12px;
  margin-top: 16px;
}

button {
  padding: 10px 24px;
  font-size: 16px;
  border: 2px solid var(--border);
  border-radius: 6px;
  background: var(--button-bg);
  color: var(--text-secondary);
  cursor: pointer;
  transition: all 0.2s;
}

button:hover {
  background: var(--button-hover);
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* 对局视图 */
.game-view {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100vw;
  height: 100vh;
  padding: 12px;
  gap: 8px;
}

.board-container {
  flex: 1;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

.game-info {
  font-size: 20px;
  font-weight: bold;
  padding: 8px;
}

.game-controls {
  display: flex;
  gap: 12px;
  padding: 8px;
}

.timer-display {
  font-size: 24px;
  font-family: monospace;
}

.timer-warning {
  color: #ff4444;
  animation: blink 0.5s infinite alternate;
}

@keyframes blink {
  from { opacity: 1; }
  to { opacity: 0.3; }
}

/* 回放视图 */
.replay-view {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100vw;
  height: 100vh;
  padding: 12px;
  gap: 8px;
}

.step-slider {
  width: 80%;
  accent-color: var(--accent);
}

.replay-controls {
  display: flex;
  gap: 12px;
}
  • Step 3: 更新 index.css
html, body, #root {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
  • Step 4: 验证
npx tauri dev

手动测试: 点击菜单项进入各页面, 验证 Canvas 渲染正常, 木纹风格生效。

  • Step 5: 提交
git add src/App.tsx src/App.css src/index.css
git commit -m "feat(frontend): App 路由集成 + 木纹风格 CSS"

Task 17: 开源文件 + README

Files:

  • Create: LICENSE

  • Create: CHANGELOG.md

  • Create: CODE_OF_CONDUCT.md

  • Create: CONTRIBUTING.md

  • Create: SECURITY.md

  • Rewrite: README.md

  • Step 1: 从 PathEditor 复制开源文件并替换项目名

所有文件以 PathEditor 对应文件为模板:

  • LICENSE — 从 D:\Code\doing_exercises\programs\PathEditor\LICENSE 复制 (MIT)
  • CODE_OF_CONDUCT.md — 从 PathEditor 复制 (行为准则通用, 无需修改)
  • CONTRIBUTING.md — 从 PathEditor 复制, 替换:
    • PathEditor → Gobang
    • patheditor → gobang
    • 删除 CLI 相关内容 (无 cli crate)
    • Rust + Node.js 版本要求保持一致
  • SECURITY.md — 从 PathEditor 复制, 替换:
    • PathEditor → Gobang
    • ~/.patheditor/~/.gobang/
    • 版本改为 v2.x
  • CHANGELOG.md — 新建, 写入:
# Changelog

## 2.0.0 (2026-05-30)

### Added
- Rust + Tauri 2.x + React 19 + TypeScript strict 全重写
- Cargo workspace 两 crate 架构 (core + gui)
- Canvas 木纹风格棋盘渲染
- 中英双语界面 (i18next)
- Alpha-Beta 剪枝 AI 引擎 (5 级难度)
- LLM 大模型 AI (OpenAI 兼容 API)
- renet 网络对战 (纯 Rust ENet 协议)
- JSON 棋谱记录与回放
- Zustand 状态管理

### Changed
- 从 C + IUP + CMake 迁移到 Rust + Tauri + Vite
- 棋谱格式从二进制改为 JSON
- 网络协议从 ENet C 库改为 renet 纯 Rust 实现
- AI 从评分制升级为 Alpha-Beta 搜索
  • Step 2: 重写 README.md
# Gobang (五子棋) v2.0

Rust + Tauri 2.x + React 19 构建的五子棋桌面应用。

## 功能

- 本地双人对战
- 人机对战 (Alpha-Beta 剪枝 AI, 5 级难度)
- 网络对战 (renet P2P)
- LLM 大模型 AI
- 棋谱记录与回放 (JSON)
- 禁手规则
- 中/英双语

## 开发

### 环境要求

- Node.js 22+
- Rust 1.95+ (stable-x86_64-pc-windows-gnu)
- MinGW-w64
- Windows 10+

### 命令

```bash
npm install
npx tauri dev     # 开发模式
npx tauri build   # 生产构建
cargo test        # Rust 测试
npm test          # 前端测试
cargo clippy -- -D warnings  # Lint

架构

core/   # Rust 游戏核心库 (零 Tauri 依赖)
gui/    # Tauri 桌面应用 (薄命令层)
src/    # React 前端 (TypeScript strict)

许可

MIT


- [ ] **Step 3: 提交**

```bash
git add LICENSE CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md README.md
git commit -m "docs: 开源文件 — LICENSE/CHANGELOG/CODE_OF_CONDUCT/CONTRIBUTING/SECURITY/README"

最终验证

# Rust 全量检查
cargo check
cargo clippy -- -D warnings
cargo test

# 前端检查
npx tsc -b
npm test

# 完整构建
npx tauri build