From 457d6cee965b5452abc28e50b764c0336162f7b4 Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Sun, 25 May 2025 15:37:20 -0600 Subject: [PATCH] working gameplay loop, better UI. Needs a bit of work --- backend/src/app/router.gleam | 1 + backend/src/app/web.gleam | 90 +++++++++++++++++++++- frontend/src/font.rs | 35 +++++++++ frontend/src/host_screen.rs | 122 ++++++++++++++++++++++++++---- frontend/src/login_screen.rs | 88 +++++++++++---------- frontend/src/main.rs | 44 ++++++++++- frontend/src/model/player.rs | 1 + frontend/src/player_screen.rs | 20 ++++- frontend/src/ui_scaling.rs | 5 ++ shared/src/player.gleam | 18 +++-- shared/src/requests/buzz_in.gleam | 5 -- 11 files changed, 357 insertions(+), 72 deletions(-) create mode 100644 frontend/src/font.rs create mode 100644 frontend/src/ui_scaling.rs delete mode 100644 shared/src/requests/buzz_in.gleam diff --git a/backend/src/app/router.gleam b/backend/src/app/router.gleam index abdd3d3..778d970 100644 --- a/backend/src/app/router.gleam +++ b/backend/src/app/router.gleam @@ -100,6 +100,7 @@ fn handle_ws_message( actor.continue(state) } mist.Custom(resp) -> { + echo resp let assert Ok(_) = mist.send_text_frame(conn, resp) actor.continue(state) } diff --git a/backend/src/app/web.gleam b/backend/src/app/web.gleam index 13be2fb..ec76291 100644 --- a/backend/src/app/web.gleam +++ b/backend/src/app/web.gleam @@ -11,6 +11,7 @@ import gleam/int import gleam/io import gleam/json import gleam/list +import gleam/option.{None, Some} import gleam/otp/actor import player import player_session @@ -53,6 +54,32 @@ pub fn handle_client_msg( } } +fn set_buzz_in( + socket_state: SocketState, + player_state: player_client.PlayerState, + time: Float, +) { + let key = storail.key(socket_state.ctx.sessions, player_state.game_id) + let assert Ok(session) = storail.read(key) + + let players = + dict.upsert(session.players, player_state.user_id, fn(player) { + let assert Some(player) = player + + player.Player(..player, buzz_in_time: Some(time)) + }) + + let session = session.Session(..session, players:) + + let assert Ok(_) = storail.write(key, session) + + send_message_to_host( + socket_state, + session.host_user_id, + host_client.UpdatePlayerStates(session.players), + ) +} + fn handle_player( socket_state: SocketState, player_state: player_client.PlayerState, @@ -69,6 +96,9 @@ fn handle_player( <> " at " <> float.to_string(time), ) + + set_buzz_in(socket_state, player_state, time) + #( socket_state, json.to_string(player_client.encode_player_server_messages( @@ -143,13 +173,20 @@ fn handle_new_user( let key = storail.key(socket_state.ctx.sessions, game_code) let assert Ok(session) = storail.read(key) - let player = player.Player(name: username, id: user_id, score: 0) + let player = + player.Player(name: username, id: user_id, score: 0, buzz_in_time: None) let session = session.Session( ..session, players: dict.insert(session.players, player.id, player), ) + send_message_to_host( + socket_state, + session.host_user_id, + host_client.UpdatePlayerStates(session.players), + ) + let assert Ok(Nil) = storail.write(key, session) #( @@ -157,7 +194,7 @@ fn handle_new_user( ..socket_state, state: Player(player_client.PlayerState(user_id, game_code)), ), - new_user_client.RegisterResponse(token, username), + new_user_client.RegisterResponse(username, token), ) } } @@ -212,6 +249,41 @@ fn send_message_to_player( |> actor.send(json.to_string(client_msg)) } +fn send_message_to_host( + socket_state: SocketState, + id: Int, + msg: host_client.HostServerMessages, +) { + let client_msg = host_client.encode_host_server_messages(msg) + + get_player_session_from_id(socket_state.ctx, id) + |> get_player_session_subject(socket_state.ctx, _) + |> actor.send(json.to_string(client_msg)) +} + +fn reset_player_buzzer( + socket_state: SocketState, + session_key: storail.Key(session.Session), + id: Int, +) { + let assert Ok(session) = storail.read(session_key) + + let assert Ok(player) = dict.get(session.players, id) + let player = player.Player(..player, buzz_in_time: None) + + let players = dict.insert(session.players, id, player) + + let session = session.Session(..session, players: players) + + let assert Ok(_) = storail.write(session_key, session) + + send_message_to_host( + socket_state, + session.host_user_id, + host_client.UpdatePlayerStates(session.players), + ) +} + fn handle_host( socket_state: SocketState, host_state: host_client.HostState, @@ -225,6 +297,11 @@ fn handle_host( let #(socket_state, resp) = case msg { host_client.ResetAllBuzers -> { + dict.to_list(session.players) + |> list.each(fn(player_entry) { + reset_player_buzzer(socket_state, session_key, player_entry.0) + }) + broadcast_message_to_players( socket_state, session, @@ -233,12 +310,13 @@ fn handle_host( #(socket_state, host_client.Ack) } host_client.ResetPlayerBuzzer(id) -> { + reset_player_buzzer(socket_state, session_key, id) send_message_to_player(socket_state, id, player_client.ResetBuzzer) #(socket_state, host_client.Ack) } host_client.UpdateScore(id, score) -> { let assert Ok(player) = dict.get(session.players, id) - let player = player.Player(..player, score:) + let player = player.Player(..player, score: player.score + score) let session = session.Session( ..session, @@ -252,6 +330,12 @@ fn handle_host( player_client.UpdatePointTotal(player.score), ) + send_message_to_host( + socket_state, + session.host_user_id, + host_client.UpdatePlayerStates(session.players), + ) + #(socket_state, host_client.Ack) } } diff --git a/frontend/src/font.rs b/frontend/src/font.rs new file mode 100644 index 0000000..d5514af --- /dev/null +++ b/frontend/src/font.rs @@ -0,0 +1,35 @@ +use macroquad::{ + text::{TextDimensions, measure_text}, + window::screen_width, +}; + +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub enum FontSize { + Small, + Medium, + Large, +} + +impl FontSize { + pub fn size(&self) -> u16 { + let size1 = measure_text("A", None, 10, 1.0); + let size2 = measure_text("A", None, 20, 1.0); + + let scale_factor = size2.width - size1.width / 10.0; + + let offset = size2.width - (10.0 * scale_factor); + + let base_font_size = screen_width() * 0.01 - offset / scale_factor; + + (match self { + FontSize::Small => base_font_size, + FontSize::Medium => base_font_size * 1.0, + FontSize::Large => base_font_size * 2.0, + }) as u16 + } + + pub fn measure(&self, text: &str) -> TextDimensions { + measure_text(text, None, self.size(), 1.0) + } +} diff --git a/frontend/src/host_screen.rs b/frontend/src/host_screen.rs index 4eff13f..48d2418 100644 --- a/frontend/src/host_screen.rs +++ b/frontend/src/host_screen.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; -use macroquad::prelude::*; +use macroquad::{ + prelude::*, + ui::{Skin, Ui, hash, root_ui, widgets::Window}, +}; use serde::{Deserialize, Serialize}; -use crate::{model::player::Player, screen::Screen}; +use crate::{font::FontSize, model::player::Player, screen::Screen, ui_scaling::window_width}; #[allow(dead_code)] #[derive(Serialize)] @@ -11,35 +14,121 @@ use crate::{model::player::Player, screen::Screen}; enum HostClientMessages { ResetAllBuzers, ResetPlayerBuzzer { user_id: u64 }, - UpdateScore { user_id: u64, diff: u32 }, + UpdateScore { user_id: u64, diff: i32 }, } #[derive(Deserialize)] #[serde(tag = "type")] enum HostServerMessages { Ack, - UpdatePlayerStates { players: HashMap }, + UpdatePlayerStates { players: HashMap }, } #[derive(Default)] pub struct HostScreen { - players: HashMap, + players: HashMap, +} + +impl HostScreen { + fn draw_user(&self, ctx: &mut crate::context::Context, user_id: &str, ui: &mut Ui, width: f32) { + let player = &self.players[user_id]; + let size = Vec2::new(width, screen_height() * 0.05); + + let first_buzzed_in_player = self.get_first_buzzed_in_player(); + + let is_first = if let Some(first_player) = first_buzzed_in_player { + first_player == user_id + } else { + false + }; + + ui.group(hash!(user_id), size, |ui| { + let player_txt = player.name.clone(); + let player_txt_size = FontSize::Medium.measure(&player_txt); + + let score_txt = format!("Score: {}", player.score); + ui.label(Vec2::new(0.0, 0.0), &player_txt); + ui.label( + Vec2::new(0.0, player_txt_size.height + size.y * 0.025), + &score_txt, + ); + + if is_first { + ui.label(Vec2::new(size.x * 0.45, 0.0), "BUZZED IN"); + } + + if ui.button(Vec2::new(size.x * 0.70, 0.0), "+") { + let msg = HostClientMessages::UpdateScore { + user_id: user_id.parse().unwrap(), + diff: 1, + }; + ctx.send_msg(&msg); + } + if ui.button(Vec2::new(size.x * 0.80, 0.0), "-") { + let msg = HostClientMessages::UpdateScore { + user_id: user_id.parse().unwrap(), + diff: -1, + }; + ctx.send_msg(&msg); + } + if ui.button(Vec2::new(size.x * 0.90, 0.0), "C") { + let msg = HostClientMessages::ResetPlayerBuzzer { + user_id: user_id.parse().unwrap(), + }; + ctx.send_msg(&msg); + } + }); + } + + fn menu_skin() -> Skin { + let label_style = root_ui() + .style_builder() + .font_size(FontSize::Medium.size()) + .text_color(WHITE) + .build(); + + Skin { + label_style, + ..root_ui().default_skin() + } + } + + fn get_first_buzzed_in_player(&self) -> Option { + self.players + .iter() + .filter_map(|(id, player)| player.buzz_in_time.map(|buzz_in| (id, buzz_in))) + .min_by(|(_, buzz_in), (_, other_buzz_in)| buzz_in.total_cmp(other_buzz_in)) + .map(|(id, _)| id) + .cloned() + } } impl Screen for HostScreen { fn handle_frame(&mut self, ctx: &mut crate::context::Context) { - clear_background(WHITE); + clear_background(BEIGE); - draw_text( - "I'm the host!", - screen_width() / 2.0, - screen_height() / 2.0, - 100.0, - BLACK, - ); + let width = window_width(); - let button_size = screen_width() * 0.01; - let button_location = Vec2::new(screen_width() * 0.50, screen_height() * 0.70); + let menu_skin = &Self::menu_skin(); + root_ui().push_skin(menu_skin); + + let window_position = + Vec2::new(screen_width() * 0.5 - (width * 0.5), screen_height() * 0.1); + let window_size = Vec2::new(width, screen_height() * 0.70); + + Window::new(hash!("HostWindow"), window_position, window_size) + .movable(false) + .ui(&mut root_ui(), |ui| { + let mut players: Vec = self.players.keys().cloned().collect(); + players.sort(); + + for user_id in &players { + self.draw_user(ctx, user_id, ui, width); + } + }); + + let button_size = screen_width() * 0.025; + let button_location = Vec2::new(screen_width() * 0.50, screen_height() * 0.90); draw_circle( button_location.x, @@ -56,6 +145,8 @@ impl Screen for HostScreen { ctx.send_msg(&HostClientMessages::ResetAllBuzers); } } + + root_ui().pop_skin(); } fn handle_messages( @@ -68,6 +159,7 @@ impl Screen for HostScreen { // pass } HostServerMessages::UpdatePlayerStates { players } => { + info!("Got new player state: {:?}", players); self.players = players; } }; diff --git a/frontend/src/login_screen.rs b/frontend/src/login_screen.rs index 5d3dc30..3662ed7 100644 --- a/frontend/src/login_screen.rs +++ b/frontend/src/login_screen.rs @@ -1,7 +1,10 @@ use crate::StateTransition; use crate::context::Context; +use crate::font::FontSize; use crate::screen::Screen; +use crate::ui_scaling::window_width; use macroquad::hash; +use macroquad::ui::widgets::Window; use macroquad::{prelude::*, ui::root_ui}; use serde::{Deserialize, Serialize}; @@ -31,56 +34,65 @@ pub struct LoginScreen { impl Screen for LoginScreen { fn handle_frame(&mut self, ctx: &mut Context) { - clear_background(WHITE); + clear_background(BEIGE); - let group_size = Vec2::new(screen_width() * 0.25, screen_height() * 0.05); - let window_size = Vec2::new(screen_width() * 0.25, screen_height() * 0.25); + let group_size = Vec2::new(window_width(), screen_height() * 0.05); + let window_size = Vec2::new(window_width(), screen_height() * 0.4); let mut pressed_join = false; let mut pressed_host = false; - root_ui().window( + Window::new( hash!("Window"), Vec2::new( screen_width() * 0.5 - window_size.x * 0.5, screen_height() * 0.5 - window_size.x * 0.5, ), - Vec2::new(screen_width() * 0.25, screen_height() * 0.25), - |ui| { - ui.group(hash!("User Group"), group_size, |ui| { - ui.input_text(hash!("Username In"), "Username", &mut self.new_username); - }); - ui.group(hash!("Room Group"), group_size, |ui| { - ui.input_text(hash!("Room Code In"), "Room Code", &mut self.new_room_code); - }); - ui.group(hash!("Join Group"), group_size, |ui| { - if ui.button(Vec2::new(group_size.x * 0.45, 0.0), "Join") { - info!( - "User pressed joined with name={} room_code={}", - self.new_username, self.new_room_code - ); + window_size, + ) + .movable(false) + .ui(&mut root_ui(), |ui| { + ui.group(hash!("User Group"), group_size, |ui| { + ui.input_text(hash!("Username In"), "Username", &mut self.new_username); + }); + ui.group(hash!("Room Group"), group_size, |ui| { + ui.input_text(hash!("Room Code In"), "Room Code", &mut self.new_room_code); + }); + ui.group(hash!("Join Group"), group_size, |ui| { + let dim = FontSize::Large.measure("Join"); + if ui.button( + Vec2::new(group_size.x * 0.50, 0.0) - Vec2::new(dim.width * 0.5, 0.0), + "Join", + ) { + info!( + "User pressed joined with name={} room_code={}", + self.new_username, self.new_room_code + ); - pressed_join = true; - } - }); - ui.group(hash!("Host Group"), group_size, |ui| { - if ui.button(Vec2::new(group_size.x * 0.45, 0.0), "Host Game") { - info!( - "User pressed host with name={} room_code={}", - self.new_username, self.new_room_code - ); - - pressed_host = true; - } - }); - - if self.error_msg { - ui.group(hash!("Error Group"), group_size, |ui| { - ui.label(Vec2::new(0.0, 0.0), "Failed to join game, try again..."); - }); + pressed_join = true; } - }, - ); + }); + ui.group(hash!("Host Group"), group_size, |ui| { + let dim = FontSize::Large.measure("Host Game"); + if ui.button( + Vec2::new(group_size.x * 0.50, 0.0) - Vec2::new(dim.width * 0.5, 0.0), + "Host Game", + ) { + info!( + "User pressed host with name={} room_code={}", + self.new_username, self.new_room_code + ); + + pressed_host = true; + } + }); + + if self.error_msg { + ui.group(hash!("Error Group"), group_size, |ui| { + ui.label(Vec2::new(0.0, 0.0), "Failed to join game, try again..."); + }); + } + }); if pressed_join { ctx.send_msg(&NewUserClientMessages::Register { diff --git a/frontend/src/main.rs b/frontend/src/main.rs index c674698..2dcdd57 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -1,16 +1,22 @@ use context::Context; +use font::FontSize; use host_screen::HostScreen; use login_screen::LoginScreen; -use macroquad::prelude::*; +use macroquad::{ + prelude::*, + ui::{Skin, root_ui}, +}; use player_screen::PlayerScreen; use screen::Screen; mod context; +mod font; mod host_screen; mod login_screen; mod model; mod player_screen; mod screen; +mod ui_scaling; pub enum State { Init, @@ -58,6 +64,38 @@ pub enum StateTransition { LeaveGame, } +pub fn default_skin() -> Skin { + let label_style = root_ui() + .style_builder() + .font_size(FontSize::Large.size()) + .text_color(WHITE) + .build(); + let editbox_style = root_ui() + .style_builder() + .font_size(FontSize::Large.size()) + .color(BROWN) + .color_selected(BROWN) + .color_clicked(BROWN) + .text_color(WHITE) + .build(); + let window_style = root_ui().style_builder().color(DARKBROWN).build(); + let button_style = root_ui() + .style_builder() + .font_size(FontSize::Large.size()) + .color(GOLD) + .text_color(BLACK) + .color_hovered(YELLOW) + .build(); + + Skin { + label_style, + editbox_style, + button_style, + window_style, + ..root_ui().default_skin() + } +} + #[macroquad::main("Play of the Game")] async fn main() { let mut context = Context::new("ws://127.0.0.1:8080/ws"); @@ -66,12 +104,16 @@ async fn main() { state.transition_state(StateTransition::Init); loop { + let skin = default_skin(); + root_ui().push_skin(&skin); + if let Some(transition) = state.handle_messages(&mut context) { state = state.transition_state(transition); } state.handle_frame(&mut context); + root_ui().pop_skin(); next_frame().await } } diff --git a/frontend/src/model/player.rs b/frontend/src/model/player.rs index 9f580b6..1f95870 100644 --- a/frontend/src/model/player.rs +++ b/frontend/src/model/player.rs @@ -5,4 +5,5 @@ pub struct Player { pub name: String, pub id: u64, pub score: i32, + pub buzz_in_time: Option, } diff --git a/frontend/src/player_screen.rs b/frontend/src/player_screen.rs index ff084cf..8b34eaa 100644 --- a/frontend/src/player_screen.rs +++ b/frontend/src/player_screen.rs @@ -3,6 +3,7 @@ use web_time::{SystemTime, UNIX_EPOCH}; use crate::StateTransition; use crate::context::Context; +use crate::font::FontSize; use crate::screen::Screen; use macroquad::prelude::*; @@ -59,11 +60,24 @@ impl Screen for PlayerScreen { RED }; + let text_measure = FontSize::Large.measure(&ctx.username); + draw_text( - &format!("Score: {}", self.score), - screen_width() * 0.5, + &ctx.username, + screen_width() * 0.5 - text_measure.width * 0.5, + screen_height() * 0.15, + FontSize::Large.size() as f32, + BLACK, + ); + + let score_text = format!("Score: {}", self.score); + let text_measure = FontSize::Large.measure(&score_text); + + draw_text( + &score_text, + screen_width() * 0.5 - text_measure.width * 0.5, screen_height() * 0.25, - 50.0, + FontSize::Large.size() as f32, BLACK, ); diff --git a/frontend/src/ui_scaling.rs b/frontend/src/ui_scaling.rs new file mode 100644 index 0000000..c023243 --- /dev/null +++ b/frontend/src/ui_scaling.rs @@ -0,0 +1,5 @@ +use macroquad::window::{screen_height, screen_width}; + +pub fn window_width() -> f32 { + (screen_width() * 0.8).min(screen_height() * 0.5) +} diff --git a/shared/src/player.gleam b/shared/src/player.gleam index 902f631..f52f1ef 100644 --- a/shared/src/player.gleam +++ b/shared/src/player.gleam @@ -1,8 +1,9 @@ import gleam/dynamic/decode import gleam/json +import gleam/option pub type Player { - Player(name: String, id: Int, score: Int) + Player(name: String, id: Int, score: Int, buzz_in_time: option.Option(Float)) } pub fn serialize(player: Player) -> json.Json { @@ -10,14 +11,17 @@ pub fn serialize(player: Player) -> json.Json { #("name", json.string(player.name)), #("id", json.int(player.id)), #("score", json.int(player.score)), + #("buzz_in_time", json.nullable(player.buzz_in_time, json.float)), ]) } pub fn decoder() -> decode.Decoder(Player) { - { - use name <- decode.field("name", decode.string) - use id <- decode.field("id", decode.int) - use score <- decode.field("score", decode.int) - decode.success(Player(name:, id:, score:)) - } + use name <- decode.field("name", decode.string) + use id <- decode.field("id", decode.int) + use score <- decode.field("score", decode.int) + use buzz_in_time <- decode.field( + "buzz_in_time", + decode.optional(decode.float), + ) + decode.success(Player(name:, id:, score:, buzz_in_time:)) } diff --git a/shared/src/requests/buzz_in.gleam b/shared/src/requests/buzz_in.gleam deleted file mode 100644 index 9fe5444..0000000 --- a/shared/src/requests/buzz_in.gleam +++ /dev/null @@ -1,5 +0,0 @@ -import score_update - -pub type UpdateScore { - BuzzIn(user_id: Int, score_update: score_update.ScoreUpdateType) -}