# 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 两 crate(core/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** ```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** ```toml [toolchain] channel = "stable-x86_64-pc-windows-gnu" ``` 文件路径: `rust-toolchain.toml` - [ ] **Step 3: 创建 core/Cargo.toml** ```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 (空壳)** ```rust // 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 编译** ```bash cargo check ``` Expected: `core` crate 编译成功,无错误。 - [ ] **Step 7: 提交** ```bash 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: 编写类型定义** ```rust 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 { 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, pub win_positions: Vec, } /// 游戏模式 (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 声明模块** ```rust // Gobang core library — 纯游戏逻辑,零 GUI 依赖 pub mod types; ``` 文件路径: `core/src/lib.rs` - [ ] **Step 3: 编译验证** ```bash cargo check -p gobang-core ``` Expected: 编译成功。 - [ ] **Step 4: 提交** ```bash 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)** ```rust #[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: 运行测试确认失败** ```bash cargo test -p gobang-core ``` Expected: 所有测试 FAIL, `Board` 未定义。 - [ ] **Step 3: 实现 Board** ```rust 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, 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 { 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 { 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 { 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: 运行测试确认通过** ```bash cargo test -p gobang-core ``` Expected: 所有 board 测试 PASS. - [ ] **Step 5: 更新 lib.rs** ```rust pub mod types; pub mod board; ``` - [ ] **Step 6: 最终编译 + 测试验证** ```bash cargo test -p gobang-core ``` Expected: 全部 PASS. - [ ] **Step 7: 提交** ```bash 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: 编写测试** ```rust #[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: 运行测试确认失败** ```bash cargo test -p gobang-core -- rules ``` Expected: FAIL. - [ ] **Step 3: 实现禁手检测** ```rust 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 { 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: 运行测试确认通过** ```bash cargo test -p gobang-core -- rules ``` Expected: 所有 rules 测试 PASS. - [ ] **Step 5: 更新 lib.rs** ```rust pub mod types; pub mod board; pub mod rules; ``` - [ ] **Step 6: 提交** ```bash 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** ```rust 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; } pub mod evaluate; pub mod search; ``` 文件路径: `core/src/ai/mod.rs` - [ ] **Step 2: 编写 evaluate 测试** ```rust #[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: 运行测试确认失败** ```bash cargo test -p gobang-core -- evaluate ``` Expected: FAIL. - [ ] **Step 4: 实现棋形评分** ```rust 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: 运行测试确认通过** ```bash cargo test -p gobang-core -- evaluate ``` Expected: PASS. - [ ] **Step 6: 更新 lib.rs** ```rust pub mod types; pub mod board; pub mod rules; pub mod ai; ``` - [ ] **Step 7: 提交** ```bash 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: 编写测试** ```rust #[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: 运行测试确认失败** ```bash cargo test -p gobang-core -- search ``` Expected: FAIL. - [ ] **Step 3: 实现 AlphaBetaAi** ```rust 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 { 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: 运行测试确认通过** ```bash cargo test -p gobang-core -- search ``` Expected: 所有测试 PASS. - [ ] **Step 5: 提交** ```bash 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: 编写测试** ```rust #[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: 运行测试确认失败** ```bash cargo test -p gobang-core -- record ``` Expected: FAIL. - [ ] **Step 3: 实现棋谱模块** ```rust 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, pub moves: Vec, } /// 棋谱中的一步 #[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) -> 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 { 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.rs` 的 `impl Board` 中添加: ```rust /// 获取落子历史 (用于棋谱) pub fn history(&self) -> &[Move] { &self.history } ``` - [ ] **Step 5: 更新 lib.rs** ```rust pub mod types; pub mod board; pub mod rules; pub mod ai; pub mod record; ``` - [ ] **Step 6: 运行测试确认通过** ```bash cargo test -p gobang-core ``` Expected: 全部 PASS. - [ ] **Step 7: 提交** ```bash 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)** ```rust 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, } 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 { 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** ```rust pub mod types; pub mod board; pub mod rules; pub mod ai; pub mod record; pub mod network; ``` - [ ] **Step 3: 编译验证** ```bash cargo check -p gobang-core ``` - [ ] **Step 4: 提交** ```bash 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)** ```rust 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 { // 尝试匹配 "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::(), y_str.parse::()) { return Some(Position::new(x, y)); } } } None } } impl AiEngine for LlmAi { fn best_move(&self, board: &Board, color: Color) -> Option { // 同步 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` 已包含: ```toml reqwest = { version = "0.12", features = ["json", "blocking"] } ``` - [ ] **Step 3: 更新 lib.rs** ```rust pub mod types; pub mod board; pub mod rules; pub mod ai; pub mod record; pub mod network; pub mod llm; ``` - [ ] **Step 4: 运行测试** ```bash cargo test -p gobang-core -- llm ``` Expected: PASS (parse 测试, 不需要网络). - [ ] **Step 5: 提交** ```bash 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** ```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** ```rust 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 ```json { "$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** ```rust // 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 (空壳)** ```rust 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" ```json { "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** ```html 五子棋 v2.0
``` - [ ] **Step 10: 创建 src/main.tsx, src/App.tsx, src/index.css, src/App.css (最小可用)** ```tsx // 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( ); ``` ```tsx // src/App.tsx function App() { return (

