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