Compare commits

...
2 Commits
Author SHA1 Message Date
henning b6fbad773d Network logic 2026-07-25 11:26:41 +02:00
henning 730855ae5d client/server 2026-07-24 14:07:18 +02:00
24 changed files with 1034 additions and 5931 deletions
Generated
+94 -5720
View File
File diff suppressed because it is too large Load Diff
+3 -17
View File
@@ -1,17 +1,3 @@
[package]
name = "audiopoker"
version = "0.1.0"
edition = "2024"
[dependencies]
bevy = { version = "0.19", features = ["webgl2"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["SpeechRecognition", "SpeechSynthesis", "SpeechSynthesisUtterance", "Window", "HtmlInputElement", "console"] }
js-sys = "0.3"
futures = "0.3"
rand = "0.8.5"
[target.wasm]
edition = 2024
[workspace]
resolver = "2"
members = ["core", "client", "server"]
+21 -3
View File
@@ -25,10 +25,21 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über
- [x] **Audio-Grundlagen**: `audio::tts` ruft die Web Speech API korrekt an (`SpeechSynthesisUtterance`); auf nativen Targets Dummy-Ausgabe für Tests.
- [x] Bevy-`App`-Grundgerüst aufgesetzt: `AudiopokerPlugin` (`src/game_plugin.rs`) verdrahtet `core::game`/`core::logic` und `audio` über Bevy-Messages (`Announce`, `PlaySfx`) mit `Startup`/`Update`-Systemen. API-Stand: Bevy 0.19 (`Message`/`MessageWriter`/`MessageReader`/`add_message`, seit 0.17 getrennt von Observer-`Event`s). **Bitte lokal mit `cargo check`/`cargo run` verifizieren**, da hier kein zu Bevy 0.19 passender Compiler zur Verfügung stand.
## Projektstruktur (seit Phase 2: Cargo-Workspace)
- `core/` (Lib `audiopoker_core`): `logic`, `game`, `network` - unverändert engine-unabhängig, jetzt von Client *und* Server genutzt.
- `client/` (Bin `audiopoker_client`): Bevy/Wasm, bisheriger Inhalt von `src/`.
- `server/` (Bin `audiopoker_server`): neuer nativer WebSocket-Server (Tokio + tokio-tungstenite).
### Phase 2: Networking & Multiplayer
- [ ] Server-Architektur für Spielräume und Spielerverwaltung aufbauen.
- [ ] Netzwerkprotokoll für Aktionen (Deal, Bet, Fold) definieren.
- [ ] Multiplayer-Lobby und Tischsuche implementieren.
- [x] **Server-Architektur**: `server/src/table.rs` (`TableActor`, Actor-Modell: ein Tokio-Task pro Tisch, kein geteilter Mutex für den Spielzustand) + `server/src/lobby.rs` (Tisch-Registry, legt Tische on-demand per Namen an). `server/src/main.rs` nimmt WebSocket-Verbindungen an (`tokio-tungstenite`) und verbindet sie mit dem passenden Tisch.
- [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).
- [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").
@@ -41,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.
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "audiopoker_client"
version = "0.1.0"
edition = "2024"
[dependencies]
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: 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", "ErrorEvent", "CloseEvent", "Location", "UrlSearchParams"] }
js-sys = "0.3"
futures = "0.3"
rand = "0.8.5"
+219
View File
@@ -0,0 +1,219 @@
//! 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, siehe `MessageWriter`/`MessageReader`/
//! `app.add_message::<T>()`. Dieser Code ist auf Bevy 0.19 ausgelegt.
//!
//! Steuerung (barrierefrei, tastaturzentriert, siehe PLAN.md):
//! - `Tab`: wechselt die ausgewählte Aktion (Fold / Call-Check / Raise).
//! - `Leertaste`/`Enter`: bestätigt die ausgewählte Aktion.
use bevy::prelude::*;
use audiopoker_core::logic::card::Card;
use audiopoker_core::network::{ClientMessage, ServerMessage};
use crate::audio::AudioEngine;
use crate::network::{self, ConnectionConfig, NetworkClient};
/// Bevy-Resource-Wrapper um die Engine-unabhängige Audio-Engine (`audio::AudioEngine`).
#[derive(Resource)]
pub struct AudioRes(pub AudioEngine);
/// Welche Aktion aktuell per `Tab` ausgewählt ist (minimalistisches,
/// tastaturzentriertes UI für Screenreader-/Audio-Nutzung).
#[derive(Resource, Default)]
pub struct ActionCursor {
pub selected: usize,
}
const ACTION_LABELS: [&str; 3] = ["Fold", "Call/Check", "Raise"];
/// 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<u32>,
pub hole_cards: Vec<Card>,
pub community_cards: Vec<Card>,
pub to_act: Option<u32>,
pub hand_finished: bool,
}
/// Message: Ein Text soll per TTS angesagt werden.
#[derive(Message, Debug, Clone)]
pub struct Announce(pub String);
/// Message: Ein Soundeffekt soll abgespielt werden (z. B. "shuffle", "chip").
#[derive(Message, Debug, Clone)]
pub struct PlaySfx(pub &'static str);
pub struct AudiopokerPlugin;
impl Plugin for AudiopokerPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(AudioRes(AudioEngine::new()))
.init_resource::<ActionCursor>()
.init_resource::<NetworkGameView>()
.add_message::<Announce>()
.add_message::<PlaySfx>()
.add_systems(Startup, setup_network)
.add_systems(
Update,
(
receive_network_messages,
handle_player_input,
speak_announcements,
play_sfx_cues,
),
);
}
}
/// 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<NetworkClient>,
mut view: ResMut<NetworkGameView>,
mut announcements: MessageWriter<Announce>,
mut sfx_cues: MessageWriter<PlaySfx>,
) {
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::<Vec<_>>()
.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}")));
}
}
}
}
/// 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<ButtonInput<KeyCode>>,
mut cursor: ResMut<ActionCursor>,
view: Res<NetworkGameView>,
net: NonSend<NetworkClient>,
mut announcements: MessageWriter<Announce>,
) {
if view.hand_finished {
// Hand ist beendet. Eine neue Hand starten ist noch nicht
// implementiert (nächster sinnvoller Schritt, siehe PLAN.md).
return;
}
if keys.just_pressed(KeyCode::Tab) {
cursor.selected = (cursor.selected + 1) % ACTION_LABELS.len();
announcements.write(Announce(format!("Ausgewählt: {}", ACTION_LABELS[cursor.selected])));
}
if keys.just_pressed(KeyCode::Space) || keys.just_pressed(KeyCode::Enter) {
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 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);
}
}
/// Liest `Announce`-Messages und spricht sie über die TTS-Engine.
fn speak_announcements(mut reader: MessageReader<Announce>, audio: Res<AudioRes>) {
for Announce(text) in reader.read() {
audio.0.tts.speak(text);
}
}
/// Liest `PlaySfx`-Messages und spielt den zugehörigen Soundeffekt.
fn play_sfx_cues(mut reader: MessageReader<PlaySfx>, audio: Res<AudioRes>) {
for PlaySfx(id) in reader.read() {
audio.0.sfx.play_sound(id);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
mod audio;
mod core;
mod game_plugin;
mod network;
use bevy::prelude::*;
use game_plugin::AudiopokerPlugin;
+196
View File
@@ -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<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::*;
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "audiopoker_core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
rand = "0.8.5"
+12 -8
View File
@@ -5,8 +5,8 @@
//! Re-Raises). Das reicht für eine barrierefreie MVP-Demo, ist aber keine
//! vollständige Tournament-taugliche Poker-Regelimplementierung.
use crate::core::logic::card::Card;
use crate::core::logic::hand::Hand;
use crate::logic::card::Card;
use crate::logic::hand::Hand;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Round {
@@ -126,6 +126,10 @@ impl GameState {
}
}
/// Fester Erhöhungsbetrag für "Raise" (vereinfachtes Modell, kein Pot-Limit/No-Limit-Sizing).
/// Zentral definiert, damit Client und Server garantiert denselben Wert verwenden.
pub const RAISE_INCREMENT: u64 = 20;
/// Wett-Aktionen, wie sie über `core::network` vom Client kommen (Deal/Bet/Fold, siehe PLAN.md).
#[derive(Debug, Clone, Copy)]
pub enum BettingAction {
@@ -363,11 +367,11 @@ mod tests {
fn volle_strasse_lässt_runde_automatisch_weiterlaufen() {
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into(), "Cara".into()]);
state.community_cards = vec![
Card::new(crate::core::logic::card::Suit::Hearts, 2),
Card::new(crate::core::logic::card::Suit::Hearts, 3),
Card::new(crate::core::logic::card::Suit::Hearts, 4),
Card::new(crate::core::logic::card::Suit::Hearts, 5),
Card::new(crate::core::logic::card::Suit::Hearts, 9),
Card::new(crate::logic::card::Suit::Hearts, 2),
Card::new(crate::logic::card::Suit::Hearts, 3),
Card::new(crate::logic::card::Suit::Hearts, 4),
Card::new(crate::logic::card::Suit::Hearts, 5),
Card::new(crate::logic::card::Suit::Hearts, 9),
];
GameManager::take_action(&mut state, 0, BettingAction::Check);
@@ -380,7 +384,7 @@ mod tests {
#[test]
fn showdown_kuert_besseren_spieler_zum_gewinner() {
use crate::core::logic::card::Suit;
use crate::logic::card::Suit;
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
state.current_round = Round::River;
state.pot = 200;
+6
View File
@@ -0,0 +1,6 @@
//! `audiopoker_core`: reine, engine-unabhängige Spiellogik (siehe PLAN.md).
//! Wird sowohl vom Bevy/Wasm-`client` als auch vom nativen `server` genutzt.
pub mod logic;
pub mod game;
pub mod network;
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use crate::core::logic::card::Card;
use crate::logic::card::Card;
/// Kategorie einer Poker-Hand, aufsteigend nach Stärke sortiert.
/// Die Reihenfolge der Varianten wird für den Vergleich (`Ord`) genutzt.
@@ -166,7 +166,7 @@ fn score_five(cards: &[Card; 5]) -> HandScore {
#[cfg(test)]
mod tests {
use super::*;
use crate::core::logic::card::Suit;
use crate::logic::card::Suit;
fn c(suit: Suit, rank: u8) -> Card {
Card::new(suit, rank)
+53
View File
@@ -0,0 +1,53 @@
//! Netzwerkprotokoll für die Multiplayer-Synchronisation (siehe PLAN.md, Phase 2).
//!
//! Design-Entscheidung: Der Server ist die einzige Quelle der Wahrheit
//! (führt `GameManager::take_action` autoritativ aus) und schickt dieselben
//! "Announce"/"PlaySfx"-Häppchen an die Clients, die der lokale
//! `game_plugin`-Code bereits als `Announce`/`PlaySfx`-Bevy-Messages kennt.
//! Der Client muss also keine eigene Spiellogik nachbilden, sondern reicht
//! ankommende `ServerMessage`s nur an TTS/SFX weiter (bzw. zeigt Fehler an).
//!
//! Sicherheitsaspekt: `Raise` enthält bewusst keinen vom Client gewählten
//! Betrag - der Server bestimmt die Höhe selbst (`RAISE_INCREMENT`), damit
//! ein Client nicht durch manipulierte Nachrichten beliebige Beträge setzen kann.
use serde::{Deserialize, Serialize};
use crate::logic::card::Card;
/// Nachrichten, die ein Client an den Server schickt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientMessage {
/// Tritt einem (ggf. neu anzulegenden) Tisch bei. `table_name` ermöglicht
/// die einfache "Tischsuche" aus PLAN.md: bekannte Namen treffen auf
/// denselben Tisch, unbekannte legen einen neuen an.
JoinTable { table_name: String, player_name: String },
Fold,
Check,
Call,
Raise,
}
/// Nachrichten, die der Server an (einen oder alle) Clients eines Tisches sendet.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServerMessage {
/// Antwort auf `JoinTable`: dem Client wurde diese Spieler-ID zugewiesen.
Welcome { player_id: u32 },
/// Textansage fürs TTS - identisch zum lokalen `Announce` in `game_plugin`.
Announce(String),
/// Soundeffekt-Cue fürs SFX - identisch zum lokalen `PlaySfx`.
PlaySfx(String),
/// Private Hole Cards; wird nur an den jeweiligen Spieler geschickt, nie gebroadcastet.
HoleCards(Vec<Card>),
/// Aktuell aufgedeckte Community Cards (kumulativ je nach Runde).
CommunityCards(Vec<Card>),
/// 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),
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "audiopoker_server"
version = "0.1.0"
edition = "2021"
[dependencies]
audiopoker_core = { path = "../core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "io-util"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
+36
View File
@@ -0,0 +1,36 @@
//! Verwaltet alle aktiven Tische. Einfache Umsetzung der "Tischsuche" aus
//! PLAN.md: Der Tischname selbst ist der Suchbegriff - unbekannte Namen
//! legen automatisch einen neuen Tisch (mit eigenem Tokio-Task) an.
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::mpsc;
use crate::table::{TableActor, TableCommand};
#[derive(Default)]
pub struct Lobby {
tables: Mutex<HashMap<String, mpsc::UnboundedSender<TableCommand>>>,
}
impl Lobby {
pub fn new() -> Self {
Self::default()
}
/// Gibt den Befehls-Sender für den genannten Tisch zurück; legt bei
/// Bedarf einen neuen Tisch samt eigenem Hintergrund-Task an.
pub fn table_sender(&self, table_name: &str) -> mpsc::UnboundedSender<TableCommand> {
let mut tables = self.tables.lock().expect("Lobby-Mutex vergiftet");
if let Some(tx) = tables.get(table_name) {
return tx.clone();
}
let (tx, rx) = mpsc::unbounded_channel();
let actor = TableActor::new(table_name.to_string());
tokio::spawn(actor.run(rx));
tables.insert(table_name.to_string(), tx.clone());
tx
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Nativer WebSocket-Server für Audiopoker (siehe PLAN.md, Phase 2).
//! Führt die Spiellogik autoritativ aus (`audiopoker_core::game`) und
//! synchronisiert alle verbundenen Clients über `audiopoker_core::network`.
mod lobby;
mod table;
use std::net::SocketAddr;
use std::sync::Arc;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use audiopoker_core::network::{ClientMessage, ServerMessage};
use lobby::Lobby;
use table::TableCommand;
type WsSink = SplitSink<WebSocketStream<TcpStream>, Message>;
type WsStream = SplitStream<WebSocketStream<TcpStream>>;
const DEFAULT_ADDR: &str = "0.0.0.0:9001";
#[tokio::main]
async fn main() {
let addr = std::env::var("AUDIOPOKER_ADDR").unwrap_or_else(|_| DEFAULT_ADDR.to_string());
let listener = TcpListener::bind(&addr)
.await
.unwrap_or_else(|err| panic!("Konnte nicht auf {addr} binden: {err}"));
println!("Audiopoker-Server lauscht auf ws://{addr}");
let lobby = Arc::new(Lobby::new());
loop {
let (stream, peer) = match listener.accept().await {
Ok(conn) => conn,
Err(err) => {
eprintln!("Fehler beim Annehmen einer Verbindung: {err}");
continue;
}
};
let lobby = Arc::clone(&lobby);
tokio::spawn(async move {
if let Err(err) = handle_connection(stream, peer, lobby).await {
eprintln!("Verbindung zu {peer} beendet: {err}");
}
});
}
}
async fn handle_connection(
stream: TcpStream,
peer: SocketAddr,
lobby: Arc<Lobby>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
println!("Neue Verbindung von {peer}");
let (mut ws_sink, mut ws_stream) = ws_stream.split();
let Some((table_name, player_name)) = await_join(&mut ws_sink, &mut ws_stream).await? else {
return Ok(()); // Verbindung wurde beendet, bevor ein Tisch gewählt wurde.
};
let table_tx = lobby.table_sender(&table_name);
let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::<ServerMessage>();
let (assigned_id_tx, assigned_id_rx) = oneshot::channel();
table_tx.send(TableCommand::Join {
player_name,
reply_tx,
assigned_id_tx,
})?;
let player_id = match assigned_id_rx.await? {
Ok(id) => id,
Err(reason) => {
send_error(&mut ws_sink, &reason).await;
return Ok(());
}
};
loop {
tokio::select! {
outgoing = reply_rx.recv() => {
match outgoing {
Some(message) => send_message(&mut ws_sink, &message).await?,
None => break,
}
}
incoming = ws_stream.next() => {
match incoming {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(client_msg) => {
table_tx.send(TableCommand::Action { player_id, message: client_msg })?;
}
Err(err) => send_error(&mut ws_sink, &format!("Ungültige Nachricht: {err}")).await,
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => {} // Ping/Pong/Binary werden ignoriert.
Some(Err(err)) => {
eprintln!("WebSocket-Fehler von {peer}: {err}");
break;
}
}
}
}
}
let _ = table_tx.send(TableCommand::Disconnected { player_id });
Ok(())
}
/// Wartet auf die erste Client-Nachricht, die ein `JoinTable` sein muss.
/// Alles andere wird mit einer Fehlermeldung beantwortet, ohne die
/// Verbindung zu schließen (der Client darf es erneut versuchen).
async fn await_join(
ws_sink: &mut WsSink,
ws_stream: &mut WsStream,
) -> Result<Option<(String, String)>, Box<dyn std::error::Error + Send + Sync>> {
loop {
let Some(msg) = ws_stream.next().await else {
return Ok(None);
};
match msg? {
Message::Text(text) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::JoinTable { table_name, player_name }) => {
return Ok(Some((table_name, player_name)));
}
Ok(_) => send_error(ws_sink, "Bitte zuerst mit JoinTable einem Tisch beitreten.").await,
Err(err) => send_error(ws_sink, &format!("Ungültige Nachricht: {err}")).await,
},
Message::Close(_) => return Ok(None),
_ => {} // Ping/Pong/Binary werden ignoriert.
}
}
}
async fn send_message(
ws_sink: &mut WsSink,
message: &ServerMessage,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let text = serde_json::to_string(message)?;
ws_sink.send(Message::Text(text)).await?;
Ok(())
}
async fn send_error(ws_sink: &mut WsSink, reason: &str) {
let _ = send_message(ws_sink, &ServerMessage::Error(reason.to_string())).await;
}
+199
View File
@@ -0,0 +1,199 @@
//! Ein `TableActor` läuft als eigener Tokio-Task und besitzt exklusiv den
//! `GameState` für genau einen Tisch (Actor-Modell: keine geteilten Mutexe
//! nötig, alle Zugriffe laufen sequenziell über einen mpsc-Channel).
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};
use audiopoker_core::game::{BettingAction, GameManager, GameState, Round, RAISE_INCREMENT};
use audiopoker_core::logic::Deck;
use audiopoker_core::network::{ClientMessage, ServerMessage};
/// Maximale Spielerzahl pro Tisch. Sobald diese Zahl an `JoinTable`-Anfragen
/// eingetroffen ist, wird automatisch eine neue Hand gestartet.
pub const MAX_PLAYERS: usize = 2;
/// Befehle, die eine WebSocket-Verbindung an den Tisch-Aktor schickt.
pub enum TableCommand {
Join {
player_name: String,
reply_tx: mpsc::UnboundedSender<ServerMessage>,
assigned_id_tx: oneshot::Sender<Result<u32, String>>,
},
Action {
player_id: u32,
message: ClientMessage,
},
/// Verbindung wurde geschlossen (aktuell nur zur Kenntnisnahme geloggt;
/// ein vorzeitig aussteigender Spieler mitten in einer Hand ist noch
/// nicht behandelt - siehe PLAN.md, offene Punkte).
Disconnected {
player_id: u32,
},
}
pub struct TableActor {
name: String,
state: Option<GameState>,
pending_names: Vec<String>,
senders: HashMap<u32, mpsc::UnboundedSender<ServerMessage>>,
}
impl TableActor {
pub fn new(name: String) -> Self {
Self {
name,
state: None,
pending_names: Vec::new(),
senders: HashMap::new(),
}
}
pub async fn run(mut self, mut rx: mpsc::UnboundedReceiver<TableCommand>) {
while let Some(cmd) = rx.recv().await {
match cmd {
TableCommand::Join { player_name, reply_tx, assigned_id_tx } => {
self.handle_join(player_name, reply_tx, assigned_id_tx);
}
TableCommand::Action { player_id, message } => {
self.handle_action(player_id, message);
}
TableCommand::Disconnected { player_id } => {
println!("[Tisch {}] Spieler {player_id} getrennt.", self.name);
self.senders.remove(&player_id);
}
}
}
}
fn broadcast(&self, message: ServerMessage) {
for tx in self.senders.values() {
let _ = tx.send(message.clone());
}
}
fn send_to(&self, player_id: u32, message: ServerMessage) {
if let Some(tx) = self.senders.get(&player_id) {
let _ = tx.send(message);
}
}
fn handle_join(
&mut self,
player_name: String,
reply_tx: mpsc::UnboundedSender<ServerMessage>,
assigned_id_tx: oneshot::Sender<Result<u32, String>>,
) {
if self.state.is_some() {
let _ = assigned_id_tx.send(Err("Tisch ist bereits voll, das Spiel läuft schon.".into()));
return;
}
let player_id = self.pending_names.len() as u32;
self.pending_names.push(player_name.clone());
self.senders.insert(player_id, reply_tx);
let _ = assigned_id_tx.send(Ok(player_id));
self.send_to(player_id, ServerMessage::Welcome { player_id });
if self.pending_names.len() < MAX_PLAYERS {
self.broadcast(ServerMessage::Announce(format!(
"{player_name} ist dem Tisch beigetreten. Warte auf weitere Spieler ({}/{}).",
self.pending_names.len(),
MAX_PLAYERS
)));
return;
}
self.start_hand();
}
/// Teilt Karten aus und startet eine neue Hand, sobald genug Spieler da sind.
fn start_hand(&mut self) {
let mut game = GameManager::start_new_game(std::mem::take(&mut self.pending_names));
let mut deck = Deck::new();
deck.shuffle();
for player in game.players.iter_mut() {
player.hole_cards = deck.deal_hand(2);
}
game.community_cards = deck.deal_hand(5);
self.broadcast(ServerMessage::Announce(format!(
"Tisch ist voll, Spiel startet mit {} Spielern.",
game.players.len()
)));
self.broadcast(ServerMessage::PlaySfx("shuffle".into()));
for player in &game.players {
self.send_to(player.id, ServerMessage::HoleCards(player.hole_cards.clone()));
}
self.broadcast(ServerMessage::TurnToAct {
player_id: game.players[game.to_act].id,
});
self.state = Some(game);
}
fn handle_action(&mut self, player_id: u32, message: ClientMessage) {
let Some(state) = self.state.as_mut() else {
self.send_to(player_id, ServerMessage::Error("Das Spiel hat noch nicht begonnen.".into()));
return;
};
if state.to_act as u32 != player_id {
self.send_to(player_id, ServerMessage::Error("Du bist gerade nicht am Zug.".into()));
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,
ClientMessage::Check if to_call == 0 => BettingAction::Check,
// Client wollte checken, es ist aber ein Call fällig - wir werten das
// grosszügig als Call, statt die Aktion abzulehnen.
ClientMessage::Check => BettingAction::Call(to_call),
ClientMessage::Call => BettingAction::Call(to_call),
// Der Erhöhungsbetrag wird bewusst serverseitig festgelegt (siehe
// Kommentar in `core::network`), damit Clients ihn nicht manipulieren können.
ClientMessage::Raise => BettingAction::Raise(to_call + RAISE_INCREMENT),
ClientMessage::JoinTable { .. } => {
self.send_to(player_id, ServerMessage::Error("Du bist diesem Tisch bereits beigetreten.".into()));
return;
}
};
let round_before = state.current_round;
let outcome = GameManager::take_action(state, player_id as usize, action);
for line in outcome.announcements {
self.broadcast(ServerMessage::Announce(line));
}
for cue in outcome.sfx_cues {
self.broadcast(ServerMessage::PlaySfx(cue.to_string()));
}
let state = self.state.as_ref().expect("state wurde oben bereits entpackt");
if state.current_round != round_before && state.current_round != Round::Showdown {
let revealed_count = state.current_round.revealed_community_count();
self.broadcast(ServerMessage::CommunityCards(
state.community_cards[..revealed_count].to_vec(),
));
}
if state.current_round == Round::Showdown {
self.broadcast(ServerMessage::HandFinished);
} else {
self.broadcast(ServerMessage::TurnToAct {
player_id: state.players[state.to_act].id,
});
}
}
}
-5
View File
@@ -1,5 +0,0 @@
//! Kern-Logik des Spiels, unabhängig von Grafik/Audio (siehe PLAN.md).
pub mod logic;
pub mod game;
pub mod network;
-31
View File
@@ -1,31 +0,0 @@
//! Netzwerkprotokoll für die Multiplayer-Synchronisation (siehe PLAN.md, Phase 2).
//!
//! Die eigentliche WebSocket-Anbindung (Server + Client) ist noch nicht
//! implementiert; dies definiert vorerst nur den Nachrichten-Vertrag,
//! damit `core::logic` und die künftige Transportschicht entkoppelt bleiben.
use serde::{Deserialize, Serialize};
use crate::core::logic::card::Card;
/// Nachrichten, die ein Client an den Server schickt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientMessage {
JoinTable { player_name: String },
Fold,
Check,
Call { amount: u64 },
Raise { amount: u64 },
}
/// Nachrichten, die der Server an alle Clients eines Tisches broadcastet.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServerMessage {
PlayerJoined { player_id: u32, name: String },
HoleCardsDealt { player_id: u32, cards: Vec<Card> },
CommunityCardsRevealed { cards: Vec<Card> },
PotUpdated { pot: u64 },
PlayerActed { player_id: u32, description: String },
RoundChanged { round: String },
Showdown { winner_id: u32 },
}
-144
View File
@@ -1,144 +0,0 @@
//! Verbindet die (bewusst Bevy-unabhängige) Kernlogik aus `core` und `audio`
//! mit einer echten Bevy-`App` (siehe PLAN.md, Phase 1 & 3).
//!
//! Hinweis zur Bevy-Version: Seit Bevy 0.17 sind gepufferte Events als
//! `Message` (statt `Event`) modelliert und laufen über `MessageWriter` /
//! `MessageReader` / `app.add_message::<T>()`. `Event` bezeichnet seither
//! ausschließlich Observer-Events. Dieser Code ist auf Bevy 0.19 ausgelegt.
//!
//! Steuerung (barrierefrei, tastaturzentriert, siehe PLAN.md):
//! - `Tab`: wechselt die ausgewählte Aktion (Fold / Call-Check / Raise).
//! - `Leertaste`/`Enter`: bestätigt die ausgewählte Aktion.
use bevy::prelude::*;
use crate::audio::AudioEngine;
use crate::core::game::{BettingAction, GameManager, GameState, Round};
use crate::core::logic::Deck;
/// 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);
/// Bevy-Resource-Wrapper um die Engine-unabhängige Audio-Engine (`audio::AudioEngine`).
#[derive(Resource)]
pub struct AudioRes(pub AudioEngine);
/// Welche Aktion aktuell per `Tab` ausgewählt ist (minimalistisches,
/// tastaturzentriertes UI für Screenreader-/Audio-Nutzung).
#[derive(Resource, Default)]
pub struct ActionCursor {
pub selected: usize,
}
const ACTION_LABELS: [&str; 3] = ["Fold", "Call/Check", "Raise"];
/// Fester Erhöhungsbetrag für "Raise" (vereinfachtes Modell, kein Pot-Limit/No-Limit-Sizing).
const RAISE_INCREMENT: u64 = 20;
/// Message: Ein Text soll per TTS angesagt werden (z. B. "Spieler 1 setzt 50").
#[derive(Message, Debug, Clone)]
pub struct Announce(pub String);
/// Message: Ein Soundeffekt soll abgespielt werden (z. B. "shuffle", "chip").
#[derive(Message, Debug, Clone)]
pub struct PlaySfx(pub &'static str);
pub struct AudiopokerPlugin;
impl Plugin for AudiopokerPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(AudioRes(AudioEngine::new()))
.init_resource::<ActionCursor>()
.add_message::<Announce>()
.add_message::<PlaySfx>()
.add_systems(Startup, setup_game)
.add_systems(Update, (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,
mut announcements: MessageWriter<Announce>,
mut sfx_cues: MessageWriter<PlaySfx>,
) {
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);
}
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`.
fn handle_player_input(
keys: Res<ButtonInput<KeyCode>>,
mut cursor: ResMut<ActionCursor>,
mut table: ResMut<Table>,
mut announcements: MessageWriter<Announce>,
mut sfx_cues: MessageWriter<PlaySfx>,
) {
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).
return;
}
if keys.just_pressed(KeyCode::Tab) {
cursor.selected = (cursor.selected + 1) % ACTION_LABELS.len();
announcements.write(Announce(format!("Ausgewählt: {}", ACTION_LABELS[cursor.selected])));
}
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 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));
}
}
}
/// Liest `Announce`-Messages und spricht sie über die TTS-Engine.
fn speak_announcements(mut reader: MessageReader<Announce>, audio: Res<AudioRes>) {
for Announce(text) in reader.read() {
audio.0.tts.speak(text);
}
}
/// Liest `PlaySfx`-Messages und spielt den zugehörigen Soundeffekt.
fn play_sfx_cues(mut reader: MessageReader<PlaySfx>, audio: Res<AudioRes>) {
for PlaySfx(id) in reader.read() {
audio.0.sfx.play_sound(id);
}
}