commit 4fb6e412febcc990e8d45be75c17ecec8fe40cb8
parent 1fb690aedead6e7a503c75f58407af5ccb62475a
Author: Bradley Gannon <bradley@bradleygannon.com>
Date: Sun, 30 Aug 2026 21:17:32 -0400
Split some files
Diffstat:
13 files changed, 886 insertions(+), 842 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -63,6 +63,7 @@ dependencies = [
"anyhow",
"common",
"confy",
+ "lazy_static",
"macroquad",
"mio",
"postcard",
diff --git a/client/Cargo.toml b/client/Cargo.toml
@@ -8,6 +8,7 @@ default-run = "client"
anyhow = "1.0.104"
common = { path = "../common/" }
confy = "2.0.0"
+lazy_static = "1.5.0"
macroquad = { version = "0.4.16", features = ["glam-serde"] }
mio = { version = "1.2.2", features = ["net", "os-poll"] }
postcard = { version = "1.1.3", default-features = false, features = ["use-std"] }
diff --git a/client/src/game.rs b/client/src/game.rs
@@ -1,32 +0,0 @@
-use std::time::Duration;
-
-use common::game::{ActionKind, GameState, Player};
-use macroquad::prelude::*;
-
-pub(crate) fn handle_movement_input(
- local_player: &mut 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
- }
-}
diff --git a/client/src/game/mod.rs b/client/src/game/mod.rs
@@ -0,0 +1,103 @@
+use std::{
+ collections::VecDeque,
+ sync::mpsc::{Receiver, Sender},
+ time::{Duration, Instant, SystemTime},
+};
+
+use crate::render::Camera;
+use common::game::{Action, ActionKind, Player, Response, 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,
+}
+
+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,
+ }
+ }
+}
+
+pub(crate) fn handle_movement_input(
+ local_player: &mut 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
+ }
+}
+
+#[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
@@ -0,0 +1,142 @@
+use std::{net::SocketAddr, sync::mpsc::channel, thread};
+
+use crate::{
+ game::{CONFY_APP_NAME, CONFY_CONFIG_NAME, ClientState, ViewMode, handle_movement_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_mut(&id)
+ {
+ // Send movement actions if requested
+ if let Some(action_kind) = handle_movement_input(local_player, &self.dt)
+ && let Some(ref mut tx) = self.tx
+ {
+ let action = Action::new(self.sequence_number, id, action_kind);
+ self.game.apply(&action);
+ if tx.send(action.clone()).is_err() {
+ return;
+ }
+ self.unresponded_actions.push_back(action);
+ self.sequence_number = self.sequence_number.wrapping_add(1);
+ }
+
+ // 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;
+ }
+
+ // Draw the frame
+ render_game(
+ &self.camera,
+ &interpolated_game,
+ self.debug_view,
+ self.local_player_id,
+ );
+ }
+}
diff --git a/client/src/game/state.rs b/client/src/game/state.rs
@@ -0,0 +1,75 @@
+use std::time::{Instant, SystemTime};
+
+use common::game::{Action, ActionKind, Response, ResponseKind, state::GameState};
+
+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 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(),
+ },
+ );
+ if let Some(ref mut tx) = self.tx
+ && tx.send(action.clone()).is_err()
+ {
+ return;
+ }
+ self.unresponded_actions.push_back(action);
+ self.sequence_number = self.sequence_number.wrapping_add(1);
+ self.local_player_id = Some(id);
+ }
+ ResponseKind::UpdateGameState { updates } => {
+ for update in updates {
+ self.game.update(&update);
+ }
+ self.game.set_timestamp(response.timestamp());
+ }
+ ResponseKind::ReplaceGameState { new_game_state } => self.game = new_game_state,
+ };
+ }
+ 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(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/main.rs b/client/src/main.rs
@@ -1,302 +1,25 @@
-use std::{
- collections::VecDeque,
- net::SocketAddr,
- sync::mpsc::{Receiver, Sender, channel},
- thread,
- time::{Duration, Instant, SystemTime},
-};
-
-use common::game::{Action, ActionKind, GameState, Response, ResponseKind};
-use macroquad::{
- prelude::*,
- ui::{
- hash, root_ui,
- widgets::{Button, InputText, Label},
- },
-};
-use serde::{Deserialize, Serialize};
-use uuid::Uuid;
+use macroquad::prelude::*;
use crate::{
- game::handle_movement_input,
- net::handle_connection,
- render::{
- Camera, SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX, Spritesheet, follow_local_player, render_game,
- },
+ game::{ClientState, ViewMode},
+ render::{SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX},
};
-pub mod game;
-pub mod net;
-pub mod render;
-
-const CONFY_APP_NAME: &str = "library-game";
-const CONFY_CONFIG_NAME: &str = "config";
-
-#[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)]
-enum ViewMode {
- #[default]
- Menu,
- Game,
-}
-
-#[derive(Debug)]
-struct ClientState {
- mode: ViewMode,
- camera: Camera,
- game: GameState,
- old_game: GameState,
- local_player_id: Option<Uuid>,
- sequence_number: u32,
- unresponded_actions: VecDeque<Action>,
- interpolation_basis: SystemTime,
- debug_view: bool,
- t0: Instant,
- t1: Instant,
- dt: Duration,
- tx: Option<Sender<Action>>,
- rx: Option<Receiver<Response>>,
- config: Config,
- menu_error: String,
-}
-
-impl Default for ClientState {
- fn default() -> Self {
- Self {
- mode: ViewMode::default(),
- camera: Camera::default(),
- game: GameState::default(),
- old_game: GameState::default(),
- local_player_id: None,
- sequence_number: 0,
- unresponded_actions: VecDeque::new(),
- interpolation_basis: SystemTime::now(),
- debug_view: cfg!(debug_assertions),
- t0: Instant::now(),
- t1: Instant::now(),
- dt: Duration::ZERO,
- tx: None,
- rx: None,
- config: confy::load(CONFY_APP_NAME, Some(CONFY_CONFIG_NAME)).unwrap_or_default(),
- menu_error: String::new(),
- }
- }
-}
-
-impl ClientState {
- fn tick(&mut self) {
- self.t0 = self.t1;
- self.t1 = Instant::now();
- self.dt = self.t1.duration_since(self.t0);
- }
- fn advance_game_state(&mut self) {
- self.old_game = self.game.clone();
- }
- fn handle_response(&mut self, response: &Response) {
- match response.kind() {
- ResponseKind::SetId { id } => {
- let action = Action::new(
- self.sequence_number,
- id,
- ActionKind::Join {
- name: self.config.player_name.clone(),
- },
- );
- if let Some(ref mut tx) = self.tx
- && tx.send(action.clone()).is_err()
- {
- return;
- }
- self.unresponded_actions.push_back(action);
- self.sequence_number = self.sequence_number.wrapping_add(1);
- self.local_player_id = Some(id);
- }
- ResponseKind::UpdateGameState { updates } => {
- for update in updates {
- self.game.update(&update);
- }
- self.game.set_timestamp(response.timestamp());
- }
- ResponseKind::ReplaceGameState { new_game_state } => self.game = new_game_state,
- };
- }
- fn reconcile(&mut self, latest_sequence_number: u32) {
- // Server reconciliation: drop any unresponded actions whose sequence numbers are less than
- // the number provided
- if let Some(new_front_index) = self
- .unresponded_actions
- .iter()
- .position(|ua: &Action| ua.sequence_number() >= latest_sequence_number)
- {
- for _ in 0..=new_front_index {
- self.unresponded_actions.pop_front();
- }
- }
- }
- fn predict(&mut self) {
- // Client prediction: apply all unresponded actions to the game state
- self.interpolation_basis = SystemTime::now();
- for unresponded_action in self.unresponded_actions.iter() {
- self.game.apply(unresponded_action);
- }
- }
- fn interpolate(&self) -> GameState {
- GameState::interpolate(
- &self.old_game,
- &self.game,
- self.interpolation_basis,
- self.local_player_id,
- )
- }
-}
+mod game;
+mod net;
+mod render;
#[macroquad::main("Library")]
async fn main() {
- // One-time startup actions
request_new_screen_size(SCREEN_WIDTH_PX, SCREEN_HEIGHT_PX);
- let spritesheet = Spritesheet::load();
let mut client = ClientState::default();
- let mut first_frame = true;
loop {
- match client.mode {
- ViewMode::Menu => {
- clear_background(MAGENTA);
- const ELEMENT_SIZE: Vec2 = Vec2::new(420., 24.);
- const NUM_ELEMENTS: usize = 4;
- const MENU_SIZE: Vec2 =
- Vec2::new(ELEMENT_SIZE.x, ELEMENT_SIZE.y * NUM_ELEMENTS as f32);
- const ELEMENT_PITCH: Vec2 = Vec2::new(0., ELEMENT_SIZE.y);
- let screen_size = vec2(screen_width(), screen_height());
- let menu_top_left = screen_size / 2. - MENU_SIZE / 2.;
- let mut ui = root_ui();
- let name_input_token = hash!();
- InputText::new(name_input_token)
- .label("name")
- .position(menu_top_left)
- .size(ELEMENT_SIZE)
- .margin(Vec2::ZERO)
- .ui(&mut ui, &mut client.config.player_name);
- InputText::new(hash!())
- .label("server")
- .position(menu_top_left)
- .size(ELEMENT_SIZE)
- .margin(Vec2::ZERO)
- .ui(&mut ui, &mut client.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) = client.config.server.parse::<SocketAddr>() {
- Some(addr)
- } else if client.config.player_name.is_empty() {
- client.menu_error = "enter your name pls".to_string();
- None
- } else {
- client.menu_error = "invalid server :-\\".to_string();
- None
- }
- } else {
- None
- };
- Label::new(client.menu_error.as_str())
- .position(menu_top_left + ELEMENT_PITCH * 3.)
- .size(ELEMENT_SIZE)
- .ui(&mut ui);
- if first_frame {
- ui.set_input_focus(name_input_token);
- }
- if let Some(addr) = addr {
- let _ = confy::store(
- CONFY_APP_NAME,
- Some(CONFY_CONFIG_NAME),
- client.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)
- });
- client.tx = Some(client_to_server_tx);
- client.rx = Some(server_to_client_rx);
- client.mode = ViewMode::Game;
- }
- }
- ViewMode::Game => {
- // Update frame timer so we move the player at the right speed
- client.tick();
-
- // Process any new responses from the server
- let mut response_count: u8 = 0;
- let mut latest_sequence_number = 0;
- if let Some(ref rx) = client.rx {
- for response in rx.try_iter().collect::<Vec<Response>>() {
- response_count = response_count.saturating_add(1);
- // If this is the first response that we're processing during this tick,
- // then we need to copy the current game state so we can interpolate against
- // it below
- if response_count == 1 {
- client.advance_game_state();
- }
- client.handle_response(&response);
- latest_sequence_number =
- latest_sequence_number.max(response.sequence_number());
- }
- }
-
- // If there were any server responses, then do server reconciliation and client
- // prediction
- if response_count > 0 {
- client.reconcile(latest_sequence_number);
- client.predict();
- }
-
- if let Some(id) = client.local_player_id
- && let Some(local_player) = client.game.get_player_mut(&id)
- {
- // Send movement actions if requested
- if let Some(action_kind) = handle_movement_input(local_player, &client.dt)
- && let Some(ref mut tx) = client.tx
- {
- let action = Action::new(client.sequence_number, id, action_kind);
- client.game.apply(&action);
- if tx.send(action.clone()).is_err() {
- return;
- }
- client.unresponded_actions.push_back(action);
- client.sequence_number = client.sequence_number.wrapping_add(1);
- }
-
- // Center local player on the screen
- follow_local_player(&mut client.camera, &client.game, &spritesheet, &id);
- }
-
- // Entity interpolation
- let interpolated_game = client.interpolate();
-
- // Toggle debug view if requested
- if is_key_pressed(KeyCode::F3) {
- client.debug_view = !client.debug_view;
- }
-
- // Draw the frame
- render_game(
- &client.camera,
- &interpolated_game,
- &spritesheet,
- client.debug_view,
- client.local_player_id,
- );
- }
+ match client.view_mode() {
+ ViewMode::Menu => client.run_menu_frame(),
+ ViewMode::Game => client.run_game_frame(),
}
- first_frame = false;
next_frame().await;
}
}
diff --git a/client/src/render.rs b/client/src/render.rs
@@ -1,7 +1,12 @@
-use common::game::{BOOKS, GameState};
+use common::game::{book::BOOKS, state::GameState};
+use lazy_static::lazy_static;
use macroquad::prelude::*;
use uuid::Uuid;
+lazy_static! {
+ pub(crate) static ref SPRITESHEET: Spritesheet = Spritesheet::load();
+}
+
pub const SCREEN_WIDTH_PX: f32 = 1280.;
pub const SCREEN_HEIGHT_PX: f32 = 720.;
@@ -71,7 +76,6 @@ impl Spritesheet {
pub(crate) fn render_game(
camera: &Camera,
game: &GameState,
- spritesheet: &Spritesheet,
debug_view: bool,
local_player_id: Option<Uuid>,
) {
@@ -84,14 +88,14 @@ pub(crate) fn render_game(
}
// Render books on the floor
- let floor_book_sprite = spritesheet.book.clone();
+ 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);
}
// Render players
- let player_sprite = spritesheet.meeple.clone();
+ let player_sprite = SPRITESHEET.meeple.clone();
for player in game.players().values() {
let position_camera = player.position() - camera.position;
player_sprite.render(position_camera);
@@ -173,15 +177,10 @@ pub(crate) fn render_game(
}
}
-pub(crate) fn follow_local_player(
- camera: &mut Camera,
- game: &GameState,
- spritesheet: &Spritesheet,
- local_player_id: &Uuid,
-) {
+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;
+ let player_sprite = &SPRITESHEET.meeple;
camera.position = player.position() - screen_center + player_sprite.size / 2.;
}
}
diff --git a/common/src/game.rs b/common/src/game.rs
@@ -1,512 +0,0 @@
-use std::{
- collections::HashMap,
- fmt::Display,
- io::Write,
- time::{Duration, SystemTime},
-};
-
-use anyhow::{Result, anyhow};
-use lazy_static::lazy_static;
-use macroquad::{
- math::{Rect, Vec2},
- rand::{rand, srand},
-};
-use serde::{Deserialize, Serialize};
-use uuid::Uuid;
-
-lazy_static! {
- pub static ref BOOKS: BookDatabase =
- BookDatabase::from_csv(include_str!("../assets/books.csv"))
- .expect("Failed to parse book database");
-}
-
-pub const TICK_DURATION: Duration = Duration::from_micros(16_667);
-
-pub type BookId = Uuid;
-pub type Players = HashMap<Uuid, Player>;
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
-pub struct GameState {
- timestamp: SystemTime,
- players: Players,
- bookshelves: Vec<Bookshelf>,
- floor_books: Vec<FloorBook>,
-}
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
-pub enum Update {
- InsertPlayer { id: Uuid, player: Player },
- RemovePlayer { player_id: Uuid },
-}
-
-/// Equivalent to [macroquad::math::Rect::overlaps], except using strict comparison (i.e., `<` and
-/// `>` instead of `<=` and `>=`)
-fn rects_strictly_overlap(a: &Rect, b: &Rect) -> bool {
- a.left() < b.right() && a.right() > b.left() && a.top() < b.bottom() && a.bottom() > b.top()
-}
-
-impl GameState {
- pub const MAX_PLAYER_SPEED_PX_S: f32 = 75.;
-
- pub fn update(&mut self, update: &Update) {
- match update {
- Update::InsertPlayer { id, player } => self.players.insert(*id, player.clone()),
- Update::RemovePlayer { player_id } => self.players.remove(player_id),
- };
- }
- pub fn apply(&mut self, action: &Action) {
- match action.kind() {
- ActionKind::Join { name } => {
- self.players.insert(action.player_id, Player::new(name));
- }
- ActionKind::Move { new_position } => {
- if let Some(player) = self.players.get_mut(&action.player_id)
- && let Ok(dt) = action.timestamp.duration_since(player.timestamp)
- {
- let velocity = (new_position - player.position) / dt.as_secs_f32();
- let limited_velocity = if velocity.length() > GameState::MAX_PLAYER_SPEED_PX_S {
- velocity.normalize() * GameState::MAX_PLAYER_SPEED_PX_S
- } else {
- velocity
- };
- player.position += limited_velocity * dt.as_secs_f32();
-
- // Handle collisions with bookshelves
- let player_collider = player.collider.rect().offset(player.position);
- for bookshelf in self.bookshelves.iter() {
- // 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 (a, b) = (bookshelf_collider, player_collider);
- if rects_strictly_overlap(&a, &b) {
- // Compute the required motion in each possible direction and select the
- // smallest among them
- let up = Vec2::new(0., a.top() - b.bottom());
- let down = Vec2::new(0., a.bottom() - b.top());
- let left = Vec2::new(a.left() - b.right(), 0.);
- let right = Vec2::new(a.right() - b.left(), 0.);
- let min_y = if up.y.abs() < down.y.abs() { up } else { down };
- let min_x = if right.x.abs() < left.x.abs() {
- right
- } else {
- left
- };
- let min = if min_x.x.abs() < min_y.y.abs() {
- min_x
- } else {
- min_y
- };
- player.position += min;
- }
- }
- }
- }
- }
- }
- pub fn interpolate(
- old: &Self,
- new: &Self,
- basis: SystemTime,
- local_player_id: Option<Uuid>,
- ) -> GameState {
- let mut output = GameState::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
- {
- output.players.insert(*new_id, new_player.clone());
- } else {
- let span = new.timestamp.duration_since(old.timestamp);
- let fraction = SystemTime::now().duration_since(basis);
- let t = match (fraction, span) {
- (Ok(f), Ok(s)) => (f.as_secs_f32() / s.as_secs_f32()).clamp(0., 1.),
- _ => return new.clone(),
- };
- let interpolated_player = Player {
- position: (new_player.position() - old_player.position()) * t
- + old_player.position(),
- ..new_player.clone()
- };
- output.players.insert(*new_id, interpolated_player);
- }
- } else {
- output.players.insert(*new_id, new_player.clone());
- }
- }
- output
- }
- pub fn get_player(&self, id: &Uuid) -> Option<&Player> {
- self.players.get(id)
- }
- pub fn get_player_mut(&mut self, id: &Uuid) -> Option<&mut Player> {
- self.players.get_mut(id)
- }
- pub fn players(&self) -> &HashMap<Uuid, Player> {
- &self.players
- }
- pub fn insert_player(&mut self, id: Uuid, player: Player) -> Option<Player> {
- self.players.insert(id, player)
- }
- pub fn remove_player(&mut self, id: &Uuid) -> Option<Player> {
- self.players.remove(id)
- }
- pub fn set_timestamp(&mut self, timestamp: SystemTime) {
- self.timestamp = timestamp;
- }
- pub fn get_timestamp(&self) -> SystemTime {
- self.timestamp
- }
- pub fn update_timestamp(&mut self) {
- self.set_timestamp(SystemTime::now());
- }
- pub fn bookshelves(&self) -> &Vec<Bookshelf> {
- self.bookshelves.as_ref()
- }
- pub fn floor_books(&self) -> &Vec<FloorBook> {
- self.floor_books.as_ref()
- }
-}
-
-impl Default for GameState {
- fn default() -> Self {
- Self {
- timestamp: SystemTime::now(),
- players: HashMap::with_capacity(32),
- bookshelves: vec![
- Bookshelf {
- bounds: RectSerde {
- x: -200.,
- y: -100.,
- w: 100.,
- h: 500.,
- },
- },
- Bookshelf {
- bounds: RectSerde {
- x: -400.,
- y: -100.,
- w: 100.,
- h: 500.,
- },
- },
- ],
- floor_books: {
- srand(0);
- BOOKS
- .books
- .keys()
- .map(|&id| FloorBook {
- id,
- position: Vec2::new((rand() % 750) as f32, (rand() % 750) as f32),
- collider: Rect::new(0., 0., 30., 23.6).into(),
- })
- .collect()
- },
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
-pub struct Player {
- name: String,
- position: Vec2,
- collider: RectSerde,
- timestamp: SystemTime,
-}
-
-impl Player {
- fn new(name: String) -> Self {
- Player {
- name,
- position: Vec2::ZERO,
- collider: RectSerde::new(0., 0., 43.4, 40.2),
- timestamp: SystemTime::now(),
- }
- }
- pub fn name(&self) -> String {
- self.name.clone()
- }
- pub fn position(&self) -> Vec2 {
- self.position
- }
- pub fn collider(&self) -> RectSerde {
- self.collider
- }
-}
-
-#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, PartialOrd, Serialize)]
-pub struct RectSerde {
- x: f32,
- y: f32,
- w: f32,
- h: f32,
-}
-
-impl RectSerde {
- pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
- RectSerde { x, y, w, h }
- }
- pub fn rect(&self) -> Rect {
- (*self).into()
- }
-}
-
-impl From<Rect> for RectSerde {
- fn from(value: Rect) -> Self {
- RectSerde {
- x: value.x,
- y: value.y,
- w: value.w,
- h: value.h,
- }
- }
-}
-
-impl From<RectSerde> for Rect {
- fn from(value: RectSerde) -> Self {
- Rect::new(value.x, value.y, value.w, value.h)
- }
-}
-
-#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, PartialOrd, Serialize)]
-pub struct Bookshelf {
- bounds: RectSerde,
-}
-
-impl Bookshelf {
- pub fn rect(&self) -> Rect {
- self.bounds.into()
- }
-}
-
-#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
-pub struct FloorBook {
- id: BookId,
- position: Vec2,
- collider: RectSerde,
-}
-
-impl FloorBook {
- pub fn id(&self) -> BookId {
- self.id
- }
- pub fn position(&self) -> Vec2 {
- self.position
- }
- pub fn collider(&self) -> RectSerde {
- self.collider
- }
-}
-
-pub struct BookDatabase {
- books: HashMap<BookId, BookMetadata>,
-}
-
-fn _csv_escape(input: &String) -> String {
- if input.contains(',') {
- format!("\"{input}\"")
- } else {
- input.clone()
- }
-}
-
-impl BookDatabase {
- const CSV_HEADER: &str = "id,title,author,publish_year";
-
- fn new() -> Self {
- BookDatabase {
- books: HashMap::new(),
- }
- }
- fn _to_csv(&self, output: &mut impl Write) -> Result<()> {
- writeln!(output, "{}", BookDatabase::CSV_HEADER)?;
- for (id, metadata) in self.books.iter() {
- let BookMetadata {
- title,
- author,
- publish_year,
- } = metadata;
- writeln!(
- output,
- "{},{},{},{}",
- id,
- _csv_escape(title),
- _csv_escape(author),
- _csv_escape(publish_year),
- )?;
- }
- Ok(())
- }
- fn from_csv(input: &str) -> Result<Self> {
- let mut output = BookDatabase::new();
- for (line_index, line) in input.lines().enumerate() {
- if line_index == 0 {
- if line != BookDatabase::CSV_HEADER {
- return Err(anyhow!(
- "Invalid CSV header: expected '{}' but got '{}'",
- BookDatabase::CSV_HEADER,
- line
- ));
- }
- continue;
- }
- let mut fields = vec![];
- #[derive(Clone, Copy, Debug)]
- enum State {
- Start,
- FieldData,
- Quote,
- FieldEnd,
- }
- let mut state = State::Start;
- let mut new_field = String::new();
- let mut quoted_field = false;
- for c in line.chars() {
- match (c, state) {
- ('"', State::Start | State::FieldEnd) => {
- quoted_field = true;
- state = State::FieldData;
- }
- ('"', State::FieldData) => state = State::Quote,
- ('"', State::Quote) => {
- if quoted_field {
- new_field.push('"');
- }
- }
- (',', State::Start | State::FieldEnd) => state = State::FieldEnd,
- (',', State::FieldData) => {
- if quoted_field {
- new_field.push(',');
- } else {
- state = State::FieldEnd;
- }
- }
- (',', State::Quote) => {
- if quoted_field {
- state = State::FieldEnd;
- }
- }
- (d, _) => {
- new_field.push(d);
- state = State::FieldData;
- }
- }
- if let State::FieldEnd = state {
- fields.push(new_field.clone());
- new_field.clear();
- quoted_field = false;
- }
- }
- fields.push(new_field);
- if fields.len() != 4 {
- return Err(anyhow!(
- "Error on line {}: expected 4 fields but got {}",
- line_index + 1,
- fields.len()
- ));
- }
- let id = fields[0].parse()?;
- let title = fields[1].clone();
- let author = fields[2].clone();
- let publish_year = fields[3].clone();
- output.books.insert(
- id,
- BookMetadata {
- title,
- author,
- publish_year,
- },
- );
- }
- Ok(output)
- }
- pub fn get(&self, k: &BookId) -> Option<&BookMetadata> {
- self.books.get(k)
- }
-}
-
-#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct BookMetadata {
- title: String,
- author: String,
- publish_year: String,
-}
-
-impl Display for BookMetadata {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "{} by {} ({})",
- self.title, self.author, self.publish_year
- )
- }
-}
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
-pub struct Action {
- sequence_number: u32,
- timestamp: SystemTime,
- player_id: Uuid,
- kind: ActionKind,
-}
-
-impl Action {
- pub fn new(sequence_number: u32, player_id: Uuid, kind: ActionKind) -> Self {
- Action {
- sequence_number,
- timestamp: SystemTime::now(),
- player_id,
- kind,
- }
- }
- pub fn sequence_number(&self) -> u32 {
- self.sequence_number
- }
- pub fn player_id(&self) -> Uuid {
- self.player_id
- }
- pub fn kind(&self) -> ActionKind {
- self.kind.clone()
- }
-}
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
-pub enum ActionKind {
- Join { name: String },
- Move { new_position: Vec2 },
-}
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
-pub struct Response {
- timestamp: SystemTime,
- sequence_number: u32,
- kind: ResponseKind,
-}
-
-impl Response {
- pub fn new(timestamp: SystemTime, sequence_number: u32, kind: ResponseKind) -> Self {
- Response {
- timestamp,
- sequence_number,
- kind,
- }
- }
- pub fn timestamp(&self) -> SystemTime {
- self.timestamp
- }
- pub fn sequence_number(&self) -> u32 {
- self.sequence_number
- }
- pub fn kind(&self) -> ResponseKind {
- self.kind.clone()
- }
-}
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
-pub enum ResponseKind {
- SetId { id: Uuid },
- UpdateGameState { updates: Vec<Update> },
- ReplaceGameState { new_game_state: GameState },
-}
diff --git a/common/src/game/book.rs b/common/src/game/book.rs
@@ -0,0 +1,212 @@
+use std::{
+ collections::{HashMap, hash_map::Keys},
+ fmt::{Display, Write},
+};
+
+use anyhow::{Result, anyhow};
+use lazy_static::lazy_static;
+use macroquad::math::{Rect, Vec2};
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use crate::game::RectSerde;
+
+lazy_static! {
+ pub static ref BOOKS: BookDatabase =
+ BookDatabase::from_csv(include_str!("../../assets/books.csv"))
+ .expect("Failed to parse book database");
+}
+
+pub type BookId = Uuid;
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, PartialOrd, Serialize)]
+pub struct Bookshelf {
+ bounds: RectSerde,
+}
+
+impl Bookshelf {
+ pub(crate) fn new(bounds: Rect) -> Self {
+ Self {
+ bounds: bounds.into(),
+ }
+ }
+ pub fn rect(&self) -> Rect {
+ self.bounds.into()
+ }
+ pub(crate) fn bounds(&self) -> RectSerde {
+ self.bounds
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
+pub struct FloorBook {
+ id: BookId,
+ position: Vec2,
+ collider: RectSerde,
+}
+
+impl FloorBook {
+ pub(crate) fn new(id: BookId, position: Vec2, collider: Rect) -> Self {
+ Self {
+ id,
+ position,
+ collider: collider.into(),
+ }
+ }
+ pub fn id(&self) -> BookId {
+ self.id
+ }
+ pub fn position(&self) -> Vec2 {
+ self.position
+ }
+ pub fn collider(&self) -> RectSerde {
+ self.collider
+ }
+}
+
+pub struct BookDatabase {
+ books: HashMap<BookId, BookMetadata>,
+}
+
+fn _csv_escape(input: &String) -> String {
+ if input.contains(',') {
+ format!("\"{input}\"")
+ } else {
+ input.clone()
+ }
+}
+
+impl BookDatabase {
+ const CSV_HEADER: &str = "id,title,author,publish_year";
+
+ fn new() -> Self {
+ BookDatabase {
+ books: HashMap::new(),
+ }
+ }
+ pub(crate) fn keys(&self) -> Keys<'_, BookId, BookMetadata> {
+ self.books.keys()
+ }
+ fn _to_csv(&self, output: &mut impl Write) -> Result<()> {
+ writeln!(output, "{}", BookDatabase::CSV_HEADER)?;
+ for (id, metadata) in self.books.iter() {
+ let BookMetadata {
+ title,
+ author,
+ publish_year,
+ } = metadata;
+ writeln!(
+ output,
+ "{},{},{},{}",
+ id,
+ _csv_escape(title),
+ _csv_escape(author),
+ _csv_escape(publish_year),
+ )?;
+ }
+ Ok(())
+ }
+ fn from_csv(input: &str) -> Result<Self> {
+ let mut output = BookDatabase::new();
+ for (line_index, line) in input.lines().enumerate() {
+ if line_index == 0 {
+ if line != BookDatabase::CSV_HEADER {
+ return Err(anyhow!(
+ "Invalid CSV header: expected '{}' but got '{}'",
+ BookDatabase::CSV_HEADER,
+ line
+ ));
+ }
+ continue;
+ }
+ let mut fields = vec![];
+ #[derive(Clone, Copy, Debug)]
+ enum State {
+ Start,
+ FieldData,
+ Quote,
+ FieldEnd,
+ }
+ let mut state = State::Start;
+ let mut new_field = String::new();
+ let mut quoted_field = false;
+ for c in line.chars() {
+ match (c, state) {
+ ('"', State::Start | State::FieldEnd) => {
+ quoted_field = true;
+ state = State::FieldData;
+ }
+ ('"', State::FieldData) => state = State::Quote,
+ ('"', State::Quote) => {
+ if quoted_field {
+ new_field.push('"');
+ }
+ }
+ (',', State::Start | State::FieldEnd) => state = State::FieldEnd,
+ (',', State::FieldData) => {
+ if quoted_field {
+ new_field.push(',');
+ } else {
+ state = State::FieldEnd;
+ }
+ }
+ (',', State::Quote) => {
+ if quoted_field {
+ state = State::FieldEnd;
+ }
+ }
+ (d, _) => {
+ new_field.push(d);
+ state = State::FieldData;
+ }
+ }
+ if let State::FieldEnd = state {
+ fields.push(new_field.clone());
+ new_field.clear();
+ quoted_field = false;
+ }
+ }
+ fields.push(new_field);
+ if fields.len() != 4 {
+ return Err(anyhow!(
+ "Error on line {}: expected 4 fields but got {}",
+ line_index + 1,
+ fields.len()
+ ));
+ }
+ let id = fields[0].parse()?;
+ let title = fields[1].clone();
+ let author = fields[2].clone();
+ let publish_year = fields[3].clone();
+ output.books.insert(
+ id,
+ BookMetadata {
+ title,
+ author,
+ publish_year,
+ },
+ );
+ }
+ Ok(output)
+ }
+ pub fn get(&self, k: &BookId) -> Option<&BookMetadata> {
+ self.books.get(k)
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct BookMetadata {
+ title: String,
+ author: String,
+ publish_year: String,
+}
+
+impl Display for BookMetadata {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "{} by {} ({})",
+ self.title, self.author, self.publish_year
+ )
+ }
+}
diff --git a/common/src/game/mod.rs b/common/src/game/mod.rs
@@ -0,0 +1,151 @@
+use std::{
+ collections::HashMap,
+ time::{Duration, SystemTime},
+};
+
+use macroquad::math::{Rect, Vec2};
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use crate::game::state::{GameState, Update};
+
+pub mod book;
+pub mod state;
+
+pub const TICK_DURATION: Duration = Duration::from_micros(16_667);
+
+pub type Players = HashMap<Uuid, Player>;
+
+/// Equivalent to [macroquad::math::Rect::overlaps], except using strict comparison (i.e., `<` and
+/// `>` instead of `<=` and `>=`)
+fn rects_strictly_overlap(a: &Rect, b: &Rect) -> bool {
+ a.left() < b.right() && a.right() > b.left() && a.top() < b.bottom() && a.bottom() > b.top()
+}
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub struct Player {
+ name: String,
+ position: Vec2,
+ collider: RectSerde,
+ timestamp: SystemTime,
+}
+
+impl Player {
+ fn new(name: String) -> Self {
+ Player {
+ name,
+ position: Vec2::ZERO,
+ collider: RectSerde::new(0., 0., 43.4, 40.2),
+ timestamp: SystemTime::now(),
+ }
+ }
+ pub fn name(&self) -> String {
+ self.name.clone()
+ }
+ pub fn position(&self) -> Vec2 {
+ self.position
+ }
+ pub fn collider(&self) -> RectSerde {
+ self.collider
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, PartialOrd, Serialize)]
+pub struct RectSerde {
+ x: f32,
+ y: f32,
+ w: f32,
+ h: f32,
+}
+
+impl RectSerde {
+ pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
+ RectSerde { x, y, w, h }
+ }
+ pub fn rect(&self) -> Rect {
+ (*self).into()
+ }
+}
+
+impl From<Rect> for RectSerde {
+ fn from(value: Rect) -> Self {
+ RectSerde {
+ x: value.x,
+ y: value.y,
+ w: value.w,
+ h: value.h,
+ }
+ }
+}
+
+impl From<RectSerde> for Rect {
+ fn from(value: RectSerde) -> Self {
+ Rect::new(value.x, value.y, value.w, value.h)
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub struct Action {
+ sequence_number: u32,
+ timestamp: SystemTime,
+ player_id: Uuid,
+ kind: ActionKind,
+}
+
+impl Action {
+ pub fn new(sequence_number: u32, player_id: Uuid, kind: ActionKind) -> Self {
+ Action {
+ sequence_number,
+ timestamp: SystemTime::now(),
+ player_id,
+ kind,
+ }
+ }
+ pub fn sequence_number(&self) -> u32 {
+ self.sequence_number
+ }
+ pub fn player_id(&self) -> Uuid {
+ self.player_id
+ }
+ pub fn kind(&self) -> ActionKind {
+ self.kind.clone()
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub enum ActionKind {
+ Join { name: String },
+ Move { new_position: Vec2 },
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub struct Response {
+ timestamp: SystemTime,
+ sequence_number: u32,
+ kind: ResponseKind,
+}
+
+impl Response {
+ pub fn new(timestamp: SystemTime, sequence_number: u32, kind: ResponseKind) -> Self {
+ Response {
+ timestamp,
+ sequence_number,
+ kind,
+ }
+ }
+ pub fn timestamp(&self) -> SystemTime {
+ self.timestamp
+ }
+ pub fn sequence_number(&self) -> u32 {
+ self.sequence_number
+ }
+ pub fn kind(&self) -> ResponseKind {
+ self.kind.clone()
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub enum ResponseKind {
+ SetId { id: Uuid },
+ UpdateGameState { updates: Vec<Update> },
+ ReplaceGameState { new_game_state: GameState },
+}
diff --git a/common/src/game/state.rs b/common/src/game/state.rs
@@ -0,0 +1,178 @@
+use std::{collections::HashMap, time::SystemTime};
+
+use macroquad::{
+ math::{Rect, Vec2},
+ rand::{rand, srand},
+};
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use crate::game::{
+ Action, ActionKind, Player, Players,
+ book::{BOOKS, Bookshelf, FloorBook},
+ rects_strictly_overlap,
+};
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub struct GameState {
+ timestamp: SystemTime,
+ players: Players,
+ bookshelves: Vec<Bookshelf>,
+ floor_books: Vec<FloorBook>,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+pub enum Update {
+ InsertPlayer { id: Uuid, player: Player },
+ RemovePlayer { player_id: Uuid },
+}
+
+impl GameState {
+ pub const MAX_PLAYER_SPEED_PX_S: f32 = 75.;
+
+ pub fn update(&mut self, update: &Update) {
+ match update {
+ Update::InsertPlayer { id, player } => self.players.insert(*id, player.clone()),
+ Update::RemovePlayer { player_id } => self.players.remove(player_id),
+ };
+ }
+ pub fn apply(&mut self, action: &Action) {
+ match action.kind() {
+ ActionKind::Join { name } => {
+ self.players.insert(action.player_id, Player::new(name));
+ }
+ ActionKind::Move { new_position } => {
+ if let Some(player) = self.players.get_mut(&action.player_id)
+ && let Ok(dt) = action.timestamp.duration_since(player.timestamp)
+ {
+ let velocity = (new_position - player.position) / dt.as_secs_f32();
+ let limited_velocity = if velocity.length() > GameState::MAX_PLAYER_SPEED_PX_S {
+ velocity.normalize() * GameState::MAX_PLAYER_SPEED_PX_S
+ } else {
+ velocity
+ };
+ player.position += limited_velocity * dt.as_secs_f32();
+
+ // Handle collisions with bookshelves
+ let player_collider = player.collider.rect().offset(player.position);
+ for bookshelf in self.bookshelves.iter() {
+ // 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 (a, b) = (bookshelf_collider, player_collider);
+ if rects_strictly_overlap(&a, &b) {
+ // Compute the required motion in each possible direction and select the
+ // smallest among them
+ let up = Vec2::new(0., a.top() - b.bottom());
+ let down = Vec2::new(0., a.bottom() - b.top());
+ let left = Vec2::new(a.left() - b.right(), 0.);
+ let right = Vec2::new(a.right() - b.left(), 0.);
+ let min_y = if up.y.abs() < down.y.abs() { up } else { down };
+ let min_x = if right.x.abs() < left.x.abs() {
+ right
+ } else {
+ left
+ };
+ let min = if min_x.x.abs() < min_y.y.abs() {
+ min_x
+ } else {
+ min_y
+ };
+ player.position += min;
+ }
+ }
+ }
+ }
+ }
+ }
+ pub fn interpolate(
+ old: &Self,
+ new: &Self,
+ basis: SystemTime,
+ local_player_id: Option<Uuid>,
+ ) -> GameState {
+ let mut output = GameState::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
+ {
+ output.players.insert(*new_id, new_player.clone());
+ } else {
+ let span = new.timestamp.duration_since(old.timestamp);
+ let fraction = SystemTime::now().duration_since(basis);
+ let t = match (fraction, span) {
+ (Ok(f), Ok(s)) => (f.as_secs_f32() / s.as_secs_f32()).clamp(0., 1.),
+ _ => return new.clone(),
+ };
+ let interpolated_player = Player {
+ position: (new_player.position() - old_player.position()) * t
+ + old_player.position(),
+ ..new_player.clone()
+ };
+ output.players.insert(*new_id, interpolated_player);
+ }
+ } else {
+ output.players.insert(*new_id, new_player.clone());
+ }
+ }
+ output
+ }
+ pub fn get_player(&self, id: &Uuid) -> Option<&Player> {
+ self.players.get(id)
+ }
+ pub fn get_player_mut(&mut self, id: &Uuid) -> Option<&mut Player> {
+ self.players.get_mut(id)
+ }
+ pub fn players(&self) -> &HashMap<Uuid, Player> {
+ &self.players
+ }
+ pub fn insert_player(&mut self, id: Uuid, player: Player) -> Option<Player> {
+ self.players.insert(id, player)
+ }
+ pub fn remove_player(&mut self, id: &Uuid) -> Option<Player> {
+ self.players.remove(id)
+ }
+ pub fn set_timestamp(&mut self, timestamp: SystemTime) {
+ self.timestamp = timestamp;
+ }
+ pub fn get_timestamp(&self) -> SystemTime {
+ self.timestamp
+ }
+ pub fn update_timestamp(&mut self) {
+ self.set_timestamp(SystemTime::now());
+ }
+ pub fn bookshelves(&self) -> &Vec<Bookshelf> {
+ self.bookshelves.as_ref()
+ }
+ pub fn floor_books(&self) -> &Vec<FloorBook> {
+ self.floor_books.as_ref()
+ }
+}
+
+impl Default for GameState {
+ fn default() -> Self {
+ 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.)),
+ ],
+ floor_books: {
+ srand(0);
+ BOOKS
+ .keys()
+ .map(|&id| {
+ FloorBook::new(
+ id,
+ Vec2::new((rand() % 750) as f32, (rand() % 750) as f32),
+ Rect::new(0., 0., 30., 23.6),
+ )
+ })
+ .collect()
+ },
+ }
+ }
+}
diff --git a/server/src/main.rs b/server/src/main.rs
@@ -5,7 +5,10 @@ use std::{
time::Instant,
};
-use common::game::{ActionKind, GameState, Response, ResponseKind, TICK_DURATION, Update};
+use common::game::{
+ ActionKind, Response, ResponseKind, TICK_DURATION,
+ state::{GameState, Update},
+};
use uuid::Uuid;
use crate::net::listen;