library

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

commit 19f210b2ade5aa6bb0b39655dd6a7702c80bb57b
parent 3d2eb959be7b8a3786299e02f62dcffc3259bbc0
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Mon,  7 Sep 2026 20:44:55 -0400

Allow players to put/take books on/off shelves

Diffstat:
Dclient/src/game/mod.rs | 138-------------------------------------------------------------------------------
Dclient/src/game/run.rs | 163-------------------------------------------------------------------------------
Dclient/src/game/state.rs | 77-----------------------------------------------------------------------------
Aclient/src/input.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mclient/src/main.rs | 40+++++++++++++++++++++++++++++++++++++---
Mclient/src/render.rs | 302++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Aclient/src/run.rs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/src/state.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/game/book.rs | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcommon/src/game/mod.rs | 28+++++++++++++++++++++++-----
Mcommon/src/game/state.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
11 files changed, 803 insertions(+), 518 deletions(-)

diff --git a/client/src/game/mod.rs b/client/src/game/mod.rs @@ -1,138 +0,0 @@ -use std::{ - collections::VecDeque, - sync::mpsc::{Receiver, Sender}, - time::{Duration, Instant, SystemTime}, -}; - -use crate::render::Camera; -use common::game::{Action, ActionKind, Player, Response, book::FloorBook, state::GameState}; -use macroquad::prelude::*; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -mod run; -mod state; - -const CONFY_APP_NAME: &str = "library-game"; -const CONFY_CONFIG_NAME: &str = "config"; - -#[derive(Debug)] -pub(crate) 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>>, - config: Config, - menu_error: String, - first_frame: bool, - selected_book: usize, -} - -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, - config: confy::load(CONFY_APP_NAME, Some(CONFY_CONFIG_NAME)).unwrap_or_default(), - menu_error: String::new(), - first_frame: true, - selected_book: 0, - } - } -} - -pub(crate) fn handle_movement_input(local_player: &Player, dt: &Duration) -> Option<ActionKind> { - let velocity_x = if is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) { - -GameState::MAX_PLAYER_SPEED_PX_S - } else if is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) { - GameState::MAX_PLAYER_SPEED_PX_S - } else { - 0. - }; - let velocity_y = if is_key_down(KeyCode::W) || is_key_down(KeyCode::Up) { - -GameState::MAX_PLAYER_SPEED_PX_S - } else if is_key_down(KeyCode::S) || is_key_down(KeyCode::Down) { - GameState::MAX_PLAYER_SPEED_PX_S - } else { - 0. - }; - if velocity_x != 0. || velocity_y != 0. { - Some(ActionKind::Move { - new_position: local_player.position() - + Vec2::new(velocity_x, velocity_y) * dt.as_secs_f32(), - }) - } else { - None - } -} - -pub(crate) fn handle_pick_up_book_input( - local_player: &Player, - floor_books: &[FloorBook], -) -> Option<ActionKind> { - if is_key_pressed(KeyCode::Space) { - let local_player_collider = local_player - .collider() - .rect() - .offset(local_player.position()); - if let Some(floor_book) = floor_books.iter().find(|fb| { - fb.collider() - .rect() - .offset(fb.position()) - .overlaps(&local_player_collider) - }) { - return Some(ActionKind::PickUpBook { - book_id: floor_book.id(), - }); - } - } - None -} - -pub(crate) fn handle_drop_book_input( - local_player: &Player, - selected_book_index: usize, -) -> Option<ActionKind> { - if is_key_pressed(KeyCode::Q) - && let Some(book_id) = local_player.books().get(selected_book_index) - { - Some(ActionKind::DropBook { book_id: *book_id }) - } else { - None - } -} - -#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -struct Config { - player_name: String, - server: String, -} - -#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub(crate) enum ViewMode { - #[default] - Menu, - Game, -} diff --git a/client/src/game/run.rs b/client/src/game/run.rs @@ -1,163 +0,0 @@ -use std::{net::SocketAddr, sync::mpsc::channel, thread}; - -use crate::{ - game::{ - CONFY_APP_NAME, CONFY_CONFIG_NAME, ClientState, ViewMode, handle_drop_book_input, - handle_movement_input, handle_pick_up_book_input, - }, - net::handle_connection, - render::{follow_local_player, render_game}, -}; - -use common::game::{Action, Response}; -use macroquad::{ - prelude::*, - ui::{ - hash, root_ui, - widgets::{Button, InputText, Label}, - }, -}; - -impl ClientState { - pub(crate) fn run_menu_frame(&mut self) { - 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 self.config.player_name); - InputText::new(hash!()) - .label("server") - .position(menu_top_left) - .size(ELEMENT_SIZE) - .margin(Vec2::ZERO) - .ui(&mut ui, &mut self.config.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) = self.config.server.parse::<SocketAddr>() { - Some(addr) - } else if self.config.player_name.is_empty() { - self.menu_error = "enter your name pls".to_string(); - None - } else { - self.menu_error = "invalid server :-\\".to_string(); - None - } - } else { - None - }; - Label::new(self.menu_error.as_str()) - .position(menu_top_left + ELEMENT_PITCH * 3.) - .size(ELEMENT_SIZE) - .ui(&mut ui); - if self.first_frame { - ui.set_input_focus(name_input_token); - self.first_frame = false; - } - if let Some(addr) = addr { - let _ = confy::store(CONFY_APP_NAME, Some(CONFY_CONFIG_NAME), self.config.clone()); - 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) - }); - self.tx = Some(client_to_server_tx); - self.rx = Some(server_to_client_rx); - self.mode = ViewMode::Game; - } - } - pub(crate) fn run_game_frame(&mut self) { - // Update frame timer so we move the player at the right speed - self.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) = self.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 { - self.advance_game_state(); - } - self.handle_response(&response); - latest_sequence_number = latest_sequence_number.max(response.sequence_number()); - } - } - - // If there were any server responses, then do server reconciliation and client - // prediction - if response_count > 0 { - self.reconcile(latest_sequence_number); - self.predict(); - } - - if let Some(id) = self.local_player_id - && let Some(local_player) = self.game.get_player(&id).cloned() - { - // Collect and send actions based on user input this frame - let mut new_actions = [None, None, None]; - - new_actions[0] = handle_movement_input(&local_player, &self.dt); - new_actions[1] = handle_pick_up_book_input(&local_player, self.game.floor_books()); - new_actions[2] = handle_drop_book_input(&local_player, self.selected_book); - - for new_action in new_actions.iter().filter_map(|na| na.clone()) { - let action = Action::new(self.sequence_number, id, new_action); - self.game.apply_action(&action); - self.send_action(&action); - } - - // Center local player on the screen - follow_local_player(&mut self.camera, &self.game, &id); - - // Entity interpolation - let interpolated_game = self.interpolate(); - - // Toggle debug view if requested - if is_key_pressed(KeyCode::F3) { - self.debug_view = !self.debug_view; - } - - // Set selected book - let books_count = local_player.books().len(); - if self.selected_book > books_count.saturating_sub(1) { - self.selected_book = books_count.saturating_sub(1); - } - if is_key_pressed(KeyCode::Key1) { - self.selected_book = 0; - } else if is_key_pressed(KeyCode::Key2) { - self.selected_book = 1; - } else if is_key_pressed(KeyCode::Key3) { - self.selected_book = 2; - } else if is_key_pressed(KeyCode::Key4) { - self.selected_book = 3; - } else if is_key_pressed(KeyCode::Key5) { - self.selected_book = 4; - } - - // Draw the frame - render_game( - &self.camera, - &interpolated_game, - self.debug_view, - &id, - self.selected_book, - ); - } - } -} diff --git a/client/src/game/state.rs b/client/src/game/state.rs @@ -1,77 +0,0 @@ -use std::time::{Instant, SystemTime}; - -use common::game::{Action, ActionKind, Response, ResponseKind, state::GameState}; -use structdiff::StructDiff; - -use crate::game::{ClientState, ViewMode}; - -impl ClientState { - pub(crate) fn view_mode(&self) -> ViewMode { - self.mode - } - pub(crate) fn tick(&mut self) { - self.t0 = self.t1; - self.t1 = Instant::now(); - self.dt = self.t1.duration_since(self.t0); - } - pub(crate) fn advance_game_state(&mut self) { - self.old_game = self.game.clone(); - } - pub(crate) fn send_action(&mut self, action: &Action) { - if let Some(ref mut tx) = self.tx - && tx.send(action.clone()).is_err() - { - return; - } - self.unresponded_actions.push_back(action.clone()); - self.sequence_number = self.sequence_number.wrapping_add(1); - } - pub(crate) 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.config.player_name.clone(), - }, - ); - self.send_action(&action); - self.local_player_id = Some(id); - } - ResponseKind::UpdateGameState { diffs } => { - self.game.set_timestamp(response.timestamp()); - self.game.apply_mut(diffs); - } - ResponseKind::ReplaceGameState { new_game_state } => self.game = new_game_state, - }; - } - pub(crate) 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(); - } - } - } - pub(crate) 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_action(unresponded_action); - } - } - pub(crate) fn interpolate(&self) -> GameState { - GameState::interpolate( - &self.old_game, - &self.game, - self.interpolation_basis, - self.local_player_id, - ) - } -} diff --git a/client/src/input.rs b/client/src/input.rs @@ -0,0 +1,127 @@ +use std::{collections::HashMap, time::Duration}; + +use common::game::{ + ActionKind, Player, + book::{Bookshelf, BookshelfId, FloorBook, Slot, SlotAddress}, + state::GameState, +}; +use macroquad::prelude::*; + +use crate::render::Camera; + +pub(crate) fn handle_movement_input(local_player: &Player, dt: &Duration) -> Option<ActionKind> { + let velocity_x = if is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) { + -GameState::MAX_PLAYER_SPEED_PX_S + } else if is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) { + GameState::MAX_PLAYER_SPEED_PX_S + } else { + 0. + }; + let velocity_y = if is_key_down(KeyCode::W) || is_key_down(KeyCode::Up) { + -GameState::MAX_PLAYER_SPEED_PX_S + } else if is_key_down(KeyCode::S) || is_key_down(KeyCode::Down) { + GameState::MAX_PLAYER_SPEED_PX_S + } else { + 0. + }; + if velocity_x != 0. || velocity_y != 0. { + Some(ActionKind::Move { + new_position: local_player.position() + + Vec2::new(velocity_x, velocity_y) * dt.as_secs_f32(), + }) + } else { + None + } +} + +pub(crate) fn handle_pick_up_book_input( + local_player: &Player, + floor_books: &[FloorBook], +) -> Option<ActionKind> { + if is_key_pressed(KeyCode::Space) { + let local_player_collider = local_player + .collider() + .rect() + .offset(local_player.position()); + if let Some(floor_book) = floor_books.iter().find(|fb| { + fb.collider() + .rect() + .offset(fb.position()) + .overlaps(&local_player_collider) + }) { + return Some(ActionKind::PickUpBook { + book_id: floor_book.id(), + }); + } + } + None +} + +pub(crate) fn handle_drop_book_input( + local_player: &Player, + selected_book_index: usize, +) -> Option<ActionKind> { + if is_key_pressed(KeyCode::Q) + && let Some(book_id) = local_player.books().get(selected_book_index) + { + Some(ActionKind::DropBook { book_id: *book_id }) + } else { + None + } +} + +fn get_bookshelf_mouse_address( + camera: &Camera, + bookshelves: &HashMap<BookshelfId, Bookshelf>, +) -> Option<SlotAddress> { + let (mouse_x, mouse_y) = mouse_position(); + let mouse = Vec2::new(mouse_x, mouse_y); + for (bookshelf_id, bookshelf) in bookshelves { + let bookshelf_collider = bookshelf.rect().offset(-camera.position()); + if bookshelf_collider.contains(mouse) { + let shelf_index = bookshelf.shelf_count() + - ((mouse.y - bookshelf_collider.y - Bookshelf::FRAME_THICKNESS) + / (Bookshelf::BOOK_HEIGHT + Bookshelf::SHELF_THICKNESS)) + as usize + - 1; + let position = ((mouse.x - bookshelf_collider.x - Bookshelf::FRAME_THICKNESS) + / Bookshelf::BOOK_WIDTH) as usize; + return Some(SlotAddress::new(*bookshelf_id, shelf_index, position)); + } + } + None +} + +pub(crate) fn handle_put_book_on_shelf_input( + bookshelves: &HashMap<BookshelfId, Bookshelf>, + camera: &Camera, + local_player: &Player, + selected_book_index: usize, +) -> Option<ActionKind> { + if is_mouse_button_pressed(MouseButton::Left) + && let Some(slot_address) = get_bookshelf_mouse_address(camera, bookshelves) + && let Some(book_id) = local_player.books().get(selected_book_index) + && let Some(shelf) = bookshelves.get(&slot_address.bookshelf_id()) + && let Some(Slot::Empty) = shelf.get_slot(&slot_address) + { + return Some(ActionKind::PutBookOnShelf { + book_id: *book_id, + slot_address, + }); + } + None +} + +pub(crate) fn handle_take_book_off_shelf_input( + bookshelves: &HashMap<BookshelfId, Bookshelf>, + camera: &Camera, +) -> Option<ActionKind> { + if is_mouse_button_pressed(MouseButton::Left) + && let Some(slot_address) = get_bookshelf_mouse_address(camera, bookshelves) + && let Some(shelf) = bookshelves.get(&slot_address.bookshelf_id()) + && let Some(Slot::Book { .. }) = shelf.get_slot(&slot_address) + { + return Some(ActionKind::TakeBookOffShelf { slot_address }); + } + None +} diff --git a/client/src/main.rs b/client/src/main.rs @@ -1,13 +1,47 @@ +use std::{ + collections::VecDeque, + sync::mpsc::{Receiver, Sender}, + time::{Duration, Instant, SystemTime}, +}; + +use common::game::{Action, Response, state::GameState}; use macroquad::prelude::*; +use uuid::Uuid; use crate::{ - game::{ClientState, ViewMode}, - render::{SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX}, + render::{Camera, SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX}, + run::Config, + state::ViewMode, }; -mod game; +mod input; mod net; mod render; +mod run; +mod state; + +#[derive(Debug)] +struct ClientState { + mode: ViewMode, + camera: Camera, + game: GameState, + old_game: GameState, + interpolated_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>>, + config: Config, + menu_error: String, + first_frame: bool, + selected_inventory_book: usize, +} #[macroquad::main("Library")] async fn main() { diff --git a/client/src/render.rs b/client/src/render.rs @@ -1,7 +1,8 @@ -use common::game::{book::BOOKS, state::GameState}; +use common::game::book::{BOOKS, Bookshelf, Slot}; use lazy_static::lazy_static; use macroquad::prelude::*; -use uuid::Uuid; + +use crate::ClientState; lazy_static! { pub(crate) static ref SPRITESHEET: Spritesheet = Spritesheet::load(); @@ -15,6 +16,12 @@ pub(crate) struct Camera { position: Vec2, } +impl Camera { + pub(crate) fn position(&self) -> Vec2 { + self.position + } +} + #[derive(Debug)] pub(crate) struct Spritesheet { meeple: Sprite, @@ -73,128 +80,189 @@ impl Spritesheet { } } -pub(crate) fn render_game( - camera: &Camera, - game: &GameState, - debug_view: bool, - local_player_id: &Uuid, - selected_book: usize, -) { - clear_background(MAGENTA); - - // Render bookshelves - for bookshelf in game.bookshelves().iter() { - let bounds: Rect = bookshelf.rect().offset(-camera.position); - draw_rectangle(bounds.x, bounds.y, bounds.w, bounds.h, DARKBROWN); - } +impl ClientState { + pub(crate) fn draw_frame(&self) { + clear_background(MAGENTA); - // Render books on the floor - let floor_book_sprite = SPRITESHEET.book.clone(); - for floor_book in game.floor_books().iter() { - let position_camera = floor_book.position() - camera.position; - floor_book_sprite.render(position_camera); - } + if let Some(local_player_id) = self.local_player_id + && let Some(local_player) = self.interpolated_game.get_player(&local_player_id) + { + // Render bookshelves + for bookshelf in self.interpolated_game.bookshelves().values() { + const BOOKSHELF_BACKGROUND: Color = BROWN; + const BOOKSHELF_FRAME: Color = DARKBROWN; + let bounds: Rect = bookshelf.rect().offset(-self.camera.position); + // Background + draw_rectangle(bounds.x, bounds.y, bounds.w, bounds.h, BOOKSHELF_BACKGROUND); + // Frame + draw_rectangle_lines( + bounds.x, + bounds.y, + bounds.w, + bounds.h, + // It seems that the thickness must be doubled to achieve the requested value + Bookshelf::FRAME_THICKNESS * 2., + BOOKSHELF_FRAME, + ); + // Shelves + for shelf_index in 0..bookshelf.shelf_count() { + let y = bounds.y + bounds.h + - shelf_index as f32 + * (Bookshelf::BOOK_HEIGHT + Bookshelf::SHELF_THICKNESS) + - Bookshelf::FRAME_THICKNESS + - Bookshelf::SHELF_THICKNESS / 2.; + draw_line( + bounds.x, + y, + bounds.x + bounds.w, + y, + Bookshelf::SHELF_THICKNESS, + BOOKSHELF_FRAME, + ); + } + // Books + for (shelf_index, shelf) in bookshelf.iter().enumerate() { + let y = bounds.y + bounds.h + - shelf_index.saturating_add(1) as f32 + * (Bookshelf::BOOK_HEIGHT + Bookshelf::SHELF_THICKNESS) + - Bookshelf::FRAME_THICKNESS; + for (slot_index, slot) in shelf.iter().enumerate() { + if let Slot::Book { .. } = slot { + let x = bounds.x + + Bookshelf::FRAME_THICKNESS + + slot_index as f32 * Bookshelf::BOOK_WIDTH; + draw_line(x, y, x, y + Bookshelf::BOOK_HEIGHT, 2., BLACK); + draw_rectangle( + x, + y, + Bookshelf::BOOK_WIDTH, + Bookshelf::BOOK_HEIGHT, + BLUE, + ); + draw_line( + x + Bookshelf::BOOK_WIDTH, + y, + x + Bookshelf::BOOK_WIDTH, + y + Bookshelf::BOOK_HEIGHT, + 1., + BLACK, + ); + } + } + } + } - // Render players - let player_sprite = SPRITESHEET.meeple.clone(); - 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 + player_sprite.size.x / 2. - name_plate_dimensions.width / 2., - position_camera.y + player_sprite.size.y + 16., - 16., - BLACK, - ); - } + // Render books on the floor + let floor_book_sprite = SPRITESHEET.book.clone(); + for floor_book in self.interpolated_game.floor_books().iter() { + let position_camera = floor_book.position() - self.camera.position; + floor_book_sprite.render(position_camera); + } - // Render book metadata popup if local player is near a floor book - if let Some(local_player) = game.get_player(local_player_id) - && let Some(nearby_floor_book) = game.floor_books().iter().find(|floor_book| { - floor_book - .collider() - .rect() - .offset(floor_book.position()) - .overlaps( - &local_player - .collider() - .rect() - .offset(local_player.position()), - ) - }) - { - let text = if let Some(metadata) = BOOKS.get(&nearby_floor_book.id()) { - metadata.to_string() - } else { - "Unknown book".to_string() - }; - let font_size = 24.; - let text_dimensions = measure_text(&text, None, font_size as u16, 1.); - let padding = 4.; - let x = nearby_floor_book.position().x - camera.position.x; - let y = nearby_floor_book.position().y - camera.position.y - 16.; - let w = text_dimensions.width; - let h = text_dimensions.height; - draw_rectangle( - x - padding, - y - text_dimensions.offset_y - padding, - w + padding, - h + padding, - WHITE.with_alpha(0.5), - ); - draw_text(text, x, y, font_size, BLACK.with_alpha(0.5)); - draw_triangle( - Vec2::new(x + 4., y + 5.), - Vec2::new(x + 12., y + 13.), - Vec2::new(x + 20., y + 5.), - WHITE.with_alpha(0.5), - ); - } + // Render players + let player_sprite = SPRITESHEET.meeple.clone(); + for player in self.interpolated_game.players().values() { + let position_camera = player.position() - self.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 + player_sprite.size.x / 2. + - name_plate_dimensions.width / 2., + position_camera.y + player_sprite.size.y + 16., + 16., + BLACK, + ); + } - if let Some(local_player) = game.get_player(local_player_id) { - // Render list of books held by the local player - for (index, book) in local_player.books().iter().enumerate() { - let book_string = if let Some(metadata) = BOOKS.get(book) { - metadata.to_string() - } else { - "Unknown book".to_string() - }; - let y = screen_height() - ((local_player.books().len() - index) as f32 * 16.) - 16.; - draw_text(book_string, 16., y, 16., BLACK); - if index == selected_book { - draw_circle(8., y - 4., 3., BLACK); + // Render book metadata popup if local player is near a floor book + if let Some(nearby_floor_book) = + self.interpolated_game + .floor_books() + .iter() + .find(|floor_book| { + floor_book + .collider() + .rect() + .offset(floor_book.position()) + .overlaps( + &local_player + .collider() + .rect() + .offset(local_player.position()), + ) + }) + { + let text = if let Some(metadata) = BOOKS.get(&nearby_floor_book.id()) { + metadata.to_string() + } else { + "Unknown book".to_string() + }; + let font_size = 24.; + let text_dimensions = measure_text(&text, None, font_size as u16, 1.); + let padding = 4.; + let x = nearby_floor_book.position().x - self.camera.position.x; + let y = nearby_floor_book.position().y - self.camera.position.y - 16.; + let w = text_dimensions.width; + let h = text_dimensions.height; + draw_rectangle( + x - padding, + y - text_dimensions.offset_y - padding, + w + padding, + h + padding, + WHITE.with_alpha(0.5), + ); + draw_text(text, x, y, font_size, BLACK.with_alpha(0.5)); + draw_triangle( + Vec2::new(x + 4., y + 5.), + Vec2::new(x + 12., y + 13.), + Vec2::new(x + 20., y + 5.), + WHITE.with_alpha(0.5), + ); } - } - } - // Render debug view if enabled - if debug_view { - draw_rectangle(0., 0., 284., 56., BLACK); - draw_text("Library Tech Demo 2026-09-05", 4., 16., 16., WHITE); - draw_text(format!("fps {}", get_fps()), 4., 32., 16., WHITE); - if let Some(player) = game.get_player(local_player_id) { - draw_text(format!("id {}", local_player_id), 4., 48., 16., WHITE); - draw_text( - format!( - "pos ({:.1}, {:.1})", - player.position().x, - player.position().y - ), - 60., - 32., - 16., - WHITE, - ); + // Render list of books held by the local player + for (index, book) in local_player.books().iter().enumerate() { + let book_string = if let Some(metadata) = BOOKS.get(book) { + metadata.to_string() + } else { + "Unknown book".to_string() + }; + let y = screen_height() - ((local_player.books().len() - index) as f32 * 16.) - 16.; + draw_text(book_string, 16., y, 16., BLACK); + if index == self.selected_inventory_book { + draw_circle(8., y - 4., 3., BLACK); + } + } + + // Render debug view if enabled + if self.debug_view { + draw_rectangle(0., 0., 284., 56., BLACK); + draw_text("Library Tech Demo 2026-09-07", 4., 16., 16., WHITE); + draw_text(format!("fps {}", get_fps()), 4., 32., 16., WHITE); + draw_text(format!("id {}", local_player_id), 4., 48., 16., WHITE); + draw_text( + format!( + "pos ({:.1}, {:.1})", + local_player.position().x, + local_player.position().y + ), + 60., + 32., + 16., + WHITE, + ); + } } } -} - -pub(crate) fn follow_local_player(camera: &mut Camera, game: &GameState, local_player_id: &Uuid) { - let screen_center = Vec2::new(screen_width() / 2., screen_height() / 2.); - if let Some((_, player)) = game.players().iter().find(|(id, _)| *id == local_player_id) { - let player_sprite = &SPRITESHEET.meeple; - camera.position = player.position() - screen_center + player_sprite.size / 2.; + pub(crate) fn follow_local_player(&mut self) { + let screen_center = Vec2::new(screen_width() / 2., screen_height() / 2.); + if let Some(local_player_id) = self.local_player_id + && let Some(local_player) = self.game.get_player(&local_player_id) + { + let player_sprite = &SPRITESHEET.meeple; + self.camera.position = + local_player.position() - screen_center + player_sprite.size / 2.; + } } } diff --git a/client/src/run.rs b/client/src/run.rs @@ -0,0 +1,214 @@ +use std::{ + collections::VecDeque, + net::SocketAddr, + sync::mpsc::channel, + thread, + time::{Duration, Instant, SystemTime}, +}; + +use common::game::{Action, Response, state::GameState}; +use macroquad::{ + prelude::*, + ui::{ + hash, root_ui, + widgets::{Button, InputText, Label}, + }, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + ClientState, + input::{ + handle_drop_book_input, handle_movement_input, handle_pick_up_book_input, + handle_put_book_on_shelf_input, handle_take_book_off_shelf_input, + }, + net::handle_connection, + render::Camera, + state::ViewMode, +}; + +const CONFY_APP_NAME: &str = "library-game"; +const CONFY_CONFIG_NAME: &str = "config"; + +impl Default for ClientState { + fn default() -> Self { + Self { + mode: ViewMode::default(), + camera: Camera::default(), + game: GameState::default(), + old_game: GameState::default(), + interpolated_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, + config: confy::load(CONFY_APP_NAME, Some(CONFY_CONFIG_NAME)).unwrap_or_default(), + menu_error: String::new(), + first_frame: true, + selected_inventory_book: 0, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub(crate) struct Config { + player_name: String, + server: String, +} + +impl Config { + pub(crate) fn player_name(&self) -> String { + self.player_name.clone() + } +} + +impl ClientState { + pub(crate) fn run_menu_frame(&mut self) { + 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 self.config.player_name); + InputText::new(hash!()) + .label("server") + .position(menu_top_left) + .size(ELEMENT_SIZE) + .margin(Vec2::ZERO) + .ui(&mut ui, &mut self.config.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) = self.config.server.parse::<SocketAddr>() { + Some(addr) + } else if self.config.player_name.is_empty() { + self.menu_error = "enter your name pls".to_string(); + None + } else { + self.menu_error = "invalid server :-\\".to_string(); + None + } + } else { + None + }; + Label::new(self.menu_error.as_str()) + .position(menu_top_left + ELEMENT_PITCH * 3.) + .size(ELEMENT_SIZE) + .ui(&mut ui); + if self.first_frame { + ui.set_input_focus(name_input_token); + self.first_frame = false; + } + if let Some(addr) = addr { + let _ = confy::store(CONFY_APP_NAME, Some(CONFY_CONFIG_NAME), self.config.clone()); + 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) + }); + self.tx = Some(client_to_server_tx); + self.rx = Some(server_to_client_rx); + self.mode = ViewMode::Game; + } + } + pub(crate) fn run_game_frame(&mut self) { + // Update frame timer so we move the player at the right speed + self.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) = self.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 { + self.advance_game_state(); + } + self.handle_response(&response); + latest_sequence_number = latest_sequence_number.max(response.sequence_number()); + } + } + + // If there were any server responses, then do server reconciliation and client + // prediction + if response_count > 0 { + self.reconcile(latest_sequence_number); + self.predict(); + } + + if let Some(id) = self.local_player_id + && let Some(local_player) = self.game.get_player(&id).cloned() + { + // Collect and send actions based on user input this frame + let mut new_actions = [None, None, None, None, None]; + + new_actions[0] = handle_movement_input(&local_player, &self.dt); + new_actions[1] = handle_pick_up_book_input(&local_player, self.game.floor_books()); + new_actions[2] = handle_drop_book_input(&local_player, self.selected_inventory_book); + new_actions[3] = handle_put_book_on_shelf_input( + self.game.bookshelves(), + &self.camera, + &local_player, + self.selected_inventory_book, + ); + new_actions[4] = + handle_take_book_off_shelf_input(self.game.bookshelves(), &self.camera); + + for new_action in new_actions.iter().filter_map(|na| na.clone()) { + let action = Action::new(self.sequence_number, id, new_action); + self.game.apply_action(&action); + self.send_action(&action); + } + + // Center local player on the screen + self.follow_local_player(); + + // Entity interpolation + self.interpolated_game = self.interpolate(); + + // Toggle debug view if requested + if is_key_pressed(KeyCode::F3) { + self.debug_view = !self.debug_view; + } + + // Set selected book + let books_count = local_player.books().len(); + if self.selected_inventory_book > books_count.saturating_sub(1) { + self.selected_inventory_book = books_count.saturating_sub(1); + } + if is_key_pressed(KeyCode::Key1) { + self.selected_inventory_book = 0; + } else if is_key_pressed(KeyCode::Key2) { + self.selected_inventory_book = 1; + } else if is_key_pressed(KeyCode::Key3) { + self.selected_inventory_book = 2; + } else if is_key_pressed(KeyCode::Key4) { + self.selected_inventory_book = 3; + } else if is_key_pressed(KeyCode::Key5) { + self.selected_inventory_book = 4; + } + + self.draw_frame(); + } + } +} diff --git a/client/src/state.rs b/client/src/state.rs @@ -0,0 +1,84 @@ +use std::time::{Instant, SystemTime}; + +use common::game::{Action, ActionKind, Response, ResponseKind, state::GameState}; +use structdiff::StructDiff; + +use crate::ClientState; + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) enum ViewMode { + #[default] + Menu, + Game, +} + +impl ClientState { + pub(crate) fn view_mode(&self) -> ViewMode { + self.mode + } + pub(crate) fn tick(&mut self) { + self.t0 = self.t1; + self.t1 = Instant::now(); + self.dt = self.t1.duration_since(self.t0); + } + pub(crate) fn advance_game_state(&mut self) { + self.old_game = self.game.clone(); + } + pub(crate) fn send_action(&mut self, action: &Action) { + if let Some(ref mut tx) = self.tx + && tx.send(action.clone()).is_err() + { + return; + } + self.unresponded_actions.push_back(action.clone()); + self.sequence_number = self.sequence_number.wrapping_add(1); + } + pub(crate) 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.config.player_name().clone(), + }, + ); + self.send_action(&action); + self.local_player_id = Some(id); + } + ResponseKind::UpdateGameState { diffs } => { + self.game.set_timestamp(response.timestamp()); + self.game.apply_mut(diffs); + } + ResponseKind::ReplaceGameState { new_game_state } => self.game = new_game_state, + }; + } + pub(crate) 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(); + } + } + } + pub(crate) 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_action(unresponded_action); + } + } + pub(crate) fn interpolate(&self) -> GameState { + GameState::interpolate( + &self.old_game, + &self.game, + self.interpolation_basis, + self.local_player_id, + ) + } +} diff --git a/common/src/game/book.rs b/common/src/game/book.rs @@ -1,6 +1,7 @@ use std::{ collections::{HashMap, hash_map::Keys}, fmt::{Display, Write}, + slice::Iter, }; use anyhow::{Result, anyhow}; @@ -18,23 +19,91 @@ lazy_static! { } pub type BookId = Uuid; +pub type BookshelfId = Uuid; -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, PartialOrd, Serialize)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, +)] +pub struct SlotAddress { + bookshelf_id: BookshelfId, + shelf_index: usize, + position: usize, +} + +impl SlotAddress { + pub fn new(bookshelf_id: BookshelfId, shelf_index: usize, position: usize) -> Self { + SlotAddress { + bookshelf_id, + shelf_index, + position, + } + } + pub fn bookshelf_id(&self) -> BookshelfId { + self.bookshelf_id + } +} + +#[derive( + Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, +)] +pub enum Slot { + Book { + book_id: BookId, + }, + #[default] + Empty, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, PartialOrd, Serialize)] pub struct Bookshelf { bounds: RectSerde, + shelves: Vec<Vec<Slot>>, } impl Bookshelf { - pub(crate) fn new(bounds: Rect) -> Self { + pub const BOOK_WIDTH: f32 = 10.; + pub const BOOK_HEIGHT: f32 = 30.; + pub const FRAME_THICKNESS: f32 = 4.; + pub const SHELF_THICKNESS: f32 = 4.; + pub(crate) fn new(position: Vec2, shelf_count: usize, books_per_shelf: usize) -> Self { Self { - bounds: bounds.into(), + bounds: Rect::new( + position.x, + position.y, + books_per_shelf as f32 * Self::BOOK_WIDTH + Self::FRAME_THICKNESS * 2., + shelf_count as f32 * Self::BOOK_HEIGHT + + shelf_count.saturating_sub(1) as f32 * Self::SHELF_THICKNESS + + Self::FRAME_THICKNESS * 2., + ) + .into(), + shelves: vec![vec![Slot::Empty; books_per_shelf]; shelf_count], } } pub fn rect(&self) -> Rect { self.bounds.into() } - pub(crate) fn bounds(&self) -> RectSerde { - self.bounds + pub fn iter(&self) -> Iter<'_, Vec<Slot>> { + self.shelves.iter() + } + pub fn shelf_count(&self) -> usize { + self.shelves.len() + } + pub fn get_shelf_mut(&mut self, index: usize) -> Option<&mut Vec<Slot>> { + self.shelves.get_mut(index) + } + pub fn get_slot(&self, slot_address: &SlotAddress) -> Option<&Slot> { + if let Some(shelf) = self.shelves.get(slot_address.shelf_index) { + shelf.get(slot_address.position) + } else { + None + } + } + pub fn get_slot_mut(&mut self, slot_address: &SlotAddress) -> Option<&mut Slot> { + if let Some(shelf) = self.shelves.get_mut(slot_address.shelf_index) { + shelf.get_mut(slot_address.position) + } else { + None + } } } diff --git a/common/src/game/mod.rs b/common/src/game/mod.rs @@ -8,7 +8,10 @@ use serde::{Deserialize, Serialize}; use structdiff::{Difference, StructDiff}; use uuid::Uuid; -use crate::game::{book::BookId, state::GameState}; +use crate::game::{ + book::{BookId, SlotAddress}, + state::GameState, +}; pub mod book; pub mod state; @@ -124,10 +127,25 @@ impl Action { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub enum ActionKind { - Join { name: String }, - Move { new_position: Vec2 }, - PickUpBook { book_id: BookId }, - DropBook { book_id: BookId }, + Join { + name: String, + }, + Move { + new_position: Vec2, + }, + PickUpBook { + book_id: BookId, + }, + DropBook { + book_id: BookId, + }, + PutBookOnShelf { + book_id: BookId, + slot_address: SlotAddress, + }, + TakeBookOffShelf { + slot_address: SlotAddress, + }, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/common/src/game/state.rs b/common/src/game/state.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::game::{ Action, ActionKind, Player, - book::{BOOKS, BookId, Bookshelf, FloorBook}, + book::{BOOKS, BookId, Bookshelf, BookshelfId, FloorBook, Slot}, rects_strictly_overlap, }; @@ -19,7 +19,7 @@ pub struct GameState { #[difference(skip)] timestamp: SystemTime, players: HashMap<Uuid, Player>, - bookshelves: Vec<Bookshelf>, + bookshelves: HashMap<BookshelfId, Bookshelf>, floor_books: Vec<FloorBook>, } @@ -41,6 +41,49 @@ impl GameState { ActionKind::DropBook { book_id } => { self.drop_book(&action.player_id, book_id); } + ActionKind::PutBookOnShelf { + book_id, + slot_address, + } => { + // Checks: + // - book id exists + // - player exists + // - book id is in player inventory + // - bookshelf exists + // - slot address is valid for bookshelf + // - slot is empty + if BOOKS.get(book_id).is_some() + && let Some(player) = self.players.get_mut(&action.player_id) + && let Some(player_book_index) = + player.books.iter().enumerate().find_map(|(index, b_id)| { + if b_id == book_id { Some(index) } else { None } + }) + && let Some(bookshelf) = self.bookshelves.get_mut(&slot_address.bookshelf_id()) + && let Some(slot) = bookshelf.get_slot_mut(slot_address) + && let Slot::Empty = slot + { + *slot = Slot::Book { + book_id: player.books.remove(player_book_index), + }; + } + } + ActionKind::TakeBookOffShelf { slot_address } => { + // Checks: + // - player exists + // - player has free inventory space + // - bookshelf exists + // - slot address is valid for bookshelf + // - slot contains a book + if let Some(player) = self.players.get_mut(&action.player_id) + && player.books.len() < Player::MAX_BOOKS_COUNT + && let Some(bookshelf) = self.bookshelves.get_mut(&slot_address.bookshelf_id()) + && let Some(slot) = bookshelf.get_slot_mut(slot_address) + && let Slot::Book { book_id } = *slot + { + *slot = Slot::Empty; + player.books.push(book_id); + } + } } } pub fn interpolate( @@ -115,8 +158,8 @@ impl GameState { pub fn update_timestamp(&mut self) { self.set_timestamp(SystemTime::now()); } - pub fn bookshelves(&self) -> &Vec<Bookshelf> { - self.bookshelves.as_ref() + pub fn bookshelves(&self) -> &HashMap<BookshelfId, Bookshelf> { + &self.bookshelves } pub fn floor_books(&self) -> &Vec<FloorBook> { self.floor_books.as_ref() @@ -135,11 +178,11 @@ impl GameState { // Handle collisions with bookshelves let player_collider = player.collider.rect().offset(player.position); - for bookshelf in self.bookshelves.iter() { + for bookshelf in self.bookshelves.values() { // If the player's movement would cause their collider to intersect with a // bookshelf's collider, then compute the minimum axis-aligned motion // required to make them touch but not intersect - let bookshelf_collider: Rect = bookshelf.bounds().into(); + let bookshelf_collider = bookshelf.rect(); let (a, b) = (bookshelf_collider, player_collider); if rects_strictly_overlap(&a, &b) { // Compute the required motion in each possible direction and select the @@ -201,10 +244,16 @@ impl Default for GameState { Self { timestamp: SystemTime::now(), players: HashMap::with_capacity(32), - bookshelves: vec![ - Bookshelf::new(Rect::new(-200., -100., 100., 500.)), - Bookshelf::new(Rect::new(-400., -100., 100., 500.)), - ], + bookshelves: HashMap::from_iter(vec![ + ( + Uuid::from_u128(0xa63ace6da898476a98645abe3665fa17), + Bookshelf::new(Vec2::new(-100., -300.), 3, 50), + ), + ( + Uuid::from_u128(0x48faf42fb7ca424b8dbb4ddcfdf3ce2a), + Bookshelf::new(Vec2::new(500., -300.), 1, 50), + ), + ]), floor_books: { srand(0); BOOKS