mirror of
https://github.com/LHY0125/Gobang-Game.git
synced 2026-06-29 00:45:55 +08:00
Compare commits
8 Commits
v2.0.1
...
v2.0.1-net
| Author | SHA1 | Date | |
|---|---|---|---|
| bda917ce37 | |||
| 9aa9de6b74 | |||
| 33c1f8d8d4 | |||
| 92894d2904 | |||
| af64a99987 | |||
| 6da7b7a43b | |||
| 105aa968e0 | |||
| f2898880eb |
Generated
+169
-2
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -17,3 +17,4 @@ serde_json = "1"
|
||||
gobang-core = { path = "../core" }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
renet2 = "0.15"
|
||||
|
||||
+130
-2
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,25 +11,67 @@ 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 });
|
||||
onStart();
|
||||
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">
|
||||
<h2>{t('menu.online_game')}</h2>
|
||||
@@ -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}×{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>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface GameConfig {
|
||||
playerColor: Color;
|
||||
isServer: boolean;
|
||||
remoteAddress: string;
|
||||
hostPort?: number;
|
||||
useLlm?: boolean;
|
||||
llmEndpoint?: string;
|
||||
llmApiKey?: string;
|
||||
|
||||
+3
-1
@@ -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
@@ -33,7 +33,9 @@
|
||||
"new_game": "新游戏",
|
||||
"waiting_opponent": "等待对手加入...",
|
||||
"your_turn": "你的回合",
|
||||
"opponent_turn": "对手回合"
|
||||
"opponent_turn": "对手回合",
|
||||
"opponent_connected": "对手已连接",
|
||||
"opponent_disconnected": "对手已断开"
|
||||
},
|
||||
"replay": {
|
||||
"play": "播放",
|
||||
|
||||
Reference in New Issue
Block a user