五子棋 v2.0

); } export default App; ``` - [ ] **Step 11: npm install + 验证编译** ```bash npm install npx tauri dev ``` Expected: 窗口打开, 显示 "五子棋 v2.0"。 - [ ] **Step 12: 提交** ```bash 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** ```rust 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>, pub game_mode: Mutex, pub config: Mutex, pub ai_engine: Mutex>, pub current_color: Mutex, pub game_over: Mutex, } 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) -> 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) -> Result { 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) -> 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) -> Result>, 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) -> Result, 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) -> Result { 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> = 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 注册命令** ```rust 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: 编译验证** ```bash cargo check ``` - [ ] **Step 4: 提交** ```bash 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** ```typescript 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** ```typescript export const DEFAULT_BOARD_SIZE = 15; export const MIN_BOARD_SIZE = 9; export const MAX_BOARD_SIZE = 19; export const CELL_COLORS: Record = { 0: 'transparent', 1: '#1a1a1a', // 黑子 2: '#f5f5f5', // 白子 }; ``` - [ ] **Step 3: 创建 src/i18n/zh-CN.json** ```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** ```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** ```typescript 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** ```typescript 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; placePiece: (x: number, y: number) => Promise; undo: (steps?: number) => Promise; aiMove: () => Promise; refreshBoard: () => Promise; loadReplayBoard: (board: CellState[][], moves: Move[]) => void; } export const useGameStore = create((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: 验证编译** ```bash npx tsc -b ``` - [ ] **Step 8: 提交** ```bash 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 依赖)** ```typescript 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** ```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(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) => { 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 ( ); } ``` - [ ] **Step 3: 验证编译** ```bash npx tsc -b ``` - [ ] **Step 4: 提交** ```bash 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** ```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('main'); if (view === 'local') return setView('main')} onStart={onGameStart} />; if (view === 'ai') return setView('main')} onStart={onGameStart} />; if (view === 'online') return setView('main')} onStart={onGameStart} />; if (view === 'replay') return setView('main')} onStart={onGameStart} />; return (

{t('app.title')}

); } ``` - [ ] **Step 2: 创建 LocalGameSetup.tsx** ```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 (

{t('menu.local_game')}

); } ``` - [ ] **Step 3: 创建 AiGameSetup.tsx** ```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('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 (

{t('menu.ai_game')}

); } ``` - [ ] **Step 4: 创建 OnlineSetup.tsx 和 LoadReplay.tsx** ```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 (

{t('menu.online_game')}

setIp(e.target.value)} placeholder="IP:端口" />
); } ``` ```tsx // 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(null); const handleFile = (e: React.ChangeEvent) => { 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 (

{t('menu.load_replay')}

); } ``` - [ ] **Step 5: 验证编译** ```bash npx tsc -b ``` - [ ] **Step 6: 提交** ```bash 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** ```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
{text}
; } ``` - [ ] **Step 2: 创建 GameControls.tsx** ```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 (
); } ``` - [ ] **Step 3: 创建 TimerDisplay.tsx** ```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 (
{Math.floor(time / 60)}:{(time % 60).toString().padStart(2, '0')}
); } ``` - [ ] **Step 4: 创建 GameView.tsx** ```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 (
); } ``` - [ ] **Step 5: 创建 ReplayView.tsx + StepSlider.tsx + ReplayControls.tsx** ```tsx // StepSlider.tsx interface Props { current: number; total: number; onChange: (step: number) => void; } export default function StepSlider({ current, total, onChange }: Props) { return ( onChange(Number(e.target.value))} className="step-slider" /> ); } ``` ```tsx // 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 (
); } ``` ```tsx // 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 (
{t('replay.step', { current: step, total: moves.length })}
setIsPlaying(!isPlaying)} onPrev={() => setStep(Math.max(0, step - 1))} onNext={() => setStep(Math.min(moves.length, step + 1))} />
); } ``` - [ ] **Step 6: 创建 hooks** ```typescript // 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 }; } ``` ```typescript // 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: 验证编译** ```bash npx tsc -b ``` - [ ] **Step 8: 提交** ```bash 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 — 路由集成** ```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('menu'); const mode = useGameStore((s) => s.mode); const handleGameStart = () => { setPage('game'); }; const handleReplayStart = () => { setPage('replay'); }; const handleBackToMenu = () => { setPage('menu'); }; if (page === 'game') { return ; } if (page === 'replay') { return ; } return ; } export default App; ``` - [ ] **Step 2: 创建 App.css — 木纹风格样式** ```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** ```css html, body, #root { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; } ``` - [ ] **Step 4: 验证** ```bash npx tauri dev ``` 手动测试: 点击菜单项进入各页面, 验证 Canvas 渲染正常, 木纹风格生效。 - [ ] **Step 5: 提交** ```bash 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` — 新建, 写入: ```markdown # 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** ```markdown # 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" ``` --- ### 最终验证 ```bash # Rust 全量检查 cargo check cargo clippy -- -D warnings cargo test # 前端检查 npx tsc -b npm test # 完整构建 npx tauri build ```