Files
Gobang-Game/docs/superpowers/plans/2026-05-31-gobang-network-implementation.md

33 KiB
Raw Permalink Blame History

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] 下添加:

renet2 = "2"
bincode = "1"

bincode 用于将 NetMessage 序列化为二进制(比 JSON 更紧凑,适合游戏网络包)。

  • Step 2: 在 gui/Cargo.toml 添加依赖

[dependencies] 下添加:

renet2 = "2"

gui 层需要 renet2 来创建网络线程中的 RenetServer/RenetClient

  • Step 3: 验证依赖解析
cargo check

Expected: 依赖下载成功,编译通过。

  • Step 4: 提交
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

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<u8> {
        bincode::serialize(self).unwrap_or_default()
    }

    pub fn from_bytes(data: &[u8]) -> Option<Self> {
        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 和构造

在文件末尾追加:

use std::net::UdpSocket;
use std::sync::mpsc;
use std::time::Duration;

pub struct NetworkLoop {
    role: NetworkRole,
    running: bool,
    cmd_rx: mpsc::Receiver<NetworkCmd>,
    event_tx: mpsc::Sender<NetworkEvent>,
}

impl NetworkLoop {
    /// 创建 Server 端 NetworkLoop
    pub fn new_server(
        port: u16,
        cmd_rx: mpsc::Receiver<NetworkCmd>,
        event_tx: mpsc::Sender<NetworkEvent>,
    ) -> 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<NetworkCmd>,
        event_tx: mpsc::Sender<NetworkEvent>,
    ) -> Self {
        Self {
            role: NetworkRole::Client,
            running: false,
            cmd_rx,
            event_tx,
        }
    }
}
  • Step 3: 写 NetworkMessage serde 往返测试

在文件末尾 #[cfg(test)] 模块中:

#[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: 验证编译和测试
cargo test -p gobang-core

Expected: 3 个新测试 + 27 个已有测试全部通过。

  • Step 5: 提交
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()

NetworkLoopimpl 块中追加 run 方法:

/// 启动网络主循环(在独立线程中调用)
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<renet2::ClientId> = 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: 验证编译
cargo check -p gobang-core

Expected: 编译通过(可能需要根据实际 renet2 API 微调方法名)。

  • Step 3: 提交
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.rsGameConfig struct 末尾(remote_address 之后)添加:

#[serde(default)]
pub host_port: u16,

Default impl 中对应位置添加:

host_port: 0,
  • Step 2: TypeScript GameConfig 加 hostPort

src/core/types.tsGameConfig interface 末尾添加:

  hostPort: number;
  • Step 3: 验证
cargo check
npx tsc -b
  • Step 4: 提交
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.rsAppState struct 中现有字段末尾添加:

pub network_tx: Mutex<Option<std::sync::mpsc::Sender<gobang_core::network::NetworkCmd>>>,

Default impl 中添加:

network_tx: Mutex::new(None),
  • Step 2: 添加 import

在文件顶部添加:

use gobang_core::network::{NetworkCmd, NetworkEvent, NetworkLoop, NetMessage};
use std::sync::mpsc;
  • Step 3: 添加 host_game 命令
