diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..9009d10 --- /dev/null +++ b/.mailmap @@ -0,0 +1,7 @@ +Andrei Kurochkin +Andrei Kurochkin <82461251+end41r@users.noreply.github.com> +Konrad Kortes +Konrad Kortes +Konrad Kortes +Tom Heinrich +Paul Gustav Hoffmann <241015669+VoidNoVerges@users.noreply.github.com> \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 892fc84..c50dbb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,6 @@ derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] } rand = "0.9.2" strum = "0.27" strum_macros = "0.27" + +[features] +wiz_debug = [] \ No newline at end of file diff --git a/README.md b/README.md index 1db7b59..205cd1c 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,7 @@ We also have a pr check for clippy and fmt, so if it fails, please use - `cargo fmt` to fix formatting (automatically) - `cargo clippy` to check for the warnings (most of them will be gone if you use `cargo clippy --fix --bin "wizard"`) - If you really trust clippy, you can also do `cargo clippy --fix --bin "wizard" --allow-dirty`, but its better not to use it :) + +### To run in debug mode with the wiz_debug feature enabled, run +`cargo run --features wiz_debug` +This will allow you to start the game with 1 player. \ No newline at end of file diff --git a/assets/MagicSchoolOne.ttf b/assets/MagicSchoolOne.ttf new file mode 100644 index 0000000..0fa7ad1 Binary files /dev/null and b/assets/MagicSchoolOne.ttf differ diff --git a/assets/Menu_Button.png b/assets/Menu_Button.png new file mode 100644 index 0000000..baa0e3b Binary files /dev/null and b/assets/Menu_Button.png differ diff --git a/assets/background_forall.png b/assets/background_forall.png new file mode 100644 index 0000000..0b75f5f Binary files /dev/null and b/assets/background_forall.png differ diff --git a/assets/button1.png b/assets/button1.png new file mode 100644 index 0000000..d3f3675 Binary files /dev/null and b/assets/button1.png differ diff --git a/assets/ingame_background.png b/assets/ingame_background.png new file mode 100644 index 0000000..6565faf Binary files /dev/null and b/assets/ingame_background.png differ diff --git a/assets/menu_container.png b/assets/menu_container.png new file mode 100644 index 0000000..1722b72 Binary files /dev/null and b/assets/menu_container.png differ diff --git a/assets/wizard_lobby_menu.png b/assets/wizard_lobby_menu.png new file mode 100644 index 0000000..cb01535 Binary files /dev/null and b/assets/wizard_lobby_menu.png differ diff --git a/assets/wizard_main_menu.png b/assets/wizard_main_menu.png new file mode 100644 index 0000000..49989cb Binary files /dev/null and b/assets/wizard_main_menu.png differ diff --git a/assets/wizard_main_menu2.png b/assets/wizard_main_menu2.png new file mode 100644 index 0000000..05d1ad1 Binary files /dev/null and b/assets/wizard_main_menu2.png differ diff --git a/src/client/mod.rs b/src/client/mod.rs index ad085f4..204249c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -21,6 +21,8 @@ pub enum PlayerCount { P6, } +const TITLE_FONT: &[u8] = include_bytes!("../../assets/MagicSchoolOne.ttf"); + impl PlayerCount { pub fn to_usize(self) -> usize { match self { @@ -54,7 +56,6 @@ pub enum MenuState { #[allow(dead_code)] PlayingTest, } - pub struct App { window_size: Size, @@ -99,6 +100,22 @@ pub struct App { // Gameplay view state pub viewable_hand: ViewableHand, + + // UI Buttons (main menu) + pub btn_host: crate::client::views::Button, + pub btn_join: crate::client::views::Button, + pub btn_rules: crate::client::views::Button, + pub btn_exit: crate::client::views::Button, + + // Buttons for other menus + pub btn_create_lobby: crate::client::views::Button, + pub btn_back: crate::client::views::Button, + pub btn_connect: crate::client::views::Button, + pub btn_send_chat: crate::client::views::Button, + pub btn_start_game: crate::client::views::Button, + pub btn_back_to_menu: crate::client::views::Button, + + pub btn_ready_owned: crate::client::views::Button, } impl Default for App { @@ -115,7 +132,6 @@ impl Default for App { msg: String::new(), menu: MenuState::Main, - host_name: "".to_string(), host_player_count: PlayerCount::P4, join_name: "".to_string(), @@ -152,6 +168,81 @@ impl Default for App { winner: None, viewable_hand: ViewableHand::new(window_size), + + //Buttons + btn_host: crate::client::views::Button::new(0, "Host", "assets/button1.png", 180, 44), + btn_join: crate::client::views::Button::new( + 1, + "Beitreten", + "assets/button1.png", + 180, + 44, + ), + btn_rules: crate::client::views::Button::new( + 2, + "Spielregeln", + "assets/button1.png", + 180, + 44, + ), + btn_exit: crate::client::views::Button::new( + 3, + "Spiel verlassen", + "assets/button1.png", + 180, + 44, + ), + + btn_create_lobby: crate::client::views::Button::new( + 10, + "Lobby erstellen", + "assets/button1.png", + 160, + 40, + ), + btn_back: crate::client::views::Button::new( + 11, + "zurück", + "assets/button1.png", + 100, + 36, + ), + btn_connect: crate::client::views::Button::new( + 12, + "Verbinden", + "assets/button1.png", + 140, + 40, + ), + btn_send_chat: crate::client::views::Button::new( + 13, + "Senden", + "assets/button1.png", + 100, + 36, + ), + btn_start_game: crate::client::views::Button::new( + 14, + "Starten", + "assets/button1.png", + 140, + 40, + ), + btn_back_to_menu: crate::client::views::Button::new( + 15, + "Zurück zum Menü", + "assets/button1.png", + 160, + 40, + ), + + btn_ready_owned: crate::client::views::Button::new( + 20, + "Bereit", + "assets/button1.png", + 100, + 36, + ), } } } @@ -167,7 +258,6 @@ pub enum AppMessage { HostPlayerCountChanged(PlayerCount), JoinNameChanged(String), ServerAddressChanged(String), - CopyToClipboard(String), SendChat, ChatInputChanged(String), @@ -192,6 +282,9 @@ pub enum AppMessage { // Gameplay view messages HandMessage(HandMessage), + + // Button messages from view widgets + ButtonMessage(crate::client::views::ButtonMessage), } fn subscription(state: &App) -> Subscription { @@ -205,10 +298,15 @@ fn subscription(state: &App) -> Subscription { } pub fn main() -> iced::Result { + use std::borrow::Cow; iced::application(App::default, update, view) .title("Wizard") .subscription(subscription) // Keep this value in sync with the App::default function. - .window_size(Size::new(640.0, 480.0)) + .window_size(Size::new(300.0, 800.0)) + .settings(iced::Settings { + fonts: vec![Cow::Borrowed(TITLE_FONT)], + ..Default::default() + }) .run() } diff --git a/src/client/update.rs b/src/client/update.rs index a4c214a..2a1c02f 100644 --- a/src/client/update.rs +++ b/src/client/update.rs @@ -1,4 +1,4 @@ -use iced::{clipboard, Task}; +use iced::Task; use std::sync::Arc; use super::{connect_ws, App, AppMessage, MenuState, PlayerCount}; @@ -70,11 +70,6 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { state.ip = addr; Task::none() } - AppMessage::CopyToClipboard(addr) => { - state.last_msg = "Server address copied to clipboard.".to_string(); - clipboard::write(addr) - } - AppMessage::SendChat => { if let Ok(guard) = state.ws_tx.lock() { if let Some(ref tx) = *guard { @@ -295,9 +290,106 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { state.viewable_hand.update_with_msg(hand_msg); Task::none() } + AppMessage::ButtonMessage(btn_msg) => { + // route to buttons (each button filters by id internally) + state.btn_host.update_with_msg(btn_msg.clone()); + state.btn_join.update_with_msg(btn_msg.clone()); + state.btn_rules.update_with_msg(btn_msg.clone()); + state.btn_exit.update_with_msg(btn_msg.clone()); + + state.btn_create_lobby.update_with_msg(btn_msg.clone()); + state.btn_back.update_with_msg(btn_msg.clone()); + state.btn_connect.update_with_msg(btn_msg.clone()); + state.btn_send_chat.update_with_msg(btn_msg.clone()); + state.btn_start_game.update_with_msg(btn_msg.clone()); + state.btn_back_to_menu.update_with_msg(btn_msg.clone()); + + state.btn_ready_owned.update_with_msg(btn_msg); + Task::none() + } AppMessage::AnimationTick => { state.viewable_hand.update_animations(); - Task::none() + + // Update button animations + state.btn_host.update_animations(); + state.btn_join.update_animations(); + state.btn_rules.update_animations(); + state.btn_exit.update_animations(); + + state.btn_create_lobby.update_animations(); + state.btn_back.update_animations(); + state.btn_connect.update_animations(); + state.btn_send_chat.update_animations(); + state.btn_start_game.update_animations(); + state.btn_back_to_menu.update_animations(); + state.btn_ready_owned.update_animations(); + + let mut pending_msgs: Vec> = Vec::new(); + + state.btn_host.check_click_end(|&_id| { + pending_msgs.push(Task::done(AppMessage::Host)); + }); + + state.btn_join.check_click_end(|_| { + pending_msgs.push(Task::done(AppMessage::Navigate(MenuState::Join))); + }); + + state.btn_rules.check_click_end(|_| { + pending_msgs.push(Task::done(AppMessage::GameRules)); + }); + + state.btn_exit.check_click_end(|_| { + pending_msgs.push(Task::done(AppMessage::CloseGame)); + }); + + state.btn_create_lobby.check_click_end(|&_id| { + pending_msgs.push(Task::done(AppMessage::CreateLobby)); + }); + + state.btn_ready_owned.check_click_end(|&_id| { + if let Some(my_id) = state.my_id { + pending_msgs.push(Task::done(AppMessage::ToggleReady(my_id))); + } + }); + + state.btn_back.check_click_end(|_| { + pending_msgs.push(Task::done(AppMessage::Navigate(MenuState::Main))); + }); + + state.btn_connect.check_click_end(|_| { + pending_msgs.push(Task::done(AppMessage::Connect)); + }); + + state.btn_send_chat.check_click_end(|_| { + pending_msgs.push(Task::done(AppMessage::SendChat)); + }); + + state.btn_start_game.check_click_end(|_| { + let can_start = if cfg!(feature = "wiz_debug") { + true + } else { + state.lobby.as_ref().is_some_and(|lobby| { + lobby.players.len() == state.host_player_count.to_usize() + && lobby.players.iter().all(|p| p.ready) + }) + }; + let is_host = state.my_id.is_some() + && state.my_id.unwrap() + == state + .lobby + .as_ref() + .and_then(|l| l.players.iter().find(|p| p.is_host).map(|p| p.id)) + .unwrap_or_default(); + if can_start && is_host { + pending_msgs.push(Task::done(AppMessage::StartGame)); + } + }); + + state.btn_back_to_menu.check_click_end(|_| { + pending_msgs.push(Task::done(AppMessage::BackToMenu)); + }); + + Task::batch(pending_msgs) } AppMessage::WindowResized(size) => { state.window_size = size; @@ -456,7 +548,19 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { println!("{}", log); state.game_log.push(log.clone()); state.last_msg = log; - state.menu = MenuState::Playing; + + // Check if the host's name is "wizard_master" to enter debug + // Easter egg :) + if let Some(ref lobby) = state.lobby { + if let Some(host) = lobby.players.iter().find(|p| p.is_host) { + if host.name == "wizard_master" { + state.menu = MenuState::PlayingTest; + } else { + state.menu = MenuState::Playing; + } + } + } + state.scores.clear(); state.bids.clear(); state.tricks_won.clear(); diff --git a/src/client/views.rs b/src/client/views.rs deleted file mode 100644 index 0897595..0000000 --- a/src/client/views.rs +++ /dev/null @@ -1,519 +0,0 @@ -use iced::{ - widget::{ - button, column, container, pick_list, row, scrollable, text, text_input, Column, Row, - }, - Element, -}; - -use super::{App, AppMessage, MenuState, PlayerCount}; -use crate::api::{Card, Suit, Value}; -use crate::gameplay_ui::hand::{HandMessage, ViewableHand}; -use crate::ui_element_traits::{Message, Viewable}; - -/// Format a card for display (e.g., "5 Red", "Wizard", "Jester") -fn format_card(card: &Card) -> String { - let value_str = match card.value { - Value::Jester => "Jester".to_string(), - Value::Wizard => "Wizard".to_string(), - Value::Number(n) => n.to_string(), - }; - - match card.value { - Value::Jester | Value::Wizard => value_str, - Value::Number(_) => format!("{}\n{:?}", value_str, card.suit), - } -} - -pub fn view(state: &App) -> Element<'_, AppMessage> { - match state.menu { - MenuState::Main => view_main_menu(state), - MenuState::Host => view_host_menu(state), - MenuState::Join => view_join_menu(state), - MenuState::Rules => view_rules_menu(), - MenuState::Lobby => view_lobby_menu(state), - MenuState::Playing => view_gameplay(state), - MenuState::PlayingTest => view_test_gameplay(state), - } -} - -fn view_main_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { - let content = column![ - text("Wizard - Main Menu").size(40), - button("Host").on_press(AppMessage::Host).padding(10), - button("Join") - .on_press(AppMessage::Navigate(MenuState::Join)) - .padding(10), - button("Gamerules") - .on_press(AppMessage::GameRules) - .padding(10), - button("Exit Game") - .on_press(AppMessage::CloseGame) - .padding(10), - text(state.last_msg.clone()), - ] - .spacing(20) - .align_x(iced::alignment::Horizontal::Center); - - container(content) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .center_x(iced::Fill) - .center_y(iced::Fill) - .into() -} - -fn view_host_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { - let count_options = vec![ - PlayerCount::P3, - PlayerCount::P4, - PlayerCount::P5, - PlayerCount::P6, - ]; - let can_join = !state.host_name.is_empty(); - let content = column![ - text("Host").size(30), - row![ - text(&state.ip), - button("copy").on_press(AppMessage::CopyToClipboard(state.ip.clone())), - ] - .spacing(10), - text("Name:"), - text_input("Your Name", &state.host_name).on_input(AppMessage::HostNameChanged), - text("Player Count:"), - pick_list( - count_options.clone(), - Some(state.host_player_count), - AppMessage::HostPlayerCountChanged - ), - button("Create Lobby").on_press_maybe(if can_join { - Some(AppMessage::CreateLobby) - } else { - None - }), - button("Back").on_press(AppMessage::BackToMenu), - ] - .spacing(10) - .padding(20) - .width(400) - .align_x(iced::alignment::Horizontal::Left); - - container(content) - .center_x(iced::Fill) - .center_y(iced::Fill) - .into() -} - -fn view_join_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { - let can_join = !state.ip.is_empty() && !state.join_name.is_empty(); - let content = column![ - text("Join").size(30), - text("Name:"), - text_input("Your Name", &state.join_name).on_input(AppMessage::JoinNameChanged), - text_input("Server Address", &state.ip).on_input(AppMessage::ServerAddressChanged), - button("Connect").on_press_maybe(if can_join { - Some(AppMessage::Connect) - } else { - None - }), - text("Progress:"), - text(&state.last_msg), - button("Back").on_press(AppMessage::BackToMenu), - ] - .spacing(10) - .padding(20) - .width(400) - .align_x(iced::alignment::Horizontal::Left); - - container(content) - .center_x(iced::Fill) - .center_y(iced::Fill) - .into() -} - -fn view_rules_menu<'a>() -> Element<'a, AppMessage> { - let content = column![ - text("Game Rules").size(30), - text("Here are the game rules (placeholder)."), - button("Back").on_press(AppMessage::BackToMenu), - ] - .spacing(10) - .padding(20) - .align_x(iced::alignment::Horizontal::Left); - - container(content) - .center_x(iced::Fill) - .center_y(iced::Fill) - .into() -} - -fn view_lobby_menu<'a>(state: &App) -> Element<'a, AppMessage> { - if !state.connected { - return container(column![ - text("Nicht verbunden zum Server. / IP wurde falsch eingegeben"), - button("Zurück").on_press(AppMessage::BackToMenu) - ]) - .center_x(iced::Fill) - .center_y(iced::Fill) - .into(); - } - if let Some(lobby) = &state.lobby { - let mut player_rows = Column::new().spacing(10); - for p in &lobby.players { - let ready_text = if p.ready { "Bereit" } else { "Nicht bereit" }; - let toggle = button(ready_text).on_press_maybe(if Some(p.id) == state.my_id { - Some(AppMessage::ToggleReady(p.id)) - } else { - None - }); - let row = row![ - text(format!( - "{}{}", - if p.is_host { "(Host) " } else { "" }, - p.name - )), - toggle - ]; - player_rows = player_rows.push(row); - } - - let mut chat_block = Column::new().spacing(5); - for (sender, msg) in &lobby.chat { - chat_block = chat_block.push(text(format!("{}: {}", sender, msg))); - } - - let can_start = lobby.players.len() == state.host_player_count.to_usize() - && lobby.players.iter().all(|p| p.ready); - let start_button = row![ - button("Starten").on_press_maybe( - if can_start - && state.my_id.is_some() - && state.my_id.unwrap() - == lobby - .players - .iter() - .find(|p| p.is_host) - .map(|p| p.id) - .unwrap_or_default() - { - Some(AppMessage::StartGame) - } else { - None - } - ), - text(if !can_start { - " (Warten auf Spieler...)" - } else if state.my_id.is_some() - && state.my_id.unwrap() - != lobby - .players - .iter() - .find(|p| p.is_host) - .map(|p| p.id) - .unwrap_or_default() - { - " (Nur der Host kann starten)" - } else { - "" - }) - ] - .spacing(5); - - let content = column![ - text("Lobby").size(30), - row![ - text("Host Address:"), - text_input("Address to share", &state.ip) - ] - .spacing(10), - text(format!( - "Spieler: {}/{}", - lobby.players.len(), - state.host_player_count.to_usize() - )), - player_rows, - scrollable(chat_block).height(150).width(400), - row![ - text_input("Nachricht", &state.chat_input).on_input(AppMessage::ChatInputChanged), - button("Senden").on_press(AppMessage::SendChat), - ], - start_button, - button("Zurück zum Menü").on_press(AppMessage::BackToMenu) - ] - .spacing(10) - .padding(20); - - container(content) - .center_x(iced::Fill) - .center_y(iced::Fill) - .into() - } else { - container(column![ - text("Keine Lobby vorhanden"), - button("Zurück").on_press(AppMessage::BackToMenu) - ]) - .center_x(iced::Fill) - .center_y(iced::Fill) - .into() - } -} - -fn get_player_name(state: &App, player_id: u64) -> String { - if state.my_id == Some(player_id) { - return "You".to_string(); - } - if let Some(ref lobby) = state.lobby { - if let Some(player) = lobby.players.iter().find(|p| p.id == player_id) { - return player.name.clone(); - } - } - format!("Player {}", player_id) -} - -fn view_test_gameplay<'a>(state: &'a App) -> Element<'a, AppMessage> { - let my_name = state - .my_id - .map(|id| get_player_name(state, id)) - .unwrap_or("?".to_string()); - let trump_str = state - .trump - .map(|s| format!("{:?}", s)) - .unwrap_or("None".to_string()); - let current_player_name = state - .current_player - .map(|id| get_player_name(state, id)) - .unwrap_or("?".to_string()); - - let header = column![ - text(format!( - "Round {} | Trump: {} | You: {}", - state.round_number, trump_str, my_name - )) - .size(18), - text(format!( - "Current Player: {} | Phase: {}", - current_player_name, - if state.must_set_trump { - "Set Trump" - } else if state.is_bidding_phase { - "Bidding" - } else { - "Playing" - } - )) - .size(14), - text(format!("Status: {}", state.last_msg)).size(12), - ] - .spacing(5); - - // Trump selection (if dealer needs to set) - let trump_section: Element<'a, AppMessage> = if state.must_set_trump { - column![ - text("SELECT TRUMP SUIT:").size(16), - row![ - button("Red") - .on_press(AppMessage::SetTrump(Suit::Red)) - .padding(8), - button("Blue") - .on_press(AppMessage::SetTrump(Suit::Blue)) - .padding(8), - button("Green") - .on_press(AppMessage::SetTrump(Suit::Green)) - .padding(8), - button("Yellow") - .on_press(AppMessage::SetTrump(Suit::Yellow)) - .padding(8), - ] - .spacing(5), - ] - .spacing(5) - .into() - } else { - text("").into() - }; - - // Bidding section - let bidding_section: Element<'a, AppMessage> = - if state.is_bidding_phase && state.is_my_turn && !state.must_set_trump { - column![ - text("YOUR BID:").size(16), - row![ - text_input("Enter bid", &state.bid_input) - .on_input(AppMessage::BidInputChanged) - .width(80), - button("Submit Bid") - .on_press(AppMessage::SubmitBid) - .padding(8), - ] - .spacing(5), - text(format!("(0 to {})", state.round_number + 1)).size(12), - ] - .spacing(5) - .into() - } else { - text("").into() - }; - - let bids_display: Element<'a, AppMessage> = if !state.bids.is_empty() { - let mut bids_col = Column::new().spacing(2); - bids_col = bids_col.push(text("Tricks / Bids:").size(14)); - for (player_id, bid) in &state.bids { - let player_name = get_player_name(state, *player_id); - let tricks = state.tricks_won.get(player_id).unwrap_or(&0); - bids_col = - bids_col.push(text(format!(" {}: {} / {}", player_name, tricks, bid)).size(12)); - } - bids_col.into() - } else { - text("").into() - }; - - let trick_display: Element<'a, AppMessage> = if !state.current_trick.is_empty() { - let mut trick_col = Column::new().spacing(2); - trick_col = trick_col.push(text("Current Trick:").size(14)); - for (player_id, card) in &state.current_trick { - let player_name = get_player_name(state, *player_id); - let card_str = format_card(card); - trick_col = trick_col.push( - text(format!( - " {} played {}", - player_name, - card_str.replace('\n', " ") - )) - .size(12), - ); - } - trick_col.into() - } else { - text("Trick: (empty)").size(12).into() - }; - - let hand_section: Element<'a, AppMessage> = { - let mut hand_col = Column::new().spacing(5); - hand_col = hand_col.push(text(format!("Your Hand ({} cards):", state.hand.len())).size(16)); - - let mut card_row = Row::new().spacing(5); - for card in &state.hand { - let card_text = format_card(card); - let is_valid = state.valid_cards.is_empty() || state.valid_cards.contains(card); - let can_play = - state.is_my_turn && !state.is_bidding_phase && !state.must_set_trump && is_valid; - - let card_btn = if can_play { - button(text(card_text).size(11)) - .on_press(AppMessage::PlayCard(*card)) - .padding(8) - } else { - button(text(card_text).size(11)).padding(8) - }; - card_row = card_row.push(card_btn); - } - hand_col = hand_col.push(scrollable(card_row).direction( - scrollable::Direction::Horizontal(scrollable::Scrollbar::default()), - )); - hand_col.into() - }; - - let scores_section: Element<'a, AppMessage> = { - let mut scores_col = Column::new().spacing(2); - scores_col = scores_col.push(text("Scores:").size(14)); - for player_id in &state.player_order { - let player_name = get_player_name(state, *player_id); - let score = state.scores.get(player_id).unwrap_or(&0); - scores_col = - scores_col.push(text(format!(" {}: {} pts", player_name, score)).size(12)); - } - scores_col.into() - }; - - let game_over_section: Element<'a, AppMessage> = if state.game_over { - let winner_name = state - .winner - .map(|id| get_player_name(state, id)) - .unwrap_or("Unknown".to_string()); - let is_me = state.my_id == state.winner; - column![ - text(if is_me { - "🎉 YOU WON! 🎉" - } else { - "GAME OVER" - }) - .size(24), - text(format!("Winner: {}", winner_name)).size(18), - button("Back to Menu") - .on_press(AppMessage::BackToMenu) - .padding(10), - ] - .spacing(10) - .into() - } else { - text("").into() - }; - - let log_section: Element<'a, AppMessage> = { - let mut log_col = Column::new().spacing(2); - log_col = log_col.push(text("Game Log:").size(14)); - let start = if state.game_log.len() > 15 { - state.game_log.len() - 15 - } else { - 0 - }; - for entry in state.game_log.iter().skip(start) { - log_col = log_col.push(text(entry).size(10)); - } - scrollable(log_col).height(150).into() - }; - - let players_section: Element<'a, AppMessage> = if !state.player_order.is_empty() { - let mut players_str = String::from("Players: "); - for (i, pid) in state.player_order.iter().enumerate() { - let is_current = state.current_player == Some(*pid); - let player_name = get_player_name(state, *pid); - players_str.push_str(&format!( - "{}{}{}", - if is_current { "[" } else { "" }, - player_name, - if is_current { "]" } else { "" } - )); - if i < state.player_order.len() - 1 { - players_str.push_str(" → "); - } - } - text(players_str).size(12).into() - } else { - text("").into() - }; - - let content = column![ - text("WIZARD").size(24), - game_over_section, - header, - players_section, - trump_section, - bidding_section, - bids_display, - trick_display, - hand_section, - scores_section, - log_section, - button("Back to Menu") - .on_press(AppMessage::BackToMenu) - .padding(8), - ] - .spacing(10) - .padding(20); - - container(scrollable(content)) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .into() -} - -fn view_gameplay<'a>(state: &'a App) -> Element<'a, AppMessage> { - column![ - state.viewable_hand.view(), - button("Draw Cards").on_press(ViewableHand::convert_to_app_message( - HandMessage::DrawCards(ViewableHand::build_test_cards(state.window_size)) - )) - ] - .into() -} diff --git a/src/client/views/button.rs b/src/client/views/button.rs new file mode 100644 index 0000000..1a4fa24 --- /dev/null +++ b/src/client/views/button.rs @@ -0,0 +1,207 @@ +use derive_more::{Deref, DerefMut}; +use iced::widget::{container, stack, text, Image, MouseArea}; + +use crate::animation::{ + animation_end_sensor::AnimationEndSensor, BasicAnimation, Easing, ReversableBasicAnimation, +}; +use crate::client::AppMessage; +use crate::ui_element_traits::{Animated, Message}; + +#[derive(Debug, Clone, Deref, DerefMut)] +pub struct HoverAnim(ReversableBasicAnimation); + +impl HoverAnim { + pub fn new(duration: usize) -> Self { + Self(ReversableBasicAnimation::new(duration)) + } + + pub fn get_expansion(&self) -> f32 { + let progress = self.progress(Easing::InOutCubic); + progress * 0.05 + 1.0 + } +} + +#[derive(Debug, Clone, Deref, DerefMut)] +pub struct ClickAnim(BasicAnimation); + +impl ClickAnim { + pub fn new(duration: usize) -> Self { + Self(BasicAnimation::new(duration)) + } + + pub fn get_contraction(&self) -> f32 { + 1.0 - self.progress(Easing::InCubic) * 0.1 + } + + pub fn get_opacity(&self) -> f32 { + 1.0 - self.progress(Easing::Linear) * 0.4 + } +} + +#[derive(Debug, Clone)] +pub enum ButtonMessage { + Hovered(usize), + NotHovered(usize), + Clicked(usize), +} + +#[derive(Debug)] +pub struct Button { + pub id: usize, + pub label: &'static str, + img_path: &'static str, + width: u16, + height: u16, + hover_animation: HoverAnim, + click_animation: ClickAnim, + click_end_sensor: AnimationEndSensor, +} + +impl Button { + pub fn new( + id: usize, + label: &'static str, + img_path: &'static str, + width: u16, + height: u16, + ) -> Self { + let click_duration: usize = 15; + Self { + id, + label, + img_path, + width, + height, + hover_animation: HoverAnim::new(12), + click_animation: ClickAnim::new(click_duration), + click_end_sensor: AnimationEndSensor::new(click_duration), + } + } + + pub fn check_click_end(&mut self, action: F) -> bool + where + F: FnOnce(&usize), + { + let finished = self.click_end_sensor.check(|h| { + if let Some(k) = h.content() { + action(k); + } + }); + if finished { + self.click_animation.reset(); + } + finished + } + + pub fn view(&self) -> container::Container<'_, AppMessage> { + self.view_internal(self.label) + } + + pub fn view_with_label<'a>(&self, label: &'a str) -> container::Container<'a, AppMessage> { + self.view_internal(label) + } + + fn view_internal<'a>(&self, label: &'a str) -> container::Container<'a, AppMessage> { + let scale = self.hover_animation.get_expansion() * self.click_animation.get_contraction(); + let width_scaled = (self.width as f32 * scale).max(1.0).round() as u16; + let height_scaled = (self.height as f32 * scale).max(1.0).round() as u16; + let txt_size = ((height_scaled as f32) * 0.4) as u32; + + let img = Image::new(self.img_path) + .width(width_scaled as u32) + .height(height_scaled as u32) + .opacity(self.click_animation.get_opacity()); + + let content = stack![ + img, + container(text(label).size(txt_size)) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Fill) + .center_y(iced::Fill), + ]; + + let base = container(content) + .width(self.width as u32) + .height(self.height as u32); + + let mouse_area = MouseArea::new(base) + .on_enter(AppMessage::ButtonMessage(ButtonMessage::Hovered(self.id))) + .on_exit(AppMessage::ButtonMessage(ButtonMessage::NotHovered( + self.id, + ))) + .on_press(AppMessage::ButtonMessage(ButtonMessage::Clicked(self.id))) + .interaction(iced::mouse::Interaction::Pointer); + + container(mouse_area) + } + + pub fn view_disabled(&self) -> container::Container<'_, AppMessage> { + self.view_disabled_internal(self.label) + } + + pub fn view_disabled_with_label<'a>( + &self, + label: &'a str, + ) -> container::Container<'a, AppMessage> { + self.view_disabled_internal(label) + } + + fn view_disabled_internal<'a>(&self, label: &'a str) -> container::Container<'a, AppMessage> { + let txt_size = ((self.height as f32) * 0.4) as u32; + + let img = Image::new(self.img_path) + .width(self.width as u32) + .height(self.height as u32) + .opacity(0.6); + + let content = stack![ + img, + container( + text(label) + .size(txt_size) + .color(iced::Color::from_rgb(0.5, 0.5, 0.5)), + ) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Fill) + .center_y(iced::Fill), + ]; + + container(content) + .width(self.width as u32) + .height(self.height as u32) + } +} + +/// Implements traits +impl Message for Button { + type OwnMessage = ButtonMessage; + + fn convert_to_app_message(msg: ButtonMessage) -> AppMessage { + AppMessage::ButtonMessage(msg) + } + + fn update_with_msg(&mut self, msg: ButtonMessage) { + match msg { + ButtonMessage::Hovered(id) if id == self.id => { + self.hover_animation.start(); + } + ButtonMessage::NotHovered(id) if id == self.id => { + self.hover_animation.reverse(); + } + ButtonMessage::Clicked(id) if id == self.id => { + self.click_animation.start(); + self.click_end_sensor.start(Some(id)); + } + _ => {} + } + } +} + +impl Animated for Button { + fn update_animations(&mut self) { + self.hover_animation.next_frame(); + self.click_animation.next_frame(); + } +} diff --git a/src/client/views/gameplay.rs b/src/client/views/gameplay.rs new file mode 100644 index 0000000..7ff5162 --- /dev/null +++ b/src/client/views/gameplay.rs @@ -0,0 +1,454 @@ +use iced::{ + widget::{ + button, column, container, row, scrollable, stack, text, text_input, Column, Image, Row, + }, + Border, Color, ContentFit, Element, +}; + +use super::utils::{format_card, get_player_name}; +use crate::api::Suit; +use crate::client::{App, AppMessage}; + +pub fn view_gameplay<'a>(state: &'a App) -> Element<'a, AppMessage> { + let scoreboard = container(view_scoreboard(state)) + .width(iced::Length::Fixed(250.0)) + .height(iced::Length::Fill) + .center_y(iced::Length::Fill) + .padding(10); + + let main_content = column![ + // Add your main game content here in rows + ] + .width(iced::Length::Fill) + .height(iced::Length::Fill); + + let content = row![main_content, scoreboard,].height(iced::Length::Fill); + + stack![ + Image::new("assets/ingame_background.png") + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .content_fit(ContentFit::Cover), + container(content) + .width(iced::Length::Fill) + .height(iced::Length::Fill), + ] + .into() +} + +// AI Usage: overwrite the view so that the scoreboard is placed correctly +// and uses rows+cells instead of rows+format strings +pub fn view_scoreboard<'a>(state: &'a App) -> Element<'a, AppMessage> { + let mut scores_col = Column::new().spacing(2); + + // Title + scores_col = scores_col.push( + container(text("Scoreboard").size(18).color(Color::WHITE)) + .width(iced::Length::Fill) + .center_x(iced::Length::Fill) + .padding(5), + ); + + scores_col = scores_col.push( + container( + text(format!("Round {}", state.round_number + 1)) + .size(12) + .color(Color::from_rgba(1.0, 1.0, 1.0, 0.7)), + ) + .width(iced::Length::Fill) + .center_x(iced::Length::Fill) + .padding([0, 5]), + ); + + // Header row + scores_col = scores_col.push(scoreboard_row("Name", "Pkt", "Won", "Bid", true, false)); + + for player_id in &state.player_order { + let mut player_name = get_player_name(state, *player_id); + let score = state.scores.get(player_id).unwrap_or(&0); + let tricks = state.tricks_won.get(player_id).unwrap_or(&0); + let bid = state.bids.get(player_id).unwrap_or(&0); + let is_self = state.my_id == Some(*player_id); + + if player_name.is_empty() { + player_name = "???".to_string(); + } + + scores_col = scores_col.push(scoreboard_row( + &player_name, + &score.to_string(), + &tricks.to_string(), + &bid.to_string(), + false, + is_self, + )); + } + + // Footer note + scores_col = scores_col.push( + container( + text("Bids for current round") + .size(10) + .color(Color::from_rgba(1.0, 1.0, 1.0, 0.5)), + ) + .width(iced::Length::Fill) + .center_x(iced::Length::Fill) + .padding([8, 0]), + ); + + // Wrap in a styled container + container(scores_col) + .width(iced::Length::Fill) + .padding(10) + .style(|_theme| container::Style { + background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.6).into()), + border: Border { + color: Color::from_rgba(1.0, 0.85, 0.4, 0.5), + width: 2.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .into() +} + +fn scoreboard_row( + name: &str, + score: &str, + tricks: &str, + bid: &str, + is_header: bool, + is_self: bool, +) -> Element<'static, AppMessage> { + let text_color = if is_self { + Color::from_rgb(1.0, 0.85, 0.4) + } else { + Color::WHITE + }; + + let cell = |content: String| { + container( + text(content) + .size(if is_header { 10 } else { 11 }) + .color(text_color), + ) + .width(iced::Length::FillPortion(1)) + .center_x(iced::Length::Fill) + .padding(4) + }; + + let name_cell = |content: String| { + container( + text(content) + .size(if is_header { 10 } else { 11 }) + .color(text_color), + ) + .width(iced::Length::FillPortion(2)) + .padding(4) + }; + + let row_content = row![ + name_cell(name.to_string()), + cell(score.to_string()), + cell(tricks.to_string()), + cell(bid.to_string()), + ] + .width(iced::Length::Fill); + + container(row_content) + .width(iced::Length::Fill) + .style(move |_theme| container::Style { + background: Some(if is_self { + Color::from_rgba(1.0, 0.85, 0.4, 0.15).into() + } else { + Color::from_rgba(0.0, 0.0, 0.0, if is_header { 0.4 } else { 0.2 }).into() + }), + border: Border { + color: if is_self { + Color::from_rgba(1.0, 0.85, 0.4, 0.5) + } else { + Color::from_rgba(1.0, 1.0, 1.0, 0.2) + }, + width: if is_self { 1.5 } else { 1.0 }, + radius: 2.0.into(), + }, + ..Default::default() + }) + .into() +} + +// bid_panel_footer is no longer needed for gameplay view +#[allow(dead_code)] +fn bid_panel_footer<'a>() -> Option> { + Some( + text("Bids are shown for the current round only.") + .size(11) + .color(Color::from_rgba(1.0, 1.0, 1.0, 0.7)) + .into(), + ) +} + +/// ======================================= +/// EASTER EGG SECTION: DEBUG GAMEPLAY VIEW +/// ======================================= +pub fn view_test_gameplay<'a>(state: &'a App) -> Element<'a, AppMessage> { + let content = column![ + text("WIZARD").size(24), + build_game_over_section_dbg(state), + build_header_dbg(state), + build_players_section_dbg(state), + build_trump_section_dbg(state), + build_bidding_section_dbg(state), + build_bids_display_dbg(state), + build_trick_display_dbg(state), + build_hand_section_dbg(state), + build_scores_section_dbg(state), + build_log_section_dbg(state), + button("Back to Menu") + .on_press(AppMessage::BackToMenu) + .padding(8), + ] + .spacing(10) + .padding(20); + + container(scrollable(content)) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .into() +} + +fn build_header_dbg<'a>(state: &'a App) -> Column<'a, AppMessage> { + let my_name = state + .my_id + .map(|id| get_player_name(state, id)) + .unwrap_or("?".to_string()); + + let trump_str = state + .trump + .map(|s| format!("{:?}", s)) + .unwrap_or("None".to_string()); + + let current_player_name = state + .current_player + .map(|id| get_player_name(state, id)) + .unwrap_or("?".to_string()); + + let phase = if state.must_set_trump { + "Set Trump" + } else if state.is_bidding_phase { + "Bidding" + } else { + "Playing" + }; + + column![ + text(format!( + "Round {} | Trump: {} | You: {}", + state.round_number, trump_str, my_name + )) + .size(18), + text(format!( + "Current Player: {} | Phase: {}", + current_player_name, phase + )) + .size(14), + text(format!("Status: {}", state.last_msg)).size(12), + ] + .spacing(5) +} + +fn build_trump_section_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + if !state.must_set_trump { + return text("").into(); + } + + column![ + text("SELECT TRUMP SUIT:").size(16), + row![ + button("Red") + .on_press(AppMessage::SetTrump(Suit::Red)) + .padding(8), + button("Blue") + .on_press(AppMessage::SetTrump(Suit::Blue)) + .padding(8), + button("Green") + .on_press(AppMessage::SetTrump(Suit::Green)) + .padding(8), + button("Yellow") + .on_press(AppMessage::SetTrump(Suit::Yellow)) + .padding(8), + ] + .spacing(5), + ] + .spacing(5) + .into() +} + +fn build_bidding_section_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + if !state.is_bidding_phase || !state.is_my_turn || state.must_set_trump { + return text("").into(); + } + + column![ + text("YOUR BID:").size(16), + row![ + text_input("Enter bid", &state.bid_input) + .on_input(AppMessage::BidInputChanged) + .width(80), + button("Submit Bid") + .on_press(AppMessage::SubmitBid) + .padding(8), + ] + .spacing(5), + text(format!("(0 to {})", state.round_number + 1)).size(12), + ] + .spacing(5) + .into() +} + +fn build_bids_display_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + if state.bids.is_empty() { + return text("").into(); + } + + let mut bids_col = Column::new().spacing(2); + bids_col = bids_col.push(text("Tricks / Bids:").size(14)); + + for (player_id, bid) in &state.bids { + let player_name = get_player_name(state, *player_id); + let tricks = state.tricks_won.get(player_id).unwrap_or(&0); + bids_col = bids_col.push(text(format!(" {}: {} / {}", player_name, tricks, bid)).size(12)); + } + + bids_col.into() +} + +fn build_trick_display_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + if state.current_trick.is_empty() { + return text("Trick: (empty)").size(12).into(); + } + + let mut trick_col = Column::new().spacing(2); + trick_col = trick_col.push(text("Current Trick:").size(14)); + + for (player_id, card) in &state.current_trick { + let player_name = get_player_name(state, *player_id); + let card_str = format_card(card); + trick_col = trick_col.push( + text(format!( + " {} played {}", + player_name, + card_str.replace('\n', " ") + )) + .size(12), + ); + } + + trick_col.into() +} + +fn build_hand_section_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + let mut hand_col = Column::new().spacing(5); + hand_col = hand_col.push(text(format!("Your Hand ({} cards):", state.hand.len())).size(16)); + + let mut card_row = Row::new().spacing(5); + for card in &state.hand { + let card_text = format_card(card); + let is_valid = state.valid_cards.is_empty() || state.valid_cards.contains(card); + let can_play = + state.is_my_turn && !state.is_bidding_phase && !state.must_set_trump && is_valid; + + let card_btn = if can_play { + button(text(card_text).size(11)) + .on_press(AppMessage::PlayCard(*card)) + .padding(8) + } else { + button(text(card_text).size(11)).padding(8) + }; + card_row = card_row.push(card_btn); + } + + hand_col = hand_col.push( + scrollable(card_row).direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default(), + )), + ); + + hand_col.into() +} + +fn build_scores_section_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + let mut scores_col = Column::new().spacing(2); + scores_col = scores_col.push(text("Scores:").size(14)); + + for player_id in &state.player_order { + let player_name = get_player_name(state, *player_id); + let score = state.scores.get(player_id).unwrap_or(&0); + scores_col = scores_col.push(text(format!(" {}: {} pts", player_name, score)).size(12)); + } + + scores_col.into() +} + +fn build_log_section_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + let mut log_col = Column::new().spacing(2); + log_col = log_col.push(text("Game Log:").size(14)); + + let start = state.game_log.len().saturating_sub(15); + for entry in state.game_log.iter().skip(start) { + log_col = log_col.push(text(entry).size(10)); + } + + scrollable(log_col).height(150).into() +} + +fn build_players_section_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + if state.player_order.is_empty() { + return text("").into(); + } + + let mut players_str = String::from("Players: "); + for (i, pid) in state.player_order.iter().enumerate() { + let is_current = state.current_player == Some(*pid); + let player_name = get_player_name(state, *pid); + + players_str.push_str(&format!( + "{}{}{}", + if is_current { "[" } else { "" }, + player_name, + if is_current { "]" } else { "" } + )); + + if i < state.player_order.len() - 1 { + players_str.push_str(" → "); + } + } + + text(players_str).size(12).into() +} + +fn build_game_over_section_dbg<'a>(state: &'a App) -> Element<'a, AppMessage> { + if !state.game_over { + return text("").into(); + } + + let winner_name = state + .winner + .map(|id| get_player_name(state, id)) + .unwrap_or("Unknown".to_string()); + + let is_me = state.my_id == state.winner; + + column![ + text(if is_me { + "🎉 YOU WON! 🎉" + } else { + "GAME OVER" + }) + .size(24), + text(format!("Winner: {}", winner_name)).size(18), + state.btn_back_to_menu.view().padding(8), + ] + .spacing(10) + .into() +} diff --git a/src/client/views/lobby.rs b/src/client/views/lobby.rs new file mode 100644 index 0000000..d2fe854 --- /dev/null +++ b/src/client/views/lobby.rs @@ -0,0 +1,168 @@ +//! Lobby view for waiting players before game start. + +use iced::{ + widget::{column, container, row, scrollable, stack, text, text_input, Column, Image}, + ContentFit, Element, +}; + +use crate::api::Lobby; +use crate::client::{App, AppMessage}; + +pub fn view_lobby_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { + if !state.connected { + return view_not_connected(state); + } + + let Some(lobby) = &state.lobby else { + return view_no_lobby(state); + }; + + view_lobby_content(state, lobby) +} + +fn view_not_connected<'a>(state: &'a App) -> Element<'a, AppMessage> { + stack![ + Image::new("assets/wizard_lobby_menu.png") + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .content_fit(ContentFit::Cover), + container(column![ + text("Nicht verbunden zum Server. / IP wurde falsch eingegeben"), + state.btn_back.view().padding(0) + ]) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill) + ] + .into() +} + +fn view_no_lobby<'a>(state: &'a App) -> Element<'a, AppMessage> { + stack![ + Image::new("assets/wizard_lobby_menu.png") + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .content_fit(ContentFit::Cover), + container(column![ + text("Keine Lobby vorhanden"), + state.btn_back.view().padding(0) + ]) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill) + ] + .into() +} + +fn view_lobby_content<'a>(state: &'a App, lobby: &'a Lobby) -> Element<'a, AppMessage> { + let player_rows = build_player_rows(state, lobby); + let chat_block = build_chat_block(lobby); + let start_button = build_start_button(state, lobby); + + let content = column![ + text("Lobby").size(30), + row![ + text("Host Address:"), + text_input("Address to share", &state.ip) + ] + .spacing(10), + text(format!( + "Spieler: {}/{}", + lobby.players.len(), + state.host_player_count.to_usize() + )), + player_rows, + scrollable(chat_block).height(150).width(400), + row![ + text_input("Nachricht", &state.chat_input).on_input(AppMessage::ChatInputChanged), + state.btn_send_chat.view().padding(0), + ], + start_button, + state.btn_back_to_menu.view().padding(0) + ] + .spacing(10) + .padding(20); + + stack![ + Image::new("assets/wizard_lobby_menu.png") + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .content_fit(ContentFit::Cover), + container(content) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill) + ] + .into() +} + +fn build_player_rows<'a>(state: &'a App, lobby: &'a Lobby) -> Column<'a, AppMessage> { + let mut player_rows = Column::new().spacing(10); + + for p in &lobby.players { + let ready_text = if p.ready { "Bereit" } else { "Nicht bereit" }; + let toggle = if Some(p.id) == state.my_id { + state.btn_ready_owned.view_with_label(ready_text) + } else { + state.btn_ready_owned.view_disabled_with_label(ready_text) + }; + + let row = row![ + text(format!( + "{}{}", + if p.is_host { "(Host) " } else { "" }, + p.name + )), + toggle + ]; + player_rows = player_rows.push(row); + } + + player_rows +} + +fn build_chat_block<'a>(lobby: &'a Lobby) -> Column<'a, AppMessage> { + let mut chat_block = Column::new().spacing(5); + + for (sender, msg) in &lobby.chat { + chat_block = chat_block.push(text(format!("{}: {}", sender, msg))); + } + + chat_block +} + +fn build_start_button<'a>(state: &'a App, lobby: &'a Lobby) -> iced::widget::Row<'a, AppMessage> { + let can_start = if cfg!(feature = "wiz_debug") { + true + } else { + lobby.players.len() == state.host_player_count.to_usize() + && lobby.players.iter().all(|p| p.ready) + }; + let host_id = lobby + .players + .iter() + .find(|p| p.is_host) + .map(|p| p.id) + .unwrap_or_default(); + + let i_am_host = state.my_id.is_some() && state.my_id.unwrap() == host_id; + + let start_button_view = if can_start && i_am_host { + state.btn_start_game.view().padding(0) + } else { + state.btn_start_game.view_disabled().padding(0) + }; + + let status_text = if !can_start { + " (Warten auf Spieler...)" + } else if state.my_id.is_some() && !i_am_host { + " (Nur der Host kann starten)" + } else { + "" + }; + + row![start_button_view, text(status_text)].spacing(5) +} diff --git a/src/client/views/menu.rs b/src/client/views/menu.rs new file mode 100644 index 0000000..a74ac60 --- /dev/null +++ b/src/client/views/menu.rs @@ -0,0 +1,200 @@ +use iced::{ + widget::{ + column, container, pick_list, row, scrollable, stack, text, text_input, Column, Image, + }, + ContentFit, Element, +}; + +use crate::client::{App, AppMessage, PlayerCount}; + +use super::utils::{back_button_footer, background_image, menu_panel, TITLE_FONT}; + +pub fn view_main_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { + let title = text("Wizard") + .size(130) + .font(TITLE_FONT) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Top); + + let menu_left: Column<'a, AppMessage> = column![ + state.btn_host.view().padding(10), + state.btn_join.view().padding(10), + ] + .spacing(100) + .align_x(iced::alignment::Horizontal::Left); + + let menu_right: Column<'a, AppMessage> = column![ + state.btn_rules.view().padding(10), + state.btn_exit.view().padding(10), + ] + .spacing(100) + .align_x(iced::alignment::Horizontal::Right); + + stack![ + Image::new("assets/wizard_main_menu.png") + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .content_fit(ContentFit::Cover), + container(title) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Fill), + row![ + container(menu_left) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Fill) + .center_y(iced::Fill), + container(menu_right) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Fill) + .center_y(iced::Fill), + ] + .align_y(iced::alignment::Vertical::Center) + ] + .into() +} + +pub fn view_host_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { + let count_options = vec![ + PlayerCount::P3, + PlayerCount::P4, + PlayerCount::P5, + PlayerCount::P6, + ]; + + let can_join = !state.host_name.is_empty(); + + let content = column![ + text("Host").size(30), + text("Name:"), + text_input("Your Name", &state.host_name).on_input(AppMessage::HostNameChanged), + text("Player Count:"), + pick_list( + count_options.clone(), + Some(state.host_player_count), + AppMessage::HostPlayerCountChanged + ), + if can_join { + state.btn_create_lobby.view().padding(0) + } else { + state.btn_create_lobby.view_disabled().padding(0) + }, + ] + .spacing(10) + .padding(20) + .width(400) + .align_x(iced::alignment::Horizontal::Left); + + stack![ + background_image("assets/background_forall.png"), + menu_panel( + state, + "Spiel hosten:", + content.into(), + back_button_footer(state) + ) + ] + .into() +} + +pub fn view_join_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { + let can_join = !state.ip.is_empty() && !state.join_name.is_empty(); + + let content = column![ + text("Join").size(30), + text("Name:"), + text_input("Your Name", &state.join_name).on_input(AppMessage::JoinNameChanged), + text_input("Server Address", &state.ip).on_input(AppMessage::ServerAddressChanged), + if can_join { + state.btn_connect.view().padding(0) + } else { + state.btn_connect.view_disabled().padding(0) + }, + text("Progress:"), + ] + .spacing(10) + .padding(20) + .width(400) + .align_x(iced::alignment::Horizontal::Left); + + stack![ + background_image("assets/background_forall.png"), + menu_panel( + state, + "Spiel beitreten:", + content.into(), + back_button_footer(state) + ) + ] + .into() +} + +pub fn view_rules_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { + let rules_body = column![ + text("Grundlagen:").size(20), + text("Wizard ist ein Stichspiel, bei dem das Ziel ist, möglichst genau vorherzusagen,"), + text("wie viele Stiche man pro Runde macht."), + text("Die Anzahl der Spieler bestimmt die Anzahl der gespielten Runden:"), + text(" - 3 Spieler: 20 Runden"), + text(" - 4 Spieler: 16 Runden"), + text(" - 5 Spieler: 13 Runden"), + text(" - 6 Spieler: 11 Runden"), + text(""), + text("Karten:").size(20), + text("Das Wizard Deck besteht aus 60 Karten:"), + text(" - Zahlen 1-13 - Kreuz"), + text(" - Zahlen 1-13 - Pik"), + text(" - Zahlen 1-13 - Herz"), + text(" - Zahlen 1-13 - Karo"), + text(" - 4 Wizards"), + text(" - 4 Narren"), + text(""), + text("Stiche:").size(20), + text("Ein Stich wird von der höchsten Karte, oder dem ersten gelegten Wizard gewonnen"), + text("\n"), + text("Trumpf:").size(20), + text("Ein Trumpf ist eine bestimmte Farbe, die im Wert über allen nicht-trumpf Farben steht"), + text("wird also eine nicht-Trumpf 12 gelegt, und darauf eine Trumpf 10, gewinnt die Trumpf 10 den Stich"), + text("die Trumpf-Farbe wird am Anfang jeder Runde festgelegt"), + text("\n"), + text("\n"), + text("Spielablauf:").size(24), + text("Anfang:").size(20), + text("In der ersten Runde bekommt jeder Spieler genau eine Karte, blabla Placeholder..."), + text("Jede Runde in Wizard hat denselben Ablauf, der Trumpf wird aufgedeckt und jeder Spieler bekommt,"), + text("der Rundenzahl entsprechend viele Karten (also in Runde 5 -> 5 Karten, in Runde 12 -> 12 Karten...)"), + text("als nächstes gibt jeder Spieler an, wie viele Stiche er diese Runde machen wird"), + text("ACHTUNG! - Die Gesamtzahl aller angesagten Stiche kann nie gleich mit den möglichen Stichen sein."), + text("Anschließend spielt jeder der Reihe nach genau eine Karte."), + text("Hat jeder Spieler genau eine Karte gelegt, beginnt der Gewinner dieses Stichs den nächsten Stich."), + text("Sind alle Karten ausgespielt, werden Stiche mit den Ansagen abgeglichen und entsprechend Punkte verteilt."), + text("\n"), + text("Punkte:").size(20), + text("stimmt die Ansage, kriegt man 20 Punkte Plus die Anzahl an gewonnenen Stichen mal 10 "), + text("also bei 5 angesagten und 5 gewonnenen: 20 + 10*5 = 70 Punkte"), + text("stimmt die Ansage nicht, wird das Zehnfache der Abweichung von den eigenen Punkten abgezogen. "), + text("also bei 5 angesagten und 7 gewonnenen: 2 zu viel -> 2*10 = 20 Punkte Abzug"), + text("\n"), + text("Ende:").size(20), + text(" in der gesamten letzten Runde wird ohne Trumpf gespielt."), + text(" ist Runde 20 zuende gespielt, gewinnt der Spieler mit den meisten Punkten"), + ]; + + let max_h = (state.window_size.height * 0.9) as u32; + let rules_body_scroll = scrollable(rules_body) + .height((max_h as f32 * 0.62) as u32) + .width(iced::Length::Fill); + + stack![ + background_image("assets/background_forall.png"), + menu_panel( + state, + "SPIELREGELN:", + rules_body_scroll.into(), + back_button_footer(state) + ) + ] + .into() +} diff --git a/src/client/views/mod.rs b/src/client/views/mod.rs new file mode 100644 index 0000000..4af654c --- /dev/null +++ b/src/client/views/mod.rs @@ -0,0 +1,23 @@ +mod button; +mod gameplay; +mod lobby; +mod menu; +mod utils; + +pub use button::{Button, ButtonMessage}; + +use iced::Element; + +use super::{App, AppMessage, MenuState}; + +pub fn view(state: &App) -> Element<'_, AppMessage> { + match state.menu { + MenuState::Main => menu::view_main_menu(state), + MenuState::Host => menu::view_host_menu(state), + MenuState::Join => menu::view_join_menu(state), + MenuState::Rules => menu::view_rules_menu(state), + MenuState::Lobby => lobby::view_lobby_menu(state), + MenuState::Playing => gameplay::view_gameplay(state), + MenuState::PlayingTest => gameplay::view_test_gameplay(state), + } +} diff --git a/src/client/views/utils.rs b/src/client/views/utils.rs new file mode 100644 index 0000000..dc3a877 --- /dev/null +++ b/src/client/views/utils.rs @@ -0,0 +1,125 @@ +use std::fs::File; +use std::io::Read; + +use iced::{ + widget::{column, container, row, stack, text, Column, Image}, + ContentFit, Element, Font, +}; + +use crate::client::{App, AppMessage}; + +pub const TITLE_FONT: Font = Font::with_name("Magic School One"); + +use crate::api::{Card, Value}; + +/// Format a card for display (e.g., "5 Red", "Wizard", "Jester"). +pub fn format_card(card: &Card) -> String { + match card.value { + Value::Jester => "Jester".to_string(), + Value::Wizard => "Wizard".to_string(), + Value::Number(n) => format!("{}\n{:?}", n, card.suit), + } +} + +pub fn get_player_name(state: &App, player_id: u64) -> String { + state + .lobby + .as_ref() + .and_then(|lobby| lobby.players.iter().find(|p| p.id == player_id)) + .map(|player| player.name.clone()) + .unwrap_or_else(|| format!("Player {}", player_id)) +} + +/// Read PNG image dimensions from the file header. +pub fn png_dimensions(path: &str) -> Option<(u32, u32)> { + let mut f = File::open(path).ok()?; + let mut buf = [0u8; 24]; + f.read_exact(&mut buf).ok()?; + + if &buf[0..8] != b"\x89PNG\r\n\x1a\n" { + return None; + } + + let width = u32::from_be_bytes([buf[16], buf[17], buf[18], buf[19]]); + let height = u32::from_be_bytes([buf[20], buf[21], buf[22], buf[23]]); + Some((width, height)) +} + +/// Creates a full-screen background with the specified image. +pub fn background_image(path: &'static str) -> Image { + Image::new(path) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .content_fit(ContentFit::Cover) +} + +pub fn menu_panel<'a>( + state: &'a App, + title: &'a str, + body: Element<'a, AppMessage>, + footer: Option>, +) -> Element<'a, AppMessage> { + let (intr_w, intr_h) = png_dimensions("assets/menu_container.png").unwrap_or((560, 440)); + let max_w = (state.window_size.width * 0.9) as u32; + let max_h = (state.window_size.height * 0.9) as u32; + + let scale = ((max_w as f32) / (intr_w as f32).max(1.0)) + .min((max_h as f32) / (intr_h as f32).max(1.0)) + .min(1.0); + + let menu_w = (intr_w as f32 * scale).round() as u32; + let menu_h = (intr_h as f32 * scale).round() as u32; + let title_top_offset = ((menu_h as f32) * 0.11).round().max(12.0) as u32; + let top_extra = ((menu_h as f32) * 0.22).round() as u32; + let inner_w = (menu_w * 85 / 100).max(100); + + let inner = column![ + container(Column::new()).height(top_extra), + body, + footer.unwrap_or_else(|| container(Column::new()).into()), + ] + .spacing(10) + .padding([12.0, 36.0]) + .width(inner_w) + .height(menu_h); + + stack![ + container( + Image::new("assets/menu_container.png") + .width(menu_w) + .height(menu_h) + ) + .center_x(iced::Fill) + .center_y(iced::Fill), + container( + Column::new() + .width(menu_w) + .height(menu_h) + .push(container(Column::new()).height(title_top_offset)) + .push( + container( + text(title) + .size(38) + .font(TITLE_FONT) + .color(iced::Color::from_rgb(0.0, 0.0, 0.0)), + ) + .height(48u32) + .center_x(iced::Fill), + ), + ) + .center_x(iced::Fill), + container(inner).center_x(iced::Fill).center_y(iced::Fill) + ] + .into() +} + +/// Creates a back button footer for menu panels. +pub fn back_button_footer<'a>(state: &'a App) -> Option> { + Some( + container(row![state.btn_back.view().padding(6)]) + .height(56u32) + .width(iced::Length::Fill) + .align_x(iced::alignment::Horizontal::Left) + .into(), + ) +} diff --git a/src/gamelogic/game.rs b/src/gamelogic/game.rs index 2dd67e6..ab9a2b4 100644 --- a/src/gamelogic/game.rs +++ b/src/gamelogic/game.rs @@ -195,7 +195,7 @@ impl Game { } pub fn start(&mut self) -> Result, &'static str> { - if self.players.len() < 3 { + if cfg!(not(feature = "wiz_debug")) && self.players.len() < 3 { return Err("Need more than two players to start a game."); } if self.players.len() > 6 { @@ -316,7 +316,11 @@ fn start_game_with_2_players() { let mut game = Game::new(); let _ = game.add_player(111); let _ = game.add_player(222); - assert!(game.start().is_err()); + if cfg!(not(feature = "wiz_debug")) { + assert!(game.start().is_err()); + } else { + assert!(game.start().is_ok()); + } } #[cfg(test)] diff --git a/src/gameplay_ui/mod.rs b/src/gameplay_ui/mod.rs index fc193da..cfce074 100644 --- a/src/gameplay_ui/mod.rs +++ b/src/gameplay_ui/mod.rs @@ -1 +1,2 @@ +#![allow(dead_code)] pub mod hand; diff --git a/src/main.rs b/src/main.rs index e7a27e3..05ba743 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ // Enable only in release before the final build. // Disables the console window on Windows. -// #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod animation; mod api;