library

Online co-op book organization video game
git clone https://git.bradleygannon.com/library.git
Log | Files | Refs

commit 8bd43575af176dcf239b6e1a5ed33d16a67ebffd
parent 05567c02bf2b514629f80628601e7b61691f5aff
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Sun, 30 Aug 2026 18:11:37 -0400

Add ClientState and main menu

Diffstat:
Mclient/src/main.rs | 361++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mclient/src/render.rs | 7++++---
2 files changed, 259 insertions(+), 109 deletions(-)

diff --git a/client/src/main.rs b/client/src/main.rs @@ -1,140 +1,289 @@ use std::{ collections::VecDeque, - sync::mpsc::channel, + net::SocketAddr, + sync::mpsc::{Receiver, Sender, channel}, thread, - time::{Instant, SystemTime}, + time::{Duration, Instant, SystemTime}, }; -use common::game::{Action, ActionKind, GameState, ResponseKind}; -use macroquad::prelude::*; +use common::game::{Action, ActionKind, GameState, Response, ResponseKind}; +use macroquad::{ + prelude::*, + ui::{ + hash, root_ui, + widgets::{Button, InputText, Label}, + }, +}; +use uuid::Uuid; use crate::{ game::handle_movement_input, net::handle_connection, - render::{Camera, SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX, Spritesheet, follow_local_player, render}, + render::{ + Camera, SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX, Spritesheet, follow_local_player, render_game, + }, }; pub mod game; pub mod net; pub mod render; +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +enum ViewMode { + #[default] + Menu, + Game, +} + +#[derive(Debug)] +struct ClientState { + mode: ViewMode, + camera: Camera, + game: GameState, + old_game: GameState, + local_player_id: Option<Uuid>, + sequence_number: u32, + unresponded_actions: VecDeque<Action>, + interpolation_basis: SystemTime, + debug_view: bool, + t0: Instant, + t1: Instant, + dt: Duration, + tx: Option<Sender<Action>>, + rx: Option<Receiver<Response>>, + player_name: String, + server: String, + menu_error: String, +} + +impl Default for ClientState { + fn default() -> Self { + Self { + mode: ViewMode::default(), + camera: Camera::default(), + game: GameState::default(), + old_game: GameState::default(), + local_player_id: None, + sequence_number: 0, + unresponded_actions: VecDeque::new(), + interpolation_basis: SystemTime::now(), + debug_view: cfg!(debug_assertions), + t0: Instant::now(), + t1: Instant::now(), + dt: Duration::ZERO, + tx: None, + rx: None, + player_name: String::new(), + server: String::new(), + menu_error: String::new(), + } + } +} + +impl ClientState { + fn tick(&mut self) { + self.t0 = self.t1; + self.t1 = Instant::now(); + self.dt = self.t1.duration_since(self.t0); + } + fn advance_game_state(&mut self) { + self.old_game = self.game.clone(); + } + fn handle_response(&mut self, response: &Response) { + match response.kind() { + ResponseKind::SetId { id } => { + let action = Action::new( + self.sequence_number, + id, + ActionKind::Join { + name: self.player_name.clone(), + }, + ); + if let Some(ref mut tx) = self.tx + && tx.send(action.clone()).is_err() + { + return; + } + self.unresponded_actions.push_back(action); + self.sequence_number = self.sequence_number.wrapping_add(1); + self.local_player_id = Some(id); + } + ResponseKind::UpdateGameState { updates } => { + for update in updates { + self.game.update(&update); + } + self.game.set_timestamp(response.timestamp()); + } + ResponseKind::ReplaceGameState { new_game_state } => self.game = new_game_state, + }; + } + fn reconcile(&mut self, latest_sequence_number: u32) { + // Server reconciliation: drop any unresponded actions whose sequence numbers are less than + // the number provided + if let Some(new_front_index) = self + .unresponded_actions + .iter() + .position(|ua: &Action| ua.sequence_number() >= latest_sequence_number) + { + for _ in 0..=new_front_index { + self.unresponded_actions.pop_front(); + } + } + } + fn predict(&mut self) { + // Client prediction: apply all unresponded actions to the game state + self.interpolation_basis = SystemTime::now(); + for unresponded_action in self.unresponded_actions.iter() { + self.game.apply(unresponded_action); + } + } + fn interpolate(&self) -> GameState { + GameState::interpolate( + &self.old_game, + &self.game, + self.interpolation_basis, + self.local_player_id, + ) + } +} + #[macroquad::main("Library")] async fn main() { + // One-time startup actions request_new_screen_size(SCREEN_WIDTH_PX, SCREEN_HEIGHT_PX); let spritesheet = Spritesheet::load(); - let (client_to_server_tx, client_to_server_rx) = channel(); - let (server_to_client_tx, server_to_client_rx) = channel(); - thread::spawn(|| { - handle_connection( - "127.0.0.1:6767".parse().unwrap(), - server_to_client_tx, - client_to_server_rx, - ) - }); - - let mut camera = Camera::default(); - let mut game_state = GameState::default(); - let mut old_game_state = GameState::default(); - let mut local_player_id = None; - let mut sequence_number = 0; - let mut unresponded_actions = VecDeque::new(); - let mut interpolation_basis = SystemTime::now(); - let mut debug_view = cfg!(debug_assertions); - let mut t0; - let mut t1 = Instant::now(); - let mut dt; + let mut client = ClientState::default(); + let mut first_frame = true; loop { - t0 = t1; - t1 = Instant::now(); - dt = t1.duration_since(t0); - - let mut response_count: u8 = 0; - let mut latest_sequence_number = 0; - for response in server_to_client_rx.try_iter() { - response_count = response_count.saturating_add(1); - // If this is the first response that we're processing during this tick, then we need to - // copy the current game state so we can interpolate against it below - if response_count == 1 { - old_game_state = game_state.clone(); - } - latest_sequence_number = latest_sequence_number.max(response.sequence_number()); - match response.kind() { - ResponseKind::SetId { id } => { - let action = Action::new( - sequence_number, - id, - ActionKind::Join { - name: id.to_string().chars().take(3).collect(), - }, - ); - if client_to_server_tx.send(action.clone()).is_err() { - return; + match client.mode { + ViewMode::Menu => { + clear_background(MAGENTA); + const ELEMENT_SIZE: Vec2 = Vec2::new(420., 24.); + const NUM_ELEMENTS: usize = 4; + const MENU_SIZE: Vec2 = + Vec2::new(ELEMENT_SIZE.x, ELEMENT_SIZE.y * NUM_ELEMENTS as f32); + const ELEMENT_PITCH: Vec2 = Vec2::new(0., ELEMENT_SIZE.y); + let screen_size = vec2(screen_width(), screen_height()); + let menu_top_left = screen_size / 2. - MENU_SIZE / 2.; + let mut ui = root_ui(); + let name_input_token = hash!(); + InputText::new(name_input_token) + .label("name") + .position(menu_top_left) + .size(ELEMENT_SIZE) + .margin(Vec2::ZERO) + .ui(&mut ui, &mut client.player_name); + InputText::new(hash!()) + .label("server") + .position(menu_top_left) + .size(ELEMENT_SIZE) + .margin(Vec2::ZERO) + .ui(&mut ui, &mut client.server); + let connect_button_clicked = Button::new("connect") + .position(menu_top_left + ELEMENT_PITCH * 2.) + .size(ELEMENT_SIZE) + .ui(&mut ui); + let addr = if connect_button_clicked || is_key_pressed(KeyCode::Enter) { + if let Ok(addr) = client.server.parse::<SocketAddr>() { + Some(addr) + } else if client.player_name.is_empty() { + client.menu_error = "enter your name pls".to_string(); + None + } else { + client.menu_error = "invalid server :-\\".to_string(); + None } - unresponded_actions.push_back(action); - sequence_number = sequence_number.wrapping_add(1); - local_player_id = Some(id); + } else { + None + }; + Label::new(client.menu_error.as_str()) + .position(menu_top_left + ELEMENT_PITCH * 3.) + .size(ELEMENT_SIZE) + .ui(&mut ui); + if first_frame { + ui.set_input_focus(name_input_token); } - ResponseKind::UpdateGameState { updates } => { - for update in updates { - game_state.update(&update); + if let Some(addr) = addr { + let (client_to_server_tx, client_to_server_rx) = channel(); + let (server_to_client_tx, server_to_client_rx) = channel(); + thread::spawn(move || { + handle_connection(addr, server_to_client_tx, client_to_server_rx) + }); + client.tx = Some(client_to_server_tx); + client.rx = Some(server_to_client_rx); + client.mode = ViewMode::Game; + } + } + ViewMode::Game => { + // Update frame timer so we move the player at the right speed + client.tick(); + + // Process any new responses from the server + let mut response_count: u8 = 0; + let mut latest_sequence_number = 0; + if let Some(ref rx) = client.rx { + for response in rx.try_iter().collect::<Vec<Response>>() { + response_count = response_count.saturating_add(1); + // If this is the first response that we're processing during this tick, + // then we need to copy the current game state so we can interpolate against + // it below + if response_count == 1 { + client.advance_game_state(); + } + client.handle_response(&response); + latest_sequence_number = + latest_sequence_number.max(response.sequence_number()); } - game_state.set_timestamp(response.timestamp()); } - ResponseKind::ReplaceGameState { new_game_state } => game_state = new_game_state, - }; - } - // If there were any server responses, then do server reconciliation and client prediction - if response_count > 0 { - // Server reconciliation: drop any unresponded actions whose sequence numbers are less - // than the number we just got from the server - if let Some(new_front_index) = unresponded_actions - .iter() - .position(|ua: &Action| ua.sequence_number() >= latest_sequence_number) - { - for _ in 0..=new_front_index { - unresponded_actions.pop_front(); + + // If there were any server responses, then do server reconciliation and client + // prediction + if response_count > 0 { + client.reconcile(latest_sequence_number); + client.predict(); } - } - // Client prediction: reapply the remaining unresponded actions to the latest server - // game state - interpolation_basis = SystemTime::now(); - for unresponded_action in unresponded_actions.iter() { - game_state.apply(unresponded_action); - } - } - if let Some(id) = local_player_id - && let Some(local_player) = game_state.get_player_mut(&id) - { - if let Some(action_kind) = handle_movement_input(local_player, &dt) { - let action = Action::new(sequence_number, id, action_kind); - game_state.apply(&action); - if client_to_server_tx.send(action.clone()).is_err() { - return; + if let Some(id) = client.local_player_id + && let Some(local_player) = client.game.get_player_mut(&id) + { + // Send movement actions if requested + if let Some(action_kind) = handle_movement_input(local_player, &client.dt) + && let Some(ref mut tx) = client.tx + { + let action = Action::new(client.sequence_number, id, action_kind); + client.game.apply(&action); + if tx.send(action.clone()).is_err() { + return; + } + client.unresponded_actions.push_back(action); + client.sequence_number = client.sequence_number.wrapping_add(1); + } + + // Center local player on the screen + follow_local_player(&mut client.camera, &client.game, &spritesheet, &id); } - unresponded_actions.push_back(action); - sequence_number = sequence_number.wrapping_add(1); + + // Entity interpolation + let interpolated_game = client.interpolate(); + + // Toggle debug view if requested + if is_key_pressed(KeyCode::F3) { + client.debug_view = !client.debug_view; + } + + // Draw the frame + render_game( + &client.camera, + &interpolated_game, + &spritesheet, + client.debug_view, + client.local_player_id, + ); } - follow_local_player(&mut camera, &game_state, &spritesheet, &id); - } - let interpolated_game_state = GameState::interpolate( - &old_game_state, - &game_state, - interpolation_basis, - local_player_id, - ); - if is_key_pressed(KeyCode::F3) { - debug_view = !debug_view; } - render( - &camera, - &interpolated_game_state, - &spritesheet, - debug_view, - local_player_id, - ); + first_frame = false; next_frame().await; } } diff --git a/client/src/render.rs b/client/src/render.rs @@ -7,7 +7,7 @@ use uuid::Uuid; pub const SCREEN_WIDTH_PX: f32 = 1280.; pub const SCREEN_HEIGHT_PX: f32 = 720.; -#[derive(Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub(crate) struct Camera { position: Vec2, } @@ -81,7 +81,7 @@ impl Index<SpriteIndex> for Spritesheet { } } -pub(crate) fn render( +pub(crate) fn render_game( camera: &Camera, game: &GameState, spritesheet: &Spritesheet, @@ -108,9 +108,10 @@ pub(crate) fn render( for player in game.players().values() { let position_camera = player.position() - camera.position; player_sprite.render(position_camera); + let name_plate_dimensions = measure_text(player.name().clone(), None, 16, 1.); draw_text( player.name().clone(), - position_camera.x + 12., + position_camera.x + player_sprite.size.x / 2. - name_plate_dimensions.width / 2., position_camera.y + player_sprite.size.y + 16., 16., BLACK,