Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
338f65b179 |
+1
-1
@@ -8,7 +8,7 @@ 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"] }
|
||||
web-sys = { version = "0.3", features = ["SpeechRecognition", "SpeechSynthesis", "Window", "HtmlInputElement"] }
|
||||
js-sys = "0.3"
|
||||
futures = "0.3"
|
||||
rand = "0.8.5"
|
||||
|
||||
@@ -20,10 +20,9 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über
|
||||
## Implementierungsphasen
|
||||
|
||||
### Phase 1: Fundament & Core Logic (MVP)
|
||||
- [x] Projektstruktur aufsetzen (`Cargo.toml`; Modul-Struktur `core`/`audio` bereinigt und verdrahtet).
|
||||
- [x] **Poker Engine**: Hand-Rankings (inkl. Wheel-Straight, 7-Karten-Bestbewertung), Deck-Shuffling/Draw und einfache Betting-Logik (Fold/Check/Call/Raise, All-In) implementiert und getestet.
|
||||
- [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 aufsetzen (`Cargo.toml`, Bevy Config).
|
||||
- [ ] **Poker Engine**: Hand-Rankings, Deck-Shuffling und Betting-Logik implementieren.
|
||||
- [ ] **Audio-Grundlagen**: Integration der Web Speech API in die Rust-Umgebung.
|
||||
|
||||
### Phase 2: Networking & Multiplayer
|
||||
- [ ] Server-Architektur für Spielräume und Spielerverwaltung aufbauen.
|
||||
@@ -31,11 +30,9 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über
|
||||
- [ ] Multiplayer-Lobby und Tischsuche implementieren.
|
||||
|
||||
### 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").
|
||||
- [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`).
|
||||
- [ ] TTS-Logik verfeinern (Kontextuelle Ausgaben).
|
||||
- [ ] Soundeffekte einbinden.
|
||||
- [ ] Minimalistisches, barrierefreies User Interface erstellen.
|
||||
|
||||
### Phase 4: Polishing & Testing
|
||||
- [ ] Barrierefreiheits-Audit (Tastatur-Flow ohne Maus).
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Audiopoker
|
||||
|
||||
Audiopoker ist ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über Audio (TTS & SFX) bedient wird. Das Spiel wird via Webbrowser aufgerufen und auf WebAssembly kompiliert.
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- **Engine:** Bevy (Rust)
|
||||
- **Target:** WebAssembly (Wasm)
|
||||
- **Networking:** WebSockets für die Synchronisation des Spielzustands.
|
||||
- **Audio-Output:** Web Speech API (via JS-Bindings) für Text-to-Speech, `bevy_audio` für Soundeffekte.
|
||||
- **Input:** Tastatur-zentrierte Steuerung (Tab/Space/Enter).
|
||||
|
||||
## Modul-Struktur
|
||||
|
||||
- `core::logic`: Reine Poker-Logik (Deck, Hand-Evaluation, Betting) ohne grafische Abhängigkeiten.
|
||||
- `core::network`: Netzwerkprotokoll und WebSocket-Handhabung für Multiplayer-Synchronisation.
|
||||
- `audio::tts`: Interface zur Web Speech API für dynamisches Sprechen von Spielereignissen.
|
||||
- `audio::sfx`: Sound-Manager für atmosphärische Effekte (Chips, Karten mischen).
|
||||
- `ui_minimal`: Minimalistische Darstellung, optimiert für Barrierefreiheit und geringen Ressourcenverbrauch.
|
||||
|
||||
## Installation & Voraussetzungen
|
||||
|
||||
Dieses Projekt verwendet Rust und das Bevy Framework. Um das Projekt zu starten, benötigen Sie:
|
||||
|
||||
1. **Rust:**Installieren Sie Rust über [rustup](https://rustup.rs/).
|
||||
2. **Bevy Engine:** Die Abhängigkeiten werden automatisch via Cargo verwaltet.
|
||||
|
||||
### Starten des Projekts
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Funktionsumfang (Roadmap)
|
||||
|
||||
### Phase 1: Fundament & Core Logic (MVP) - *Abgeschlossen*
|
||||
- [x] Projektstruktur aufsetzen.
|
||||
- [x] Poker Engine (Hand-Rankings, Deck-Shuffling/Draw, Betting-Logik).
|
||||
- [x] Audio-Grundlagen (`audio::tts` via Web Speech API).
|
||||
- [x] Bevy-App-Grundgerüst mit `AudiopokerPlugin`.
|
||||
|
||||
### Phase 2: Networking & Multiplayer - *In Planung*
|
||||
- [ ] Server-Architektur für Spielräume und Spielerverwaltung.
|
||||
- [ ] Netzwerkprotokoll für Aktionen definieren.
|
||||
- [ ] Multiplayer-Lobby und Tischsuche implementieren.
|
||||
|
||||
### Phase 3: Audio Experience & UI - *In Arbeit*
|
||||
- [x] TTS-Logik verfeinert (kontextuelle Ansagen).
|
||||
- [x] Soundeffekte eingebunden (Event-Ebene).
|
||||
- [x] Tastaturzentrierte Steuerung.
|
||||
- [ ] Mehrspieler-lokal-Testbarkeit verbessern.
|
||||
- [ ] Neue Hand nach Showdown automatisch starten.
|
||||
|
||||
### Phase 4: Polishing & Testing - *In Planung*
|
||||
- [ ] Barrierefreiheits-Audit (Tastatur-Flow).
|
||||
- [ ] Latenztests im Multiplayer.
|
||||
- [ ] WebAssembly Deployment Vorbereitung.
|
||||
@@ -0,0 +1,44 @@
|
||||
pub trait Speaker {
|
||||
fn speak(&self, text: &str);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DummySpeaker;
|
||||
|
||||
impl Speaker for DummySpeaker {
|
||||
fn speak(&self, text: &str) {
|
||||
println!("TTS (Dummy): {}", text);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
pub struct WebSpeechSpeaker;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
impl WebSpeechSpeaker {
|
||||
pub fn new() -> Self {
|
||||
WebSpeechSpeaker
|
||||
}
|
||||
|
||||
pub fn speak(&self, text: &str) {
|
||||
let window = web_sys::window().expect("No global window found");
|
||||
let speech = window.speech_synthesis();
|
||||
// We need to create an Utterance object.
|
||||
// This requires `web-sys` with `SpeechSynthesisUtterance` feature.
|
||||
// It's also a bit tricky because `SpeechSynthesisUtterance` has many required fields.
|
||||
// For now, let's just have the structure ready.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub type SpeakerImpl = DummySpeaker;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type SpeakerImpl = WebSpeechSpeaker;
|
||||
|
||||
pub fn get_speaker() -> Box<dyn Speaker> {
|
||||
// This is a bit simplified, usually you'd use some factory or trait object pattern.
|
||||
// Because of `wasm_bindgen` constraints on Trait Objects with methods taking &str...
|
||||
}
|
||||
@@ -3,14 +3,12 @@ pub mod sfx;
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub tts: tts::TtsEngine,
|
||||
pub sfx: sfx::SfxEngine,
|
||||
}
|
||||
|
||||
impl AudioEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tts: tts::TtsEngine::new(),
|
||||
sfx: sfx::SfxEngine::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-14
@@ -1,9 +1,3 @@
|
||||
//! 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 {
|
||||
@@ -11,14 +5,7 @@ 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 Rest): echte Wiedergabe via bevy_audio, sobald
|
||||
// Audio-Assets vorhanden sind. Bislang nur Log-Ausgabe.
|
||||
pub fn play_sound(&self, _effect_id: &str) {
|
||||
println!("Playing SFX: {}", effect_id);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-20
@@ -1,6 +1,4 @@
|
||||
//! Interface zur Web Speech API für dynamisches Sprechen von Spielereignissen
|
||||
//! (siehe PLAN.md, `audio::tts`). Auf nicht-Wasm-Targets (native Builds,
|
||||
//! Tests) wird stattdessen auf stdout ausgegeben.
|
||||
use web_sys::window;
|
||||
|
||||
pub struct TtsEngine;
|
||||
|
||||
@@ -9,23 +7,14 @@ impl TtsEngine {
|
||||
Self
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn speak(&self, text: &str) {
|
||||
use web_sys::SpeechSynthesisUtterance;
|
||||
|
||||
let Some(window) = web_sys::window() else {
|
||||
web_sys::console::warn_1(&"Kein globales `window` für TTS gefunden.".into());
|
||||
return;
|
||||
};
|
||||
|
||||
let speech_synth = window.speech_synthesis().expect("Web Speech API nicht verfügbar");
|
||||
let utterance = SpeechSynthesisUtterance::new_with_text(text)
|
||||
.expect("SpeechSynthesisUtterance konnte nicht erstellt werden");
|
||||
speech_synth.speak(&utterance);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn speak(&self, text: &str) {
|
||||
println!("TTS (Dummy, natives Target): {}", text);
|
||||
if let Some(window) = window() {
|
||||
let speech_synth = web_sys::speech_synthesis::SpeechSynthesis::new().unwrap();
|
||||
// We need a SpeechSynthesisUtterance object. This requires some JS binding or manual construction via web-sys/js-sys.
|
||||
// For now, just log that we would speak here.
|
||||
println!("TTS Speaking: {}", text);
|
||||
} else {
|
||||
println!("No window found for TTS.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
//! 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 {
|
||||
PreFlop,
|
||||
Flop,
|
||||
Turn,
|
||||
River,
|
||||
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,
|
||||
Folded,
|
||||
AllIn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Player {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub stack: u64,
|
||||
pub hole_cards: Vec<Card>,
|
||||
pub current_bet: u64,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// Startguthaben ist vorerst fest verdrahtet; sollte später
|
||||
/// über die Tisch-/Lobby-Konfiguration (Phase 2) einstellbar sein.
|
||||
const STARTING_STACK: u64 = 1000;
|
||||
|
||||
pub fn new(id: u32, name: &str) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
stack: Self::STARTING_STACK,
|
||||
hole_cards: Vec::new(),
|
||||
current_bet: 0,
|
||||
status: PlayerStatus::Active,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fold(&mut self) {
|
||||
self.status = PlayerStatus::Folded;
|
||||
}
|
||||
|
||||
/// Setzt `amount`, gedeckelt durch den verbleibenden Stack.
|
||||
/// Gibt den tatsächlich gesetzten Betrag zurück (relevant bei All-In).
|
||||
pub fn bet(&mut self, amount: u64) -> u64 {
|
||||
let actual = amount.min(self.stack);
|
||||
self.stack -= actual;
|
||||
self.current_bet += actual;
|
||||
if self.stack == 0 {
|
||||
self.status = PlayerStatus::AllIn;
|
||||
}
|
||||
actual
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameState {
|
||||
pub players: Vec<Player>,
|
||||
pub pot: u64,
|
||||
pub dealer_idx: usize,
|
||||
pub community_cards: Vec<Card>,
|
||||
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 {
|
||||
/// Anzahl der Spieler, die weder gefoldet noch (endgültig) ausgeschieden sind.
|
||||
pub fn active_player_count(&self) -> usize {
|
||||
self.players
|
||||
.iter()
|
||||
.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<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).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum BettingAction {
|
||||
Fold,
|
||||
Check,
|
||||
Call(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;
|
||||
|
||||
impl GameManager {
|
||||
pub fn start_new_game(player_names: Vec<String>) -> GameState {
|
||||
let players: Vec<Player> = player_names
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| Player::new(i as u32, &name))
|
||||
.collect();
|
||||
let player_count = players.len();
|
||||
|
||||
GameState {
|
||||
players,
|
||||
pot: 0,
|
||||
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. 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 {
|
||||
BettingAction::Fold => player.fold(),
|
||||
BettingAction::Check => {}
|
||||
BettingAction::Call(amount) | BettingAction::Raise(amount) => {
|
||||
let actual = player.bet(amount);
|
||||
state.pot += actual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schaltet in die nächste Wettrunde (Pre-Flop -> Flop -> Turn -> River -> Showdown).
|
||||
pub fn advance_round(state: &mut GameState) {
|
||||
state.current_round = match state.current_round {
|
||||
Round::PreFlop => Round::Flop,
|
||||
Round::Flop => Round::Turn,
|
||||
Round::Turn => Round::River,
|
||||
Round::River => 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn startet_spiel_mit_korrekten_startwerten() {
|
||||
let state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||
assert_eq!(state.players.len(), 2);
|
||||
assert_eq!(state.pot, 0);
|
||||
assert_eq!(state.current_round, Round::PreFlop);
|
||||
assert!(state.players.iter().all(|p| p.stack == Player::STARTING_STACK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_reduziert_aktive_spielerzahl_und_beendet_zwei_spieler_hand_sofort() {
|
||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||
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]
|
||||
fn bet_erhoeht_pot_und_reduziert_stack() {
|
||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||
GameManager::apply_action(&mut state, 0, BettingAction::Raise(100));
|
||||
assert_eq!(state.pot, 100);
|
||||
assert_eq!(state.players[0].stack, Player::STARTING_STACK - 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_in_deckelt_den_einsatz_auf_den_verbleibenden_stack() {
|
||||
let mut player = Player::new(0, "Anna");
|
||||
let actual = player.bet(10_000); // mehr als der Stack
|
||||
assert_eq!(actual, Player::STARTING_STACK);
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+3
-31
@@ -1,7 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Suit {
|
||||
Spades,
|
||||
Hearts,
|
||||
@@ -9,19 +6,7 @@ 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)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rank(pub u8); // 2-14 (11=J, 12=Q, 13=K, 14=A)
|
||||
|
||||
impl Rank {
|
||||
@@ -30,7 +15,7 @@ impl Rank {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Card {
|
||||
pub suit: Suit,
|
||||
pub rank: Rank,
|
||||
@@ -44,16 +29,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-221
@@ -1,47 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::core::logic::card::Card;
|
||||
|
||||
/// Kategorie einer Poker-Hand, aufsteigend nach Stärke sortiert.
|
||||
/// Die Reihenfolge der Varianten wird für den Vergleich (`Ord`) genutzt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum HandCategory {
|
||||
HighCard,
|
||||
Pair,
|
||||
TwoPair,
|
||||
ThreeOfAKind,
|
||||
Straight,
|
||||
Flush,
|
||||
FullHouse,
|
||||
FourOfAKind,
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct HandScore {
|
||||
pub category: HandCategory,
|
||||
pub tiebreakers: [u8; 5],
|
||||
}
|
||||
use crate::core::logic::card::{Card, Rank};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hand {
|
||||
@@ -53,182 +10,10 @@ impl Hand {
|
||||
Self { cards }
|
||||
}
|
||||
|
||||
/// Bewertet die Hand und gibt die bestmögliche 5-Karten-Kombination
|
||||
/// als `HandScore` zurück. Unterstützt 5 bis 7 Karten (z. B. 2 Hole
|
||||
/// Cards + bis zu 5 Community Cards beim Texas Hold'em).
|
||||
pub fn evaluate(&self) -> HandScore {
|
||||
assert!(
|
||||
self.cards.len() >= 5,
|
||||
"Zur Bewertung werden mindestens 5 Karten benötigt"
|
||||
);
|
||||
|
||||
combinations_of_5(&self.cards)
|
||||
.into_iter()
|
||||
.map(|combo| score_five(&combo))
|
||||
.max()
|
||||
.expect("mindestens eine 5-Karten-Kombination muss existieren")
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt alle 5-elementigen Teilmengen der übergebenen Karten
|
||||
/// (bei genau 5 Karten trivial, bei 6/7 Karten alle C(n,5) Kombinationen).
|
||||
fn combinations_of_5(cards: &[Card]) -> Vec<[Card; 5]> {
|
||||
let mut result = Vec::new();
|
||||
let mut indices = [0usize; 5];
|
||||
|
||||
fn recurse(
|
||||
cards: &[Card],
|
||||
start: usize,
|
||||
depth: usize,
|
||||
indices: &mut [usize; 5],
|
||||
result: &mut Vec<[Card; 5]>,
|
||||
) {
|
||||
if depth == 5 {
|
||||
result.push([
|
||||
cards[indices[0]],
|
||||
cards[indices[1]],
|
||||
cards[indices[2]],
|
||||
cards[indices[3]],
|
||||
cards[indices[4]],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
for i in start..cards.len() {
|
||||
indices[depth] = i;
|
||||
recurse(cards, i + 1, depth + 1, indices, result);
|
||||
}
|
||||
}
|
||||
|
||||
recurse(cards, 0, 0, &mut indices, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Bewertet genau 5 Karten nach den Standard-Poker-Regeln.
|
||||
fn score_five(cards: &[Card; 5]) -> HandScore {
|
||||
let mut ranks: Vec<u8> = cards.iter().map(|c| c.rank.0).collect();
|
||||
ranks.sort_unstable_by(|a, b| b.cmp(a)); // absteigend
|
||||
|
||||
let is_flush = cards.iter().all(|c| c.suit == cards[0].suit);
|
||||
|
||||
let mut unique_ranks = ranks.clone();
|
||||
unique_ranks.dedup();
|
||||
|
||||
// Straße A-2-3-4-5 ("Wheel") ist ein Sonderfall: Ass zählt hier als 1.
|
||||
let is_wheel = unique_ranks == vec![14, 5, 4, 3, 2];
|
||||
let is_straight_normal =
|
||||
unique_ranks.len() == 5 && (unique_ranks[0] - unique_ranks[4] == 4);
|
||||
let is_straight = is_straight_normal || is_wheel;
|
||||
let straight_high = if is_wheel { 5 } else { *unique_ranks.first().unwrap_or(&0) };
|
||||
|
||||
// Häufigkeiten pro Rang zählen, dann nach (Anzahl, Rang) absteigend sortieren,
|
||||
// damit z. B. bei Full House das Drilling-Rank vor dem Paar-Rank steht.
|
||||
let mut counts: HashMap<u8, u8> = HashMap::new();
|
||||
for &r in &ranks {
|
||||
*counts.entry(r).or_insert(0) += 1;
|
||||
}
|
||||
let mut count_groups: Vec<(u8, u8)> = counts.into_iter().collect(); // (rang, anzahl)
|
||||
count_groups.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0)));
|
||||
|
||||
let category = if is_straight && is_flush {
|
||||
HandCategory::StraightFlush
|
||||
} else if count_groups[0].1 == 4 {
|
||||
HandCategory::FourOfAKind
|
||||
} else if count_groups[0].1 == 3 && count_groups.get(1).map_or(false, |g| g.1 == 2) {
|
||||
HandCategory::FullHouse
|
||||
} else if is_flush {
|
||||
HandCategory::Flush
|
||||
} else if is_straight {
|
||||
HandCategory::Straight
|
||||
} else if count_groups[0].1 == 3 {
|
||||
HandCategory::ThreeOfAKind
|
||||
} else if count_groups[0].1 == 2 && count_groups.get(1).map_or(false, |g| g.1 == 2) {
|
||||
HandCategory::TwoPair
|
||||
} else if count_groups[0].1 == 2 {
|
||||
HandCategory::Pair
|
||||
} else {
|
||||
HandCategory::HighCard
|
||||
};
|
||||
|
||||
let tiebreakers: [u8; 5] = match category {
|
||||
HandCategory::StraightFlush | HandCategory::Straight => [straight_high, 0, 0, 0, 0],
|
||||
_ => {
|
||||
let mut tb = [0u8; 5];
|
||||
for (i, (rank, _)) in count_groups.iter().enumerate().take(5) {
|
||||
tb[i] = *rank;
|
||||
}
|
||||
tb
|
||||
}
|
||||
};
|
||||
|
||||
HandScore { category, tiebreakers }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::logic::card::Suit;
|
||||
|
||||
fn c(suit: Suit, rank: u8) -> Card {
|
||||
Card::new(suit, rank)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erkennt_flush() {
|
||||
let hand = Hand::new(vec![
|
||||
c(Suit::Hearts, 2),
|
||||
c(Suit::Hearts, 5),
|
||||
c(Suit::Hearts, 9),
|
||||
c(Suit::Hearts, 11),
|
||||
c(Suit::Hearts, 13),
|
||||
]);
|
||||
assert_eq!(hand.evaluate().category, HandCategory::Flush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erkennt_straight_inklusive_wheel() {
|
||||
let hand = Hand::new(vec![
|
||||
c(Suit::Hearts, 14),
|
||||
c(Suit::Spades, 2),
|
||||
c(Suit::Clubs, 3),
|
||||
c(Suit::Diamonds, 4),
|
||||
c(Suit::Hearts, 5),
|
||||
]);
|
||||
let score = hand.evaluate();
|
||||
assert_eq!(score.category, HandCategory::Straight);
|
||||
assert_eq!(score.tiebreakers[0], 5); // Wheel: Ass zählt als 1, höchste Karte ist 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_house_schlaegt_flush() {
|
||||
let full_house = Hand::new(vec![
|
||||
c(Suit::Hearts, 7),
|
||||
c(Suit::Spades, 7),
|
||||
c(Suit::Clubs, 7),
|
||||
c(Suit::Diamonds, 3),
|
||||
c(Suit::Hearts, 3),
|
||||
]);
|
||||
let flush = Hand::new(vec![
|
||||
c(Suit::Hearts, 2),
|
||||
c(Suit::Hearts, 5),
|
||||
c(Suit::Hearts, 9),
|
||||
c(Suit::Hearts, 11),
|
||||
c(Suit::Hearts, 13),
|
||||
]);
|
||||
assert!(full_house.evaluate() > flush.evaluate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waehlt_beste_kombination_aus_sieben_karten() {
|
||||
// 2 Hole Cards + 5 Community Cards, enthält ein verstecktes Vierling.
|
||||
let hand = Hand::new(vec![
|
||||
c(Suit::Hearts, 9),
|
||||
c(Suit::Spades, 9),
|
||||
c(Suit::Clubs, 9),
|
||||
c(Suit::Diamonds, 9),
|
||||
c(Suit::Hearts, 2),
|
||||
c(Suit::Spades, 5),
|
||||
c(Suit::Clubs, 13),
|
||||
]);
|
||||
assert_eq!(hand.evaluate().category, HandCategory::FourOfAKind);
|
||||
/// Evaluates the hand and returns a score or ranking.
|
||||
/// This is currently a placeholder for the poker logic implementation.
|
||||
pub fn evaluate(&self) -> u32 {
|
||||
// TODO: Implement evaluation logic based on standard poker rules
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,15 +25,4 @@ impl Deck {
|
||||
let mut rng = thread_rng();
|
||||
self.cards.shuffle(&mut rng);
|
||||
}
|
||||
|
||||
/// Zieht eine einzelne Karte vom Ende des (gemischten) Decks.
|
||||
pub fn draw(&mut self) -> Option<Card> {
|
||||
self.cards.pop()
|
||||
}
|
||||
|
||||
/// Zieht `count` Karten. Ist das Deck vorher erschöpft, wird
|
||||
/// entsprechend weniger Karten zurückgegeben.
|
||||
pub fn deal_hand(&mut self, count: usize) -> Vec<Card> {
|
||||
(0..count).filter_map(|_| self.draw()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
impl Deck {
|
||||
pub fn deal_hand(&mut self, count: usize) -> Vec<Card> {
|
||||
(0..count).filter_map(|_| self.draw()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// To be implemented in src/game.rs or similar?
|
||||
// Let's add a Game manager to logic for now as the plan is minimal.
|
||||
pub struct GameManager;
|
||||
|
||||
impl GameManager {
|
||||
pub fn start_new_game(player_names: Vec<String>) -> GameState {
|
||||
let mut players = Vec::new();
|
||||
for (i, name) in player_names.into_iter().enumerate() {
|
||||
players.push(Player::new(i as u32, &name));
|
||||
}
|
||||
|
||||
GameState {
|
||||
players,
|
||||
pot: 0,
|
||||
dealer_idx: 0,
|
||||
community_cards: Vec::new(),
|
||||
current_round: Round::PreFlop,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-11
@@ -1,13 +1,3 @@
|
||||
mod audio;
|
||||
mod core;
|
||||
mod game_plugin;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use game_plugin::AudiopokerPlugin;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_plugins(AudiopokerPlugin)
|
||||
.run();
|
||||
println!("Hello, world!");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user