working gameplay loop, better UI. Needs a bit of work

This commit is contained in:
Joey Hines 2025-05-25 15:37:20 -06:00
parent abb3f4c2da
commit 457d6cee96
Signed by: joeyahines
GPG Key ID: 38BA6F25C94C9382
11 changed files with 357 additions and 72 deletions

View File

@ -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)
}

View File

@ -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)
}
}

35
frontend/src/font.rs Normal file
View File

@ -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)
}
}

View File

@ -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<u64, Player> },
UpdatePlayerStates { players: HashMap<String, Player> },
}
#[derive(Default)]
pub struct HostScreen {
players: HashMap<u64, Player>,
players: HashMap<String, Player>,
}
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<String> {
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<String> = 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;
}
};

View File

@ -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 {

View File

@ -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
}
}

View File

@ -5,4 +5,5 @@ pub struct Player {
pub name: String,
pub id: u64,
pub score: i32,
pub buzz_in_time: Option<f64>,
}

View File

@ -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,
);

View File

@ -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)
}

View File

@ -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:))
}

View File

@ -1,5 +0,0 @@
import score_update
pub type UpdateScore {
BuzzIn(user_id: Int, score_update: score_update.ScoreUpdateType)
}