8 Commits

Author SHA1 Message Date
Serendipity bda917ce37 feat: Online 模式前端 UI — 房间管理/连接状态/remote-move/禁悔棋
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:16:40 +08:00
Serendipity 9aa9de6b74 feat: 添加 host_game/join_game/send_move/send_undo/send_resign 网络命令
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:13:55 +08:00
Serendipity 33c1f8d8d4 feat: 实现 NetworkLoop::run — Server/Client renet 主循环
添加 renet2_netcode 依赖,使用 renet2 + renet2_netcode + renetcode2
三 crate 架构实现完整网络循环。Server 端监听 UDP 端口并通过
channel 广播游戏消息,Client 端连接并双工通信。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:10:18 +08:00
Serendipity 92894d2904 feat: 重写 network.rs — NetMessage/NetworkCmd/NetworkEvent + bincode serde 测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:00:54 +08:00
Serendipity af64a99987 chore: 添加 renet2 + bincode 网络库依赖 2026-05-31 14:59:37 +08:00
Serendipity 6da7b7a43b feat: GameConfig 新增 hostPort 字段
- Rust GameConfig 添加 host_port: u16(#[serde(default)],默认 0)
- TypeScript GameConfig 添加 hostPort?: number(可选,匹配其他带默认值字段的模式)
2026-05-31 14:58:45 +08:00
Serendipity 105aa968e0 docs: 网络对战实施计划 (10 tasks) 2026-05-31 14:56:55 +08:00
Serendipity f2898880eb docs: 网络对战功能设计文档 2026-05-31 14:51:40 +08:00
17 changed files with 2071 additions and 64 deletions
Generated
+169 -2
View File
@@ -41,6 +41,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -225,6 +275,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -465,6 +524,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@@ -901,6 +966,29 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1376,8 +1464,10 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gobang-core"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"bincode",
"renet2",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -1385,9 +1475,12 @@ dependencies = [
[[package]]
name = "gobang-gui"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"env_logger",
"gobang-core",
"log",
"renet2",
"serde",
"serde_json",
"tauri",
@@ -1854,6 +1947,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1883,6 +1982,30 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2416,12 +2539,24 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "octets"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.3.5"
@@ -2688,6 +2823,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2886,6 +3036,17 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "renet2"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "689381686b0760f174e973d038df220eec2834d64345c2b609212d40679dc274"
dependencies = [
"bytes",
"log",
"octets",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -4406,6 +4567,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.2"
+3
View File
@@ -10,3 +10,6 @@ repository.workspace = true
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "blocking"] }
renet2 = "0.15"
renet2_netcode = "0.15"
bincode = "1"
+348 -35
View File
@@ -1,61 +1,374 @@
use serde::{Deserialize, Serialize};
/// 游戏网络消息
/// 网络传输的游戏消息(bincode 序列化)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameMessage {
pub enum NetMessage {
Move { x: usize, y: usize, turn: u32 },
Undo { steps: u32 },
Resign,
Chat(String),
Heartbeat,
}
/// 网络连接角色
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 层 → 网络线程
#[derive(Debug)]
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,
}
/// 网络会话配置
#[derive(Debug, Clone)]
pub struct NetworkConfig {
pub role: NetworkRole,
pub bind_port: u16,
pub remote_addr: String,
pub remote_port: u16,
/// 网络循环句柄(在独立线程中运行)
pub struct NetworkLoop {
role: NetworkRole,
running: bool,
cmd_rx: std::sync::mpsc::Receiver<NetworkCmd>,
event_tx: std::sync::mpsc::Sender<NetworkEvent>,
}
/// 网络会话状态
#[derive(Debug, Clone)]
pub struct NetworkSession {
pub role: NetworkRole,
pub is_connected: bool,
pub config: NetworkConfig,
pending_messages: Vec<GameMessage>,
}
impl NetworkSession {
pub fn new(config: NetworkConfig) -> Self {
impl NetworkLoop {
/// 创建 Server 端
pub fn new_server(
cmd_rx: std::sync::mpsc::Receiver<NetworkCmd>,
event_tx: std::sync::mpsc::Sender<NetworkEvent>,
) -> Self {
Self {
role: config.role,
is_connected: false,
config,
pending_messages: Vec::new(),
role: NetworkRole::Server,
running: false,
cmd_rx,
event_tx,
}
}
/// 发送消息 (实际 renet 集成在 gui 层处理)
pub fn enqueue_message(&mut self, msg: GameMessage) {
self.pending_messages.push(msg);
/// 创建 Client
pub fn new_client(
cmd_rx: std::sync::mpsc::Receiver<NetworkCmd>,
event_tx: std::sync::mpsc::Sender<NetworkEvent>,
) -> Self {
Self {
role: NetworkRole::Client,
running: false,
cmd_rx,
event_tx,
}
}
/// 取出待发送的消息
pub fn drain_messages(&mut self) -> Vec<GameMessage> {
std::mem::take(&mut self.pending_messages)
/// 启动网络主循环(阻塞,在独立线程中调用)
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),
}
}
pub fn set_connected(&mut self, connected: bool) {
self.is_connected = connected;
fn run_server(&mut self, protocol_id: u64) -> Result<(), String> {
use std::net::UdpSocket;
use std::time::{Duration, Instant};
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("Server 绑定失败: {}", e))?;
let local_addr = socket.local_addr().map_err(|e| e.to_string())?;
let local_port = local_addr.port();
let _ = self.event_tx.send(NetworkEvent::Listening(local_port));
let connection_config =
renet2::ConnectionConfig::from_shared_channels(vec![renet2::ChannelConfig {
channel_id: 0,
max_memory_usage_bytes: 5 * 1024 * 1024,
send_type: renet2::SendType::ReliableOrdered {
resend_time: Duration::from_millis(300),
},
}]);
let mut server = renet2::RenetServer::new(connection_config);
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let server_config = renet2_netcode::ServerSetupConfig {
current_time,
max_clients: 1,
protocol_id,
socket_addresses: vec![vec![local_addr]],
authentication: renet2_netcode::ServerAuthentication::Unsecure,
};
let native_socket = renet2_netcode::NativeSocket::new(socket)
.map_err(|e| format!("创建 NativeSocket 失败: {}", e))?;
let mut transport =
renet2_netcode::NetcodeServerTransport::new(server_config, native_socket)
.map_err(|e| format!("创建传输层失败: {}", e))?;
let tick = Duration::from_millis(16);
while self.running {
let now = Instant::now();
// 处理 commands 层发来的指令
while let Ok(cmd) = self.cmd_rx.try_recv() {
match cmd {
NetworkCmd::SendMove { x, y, turn } => {
let msg = NetMessage::Move { x, y, turn };
for cid in server.clients_id() {
server.send_message(cid, 0u8, msg.to_bytes());
}
}
NetworkCmd::SendUndo { steps } => {
let msg = NetMessage::Undo { steps };
for cid in server.clients_id() {
server.send_message(cid, 0u8, msg.to_bytes());
}
}
NetworkCmd::SendResign => {
for cid in server.clients_id() {
server.send_message(cid, 0u8, NetMessage::Resign.to_bytes());
}
}
NetworkCmd::Shutdown => {
self.running = false;
break;
}
}
}
server.update(tick);
transport
.update(tick, &mut server)
.map_err(|e| format!("传输层更新失败: {e:?}"))?;
// 接收客户端消息
for cid in server.clients_id() {
while let Some(data) = server.receive_message(cid, 0u8) {
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 { .. } => {
let _ = self.event_tx.send(NetworkEvent::ClientConnected);
}
renet2::ServerEvent::ClientDisconnected { .. } => {
let _ = self.event_tx.send(NetworkEvent::ClientDisconnected);
}
}
}
transport.send_packets(&mut server);
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> {
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
let server_addr: SocketAddr = server_addr
.parse()
.map_err(|e| format!("地址解析失败: {}", e))?;
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("Client 绑定失败: {}", e))?;
let connection_config =
renet2::ConnectionConfig::from_shared_channels(vec![renet2::ChannelConfig {
channel_id: 0,
max_memory_usage_bytes: 5 * 1024 * 1024,
send_type: renet2::SendType::ReliableOrdered {
resend_time: Duration::from_millis(300),
},
}]);
let mut client = renet2::RenetClient::new(connection_config, false);
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let authentication = renet2_netcode::ClientAuthentication::Unsecure {
server_addr,
client_id: current_time.as_millis() as u64,
user_data: None,
protocol_id,
socket_id: 0,
};
let native_socket = renet2_netcode::NativeSocket::new(socket)
.map_err(|e| format!("创建 NativeSocket 失败: {}", e))?;
let mut transport = renet2_netcode::NetcodeClientTransport::new(
current_time,
authentication,
native_socket,
)
.map_err(|e| format!("创建传输层失败: {}", e))?;
let tick = Duration::from_millis(16);
let mut was_connected = false;
while self.running {
let now = 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(0u8, msg.to_bytes());
}
NetworkCmd::SendUndo { steps } => {
let msg = NetMessage::Undo { steps };
client.send_message(0u8, msg.to_bytes());
}
NetworkCmd::SendResign => {
client.send_message(0u8, NetMessage::Resign.to_bytes());
}
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(0u8) {
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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_net_message_move_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));
}
#[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)]);
}
}
+3
View File
@@ -122,6 +122,8 @@ pub struct GameConfig {
#[serde(default)]
pub remote_address: String,
#[serde(default)]
pub host_port: u16,
#[serde(default)]
pub use_llm: bool,
#[serde(default)]
pub llm_endpoint: String,
@@ -142,6 +144,7 @@ impl Default for GameConfig {
player_color: Color::Black,
is_server: false,
remote_address: String::new(),
host_port: 0,
use_llm: false,
llm_endpoint: String::new(),
llm_api_key: String::new(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
# Gobang 网络对战设计文档
> 状态: 已确认 | 日期: 2026-05-31
## 目标
为 Gobang v2.0 实现基于 renet 的 P2P 网络对战,两人通过 IP:端口直连对弈。
## 架构
```
┌─ React 前端 ───────────────────────────────────────────┐
│ OnlineSetup GameView │
│ 创建房间/加入房间 连接状态指示 │
│ listen("remote-move") │
└─────────────────────┬───────────────────────────────────┘
│ invoke / listen
┌─ Tauri IPC ─────────┼───────────────────────────────────┐
│ commands.rs │ │
│ host_game(port) │ 对手落子 → │
│ join_game(ip,port) │ app.emit("remote-move") │
│ send_move(x,y) │ │
│ send_undo() │ AppState.network_tx │
│ send_resign() │ channel 发送端 │
└─────────┬────────────┴──────────────────────────────────┘
│ mpsc::channel (NetworkCmd / NetworkEvent)
┌─ 网络线程 ──────────────────────────────────────────────┐
│ NetworkLoop │
│ Server: RenetServer + NetcodeServerTransport │
│ Client: RenetClient + NetcodeClientTransport │
│ │
│ loop { update → recv → process → send_packets } │
│ 16ms 帧率,消息通过 channel 与 commands 层通信 │
└──────────────────────────────────────────────────────────┘
```
## 通信模型
renet 内置 Server/Client。主机运行 RenetServer,对手作为 RenetClient 连接。所有消息经主机转发。
### 消息协议
网络传输使用 serde JSON + renet ReliableOrdered 通道:
```rust
enum NetMessage {
Move { x: usize, y: usize, turn: u32 },
Undo { steps: u32 },
Resign,
}
```
### Channel 接口
```rust
// commands → 网络线程
enum NetworkCmd {
SendMove { x: usize, y: usize, turn: u32 },
SendUndo { steps: u32 },
SendResign,
Shutdown,
}
// 网络线程 → commands
enum NetworkEvent {
RemoteMove { x: usize, y: usize },
RemoteUndo { steps: u32 },
RemoteResign,
ClientConnected,
ClientDisconnected,
Error(String),
}
```
## 连接流程
```
主机 (Server) 对手 (Client)
host_game(port)
绑定 UDP "0.0.0.0:{port}"
NetworkLoop::Server 启动
返回实际端口
emit("waiting") ───────────────────→ 对手 join_game(ip, port)
bind UDP "0.0.0.0:0"
NetworkLoop::Client 启动
connect to server ──→
← connected ─────────
收到 ClientConnected
emit("opponent-joined") ←──────────→ emit("connected")
游戏开始,黑方(主机)先手正常落子
place_piece → NetworkCmd::SendMove ─→ broadcast ─→ client recv
NetworkEvent::RemoteMove
invoke place_piece
```
## 生命周期
- 网络线程在 `new_game(Online)` 时 spawn
- 游戏结束或 AppState drop 时:发送 Shutdown → 线程退出 → join
- 对手断开:主机的 ClientDisconnected event → emit 对手获胜
## 前端改动
### OnlineSetup
- 创建房间:输入端口号(可选,默认随机),显示"我的地址: IP:端口"
- 加入房间:输入"IP:端口",连接
### GameView — 新增连接状态条
- 等待中:显示"等待对手加入... (你的地址: IP:端口)"
- 已连接:显示"已连接"
- 已断开:显示"对手断开连接" + 对手获胜
### BoardCanvas — 监听 remote-move
```typescript
useEffect(() => {
const unlisten = listen<{ x: number; y: number }>('remote-move', (event) => {
placePiece(event.payload.x, event.payload.y);
});
return () => { unlisten.then(fn => fn()); };
}, []);
```
### GameControls
- Online 模式:不显示悔棋(需双方同意,暂不做)
- 认输:调用 send_resign
## AppState 改动
```rust
pub struct AppState {
// ... 现有字段 ...
pub network_tx: Mutex<Option<Sender<NetworkCmd>>>,
}
```
## 依赖
```toml
# core/Cargo.toml & gui/Cargo.toml
renet = { version = "0.0.23", features = ["netcode"] }
renet_netcode = "0.0.15"
serde_json = "1" # 已有
```
## 不做 (YAGNI)
- Chat 功能(NetMessage 保留类型但无 UI
- NAT 穿透 / 中转服务器
- 断线重连
- 观战模式
- 悔棋双方确认(Online 模式直接禁悔棋)
## 测试策略
- Rust 单元测试:NetMessage serde 往返、NetworkCmd/Event channel 通信、NetworkLoop::new 创建
- 集成测试:renet 本地 client 模拟(server.new_local_client
- 前端测试:OnlineSetup 组件渲染、状态文本切换
- 手动测试:双窗口(host + join localhost
+1
View File
@@ -17,3 +17,4 @@ serde_json = "1"
gobang-core = { path = "../core" }
log = "0.4"
env_logger = "0.11"
renet2 = "0.15"
+130 -2
View File
@@ -1,11 +1,13 @@
use gobang_core::ai::search::AlphaBetaAi;
use gobang_core::ai::AiEngine;
use gobang_core::llm::LlmAi;
use gobang_core::board::Board;
use gobang_core::llm::LlmAi;
use gobang_core::network::{NetworkCmd, NetworkEvent, NetworkLoop};
use gobang_core::rules;
use gobang_core::types::*;
use std::sync::mpsc;
use std::sync::Mutex;
use tauri::State;
use tauri::{Emitter, State};
/// 应用全局状态
pub struct AppState {
@@ -15,6 +17,7 @@ pub struct AppState {
pub ai_engine: Mutex<Option<std::sync::Arc<dyn AiEngine + Send + Sync>>>,
pub current_color: Mutex<Color>,
pub game_over: Mutex<bool>,
pub network_tx: Mutex<Option<mpsc::Sender<NetworkCmd>>>,
}
impl Default for AppState {
@@ -26,12 +29,20 @@ impl Default for AppState {
ai_engine: Mutex::new(None),
current_color: Mutex::new(Color::Black),
game_over: Mutex::new(true),
network_tx: Mutex::new(None),
}
}
}
#[tauri::command]
pub fn new_game(mode: GameMode, config: GameConfig, state: State<AppState>) -> Result<(), String> {
// 清理旧的网络连接
if let Ok(mut tx) = state.network_tx.lock() {
if let Some(tx) = tx.take() {
let _ = tx.send(NetworkCmd::Shutdown);
}
}
let is_vs_ai = mode == GameMode::VsAi;
let board = Board::new(config.board_size);
log::info!("新游戏: mode={:?}, board_size={}", mode, config.board_size);
@@ -203,3 +214,120 @@ pub fn save_record(state: State<AppState>) -> Result<String, String> {
let record = gobang_core::record::GameRecord::from_board(board, "玩家", "对手", None);
serde_json::to_string_pretty(&record).map_err(|e| e.to_string())
}
#[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 = NetworkLoop::new_server(cmd_rx, event_tx);
let sock = std::net::UdpSocket::bind(format!("0.0.0.0:{}", port))
.map_err(|e| format!("绑定端口失败: {}", e))?;
let actual_port = sock.local_addr().map_err(|e| e.to_string())?.port();
drop(sock);
*state.network_tx.lock().map_err(|e| e.to_string())? = Some(cmd_tx);
let protocol_id: u64 = 7777;
std::thread::spawn(move || {
let _ = network.run("", protocol_id);
});
// event 转发线程
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 | NetworkEvent::ClientConnected => {
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);
}
NetworkEvent::Listening(port) => {
let _ = app_clone.emit("listening-port", port);
}
}
}
});
Ok(actual_port)
}
#[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_tx);
*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 | NetworkEvent::ClientConnected => {
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(())
}
#[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())
}
+5 -1
View File
@@ -12,11 +12,15 @@ pub fn run() {
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,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+15
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useMemo } from 'react';
import { listen } from '@tauri-apps/api/event';
import { useGameStore, buildReplayBoard } from '../../store/gameStore';
import {
computeBoardDimensions,
@@ -59,6 +60,20 @@ export default function BoardCanvas() {
return () => window.removeEventListener('resize', handleResize);
}, [render]);
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]);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (status !== 'playing') return;
+2 -1
View File
@@ -10,6 +10,7 @@ export default function GameControls({ onBackToMenu }: Props) {
const { t } = useTranslation();
const undo = useGameStore((s) => s.undo);
const status = useGameStore((s) => s.status);
const mode = useGameStore((s) => s.mode);
const refreshBoard = useGameStore((s) => s.refreshBoard);
const handleUndo = () => {
@@ -38,7 +39,7 @@ export default function GameControls({ onBackToMenu }: Props) {
return (
<div className="game-controls">
<button onClick={handleUndo} disabled={status === 'game_over'}>
<button onClick={handleUndo} disabled={status === 'game_over' || mode === 'Online'}>
{t('game.undo')}
</button>
<button onClick={handleResign} disabled={status === 'game_over'}>
+29
View File
@@ -1,3 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event';
import { useGameStore } from '../../store/gameStore';
import BoardCanvas from '../board/BoardCanvas';
import GameInfo from './GameInfo';
import GameControls from './GameControls';
@@ -8,8 +12,33 @@ interface Props {
}
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;
let unlisten1: (() => void) | undefined;
let unlisten2: (() => void) | undefined;
const setup = async () => {
unlisten1 = await listen<string>('connection-status', (e) => setConnStatus(e.payload));
unlisten2 = await listen<number>('listening-port', (e) => setConnStatus('waiting:' + e.payload));
};
setup();
return () => { unlisten1?.(); unlisten2?.(); };
}, [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 />
+1 -7
View File
@@ -27,13 +27,7 @@ export default function MainMenu({ onGameStart, onReplayStart }: Props) {
<div className="menu-buttons">
<button onClick={() => setView('local')}>{t('menu.local_game')}</button>
<button onClick={() => setView('ai')}>{t('menu.ai_game')}</button>
<button
onClick={() => setView('online')}
disabled
title={t('menu.online_game_disabled')}
>
{t('menu.online_game')} ()
</button>
<button onClick={() => setView('online')}>{t('menu.online_game')}</button>
<button onClick={() => setView('replay')}>{t('menu.load_replay')}</button>
</div>
</div>
+57 -13
View File
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import { useState } from 'react';
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';
@@ -9,24 +11,66 @@ 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 [boardSize, setBoardSize] = useState(15);
const baseConfig: GameConfig = {
boardSize, useForbiddenRules: true, useTimer: false,
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false,
remoteAddress: '',
};
const [ip, setIp] = useState('');
const [myAddress, setMyAddress] = useState('');
const [isHosting, setIsHosting] = useState(false);
const handleHost = async () => {
await startGame('Online', { ...baseConfig, isServer: true });
try {
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,
useLlm: false, llmEndpoint: '', llmApiKey: '', llmModel: '',
};
await startGame('Online', config);
onStart();
}
});
} catch (e) {
alert('创建房间失败: ' + e);
}
};
const handleJoin = async () => {
await startGame('Online', { ...baseConfig, remoteAddress: ip });
onStart();
try {
const [_, portStr] = ip.split(':');
const port = parseInt(portStr) || 0;
const config: GameConfig = {
boardSize, useForbiddenRules: true, useTimer: false,
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'White',
isServer: false, remoteAddress: ip, hostPort: port,
useLlm: false, llmEndpoint: '', llmApiKey: '', llmModel: '',
};
await startGame('Online', config);
await invoke('join_game', { address: ip });
onStart();
} catch (e) {
alert('加入房间失败: ' + e);
}
};
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">
@@ -35,13 +79,13 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
{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}&times;{s}</option>
<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') as string} />
<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>
+1
View File
@@ -20,6 +20,7 @@ export interface GameConfig {
playerColor: Color;
isServer: boolean;
remoteAddress: string;
hostPort?: number;
useLlm?: boolean;
llmEndpoint?: string;
llmApiKey?: string;
+3 -1
View File
@@ -33,7 +33,9 @@
"new_game": "New Game",
"waiting_opponent": "Waiting for Opponent...",
"your_turn": "Your Turn",
"opponent_turn": "Opponent's Turn"
"opponent_turn": "Opponent's Turn",
"opponent_connected": "Opponent Connected",
"opponent_disconnected": "Opponent Disconnected"
},
"replay": {
"play": "Play",
+3 -1
View File
@@ -33,7 +33,9 @@
"new_game": "新游戏",
"waiting_opponent": "等待对手加入...",
"your_turn": "你的回合",
"opponent_turn": "对手回合"
"opponent_turn": "对手回合",
"opponent_connected": "对手已连接",
"opponent_disconnected": "对手已断开"
},
"replay": {
"play": "播放",