Files
audiopoker/client/src/network.rs
T
2026-07-25 11:26:41 +02:00

197 lines
7.9 KiB
Rust

//! Client-seitige WebSocket-Anbindung an `audiopoker_server` (siehe PLAN.md, Phase 2).
//!
//! `web_sys::WebSocket` ist callback-basiert (onopen/onmessage/onerror/onclose)
//! und die dafür nötigen `Closure`s sind nicht `Send`. Deshalb wird die
//! Verbindung nicht als normale Bevy-`Resource`, sondern als
//! Non-Send-Resource gehalten (`app.insert_non_send_resource`, gelesen über
//! `NonSend`/`NonSendMut`) - das ist das übliche Bevy-Muster für JS-Interop.
//! Eingehende Nachrichten landen zunächst in einer gemeinsamen Queue
//! (`Rc<RefCell<VecDeque<...>>>`), die ein normales Bevy-System jeden Frame
//! leert und in `Announce`/`PlaySfx`/`NetworkGameView`-Updates übersetzt
//! (siehe `game_plugin.rs`).
//!
//! Wichtiger Hinweis zur Verifikation: Dieser Code nutzt `#[cfg(target_arch
//! = "wasm32")]`, weil `web_sys::WebSocket` zur Laufzeit eine echte
//! Browser-Umgebung braucht. Ein natives `cargo check`/`cargo build` prüft
//! ausschließlich den `not(wasm32)`-Dummy-Zweig unten - der eigentliche
//! WebSocket-Code wird dabei NICHT typgeprüft. Zur echten Verifikation bitte
//! zusätzlich `cargo check --target wasm32-unknown-unknown -p
//! audiopoker_client` ausführen.
use audiopoker_core::network::{ClientMessage, ServerMessage};
/// Serverurl/Tisch/Spielername lassen sich über die Browser-URL steuern,
/// z. B. `index.html?server=ws://localhost:9001&table=tisch1&name=Anna` -
/// praktisch, um mehrere Browser-Tabs als unterschiedliche Spieler an
/// denselben Tisch zu hängen.
pub struct ConnectionConfig {
pub server_url: String,
pub table_name: String,
pub player_name: String,
}
#[cfg(target_arch = "wasm32")]
mod wasm_impl {
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
use super::{ClientMessage, ConnectionConfig, ServerMessage};
/// Non-Send-Resource: hält die WebSocket-Verbindung und die Closures,
/// die JS beim Aufruf von `set_on...` am Leben halten muss (würden sie
/// gedroppt, ruft der Browser sie nicht mehr auf bzw. meldet einen Fehler).
pub struct NetworkClient {
socket: WebSocket,
incoming: Rc<RefCell<VecDeque<ServerMessage>>>,
_on_open: Closure<dyn FnMut()>,
_on_message: Closure<dyn FnMut(MessageEvent)>,
_on_error: Closure<dyn FnMut(ErrorEvent)>,
_on_close: Closure<dyn FnMut(CloseEvent)>,
}
impl NetworkClient {
pub fn connect(config: ConnectionConfig) -> Self {
let socket = WebSocket::new(&config.server_url)
.expect("WebSocket-Verbindung konnte nicht aufgebaut werden");
let incoming = Rc::new(RefCell::new(VecDeque::new()));
let on_open = {
let socket = socket.clone();
let table_name = config.table_name.clone();
let player_name = config.player_name.clone();
Closure::<dyn FnMut()>::new(move || {
let join = ClientMessage::JoinTable {
table_name: table_name.clone(),
player_name: player_name.clone(),
};
if let Ok(text) = serde_json::to_string(&join) {
let _ = socket.send_with_str(&text);
}
})
};
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
let on_message = {
let incoming = Rc::clone(&incoming);
Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
let Some(text) = event.data().as_string() else {
return;
};
match serde_json::from_str::<ServerMessage>(&text) {
Ok(message) => incoming.borrow_mut().push_back(message),
Err(err) => web_sys::console::warn_1(
&format!("Ungültige Server-Nachricht ignoriert: {err}").into(),
),
}
})
};
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
let on_error = Closure::<dyn FnMut(ErrorEvent)>::new(move |_event: ErrorEvent| {
web_sys::console::error_1(&"WebSocket-Fehler.".into());
});
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
let on_close = Closure::<dyn FnMut(CloseEvent)>::new(move |event: CloseEvent| {
web_sys::console::warn_1(
&format!("WebSocket-Verbindung geschlossen ({}).", event.reason()).into(),
);
});
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
Self {
socket,
incoming,
_on_open: on_open,
_on_message: on_message,
_on_error: on_error,
_on_close: on_close,
}
}
/// Schickt eine Client-Nachricht, sofern die Verbindung offen ist.
/// Verworfene Nachrichten (z. B. weil noch nicht verbunden) landen
/// nur als Konsolen-Warnung, nicht als Panik - ein Verbindungsabbruch
/// soll das Spiel nicht crashen lassen.
pub fn send(&self, message: &ClientMessage) {
if self.socket.ready_state() != WebSocket::OPEN {
web_sys::console::warn_1(&"Nicht verbunden, Nachricht verworfen.".into());
return;
}
match serde_json::to_string(message) {
Ok(text) => {
let _ = self.socket.send_with_str(&text);
}
Err(err) => {
web_sys::console::error_1(&format!("Konnte Nachricht nicht kodieren: {err}").into());
}
}
}
/// Entnimmt alle bislang eingegangenen Server-Nachrichten (FIFO).
pub fn drain_incoming(&self) -> Vec<ServerMessage> {
self.incoming.borrow_mut().drain(..).collect()
}
}
/// Liest einen Query-Parameter aus der aktuellen Browser-URL, mit Fallback.
pub fn read_query_param(name: &str, default: &str) -> String {
web_sys::window()
.and_then(|w| w.location().search().ok())
.and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
.and_then(|params| params.get(name))
.unwrap_or_else(|| default.to_string())
}
/// Zufallszahl für Default-Spielernamen ("Spieler1234"), ohne auf
/// `rand`/OS-Entropie angewiesen zu sein (im Browser über `Math.random`).
pub fn random_suffix() -> u32 {
(js_sys::Math::random() * 10_000.0) as u32
}
}
#[cfg(not(target_arch = "wasm32"))]
mod native_stub {
use super::{ClientMessage, ConnectionConfig, ServerMessage};
/// Dummy für native Builds/Tests: `web_sys::WebSocket` funktioniert zur
/// Laufzeit nur im Browser. Diese Variante verbindet sich nicht wirklich,
/// sondern loggt nur - so bleiben `cargo check`/`cargo test` auf dem
/// nativen Target weiterhin nutzbar (siehe Modul-Kommentar oben).
pub struct NetworkClient;
impl NetworkClient {
pub fn connect(config: ConnectionConfig) -> Self {
println!(
"Networking (Dummy, natives Target): würde zu {} verbinden (Tisch '{}', Name '{}').",
config.server_url, config.table_name, config.player_name
);
Self
}
pub fn send(&self, _message: &ClientMessage) {}
pub fn drain_incoming(&self) -> Vec<ServerMessage> {
Vec::new()
}
}
pub fn read_query_param(_name: &str, default: &str) -> String {
default.to_string()
}
pub fn random_suffix() -> u32 {
0
}
}
#[cfg(target_arch = "wasm32")]
pub use wasm_impl::*;
#[cfg(not(target_arch = "wasm32"))]
pub use native_stub::*;