├── .gitignore ├── ferrogallic_shared ├── src │ ├── lib.rs │ ├── api │ │ ├── lobby.rs │ │ └── game.rs │ ├── api.rs │ ├── paths.rs │ ├── config.rs │ └── domain.rs ├── Cargo.toml └── Cargo.lock ├── ferrogallic ├── src │ ├── words.rs │ ├── files │ │ ├── ferris-648x.png │ │ ├── ferris-64x.png │ │ ├── ferris-orig.png │ │ ├── audio │ │ │ ├── 95_ding.wav │ │ │ ├── 95_tada.wav │ │ │ ├── 95_chimes.wav │ │ │ ├── 95_chord.wav │ │ │ ├── 98_startup.wav │ │ │ ├── 98_asterisk.wav │ │ │ ├── 98_maximize.wav │ │ │ ├── 98_shutdown.wav │ │ │ ├── 98_exclamation.wav │ │ │ ├── 95_chord_16bit_nr.wav │ │ │ ├── 95_ding_16bit_nr.wav │ │ │ ├── 95_tada_16bit_nr.wav │ │ │ └── 95_chimes_16bit_nr.wav │ │ ├── web.rs │ │ └── audio.rs │ ├── files.rs │ ├── opt.rs │ ├── reply.rs │ ├── api │ │ ├── lobby.rs │ │ └── game.rs │ ├── main.rs │ ├── server.rs │ ├── api.rs │ └── words │ │ ├── common.rs │ │ └── game.rs └── Cargo.toml ├── ferrogallic_web ├── src │ ├── page.rs │ ├── dom.rs │ ├── component.rs │ ├── lib.rs │ ├── util.rs │ ├── component │ │ ├── timer.rs │ │ ├── guess_input.rs │ │ ├── color_toolbar.rs │ │ ├── choose_popup.rs │ │ ├── error_popup.rs │ │ ├── players.rs │ │ ├── tool_toolbar.rs │ │ ├── guess_template.rs │ │ └── guess_area.rs │ ├── route.rs │ ├── canvas │ │ ├── draw.rs │ │ └── flood_fill.rs │ ├── app.rs │ ├── audio.rs │ ├── canvas.rs │ ├── page │ │ ├── choose_name.rs │ │ ├── create.rs │ │ └── in_game.rs │ ├── api.rs │ └── styles │ │ └── main.css ├── LICENSE └── Cargo.toml ├── README.md ├── LICENSE └── .github └── workflows └── ci.yml /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | **/*.rs.bk 3 | bin/ 4 | pkg/ 5 | wasm-pack.log 6 | -------------------------------------------------------------------------------- /ferrogallic_shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod config; 3 | pub mod domain; 4 | pub mod paths; 5 | -------------------------------------------------------------------------------- /ferrogallic/src/words.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | mod game; 3 | 4 | pub use common::COMMON_FOR_ROOM_NAMES; 5 | pub use game::GAME; 6 | -------------------------------------------------------------------------------- /ferrogallic/src/files/ferris-648x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/ferris-648x.png -------------------------------------------------------------------------------- /ferrogallic/src/files/ferris-64x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/ferris-64x.png -------------------------------------------------------------------------------- /ferrogallic/src/files/ferris-orig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/ferris-orig.png -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_ding.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_ding.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_tada.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_tada.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_chimes.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_chimes.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_chord.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_chord.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/98_startup.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/98_startup.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/98_asterisk.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/98_asterisk.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/98_maximize.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/98_maximize.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/98_shutdown.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/98_shutdown.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/98_exclamation.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/98_exclamation.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_chord_16bit_nr.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_chord_16bit_nr.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_ding_16bit_nr.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_ding_16bit_nr.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_tada_16bit_nr.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_tada_16bit_nr.wav -------------------------------------------------------------------------------- /ferrogallic/src/files/audio/95_chimes_16bit_nr.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikdesjardins/ferrogallic/master/ferrogallic/src/files/audio/95_chimes_16bit_nr.wav -------------------------------------------------------------------------------- /ferrogallic/src/files.rs: -------------------------------------------------------------------------------- 1 | pub mod audio; 2 | pub mod web; 3 | 4 | // from https://rustacean.net/ 5 | pub const FAVICON: &[u8] = include_bytes!("./files/ferris-64x.png"); 6 | -------------------------------------------------------------------------------- /ferrogallic_web/src/page.rs: -------------------------------------------------------------------------------- 1 | pub mod choose_name; 2 | pub mod create; 3 | pub mod in_game; 4 | 5 | pub use choose_name::ChooseName; 6 | pub use create::Create; 7 | pub use in_game::InGame; 8 | -------------------------------------------------------------------------------- /ferrogallic/src/files/web.rs: -------------------------------------------------------------------------------- 1 | pub const CSS: &[u8] = include_bytes!("../../../ferrogallic_web/src/styles/main.css"); 2 | 3 | pub const JS: &[u8] = include_bytes!("../../../ferrogallic_web/pkg/ferrogallic_web.js"); 4 | 5 | pub const WASM: &[u8] = include_bytes!("../../../ferrogallic_web/pkg/ferrogallic_web_bg.wasm"); 6 | -------------------------------------------------------------------------------- /ferrogallic_shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ferrogallic_shared" 3 | version = "0.0.0" 4 | authors = ["Erik Desjardins "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | serde = { version = "1.0", features = ["derive", "rc"] } 9 | time = { version = "0.3", default-features = false, features = ["serde"] } 10 | -------------------------------------------------------------------------------- /ferrogallic_shared/src/api/lobby.rs: -------------------------------------------------------------------------------- 1 | use crate::api::ApiEndpoint; 2 | use crate::domain::Lobby; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Deserialize, Serialize)] 6 | pub struct RandomLobbyName { 7 | pub lobby: Lobby, 8 | } 9 | 10 | impl ApiEndpoint for RandomLobbyName { 11 | const PATH: &'static str = "random_lobby_name"; 12 | type Req = (); 13 | } 14 | -------------------------------------------------------------------------------- /ferrogallic/src/opt.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgAction, Parser}; 2 | use std::net::SocketAddr; 3 | 4 | #[derive(Parser, Debug)] 5 | #[clap(version, about)] 6 | pub struct Options { 7 | /// logging verbosity (-v info, -vv debug, -vvv trace) 8 | #[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)] 9 | pub verbose: u8, 10 | 11 | pub listen_addr: SocketAddr, 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ferrogallic 2 | 3 | Clone of skribble.io. 4 | 5 | image 6 | 7 | ## Development 8 | 9 | ```sh 10 | watchexec -d 1000 -c -r "wasm-pack build --target web --dev ferrogallic_web && cargo run --manifest-path ferrogallic/Cargo.toml -- 127.0.0.1:8080 -v" 11 | ``` 12 | -------------------------------------------------------------------------------- /ferrogallic/src/reply.rs: -------------------------------------------------------------------------------- 1 | use warp::{http, Reply}; 2 | 3 | pub fn bytes(data: &'static [u8], content_type: &'static str) -> impl Reply { 4 | http::Response::builder() 5 | .header(http::header::CONTENT_TYPE, content_type) 6 | .body(data) 7 | } 8 | 9 | pub fn string(data: &'static str, content_type: &'static str) -> impl Reply { 10 | bytes(data.as_bytes(), content_type) 11 | } 12 | -------------------------------------------------------------------------------- /ferrogallic_web/src/dom.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::JsCast; 2 | use web_sys::{HtmlInputElement, InputEvent}; 3 | 4 | pub trait InputEventExt { 5 | fn target_value(&self) -> String; 6 | } 7 | 8 | impl InputEventExt for InputEvent { 9 | fn target_value(&self) -> String { 10 | let optional_value = || Some(self.target()?.dyn_into::().ok()?.value()); 11 | optional_value().unwrap_or_default() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ferrogallic_shared/src/api.rs: -------------------------------------------------------------------------------- 1 | use serde::de::DeserializeOwned; 2 | use serde::Serialize; 3 | 4 | pub mod game; 5 | pub mod lobby; 6 | 7 | pub trait ApiEndpoint: Serialize + DeserializeOwned + 'static { 8 | const PATH: &'static str; 9 | type Req: Serialize + DeserializeOwned; 10 | } 11 | 12 | pub trait WsEndpoint: Serialize + DeserializeOwned + 'static { 13 | const PATH: &'static str; 14 | type Req: Serialize + DeserializeOwned; 15 | } 16 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component.rs: -------------------------------------------------------------------------------- 1 | pub mod choose_popup; 2 | pub mod color_toolbar; 3 | pub mod error_popup; 4 | pub mod guess_area; 5 | pub mod guess_input; 6 | pub mod guess_template; 7 | pub mod players; 8 | pub mod timer; 9 | pub mod tool_toolbar; 10 | 11 | pub use choose_popup::ChoosePopup; 12 | pub use color_toolbar::ColorToolbar; 13 | pub use error_popup::ErrorPopup; 14 | pub use guess_area::GuessArea; 15 | pub use guess_input::GuessInput; 16 | pub use guess_template::GuessTemplate; 17 | pub use players::Players; 18 | pub use timer::Timer; 19 | pub use tool_toolbar::ToolToolbar; 20 | -------------------------------------------------------------------------------- /ferrogallic_web/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "1024"] 2 | #![allow(clippy::let_unit_value, clippy::match_bool)] 3 | 4 | use wasm_bindgen::prelude::wasm_bindgen; 5 | 6 | mod api; 7 | mod app; 8 | mod audio; 9 | mod canvas; 10 | mod component; 11 | mod dom; 12 | mod page; 13 | mod route; 14 | mod util; 15 | 16 | #[wasm_bindgen(start)] 17 | pub fn start() { 18 | #[cfg(debug_assertions)] 19 | console_error_panic_hook::set_once(); 20 | #[cfg(debug_assertions)] 21 | wasm_logger::init(wasm_logger::Config::new(log::Level::Trace)); 22 | 23 | yew::Renderer::::new().render(); 24 | } 25 | -------------------------------------------------------------------------------- /ferrogallic/src/api/lobby.rs: -------------------------------------------------------------------------------- 1 | use crate::words; 2 | use ferrogallic_shared::api::lobby::RandomLobbyName; 3 | use ferrogallic_shared::domain::Lobby; 4 | use rand::seq::SliceRandom; 5 | use rand::thread_rng; 6 | use std::convert::Infallible; 7 | 8 | pub fn random_name(_state: (), _req: ()) -> Result { 9 | let lobby = words::COMMON_FOR_ROOM_NAMES 10 | .choose_multiple(&mut thread_rng(), 3) 11 | .map(|word| word[0..1].to_uppercase() + &word[1..]) 12 | .collect::(); 13 | Ok(RandomLobbyName { 14 | lobby: Lobby::new(lobby), 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /ferrogallic_shared/src/paths.rs: -------------------------------------------------------------------------------- 1 | pub mod audio { 2 | pub const PREFIX: &str = "audio"; 3 | 4 | pub const CHIMES: &str = "chimes.wav"; 5 | pub const CHORD: &str = "chord.wav"; 6 | pub const DING: &str = "ding.wav"; 7 | pub const TADA: &str = "tada.wav"; 8 | 9 | pub const ASTERISK: &str = "asterisk.wav"; 10 | pub const EXCLAM: &str = "exclamation.wav"; 11 | pub const MAXIMIZE: &str = "maximize.wav"; 12 | pub const SHUTDOWN: &str = "shutdown.wav"; 13 | pub const STARTUP: &str = "startup.wav"; 14 | } 15 | 16 | pub mod api { 17 | pub const PREFIX: &str = "api"; 18 | } 19 | 20 | pub mod ws { 21 | pub const PREFIX: &str = "ws"; 22 | } 23 | -------------------------------------------------------------------------------- /ferrogallic/src/files/audio.rs: -------------------------------------------------------------------------------- 1 | pub const CHIMES: &[u8] = include_bytes!("./audio/95_chimes_16bit_nr.wav"); 2 | pub const CHORD: &[u8] = include_bytes!("./audio/95_chord_16bit_nr.wav"); 3 | pub const DING: &[u8] = include_bytes!("./audio/95_ding_16bit_nr.wav"); 4 | pub const TADA: &[u8] = include_bytes!("./audio/95_tada_16bit_nr.wav"); 5 | 6 | pub const ASTERISK: &[u8] = include_bytes!("./audio/98_asterisk.wav"); 7 | pub const EXCLAM: &[u8] = include_bytes!("./audio/98_exclamation.wav"); 8 | pub const MAXIMIZE: &[u8] = include_bytes!("./audio/98_maximize.wav"); 9 | pub const SHUTDOWN: &[u8] = include_bytes!("./audio/98_shutdown.wav"); 10 | // pub const STARTUP: &[u8] = include_bytes!("./files/98_startup.wav"); 11 | -------------------------------------------------------------------------------- /ferrogallic/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow( 2 | clippy::redundant_pattern_matching, 3 | clippy::single_match, 4 | clippy::too_many_arguments, 5 | clippy::vec_init_then_push 6 | )] 7 | 8 | mod api; 9 | mod files; 10 | mod opt; 11 | mod reply; 12 | mod server; 13 | mod words; 14 | 15 | #[tokio::main(flavor = "multi_thread")] 16 | async fn main() { 17 | let opt::Options { 18 | verbose, 19 | listen_addr, 20 | } = clap::Parser::parse(); 21 | 22 | env_logger::Builder::new() 23 | .filter_level(match verbose { 24 | 0 => log::LevelFilter::Warn, 25 | 1 => log::LevelFilter::Info, 26 | 2 => log::LevelFilter::Debug, 27 | _ => log::LevelFilter::Trace, 28 | }) 29 | .init(); 30 | 31 | server::run(listen_addr).await; 32 | } 33 | -------------------------------------------------------------------------------- /ferrogallic_shared/src/config.rs: -------------------------------------------------------------------------------- 1 | pub const MAX_REQUEST_BYTES: u64 = 4 * 1024; 2 | pub const MAX_WS_MESSAGE_BYTES: usize = 4 * 1024; 3 | 4 | pub const RX_SHARED_BUFFER: usize = 64; 5 | pub const TX_BROADCAST_BUFFER: usize = 256; 6 | pub const TX_SELF_DELAYED_BUFFER: usize = 4; 7 | 8 | pub const CANVAS_WIDTH: usize = 800; 9 | pub const CANVAS_HEIGHT: usize = 600; 10 | 11 | pub const HEARTBEAT_SECONDS: u64 = 45; 12 | 13 | pub const NUMBER_OF_WORDS_TO_CHOOSE: usize = 3; 14 | pub const DEFAULT_ROUNDS: u8 = 3; 15 | pub const DEFAULT_GUESS_SECONDS: u16 = 120; 16 | pub const PERFECT_GUESS_SCORE: u32 = 500; 17 | pub const MINIMUM_GUESS_SCORE: u32 = 100; 18 | pub const FIRST_CORRECT_BONUS: u32 = 50; 19 | pub fn close_guess_levenshtein(word: &str) -> usize { 20 | match word.len() { 21 | 0..=4 => 1, 22 | 5..=7 => 2, 23 | _ => 3, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ferrogallic/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ferrogallic" 3 | version = "0.4.22" 4 | authors = ["Erik Desjardins "] 5 | description = "Clone of skribble.io." 6 | repository = "https://github.com/erikdesjardins/ferrogallic" 7 | license = "MIT" 8 | edition = "2018" 9 | 10 | [dependencies] 11 | anyhow = "1.0" 12 | bincode = "1.2" 13 | clap = { version = "4", features = ["derive"] } 14 | ferrogallic_shared = { path = "../ferrogallic_shared" } 15 | futures = "0.3" 16 | env_logger = { version = "0.11", default-features = false, features = ["humantime"] } 17 | log = "0.4" 18 | rand = "0.8" 19 | strsim = "0.11" 20 | time = { version = "0.3", default-features = false, features = ["std"] } 21 | tokio = { version = "1.0", features = ["fs", "io-util", "macros", "rt", "rt-multi-thread", "sync", "time", "parking_lot"] } 22 | tokio-util = { version = "0.7", features = ["time"] } 23 | warp = { version = "0.3", default-features = false, features = ["websocket"] } 24 | 25 | [profile.release] 26 | panic = "abort" 27 | lto = true 28 | codegen-units = 1 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ferrogallic_web/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ferrogallic_web/src/util.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{Deref, DerefMut}; 2 | use std::sync::Arc; 3 | use yew::html::IntoPropValue; 4 | 5 | #[derive(Default)] 6 | pub struct ArcPtrEq(Arc); 7 | 8 | impl From for ArcPtrEq { 9 | fn from(x: T) -> Self { 10 | Self(Arc::new(x)) 11 | } 12 | } 13 | 14 | impl From> for ArcPtrEq { 15 | fn from(x: Arc) -> Self { 16 | Self(x) 17 | } 18 | } 19 | 20 | impl IntoPropValue> for Arc { 21 | fn into_prop_value(self) -> ArcPtrEq { 22 | ArcPtrEq(self) 23 | } 24 | } 25 | 26 | impl Clone for ArcPtrEq { 27 | fn clone(&self) -> Self { 28 | ArcPtrEq(self.0.clone()) 29 | } 30 | } 31 | 32 | impl PartialEq for ArcPtrEq { 33 | fn eq(&self, other: &Self) -> bool { 34 | Arc::ptr_eq(&self.0, &other.0) 35 | } 36 | } 37 | 38 | impl Deref for ArcPtrEq { 39 | type Target = Arc; 40 | 41 | fn deref(&self) -> &Self::Target { 42 | &self.0 43 | } 44 | } 45 | 46 | impl DerefMut for ArcPtrEq { 47 | fn deref_mut(&mut self) -> &mut Self::Target { 48 | &mut self.0 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ferrogallic_web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ferrogallic_web" 3 | version = "0.0.0" 4 | authors = ["Erik Desjardins "] 5 | description = "Clone of skribble.io." 6 | repository = "https://github.com/erikdesjardins/ferrogallic" 7 | license = "MIT" 8 | edition = "2018" 9 | 10 | [lib] 11 | crate-type = ["cdylib"] 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | bincode = "1.3" 16 | console_error_panic_hook = "0.1" 17 | ferrogallic_shared = { path = "../ferrogallic_shared" } 18 | futures = "0.3.21" 19 | gloo = "0.11" 20 | itertools = "0.12" 21 | js-sys = "0.3" 22 | log = { version = "0.4", features = ["release_max_level_off"] } 23 | percent-encoding = "2.1" 24 | thiserror = "1.0" 25 | time = { version = "0.3", default-features = false } 26 | wasm-bindgen = { version = "0.2", features = ["strict-macro"] } 27 | wasm-bindgen-futures = "0.4" 28 | wasm-logger = "0.2" 29 | web-sys = { version = "0.3", features = ["Window", "Location", "HtmlAudioElement", "HtmlCanvasElement", "CanvasRenderingContext2d", "Element", "DomRect", "ImageData", "TouchList", "Touch"] } 30 | yew = { version = "0.21", features = ["csr"] } 31 | yew-router = "0.18" 32 | 33 | [dev-dependencies] 34 | wasm-bindgen-test = "0.3" 35 | 36 | [profile.release] 37 | panic = "abort" 38 | lto = true 39 | codegen-units = 1 40 | opt-level = "s" 41 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/timer.rs: -------------------------------------------------------------------------------- 1 | use gloo::timers::callback::Interval; 2 | use js_sys::Date; 3 | use time::Duration; 4 | use time::OffsetDateTime; 5 | use yew::{html, Component, Context, Html, Properties}; 6 | 7 | pub enum Msg { 8 | Tick, 9 | } 10 | 11 | #[derive(Properties, PartialEq)] 12 | pub struct Props { 13 | pub started: OffsetDateTime, 14 | pub count_down_from: Duration, 15 | } 16 | 17 | pub struct Timer { 18 | #[allow(dead_code)] // timer is cancelled when this is dropped 19 | active_timer: Interval, 20 | } 21 | 22 | impl Component for Timer { 23 | type Message = Msg; 24 | type Properties = Props; 25 | 26 | fn create(ctx: &Context) -> Self { 27 | let link = ctx.link().clone(); 28 | let active_timer = Interval::new(1000, move || link.send_message(Msg::Tick)); 29 | Self { active_timer } 30 | } 31 | 32 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 33 | match msg { 34 | Msg::Tick => true, 35 | } 36 | } 37 | 38 | fn view(&self, ctx: &Context) -> Html { 39 | let elapsed = Duration::milliseconds( 40 | Date::now() as i64 - ctx.props().started.unix_timestamp() * 1000, 41 | ); 42 | let time_left = ctx.props().count_down_from - elapsed; 43 | let seconds_left = time_left.whole_seconds(); 44 | html! { 45 | {seconds_left} 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ferrogallic_web/src/route.rs: -------------------------------------------------------------------------------- 1 | use ferrogallic_shared::domain::{Lobby, Nickname}; 2 | use percent_encoding::{percent_decode, percent_encode, AsciiSet, CONTROLS}; 3 | use std::fmt; 4 | use std::ops::Deref; 5 | use std::str::FromStr; 6 | use yew_router::Routable; 7 | 8 | #[derive(Clone, PartialEq, Routable)] 9 | pub enum AppRoute { 10 | #[at("/join/:lobby/as/:nick")] 11 | InGame { 12 | lobby: UrlEncoded, 13 | nick: UrlEncoded, 14 | }, 15 | #[at("/join/:lobby")] 16 | ChooseName { lobby: UrlEncoded }, 17 | #[at("/create")] 18 | #[not_found] 19 | Create, 20 | } 21 | 22 | #[derive(Clone, PartialEq)] 23 | pub struct UrlEncoded(pub T); 24 | 25 | impl FromStr for UrlEncoded { 26 | type Err = T::Err; 27 | 28 | fn from_str(s: &str) -> Result { 29 | Ok(Self(T::from_str( 30 | &percent_decode(s.as_bytes()).decode_utf8_lossy(), 31 | )?)) 32 | } 33 | } 34 | 35 | impl> fmt::Display for UrlEncoded { 36 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 37 | const URL_ESCAPE_CHARS: AsciiSet = CONTROLS 38 | .add(b' ') 39 | .add(b'/') 40 | .add(b'\\') 41 | .add(b'?') 42 | .add(b'&') 43 | .add(b'=') 44 | .add(b'#') 45 | .add(b'*'); 46 | fmt::Display::fmt(&percent_encode(self.0.as_bytes(), &URL_ESCAPE_CHARS), f) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/guess_input.rs: -------------------------------------------------------------------------------- 1 | use crate::dom::InputEventExt; 2 | use crate::page; 3 | use ferrogallic_shared::domain::Lowercase; 4 | use web_sys::{InputEvent, SubmitEvent}; 5 | use yew::{html, Callback, Component, Context, Html, Properties}; 6 | 7 | pub enum Msg {} 8 | 9 | #[derive(PartialEq, Properties)] 10 | pub struct Props { 11 | pub game_link: Callback, 12 | pub guess: Lowercase, 13 | } 14 | 15 | pub struct GuessInput {} 16 | 17 | impl Component for GuessInput { 18 | type Message = Msg; 19 | type Properties = Props; 20 | 21 | fn create(_ctx: &Context) -> Self { 22 | Self {} 23 | } 24 | 25 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 26 | match msg {} 27 | } 28 | 29 | fn view(&self, ctx: &Context) -> Html { 30 | let on_change_guess = ctx.props().game_link.reform(|e: InputEvent| { 31 | page::in_game::Msg::SetGuess(Lowercase::new(e.target_value().trim())) 32 | }); 33 | let on_submit = ctx.props().game_link.reform(|e: SubmitEvent| { 34 | e.prevent_default(); 35 | page::in_game::Msg::SendGuess 36 | }); 37 | html! { 38 |
39 | 45 |
46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/color_toolbar.rs: -------------------------------------------------------------------------------- 1 | use crate::page; 2 | use ferrogallic_shared::domain::Color; 3 | use yew::{classes, html, Callback, Component, Context, Html, Properties}; 4 | 5 | pub enum Msg {} 6 | 7 | #[derive(PartialEq, Properties)] 8 | pub struct Props { 9 | pub game_link: Callback, 10 | pub color: Color, 11 | } 12 | 13 | pub struct ColorToolbar {} 14 | 15 | impl Component for ColorToolbar { 16 | type Message = Msg; 17 | type Properties = Props; 18 | 19 | fn create(_ctx: &Context) -> Self { 20 | Self {} 21 | } 22 | 23 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 24 | match msg {} 25 | } 26 | 27 | fn view(&self, ctx: &Context) -> Html { 28 | let colors = Color::ALL 29 | .iter() 30 | .map(|&color| { 31 | let on_click = ctx.props() 32 | .game_link 33 | .reform(move |_| page::in_game::Msg::SetColor(color)); 34 | let active = (color == ctx.props().color).then_some("active"); 35 | let style = format!( 36 | "background-color: rgb({}, {}, {})", 37 | color.r, color.g, color.b 38 | ); 39 | html! { 40 | 40 | } 41 | }) 42 | .collect::(); 43 | 44 | html! { 45 | 46 |
47 |
48 |
{"Choose Word"}
49 |
50 |
51 |
52 | {words} 53 |
54 |
55 |
56 |
57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ferrogallic_web/src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::component; 2 | use crate::page; 3 | use crate::route::AppRoute; 4 | use crate::util::ArcPtrEq; 5 | use anyhow::Error; 6 | use std::convert::identity; 7 | use yew::{html, Callback, Component, Context, Html}; 8 | use yew_router::router::BrowserRouter; 9 | use yew_router::Switch; 10 | 11 | pub enum Msg { 12 | SetError(Error), 13 | ClearError, 14 | } 15 | 16 | pub struct App { 17 | link: Callback, 18 | error: Option>, 19 | } 20 | 21 | impl Component for App { 22 | type Message = Msg; 23 | type Properties = (); 24 | 25 | fn create(ctx: &Context) -> Self { 26 | Self { 27 | link: ctx.link().callback(identity), 28 | error: None, 29 | } 30 | } 31 | 32 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 33 | match msg { 34 | Msg::SetError(e) => { 35 | self.error = Some(e.into()); 36 | true 37 | } 38 | Msg::ClearError => { 39 | self.error = None; 40 | true 41 | } 42 | } 43 | } 44 | 45 | fn view(&self, _ctx: &Context) -> Html { 46 | let app_link = self.link.clone(); 47 | let render_app = move |route| match route { 48 | AppRoute::Create => html! {}, 49 | AppRoute::ChooseName { lobby } => html! {}, 50 | AppRoute::InGame { lobby, nick } => { 51 | html! {} 52 | } 53 | }; 54 | html! { 55 | <> 56 | 57 | render={render_app}/> 58 | 59 | 60 | 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ferrogallic_web/src/audio.rs: -------------------------------------------------------------------------------- 1 | use ferrogallic_shared::domain::{Guess, UserId}; 2 | use ferrogallic_shared::paths; 3 | use js_sys::Promise; 4 | use wasm_bindgen::JsValue; 5 | use web_sys::HtmlAudioElement; 6 | 7 | pub struct AudioService { 8 | elems: Result, 9 | } 10 | 11 | struct Elems { 12 | chimes: HtmlAudioElement, 13 | chord: HtmlAudioElement, 14 | ding: HtmlAudioElement, 15 | tada: HtmlAudioElement, 16 | asterisk: HtmlAudioElement, 17 | exclam: HtmlAudioElement, 18 | maximize: HtmlAudioElement, 19 | shutdown: HtmlAudioElement, 20 | } 21 | 22 | impl AudioService { 23 | pub fn new() -> Self { 24 | let elem = 25 | |path| HtmlAudioElement::new_with_src(&format!("/{}/{}", paths::audio::PREFIX, path)); 26 | let elems = || { 27 | Ok(Elems { 28 | chimes: elem(paths::audio::CHIMES)?, 29 | chord: elem(paths::audio::CHORD)?, 30 | ding: elem(paths::audio::DING)?, 31 | tada: elem(paths::audio::TADA)?, 32 | asterisk: elem(paths::audio::ASTERISK)?, 33 | exclam: elem(paths::audio::EXCLAM)?, 34 | maximize: elem(paths::audio::MAXIMIZE)?, 35 | shutdown: elem(paths::audio::SHUTDOWN)?, 36 | }) 37 | }; 38 | 39 | Self { elems: elems() } 40 | } 41 | 42 | pub fn handle_guess(&mut self, user_id: UserId, guess: &Guess) -> Result<(), JsValue> { 43 | let elems = self.elems.as_ref()?; 44 | let elem = match guess { 45 | Guess::NowChoosing(uid) if *uid == user_id => &elems.maximize, 46 | Guess::NowDrawing(_) => &elems.exclam, 47 | Guess::Guess(_, _) => &elems.ding, 48 | Guess::CloseGuess(_) => &elems.asterisk, 49 | Guess::Correct(uid) if *uid == user_id => &elems.tada, 50 | Guess::Correct(_) => &elems.chimes, 51 | Guess::TimeExpired(_) => &elems.chord, 52 | Guess::GameOver => &elems.shutdown, 53 | _ => return Ok(()), 54 | }; 55 | 56 | elem.set_current_time(0.); 57 | // ignore the promise, we don't care when it starts playing 58 | let _: Promise = elem.play()?; 59 | 60 | Ok(()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/error_popup.rs: -------------------------------------------------------------------------------- 1 | use crate::app; 2 | use crate::util::ArcPtrEq; 3 | use anyhow::Error; 4 | use gloo::utils::window; 5 | use yew::{html, Callback, Component, Context, Html, Properties}; 6 | 7 | pub enum Msg {} 8 | 9 | #[derive(Properties, PartialEq)] 10 | pub struct Props { 11 | pub app_link: Callback, 12 | pub error: Option>, 13 | } 14 | 15 | pub struct ErrorPopup {} 16 | 17 | impl Component for ErrorPopup { 18 | type Message = Msg; 19 | type Properties = Props; 20 | 21 | fn create(_ctx: &Context) -> Self { 22 | Self {} 23 | } 24 | 25 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 26 | match msg {} 27 | } 28 | 29 | fn view(&self, ctx: &Context) -> Html { 30 | let e = match &ctx.props().error { 31 | Some(e) => e, 32 | None => return html! {}, 33 | }; 34 | 35 | let message = e 36 | .chain() 37 | .map(|e| e.to_string()) 38 | .collect::>() 39 | .join("; caused by: "); 40 | let on_close = ctx.props().app_link.reform(|_| app::Msg::ClearError); 41 | let on_refresh = Callback::from(|_| { 42 | if let Err(e) = window().location().reload() { 43 | log::error!("Failed to reload: {:?}", e); 44 | } 45 | }); 46 | 47 | html! { 48 | 49 |
50 |
51 |
{"Error!"}
52 |
53 |
55 |
56 |
57 |

58 |

{message}
59 |

60 |
61 | 62 |
63 |
64 |
65 |
66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ferrogallic_web/src/canvas.rs: -------------------------------------------------------------------------------- 1 | use ferrogallic_shared::api::game::Canvas; 2 | use ferrogallic_shared::domain::CanvasBuffer; 3 | use std::mem; 4 | use wasm_bindgen::{Clamped, JsValue}; 5 | use web_sys::{CanvasRenderingContext2d, ImageData}; 6 | 7 | mod draw; 8 | mod flood_fill; 9 | 10 | pub struct VirtualCanvas { 11 | buffer: Box, 12 | undo_stage: Option>, 13 | undo_stack: Vec>, 14 | } 15 | 16 | impl VirtualCanvas { 17 | pub fn new() -> Self { 18 | Self { 19 | buffer: CanvasBuffer::boxed(), 20 | undo_stage: Default::default(), 21 | undo_stack: Default::default(), 22 | } 23 | } 24 | 25 | pub fn handle_event(&mut self, event: Canvas) { 26 | match event { 27 | Canvas::Line { 28 | from, 29 | to, 30 | width, 31 | color, 32 | } => { 33 | draw::stroke_line( 34 | &mut self.buffer, 35 | from.x(), 36 | from.y(), 37 | to.x(), 38 | to.y(), 39 | width, 40 | color, 41 | ); 42 | } 43 | Canvas::Fill { at, color } => { 44 | flood_fill::fill(&mut self.buffer, at.x(), at.y(), color); 45 | } 46 | Canvas::PushUndo => { 47 | let buffer = self.buffer.clone_boxed(); 48 | let prev_buffer = mem::replace(&mut self.undo_stage, Some(buffer)); 49 | if let Some(prev) = prev_buffer { 50 | self.undo_stack.push(prev); 51 | } 52 | } 53 | Canvas::PopUndo => { 54 | self.undo_stage = None; 55 | self.buffer = match self.undo_stack.pop() { 56 | Some(undo) => undo, 57 | // all the way back to the beginning 58 | None => CanvasBuffer::boxed(), 59 | }; 60 | } 61 | Canvas::Clear => { 62 | *self = Self::new(); 63 | } 64 | } 65 | } 66 | 67 | pub fn render_to(&mut self, canvas: &CanvasRenderingContext2d) -> Result<(), JsValue> { 68 | let width = self.buffer.x_len(); 69 | let image_data = ImageData::new_with_u8_clamped_array( 70 | Clamped(self.buffer.as_mut_bytes()), 71 | width as u32, 72 | )?; 73 | canvas.put_image_data(&image_data, 0., 0.) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/players.rs: -------------------------------------------------------------------------------- 1 | use crate::page; 2 | use ferrogallic_shared::api::game::{Player, PlayerStatus}; 3 | use ferrogallic_shared::domain::UserId; 4 | use std::collections::BTreeMap; 5 | use std::sync::Arc; 6 | use yew::{html, Callback, Component, Context, Html, MouseEvent, Properties}; 7 | 8 | pub enum Msg {} 9 | 10 | #[derive(PartialEq, Properties)] 11 | pub struct Props { 12 | pub game_link: Callback, 13 | pub players: Arc>, 14 | } 15 | 16 | pub struct Players {} 17 | 18 | impl Component for Players { 19 | type Message = Msg; 20 | type Properties = Props; 21 | 22 | fn create(_ctx: &Context) -> Self { 23 | Self {} 24 | } 25 | 26 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 27 | match msg {} 28 | } 29 | 30 | fn view(&self, ctx: &Context) -> Html { 31 | let player_rankings = Player::rankings(ctx.props().players.as_ref()) 32 | .take_while(|(_, _, player)| player.score > 0) 33 | .map(|(rank, uid, _)| (uid, rank)) 34 | .collect::>(); 35 | let players = ctx 36 | .props() 37 | .players 38 | .iter() 39 | .map(|(&user_id, player)| { 40 | let ranking = match player_rankings.get(&user_id) { 41 | Some(rank) => html! { <>{" (#"}{rank}{")"} }, 42 | None => html! {}, 43 | }; 44 | let status = match player.status { 45 | PlayerStatus::Connected => html! { "connected" }, 46 | PlayerStatus::Disconnected => { 47 | let epoch = player.epoch; 48 | let on_remove = ctx.props().game_link.reform(move |e: MouseEvent| { 49 | e.prevent_default(); 50 | page::in_game::Msg::RemovePlayer(user_id, epoch) 51 | }); 52 | html! { 53 | <> 54 | {"disconnected "} 55 | {"(remove)"} 56 | 57 | } 58 | } 59 | }; 60 | html! { 61 |
  • 62 | {&player.nick} 63 |
      64 |
    • {"Score: "}{player.score}{ranking}
    • 65 |
    • {"Status: "}{status}
    • 66 |
    67 |
  • 68 | } 69 | }) 70 | .collect::(); 71 | 72 | html! { 73 |
      74 | {players} 75 |
    76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/tool_toolbar.rs: -------------------------------------------------------------------------------- 1 | use crate::page; 2 | use ferrogallic_shared::domain::{LineWidth, Tool}; 3 | use yew::{classes, html, Callback, Component, Context, Html, Properties}; 4 | 5 | pub enum Msg {} 6 | 7 | #[derive(PartialEq, Properties)] 8 | pub struct Props { 9 | pub game_link: Callback, 10 | pub tool: Tool, 11 | } 12 | 13 | pub struct ToolToolbar {} 14 | 15 | impl Component for ToolToolbar { 16 | type Message = Msg; 17 | type Properties = Props; 18 | 19 | fn create(_ctx: &Context) -> Self { 20 | Self {} 21 | } 22 | 23 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 24 | match msg {} 25 | } 26 | 27 | fn view(&self, ctx: &Context) -> Html { 28 | let tools = Tool::ALL 29 | .iter() 30 | .map(|&tool| { 31 | let on_click = ctx 32 | .props() 33 | .game_link 34 | .reform(move |_| page::in_game::Msg::SetTool(tool)); 35 | let active = (tool == ctx.props().tool).then_some("active"); 36 | let (text, style, title) = match tool { 37 | Tool::Pen(width) => ( 38 | "⚫", 39 | match width { 40 | LineWidth::R0 => "font-size: 2px", 41 | LineWidth::R1 => "font-size: 4px", 42 | LineWidth::R2 => "font-size: 6px", 43 | LineWidth::R4 => "font-size: 10px", 44 | LineWidth::R7 => "font-size: 14px", 45 | }, 46 | match width { 47 | LineWidth::R0 => "Pen (1)", 48 | LineWidth::R1 => "Pen (2)", 49 | LineWidth::R2 => "Pen (3)", 50 | LineWidth::R4 => "Pen (4)", 51 | LineWidth::R7 => "Pen (5)", 52 | }, 53 | ), 54 | Tool::Fill => ("▧", "font-size: 28px", "Fill (F)"), 55 | }; 56 | html! { 57 | 60 | } 61 | }) 62 | .collect::(); 63 | 64 | let on_undo = ctx.props().game_link.reform(|_| page::in_game::Msg::Undo); 65 | let undo = html! { 66 | 69 | }; 70 | 71 | html! { 72 |
    73 | {tools} 74 | {undo} 75 |
    76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v*.*.* 9 | pull_request: 10 | 11 | jobs: 12 | fmt: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: stable 19 | - run: rustup component add rustfmt 20 | 21 | - run: cargo fmt --manifest-path ferrogallic/Cargo.toml -- --check 22 | if: "!cancelled()" 23 | - run: cargo fmt --manifest-path ferrogallic_shared/Cargo.toml -- --check 24 | if: "!cancelled()" 25 | - run: cargo fmt --manifest-path ferrogallic_web/Cargo.toml -- --check 26 | if: "!cancelled()" 27 | 28 | clippy: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions-rs/toolchain@v1 33 | with: 34 | toolchain: stable 35 | - run: rustup component add clippy 36 | - run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -f 37 | 38 | - run: wasm-pack build --dev --target web ferrogallic_web 39 | - run: RUSTFLAGS="-D warnings" cargo clippy --manifest-path ferrogallic/Cargo.toml 40 | if: "!cancelled()" 41 | - run: RUSTFLAGS="-D warnings" cargo clippy --manifest-path ferrogallic_shared/Cargo.toml 42 | if: "!cancelled()" 43 | - run: RUSTFLAGS="-D warnings" cargo clippy --manifest-path ferrogallic_web/Cargo.toml 44 | if: "!cancelled()" 45 | 46 | test: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v2 50 | - uses: actions-rs/toolchain@v1 51 | with: 52 | toolchain: stable 53 | - run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -f 54 | 55 | - run: wasm-pack build --dev --target web ferrogallic_web 56 | - run: cargo test --manifest-path ferrogallic/Cargo.toml 57 | if: "!cancelled()" 58 | - run: cargo test --manifest-path ferrogallic_shared/Cargo.toml 59 | if: "!cancelled()" 60 | - run: wasm-pack test --chrome --headless ferrogallic_web 61 | if: "!cancelled()" 62 | 63 | build-linux: 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v2 67 | - uses: actions-rs/toolchain@v1 68 | with: 69 | toolchain: stable 70 | target: x86_64-unknown-linux-musl 71 | - run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -f 72 | 73 | - run: wasm-pack build --release --target web ferrogallic_web 74 | - run: ls -lh ferrogallic_web/pkg/ferrogallic_web_bg.wasm 75 | - run: cargo build --release --manifest-path ferrogallic/Cargo.toml --target=x86_64-unknown-linux-musl 76 | - run: strip ferrogallic/target/x86_64-unknown-linux-musl/release/ferrogallic 77 | - run: ls -lh ferrogallic/target/x86_64-unknown-linux-musl/release/ferrogallic 78 | 79 | - uses: softprops/action-gh-release@v1 80 | if: startsWith(github.ref, 'refs/tags/') 81 | with: 82 | files: ferrogallic/target/x86_64-unknown-linux-musl/release/ferrogallic 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | -------------------------------------------------------------------------------- /ferrogallic_web/src/page/choose_name.rs: -------------------------------------------------------------------------------- 1 | use crate::dom::InputEventExt; 2 | use crate::route::{AppRoute, UrlEncoded}; 3 | use ferrogallic_shared::domain::{Lobby, Nickname}; 4 | use web_sys::SubmitEvent; 5 | use yew::{html, Component, Context, Html, InputEvent, Properties}; 6 | use yew_router::scope_ext::RouterScopeExt; 7 | 8 | pub enum Msg { 9 | SetNick(Nickname), 10 | GoToLobby, 11 | } 12 | 13 | #[derive(PartialEq, Properties)] 14 | pub struct Props { 15 | pub lobby: Lobby, 16 | } 17 | 18 | pub struct ChooseName { 19 | nick: Nickname, 20 | } 21 | 22 | impl Component for ChooseName { 23 | type Message = Msg; 24 | type Properties = Props; 25 | 26 | fn create(_ctx: &Context) -> Self { 27 | Self { 28 | nick: Nickname::new(""), 29 | } 30 | } 31 | 32 | fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { 33 | match msg { 34 | Msg::SetNick(nick) => { 35 | self.nick = nick; 36 | true 37 | } 38 | Msg::GoToLobby => { 39 | if let Some(navigator) = ctx.link().navigator() { 40 | navigator.push(&AppRoute::InGame { 41 | lobby: UrlEncoded(ctx.props().lobby.clone()), 42 | nick: UrlEncoded(self.nick.clone()), 43 | }); 44 | } 45 | false 46 | } 47 | } 48 | } 49 | 50 | fn view(&self, ctx: &Context) -> Html { 51 | let on_change_nick = ctx 52 | .link() 53 | .callback(|e: InputEvent| Msg::SetNick(Nickname::new(e.target_value()))); 54 | let on_join_game = ctx.link().callback(|e: SubmitEvent| { 55 | e.prevent_default(); 56 | Msg::GoToLobby 57 | }); 58 | html! { 59 |
    60 |
    61 |
    62 |
    {"Join Game - "}{&ctx.props().lobby}
    63 |
    64 |
    65 |
    66 |

    67 | 68 | 74 |

    75 |
    76 | 79 |
    80 |
    81 |
    82 |
    83 |
    84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /ferrogallic_shared/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "deranged" 7 | version = "0.3.11" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 10 | dependencies = [ 11 | "powerfmt", 12 | "serde", 13 | ] 14 | 15 | [[package]] 16 | name = "ferrogallic_shared" 17 | version = "0.0.0" 18 | dependencies = [ 19 | "serde", 20 | "time", 21 | ] 22 | 23 | [[package]] 24 | name = "powerfmt" 25 | version = "0.2.0" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 28 | 29 | [[package]] 30 | name = "proc-macro2" 31 | version = "1.0.76" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" 34 | dependencies = [ 35 | "unicode-ident", 36 | ] 37 | 38 | [[package]] 39 | name = "quote" 40 | version = "1.0.35" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 43 | dependencies = [ 44 | "proc-macro2", 45 | ] 46 | 47 | [[package]] 48 | name = "serde" 49 | version = "1.0.195" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" 52 | dependencies = [ 53 | "serde_derive", 54 | ] 55 | 56 | [[package]] 57 | name = "serde_derive" 58 | version = "1.0.195" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" 61 | dependencies = [ 62 | "proc-macro2", 63 | "quote", 64 | "syn", 65 | ] 66 | 67 | [[package]] 68 | name = "syn" 69 | version = "2.0.48" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" 72 | dependencies = [ 73 | "proc-macro2", 74 | "quote", 75 | "unicode-ident", 76 | ] 77 | 78 | [[package]] 79 | name = "time" 80 | version = "0.3.31" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" 83 | dependencies = [ 84 | "deranged", 85 | "powerfmt", 86 | "serde", 87 | "time-core", 88 | "time-macros", 89 | ] 90 | 91 | [[package]] 92 | name = "time-core" 93 | version = "0.1.2" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" 96 | 97 | [[package]] 98 | name = "time-macros" 99 | version = "0.2.16" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" 102 | dependencies = [ 103 | "time-core", 104 | ] 105 | 106 | [[package]] 107 | name = "unicode-ident" 108 | version = "1.0.12" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 111 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/guess_template.rs: -------------------------------------------------------------------------------- 1 | use ferrogallic_shared::domain::Lowercase; 2 | use itertools::{EitherOrBoth, Itertools}; 3 | use yew::{classes, html, Component, Context, Html, Properties}; 4 | 5 | pub enum Msg {} 6 | 7 | #[derive(Properties, PartialEq)] 8 | pub struct Props { 9 | pub word: Lowercase, 10 | pub reveal: Reveal, 11 | pub guess: Lowercase, 12 | } 13 | 14 | pub struct GuessTemplate {} 15 | 16 | #[derive(Copy, Clone, PartialEq)] 17 | pub enum Reveal { 18 | All, 19 | Spaces, 20 | } 21 | 22 | impl Component for GuessTemplate { 23 | type Message = Msg; 24 | type Properties = Props; 25 | 26 | fn create(_ctx: &Context) -> Self { 27 | Self {} 28 | } 29 | 30 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 31 | match msg {} 32 | } 33 | 34 | fn view(&self, ctx: &Context) -> Html { 35 | use EitherOrBoth::*; 36 | 37 | let reveal_chars: fn(char) -> Template = match ctx.props().reveal { 38 | Reveal::All => |c| match c { 39 | ' ' => Template::Space, 40 | _ => Template::Exact(c), 41 | }, 42 | Reveal::Spaces => |c| match c { 43 | ' ' => Template::Space, 44 | _ => Template::NonSpace, 45 | }, 46 | }; 47 | 48 | let template_chars = ctx.props().word.chars().map(reveal_chars); 49 | let guess_chars = ctx.props().guess.chars(); 50 | 51 | let template = template_chars 52 | .zip_longest(guess_chars) 53 | .map(|entry| match entry { 54 | Both(template, guess) => { 55 | let underlined = template.is_underlined().then_some("underlined"); 56 | let invalid = (!template.is_valid(guess)).then_some("invalid"); 57 | html! { {guess} } 58 | } 59 | Left(template) => { 60 | let underlined = template.is_underlined().then_some("underlined"); 61 | html! { {template.char()} } 62 | } 63 | Right(guess) => { 64 | html! { {guess} } 65 | } 66 | }) 67 | .collect::(); 68 | 69 | html! { 70 |
    {template}
    71 | } 72 | } 73 | } 74 | 75 | #[derive(Copy, Clone, PartialEq, Eq)] 76 | enum Template { 77 | Space, 78 | NonSpace, 79 | Exact(char), 80 | } 81 | 82 | impl Template { 83 | fn is_underlined(self) -> bool { 84 | match self { 85 | Self::Space => false, 86 | Self::NonSpace => true, 87 | Self::Exact(_) => true, 88 | } 89 | } 90 | 91 | fn is_valid(self, c: char) -> bool { 92 | match self { 93 | Self::Space => c == ' ', 94 | Self::NonSpace => c != ' ', 95 | Self::Exact(e) => c == e, 96 | } 97 | } 98 | 99 | fn char(self) -> char { 100 | match self { 101 | Self::Space => ' ', 102 | Self::NonSpace => ' ', 103 | Self::Exact(e) => e, 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /ferrogallic_web/src/canvas/flood_fill.rs: -------------------------------------------------------------------------------- 1 | // http://www.adammil.net/blog/v126_A_More_Efficient_Flood_Fill.html 2 | 3 | use ferrogallic_shared::domain::{CanvasBuffer, Color}; 4 | use std::convert::TryInto; 5 | 6 | pub fn fill(buf: &mut CanvasBuffer, x: i16, y: i16, to: Color) { 7 | let (x, y) = match (x.try_into(), y.try_into()) { 8 | (Ok(x), Ok(y)) => (x, y), 9 | _ => return, 10 | }; 11 | if x < buf.x_len() && y < buf.y_len() { 12 | let from = buf.get(x, y); 13 | if from != to { 14 | run_flood_fill(buf, x, y, from, to); 15 | } 16 | } 17 | } 18 | 19 | fn run_flood_fill(buf: &mut CanvasBuffer, mut x: usize, mut y: usize, from: Color, to: Color) { 20 | loop { 21 | let ox = x; 22 | let oy = y; 23 | while y != 0 && buf.get(x, y - 1) == from { 24 | y -= 1; 25 | } 26 | while x != 0 && buf.get(x - 1, y) == from { 27 | x -= 1; 28 | } 29 | if x == ox && y == oy { 30 | break; 31 | } 32 | } 33 | run_flood_fill_core(buf, x, y, from, to); 34 | } 35 | 36 | #[allow(clippy::useless_let_if_seq)] 37 | fn run_flood_fill_core(buf: &mut CanvasBuffer, mut x: usize, mut y: usize, from: Color, to: Color) { 38 | let mut last_row_len = 0; 39 | loop { 40 | let mut row_len = 0; 41 | let mut sx = x; 42 | if last_row_len != 0 && buf.get(x, y) == to { 43 | loop { 44 | last_row_len -= 1; 45 | if last_row_len == 0 { 46 | return; 47 | } 48 | x += 1; 49 | if buf.get(x, y) != to { 50 | break; 51 | } 52 | } 53 | sx = x; 54 | } else { 55 | loop { 56 | if x == 0 || buf.get(x - 1, y) != from { 57 | break; 58 | } 59 | x -= 1; 60 | buf.set(x, y, to); 61 | if y != 0 && buf.get(x, y - 1) == from { 62 | run_flood_fill(buf, x, y - 1, from, to); 63 | } 64 | row_len += 1; 65 | last_row_len += 1; 66 | } 67 | } 68 | 69 | loop { 70 | if sx >= buf.x_len() || buf.get(sx, y) != from { 71 | break; 72 | } 73 | buf.set(sx, y, to); 74 | row_len += 1; 75 | sx += 1; 76 | } 77 | 78 | if row_len < last_row_len { 79 | let end = x + last_row_len; 80 | loop { 81 | sx += 1; 82 | if sx >= end { 83 | break; 84 | } 85 | if buf.get(sx, y) == from { 86 | run_flood_fill_core(buf, sx, y, from, to); 87 | } 88 | } 89 | } else if row_len > last_row_len && y != 0 { 90 | let mut ux = x + last_row_len; 91 | loop { 92 | ux += 1; 93 | if ux >= sx { 94 | break; 95 | } 96 | if buf.get(ux, y - 1) == from { 97 | run_flood_fill(buf, ux, y - 1, from, to); 98 | } 99 | } 100 | } 101 | last_row_len = row_len; 102 | y += 1; 103 | if last_row_len == 0 || y >= buf.y_len() { 104 | break; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /ferrogallic_web/src/api.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Error}; 2 | use ferrogallic_shared::api::{ApiEndpoint, WsEndpoint}; 3 | use ferrogallic_shared::paths; 4 | use futures::stream::{SplitSink, SplitStream}; 5 | use futures::{FutureExt, SinkExt, StreamExt}; 6 | use gloo::net::http::Request; 7 | use gloo::net::websocket::futures::WebSocket; 8 | use gloo::net::websocket::Message; 9 | use js_sys::Uint8Array; 10 | use std::marker::PhantomData; 11 | use web_sys::window; 12 | 13 | pub async fn fetch_api(req: &T::Req) -> Result { 14 | let url = format!("/{}/{}", paths::api::PREFIX, T::PATH); 15 | let payload = bincode::serialize(req)?; 16 | let body = { 17 | // Safety: the vec backing `payload` is not resized, modified, or dropped while the Uint8Array exists 18 | let payload = unsafe { Uint8Array::view(&payload) }; 19 | let response = Request::post(&url).body(payload)?.send().await?; 20 | response.binary().await? 21 | }; 22 | drop(payload); 23 | let parsed = bincode::deserialize(&body)?; 24 | Ok(parsed) 25 | } 26 | 27 | pub fn connect_api( 28 | ) -> Result<(TypedWebSocketReader, TypedWebSocketWriter), Error> { 29 | let url = match window() 30 | .map(|w| w.location()) 31 | .and_then(|l| Some((l.protocol().ok()?, l.host().ok()?))) 32 | { 33 | Some((proto, host)) => { 34 | let proto = if proto == "http:" { "ws:" } else { "wss:" }; 35 | format!("{}//{}/{}/{}", proto, host, paths::ws::PREFIX, T::PATH) 36 | } 37 | None => { 38 | return Err(anyhow!("Failed to get window.location")); 39 | } 40 | }; 41 | let socket = WebSocket::open(&url).map_err(|e| anyhow!("Failed to open websocket: {}", e))?; 42 | let (writer, reader) = socket.split(); 43 | Ok(( 44 | TypedWebSocketReader(reader, PhantomData), 45 | TypedWebSocketWriter(writer, PhantomData), 46 | )) 47 | } 48 | 49 | pub struct TypedWebSocketReader(SplitStream, PhantomData T>); 50 | 51 | impl TypedWebSocketReader { 52 | pub async fn next_api(&mut self) -> Option> { 53 | self.0.next().await.map(|msg| { 54 | let msg = msg.map_err(|e| anyhow!("Failed to read websocket message: {}", e))?; 55 | let body = match msg { 56 | Message::Bytes(v) => v, 57 | Message::Text(s) => return Err(anyhow!("Unexpected string ws body: {}", s)), 58 | }; 59 | let parsed = bincode::deserialize(&body)?; 60 | Ok(parsed) 61 | }) 62 | } 63 | } 64 | 65 | pub struct TypedWebSocketWriter( 66 | SplitSink, 67 | PhantomData T>, 68 | ); 69 | 70 | impl TypedWebSocketWriter { 71 | pub async fn wait_for_connection_and_send(&mut self, req: &T::Req) -> Result<(), Error> { 72 | let payload = bincode::serialize(req)?; 73 | self.0 74 | .send(Message::Bytes(payload)) 75 | .await 76 | .map_err(|e| anyhow!("Failed to send websocket message: {}", e))?; 77 | Ok(()) 78 | } 79 | 80 | pub fn send_sync(&mut self, req: &T::Req) -> Result<(), Error> { 81 | let payload = bincode::serialize(req)?; 82 | match self.0.send(Message::Bytes(payload)).now_or_never() { 83 | Some(r) => r.map_err(|e| anyhow!("Failed to send websocket message: {}", e)), 84 | None => Err(anyhow!("Websocket wasn't ready to send")), 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /ferrogallic/src/server.rs: -------------------------------------------------------------------------------- 1 | use crate::api; 2 | use crate::files; 3 | use crate::reply::{bytes, string}; 4 | use ferrogallic_shared::config::MAX_REQUEST_BYTES; 5 | use ferrogallic_shared::paths; 6 | use std::net::SocketAddr; 7 | use std::sync::Arc; 8 | use warp::{http, Filter}; 9 | 10 | #[allow(clippy::let_and_return)] 11 | pub async fn run(addr: SocketAddr) { 12 | let static_files = warp::get().and(warp::path("static")).and({ 13 | let favicon = warp::path!("favicon.png").map(|| bytes(files::FAVICON, "image/png")); 14 | let main_css = warp::path!("main.css").map(|| bytes(files::web::CSS, "text/css")); 15 | let main_js = 16 | warp::path!("main.js").map(|| bytes(files::web::JS, "application/javascript")); 17 | let main_wasm = 18 | warp::path!("main.wasm").map(|| bytes(files::web::WASM, "application/wasm")); 19 | let index_js = warp::path!("index.js").map(|| { 20 | string( 21 | "import init from '/static/main.js'; init('/static/main.wasm');", 22 | "application/javascript", 23 | ) 24 | }); 25 | favicon.or(main_css).or(main_js).or(main_wasm).or(index_js) 26 | }); 27 | 28 | let audio_files = warp::get() 29 | .and(warp::path(paths::audio::PREFIX)) 30 | .and({ 31 | api::wav(paths::audio::CHIMES, files::audio::CHIMES) 32 | .or(api::wav(paths::audio::CHORD, files::audio::CHORD)) 33 | .or(api::wav(paths::audio::DING, files::audio::DING)) 34 | .or(api::wav(paths::audio::TADA, files::audio::TADA)) 35 | .or(api::wav(paths::audio::ASTERISK, files::audio::ASTERISK)) 36 | .or(api::wav(paths::audio::EXCLAM, files::audio::EXCLAM)) 37 | .or(api::wav(paths::audio::MAXIMIZE, files::audio::MAXIMIZE)) 38 | .or(api::wav(paths::audio::SHUTDOWN, files::audio::SHUTDOWN)) 39 | // .or(api::wav(paths::audio::STARTUP, files::audio::STARTUP)) 40 | }) 41 | .with(warp::reply::with::header( 42 | http::header::CACHE_CONTROL, 43 | "public, max-age=86400", 44 | )); 45 | 46 | let index = warp::get().map(|| { 47 | string( 48 | concat!( 49 | "", 50 | "", 51 | "", 52 | "", 53 | "", 54 | "", 55 | "", 56 | "", 57 | "", 58 | "", 59 | "", 60 | "", 61 | ), 62 | "text/html", 63 | ) 64 | }); 65 | 66 | let state = Arc::default(); 67 | 68 | let api = warp::post() 69 | .and(warp::path(paths::api::PREFIX)) 70 | .and(warp::body::content_length_limit(MAX_REQUEST_BYTES)) 71 | .and({ 72 | let random_lobby_name = api::endpoint((), api::lobby::random_name); 73 | random_lobby_name 74 | }); 75 | 76 | let ws = warp::path(paths::ws::PREFIX).and({ 77 | let game = api::websocket(state, api::game::join_game); 78 | game 79 | }); 80 | 81 | let server = static_files 82 | .or(audio_files) 83 | .or(api) 84 | .or(ws) 85 | .or(index) 86 | .with(warp::log(env!("CARGO_PKG_NAME"))); 87 | 88 | warp::serve(server).run(addr).await; 89 | } 90 | -------------------------------------------------------------------------------- /ferrogallic_shared/src/api/game.rs: -------------------------------------------------------------------------------- 1 | use crate::api::WsEndpoint; 2 | use crate::config::{DEFAULT_GUESS_SECONDS, DEFAULT_ROUNDS}; 3 | use crate::domain::{Color, Epoch, Guess, I12Pair, LineWidth, Lobby, Lowercase, Nickname, UserId}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::BTreeMap; 6 | use std::sync::Arc; 7 | use time::OffsetDateTime; 8 | 9 | #[derive(Debug, Deserialize, Serialize, Clone)] 10 | pub enum Game { 11 | Canvas(Canvas), 12 | Guess(Guess), 13 | Players(Arc>), 14 | Game(Arc), 15 | Heartbeat, 16 | CanvasBulk(Vec), 17 | GuessBulk(Vec), 18 | ClearGuesses, 19 | } 20 | 21 | #[test] 22 | fn game_size() { 23 | assert_eq!(std::mem::size_of::(), 32); 24 | } 25 | 26 | #[derive(Debug, Deserialize, Serialize)] 27 | pub enum GameReq { 28 | Canvas(Canvas), 29 | Choose(Lowercase), 30 | Guess(Lowercase), 31 | Join(Lobby, Nickname), 32 | Remove(UserId, Epoch), 33 | } 34 | 35 | #[test] 36 | fn gamereq_size() { 37 | assert_eq!(std::mem::size_of::(), 40); 38 | } 39 | 40 | impl WsEndpoint for Game { 41 | const PATH: &'static str = "game"; 42 | type Req = GameReq; 43 | } 44 | 45 | #[derive(Debug, Deserialize, Serialize, Clone, Default)] 46 | pub struct GameState { 47 | pub config: GameConfig, 48 | pub phase: GamePhase, 49 | } 50 | 51 | #[derive(Debug, Deserialize, Serialize, Clone)] 52 | pub struct GameConfig { 53 | pub rounds: u8, 54 | pub guess_seconds: u16, 55 | } 56 | 57 | impl Default for GameConfig { 58 | fn default() -> Self { 59 | Self { 60 | rounds: DEFAULT_ROUNDS, 61 | guess_seconds: DEFAULT_GUESS_SECONDS, 62 | } 63 | } 64 | } 65 | 66 | #[derive(Debug, Deserialize, Serialize, Clone)] 67 | pub enum GamePhase { 68 | WaitingToStart, 69 | ChoosingWords { 70 | round: u8, 71 | choosing: UserId, 72 | words: Arc<[Lowercase]>, 73 | }, 74 | Drawing { 75 | round: u8, 76 | drawing: UserId, 77 | correct: BTreeMap, 78 | word: Lowercase, 79 | epoch: Epoch, 80 | started: OffsetDateTime, 81 | }, 82 | } 83 | 84 | impl Default for GamePhase { 85 | fn default() -> Self { 86 | Self::WaitingToStart 87 | } 88 | } 89 | 90 | #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] 91 | pub struct Player { 92 | pub nick: Nickname, 93 | pub epoch: Epoch, 94 | pub status: PlayerStatus, 95 | pub score: u32, 96 | } 97 | 98 | impl Player { 99 | pub fn rankings<'a>( 100 | players: impl IntoIterator, 101 | ) -> impl Iterator { 102 | let mut players_by_score = players 103 | .into_iter() 104 | .map(|(uid, player)| (*uid, player)) 105 | .collect::>(); 106 | players_by_score.sort_by_key(|(_, player)| player.score); 107 | players_by_score.into_iter().rev().enumerate().scan( 108 | (u32::MAX, 0), 109 | |(prev_score, prev_rank), (index, (uid, player))| { 110 | if player.score == *prev_score { 111 | Some((*prev_rank, uid, player)) 112 | } else { 113 | let rank = index as u64 + 1; 114 | *prev_score = player.score; 115 | *prev_rank = rank; 116 | Some((rank, uid, player)) 117 | } 118 | }, 119 | ) 120 | } 121 | } 122 | 123 | #[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialEq)] 124 | pub enum PlayerStatus { 125 | Connected, 126 | Disconnected, 127 | } 128 | 129 | #[derive(Debug, Deserialize, Serialize, Copy, Clone)] 130 | pub enum Canvas { 131 | Line { 132 | from: I12Pair, 133 | to: I12Pair, 134 | width: LineWidth, 135 | color: Color, 136 | }, 137 | Fill { 138 | at: I12Pair, 139 | color: Color, 140 | }, 141 | PushUndo, 142 | PopUndo, 143 | Clear, 144 | } 145 | 146 | #[test] 147 | fn canvas_size() { 148 | assert_eq!(std::mem::size_of::(), 11); 149 | } 150 | -------------------------------------------------------------------------------- /ferrogallic_web/src/component/guess_area.rs: -------------------------------------------------------------------------------- 1 | use crate::util::ArcPtrEq; 2 | use ferrogallic_shared::api::game::Player; 3 | use ferrogallic_shared::domain::{Guess, UserId}; 4 | use std::collections::BTreeMap; 5 | use web_sys::Element; 6 | use yew::{html, Component, Context, Html, NodeRef, Properties}; 7 | 8 | pub enum Msg {} 9 | 10 | #[derive(Properties, PartialEq)] 11 | pub struct Props { 12 | pub players: ArcPtrEq>, 13 | pub guesses: ArcPtrEq>, 14 | } 15 | 16 | pub struct GuessArea { 17 | area_ref: NodeRef, 18 | } 19 | 20 | impl Component for GuessArea { 21 | type Message = Msg; 22 | type Properties = Props; 23 | 24 | fn create(_ctx: &Context) -> Self { 25 | Self { 26 | area_ref: Default::default(), 27 | } 28 | } 29 | 30 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 31 | match msg {} 32 | } 33 | 34 | fn rendered(&mut self, _ctx: &Context, _first_render: bool) { 35 | if let Some(area) = self.area_ref.cast::() { 36 | area.set_scroll_top(i32::MAX); 37 | } 38 | } 39 | 40 | fn view(&self, ctx: &Context) -> Html { 41 | let guesses = ctx 42 | .props() 43 | .guesses 44 | .iter() 45 | .map(|guess| html! { }) 46 | .collect::(); 47 | 48 | html! { 49 |
      {guesses}
    50 | } 51 | } 52 | } 53 | 54 | mod guess { 55 | use super::*; 56 | 57 | pub enum Msg {} 58 | 59 | #[derive(PartialEq, Properties)] 60 | pub struct Props { 61 | pub players: ArcPtrEq>, 62 | pub guess: Guess, 63 | } 64 | 65 | pub struct GuessLine {} 66 | 67 | impl Component for GuessLine { 68 | type Message = Msg; 69 | type Properties = Props; 70 | 71 | fn create(_ctx: &Context) -> Self { 72 | Self {} 73 | } 74 | 75 | fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { 76 | match msg {} 77 | } 78 | 79 | fn view(&self, ctx: &Context) -> Html { 80 | let nickname = |user_id| { 81 | ctx.props() 82 | .players 83 | .get(&user_id) 84 | .map(|p| &*p.nick) 85 | .unwrap_or("") 86 | }; 87 | 88 | let rank_emoji = |rank| match rank { 89 | 1 => "🏆", 90 | 2 | 3 => "🏅", 91 | _ => "🎖️", 92 | }; 93 | 94 | match &ctx.props().guess { 95 | Guess::System(system) => html! { 96 |
  • {"🖥️ "}{system}
  • 97 | }, 98 | Guess::Help => html! { 99 | <> 100 |
  • {"❓ Type 'start' to start the game."}
  • 101 |
  • {"❓ Type 'rounds ' to change number of rounds."}
  • 102 |
  • {"❓ Type 'seconds ' to change guess timer."}
  • 103 | 104 | }, 105 | Guess::Message(user_id, message) => html! { 106 |
  • {nickname(*user_id)}{": "}{message}
  • 107 | }, 108 | Guess::NowChoosing(user_id) => html! { 109 |
  • {"✨ "}{nickname(*user_id)}{" is choosing a word."}
  • 110 | }, 111 | Guess::NowDrawing(user_id) => html! { 112 |
  • {"🖌️ "}{nickname(*user_id)}{" is drawing!"}
  • 113 | }, 114 | Guess::Guess(user_id, guess) => html! { 115 |
  • {"❌ "}{nickname(*user_id)}{" guessed '"}{guess}{"'."}
  • 116 | }, 117 | Guess::CloseGuess(guess) => html! { 118 |
  • {"🤏 '"}{guess}{"' is close!"}
  • 119 | }, 120 | Guess::Correct(user_id) => html! { 121 |
  • {"✔️ "}{nickname(*user_id)}{" guessed correctly!"}
  • 122 | }, 123 | Guess::EarnedPoints(user_id, points) => html! { 124 |
  • {"💵 "}{nickname(*user_id)}{" earned "}{points}{" points."}
  • 125 | }, 126 | Guess::TimeExpired(word) => html! { 127 |
  • {"⏰ Time's up! The word was '"}{word}{"'."}
  • 128 | }, 129 | Guess::GameOver => html! { 130 |
  • {"🎮 Game over!"}
  • 131 | }, 132 | Guess::FinalScore { 133 | rank, 134 | user_id, 135 | score, 136 | } => html! { 137 |
  • {rank_emoji(*rank)}{" (#"}{rank}{") "}{nickname(*user_id)}{" with "}{score}{" points."}
  • 138 | }, 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /ferrogallic/src/api.rs: -------------------------------------------------------------------------------- 1 | use crate::reply::bytes; 2 | use anyhow::Error; 3 | use ferrogallic_shared::api::{ApiEndpoint, WsEndpoint}; 4 | use ferrogallic_shared::config::MAX_WS_MESSAGE_BYTES; 5 | use futures::ready; 6 | use futures::task::{Context, Poll}; 7 | use std::convert::Infallible; 8 | use std::future::Future; 9 | use std::marker::PhantomData; 10 | use std::pin::Pin; 11 | use warp::http::{Response, StatusCode}; 12 | use warp::hyper::body::Buf; 13 | use warp::ws::{Message, WebSocket}; 14 | use warp::{Filter, Rejection, Reply, Sink, Stream}; 15 | 16 | pub mod game; 17 | pub mod lobby; 18 | 19 | pub fn endpoint( 20 | state: S, 21 | f: F, 22 | ) -> impl Filter + Clone 23 | where 24 | S: Clone + Send + 'static, 25 | T: ApiEndpoint, 26 | E: Into, 27 | F: Fn(S, T::Req) -> Result + Clone + Send, 28 | { 29 | warp::path(T::PATH) 30 | .and(warp::path::end()) 31 | .and(warp::body::aggregate()) 32 | .and(with_cloned(state)) 33 | .map(move |buf, state| { 34 | let req = match bincode::deserialize_from(Buf::reader(buf)) { 35 | Ok(req) => req, 36 | Err(e) => { 37 | log::warn!("Failed to deserialize request '{}': {}", T::PATH, e); 38 | return warp::reply::with_status(Response::default(), StatusCode::BAD_REQUEST); 39 | } 40 | }; 41 | let reply = match f(state, req) { 42 | Ok(reply) => reply, 43 | Err(e) => { 44 | log::error!("Error in API handler '{}': {}", T::PATH, e.into()); 45 | return warp::reply::with_status(Response::default(), StatusCode::CONFLICT); 46 | } 47 | }; 48 | match bincode::serialize(&reply) { 49 | Ok(body) => warp::reply::with_status(Response::new(body), StatusCode::OK), 50 | Err(e) => { 51 | log::error!("Failed to serialize response '{}': {}", T::PATH, e); 52 | warp::reply::with_status(Response::default(), StatusCode::INTERNAL_SERVER_ERROR) 53 | } 54 | } 55 | }) 56 | } 57 | 58 | pub fn websocket( 59 | state: S, 60 | f: F, 61 | ) -> impl Filter + Clone 62 | where 63 | S: Clone + Send + 'static, 64 | T: WsEndpoint, 65 | F: Fn(S, TypedWebSocket) -> Fut + Copy + Send + 'static, 66 | Fut: Future> + Send, 67 | E: Into, 68 | { 69 | warp::path(T::PATH) 70 | .and(warp::path::end()) 71 | .and(warp::ws()) 72 | .and(with_cloned(state)) 73 | .map(move |ws: warp::ws::Ws, state| { 74 | ws.max_message_size(MAX_WS_MESSAGE_BYTES) 75 | .max_frame_size(MAX_WS_MESSAGE_BYTES) 76 | .on_upgrade(move |websocket| async move { 77 | let fut = f(state, TypedWebSocket::new(websocket)); 78 | if let Err(e) = fut.await { 79 | log::error!("Error in WS handler '{}': {}", T::PATH, e.into()); 80 | } 81 | }) 82 | }) 83 | } 84 | 85 | pub fn wav( 86 | path: &'static str, 87 | data: &'static [u8], 88 | ) -> impl Filter + Clone { 89 | warp::path(path) 90 | .and(warp::path::end()) 91 | .map(move || bytes(data, "audio/wav")) 92 | } 93 | 94 | fn with_cloned(val: T) -> impl Filter + Clone { 95 | warp::any().map(move || val.clone()) 96 | } 97 | 98 | pub struct TypedWebSocket(WebSocket, PhantomData); 99 | 100 | impl TypedWebSocket { 101 | fn new(ws: WebSocket) -> Self { 102 | Self(ws, PhantomData) 103 | } 104 | } 105 | 106 | impl Stream for TypedWebSocket { 107 | type Item = Result; 108 | 109 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 110 | Poll::Ready(match ready!(Pin::new(&mut self.0).poll_next(cx)) { 111 | Some(Ok(msg)) => match bincode::deserialize(msg.as_bytes()) { 112 | Ok(req) => Some(Ok(req)), 113 | Err(e) => Some(Err(e.into())), 114 | }, 115 | Some(Err(e)) => Some(Err(e.into())), 116 | None => None, 117 | }) 118 | } 119 | } 120 | 121 | impl Sink<&T> for TypedWebSocket { 122 | type Error = Error; 123 | 124 | fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 125 | Poll::Ready(match ready!(Pin::new(&mut self.0).poll_ready(cx)) { 126 | Ok(()) => Ok(()), 127 | Err(e) => Err(e.into()), 128 | }) 129 | } 130 | 131 | fn start_send(mut self: Pin<&mut Self>, item: &T) -> Result<(), Self::Error> { 132 | match bincode::serialize(item) { 133 | Ok(msg) => match Pin::new(&mut self.0).start_send(Message::binary(msg)) { 134 | Ok(()) => Ok(()), 135 | Err(e) => Err(e.into()), 136 | }, 137 | Err(e) => Err(e.into()), 138 | } 139 | } 140 | 141 | fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 142 | Poll::Ready(match ready!(Pin::new(&mut self.0).poll_flush(cx)) { 143 | Ok(()) => Ok(()), 144 | Err(e) => Err(e.into()), 145 | }) 146 | } 147 | 148 | fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 149 | Poll::Ready(match ready!(Pin::new(&mut self.0).poll_close(cx)) { 150 | Ok(()) => Ok(()), 151 | Err(e) => Err(e.into()), 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /ferrogallic_web/src/page/create.rs: -------------------------------------------------------------------------------- 1 | use crate::api::fetch_api; 2 | use crate::app; 3 | use crate::dom::InputEventExt; 4 | use crate::route::{AppRoute, UrlEncoded}; 5 | use anyhow::Error; 6 | use ferrogallic_shared::api::lobby::RandomLobbyName; 7 | use ferrogallic_shared::domain::Lobby; 8 | use wasm_bindgen_futures::spawn_local; 9 | use web_sys::{InputEvent, SubmitEvent}; 10 | use yew::{html, Callback, Component, Context, Html, Properties}; 11 | use yew_router::scope_ext::RouterScopeExt; 12 | 13 | pub enum Msg { 14 | SetCustomLobbyName(Lobby), 15 | SetGeneratedLobbyName(Lobby), 16 | GoToCustomLobby, 17 | GoToGeneratedLobby, 18 | SetGlobalError(Error), 19 | } 20 | 21 | #[derive(PartialEq, Properties)] 22 | pub struct Props { 23 | pub app_link: Callback, 24 | } 25 | 26 | pub struct Create { 27 | custom_lobby_name: Lobby, 28 | generated_lobby_name: Lobby, 29 | } 30 | 31 | impl Component for Create { 32 | type Message = Msg; 33 | type Properties = Props; 34 | 35 | fn create(_ctx: &Context) -> Self { 36 | Self { 37 | custom_lobby_name: Lobby::new(""), 38 | generated_lobby_name: Lobby::new(""), 39 | } 40 | } 41 | 42 | fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { 43 | match msg { 44 | Msg::SetCustomLobbyName(lobby) => { 45 | self.custom_lobby_name = lobby; 46 | true 47 | } 48 | Msg::SetGeneratedLobbyName(lobby) => { 49 | self.generated_lobby_name = lobby; 50 | true 51 | } 52 | Msg::GoToCustomLobby => { 53 | if let Some(navigator) = ctx.link().navigator() { 54 | navigator.push(&AppRoute::ChooseName { 55 | lobby: UrlEncoded(self.custom_lobby_name.clone()), 56 | }); 57 | } 58 | false 59 | } 60 | Msg::GoToGeneratedLobby => { 61 | if let Some(navigator) = ctx.link().navigator() { 62 | navigator.push(&AppRoute::ChooseName { 63 | lobby: UrlEncoded(self.generated_lobby_name.clone()), 64 | }); 65 | } 66 | false 67 | } 68 | Msg::SetGlobalError(e) => { 69 | ctx.props().app_link.emit(app::Msg::SetError(e)); 70 | false 71 | } 72 | } 73 | } 74 | 75 | fn rendered(&mut self, ctx: &Context, first_render: bool) { 76 | if first_render { 77 | let link = ctx.link().clone(); 78 | spawn_local(async move { 79 | link.send_message(match fetch_api(&()).await { 80 | Ok(RandomLobbyName { lobby }) => Msg::SetGeneratedLobbyName(lobby), 81 | Err(e) => Msg::SetGlobalError(e.context("Failed to fetch lobby name")), 82 | }); 83 | }); 84 | } 85 | } 86 | 87 | fn view(&self, ctx: &Context) -> Html { 88 | let on_change_custom_lobby = ctx 89 | .link() 90 | .callback(|e: InputEvent| Msg::SetCustomLobbyName(Lobby::new(e.target_value()))); 91 | let on_join_custom = ctx.link().callback(|e: SubmitEvent| { 92 | e.prevent_default(); 93 | Msg::GoToCustomLobby 94 | }); 95 | let on_join_generated = ctx.link().callback(|e: SubmitEvent| { 96 | e.prevent_default(); 97 | Msg::GoToGeneratedLobby 98 | }); 99 | html! { 100 |
    101 |
    102 |
    103 |
    {"Join Existing Game"}
    104 |
    105 |
    106 |
    107 |

    108 | 109 | 115 |

    116 |
    117 | 120 |
    121 |
    122 |
    123 |
    124 |
    125 |
    126 |
    {"Create New Game"}
    127 |
    128 |
    129 |
    130 |

    131 | 132 | 138 |

    139 |
    140 | 143 |
    144 |
    145 |
    146 |
    147 |
    148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /ferrogallic/src/words/common.rs: -------------------------------------------------------------------------------- 1 | pub const COMMON_FOR_ROOM_NAMES: &[&str] = &[ 2 | "accept", 3 | "actually", 4 | "afraid", 5 | "afternoon", 6 | "against", 7 | "almost", 8 | "already", 9 | "although", 10 | "angrier", 11 | "animal", 12 | "another", 13 | "answer", 14 | "anybody", 15 | "anymore", 16 | "anyone", 17 | "anyway", 18 | "anywhere", 19 | "apartment", 20 | "appear", 21 | "approach", 22 | "around", 23 | "arrive", 24 | "asleep", 25 | "attention", 26 | "barely", 27 | "bathroom", 28 | "beautiful", 29 | "because", 30 | "bedroom", 31 | "before", 32 | "behind", 33 | "belief", 34 | "believe", 35 | "believer", 36 | "belong", 37 | "beneath", 38 | "beside", 39 | "better", 40 | "between", 41 | "beyond", 42 | "bigger", 43 | "blocker", 44 | "blower", 45 | "bother", 46 | "bottle", 47 | "bottom", 48 | "bought", 49 | "branch", 50 | "breather", 51 | "bridge", 52 | "bright", 53 | "brighter", 54 | "bringer", 55 | "broken", 56 | "brother", 57 | "brought", 58 | "browner", 59 | "builder", 60 | "burner", 61 | "busiest", 62 | "calmer", 63 | "camera", 64 | "cannot", 65 | "careful", 66 | "carefully", 67 | "carrier", 68 | "catcher", 69 | "caught", 70 | "center", 71 | "certain", 72 | "certainly", 73 | "chance", 74 | "change", 75 | "checker", 76 | "children", 77 | "choice", 78 | "choose", 79 | "chosen", 80 | "church", 81 | "circle", 82 | "cleaner", 83 | "clearer", 84 | "clearly", 85 | "climber", 86 | "closer", 87 | "coffee", 88 | "colder", 89 | "college", 90 | "company", 91 | "completely", 92 | "computer", 93 | "confuse", 94 | "consider", 95 | "continue", 96 | "control", 97 | "controller", 98 | "conversation", 99 | "cooler", 100 | "corner", 101 | "counter", 102 | "country", 103 | "couple", 104 | "course", 105 | "crazier", 106 | "create", 107 | "creature", 108 | "crosser", 109 | "cutter", 110 | "darker", 111 | "daughter", 112 | "decide", 113 | "deeper", 114 | "despite", 115 | "different", 116 | "dinner", 117 | "direction", 118 | "disappear", 119 | "discover", 120 | "distance", 121 | "doctor", 122 | "doorway", 123 | "drinker", 124 | "driver", 125 | "dropper", 126 | "earlier", 127 | "easier", 128 | "easily", 129 | "effort", 130 | "either", 131 | "emptier", 132 | "engine", 133 | "enough", 134 | "entire", 135 | "especially", 136 | "everybody", 137 | "everyone", 138 | "everywhere", 139 | "exactly", 140 | "except", 141 | "expect", 142 | "explain", 143 | "expression", 144 | "fallen", 145 | "familiar", 146 | "family", 147 | "faster", 148 | "father", 149 | "fewest", 150 | "fighter", 151 | "figure", 152 | "finally", 153 | "finder", 154 | "finest", 155 | "finger", 156 | "finish", 157 | "fitter", 158 | "flatter", 159 | "flight", 160 | "flower", 161 | "follow", 162 | "forehead", 163 | "forest", 164 | "forever", 165 | "forget", 166 | "forgotten", 167 | "forward", 168 | "fought", 169 | "fresher", 170 | "friend", 171 | "fullest", 172 | "funnier", 173 | "future", 174 | "garden", 175 | "gather", 176 | "gently", 177 | "getter", 178 | "glance", 179 | "golder", 180 | "grandfather", 181 | "grandmother", 182 | "greater", 183 | "greener", 184 | "ground", 185 | "hallway", 186 | "happen", 187 | "happier", 188 | "harder", 189 | "hardly", 190 | "heater", 191 | "heavier", 192 | "helper", 193 | "herself", 194 | "hidden", 195 | "higher", 196 | "himself", 197 | "history", 198 | "holder", 199 | "hospital", 200 | "however", 201 | "husband", 202 | "ignore", 203 | "imagine", 204 | "immediately", 205 | "important", 206 | "information", 207 | "inside", 208 | "instead", 209 | "interest", 210 | "itself", 211 | "jacket", 212 | "joiner", 213 | "jumper", 214 | "keeper", 215 | "kicker", 216 | "kinder", 217 | "kitchen", 218 | "lander", 219 | "language", 220 | "larger", 221 | "leader", 222 | "lesser", 223 | "letter", 224 | "lifter", 225 | "lighter", 226 | "listen", 227 | "little", 228 | "longer", 229 | "louder", 230 | "lowest", 231 | "luckier", 232 | "machine", 233 | "manage", 234 | "marriage", 235 | "matter", 236 | "meanest", 237 | "member", 238 | "memory", 239 | "mention", 240 | "message", 241 | "middle", 242 | "minute", 243 | "mirror", 244 | "moment", 245 | "mostly", 246 | "mother", 247 | "mountain", 248 | "myself", 249 | "narrow", 250 | "narrower", 251 | "nearer", 252 | "nearly", 253 | "neighbor", 254 | "newest", 255 | "nicest", 256 | "nobody", 257 | "normal", 258 | "notice", 259 | "number", 260 | "office", 261 | "officer", 262 | "oldest", 263 | "opener", 264 | "outside", 265 | "parent", 266 | "people", 267 | "perfect", 268 | "person", 269 | "personal", 270 | "picture", 271 | "pinker", 272 | "plastic", 273 | "player", 274 | "please", 275 | "pocket", 276 | "pointer", 277 | "pointy", 278 | "police", 279 | "poorest", 280 | "popper", 281 | "position", 282 | "possible", 283 | "prepare", 284 | "presser", 285 | "pretend", 286 | "prettier", 287 | "pretty", 288 | "probably", 289 | "problem", 290 | "promise", 291 | "proven", 292 | "puller", 293 | "pusher", 294 | "question", 295 | "quicker", 296 | "quickly", 297 | "quieter", 298 | "quietly", 299 | "rather", 300 | "reader", 301 | "readier", 302 | "realest", 303 | "realize", 304 | "really", 305 | "reason", 306 | "receive", 307 | "recognize", 308 | "reddest", 309 | "refuse", 310 | "remain", 311 | "remember", 312 | "remind", 313 | "remove", 314 | "repeat", 315 | "replier", 316 | "return", 317 | "reveal", 318 | "richer", 319 | "righter", 320 | "rounder", 321 | "runner", 322 | "sadder", 323 | "safest", 324 | "school", 325 | "scream", 326 | "screen", 327 | "search", 328 | "second", 329 | "seller", 330 | "sender", 331 | "seriously", 332 | "service", 333 | "settle", 334 | "several", 335 | "shadow", 336 | "shaken", 337 | "shaker", 338 | "sharper", 339 | "shorter", 340 | "should", 341 | "shoulder", 342 | "shower", 343 | "sickest", 344 | "silence", 345 | "silent", 346 | "silver", 347 | "simple", 348 | "simply", 349 | "single", 350 | "sister", 351 | "situation", 352 | "sleeper", 353 | "slider", 354 | "slightly", 355 | "slower", 356 | "slowly", 357 | "smaller", 358 | "smelly", 359 | "smiler", 360 | "smoker", 361 | "softer", 362 | "softly", 363 | "somebody", 364 | "somehow", 365 | "someone", 366 | "somewhere", 367 | "sorrier", 368 | "soundest", 369 | "speaker", 370 | "special", 371 | "spender", 372 | "spinner", 373 | "spirit", 374 | "spoken", 375 | "sprang", 376 | "spread", 377 | "sprung", 378 | "starer", 379 | "starter", 380 | "station", 381 | "stealer", 382 | "sticker", 383 | "stolen", 384 | "stomach", 385 | "stopper", 386 | "straight", 387 | "strange", 388 | "street", 389 | "stretch", 390 | "strike", 391 | "strong", 392 | "stronger", 393 | "student", 394 | "suddenly", 395 | "suggest", 396 | "summer", 397 | "suppose", 398 | "surface", 399 | "surprise", 400 | "sweeter", 401 | "system", 402 | "taller", 403 | "taught", 404 | "teacher", 405 | "television", 406 | "terrible", 407 | "thicker", 408 | "thinner", 409 | "thirty", 410 | "throat", 411 | "thrower", 412 | "thrown", 413 | "tinier", 414 | "together", 415 | "tomorrow", 416 | "tongue", 417 | "tonight", 418 | "toothy", 419 | "toward", 420 | "travel", 421 | "trouble", 422 | "twenty", 423 | "understand", 424 | "usually", 425 | "village", 426 | "visitor", 427 | "walker", 428 | "warmer", 429 | "watery", 430 | "weight", 431 | "whatever", 432 | "whether", 433 | "whisper", 434 | "widest", 435 | "wilder", 436 | "window", 437 | "winter", 438 | "within", 439 | "without", 440 | "wonder", 441 | "wooden", 442 | "worker", 443 | "writer", 444 | "written", 445 | "yellow", 446 | "younger", 447 | "yourself", 448 | ]; 449 | -------------------------------------------------------------------------------- /ferrogallic_shared/src/domain.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{CANVAS_HEIGHT, CANVAS_WIDTH}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::alloc; 4 | use std::collections::hash_map::DefaultHasher; 5 | use std::convert::Infallible; 6 | use std::fmt; 7 | use std::hash::{Hash, Hasher}; 8 | use std::marker::PhantomData; 9 | use std::mem; 10 | use std::num::NonZeroUsize; 11 | use std::ops::Deref; 12 | use std::ptr; 13 | use std::slice; 14 | use std::str::FromStr; 15 | use std::sync::atomic::{AtomicUsize, Ordering}; 16 | use std::sync::Arc; 17 | 18 | #[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)] 19 | pub struct UserId(u64); 20 | 21 | #[derive(Debug, Deserialize, Serialize, Clone, PartialOrd, Ord, PartialEq, Eq)] 22 | pub struct Nickname(Arc); 23 | 24 | impl Nickname { 25 | pub fn new(nick: impl Into>) -> Self { 26 | Self(nick.into()) 27 | } 28 | 29 | pub fn user_id(&self) -> UserId { 30 | let mut s = DefaultHasher::new(); 31 | self.0.hash(&mut s); 32 | UserId(s.finish()) 33 | } 34 | } 35 | 36 | impl Deref for Nickname { 37 | type Target = str; 38 | 39 | fn deref(&self) -> &Self::Target { 40 | &self.0 41 | } 42 | } 43 | 44 | impl FromStr for Nickname { 45 | type Err = Infallible; 46 | 47 | fn from_str(s: &str) -> Result { 48 | Ok(Self(s.into())) 49 | } 50 | } 51 | 52 | impl fmt::Display for Nickname { 53 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 54 | fmt::Display::fmt(&self.0, f) 55 | } 56 | } 57 | 58 | #[derive(Debug, Deserialize, Serialize, Clone, PartialOrd, Ord, PartialEq, Eq)] 59 | pub struct Lobby(Arc); 60 | 61 | impl Lobby { 62 | pub fn new(lobby: impl Into>) -> Self { 63 | Self(lobby.into()) 64 | } 65 | } 66 | 67 | impl Deref for Lobby { 68 | type Target = str; 69 | 70 | fn deref(&self) -> &Self::Target { 71 | &self.0 72 | } 73 | } 74 | 75 | impl FromStr for Lobby { 76 | type Err = Infallible; 77 | 78 | fn from_str(s: &str) -> Result { 79 | Ok(Self(s.into())) 80 | } 81 | } 82 | 83 | impl fmt::Display for Lobby { 84 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 85 | fmt::Display::fmt(&self.0, f) 86 | } 87 | } 88 | 89 | #[derive(Debug, Deserialize, Serialize)] 90 | pub struct Epoch(NonZeroUsize, PhantomData); 91 | 92 | impl Copy for Epoch {} 93 | 94 | impl Clone for Epoch { 95 | fn clone(&self) -> Self { 96 | *self 97 | } 98 | } 99 | 100 | impl PartialEq for Epoch { 101 | fn eq(&self, other: &Self) -> bool { 102 | self.0 == other.0 103 | } 104 | } 105 | 106 | impl Eq for Epoch {} 107 | 108 | impl Epoch { 109 | pub fn next() -> Self { 110 | static NEXT: AtomicUsize = AtomicUsize::new(1); 111 | 112 | let epoch = NEXT.fetch_add(1, Ordering::Relaxed); 113 | Self(NonZeroUsize::new(epoch).unwrap(), PhantomData) 114 | } 115 | } 116 | 117 | impl fmt::Display for Epoch { 118 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 119 | fmt::Display::fmt(&self.0, f) 120 | } 121 | } 122 | 123 | #[derive(Debug, Deserialize, Serialize, Clone, PartialOrd, Ord, PartialEq, Eq)] 124 | pub struct Lowercase(Arc); 125 | 126 | impl Lowercase { 127 | pub fn new(str: impl Into) -> Self { 128 | let mut str = str.into(); 129 | str.make_ascii_lowercase(); 130 | Self(str.into()) 131 | } 132 | 133 | pub fn as_str(&self) -> &str { 134 | &self.0 135 | } 136 | } 137 | 138 | impl Default for Lowercase { 139 | fn default() -> Self { 140 | Self(Arc::from("")) 141 | } 142 | } 143 | 144 | impl Deref for Lowercase { 145 | type Target = str; 146 | 147 | fn deref(&self) -> &Self::Target { 148 | &self.0 149 | } 150 | } 151 | 152 | impl fmt::Display for Lowercase { 153 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 154 | fmt::Display::fmt(&self.0, f) 155 | } 156 | } 157 | 158 | #[derive(Debug, Deserialize, Serialize, Clone, PartialOrd, Ord, PartialEq, Eq)] 159 | pub enum Guess { 160 | System(Arc), 161 | Help, 162 | Message(UserId, Lowercase), 163 | NowChoosing(UserId), 164 | NowDrawing(UserId), 165 | Guess(UserId, Lowercase), 166 | CloseGuess(Lowercase), 167 | Correct(UserId), 168 | EarnedPoints(UserId, u32), 169 | TimeExpired(Lowercase), 170 | GameOver, 171 | FinalScore { 172 | rank: u64, 173 | user_id: UserId, 174 | score: u32, 175 | }, 176 | } 177 | 178 | #[test] 179 | fn guess_size() { 180 | assert_eq!(std::mem::size_of::(), 32); 181 | } 182 | 183 | #[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)] 184 | pub enum LineWidth { 185 | R0, 186 | R1, 187 | R2, 188 | R4, 189 | R7, 190 | } 191 | 192 | impl Default for LineWidth { 193 | fn default() -> Self { 194 | Self::R2 195 | } 196 | } 197 | 198 | impl LineWidth { 199 | pub fn scanlines(self) -> &'static [u16] { 200 | match self { 201 | Self::R0 => { 202 | // 0.5px radius 203 | // + 1 204 | &[1] 205 | } 206 | Self::R1 => { 207 | // 1px radius 208 | // +++ 209 | // +++ 3 210 | // +++ 3 211 | &[3, 3] 212 | } 213 | Self::R2 => { 214 | // 2px radius 215 | // +++ 216 | // +++++ 217 | // +++++ 5 218 | // +++++ 5 219 | // +++ 3 220 | &[5, 5, 3] 221 | } 222 | Self::R4 => { 223 | // 4px radius 224 | // +++++ 225 | // +++++++ 226 | // +++++++++ 227 | // +++++++++ 228 | // +++++++++ 9 229 | // +++++++++ 9 230 | // +++++++++ 9 231 | // +++++++ 7 232 | // +++++ 5 233 | &[9, 9, 9, 7, 5] 234 | } 235 | Self::R7 => { 236 | // 7px radius 237 | &[15, 15, 15, 13, 13, 11, 9, 5] 238 | } 239 | } 240 | } 241 | } 242 | 243 | #[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)] 244 | pub enum Tool { 245 | Pen(LineWidth), 246 | Fill, 247 | } 248 | 249 | impl Default for Tool { 250 | fn default() -> Self { 251 | Self::Pen(Default::default()) 252 | } 253 | } 254 | 255 | impl Tool { 256 | pub const ALL: [Self; 6] = [ 257 | Self::Pen(LineWidth::R0), 258 | Self::Pen(LineWidth::R1), 259 | Self::Pen(LineWidth::R2), 260 | Self::Pen(LineWidth::R4), 261 | Self::Pen(LineWidth::R7), 262 | Self::Fill, 263 | ]; 264 | } 265 | 266 | #[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)] 267 | pub struct I12Pair { 268 | bytes: [u8; 3], 269 | } 270 | 271 | impl I12Pair { 272 | pub fn new(x: i16, y: i16) -> Self { 273 | debug_assert!((-(1 << 11)..(1 << 11)).contains(&x), "x={} out of range", x); 274 | debug_assert!((-(1 << 11)..(1 << 11)).contains(&y), "y={} out of range", y); 275 | 276 | Self { 277 | bytes: [ 278 | x as u8, 279 | (x >> 8 & 0xf) as u8 | (y << 4) as u8, 280 | (y >> 4) as u8, 281 | ], 282 | } 283 | } 284 | 285 | pub fn x(self) -> i16 { 286 | let unsigned = self.bytes[0] as u16 | (self.bytes[1] as u16 & 0xf) << 8; 287 | // sign-extend 288 | ((unsigned << 4) as i16) >> 4 289 | } 290 | 291 | pub fn y(self) -> i16 { 292 | let unsigned = (self.bytes[1] as u16) >> 4 | (self.bytes[2] as u16) << 4; 293 | // sign-extend 294 | ((unsigned << 4) as i16) >> 4 295 | } 296 | } 297 | 298 | #[test] 299 | fn i12pair_exhaustive() { 300 | for x in -(1 << 11)..(1 << 11) { 301 | for y in -(1 << 11)..(1 << 11) { 302 | let pair = I12Pair::new(x, y); 303 | assert_eq!(pair.x(), x); 304 | assert_eq!(pair.y(), y); 305 | } 306 | } 307 | } 308 | 309 | #[derive(Debug, Deserialize, Serialize, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)] 310 | #[repr(C)] 311 | pub struct Color { 312 | pub r: u8, 313 | pub g: u8, 314 | pub b: u8, 315 | a: u8, 316 | } 317 | 318 | impl Default for Color { 319 | fn default() -> Self { 320 | Self::BLACK 321 | } 322 | } 323 | 324 | impl Color { 325 | pub const TRANSPARENT: Self = Self { 326 | r: 0, 327 | g: 0, 328 | b: 0, 329 | a: 0, 330 | }; 331 | pub const WHITE: Self = Self::new(0xff, 0xff, 0xff); 332 | pub const BLACK: Self = Self::new(0x00, 0x00, 0x00); 333 | 334 | pub const ALL: [Self; 33] = [ 335 | Self::WHITE, // White 336 | Self::BLACK, // Black 337 | Self::new(0x7F, 0x7F, 0x7F), // 50% Grey 338 | Self::new(0xC1, 0xC1, 0xC1), // Grey 339 | Self::new(0x4C, 0x4C, 0x4C), // DarkGrey 340 | Self::new(0xE0, 0xE0, 0xE0), // LightGrey 341 | Self::new(0xEF, 0x13, 0x0B), // Red 342 | Self::new(0x74, 0x0B, 0x07), // DarkRed 343 | Self::new(0xF9, 0x86, 0x82), // LightRed 344 | Self::new(0xFF, 0x71, 0x00), // Orange 345 | Self::new(0xC2, 0x38, 0x00), // DarkOrange 346 | Self::new(0xFF, 0xB8, 0x7F), // LightOrange 347 | Self::new(0xFF, 0xE4, 0x00), // Yellow 348 | Self::new(0xE8, 0xA2, 0x00), // DarkYellow 349 | Self::new(0xFF, 0xF1, 0x7F), // LightYellow 350 | Self::new(0x00, 0xCC, 0x00), // Green 351 | Self::new(0x00, 0x55, 0x10), // DarkGreen 352 | Self::new(0x65, 0xFF, 0x65), // LightGreen 353 | Self::new(0x00, 0xB2, 0xFF), // Blue 354 | Self::new(0x00, 0x56, 0x9E), // DarkBlue 355 | Self::new(0x7F, 0xD8, 0xFF), // LightBlue 356 | Self::new(0x23, 0x1F, 0xD3), // Indigo 357 | Self::new(0x0E, 0x08, 0x65), // DarkIndigo 358 | Self::new(0x8C, 0x8A, 0xED), // LightIndigo 359 | Self::new(0xA3, 0x00, 0xBA), // Violet 360 | Self::new(0x55, 0x00, 0x69), // DarkViolet 361 | Self::new(0xEA, 0x5D, 0xFF), // LightViolet 362 | Self::new(0xD3, 0x7C, 0xAA), // Pink 363 | Self::new(0xA7, 0x55, 0x74), // DarkPink 364 | Self::new(0xE9, 0xBD, 0xD4), // LightPink 365 | Self::new(0xA0, 0x52, 0x2D), // Brown 366 | Self::new(0x63, 0x30, 0x0D), // DarkBrown 367 | Self::new(0xDD, 0xA3, 0x87), // LightBrown 368 | ]; 369 | 370 | pub const fn new(r: u8, g: u8, b: u8) -> Self { 371 | Self { a: 0xff, r, g, b } 372 | } 373 | } 374 | 375 | #[repr(transparent)] 376 | pub struct CanvasBuffer([[Color; CANVAS_WIDTH]; CANVAS_HEIGHT]); 377 | 378 | impl CanvasBuffer { 379 | pub fn boxed() -> Box { 380 | // avoid blowing the wasm stack 381 | let layout = alloc::Layout::new::(); 382 | // Safety: layout is valid for type, allocator is initialized 383 | // Safety: type can be safely zero-initialized, as it is repr(transparent) over an array whose elements can safely be zero-initialized 384 | let ptr = unsafe { alloc::alloc_zeroed(layout) as *mut Self }; 385 | if ptr.is_null() { 386 | alloc::handle_alloc_error(layout); 387 | } 388 | // Safety: ptr is non-null, unowned, and points to a valid object (since it was zero-initialized) 389 | unsafe { Box::from_raw(ptr) } 390 | } 391 | 392 | pub fn clone_boxed(&self) -> Box { 393 | let layout = alloc::Layout::new::(); 394 | // Safety: layout is valid for type, allocator is initialized 395 | let ptr = unsafe { alloc::alloc(layout) as *mut Self }; 396 | if ptr.is_null() { 397 | alloc::handle_alloc_error(layout); 398 | } 399 | // Safety: type contains only Copy types, so it can be safely bitwise copied; ptr was just allocated so it cannot overlap 400 | unsafe { ptr::copy_nonoverlapping(self, ptr, 1) }; 401 | // Safety: ptr is non-null, unowned, and points to a valid object (since it was fully overwritten by a valid object) 402 | unsafe { Box::from_raw(ptr) } 403 | } 404 | 405 | pub fn x_len(&self) -> usize { 406 | self.0[0].len() 407 | } 408 | 409 | pub fn y_len(&self) -> usize { 410 | self.0.len() 411 | } 412 | 413 | pub fn get(&self, x: usize, y: usize) -> Color { 414 | self.0 415 | .get(y) 416 | .and_then(|row| row.get(x)) 417 | .copied() 418 | .unwrap_or(Color::TRANSPARENT) 419 | } 420 | 421 | pub fn set(&mut self, x: usize, y: usize, color: Color) { 422 | if let Some(elem) = self.0.get_mut(y).and_then(|row| row.get_mut(x)) { 423 | *elem = color; 424 | } 425 | } 426 | 427 | pub fn as_mut_bytes(&mut self) -> &mut [u8] { 428 | assert_eq!(mem::size_of::(), 4); 429 | // Safety: Color can safely be read/written as bytes, and has no invalid values 430 | unsafe { 431 | let len = mem::size_of_val(&self.0); 432 | slice::from_raw_parts_mut(&mut self.0 as *mut _ as *mut u8, len) 433 | } 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /ferrogallic_web/src/page/in_game.rs: -------------------------------------------------------------------------------- 1 | use crate::api::{connect_api, TypedWebSocketWriter}; 2 | use crate::app; 3 | use crate::audio::AudioService; 4 | use crate::canvas::VirtualCanvas; 5 | use crate::component; 6 | use anyhow::{anyhow, Error}; 7 | use ferrogallic_shared::api::game::{Canvas, Game, GamePhase, GameReq, GameState, Player}; 8 | use ferrogallic_shared::config::{CANVAS_HEIGHT, CANVAS_WIDTH}; 9 | use ferrogallic_shared::domain::{ 10 | Color, Epoch, Guess, I12Pair, LineWidth, Lobby, Lowercase, Nickname, Tool, UserId, 11 | }; 12 | use gloo::events::{EventListener, EventListenerOptions}; 13 | use gloo::render::{request_animation_frame, AnimationFrame}; 14 | use std::collections::BTreeMap; 15 | use std::convert::identity; 16 | use std::mem; 17 | use std::sync::Arc; 18 | use time::Duration; 19 | use wasm_bindgen::JsCast; 20 | use wasm_bindgen_futures::spawn_local; 21 | use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, HtmlElement, KeyboardEvent}; 22 | use yew::{html, Callback, Component, Context, Html, NodeRef, PointerEvent, Properties}; 23 | 24 | pub enum Msg { 25 | WebSocketConnected(TypedWebSocketWriter), 26 | WebSocketError(Error), 27 | Message(Game), 28 | RemovePlayer(UserId, Epoch), 29 | ChooseWord(Lowercase), 30 | Pointer(PointerAction), 31 | Undo, 32 | Render, 33 | SetGuess(Lowercase), 34 | SendGuess, 35 | SetTool(Tool), 36 | SetColor(Color), 37 | Ignore, 38 | } 39 | 40 | pub enum PointerAction { 41 | Down(I12Pair), 42 | Move(I12Pair), 43 | Up(I12Pair), 44 | } 45 | 46 | #[derive(PartialEq, Properties)] 47 | pub struct Props { 48 | pub app_link: Callback, 49 | pub lobby: Lobby, 50 | pub nick: Nickname, 51 | } 52 | 53 | pub struct InGame { 54 | link: Callback, 55 | user_id: UserId, 56 | active_ws: Option>, 57 | audio: AudioService, 58 | scheduled_render: Option, 59 | canvas_ref: NodeRef, 60 | canvas: Option, 61 | pointer: PointerState, 62 | guess: Lowercase, 63 | tool: Tool, 64 | color: Color, 65 | players: Arc>, 66 | game: Arc, 67 | guesses: Arc>, 68 | } 69 | 70 | struct CanvasState { 71 | vr: VirtualCanvas, 72 | context: CanvasRenderingContext2d, 73 | _disable_touchstart: EventListener, 74 | } 75 | 76 | #[derive(Copy, Clone)] 77 | enum PointerState { 78 | Up, 79 | Down { at: I12Pair }, 80 | } 81 | 82 | impl Component for InGame { 83 | type Message = Msg; 84 | type Properties = Props; 85 | 86 | fn create(ctx: &Context) -> Self { 87 | Self { 88 | link: ctx.link().callback(identity), 89 | user_id: ctx.props().nick.user_id(), 90 | active_ws: None, 91 | audio: AudioService::new(), 92 | scheduled_render: None, 93 | canvas_ref: Default::default(), 94 | canvas: None, 95 | pointer: PointerState::Up, 96 | guess: Default::default(), 97 | tool: Default::default(), 98 | color: Default::default(), 99 | players: Default::default(), 100 | game: Default::default(), 101 | guesses: Default::default(), 102 | } 103 | } 104 | 105 | fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { 106 | match msg { 107 | Msg::WebSocketConnected(writer) => { 108 | self.active_ws = Some(writer); 109 | false 110 | } 111 | Msg::WebSocketError(e) => { 112 | self.active_ws = None; 113 | ctx.props().app_link.emit(app::Msg::SetError(e)); 114 | false 115 | } 116 | Msg::Message(msg) => match msg { 117 | Game::Canvas(event) => { 118 | self.render_to_virtual(event); 119 | self.schedule_render_to_canvas(ctx); 120 | false 121 | } 122 | Game::CanvasBulk(events) => { 123 | for event in events { 124 | self.render_to_virtual(event); 125 | } 126 | self.schedule_render_to_canvas(ctx); 127 | false 128 | } 129 | Game::Players(players) => { 130 | self.players = players; 131 | true 132 | } 133 | Game::Game(game) => { 134 | self.game = game; 135 | true 136 | } 137 | Game::Guess(guess) => { 138 | self.play_sound(&guess); 139 | Arc::make_mut(&mut self.guesses).push(guess); 140 | true 141 | } 142 | Game::GuessBulk(guesses) => { 143 | Arc::make_mut(&mut self.guesses).extend(guesses); 144 | true 145 | } 146 | Game::ClearGuesses => { 147 | self.guesses = Default::default(); 148 | true 149 | } 150 | Game::Heartbeat => false, 151 | }, 152 | Msg::RemovePlayer(user_id, epoch) => { 153 | self.send_if_connected(ctx, &GameReq::Remove(user_id, epoch)); 154 | false 155 | } 156 | Msg::ChooseWord(word) => { 157 | self.send_if_connected(ctx, &GameReq::Choose(word)); 158 | false 159 | } 160 | Msg::SendGuess => { 161 | let guess = mem::take(&mut self.guess); 162 | self.send_if_connected(ctx, &GameReq::Guess(guess)); 163 | false 164 | } 165 | Msg::Pointer(action) => { 166 | let one_event; 167 | let two_events; 168 | let events: &[Canvas] = match (self.tool, action) { 169 | (Tool::Pen(_), PointerAction::Down(at)) => { 170 | self.pointer = PointerState::Down { at }; 171 | &[] 172 | } 173 | (Tool::Pen(width), PointerAction::Move(to)) => match self.pointer { 174 | PointerState::Down { at: from } if to != from => { 175 | self.pointer = PointerState::Down { at: to }; 176 | one_event = [Canvas::Line { 177 | from, 178 | to, 179 | width, 180 | color: self.color, 181 | }]; 182 | &one_event 183 | } 184 | PointerState::Down { .. } | PointerState::Up => &[], 185 | }, 186 | (Tool::Pen(width), PointerAction::Up(to)) => match self.pointer { 187 | PointerState::Down { at: from } => { 188 | self.pointer = PointerState::Up; 189 | two_events = [ 190 | Canvas::Line { 191 | from, 192 | to, 193 | width, 194 | color: self.color, 195 | }, 196 | Canvas::PushUndo, 197 | ]; 198 | &two_events 199 | } 200 | PointerState::Up => &[], 201 | }, 202 | (Tool::Fill, PointerAction::Down(at)) => { 203 | two_events = [ 204 | Canvas::Fill { 205 | at, 206 | color: self.color, 207 | }, 208 | Canvas::PushUndo, 209 | ]; 210 | &two_events 211 | } 212 | (Tool::Fill, PointerAction::Move(_)) | (Tool::Fill, PointerAction::Up(_)) => { 213 | &[] 214 | } 215 | }; 216 | for &event in events { 217 | self.render_to_virtual(event); 218 | self.schedule_render_to_canvas(ctx); 219 | self.send_if_connected(ctx, &GameReq::Canvas(event)); 220 | } 221 | false 222 | } 223 | Msg::Undo => { 224 | let event = Canvas::PopUndo; 225 | self.render_to_virtual(event); 226 | self.schedule_render_to_canvas(ctx); 227 | self.send_if_connected(ctx, &GameReq::Canvas(event)); 228 | false 229 | } 230 | Msg::Render => { 231 | self.render_to_canvas(); 232 | false 233 | } 234 | Msg::SetGuess(guess) => { 235 | self.guess = guess; 236 | true 237 | } 238 | Msg::SetTool(tool) => { 239 | self.tool = tool; 240 | true 241 | } 242 | Msg::SetColor(color) => { 243 | self.color = color; 244 | true 245 | } 246 | Msg::Ignore => false, 247 | } 248 | } 249 | 250 | fn changed(&mut self, _ctx: &Context, props: &Self::Properties) -> bool { 251 | self.user_id = props.nick.user_id(); 252 | true 253 | } 254 | 255 | fn rendered(&mut self, ctx: &Context, first_render: bool) { 256 | if first_render { 257 | if let Some(canvas) = self.canvas_ref.cast::() { 258 | if let Some(context) = canvas 259 | .get_context("2d") 260 | .ok() 261 | .flatten() 262 | .and_then(|c| c.dyn_into::().ok()) 263 | { 264 | let disable_touchstart = EventListener::new_with_options( 265 | &canvas.into(), 266 | "touchstart", 267 | EventListenerOptions::enable_prevent_default(), 268 | |e| e.prevent_default(), 269 | ); 270 | self.canvas = Some(CanvasState { 271 | vr: VirtualCanvas::new(), 272 | context, 273 | _disable_touchstart: disable_touchstart, 274 | }); 275 | } 276 | } 277 | 278 | self.connect_to_websocket(ctx); 279 | } 280 | } 281 | 282 | fn view(&self, ctx: &Context) -> Html { 283 | enum Status<'a> { 284 | Waiting, 285 | Choosing(&'a Player), 286 | Drawing(&'a Player), 287 | } 288 | 289 | let mut can_draw = false; 290 | let mut choose_words = None; 291 | let mut cur_round = None; 292 | let mut status = Status::Waiting; 293 | let mut drawing_started = None; 294 | let mut guess_template = None; 295 | let _: () = match &self.game.phase { 296 | GamePhase::WaitingToStart => { 297 | can_draw = true; 298 | } 299 | GamePhase::ChoosingWords { 300 | round, 301 | choosing, 302 | words, 303 | } => { 304 | cur_round = Some(*round); 305 | if let Some(player) = self.players.get(choosing) { 306 | status = Status::Choosing(player); 307 | } 308 | if *choosing == self.user_id { 309 | choose_words = Some(words.clone()); 310 | } 311 | } 312 | GamePhase::Drawing { 313 | round, 314 | drawing, 315 | correct: _, 316 | word, 317 | epoch: _, 318 | started, 319 | } => { 320 | cur_round = Some(*round); 321 | if let Some(player) = self.players.get(drawing) { 322 | status = Status::Drawing(player); 323 | } 324 | drawing_started = Some(*started); 325 | if *drawing == self.user_id { 326 | can_draw = true; 327 | guess_template = Some((word.clone(), component::guess_template::Reveal::All)); 328 | } else { 329 | guess_template = 330 | Some((word.clone(), component::guess_template::Reveal::Spaces)); 331 | } 332 | } 333 | }; 334 | 335 | let on_keydown; 336 | let on_pointerdown; 337 | let on_pointermove; 338 | let on_pointerup; 339 | if can_draw { 340 | on_keydown = ctx.link().callback(|e: KeyboardEvent| { 341 | let ctrl = e.ctrl_key(); 342 | let msg = match e.key_code() { 343 | 49 /* 1 */ if !ctrl => Msg::SetTool(Tool::Pen(LineWidth::R0)), 344 | 50 /* 2 */ if !ctrl => Msg::SetTool(Tool::Pen(LineWidth::R1)), 345 | 51 /* 3 */ if !ctrl => Msg::SetTool(Tool::Pen(LineWidth::R2)), 346 | 52 /* 4 */ if !ctrl => Msg::SetTool(Tool::Pen(LineWidth::R4)), 347 | 53 /* 5 */ if !ctrl => Msg::SetTool(Tool::Pen(LineWidth::R7)), 348 | 70 /* f */ if !ctrl => Msg::SetTool(Tool::Fill), 349 | 90 /* z */ if ctrl => Msg::Undo, 350 | _ => return Msg::Ignore, 351 | }; 352 | e.prevent_default(); 353 | msg 354 | }); 355 | on_pointerdown = self.handle_pointer_event_if( 356 | ctx, 357 | |e| e.buttons() == 1, 358 | |e, target, at| { 359 | if let Err(e) = target.focus() { 360 | log::warn!("Failed to focus canvas: {:?}", e); 361 | } 362 | if let Err(e) = target.set_pointer_capture(e.pointer_id()) { 363 | log::warn!("Failed to set pointer capture: {:?}", e); 364 | } 365 | PointerAction::Down(at) 366 | }, 367 | ); 368 | on_pointermove = self.handle_pointer_event(ctx, |_, _, at| PointerAction::Move(at)); 369 | on_pointerup = self.handle_pointer_event(ctx, |e, target, at| { 370 | if let Err(e) = target.release_pointer_capture(e.pointer_id()) { 371 | log::warn!("Failed to release pointer capture: {:?}", e); 372 | } 373 | PointerAction::Up(at) 374 | }); 375 | } else { 376 | on_keydown = Callback::from(|_| {}); 377 | let noop = Callback::from(|_| {}); 378 | on_pointerdown = noop.clone(); 379 | on_pointermove = noop.clone(); 380 | on_pointerup = noop; 381 | } 382 | 383 | html! { 384 |
    385 |
    386 |
    {"In Game - "}{&ctx.props().lobby}
    387 |
    388 |
    389 |
    390 | 391 |
    392 |
    393 |
    394 | 404 |
    405 |
    406 | 407 | 408 |
    412 |
    413 | {choose_words.map(|words| html! { 414 | 415 | }).unwrap_or_default()} 416 |
    417 |
    418 |
    419 | 420 |
    421 | 422 |
    423 |
    424 |
    425 |
    426 | {match status { 427 | Status::Waiting => html! { {"Waiting to start"} }, 428 | Status::Choosing(player) => html! { <>{&player.nick}{" is choosing a word"} }, 429 | Status::Drawing(player) => html! { <>{&player.nick}{" is drawing"} }, 430 | }} 431 |
    432 |
    433 | {drawing_started.map(|drawing_started| html! { 434 | 435 | }).unwrap_or_default()} 436 | {"/"}{self.game.config.guess_seconds}{" seconds"} 437 |
    438 |
    439 | {cur_round.map(|cur_round| html! { 440 | {cur_round} 441 | }).unwrap_or_default()} 442 | {"/"}{self.game.config.rounds}{" rounds"} 443 |
    444 |
    445 | {guess_template.map(|(word, reveal)| html! { 446 | 447 | }).unwrap_or_default()} 448 |
    449 |
    450 |
    451 | } 452 | } 453 | } 454 | 455 | impl InGame { 456 | fn connect_to_websocket(&self, ctx: &Context) { 457 | match connect_api() { 458 | Ok((mut reader, mut writer)) => { 459 | let link = ctx.link().clone(); 460 | let join_rec = GameReq::Join(ctx.props().lobby.clone(), ctx.props().nick.clone()); 461 | spawn_local(async move { 462 | match writer.wait_for_connection_and_send(&join_rec).await { 463 | Ok(()) => link.send_message(Msg::WebSocketConnected(writer)), 464 | Err(e) => link.send_message(Msg::WebSocketError(e)), 465 | } 466 | }); 467 | let link = ctx.link().clone(); 468 | spawn_local(async move { 469 | loop { 470 | match reader.next_api().await { 471 | Some(Ok(msg)) => { 472 | link.send_message(Msg::Message(msg)); 473 | } 474 | Some(Err(e)) => { 475 | link.send_message(Msg::WebSocketError(e)); 476 | break; 477 | } 478 | None => { 479 | link.send_message(Msg::WebSocketError(anyhow!("Websocket closed"))); 480 | break; 481 | } 482 | } 483 | } 484 | }); 485 | } 486 | Err(e) => ctx.link().send_message(Msg::WebSocketError(e)), 487 | } 488 | } 489 | 490 | fn send_if_connected(&mut self, ctx: &Context, req: &GameReq) { 491 | if let Some(ws) = &mut self.active_ws { 492 | if let Err(e) = ws.send_sync(req) { 493 | ctx.link().send_message(Msg::WebSocketError(e)); 494 | } 495 | } 496 | } 497 | 498 | fn handle_pointer_event( 499 | &self, 500 | ctx: &Context, 501 | f: impl Fn(&PointerEvent, &HtmlElement, I12Pair) -> PointerAction + 'static, 502 | ) -> Callback { 503 | self.handle_pointer_event_if(ctx, |_| true, f) 504 | } 505 | 506 | fn handle_pointer_event_if( 507 | &self, 508 | ctx: &Context, 509 | pred: impl Fn(&PointerEvent) -> bool + 'static, 510 | f: impl Fn(&PointerEvent, &HtmlElement, I12Pair) -> PointerAction + 'static, 511 | ) -> Callback { 512 | ctx.link().callback(move |e: PointerEvent| { 513 | if pred(&e) { 514 | if let Some(target) = e.target().and_then(|t| t.dyn_into::().ok()) { 515 | e.prevent_default(); 516 | let origin = target.get_bounding_client_rect(); 517 | Msg::Pointer(f( 518 | &e, 519 | &target, 520 | I12Pair::new( 521 | e.client_x() as i16 - origin.x() as i16, 522 | e.client_y() as i16 - origin.y() as i16, 523 | ), 524 | )) 525 | } else { 526 | Msg::Ignore 527 | } 528 | } else { 529 | Msg::Ignore 530 | } 531 | }) 532 | } 533 | 534 | fn play_sound(&mut self, guess: &Guess) { 535 | if let Err(e) = self.audio.handle_guess(self.user_id, guess) { 536 | log::error!("Failed to play sound: {:?}", e); 537 | } 538 | } 539 | 540 | fn render_to_virtual(&mut self, event: Canvas) { 541 | if let Some(canvas) = &mut self.canvas { 542 | canvas.vr.handle_event(event); 543 | } 544 | } 545 | 546 | fn schedule_render_to_canvas(&mut self, ctx: &Context) { 547 | if let scheduled @ None = &mut self.scheduled_render { 548 | let link = ctx.link().clone(); 549 | *scheduled = Some(request_animation_frame(move |_| { 550 | link.send_message(Msg::Render) 551 | })); 552 | } 553 | } 554 | 555 | fn render_to_canvas(&mut self) { 556 | self.scheduled_render = None; 557 | if let Some(canvas) = &mut self.canvas { 558 | if let Err(e) = canvas.vr.render_to(&canvas.context) { 559 | log::error!("Failed to render to canvas: {:?}", e); 560 | } 561 | } 562 | } 563 | } 564 | -------------------------------------------------------------------------------- /ferrogallic_web/src/styles/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background: #00807F; 7 | } 8 | 9 | dialog { 10 | top: 0; 11 | width: 100%; 12 | height: 100%; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | border: none; 17 | } 18 | 19 | .hatched-background { 20 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='2' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 0H0v1h1v1h1V1H1V0z' fill='silver'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 0H1v1H0v1h1V1h1V0z' fill='%23fff'/%3E%3C/svg%3E"); 21 | } 22 | 23 | button:not(:disabled).active.active { 24 | box-shadow: inset -2px -2px #fff, inset 2px 2px #0a0a0a, inset -4px -4px #dfdfdf, inset 4px 4px grey 25 | } 26 | 27 | /* Color toolbar */ 28 | .color-buttons { 29 | height: 150px; 30 | display: flex; 31 | flex-flow: column wrap; 32 | } 33 | .color-button { 34 | min-width: unset; 35 | height: 50px; 36 | } 37 | 38 | /* Tool toolbar */ 39 | .tool-buttons { 40 | display: flex; 41 | } 42 | .tool-button { 43 | height: 50px; 44 | flex: 1; 45 | } 46 | 47 | /* Guess input */ 48 | .guess-chars { 49 | font-family: monospace; 50 | white-space: pre; 51 | text-align: center; 52 | } 53 | .guess-char { 54 | padding: 0 0.25em; 55 | } 56 | .guess-char.underlined { 57 | text-decoration: underline; 58 | } 59 | .guess-char.invalid { 60 | color: red; 61 | } 62 | 63 | /*! 98.css v0.1.16 - https://github.com/jdan/98.css */ 64 | body { 65 | font-family: Arial, sans-serif; 66 | font-size: 12px; 67 | color: #222 68 | } 69 | 70 | .title-bar, .window, button, input, label, option, select, textarea, ul.tree-view { 71 | font-family: "MS Sans Serif", Arial, sans-serif; 72 | -webkit-font-smoothing: none; 73 | font-size: 11px 74 | } 75 | 76 | h1 { 77 | font-size: 5rem 78 | } 79 | 80 | h2 { 81 | font-size: 2.5rem 82 | } 83 | 84 | h3 { 85 | font-size: 2rem 86 | } 87 | 88 | h4 { 89 | font-size: 1.5rem 90 | } 91 | 92 | u { 93 | text-decoration: none; 94 | border-bottom: .5px solid #222 95 | } 96 | 97 | button { 98 | box-sizing: border-box; 99 | border: none; 100 | border-radius: 0; 101 | min-width: 75px; 102 | min-height: 23px; 103 | padding: 0 12px 104 | } 105 | 106 | .vertical-bar, button { 107 | background: silver; 108 | box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf 109 | } 110 | 111 | .vertical-bar { 112 | width: 4px; 113 | height: 20px 114 | } 115 | 116 | button:not(:disabled):active { 117 | box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey; 118 | padding: 2px 11px 0 13px 119 | } 120 | 121 | button:not(:disabled):hover { 122 | box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey 123 | } 124 | 125 | button:focus { 126 | outline: 1px dotted #000; 127 | outline-offset: -4px 128 | } 129 | 130 | button::-moz-focus-inner { 131 | border: 0 132 | } 133 | 134 | :disabled, :disabled + label { 135 | color: grey; 136 | text-shadow: 1px 1px 0 #fff 137 | } 138 | 139 | .window { 140 | box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #dfdfdf, inset -2px -2px grey, inset 2px 2px #fff; 141 | background: silver; 142 | padding: 3px 143 | } 144 | 145 | .title-bar { 146 | background: linear-gradient(90deg, navy, #1084d0); 147 | padding: 3px 2px 3px 3px; 148 | display: flex; 149 | justify-content: space-between; 150 | align-items: center 151 | } 152 | 153 | .title-bar.inactive { 154 | background: linear-gradient(90deg, grey, #b5b5b5) 155 | } 156 | 157 | .title-bar-text { 158 | font-weight: 700; 159 | color: #fff; 160 | letter-spacing: 0; 161 | margin-right: 24px 162 | } 163 | 164 | .title-bar-controls { 165 | display: flex 166 | } 167 | 168 | .title-bar-controls button { 169 | padding: 0; 170 | display: block; 171 | min-width: 16px; 172 | min-height: 14px 173 | } 174 | 175 | .title-bar-controls button:active { 176 | padding: 0 177 | } 178 | 179 | .title-bar-controls button:focus { 180 | outline: none 181 | } 182 | 183 | .title-bar-controls button[aria-label=Minimize] { 184 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='6' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' d='M0 0h6v2H0z'/%3E%3C/svg%3E"); 185 | background-repeat: no-repeat; 186 | background-position: bottom 3px left 4px 187 | } 188 | 189 | .title-bar-controls button[aria-label=Maximize] { 190 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='9' height='9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 0H0v9h9V0zM8 2H1v6h7V2z' fill='%23000'/%3E%3C/svg%3E"); 191 | background-repeat: no-repeat; 192 | background-position: top 2px left 3px 193 | } 194 | 195 | .title-bar-controls button[aria-label=Restore] { 196 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='8' height='9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' d='M2 0h6v2H2zM7 2h1v4H7zM2 2h1v1H2zM6 5h1v1H6zM0 3h6v2H0zM5 5h1v4H5zM0 5h1v4H0zM1 8h4v1H1z'/%3E%3C/svg%3E"); 197 | background-repeat: no-repeat; 198 | background-position: top 2px left 3px 199 | } 200 | 201 | .title-bar-controls button[aria-label=Help] { 202 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='6' height='9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' d='M0 1h2v2H0zM1 0h4v1H1zM4 1h2v2H4zM3 3h2v1H3zM2 4h2v2H2zM2 7h2v2H2z'/%3E%3C/svg%3E"); 203 | background-repeat: no-repeat; 204 | background-position: top 2px left 5px 205 | } 206 | 207 | .title-bar-controls button[aria-label=Close] { 208 | margin-left: 2px; 209 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='8' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h2v1h1v1h2V1h1V0h2v1H7v1H6v1H5v1h1v1h1v1h1v1H6V6H5V5H3v1H2v1H0V6h1V5h1V4h1V3H2V2H1V1H0V0z' fill='%23000'/%3E%3C/svg%3E"); 210 | background-repeat: no-repeat; 211 | background-position: top 3px left 4px 212 | } 213 | 214 | .window-body { 215 | margin: 8px 216 | } 217 | 218 | fieldset { 219 | border: none; 220 | box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px grey, inset 2px 2px #dfdfdf; 221 | padding: 10px; 222 | padding-block-start: 8px; 223 | margin: 0 224 | } 225 | 226 | legend { 227 | background: silver 228 | } 229 | 230 | .field-row { 231 | display: flex; 232 | align-items: center 233 | } 234 | 235 | [class^=field-row] + [class^=field-row] { 236 | margin-top: 6px 237 | } 238 | 239 | .field-row > * + * { 240 | margin-left: 6px 241 | } 242 | 243 | .field-row-stacked { 244 | display: flex; 245 | flex-direction: column 246 | } 247 | 248 | .field-row-stacked * + * { 249 | margin-top: 6px 250 | } 251 | 252 | label { 253 | display: inline-flex; 254 | align-items: center 255 | } 256 | 257 | input[type=checkbox], input[type=radio] { 258 | appearance: none; 259 | -webkit-appearance: none; 260 | -moz-appearance: none; 261 | margin: 0; 262 | background: 0; 263 | position: fixed; 264 | opacity: 0; 265 | border: none 266 | } 267 | 268 | input[type=checkbox] + label, input[type=radio] + label { 269 | line-height: 13px 270 | } 271 | 272 | input[type=radio] + label { 273 | position: relative; 274 | margin-left: 18px 275 | } 276 | 277 | input[type=radio] + label:before { 278 | content: ""; 279 | position: absolute; 280 | top: 0; 281 | left: -18px; 282 | display: inline-block; 283 | width: 12px; 284 | height: 12px; 285 | margin-right: 6px; 286 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 0H4v1H2v1H1v2H0v4h1v2h1V8H1V4h1V2h2V1h4v1h2V1H8V0z' fill='gray'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 1H4v1H2v2H1v4h1v1h1V8H2V4h1V3h1V2h4v1h2V2H8V1z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 3h1v1H9V3zm1 5V4h1v4h-1zm-2 2V9h1V8h1v2H8zm-4 0v1h4v-1H4zm0 0V9H2v1h2z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 2h-1v2h1v4h-1v2H8v1H4v-1H2v1h2v1h4v-1h2v-1h1V8h1V4h-1V2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4 2h4v1h1v1h1v4H9v1H8v1H4V9H3V8H2V4h1V3h1V2z' fill='%23fff'/%3E%3C/svg%3E") 287 | } 288 | 289 | input[type=radio]:active + label:before { 290 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 0H4v1H2v1H1v2H0v4h1v2h1V8H1V4h1V2h2V1h4v1h2V1H8V0z' fill='gray'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 1H4v1H2v2H1v4h1v1h1V8H2V4h1V3h1V2h4v1h2V2H8V1z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 3h1v1H9V3zm1 5V4h1v4h-1zm-2 2V9h1V8h1v2H8zm-4 0v1h4v-1H4zm0 0V9H2v1h2z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 2h-1v2h1v4h-1v2H8v1H4v-1H2v1h2v1h4v-1h2v-1h1V8h1V4h-1V2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4 2h4v1h1v1h1v4H9v1H8v1H4V9H3V8H2V4h1V3h1V2z' fill='silver'/%3E%3C/svg%3E") 291 | } 292 | 293 | input[type=radio]:checked + label:after { 294 | content: ""; 295 | display: block; 296 | width: 4px; 297 | height: 4px; 298 | top: 4px; 299 | left: -14px; 300 | position: absolute; 301 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='4' height='4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3 0H1v1H0v2h1v1h2V3h1V1H3V0z' fill='%23000'/%3E%3C/svg%3E") 302 | } 303 | 304 | input[type=checkbox]:focus + label, input[type=radio]:focus + label { 305 | outline: 1px dotted #000 306 | } 307 | 308 | input[type=radio][disabled] + label:before { 309 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 0H4v1H2v1H1v2H0v4h1v2h1V8H1V4h1V2h2V1h4v1h2V1H8V0z' fill='gray'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 1H4v1H2v2H1v4h1v1h1V8H2V4h1V3h1V2h4v1h2V2H8V1z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 3h1v1H9V3zm1 5V4h1v4h-1zm-2 2V9h1V8h1v2H8zm-4 0v1h4v-1H4zm0 0V9H2v1h2z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 2h-1v2h1v4h-1v2H8v1H4v-1H2v1h2v1h4v-1h2v-1h1V8h1V4h-1V2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4 2h4v1h1v1h1v4H9v1H8v1H4V9H3V8H2V4h1V3h1V2z' fill='silver'/%3E%3C/svg%3E") 310 | } 311 | 312 | input[type=radio][disabled]:checked + label:after { 313 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='4' height='4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3 0H1v1H0v2h1v1h2V3h1V1H3V0z' fill='gray'/%3E%3C/svg%3E") 314 | } 315 | 316 | input[type=checkbox] + label { 317 | position: relative; 318 | margin-left: 19px 319 | } 320 | 321 | input[type=checkbox] + label:before { 322 | content: ""; 323 | position: absolute; 324 | left: -19px; 325 | display: inline-block; 326 | width: 13px; 327 | height: 13px; 328 | background: #fff; 329 | box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a; 330 | margin-right: 6px 331 | } 332 | 333 | input[type=checkbox]:active + label:before { 334 | background: silver 335 | } 336 | 337 | input[type=checkbox]:checked + label:after { 338 | content: ""; 339 | display: block; 340 | width: 7px; 341 | height: 7px; 342 | position: absolute; 343 | top: 3px; 344 | left: -16px; 345 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='7' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M7 0H6v1H5v1H4v1H3v1H2V3H1V2H0v3h1v1h1v1h1V6h1V5h1V4h1V3h1V0z' fill='%23000'/%3E%3C/svg%3E") 346 | } 347 | 348 | input[type=checkbox][disabled] + label:before { 349 | background: silver 350 | } 351 | 352 | input[type=checkbox][disabled]:checked + label:after { 353 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='7' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M7 0H6v1H5v1H4v1H3v1H2V3H1V2H0v3h1v1h1v1h1V6h1V5h1V4h1V3h1V0z' fill='gray'/%3E%3C/svg%3E") 354 | } 355 | 356 | input[type=email], input[type=password], input[type=text] { 357 | border: none; 358 | -webkit-appearance: none; 359 | -moz-appearance: none; 360 | appearance: none; 361 | border-radius: 0 362 | } 363 | 364 | input[type=email], input[type=password], input[type=text], select { 365 | padding: 3px 4px; 366 | box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a; 367 | background-color: #fff; 368 | box-sizing: border-box 369 | } 370 | 371 | select, textarea { 372 | border: none 373 | } 374 | 375 | textarea { 376 | padding: 3px 4px; 377 | box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a; 378 | background-color: #fff; 379 | box-sizing: border-box; 380 | -webkit-appearance: none; 381 | -moz-appearance: none; 382 | appearance: none; 383 | border-radius: 0 384 | } 385 | 386 | input[type=email], input[type=password], input[type=text], select { 387 | height: 21px 388 | } 389 | 390 | input[type=email], input[type=password], input[type=text] { 391 | line-height: 2 392 | } 393 | 394 | select { 395 | appearance: none; 396 | -webkit-appearance: none; 397 | -moz-appearance: none; 398 | position: relative; 399 | padding-right: 32px; 400 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E"); 401 | background-position: top 2px right 2px; 402 | background-repeat: no-repeat; 403 | border-radius: 0 404 | } 405 | 406 | input[type=email]:focus, input[type=password]:focus, input[type=text]:focus, select:focus, textarea:focus { 407 | outline: none 408 | } 409 | 410 | input[type=range] { 411 | -webkit-appearance: none; 412 | width: 100%; 413 | background: transparent 414 | } 415 | 416 | input[type=range]:focus { 417 | outline: none 418 | } 419 | 420 | input[type=range]::-webkit-slider-thumb { 421 | -webkit-appearance: none; 422 | height: 21px; 423 | width: 11px; 424 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='21' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0v16h2v2h2v2h1v-1H3v-2H1V1h9V0z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 1v15h1v1h1v1h1v1h2v-1h1v-1h1v-1h1V1z' fill='%23C0C7C8'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 1h1v15H8v2H6v2H5v-1h2v-2h2z' fill='%2387888F'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10 0h1v16H9v2H7v2H5v1h1v-2h2v-2h2z' fill='%23000'/%3E%3C/svg%3E"); 425 | transform: translateY(-8px) 426 | } 427 | 428 | input[type=range].has-box-indicator::-webkit-slider-thumb { 429 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='21' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0v20h1V1h9V0z' fill='%23fff'/%3E%3Cpath fill='%23C0C7C8' d='M1 1h8v18H1z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 1h1v19H1v-1h8z' fill='%2387888F'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10 0h1v21H0v-1h10z' fill='%23000'/%3E%3C/svg%3E"); 430 | transform: translateY(-10px) 431 | } 432 | 433 | input[type=range]::-moz-range-thumb { 434 | height: 21px; 435 | width: 11px; 436 | border: 0; 437 | border-radius: 0; 438 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='21' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0v16h2v2h2v2h1v-1H3v-2H1V1h9V0z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 1v15h1v1h1v1h1v1h2v-1h1v-1h1v-1h1V1z' fill='%23C0C7C8'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 1h1v15H8v2H6v2H5v-1h2v-2h2z' fill='%2387888F'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10 0h1v16H9v2H7v2H5v1h1v-2h2v-2h2z' fill='%23000'/%3E%3C/svg%3E"); 439 | transform: translateY(2px) 440 | } 441 | 442 | input[type=range].has-box-indicator::-moz-range-thumb { 443 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='21' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0v20h1V1h9V0z' fill='%23fff'/%3E%3Cpath fill='%23C0C7C8' d='M1 1h8v18H1z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 1h1v19H1v-1h8z' fill='%2387888F'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10 0h1v21H0v-1h10z' fill='%23000'/%3E%3C/svg%3E"); 444 | transform: translateY(0) 445 | } 446 | 447 | input[type=range]::-webkit-slider-runnable-track { 448 | width: 100%; 449 | height: 2px; 450 | box-sizing: border-box; 451 | background: #000; 452 | border-right: 1px solid grey; 453 | border-bottom: 1px solid grey; 454 | box-shadow: 1px 0 0 #fff, 1px 1px 0 #fff, 0 1px 0 #fff, -1px 0 0 #a9a9a9, -1px -1px 0 #a9a9a9, 0 -1px 0 #a9a9a9, -1px 1px 0 #fff, 1px -1px #a9a9a9 455 | } 456 | 457 | input[type=range]::-moz-range-track { 458 | width: 100%; 459 | height: 2px; 460 | box-sizing: border-box; 461 | background: #000; 462 | border-right: 1px solid grey; 463 | border-bottom: 1px solid grey; 464 | box-shadow: 1px 0 0 #fff, 1px 1px 0 #fff, 0 1px 0 #fff, -1px 0 0 #a9a9a9, -1px -1px 0 #a9a9a9, 0 -1px 0 #a9a9a9, -1px 1px 0 #fff, 1px -1px #a9a9a9 465 | } 466 | 467 | .is-vertical { 468 | display: inline-block; 469 | width: 4px; 470 | height: 150px; 471 | transform: translateY(50%) 472 | } 473 | 474 | .is-vertical > input[type=range] { 475 | width: 150px; 476 | height: 4px; 477 | margin: 0 16px 0 10px; 478 | transform-origin: left; 479 | transform: rotate(270deg) translateX(calc(-50% + 8px)) 480 | } 481 | 482 | .is-vertical > input[type=range]::-webkit-slider-runnable-track { 483 | border-left: 1px solid grey; 484 | border-right: 0; 485 | border-bottom: 1px solid grey; 486 | box-shadow: -1px 0 0 #fff, -1px 1px 0 #fff, 0 1px 0 #fff, 1px 0 0 #a9a9a9, 1px -1px 0 #a9a9a9, 0 -1px 0 #a9a9a9, 1px 1px 0 #fff, -1px -1px #a9a9a9 487 | } 488 | 489 | .is-vertical > input[type=range]::-moz-range-track { 490 | border-left: 1px solid grey; 491 | border-right: 0; 492 | border-bottom: 1px solid grey; 493 | box-shadow: -1px 0 0 #fff, -1px 1px 0 #fff, 0 1px 0 #fff, 1px 0 0 #a9a9a9, 1px -1px 0 #a9a9a9, 0 -1px 0 #a9a9a9, 1px 1px 0 #fff, -1px -1px #a9a9a9 494 | } 495 | 496 | .is-vertical > input[type=range]::-webkit-slider-thumb { 497 | transform: translateY(-8px) scaleX(-1) 498 | } 499 | 500 | .is-vertical > input[type=range].has-box-indicator::-webkit-slider-thumb { 501 | transform: translateY(-10px) scaleX(-1) 502 | } 503 | 504 | .is-vertical > input[type=range]::-moz-range-thumb { 505 | transform: translateY(2px) scaleX(-1) 506 | } 507 | 508 | .is-vertical > input[type=range].has-box-indicator::-moz-range-thumb { 509 | transform: translateY(0) scaleX(-1) 510 | } 511 | 512 | select:focus { 513 | color: #fff; 514 | background-color: navy 515 | } 516 | 517 | select:focus option { 518 | color: #000; 519 | background-color: #fff 520 | } 521 | 522 | select:active { 523 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h16v17H0V0zm1 16h14V1H1v15z' fill='gray'/%3E%3Cpath fill='silver' d='M1 1h14v15H1z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M12 7H5v1h1v1h1v1h1v1h1v-1h1V9h1V8h1V7z' fill='%23000'/%3E%3C/svg%3E") 524 | } 525 | 526 | a { 527 | color: #00f 528 | } 529 | 530 | a:focus { 531 | outline: 1px dotted #00f 532 | } 533 | 534 | ul.tree-view { 535 | display: block; 536 | background: #fff; 537 | box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a; 538 | padding: 6px; 539 | margin: 0 540 | } 541 | 542 | ul.tree-view li { 543 | list-style-type: none 544 | } 545 | 546 | ul.tree-view a { 547 | text-decoration: none; 548 | color: #000 549 | } 550 | 551 | ul.tree-view a:focus { 552 | background-color: navy; 553 | color: #fff 554 | } 555 | 556 | ul.tree-view li, ul.tree-view ul { 557 | margin-top: 3px 558 | } 559 | 560 | ul.tree-view ul { 561 | margin-left: 16px; 562 | padding-left: 16px; 563 | border-left: 1px dotted grey 564 | } 565 | 566 | ul.tree-view ul > li { 567 | position: relative 568 | } 569 | 570 | ul.tree-view ul > li:before { 571 | content: ""; 572 | display: block; 573 | position: absolute; 574 | left: -16px; 575 | top: 6px; 576 | width: 12px; 577 | border-bottom: 1px dotted grey 578 | } 579 | 580 | ul.tree-view ul > li:last-child:after { 581 | content: ""; 582 | display: block; 583 | position: absolute; 584 | left: -20px; 585 | top: 7px; 586 | bottom: 0; 587 | width: 8px; 588 | background: #fff 589 | } 590 | 591 | ul.tree-view details { 592 | margin-top: 0 593 | } 594 | 595 | ul.tree-view details[open] summary { 596 | margin-bottom: 0 597 | } 598 | 599 | ul.tree-view ul details > summary:before { 600 | margin-left: -22px; 601 | position: relative; 602 | z-index: 1 603 | } 604 | 605 | ul.tree-view details > summary:before { 606 | text-align: center; 607 | display: block; 608 | float: left; 609 | content: "+"; 610 | border: 1px solid grey; 611 | width: 8px; 612 | height: 9px; 613 | line-height: 8px; 614 | margin-right: 5px; 615 | padding-left: 1px; 616 | background-color: #fff 617 | } 618 | 619 | ul.tree-view details[open] > summary:before { 620 | content: "-" 621 | } 622 | 623 | pre { 624 | display: block; 625 | background: #fff; 626 | box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a; 627 | padding: 12px 8px; 628 | margin: 0 629 | } 630 | 631 | code, code * { 632 | font-family: monospace 633 | } 634 | 635 | summary:focus { 636 | outline: 1px dotted #000 637 | } 638 | 639 | ::-webkit-scrollbar { 640 | width: 16px 641 | } 642 | 643 | ::-webkit-scrollbar:horizontal { 644 | height: 17px 645 | } 646 | 647 | ::-webkit-scrollbar-corner { 648 | background: #dfdfdf 649 | } 650 | 651 | ::-webkit-scrollbar-track { 652 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='2' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 0H0v1h1v1h1V1H1V0z' fill='silver'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 0H1v1H0v1h1V1h1V0z' fill='%23fff'/%3E%3C/svg%3E") 653 | } 654 | 655 | ::-webkit-scrollbar-thumb { 656 | background-color: #dfdfdf; 657 | box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf 658 | } 659 | 660 | ::-webkit-scrollbar-button:horizontal:end:increment, ::-webkit-scrollbar-button:horizontal:start:decrement, ::-webkit-scrollbar-button:vertical:end:increment, ::-webkit-scrollbar-button:vertical:start:decrement { 661 | display: block 662 | } 663 | 664 | ::-webkit-scrollbar-button:vertical:start { 665 | height: 17px; 666 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 6H7v1H6v1H5v1H4v1h7V9h-1V8H9V7H8V6z' fill='%23000'/%3E%3C/svg%3E") 667 | } 668 | 669 | ::-webkit-scrollbar-button:vertical:end { 670 | height: 17px; 671 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E") 672 | } 673 | 674 | ::-webkit-scrollbar-button:horizontal:start { 675 | width: 16px; 676 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 4H8v1H7v1H6v1H5v1h1v1h1v1h1v1h1V4z' fill='%23000'/%3E%3C/svg%3E") 677 | } 678 | 679 | ::-webkit-scrollbar-button:horizontal:end { 680 | width: 16px; 681 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M7 4H6v7h1v-1h1V9h1V8h1V7H9V6H8V5H7V4z' fill='%23000'/%3E%3C/svg%3E") 682 | } 683 | 684 | .status-bar { 685 | display: flex; 686 | margin: 1px; 687 | } 688 | 689 | .status-bar > :first-child { 690 | flex-grow: 1; 691 | } 692 | 693 | .status-bar > * { 694 | box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a; 695 | padding: 4px 6px; 696 | } 697 | 698 | .status-bar > * + * { 699 | margin-left: 2px; 700 | } 701 | -------------------------------------------------------------------------------- /ferrogallic/src/api/game.rs: -------------------------------------------------------------------------------- 1 | use crate::api::TypedWebSocket; 2 | use crate::words; 3 | use anyhow::{anyhow, Error}; 4 | use ferrogallic_shared::api::game::{ 5 | Canvas, Game, GamePhase, GameReq, GameState, Player, PlayerStatus, 6 | }; 7 | use ferrogallic_shared::config::{ 8 | close_guess_levenshtein, FIRST_CORRECT_BONUS, HEARTBEAT_SECONDS, MINIMUM_GUESS_SCORE, 9 | NUMBER_OF_WORDS_TO_CHOOSE, PERFECT_GUESS_SCORE, RX_SHARED_BUFFER, TX_BROADCAST_BUFFER, 10 | TX_SELF_DELAYED_BUFFER, 11 | }; 12 | use ferrogallic_shared::domain::{Epoch, Guess, Lobby, Lowercase, Nickname, UserId}; 13 | use futures::{SinkExt, StreamExt}; 14 | use rand::seq::SliceRandom; 15 | use rand::thread_rng; 16 | use std::cell::Cell; 17 | use std::collections::btree_map::Entry; 18 | use std::collections::{BTreeMap, HashMap}; 19 | use std::mem; 20 | use std::ops::Bound; 21 | use std::sync::Arc; 22 | use strsim::levenshtein; 23 | use time::OffsetDateTime; 24 | use tokio::select; 25 | use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; 26 | use tokio::task::spawn; 27 | use tokio::time::{interval, Duration, Instant}; 28 | use tokio_util::time::DelayQueue; 29 | 30 | #[derive(Default)] 31 | pub struct ActiveLobbies { 32 | tx_lobby: Mutex>>, 33 | } 34 | 35 | #[derive(Debug, PartialEq, Eq, Hash)] 36 | struct CaseInsensitiveLobby(Box); 37 | 38 | impl CaseInsensitiveLobby { 39 | fn new(lobby: &Lobby) -> Self { 40 | Self(lobby.to_ascii_lowercase().into_boxed_str()) 41 | } 42 | } 43 | 44 | pub async fn join_game( 45 | state: Arc, 46 | mut ws: TypedWebSocket, 47 | ) -> Result<(), Error> { 48 | let (lobby, nick) = match ws.next().await { 49 | Some(Ok(GameReq::Join(lobby, nick))) => (lobby, nick), 50 | Some(Ok(m)) => return Err(anyhow!("Initial message was not Join: {:?}", m)), 51 | Some(Err(e)) => return Err(e.context("Failed to receive initial message")), 52 | None => return Err(anyhow!("WS closed before initial message")), 53 | }; 54 | let user_id = nick.user_id(); 55 | let epoch = Epoch::next(); 56 | 57 | let (tx_lobby, rx_onboard) = loop { 58 | let tx_lobby = state 59 | .tx_lobby 60 | .lock() 61 | .await 62 | .entry(CaseInsensitiveLobby::new(&lobby)) 63 | .or_insert_with(|| { 64 | let (tx, rx) = mpsc::channel(RX_SHARED_BUFFER); 65 | spawn(run_game_loop(lobby.clone(), tx.clone(), rx)); 66 | tx 67 | }) 68 | .clone(); 69 | 70 | let (tx_onboard, rx_onboard) = oneshot::channel(); 71 | 72 | match tx_lobby 73 | .send(GameLoop::Connect(user_id, epoch, nick.clone(), tx_onboard)) 74 | .await 75 | { 76 | Ok(()) => break (tx_lobby, rx_onboard), 77 | Err(mpsc::error::SendError(_)) => { 78 | log::warn!("Player={} Lobby={} was shutdown, restart", nick, lobby); 79 | state 80 | .tx_lobby 81 | .lock() 82 | .await 83 | .remove(&CaseInsensitiveLobby::new(&lobby)); 84 | } 85 | } 86 | }; 87 | 88 | let handle_messages = || async { 89 | let Onboarding { 90 | mut rx_broadcast, 91 | messages, 92 | } = rx_onboard.await?; 93 | 94 | for msg in &messages { 95 | ws.send(msg).await?; 96 | } 97 | 98 | loop { 99 | select! { 100 | outbound = rx_broadcast.recv() => match outbound { 101 | Ok(broadcast) => match broadcast { 102 | Broadcast::Everyone(resp) => ws.send(&resp).await?, 103 | Broadcast::Exclude(uid, resp) if uid != user_id => ws.send(&resp).await?, 104 | Broadcast::Only(uid, resp) if uid == user_id => ws.send(&resp).await?, 105 | Broadcast::Kill(uid, ep) if uid == user_id && ep == epoch => { 106 | log::info!("Player={} Lobby={} Epoch={} killed", nick, lobby, epoch); 107 | return Ok(()); 108 | } 109 | Broadcast::Exclude(_, _) | Broadcast::Only(_, _) | Broadcast::Kill(_, _) => { 110 | log::trace!("Player={} Lobby={} Epoch={} ignored: {:?}", nick, lobby, epoch, broadcast); 111 | } 112 | }, 113 | Err(broadcast::error::RecvError::Lagged(msgs)) => { 114 | log::warn!("Player={} Lobby={} Epoch={} lagged {} messages", nick, lobby, epoch, msgs); 115 | return Ok(()); 116 | } 117 | Err(broadcast::error::RecvError::Closed) => { 118 | log::info!("Player={} Lobby={} Epoch={} dropped on shutdown", nick, lobby, epoch); 119 | return Ok(()); 120 | } 121 | }, 122 | inbound = ws.next() => match inbound { 123 | Some(Ok(req)) => match tx_lobby.send(GameLoop::Message(user_id, epoch, req)).await { 124 | Ok(()) => {} 125 | Err(mpsc::error::SendError(_)) => { 126 | log::info!("Player={} Lobby={} Epoch={} dropped on shutdown", nick, lobby, epoch); 127 | return Ok(()); 128 | } 129 | } 130 | Some(Err(e)) => { 131 | log::info!("Player={} Lobby={} Epoch={} failed to receive: {}", nick, lobby, epoch, e); 132 | return Ok(()); 133 | } 134 | None => { 135 | log::info!("Player={} Lobby={} Epoch={} disconnected", nick, lobby, epoch); 136 | return Ok(()); 137 | } 138 | }, 139 | } 140 | } 141 | }; 142 | 143 | let res = handle_messages().await; 144 | 145 | // if this fails, nothing we can do at this point, everyone is gone 146 | let _ = tx_lobby.send(GameLoop::Disconnect(user_id, epoch)).await; 147 | 148 | res 149 | } 150 | 151 | struct Onboarding { 152 | rx_broadcast: broadcast::Receiver, 153 | messages: [Game; 3], 154 | } 155 | 156 | enum GameLoop { 157 | Connect(UserId, Epoch, Nickname, oneshot::Sender), 158 | Message(UserId, Epoch, GameReq), 159 | Disconnect(UserId, Epoch), 160 | Heartbeat, 161 | GameEnd(Epoch), 162 | } 163 | 164 | #[derive(Debug, Clone)] 165 | enum Broadcast { 166 | Everyone(Game), 167 | Exclude(UserId, Game), 168 | Only(UserId, Game), 169 | Kill(UserId, Epoch), 170 | } 171 | 172 | #[test] 173 | fn broadcast_size() { 174 | assert_eq!(std::mem::size_of::(), 48); 175 | } 176 | 177 | async fn run_game_loop( 178 | lobby: Lobby, 179 | tx_self: mpsc::Sender, 180 | rx: mpsc::Receiver, 181 | ) { 182 | log::info!("Lobby={} starting", lobby); 183 | 184 | let (tx_self_delayed, mut rx_self_delayed) = mpsc::channel(TX_SELF_DELAYED_BUFFER); 185 | 186 | spawn({ 187 | let lobby = lobby.clone(); 188 | let tx_self_sending_and_receiving_on_same_task_can_deadlock = tx_self; 189 | async move { 190 | let mut delay = DelayQueue::new(); 191 | let mut heartbeat = interval(Duration::from_secs(HEARTBEAT_SECONDS)); 192 | loop { 193 | let msg = select! { 194 | to_delay = rx_self_delayed.recv() => match to_delay { 195 | Some((to_delay, instant)) => { 196 | delay.insert_at(to_delay, instant); 197 | continue; 198 | } 199 | None => { 200 | log::info!("Lobby={} stopping timer: game loop disconnected", lobby); 201 | return; 202 | } 203 | }, 204 | Some(delayed) = delay.next() => { 205 | delayed.into_inner() 206 | }, 207 | now = heartbeat.tick() => { 208 | let _: Instant = now; 209 | GameLoop::Heartbeat 210 | } 211 | }; 212 | if let Err(e) = tx_self_sending_and_receiving_on_same_task_can_deadlock 213 | .send(msg) 214 | .await 215 | { 216 | log::info!("Lobby={} stopping timer: {}", lobby, e); 217 | return; 218 | } 219 | } 220 | } 221 | }); 222 | 223 | match game_loop(&lobby, tx_self_delayed, rx).await { 224 | Ok(()) => log::info!("Lobby={} shutdown, no new connections", lobby), 225 | Err(e) => match e { 226 | GameLoopError::NoPlayers => { 227 | log::info!("Lobby={} shutdown: no players left", lobby); 228 | } 229 | GameLoopError::NoConnectionsDuringStateChange => { 230 | log::info!("Lobby={} shutdown: no conns during state change", lobby); 231 | } 232 | GameLoopError::DelayRecvGone => { 233 | log::error!("Lobby={} shutdown: delay receiver gone", lobby); 234 | } 235 | }, 236 | } 237 | } 238 | 239 | enum GameLoopError { 240 | NoPlayers, 241 | NoConnectionsDuringStateChange, 242 | DelayRecvGone, 243 | } 244 | 245 | impl From> for GameLoopError { 246 | fn from(_: broadcast::error::SendError) -> Self { 247 | Self::NoPlayers 248 | } 249 | } 250 | 251 | impl From> for GameLoopError { 252 | fn from(_: mpsc::error::SendError<(GameLoop, Instant)>) -> Self { 253 | Self::DelayRecvGone 254 | } 255 | } 256 | 257 | async fn game_loop( 258 | lobby: &Lobby, 259 | mut tx_self_delayed: mpsc::Sender<(GameLoop, Instant)>, 260 | mut rx: mpsc::Receiver, 261 | ) -> Result<(), GameLoopError> { 262 | let (tx, _) = broadcast::channel(TX_BROADCAST_BUFFER); 263 | 264 | let mut players = Invalidate::new(Arc::new(BTreeMap::new())); 265 | let mut game_state = Invalidate::new(Arc::new(GameState::default())); 266 | let mut canvas_events = Vec::new(); 267 | let mut guesses = Vec::new(); 268 | 269 | guesses.push(Guess::Help); 270 | 271 | loop { 272 | let msg = match rx.recv().await { 273 | Some(msg) => msg, 274 | None => return Ok(()), 275 | }; 276 | match msg { 277 | GameLoop::Connect(user_id, epoch, nick, tx_onboard) => { 278 | let onboarding = Onboarding { 279 | rx_broadcast: tx.subscribe(), 280 | messages: [ 281 | Game::Game(game_state.read().clone()), 282 | Game::GuessBulk(guesses.clone()), 283 | Game::CanvasBulk(canvas_events.clone()), 284 | ], 285 | }; 286 | if let Err(_) = tx_onboard.send(onboarding) { 287 | log::warn!("Lobby={} Player={} Epoch={} no onboard", lobby, nick, epoch); 288 | continue; 289 | } 290 | match Arc::make_mut(players.write()).entry(user_id) { 291 | Entry::Vacant(entry) => { 292 | log::info!("Lobby={} Player={} Epoch={} join", lobby, nick, epoch); 293 | entry.insert(Player { 294 | nick, 295 | epoch, 296 | status: PlayerStatus::Connected, 297 | score: 0, 298 | }); 299 | } 300 | Entry::Occupied(mut entry) => { 301 | log::info!("Lobby={} Player={} Epoch={} reconn", lobby, nick, epoch); 302 | let player = entry.get_mut(); 303 | player.epoch = epoch; 304 | player.status = PlayerStatus::Connected; 305 | } 306 | } 307 | } 308 | GameLoop::Message(user_id, epoch, req) => { 309 | let player = match players.read().get(&user_id) { 310 | Some(player) if player.epoch == epoch => player, 311 | _ => { 312 | tx.send(Broadcast::Kill(user_id, epoch))?; 313 | continue; 314 | } 315 | }; 316 | let GameState { config, phase } = game_state.read().as_ref(); 317 | match (req, phase) { 318 | (GameReq::Canvas(event), _) => { 319 | (&tx, &mut canvas_events).send(user_id, event)?; 320 | continue; 321 | } 322 | ( 323 | GameReq::Choose(word), 324 | GamePhase::ChoosingWords { 325 | round, 326 | choosing, 327 | words, 328 | }, 329 | ) if *choosing == user_id && words.contains(&word) => { 330 | let round = *round; 331 | let drawing = *choosing; 332 | trans_to_drawing( 333 | &tx, 334 | &mut tx_self_delayed, 335 | Arc::make_mut(game_state.write()), 336 | &mut canvas_events, 337 | &mut guesses, 338 | round, 339 | drawing, 340 | word, 341 | ) 342 | .await?; 343 | } 344 | (GameReq::Guess(guess), phase) => match phase { 345 | GamePhase::WaitingToStart => match guess.as_ref() { 346 | "start" => trans_at_game_start( 347 | &tx, 348 | Arc::make_mut(players.write()), 349 | Arc::make_mut(game_state.write()), 350 | &mut guesses, 351 | )?, 352 | guess if guess.starts_with("rounds ") => { 353 | match guess.trim_start_matches("rounds ").parse() { 354 | Ok(rounds) => { 355 | Arc::make_mut(game_state.write()).config.rounds = rounds; 356 | } 357 | Err(e) => (&tx, &mut guesses) 358 | .send(Guess::System(format!("Error: {}.", e).into()))?, 359 | } 360 | } 361 | guess if guess.starts_with("seconds ") => { 362 | match guess.trim_start_matches("seconds ").parse() { 363 | Ok(s) => { 364 | Arc::make_mut(game_state.write()).config.guess_seconds = s; 365 | } 366 | Err(e) => (&tx, &mut guesses) 367 | .send(Guess::System(format!("Error: {}.", e).into()))?, 368 | } 369 | } 370 | _ => (&tx, &mut guesses).send(Guess::Message(user_id, guess))?, 371 | }, 372 | GamePhase::ChoosingWords { .. } => { 373 | (&tx, &mut guesses).send(Guess::Message(user_id, guess))?; 374 | } 375 | GamePhase::Drawing { 376 | round: _, 377 | drawing, 378 | correct, 379 | word, 380 | epoch: _, 381 | started, 382 | } => { 383 | if *drawing == user_id || correct.contains_key(&user_id) { 384 | (&tx, &mut guesses).send(Guess::Message(user_id, guess))?; 385 | } else if guess == *word { 386 | let elapsed = OffsetDateTime::now_utc() - *started; 387 | let guess_seconds = config.guess_seconds; 388 | if let GamePhase::Drawing { correct, .. } = 389 | &mut Arc::make_mut(game_state.write()).phase 390 | { 391 | let score = guesser_score(elapsed, guess_seconds, &*correct); 392 | correct.insert(user_id, score); 393 | } 394 | (&tx, &mut guesses).send(Guess::Correct(user_id))?; 395 | } else { 396 | let was_close = 397 | if levenshtein(&guess, word) <= close_guess_levenshtein(word) { 398 | Some(guess.clone()) 399 | } else { 400 | None 401 | }; 402 | (&tx, &mut guesses).send(Guess::Guess(user_id, guess))?; 403 | if let Some(guess) = was_close { 404 | tx.send(Broadcast::Only( 405 | user_id, 406 | Game::Guess(Guess::CloseGuess(guess)), 407 | ))?; 408 | } 409 | } 410 | } 411 | }, 412 | (GameReq::Remove(remove_uid, remove_epoch), _) => { 413 | if let Entry::Occupied(entry) = 414 | Arc::make_mut(players.write()).entry(remove_uid) 415 | { 416 | if entry.get().epoch == remove_epoch { 417 | let removed = entry.remove(); 418 | log::info!("Lobby={} Player={} removed", lobby, removed.nick); 419 | } 420 | } 421 | } 422 | (req @ GameReq::Choose(..), _) | (req @ GameReq::Join(..), _) => { 423 | log::warn!("Lobby={} Player={} invalid: {:?}", lobby, player.nick, req); 424 | tx.send(Broadcast::Kill(user_id, epoch))?; 425 | } 426 | } 427 | } 428 | GameLoop::Disconnect(user_id, epoch) => { 429 | if let Some(player) = Arc::make_mut(players.write()).get_mut(&user_id) { 430 | if player.epoch == epoch { 431 | player.status = PlayerStatus::Disconnected; 432 | } 433 | } 434 | } 435 | GameLoop::Heartbeat => { 436 | tx.send(Broadcast::Everyone(Game::Heartbeat))?; 437 | } 438 | GameLoop::GameEnd(ended_epoch) => { 439 | if let GamePhase::Drawing { epoch, .. } = &game_state.read().phase { 440 | if *epoch == ended_epoch { 441 | let game_state = Arc::make_mut(game_state.write()); 442 | if let GamePhase::Drawing { 443 | round, 444 | drawing, 445 | correct, 446 | word, 447 | .. 448 | } = &mut game_state.phase 449 | { 450 | (&tx, &mut guesses).send(Guess::TimeExpired(word.clone()))?; 451 | let (round, drawing) = (*round, *drawing); 452 | let correct = mem::take(correct); 453 | trans_at_round_end( 454 | &tx, 455 | Arc::make_mut(players.write()), 456 | game_state, 457 | &mut canvas_events, 458 | &mut guesses, 459 | round, 460 | drawing, 461 | correct, 462 | )?; 463 | } 464 | } 465 | } 466 | } 467 | } 468 | 469 | if players.is_changed() || game_state.is_changed() { 470 | match &game_state.read().phase { 471 | GamePhase::ChoosingWords { choosing, .. } 472 | if !players.read().contains_key(choosing) => 473 | { 474 | // ...the chooser is gone 475 | let game_state = Arc::make_mut(game_state.write()); 476 | if let GamePhase::ChoosingWords { 477 | round, choosing, .. 478 | } = &mut game_state.phase 479 | { 480 | let round = *round; 481 | let drawing = *choosing; 482 | let correct = Default::default(); 483 | trans_at_round_end( 484 | &tx, 485 | Arc::make_mut(players.write()), 486 | game_state, 487 | &mut canvas_events, 488 | &mut guesses, 489 | round, 490 | drawing, 491 | correct, 492 | )?; 493 | } 494 | } 495 | GamePhase::Drawing { 496 | drawing, correct, .. 497 | } if players 498 | .read() 499 | .keys() 500 | .all(|uid| drawing == uid || correct.contains_key(uid)) 501 | || !players.read().contains_key(drawing) => 502 | { 503 | // ...all players guessed correctly or the drawer is gone 504 | let game_state = Arc::make_mut(game_state.write()); 505 | if let GamePhase::Drawing { 506 | round, 507 | drawing, 508 | correct, 509 | .. 510 | } = &mut game_state.phase 511 | { 512 | let (round, drawing) = (*round, *drawing); 513 | let correct = mem::take(correct); 514 | trans_at_round_end( 515 | &tx, 516 | Arc::make_mut(players.write()), 517 | game_state, 518 | &mut canvas_events, 519 | &mut guesses, 520 | round, 521 | drawing, 522 | correct, 523 | )?; 524 | } 525 | } 526 | _ => {} 527 | } 528 | } 529 | 530 | if let Some(players) = players.reset_if_changed() { 531 | tx.send(Broadcast::Everyone(Game::Players(players.clone())))?; 532 | } 533 | if let Some(state) = game_state.reset_if_changed() { 534 | tx.send(Broadcast::Everyone(Game::Game(state.clone())))?; 535 | } 536 | } 537 | } 538 | 539 | trait CanvasExt { 540 | fn send(self, user_id: UserId, event: Canvas) -> Result<(), GameLoopError>; 541 | 542 | fn clear(self) -> Result<(), GameLoopError>; 543 | } 544 | 545 | impl CanvasExt for (&broadcast::Sender, &mut Vec) { 546 | fn send(self, user_id: UserId, event: Canvas) -> Result<(), GameLoopError> { 547 | self.1.push(event); 548 | self.0 549 | .send(Broadcast::Exclude(user_id, Game::Canvas(event)))?; 550 | Ok(()) 551 | } 552 | 553 | fn clear(self) -> Result<(), GameLoopError> { 554 | self.1.clear(); 555 | self.0 556 | .send(Broadcast::Everyone(Game::Canvas(Canvas::Clear)))?; 557 | Ok(()) 558 | } 559 | } 560 | 561 | trait GuessExt { 562 | fn send(self, guess: Guess) -> Result<(), GameLoopError>; 563 | 564 | fn clear(self) -> Result<(), GameLoopError>; 565 | } 566 | 567 | impl GuessExt for (&broadcast::Sender, &mut Vec) { 568 | fn send(self, guess: Guess) -> Result<(), GameLoopError> { 569 | self.1.push(guess.clone()); 570 | self.0.send(Broadcast::Everyone(Game::Guess(guess)))?; 571 | Ok(()) 572 | } 573 | 574 | fn clear(self) -> Result<(), GameLoopError> { 575 | self.1.clear(); 576 | self.0.send(Broadcast::Everyone(Game::ClearGuesses))?; 577 | Ok(()) 578 | } 579 | } 580 | 581 | fn trans_at_game_start( 582 | tx: &broadcast::Sender, 583 | players: &mut BTreeMap, 584 | game_state: &mut GameState, 585 | guesses: &mut Vec, 586 | ) -> Result<(), GameLoopError> { 587 | players.values_mut().for_each(|player| player.score = 0); 588 | (tx, &mut *guesses).clear()?; 589 | let round = 1; 590 | let next_choosing = match players.keys().next() { 591 | Some(choosing) => *choosing, 592 | None => return Err(GameLoopError::NoConnectionsDuringStateChange), 593 | }; 594 | trans_to_choosing(tx, game_state, guesses, round, next_choosing)?; 595 | Ok(()) 596 | } 597 | 598 | fn trans_to_choosing( 599 | tx: &broadcast::Sender, 600 | game_state: &mut GameState, 601 | guesses: &mut Vec, 602 | round: u8, 603 | next_choosing: UserId, 604 | ) -> Result<(), GameLoopError> { 605 | let words = words::GAME 606 | .choose_multiple(&mut thread_rng(), NUMBER_OF_WORDS_TO_CHOOSE) 607 | .copied() 608 | .map(Lowercase::new) 609 | .collect(); 610 | game_state.phase = GamePhase::ChoosingWords { 611 | round, 612 | choosing: next_choosing, 613 | words, 614 | }; 615 | (tx, guesses).send(Guess::NowChoosing(next_choosing))?; 616 | Ok(()) 617 | } 618 | 619 | async fn trans_to_drawing( 620 | tx: &broadcast::Sender, 621 | tx_self_delayed: &mut mpsc::Sender<(GameLoop, Instant)>, 622 | game_state: &mut GameState, 623 | canvas_events: &mut Vec, 624 | guesses: &mut Vec, 625 | round: u8, 626 | drawing: UserId, 627 | word: Lowercase, 628 | ) -> Result<(), GameLoopError> { 629 | let game_epoch = Epoch::next(); 630 | let started = OffsetDateTime::now_utc(); 631 | let will_end = Instant::now() + Duration::from_secs(u64::from(game_state.config.guess_seconds)); 632 | game_state.phase = GamePhase::Drawing { 633 | round, 634 | drawing, 635 | correct: Default::default(), 636 | word, 637 | epoch: game_epoch, 638 | started, 639 | }; 640 | (tx, guesses).send(Guess::NowDrawing(drawing))?; 641 | (tx, canvas_events).clear()?; 642 | tx_self_delayed 643 | .send((GameLoop::GameEnd(game_epoch), will_end)) 644 | .await?; 645 | Ok(()) 646 | } 647 | 648 | fn trans_at_round_end( 649 | tx: &broadcast::Sender, 650 | players: &mut BTreeMap, 651 | game_state: &mut GameState, 652 | canvas_events: &mut Vec, 653 | guesses: &mut Vec, 654 | round: u8, 655 | drawing: UserId, 656 | correct: BTreeMap, 657 | ) -> Result<(), GameLoopError> { 658 | for (&user_id, &score) in &correct { 659 | if let Some(player) = players.get_mut(&user_id) { 660 | player.score += score; 661 | } 662 | (tx, &mut *guesses).send(Guess::EarnedPoints(user_id, score))?; 663 | } 664 | let drawer_score = drawer_score(correct.values().copied(), players.len() as u32); 665 | if let Some(drawer) = players.get_mut(&drawing) { 666 | drawer.score += drawer_score; 667 | } 668 | 669 | let player_after_previous = players 670 | .range((Bound::Excluded(drawing), Bound::Unbounded)) 671 | .next(); 672 | let next = if let Some(after_prev) = player_after_previous { 673 | // advancing to next player, same round 674 | Some((round, *after_prev.0)) 675 | } else if round < game_state.config.rounds { 676 | // no next player; change to next round 677 | let next_choosing = match players.keys().next() { 678 | Some(choosing) => *choosing, 679 | None => return Err(GameLoopError::NoConnectionsDuringStateChange), 680 | }; 681 | Some((round + 1, next_choosing)) 682 | } else { 683 | // end game 684 | None 685 | }; 686 | 687 | match next { 688 | Some((round, next_choosing)) => { 689 | trans_to_choosing(tx, game_state, guesses, round, next_choosing) 690 | } 691 | None => trans_at_game_end(tx, players, game_state, canvas_events, guesses), 692 | } 693 | } 694 | 695 | #[allow(clippy::ptr_arg)] 696 | fn trans_at_game_end( 697 | tx: &broadcast::Sender, 698 | players: &mut BTreeMap, 699 | game_state: &mut GameState, 700 | canvas_events: &mut Vec, 701 | guesses: &mut Vec, 702 | ) -> Result<(), GameLoopError> { 703 | (tx, &mut *guesses).send(Guess::GameOver)?; 704 | for (rank, user_id, player) in Player::rankings(&*players) { 705 | (tx, &mut *guesses).send(Guess::FinalScore { 706 | rank, 707 | user_id, 708 | score: player.score, 709 | })?; 710 | } 711 | game_state.phase = GamePhase::WaitingToStart; 712 | (tx, guesses).send(Guess::Help)?; 713 | (tx, canvas_events).clear()?; 714 | Ok(()) 715 | } 716 | 717 | fn guesser_score( 718 | elapsed: time::Duration, 719 | guess_seconds: u16, 720 | existing: &BTreeMap, 721 | ) -> u32 { 722 | let guess_millis = u32::from(guess_seconds) * 1000; 723 | let elapsed_millis = elapsed.whole_milliseconds() as u32; 724 | let time_score = ((guess_millis - elapsed_millis) * PERFECT_GUESS_SCORE) 725 | .checked_div(guess_millis) 726 | .unwrap_or(0); 727 | 728 | let first_bonus = if existing.is_empty() { 729 | FIRST_CORRECT_BONUS 730 | } else { 731 | 0 732 | }; 733 | 734 | time_score + first_bonus + MINIMUM_GUESS_SCORE 735 | } 736 | 737 | fn drawer_score(scores: impl Iterator, player_count: u32) -> u32 { 738 | scores 739 | .sum::() 740 | .checked_div(player_count - 1) 741 | .unwrap_or(0) 742 | } 743 | 744 | struct Invalidate { 745 | value: T, 746 | changed: Cell, 747 | } 748 | 749 | impl Invalidate { 750 | fn new(value: T) -> Self { 751 | Self { 752 | value, 753 | changed: Cell::new(true), 754 | } 755 | } 756 | 757 | fn read(&self) -> &T { 758 | &self.value 759 | } 760 | 761 | fn write(&mut self) -> &mut T { 762 | self.changed.set(true); 763 | &mut self.value 764 | } 765 | 766 | fn is_changed(&self) -> bool { 767 | self.changed.get() 768 | } 769 | 770 | fn reset_if_changed(&self) -> Option<&T> { 771 | if self.changed.replace(false) { 772 | Some(&self.value) 773 | } else { 774 | None 775 | } 776 | } 777 | } 778 | -------------------------------------------------------------------------------- /ferrogallic/src/words/game.rs: -------------------------------------------------------------------------------- 1 | pub const GAME: &[&str] = &[ 2 | "accounting", 3 | "acne", 4 | "acre", 5 | "acrobat", 6 | "actor", 7 | "addendum", 8 | "address", 9 | "advertise", 10 | "aircraft carrier", 11 | "aircraft", 12 | "airport security", 13 | "airport", 14 | "aisle", 15 | "alarm clock", 16 | "albatross", 17 | "alligator", 18 | "alphabetize", 19 | "ambulance", 20 | "america", 21 | "amusement park", 22 | "anemone", 23 | "angel", 24 | "ankle", 25 | "anvil", 26 | "apathetic", 27 | "apathy", 28 | "apologize", 29 | "applause", 30 | "apple pie", 31 | "applesauce", 32 | "application", 33 | "aquarium", 34 | "arcade", 35 | "archaeologist", 36 | "aristocrat", 37 | "arm", 38 | "armada", 39 | "art gallery", 40 | "art", 41 | "artist", 42 | "ashamed", 43 | "ask", 44 | "asleep", 45 | "astronaut", 46 | "athlete", 47 | "atlantis", 48 | "atlas", 49 | "attack", 50 | "attic", 51 | "aunt", 52 | "avocado", 53 | "baby", 54 | "babysitter", 55 | "back flip", 56 | "back", 57 | "backbone", 58 | "bacteria", 59 | "bag", 60 | "bagel", 61 | "baggage", 62 | "bagpipe", 63 | "baguette", 64 | "baker", 65 | "bakery", 66 | "balance beam", 67 | "bald eagle", 68 | "bald", 69 | "balloon", 70 | "banana peel", 71 | "banana split", 72 | "banana", 73 | "banister", 74 | "banjo", 75 | "barber", 76 | "barbershop", 77 | "bargain", 78 | "barn", 79 | "barrel", 80 | "base", 81 | "baseball", 82 | "baseboards", 83 | "basket", 84 | "basketball", 85 | "bat", 86 | "bathroom scale", 87 | "bathtub", 88 | "batteries", 89 | "battery", 90 | "beach", 91 | "beanstalk", 92 | "beaver", 93 | "bedbug", 94 | "beehive", 95 | "beer", 96 | "beethoven", 97 | "bell pepper", 98 | "bell", 99 | "belt", 100 | "beluga whale", 101 | "best friend", 102 | "bib", 103 | "bicycle", 104 | "big", 105 | "bike", 106 | "billboard", 107 | "bird", 108 | "birthday cake", 109 | "birthday", 110 | "biscuit", 111 | "bite", 112 | "black belt", 113 | "black hole", 114 | "black widow", 115 | "blacksmith", 116 | "blanket", 117 | "bleach", 118 | "blimp", 119 | "blizzard", 120 | "blossom", 121 | "blowfish", 122 | "blue jeans", 123 | "blueprint", 124 | "blur", 125 | "blush", 126 | "boa constrictor", 127 | "boa", 128 | "boat", 129 | "bob", 130 | "bobsled", 131 | "body", 132 | "bomb", 133 | "bonnet", 134 | "book", 135 | "bookend", 136 | "bookstore", 137 | "boot", 138 | "booth", 139 | "border", 140 | "bottle", 141 | "boulevard", 142 | "bowtie", 143 | "box", 144 | "boxing", 145 | "boy", 146 | "braid", 147 | "brain", 148 | "brainstorm", 149 | "brand", 150 | "brave", 151 | "breakfast", 152 | "brick", 153 | "bride", 154 | "bridge", 155 | "broccoli", 156 | "broken", 157 | "broom", 158 | "bruise", 159 | "brunette", 160 | "brush", 161 | "bubble", 162 | "bucket", 163 | "buddy", 164 | "buffalo", 165 | "bug spray", 166 | "buggy", 167 | "bulb", 168 | "bulldog", 169 | "bunny", 170 | "bus", 171 | "bushes", 172 | "butcher", 173 | "button", 174 | "buy", 175 | "cabin", 176 | "cable car", 177 | "cactus", 178 | "cafeteria", 179 | "cage", 180 | "cake", 181 | "calculator", 182 | "calendar", 183 | "calm", 184 | "camera", 185 | "campfire", 186 | "campsite", 187 | "can", 188 | "canada", 189 | "candle", 190 | "candy", 191 | "cannon", 192 | "canoe", 193 | "cape", 194 | "capitalism", 195 | "captain", 196 | "car dealership", 197 | "car", 198 | "carat", 199 | "cardboard", 200 | "cargo", 201 | "carnival", 202 | "carousel", 203 | "carpenter", 204 | "carpet", 205 | "cartography", 206 | "cartoon", 207 | "cash", 208 | "cast", 209 | "castle", 210 | "cat", 211 | "catalog", 212 | "catfish", 213 | "cattle", 214 | "cave", 215 | "caviar", 216 | "ceiling fan", 217 | "ceiling", 218 | "celery", 219 | "cell phone charger", 220 | "cell phone", 221 | "cell", 222 | "cellar", 223 | "cello", 224 | "cemetery", 225 | "century", 226 | "chain mail", 227 | "chain", 228 | "chair", 229 | "chairman", 230 | "chalk", 231 | "chameleon", 232 | "champion", 233 | "character", 234 | "charger", 235 | "chariot racing", 236 | "chariot", 237 | "chart", 238 | "cheat", 239 | "check", 240 | "cheek", 241 | "cheerleader", 242 | "cheeseburger", 243 | "cheetah", 244 | "chef", 245 | "chemical", 246 | "cherub", 247 | "chess", 248 | "chest", 249 | "chestnut", 250 | "chew", 251 | "chicken coop", 252 | "chicken", 253 | "children", 254 | "chime", 255 | "chimney", 256 | "chin", 257 | "china", 258 | "chip", 259 | "chisel", 260 | "chocolate chip cookie", 261 | "chocolate", 262 | "church", 263 | "circus", 264 | "city", 265 | "clam", 266 | "clamp", 267 | "class", 268 | "classroom", 269 | "claw", 270 | "clay", 271 | "cliff diving", 272 | "cliff", 273 | "clique", 274 | "cloak", 275 | "clockwork", 276 | "clog", 277 | "closed", 278 | "clown", 279 | "clownfish", 280 | "clue", 281 | "coach", 282 | "coal", 283 | "coast", 284 | "coaster", 285 | "coastline", 286 | "coat", 287 | "cobra", 288 | "cobweb", 289 | "cockpit", 290 | "coconut", 291 | "cocoon", 292 | "cog", 293 | "coil", 294 | "coin", 295 | "cold", 296 | "collar", 297 | "college", 298 | "comfort", 299 | "comfy", 300 | "commercial", 301 | "company", 302 | "compare", 303 | "compass", 304 | "competition", 305 | "computer", 306 | "concession stand", 307 | "cone", 308 | "connect", 309 | "connection", 310 | "constellation", 311 | "constrictor", 312 | "contain", 313 | "continuum", 314 | "conversation", 315 | "conveyor belt", 316 | "cook", 317 | "coop", 318 | "cord", 319 | "corduroy", 320 | "cork", 321 | "corn", 322 | "corndog", 323 | "corner", 324 | "correct", 325 | "costume", 326 | "cot", 327 | "cotton candy", 328 | "cougar", 329 | "cough", 330 | "country", 331 | "cousin", 332 | "cover", 333 | "cow", 334 | "cowboy", 335 | "coworker", 336 | "coyote", 337 | "crack", 338 | "cracker", 339 | "crane", 340 | "crate", 341 | "crater", 342 | "crayon", 343 | "cream", 344 | "crib", 345 | "cricket", 346 | "crime", 347 | "crisp", 348 | "criticize", 349 | "crop duster", 350 | "crow", 351 | "crow's nest", 352 | "crown", 353 | "cruise ship", 354 | "cruise", 355 | "crumb", 356 | "crust", 357 | "cub", 358 | "cubicle", 359 | "cuckoo clock", 360 | "cucumber", 361 | "cuff", 362 | "curb", 363 | "cure", 364 | "curtain", 365 | "curtains", 366 | "curve", 367 | "cushion", 368 | "customer", 369 | "cuticle", 370 | "czar", 371 | "dad", 372 | "daddy longlegs", 373 | "dance", 374 | "darkness", 375 | "dart", 376 | "darts", 377 | "dashboard", 378 | "date", 379 | "dawn", 380 | "day", 381 | "dead end", 382 | "deep", 383 | "deer", 384 | "defect", 385 | "degree", 386 | "deliver", 387 | "delivery", 388 | "density", 389 | "dent", 390 | "dentist", 391 | "desert", 392 | "desk", 393 | "detective", 394 | "devious", 395 | "dew", 396 | "diagonal", 397 | "dictionary", 398 | "dig", 399 | "dimple", 400 | "dinner", 401 | "dirt", 402 | "dirty", 403 | "disc jockey", 404 | "dismantle", 405 | "distance", 406 | "ditch", 407 | "diver", 408 | "dizzy", 409 | "dock", 410 | "doctor", 411 | "dodgeball", 412 | "dog leash", 413 | "dog", 414 | "doghouse", 415 | "doll", 416 | "dollar", 417 | "dolphin", 418 | "dominoes", 419 | "donkey", 420 | "door", 421 | "doorknob", 422 | "doormat", 423 | "dorsal", 424 | "dot", 425 | "double", 426 | "download", 427 | "downpour", 428 | "dragon", 429 | "dragonfly", 430 | "drain", 431 | "draw", 432 | "drawback", 433 | "drawer", 434 | "dream", 435 | "dress shirt", 436 | "dress", 437 | "drill bit", 438 | "drill", 439 | "drink", 440 | "drip", 441 | "dripping", 442 | "driveway", 443 | "drought", 444 | "drugstore", 445 | "drums", 446 | "drumstick", 447 | "dryer", 448 | "duck", 449 | "dump truck", 450 | "dump", 451 | "dunk", 452 | "dust bunny", 453 | "dust", 454 | "dustpan", 455 | "eagle", 456 | "ear", 457 | "earache", 458 | "earmuffs", 459 | "earthquake", 460 | "easel", 461 | "east", 462 | "eat", 463 | "ebony", 464 | "eclipse", 465 | "economics", 466 | "edge", 467 | "edit", 468 | "eel", 469 | "eighteen wheeler", 470 | "elbow", 471 | "electrical outlet", 472 | "electricity", 473 | "elephant", 474 | "elevator", 475 | "elf", 476 | "elm", 477 | "elope", 478 | "empty", 479 | "end zone", 480 | "engaged", 481 | "engine", 482 | "england", 483 | "enter", 484 | "envelope", 485 | "equator", 486 | "eraser", 487 | "ergonomic", 488 | "escalator", 489 | "eureka", 490 | "europe", 491 | "evolution", 492 | "exercise", 493 | "expert", 494 | "extension cord", 495 | "extension", 496 | "eye patch", 497 | "eyebrow", 498 | "fabric", 499 | "face", 500 | "factory", 501 | "fade", 502 | "fairies", 503 | "family", 504 | "fan", 505 | "fancy", 506 | "fang", 507 | "fanny pack", 508 | "farm", 509 | "farmer", 510 | "fast food", 511 | "fast", 512 | "faucet", 513 | "fax", 514 | "feast", 515 | "fence", 516 | "fern", 517 | "ferry", 518 | "feudalism", 519 | "fiance", 520 | "fiddle", 521 | "field", 522 | "figment", 523 | "fin", 524 | "finger", 525 | "fire hydrant", 526 | "fire", 527 | "firefighter", 528 | "fireman pole", 529 | "fireside", 530 | "first class", 531 | "first", 532 | "fishing pole", 533 | "fishing", 534 | "fist", 535 | "fix", 536 | "fizz", 537 | "flagpole", 538 | "flamingo", 539 | "flannel", 540 | "flashlight", 541 | "flavor", 542 | "flock", 543 | "flood", 544 | "floor", 545 | "florist", 546 | "flotsam", 547 | "flower", 548 | "flu", 549 | "flush", 550 | "flute", 551 | "flutter", 552 | "foam", 553 | "fog", 554 | "foil", 555 | "food", 556 | "football", 557 | "forehead", 558 | "forest", 559 | "forever", 560 | "fork", 561 | "fortnight", 562 | "fortress", 563 | "fox", 564 | "frame", 565 | "france", 566 | "freckle", 567 | "free", 568 | "freight", 569 | "french fries", 570 | "fresh water", 571 | "freshman", 572 | "fringe", 573 | "frog", 574 | "front porch", 575 | "front", 576 | "frost", 577 | "frown", 578 | "fruit", 579 | "frying pan", 580 | "full moon", 581 | "full", 582 | "fungus", 583 | "fur", 584 | "gallon", 585 | "gallop", 586 | "game", 587 | "gap", 588 | "garage", 589 | "garbage truck", 590 | "garbage", 591 | "garden hose", 592 | "garden", 593 | "gas station", 594 | "gasoline", 595 | "gate", 596 | "gem", 597 | "geologist", 598 | "germ", 599 | "geyser", 600 | "giant", 601 | "gift", 602 | "ginger", 603 | "gingerbread man", 604 | "gingerbread", 605 | "girl", 606 | "girlfriend", 607 | "glass", 608 | "glasses", 609 | "glitter", 610 | "globe", 611 | "glove", 612 | "glue gun", 613 | "glue stick", 614 | "glue", 615 | "goalkeeper", 616 | "goat", 617 | "goblin", 618 | "gold medal", 619 | "gold", 620 | "goldfish", 621 | "golf cart", 622 | "golf", 623 | "goodbye", 624 | "goose", 625 | "government", 626 | "gown", 627 | "grandma", 628 | "grandpa", 629 | "grape", 630 | "graph", 631 | "grass", 632 | "grasslands", 633 | "gratitude", 634 | "gravity", 635 | "gray", 636 | "green", 637 | "grill", 638 | "grocery store", 639 | "groom", 640 | "growl", 641 | "guarantee", 642 | "guitar", 643 | "gum", 644 | "gumball", 645 | "hail", 646 | "hair dryer", 647 | "hair", 648 | "hairbrush", 649 | "haircut", 650 | "hairspray", 651 | "half", 652 | "hammer", 653 | "hand soap", 654 | "handle", 655 | "handwriting", 656 | "hang glider", 657 | "hang", 658 | "happy", 659 | "harmonica", 660 | "harp", 661 | "hat", 662 | "hatch", 663 | "headache", 664 | "headband", 665 | "heart", 666 | "heater", 667 | "hedge", 668 | "heel", 669 | "helicopter", 670 | "helium", 671 | "hem", 672 | "hen", 673 | "hermit crab", 674 | "hero", 675 | "hide", 676 | "highway", 677 | "hill", 678 | "hip", 679 | "hippopotamus", 680 | "hipster", 681 | "hiss", 682 | "hockey", 683 | "hole", 684 | "homeless", 685 | "homework", 686 | "honey", 687 | "honk", 688 | "hoof", 689 | "hook", 690 | "hoop", 691 | "hopscotch", 692 | "horn", 693 | "horse", 694 | "hose", 695 | "hospital", 696 | "hot dog", 697 | "hot tub", 698 | "hot", 699 | "hotel", 700 | "hour", 701 | "hourglass", 702 | "house", 703 | "houseboat", 704 | "hovercraft", 705 | "howl", 706 | "hug", 707 | "hula hoop", 708 | "human", 709 | "humidifier", 710 | "humidity", 711 | "hummingbird", 712 | "hungry", 713 | "hunter", 714 | "hurdle", 715 | "hurricane", 716 | "hurt", 717 | "husband", 718 | "hut", 719 | "hydrogen", 720 | "ice", 721 | "icicle", 722 | "idea", 723 | "imagine", 724 | "implode", 725 | "important", 726 | "inch", 727 | "ink", 728 | "inn", 729 | "inquisition", 730 | "insect", 731 | "interception", 732 | "intern", 733 | "internet", 734 | "invent", 735 | "invitation", 736 | "iron", 737 | "ironic", 738 | "ironing board", 739 | "irrigation", 740 | "island", 741 | "ivory", 742 | "ivy", 743 | "jacket", 744 | "jade", 745 | "jail", 746 | "janitor", 747 | "japan", 748 | "jar", 749 | "jaw", 750 | "jazz", 751 | "jeans", 752 | "jelly", 753 | "jet ski", 754 | "jet", 755 | "jewelry", 756 | "jig", 757 | "jigsaw", 758 | "jog", 759 | "journal", 760 | "judge", 761 | "juggle", 762 | "juice", 763 | "jump", 764 | "jungle", 765 | "junk", 766 | "kayak", 767 | "kettle", 768 | "key", 769 | "killer", 770 | "kilogram", 771 | "king", 772 | "kiss", 773 | "kitchen", 774 | "kite", 775 | "knee", 776 | "kneel", 777 | "knife", 778 | "knight", 779 | "knot", 780 | "koala", 781 | "lace", 782 | "ladder", 783 | "ladybug", 784 | "lag", 785 | "lake", 786 | "lamp", 787 | "lance", 788 | "landfill", 789 | "landlord", 790 | "landscape", 791 | "lap", 792 | "laser", 793 | "last", 794 | "latitude", 795 | "laugh", 796 | "laundry basket", 797 | "laundry detergent", 798 | "laundry", 799 | "law", 800 | "lawn", 801 | "lawnmower", 802 | "leak", 803 | "learn", 804 | "leather", 805 | "lecture", 806 | "leg", 807 | "lemon", 808 | "letter opener", 809 | "letter", 810 | "level", 811 | "librarian", 812 | "library", 813 | "lifejacket", 814 | "lifestyle", 815 | "ligament", 816 | "light switch", 817 | "light", 818 | "lighthouse", 819 | "lightsaber", 820 | "lime", 821 | "limit", 822 | "limousine", 823 | "lion", 824 | "lip", 825 | "lipstick", 826 | "liquid", 827 | "list", 828 | "living room", 829 | "lizard", 830 | "loaf", 831 | "lobster", 832 | "lock", 833 | "locket", 834 | "log", 835 | "logo", 836 | "lollipop", 837 | "loveseat", 838 | "loyalty", 839 | "lucky", 840 | "lullaby", 841 | "lumberyard", 842 | "lunar rover", 843 | "lunch tray", 844 | "lunch", 845 | "lunchbox", 846 | "lung", 847 | "lyrics", 848 | "macaroni", 849 | "machine", 850 | "macho", 851 | "magazine", 852 | "magic carpet", 853 | "magic", 854 | "magnet", 855 | "maid", 856 | "mail", 857 | "mailbox", 858 | "mailman", 859 | "mammoth", 860 | "manatee", 861 | "map", 862 | "mark", 863 | "marker", 864 | "marry", 865 | "mars", 866 | "marshmallow", 867 | "mascot", 868 | "mask", 869 | "mast", 870 | "mat", 871 | "match", 872 | "matchstick", 873 | "mattress", 874 | "mayor", 875 | "maze", 876 | "meat", 877 | "melt", 878 | "merry go round", 879 | "mess", 880 | "meteor", 881 | "mexico", 882 | "middle", 883 | "midnight", 884 | "midsummer", 885 | "migrate", 886 | "milk", 887 | "mime", 888 | "mine", 889 | "miner", 890 | "minivan", 891 | "mirror", 892 | "mistake", 893 | "mitten", 894 | "modern", 895 | "molar", 896 | "mold", 897 | "molecule", 898 | "mom", 899 | "monday", 900 | "money", 901 | "monitor", 902 | "monkey", 903 | "monsoon", 904 | "monster", 905 | "mooch", 906 | "moon", 907 | "mop", 908 | "moth", 909 | "motorcycle", 910 | "mountain", 911 | "mouse pad", 912 | "mouse", 913 | "mouth", 914 | "movie theater", 915 | "movie", 916 | "mower", 917 | "mud", 918 | "muffin", 919 | "mug", 920 | "muscle", 921 | "museum", 922 | "mushroom", 923 | "music", 924 | "musician", 925 | "mute", 926 | "mysterious", 927 | "myth", 928 | "nail", 929 | "nanny", 930 | "nap", 931 | "napkin", 932 | "narwhal", 933 | "nature", 934 | "neck", 935 | "necktie", 936 | "needle", 937 | "negotiate", 938 | "neighbor", 939 | "neighborhood", 940 | "nest", 941 | "net", 942 | "neutron", 943 | "newborn", 944 | "newlywed", 945 | "newsletter", 946 | "newspaper", 947 | "niece", 948 | "night", 949 | "nightmare", 950 | "noon", 951 | "north", 952 | "nose", 953 | "notebook", 954 | "notepad", 955 | "nun", 956 | "nurse", 957 | "nut", 958 | "oar", 959 | "obey", 960 | "observatory", 961 | "ocean", 962 | "office", 963 | "oil", 964 | "old", 965 | "olympian", 966 | "omnivore", 967 | "onion", 968 | "opaque", 969 | "open", 970 | "opener", 971 | "optometrist", 972 | "orange", 973 | "orbit", 974 | "organ", 975 | "organize", 976 | "ornament", 977 | "orphan", 978 | "ounce", 979 | "outer space", 980 | "outer", 981 | "outside", 982 | "ovation", 983 | "oven", 984 | "overture", 985 | "owl", 986 | "owner", 987 | "oxcart", 988 | "package", 989 | "page", 990 | "pail", 991 | "pain", 992 | "paint", 993 | "pajamas", 994 | "palace", 995 | "pan", 996 | "pancake", 997 | "panda", 998 | "pantry", 999 | "pants", 1000 | "paper", 1001 | "paperclip", 1002 | "parachute", 1003 | "parade", 1004 | "parent", 1005 | "park", 1006 | "parka", 1007 | "parking garage", 1008 | "parody", 1009 | "partner", 1010 | "party", 1011 | "passenger", 1012 | "password", 1013 | "pastry", 1014 | "paw", 1015 | "pawn", 1016 | "pea", 1017 | "peace", 1018 | "peach", 1019 | "peanut", 1020 | "pear", 1021 | "peasant", 1022 | "peck", 1023 | "pelican", 1024 | "pen", 1025 | "pencil", 1026 | "pendulum", 1027 | "penguin", 1028 | "penny", 1029 | "pepper", 1030 | "personal", 1031 | "pest", 1032 | "pet store", 1033 | "pet", 1034 | "pharaoh", 1035 | "pharmacist", 1036 | "philosopher", 1037 | "phone", 1038 | "photograph", 1039 | "photosynthesis", 1040 | "piano", 1041 | "pickle", 1042 | "pickup truck", 1043 | "picnic", 1044 | "picture frame", 1045 | "pie", 1046 | "pigpen", 1047 | "pile", 1048 | "pillow", 1049 | "pillowcase", 1050 | "pilot", 1051 | "pinch", 1052 | "pine tree", 1053 | "pineapple", 1054 | "pinecone", 1055 | "ping pong", 1056 | "ping", 1057 | "pinwheel", 1058 | "pipe", 1059 | "piranha", 1060 | "pirate", 1061 | "pitchfork", 1062 | "pizza sauce", 1063 | "pizza", 1064 | "plaid", 1065 | "plan", 1066 | "plank", 1067 | "plant", 1068 | "plantation", 1069 | "plastic", 1070 | "plate", 1071 | "platypus", 1072 | "playground", 1073 | "plow", 1074 | "plug", 1075 | "plumber", 1076 | "pocket", 1077 | "poem", 1078 | "pogo stick", 1079 | "point", 1080 | "poison", 1081 | "pole", 1082 | "police", 1083 | "pollution", 1084 | "pomp", 1085 | "pond", 1086 | "pong", 1087 | "poodle", 1088 | "pool", 1089 | "pop", 1090 | "popcorn", 1091 | "popsicle", 1092 | "population", 1093 | "porch", 1094 | "porcupine", 1095 | "portfolio", 1096 | "porthole", 1097 | "positive", 1098 | "post office", 1099 | "post", 1100 | "postcard", 1101 | "pot", 1102 | "potato", 1103 | "powder", 1104 | "present", 1105 | "president", 1106 | "pretzel", 1107 | "prey", 1108 | "prime meridian", 1109 | "prince", 1110 | "princess", 1111 | "print", 1112 | "printer ink", 1113 | "printer", 1114 | "prize", 1115 | "pro", 1116 | "procrastinate", 1117 | "produce", 1118 | "professor", 1119 | "propeller", 1120 | "propose", 1121 | "protestant", 1122 | "psychologist", 1123 | "publisher", 1124 | "puddle", 1125 | "pulley", 1126 | "pumpkin", 1127 | "punk", 1128 | "puppet", 1129 | "puppy", 1130 | "purse", 1131 | "push", 1132 | "putty", 1133 | "puzzle", 1134 | "quadrant", 1135 | "quadruplets", 1136 | "quarantine", 1137 | "quarter", 1138 | "quartz", 1139 | "queen", 1140 | "quicksand", 1141 | "quiet", 1142 | "quilt", 1143 | "quit", 1144 | "race car", 1145 | "race", 1146 | "radio", 1147 | "radish", 1148 | "raft", 1149 | "rag", 1150 | "railroad", 1151 | "rain", 1152 | "rainbow", 1153 | "rainstorm", 1154 | "rainwater", 1155 | "rake", 1156 | "random", 1157 | "rat", 1158 | "ratchet", 1159 | "rattle", 1160 | "ray", 1161 | "razor", 1162 | "read", 1163 | "ream", 1164 | "receipt", 1165 | "recess", 1166 | "record", 1167 | "recycle", 1168 | "red", 1169 | "refrigerator", 1170 | "regret", 1171 | "reindeer", 1172 | "religion", 1173 | "reservoir", 1174 | "restaurant", 1175 | "retail", 1176 | "retaliate", 1177 | "reveal", 1178 | "rhinoceros", 1179 | "rhythm", 1180 | "rib", 1181 | "ribbon", 1182 | "rice", 1183 | "riddle", 1184 | "right", 1185 | "rim", 1186 | "rind", 1187 | "ring", 1188 | "ringleader", 1189 | "rink", 1190 | "river", 1191 | "road", 1192 | "robe", 1193 | "robin", 1194 | "rock", 1195 | "rocket", 1196 | "rocking chair", 1197 | "rodeo", 1198 | "roller blades", 1199 | "roller coaster", 1200 | "roller", 1201 | "roof", 1202 | "room", 1203 | "roommate", 1204 | "root", 1205 | "rope", 1206 | "rose", 1207 | "round", 1208 | "roundabout", 1209 | "rowboat", 1210 | "rubber", 1211 | "ruby", 1212 | "rudder", 1213 | "rug", 1214 | "run", 1215 | "rung", 1216 | "runoff", 1217 | "runt", 1218 | "rut", 1219 | "sack", 1220 | "sad", 1221 | "saddle", 1222 | "safe", 1223 | "safety goggles", 1224 | "sail", 1225 | "sailboat", 1226 | "salmon", 1227 | "salt and pepper", 1228 | "salt", 1229 | "saltwater", 1230 | "sand", 1231 | "sandal", 1232 | "sandbox", 1233 | "sandcastle", 1234 | "sandpaper", 1235 | "sandwich", 1236 | "sap", 1237 | "sash", 1238 | "satellite", 1239 | "save", 1240 | "saw", 1241 | "saxophone", 1242 | "scale", 1243 | "scar", 1244 | "scarecrow", 1245 | "scared", 1246 | "scarf", 1247 | "school bus", 1248 | "school", 1249 | "science", 1250 | "scientist", 1251 | "scissors", 1252 | "scoundrel", 1253 | "scramble", 1254 | "scream", 1255 | "screwdriver", 1256 | "script", 1257 | "scuba diving", 1258 | "scuff", 1259 | "sea turtle", 1260 | "seahorse", 1261 | "seal", 1262 | "seashell", 1263 | "season", 1264 | "seat", 1265 | "seaweed", 1266 | "seed", 1267 | "seesaw", 1268 | "sentence", 1269 | "sequins", 1270 | "servant", 1271 | "set", 1272 | "shack", 1273 | "shade", 1274 | "shadow", 1275 | "shaft", 1276 | "shake", 1277 | "shallow", 1278 | "shampoo", 1279 | "shape", 1280 | "shark", 1281 | "sheep dog", 1282 | "sheep", 1283 | "sheets", 1284 | "shelf", 1285 | "shelter", 1286 | "sheriff", 1287 | "ship", 1288 | "shipwreck", 1289 | "shirt", 1290 | "shoelace", 1291 | "shopping cart", 1292 | "short", 1293 | "shoulder", 1294 | "shovel", 1295 | "shower curtain", 1296 | "shower", 1297 | "shrew", 1298 | "shrink ray", 1299 | "shrink", 1300 | "sick", 1301 | "sidekick", 1302 | "sidewalk", 1303 | "siesta", 1304 | "sign", 1305 | "signal", 1306 | "silhouette", 1307 | "silverware", 1308 | "singer", 1309 | "sink", 1310 | "sip", 1311 | "sister", 1312 | "sit", 1313 | "skate", 1314 | "skateboard", 1315 | "skating rink", 1316 | "skating", 1317 | "ski goggles", 1318 | "ski lift", 1319 | "ski", 1320 | "skirt", 1321 | "skunk", 1322 | "sky", 1323 | "slam", 1324 | "sled", 1325 | "sleep", 1326 | "sleeping bag", 1327 | "sleeve", 1328 | "slide", 1329 | "sling", 1330 | "slope", 1331 | "slow", 1332 | "slump", 1333 | "smile", 1334 | "smith", 1335 | "smoke", 1336 | "snag", 1337 | "snail", 1338 | "snarl", 1339 | "sneeze", 1340 | "snooze", 1341 | "snore", 1342 | "snow", 1343 | "snowball", 1344 | "snowboarding", 1345 | "snowflake", 1346 | "snuggle", 1347 | "soak", 1348 | "soap", 1349 | "soccer", 1350 | "sock", 1351 | "soda", 1352 | "softball", 1353 | "solar system", 1354 | "somersault", 1355 | "song", 1356 | "soup", 1357 | "space", 1358 | "spaceship", 1359 | "spare", 1360 | "speakers", 1361 | "spear", 1362 | "spell", 1363 | "spider web", 1364 | "spider", 1365 | "spill", 1366 | "spine", 1367 | "spit", 1368 | "sponge", 1369 | "spool", 1370 | "spoon", 1371 | "spot", 1372 | "spring", 1373 | "sprinkler", 1374 | "spy", 1375 | "square", 1376 | "squint", 1377 | "squirrel", 1378 | "stadium", 1379 | "stage fright", 1380 | "stage", 1381 | "stain", 1382 | "stairs", 1383 | "stamp", 1384 | "standing", 1385 | "staple", 1386 | "stapler", 1387 | "star", 1388 | "starfish", 1389 | "start", 1390 | "startup", 1391 | "state", 1392 | "stationery", 1393 | "stay", 1394 | "steam", 1395 | "stem", 1396 | "step", 1397 | "stew", 1398 | "stick", 1399 | "sticky note", 1400 | "stingray", 1401 | "stockholder", 1402 | "stocking", 1403 | "stomach", 1404 | "stoplight", 1405 | "stopwatch", 1406 | "store", 1407 | "stork", 1408 | "storm", 1409 | "story", 1410 | "stout", 1411 | "stove", 1412 | "stow", 1413 | "stowaway", 1414 | "strap", 1415 | "straw", 1416 | "strawberry", 1417 | "stream", 1418 | "streamline", 1419 | "string", 1420 | "stripe", 1421 | "stroller", 1422 | "student", 1423 | "stuffed animal", 1424 | "stump", 1425 | "stutter", 1426 | "submarine", 1427 | "subway", 1428 | "sugar", 1429 | "suit", 1430 | "suitcase", 1431 | "summer", 1432 | "sun", 1433 | "sunburn", 1434 | "sunflower", 1435 | "sunglasses", 1436 | "sunrise", 1437 | "sunset", 1438 | "surfboard", 1439 | "surround", 1440 | "sushi", 1441 | "swamp", 1442 | "swarm", 1443 | "sweater vest", 1444 | "sweater", 1445 | "swimming pool", 1446 | "swimming", 1447 | "swing", 1448 | "swoop", 1449 | "sword", 1450 | "synchronized swimming", 1451 | "table", 1452 | "tablespoon", 1453 | "tachometer", 1454 | "tackle", 1455 | "tadpole", 1456 | "tag", 1457 | "tail", 1458 | "talk", 1459 | "tank", 1460 | "tape", 1461 | "target", 1462 | "taxes", 1463 | "taxi", 1464 | "taxidermist", 1465 | "teacher", 1466 | "team", 1467 | "teapot", 1468 | "tearful", 1469 | "teenager", 1470 | "teeth", 1471 | "telephone booth", 1472 | "telephone", 1473 | "television", 1474 | "ten", 1475 | "tennis", 1476 | "tent", 1477 | "testify", 1478 | "thaw", 1479 | "thermometer", 1480 | "thief", 1481 | "think", 1482 | "third plate", 1483 | "three toed sloth", 1484 | "thrift store", 1485 | "throat", 1486 | "throne", 1487 | "through", 1488 | "thumb", 1489 | "thunder", 1490 | "ticket", 1491 | "tide", 1492 | "tie", 1493 | "tiger", 1494 | "tightrope", 1495 | "time machine", 1496 | "time", 1497 | "timer", 1498 | "tin", 1499 | "tinting", 1500 | "tip", 1501 | "tiptoe", 1502 | "tiptop", 1503 | "tire", 1504 | "tired", 1505 | "tissue", 1506 | "toast", 1507 | "toaster", 1508 | "toddler", 1509 | "toe", 1510 | "toilet paper", 1511 | "toilet", 1512 | "toll road", 1513 | "tongs", 1514 | "tongue", 1515 | "tool", 1516 | "toolbox", 1517 | "tooth", 1518 | "toothbrush", 1519 | "toothpaste", 1520 | "top hat", 1521 | "torch", 1522 | "tornado", 1523 | "tourist", 1524 | "tournament", 1525 | "tow truck", 1526 | "tow", 1527 | "towel", 1528 | "tower", 1529 | "toy store", 1530 | "toy", 1531 | "tractor", 1532 | "traffic jam", 1533 | "trail", 1534 | "train", 1535 | "trampoline", 1536 | "trap", 1537 | "trapeze", 1538 | "trapped", 1539 | "trash can", 1540 | "trash", 1541 | "treasure", 1542 | "tree", 1543 | "triangle", 1544 | "tricycle", 1545 | "trip", 1546 | "trombone", 1547 | "trophy", 1548 | "truck stop", 1549 | "truck", 1550 | "trumpet", 1551 | "trunk", 1552 | "tub", 1553 | "tuba", 1554 | "tugboat", 1555 | "tulip", 1556 | "turkey", 1557 | "turtleneck", 1558 | "tusk", 1559 | "tutor", 1560 | "twang", 1561 | "twig", 1562 | "twist", 1563 | "type", 1564 | "umbrella", 1565 | "unemployed", 1566 | "unicorn", 1567 | "unicycle", 1568 | "unite", 1569 | "university", 1570 | "upgrade", 1571 | "vacation", 1572 | "van", 1573 | "vanilla", 1574 | "vanish", 1575 | "vase", 1576 | "vegetable", 1577 | "vegetarian", 1578 | "vehicle", 1579 | "vest", 1580 | "vet", 1581 | "video camera", 1582 | "violin", 1583 | "vision", 1584 | "vitamin", 1585 | "volcano", 1586 | "volleyball", 1587 | "waffle", 1588 | "wag", 1589 | "wagon", 1590 | "waist", 1591 | "wall", 1592 | "wallet", 1593 | "wallow", 1594 | "washing machine", 1595 | "watch", 1596 | "water buffalo", 1597 | "water cycle", 1598 | "water", 1599 | "waterfall", 1600 | "watering can", 1601 | "watermelon", 1602 | "wave", 1603 | "wax", 1604 | "weather", 1605 | "wedding cake", 1606 | "wedding", 1607 | "wedge", 1608 | "weight", 1609 | "welder", 1610 | "well", 1611 | "whatever", 1612 | "wheelbarrow", 1613 | "wheelchair", 1614 | "wheelie", 1615 | "whiplash", 1616 | "whisk", 1617 | "whistle", 1618 | "white", 1619 | "wick", 1620 | "wig", 1621 | "win", 1622 | "wind", 1623 | "windmill", 1624 | "window", 1625 | "windshield", 1626 | "wing", 1627 | "winter", 1628 | "wish", 1629 | "wobble", 1630 | "wolf", 1631 | "wood", 1632 | "wool", 1633 | "world", 1634 | "worm", 1635 | "wrap", 1636 | "wreath", 1637 | "wreck", 1638 | "wrench", 1639 | "wrist", 1640 | "wristwatch", 1641 | "yacht", 1642 | "yak", 1643 | "yard", 1644 | "yardstick", 1645 | "yarn", 1646 | "yawn", 1647 | "yo yo", 1648 | "yodel", 1649 | "yolk", 1650 | "zamboni", 1651 | "zebra", 1652 | "zen", 1653 | "zero", 1654 | "zipper", 1655 | "zone", 1656 | "zoo", 1657 | "zookeeper", 1658 | "zoom", 1659 | ]; 1660 | --------------------------------------------------------------------------------