diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..516ec22 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,502 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "audiopoker_core" +version = "0.1.0" +dependencies = [ + "rand", + "serde", +] + +[[package]] +name = "audiopoker_server" +version = "0.1.0" +dependencies = [ + "audiopoker_core", + "futures-util", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/PLAN.md b/PLAN.md index 56571a1..6b3bb0b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -35,8 +35,11 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über - [x] **Netzwerkprotokoll**: `core::network::{ClientMessage, ServerMessage}` neu entworfen, sodass der Server dieselben Announce-/PlaySfx-"Häppchen" verschickt, die der lokale Client schon per TTS/SFX verarbeitet (`GameManager::take_action` liefert sie direkt). Erhöhungsbeträge werden bewusst serverseitig festgelegt (kein clientseitig frei wählbarer Raise-Betrag). - [x] **Multiplayer-Lobby/Tischsuche (einfache Variante)**: Tischname im `JoinTable` dient als Suchbegriff; unbekannte Namen legen automatisch einen neuen Tisch an. Kein Auflisten aller offenen Tische (`ListTables`) - falls gewünscht, wäre das die nächste Ausbaustufe. - **Getestet**: `cargo test -p audiopoker_core` (10 Tests) und ein Ende-zu-Ende-Smoke-Test mit zwei echten WebSocket-Clients (Python) gegen den laufenden `audiopoker_server` liefen hier erfolgreich durch (kompletter Hand-Durchlauf Pre-Flop bis Showdown, korrekte Sieger-Ermittlung). -- [ ] **Client-Anbindung an den Server fehlt noch**: Der Bevy-Client (`client/`) spielt aktuell weiterhin nur lokal/offline (siehe `game_plugin.rs`, 2 fest verdrahtete Spieler). Die Anbindung per `web_sys::WebSocket` (Feature ist in `client/Cargo.toml` schon vorbereitet) an `audiopoker_server` ist der nächste sinnvolle Schritt. +- [x] **Client-Anbindung an den Server**: `client/src/network.rs` verbindet sich per `web_sys::WebSocket` mit `audiopoker_server` (Non-Send-Resource, da die JS-Callbacks nicht `Send` sind). `game_plugin.rs` spielt jetzt nicht mehr lokal/offline, sondern hält nur noch eine schlanke `NetworkGameView` (eigene Hole Cards, sichtbare Community Cards, wer am Zug ist) und schickt Aktionen als `ClientMessage` an den Server; `ServerMessage`s werden zu `Announce`/`PlaySfx` übersetzt (gleicher Audio-Pfad wie zuvor lokal). Server-Bugfix nebenbei gefixt: Aktionen nach `Round::Showdown` wurden bisher nicht abgelehnt - jetzt gibt's dafür einen `Error` plus ein neues `ServerMessage::HandFinished`-Signal, mit dem Clients die Eingabe sperren. + - Server-/Tisch-/Spielername sind über Browser-URL-Parameter steuerbar (`?server=...&table=...&name=...`), damit sich mehrere Browser-Tabs bequem als unterschiedliche Spieler an denselben Tisch hängen lassen. + - **Wichtiger Verifikationshinweis**: Der eigentliche `web_sys::WebSocket`-Code (`#[cfg(target_arch = "wasm32")]`) konnte hier nicht kompiliert werden (kein wasm32-Std verfügbar). Ein natives `cargo check -p audiopoker_client` prüft nur den `not(wasm32)`-Dummy-Zweig, NICHT den echten WebSocket-Pfad! Bitte zusätzlich `cargo check --target wasm32-unknown-unknown -p audiopoker_client` (Target ggf. per `rustup target add wasm32-unknown-unknown` installieren) ausführen, um das wirklich zu verifizieren. Der plattformunabhängige Teil (`ClientMessage`/`ServerMessage`-Nutzung, Query-Parameter-Parsing) wurde hier isoliert gegen den nativen Dummy-Zweig getestet und funktioniert. - [ ] Tisch-Übersicht/`ListTables` für echte Tischsuche (aktuell nur "Name kennen und beitreten"). +- [ ] Neue Hand nach Showdown starten (serverseitig; der Client sperrt nach `HandFinished` nur die Eingabe). ### Phase 3: Audio Experience & UI - [x] **TTS-Logik verfeinert**: `GameManager::take_action` erzeugt kontextuelle Ansagen (Aktion des Spielers, Rundenwechsel inkl. aufgedeckter Community Cards, Showdown-Gewinner mit Hand-Kategorie, "wer ist am Zug"). Karten werden über `Card`/`Suit`-`Display` sprachfreundlich ausgegeben ("König Herz"). @@ -49,3 +52,10 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über - [ ] Barrierefreiheits-Audit (Tastatur-Flow ohne Maus). - [ ] Latenztests im Multiplayer. - [ ] WebAssembly Deployment Vorbereitung. + +## Lokal zu zweit testen (nach Client-Netzwerk-Anbindung) +1. Server starten: `cargo run -p audiopoker_server` (lauscht standardmäßig auf `ws://0.0.0.0:9001`, überschreibbar über die Umgebungsvariable `AUDIOPOKER_ADDR`). +2. Client für Wasm bauen/starten (z. B. über `trunk serve` o. ä. - noch nicht Teil dieses Repos, siehe offene Punkte) und zweimal im Browser öffnen: + - Tab 1: `?server=ws://127.0.0.1:9001&table=tisch1&name=Anna` + - Tab 2: `?server=ws://127.0.0.1:9001&table=tisch1&name=Ben` +3. Sobald beide beigetreten sind, startet die Hand automatisch; Steuerung wie gehabt über Tab/Space/Enter. diff --git a/client/Cargo.toml b/client/Cargo.toml index 5550329..3b428bf 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -8,12 +8,10 @@ audiopoker_core = { path = "../core" } bevy = { version = "0.19", features = ["webgl2"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# wasm-bindgen/web-sys/js-sys/futures: aktuell nur für audio::tts (Web Speech -# API) genutzt. Werden zusätzlich gebraucht, sobald der Client per -# web_sys::WebSocket an den `server` (siehe ../server) angebunden wird - -# das ist noch offen, siehe PLAN.md. +# wasm-bindgen/web-sys/js-sys/futures: für audio::tts (Web Speech API) und +# network (WebSocket-Anbindung an ../server), siehe src/network.rs. wasm-bindgen = "0.2" -web-sys = { version = "0.3", features = ["SpeechRecognition", "SpeechSynthesis", "SpeechSynthesisUtterance", "Window", "HtmlInputElement", "console", "WebSocket", "MessageEvent"] } +web-sys = { version = "0.3", features = ["SpeechRecognition", "SpeechSynthesis", "SpeechSynthesisUtterance", "Window", "HtmlInputElement", "console", "WebSocket", "MessageEvent", "ErrorEvent", "CloseEvent", "Location", "UrlSearchParams"] } js-sys = "0.3" futures = "0.3" rand = "0.8.5" diff --git a/client/src/game_plugin.rs b/client/src/game_plugin.rs index 8115ebc..6729ab4 100644 --- a/client/src/game_plugin.rs +++ b/client/src/game_plugin.rs @@ -1,10 +1,17 @@ -//! Verbindet die (bewusst Bevy-unabhängige) Kernlogik aus `core` und `audio` -//! mit einer echten Bevy-`App` (siehe PLAN.md, Phase 1 & 3). +//! Verbindet die Bevy-`App` mit der Audio-Engine (`audio`) und dem +//! `network`-Modul, das die WebSocket-Verbindung zu `audiopoker_server` +//! hält (siehe PLAN.md, Phase 2/3). +//! +//! Architektur: Der Server ist die einzige Quelle der Wahrheit für den +//! Spielzustand (siehe `core::network`-Doku). Der Client hält deshalb nur +//! eine schlanke `NetworkGameView` (eigene Hole Cards, sichtbare Community +//! Cards, wer am Zug ist) statt eines vollen `GameState` - das verhindert +//! auch, dass ein manipulierter Client sich selbst fremde Hole Cards zeigen +//! könnte, da der Server sie ohnehin nur an den jeweiligen Spieler schickt. //! //! Hinweis zur Bevy-Version: Seit Bevy 0.17 sind gepufferte Events als -//! `Message` (statt `Event`) modelliert und laufen über `MessageWriter` / -//! `MessageReader` / `app.add_message::()`. `Event` bezeichnet seither -//! ausschließlich Observer-Events. Dieser Code ist auf Bevy 0.19 ausgelegt. +//! `Message` (statt `Event`) modelliert, siehe `MessageWriter`/`MessageReader`/ +//! `app.add_message::()`. Dieser Code ist auf Bevy 0.19 ausgelegt. //! //! Steuerung (barrierefrei, tastaturzentriert, siehe PLAN.md): //! - `Tab`: wechselt die ausgewählte Aktion (Fold / Call-Check / Raise). @@ -12,14 +19,11 @@ use bevy::prelude::*; -use crate::audio::AudioEngine; -use audiopoker_core::game::{BettingAction, GameManager, GameState, Round, RAISE_INCREMENT}; -use audiopoker_core::logic::Deck; +use audiopoker_core::logic::card::Card; +use audiopoker_core::network::{ClientMessage, ServerMessage}; -/// Bevy-Resource-Wrapper um den Engine-unabhängigen Spielzustand. -/// Bewusst als Newtype, damit `core::game` selbst keine Bevy-Abhängigkeit braucht. -#[derive(Resource)] -pub struct Table(pub GameState); +use crate::audio::AudioEngine; +use crate::network::{self, ConnectionConfig, NetworkClient}; /// Bevy-Resource-Wrapper um die Engine-unabhängige Audio-Engine (`audio::AudioEngine`). #[derive(Resource)] @@ -34,7 +38,19 @@ pub struct ActionCursor { const ACTION_LABELS: [&str; 3] = ["Fold", "Call/Check", "Raise"]; -/// Message: Ein Text soll per TTS angesagt werden (z. B. "Spieler 1 setzt 50"). +/// Der clientseitige Blick auf das Spiel: nur das, was der Server diesem +/// Client tatsächlich mitgeteilt hat. Kein autoritativer Zustand - der +/// liegt beim Server (`audiopoker_server::table::TableActor`). +#[derive(Resource, Default)] +pub struct NetworkGameView { + pub my_player_id: Option, + pub hole_cards: Vec, + pub community_cards: Vec, + pub to_act: Option, + pub hand_finished: bool, +} + +/// Message: Ein Text soll per TTS angesagt werden. #[derive(Message, Debug, Clone)] pub struct Announce(pub String); @@ -48,55 +64,119 @@ impl Plugin for AudiopokerPlugin { fn build(&self, app: &mut App) { app.insert_resource(AudioRes(AudioEngine::new())) .init_resource::() + .init_resource::() .add_message::() .add_message::() - .add_systems(Startup, setup_game) - .add_systems(Update, (handle_player_input, speak_announcements, play_sfx_cues)); + .add_systems(Startup, setup_network) + .add_systems( + Update, + ( + receive_network_messages, + handle_player_input, + speak_announcements, + play_sfx_cues, + ), + ); } } -/// Startet eine neue Partie, teilt Karten aus und kündigt beides per -/// TTS/SFX-Message an. Läuft einmalig im `Startup`-Schedule. -fn setup_game( - mut commands: Commands, +/// Baut beim Start die WebSocket-Verbindung auf. Server-/Tisch-/Spielername +/// lassen sich über die Browser-URL steuern (siehe `network::read_query_param`), +/// damit mehrere Browser-Tabs bequem als unterschiedliche Spieler denselben +/// Tisch testen können. +fn setup_network(world: &mut World) { + let config = ConnectionConfig { + server_url: network::read_query_param("server", "ws://127.0.0.1:9001"), + table_name: network::read_query_param("table", "tisch1"), + player_name: network::read_query_param( + "name", + &format!("Spieler{}", network::random_suffix()), + ), + }; + + let client = NetworkClient::connect(config); + world.insert_non_send_resource(client); +} + +/// Übersetzt eine Server-`PlaySfx`-Cue-ID (freier String) in eine der lokal +/// bekannten, `'static` Cue-IDs. Unbekannte IDs werden ignoriert, statt das +/// Spiel abstürzen zu lassen (z. B. falls Server und Client mal auseinanderlaufen). +fn map_sfx_id(id: &str) -> Option<&'static str> { + match id { + "shuffle" => Some("shuffle"), + "chip" => Some("chip"), + "fold" => Some("fold"), + "flip" => Some("flip"), + _ => None, + } +} + +/// Leert die eingehende Nachrichten-Queue der Netzwerkverbindung und +/// übersetzt jede `ServerMessage` in ein Update von `NetworkGameView` +/// und/oder eine `Announce`/`PlaySfx`-Message fürs Audio-Feedback. +fn receive_network_messages( + net: NonSend, + mut view: ResMut, mut announcements: MessageWriter, mut sfx_cues: MessageWriter, ) { - let mut game = GameManager::start_new_game(vec!["Spieler 1".into(), "Spieler 2".into()]); - - let mut deck = Deck::new(); - deck.shuffle(); - for player in game.players.iter_mut() { - player.hole_cards = deck.deal_hand(2); + for message in net.drain_incoming() { + match message { + ServerMessage::Welcome { player_id } => { + view.my_player_id = Some(player_id); + announcements.write(Announce(format!("Verbunden als Spieler {player_id}."))); + announcements.write(Announce( + "Steuerung: Tab wählt eine Aktion aus, Leertaste oder Eingabetaste bestätigt.".into(), + )); + } + ServerMessage::Announce(text) => announcements.write(Announce(text)), + ServerMessage::PlaySfx(id) => { + if let Some(cue) = map_sfx_id(&id) { + sfx_cues.write(PlaySfx(cue)); + } + } + ServerMessage::HoleCards(cards) => { + let spoken = cards + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(" und "); + announcements.write(Announce(format!("Deine Karten: {spoken}."))); + view.hole_cards = cards; + } + ServerMessage::CommunityCards(cards) => { + view.community_cards = cards; + } + ServerMessage::TurnToAct { player_id } => { + view.to_act = Some(player_id); + if view.my_player_id == Some(player_id) { + announcements.write(Announce("Du bist am Zug.".into())); + } + } + ServerMessage::HandFinished => { + view.hand_finished = true; + } + ServerMessage::Error(reason) => { + announcements.write(Announce(format!("Fehler: {reason}"))); + } + } } - game.community_cards = deck.deal_hand(5); - - sfx_cues.write(PlaySfx("shuffle")); - announcements.write(Announce(format!( - "Neues Spiel gestartet mit {} Spielern.", - game.players.len() - ))); - announcements.write(Announce( - "Steuerung: Tab wählt eine Aktion aus, Leertaste oder Eingabetaste bestätigt.".into(), - )); - announcements.write(Announce(format!("{} ist am Zug.", game.players[game.to_act].name))); - - commands.insert_resource(Table(game)); } -/// Liest Tab/Space/Enter und wendet die ausgewählte Aktion auf den aktuell -/// am Zug befindlichen Spieler an. Die eigentliche Spiellogik (Straßen- -/// Fortschritt, Showdown) steckt komplett in `GameManager::take_action`. +/// Liest Tab/Space/Enter und schickt bei Bestätigung die passende +/// `ClientMessage` an den Server. Die eigentliche Spiellogik läuft +/// ausschließlich serverseitig (`GameManager::take_action`); der Client +/// wartet einfach auf die resultierenden `ServerMessage`s. fn handle_player_input( keys: Res>, mut cursor: ResMut, - mut table: ResMut, + view: Res, + net: NonSend, mut announcements: MessageWriter, - mut sfx_cues: MessageWriter, ) { - if table.0.current_round == Round::Showdown { - // Hand ist beendet. Eine neue Hand starten ist noch nicht implementiert - // (nächster sinnvoller Schritt, z. B. über eine eigene "Neue Hand"-Taste). + if view.hand_finished { + // Hand ist beendet. Eine neue Hand starten ist noch nicht + // implementiert (nächster sinnvoller Schritt, siehe PLAN.md). return; } @@ -106,24 +186,21 @@ fn handle_player_input( } if keys.just_pressed(KeyCode::Space) || keys.just_pressed(KeyCode::Enter) { - let state = &mut table.0; - let player_idx = state.to_act; - let to_call = GameManager::amount_to_call(state, player_idx); - - let action = match cursor.selected { - 0 => BettingAction::Fold, - 1 if to_call == 0 => BettingAction::Check, - 1 => BettingAction::Call(to_call), - _ => BettingAction::Raise(to_call + RAISE_INCREMENT), + let Some(my_id) = view.my_player_id else { + announcements.write(Announce("Noch nicht mit dem Tisch verbunden.".into())); + return; }; + if view.to_act != Some(my_id) { + announcements.write(Announce("Du bist gerade nicht am Zug.".into())); + return; + } - let outcome = GameManager::take_action(state, player_idx, action); - for line in outcome.announcements { - announcements.write(Announce(line)); - } - for cue in outcome.sfx_cues { - sfx_cues.write(PlaySfx(cue)); - } + let message = match cursor.selected { + 0 => ClientMessage::Fold, + 1 => ClientMessage::Check, // Server wertet das bei fälligem Call automatisch als Call. + _ => ClientMessage::Raise, + }; + net.send(&message); } } diff --git a/client/src/main.rs b/client/src/main.rs index 733ca91..1b5d0c1 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,5 +1,6 @@ mod audio; mod game_plugin; +mod network; use bevy::prelude::*; use game_plugin::AudiopokerPlugin; diff --git a/client/src/network.rs b/client/src/network.rs new file mode 100644 index 0000000..0d98bee --- /dev/null +++ b/client/src/network.rs @@ -0,0 +1,196 @@ +//! 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>>`), 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>>, + _on_open: Closure, + _on_message: Closure, + _on_error: Closure, + _on_close: Closure, + } + + 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::::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::::new(move |event: MessageEvent| { + let Some(text) = event.data().as_string() else { + return; + }; + match serde_json::from_str::(&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::::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::::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 { + 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 { + 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::*; diff --git a/core/src/network.rs b/core/src/network.rs index 308597e..8fb98a4 100644 --- a/core/src/network.rs +++ b/core/src/network.rs @@ -43,6 +43,10 @@ pub enum ServerMessage { CommunityCards(Vec), /// Wer als Nächstes agieren muss. TurnToAct { player_id: u32 }, + /// Die Hand ist vorbei (Showdown erreicht oder alle bis auf einen + /// gefoldet). Der Text dazu kommt separat als `Announce`; dies ist nur + /// das strukturierte Signal, damit Clients z. B. die Eingabe sperren können. + HandFinished, /// Aktion war ungültig (z. B. nicht am Zug, Tisch schon voll) oder ein /// sonstiger Fehler ist aufgetreten. Error(String), diff --git a/server/src/table.rs b/server/src/table.rs index b1d2a8c..90b93a9 100644 --- a/server/src/table.rs +++ b/server/src/table.rs @@ -147,6 +147,11 @@ impl TableActor { return; } + if state.current_round == Round::Showdown { + self.send_to(player_id, ServerMessage::Error("Die Hand ist bereits beendet.".into())); + return; + } + let to_call = GameManager::amount_to_call(state, player_id as usize); let action = match message { ClientMessage::Fold => BettingAction::Fold, @@ -183,7 +188,9 @@ impl TableActor { )); } - if state.current_round != Round::Showdown { + if state.current_round == Round::Showdown { + self.broadcast(ServerMessage::HandFinished); + } else { self.broadcast(ServerMessage::TurnToAct { player_id: state.players[state.to_act].id, });