#[tauri::command]
pub fn host_game(port: u16, state: State<AppState>, app: tauri::AppHandle) -> Result<u16, String> {
    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 命令
#[tauri::command]
pub fn join_game(address: String, state: State<AppState>, 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 命令
#[tauri::command]
pub fn send_move(x: usize, y: usize, turn: u32, state: State<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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

// 清理旧网络连接
if let Ok(mut tx) = state.network_tx.lock() {
    if let Some(tx) = tx.take() {
        let _ = tx.send(NetworkCmd::Shutdown);
    }
}
  • Step 7: 验证编译
cargo check -p gobang-gui

Expected: 编译通过。renet2 API 可能需要根据实际路径调整。

  • Step 8: 提交
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.rsgenerate_handler! 中添加:

.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 属性,去掉 (开发中) 后缀:

<button onClick={() => setView('online')}>
  {t('menu.online_game')}
</button>
  • Step 3: 验证
cargo check
npx tsc -b
  • Step 4: 提交
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

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<string>('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 (
      <div className="setup-panel">
        <h2>{t('menu.online_game')}</h2>
        <p style={{ fontSize: 18 }}>等待对手加入...</p>
        <p style={{ fontSize: 24, fontFamily: 'monospace', background: '#F5DEB3', color: '#3C2415', padding: '8px 16px', borderRadius: 4 }}>
          {myAddress}
        </p>
        <p style={{ fontSize: 14, opacity: 0.7 }}>将此地址发给对手</p>
        <button onClick={onBack}>{t('common.back')}</button>
      </div>
    );
  }

  return (
    <div className="setup-panel">
      <h2>{t('menu.online_game')}</h2>
      <label>
        {t('settings.board_size')}:
        <select value={boardSize} onChange={(e) => setBoardSize(Number(e.target.value))}>
          {Array.from({ length: MAX_BOARD_SIZE - MIN_BOARD_SIZE + 1 }, (_, i) => MIN_BOARD_SIZE + i).map((s) => (
            <option key={s} value={s}>{s}×{s}</option>
          ))}
        </select>
      </label>
      <button onClick={handleHost}>{t('menu.host_room')}</button>
      <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
        <input value={ip} onChange={(e) => setIp(e.target.value)} placeholder={t('menu.ip_placeholder')} />
        <button onClick={handleJoin} disabled={!ip}>{t('menu.join_room')}</button>
      </div>
      <button onClick={onBack} style={{ marginTop: 12 }}>{t('common.back')}</button>
    </div>
  );
}
  • Step 2: 修改 GameView.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<string>('');

  useEffect(() => {
    if (mode !== 'Online') return;
    const init = async () => {
      const u1 = await listen<string>('connection-status', (e) => setConnStatus(e.payload));
      const u2 = await listen<string>('listening-port', (e) => setConnStatus(`waiting:${e.payload}`));
      return () => { u1(); u2(); };
    };
    init();
  }, [mode]);

  return (
    <div className="game-view">
      {mode === 'Online' && connStatus && (
        <div style={{ fontSize: 14, opacity: 0.8 }}>
          {connStatus.startsWith('waiting') ? '等待对手加入...' :
           connStatus === 'connected' ? t('game.opponent_connected') :
           connStatus === 'disconnected' ? t('game.opponent_disconnected') : ''}
        </div>
      )}
      <GameInfo />
      <div className="board-container">
        <BoardCanvas />
      </div>
      <TimerDisplay />
      <GameControls onBackToMenu={onBackToMenu} />
    </div>
  );
}
  • Step 3: 修改 BoardCanvas.tsx — 监听 remote-move

BoardCanvas 组件的 useEffect 或新 useEffect 中添加 event listener

// 在组件中添加(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 模式下禁用:

const mode = useGameStore((s) => s.mode);

// 悔棋按钮:
<button onClick={handleUndo} disabled={status === 'game_over' || mode === 'Online'}>
  {t('game.undo')}
</button>
  • Step 5: 验证
npx tsc -b
  • Step 6: 提交
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.jsongame 块中添加:

"opponent_connected": "对手已连接",
"opponent_disconnected": "对手已断开",

en.jsongame 块中添加:

"opponent_connected": "Opponent Connected",
"opponent_disconnected": "Opponent Disconnected",
  • Step 2: gameStore 的 startGame 适配 Online 模式

确保 startGameOnline 模式设置正确的初始 currentColor(主机黑=Black,对手白=White)。

当前逻辑已通过 config.playerColor 设置,无需改动。

placePiece 中需要处理 Online 模式:当本地玩家落子后,调用 send_move 同步给对手。

gameStore.tsplacePiece 成功返回后添加:

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: 验证
npx tsc -b
  • Step 4: 提交
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 模块中追加:

#[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: 运行全部测试
cargo test
npx vitest run

Expected: Rust 32+ passed, vitest 10 passed。

  • Step 3: 构建
cargo check && cargo clippy -- -D warnings
npx tsc -b
  • Step 4: 提交
git add core/src/network.rs
git commit -m "test: NetMessage 全变体 + NetworkCmd channel 集成测试"

Task 10: 最终验证 + 手动测试

  • Step 1: 完整测试套件
cargo test
cargo clippy -- -D warnings
npx tsc -b
npx vitest run

Expected: 全部通过。

  • Step 2: 构建打包
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 (最终验证)