initial commit

This commit is contained in:
2026-07-23 09:09:59 +02:00
commit 95225b3ed8
14 changed files with 6779 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+6128
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[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
+41
View File
@@ -0,0 +1,41 @@
# Projektplan: Audiopoker (Bevy + Wasm)
## Zielsetzung
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.
## 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.
- [ ] Bevy-`App`-Grundgerüst (Plugins/Systems) aufsetzen noch offen, da die Bevy-0.19-API in dieser Umgebung nicht gegen einen echten Build geprüft werden konnte (siehe Hinweis in `main.rs`).
### 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.
### Phase 3: Audio Experience & UI
- [ ] TTS-Logik verfeinern (Kontextuelle Ausgaben).
- [ ] Soundeffekte einbinden.
- [ ] Minimalistisches, barrierefreies User Interface erstellen.
### Phase 4: Polishing & Testing
- [ ] Barrierefreiheits-Audit (Tastatur-Flow ohne Maus).
- [ ] Latenztests im Multiplayer.
- [ ] WebAssembly Deployment Vorbereitung.
+16
View File
@@ -0,0 +1,16 @@
pub mod tts;
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(),
}
}
}
+12
View File
@@ -0,0 +1,12 @@
pub struct SfxEngine;
impl SfxEngine {
pub fn new() -> Self {
Self
}
pub fn play_sound(&self, effect_id: &str) {
// TODO(Phase 3): echte Wiedergabe via bevy_audio; bislang nur Log-Ausgabe.
println!("Playing SFX: {}", effect_id);
}
}
+31
View File
@@ -0,0 +1,31 @@
//! 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.
pub struct TtsEngine;
impl TtsEngine {
pub fn new() -> Self {
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);
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Spielzustand und Betting-Logik (siehe PLAN.md, Phase 1: "Betting-Logik").
use crate::core::logic::card::Card;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Round {
PreFlop,
Flop,
Turn,
River,
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,
}
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()
}
}
/// Wett-Aktionen, wie sie über `core::network` vom Client kommen (Deal/Bet/Fold, siehe PLAN.md).
pub enum BettingAction {
Fold,
Check,
Call(u64),
Raise(u64),
}
pub struct GameManager;
impl GameManager {
pub fn start_new_game(player_names: Vec<String>) -> GameState {
let players = player_names
.into_iter()
.enumerate()
.map(|(i, name)| Player::new(i as u32, &name))
.collect();
GameState {
players,
pot: 0,
dealer_idx: 0,
community_cards: Vec::new(),
current_round: Round::PreFlop,
}
}
/// Wendet die Aktion eines Spielers auf den Spielzustand an und
/// aktualisiert den Pot entsprechend.
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,
};
}
}
#[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() {
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
GameManager::apply_action(&mut state, 0, BettingAction::Fold);
assert_eq!(state.active_player_count(), 1);
}
#[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);
}
}
+33
View File
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Suit {
Spades,
Hearts,
Diamonds,
Clubs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rank(pub u8); // 2-14 (11=J, 12=Q, 13=K, 14=A)
impl Rank {
pub fn new(value: u8) -> Self {
Self(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card {
pub suit: Suit,
pub rank: Rank,
}
impl Card {
pub fn new(suit: Suit, rank_val: u8) -> Self {
Self {
suit,
rank: Rank::new(rank_val),
}
}
}
+217
View File
@@ -0,0 +1,217 @@
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,
}
/// 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],
}
#[derive(Debug, Clone)]
pub struct Hand {
pub cards: Vec<Card>,
}
impl Hand {
pub fn new(cards: Vec<Card>) -> Self {
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);
}
}
+39
View File
@@ -0,0 +1,39 @@
pub mod card;
pub mod hand;
use card::Card;
use rand::seq::SliceRandom;
use rand::thread_rng;
pub struct Deck {
pub cards: Vec<Card>,
}
impl Deck {
pub fn new() -> Self {
let mut cards = Vec::new();
let suits = [card::Suit::Spades, card::Suit::Hearts, card::Suit::Diamonds, card::Suit::Clubs];
for suit in suits.iter() {
for rank in 2..=14 {
cards.push(Card::new(*suit, rank));
}
}
Self { cards }
}
pub fn shuffle(&mut self) {
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()
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Kern-Logik des Spiels, unabhängig von Grafik/Audio (siehe PLAN.md).
pub mod logic;
pub mod game;
pub mod network;
+31
View File
@@ -0,0 +1,31 @@
//! 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 },
}
+36
View File
@@ -0,0 +1,36 @@
mod audio;
mod core;
use audio::AudioEngine;
use core::game::GameManager;
use core::logic::Deck;
// TODO(Phase 1 Rest / Phase 3): echte Bevy-`App` mit Plugins/Systems
// aufsetzen, sobald die Bevy-0.19-API-Oberfläche geprüft ist. Ohne
// funktionierende Rust-Toolchain in dieser Umgebung wollte ich hier
// keinen ungeprüften Bevy-Boilerplate-Code hinterlassen, der eventuell
// gegen die tatsächliche 0.19-API nicht kompiliert. Stattdessen dient
// main() aktuell als Smoke-Test für die bisher fertiggestellte
// Poker-Engine (Deck/Hand/Betting) und die Audio-Anbindung.
fn main() {
let audio_engine = AudioEngine::new();
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);
audio_engine.tts.speak("Neues Spiel gestartet.");
audio_engine.sfx.play_sound("shuffle");
println!(
"Spiel gestartet mit {} Spielern, Runde: {:?}",
game.players.len(),
game.current_round
);
}