From 7c235d6d612d70f6688a0429e0b6784215e749c2 Mon Sep 17 00:00:00 2001 From: copygirl Date: Thu, 13 Nov 2025 03:18:58 +0100 Subject: [PATCH] Sync client-to-server block changes --- client/src/assets/block.rs | 2 +- client/src/input/client_inputs.rs | 49 ++++++++++++- client/src/main.rs | 14 +--- client/src/placement.rs | 46 ------------- common/src/block.rs | 83 +++++++++++++++++++---- common/src/network/client_webtransport.rs | 5 +- common/src/network/protocol.rs | 11 +++ common/src/player.rs | 31 ++++++++- 8 files changed, 166 insertions(+), 75 deletions(-) delete mode 100644 client/src/placement.rs diff --git a/client/src/assets/block.rs b/client/src/assets/block.rs index e0c31fa..22562da 100644 --- a/client/src/assets/block.rs +++ b/client/src/assets/block.rs @@ -27,8 +27,8 @@ impl FromWorld for BlockAssets { fn insert_block_visuals( event: On, - mut commands: Commands, block_assets: Res, + mut commands: Commands, ) { let block = event.entity; commands.entity(block).insert(( diff --git a/client/src/input/client_inputs.rs b/client/src/input/client_inputs.rs index 2a8c597..0c2ea77 100644 --- a/client/src/input/client_inputs.rs +++ b/client/src/input/client_inputs.rs @@ -11,7 +11,7 @@ use bevy::window::{CursorGrabMode, CursorOptions}; pub fn plugin(app: &mut App) { app.add_systems( FixedPreUpdate, - buffer_input.in_set(InputSystems::WriteClientInputs), + (buffer_input, buffer_action).in_set(InputSystems::WriteClientInputs), ); } @@ -43,3 +43,50 @@ fn buffer_input( } } } + +// TODO: Use picking system instead of manually raycasting. +pub fn buffer_action( + buttons: Res>, + mut player: Single<&mut ActionState, With>>, + cursor: Single<&CursorOptions>, + window: Single<(&Window, &CursorOptions)>, + camera: Single<(&GlobalTransform, &Camera)>, + mut ray_cast: MeshRayCast, + blocks: Blocks, +) { + player.0 = Action::None; + + if cursor.grab_mode == CursorGrabMode::None { + return; + } + if !buttons.any_just_pressed([MouseButton::Right, MouseButton::Left]) { + return; + } + + let (window, cursor) = window.into_inner(); + let (cam_transform, camera) = camera.into_inner(); + + let ray = if cursor.grab_mode == CursorGrabMode::Locked { + Ray3d::new(cam_transform.translation(), cam_transform.forward()) + } else if let Some(cursor_pos) = window.cursor_position() { + camera.viewport_to_world(cam_transform, cursor_pos).unwrap() + } else { + return; // cursor outside window area + }; + + let settings = MeshRayCastSettings::default(); + let Some((block, hit)) = ray_cast.cast_ray(ray, &settings).first() else { + return; // ray didn't hit anything + }; + let Some(block_pos) = blocks.position(*block) else { + return; // entity hit is not a block + }; + + player.0 = if buttons.just_pressed(MouseButton::Right) { + // FIXME: This only works for axis-aligned normals. + let offset = hit.normal.normalize().round().as_ivec3(); + Action::PlaceBlock(block_pos + offset) + } else { + Action::BreakBlock(block_pos) + }; +} diff --git a/client/src/main.rs b/client/src/main.rs index a0c2f35..71cd607 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -11,13 +11,9 @@ use lightyear::prelude::server::Started; mod args; mod assets; mod input; -mod placement; mod ui; use args::*; -use placement::*; - -use input::is_cursor_grabbed; #[derive(States, Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum Screen { @@ -67,15 +63,6 @@ fn main() -> Result { (setup_scene, start_server_or_connect), ); - // `place_break_blocks` requires the camera's `GlobalTransform`. - // For a most up-to-date value, run it after that's been updated. - app.add_systems( - PostUpdate, - place_break_blocks - .after(TransformSystems::Propagate) - .run_if(in_state(Screen::Gameplay).and(is_cursor_grabbed)), - ); - app.add_observer(spawn_initial_blocks); app.add_observer(handle_predicted_player_spawn); @@ -129,6 +116,7 @@ fn handle_predicted_player_spawn(event: On, mut commands: Comman commands.entity(player).insert(( // Handle inputs on this entity. InputMarker::::default(), + InputMarker::::default(), // TODO: Attach camera to player head eventually. Camera3d::default(), )); diff --git a/client/src/placement.rs b/client/src/placement.rs deleted file mode 100644 index d093a9b..0000000 --- a/client/src/placement.rs +++ /dev/null @@ -1,46 +0,0 @@ -use bevy::prelude::*; -use common::prelude::*; - -use bevy::window::{CursorGrabMode, CursorOptions}; - -// TODO: Use picking system instead of manually raycasting. - -pub fn place_break_blocks( - mut commands: Commands, - mut blocks: Blocks, - mut ray_cast: MeshRayCast, - mouse_button_input: Res>, - window: Single<(&Window, &CursorOptions)>, - camera: Single<(&GlobalTransform, &Camera)>, -) { - let (window, cursor) = window.into_inner(); - let (cam_transform, camera) = camera.into_inner(); - - let ray = if cursor.grab_mode == CursorGrabMode::Locked { - Ray3d::new(cam_transform.translation(), cam_transform.forward()) - } else if let Some(cursor_pos) = window.cursor_position() { - camera.viewport_to_world(cam_transform, cursor_pos).unwrap() - } else { - return; // cursor outside window area - }; - - let settings = &MeshRayCastSettings::default(); - let Some((block, hit)) = ray_cast.cast_ray(ray, settings).first() else { - return; // ray didn't hit anything - }; - let Some(block_pos) = blocks.position(*block) else { - return; // entity hit is not a block - }; - - if mouse_button_input.just_pressed(MouseButton::Left) { - // Destroy the block clicked. - commands.entity(*block).despawn(); - } else if mouse_button_input.just_pressed(MouseButton::Right) { - // Create a new block next to the one that was just clicked. - - // FIXME: This only works for axis-aligned normals. - let offset = hit.normal.normalize().round().as_ivec3(); - - blocks.spawn(block_pos + offset); - } -} diff --git a/common/src/block.rs b/common/src/block.rs index 8001c02..ba2659c 100644 --- a/common/src/block.rs +++ b/common/src/block.rs @@ -2,29 +2,88 @@ use bevy::prelude::*; use lightyear::prelude::*; use bevy::ecs::system::SystemParam; +use bevy::platform::collections::HashMap; use serde::{Deserialize, Serialize}; -#[derive(Component, PartialEq, Deserialize, Serialize)] -pub struct Block; +pub(crate) fn plugin(app: &mut App) { + app.init_resource::(); + app.add_observer(block_added); + app.add_observer(block_removed); +} + +#[derive(Component, Deserialize, Serialize, PartialEq)] +pub struct Block(pub IVec3); + +#[derive(Resource, Default)] +pub struct BlockMap(HashMap); #[derive(SystemParam)] pub struct Blocks<'w, 's> { + map: Res<'w, BlockMap>, + blocks: Query<'w, 's, &'static Block>, commands: Commands<'w, 's>, - blocks: Query<'w, 's, &'static Transform, With>, } impl Blocks<'_, '_> { - pub fn spawn(&mut self, pos: IVec3) { - self.commands.spawn(( - Block, - Transform::from_translation(pos.as_vec3() + Vec3::ONE / 2.), - Replicate::to_clients(NetworkTarget::All), - )); + /// Gets the block [`Entity`] at the given position, if any. + pub fn get(&self, pos: IVec3) -> Option { + self.map.0.get(&pos).copied() + } + + /// Gets an [`EntityCommands`] for the block at the given position, if any. + pub fn entity(&mut self, pos: IVec3) -> Option { + self.get(pos).map(|block| self.commands.entity(block)) + } + + /// Spawns a block at the given position. + pub fn spawn(&mut self, pos: IVec3) -> EntityCommands { + self.commands.spawn(Block(pos)) + } + + /// Tries to spawn a block at the given position, as long as there isn't one already. + /// If there already is a block, its [`Entity`] is returned as the `Err` variant. + pub fn try_spawn(&mut self, pos: IVec3) -> Result { + if let Some(existing) = self.get(pos) { + Err(existing) + } else { + Ok(self.spawn(pos)) + } } - /// Gets the position of a block entity, or `None` if not a block. + /// Despawns the block at the given position, returning if successful. + pub fn despawn(&mut self, pos: IVec3) -> bool { + self.entity(pos).map(|mut block| block.despawn()).is_some() + } + + /// Gets the position of the given block [`Entity`], if it exists and is a [`Block`]. pub fn position(&self, entity: Entity) -> Option { - let transform = self.blocks.get(entity).ok(); - transform.map(|t| t.translation.floor().as_ivec3()) + self.blocks.get(entity).ok().map(|block| block.0) + } +} + +fn block_added( + event: On, + blocks: Query<&Block>, + mut map: ResMut, + server: Option>, + mut commands: Commands, +) { + let block = event.entity; + let pos = blocks.get(block).unwrap().0; + if map.0.insert(pos, block).is_some() { + // TODO: This IS going to happen occasionally. + warn!("Duplicate block at pos {pos}"); } + + let mut entity = commands.entity(block); + entity.insert(Transform::from_translation(pos.as_vec3() + Vec3::ONE / 2.)); + if server.is_some() { + entity.insert(Replicate::to_clients(NetworkTarget::All)); + } +} + +fn block_removed(event: On, blocks: Query<&Block>, mut map: ResMut) { + let block = event.entity; + let pos = blocks.get(block).unwrap().0; + map.0.remove(&pos); } diff --git a/common/src/network/client_webtransport.rs b/common/src/network/client_webtransport.rs index 34e1a3b..728b9c3 100644 --- a/common/src/network/client_webtransport.rs +++ b/common/src/network/client_webtransport.rs @@ -28,9 +28,12 @@ impl Command for ConnectWebTransportCommand { .spawn(( Client::default(), Name::from("Client"), + // configuration + ReplicationReceiver::default(), + PredictionManager::default(), + // connection LocalAddr(client_addr), PeerAddr(self.server_addr), - ReplicationReceiver::default(), WebTransportClientIo { certificate_digest }, RawClient, )) diff --git a/common/src/network/protocol.rs b/common/src/network/protocol.rs index ead826e..4a2b136 100644 --- a/common/src/network/protocol.rs +++ b/common/src/network/protocol.rs @@ -9,7 +9,9 @@ use crate::block::Block; use crate::player::{HeadOrientation, Player}; pub(super) fn plugin(app: &mut App) { + app.add_plugins(crate::block::plugin); app.add_plugins(InputPlugin::::default()); + app.add_plugins(InputPlugin::::default()); // marker components app.register_component::(); @@ -21,6 +23,7 @@ pub(super) fn plugin(app: &mut App) { // unified update systems app.add_systems(FixedUpdate, crate::player::movement); + app.add_systems(FixedUpdate, crate::player::placement); } #[derive(Default, MapEntities, Deserialize, Serialize, Reflect, Clone, PartialEq, Debug)] @@ -31,6 +34,14 @@ pub struct Inputs { pub down: bool, } +#[derive(Default, MapEntities, Deserialize, Serialize, Reflect, Clone, PartialEq, Debug)] +pub enum Action { + #[default] + None, + PlaceBlock(IVec3), + BreakBlock(IVec3), +} + fn interpolate_transform(start: Transform, other: Transform, t: f32) -> Transform { Transform { translation: start.translation.lerp(other.translation, t), diff --git a/common/src/player.rs b/common/src/player.rs index 2f9012d..f56e57e 100644 --- a/common/src/player.rs +++ b/common/src/player.rs @@ -6,7 +6,8 @@ use lightyear::prelude::*; use lightyear::prelude::input::native::ActionState; use serde::{Deserialize, Serialize}; -use crate::network::protocol::Inputs; +use crate::block::Blocks; +use crate::network::protocol::{Action, Inputs}; const MOVEMENT_SPEED: f32 = 5.0; const PITCH_LIMIT: f32 = TAU / 4.0; @@ -93,3 +94,31 @@ pub fn movement( transform.rotation = orientation.to_quat(); } } + +pub fn placement( + players: Query< + &mut ActionState, + (With, Or<(With, With)>), + >, + mut blocks: Blocks, +) { + for mut action in players { + match **action { + Action::None => { + // do nothing + } + Action::PlaceBlock(pos) => { + if let Ok(mut block) = blocks.try_spawn(pos) { + // TODO: Use `default_with_salt` with the player's `client_id`? + block.insert(PreSpawned::default()); + } + } + Action::BreakBlock(pos) => { + blocks.despawn(pos); + } + } + // HACK: Ensure an action will only run once? + // Probably won't be relevant once we move to BEI. + action.0 = Action::None; + } +}