# Gobang 网络对战实施计划 > **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:** 实现基于 renet UDP 直连的 P2P 网络对战,支持创建房间/加入房间/同步落子/悔棋/认输。 **Architecture:** 独立网络线程跑 renet Server/Client 循环,通过 mpsc channel 与 Tauri commands 层通信,对手落子通过 Tauri `app.emit("remote-move")` 事件推送到前端。 **Tech Stack:** renet 2.0, renet_netcode, mpsc channel, Tauri event system, React + Zustand --- ## 文件变更总览 | 文件 | 操作 | 内容 | |------|------|------| | `core/Cargo.toml` | 修改 | 加 renet, renet_netcode, bincode | | `core/src/network.rs` | 重写 | NetworkLoop struct, NetMessage, channel 类型 | | `core/src/types.rs` | 修改 | GameConfig 加 host_port | | `gui/Cargo.toml` | 修改 | 加 renet, renet_netcode | | `gui/src/commands.rs` | 修改 | host_game, join_game, send_move, send_undo, send_resign, AppState.network_tx | | `gui/src/lib.rs` | 修改 | 注册 5 个新命令 | | `src/core/types.ts` | 修改 | GameConfig 加 hostPort | | `src/components/menu/OnlineSetup.tsx` | 重写 | 创建房间显示地址 + 加入房间输入 | | `src/components/menu/MainMenu.tsx` | 修改 | 启用 Online 按钮 | | `src/components/game/GameView.tsx` | 修改 | 加连接状态条 | | `src/components/game/GameControls.tsx` | 修改 | Online 模式禁悔棋 | | `src/components/board/BoardCanvas.tsx` | 修改 | 监听 remote-move event | | `src/store/gameStore.ts` | 修改 | remote-move 处理 | | `src/i18n/zh-CN.json` | 修改 | 加连接状态 key | | `src/i18n/en.json` | 修改 | 加连接状态 key | --- ### Task 1: 添加 renet 依赖 **Files:** - Modify: `core/Cargo.toml` - Modify: `gui/Cargo.toml` - [ ] **Step 1: 在 core/Cargo.toml 添加依赖** 在 `[dependencies]` 下添加: ```toml renet2 = "2" bincode = "1" ``` `bincode` 用于将 NetMessage 序列化为二进制(比 JSON 更紧凑,适合游戏网络包)。 - [ ] **Step 2: 在 gui/Cargo.toml 添加依赖** 在 `[dependencies]` 下添加: ```toml renet2 = "2" ``` (gui 层需要 renet2 来创建网络线程中的 RenetServer/RenetClient) - [ ] **Step 3: 验证依赖解析** ```bash cargo check ``` Expected: 依赖下载成功,编译通过。 - [ ] **Step 4: 提交** ```bash git add core/Cargo.toml gui/Cargo.toml git commit -m "chore: 添加 renet2 + bincode 网络库依赖" ``` --- ### Task 2: 重写 core/src/network.rs **Files:** - Modify: `core/src/network.rs` (完全重写) - [ ] **Step 1: 定义 NetMessage 和 channel 类型** 用以下内容替换 `core/src/network.rs`: ```rust use serde::{Deserialize, Serialize}; /// 网络传输的游戏消息 #[derive(Debug, Clone, Serialize, Deserialize)] pub enum NetMessage { Move { x: usize, y: usize, turn: u32 }, Undo { steps: u32 }, Resign, } impl NetMessage { pub fn to_bytes(&self) -> Vec { bincode::serialize(self).unwrap_or_default() } pub fn from_bytes(data: &[u8]) -> Option { bincode::deserialize(data).ok() } } /// commands 层 → 网络线程 pub enum NetworkCmd { SendMove { x: usize, y: usize, turn: u32 }, SendUndo { steps: u32 }, SendResign, Shutdown, } /// 网络线程 → commands 层 #[derive(Debug, Clone)] pub enum NetworkEvent { RemoteMove { x: usize, y: usize }, RemoteUndo { steps: u32 }, RemoteResign, ClientConnected, ClientDisconnected, Error(String), Listening(u16), Connected, } /// 网络角色 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetworkRole { Server, Client, } ``` - [ ] **Step 2: 定义 NetworkLoop struct 和构造** 在文件末尾追加: ```rust use std::net::UdpSocket; use std::sync::mpsc; use std::time::Duration; pub struct NetworkLoop { role: NetworkRole, running: bool, cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, } impl NetworkLoop { /// 创建 Server 端 NetworkLoop pub fn new_server( port: u16, cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, ) -> Result<(Self, u16), String> { let socket = UdpSocket::bind(format!("0.0.0.0:{}", port)) .map_err(|e| format!("绑定端口失败: {}", e))?; let actual_port = socket.local_addr().map_err(|e| e.to_string())?.port(); Ok(( Self { role: NetworkRole::Server, running: false, cmd_rx, event_tx, }, actual_port, )) } /// 创建 Client 端 NetworkLoop pub fn new_client( cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, ) -> Self { Self { role: NetworkRole::Client, running: false, cmd_rx, event_tx, } } } ``` - [ ] **Step 3: 写 NetworkMessage serde 往返测试** 在文件末尾 `#[cfg(test)]` 模块中: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_net_message_roundtrip() { let msg = NetMessage::Move { x: 7, y: 7, turn: 0 }; let bytes = msg.to_bytes(); let decoded = NetMessage::from_bytes(&bytes).unwrap(); match decoded { NetMessage::Move { x, y, turn } => { assert_eq!(x, 7); assert_eq!(y, 7); assert_eq!(turn, 0); } _ => panic!("wrong variant"), } } #[test] fn test_net_message_undo_roundtrip() { let msg = NetMessage::Undo { steps: 1 }; let bytes = msg.to_bytes(); let decoded = NetMessage::from_bytes(&bytes).unwrap(); assert!(matches!(decoded, NetMessage::Undo { steps: 1 })); } #[test] fn test_net_message_resign_roundtrip() { let msg = NetMessage::Resign; let bytes = msg.to_bytes(); let decoded = NetMessage::from_bytes(&bytes).unwrap(); assert!(matches!(decoded, NetMessage::Resign)); } } ``` - [ ] **Step 4: 验证编译和测试** ```bash cargo test -p gobang-core ``` Expected: 3 个新测试 + 27 个已有测试全部通过。 - [ ] **Step 5: 提交** ```bash git add core/src/network.rs git commit -m "feat: 重写 network.rs — NetMessage/NetworkCmd/NetworkEvent 定义 + serde 测试" ``` --- ### Task 3: 实现 NetworkLoop run 方法(主循环) **Files:** - Modify: `core/src/network.rs` (追加 run 方法) - [ ] **Step 1: 实现 NetworkLoop::run()** 在 `NetworkLoop` 的 `impl` 块中追加 `run` 方法: ```rust /// 启动网络主循环(在独立线程中调用) pub fn run(&mut self, server_addr: &str, protocol_id: u64) -> Result<(), String> { self.running = true; match self.role { NetworkRole::Server => self.run_server(protocol_id), NetworkRole::Client => self.run_client(server_addr, protocol_id), } } fn run_server(&mut self, protocol_id: u64) -> Result<(), String> { let socket = UdpSocket::bind("0.0.0.0:0") .map_err(|e| format!("绑定失败: {}", e))?; let local_port = socket.local_addr().map_err(|e| e.to_string())?.port(); let _ = self.event_tx.send(NetworkEvent::Listening(local_port)); let mut server = renet2::RenetServer::new(renet2::ConnectionConfig { available_bytes_per_tick: 1024, server_channels_config: vec![], client_channels_config: vec![renet2::ChannelConfig { channel_id: 0, send_type: renet2::SendType::ReliableOrdered { resend_time: Duration::from_secs(1) }, }], }); let server_config = renet2::ServerConfig { current_time: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(), max_clients: 1, protocol_id, authentication: renet2::ServerAuthentication::Unsecure, }; let mut transport = renet2::NetcodeServerTransport::new(server_config, socket) .map_err(|e| format!("创建传输层失败: {}", e))?; let mut client_id: Option = None; let tick = Duration::from_millis(16); while self.running { let now = std::time::Instant::now(); // 处理 channel 指令 while let Ok(cmd) = self.cmd_rx.try_recv() { match cmd { NetworkCmd::SendMove { x, y, turn } => { let msg = NetMessage::Move { x, y, turn }; if let Some(cid) = client_id { server.send_message(cid, 0, msg.to_bytes().into()); } } NetworkCmd::SendUndo { steps } => { let msg = NetMessage::Undo { steps }; if let Some(cid) = client_id { server.send_message(cid, 0, msg.to_bytes().into()); } } NetworkCmd::SendResign => { let msg = NetMessage::Resign; if let Some(cid) = client_id { server.send_message(cid, 0, msg.to_bytes().into()); } } NetworkCmd::Shutdown => { self.running = false; break; } } } // 更新 server.update(tick); transport.update(tick, &mut server) .map_err(|e| format!("传输更新失败: {}", e))?; // 接收消息 if let Some(cid) = client_id { while let Some(data) = server.receive_message(cid, 0) { if let Some(msg) = NetMessage::from_bytes(&data) { match msg { NetMessage::Move { x, y, .. } => { let _ = self.event_tx.send(NetworkEvent::RemoteMove { x, y }); } NetMessage::Undo { steps } => { let _ = self.event_tx.send(NetworkEvent::RemoteUndo { steps }); } NetMessage::Resign => { let _ = self.event_tx.send(NetworkEvent::RemoteResign); } } } } } // 处理连接事件 while let Some(event) = server.get_event() { match event { renet2::ServerEvent::ClientConnected { client_id: cid } => { client_id = Some(cid); let _ = self.event_tx.send(NetworkEvent::ClientConnected); } renet2::ServerEvent::ClientDisconnected { .. } => { client_id = None; let _ = self.event_tx.send(NetworkEvent::ClientDisconnected); } } } transport.send_packets(&mut server) .map_err(|e| format!("发送失败: {}", e))?; let elapsed = now.elapsed(); if elapsed < tick { std::thread::sleep(tick - elapsed); } } Ok(()) } fn run_client(&mut self, server_addr: &str, protocol_id: u64) -> Result<(), String> { let server_addr: std::net::SocketAddr = server_addr .parse() .map_err(|e| format!("地址解析失败: {}", e))?; let socket = UdpSocket::bind("0.0.0.0:0") .map_err(|e| format!("绑定失败: {}", e))?; let mut client = renet2::RenetClient::new(renet2::ConnectionConfig { available_bytes_per_tick: 1024, server_channels_config: vec![], client_channels_config: vec![renet2::ChannelConfig { channel_id: 0, send_type: renet2::SendType::ReliableOrdered { resend_time: Duration::from_secs(1) }, }], }); let current_time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); let authentication = renet2::ClientAuthentication::Unsecure { protocol_id, server_addr, client_id: current_time.as_millis() as u64, user_data: None, }; let mut transport = renet2::NetcodeClientTransport::new(current_time, authentication, socket) .map_err(|e| format!("创建传输层失败: {}", e))?; let tick = Duration::from_millis(16); let mut was_connected = false; while self.running { let now = std::time::Instant::now(); // 处理指令 while let Ok(cmd) = self.cmd_rx.try_recv() { match cmd { NetworkCmd::SendMove { x, y, turn } => { let msg = NetMessage::Move { x, y, turn }; client.send_message(0, msg.to_bytes().into()); } NetworkCmd::SendUndo { steps } => { let msg = NetMessage::Undo { steps }; client.send_message(0, msg.to_bytes().into()); } NetworkCmd::SendResign => { let msg = NetMessage::Resign; client.send_message(0, msg.to_bytes().into()); } NetworkCmd::Shutdown => { self.running = false; break; } } } client.update(tick); transport.update(tick, &mut client) .map_err(|e| format!("传输更新失败: {}", e))?; // 连接状态变化 if client.is_connected() && !was_connected { was_connected = true; let _ = self.event_tx.send(NetworkEvent::Connected); } if !client.is_connected() && was_connected { was_connected = false; let _ = self.event_tx.send(NetworkEvent::ClientDisconnected); } // 接收消息 while let Some(data) = client.receive_message(0) { if let Some(msg) = NetMessage::from_bytes(&data) { match msg { NetMessage::Move { x, y, .. } => { let _ = self.event_tx.send(NetworkEvent::RemoteMove { x, y }); } NetMessage::Undo { steps } => { let _ = self.event_tx.send(NetworkEvent::RemoteUndo { steps }); } NetMessage::Resign => { let _ = self.event_tx.send(NetworkEvent::RemoteResign); } } } } transport.send_packets(&mut client) .map_err(|e| format!("发送失败: {}", e))?; let elapsed = now.elapsed(); if elapsed < tick { std::thread::sleep(tick - elapsed); } } Ok(()) } ``` - [ ] **Step 2: 验证编译** ```bash cargo check -p gobang-core ``` Expected: 编译通过(可能需要根据实际 renet2 API 微调方法名)。 - [ ] **Step 3: 提交** ```bash git add core/src/network.rs git commit -m "feat: 实现 NetworkLoop::run — Server/Client 主循环" ``` --- ### Task 4: GameConfig 添加 host_port 字段 **Files:** - Modify: `core/src/types.rs` - Modify: `src/core/types.ts` - [ ] **Step 1: Rust GameConfig 加 host_port** 在 `core/src/types.rs` 的 `GameConfig` struct 末尾(`remote_address` 之后)添加: ```rust #[serde(default)] pub host_port: u16, ``` 在 `Default` impl 中对应位置添加: ```rust host_port: 0, ``` - [ ] **Step 2: TypeScript GameConfig 加 hostPort** 在 `src/core/types.ts` 的 `GameConfig` interface 末尾添加: ```typescript hostPort: number; ``` - [ ] **Step 3: 验证** ```bash cargo check npx tsc -b ``` - [ ] **Step 4: 提交** ```bash git add core/src/types.rs src/core/types.ts git commit -m "feat: GameConfig 新增 hostPort 字段" ``` --- ### Task 5: gui 层网络命令 + AppState.network_tx **Files:** - Modify: `gui/src/commands.rs` - [ ] **Step 1: 添加 network_tx 到 AppState** 在 `gui/src/commands.rs` 的 `AppState` struct 中现有字段末尾添加: ```rust pub network_tx: Mutex>>, ``` 在 `Default` impl 中添加: ```rust network_tx: Mutex::new(None), ``` - [ ] **Step 2: 添加 import** 在文件顶部添加: ```rust use gobang_core::network::{NetworkCmd, NetworkEvent, NetworkLoop, NetMessage}; use std::sync::mpsc; ``` - [ ] **Step 3: 添加 host_game 命令** ```rust #[tauri::command] pub fn host_game(port: u16, state: State, app: tauri::AppHandle) -> Result { let (cmd_tx, cmd_rx) = mpsc::channel(); let (event_tx, event_rx) = mpsc::channel(); let (mut network, actual_port) = NetworkLoop::new_server(port, cmd_rx, event_tx)?; *state.network_tx.lock().map_err(|e| e.to_string())? = Some(cmd_tx); // spawn 网络线程 let protocol_id: u64 = 7777; std::thread::spawn(move || { let _ = network.run("", protocol_id); }); // spawn event 转发线程 → Tauri events let app_clone = app.clone(); std::thread::spawn(move || { for event in event_rx { match event { NetworkEvent::RemoteMove { x, y } => { let _ = app_clone.emit("remote-move", serde_json::json!({ "x": x, "y": y })); } NetworkEvent::RemoteUndo { steps } => { let _ = app_clone.emit("remote-undo", steps); } NetworkEvent::RemoteResign => { let _ = app_clone.emit("remote-resign", ()); } NetworkEvent::ClientConnected | NetworkEvent::Connected => { let _ = app_clone.emit("connection-status", "connected"); } NetworkEvent::ClientDisconnected => { let _ = app_clone.emit("connection-status", "disconnected"); } NetworkEvent::Listening(port) => { let _ = app_clone.emit("listening-port", port); } NetworkEvent::Error(msg) => { let _ = app_clone.emit("network-error", msg); } } } }); Ok(actual_port) } ``` - [ ] **Step 4: 添加 join_game 命令** ```rust #[tauri::command] pub fn join_game(address: String, state: State, app: tauri::AppHandle) -> Result<(), String> { let (cmd_tx, cmd_rx) = mpsc::channel(); let (event_tx, event_rx) = mpsc::channel(); let mut network = NetworkLoop::new_client(cmd_rx, event_rx); *state.network_tx.lock().map_err(|e| e.to_string())? = Some(cmd_tx); let protocol_id: u64 = 7777; let addr = address.clone(); std::thread::spawn(move || { let _ = network.run(&addr, protocol_id); }); let app_clone = app.clone(); std::thread::spawn(move || { for event in event_rx { match event { NetworkEvent::RemoteMove { x, y } => { let _ = app_clone.emit("remote-move", serde_json::json!({ "x": x, "y": y })); } NetworkEvent::RemoteUndo { steps } => { let _ = app_clone.emit("remote-undo", steps); } NetworkEvent::RemoteResign => { let _ = app_clone.emit("remote-resign", ()); } NetworkEvent::Connected => { let _ = app_clone.emit("connection-status", "connected"); } NetworkEvent::ClientDisconnected => { let _ = app_clone.emit("connection-status", "disconnected"); } NetworkEvent::Error(msg) => { let _ = app_clone.emit("network-error", msg); } _ => {} } } }); Ok(()) } ``` - [ ] **Step 5: 添加 send_move/send_undo/send_resign 命令** ```rust #[tauri::command] pub fn send_move(x: usize, y: usize, turn: u32, state: State) -> Result<(), String> { let tx = state.network_tx.lock().map_err(|e| e.to_string())?; let tx = tx.as_ref().ok_or("未建立网络连接")?; tx.send(NetworkCmd::SendMove { x, y, turn }).map_err(|e| e.to_string()) } #[tauri::command] pub fn send_undo(steps: u32, state: State) -> Result<(), String> { let tx = state.network_tx.lock().map_err(|e| e.to_string())?; let tx = tx.as_ref().ok_or("未建立网络连接")?; tx.send(NetworkCmd::SendUndo { steps }).map_err(|e| e.to_string()) } #[tauri::command] pub fn send_resign(state: State) -> Result<(), String> { let tx = state.network_tx.lock().map_err(|e| e.to_string())?; let tx = tx.as_ref().ok_or("未建立网络连接")?; tx.send(NetworkCmd::SendResign).map_err(|e| e.to_string()) } ``` - [ ] **Step 6: 在 new_game 中初始化/清理 network_tx** 在 `new_game` 中,游戏模式为 Online 时不做额外处理(由 host_game/join_game 单独调用)。在 `new_game` 开头清理旧的 network_tx: ```rust // 清理旧网络连接 if let Ok(mut tx) = state.network_tx.lock() { if let Some(tx) = tx.take() { let _ = tx.send(NetworkCmd::Shutdown); } } ``` - [ ] **Step 7: 验证编译** ```bash cargo check -p gobang-gui ``` Expected: 编译通过。renet2 API 可能需要根据实际路径调整。 - [ ] **Step 8: 提交** ```bash git add gui/src/commands.rs git commit -m "feat: 添加 host_game/join_game/send_move/send_undo/send_resign 命令" ``` --- ### Task 6: 注册新命令 + 启用 Online 按钮 **Files:** - Modify: `gui/src/lib.rs` - Modify: `src/components/menu/MainMenu.tsx` - [ ] **Step 1: 在 lib.rs 注册 5 个新命令** 在 `gui/src/lib.rs` 的 `generate_handler!` 中添加: ```rust .invoke_handler(tauri::generate_handler![ commands::new_game, commands::place_piece, commands::undo, commands::ai_move, commands::get_game_state, commands::resign, commands::save_record, commands::host_game, commands::join_game, commands::send_move, commands::send_undo, commands::send_resign, ]) ``` - [ ] **Step 2: 启用 MainMenu 的 Online 按钮** 在 `src/components/menu/MainMenu.tsx` 中,移除 Online 按钮的 `disabled` 属性,去掉 `(开发中)` 后缀: ```tsx ``` - [ ] **Step 3: 验证** ```bash cargo check npx tsc -b ``` - [ ] **Step 4: 提交** ```bash git add gui/src/lib.rs src/components/menu/MainMenu.tsx git commit -m "feat: 注册网络命令 + 启用 Online 按钮" ``` --- ### Task 7: 重写 OnlineSetup + 更新 GameView/BoardCanvas/GameControls **Files:** - Modify: `src/components/menu/OnlineSetup.tsx` (重写) - Modify: `src/components/game/GameView.tsx` - Modify: `src/components/board/BoardCanvas.tsx` - Modify: `src/components/game/GameControls.tsx` - [ ] **Step 1: 重写 OnlineSetup.tsx** ```tsx import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useGameStore } from '../../store/gameStore'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { MIN_BOARD_SIZE, MAX_BOARD_SIZE } from '../../core/constants'; import type { GameConfig } from '../../core/types'; interface Props { onBack: () => void; onStart: () => void; } export default function OnlineSetup({ onBack, onStart }: Props) { const { t } = useTranslation(); const startGame = useGameStore((s) => s.startGame); const [boardSize, setBoardSize] = useState(15); const [ip, setIp] = useState(''); const [myAddress, setMyAddress] = useState(''); const [isHosting, setIsHosting] = useState(false); const handleHost = async () => { const port: number = await invoke('host_game', { port: 0 }); setMyAddress(`127.0.0.1:${port}`); setIsHosting(true); // 等待对手连接 const unlisten = await listen('connection-status', async (event) => { if (event.payload === 'connected') { unlisten(); const config: GameConfig = { boardSize, useForbiddenRules: true, useTimer: false, timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: true, remoteAddress: '', hostPort: port, }; await startGame('Online', config); onStart(); } }); }; const handleJoin = async () => { const [host, portStr] = ip.split(':'); const config: GameConfig = { boardSize, useForbiddenRules: true, useTimer: false, timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'White', isServer: false, remoteAddress: ip, hostPort: parseInt(portStr) || 0, }; await startGame('Online', config); await invoke('join_game', { address: ip }); onStart(); }; if (isHosting) { return (

