commit 8bd43575af176dcf239b6e1a5ed33d16a67ebffd
parent 05567c02bf2b514629f80628601e7b61691f5aff
Author: Bradley Gannon <bradley@bradleygannon.com>
Date: Sun, 30 Aug 2026 18:11:37 -0400
Add ClientState and main menu
Diffstat:
2 files changed, 259 insertions(+), 109 deletions(-)
diff --git a/client/src/main.rs b/client/src/main.rs
@@ -1,140 +1,289 @@
use std::{
collections::VecDeque,
- sync::mpsc::channel,
+ net::SocketAddr,
+ sync::mpsc::{Receiver, Sender, channel},
thread,
- time::{Instant, SystemTime},
+ time::{Duration, Instant, SystemTime},
};
-use common::game::{Action, ActionKind, GameState, ResponseKind};
-use macroquad::prelude::*;
+use common::game::{Action, ActionKind, GameState, Response, ResponseKind};
+use macroquad::{
+ prelude::*,
+ ui::{
+ hash, root_ui,
+ widgets::{Button, InputText, Label},
+ },
+};
+use uuid::Uuid;
use crate::{
game::handle_movement_input,
net::handle_connection,
- render::{Camera, SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX, Spritesheet, follow_local_player, render},
+ render::{
+ Camera, SCREEN_HEIGHT_PX, SCREEN_WIDTH_PX, Spritesheet, follow_local_player, render_game,
+ },
};
pub mod game;
pub mod net;
pub mod render;
+#[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>>,
+ player_name: String,
+ server: String,
+ 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,
+ player_name: String::new(),
+ server: String::new(),
+ 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.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,
+ )
+ }
+}
+
#[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 (client_to_server_tx, client_to_server_rx) = channel();
- let (server_to_client_tx, server_to_client_rx) = channel();
- thread::spawn(|| {
- handle_connection(
- "127.0.0.1:6767".parse().unwrap(),
- server_to_client_tx,
- client_to_server_rx,
- )
- });
-
- let mut camera = Camera::default();
- let mut game_state = GameState::default();
- let mut old_game_state = GameState::default();
- let mut local_player_id = None;
- let mut sequence_number = 0;
- let mut unresponded_actions = VecDeque::new();
- let mut interpolation_basis = SystemTime::now();
- let mut debug_view = cfg!(debug_assertions);
- let mut t0;
- let mut t1 = Instant::now();
- let mut dt;
+ let mut client = ClientState::default();
+ let mut first_frame = true;
loop {
- t0 = t1;
- t1 = Instant::now();
- dt = t1.duration_since(t0);
-
- let mut response_count: u8 = 0;
- let mut latest_sequence_number = 0;
- for response in server_to_client_rx.try_iter() {
- 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 {
- old_game_state = game_state.clone();
- }
- latest_sequence_number = latest_sequence_number.max(response.sequence_number());
- match response.kind() {
- ResponseKind::SetId { id } => {
- let action = Action::new(
- sequence_number,
- id,
- ActionKind::Join {
- name: id.to_string().chars().take(3).collect(),
- },
- );
- if client_to_server_tx.send(action.clone()).is_err() {
- return;
+ 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.player_name);
+ InputText::new(hash!())
+ .label("server")
+ .position(menu_top_left)
+ .size(ELEMENT_SIZE)
+ .margin(Vec2::ZERO)
+ .ui(&mut ui, &mut client.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.server.parse::<SocketAddr>() {
+ Some(addr)
+ } else if client.player_name.is_empty() {
+ client.menu_error = "enter your name pls".to_string();
+ None
+ } else {
+ client.menu_error = "invalid server :-\\".to_string();
+ None
}
- unresponded_actions.push_back(action);
- sequence_number = sequence_number.wrapping_add(1);
- local_player_id = Some(id);
+ } 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);
}
- ResponseKind::UpdateGameState { updates } => {
- for update in updates {
- game_state.update(&update);
+ if let Some(addr) = addr {
+ 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());
}
- game_state.set_timestamp(response.timestamp());
}
- ResponseKind::ReplaceGameState { new_game_state } => game_state = new_game_state,
- };
- }
- // If there were any server responses, then do server reconciliation and client prediction
- if response_count > 0 {
- // Server reconciliation: drop any unresponded actions whose sequence numbers are less
- // than the number we just got from the server
- if let Some(new_front_index) = unresponded_actions
- .iter()
- .position(|ua: &Action| ua.sequence_number() >= latest_sequence_number)
- {
- for _ in 0..=new_front_index {
- unresponded_actions.pop_front();
+
+ // If there were any server responses, then do server reconciliation and client
+ // prediction
+ if response_count > 0 {
+ client.reconcile(latest_sequence_number);
+ client.predict();
}
- }
- // Client prediction: reapply the remaining unresponded actions to the latest server
- // game state
- interpolation_basis = SystemTime::now();
- for unresponded_action in unresponded_actions.iter() {
- game_state.apply(unresponded_action);
- }
- }
- if let Some(id) = local_player_id
- && let Some(local_player) = game_state.get_player_mut(&id)
- {
- if let Some(action_kind) = handle_movement_input(local_player, &dt) {
- let action = Action::new(sequence_number, id, action_kind);
- game_state.apply(&action);
- if client_to_server_tx.send(action.clone()).is_err() {
- return;
+ 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);
}
- unresponded_actions.push_back(action);
- sequence_number = sequence_number.wrapping_add(1);
+
+ // 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,
+ );
}
- follow_local_player(&mut camera, &game_state, &spritesheet, &id);
- }
- let interpolated_game_state = GameState::interpolate(
- &old_game_state,
- &game_state,
- interpolation_basis,
- local_player_id,
- );
- if is_key_pressed(KeyCode::F3) {
- debug_view = !debug_view;
}
- render(
- &camera,
- &interpolated_game_state,
- &spritesheet,
- debug_view,
- local_player_id,
- );
+ first_frame = false;
next_frame().await;
}
}
diff --git a/client/src/render.rs b/client/src/render.rs
@@ -7,7 +7,7 @@ use uuid::Uuid;
pub const SCREEN_WIDTH_PX: f32 = 1280.;
pub const SCREEN_HEIGHT_PX: f32 = 720.;
-#[derive(Debug, Default)]
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct Camera {
position: Vec2,
}
@@ -81,7 +81,7 @@ impl Index<SpriteIndex> for Spritesheet {
}
}
-pub(crate) fn render(
+pub(crate) fn render_game(
camera: &Camera,
game: &GameState,
spritesheet: &Spritesheet,
@@ -108,9 +108,10 @@ pub(crate) fn render(
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 + 12.,
+ position_camera.x + player_sprite.size.x / 2. - name_plate_dimensions.width / 2.,
position_camera.y + player_sprite.size.y + 16.,
16.,
BLACK,