diff --git a/PLAN.md b/PLAN.md index cf9b5fa..f5cfdeb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -31,9 +31,11 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über - [ ] Multiplayer-Lobby und Tischsuche implementieren. ### Phase 3: Audio Experience & UI -- [ ] TTS-Logik verfeinern (Kontextuelle Ausgaben). -- [ ] Soundeffekte einbinden. -- [ ] Minimalistisches, barrierefreies User Interface erstellen. +- [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"). +- [x] **Soundeffekte eingebunden** (Event-Ebene): `PlaySfx`-Messages für "shuffle", "chip", "fold", "flip" werden an den entsprechenden Stellen ausgelöst und von `audio::sfx` entgegengenommen. Echte Audiodateien/`bevy_audio`-Wiedergabe fehlen noch (keine Assets im Projekt) – aktuell Log-Ausgabe als Platzhalter. +- [x] **Minimalistische, tastaturzentrierte Steuerung**: `Tab` wählt zwischen Fold/Call-Check/Raise, `Leertaste`/`Enter` bestätigt (siehe `game_plugin::handle_player_input`). Kein grafisches UI nötig, da das Spiel primär auf Audio-Feedback setzt. +- [ ] Mehrspieler-lokal-Testbarkeit über die Konsole hinaus (aktuell nur 2 fest verdrahtete Spieler; echte Steuerung pro Spieler braucht Networking, siehe Phase 2). +- [ ] Neue Hand nach Showdown starten (aktuell stoppt die Eingabe-Verarbeitung nach `Round::Showdown`). ### Phase 4: Polishing & Testing - [ ] Barrierefreiheits-Audit (Tastatur-Flow ohne Maus). diff --git a/src/audio/sfx.rs b/src/audio/sfx.rs index 2150226..bc4a55d 100644 --- a/src/audio/sfx.rs +++ b/src/audio/sfx.rs @@ -1,3 +1,9 @@ +//! Sound-Manager für atmosphärische Effekte (siehe PLAN.md, `audio::sfx`). +//! +//! Es gibt noch keine Audio-Assets im Projekt; sobald welche existieren, +//! kann `play_sound` über `bevy_audio` (`AudioPlayer`/`AssetServer`) echte +//! Dateien abspielen, gemappt über die Effekt-IDs unten. + pub struct SfxEngine; impl SfxEngine { @@ -5,8 +11,14 @@ impl SfxEngine { Self } + /// Effekt-IDs, die aktuell im Spiel ausgelöst werden: + /// - "shuffle": Karten werden gemischt (Spielstart). + /// - "chip": Einsatz/Call/Raise/Gewinn (Chip-Geräusch). + /// - "fold": Spieler foldet (Karten werden weggelegt). + /// - "flip": Community Cards werden aufgedeckt (Flop/Turn/River). pub fn play_sound(&self, effect_id: &str) { - // TODO(Phase 3): echte Wiedergabe via bevy_audio; bislang nur Log-Ausgabe. + // TODO(Phase 3 Rest): echte Wiedergabe via bevy_audio, sobald + // Audio-Assets vorhanden sind. Bislang nur Log-Ausgabe. println!("Playing SFX: {}", effect_id); } } diff --git a/src/core/game.rs b/src/core/game.rs index 3634c2a..1081db2 100644 --- a/src/core/game.rs +++ b/src/core/game.rs @@ -1,6 +1,12 @@ -//! Spielzustand und Betting-Logik (siehe PLAN.md, Phase 1: "Betting-Logik"). +//! Spielzustand und Betting-Logik (siehe PLAN.md, Phase 1 & 3). +//! +//! Vereinfachtes Rundenmodell: Pro Straße (Pre-Flop/Flop/Turn/River) agiert +//! jeder noch aktive Spieler genau einmal (kein erneutes Nachziehen bei +//! 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; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Round { @@ -11,6 +17,29 @@ pub enum Round { Showdown, } +impl Round { + /// Wie viele Community Cards bei dieser Straße aufgedeckt sind. + pub fn revealed_community_count(&self) -> usize { + match self { + Round::PreFlop => 0, + Round::Flop => 3, + Round::Turn => 4, + Round::River | Round::Showdown => 5, + } + } + + /// Deutschsprachige Bezeichnung für Ansagen. + pub fn label(&self) -> &'static str { + match self { + Round::PreFlop => "Pre-Flop", + Round::Flop => "Flop", + Round::Turn => "Turn", + Round::River => "River", + Round::Showdown => "Showdown", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlayerStatus { Active, @@ -68,6 +97,11 @@ pub struct GameState { pub dealer_idx: usize, pub community_cards: Vec, pub current_round: Round, + /// Index des Spielers, der als Nächstes agieren muss. + pub to_act: usize, + /// Wie viele Spieler auf der aktuellen Straße noch agieren müssen, + /// bevor automatisch zur nächsten Straße übergegangen wird. + pub players_to_act_this_street: usize, } impl GameState { @@ -78,9 +112,22 @@ impl GameState { .filter(|p| p.status != PlayerStatus::Folded) .count() } + + /// Nächster Spieler nach `from`, der noch aktiv (nicht gefoldet, nicht + /// All-In) handeln kann. `None`, falls niemand mehr agieren kann. + pub fn next_to_act(&self, from: usize) -> Option { + let n = self.players.len(); + if n == 0 { + return None; + } + (1..=n) + .map(|step| (from + step) % n) + .find(|&idx| self.players[idx].status == PlayerStatus::Active) + } } /// Wett-Aktionen, wie sie über `core::network` vom Client kommen (Deal/Bet/Fold, siehe PLAN.md). +#[derive(Debug, Clone, Copy)] pub enum BettingAction { Fold, Check, @@ -88,15 +135,25 @@ pub enum BettingAction { Raise(u64), } +/// Ergebnis einer Aktion, aufbereitet für die Audio-Ausgabe: eine Liste von +/// Ansagen (TTS) und Soundeffekt-IDs, in der Reihenfolge, in der sie +/// passiert sind (Aktion -> ggf. Straßenwechsel -> ggf. Showdown). +#[derive(Debug, Clone, Default)] +pub struct ActionOutcome { + pub announcements: Vec, + pub sfx_cues: Vec<&'static str>, +} + pub struct GameManager; impl GameManager { pub fn start_new_game(player_names: Vec) -> GameState { - let players = player_names + let players: Vec = player_names .into_iter() .enumerate() .map(|(i, name)| Player::new(i as u32, &name)) .collect(); + let player_count = players.len(); GameState { players, @@ -104,11 +161,21 @@ impl GameManager { dealer_idx: 0, community_cards: Vec::new(), current_round: Round::PreFlop, + to_act: 0, + players_to_act_this_street: player_count, } } + /// Betrag, den `player_idx` mindestens setzen müsste, um zum höchsten + /// aktuellen Einsatz aufzuschließen (0, wenn Checken möglich ist). + pub fn amount_to_call(state: &GameState, player_idx: usize) -> u64 { + let highest = state.players.iter().map(|p| p.current_bet).max().unwrap_or(0); + highest.saturating_sub(state.players[player_idx].current_bet) + } + /// Wendet die Aktion eines Spielers auf den Spielzustand an und - /// aktualisiert den Pot entsprechend. + /// aktualisiert den Pot entsprechend. Enthält keine Ansage-/Turn-Logik; + /// dafür siehe [`GameManager::take_action`]. pub fn apply_action(state: &mut GameState, player_idx: usize, action: BettingAction) { let player = &mut state.players[player_idx]; match action { @@ -131,6 +198,126 @@ impl GameManager { Round::Showdown => Round::Showdown, }; } + + /// Führt die Aktion eines Spielers komplett aus: wendet sie an, lässt bei + /// Bedarf automatisch die Wettrunde bzw. die Hand weiterlaufen, und gibt + /// alle dabei entstandenen Ansagen/Soundcues zurück (für `audio::tts`/`audio::sfx`). + pub fn take_action(state: &mut GameState, player_idx: usize, action: BettingAction) -> ActionOutcome { + let mut out = ActionOutcome::default(); + let player_name = state.players[player_idx].name.clone(); + + match action { + BettingAction::Fold => { + out.announcements.push(format!("{player_name} foldet.")); + out.sfx_cues.push("fold"); + } + BettingAction::Check => { + out.announcements.push(format!("{player_name} checkt.")); + } + BettingAction::Call(amount) => { + out.announcements.push(format!("{player_name} callt {amount}.")); + out.sfx_cues.push("chip"); + } + BettingAction::Raise(amount) => { + out.announcements.push(format!("{player_name} erhöht auf {amount}.")); + out.sfx_cues.push("chip"); + } + } + + Self::apply_action(state, player_idx, action); + state.players_to_act_this_street = state.players_to_act_this_street.saturating_sub(1); + + if state.active_player_count() <= 1 { + Self::finish_hand(state, &mut out); + } else if state.players_to_act_this_street == 0 { + Self::advance_round(state); + out.announcements.push(format!("Runde: {}.", state.current_round.label())); + out.sfx_cues.push("flip"); + + if state.current_round == Round::Showdown { + Self::finish_hand(state, &mut out); + } else { + for p in state.players.iter_mut() { + p.current_bet = 0; + } + state.players_to_act_this_street = state.active_player_count(); + state.to_act = state.next_to_act(state.dealer_idx).unwrap_or(state.dealer_idx); + + let revealed_count = state.current_round.revealed_community_count(); + let revealed: Vec = state.community_cards[..revealed_count] + .iter() + .map(|c| c.to_string()) + .collect(); + if !revealed.is_empty() { + out.announcements.push(format!("Community Cards: {}.", revealed.join(", "))); + } + out.announcements.push(format!("{} ist am Zug.", state.players[state.to_act].name)); + } + } else if let Some(next_idx) = state.next_to_act(player_idx) { + state.to_act = next_idx; + out.announcements.push(format!("{} ist am Zug.", state.players[next_idx].name)); + } + + out + } + + /// Wertet am Showdown alle nicht gefoldeten Hände aus (bzw. beendet die + /// Hand sofort, wenn nur noch ein Spieler übrig ist) und schreibt Pot + /// und Sieger-Ansage in `out`. + fn finish_hand(state: &mut GameState, out: &mut ActionOutcome) { + let non_folded: Vec = state + .players + .iter() + .enumerate() + .filter(|(_, p)| p.status != PlayerStatus::Folded) + .map(|(idx, _)| idx) + .collect(); + + // Alle anderen haben gefoldet: Sieger steht ohne Kartenvergleich fest, + // unabhängig davon, wie viele Community Cards schon aufgedeckt waren. + if non_folded.len() == 1 { + let winner_idx = non_folded[0]; + let pot = state.pot; + let winner_name = state.players[winner_idx].name.clone(); + state.players[winner_idx].stack += pot; + state.pot = 0; + out.announcements.push(format!( + "{winner_name} gewinnt den Pot von {pot}, da alle anderen Spieler gefoldet haben." + )); + out.sfx_cues.push("chip"); + state.current_round = Round::Showdown; + return; + } + + let community = state.community_cards.clone(); + let mut scores = Vec::new(); + for &idx in &non_folded { + let mut cards = state.players[idx].hole_cards.clone(); + cards.extend(community.iter().copied()); + if cards.len() >= 5 { + scores.push((idx, Hand::new(cards).evaluate())); + } + } + + let winner = scores.iter().max_by_key(|(_, score)| *score).copied(); + + if let Some((winner_idx, score)) = winner { + let pot = state.pot; + let winner_name = state.players[winner_idx].name.clone(); + state.players[winner_idx].stack += pot; + state.pot = 0; + out.announcements.push(format!( + "{winner_name} gewinnt den Pot von {pot} mit {}.", + score.category.label() + )); + out.sfx_cues.push("chip"); + } else { + out.announcements + .push("Kein Gewinner konnte ermittelt werden.".to_string()); + } + + state.current_round = Round::Showdown; + } } #[cfg(test)] @@ -147,10 +334,12 @@ mod tests { } #[test] - fn fold_reduziert_aktive_spielerzahl() { + fn fold_reduziert_aktive_spielerzahl_und_beendet_zwei_spieler_hand_sofort() { let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]); - GameManager::apply_action(&mut state, 0, BettingAction::Fold); + let outcome = GameManager::take_action(&mut state, 0, BettingAction::Fold); assert_eq!(state.active_player_count(), 1); + assert_eq!(state.current_round, Round::Showdown); + assert!(outcome.announcements.iter().any(|a| a.contains("Ben"))); } #[test] @@ -169,4 +358,48 @@ mod tests { assert_eq!(player.stack, 0); assert_eq!(player.status, PlayerStatus::AllIn); } + + #[test] + 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), + ]; + + GameManager::take_action(&mut state, 0, BettingAction::Check); + GameManager::take_action(&mut state, 1, BettingAction::Check); + assert_eq!(state.current_round, Round::PreFlop); // noch nicht alle dran gewesen + GameManager::take_action(&mut state, 2, BettingAction::Check); + assert_eq!(state.current_round, Round::Flop); // jetzt automatisch weitergeschaltet + assert_eq!(state.players_to_act_this_street, 3); + } + + #[test] + fn showdown_kuert_besseren_spieler_zum_gewinner() { + use crate::core::logic::card::Suit; + let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]); + state.current_round = Round::River; + state.pot = 200; + state.community_cards = vec![ + Card::new(Suit::Clubs, 2), + Card::new(Suit::Clubs, 5), + Card::new(Suit::Diamonds, 9), + Card::new(Suit::Spades, 11), + Card::new(Suit::Hearts, 4), + ]; + // Anna hat ein Paar Asse, Ben nur High Card. + state.players[0].hole_cards = vec![Card::new(Suit::Hearts, 14), Card::new(Suit::Spades, 14)]; + state.players[1].hole_cards = vec![Card::new(Suit::Diamonds, 7), Card::new(Suit::Clubs, 8)]; + + let mut out = ActionOutcome::default(); + GameManager::finish_hand(&mut state, &mut out); + + assert_eq!(state.players[0].stack, Player::STARTING_STACK + 200); + assert_eq!(state.pot, 0); + assert!(out.announcements[0].contains("Anna")); + } } diff --git a/src/core/logic/card.rs b/src/core/logic/card.rs index c420f5d..f7c9abb 100644 --- a/src/core/logic/card.rs +++ b/src/core/logic/card.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Suit { @@ -8,6 +9,18 @@ pub enum Suit { Clubs, } +impl fmt::Display for Suit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Suit::Spades => "Pik", + Suit::Hearts => "Herz", + Suit::Diamonds => "Karo", + Suit::Clubs => "Kreuz", + }; + write!(f, "{name}") + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Rank(pub u8); // 2-14 (11=J, 12=Q, 13=K, 14=A) @@ -31,3 +44,16 @@ impl Card { } } } + +impl fmt::Display for Card { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let rank_name = match self.rank.0 { + 11 => "Bube".to_string(), + 12 => "Dame".to_string(), + 13 => "König".to_string(), + 14 => "Ass".to_string(), + n => n.to_string(), + }; + write!(f, "{rank_name} {}", self.suit) + } +} diff --git a/src/core/logic/hand.rs b/src/core/logic/hand.rs index a54d3e7..1521610 100644 --- a/src/core/logic/hand.rs +++ b/src/core/logic/hand.rs @@ -17,6 +17,23 @@ pub enum HandCategory { StraightFlush, } +impl HandCategory { + /// Deutschsprachige Beschreibung für Sprachausgabe/Ansagen. + pub fn label(&self) -> &'static str { + match self { + HandCategory::HighCard => "High Card", + HandCategory::Pair => "einem Paar", + HandCategory::TwoPair => "zwei Paaren", + HandCategory::ThreeOfAKind => "einem Drilling", + HandCategory::Straight => "einer Straße", + HandCategory::Flush => "einem Flush", + HandCategory::FullHouse => "einem Full House", + HandCategory::FourOfAKind => "einem Vierling", + HandCategory::StraightFlush => "einem Straight Flush", + } + } +} + /// Vollständig vergleichbare Bewertung einer 5-Karten-Hand: /// zuerst die Kategorie, danach die Kicker/Tiebreaker in absteigender /// Relevanz. Zwei `HandScore`-Werte lassen sich direkt per `>`/`<` vergleichen. diff --git a/src/game_plugin.rs b/src/game_plugin.rs index 694569e..c238386 100644 --- a/src/game_plugin.rs +++ b/src/game_plugin.rs @@ -1,15 +1,19 @@ //! Verbindet die (bewusst Bevy-unabhängige) Kernlogik aus `core` und `audio` -//! mit einer echten Bevy-`App` (siehe PLAN.md, Phase 1: "Bevy Config"). +//! 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::()`. `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::{GameManager, GameState}; +use crate::core::game::{BettingAction, GameManager, GameState, Round}; use crate::core::logic::Deck; /// Bevy-Resource-Wrapper um den Engine-unabhängigen Spielzustand. @@ -21,6 +25,17 @@ pub struct Table(pub GameState); #[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); @@ -34,10 +49,11 @@ pub struct AudiopokerPlugin; impl Plugin for AudiopokerPlugin { fn build(&self, app: &mut App) { app.insert_resource(AudioRes(AudioEngine::new())) + .init_resource::() .add_message::() .add_message::() .add_systems(Startup, setup_game) - .add_systems(Update, (speak_announcements, play_sfx_cues)); + .add_systems(Update, (handle_player_input, speak_announcements, play_sfx_cues)); } } @@ -62,10 +78,57 @@ fn setup_game( "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>, + mut cursor: ResMut, + mut table: ResMut, + 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). + 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, audio: Res) { for Announce(text) in reader.read() {