diff --git a/.mailmap b/.mailmap index 9009d10..8ba6eed 100644 --- a/.mailmap +++ b/.mailmap @@ -1,5 +1,6 @@ Andrei Kurochkin Andrei Kurochkin <82461251+end41r@users.noreply.github.com> +Andrei Kurochkin Konrad Kortes Konrad Kortes Konrad Kortes diff --git a/Cargo.toml b/Cargo.toml index c50dbb3..436bc9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "wizard" -version = "0.1.0" -edition = "2021" +version = "1.0.0" +edition = "2024" [dependencies] iced = { version = "0.14", features = ["image", "tokio"] } @@ -17,6 +17,7 @@ derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] } rand = "0.9.2" strum = "0.27" strum_macros = "0.27" +rodio = "0.21.1" [features] wiz_debug = [] \ No newline at end of file diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..6c31da8 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,177 @@ +# DOCUMENTATION + +## Overview + +A digital implementation of the classic [Wizard card game](https://en.wikipedia.org/wiki/Wizard_(card_game)), built in Rust using the Iced UI framework for the client and Axum for the server, designed for playing in a local network. + +--- + +## Building and Running + +### Prerequisites + +- **Cargo, Rust** toolchain (`edition=2024`, the project enforces `1.90.0` via `rust-toolchain.toml`) +- **On Linux:** the `alsa` library for audio must be installed (e.g. `sudo apt install libasound2-dev` on Ubuntu) + +### Clone and Run + +```sh +git clone https://github.com/end41r/wizard.git +cd wizard +cargo build +cargo run +``` + +### Cross-Compilation with `build.sh` + +Primarily for WSL > Windows for better testing experience: + +```sh +./build.sh -t windows # Windows release build +./build.sh -t linux # Linux release build +./build.sh -t windows -f # Windows release build with debug features (wiz_debug) +``` + +The script compiles a release binary and copies both the executable and the `assets/` folder to a directory specified in `DEST_PATH`. + +### Running Tests + +```sh +cargo test +``` + +Unit are located in `src/gamelogic/game.rs` (UI testing was done differently). + +--- + +## Implemented Features and How to Discover Them + +### Main Menu + +When the application starts, the main menu is displayed with four buttons: + +| Button | Action | +| -------- | -------- | +| **Host** | Opens the host menu to create a new game lobby | +| **Beitreten** | Opens the join menu to connect to an existing lobby | +| **Optionen** | Opens audio settings | +| **Spiel Verlassen** | Exits the application | + +### Hosting a Game + +1. Click **Host** in the main menu. +2. Enter your player name. +3. Select the player count (3–6 players). +4. Click **Lobby Erstellen** — this starts the WebSocket server on port `3000` and puts you in the lobby. +5. Share your IP address (shown in the lobby) with the other players. + +### Joining a Game + +1. Click **Beitreten** in the main menu. +2. Enter your player name and the host's IP address. +3. Click **Verbinden** to join the lobby. + +### Lobby + +- All connected players are listed with a **Bereit | Nicht Bereit** toggle. +- The lobby includes a chat where players can send messages. +- The game starts when all players are ready and the host presses **Starten**. + +### Rules + +The game follows the official Wizard card game rules. A full in-game rules screen is accessible via the **Spielregeln** button in **Optionen**. In brief: + +#### Gameplay UI + +- **Card hand:** Cards that are displayed at the bottom, with a right click you can also see which are playable. +- **Game table:** Shows other players' avatars, the current trick's cards in the middle, if you click the trick's cards you can toggle the view of all played cards in the trick, by just hovering you can temporarily see the played cards in the trick. +- **Scoreboard:** Displayed on the side, shows each player's current score, bid, and tricks won. The scoreboard also provides trump-suit selection buttons when the dealer must set trump. +- **Avatars:** Each player has an animated avatar (Elf, Knight, Mage, or Witch) with idle and casting animations. Casting is triggered when a player plays acard. Each avatar type has unique sound effects upon clicking the avatar. +- **Shards:** Float around the avatar of each player, indicating the amount of cards left in their hand. + +### Audio + +- **Background music** plays on the menu, in the lobby, and during gameplay (different tracks for each). +- **Sound effects** for: button clicks, card hover, card shuffle, card deal, card play, card error, shard play, avatar-specific casting and click sounds, and game over. +- **Volume control:** Music and SFX volume can be adjusted via sliders in the **Optionen** menu. + +### Easter Egg + +If the host enters the name `wizard_master`, the game launches in a **debug gameplay view** — a text-based, scrollable interface showing all game states (round, trump, bids, hand, tricks, scores, log) with minimal styling. This is located in `src/client/views/debug_gameplay.rs`. + +### Debug Build Feature + +Compiling with the `wiz_debug` Cargo feature (`cargo run --features wiz_debug`) enables: + +- The Windows console window in release builds (for logging output). +- Better testing conditions (you can start with 1 player without waiting for everybody to get ready). + +### Cross-Platform + +The application runs on **Linux**, **Windows**, and **macOS**. + +--- + +## Architecture + +### Client ↔ Server Model + +- `src/server.rs`: An Axum + Tokio WebSocket server. Manages player connections, lobby state, game state, and event broadcasting. Binds to `0.0.0.0:3000`. +- `src/client/`: An Iced GUI application. Handles user input, animations, rendering, audio playback, and communicates with the server via WebSockets. + +### Core Modules + +- `src/main.rs` : Entry point; launches the Iced client +- `src/api.rs` : Shared types, protocol messages (`C`, `S`, `B`), card/suit/value definitions, avatar types, lobby/player structs +- `src/server.rs` : Axum WebSocket server, game event dispatching, lobby management +- `src/gamelogic/` : Core game logic — `Game` (overall game flow) and `Round` (per-round state, bidding, trick resolution, scoring) +- `src/client/mod.rs` : Client application state (`App`), message enum, initialization +- `src/client/update.rs` : Message handling and state transitions +- `src/client/ws.rs` : WebSocket connection management +- `src/client/audio.rs` : Music and SFX playback via `rodio` +- `src/client/views/` : UI views: main menu, host menu, join menu, options, lobby, rules, debug gameplay +- `src/gameplay_ui/` : Game-table UI: hand display, card rendering, avatars, scoreboard, table layout +- `src/animation.rs` : Animation framework with easing functions (basic, auto-reversing, circular animations) +- `src/ui_element_traits.rs` : Shared traits for UI elements (Viewable, Animated, Resizable, etc.) + +### Main Dependencies + +- `iced` : UI framework (with image and tokio features) +- `axum` : Web server framework with WebSocket support +- `tokio` : Async runtime +- `tokio-tungstenite` : WebSocket client implementation +- `serde` / `serde_json` : Serialization and deserialization of protocol messages +- `rodio` : Audio playback (music and sound effects) + +--- + +## Testing Method + +### Approach + +The project uses Unit tests for the core game logic module (`src/gamelogic/game.rs`). This is the only module we could write tests for. + +### UI tests + +- UI testing was done manually by running the application and verifying that all features work as intended through hours of playing together / alone, because iced doesnt provide a nice way to test it's elements. + +--- + +## AI / LLM Usage + +- **Easing functions** (`src/animation.rs`): Claude.ai generated the mathematical logic for easing functions (`ease_in_cubic`, `ease_out_cubic`, `ease_in_out_cubic`, `ease_in_sine`, `ease_out_sine`, `ease_in_out_sine`, `ease_out_elastic`, `ease_out_bounce`). +- **Animation macros** (`src/animation.rs`): Claude.ai helped to learn how to write Rust macros and partially generated the `impl_animation_common!` macro and trait bound patterns. +- **Scoreboard functions** (`src/gameplay_ui/scoreboard.rs`): AI helped to write the `sorted_player_order_by_score` function and the scoreboard view placement logic. +- **Pixel art rendering** (`src/gameplay_ui/table/avatar.rs`): Claude helped to learn how to use `filter_method` to achieve non-blurred pixel art scaling. +- **Hand generics** (`src/gameplay_ui/hand/mod.rs`): Claude.ai suggested passing a union type for generic hand card handling and helped to learn `Vec::contains` usage. +- **Server WebSocket handler** (`src/server.rs`): Claude Opus helped implement the WebSocket connection handler. + +- **Array filtering** (`src/client/mod.rs`): Gemini helped to learn how to pass an array into a function and filter it. + +### AI-Generated Images + +The following image assets were created with AI assistance: + +- `background_forall.png`, `ingame_background.png`, `menu_container.png` +- `wizard_lobby_menu.png`, `wizard_main_menu*.png` +- `button1.png`, `Menu_Button.png` diff --git a/README.md b/README.md index 7e85050..c1985df 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,37 @@ # wizard -A Wizard Card game ( https://en.wikipedia.org/wiki/Wizard_(card_game) ) based on the Iced UI Framework to play in a local network with friends +A Wizard Card game () based on the Iced UI Framework to play in a local network with friends + +## To get the dependencies run -## To get the dependencies run `cargo build` ## Building for windows + Uncomment a line at the top of main to remove the stdout terminal for a better experience ## Running + `cargo run` ## Constraints + Please refer to REQUIREMENTS.md 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"` (commit your changes beforehand)) - 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. ### build.sh usage + - Allows building release executable files for windows/linux/mac with `./build.sh -t `, also allows to build with a `wiz_debug` feature flag with `./build.sh -t -f` - To build you would need to install targets first - To correctly copy the assets, you would need to run the script from the root of the project -- The script copies the needed files as a release in your specified folder (in `DEST_PATH`) \ No newline at end of file +- The script copies the needed files as a release in your specified folder (in `DEST_PATH`) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 0e43854..888ec17 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,12 +1,14 @@ # Rust SEP Winter Term 25/26 – Final Project ## Requirements + **Requirements written in bold are mandatory but not sufficient to pass.** Other requirements should be fulfilled for a 1.0, but are also not sufficient. --- ## Git + - [ ] **(G1) The submitted project is a Git repository.** - [ ] **(G2) The main branch contains the final project.** - [ ] **(G3) Collaborative development was continuously done via Git (commit history from December to February).** @@ -21,7 +23,8 @@ Other requirements should be fulfilled for a 1.0, but are also not sufficient. --- ## Code -- [ ] **(C1) The project must be compilable with rustc stable 1.85.0 (edition=2024).** + +- [ ] **(C1) The project must be compilable with rustc stable 1.85.0 (edition=2024).** - [ ] **(C2) The project is executable.** - If the project is runnable via `cargo run`, it’s fine. - If depending on non–crates.io dependencies, a devcontainer must include all required components. @@ -40,6 +43,7 @@ Other requirements should be fulfilled for a 1.0, but are also not sufficient. --- ## Functional + - [ ] **(F1) The project was outlined in the pitch presentation.** - [ ] **(F2) The final submission resembles the pitched project.** - [ ] **(F3) The project must allow user interaction (usable without writing code).** @@ -48,10 +52,11 @@ Other requirements should be fulfilled for a 1.0, but are also not sufficient. --- ## Documentation + - [ ] **(D1) The documentation contains all steps needed to run the project.** - [ ] **(D2) The documentation lists all implemented features and how to discover them.** - [ ] **(D3) The logbook is submitted as a separate PDF on Moodle.** - [ ] **(D4) IFI Rule: State all used AI/LLMs and their purpose.** - [ ] (D5) The documentation contains proof of any requirement not proven by code or repo. -- [ ] (D6) The documentation describes and justifies the testing method. \ No newline at end of file +- [ ] (D6) The documentation describes and justifies the testing method. diff --git a/assets/audio/sfx_card_dealed.mp3 b/assets/audio/sfx_card_dealed.mp3 new file mode 100644 index 0000000..b7a19f7 Binary files /dev/null and b/assets/audio/sfx_card_dealed.mp3 differ diff --git a/assets/audio/sfx_card_error.mp3 b/assets/audio/sfx_card_error.mp3 new file mode 100644 index 0000000..202ded0 Binary files /dev/null and b/assets/audio/sfx_card_error.mp3 differ diff --git a/assets/audio/sfx_card_hovered.mp3 b/assets/audio/sfx_card_hovered.mp3 new file mode 100644 index 0000000..25d97b1 Binary files /dev/null and b/assets/audio/sfx_card_hovered.mp3 differ diff --git a/assets/audio/sfx_card_played.mp3 b/assets/audio/sfx_card_played.mp3 new file mode 100644 index 0000000..e6ef2fb Binary files /dev/null and b/assets/audio/sfx_card_played.mp3 differ diff --git a/assets/audio/sfx_card_shuffle.mp3 b/assets/audio/sfx_card_shuffle.mp3 new file mode 100644 index 0000000..29f1db2 Binary files /dev/null and b/assets/audio/sfx_card_shuffle.mp3 differ diff --git a/assets/audio/sfx_click.mp3 b/assets/audio/sfx_click.mp3 new file mode 100644 index 0000000..e7cf0d1 Binary files /dev/null and b/assets/audio/sfx_click.mp3 differ diff --git a/assets/audio/sfx_elf_cast.mp3 b/assets/audio/sfx_elf_cast.mp3 new file mode 100644 index 0000000..bd77dd3 Binary files /dev/null and b/assets/audio/sfx_elf_cast.mp3 differ diff --git a/assets/audio/sfx_elf_clicked.mp3 b/assets/audio/sfx_elf_clicked.mp3 new file mode 100644 index 0000000..8126a07 Binary files /dev/null and b/assets/audio/sfx_elf_clicked.mp3 differ diff --git a/assets/audio/sfx_game_over.mp3 b/assets/audio/sfx_game_over.mp3 new file mode 100644 index 0000000..ea1e4f7 Binary files /dev/null and b/assets/audio/sfx_game_over.mp3 differ diff --git a/assets/audio/sfx_knight_cast.mp3 b/assets/audio/sfx_knight_cast.mp3 new file mode 100644 index 0000000..bb0e2b7 Binary files /dev/null and b/assets/audio/sfx_knight_cast.mp3 differ diff --git a/assets/audio/sfx_knight_clicked.mp3 b/assets/audio/sfx_knight_clicked.mp3 new file mode 100644 index 0000000..06804cb Binary files /dev/null and b/assets/audio/sfx_knight_clicked.mp3 differ diff --git a/assets/audio/sfx_mage_cast.mp3 b/assets/audio/sfx_mage_cast.mp3 new file mode 100644 index 0000000..e1910df Binary files /dev/null and b/assets/audio/sfx_mage_cast.mp3 differ diff --git a/assets/audio/sfx_mage_clicked.mp3 b/assets/audio/sfx_mage_clicked.mp3 new file mode 100644 index 0000000..1ca0e70 Binary files /dev/null and b/assets/audio/sfx_mage_clicked.mp3 differ diff --git a/assets/audio/sfx_shard_played.mp3 b/assets/audio/sfx_shard_played.mp3 new file mode 100644 index 0000000..e8fa29f Binary files /dev/null and b/assets/audio/sfx_shard_played.mp3 differ diff --git a/assets/audio/sfx_witch_cast.mp3 b/assets/audio/sfx_witch_cast.mp3 new file mode 100644 index 0000000..c7366e6 Binary files /dev/null and b/assets/audio/sfx_witch_cast.mp3 differ diff --git a/assets/audio/sfx_witch_clicked.mp3 b/assets/audio/sfx_witch_clicked.mp3 new file mode 100644 index 0000000..ceb655c Binary files /dev/null and b/assets/audio/sfx_witch_clicked.mp3 differ diff --git a/assets/audio/wizard_black_shores.mp3 b/assets/audio/wizard_black_shores.mp3 new file mode 100644 index 0000000..1aa6dde Binary files /dev/null and b/assets/audio/wizard_black_shores.mp3 differ diff --git a/assets/audio/wizard_clash_of_mages.mp3 b/assets/audio/wizard_clash_of_mages.mp3 new file mode 100644 index 0000000..3e9ec82 Binary files /dev/null and b/assets/audio/wizard_clash_of_mages.mp3 differ diff --git a/assets/audio/wizard_peaceful.mp3 b/assets/audio/wizard_peaceful.mp3 new file mode 100644 index 0000000..4a6ffd1 Binary files /dev/null and b/assets/audio/wizard_peaceful.mp3 differ diff --git a/assets/avatars/avatar_frame_idle.png b/assets/avatars/avatar_frame_idle.png new file mode 100644 index 0000000..15cb171 Binary files /dev/null and b/assets/avatars/avatar_frame_idle.png differ diff --git a/assets/avatars/avatar_frame_turn.png b/assets/avatars/avatar_frame_turn.png new file mode 100644 index 0000000..afbcb8f Binary files /dev/null and b/assets/avatars/avatar_frame_turn.png differ diff --git a/assets/avatars/elf/elf_casting1.png b/assets/avatars/elf/elf_casting1.png new file mode 100644 index 0000000..c9f36f2 Binary files /dev/null and b/assets/avatars/elf/elf_casting1.png differ diff --git a/assets/avatars/elf/elf_casting2.png b/assets/avatars/elf/elf_casting2.png new file mode 100644 index 0000000..a3ee4d3 Binary files /dev/null and b/assets/avatars/elf/elf_casting2.png differ diff --git a/assets/avatars/elf/elf_standing1.png b/assets/avatars/elf/elf_standing1.png new file mode 100644 index 0000000..e8d3ef7 Binary files /dev/null and b/assets/avatars/elf/elf_standing1.png differ diff --git a/assets/avatars/elf/elf_standing2.png b/assets/avatars/elf/elf_standing2.png new file mode 100644 index 0000000..4531336 Binary files /dev/null and b/assets/avatars/elf/elf_standing2.png differ diff --git a/assets/avatars/elf/shard.png b/assets/avatars/elf/shard.png new file mode 100644 index 0000000..d351712 Binary files /dev/null and b/assets/avatars/elf/shard.png differ diff --git a/assets/avatars/knight/knight_casting1.png b/assets/avatars/knight/knight_casting1.png new file mode 100644 index 0000000..03012c2 Binary files /dev/null and b/assets/avatars/knight/knight_casting1.png differ diff --git a/assets/avatars/knight/knight_casting2.png b/assets/avatars/knight/knight_casting2.png new file mode 100644 index 0000000..d12fd3e Binary files /dev/null and b/assets/avatars/knight/knight_casting2.png differ diff --git a/assets/avatars/knight/knight_standing1.png b/assets/avatars/knight/knight_standing1.png new file mode 100644 index 0000000..4b86196 Binary files /dev/null and b/assets/avatars/knight/knight_standing1.png differ diff --git a/assets/avatars/knight/knight_standing2.png b/assets/avatars/knight/knight_standing2.png new file mode 100644 index 0000000..db4ec3e Binary files /dev/null and b/assets/avatars/knight/knight_standing2.png differ diff --git a/assets/avatars/knight/shard.png b/assets/avatars/knight/shard.png new file mode 100644 index 0000000..cbe1046 Binary files /dev/null and b/assets/avatars/knight/shard.png differ diff --git a/assets/avatars/mage/mage_casting1.png b/assets/avatars/mage/mage_casting1.png new file mode 100644 index 0000000..8d98287 Binary files /dev/null and b/assets/avatars/mage/mage_casting1.png differ diff --git a/assets/avatars/mage/mage_casting2.png b/assets/avatars/mage/mage_casting2.png new file mode 100644 index 0000000..f554c30 Binary files /dev/null and b/assets/avatars/mage/mage_casting2.png differ diff --git a/assets/avatars/mage/mage_standing1.png b/assets/avatars/mage/mage_standing1.png new file mode 100644 index 0000000..c9574b0 Binary files /dev/null and b/assets/avatars/mage/mage_standing1.png differ diff --git a/assets/avatars/mage/mage_standing2.png b/assets/avatars/mage/mage_standing2.png new file mode 100644 index 0000000..9dac667 Binary files /dev/null and b/assets/avatars/mage/mage_standing2.png differ diff --git a/assets/avatars/mage/shard.png b/assets/avatars/mage/shard.png new file mode 100644 index 0000000..2ef22b5 Binary files /dev/null and b/assets/avatars/mage/shard.png differ diff --git a/assets/avatars/witch/shard.png b/assets/avatars/witch/shard.png new file mode 100644 index 0000000..554c2d5 Binary files /dev/null and b/assets/avatars/witch/shard.png differ diff --git a/assets/avatars/witch/witch_casting1.png b/assets/avatars/witch/witch_casting1.png new file mode 100644 index 0000000..01c8573 Binary files /dev/null and b/assets/avatars/witch/witch_casting1.png differ diff --git a/assets/avatars/witch/witch_casting2.png b/assets/avatars/witch/witch_casting2.png new file mode 100644 index 0000000..eebca2d Binary files /dev/null and b/assets/avatars/witch/witch_casting2.png differ diff --git a/assets/avatars/witch/witch_standing1.png b/assets/avatars/witch/witch_standing1.png new file mode 100644 index 0000000..8e4ff4c Binary files /dev/null and b/assets/avatars/witch/witch_standing1.png differ diff --git a/assets/avatars/witch/witch_standing2.png b/assets/avatars/witch/witch_standing2.png new file mode 100644 index 0000000..fb0179e Binary files /dev/null and b/assets/avatars/witch/witch_standing2.png differ diff --git a/assets/black_screen.png b/assets/black_screen.png new file mode 100644 index 0000000..78bd0ab Binary files /dev/null and b/assets/black_screen.png differ diff --git a/assets/menu/MagicSchoolOne.ttf b/assets/menu/MagicSchoolOne.ttf new file mode 100644 index 0000000..0fa7ad1 Binary files /dev/null and b/assets/menu/MagicSchoolOne.ttf differ diff --git a/assets/menu/Menu_Button.png b/assets/menu/Menu_Button.png new file mode 100644 index 0000000..baa0e3b Binary files /dev/null and b/assets/menu/Menu_Button.png differ diff --git a/assets/menu/background_forall.png b/assets/menu/background_forall.png new file mode 100644 index 0000000..0b75f5f Binary files /dev/null and b/assets/menu/background_forall.png differ diff --git a/assets/menu/button1.png b/assets/menu/button1.png new file mode 100644 index 0000000..d3f3675 Binary files /dev/null and b/assets/menu/button1.png differ diff --git a/assets/menu/ingame_background.png b/assets/menu/ingame_background.png new file mode 100644 index 0000000..6565faf Binary files /dev/null and b/assets/menu/ingame_background.png differ diff --git a/assets/menu/menu_container.png b/assets/menu/menu_container.png new file mode 100644 index 0000000..1722b72 Binary files /dev/null and b/assets/menu/menu_container.png differ diff --git a/assets/menu/wizard_lobby_menu.png b/assets/menu/wizard_lobby_menu.png new file mode 100644 index 0000000..cb01535 Binary files /dev/null and b/assets/menu/wizard_lobby_menu.png differ diff --git a/assets/menu/wizard_main_menu.png b/assets/menu/wizard_main_menu.png new file mode 100644 index 0000000..49989cb Binary files /dev/null and b/assets/menu/wizard_main_menu.png differ diff --git a/assets/menu/wizard_main_menu2.png b/assets/menu/wizard_main_menu2.png new file mode 100644 index 0000000..05d1ad1 Binary files /dev/null and b/assets/menu/wizard_main_menu2.png differ diff --git a/assets/suits/blue.png b/assets/suits/blue.png new file mode 100644 index 0000000..59ca12a Binary files /dev/null and b/assets/suits/blue.png differ diff --git a/assets/suits/green.png b/assets/suits/green.png new file mode 100644 index 0000000..6d03ff9 Binary files /dev/null and b/assets/suits/green.png differ diff --git a/assets/suits/red.png b/assets/suits/red.png new file mode 100644 index 0000000..162265c Binary files /dev/null and b/assets/suits/red.png differ diff --git a/assets/suits/yellow.png b/assets/suits/yellow.png new file mode 100644 index 0000000..7e3b182 Binary files /dev/null and b/assets/suits/yellow.png differ diff --git a/build.sh b/build.sh index f8edeab..61a17cf 100755 --- a/build.sh +++ b/build.sh @@ -4,6 +4,7 @@ TARGET="linux" DEST_PATH="/mnt/c/Users/capos/Downloads" BUILD_TYPE="release" FEATURES="" +EXE_EXT="" while getopts "t:fh" opt; do case $opt in @@ -35,11 +36,9 @@ case $TARGET in ;; linux) TARGET_NAME="x86_64-unknown-linux-gnu" - EXE_EXT="" ;; macos) TARGET_NAME="aarch64-apple-darwin" - EXE_EXT="" ;; *) echo "Unknown target: $TARGET" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..31d6f72 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.90.0" +components = ["rustfmt", "clippy"] +profile = "minimal" \ No newline at end of file diff --git a/src/animation.rs b/src/animation.rs index 5d9a822..423b111 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -138,7 +138,7 @@ pub fn ease_out_bounce(v: f32) -> f32 { #[derive(Clone, Debug)] pub struct AnimationCore { - max_frame_number: usize, + after_max_frame_number: usize, current_frame_number: usize, animation_state: AnimationState, on_start: Option, @@ -148,7 +148,7 @@ pub struct AnimationCore { impl AnimationCore { fn new(duration: usize) -> Self { Self { - max_frame_number: duration, + after_max_frame_number: duration, current_frame_number: 0, animation_state: AnimationState::NotMoving, on_start: None, @@ -173,10 +173,10 @@ impl AnimationCore { /// This function represents the progress of the animation ranging from 0.0 to 1.0. /// Choose an easing type to manipulate the look of the animation to your liking. pub fn progress(&self, curve: Easing) -> f32 { - let progress: f32 = if self.max_frame_number == 0 { + let progress: f32 = if self.after_max_frame_number == 0 { 1.0 } else { - self.current_frame_number as f32 / self.max_frame_number as f32 + self.current_frame_number as f32 / self.after_max_frame_number as f32 }; match curve { Easing::Linear => progress, @@ -190,15 +190,21 @@ impl AnimationCore { Easing::OutBounce => ease_out_bounce(progress), } } - #[allow(dead_code)] pub fn current_frame_number(&self) -> usize { self.current_frame_number } + pub fn max_frame_number(&self) -> usize { + if self.after_max_frame_number == 0 { + 0 + } else { + self.after_max_frame_number - 1 + } + } /// Message sent when an animation reaches an end point - pub fn on_end(&mut self, msg: AppMessage) { + pub fn on_end_reached(&mut self, msg: AppMessage) { self.on_end = Some(msg) } - pub fn on_start(&mut self, msg: AppMessage) { + pub fn on_start_reached(&mut self, msg: AppMessage) { self.on_start = Some(msg) } /// Send a message when an animation reaches a reverse point or end if not reversing. @@ -210,7 +216,7 @@ impl AnimationCore { } /// Send a message everytime when an animation returns to start. /// This won't trigger when the animation is started. - fn start_task(&self) -> Task { + fn start_reached_task(&self) -> Task { match &self.on_start { Some(msg) => Task::done(msg.clone()), None => Task::none(), @@ -224,7 +230,6 @@ macro_rules! new_core { ($name:ident) => { impl $name { // This is marked as not used because CircularAnimation is as of now not used. - #[allow(dead_code)] pub fn new(duration: usize) -> Self { Self(AnimationCore::new(duration)) } @@ -243,7 +248,7 @@ pub struct BasicAnimation(AnimationCore); impl BasicAnimation { pub fn next_frame(&mut self) -> Task { if self.animation_state == AnimationState::MovingForward { - if self.current_frame_number < self.max_frame_number { + if self.current_frame_number < self.after_max_frame_number { self.current_frame_number += 1; } else { self.animation_state = AnimationState::Ended; @@ -254,15 +259,18 @@ impl BasicAnimation { } } -#[allow(dead_code)] #[derive(Clone, Debug, Deref, DerefMut)] pub struct CircularAnimation(AnimationCore); impl CircularAnimation { - #[allow(dead_code)] + pub fn start_infinite(&mut self) { + self.animation_state = AnimationState::MovingForward; + } pub fn next_frame(&mut self) -> Task { - if self.animation_state == AnimationState::MovingForward && self.max_frame_number != 0 { + if self.animation_state == AnimationState::MovingForward && self.after_max_frame_number != 0 + { let frame_number_before = self.current_frame_number; - self.current_frame_number = (self.current_frame_number + 1) % self.max_frame_number; + self.current_frame_number = + (self.current_frame_number + 1) % self.after_max_frame_number; let frame_number_after = self.current_frame_number; if frame_number_before >= frame_number_after { return self.end_task(); @@ -280,13 +288,13 @@ impl ReversableBasicAnimation { } #[allow(dead_code)] pub fn reverse_force(&mut self) { - self.current_frame_number = self.max_frame_number; + self.current_frame_number = self.after_max_frame_number; self.animation_state = AnimationState::Reversing; } pub fn next_frame(&mut self) -> Task { match self.animation_state { AnimationState::MovingForward => { - if self.current_frame_number < self.max_frame_number { + if self.current_frame_number < self.after_max_frame_number { self.current_frame_number += 1; } else { self.animation_state = AnimationState::NotMoving; @@ -298,7 +306,7 @@ impl ReversableBasicAnimation { self.current_frame_number -= 1; } else { self.animation_state = AnimationState::Ended; - return self.start_task(); + return self.start_reached_task(); } } _ => {} @@ -313,7 +321,7 @@ impl AutoReversingAnimation { pub fn next_frame(&mut self) -> Task { match self.animation_state { AnimationState::MovingForward => { - if self.current_frame_number < self.max_frame_number { + if self.current_frame_number < self.after_max_frame_number { self.current_frame_number += 1; } else { self.animation_state = AnimationState::Reversing; @@ -324,8 +332,9 @@ impl AutoReversingAnimation { if self.current_frame_number > 0 { self.current_frame_number -= 1; } else { - self.reset(); - return self.start_task(); + self.current_frame_number = 0; + self.animation_state = AnimationState::NotMoving; + return self.start_reached_task(); } } _ => {} @@ -337,10 +346,13 @@ impl AutoReversingAnimation { #[derive(Clone, Debug, Deref, DerefMut)] pub struct CircularAutoReversingAnimation(AnimationCore); impl CircularAutoReversingAnimation { + pub fn start_infinite(&mut self) { + self.animation_state = AnimationState::MovingForward; + } pub fn next_frame(&mut self) -> Task { match self.animation_state { AnimationState::MovingForward => { - if self.current_frame_number < self.max_frame_number { + if self.current_frame_number < self.after_max_frame_number { self.current_frame_number += 1; } else { self.animation_state = AnimationState::Reversing; @@ -352,7 +364,7 @@ impl CircularAutoReversingAnimation { self.current_frame_number -= 1; } else { self.animation_state = AnimationState::MovingForward; - return self.start_task(); + return self.start_reached_task(); } } _ => {} @@ -387,9 +399,10 @@ impl AnimationStarter Task { self.times = times; self.state = AnimationState::MovingForward; + Task::none() } pub fn next_frame(&mut self) -> Task { if self.state == AnimationState::MovingForward { @@ -410,27 +423,26 @@ impl AnimationStarter bool { self.tick.is_multiple_of(self.animation_delay) + && self.tick <= self.animation_delay * self.times } fn check_all_ended(&self) -> bool { (self.tick == self.animation_delay * self.times + self.animation_length) || (self.times == 0) } fn all_ended(&mut self) -> Task { - let task: Task = match &self.on_all_ended { + let end_task: Task = match &self.on_all_ended { Some(msg) => Task::done(msg.convert_msg()), None => Task::none(), }; self.state = AnimationState::NotMoving; self.tick = 0; self.started = 0; - task + end_task } fn start_single(&mut self) -> Task { self.started += 1; - Task::done( - self.on_start_single - .replace_usize(self.started - 1) - .convert_msg(), - ) + self.on_start_single + .replace_usize(self.started - 1) + .convert_msg_to_task() } } diff --git a/src/api.rs b/src/api.rs index 63c0436..4f5dbc7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,10 +12,21 @@ pub static FALSE_PLAYED_PATH: &str = "assets/cards/false_played.png"; pub type PlayerId = u64; pub type SessionId = u64; +pub trait TextColor { + fn white(self) -> Self; +} + +impl<'a> TextColor for iced::widget::Text<'a> { + fn white(self) -> Self { + self.color(iced::Color::WHITE) + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub enum ServerMessage { Server(S), Broadcast(B), + ConnectionClosed, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -130,6 +141,175 @@ pub enum B { ServerShutdown, } +#[derive(Clone, Debug)] +pub struct Avatar { + kind: AvatarKind, + pose: AvatarPose, + casting_finished: bool, + continue_casting: bool, +} + +impl Avatar { + pub fn new(kind: AvatarKind) -> Self { + Self { + kind, + pose: AvatarPose::Standing1, + casting_finished: false, + continue_casting: false, + } + } + pub fn kind(&self) -> AvatarKind { + self.kind + } + pub fn pose(&self) -> AvatarPose { + self.pose + } + pub fn next_pose(&mut self) { + match self.pose { + AvatarPose::Standing1 => { + if self.continue_casting { + self.pose = AvatarPose::Casting1 + } else { + self.pose = AvatarPose::Standing2 + } + } + AvatarPose::Standing2 => { + if self.continue_casting { + self.pose = AvatarPose::Casting1 + } else { + self.pose = AvatarPose::Standing1 + } + } + AvatarPose::Casting1 => { + if self.continue_casting && !self.casting_finished { + self.pose = AvatarPose::Casting2 + } else { + self.pose = AvatarPose::Standing1; + self.casting_finished = false; + self.continue_casting = false; + } + } + AvatarPose::Casting2 => { + self.pose = AvatarPose::Casting1; + self.casting_finished = true; + } + } + } + pub fn start_casting(&mut self) { + self.continue_casting = true; + } + pub fn is_casting(&self) -> bool { + self.continue_casting + } + pub fn img_path(&self) -> String { + let mut path: String = "assets/avatars/".to_owned(); + match &self.kind { + AvatarKind::Elf => { + path.push_str("elf/elf_"); + } + AvatarKind::Knight => { + path.push_str("knight/knight_"); + } + AvatarKind::Mage => { + path.push_str("mage/mage_"); + } + AvatarKind::Witch => { + path.push_str("witch/witch_"); + } + } + match &self.pose { + AvatarPose::Standing1 => { + path.push_str("standing1"); + } + AvatarPose::Standing2 => { + path.push_str("standing2"); + } + AvatarPose::Casting1 => { + path.push_str("casting1"); + } + AvatarPose::Casting2 => { + path.push_str("casting2"); + } + } + path.push_str(".png"); + path + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum AvatarKind { + Elf, + Knight, + Mage, + Witch, +} + +impl AvatarKind { + pub fn as_avatar(&self) -> Avatar { + Avatar::new(*self) + } + pub fn img_path(&self, pose: AvatarPose) -> String { + let mut path: String = "assets/avatars/".to_owned(); + match &self { + AvatarKind::Elf => { + path.push_str("elf/elf_"); + } + AvatarKind::Knight => { + path.push_str("knight/knight_"); + } + AvatarKind::Mage => { + path.push_str("mage/mage_"); + } + AvatarKind::Witch => { + path.push_str("witch/witch_"); + } + } + match pose { + AvatarPose::Standing1 => { + path.push_str("standing1"); + } + AvatarPose::Standing2 => { + path.push_str("standing2"); + } + AvatarPose::Casting1 => { + path.push_str("casting1"); + } + AvatarPose::Casting2 => { + path.push_str("casting2"); + } + } + path.push_str(".png"); + path + } + pub fn shard_path(&self) -> String { + let mut path: String = "assets/avatars/".to_owned(); + match &self { + AvatarKind::Elf => { + path.push_str("elf/"); + } + AvatarKind::Knight => { + path.push_str("knight/"); + } + AvatarKind::Mage => { + path.push_str("mage/"); + } + AvatarKind::Witch => { + path.push_str("witch/"); + } + } + path.push_str("shard.png"); + path + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum AvatarPose { + Standing1, + Standing2, + Casting1, + Casting2, +} + #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Hash)] pub struct Card { pub value: Value, @@ -140,6 +320,64 @@ impl Card { pub fn new(suit: Suit, value: Value) -> Self { Self { suit, value } } + + pub fn img_path(&self) -> String { + let mut path: String = "assets/cards/variations/".to_owned(); + if self.value == Value::Jester { + path.push_str("jester"); + } else if self.value == Value::Wizard { + path.push_str("wizard"); + } else { + match self.suit { + Suit::Blue => { + path.push_str("blue_"); + } + Suit::Green => { + path.push_str("green_"); + } + Suit::Red => { + path.push_str("red_"); + } + Suit::Yellow => { + path.push_str("yellow_"); + } + } + if let Value::Number(number) = self.value { + path.push_str(number.to_string().as_str()); + } + } + path.push_str(".png"); + path + } + + pub fn glow_path(&self) -> String { + if self.value == Value::Jester { + // glow card will treat this as no existant glow yet + "".to_string() + } else if self.value == Value::Wizard { + // glow card will treat this as a existant glow bu won't find the image, + // so glow is invisible + "NOT VALID".to_string() + } else { + let mut path: String = "assets/cards/".to_owned(); + match self.suit { + Suit::Blue => { + path.push_str("glow_blue"); + } + Suit::Green => { + path.push_str("glow_green"); + } + Suit::Red => { + path.push_str("glow_red"); + } + Suit::Yellow => { + path.push_str("glow_yellow"); + } + } + path.push_str(".png"); + path + } + } } #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Hash, EnumIter)] @@ -162,6 +400,7 @@ pub enum Value { pub struct Player { pub id: PlayerId, pub name: String, + pub avatar: AvatarKind, pub ready: bool, pub is_host: bool, } @@ -171,56 +410,3 @@ pub struct Lobby { /// Contains tuples of (sender, message). pub chat: Vec<(String, String)>, } - -pub fn get_card_path(card: Card) -> String { - let mut path: String = "assets/cards/variations/".to_owned(); - if card.value == Value::Jester { - path.push_str("jester"); - } else if card.value == Value::Wizard { - path.push_str("wizard"); - } else { - match card.suit { - Suit::Blue => { - path.push_str("blue_"); - } - Suit::Green => { - path.push_str("green_"); - } - Suit::Red => { - path.push_str("red_"); - } - Suit::Yellow => { - path.push_str("yellow_"); - } - } - if let Value::Number(number) = card.value { - path.push_str(number.to_string().as_str()); - } - } - path.push_str(".png"); - path -} - -pub fn get_glow_path(card: Card) -> String { - let mut path: String = "assets/cards/".to_owned(); - if card.value == Value::Jester || card.value == Value::Wizard { - path.push_str(""); - } else { - match card.suit { - Suit::Blue => { - path.push_str("glow_blue"); - } - Suit::Green => { - path.push_str("glow_green"); - } - Suit::Red => { - path.push_str("glow_red"); - } - Suit::Yellow => { - path.push_str("glow_yellow"); - } - } - } - path.push_str(".png"); - path -} diff --git a/src/client/audio.rs b/src/client/audio.rs new file mode 100644 index 0000000..2de7dab --- /dev/null +++ b/src/client/audio.rs @@ -0,0 +1,140 @@ +use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source}; +use std::{ + collections::HashMap, error::Error, fs::File, io::Cursor, io::Read, path::PathBuf, sync::Arc, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Music { + Menu, + Lobby, + InGame, +} + +#[derive(Clone, Debug)] +pub enum Sfx { + Click, + GameOver, + CardHover, + CardShuffle, + CardDeal, + CardPlay, + CardError, + ShardPlay, + CastMage, + CastWitch, + CastElf, + CastKnight, + ClickedMage, + ClickedWitch, + ClickedElf, + ClickedKnight, +} + +impl Sfx { + pub fn key(&self) -> &'static str { + match self { + Sfx::Click => "click", + Sfx::GameOver => "game_over", + Sfx::CardHover => "card_hovered", + Sfx::CardShuffle => "card_shuffle", + Sfx::CardDeal => "card_dealed", + Sfx::CardPlay => "card_played", + Sfx::CardError => "card_error", + Sfx::ShardPlay => "shard_played", + Sfx::CastMage => "mage_cast", + Sfx::CastWitch => "witch_cast", + Sfx::CastElf => "elf_cast", + Sfx::CastKnight => "knight_cast", + Sfx::ClickedMage => "mage_clicked", + Sfx::ClickedWitch => "witch_clicked", + Sfx::ClickedElf => "elf_clicked", + Sfx::ClickedKnight => "knight_clicked", + } + } +} + +pub struct Audio { + stream_handle: OutputStream, + music_sink: Sink, + clips: HashMap>>, + music_volume: f32, + sfx_volume: f32, +} + +impl Audio { + pub fn new() -> Result> { + let stream_handle = OutputStreamBuilder::open_default_stream()?; + let music_sink = Sink::connect_new(stream_handle.mixer()); + Ok(Self { + stream_handle, + music_sink, + clips: HashMap::new(), + music_volume: 1.0, + sfx_volume: 1.0, + }) + } + + pub fn load_clip( + &mut self, + name: &str, + path: impl Into, + ) -> Result<(), Box> { + let path = path.into(); + let mut f = File::open(&path)?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + self.clips.insert(name.to_string(), Arc::new(buf)); + Ok(()) + } + + fn make_decoder_from_arc( + bytes: Arc>, + ) -> Result>>, Box> { + let vec = (*bytes).clone(); + let cursor = Cursor::new(vec); + Ok(Decoder::try_from(cursor)?) + } + + pub fn play_sfx(&self, name: &str) { + if let Some(bytes) = self.clips.get(name) + && let Ok(decoder) = Self::make_decoder_from_arc(bytes.clone()) + { + let source = decoder.buffered(); + let sink = Sink::connect_new(self.stream_handle.mixer()); + sink.set_volume(self.sfx_volume); + sink.append(source); + sink.detach(); + } + } + //for type safety and convenience + pub fn play_sfx_enum(&self, sfx: Sfx) { + self.play_sfx(sfx.key()); + } + + pub fn play_music(&mut self, kind: Music) { + self.music_sink.stop(); + self.music_sink = Sink::connect_new(self.stream_handle.mixer()); + let key = match kind { + Music::Menu => "menu", + Music::Lobby => "lobby", + Music::InGame => "ingame", + }; + if let Some(bytes) = self.clips.get(key) + && let Ok(decoder) = Self::make_decoder_from_arc(bytes.clone()) + { + let src = decoder.repeat_infinite(); + self.music_sink.append(src); + self.music_sink.set_volume(self.music_volume); + } + } + + pub fn set_music_volume(&mut self, v: f32) { + let clamped = v.clamp(0.0, 100.0) / 100.0; + self.music_volume = clamped; + self.music_sink.set_volume(clamped); + } + pub fn set_sfx_volume(&mut self, v: f32) { + let clamped = v.clamp(0.0, 100.0) / 100.0; + self.sfx_volume = clamped; + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index e7cf63d..5581502 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,13 +1,15 @@ +pub mod audio; mod update; -mod views; +pub mod views; mod ws; use crate::api::{Card, Lobby, PlayerId, Suit}; +use crate::client::audio::{Music, Sfx}; use crate::client::views::Button; -use crate::gameplay_ui::hand::{HandMessage, ViewableHand}; -use crate::gameplay_ui::table::{TableMessage, ViewableTable}; +use crate::gameplay_ui::scoreboard::ScoreBoardInfo; +use crate::gameplay_ui::{GameStartInfo, GameView, GameViewMessage}; use crate::ui_element_traits::Message; -use iced::{time, widget::image, window, Size, Subscription, Task}; +use iced::{Size, Subscription, Task, time, widget::image, window}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -15,7 +17,7 @@ use strum::IntoEnumIterator; pub use update::update; pub use views::view; -pub use ws::{connect_ws, ServerMsgReceiver, WsConnection}; +pub use ws::{ServerMsgReceiver, WsConnection, connect_ws}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlayerCount { @@ -57,6 +59,7 @@ pub enum MenuState { Rules, Lobby, Playing, + Options, #[allow(dead_code)] PlayingTest, } @@ -69,9 +72,13 @@ impl Message for MenuState { pub struct App { window_size: Size, + pub msg_queue: Vec, + pub msg_queue_delayed: Vec, + pub animation_count_down_latch: usize, pub connected: bool, pub connecting: bool, + pub disconnected: bool, pub ws_tx: WsConnection, pub server_rx: ServerMsgReceiver, pub msg: String, @@ -110,13 +117,13 @@ pub struct App { pub winner: Option, // Gameplay view state - pub viewable_hand: ViewableHand, - pub viewable_table: ViewableTable, + pub game_view: GameView, // 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_options: crate::client::views::Button, pub btn_close: crate::client::views::Button, // Buttons for other menus @@ -128,27 +135,88 @@ pub struct App { pub btn_back_to_menu: crate::client::views::Button, pub btn_ready_owned: crate::client::views::Button, - pub btn_submit_bid: crate::client::views::Button, + // Audio + pub audio: Option, + pub music_volume: i32, + pub sfx_volume: i32, + current_music: Option, pub img_main_menu: image::Handle, pub img_lobby_menu: image::Handle, pub img_background: image::Handle, - pub img_ingame_background: image::Handle, pub img_menu_container: image::Handle, #[allow(dead_code)] pub card_images: HashMap, } +impl App { + pub fn scoreboard_info(&self) -> ScoreBoardInfo { + ScoreBoardInfo { + round_number: self.round_number, + player_order: self.player_order.clone(), + scores: self.scores.clone(), + tricks_won: self.tricks_won.clone(), + bids: self.bids.clone(), + my_id: self.my_id, + lobby: self.lobby.clone(), + must_set_trump: self.must_set_trump, + dealer: self.dealer, + is_bidding_phase: self.is_bidding_phase, + is_my_turn: self.is_my_turn, + bid_input: self.bid_input.clone(), + current_player: self.current_player, + } + } + + pub fn game_start_info(&self) -> GameStartInfo { + GameStartInfo::new(self) + } + + fn preload_card_images() -> HashMap { + let mut map = HashMap::new(); + + map.insert( + Card::new(Suit::Red, crate::api::Value::Jester), + image::Handle::from_path("assets/cards/variations/jester.png"), + ); + map.insert( + Card::new(Suit::Red, crate::api::Value::Wizard), + image::Handle::from_path("assets/cards/variations/wizard.png"), + ); + + for suit in Suit::iter() { + for num in 1..=13 { + let card = Card::new(suit, crate::api::Value::Number(num)); + let path = card.img_path(); + map.insert(card, image::Handle::from_path(path)); + } + } + + map + } + + pub fn get_card_image(&self, card: Card) -> image::Handle { + self.card_images.get(&card).cloned().unwrap_or_else(|| { + //just in case + image::Handle::from_path(card.img_path()) + }) + } +} + impl Default for App { fn default() -> Self { // Keep this value ins sync with the window size of the main function. let window_size: Size = Size::new(640.0, 480.0); - Self { + let mut app = Self { window_size, + msg_queue: Vec::new(), + msg_queue_delayed: Vec::new(), + animation_count_down_latch: 0, connected: false, connecting: false, + disconnected: false, ws_tx: Arc::new(Mutex::new(None)), server_rx: Arc::new(Mutex::new(None)), msg: String::new(), @@ -159,7 +227,6 @@ impl Default for App { join_name: "".to_string(), my_id: None, - lobby: Some(Lobby { players: Vec::new(), chat: Vec::new(), @@ -189,12 +256,12 @@ impl Default for App { game_over: false, winner: None, - viewable_hand: ViewableHand::new(window_size), - viewable_table: ViewableTable::new(window_size), + game_view: GameView::new(window_size), //Buttons btn_host: Button::new_host_button(0, 180, 44), btn_join: Button::new_join_button(1, 180, 44), + btn_options: Button::new_options_button(4, 180, 44), btn_rules: Button::new_rules_button(2, 180, 44), btn_close: Button::new_close_button(3, 180, 44), @@ -206,49 +273,72 @@ impl Default for App { btn_back_to_menu: Button::new_back_to_menu_button(15, 160, 40), btn_ready_owned: Button::new_ready_owned_button(20, 100, 36), - btn_submit_bid: Button::new_submit_bid_button(21, 110, 36), img_main_menu: image::Handle::from_path("assets/wizard_main_menu.png"), img_lobby_menu: image::Handle::from_path("assets/wizard_lobby_menu.png"), img_background: image::Handle::from_path("assets/background_forall.png"), - img_ingame_background: image::Handle::from_path("assets/ingame_background.png"), img_menu_container: image::Handle::from_path("assets/menu_container.png"), card_images: Self::preload_card_images(), + audio: None, + music_volume: 100, + sfx_volume: 100, + current_music: None, + }; + + if let Ok(mut a) = crate::client::audio::Audio::new() { + let _ = a.load_clip("menu", "assets/audio/wizard_black_shores.mp3"); + let _ = a.load_clip("lobby", "assets/audio/wizard_clash_of_mages.mp3"); + let _ = a.load_clip("ingame", "assets/audio/wizard_peaceful.mp3"); + + let _ = a.load_clip("click", "assets/audio/sfx_click.mp3"); + let _ = a.load_clip("game_over", "assets/audio/sfx_game_over.mp3"); + let _ = a.load_clip("card_hovered", "assets/audio/sfx_card_hovered.mp3"); + let _ = a.load_clip("card_shuffle", "assets/audio/sfx_card_shuffle.mp3"); + let _ = a.load_clip("card_dealed", "assets/audio/sfx_card_dealed.mp3"); + let _ = a.load_clip("card_played", "assets/audio/sfx_card_played.mp3"); + let _ = a.load_clip("card_error", "assets/audio/sfx_card_error.mp3"); + let _ = a.load_clip("shard_played", "assets/audio/sfx_shard_played.mp3"); + let _ = a.load_clip("mage_cast", "assets/audio/sfx_mage_cast.mp3"); + let _ = a.load_clip("witch_cast", "assets/audio/sfx_witch_cast.mp3"); + let _ = a.load_clip("elf_cast", "assets/audio/sfx_elf_cast.mp3"); + let _ = a.load_clip("knight_cast", "assets/audio/sfx_knight_cast.mp3"); + let _ = a.load_clip("mage_clicked", "assets/audio/sfx_mage_clicked.mp3"); + let _ = a.load_clip("witch_clicked", "assets/audio/sfx_witch_clicked.mp3"); + let _ = a.load_clip("elf_clicked", "assets/audio/sfx_elf_clicked.mp3"); + let _ = a.load_clip("knight_clicked", "assets/audio/sfx_knight_clicked.mp3"); + + a.play_music(crate::client::audio::Music::Menu); + app.audio = Some(a); } + app } } impl App { - fn preload_card_images() -> HashMap { - let mut map = HashMap::new(); - - map.insert( - Card::new(Suit::Red, crate::api::Value::Jester), - image::Handle::from_path("assets/cards/variations/jester.png"), - ); - map.insert( - Card::new(Suit::Red, crate::api::Value::Wizard), - image::Handle::from_path("assets/cards/variations/wizard.png"), - ); - - for suit in Suit::iter() { - for num in 1..=13 { - let card = Card::new(suit, crate::api::Value::Number(num)); - let path = crate::api::get_card_path(card); - map.insert(card, image::Handle::from_path(path)); + pub fn set_menu(&mut self, menu: MenuState) { + // Determine what music should play for this menu + let target_music = match menu { + MenuState::Main + | MenuState::Host + | MenuState::Join + | MenuState::Rules + | MenuState::Options => Some(Music::Menu), + MenuState::Lobby => Some(Music::Lobby), + MenuState::Playing | MenuState::PlayingTest => Some(Music::InGame), + }; + + // Only restart music if it's different from what's currently playing + if target_music != self.current_music { + if let Some(a) = &mut self.audio + && let Some(music) = target_music + { + a.play_music(music); } + self.current_music = target_music; } - map - } - - #[allow(dead_code)] - pub fn get_card_image(&self, card: Card) -> image::Handle { - self.card_images.get(&card).cloned().unwrap_or_else(|| { - //just in case - image::Handle::from_path(crate::api::get_card_path(card)) - }) + self.menu = menu; } } @@ -285,12 +375,20 @@ pub enum AppMessage { BackToMenu, CloseGame, - // Gameplay view messages - HandMessage(HandMessage), - TableMessage(TableMessage), + // Gameview messages + GameViewMessage(Box), // Button messages from view widgets ButtonMessage(crate::client::views::ButtonMessage), + + // Audio messages + MusicVolumeChanged(f32), + SfxVolumeChanged(f32), + // Animation Count Down Letch + IncrementACDL(usize), + DecrementACDL(usize), + + PlaySfx(Sfx), } impl Message for AppMessage { @@ -312,7 +410,9 @@ impl TaskBatcher { self.tasks.push(task) } } - #[allow(dead_code)] + pub fn push_msg(&mut self, msg: impl Message) { + self.push(msg.convert_msg_to_task()) + } pub fn push_mult(&mut self, tasks: [Task; SIZE]) { self.tasks .extend(tasks.into_iter().filter(|task| task.units() != 0)); diff --git a/src/client/update.rs b/src/client/update.rs index 2ad72d2..8a84f31 100644 --- a/src/client/update.rs +++ b/src/client/update.rs @@ -1,21 +1,26 @@ use iced::Task; use std::sync::Arc; -use super::{connect_ws, App, AppMessage, MenuState, PlayerCount}; -use crate::api::{Card, Lobby, PlayerId, ServerMessage, Value, B, C, S}; +use super::{App, AppMessage, MenuState, PlayerCount, connect_ws}; +use crate::api::{B, C, Card, Lobby, PlayerId, S, ServerMessage, Value}; use crate::client::TaskBatcher; -use crate::gameplay_ui::hand::ViewableHand; -use crate::ui_element_traits::{Animated, Notifiable, Resizable}; +use crate::gameplay_ui::GameViewMessage; +use crate::gameplay_ui::hand::HandMessage; +use crate::gameplay_ui::hand::hand_card::CardMessage; +use crate::gameplay_ui::scoreboard::ScoreBoardMessage; +use crate::gameplay_ui::table::TableMessage; +use crate::gameplay_ui::table::middle::card_deck::glow_card::GlowMessage; +use crate::ui_element_traits::{Animated, Message, Notifiable, Resizable}; /// Get player name from ID using lobby data fn get_player_name(state: &App, player_id: PlayerId) -> 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(); - } + if let Some(ref lobby) = state.lobby + && let Some(player) = lobby.players.iter().find(|p| p.id == player_id) + { + return player.name.clone(); } format!("Player {}", player_id) } @@ -34,30 +39,101 @@ fn format_card(card: &Card) -> String { } } -pub fn update(state: &mut App, msg: AppMessage) -> Task { +fn is_msg_not_ready(state: &App, msg: AppMessage) -> bool { + if state.animation_count_down_latch > 0 { + match msg { + AppMessage::GameViewMessage(inner) => matches!( + &*inner, + GameViewMessage::NewRound(_, _, _) + | GameViewMessage::ChangeTurn(_, _) + | GameViewMessage::NewTrick + | GameViewMessage::ScoreBoardMessage(ScoreBoardMessage::Update(_)) + ), + _ => false, + } + } else { + false + } +} + +pub fn update(state: &mut App, msg_unaltered: AppMessage) -> Task { + match msg_unaltered.clone() { + AppMessage::AnimationTick => (), + AppMessage::ServerTick => (), + AppMessage::GameViewMessage(inner) => match &*inner { + GameViewMessage::ScoreBoardMessage(ScoreBoardMessage::Update(_)) => (), + GameViewMessage::HandMessage(HandMessage::CardMessage(CardMessage::CursorMoved( + _, + _, + ))) => (), + _ => println!("{:?}", msg_unaltered), + }, + _ => { + println!("{:?}", msg_unaltered) + } + }; + let mut tb = TaskBatcher::new(); + for queue_msg in state.msg_queue.iter() { + tb.push_msg(queue_msg.clone()) + } + state.msg_queue.clear(); + if is_msg_not_ready(state, msg_unaltered.clone()) { + state.msg_queue_delayed.push(msg_unaltered); + return tb.batch(); + } + let msg = match msg_unaltered.clone() { + AppMessage::GameViewMessage(inner) => match &*inner { + GameViewMessage::ScoreBoardMessage(ScoreBoardMessage::Update(_)) => { + AppMessage::GameViewMessage(Box::new(GameViewMessage::ScoreBoardMessage( + ScoreBoardMessage::Update(Box::new(state.scoreboard_info())), + ))) + } + _ => msg_unaltered, + }, + _ => msg_unaltered, + }; match msg { + AppMessage::DecrementACDL(amount) => { + if state.animation_count_down_latch >= amount { + state.animation_count_down_latch -= amount; + } else { + state.animation_count_down_latch = 0; + } + if state.animation_count_down_latch == 0 { + for queue_msg in state.msg_queue_delayed.iter() { + tb.push_msg(queue_msg.clone()) + } + state.msg_queue_delayed.clear(); + } + } + AppMessage::IncrementACDL(amount) => state.animation_count_down_latch += amount, AppMessage::Navigate(menu) => { - state.menu = menu; + state.set_menu(menu); + } + AppMessage::PlaySfx(sfx) => { + if let Some(audio) = &state.audio { + audio.play_sfx_enum(sfx); + } } AppMessage::Host => { let local_ip = crate::server::local_ip(); state.msg = format!("Hosting on {local_ip}"); state.ip = local_ip; - state.menu = MenuState::Host; + state.set_menu(MenuState::Host); } AppMessage::HostNameChanged(name) => { state.host_name = name; } AppMessage::HostPlayerCountChanged(count) => { state.host_player_count = count; - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::SetPlayerCount { - count: count.to_usize(), - }); - state.last_msg = format!("Player count set to {count}"); - } + if let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::SetPlayerCount { + count: count.to_usize(), + }); + state.last_msg = format!("Player count set to {count}"); } } AppMessage::JoinNameChanged(name) => { @@ -67,15 +143,15 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { state.ip = addr; } AppMessage::SendChat => { - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::ChatMessage { - sender: state.join_name.clone(), - message: state.chat_input.clone(), - }); - state.last_msg = "Sending chat message...".to_string(); - state.chat_input.clear(); - } + if let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::ChatMessage { + sender: state.join_name.clone(), + message: state.chat_input.clone(), + }); + state.last_msg = "Sending chat message...".to_string(); + state.chat_input.clear(); } } AppMessage::ChatInputChanged(input) => { @@ -97,18 +173,18 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { println!("Creating lobby..."); std::thread::sleep(std::time::Duration::from_millis(2000)); - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::SetPlayerCount { - count: state.host_player_count.to_usize(), - }); - let _ = tx.send(C::JoinLobby { - name: state.host_name.clone(), - }); - state.last_msg = "Creating lobby...".to_string(); - } + if let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::SetPlayerCount { + count: state.host_player_count.to_usize(), + }); + let _ = tx.send(C::JoinLobby { + name: state.host_name.clone(), + }); + state.last_msg = "Creating lobby...".to_string(); } - state.menu = MenuState::Lobby; + state.set_menu(MenuState::Lobby); } AppMessage::Connect => { if !state.connected { @@ -140,13 +216,13 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { false }; - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::SetReady { - ready: new_ready_state, - }); - state.last_msg = format!("Set ready: {new_ready_state}"); - } + if let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::SetReady { + ready: new_ready_state, + }); + state.last_msg = format!("Set ready: {new_ready_state}"); } } AppMessage::StartGame => { @@ -165,18 +241,17 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { .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 { - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::StartGame); - state.last_msg = "Starting game...".to_string(); - } - } + if can_start + && is_host + && let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::StartGame); + state.last_msg = "Starting game...".to_string(); } } - AppMessage::GameRules => { - state.menu = MenuState::Rules; + state.set_menu(MenuState::Rules); } AppMessage::BackToMenu => { // Stops the server if the player is the host; otherwise drops the connection. @@ -186,12 +261,11 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { false }; - if am_host { - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::RequestShutdown); - } - } + if am_host + && let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::RequestShutdown); } if let Ok(mut guard) = state.ws_tx.lock() { @@ -236,7 +310,7 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { state.dealer = None; state.game_over = false; state.winner = None; - state.menu = MenuState::Main; + state.set_menu(MenuState::Main); } AppMessage::CloseGame => { // Calls the application to exit. @@ -249,54 +323,53 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { } AppMessage::SubmitBid => { if let Ok(amount) = state.bid_input.parse::() { - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::Bid { amount }); - let log = format!("[YOU] Submitting bid: {}", amount); - println!("{}", log); - state.game_log.push(log); - state.bid_input.clear(); - } + if let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::Bid { amount }); + let log = format!("[YOU] Submitting bid: {}", amount); + println!("{}", log); + state.game_log.push(log); + state.bid_input.clear(); } } else { state.last_msg = "Invalid bid - enter a number".to_string(); } } AppMessage::PlayCard(card) => { - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::PlayCard { card }); - let log = format!("[YOU] Playing card: {:?} of {:?}", card.value, card.suit); - println!("{}", log); - state.game_log.push(log); - } + if let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::PlayCard { card }); + let log = format!("[YOU] Playing card: {:?} of {:?}", card.value, card.suit); + println!("{}", log); + state.game_log.push(log); } } AppMessage::SetTrump(suit) => { - if let Ok(guard) = state.ws_tx.lock() { - if let Some(ref tx) = *guard { - let _ = tx.send(C::SetTrump { suit }); - let log = format!("[YOU] Setting trump to: {:?}", suit); - println!("{}", log); - state.game_log.push(log); - state.must_set_trump = false; - } + if let Ok(guard) = state.ws_tx.lock() + && let Some(ref tx) = *guard + { + let _ = tx.send(C::SetTrump { suit }); + let log = format!("[YOU] Setting trump to: {:?}", suit); + println!("{}", log); + state.game_log.push(log); + state.must_set_trump = false; } } AppMessage::ServerTick => { handle_tick(state); + tb.push_msg(ScoreBoardMessage::Update(Box::new(state.scoreboard_info()))); } - AppMessage::HandMessage(hand_msg) => { - return state.viewable_hand.update_with_msg(hand_msg.clone()); - } - AppMessage::TableMessage(table_msg) => { - return state.viewable_table.update_with_msg(table_msg.clone()); + AppMessage::GameViewMessage(inner) => { + tb.push(state.game_view.update_with_msg(*inner)); } AppMessage::ButtonMessage(btn_msg) => { // Route to buttons (each button filters by id internally) - return TaskBatcher::instant_batch([ + tb.push_mult([ state.btn_host.update_with_msg(btn_msg.clone()), state.btn_join.update_with_msg(btn_msg.clone()), + state.btn_options.update_with_msg(btn_msg.clone()), state.btn_rules.update_with_msg(btn_msg.clone()), state.btn_close.update_with_msg(btn_msg.clone()), state.btn_create_lobby.update_with_msg(btn_msg.clone()), @@ -306,18 +379,17 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { 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.clone()), - state.btn_submit_bid.update_with_msg(btn_msg.clone()), + state.game_view.update_buttons_with_msg(btn_msg.clone()), ]); } AppMessage::AnimationTick => { - return TaskBatcher::instant_batch([ - state.viewable_hand.update_animations(), - state.viewable_table.update_animations(), - // Update button animations + tb.push_mult([ + state.game_view.update_animations(), state.btn_host.update_animations(), state.btn_join.update_animations(), state.btn_rules.update_animations(), state.btn_close.update_animations(), + state.btn_options.update_animations(), state.btn_create_lobby.update_animations(), state.btn_back.update_animations(), state.btn_connect.update_animations(), @@ -325,44 +397,59 @@ pub fn update(state: &mut App, msg: AppMessage) -> Task { state.btn_start_game.update_animations(), state.btn_back_to_menu.update_animations(), state.btn_ready_owned.update_animations(), - state.btn_submit_bid.update_animations(), ]); } + AppMessage::WindowResized(window_size) => { state.window_size = window_size; - state.viewable_hand.update_size(window_size); - state.viewable_table.update_size(window_size); + state.game_view.update_size(window_size); + } + + AppMessage::MusicVolumeChanged(volume) => { + state.music_volume = volume as i32; + if let Some(a) = &mut state.audio { + a.set_music_volume(volume); + } + } + AppMessage::SfxVolumeChanged(volume) => { + state.sfx_volume = volume as i32; + if let Some(a) = &mut state.audio { + a.set_sfx_volume(volume); + } } } - Task::none() + tb.batch() } fn handle_tick(state: &mut App) { if state.connecting && !state.connected { state.last_msg = "Connecting".to_string(); - if let Ok(guard) = state.ws_tx.lock() { - if guard.is_some() { - state.connected = true; - state.connecting = false; - if let Some(ref tx) = *guard { - let _ = tx.send(C::JoinLobby { - name: state.join_name.clone(), - }); - state.last_msg = "Joining lobby...".to_string(); - state.menu = MenuState::Lobby; - } + let mut should_navigate_lobby = false; + if let Ok(guard) = state.ws_tx.lock() + && guard.is_some() + { + state.connected = true; + state.connecting = false; + if let Some(ref tx) = *guard { + let _ = tx.send(C::JoinLobby { + name: state.join_name.clone(), + }); + state.last_msg = "Joining lobby...".to_string(); + should_navigate_lobby = true; } } + if should_navigate_lobby { + state.set_menu(MenuState::Lobby); + } } - // Collects messages first to avoid borrowing issues. - let messages: Vec = if let Ok(guard) = state.server_rx.lock() { - if let Some(ref rx) = *guard { - let mut msgs = Vec::new(); - while let Ok(msg) = rx.try_recv() { - println!("Received: {msg:?}"); - msgs.push(msg); - } - msgs + // Detect if the connection was dropped externally (ws_tx went None while connected). + let mut messages: Vec = if state.connected { + if let Ok(guard) = state.ws_tx.lock() { + if guard.is_none() { + vec![ServerMessage::ConnectionClosed] + } else { + Vec::new() + } } else { Vec::new() } @@ -370,6 +457,16 @@ fn handle_tick(state: &mut App) { Vec::new() }; + // Collects messages first to avoid borrowing issues. + if let Ok(guard) = state.server_rx.lock() + && let Some(ref rx) = *guard + { + while let Ok(msg) = rx.try_recv() { + println!("Received: {msg:?}"); + messages.push(msg); + } + }; + for msg in messages { handle_server_message(state, msg); } @@ -377,6 +474,39 @@ fn handle_tick(state: &mut App) { fn handle_server_message(state: &mut App, msg: ServerMessage) { match msg { + ServerMessage::ConnectionClosed => { + println!("Connection lost, resetting state."); + state.connected = false; + state.connecting = false; + state.disconnected = true; + state.my_id = None; + state.lobby = Some(Lobby { + players: Vec::new(), + chat: Vec::new(), + }); + state.chat_input.clear(); + state.server_messages.clear(); + state.last_msg = "Disconnected from server.".to_string(); + state.game_log.clear(); + state.hand.clear(); + state.current_trick.clear(); + state.trump = None; + state.round_number = 0; + state.is_my_turn = false; + state.is_bidding_phase = false; + state.must_set_trump = false; + state.current_player = None; + state.player_order.clear(); + state.bids.clear(); + state.tricks_won.clear(); + state.scores.clear(); + state.bid_input.clear(); + state.valid_cards.clear(); + state.dealer = None; + state.game_over = false; + state.winner = None; + state.set_menu(MenuState::Main); + } ServerMessage::Server(s) => match s { S::HandshakeConfirmation { version, supported } => { let log = format!("[SERVER] Handshake: version {version}, supported {supported}"); @@ -391,6 +521,7 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { state .btn_ready_owned .set_on_click(AppMessage::ToggleReady(id)); + state.disconnected = false; } S::Error { reason } => { let log = format!("[ERROR] {reason}"); @@ -408,13 +539,9 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { state.last_msg = log; state.hand = cards.clone(); - // Create viewable cards with preloaded images - let viewable_cards = ViewableHand::create_viewable_cards_static( - &cards, - &state.valid_cards, - state.window_size, - ); - state.viewable_hand.set_cards(viewable_cards); + state + .msg_queue + .push(GameViewMessage::NewRound(state.trump, cards, Vec::new()).convert_msg()); } S::TrumpRequest => { let log = "[SERVER] You must set the trump suit!".to_string(); @@ -452,6 +579,10 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { state.is_my_turn = true; state.is_bidding_phase = false; state.valid_cards = valid_cards; + state.msg_queue.push( + GameViewMessage::ChangeTurn(state.my_id.unwrap(), state.valid_cards.clone()) + .convert_msg(), + ); } S::InvalidMove { reason } => { let log = format!("[ERROR] Invalid move: {reason}"); @@ -500,19 +631,22 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { // 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; - } + if let Some(ref lobby) = state.lobby + && let Some(host) = lobby.players.iter().find(|p| p.is_host) + { + if host.name == "wizard_master" { + state.set_menu(MenuState::PlayingTest); + } else { + state.set_menu(MenuState::Playing); } } state.scores.clear(); state.bids.clear(); state.tricks_won.clear(); + state.msg_queue.push( + GameViewMessage::StartGame(Box::new(state.game_start_info())).convert_msg(), + ); } B::RoundStarted { round, @@ -552,6 +686,9 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { if is_me { state.must_set_trump = true; } + state + .msg_queue + .push(TableMessage::ChangeTurn(dealer).convert_msg()); } B::TrumpSet { suit, by_dealer } => { let dealer_name = get_player_name(state, by_dealer); @@ -564,6 +701,9 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { value: Value::Number(1), }); state.must_set_trump = false; + state + .msg_queue + .push(GlowMessage::TryChangeGlow(state.trump.unwrap()).convert_msg()) } B::BiddingStarted { starting_player, @@ -592,6 +732,9 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { state.game_log.push(log.clone()); state.last_msg = log; state.current_player = Some(player); + state + .msg_queue + .push(TableMessage::ChangeTurn(player).convert_msg()); } B::BidMade { player, amount } => { let player_name = get_player_name(state, player); @@ -631,6 +774,9 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { // Reset turn - only the leader who receives YourTurn should be able to play state.is_my_turn = false; state.valid_cards.clear(); + state + .msg_queue + .push(GameViewMessage::NewTrick.convert_msg()); } B::TurnChanged { player } => { let is_me = state.my_id == Some(player); @@ -644,6 +790,12 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { state.game_log.push(log.clone()); state.last_msg = log; state.current_player = Some(player); + println!("~~~TURN CHANGE~~~"); + if !is_me { + state + .msg_queue + .push(GameViewMessage::ChangeTurn(player, Vec::new()).convert_msg()); + } } B::CardPlayed { player, card } => { let player_name = get_player_name(state, player); @@ -658,6 +810,10 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { if state.my_id == Some(player) { state.hand.retain(|c| *c != card); } + state + .msg_queue + .push(GameViewMessage::CardPlayed(player, card).convert_msg()); + state.msg_queue.push(AppMessage::IncrementACDL(1)); } B::PoolFinished { winner, cards } => { let is_me = state.my_id == Some(winner); @@ -724,11 +880,14 @@ fn handle_server_message(state: &mut App, msg: ServerMessage) { } state.game_over = true; state.winner = Some(winner); + state + .msg_queue + .push(GameViewMessage::EndGame(winner).convert_msg()); } B::ServerShutdown => { println!("[SERVER] Server shutdown received"); state.last_msg = "Lost connection to host".to_string(); - state.menu = MenuState::Main; + state.set_menu(MenuState::Main); state.connected = false; state.connecting = false; state.my_id = None; diff --git a/src/client/views/button.rs b/src/client/views/button.rs index 632f586..95c71f3 100644 --- a/src/client/views/button.rs +++ b/src/client/views/button.rs @@ -1,10 +1,12 @@ use derive_more::{Deref, DerefMut}; -use iced::widget::{container, stack, text, Image, MouseArea}; use iced::Task; +use iced::widget::{Image, MouseArea, container, stack, text}; use crate::animation::{BasicAnimation, Easing, ReversableBasicAnimation}; -use crate::api::{PlayerId, BUTTON1_PATH}; +use crate::api::{BUTTON1_PATH, PlayerId, TextColor}; +use crate::client::audio::Sfx; use crate::client::{AppMessage, MenuState, TaskBatcher}; +use crate::gameplay_ui::GameViewMessage; use crate::ui_element_traits::{Animated, Message, Notifiable}; #[derive(Debug, Clone)] @@ -52,7 +54,7 @@ impl ClickAnim { } } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Button { pub id: usize, pub text: &'static str, @@ -85,7 +87,7 @@ impl Button { }; button .click_animation - .on_end(ButtonMessage::ClickEnded(id).convert_msg()); + .on_end_reached(ButtonMessage::ClickEnded(id).convert_msg()); button } @@ -102,6 +104,17 @@ impl Button { AppMessage::Navigate(MenuState::Join), ) } + pub fn new_options_button(id: usize, width: u16, height: u16) -> Self { + Self::new( + id, + "Optionen", + BUTTON1_PATH, + width, + height, + AppMessage::Navigate(MenuState::Options), + ) + } + pub fn new_rules_button(id: usize, width: u16, height: u16) -> Self { Self::new( id, @@ -200,7 +213,7 @@ impl Button { BUTTON1_PATH, width, height, - AppMessage::SubmitBid, + GameViewMessage::TryBid.convert_msg(), ) } @@ -208,7 +221,7 @@ impl Button { self.on_click = on_click; } - pub fn view(&self) -> container::Container<'_, AppMessage> { + pub fn view<'a>(&self) -> container::Container<'a, AppMessage> { self.view_internal(self.text) } @@ -218,18 +231,18 @@ impl Button { 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 width_scaled = (self.width as f32 * scale).max(1.0).round() as u32; + let height_scaled = (self.height as f32 * scale).max(1.0).round() as u32; 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) + .width(width_scaled) + .height(height_scaled) .opacity(self.click_animation.get_opacity()); let content = stack![ img, - container(text(label).size(txt_size)) + container(text(label).size(txt_size).white()) .width(iced::Length::Fill) .height(iced::Length::Fill) .center_x(iced::Fill) @@ -296,17 +309,18 @@ impl Notifiable for Button { match msg { ButtonMessage::Hovered(id) => { if id == self.id { - self.hover_animation.start() + self.hover_animation.start(); } } ButtonMessage::NotHovered(id) => { if id == self.id { - self.hover_animation.reverse() + self.hover_animation.reverse(); } } ButtonMessage::Clicked(id) => { if id == self.id { self.click_animation.start(); + return Task::done(AppMessage::PlaySfx(Sfx::Click)); } } ButtonMessage::ClickEnded(id) => { diff --git a/src/client/views/debug_gameplay.rs b/src/client/views/debug_gameplay.rs new file mode 100644 index 0000000..313ad38 --- /dev/null +++ b/src/client/views/debug_gameplay.rs @@ -0,0 +1,284 @@ +use super::utils::{format_card, get_player_name}; +use crate::api::Suit; +use crate::client::{App, AppMessage}; +use iced::{ + Element, + widget::{ + Column, Image, Row, button, column, container, row, scrollable, stack, text, text_input, + }, +}; + +/// This view of the gameplay is an easter egg. +pub fn view_debug_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 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; + + println!( + "Card: {}, Valid: {}, Can Play: {}", + format_card(card), + is_valid, + can_play + ); + let img_handle = state.get_card_image(*card).clone(); + + let card_btn = if can_play { + stack![ + button(text("").size(11)) + .on_press(AppMessage::PlayCard(*card)) + .padding(8) + .width(80) + .height(120), + Image::new(img_handle).width(80).height(120), + ] + } else { + stack![Image::new(img_handle).width(80).height(120).opacity(0.5),] + }; + 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/gameplay.rs b/src/client/views/gameplay.rs deleted file mode 100644 index d76c2ba..0000000 --- a/src/client/views/gameplay.rs +++ /dev/null @@ -1,559 +0,0 @@ -use super::utils::{format_card, get_player_name}; -use crate::api::Suit; -use crate::client::{App, AppMessage}; -use iced::{ - alignment, - widget::{ - button, column, container, row, scrollable, stack, text, text_input, Column, Image, Row, - }, - Border, Color, ContentFit, Element, -}; - -// AI usage: write this function for testing purposes as a placeholder -pub fn display_trump_card<'a>(state: &'a App) -> Element<'a, AppMessage> { - if let Some(trump) = state.trump { - let img_path = state.get_card_image(trump); - container(Image::new(img_path).width(100).height(150)) - .width(iced::Length::Fill) - .center_x(iced::Length::Fill) - .align_x(alignment::Horizontal::Center) - .padding([8, 0]) - .into() - } else { - let img_path = "assets/cards/variations/back.png"; - container(Image::new(img_path).width(100).height(150)) - .width(iced::Length::Fill) - .center_x(iced::Length::Fill) - .align_x(alignment::Horizontal::Center) - .padding([8, 0]) - .into() - } -} - -pub fn view_gameplay<'a>(state: &'a App) -> Element<'a, AppMessage> { - let scoreboard = container(view_scoreboard(state)) - .width(iced::Length::Fixed(350.0)) - .height(iced::Length::Fill) - .align_y(alignment::Vertical::Center) - .padding([24.0, 24.0]); - - let main_content = container(Column::new()) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .align_x(alignment::Horizontal::Left) - .align_y(alignment::Vertical::Top); - - let trump_card = display_trump_card(state); - let hand_display = container(build_hand_section_dbg(state)) - .width(iced::Length::Fill) - .height(iced::Length::Shrink) - .align_x(alignment::Horizontal::Center) - .padding([24.0, 24.0]); - - let left_panel = Column::new() - .push(main_content) - .push(trump_card) - .push(hand_display) - .width(iced::Length::Fill) - .height(iced::Length::Fill); - - let content = row![left_panel, scoreboard].height(iced::Length::Fill); - - stack![ - Image::new(state.img_ingame_background.clone()) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .content_fit(ContentFit::Cover), - container(content) - .width(iced::Length::Fill) - .height(iced::Length::Fill), - ] - .into() -} - -fn build_bidding_panel<'a>(state: &'a App) -> Element<'a, AppMessage> { - if !state.is_bidding_phase || !state.is_my_turn || state.must_set_trump { - return text("").into(); - } - - let max_bid = state.round_number + 1; - - let panel = column![ - text("Bid:").size(16).color(Color::from_rgb(1.0, 1.0, 1.0)), - row![ - text_input("Enter bid", &state.bid_input) - .on_input(AppMessage::BidInputChanged) - .width(80), - state.btn_submit_bid.view().padding(0), - ] - .spacing(6), - text(format!("(0 to {max_bid})")) - .size(12) - .color(Color::from_rgba(1.0, 1.0, 1.0, 0.7)), - ] - .spacing(6); - - container(panel).padding([8, 0]).into() -} - -fn build_trump_panel<'a>(state: &'a App) -> Element<'a, AppMessage> { - // Show only if dealer and must_set_trump - if !state.must_set_trump || state.dealer != state.my_id { - return text("").into(); - } - - let panel = column![ - text("Select Trump Suit:") - .size(16) - .color(Color::from_rgb(1.0, 1.0, 1.0)), - row![ - button(Image::new("assets/cards/variations/red_1.png")) - .width(80) - .height(120) - .on_press(AppMessage::SetTrump(Suit::Red)) - .padding(0), - button(Image::new("assets/cards/variations/green_1.png")) - .width(80) - .height(120) - .on_press(AppMessage::SetTrump(Suit::Green)) - .padding(0), - button(Image::new("assets/cards/variations/blue_1.png")) - .width(80) - .height(120) - .on_press(AppMessage::SetTrump(Suit::Blue)) - .padding(0), - button(Image::new("assets/cards/variations/yellow_1.png")) - .width(80) - .height(120) - .on_press(AppMessage::SetTrump(Suit::Yellow)) - .padding(0), - ] - .spacing(6), - ] - .spacing(6); - - container(panel).padding([8, 0]).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); - - 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]), - ); - - 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, - )); - } - - 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]), - ); - - if state.must_set_trump && state.dealer == state.my_id { - scores_col = scores_col - .push(build_trump_panel(state)) - .align_x(iced::Center); - } else if !state.must_set_trump { - scores_col = scores_col - .push(build_bidding_panel(state)) - .align_x(iced::Center); - } - - 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() -} - -/// ======================================= -/// 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 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; - - println!( - "Card: {}, Valid: {}, Can Play: {}", - format_card(card), - is_valid, - can_play - ); - let img_handle = state.get_card_image(*card).clone(); - - let card_btn = if can_play { - stack![ - button(text("").size(11)) - .on_press(AppMessage::PlayCard(*card)) - .padding(8) - .width(80) - .height(120), - Image::new(img_handle).width(80).height(120), - ] - } else { - stack![Image::new(img_handle).width(80).height(120).opacity(0.5),] - }; - 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 index ece2427..912c143 100644 --- a/src/client/views/lobby.rs +++ b/src/client/views/lobby.rs @@ -1,12 +1,16 @@ //! Lobby view for waiting players before game start. use iced::{ - widget::{column, container, row, scrollable, stack, text, text_input, Column, Image}, ContentFit, Element, + widget::{Column, Image, column, container, row, scrollable, stack, text, text_input}, }; -use crate::api::Lobby; +use super::utils::menu_panel; use crate::client::{App, AppMessage}; +use crate::{ + api::{Lobby, TextColor}, + client::views::utils::back_button_footer, +}; pub fn view_lobby_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { if !state.connected { @@ -27,7 +31,7 @@ fn view_not_connected<'a>(state: &'a App) -> Element<'a, AppMessage> { .height(iced::Length::Fill) .content_fit(ContentFit::Cover), container(column![ - text("Nicht verbunden zum Server. / IP wurde falsch eingegeben"), + text("Nicht verbunden zum Server. / IP wurde falsch eingegeben").white(), state.btn_back.view().padding(0) ]) .width(iced::Length::Fill) @@ -45,7 +49,7 @@ fn view_no_lobby<'a>(state: &'a App) -> Element<'a, AppMessage> { .height(iced::Length::Fill) .content_fit(ContentFit::Cover), container(column![ - text("Keine Lobby vorhanden"), + text("Keine Lobby vorhanden").white(), state.btn_back.view().padding(0) ]) .width(iced::Length::Fill) @@ -62,7 +66,7 @@ fn view_lobby_content<'a>(state: &'a App, lobby: &'a Lobby) -> Element<'a, AppMe let start_button = build_start_button(state, lobby); let content = column![ - text("Lobby").size(30), + text("Lobby").size(30).white(), row![ text("Host Address:"), text_input("Address to share", &state.ip) @@ -72,7 +76,8 @@ fn view_lobby_content<'a>(state: &'a App, lobby: &'a Lobby) -> Element<'a, AppMe "Spieler: {}/{}", lobby.players.len(), state.host_player_count.to_usize() - )), + )) + .white(), player_rows, scrollable(chat_block).height(150).width(400), row![ @@ -90,11 +95,7 @@ fn view_lobby_content<'a>(state: &'a App, lobby: &'a Lobby) -> Element<'a, AppMe .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) + menu_panel(state, "Lobby", content.into(), back_button_footer(state)) ] .into() } @@ -115,7 +116,8 @@ fn build_player_rows<'a>(state: &'a App, lobby: &'a Lobby) -> Column<'a, AppMess "{}{}", if p.is_host { "(Host) " } else { "" }, p.name - )), + )) + .white(), toggle ]; player_rows = player_rows.push(row); @@ -128,7 +130,7 @@ 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 = chat_block.push(text(format!("{}: {}", sender, msg)).white()); } chat_block @@ -164,5 +166,5 @@ fn build_start_button<'a>(state: &'a App, lobby: &'a Lobby) -> iced::widget::Row "" }; - row![start_button_view, text(status_text)].spacing(5) + row![start_button_view, text(status_text).white()].spacing(5) } diff --git a/src/client/views/menu.rs b/src/client/views/menu.rs index 1fda5cf..fecbaf0 100644 --- a/src/client/views/menu.rs +++ b/src/client/views/menu.rs @@ -1,18 +1,20 @@ use iced::{ + ContentFit, Element, widget::{ - column, container, pick_list, row, scrollable, stack, text, text_input, Column, Image, + Column, Image, Row, column, container, pick_list, row, scrollable, slider, stack, text, + text_input, }, - ContentFit, Element, }; +use super::utils::{TITLE_FONT, back_button_footer, background_image, menu_panel}; +use crate::api::TextColor; 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) + .white() .align_x(iced::alignment::Horizontal::Center) .align_y(iced::alignment::Vertical::Top); @@ -24,12 +26,19 @@ pub fn view_main_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { .align_x(iced::alignment::Horizontal::Left); let menu_right: Column<'a, AppMessage> = column![ - state.btn_rules.view().padding(10), + state.btn_options.view().padding(10), state.btn_close.view().padding(10), ] .spacing(100) .align_x(iced::alignment::Horizontal::Right); - + let bottom: Row<'a, AppMessage> = row![if state.disconnected { + text("You have been disconnected from the server. Please check your connection and try again.") + .size(20) + .color(iced::Color::from_rgb(1.0, 0.0, 0.0)) + .white() + } else { + text("") + }]; stack![ Image::new(state.img_main_menu.clone()) .width(iced::Length::Fill) @@ -51,7 +60,12 @@ pub fn view_main_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { .center_x(iced::Fill) .center_y(iced::Fill), ] - .align_y(iced::alignment::Vertical::Center) + .align_y(iced::alignment::Vertical::Center), + container(bottom) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .center_x(iced::Fill) + .align_y(iced::alignment::Vertical::Bottom), ] .into() } @@ -67,10 +81,10 @@ pub fn view_host_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { let can_join = !state.host_name.is_empty(); let content = column![ - text("Host").size(30), - text("Name:"), + text("Host").size(30).white(), + text("Name:").white(), text_input("Your Name", &state.host_name).on_input(AppMessage::HostNameChanged), - text("Player Count:"), + text("Player Count:").white(), pick_list( count_options.clone(), Some(state.host_player_count), @@ -103,8 +117,8 @@ 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("Join").size(30).white(), + text("Name:").white(), text_input("Your Name", &state.join_name).on_input(AppMessage::JoinNameChanged), text_input("Server Address", &state.ip).on_input(AppMessage::ServerAddressChanged), if can_join { @@ -112,7 +126,7 @@ pub fn view_join_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { } else { state.btn_connect.view_disabled().padding(0) }, - text("Progress:"), + text("Progress:").white(), ] .spacing(10) .padding(20) @@ -131,55 +145,96 @@ pub fn view_join_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { .into() } +pub fn view_options_menu<'a>(state: &'a App) -> Element<'a, AppMessage> { + let content = column![ + text("Musiklautstärke:").white(), + slider( + 0.0..=100.0, + state.music_volume as f32, + AppMessage::MusicVolumeChanged + ) + .step(1.0) + .width(iced::Length::Fill), + text(format!("{}%", state.music_volume)).white(), + text("SFX Lautstärke:").white(), + slider( + 0.0..=100.0, + state.sfx_volume as f32, + AppMessage::SfxVolumeChanged + ) + .step(1.0) + .width(iced::Length::Fill), + text(format!("{}%", state.sfx_volume)).white(), + state.btn_rules.view().padding(0), + ] + .spacing(10) + .padding(20) + .width(400) + .align_x(iced::alignment::Horizontal::Left); + + stack![ + background_image(&state.img_background), + menu_panel( + state, + "Optionen:", + 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"), + text("Grundlagen:").size(20).white(), + text("Wizard ist ein Stichspiel, bei dem das Ziel ist, möglichst genau vorherzusagen,").white(), + text("wie viele Stiche man pro Runde macht.").white(), + text("Die Anzahl der Spieler bestimmt die Anzahl der gespielten Runden:").white(), + text(" - 3 Spieler: 20 Runden").white(), + text(" - 4 Spieler: 16 Runden").white(), + text(" - 5 Spieler: 13 Runden").white(), + text(" - 6 Spieler: 11 Runden").white(), + text("").white(), + text("Karten:").size(20).white(), + text("Das Wizard Deck besteht aus 60 Karten:").white(), + text(" - Zahlen 1-13 - Kreuz").white(), + text(" - Zahlen 1-13 - Pik").white(), + text(" - Zahlen 1-13 - Herz").white(), + text(" - Zahlen 1-13 - Karo").white(), + text(" - 4 Wizards").white(), + text(" - 4 Jester").white(), + text("").white(), + text("Stiche:").size(20).white(), + text("Ein Stich wird von der höchsten Karte, oder dem ersten gelegten Wizard gewonnen").white(), + text("\n").white(), + text("Trumpf:").size(20).white(), + text("Ein Trumpf ist eine bestimmte Farbe, die im Wert über allen nicht-trumpf Farben steht").white(), + text("wird also eine nicht-Trumpf 12 gelegt, und darauf eine Trumpf 10, gewinnt die Trumpf 10 den Stich").white(), + text("die Trumpf-Farbe wird am Anfang jeder Runde festgelegt").white(), + text("\n").white(), + text("\n").white(), + text("Spielablauf:").size(24).white(), + text("Jede Runde in Wizard hat denselben Ablauf:").size(20).white(), + text("Am Anfang jeder Runde wird ein Dealer und der Trumpf bestimmt.").white(), + text("Wenn der Trumpf ein Wizard ist, wird die Trumpf-Farbe vom Dealer festgelegt.").white(), + text("Ist der Trumpf ein Narr, gibt es in dieser Runde keinen Trumpf").white(), + text("Danach setzt jeder Spieler seinen Bid, also die Anzahl der Tricks, die er in dieser Runde gewinnen muss.").white(), + text("Es werden so viele Karten ausgeteilt, wie die aktuelle Rundennummer angibt, also in Runde 1 eine Karte, in Runde 2 zwei Karten, etc.").white(), + text("ACHTUNG! - Die Gesamtzahl aller angesagten Stiche darf nicht gleich der Anzahl der möglichen Stiche sein.").white(), + text("Anschließend spielt jeder der Reihe nach genau eine Karte.").white(), + text("Hat jeder Spieler genau eine Karte gelegt, beginnt der Gewinner dieses Stichs den nächsten Stich.").white(), + text("Sind alle Karten ausgespielt, werden Stiche mit den Ansagen abgeglichen und die entsprechenden Punkte verteilt.").white(), + text("\n").white(), + text("Punkte:").size(20).white(), + text("Hat ein Spieler genau die Anzahl an Stichen gewonnen, die er angesagt hat, bekommt er 20 Punkte plus 10 Punkte pro gewonnenem Stich.").white(), + text("Beispiel: 3 Stiche angesagt, 3 Stiche gewonnen -> 20 + 10*3 = 50 Punkte").white(), + text("Hat ein Spieler nicht die Anzahl an Stichen gewonnen, die er angesagt hat, bekommt er -10 Punkte pro zu viel oder zu wenig gewonnenem Stich.").white(), + text("Beispiel: 3 Stiche angesagt, 5 Stiche gewonnen -> -10 * (5-3) = -20 Punkte").white(), + text("\n").white(), + text("Ende:").size(20).white(), + text("In der letzten Runde gibt es keinen Trumpf").white(), + text("Es werden so viele Runden gespielt bis alle Karten ausgeteilt wurden, also bei 3 Spielern 20 Runden, bei 4 Spielern 15 Runden, etc.").white(), + text("Der Spieler mit den meisten Punkten am Ende des Spiels gewinnt!").white() ]; let max_h = (state.window_size.height * 0.9) as u32; diff --git a/src/client/views/mod.rs b/src/client/views/mod.rs index 4af654c..ab41a02 100644 --- a/src/client/views/mod.rs +++ b/src/client/views/mod.rs @@ -1,5 +1,5 @@ mod button; -mod gameplay; +mod debug_gameplay; mod lobby; mod menu; mod utils; @@ -8,6 +8,8 @@ pub use button::{Button, ButtonMessage}; use iced::Element; +use crate::ui_element_traits::Viewable; + use super::{App, AppMessage, MenuState}; pub fn view(state: &App) -> Element<'_, AppMessage> { @@ -16,8 +18,9 @@ pub fn view(state: &App) -> Element<'_, AppMessage> { MenuState::Host => menu::view_host_menu(state), MenuState::Join => menu::view_join_menu(state), MenuState::Rules => menu::view_rules_menu(state), + MenuState::Options => menu::view_options_menu(state), MenuState::Lobby => lobby::view_lobby_menu(state), - MenuState::Playing => gameplay::view_gameplay(state), - MenuState::PlayingTest => gameplay::view_test_gameplay(state), + MenuState::Playing => state.game_view.view().into(), + MenuState::PlayingTest => debug_gameplay::view_debug_gameplay(state), } } diff --git a/src/client/views/utils.rs b/src/client/views/utils.rs index 5da51be..26b5d36 100644 --- a/src/client/views/utils.rs +++ b/src/client/views/utils.rs @@ -2,15 +2,15 @@ use std::fs::File; use std::io::Read; use iced::{ - widget::{column, container, row, stack, text, Column, Image}, ContentFit, Element, Font, + widget::{Column, Image, column, container, row, stack, text}, }; use crate::client::{App, AppMessage}; pub const TITLE_FONT: Font = Font::with_name("Magic School One"); -use crate::api::{Card, PlayerId, Value}; +use crate::api::{Card, PlayerId, TextColor, Value}; /// Format a card for display (e.g., "5 Red", "Wizard", "Jester"). pub fn format_card(card: &Card) -> String { @@ -98,14 +98,9 @@ pub fn menu_panel<'a>( .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), + container(text(title).size(38).font(TITLE_FONT).white(),) + .height(48u32) + .center_x(iced::Fill), ), ) .center_x(iced::Fill), diff --git a/src/client/ws.rs b/src/client/ws.rs index ede021c..60fc186 100644 --- a/src/client/ws.rs +++ b/src/client/ws.rs @@ -46,7 +46,7 @@ pub async fn connect_ws(ws_tx: WsConnection, server_rx: ServerMsgReceiver, ip: S } } } - println!("Receive loop ended"); + let _ = srv_tx.send(ServerMessage::ConnectionClosed); }); // Waits for either task to complete. diff --git a/src/gamelogic/game.rs b/src/gamelogic/game.rs index ab9a2b4..c24da81 100644 --- a/src/gamelogic/game.rs +++ b/src/gamelogic/game.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use crate::api::{Card, PlayerId, Suit, Value}; -use crate::gamelogic::{round::Round, GameEvent}; +use crate::gamelogic::{GameEvent, round::Round}; use rand::seq::SliceRandom; #[derive(Clone, PartialEq, Eq, Debug)] diff --git a/src/gamelogic/round.rs b/src/gamelogic/round.rs index 5c59da7..bf97344 100644 --- a/src/gamelogic/round.rs +++ b/src/gamelogic/round.rs @@ -1,6 +1,6 @@ // use iced::window::UserAttention; -use rand::rng; use rand::Rng; +use rand::rng; use std::collections::HashMap; use strum::IntoEnumIterator; @@ -68,11 +68,9 @@ impl Round { let mut dealer_needs_to_set_trump = false; if !deck.is_empty() { let trump_card = draw_random_card(&mut deck); - if trump_card.value != Value::Wizard { - // since out jester doesnt have a suit, we can show it to players - // to indicate that there is no trump suit this round - trump = Some(trump_card); - } + + trump = Some(trump_card); + dealer_needs_to_set_trump = trump_card.value == Value::Wizard; } diff --git a/src/gameplay_ui/hand/hand_card/mod.rs b/src/gameplay_ui/hand/hand_card/mod.rs index fa7d755..18ec977 100644 --- a/src/gameplay_ui/hand/hand_card/mod.rs +++ b/src/gameplay_ui/hand/hand_card/mod.rs @@ -6,48 +6,37 @@ mod animation_hover; mod animation_play; mod animation_playable; -use crate::api::{ - get_card_path, Card, FALSE_PLAYED_PATH, FRAME_PLAYABLE_FOCUSED_PATH, FRAME_PLAYABLE_PATH, -}; +use crate::api::{Card, FALSE_PLAYED_PATH, FRAME_PLAYABLE_FOCUSED_PATH, FRAME_PLAYABLE_PATH}; +use crate::client::audio::Sfx; use crate::client::{AppMessage, TaskBatcher}; +use crate::gameplay_ui::hand::HandMessage; use crate::gameplay_ui::hand::hand_card::{ animation_draw::DrawAnimation, animation_false_played::FalsePlayedAnimation, animation_focus::FocusAnimation, animation_hide::HideAnimation, animation_hover::HoverAnimation, animation_play::PlayAnimation, animation_playable::PlayableAnimation, }; -use crate::gameplay_ui::hand::HandMessage; -use crate::gameplay_ui::table::middle::card_stack::CardStackMessage; -use crate::gameplay_ui::{card_height_hand, card_img_base_scale, card_width_hand}; +use crate::gameplay_ui::{GameViewMessage, card_height_hand, card_img_base_scale, card_width_hand}; use crate::ui_element_traits::*; use iced::Task; use iced::{ - mouse::Interaction, - widget::{container, image, pin, stack, Container, MouseArea}, ContentFit::Fill, Point, Size, + mouse::Interaction, + widget::{Container, MouseArea, container, image, pin, stack}, }; use std::ops::Not; -pub fn f32_min_2(v1: f32, v2: f32) -> f32 { - if v1 < v2 { - return v1; - } - v2 -} - #[derive(Debug, Clone)] pub enum CardMessage { - Played(usize), - FalsePlayed(usize), - Hovered(usize), - NotHovered(usize), - Hide(usize), - Show(usize), - Draw(usize), - CursorMoved(usize, Point), - ShowPlayableStatus(usize, bool), - MakeClickable(usize), + Played(Card), + Clicked(Card), + Hovered(Card), + NotHovered(Card), + Hide(Card), + Show(Card), + CursorMoved(Card, Point), + ShowPlayableStatus(Card, bool), } impl Message for CardMessage { @@ -56,36 +45,16 @@ impl Message for CardMessage { } } -impl ReplaceUsize for CardMessage { - fn replace_usize(&self, value: usize) -> Self { - match self { - CardMessage::Played(_) => CardMessage::Played(value), - CardMessage::FalsePlayed(_) => CardMessage::FalsePlayed(value), - CardMessage::Hovered(_) => CardMessage::Hovered(value), - CardMessage::NotHovered(_) => CardMessage::NotHovered(value), - CardMessage::Hide(_) => CardMessage::Hide(value), - CardMessage::Show(_) => CardMessage::Show(value), - CardMessage::Draw(_) => CardMessage::Draw(value), - CardMessage::CursorMoved(_, point) => CardMessage::CursorMoved(value, *point), - CardMessage::ShowPlayableStatus(_, bool) => { - CardMessage::ShowPlayableStatus(value, *bool) - } - CardMessage::MakeClickable(_) => CardMessage::MakeClickable(value), - } - } -} - #[derive(Debug, Clone)] pub struct ViewableHandCard { - id: usize, - card: Card, + pub my_turn: bool, + pub card: Card, window_size: Size, - clickable: bool, playable: bool, show_playable_status: bool, rotation: f32, - draw_animation: DrawAnimation, + pub draw_animation: DrawAnimation, hover_animation: HoverAnimation, play_animation: PlayAnimation, playable_animation: PlayableAnimation, @@ -100,13 +69,12 @@ pub struct ViewableHandCard { } impl ViewableHandCard { - pub fn new(id: usize, card: Card, window_size: Size, playable: bool) -> Self { + pub fn new(card: Card, window_size: Size, playable: bool) -> Self { let play_duration: usize = 12; let mut viewable_card: ViewableHandCard = Self { - id, + my_turn: false, card, window_size, - clickable: true, playable, show_playable_status: false, rotation: 0.0, @@ -119,7 +87,7 @@ impl ViewableHandCard { focus_animation: FocusAnimation::new(70), hide_animation: HideAnimation::new(play_duration), - img_handle: iced::widget::image::Handle::from_path(get_card_path(card)), + img_handle: iced::widget::image::Handle::from_path(card.img_path()), img_frame_playable: iced::widget::image::Handle::from_path(FRAME_PLAYABLE_PATH), img_frame_playable_focused: iced::widget::image::Handle::from_path( FRAME_PLAYABLE_FOCUSED_PATH, @@ -128,16 +96,12 @@ impl ViewableHandCard { }; viewable_card .play_animation - .on_end(HandMessage::DeleteCard(id).convert_msg()); - viewable_card - .hide_animation - .on_end(CardMessage::MakeClickable(id).convert_msg()); - viewable_card.playable_animation.start(); + .on_end_reached(HandMessage::DeleteCard(card).convert_msg()); + viewable_card.playable_animation.start_infinite(); viewable_card } - - pub fn id(&self) -> usize { - self.id + pub fn validate(&mut self, valid_cards: Vec) { + self.playable = valid_cards.contains(&self.card); } } @@ -147,71 +111,66 @@ impl Notifiable for ViewableHandCard { fn update_with_msg(&mut self, msg: CardMessage) -> Task { let mut tb = TaskBatcher::new(); match msg { - CardMessage::Hovered(id) => { - if id == self.id { + CardMessage::Hovered(card) => { + if card == self.card { self.hover_animation.start(); self.focus_animation.start(); + tb.push_msg(AppMessage::PlaySfx(Sfx::CardHover)) } else { // Sometimes on_exit for a viewed card won't register // and won't send the CardNotHovered msg. // To ensure that an unhovered card is not sticking up all the time // send NotHovered to all cards except the hovered one. - tb.push(CardMessage::NotHovered(self.id).convert_msg_to_task()) + tb.push(CardMessage::NotHovered(self.card).convert_msg_to_task()) }; } - CardMessage::Played(id) => { - if id == self.id { - self.clickable = false; + // Comes from Server, so this needs no checks. + CardMessage::Played(card) => { + if card == self.card { self.play_animation.start(); - tb.push(CardStackMessage::CardPlayed(self.card).convert_msg_to_task()); }; } - CardMessage::NotHovered(id) => { - if id == self.id { + CardMessage::Clicked(card) => { + if card == self.card { + if self.playable { + tb.push_msg(HandMessage::NotMyTurn); + tb.push_msg(GameViewMessage::TryPlayCard(self.card)); + } else { + self.false_played_animation.start(); + tb.push_msg(AppMessage::PlaySfx(Sfx::CardError)) + } + } + } + CardMessage::NotHovered(card) => { + if card == self.card { self.hover_animation.reverse(); self.focus_animation.reset(); self.rotation = 0.0; }; } - CardMessage::Hide(id) => { - if id == self.id { - self.clickable = false; + CardMessage::Hide(card) => { + if card == self.card { self.hide_animation.start(); }; } - CardMessage::Show(id) => { - if id == self.id { + CardMessage::Show(card) => { + if card == self.card { self.hide_animation.reverse(); }; } - CardMessage::Draw(id) => { - if id == self.id { - self.draw_animation.start(); - }; - } - CardMessage::CursorMoved(id, point) => { - if id == self.id { + CardMessage::CursorMoved(card, point) => { + if card == self.card { let halve_card_width: f32 = self.width() / 2.0; let factor_width: f32 = (point.x - halve_card_width) / halve_card_width; // The factor 0.05 is representing a 5% rotation offset on maximum. self.rotation = 0.05 * factor_width; }; } - CardMessage::FalsePlayed(id) => { - if id == self.id { - self.false_played_animation.start(); - }; - } - CardMessage::ShowPlayableStatus(id, do_show) => { - if id == self.id { + CardMessage::ShowPlayableStatus(card, do_show) => { + if card == self.card { self.show_playable_status = do_show; }; } - CardMessage::MakeClickable(id) => { - if id == self.id { - self.clickable = true; - } - } } tb.batch() } @@ -260,17 +219,10 @@ impl Viewable for ViewableHandCard { /// This calculation is moved to view_and_move, because otherwise /// some effects of the card won't render for an unknown reason. fn view<'a>(&self) -> Container<'a, AppMessage> { - let img_opacity: f32 = f32_min_2( - self.play_animation.get_opacity(), - self.hide_animation.get_opacity(), - ); - - let hover_effect_opacity: f32 = f32_min_2(img_opacity, self.focus_animation.get_opacity()); - - let playable_opacity: f32 = f32_min_2(img_opacity, self.playable_animation.get_opacity()); - - let false_played_opacity: f32 = - f32_min_2(img_opacity, self.false_played_animation.get_opacity()); + let img_opacity: f32 = self + .play_animation + .get_opacity() + .min(self.hide_animation.get_opacity()); let width: f32 = self.width() * self.hover_animation.get_expansion() @@ -296,56 +248,66 @@ impl Viewable for ViewableHandCard { .opacity(img_opacity); card = card.push(img); - if self.playable { - if self.show_playable_status { - let playable_effect = image(self.img_frame_playable.clone()) + if self.my_turn { + let hover_effect_opacity: f32 = self.focus_animation.get_opacity().min(img_opacity); + let playable_opacity: f32 = self.playable_animation.get_opacity().min(img_opacity); + let false_played_opacity: f32 = + self.false_played_animation.get_opacity().min(img_opacity); + if self.playable { + if self.show_playable_status { + let playable_effect = image(self.img_frame_playable.clone()) + .content_fit(Fill) + .width(width) + .height(height) + .rotation(rotation) + .scale(scale) + .opacity(playable_opacity); + card = card.push(playable_effect); + } + let hover_effect = image(self.img_frame_playable_focused.clone()) + .content_fit(Fill) + .width(width) + .height(height) + .rotation(rotation) + .scale(scale) + .opacity(hover_effect_opacity); + card = card.push(hover_effect); + } else { + let false_played_effect = image(self.img_false_played.clone()) .content_fit(Fill) .width(width) .height(height) .rotation(rotation) .scale(scale) - .opacity(playable_opacity); - card = card.push(playable_effect); + .opacity(false_played_opacity); + card = card.push(false_played_effect) } - let hover_effect = image(self.img_frame_playable_focused.clone()) - .content_fit(Fill) - .width(width) - .height(height) - .rotation(rotation) - .scale(scale) - .opacity(hover_effect_opacity); - card = card.push(hover_effect); - } else { - let false_played_effect = image(self.img_false_played.clone()) - .content_fit(Fill) - .width(width) - .height(height) - .rotation(rotation) - .scale(scale) - .opacity(false_played_opacity); - card = card.push(false_played_effect) - } + }; - let msg_hovered: AppMessage = CardMessage::Hovered(self.id).convert_msg(); - let msg_not_hoverd: AppMessage = CardMessage::NotHovered(self.id).convert_msg(); - let msg_show_playable_status = - HandMessage::ShowPlayableStatus(self.show_playable_status.not()).convert_msg(); - let card_id: usize = self.id; + let msg_hovered: AppMessage = CardMessage::Hovered(self.card).convert_msg(); + let msg_not_hoverd: AppMessage = CardMessage::NotHovered(self.card).convert_msg(); + let card_data = self.card; let msg_cursor_moved = - move |position: Point| CardMessage::CursorMoved(card_id, position).convert_msg(); - let msg_played = CardMessage::Played(self.id).convert_msg(); - let msg_false_played = CardMessage::FalsePlayed(self.id).convert_msg(); + move |position: Point| CardMessage::CursorMoved(card_data, position).convert_msg(); let mut mouse_area = MouseArea::new(card) .on_enter(msg_hovered) .on_exit(msg_not_hoverd) - .on_right_press(msg_show_playable_status) - .on_move(msg_cursor_moved) - .interaction(Interaction::Pointer); - if self.playable { - mouse_area = mouse_area.on_double_click(msg_played) - } else { - mouse_area = mouse_area.on_double_click(msg_false_played) + .on_move(msg_cursor_moved); + + if self.my_turn { + let msg_show_playable_status = + HandMessage::ShowPlayableStatus(self.show_playable_status.not()).convert_msg(); + let msg_double_clicked = CardMessage::Clicked(self.card).convert_msg(); + let interaction: Interaction = if self.playable { + Interaction::Pointer + } else { + Interaction::NotAllowed + }; + mouse_area = mouse_area + .on_right_press(msg_show_playable_status) + .on_press(msg_double_clicked) + .interaction(interaction); } container(mouse_area) diff --git a/src/gameplay_ui/hand/mod.rs b/src/gameplay_ui/hand/mod.rs index 720aa98..3bc2b94 100644 --- a/src/gameplay_ui/hand/mod.rs +++ b/src/gameplay_ui/hand/mod.rs @@ -1,123 +1,116 @@ pub mod hand_card; use crate::animation::AnimationStarter; +use crate::api::{Card, PlayerId}; use crate::client::{AppMessage, TaskBatcher}; -use crate::gamelogic::round::random_card; use crate::gameplay_ui::hand::hand_card::{CardMessage, ViewableHandCard}; -use crate::gameplay_ui::{card_column_step_hand, card_row_step_hand}; +use crate::gameplay_ui::{GameViewMessage, card_column_step_hand, card_row_step_hand}; use crate::ui_element_traits::*; use iced::{ - widget::{container, pin, stack, Container, Pin, Stack}, Point, Size, Task, + widget::{Container, Pin, Stack, container, pin, stack}, }; -use indexmap::{map::MutableKeys, IndexMap}; #[derive(Debug, Clone)] pub enum HandMessage { CardMessage(CardMessage), - DeleteCard(usize), - DrawCards(Vec), + PlayedCard(Card), + DeleteCard(Card), + DrawCards(Vec, Vec), HideCards, ShowCards, ShowPlayableStatus(bool), + ChangeTurn(PlayerId, Vec), + NobodiesTurn, + Draw(usize), + NotMyTurn, } impl Message for HandMessage { fn convert_msg_from(msg: Self) -> AppMessage { - AppMessage::HandMessage(msg) + GameViewMessage::convert_msg_from(GameViewMessage::HandMessage(msg)) + } +} + +impl ReplaceUsize for HandMessage { + fn replace_usize(&self, value: usize) -> Self { + match &self { + HandMessage::Draw(_) => HandMessage::Draw(value), + _ => self.clone(), + } + } +} + +use crate::animation::ReversableBasicAnimation; +use derive_more::{Deref, DerefMut}; + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct HideAnimationTracker(ReversableBasicAnimation); + +impl HideAnimationTracker { + pub fn new(duration: usize) -> Self { + Self(ReversableBasicAnimation::new(duration)) } } #[derive(Debug)] pub struct ViewableHand { window_size: Size, - - pub cards: IndexMap, + pub my_id: Option, + pub cards: Vec, hovered_card_row_low: bool, allow_hover: bool, - hovered_card_id: Option, - played_card_id: Option, - top_card_id_upper: Option, - top_card_id_lower: Option, + played_card: Option, + top_card_upper: Option, + top_card_lower: Option, + hide_animation_tracker: HideAnimationTracker, // AI-Usage: Claude.ai for the idea of passing a union type for a generic // where the type doesn't matter. - draw_animation_starter: AnimationStarter, + draw_animation_starter: AnimationStarter, } impl ViewableHand { pub fn new(window_size: Size) -> Self { - Self { + let mut vh = Self { window_size, - cards: IndexMap::from([]), + my_id: None, + cards: Vec::new(), hovered_card_row_low: true, allow_hover: true, - hovered_card_id: None, - played_card_id: None, - top_card_id_upper: None, - top_card_id_lower: None, - // 3 is the delay for the animation start. - draw_animation_starter: AnimationStarter::new(3, 10, CardMessage::Draw(0)), - } - } - - /// This function is only for testing and may return impossible dupes of cards. - pub fn build_test_cards(window_size: Size) -> Vec { - vec![ - ViewableHandCard::new(0, random_card(), window_size, true), - ViewableHandCard::new(1, random_card(), window_size, false), - ViewableHandCard::new(2, random_card(), window_size, true), - ViewableHandCard::new(3, random_card(), window_size, true), - ViewableHandCard::new(4, random_card(), window_size, true), - ViewableHandCard::new(5, random_card(), window_size, true), - ViewableHandCard::new(6, random_card(), window_size, true), - ViewableHandCard::new(7, random_card(), window_size, false), - ViewableHandCard::new(8, random_card(), window_size, true), - ViewableHandCard::new(9, random_card(), window_size, true), - ViewableHandCard::new(10, random_card(), window_size, true), - ViewableHandCard::new(11, random_card(), window_size, true), - ViewableHandCard::new(12, random_card(), window_size, true), - ViewableHandCard::new(13, random_card(), window_size, false), - ViewableHandCard::new(14, random_card(), window_size, true), - ViewableHandCard::new(15, random_card(), window_size, true), - ViewableHandCard::new(16, random_card(), window_size, true), - ViewableHandCard::new(17, random_card(), window_size, true), - ViewableHandCard::new(18, random_card(), window_size, true), - ViewableHandCard::new(19, random_card(), window_size, true), - ] + played_card: None, + top_card_upper: None, + top_card_lower: None, + hide_animation_tracker: HideAnimationTracker::new(12), + draw_animation_starter: AnimationStarter::new(3, 10, HandMessage::Draw(0)), + }; + vh.hide_animation_tracker + .on_end_reached(HandMessage::ShowCards.convert_msg()); + vh } - pub fn set_cards(&mut self, cards: Vec) { + pub fn set_cards(&mut self, cards: Vec, valid_cards: Vec) { self.cards.clear(); - for card in cards.iter() { - self.cards.insert(card.id(), card.clone()); + for card in self.create_viewable_cards(cards, valid_cards).iter() { + self.cards.push(card.clone()); } } /// Create ViewableHandCards from game cards (static version for use in update functions) - pub fn create_viewable_cards_static( - game_cards: &[crate::api::Card], - valid_cards: &[crate::api::Card], - window_size: Size, + pub fn create_viewable_cards( + &self, + game_cards: Vec, + valid_cards: Vec, ) -> Vec { game_cards .iter() - .enumerate() - .map(|(id, &card)| { - let playable = valid_cards.contains(&card); - ViewableHandCard::new(id, card, window_size, playable) + .map(|card| { + let playable = valid_cards.contains(card); + ViewableHandCard::new(*card, self.window_size, playable) }) .collect() } - pub fn card_ids(&self) -> Vec { - let mut card_ids: Vec = vec![]; - for (id, _) in self.cards.iter() { - card_ids.push(*id); - } - card_ids - } - /// The return value represents the step length between cards in a row length of 10 cards. fn card_minimum_step(&self) -> f32 { card_column_step_hand(ViewableHandCard::width_for(self.window_size)) @@ -240,7 +233,7 @@ impl ViewableHand { fn update_cards_with_msg(&mut self, msg: CardMessage) -> Task { let mut tb = TaskBatcher::new(); - for (_, card) in self.cards.iter_mut() { + for card in self.cards.iter_mut() { tb.push(card.update_with_msg(msg.clone())) } tb.batch() @@ -255,64 +248,99 @@ impl Notifiable for ViewableHand { match msg { HandMessage::CardMessage(card_msg) => { match card_msg { - CardMessage::Hovered(id) => { + CardMessage::Hovered(card) => { if self.allow_hover { tb.push(self.update_cards_with_msg(card_msg)); - self.hovered_card_id = Some(id); if self.upper_row_exists() && // AI-Usage: Claude.ai for learning how to see if value is in a vec // without the last few elements. - self.card_ids()[..self.upper_row_card_count()].contains(&id) + self.cards[..self.upper_row_card_count()].iter().map(|vhc| vhc.card).any(|ls_card| ls_card == card) { self.hovered_card_row_low = false; - self.top_card_id_upper = Some(id); + self.top_card_upper = Some(card); } else { self.hovered_card_row_low = true; - self.top_card_id_lower = Some(id); + self.top_card_lower = Some(card); } } } - CardMessage::Played(id) => { - self.played_card_id = Some(id); + CardMessage::Clicked(card) => { + self.played_card = Some(card); tb.push(self.update_cards_with_msg(card_msg)); - tb.push(HandMessage::HideCards.convert_msg_to_task()); } _ => { tb.push(self.update_cards_with_msg(card_msg)); } } } + HandMessage::NotMyTurn => { + for card in self.cards.iter_mut() { + card.my_turn = false; + } + } + HandMessage::Draw(card_number) => { + if card_number <= self.cards.len() { + self.cards[card_number].draw_animation.start() + } + } + HandMessage::PlayedCard(played_card) => { + for card in self.cards.iter() { + if played_card == card.card { + tb.push(CardMessage::Played(played_card).convert_msg_to_task()); + tb.push(HandMessage::HideCards.convert_msg_to_task()); + } + } + } HandMessage::HideCards => { self.allow_hover = false; - for (id, card) in self.cards.iter_mut() { - if self.played_card_id.is_some() && self.played_card_id.unwrap() != *id { - tb.push(card.update_with_msg(CardMessage::Hide(*id))); + for card in self.cards.iter_mut() { + if self.played_card.is_some() && self.played_card.unwrap() != card.card { + tb.push(card.update_with_msg(CardMessage::Hide(card.card))); } } - self.played_card_id = None; + self.played_card = None; + self.hide_animation_tracker.start() } - HandMessage::DeleteCard(id) => { - self.cards.shift_remove(&id); - tb.push(HandMessage::ShowCards.convert_msg_to_task()); + HandMessage::DeleteCard(card) => { + self.cards.retain(|vhc| vhc.card != card); } HandMessage::ShowCards => { + self.hide_animation_tracker.reset(); self.allow_hover = true; - for (id, card) in self.cards.iter_mut() { - tb.push(card.update_with_msg(CardMessage::Show(*id))); + for card in self.cards.iter_mut() { + tb.push(card.update_with_msg(CardMessage::Show(card.card))); } } - HandMessage::DrawCards(cards) => { - self.set_cards(cards.clone()); + HandMessage::DrawCards(cards, valid_cards) => { + self.set_cards(cards, valid_cards); self.hovered_card_row_low = true; - self.hovered_card_id = None; - self.top_card_id_lower = None; - self.top_card_id_upper = None; + self.top_card_lower = None; + self.top_card_upper = None; self.update_size(self.window_size); - self.draw_animation_starter.start(self.cards.len()); + tb.push(self.draw_animation_starter.start(self.cards.len())); } HandMessage::ShowPlayableStatus(do_show) => { - for (id, card) in self.cards.iter_mut() { - tb.push(card.update_with_msg(CardMessage::ShowPlayableStatus(*id, do_show))); + for card in self.cards.iter_mut() { + tb.push( + card.update_with_msg(CardMessage::ShowPlayableStatus(card.card, do_show)), + ); + } + } + HandMessage::ChangeTurn(player_id, valid_cards) => { + if player_id == self.my_id.unwrap() { + for card in self.cards.iter_mut() { + card.my_turn = true; + card.validate(valid_cards.clone()); + } + } else { + for card in self.cards.iter_mut() { + card.my_turn = false; + } + } + } + HandMessage::NobodiesTurn => { + for card in self.cards.iter_mut() { + card.my_turn = false; } } } @@ -324,8 +352,8 @@ impl Animated for ViewableHand { fn update_animations(&mut self) -> Task { let mut tb = TaskBatcher::new(); tb.push(self.draw_animation_starter.next_frame()); - - for (_, card) in self.cards.iter_mut2() { + tb.push(self.hide_animation_tracker.next_frame()); + for card in self.cards.iter_mut() { tb.push(card.update_animations()); } tb.batch() @@ -335,7 +363,7 @@ impl Animated for ViewableHand { impl Resizable for ViewableHand { fn update_size(&mut self, window_size: Size) { self.window_size = window_size; - for (_, card) in self.cards.iter_mut2() { + for card in self.cards.iter_mut() { card.update_size(window_size); } } @@ -379,7 +407,7 @@ impl Viewable for ViewableHand { } // Add all cards to their corresponding row. - for (i, (card_id, card)) in self.cards.iter().enumerate() { + for (i, card) in self.cards.iter().enumerate() { let viewable_card: Container<'_, AppMessage> = card.view_and_move(x_pos + x_pos_offset, y_pos + y_pos_correction); @@ -396,10 +424,10 @@ impl Viewable for ViewableHand { } // The top card of the current row is reached. - if (self.top_card_id_upper.is_some() - && (!move_lower_card_row && *card_id == self.top_card_id_upper.unwrap())) - || (self.top_card_id_lower.is_some() - && (move_lower_card_row && *card_id == self.top_card_id_lower.unwrap())) + if (self.top_card_upper.is_some() + && (!move_lower_card_row && card.card == self.top_card_upper.unwrap())) + || (self.top_card_lower.is_some() + && (move_lower_card_row && card.card == self.top_card_lower.unwrap())) { push_lower = true; } diff --git a/src/gameplay_ui/mod.rs b/src/gameplay_ui/mod.rs index 302a9c5..89c6bd8 100644 --- a/src/gameplay_ui/mod.rs +++ b/src/gameplay_ui/mod.rs @@ -1,12 +1,46 @@ #![allow(dead_code)] pub mod hand; +pub mod scoreboard; pub mod table; -use iced::{Point, Size}; +use derive_more::{Deref, DerefMut}; +use iced::{ + ContentFit, + Length::Fill, + Point, Size, Task, + widget::{Container, container, image, mouse_area, pin, stack}, +}; + +use crate::{ + animation::{BasicAnimation, Easing}, + api::{Card, Player, PlayerId, Suit}, + client::{App, AppMessage, TaskBatcher, audio::Sfx, views::ButtonMessage}, + gameplay_ui::{ + hand::{HandMessage, ViewableHand}, + scoreboard::{ScoreBoard, ScoreBoardInfo, ScoreBoardMessage}, + table::{ + TableMessage, ViewableTable, + avatar::AvatarMessage, + middle::{card_deck::CardDeckMessage, card_stack::CardStackMessage}, + }, + }, + ui_element_traits::{Animated, Message, Notifiable, Resizable, ResizableDynHeight, Viewable}, +}; + +static TABLE_Y_POSITION_MULT_WITH_WINDOW_HEIGHT: f32 = 0.1; + +static SCOREBOARD_WIDTH_MUTL_WITH_WINDOW_WIDTH: f32 = 0.2; + +static AVATAR_SIZE_MULT_WITH_WINDOW_WIDTH: f32 = 0.1; +static AVATAR_IMG_SIZE_MULT_WITH_WINDOW_WIDTH: f32 = AVATAR_SIZE_MULT_WITH_WINDOW_WIDTH * 0.75; +static AVATAR_SHARD_SIZE_MULT_WITH_WINDOW_WIDTH: f32 = + (AVATAR_SIZE_MULT_WITH_WINDOW_WIDTH - AVATAR_IMG_SIZE_MULT_WITH_WINDOW_WIDTH) / 2.0; +static AVATAR_FRAME_WIDTH_HEIGHT_RATIO: f32 = 1.2; // The hand size is depending on the window size with the factor 0.1. static CARD_WIDTH_MULT_WITH_WINDOW_WIDTH: f32 = 0.1; // 1.54 is around 1245 / 806 (height to width ratio of a card image). +static CARD_WIDTH_HEIGHT_RATIO: f32 = 1.54; pub static CARD_HEIGHT_MULT_WITH_WINDOW_WIDTH: f32 = CARD_WIDTH_MULT_WITH_WINDOW_WIDTH * 1.54; // Adjust this arbitrary value to manipulate the width of the hand relative to its size, @@ -59,3 +93,231 @@ fn card_area_middle_spawn_point(width: f32, height: f32, window_size: Size) -> P (card_area_middle_space_height(window_size) - height) / 2.0, ) } + +#[derive(Clone, Debug)] +pub struct GameStartInfo { + players: Vec, + my_id: PlayerId, + sb_info: ScoreBoardInfo, +} + +impl GameStartInfo { + pub fn new(app: &App) -> Self { + Self { + players: app.lobby.as_ref().unwrap().players.clone(), + my_id: *app.my_id.as_ref().unwrap(), + sb_info: app.scoreboard_info(), + } + } +} + +#[derive(Clone, Debug)] +pub enum GameViewMessage { + // gui -> gui + HandMessage(HandMessage), + TableMessage(TableMessage), + ScoreBoardMessage(ScoreBoardMessage), + + // server -> gui + StartGame(Box), + EndGame(PlayerId), + NewRound(Option, Vec, Vec), + NewTrick, + ChangeTurn(PlayerId, Vec), + CardPlayed(PlayerId, Card), + + // gui -> server + TryPlayCard(Card), + TryBid, + TryChooseSuit(Suit), +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct BlackScreenFadeInAnimation(BasicAnimation); + +impl BlackScreenFadeInAnimation { + pub fn new(duration: usize) -> Self { + Self(BasicAnimation::new(duration)) + } + pub fn get_opacity(&self) -> f32 { + self.progress(Easing::InSine) * 0.8 + } +} + +impl Message for GameViewMessage { + fn convert_msg_from(msg: Self) -> crate::client::AppMessage { + AppMessage::GameViewMessage(Box::new(msg)) + } +} + +pub struct GameView { + window_size: Size, + img_ingame_background: image::Handle, + + // game data + my_id: Option, + game_ended: bool, + game_ended_animation: BlackScreenFadeInAnimation, + + // gui elements + viewable_hand: ViewableHand, + viewable_table: ViewableTable, + scoreboard: ScoreBoard, +} + +impl GameView { + pub fn new(window_size: Size) -> Self { + Self { + window_size, + img_ingame_background: image::Handle::from_path("assets/ingame_background.png"), + my_id: None, + game_ended: false, + game_ended_animation: BlackScreenFadeInAnimation::new(150), + viewable_hand: ViewableHand::new(window_size), + viewable_table: ViewableTable::new(window_size), + scoreboard: ScoreBoard::new(window_size, ScoreBoardInfo::default()), + } + } + pub fn update_buttons_with_msg(&mut self, btn_msg: ButtonMessage) -> Task { + TaskBatcher::instant_batch([ + self.scoreboard + .btn_submit_bid + .update_with_msg(btn_msg.clone()), + self.scoreboard.btn_submit_bid.update_with_msg(btn_msg), + ]) + } +} + +impl Notifiable for GameView { + type OwnMessage = GameViewMessage; + fn update_with_msg(&mut self, msg: Self::OwnMessage) -> Task { + match msg { + GameViewMessage::HandMessage(hand_msg) => self.viewable_hand.update_with_msg(hand_msg), + GameViewMessage::TableMessage(table_msg) => { + self.viewable_table.update_with_msg(table_msg) + } + GameViewMessage::ScoreBoardMessage(sb_msg) => self.scoreboard.update_with_msg(sb_msg), + GameViewMessage::StartGame(info) => { + let mut tb = TaskBatcher::new(); + self.my_id = Some(info.my_id); + self.viewable_hand.my_id = Some(info.my_id); + tb.push( + self.scoreboard + .update_with_msg(ScoreBoardMessage::Update(Box::new(info.sb_info))), + ); + self.viewable_table.build_avatars(info.players); + tb.batch() + } + GameViewMessage::CardPlayed(played_by, card) => { + let mut tb = TaskBatcher::new(); + tb.push_msg(CardStackMessage::CardPlayed(card)); + tb.push_msg(AvatarMessage::PlayShard(played_by)); + if played_by == self.my_id.unwrap() { + tb.push_msg(HandMessage::PlayedCard(card)) + }; + tb.batch() + } + GameViewMessage::NewRound(trump_card, hand_cards, valid_cards) => { + let mut tb = TaskBatcher::new(); + let hand_cards_len = hand_cards.len(); + tb.push_msg(HandMessage::DrawCards(hand_cards, valid_cards)); + tb.push_msg(CardStackMessage::HideAllCards); + tb.push_msg(CardDeckMessage::Deal(hand_cards_len, trump_card)); + tb.push_msg(TableMessage::DrawShards(hand_cards_len)); + tb.push_msg(HandMessage::NobodiesTurn); + tb.batch() + } + GameViewMessage::NewTrick => CardStackMessage::HideAllCards.convert_msg_to_task(), + GameViewMessage::ChangeTurn(player_id, valid_cards) => TaskBatcher::instant_batch([ + TableMessage::ChangeTurn(player_id).convert_msg_to_task(), + HandMessage::ChangeTurn(player_id, valid_cards).convert_msg_to_task(), + ]), + GameViewMessage::TryPlayCard(card) => AppMessage::PlayCard(card).convert_msg_to_task(), + GameViewMessage::TryBid => AppMessage::SubmitBid.convert_msg_to_task(), + GameViewMessage::TryChooseSuit(suit) => TaskBatcher::instant_batch([ + AppMessage::SetTrump(suit).convert_msg_to_task(), + AppMessage::PlaySfx(Sfx::Click).convert_msg_to_task(), + ]), + GameViewMessage::EndGame(_) => { + self.game_ended = true; + self.game_ended_animation.start(); + self.scoreboard.move_end_board_animation.start(); + Task::done(AppMessage::PlaySfx(Sfx::GameOver)) + } + } + } +} + +impl Animated for GameView { + fn update_animations(&mut self) -> Task { + TaskBatcher::instant_batch([ + self.viewable_hand.update_animations(), + self.viewable_table.update_animations(), + self.scoreboard.update_animations(), + self.game_ended_animation.next_frame(), + ]) + } +} + +impl Resizable for GameView { + fn height(&self) -> f32 { + self.window_size.height + } + fn width(&self) -> f32 { + self.window_size.width + } + fn update_size(&mut self, window_size: Size) { + self.window_size = window_size; + self.viewable_hand.update_size(window_size); + self.viewable_table.update_size(window_size); + self.scoreboard.update_size(window_size); + } +} + +impl Viewable for GameView { + fn view<'a>(&self) -> Container<'a, AppMessage> { + let mut content = stack!().width(self.width()).height(self.height()); + // Background + content = + content.push(image(self.img_ingame_background.clone()).content_fit(ContentFit::Cover)); + // Scoreboard + content = content.push( + self.scoreboard + .view_and_move(self.width() - self.scoreboard.width(), 0.0), + ); + // Card Table + content = content.push(self.viewable_table.view_and_move( + (self.width() - self.viewable_table.width()) / 2.0, + self.height() * TABLE_Y_POSITION_MULT_WITH_WINDOW_HEIGHT, + )); + // Card Hand + content = content.push(self.viewable_hand.view_and_move( + (self.width() - self.viewable_hand.width()) / 2.0, + self.height() - self.viewable_hand.height(), + )); + if self.game_ended { + let mut winner_avatar = self + .viewable_table + .find_avatar(self.my_id.unwrap()) + .unwrap(); + winner_avatar.turn_frame_animation.reset(); + winner_avatar.turn_frame_glow_animation.reset(); + winner_avatar.shards = 0; + content = content.push(mouse_area( + image("assets/black_screen.png") + .content_fit(ContentFit::Fill) + .opacity(self.game_ended_animation.get_opacity()) + .width(Fill) + .height(Fill), + )); + content = content.push( + pin(self.scoreboard.view_as_game_end_board(winner_avatar)).position(Point::new( + (self.width() - self.scoreboard.width()) * 0.5, + self.height() * 0.2 + - self.scoreboard.move_end_board_animation.get_offset() * self.height(), + )), + ); + } + container(content) + } +} diff --git a/src/gameplay_ui/scoreboard.rs b/src/gameplay_ui/scoreboard.rs new file mode 100644 index 0000000..79ee796 --- /dev/null +++ b/src/gameplay_ui/scoreboard.rs @@ -0,0 +1,528 @@ +use std::collections::HashMap; + +use iced::{ + Alignment::Center, + Border, Color, + Length::Shrink, + Size, Task, + mouse::Interaction, + widget::{ + Column, Container, Image, column, container, image::FilterMethod, mouse_area, row, text, + text_input, + }, +}; + +use crate::{ + animation::{BasicAnimation, Easing, ReversableBasicAnimation}, + gameplay_ui::table::avatar::ViewableAvatar, +}; +use derive_more::{Deref, DerefMut}; + +use crate::{ + api::{Lobby, PlayerId, Suit}, + client::{AppMessage, TaskBatcher, views::Button}, + gameplay_ui::{ + CARD_WIDTH_HEIGHT_RATIO, GameViewMessage, SCOREBOARD_WIDTH_MUTL_WITH_WINDOW_WIDTH, + }, + ui_element_traits::{Animated, Message, Notifiable, ResizableDynHeight, Viewable}, +}; + +#[derive(Clone, Debug, Default)] +pub struct ScoreBoardInfo { + pub round_number: usize, + pub player_order: Vec, + pub scores: HashMap, + pub tricks_won: HashMap, + pub bids: HashMap, + pub my_id: Option, + pub lobby: Option, + pub must_set_trump: bool, + pub dealer: Option, + pub is_bidding_phase: bool, + pub is_my_turn: bool, + pub bid_input: String, + pub current_player: Option, +} + +#[derive(Clone, Debug)] +pub enum ScoreBoardMessage { + Update(Box), + SuitHovered(Suit), + SuitNotHovered(Suit), +} + +impl Message for ScoreBoardMessage { + fn convert_msg_from(msg: Self) -> AppMessage { + GameViewMessage::convert_msg_from(GameViewMessage::ScoreBoardMessage(msg)) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct HoverAnimation(ReversableBasicAnimation); + +impl HoverAnimation { + pub fn new(duration: usize) -> Self { + Self(ReversableBasicAnimation::new(duration)) + } + pub fn get_scale(&self) -> f32 { + 0.8 + 0.2 * self.progress(Easing::InOutSine) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct MoveEndBoardAnimation(BasicAnimation); + +impl MoveEndBoardAnimation { + pub fn new(duration: usize) -> Self { + Self(BasicAnimation::new(duration)) + } + pub fn get_offset(&self) -> f32 { + 1.0 - self.progress(Easing::OutBounce) + } +} + +#[derive(Debug, Clone)] +pub struct ScoreBoard { + window_size: Size, + pub btn_submit_bid: Button, + pub btn_back_to_menu: Button, + info: ScoreBoardInfo, + hover_animation_red: HoverAnimation, + hover_animation_green: HoverAnimation, + hover_animation_blue: HoverAnimation, + hover_animation_yellow: HoverAnimation, + pub move_end_board_animation: MoveEndBoardAnimation, +} + +impl ScoreBoard { + /// AI Usage: write this function to get player order sorted by score + fn sorted_player_order_by_score(&self) -> Vec { + let mut players: Vec = self.info.player_order.clone(); + players.sort_by_key(|pid| std::cmp::Reverse(*self.info.scores.get(pid).unwrap_or(&0))); + players + } + pub fn new(window_size: Size, info: ScoreBoardInfo) -> Self { + let hover_animation_duration: usize = 10; + Self { + window_size, + btn_submit_bid: Button::new_submit_bid_button(21, 110, 36), + btn_back_to_menu: Button::new_back_to_menu_button(22, 250, 54), + info, + hover_animation_red: HoverAnimation::new(hover_animation_duration), + hover_animation_green: HoverAnimation::new(hover_animation_duration), + hover_animation_blue: HoverAnimation::new(hover_animation_duration), + hover_animation_yellow: HoverAnimation::new(hover_animation_duration), + move_end_board_animation: MoveEndBoardAnimation::new(100), + } + } + + pub fn view_as_game_end_board<'a>( + &self, + winner_avatar: ViewableAvatar, + ) -> Container<'a, AppMessage> { + let mut scores_col = Column::new().spacing(2); + + scores_col = scores_col.push(winner_avatar.view()).align_x(Center); + + scores_col = scores_col.push( + container(text("has won!").size(self.size_huge()).color(Color::WHITE)).padding(5), + ); + + // Header row + scores_col = scores_col.push(self.scoreboard_row("Name", "Pkt", "Won", "Bid", true, false)); + + for player_id in self.sorted_player_order_by_score() { + let mut player_name = self.get_player_name(player_id); + let score = self.info.scores.get(&player_id).unwrap_or(&0); + let tricks = self.info.tricks_won.get(&player_id).unwrap_or(&0); + let bid = self.info.bids.get(&player_id).unwrap_or(&0); + let is_current_turn = self.info.current_player == Some(player_id); + + if player_name.is_empty() { + player_name = "???".to_string(); + } + + scores_col = scores_col.push(self.scoreboard_row( + &player_name, + &score.to_string(), + &tricks.to_string(), + &bid.to_string(), + false, + is_current_turn, + )); + } + + scores_col = scores_col + .push(self.btn_back_to_menu.view()) + .align_x(Center); + + // Wrap in a styled container + container(scores_col) + .width(self.width()) + .height(Shrink) + .padding([24.0, 24.0]) + .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() + }) + } + + fn scoreboard_row<'a>( + &self, + name: &str, + score: &str, + tricks: &str, + bid: &str, + is_header: bool, + is_current_turn: bool, + ) -> Container<'a, AppMessage> { + let text_color = if is_current_turn { + Color::from_rgb(1.0, 0.85, 0.4) + } else { + Color::WHITE + }; + + let cell = |content: String| { + container( + text(content) + .size(if is_header { + self.size_small() + } else { + self.size_middle() + }) + .color(text_color), + ) + .width(iced::Length::FillPortion(1)) + .padding(4) + }; + + let name_cell = |content: String| { + container( + text(content) + .size(if is_header { + self.size_small() + } else { + self.size_middle() + }) + .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(self.width()); + + container(row_content) + .width(self.width()) + .style(move |_theme| container::Style { + background: Some(if is_current_turn { + 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_current_turn { + 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_current_turn { 1.5 } else { 1.0 }, + radius: 2.0.into(), + }, + ..Default::default() + }) + } + + fn reset_animations(&mut self) { + self.hover_animation_blue.reset(); + self.hover_animation_green.reset(); + self.hover_animation_red.reset(); + self.hover_animation_yellow.reset(); + } + fn not_show_trump_pannel(&self) -> bool { + !self.info.must_set_trump || self.info.dealer != self.info.my_id + } + fn not_show_bidding_pannel(&self) -> bool { + !self.info.is_bidding_phase || !self.info.is_my_turn || self.info.must_set_trump + } + fn build_bidding_panel<'a>(&self) -> Container<'a, AppMessage> { + if self.not_show_bidding_pannel() { + return container(None::<&str>); + } + let max_bid = self.info.round_number + 1; + + let panel = column![ + text("Bid:") + .size(self.size_big()) + .color(Color::from_rgb(1.0, 1.0, 1.0)), + row![ + text_input("Enter bid", &self.info.bid_input) + .on_input(AppMessage::BidInputChanged) + .width(self.bid_input_size()), + self.btn_submit_bid.view(), + ] + .spacing(6), + text(format!("(0 to {max_bid})")) + .size(self.size_middle()) + .color(Color::from_rgba(1.0, 1.0, 1.0, 0.7)), + ] + .spacing(6); + + container(panel).padding([8, 0]) + } + + fn build_trump_panel<'a>(&self) -> Container<'a, AppMessage> { + // Show only if dealer and must_set_trump + if self.not_show_trump_pannel() { + return container(None::<&str>); + } + let panel = column![ + text("Select Trump Suit:") + .size(self.size_big()) + .color(Color::from_rgb(1.0, 1.0, 1.0)), + row![ + mouse_area( + Image::new("assets/suits/red.png") + .filter_method(FilterMethod::Nearest) + .width(self.card_width()) + .height(self.card_width() * CARD_WIDTH_HEIGHT_RATIO) + .scale(self.hover_animation_red.get_scale()) + ) + .interaction(Interaction::Pointer) + .on_press(GameViewMessage::TryChooseSuit(Suit::Red).convert_msg()) + .on_enter(ScoreBoardMessage::SuitHovered(Suit::Red).convert_msg()) + .on_exit(ScoreBoardMessage::SuitNotHovered(Suit::Red).convert_msg()), + mouse_area( + Image::new("assets/suits/green.png") + .filter_method(FilterMethod::Nearest) + .width(self.card_width()) + .height(self.card_width() * CARD_WIDTH_HEIGHT_RATIO) + .scale(self.hover_animation_green.get_scale()) + ) + .interaction(Interaction::Pointer) + .on_press(GameViewMessage::TryChooseSuit(Suit::Green).convert_msg()) + .on_enter(ScoreBoardMessage::SuitHovered(Suit::Green).convert_msg()) + .on_exit(ScoreBoardMessage::SuitNotHovered(Suit::Green).convert_msg()), + mouse_area( + Image::new("assets/suits/blue.png") + .filter_method(FilterMethod::Nearest) + .width(self.card_width()) + .height(self.card_width() * CARD_WIDTH_HEIGHT_RATIO) + .scale(self.hover_animation_blue.get_scale()) + ) + .interaction(Interaction::Pointer) + .on_press(GameViewMessage::TryChooseSuit(Suit::Blue).convert_msg()) + .on_enter(ScoreBoardMessage::SuitHovered(Suit::Blue).convert_msg()) + .on_exit(ScoreBoardMessage::SuitNotHovered(Suit::Blue).convert_msg()), + mouse_area( + Image::new("assets/suits/yellow.png") + .filter_method(FilterMethod::Nearest) + .width(self.card_width()) + .height(self.card_width() * CARD_WIDTH_HEIGHT_RATIO) + .scale(self.hover_animation_yellow.get_scale()) + ) + .interaction(Interaction::Pointer) + .on_press(GameViewMessage::TryChooseSuit(Suit::Yellow).convert_msg()) + .on_enter(ScoreBoardMessage::SuitHovered(Suit::Yellow).convert_msg()) + .on_exit(ScoreBoardMessage::SuitNotHovered(Suit::Yellow).convert_msg()), + ] + .spacing(6), + ] + .spacing(6); + + container(panel).padding([8, 0]) + } + + /// Get player name from ID using lobby data + pub fn get_player_name(&self, player_id: PlayerId) -> String { + if self.info.my_id == Some(player_id) { + return "You".to_string(); + } + if let Some(ref lobby) = self.info.lobby + && let Some(player) = lobby.players.iter().find(|p| p.id == player_id) + { + return player.name.clone(); + } + format!("Player {}", player_id) + } + + fn size_small(&self) -> f32 { + self.width() / 22.0 + } + fn size_middle(&self) -> f32 { + self.width() / 19.0 + } + fn size_big(&self) -> f32 { + self.width() / 16.0 + } + fn size_huge(&self) -> f32 { + self.width() / 10.0 + } + fn card_width(&self) -> f32 { + self.width() * 0.15 + } + fn bid_input_size(&self) -> f32 { + self.width() * 0.3 + } +} + +impl Notifiable for ScoreBoard { + type OwnMessage = ScoreBoardMessage; + fn update_with_msg(&mut self, msg: Self::OwnMessage) -> Task { + match msg { + ScoreBoardMessage::Update(info) => { + self.info = *info; + if !self.not_show_bidding_pannel() { + self.reset_animations() + } + } + ScoreBoardMessage::SuitHovered(suit) => match suit { + Suit::Red => { + self.hover_animation_red.start(); + } + Suit::Blue => { + self.hover_animation_blue.start(); + } + Suit::Green => { + self.hover_animation_green.start(); + } + Suit::Yellow => { + self.hover_animation_yellow.start(); + } + }, + ScoreBoardMessage::SuitNotHovered(suit) => match suit { + Suit::Red => { + self.hover_animation_red.reverse(); + } + Suit::Blue => { + self.hover_animation_blue.reverse(); + } + Suit::Green => { + self.hover_animation_green.reverse(); + } + Suit::Yellow => { + self.hover_animation_yellow.reverse(); + } + }, + } + Task::none() + } +} + +impl ResizableDynHeight for ScoreBoard { + fn update_size(&mut self, window_size: Size) { + self.window_size = window_size + } + fn width(&self) -> f32 { + SCOREBOARD_WIDTH_MUTL_WITH_WINDOW_WIDTH * self.window_size.width + } +} + +impl Animated for ScoreBoard { + fn update_animations(&mut self) -> Task { + TaskBatcher::instant_batch([ + self.btn_submit_bid.update_animations(), + self.btn_back_to_menu.update_animations(), + self.hover_animation_blue.next_frame(), + self.hover_animation_green.next_frame(), + self.hover_animation_red.next_frame(), + self.hover_animation_yellow.next_frame(), + self.move_end_board_animation.next_frame(), + ]) + } +} + +impl Viewable for ScoreBoard { + // AI Usage: overwrite the view so that the scoreboard is placed correctly + // and uses rows+cells instead of rows+format strings + fn view<'a>(&self) -> Container<'a, AppMessage> { + let mut scores_col = Column::new().spacing(2); + + scores_col = scores_col.push( + container( + text("Scoreboard") + .size(self.size_huge()) + .color(Color::WHITE), + ) + .padding(5), + ); + + scores_col = scores_col.push( + container( + text(format!("Round {}", self.info.round_number + 1)) + .size(self.size_big()) + .color(Color::from_rgba(1.0, 1.0, 1.0, 0.7)), + ) + .padding([0, 5]), + ); + + // Header row + scores_col = scores_col.push(self.scoreboard_row("Name", "Pkt", "Won", "Bid", true, false)); + + for player_id in self.sorted_player_order_by_score() { + let mut player_name = self.get_player_name(player_id); + let score = self.info.scores.get(&player_id).unwrap_or(&0); + let tricks = self.info.tricks_won.get(&player_id).unwrap_or(&0); + let bid = self.info.bids.get(&player_id).unwrap_or(&0); + let is_current_turn = self.info.current_player == Some(player_id); + + if player_name.is_empty() { + player_name = "???".to_string(); + } + + scores_col = scores_col.push(self.scoreboard_row( + &player_name, + &score.to_string(), + &tricks.to_string(), + &bid.to_string(), + false, + is_current_turn, + )); + } + + scores_col = scores_col.push( + container( + text("Bids for current round") + .size(self.size_small()) + .color(Color::from_rgba(1.0, 1.0, 1.0, 0.5)), + ) + .padding([8, 0]), + ); + + if !self.not_show_trump_pannel() { + scores_col = scores_col + .push(self.build_trump_panel()) + .align_x(iced::Center); + } else if !self.info.must_set_trump { + scores_col = scores_col + .push(self.build_bidding_panel()) + .align_x(iced::Center); + } + + // Wrap in a styled container + container(scores_col) + .width(self.width()) + .height(Shrink) + .padding([24.0, 24.0]) + .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() + }) + } +} diff --git a/src/gameplay_ui/table/avatar.rs b/src/gameplay_ui/table/avatar.rs new file mode 100644 index 0000000..7c198c4 --- /dev/null +++ b/src/gameplay_ui/table/avatar.rs @@ -0,0 +1,444 @@ +use std::f32::consts::PI; + +use iced::{ + Alignment, Color, ContentFit, Point, Size, Task, + mouse::Interaction, + widget::{Container, Pin, container, image, image::FilterMethod, mouse_area, pin, stack, text}, +}; + +use derive_more::{Deref, DerefMut}; + +use crate::{ + animation::{ + AutoReversingAnimation, BasicAnimation, CircularAnimation, CircularAutoReversingAnimation, + Easing, ReversableBasicAnimation, + }, + api::{Avatar, AvatarKind, AvatarPose, PlayerId}, + client::{AppMessage, TaskBatcher, audio::Sfx}, + gameplay_ui::{ + AVATAR_FRAME_WIDTH_HEIGHT_RATIO, AVATAR_IMG_SIZE_MULT_WITH_WINDOW_WIDTH, + AVATAR_SHARD_SIZE_MULT_WITH_WINDOW_WIDTH, AVATAR_SIZE_MULT_WITH_WINDOW_WIDTH, + table::TableMessage, + }, + ui_element_traits::{Animated, Message, Notifiable, Resizable, SizeFromOutside, Viewable}, +}; + +#[derive(Clone, Debug)] +pub enum AvatarMessage { + AddShards(PlayerId, usize), + PlayShard(PlayerId), + InterpolationEnded(PlayerId), + ChangeTurn(PlayerId), + Clicked(PlayerId), +} + +impl Message for AvatarMessage { + fn convert_msg_from(msg: Self) -> crate::client::AppMessage { + TableMessage::convert_msg_from(TableMessage::AvatarMessage(msg)) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct SpriteAnimation(CircularAnimation); + +impl SpriteAnimation { + fn new() -> Self { + Self(CircularAnimation::new(100)) + } + fn new_frame(&self) -> bool { + self.current_frame_number() == 80 || self.current_frame_number() == self.max_frame_number() + } + fn new_casting_frame(&self) -> bool { + self.current_frame_number() == 15 + || self.current_frame_number() == 30 + || self.current_frame_number() == 45 + || self.current_frame_number() == 60 + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct RevealAnimation(ReversableBasicAnimation); + +impl RevealAnimation { + fn new() -> Self { + Self(ReversableBasicAnimation::new(100)) + } + fn get_opacity(&self) -> f32 { + self.progress(Easing::OutElastic) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct PlayShardAnimation(BasicAnimation); + +impl PlayShardAnimation { + fn new() -> Self { + Self(BasicAnimation::new(100)) + } + fn get_opacity(&self) -> f32 { + 1.0 - self.progress(Easing::OutCubic) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct ShardRotationAnimation(CircularAnimation); + +impl ShardRotationAnimation { + fn new() -> Self { + Self(CircularAnimation::new(400)) + } + /// Scaled to PI not to a 100%. + fn get_rotation(&self) -> f32 { + self.progress(Easing::Linear) * 2.0 * PI + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct InterpolationAnimation(BasicAnimation); + +impl InterpolationAnimation { + fn new() -> Self { + Self(BasicAnimation::new(100)) + } + fn get_progress(&self) -> f32 { + self.progress(Easing::OutElastic) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct TurnFrameAnimation(ReversableBasicAnimation); + +impl TurnFrameAnimation { + pub fn new() -> Self { + Self(ReversableBasicAnimation::new(20)) + } + pub fn get_opacity(&self) -> f32 { + self.progress(Easing::Linear) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct TurnFrameGlowAnimation(CircularAutoReversingAnimation); + +impl TurnFrameGlowAnimation { + pub fn new() -> Self { + Self(CircularAutoReversingAnimation::new(40)) + } + pub fn get_opacity(&self) -> f32 { + 0.5 + 0.5 * self.progress(Easing::Linear) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct ClickedAnimation(AutoReversingAnimation); + +impl ClickedAnimation { + pub fn new() -> Self { + Self(AutoReversingAnimation::new(20)) + } + pub fn get_contraction(&self) -> f32 { + 1.0 - 0.3 * self.progress(Easing::OutCubic) + } +} + +#[derive(Clone, Debug)] +pub struct ViewableAvatar { + window_size: Size, + id: PlayerId, + pub name: String, + my_turn: bool, + pub avatar: Avatar, + pub shards: usize, + interpolation: bool, + sprite_animation: SpriteAnimation, + reveal_animation: RevealAnimation, + play_shard_animation: PlayShardAnimation, + shard_rotation_animation: ShardRotationAnimation, + interpolation_animation: InterpolationAnimation, + clicked_animation: ClickedAnimation, + pub turn_frame_animation: TurnFrameAnimation, + pub turn_frame_glow_animation: TurnFrameGlowAnimation, +} + +impl ViewableAvatar { + pub fn new(window_size: Size, avatar_kind: AvatarKind, id: PlayerId, name: String) -> Self { + let mut viewable_avatar = Self { + window_size, + id, + name, + my_turn: false, + avatar: avatar_kind.as_avatar(), + shards: 20, + interpolation: false, + sprite_animation: SpriteAnimation::new(), + reveal_animation: RevealAnimation::new(), + play_shard_animation: PlayShardAnimation::new(), + shard_rotation_animation: ShardRotationAnimation::new(), + interpolation_animation: InterpolationAnimation::new(), + clicked_animation: ClickedAnimation::new(), + turn_frame_animation: TurnFrameAnimation::new(), + turn_frame_glow_animation: TurnFrameGlowAnimation::new(), + }; + viewable_avatar + .interpolation_animation + .on_end_reached(AvatarMessage::InterpolationEnded(id).convert_msg()); + viewable_avatar.sprite_animation.start_infinite(); + viewable_avatar.shard_rotation_animation.start_infinite(); + viewable_avatar.turn_frame_glow_animation.start_infinite(); + viewable_avatar + } + pub fn id(&self) -> PlayerId { + self.id + } + fn sprite_position(&self) -> Point { + let size: f32 = self.window_size.width * AVATAR_SHARD_SIZE_MULT_WITH_WINDOW_WIDTH; + Point::new(size, size) + } + fn shard_position(&self, shard_number: i64) -> Point { + if self.interpolation { + self.interpolated_position(shard_number) + } else { + let shard_size: f32 = self.window_size.width * AVATAR_SHARD_SIZE_MULT_WITH_WINDOW_WIDTH; + let circle_radius: f32 = self.width() / 2.0; + let mut x: f32 = circle_radius - shard_size / 2.0; + let mut y: f32 = x; + let rotation: f32 = self.shard_position_rotation_angle(shard_number, 0); + x += x * rotation.cos(); + y += y * rotation.sin(); + Point::new(x, y) + } + } + fn shard_position_rotation_angle(&self, shard_number: i64, adjust: i64) -> f32 { + // The angle is scaled from 0.0 to PI, not from 0.0 to 1.0. + (((shard_number + adjust) as f32 / (self.shards as i64 + adjust) as f32) * 2.0 * PI + + self.shard_rotation_animation.get_rotation()) + - (PI / 2.0) // To start on top of the circle. + } + fn interpolated_position(&self, shard_number: i64) -> Point { + let shard_size: f32 = self.window_size.width * AVATAR_SHARD_SIZE_MULT_WITH_WINDOW_WIDTH; + let circle_radius: f32 = self.width() / 2.0; + let mut x: f32 = circle_radius - shard_size / 2.0; + let mut y: f32 = x; + let rotation_before: f32 = self.shard_position_rotation_angle(shard_number, 0); + let rotation_after: f32 = self.shard_position_rotation_angle(shard_number, -1); + let rotation: f32 = rotation_before + + (rotation_after - rotation_before) * self.interpolation_animation.get_progress(); + x += x * rotation.cos(); + y += y * rotation.sin(); + Point::new(x, y) + } + fn shard_rotation(&self, shard_number: i64) -> f32 { + let rotation_before: f32 = self.shard_rotation_helper(shard_number, 0); + if self.interpolation { + let rotation_after: f32 = self.shard_rotation_helper(shard_number, -1); + rotation_before + + (rotation_after - rotation_before) * self.interpolation_animation.get_progress() + } else { + rotation_before + } + } + fn shard_rotation_helper(&self, shard_number: i64, adjust: i64) -> f32 { + ((shard_number + adjust) as f32 / (self.shards as i64 + adjust) as f32) * 2.0 * PI + + self.shard_rotation_animation.get_rotation() + } + fn sprite_size(&self) -> f32 { + AVATAR_IMG_SIZE_MULT_WITH_WINDOW_WIDTH * self.window_size.width + } + fn sprite<'a>(&self, pose: AvatarPose, compare_pose: AvatarPose) -> Pin<'a, AppMessage> { + let sprite_size: f32 = self.sprite_size(); + let opacity: f32 = if pose == compare_pose { 1.0 } else { 0.0 }; + let contracted_size: f32 = self.sprite_size() * self.clicked_animation.get_contraction(); + pin(container( + pin(image(self.avatar.kind().img_path(compare_pose)) + // AI-Usage: Claude for learning filter_method to achieve non blurred pixel art. + .filter_method(FilterMethod::Nearest) + .opacity(opacity) + .width(sprite_size) + .height(contracted_size) + .content_fit(ContentFit::Fill)) + .position(Point::new(0.0, sprite_size - contracted_size)), + ) + .height(sprite_size)) + .position(self.sprite_position()) + } + fn text_size(&self) -> f32 { + self.width() / 8.0 + } +} + +impl Notifiable for ViewableAvatar { + type OwnMessage = AvatarMessage; + fn update_with_msg(&mut self, msg: Self::OwnMessage) -> Task { + match msg { + AvatarMessage::AddShards(id, shards) => { + if self.id == id { + self.shards = shards; + self.reveal_animation.start_force(); + } + } + AvatarMessage::PlayShard(id) => { + if self.id == id && self.shards > 0 { + self.avatar.start_casting(); + self.interpolation = true; + self.play_shard_animation.start_force(); + self.sprite_animation.start_force(); + self.interpolation_animation.start_force(); + } + } + AvatarMessage::InterpolationEnded(id) => { + if self.id == id && self.shards > 0 { + self.shards -= 1; + self.interpolation = false; + self.play_shard_animation.reset(); + return AppMessage::DecrementACDL(1).convert_msg_to_task(); + } + } + AvatarMessage::ChangeTurn(id) => { + if id == self.id { + self.my_turn = true; + self.turn_frame_animation.start(); + } else { + self.my_turn = false; + self.turn_frame_animation.reverse(); + } + } + AvatarMessage::Clicked(id) => { + if id == self.id { + self.clicked_animation.start(); + let sfx: Sfx = match self.avatar.kind() { + AvatarKind::Elf => Sfx::ClickedElf, + AvatarKind::Knight => Sfx::ClickedKnight, + AvatarKind::Mage => Sfx::ClickedMage, + AvatarKind::Witch => Sfx::ClickedWitch, + }; + return AppMessage::PlaySfx(sfx).convert_msg_to_task(); + } + } + } + Task::none() + } +} + +impl Animated for ViewableAvatar { + fn update_animations(&mut self) -> Task { + let mut tb = TaskBatcher::new(); + + tb.push(self.sprite_animation.next_frame()); + if (!self.avatar.is_casting() && self.sprite_animation.new_frame()) + || (self.avatar.is_casting() && self.sprite_animation.new_casting_frame()) + { + self.avatar.next_pose(); + } + + tb.push(self.reveal_animation.next_frame()); + tb.push(self.play_shard_animation.next_frame()); + tb.push(self.shard_rotation_animation.next_frame()); + tb.push(self.interpolation_animation.next_frame()); + tb.push(self.clicked_animation.next_frame()); + tb.push(self.turn_frame_animation.next_frame()); + tb.push(self.turn_frame_glow_animation.next_frame()); + tb.batch() + } +} + +impl Resizable for ViewableAvatar { + fn update_size(&mut self, window_size: Size) { + self.window_size = window_size; + } + fn width(&self) -> f32 { + self.window_size.width * AVATAR_SIZE_MULT_WITH_WINDOW_WIDTH + } + fn height(&self) -> f32 { + self.width() * AVATAR_FRAME_WIDTH_HEIGHT_RATIO + } +} + +impl SizeFromOutside for ViewableAvatar { + fn width_for(window_size: Size) -> f32 { + window_size.width * AVATAR_SIZE_MULT_WITH_WINDOW_WIDTH + } + fn height_for(window_size: Size) -> f32 { + Self::width_for(window_size) * AVATAR_FRAME_WIDTH_HEIGHT_RATIO + } +} + +impl Viewable for ViewableAvatar { + fn view<'a>(&self) -> Container<'a, AppMessage> { + let mut avatar = stack!().width(self.width()).height(self.height()); + avatar = avatar.push( + image("assets/avatars/avatar_frame_idle.png") + .filter_method(FilterMethod::Nearest) + .width(self.width()) + .height(self.height()), + ); + avatar = avatar.push( + image("assets/avatars/avatar_frame_turn.png") + .filter_method(FilterMethod::Nearest) + .width(self.width()) + .height(self.height()) + .opacity( + self.turn_frame_animation + .get_opacity() + .min(self.turn_frame_glow_animation.get_opacity()), + ), + ); + + avatar = avatar.push(self.sprite(self.avatar.pose(), AvatarPose::Casting1)); + avatar = avatar.push(self.sprite(self.avatar.pose(), AvatarPose::Casting2)); + avatar = avatar.push(self.sprite(self.avatar.pose(), AvatarPose::Standing1)); + avatar = avatar.push(self.sprite(self.avatar.pose(), AvatarPose::Standing2)); + + avatar = avatar.push( + pin(mouse_area( + container(None::<&str>) + .width(self.sprite_size()) + .height(self.sprite_size()), + ) + .on_press(AvatarMessage::Clicked(self.id).convert_msg()) + .interaction(Interaction::Pointer)) + .position(self.sprite_position()), + ); + + if self.shards > 0 { + let play_opacity: f32 = self.play_shard_animation.get_opacity(); + let shard_size: f32 = self.window_size.width * AVATAR_SHARD_SIZE_MULT_WITH_WINDOW_WIDTH; + for shard in 0..self.shards { + let opacity: f32 = if shard == self.shards - 1 { + self.reveal_animation.get_opacity().min(play_opacity) + } else { + self.reveal_animation.get_opacity() + }; + avatar = avatar.push( + pin(image(self.avatar.kind().shard_path()) + .rotation(self.shard_rotation(shard as i64)) + .scale(0.8) + .opacity(opacity) + .width(shard_size) + .height(shard_size)) + .position(self.shard_position(shard as i64)), + ); + } + } + + avatar = avatar.push( + pin(container( + text(self.name.clone()) + .size(self.text_size()) + .color(Color::from_rgb(1.0, 0.85, 0.4)), + ) + .width(self.width()) + .height(self.height() - self.sprite_size() - self.sprite_position().y) + .align_x(Alignment::Center) + .align_y(Alignment::Center)) + .position(Point::new( + 0.0, + // 1.7 is an arbitrary number that results into a good text position. + self.sprite_size() + self.sprite_position().y * 1.7, + )), + ); + + Container::new(avatar) + .width(self.width()) + .height(self.height()) + } +} diff --git a/src/gameplay_ui/table/middle/card_deck/deck_card.rs b/src/gameplay_ui/table/middle/card_deck/deck_card.rs index d9f1a4c..3631f3c 100644 --- a/src/gameplay_ui/table/middle/card_deck/deck_card.rs +++ b/src/gameplay_ui/table/middle/card_deck/deck_card.rs @@ -3,15 +3,15 @@ use crate::{ api::CARD_BACK_PATH, client::AppMessage, gameplay_ui::{ - card_height_middle, card_img_middle_base_scale, card_width_middle, - CARD_AREA_MIDDLE_RELATION, + CARD_AREA_MIDDLE_RELATION, card_height_middle, card_img_middle_base_scale, + card_width_middle, }, ui_element_traits::*, }; use derive_more::{Deref, DerefMut}; use iced::{ - widget::{image, Container}, Point, Size, Task, + widget::{Container, image}, }; #[derive(Debug, Clone)] @@ -42,19 +42,17 @@ pub struct ViewableDeckCard { window_size: Size, add: bool, direction: Direction, - deal_animation: DealAnimation, + pub deal_animation: DealAnimation, } impl ViewableDeckCard { pub fn new(window_size: Size, cycle: usize, add: bool) -> Self { - let mut viewable_deck_card = Self { + Self { window_size, add, direction: Self::choose_direction(cycle), deal_animation: DealAnimation::new(10), - }; - viewable_deck_card.deal_animation.start(); - viewable_deck_card + } } pub fn offset(&self) -> Point { let mut linear_progress: f32 = self.deal_animation.get_offset(); diff --git a/src/gameplay_ui/table/middle/card_deck/glow_card.rs b/src/gameplay_ui/table/middle/card_deck/glow_card.rs index c55329e..3af5797 100644 --- a/src/gameplay_ui/table/middle/card_deck/glow_card.rs +++ b/src/gameplay_ui/table/middle/card_deck/glow_card.rs @@ -1,6 +1,6 @@ use crate::{ animation::{CircularAutoReversingAnimation, Easing, ReversableBasicAnimation}, - api::{get_glow_path, Card}, + api::Card, client::{AppMessage, TaskBatcher}, gameplay_ui::{ card_height_middle, card_width_middle, table::middle::card_deck::CardDeckMessage, @@ -9,8 +9,8 @@ use crate::{ }; use derive_more::{Deref, DerefMut}; use iced::{ - widget::{image, Container}, Size, Task, + widget::{Container, image}, }; #[derive(Debug, Clone, Deref, DerefMut)] @@ -39,7 +39,9 @@ impl GlowAnimation { #[derive(Debug, Clone)] pub enum GlowMessage { + RemoveColor, ResetColor, + TryChangeGlow(Card), } impl Message for GlowMessage { @@ -51,26 +53,28 @@ impl Message for GlowMessage { pub struct CardStackGlow { window_size: Size, img_path: String, - pub reveal_animation: RevealAnimation, - pub glow_animation: GlowAnimation, + reveal_animation: RevealAnimation, + glow_animation: GlowAnimation, } impl CardStackGlow { pub fn new(window_size: Size) -> Self { - let mut card_stack_glow = Self { + let mut csg = Self { window_size, img_path: "".to_string(), reveal_animation: RevealAnimation::new(30), glow_animation: GlowAnimation::new(60), }; - card_stack_glow - .reveal_animation - .on_start(GlowMessage::ResetColor.convert_msg()); - card_stack_glow.glow_animation.start(); - card_stack_glow + csg.reveal_animation + .on_start_reached(GlowMessage::ResetColor.convert_msg()); + csg } - pub fn change_color(&mut self, card: Card) { - self.img_path = get_glow_path(card); + /// Only change the color when there is currently no color. + pub fn try_change_color(&mut self, card: Card) { + if self.img_path.is_empty() { + self.img_path = card.glow_path(); + self.reveal_animation.start(); + } } } @@ -78,9 +82,15 @@ impl Notifiable for CardStackGlow { type OwnMessage = GlowMessage; fn update_with_msg(&mut self, msg: Self::OwnMessage) -> Task { match msg { + GlowMessage::RemoveColor => { + self.reveal_animation.reverse(); + } GlowMessage::ResetColor => { self.img_path = "".to_string(); } + GlowMessage::TryChangeGlow(card) => { + self.try_change_color(card); + } } Task::none() } diff --git a/src/gameplay_ui/table/middle/card_deck/mod.rs b/src/gameplay_ui/table/middle/card_deck/mod.rs index ea5f687..1d4a73d 100644 --- a/src/gameplay_ui/table/middle/card_deck/mod.rs +++ b/src/gameplay_ui/table/middle/card_deck/mod.rs @@ -4,30 +4,26 @@ pub mod trump_card; use crate::{ animation::AnimationStarter, - api::{Card, CARD_BACK_PATH}, - client::{AppMessage, TaskBatcher}, + api::{CARD_BACK_PATH, Card}, + client::{AppMessage, TaskBatcher, audio::Sfx}, gameplay_ui::{ card_area_middle_space_height, card_area_middle_space_width, card_area_middle_spawn_point, card_img_middle_base_scale, - hand::ViewableHand, - table::{ - middle::{ - card_deck::{ - deck_card::ViewableDeckCard, - glow_card::{CardStackGlow, GlowMessage}, - trump_card::{TrumpCardMessage, ViewableTrumpCard}, - }, - card_stack::CardStackMessage, - TableMiddleMessage, + table::middle::{ + TableMiddleMessage, + card_deck::{ + deck_card::ViewableDeckCard, + glow_card::{CardStackGlow, GlowMessage}, + trump_card::{TrumpCardMessage, ViewableTrumpCard}, }, - HandMessage, + card_stack::CardStackMessage, }, }, ui_element_traits::*, }; use iced::{ - widget::{image, pin, stack, Container}, Size, Task, + widget::{Container, image, pin, stack}, }; type TrumpCard = Card; @@ -42,9 +38,7 @@ pub enum CardDeckMessage { AddDeckCard(usize), DealDeckCard(usize), TrumpCardMessage(TrumpCardMessage), - ChangeGlow(Card), GlowMessage(GlowMessage), - ShowGlow, } impl Message for CardDeckMessage { @@ -64,9 +58,7 @@ impl ReplaceUsize for CardDeckMessage { CardDeckMessage::TrumpCardMessage(_) => self.clone(), CardDeckMessage::ClearTrumpCard => self.clone(), CardDeckMessage::Shuffle => self.clone(), - CardDeckMessage::ChangeGlow(_) => self.clone(), CardDeckMessage::GlowMessage(_) => self.clone(), - CardDeckMessage::ShowGlow => self.clone(), } } } @@ -118,40 +110,48 @@ impl Notifiable for ViewableCardDeck { fn update_with_msg(&mut self, msg: Self::OwnMessage) -> Task { match msg { CardDeckMessage::AddDeckCard(cycle) => { - let view_able_deck_card = ViewableDeckCard::new(self.window_size, cycle, true); - self.deck_cards.push(view_able_deck_card); + let mut viewable_deck_card = ViewableDeckCard::new(self.window_size, cycle, true); + viewable_deck_card.deal_animation.start(); + self.deck_cards.push(viewable_deck_card); } CardDeckMessage::DealDeckCard(cycle) => { - let view_able_deck_card = ViewableDeckCard::new(self.window_size, cycle, false); - self.deck_cards.push(view_able_deck_card); + let mut viewable_deck_card = ViewableDeckCard::new(self.window_size, cycle, false); + viewable_deck_card.deal_animation.start(); + self.deck_cards.push(viewable_deck_card); } CardDeckMessage::ClearTrumpCard => { self.trump_card = None; - self.clear_card_animation_starter - .start(self.deal_card_animation_starter.times()); + + return TaskBatcher::instant_batch([ + self.clear_card_animation_starter + .start(self.deal_card_animation_starter.times()), + AppMessage::PlaySfx(Sfx::CardShuffle).convert_msg_to_task(), + ]); } CardDeckMessage::Deal(cards, trump_card) => { let mut tb = TaskBatcher::new(); if self.deal_msg.is_none() { self.deal_msg = Some(CardDeckMessage::Deal(cards, trump_card)); - self.deal_card_animation_starter.start(cards); if let Some(card) = trump_card { self.trump_card = Some(ViewableTrumpCard::new(self.window_size, card)); } else { self.trump_card = None; } - tb.push( - HandMessage::DrawCards(ViewableHand::build_test_cards(self.window_size)) - .convert_msg_to_task(), - ); + return TaskBatcher::instant_batch([ + self.deal_card_animation_starter.start(cards), + AppMessage::PlaySfx(Sfx::CardDeal).convert_msg_to_task(), + ]); } else { self.deal_msg = Some(CardDeckMessage::Deal(cards, trump_card)); tb.push(CardDeckMessage::Shuffle.convert_msg_to_task()); - tb.push(CardStackMessage::HideAllCard.convert_msg_to_task()); + tb.push(CardStackMessage::HideAllCards.convert_msg_to_task()); } return tb.batch(); } CardDeckMessage::AllDealt => { + for card in self.deck_cards.iter() { + println!("all dealt: {}", card.deal_animation.current_frame_number()); + } self.deck_cards.clear(); return TrumpCardMessage::TurnPart1.convert_msg_to_task(); } @@ -167,19 +167,10 @@ impl Notifiable for ViewableCardDeck { return trump_card.update_with_msg(trump_card_msg); } } - CardDeckMessage::Shuffle => { - self.glow.reveal_animation.reverse(); - return TrumpCardMessage::RemovePart1.convert_msg_to_task(); - } - CardDeckMessage::ChangeGlow(card) => { - self.glow.change_color(card); - } + CardDeckMessage::Shuffle => return TrumpCardMessage::RemovePart1.convert_msg_to_task(), CardDeckMessage::GlowMessage(glow_msg) => { return self.glow.update_with_msg(glow_msg); } - CardDeckMessage::ShowGlow => { - self.glow.reveal_animation.start_force(); - } } Task::none() } diff --git a/src/gameplay_ui/table/middle/card_deck/trump_card.rs b/src/gameplay_ui/table/middle/card_deck/trump_card.rs index 409f168..b1b9e03 100644 --- a/src/gameplay_ui/table/middle/card_deck/trump_card.rs +++ b/src/gameplay_ui/table/middle/card_deck/trump_card.rs @@ -1,6 +1,6 @@ use crate::{ - animation::{AutoReversingAnimation, Easing}, - api::{get_card_path, CARD_BACK_PATH}, + animation::{Easing, ReversableBasicAnimation}, + api::CARD_BACK_PATH, client::{AppMessage, TaskBatcher}, gameplay_ui::{ card_height_middle, card_img_middle_base_scale, card_width_middle, @@ -10,8 +10,8 @@ use crate::{ }; use derive_more::{Deref, DerefMut}; use iced::{ - widget::{image, Container}, Size, Task, + widget::{Container, image}, }; #[derive(Debug, Clone)] @@ -29,11 +29,11 @@ impl Message for TrumpCardMessage { } #[derive(Debug, Clone, Deref, DerefMut)] -pub struct TurnAnimation(AutoReversingAnimation); +pub struct TurnAnimation(ReversableBasicAnimation); impl TurnAnimation { fn new(duration: usize) -> Self { - Self(AutoReversingAnimation::new(duration)) + Self(ReversableBasicAnimation::new(duration)) } fn get_contraction(&self) -> f32 { 1.0 - self.progress(Easing::InSine) @@ -60,13 +60,13 @@ impl ViewableTrumpCard { }; viewable_trump_card .reveal_animation - .on_end(TrumpCardMessage::TurnPart2.convert_msg()); + .on_end_reached(TrumpCardMessage::TurnPart2.convert_msg()); viewable_trump_card .remove_animation - .on_end(TrumpCardMessage::RemovePart2.convert_msg()); + .on_end_reached(TrumpCardMessage::RemovePart2.convert_msg()); viewable_trump_card .remove_animation - .on_start(CardDeckMessage::ClearTrumpCard.convert_msg()); + .on_start_reached(CardDeckMessage::ClearTrumpCard.convert_msg()); viewable_trump_card } } @@ -75,17 +75,15 @@ impl Notifiable for ViewableTrumpCard { type OwnMessage = TrumpCardMessage; fn update_with_msg(&mut self, msg: Self::OwnMessage) -> Task { match msg { - TrumpCardMessage::TurnPart1 => { - self.reveal_animation.start(); - } + TrumpCardMessage::TurnPart1 => self.reveal_animation.start(), TrumpCardMessage::TurnPart2 => { self.show_back = false; + self.reveal_animation.reverse(); } - TrumpCardMessage::RemovePart1 => { - self.remove_animation.start(); - } + TrumpCardMessage::RemovePart1 => self.remove_animation.start(), TrumpCardMessage::RemovePart2 => { self.show_back = true; + self.remove_animation.reverse(); } } Task::none() @@ -118,7 +116,7 @@ impl Resizable for ViewableTrumpCard { impl Viewable for ViewableTrumpCard { fn view<'a>(&self) -> Container<'a, AppMessage> { let img_path = if !self.show_back { - get_card_path(self.trump_card) + self.trump_card.img_path() } else { CARD_BACK_PATH.to_string() }; diff --git a/src/gameplay_ui/table/middle/card_stack/mod.rs b/src/gameplay_ui/table/middle/card_stack/mod.rs index e85f4ca..7ca1d58 100644 --- a/src/gameplay_ui/table/middle/card_stack/mod.rs +++ b/src/gameplay_ui/table/middle/card_stack/mod.rs @@ -1,29 +1,36 @@ pub mod stack_card; +use std::ops::Not; + use crate::{ - animation::AnimationStarter, + animation::{BasicAnimation, Easing, ReversableBasicAnimation}, api::Card, - client::{AppMessage, TaskBatcher}, + client::{AppMessage, TaskBatcher, audio::Sfx}, gameplay_ui::{ - card_area_middle_space_height, card_area_middle_space_width, card_area_middle_spawn_point, + CARD_WIDTH_HEIGHT_RATIO, card_area_middle_space_height, card_area_middle_space_width, + card_area_middle_spawn_point, table::middle::{ - card_deck::CardDeckMessage, card_stack::stack_card::ViewableStackCard, - TableMiddleMessage, + TableMiddleMessage, card_deck::glow_card::GlowMessage, + card_stack::stack_card::ViewableStackCard, }, }, ui_element_traits::*, }; +use derive_more::{Deref, DerefMut}; use iced::{ - widget::{Container, Stack}, - Size, Task, + Point, Size, Task, + mouse::Interaction, + widget::{Container, MouseArea, Stack, container, image, pin}, }; #[derive(Debug, Clone)] pub enum CardStackMessage { CardPlayed(Card), - HideAllCard, - HideCard(usize), + HideAllCards, RemoveAllCards, + ShowPlayedCards, + HidePlayedCards, + SwitchAlwaysShowPlayedCards, } impl Message for CardStackMessage { @@ -32,39 +39,52 @@ impl Message for CardStackMessage { } } -impl ReplaceUsize for CardStackMessage { - fn replace_usize(&self, value: usize) -> Self { - match self { - CardStackMessage::HideCard(_) => CardStackMessage::HideCard(value), - CardStackMessage::HideAllCard => self.clone(), - CardStackMessage::CardPlayed(_) => self.clone(), - CardStackMessage::RemoveAllCards => self.clone(), - } +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct ViewPlayedCardsAnimation(ReversableBasicAnimation); + +impl ViewPlayedCardsAnimation { + pub fn new(duration: usize) -> Self { + Self(ReversableBasicAnimation::new(duration)) + } + pub fn get_progress(&self) -> f32 { + self.progress(Easing::OutCubic) + } + pub fn get_opacity(&self) -> f32 { + self.progress(Easing::InOutCubic) + } +} + +#[derive(Clone, Debug, Deref, DerefMut)] +pub struct NewCardPlayedAniamtion(BasicAnimation); + +impl NewCardPlayedAniamtion { + pub fn new(duration: usize) -> Self { + Self(BasicAnimation::new(duration)) + } + pub fn get_opacity(&self) -> f32 { + self.progress(Easing::Linear) } } pub struct ViewableCardStack { window_size: Size, cards: Vec, - clear_card_stack_animation_starter: AnimationStarter, + always_show_played_cards: bool, + remove_ready: bool, + view_played_cards_animation: ViewPlayedCardsAnimation, + new_card_played_animation: NewCardPlayedAniamtion, } impl ViewableCardStack { pub fn new(window_size: Size) -> Self { - let mut viewable_stack_card = Self { + Self { window_size, - cards: Vec::new(), - clear_card_stack_animation_starter: AnimationStarter::new( - 10, - 20, - CardStackMessage::HideCard(0), - ), - }; - viewable_stack_card - .clear_card_stack_animation_starter - .on_all_ended(CardStackMessage::RemoveAllCards); - viewable_stack_card + always_show_played_cards: false, + remove_ready: false, + view_played_cards_animation: ViewPlayedCardsAnimation::new(40), + new_card_played_animation: NewCardPlayedAniamtion::new(20), + } } } @@ -74,25 +94,45 @@ impl Notifiable for ViewableCardStack { fn update_with_msg(&mut self, msg: Self::OwnMessage) -> Task { match msg { CardStackMessage::CardPlayed(card) => { - self.cards - .push(ViewableStackCard::new(self.window_size, card)); - if self.cards.len() == 1 { - return TaskBatcher::instant_batch([ - CardDeckMessage::ChangeGlow(card).convert_msg_to_task(), - CardDeckMessage::ShowGlow.convert_msg_to_task(), - ]); + let mut tb = TaskBatcher::new(); + let mut stack_card = ViewableStackCard::new(self.window_size, card); + stack_card.reveal_animation.start(); + self.cards.push(stack_card); + tb.push_msg(GlowMessage::TryChangeGlow(card)); + tb.push_msg(AppMessage::PlaySfx(Sfx::CardPlay)); + self.new_card_played_animation.start_force(); + if self.cards.len() == 1 && self.always_show_played_cards { + self.view_played_cards_animation.start(); } + return tb.batch(); } - CardStackMessage::HideAllCard => { - self.clear_card_stack_animation_starter - .start(self.cards.len().max(1) - 1); - } - CardStackMessage::HideCard(id) => { - let card_count: usize = self.cards.len(); - self.cards[card_count - 1 - id].remove_animation.start(); + CardStackMessage::HideAllCards => { + if !self.cards.is_empty() { + self.view_played_cards_animation.reverse(); + for card in self.cards.iter_mut() { + card.remove_animation.start(); + } + return GlowMessage::RemoveColor.convert_msg_to_task(); + } } CardStackMessage::RemoveAllCards => { - self.cards.clear(); + self.remove_ready = true; + } + CardStackMessage::ShowPlayedCards => { + self.view_played_cards_animation.start(); + } + CardStackMessage::HidePlayedCards => { + if !self.always_show_played_cards { + self.view_played_cards_animation.reverse(); + } + } + CardStackMessage::SwitchAlwaysShowPlayedCards => { + self.always_show_played_cards = self.always_show_played_cards.not(); + if self.always_show_played_cards { + self.view_played_cards_animation.start(); + } else { + self.view_played_cards_animation.reverse(); + } } }; Task::none() @@ -102,10 +142,15 @@ impl Notifiable for ViewableCardStack { impl Animated for ViewableCardStack { fn update_animations(&mut self) -> Task { let mut tb = TaskBatcher::new(); - tb.push(self.clear_card_stack_animation_starter.next_frame()); + tb.push(self.view_played_cards_animation.next_frame()); + tb.push(self.new_card_played_animation.next_frame()); for card in self.cards.iter_mut() { tb.push(card.update_animations()); } + if self.remove_ready && self.view_played_cards_animation.current_frame_number() == 0 { + self.cards.clear(); + self.remove_ready = false; + } tb.batch() } } @@ -127,13 +172,70 @@ impl Resizable for ViewableCardStack { impl Viewable for ViewableCardStack { fn view<'a>(&self) -> Container<'a, AppMessage> { + let mut content = Stack::new().width(self.width()).height(self.height()); + let mut card_stack = Stack::new(); + let stack_card_width: f32 = ViewableStackCard::width_for(self.window_size); + let stack_card_height: f32 = ViewableStackCard::height_for(self.window_size); + let spawn_point: Point = card_area_middle_spawn_point( + ViewableStackCard::width_for(self.window_size), + ViewableStackCard::height_for(self.window_size), + self.window_size, + ); for card in self.cards.iter() { - let spawn_point = - card_area_middle_spawn_point(card.width(), card.height(), self.window_size); card_stack = card_stack.push(card.view_and_move(spawn_point.x, spawn_point.y)) } - Container::new(card_stack) + if !self.cards.is_empty() { + card_stack = card_stack.push( + pin(MouseArea::new( + container(None::<&str>) + .width(stack_card_width) + .height(stack_card_height), + ) + .interaction(Interaction::Pointer) + .on_enter(CardStackMessage::ShowPlayedCards.convert_msg()) + .on_exit(CardStackMessage::HidePlayedCards.convert_msg()) + .on_press(CardStackMessage::SwitchAlwaysShowPlayedCards.convert_msg())) + .position(spawn_point), + ); + } + content = content.push(pin(card_stack).position(Point::new(0.0, 0.0))); + + // played cards history + if !self.cards.is_empty() { + let mut cards = Stack::new(); + let card_width: f32 = self.width() / 6.0; // There can be 6 cards played at max. + let card_height: f32 = card_width * CARD_WIDTH_HEIGHT_RATIO; + let start_position_x: f32 = (self.width() - card_width) / 2.0; + let start_position_y: f32 = self.height() - card_height; + let start_point: Point = Point::new(start_position_x, start_position_y); + for card_number in 0..self.cards.len() { + let end_point: Point = Point::new(card_number as f32 * card_width, 0.0); + let spawn_point: Point = Point::new( + start_point.x + + (end_point.x - start_point.x) + * self.view_played_cards_animation.get_progress(), + start_point.y + + (end_point.y - start_point.y) + * self.view_played_cards_animation.get_progress(), + ); + let mut opacity = self.view_played_cards_animation.get_opacity(); + if card_number == self.cards.len() - 1 { + // last card + opacity = opacity.min(self.new_card_played_animation.get_opacity()); + }; + cards = cards.push( + pin(image(self.cards[card_number].card().img_path()) + .opacity(opacity) + .width(card_width) + .height(card_height)) + .position(spawn_point), + ); + } + content = content.push(cards); + }; + + Container::new(content) .width(self.width()) .height(self.height()) } diff --git a/src/gameplay_ui/table/middle/card_stack/stack_card.rs b/src/gameplay_ui/table/middle/card_stack/stack_card.rs index b9f4020..c8c98fe 100644 --- a/src/gameplay_ui/table/middle/card_stack/stack_card.rs +++ b/src/gameplay_ui/table/middle/card_stack/stack_card.rs @@ -1,19 +1,19 @@ use crate::{ animation::{BasicAnimation, Easing}, - api::{get_card_path, Card}, + api::Card, client::{AppMessage, TaskBatcher}, gameplay_ui::{ - card_height_middle, card_img_middle_base_scale, card_width_middle, - CARD_AREA_MIDDLE_RELATION, + CARD_AREA_MIDDLE_RELATION, card_height_middle, card_img_middle_base_scale, + card_width_middle, table::middle::card_stack::CardStackMessage, }, ui_element_traits::*, }; use derive_more::{Deref, DerefMut}; use iced::{ - widget::{image, Container}, ContentFit::Fill, Size, Task, + widget::{Container, image}, }; use rand::Rng; @@ -51,22 +51,26 @@ impl RemoveAnimation { pub struct ViewableStackCard { window_size: Size, card: Card, - reveal_animation: RevealAnimation, + pub reveal_animation: RevealAnimation, pub remove_animation: RemoveAnimation, rotation: f32, } impl ViewableStackCard { pub fn new(window_size: Size, card: Card) -> Self { - let mut viewable_stack_card = Self { + let mut vsc = Self { window_size, card, reveal_animation: RevealAnimation::new(50), remove_animation: RemoveAnimation::new(10), rotation: rand::rng().random_range(-0.15..0.15), }; - viewable_stack_card.reveal_animation.start(); - viewable_stack_card + vsc.remove_animation + .on_end_reached(CardStackMessage::RemoveAllCards.convert_msg()); + vsc + } + pub fn card(&self) -> Card { + self.card } } @@ -104,7 +108,7 @@ impl SizeFromOutside for ViewableStackCard { impl Viewable for ViewableStackCard { fn view<'a>(&self) -> Container<'a, AppMessage> { - let img = image(get_card_path(self.card)) + let img = image(self.card.img_path()) .width(self.width()) .height(self.height()) .scale(card_img_middle_base_scale()) diff --git a/src/gameplay_ui/table/middle/mod.rs b/src/gameplay_ui/table/middle/mod.rs index 62c904c..4390e5d 100644 --- a/src/gameplay_ui/table/middle/mod.rs +++ b/src/gameplay_ui/table/middle/mod.rs @@ -4,17 +4,17 @@ pub mod card_stack; use crate::{ client::{AppMessage, TaskBatcher}, gameplay_ui::table::{ + TableMessage, middle::{ card_deck::{CardDeckMessage, ViewableCardDeck}, card_stack::{CardStackMessage, ViewableCardStack}, }, - TableMessage, }, ui_element_traits::*, }; use iced::{ - widget::{row, Container}, Size, Task, + widget::{Container, row}, }; #[derive(Debug, Clone)] diff --git a/src/gameplay_ui/table/mod.rs b/src/gameplay_ui/table/mod.rs index 78f08a8..b0a0bf6 100644 --- a/src/gameplay_ui/table/mod.rs +++ b/src/gameplay_ui/table/mod.rs @@ -1,29 +1,41 @@ +pub mod avatar; pub mod middle; use crate::{ - client::AppMessage, + api::{AvatarKind, Player, PlayerId}, + client::{AppMessage, TaskBatcher, audio::Sfx}, gameplay_ui::{ - hand::HandMessage, - table::middle::{TableMiddleMessage, ViewableTableMiddle}, + GameViewMessage, + table::{ + avatar::{AvatarMessage, ViewableAvatar}, + middle::{TableMiddleMessage, ViewableTableMiddle}, + }, }, ui_element_traits::*, }; -use iced::{widget::Container, Size, Task}; +use iced::{ + Size, Task, + widget::{Container, container, stack}, +}; #[derive(Debug, Clone)] pub enum TableMessage { TableMiddleMessage(TableMiddleMessage), + AvatarMessage(AvatarMessage), + DrawShards(usize), + ChangeTurn(PlayerId), } impl Message for TableMessage { fn convert_msg_from(msg: Self) -> AppMessage { - AppMessage::TableMessage(msg) + GameViewMessage::convert_msg_from(GameViewMessage::TableMessage(msg)) } } pub struct ViewableTable { window_size: Size, middle: ViewableTableMiddle, + avatars: Vec, } impl ViewableTable { @@ -31,7 +43,29 @@ impl ViewableTable { Self { window_size, middle: ViewableTableMiddle::new(window_size), + avatars: Vec::new(), + } + } + /// This method is highly critical and needs to be executed as soon as possible + /// which is why this is not handled via a Task. + pub fn build_avatars(&mut self, players: Vec) { + for player in players.iter() { + self.avatars.push(ViewableAvatar::new( + self.window_size, + player.avatar, + player.id, + player.name.clone(), + )); + } + } + + pub fn find_avatar(&self, id: PlayerId) -> Option { + for avatar in self.avatars.iter() { + if avatar.id() == id { + return Some(avatar.clone()); + } } + None } } @@ -43,31 +77,103 @@ impl Notifiable for ViewableTable { TableMessage::TableMiddleMessage(table_middle_msg) => { self.middle.update_with_msg(table_middle_msg) } + TableMessage::AvatarMessage(avatar_msg) => { + let mut tb = TaskBatcher::new(); + for avatar in self.avatars.iter_mut() { + tb.push(avatar.update_with_msg(avatar_msg.clone())); + } + if let AvatarMessage::PlayShard(player) = avatar_msg { + let avatar = self.find_avatar(player).unwrap(); + match avatar.avatar.kind() { + AvatarKind::Elf => { + tb.push_msg(AppMessage::PlaySfx(Sfx::CastElf)); + } + AvatarKind::Knight => { + tb.push_msg(AppMessage::PlaySfx(Sfx::CastKnight)); + } + AvatarKind::Mage => { + tb.push_msg(AppMessage::PlaySfx(Sfx::CastMage)); + } + AvatarKind::Witch => { + tb.push_msg(AppMessage::PlaySfx(Sfx::CastWitch)); + } + } + tb.push_msg(AppMessage::PlaySfx(Sfx::ShardPlay)); + }; + tb.batch() + } + TableMessage::DrawShards(amount) => { + let mut tb = TaskBatcher::new(); + for avatar in self.avatars.iter_mut() { + tb.push(avatar.update_with_msg(AvatarMessage::AddShards(avatar.id(), amount))) + } + tb.batch() + } + TableMessage::ChangeTurn(id) => { + let mut tb = TaskBatcher::new(); + for avatar in self.avatars.iter_mut() { + tb.push(avatar.update_with_msg(AvatarMessage::ChangeTurn(id))) + } + tb.batch() + } } } } impl Animated for ViewableTable { fn update_animations(&mut self) -> Task { - self.middle.update_animations() + let mut tb = TaskBatcher::new(); + tb.push(self.middle.update_animations()); + for avatar in self.avatars.iter_mut() { + tb.push(avatar.update_animations()); + } + tb.batch() } } impl Resizable for ViewableTable { fn height(&self) -> f32 { - self.middle.height() + self.middle.height() * 2.0 } fn width(&self) -> f32 { - self.middle.width() + self.middle.width() * 2.0 } fn update_size(&mut self, window_size: iced::Size) { self.window_size = window_size; self.middle.update_size(window_size); + for avatar in self.avatars.iter_mut() { + avatar.update_size(window_size); + } } } impl Viewable for ViewableTable { fn view<'a>(&self) -> Container<'a, AppMessage> { - self.middle.view() + let mut content = stack!().width(self.width()).height(self.height()); + // Table Middle + content = content.push( + self.middle + .view_and_move(self.middle.width() * 0.5, self.middle.height() * 0.15), + ); + // Player Avatars + let avatar_size: f32 = ViewableAvatar::width_for(self.window_size); + let sec_col_x_spawn: f32 = self.width() - avatar_size; + if !self.avatars.is_empty() { + content = content.push(self.avatars[0].view()); + content = content.push(self.avatars[1].view_and_move(sec_col_x_spawn, 0.0)); + content = content.push(self.avatars[2].view_and_move(0.0, avatar_size * 1.5)); + } + if self.avatars.len() > 3 { + content = + content.push(self.avatars[3].view_and_move(sec_col_x_spawn, avatar_size * 1.5)) + } + if self.avatars.len() > 4 { + content = content.push(self.avatars[4].view_and_move(0.0, avatar_size * 3.0)) + } + if self.avatars.len() > 5 { + content = + content.push(self.avatars[5].view_and_move(sec_col_x_spawn, avatar_size * 3.0)) + } + container(content) } } diff --git a/src/main.rs b/src/main.rs index 9748921..388915a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -// Disables the console window on Windows. +// Disables the console widndow on Windows. #![cfg_attr(not(feature = "wiz_debug"), windows_subsystem = "windows")] mod animation; diff --git a/src/server.rs b/src/server.rs index 6305764..eba2d8f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,20 +1,21 @@ -use crate::api::{Lobby, Player, PlayerId, ServerMessage, B, C, S}; -use crate::gamelogic::game::Game; +use crate::api::{AvatarKind, B, C, Lobby, Player, PlayerId, S, ServerMessage}; use crate::gamelogic::GameEvent; +use crate::gamelogic::game::Game; use axum::{ + Router, extract::ws::{Message, WebSocket, WebSocketUpgrade}, response::IntoResponse, routing::get, - Router, }; use futures::{SinkExt, StreamExt}; +use rand::seq::IndexedRandom; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::{RwLock, mpsc}; use uuid::Uuid; type Clients = Arc>>>; @@ -216,10 +217,10 @@ async fn ws_handler( /// Stops the server by sending a shutdown signal. /// Was made using Claude Opuss' help. pub fn stop_server() { - if let Ok(mut guard) = SHUTDOWN_SENDER.lock() { - if let Some(tx) = guard.take() { - let _ = tx.send(()); - } + if let Ok(mut guard) = SHUTDOWN_SENDER.lock() + && let Some(tx) = guard.take() + { + let _ = tx.send(()); } } @@ -311,9 +312,16 @@ async fn handle_socket(socket: WebSocket, clients: Clients, players: PlayerList, } drop(players_map); let is_host = players_clone.read().await.is_empty(); // thats really unsafe + let avatar_kinds: [AvatarKind; 4] = [ + AvatarKind::Elf, + AvatarKind::Knight, + AvatarKind::Mage, + AvatarKind::Witch, + ]; let player = Player { id, name: name.clone(), + avatar: *avatar_kinds.choose(&mut rand::rng()).unwrap(), ready: false, is_host, }; diff --git a/src/ui_element_traits.rs b/src/ui_element_traits.rs index ef3fbec..92cf770 100644 --- a/src/ui_element_traits.rs +++ b/src/ui_element_traits.rs @@ -1,7 +1,7 @@ use crate::client::AppMessage; use iced::{ - widget::{pin, Container}, Point, Size, Task, + widget::{Container, pin}, }; pub trait Notifiable { @@ -44,6 +44,17 @@ pub trait SizeFromOutside: Resizable { fn height_for(window_size: Size) -> f32; } +/// Like Resizable, but without the height. +/// Implmenet this instead if the item can dynamically interference its height. +pub trait ResizableDynHeight { + /// Every time an resize event occures call this function. + /// Use it to set self.window_size and to call other update_size functions of ui elements of + /// lesser hierarchy + fn update_size(&mut self, window_size: Size); + /// Uses the window size from self to calculate the total width of the ui element. + fn width(&self) -> f32; +} + pub trait Viewable { fn view<'a>(&self) -> Container<'a, AppMessage>; fn view_and_move<'a>(&self, x: f32, y: f32) -> Container<'a, AppMessage> {