library

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

commit 3d2eb959be7b8a3786299e02f62dcffc3259bbc0
parent fea35bf98f9cac945309f65b056f6e615e3acb1e
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Sat,  5 Sep 2026 19:51:49 -0400

Allow players to pick up and drop books

Diffstat:
Mclient/src/game/mod.rs | 45++++++++++++++++++++++++++++++++++++++++-----
Mclient/src/game/run.rs | 65++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mclient/src/render.rs | 30++++++++++++++++++++++--------
Mcommon/src/game/mod.rs | 11++++++++++-
Mcommon/src/game/state.rs | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 171 insertions(+), 36 deletions(-)

diff --git a/client/src/game/mod.rs b/client/src/game/mod.rs @@ -5,7 +5,7 @@ use std::{ }; use crate::render::Camera; -use common::game::{Action, ActionKind, Player, Response, state::GameState}; +use common::game::{Action, ActionKind, Player, Response, book::FloorBook, state::GameState}; use macroquad::prelude::*; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -35,6 +35,7 @@ pub(crate) struct ClientState { config: Config, menu_error: String, first_frame: bool, + selected_book: usize, } impl Default for ClientState { @@ -57,14 +58,12 @@ impl Default for ClientState { 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: &mut Player, - dt: &Duration, -) -> Option<ActionKind> { +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) { @@ -89,6 +88,42 @@ pub(crate) fn handle_movement_input( } } +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, diff --git a/client/src/game/run.rs b/client/src/game/run.rs @@ -1,7 +1,10 @@ use std::{net::SocketAddr, sync::mpsc::channel, thread}; use crate::{ - game::{CONFY_APP_NAME, CONFY_CONFIG_NAME, ClientState, ViewMode, handle_movement_input}, + 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}, }; @@ -104,33 +107,57 @@ impl ClientState { } if let Some(id) = self.local_player_id - && let Some(local_player) = self.game.get_player_mut(&id) + && let Some(local_player) = self.game.get_player(&id).cloned() { - // Send movement actions if requested - if let Some(action_kind) = handle_movement_input(local_player, &self.dt) { - let action = Action::new(self.sequence_number, id, action_kind); + // 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(); + // Entity interpolation + let interpolated_game = self.interpolate(); - // Toggle debug view if requested - if is_key_pressed(KeyCode::F3) { - self.debug_view = !self.debug_view; - } + // Toggle debug view if requested + if is_key_pressed(KeyCode::F3) { + self.debug_view = !self.debug_view; + } - // Draw the frame - render_game( - &self.camera, - &interpolated_game, - self.debug_view, - self.local_player_id, - ); + // 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/render.rs b/client/src/render.rs @@ -77,7 +77,8 @@ pub(crate) fn render_game( camera: &Camera, game: &GameState, debug_view: bool, - local_player_id: Option<Uuid>, + local_player_id: &Uuid, + selected_book: usize, ) { clear_background(MAGENTA); @@ -110,8 +111,7 @@ pub(crate) fn render_game( } // Render book metadata popup if local player is near a floor book - if let Some(id) = local_player_id - && let Some(local_player) = game.get_player(&id) + 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() @@ -153,15 +153,29 @@ pub(crate) fn render_game( ); } + 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 debug view if enabled if debug_view { draw_rectangle(0., 0., 284., 56., BLACK); - draw_text("Library Tech Demo 2026-08-30", 4., 16., 16., WHITE); + 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(id) = local_player_id - && let Some(player) = game.get_player(&id) - { - draw_text(format!("id {}", id), 4., 48., 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})", diff --git a/common/src/game/mod.rs b/common/src/game/mod.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use structdiff::{Difference, StructDiff}; use uuid::Uuid; -use crate::game::state::GameState; +use crate::game::{book::BookId, state::GameState}; pub mod book; pub mod state; @@ -28,15 +28,19 @@ pub struct Player { position: Vec2, collider: RectSerde, timestamp: SystemTime, + books: Vec<BookId>, } impl Player { + const MAX_BOOKS_COUNT: usize = 5; + fn new(name: String) -> Self { Player { name, position: Vec2::ZERO, collider: RectSerde::new(0., 0., 43.4, 40.2), timestamp: SystemTime::now(), + books: vec![], } } pub fn name(&self) -> String { @@ -48,6 +52,9 @@ impl Player { pub fn collider(&self) -> RectSerde { self.collider } + pub fn books(&self) -> Vec<BookId> { + self.books.clone() + } } #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, PartialOrd, Serialize)] @@ -119,6 +126,8 @@ impl Action { pub enum ActionKind { Join { name: String }, Move { new_position: Vec2 }, + PickUpBook { book_id: BookId }, + DropBook { book_id: BookId }, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/common/src/game/state.rs b/common/src/game/state.rs @@ -10,12 +10,11 @@ use uuid::Uuid; use crate::game::{ Action, ActionKind, Player, - book::{BOOKS, Bookshelf, FloorBook}, + book::{BOOKS, BookId, Bookshelf, FloorBook}, rects_strictly_overlap, }; #[derive(Clone, Debug, Difference, Deserialize, PartialEq, Serialize)] -#[difference(expose)] pub struct GameState { #[difference(skip)] timestamp: SystemTime, @@ -36,6 +35,12 @@ impl GameState { ActionKind::Move { new_position } => { self.move_player(action.player_id, *new_position, action.timestamp); } + ActionKind::PickUpBook { book_id } => { + self.pick_up_book(&action.player_id, book_id); + } + ActionKind::DropBook { book_id } => { + self.drop_book(&action.player_id, book_id); + } } } pub fn interpolate( @@ -44,14 +49,27 @@ impl GameState { basis: SystemTime, local_player_id: Option<Uuid>, ) -> GameState { - let mut output = GameState::default(); + // Some fields should not be interpolated, and to minimize apparently acausal behavior we + // default to the old game state's version + let mut output = GameState { + bookshelves: old.bookshelves.clone(), + floor_books: old.floor_books.clone(), + ..Default::default() + }; + for (new_id, new_player) in new.players.iter() { if let Some(old_player) = old.players.get(new_id) { if let Some(ref lpid) = local_player_id && new_id == lpid { + // If the player we're interpolating is the local player (that is, the one that + // a user is controlling in their client), then skip interpolation and use the + // new game state's version output.players.insert(*new_id, new_player.clone()); } else { + // For all other players, linearly interpolate their positions based on the two + // known game states and the elapsed time between them; this gives the illusion + // of smooth motion at the expense of latency let span = new.timestamp.duration_since(old.timestamp); let fraction = SystemTime::now().duration_since(basis); let t = match (fraction, span) { @@ -66,6 +84,8 @@ impl GameState { output.players.insert(*new_id, interpolated_player); } } else { + // If the player exists in the new game state but not the old one, then don't try to + // interpolate anything output.players.insert(*new_id, new_player.clone()); } } @@ -144,6 +164,36 @@ impl GameState { } } } + fn pick_up_book(&mut self, player_id: &Uuid, book_id: &BookId) { + if let Some(player) = self.players.get_mut(player_id) + && let Some((floor_book_index, floor_book)) = self + .floor_books + .iter() + .enumerate() + .find(|(_, fb)| fb.id() == *book_id) + { + let player_collider = player.collider.rect().offset(player.position); + let floor_book_collider = floor_book.collider().rect().offset(floor_book.position()); + if player_collider.overlaps(&floor_book_collider) + && player.books.len() < Player::MAX_BOOKS_COUNT + { + let book = self.floor_books.remove(floor_book_index); + player.books.push(book.id()); + } + } + } + fn drop_book(&mut self, player_id: &Uuid, book_id: &BookId) { + if let Some(player) = self.players.get_mut(player_id) + && let Some(book_index) = player.books.iter().position(|b| *b == *book_id) + { + let book = player.books.remove(book_index); + self.floor_books.push(FloorBook::new( + book, + player.position, + Rect::new(0., 0., 30., 23.6), + )); + } + } } impl Default for GameState {