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