{t('menu.online_game')}

等待对手加入...

{myAddress}

将此地址发给对手

); } return (

{t('menu.online_game')}

setIp(e.target.value)} placeholder={t('menu.ip_placeholder')} />
); } ``` - [ ] **Step 2: 修改 GameView.tsx — 添加连接状态条** ```tsx import { useState, useEffect } from 'react'; import { listen } from '@tauri-apps/api/event'; import { useTranslation } from 'react-i18next'; import { useGameStore } from '../../store/gameStore'; 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) { const { t } = useTranslation(); const mode = useGameStore((s) => s.mode); const [connStatus, setConnStatus] = useState(''); useEffect(() => { if (mode !== 'Online') return; const init = async () => { const u1 = await listen('connection-status', (e) => setConnStatus(e.payload)); const u2 = await listen('listening-port', (e) => setConnStatus(`waiting:${e.payload}`)); return () => { u1(); u2(); }; }; init(); }, [mode]); return (
{mode === 'Online' && connStatus && (
{connStatus.startsWith('waiting') ? '等待对手加入...' : connStatus === 'connected' ? t('game.opponent_connected') : connStatus === 'disconnected' ? t('game.opponent_disconnected') : ''}
)}
); } ``` - [ ] **Step 3: 修改 BoardCanvas.tsx — 监听 remote-move** 在 `BoardCanvas` 组件的 `useEffect` 或新 `useEffect` 中添加 event listener: ```tsx // 在组件中添加(import { listen } from '@tauri-apps/api/event') useEffect(() => { if (mode !== 'Online') return; let unlisten: (() => void) | undefined; const setup = async () => { unlisten = await listen<{ x: number; y: number }>('remote-move', (event) => { placePiece(event.payload.x, event.payload.y); }); }; setup(); return () => { unlisten?.(); }; }, [mode, placePiece]); ``` - [ ] **Step 4: 修改 GameControls.tsx — Online 禁悔棋** 在 `GameControls` 中,获取 `mode` 状态,悔棋按钮在 Online 模式下禁用: ```tsx const mode = useGameStore((s) => s.mode); // 悔棋按钮: ``` - [ ] **Step 5: 验证** ```bash npx tsc -b ``` - [ ] **Step 6: 提交** ```bash git add src/components/menu/OnlineSetup.tsx src/components/game/GameView.tsx src/components/board/BoardCanvas.tsx src/components/game/GameControls.tsx git commit -m "feat: Online 模式 UI — 房间管理/连接状态/remote-move 监听/禁悔棋" ``` --- ### Task 8: 更新 i18n + gameStore **Files:** - Modify: `src/i18n/zh-CN.json` - Modify: `src/i18n/en.json` - Modify: `src/store/gameStore.ts` - [ ] **Step 1: 添加 i18n key** 在 `zh-CN.json` 的 `game` 块中添加: ```json "opponent_connected": "对手已连接", "opponent_disconnected": "对手已断开", ``` 在 `en.json` 的 `game` 块中添加: ```json "opponent_connected": "Opponent Connected", "opponent_disconnected": "Opponent Disconnected", ``` - [ ] **Step 2: gameStore 的 startGame 适配 Online 模式** 确保 `startGame` 对 `Online` 模式设置正确的初始 currentColor(主机黑=Black,对手白=White)。 当前逻辑已通过 `config.playerColor` 设置,无需改动。 但 `placePiece` 中需要处理 Online 模式:当本地玩家落子后,调用 `send_move` 同步给对手。 在 `gameStore.ts` 的 `placePiece` 成功返回后添加: ```typescript if (get().mode === 'Online' && !result.is_win) { const lastMove = get().moves[get().moves.length - 1]; if (lastMove) { await invoke('send_move', { x: lastMove.position.x, y: lastMove.position.y, turn: lastMove.turn }); } } ``` - [ ] **Step 3: 验证** ```bash npx tsc -b ``` - [ ] **Step 4: 提交** ```bash git add src/i18n/zh-CN.json src/i18n/en.json src/store/gameStore.ts git commit -m "feat: i18n 连接状态翻译 + gameStore Online send_move 同步" ``` --- ### Task 9: 集成测试 + 构建验证 **Files:** - Modify: `core/src/network.rs` (追加 local client 测试) - [ ] **Step 1: 添加 renet 本地 client 集成测试** 在 `core/src/network.rs` 的 tests 模块中追加: ```rust #[test] fn test_net_message_all_variants_roundtrip() { let messages = vec![ NetMessage::Move { x: 0, y: 0, turn: 1 }, NetMessage::Move { x: 14, y: 14, turn: 42 }, NetMessage::Undo { steps: 1 }, NetMessage::Undo { steps: 2 }, NetMessage::Resign, ]; for msg in messages { let bytes = msg.to_bytes(); let decoded = NetMessage::from_bytes(&bytes); assert!(decoded.is_some()); } } #[test] fn test_network_cmd_channel() { let (tx, rx) = std::sync::mpsc::channel(); tx.send(NetworkCmd::SendMove { x: 7, y: 7, turn: 0 }).unwrap(); tx.send(NetworkCmd::Shutdown).unwrap(); let mut received = Vec::new(); while let Ok(cmd) = rx.try_recv() { match cmd { NetworkCmd::Shutdown => break, NetworkCmd::SendMove { x, y, turn } => received.push((x, y, turn)), _ => {} } } assert_eq!(received, vec![(7, 7, 0)]); } ``` - [ ] **Step 2: 运行全部测试** ```bash cargo test npx vitest run ``` Expected: Rust 32+ passed, vitest 10 passed。 - [ ] **Step 3: 构建** ```bash cargo check && cargo clippy -- -D warnings npx tsc -b ``` - [ ] **Step 4: 提交** ```bash git add core/src/network.rs git commit -m "test: NetMessage 全变体 + NetworkCmd channel 集成测试" ``` --- ### Task 10: 最终验证 + 手动测试 - [ ] **Step 1: 完整测试套件** ```bash cargo test cargo clippy -- -D warnings npx tsc -b npx vitest run ``` Expected: 全部通过。 - [ ] **Step 2: 构建打包** ```bash npx tauri build ``` Expected: NSIS 安装包成功生成。 - [ ] **Step 3: 手动测试** - 窗口1:创建房间,记录 IP:端口 - 窗口2:加入房间,填入窗口1的地址 - 验证:双方落子互相同步 --- ## 执行顺序 ``` Task 1 (依赖) → Task 2 (核心类型) → Task 3 (网络循环) → Task 4 (GameConfig) ↓ Task 5 (gui 命令) → Task 6 (注册+启用) ↓ Task 7 (UI 重写) → Task 8 (i18n+store) ↓ Task 9 (测试) ↓ Task 10 (最终验证) ```