This commit is contained in:
2026-07-23 15:40:44 +02:00
parent 5ed6229ed5
commit 27bc97e707
6 changed files with 365 additions and 12 deletions
+5 -3
View File
@@ -31,9 +31,11 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über
- [ ] Multiplayer-Lobby und Tischsuche implementieren. - [ ] Multiplayer-Lobby und Tischsuche implementieren.
### Phase 3: Audio Experience & UI ### Phase 3: Audio Experience & UI
- [ ] TTS-Logik verfeinern (Kontextuelle Ausgaben). - [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").
- [ ] Soundeffekte einbinden. - [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.
- [ ] Minimalistisches, barrierefreies User Interface erstellen. - [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 ### Phase 4: Polishing & Testing
- [ ] Barrierefreiheits-Audit (Tastatur-Flow ohne Maus). - [ ] Barrierefreiheits-Audit (Tastatur-Flow ohne Maus).
+13 -1
View File
@@ -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; pub struct SfxEngine;
impl SfxEngine { impl SfxEngine {
@@ -5,8 +11,14 @@ impl SfxEngine {
Self 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) { 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); println!("Playing SFX: {}", effect_id);
} }
} }
+238 -5
View File
@@ -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::card::Card;
use crate::core::logic::hand::Hand;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Round { pub enum Round {
@@ -11,6 +17,29 @@ pub enum Round {
Showdown, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerStatus { pub enum PlayerStatus {
Active, Active,
@@ -68,6 +97,11 @@ pub struct GameState {
pub dealer_idx: usize, pub dealer_idx: usize,
pub community_cards: Vec<Card>, pub community_cards: Vec<Card>,
pub current_round: Round, 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 { impl GameState {
@@ -78,9 +112,22 @@ impl GameState {
.filter(|p| p.status != PlayerStatus::Folded) .filter(|p| p.status != PlayerStatus::Folded)
.count() .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<usize> {
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). /// Wett-Aktionen, wie sie über `core::network` vom Client kommen (Deal/Bet/Fold, siehe PLAN.md).
#[derive(Debug, Clone, Copy)]
pub enum BettingAction { pub enum BettingAction {
Fold, Fold,
Check, Check,
@@ -88,15 +135,25 @@ pub enum BettingAction {
Raise(u64), 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<String>,
pub sfx_cues: Vec<&'static str>,
}
pub struct GameManager; pub struct GameManager;
impl GameManager { impl GameManager {
pub fn start_new_game(player_names: Vec<String>) -> GameState { pub fn start_new_game(player_names: Vec<String>) -> GameState {
let players = player_names let players: Vec<Player> = player_names
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, name)| Player::new(i as u32, &name)) .map(|(i, name)| Player::new(i as u32, &name))
.collect(); .collect();
let player_count = players.len();
GameState { GameState {
players, players,
@@ -104,11 +161,21 @@ impl GameManager {
dealer_idx: 0, dealer_idx: 0,
community_cards: Vec::new(), community_cards: Vec::new(),
current_round: Round::PreFlop, 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 /// 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) { pub fn apply_action(state: &mut GameState, player_idx: usize, action: BettingAction) {
let player = &mut state.players[player_idx]; let player = &mut state.players[player_idx];
match action { match action {
@@ -131,6 +198,126 @@ impl GameManager {
Round::Showdown => Round::Showdown, 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<String> = 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<usize> = 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)] #[cfg(test)]
@@ -147,10 +334,12 @@ mod tests {
} }
#[test] #[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()]); 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.active_player_count(), 1);
assert_eq!(state.current_round, Round::Showdown);
assert!(outcome.announcements.iter().any(|a| a.contains("Ben")));
} }
#[test] #[test]
@@ -169,4 +358,48 @@ mod tests {
assert_eq!(player.stack, 0); assert_eq!(player.stack, 0);
assert_eq!(player.status, PlayerStatus::AllIn); 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"));
}
} }
+26
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Suit { pub enum Suit {
@@ -8,6 +9,18 @@ pub enum Suit {
Clubs, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rank(pub u8); // 2-14 (11=J, 12=Q, 13=K, 14=A) 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)
}
}
+17
View File
@@ -17,6 +17,23 @@ pub enum HandCategory {
StraightFlush, 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: /// Vollständig vergleichbare Bewertung einer 5-Karten-Hand:
/// zuerst die Kategorie, danach die Kicker/Tiebreaker in absteigender /// zuerst die Kategorie, danach die Kicker/Tiebreaker in absteigender
/// Relevanz. Zwei `HandScore`-Werte lassen sich direkt per `>`/`<` vergleichen. /// Relevanz. Zwei `HandScore`-Werte lassen sich direkt per `>`/`<` vergleichen.
+66 -3
View File
@@ -1,15 +1,19 @@
//! Verbindet die (bewusst Bevy-unabhängige) Kernlogik aus `core` und `audio` //! 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 //! Hinweis zur Bevy-Version: Seit Bevy 0.17 sind gepufferte Events als
//! `Message` (statt `Event`) modelliert und laufen über `MessageWriter` / //! `Message` (statt `Event`) modelliert und laufen über `MessageWriter` /
//! `MessageReader` / `app.add_message::<T>()`. `Event` bezeichnet seither //! `MessageReader` / `app.add_message::<T>()`. `Event` bezeichnet seither
//! ausschließlich Observer-Events. Dieser Code ist auf Bevy 0.19 ausgelegt. //! 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 bevy::prelude::*;
use crate::audio::AudioEngine; use crate::audio::AudioEngine;
use crate::core::game::{GameManager, GameState}; use crate::core::game::{BettingAction, GameManager, GameState, Round};
use crate::core::logic::Deck; use crate::core::logic::Deck;
/// Bevy-Resource-Wrapper um den Engine-unabhängigen Spielzustand. /// Bevy-Resource-Wrapper um den Engine-unabhängigen Spielzustand.
@@ -21,6 +25,17 @@ pub struct Table(pub GameState);
#[derive(Resource)] #[derive(Resource)]
pub struct AudioRes(pub AudioEngine); 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"). /// Message: Ein Text soll per TTS angesagt werden (z. B. "Spieler 1 setzt 50").
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
pub struct Announce(pub String); pub struct Announce(pub String);
@@ -34,10 +49,11 @@ pub struct AudiopokerPlugin;
impl Plugin for AudiopokerPlugin { impl Plugin for AudiopokerPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.insert_resource(AudioRes(AudioEngine::new())) app.insert_resource(AudioRes(AudioEngine::new()))
.init_resource::<ActionCursor>()
.add_message::<Announce>() .add_message::<Announce>()
.add_message::<PlaySfx>() .add_message::<PlaySfx>()
.add_systems(Startup, setup_game) .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.", "Neues Spiel gestartet mit {} Spielern.",
game.players.len() 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)); 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. /// Liest `Announce`-Messages und spricht sie über die TTS-Engine.
fn speak_announcements(mut reader: MessageReader<Announce>, audio: Res<AudioRes>) { fn speak_announcements(mut reader: MessageReader<Announce>, audio: Res<AudioRes>) {
for Announce(text) in reader.read() { for Announce(text) in reader.read() {