diff --git a/crates/inputs/input_bei/src/marker.rs b/crates/inputs/input_bei/src/marker.rs index 4218207b5..ede392589 100644 --- a/crates/inputs/input_bei/src/marker.rs +++ b/crates/inputs/input_bei/src/marker.rs @@ -1,12 +1,11 @@ //! Add an [`InputMarker`] component automatically to [`Action`] entities that need it -use crate::setup::NetworkActionOf; use bevy_ecs::prelude::*; use bevy_ecs::relationship::Relationship; use bevy_enhanced_input::prelude::*; use bevy_replicon::client::confirm_history::ConfirmHistory; use lightyear_connection::client::Client; -use lightyear_replication::prelude::{Controlled, ControlledBy, HasAuthority}; +use lightyear_replication::prelude::{Controlled, ControlledBy}; /// Marker component that indicates that the entity is actively listening for physical user inputs. /// @@ -40,18 +39,17 @@ fn action_targets_local_client( /// Propagate the InputMarker component from the Context entity to the Action entities /// whenever an InputMarker is added to a Context entity. -/// Skip replicated action entities (those received from remote clients). +/// +/// `InputMarker` on the context is the explicit local-input signal, so +/// confirmed prespawned owner actions should still receive it. Remote +/// rebroadcasted actions should be attached to contexts without this marker. pub(crate) fn propagate_input_marker( trigger: On>, actions: Query<&Actions>, - confirm: Query<(), With>, mut commands: Commands, ) { if let Ok(actions) = actions.get(trigger.entity) { actions.iter().for_each(|action| { - if confirm.get(action).is_ok() { - return; - } commands.entity(action).insert(InputMarker::::default()); }); } @@ -59,113 +57,65 @@ pub(crate) fn propagate_input_marker( /// When an Action entity is added to a Context entity that has an InputMarker, /// add the InputMarker to the Action entity as well. -/// Skip replicated entities — those are received from remote clients and should not -/// be marked as local input sources. pub(crate) fn add_input_marker_from_parent( trigger: On>, - action_of: Query<&ActionOf, Without>, + action_of: Query<&ActionOf>, context: Query<(), With>>, mut commands: Commands, ) { - if let Ok(action_of) = action_of.get(trigger.entity) - && context.get(action_of.get()).is_ok() - { + let Ok(action_of) = action_of.get(trigger.entity) else { + return; + }; + if context.get(action_of.get()).is_ok() { commands .entity(trigger.entity) .insert(InputMarker::::default()); } } -/// If Bindings or ActionMock is added to an Action entity, add the InputMarker to that Action entity. -/// Only add the marker on locally controlled action entities that already have a network-facing -/// action mapping. This avoids emitting inputs for entities that the server cannot resolve yet. +/// If Bindings or ActionMock is added to an Action entity, add the InputMarker +/// to that Action entity once the action has a network-resolvable identity. +/// +/// Replicated/prespawned actions become resolvable when [`ConfirmHistory`] is +/// present, because the client's action entity can then be mapped back to the +/// server action entity when an input message is sent. pub(crate) fn add_input_marker_from_binding( trigger: On, - action: Query< - &ActionOf, - ( - With>, - With>, - Without, - ), - >, + action: Query<&ActionOf, (With, Without>)>, contexts: Query, With>, clients: Query<(), With>, mut commands: Commands, ) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { + let Ok(action_of) = action.get(trigger.entity) else { + return; + }; + if action_targets_local_client(action_of, &contexts, &clients) { commands .entity(trigger.entity) .insert(InputMarker::::default()); } } -/// If authority is granted after the action entity already exists, add the InputMarker -/// once the entity becomes locally controlled. -pub(crate) fn add_input_marker_from_authority( - trigger: On, - action: Query< - &ActionOf, - ( - With>, - With>, - Or<(With, With)>, - Without, - ), - >, - contexts: Query, With>, - clients: Query<(), With>, - mut commands: Commands, -) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { - commands - .entity(trigger.entity) - .insert(InputMarker::::default()); - } -} - -/// If the network-facing action mapping becomes available after the entity already has bindings, -/// start treating it as a local input source at that point. -pub(crate) fn add_input_marker_from_network_action( - trigger: On>, +/// If an existing bound action becomes network-resolvable, add the InputMarker +/// once it targets the local client. +pub(crate) fn add_input_marker_when_action_becomes_ready( + trigger: On, action: Query< &ActionOf, ( - With>, - With>, + With, Or<(With, With)>, - Without, + Without>, ), >, contexts: Query, With>, clients: Query<(), With>, mut commands: Commands, ) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { - commands - .entity(trigger.entity) - .insert(InputMarker::::default()); - } -} - -/// If a prespawned action entity is confirmed but still targets a locally controlled context, -/// keep using it as a local input source. -pub(crate) fn add_input_marker_from_confirmed_controlled_action( - trigger: On, - action: Query<&ActionOf, (With, Or<(With, With)>)>, - contexts: Query, With>, - clients: Query<(), With>, - mut commands: Commands, -) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { + let Ok(action_of) = action.get(trigger.entity) else { + return; + }; + if action_targets_local_client(action_of, &contexts, &clients) { commands .entity(trigger.entity) .insert(InputMarker::::default()); diff --git a/crates/inputs/input_bei/src/plugin.rs b/crates/inputs/input_bei/src/plugin.rs index fef2e148a..99e018ab5 100644 --- a/crates/inputs/input_bei/src/plugin.rs +++ b/crates/inputs/input_bei/src/plugin.rs @@ -1,9 +1,16 @@ #[cfg(any(feature = "client", feature = "server"))] use crate::input_message::{BEIBuffer, BEIStateSequence}; -use crate::setup::InputRegistryPlugin; -use bevy_app::{PreUpdate, prelude::*}; +#[cfg(feature = "client")] +use crate::setup::resolve_pending_action_of; +#[cfg(any(feature = "client", feature = "server"))] +use crate::setup::{ + InputRegistryPlugin, deserialize_action_of, remove_action_of, serialize_action_of, + write_action_of, +}; +use bevy_app::prelude::*; use bevy_ecs::prelude::*; +#[cfg(any(feature = "client", feature = "server"))] use bevy_ecs::schedule::IntoScheduleConfigs; #[cfg(all(feature = "client", feature = "server"))] use bevy_ecs::schedule::common_conditions::not; @@ -12,10 +19,9 @@ use bevy_enhanced_input::EnhancedInputSystems; #[cfg(feature = "client")] use bevy_enhanced_input::action::TriggerState; use bevy_enhanced_input::context::InputContextAppExt; -#[cfg(any(feature = "client", feature = "server"))] use bevy_enhanced_input::prelude::ActionOf; use bevy_reflect::TypePath; -use bevy_replicon::prelude::{AppRuleExt, ReplicationMode, RuleFns}; +use bevy_replicon::prelude::{AppMarkerExt, AppRuleExt, ReplicationMode, RuleFns}; use bevy_replicon::shared::replication::registry::receive_fns::MutWrite; use core::fmt::Debug; #[cfg(feature = "client")] @@ -23,8 +29,6 @@ use lightyear_core::prelude::is_in_rollback; #[cfg(feature = "client")] use lightyear_inputs::client::InputSystems; use lightyear_inputs::config::InputConfig; -use lightyear_messages::plugin::MessageSystems; -use lightyear_replication::ReplicationSystems; use serde::Serialize; use serde::de::DeserializeOwned; @@ -45,14 +49,37 @@ use serde::de::DeserializeOwned; /// # Action entities /// /// BEI uses separate "action entities" with [`ActionOf`] to represent -/// individual actions. These entities need to exist on both client and server. -/// The recommended approach is to use [`PreSpawned`] so both sides spawn them -/// independently and match via a deterministic hash — this avoids the need -/// for client-to-server entity replication. +/// individual actions. In the server-authoritative flow, spawn those action +/// entities on the server and replicate them to clients along with the context +/// entity. The owning client should add local-only [`Bindings`] once its local +/// controlled context has the replicated [`Action`] entity in its +/// [`ActionOf`]/`Actions` relationship. +/// +/// Replicating the action entity is also what lets remote clients receive +/// rebroadcasted BEI input. Rebroadcasted [`BEIStateSequence`] messages target +/// action entities, so a remote client needs a corresponding replicated action +/// entity to resolve the target and buffer the remote player's input state. /// +/// The replicated [`Action`] component is structural: it recreates the typed BEI +/// action entity on the receiver, but does not carry runtime input state. The +/// action relationship is replicated directly through [`ActionOf`]. +/// Lightyear uses a custom receive path for [`ActionOf`] so the context +/// entity is only inserted into Bevy's relationship component once Replicon's +/// server-to-client entity map already contains it. If the action arrives first, +/// the relationship is held as a local pending marker and resolved after the +/// context mapping is available. +/// Live action state is sent by [`BEIStateSequence`] input messages. The owning +/// client adds [`InputMarker`] to local action entities, buffers BEI trigger +/// state/value/time each tick, and sends those snapshots to the server. If input +/// rebroadcasting is enabled, the server forwards those input messages to other +/// clients so they can update remote action buffers for prediction. +/// +/// [`Action`]: bevy_enhanced_input::prelude::Action +/// [`Bindings`]: bevy_enhanced_input::prelude::Bindings /// [`BEIStateSequence`]: crate::input_message::BEIStateSequence /// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf -/// [`PreSpawned`]: lightyear_replication::prelude::PreSpawned +/// [`InputMarker`]: crate::marker::InputMarker +/// [`Replicate`]: lightyear_replication::prelude::Replicate pub struct InputPlugin { pub config: InputConfig, } @@ -91,37 +118,16 @@ impl< app.add_input_context_to::(); // we register the context C entity so that it can be replicated from the server to the client app.replicate::(); - - // We mirror ActionOf into a separate component that stores the authoritative - // remote entity. That avoids depending on sender-side entity mapping in replicon's - // SerializeCtx. app.replicate_with(( - RuleFns::new( - crate::setup::serialize_network_action_of::, - crate::setup::deserialize_network_action_of::, - ), - ReplicationMode::default(), - )); - app.add_observer(InputRegistryPlugin::mirror_action_of_for_replication::); - app.add_observer(InputRegistryPlugin::insert_action_of_from_network::); - app.add_systems( - PreUpdate, - ( - InputRegistryPlugin::resolve_pending_network_action_of::, - InputRegistryPlugin::resolve_pending_action_of::, - ) - .chain() - .after(ReplicationSystems::Receive) - .before(MessageSystems::Receive), - ); - + RuleFns::new(serialize_action_of::, deserialize_action_of::), + ReplicationMode::Once, + )) + .set_receive_fns::>(write_action_of::, remove_action_of::); #[cfg(feature = "client")] { use crate::marker::{ - add_input_marker_from_authority, add_input_marker_from_binding, - add_input_marker_from_confirmed_controlled_action, - add_input_marker_from_network_action, add_input_marker_from_parent, - propagate_input_marker, + add_input_marker_from_binding, add_input_marker_from_parent, + add_input_marker_when_action_becomes_ready, propagate_input_marker, }; // for rebroadcasting inputs, we insert TriggerState (which inserts the InputBuffer) when ActionOf is added // on an entity @@ -130,9 +136,8 @@ impl< app.add_observer(propagate_input_marker::); app.add_observer(add_input_marker_from_parent::); app.add_observer(add_input_marker_from_binding::); - app.add_observer(add_input_marker_from_authority::); - app.add_observer(add_input_marker_from_network_action::); - app.add_observer(add_input_marker_from_confirmed_controlled_action::); + app.add_observer(add_input_marker_when_action_becomes_ready::); + app.add_systems(PreUpdate, resolve_pending_action_of::); if self.config.rebroadcast_inputs { app.add_observer(InputRegistryPlugin::on_rebroadcast_action_received::); @@ -147,8 +152,6 @@ impl< ); } - app.add_observer(InputRegistryPlugin::add_action_of_replicate::); - app.add_plugins(lightyear_inputs::client::ClientInputPlugin::< BEIStateSequence, >::new(self.config)); diff --git a/crates/inputs/input_bei/src/setup.rs b/crates/inputs/input_bei/src/setup.rs index 128e06555..ec6f2b76f 100644 --- a/crates/inputs/input_bei/src/setup.rs +++ b/crates/inputs/input_bei/src/setup.rs @@ -1,173 +1,68 @@ use alloc::vec::Vec; use bevy_app::App; +#[cfg(any(feature = "client", feature = "server"))] use bevy_ecs::prelude::*; use bevy_ecs::relationship::Relationship; -use bevy_replicon::bytes::Bytes; use bevy_replicon::prelude::*; -use bevy_replicon::shared::replication::registry::ctx::{SerializeCtx, WriteCtx}; +use bevy_replicon::shared::replication::deferred_entity::DeferredEntity; +use bevy_replicon::shared::replication::registry::ctx::{RemoveCtx, SerializeCtx, WriteCtx}; +#[cfg(feature = "client")] use bevy_replicon::shared::server_entity_map::ServerEntityMap; +use bevy_replicon::{bytes::Bytes, postcard_utils}; #[cfg(feature = "client")] use { bevy_enhanced_input::context::ExternallyMocked, lightyear_connection::client::Client, - lightyear_replication::prelude::{Controlled, ControlledBy, Replicate}, + lightyear_replication::prelude::{Controlled, ControlledBy}, }; use bevy_enhanced_input::prelude::*; #[cfg(any(feature = "client", feature = "server"))] use bevy_utils::prelude::DebugName; +#[cfg(any(feature = "client", feature = "server"))] +use lightyear_connection::host::HostClient; #[cfg(all(feature = "client", feature = "server"))] use lightyear_connection::host::HostServer; -use lightyear_connection::{host::HostClient, server::Started}; +#[cfg(feature = "server")] +use lightyear_connection::server::Started; +#[cfg(feature = "server")] use lightyear_link::prelude::Server; +#[cfg(feature = "server")] use lightyear_messages::MessageManager; -#[cfg(feature = "client")] +#[cfg(all(feature = "client", feature = "server"))] use lightyear_replication::prelude::PreSpawned; #[allow(unused_imports)] -use tracing::{debug, info}; +use tracing::{debug, warn}; #[cfg(feature = "server")] use { lightyear_inputs::server::ServerInputConfig, lightyear_replication::prelude::{InterpolationTarget, PredictionTarget, ReplicateLike}, }; -// TODO: ideally we would have an entity-mapped that is PreSpawn aware. If you include an entity -// that is PreSpawned, then in the entity-mapper we use a Query> to check the hash -// of the entity and serialize it as the hash. Then the receiving entity mapper could look up the corresponding -// entity by the PreSpawn hash to apply entity mapping. -// 1. In common case, server sends P1,C1. It does NOT need to change ChildOf(P1) because client will match P1/C1 on receipt, then -// update its entity maps, then the component map entity will work correctly. We just need to make sure that C1 is also Prespawned, -// which we could do in ReplicateLike Propagation? (but how to do it on the receiver side?) -// - -pub struct InputRegistryPlugin; -#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct NetworkActionOf { - entity: Entity, +/// Client-side placeholder for a replicated [`ActionOf`] whose context +/// entity has not been mapped yet. +/// +/// This is deliberately local-only. Once the context entity appears in +/// Replicon's server-to-client entity map, [`resolve_pending_action_of`] +/// replaces this component with the real BEI relationship. +#[derive(Component)] +pub(crate) struct PendingActionOf { + server_context: Entity, marker: core::marker::PhantomData, } -impl NetworkActionOf { - fn new(entity: Entity) -> Self { +impl PendingActionOf { + fn new(server_context: Entity) -> Self { Self { - entity, + server_context, marker: core::marker::PhantomData, } } - - fn get(&self) -> Entity { - self.entity - } } -impl InputRegistryPlugin { - pub(crate) fn mirror_action_of_for_replication( - trigger: On>, - action_of: Query<&ActionOf, Without>, - remote_contexts: Query<(), With>, - entity_map: Option>, - managers: Query<&MessageManager>, - mut commands: Commands, - ) { - let entity = trigger.entity; - let Ok(action_of) = action_of.get(entity) else { - return; - }; - - let context_entity = action_of.get(); - let remote_entity = resolve_remote_action_context( - context_entity, - remote_contexts.contains(context_entity), - entity_map.as_deref(), - managers.iter(), - ); - let Some(remote_entity) = remote_entity else { - return; - }; - commands - .entity(entity) - .insert(NetworkActionOf::::new(remote_entity)); - } - - pub(crate) fn resolve_pending_network_action_of( - pending: Query<(Entity, &ActionOf), (Without>, Without)>, - remote_contexts: Query<(), With>, - entity_map: Option>, - managers: Query<&MessageManager>, - mut commands: Commands, - ) { - for (entity, action_of) in pending.iter() { - let context_entity = action_of.get(); - let remote_entity = resolve_remote_action_context( - context_entity, - remote_contexts.contains(context_entity), - entity_map.as_deref(), - managers.iter(), - ); - let Some(remote_entity) = remote_entity else { - continue; - }; - - commands - .entity(entity) - .insert(NetworkActionOf::::new(remote_entity)); - } - } - - pub(crate) fn insert_action_of_from_network( - trigger: On>, - query: Query<&NetworkActionOf, (Without>, With)>, - entity_map: Option>, - managers: Query<&MessageManager>, - all_entities: Query<(), ()>, - host_clients: Query<(), With>, - servers: Query<(), (With, With)>, - mut commands: Commands, - ) { - let entity = trigger.entity; - let Ok(network_action_of) = query.get(entity) else { - return; - }; - - let allow_identity = !host_clients.is_empty() || !servers.is_empty(); - if let Some(mapped) = resolve_local_entity( - network_action_of.get(), - entity_map.as_deref(), - managers.iter(), - &all_entities, - allow_identity, - ) - .filter(|mapped| *mapped != entity) - { - commands.entity(entity).insert(ActionOf::::new(mapped)); - } - } - - pub(crate) fn resolve_pending_action_of( - pending: Query<(Entity, &NetworkActionOf), (Without>, With)>, - entity_map: Option>, - managers: Query<&MessageManager>, - all_entities: Query<(), ()>, - host_clients: Query<(), With>, - servers: Query<(), (With, With)>, - mut commands: Commands, - ) { - let allow_identity = !host_clients.is_empty() || !servers.is_empty(); - for (entity, network_action_of) in pending.iter() { - if let Some(mapped) = resolve_local_entity( - network_action_of.get(), - entity_map.as_deref(), - managers.iter(), - &all_entities, - allow_identity, - ) - .filter(|mapped| *mapped != entity) - { - commands.entity(entity).insert(ActionOf::::new(mapped)); - } - } - } +pub struct InputRegistryPlugin; +impl InputRegistryPlugin { /// For Host-Server, if an ActionOf is spawned directly on the HostClient. /// (without being received from replication, or with Prespawned) /// Then we initiate rebroadcast @@ -242,40 +137,6 @@ impl InputRegistryPlugin { } } - /// When an [`ActionOf`] component is added to an entity (usually on the client), - /// we add Replicate to it so that the action entity is also created on the server. - /// - /// PreSpawned Actions must be replicated from server to client. - /// No need to change anything about ActionOf because the Context and Action will be received at the same time, - /// so the entity mapping in ActionOf will work properly. - #[cfg(feature = "client")] - pub(crate) fn add_action_of_replicate( - trigger: On>, - server: Query<(), (With, With)>, - // we don't want to add Replicate on action entities that were already received - // PreSpawned entities are replicated from server to client - action: Query< - &ActionOf, - ( - With>, - Without, - Without, - ), - >, - mut commands: Commands, - ) { - if server.single().is_ok() { - // we're on the server, don't do anything - return; - } - let entity = trigger.entity; - if let Ok(action_of) = action.get(entity) { - let context_entity = action_of.get(); - debug!(action_entity = ?entity, "Replicating ActionOf<{:?}> for context entity {context_entity:?} from client to server", DebugName::type_name::()); - commands.entity(entity).insert((Replicate::to_server(),)); - } - } - /// When the server receives [`ActionOf`], optionally rebroadcast to other clients if rebroadcast_inputs is enabled #[cfg(feature = "server")] pub(crate) fn on_action_of_replicated( @@ -348,55 +209,101 @@ impl InputRegistryPlugin { } } -fn resolve_remote_entity<'a>( - local_entity: Entity, - entity_map: Option<&ServerEntityMap>, - mut managers: impl Iterator, -) -> Option { - if let Some(entity_map) = entity_map - && let Some(remote_entity) = entity_map.to_server().get(&local_entity) - { - return Some(*remote_entity); - } - - managers.find_map(|manager| manager.entity_mapper.get_remote(local_entity)) +/// Serializes the server context entity targeted by [`ActionOf`]. +/// +/// This uses a custom rule instead of Replicon's default component +/// serialization because [`ActionOf`] is a relationship component. The +/// default receive path would call [`EntityMapper::get_mapped`] for the context +/// entity and create a placeholder if the context has not been mapped yet. That +/// placeholder is unsafe for a relationship component: Bevy relationship hooks +/// can observe the target during insertion, and Replicon also asserts if that +/// buffered placeholder is still pending when the next component in the same +/// entity bundle is decoded. +pub(crate) fn serialize_action_of( + _ctx: &mut SerializeCtx, + action_of: &ActionOf, + message: &mut Vec, +) -> bevy_ecs::error::Result<()> { + postcard_utils::entity_to_extend_mut(&action_of.get(), message)?; + Ok(()) } -fn resolve_remote_action_context<'a>( - local_entity: Entity, - remote_context: bool, - entity_map: Option<&ServerEntityMap>, - managers: impl Iterator, -) -> Option { - resolve_remote_entity(local_entity, entity_map, managers) - .or_else(|| (!remote_context).then_some(local_entity)) +/// Deserializes the raw server context entity for stale-message consumption. +/// +/// The active receive path uses [`write_action_of`] below. This function exists +/// for Replicon's `RuleFns` contract and for consuming stale updates without +/// creating mapped placeholder entities. +pub(crate) fn deserialize_action_of( + _ctx: &mut WriteCtx, + message: &mut Bytes, +) -> bevy_ecs::error::Result> { + let server_context = postcard_utils::entity_from_buf(message)?; + Ok(ActionOf::new(server_context)) } -fn resolve_local_entity<'a>( - remote_entity: Entity, - entity_map: Option<&ServerEntityMap>, - mut managers: impl Iterator, - all_entities: &Query<(), ()>, - allow_identity: bool, -) -> Option { - if let Some(entity_map) = entity_map - && let Some(local_entity) = entity_map.to_client().get(&remote_entity) - { - return Some(*local_entity); +/// Receives [`ActionOf`] without using Replicon's placeholder entity mapper. +/// +/// If the context entity is already mapped, this inserts the real BEI +/// relationship immediately. If not, it stores [`PendingActionOf`] so the +/// relationship can be attached later by [`resolve_pending_action_of`]. +pub(crate) fn write_action_of( + ctx: &mut WriteCtx, + _rule_fns: &RuleFns>, + entity: &mut DeferredEntity, + message: &mut Bytes, +) -> bevy_ecs::error::Result<()> { + let server_context = postcard_utils::entity_from_buf(message)?; + if let Some(&client_context) = ctx.entity_map.to_client().get(&server_context) { + entity.insert(ActionOf::::new(client_context)); + entity.remove::>(); + } else { + entity.insert(PendingActionOf::::new(server_context)); + entity.remove::>(); } + Ok(()) +} - if let Some(local_entity) = - managers.find_map(|manager| manager.entity_mapper.get_local(remote_entity)) - { - return Some(local_entity); - } +pub(crate) fn remove_action_of(_ctx: &mut RemoveCtx, entity: &mut DeferredEntity) { + entity.remove::>(); + entity.remove::>(); +} - allow_identity - .then(|| all_entities.get(remote_entity).ok().map(|()| remote_entity)) - .flatten() +/// Attach delayed BEI relationships once Replicon has mapped their context. +#[cfg(feature = "client")] +pub(crate) fn resolve_pending_action_of( + entity_map: Option>, + pending: Query<(Entity, &PendingActionOf)>, + mut commands: Commands, +) { + let Some(entity_map) = entity_map else { + return; + }; + for (entity, pending) in &pending { + let Some(&client_context) = entity_map.to_client().get(&pending.server_context) else { + continue; + }; + commands + .entity(entity) + .insert(ActionOf::::new(client_context)) + .remove::>(); + } } -// we don't care about the actual data in Action, so nothing to serialize +/// Serializes only the presence and type of [`Action`]. +/// +/// The value stored inside BEI's [`Action`] is local runtime state. Lightyear +/// sends that state through [`BEIStateSequence`](crate::input_message::BEIStateSequence), +/// whose snapshots include the trigger state, action value, events, and timing. +/// The [`Action`] component also does not carry the context relationship: +/// [`ActionOf`] is replicated as its own component, and Replicon's default +/// mapping path is avoided there because it can create placeholder relationship +/// targets when the context has not been mapped yet. Instead, Lightyear defers +/// inserting [`ActionOf`] until the context entity is present in the +/// server-to-client map. Therefore component replication only needs to create +/// the correctly typed action component on the receiver, and +/// [`deserialize_action`] can rebuild it from `Default`. +/// +/// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf fn serialize_action( _ctx: &mut SerializeCtx, _: &Action, @@ -411,31 +318,6 @@ fn deserialize_action( Ok(Action::::default()) } -/// Serialize the authoritative remote entity for an action context. -/// -/// Entity mapping is handled out-of-band before replication by mirroring [`ActionOf`] -/// into [`NetworkActionOf`]. -pub(crate) fn serialize_network_action_of( - _ctx: &mut SerializeCtx, - action_of: &NetworkActionOf, - message: &mut Vec, -) -> bevy_ecs::error::Result<()> { - bevy_replicon::postcard_utils::entity_to_extend_mut(&action_of.get(), message)?; - Ok(()) -} - -/// Deserialize the authoritative remote entity for an action context. -/// -/// We intentionally do not apply replicon's entity mapping here because the authoritative -/// entity may come from either the replicon server map or lightyear's message entity map. -pub(crate) fn deserialize_network_action_of( - _: &mut WriteCtx, - message: &mut Bytes, -) -> bevy_ecs::error::Result> { - let entity = bevy_replicon::postcard_utils::entity_from_buf(message)?; - Ok(NetworkActionOf::::new(entity)) -} - pub trait InputRegistryExt { /// Registers a new input action type and returns its kind. fn register_input_action(self) -> Self; diff --git a/crates/inputs/inputs/src/client.rs b/crates/inputs/inputs/src/client.rs index 2016ef9e7..e1697a7b8 100644 --- a/crates/inputs/inputs/src/client.rs +++ b/crates/inputs/inputs/src/client.rs @@ -696,7 +696,6 @@ fn prepare_input_message( input_buffer ); - // PreSpawned: the include the Prespawned Hash let target = if let Some(prespawned) = pre_spawned && let Some(hash) = prespawned.hash { diff --git a/crates/inputs/inputs/src/input_message.rs b/crates/inputs/inputs/src/input_message.rs index 05b5a36a1..7fc52f9c1 100644 --- a/crates/inputs/inputs/src/input_message.rs +++ b/crates/inputs/inputs/src/input_message.rs @@ -30,7 +30,7 @@ pub enum InputTarget { /// (Also when rebroadcast from server to client) Entity(Entity), /// The input is for a prespawned entity. - /// We wan the client to be able to send inputs for a prespawned entity before it gets matched with a server entity. + /// We want the client to be able to send inputs for a prespawned entity before it gets matched with a server entity. /// To achieve this, the client sends the PreSpawned hash and the server will map it to the correct server entity. /// When rebroadcasting from server to other client, we rebroadcast it as a normal Entity? PreSpawned(u64), diff --git a/crates/inputs/inputs/src/server.rs b/crates/inputs/inputs/src/server.rs index 4afcfecf0..2f8cf438b 100644 --- a/crates/inputs/inputs/src/server.rs +++ b/crates/inputs/inputs/src/server.rs @@ -470,11 +470,8 @@ fn receive_input_message( }, InputTarget::PreSpawned(hash) => { debug!(?hash, "Received input for prespawned entity"); - // we cannot match using the PreSpawnedReceiver since it only stores hashes for entities - // with no Replicate component. - // Instead, since there shouldn't be many entities with inputs and PreSpawned, we just iterate - // through the query. We don't need to do entity-mapping because as soon as the entity is mapped - // on the client, PreSpawned is removed and the input will contain the mapped entity. + // We cannot match using the PreSpawnedReceiver since it only stores hashes for entities + // with no Replicate component, so resolve the input target against server-side input entities. prespawned .iter() .filter_map(|(e, p)| p.hash.is_some_and(|h| h == hash).then_some(e)).next() diff --git a/crates/replication/prediction/src/plugin.rs b/crates/replication/prediction/src/plugin.rs index 57be98658..219ff4ed9 100644 --- a/crates/replication/prediction/src/plugin.rs +++ b/crates/replication/prediction/src/plugin.rs @@ -5,9 +5,9 @@ use crate::diagnostics::PredictionDiagnosticsPlugin; use crate::manager::PredictionManager; use crate::predicted_history::{ PredictionHistory, add_history_diff_receiver, add_prediction_history, - apply_component_removal_predicted, handle_tick_event_confirmed_history, - handle_tick_event_history_diff_receiver, handle_tick_event_prediction_history, - prune_history_diff_receiver, snap_to_confirmed_during_rollback, update_prediction_history, + apply_component_removal_predicted, handle_tick_event_history_diff_receiver, + handle_tick_event_prediction_history, prune_history_diff_receiver, + snap_to_confirmed_during_rollback, update_prediction_history, }; use crate::registry::PredictionRegistry; use crate::rollback::DisabledDuringRollback; @@ -83,8 +83,12 @@ pub fn add_non_networked_rollback_systems + C // would not get its tick values shifted on timeline-sync, so any // history entries accumulated pre-sync would point to stale // (pre-sync) ticks after the client clock jumps forward. + // + // Do not shift `ConfirmedHistory` here: confirmed samples are resolved + // through `ReplicationCheckpointMap` and are already in authoritative + // server tick space. Shifting them can move an init-message seed into the + // future and make rollback prefer stale state over later server updates. app.add_observer(handle_tick_event_prediction_history::); - app.add_observer(handle_tick_event_confirmed_history::); app.add_systems( PreUpdate, prepare_rollback::.in_set(RollbackSystems::Prepare), @@ -152,7 +156,6 @@ pub(crate) fn add_prediction_systems(app: &mut App) { app.add_observer(apply_component_removal_predicted::); app.add_observer(handle_tick_event_prediction_history::); - app.add_observer(handle_tick_event_confirmed_history::); app.add_observer(add_prediction_history::); app.add_systems( diff --git a/crates/replication/prediction/src/predicted_history.rs b/crates/replication/prediction/src/predicted_history.rs index 1fde546e6..bf991ae87 100644 --- a/crates/replication/prediction/src/predicted_history.rs +++ b/crates/replication/prediction/src/predicted_history.rs @@ -144,27 +144,6 @@ pub(crate) fn handle_tick_event_prediction_history( } } -/// If there is a TickEvent and the client tick suddenly changes, update confirmed-history ticks too. -pub(crate) fn handle_tick_event_confirmed_history( - trigger: On>, - mut query: Query<&mut ConfirmedHistory>, -) { - for mut history in query.iter_mut() { - history.update_ticks(trigger.tick_delta); - trace!( - target: "lightyear_debug::prediction", - kind = "confirmed_history_tick_delta", - schedule = "PostUpdate", - sample_point = "PostUpdate", - entity = ?trigger.entity, - component = ?DebugName::type_name::(), - tick_delta = trigger.tick_delta, - history_len = history.len(), - "shifted confirmed history ticks" - ); - } -} - pub(crate) fn handle_tick_event_history_diff_receiver( trigger: On>, mut storage: ResMut, @@ -212,7 +191,6 @@ pub(crate) fn prune_history_diff_receiver( } } } - /// If a predicted component is removed on the [`Predicted`] entity, add the removal to the history. pub(crate) fn apply_component_removal_predicted( trigger: On, diff --git a/crates/replication/replication/src/hierarchy.rs b/crates/replication/replication/src/hierarchy.rs index af350902d..92a982d9c 100644 --- a/crates/replication/replication/src/hierarchy.rs +++ b/crates/replication/replication/src/hierarchy.rs @@ -14,7 +14,13 @@ use bevy_ecs::query::QueryData; use bevy_ecs::reflect::ReflectMapEntities; use bevy_ecs::relationship::Relationship; use bevy_reflect::Reflect; -use bevy_replicon::prelude::SyncRelatedAppExt; +use bevy_replicon::bytes::Bytes; +use bevy_replicon::postcard_utils; +use bevy_replicon::prelude::{RuleFns, SyncRelatedAppExt}; +use bevy_replicon::shared::replication::deferred_entity::DeferredEntity; +use bevy_replicon::shared::replication::registry::ctx::{RemoveCtx, SerializeCtx, WriteCtx}; +#[cfg(feature = "client")] +use bevy_replicon::shared::server_entity_map::ServerEntityMap; use core::fmt::Debug; use serde::Serialize; use serde::de::DeserializeOwned; @@ -33,6 +39,26 @@ pub enum RelationshipSystems { pub(crate) struct HierarchyPlugin; +/// Client-side placeholder for a replicated [`ChildOf`] whose parent entity +/// has not been mapped yet. +/// +/// Replicon's default entity mapping creates a buffered placeholder when a +/// replicated component references an entity that has not appeared in the +/// server-to-client map yet. That is unsafe for relationship components like +/// [`ChildOf`], because inserting the relationship immediately runs Bevy's +/// relationship hooks and leaves Replicon's placeholder buffer alive while the +/// next component in the same entity bundle is decoded. +#[derive(Component)] +pub(crate) struct PendingChildOf { + server_parent: Entity, +} + +impl PendingChildOf { + fn new(server_parent: Entity) -> Self { + Self { server_parent } + } +} + #[derive(QueryData)] struct PropagationQuery { replicate: &'static Replicate, @@ -91,6 +117,82 @@ impl Plugin for HierarchyPlugin { } } +/// Serializes the server parent entity targeted by [`ChildOf`]. +/// +/// Lightyear registers a custom rule for [`ChildOf`] so the receive path can +/// inspect the raw server entity before mapping it. If the parent is not mapped +/// yet, the receiver defers inserting the relationship instead of letting +/// Replicon create a placeholder entity inside the relationship component. +pub(crate) fn serialize_child_of( + _ctx: &mut SerializeCtx, + child_of: &ChildOf, + message: &mut Vec, +) -> bevy_ecs::error::Result<()> { + postcard_utils::entity_to_extend_mut(&child_of.parent(), message)?; + Ok(()) +} + +/// Deserializes the raw server parent entity for stale-message consumption. +/// +/// The active receive path uses [`write_child_of`] so it can defer insertion +/// until the parent is mapped. +pub(crate) fn deserialize_child_of( + _ctx: &mut WriteCtx, + message: &mut Bytes, +) -> bevy_ecs::error::Result { + let server_parent = postcard_utils::entity_from_buf(message)?; + Ok(ChildOf(server_parent)) +} + +/// Receives [`ChildOf`] without using Replicon's placeholder entity mapper. +/// +/// If the parent has already been mapped, this inserts the real Bevy hierarchy +/// relationship. If not, it stores [`PendingChildOf`] and waits for +/// [`resolve_pending_child_of`] to attach the relationship once the parent +/// mapping is available. +pub(crate) fn write_child_of( + ctx: &mut WriteCtx, + _rule_fns: &RuleFns, + entity: &mut DeferredEntity, + message: &mut Bytes, +) -> bevy_ecs::error::Result<()> { + let server_parent = postcard_utils::entity_from_buf(message)?; + if let Some(&client_parent) = ctx.entity_map.to_client().get(&server_parent) { + entity.insert(ChildOf(client_parent)); + entity.remove::(); + } else { + entity.insert(PendingChildOf::new(server_parent)); + entity.remove::(); + } + Ok(()) +} + +pub(crate) fn remove_child_of(_ctx: &mut RemoveCtx, entity: &mut DeferredEntity) { + entity.remove::(); + entity.remove::(); +} + +/// Attach delayed hierarchy relationships once Replicon has mapped the parent. +#[cfg(feature = "client")] +pub(crate) fn resolve_pending_child_of( + entity_map: Option>, + pending: Query<(Entity, &PendingChildOf)>, + mut commands: Commands, +) { + let Some(entity_map) = entity_map else { + return; + }; + for (entity, pending) in &pending { + let Some(&client_parent) = entity_map.to_client().get(&pending.server_parent) else { + continue; + }; + commands + .entity(entity) + .insert(ChildOf(client_parent)) + .remove::(); + } +} + /// When the `DisableReplicateHierarchy` marker component is added to an entity, we will stop replicating their children. #[derive(Component, Clone, Copy, Debug, Default, PartialEq, Reflect)] #[reflect(Component)] diff --git a/crates/replication/replication/src/lib.rs b/crates/replication/replication/src/lib.rs index 8ddb19d75..af08c0691 100644 --- a/crates/replication/replication/src/lib.rs +++ b/crates/replication/replication/src/lib.rs @@ -175,7 +175,8 @@ struct SharedComponentRegistrationPlugin; impl Plugin for SharedComponentRegistrationPlugin { fn build(&self, app: &mut bevy_app::prelude::App) { - use bevy_replicon::prelude::AppRuleExt; + use bevy_ecs::prelude::ChildOf; + use bevy_replicon::prelude::{AppMarkerExt, AppRuleExt, RuleFns}; // The order of app.replicate() calls must be identical on client and server. // These marker components are sent from server to client as part of entity replication. #[cfg(feature = "prediction")] @@ -185,7 +186,11 @@ impl Plugin for SharedComponentRegistrationPlugin { app.replicate::(); // ChildOf is registered for replication in HierarchySendPlugin (server-only), // but must also be registered on the client so FnsIds match. - app.replicate::(); + app.replicate_with(RuleFns::new( + hierarchy::serialize_child_of, + hierarchy::deserialize_child_of, + )) + .set_receive_fns::(hierarchy::write_child_of, hierarchy::remove_child_of); // ServerMutateTicks is normally only initialized by bevy_replicon's ClientPlugin, // but prediction systems on server-only builds also reference it. Init it here @@ -244,5 +249,9 @@ impl Plugin for LightyearRepliconClientBackend { fn build(&self, app: &mut bevy_app::prelude::App) { app.add_plugins(bevy_replicon::client::ClientPlugin); app.add_plugins(client::RepliconClientPlugin); + app.add_systems( + bevy_app::prelude::PreUpdate, + hierarchy::resolve_pending_child_of, + ); } } diff --git a/crates/tests/src/client_server/input/bei.rs b/crates/tests/src/client_server/input/bei.rs index 6478f0ec8..f99cdc5b9 100644 --- a/crates/tests/src/client_server/input/bei.rs +++ b/crates/tests/src/client_server/input/bei.rs @@ -17,7 +17,9 @@ use lightyear_link::Link; use lightyear_link::prelude::LinkConditionerConfig; use lightyear_messages::MessageManager; use lightyear_prediction::diagnostics::PredictionMetrics; -use lightyear_replication::prelude::{PreSpawned, PredictionTarget, Replicate}; +use lightyear_replication::prelude::{ + ControlledBy, PreSpawned, PredictionTarget, Replicate, ReplicateLike, +}; use lightyear_sync::prelude::client::{InputDelayConfig, InputTimelineConfig}; use test_log::test; use tracing::info; @@ -77,26 +79,35 @@ fn spawn_action_pair( (client_action, server_action) } -/// Check that we can insert actions on the client entity using PreSpawned +/// Check that we can insert actions on a replicated client context using PreSpawned #[test] -fn test_actions_on_client_entity() { +fn test_actions_on_replicated_context_entity() { let mut stepper = ClientServerStepper::from_config(StepperConfig::single()); - let client_entity = stepper.client(0).id(); - let client_of_entity = stepper.client_of(0).id(); + let server_entity = stepper + .server_app + .world_mut() + .spawn((BEIContext, Replicate::to_clients(NetworkTarget::All))) + .id(); + stepper.frame_step(3); + + let client_entity = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(server_entity) + .expect("context entity should be replicated to client"); let (client_action, server_action) = - spawn_action_pair(&mut stepper, client_entity, client_of_entity, TEST_HASH); - stepper.frame_step(1); + spawn_action_pair(&mut stepper, client_entity, server_entity, TEST_HASH); + stepper.frame_step(2); // Add an InputMarker to the Context entity on the client stepper .client_app() .world_mut() .entity_mut(client_entity) - .insert(( - BEIContext, - bei::prelude::InputMarker::::default(), - )); + .insert(bei::prelude::InputMarker::::default()); // Check that the ActionOf component points to the correct entity on the server assert_eq!( @@ -107,7 +118,19 @@ fn test_actions_on_client_entity() { .get::>() .unwrap() .get(), - client_of_entity + server_entity + ); + + // Check that the replicated ActionOf component maps back to the local context on the client + assert_eq!( + stepper + .client_app() + .world() + .entity(client_action) + .get::>() + .unwrap() + .get(), + client_entity ); info!("Mocking press on BEIAction1"); @@ -263,6 +286,84 @@ fn test_bound_action_spawned_from_received_context_sends_inputs_after_mapping() ); } +/// Check that the owner can drive a server-spawned replicated action entity. +/// +/// The client receives the action entity from replication, adds local-only +/// bindings/input mock to that replicated entity, and input messages target the +/// action entity through the normal client-to-server entity map. +#[test] +fn test_server_replicated_action_sends_inputs_after_client_adds_bindings() { + let mut stepper = ClientServerStepper::from_config(StepperConfig::single()); + stepper.server_app.init_resource::(); + stepper.server_app.add_systems( + FixedPreUpdate, + record_server_fired_action + .before(lightyear::input::server::InputSystems::UpdateActionState), + ); + + let client_of_entity = stepper.client_of(0).id(); + let client_id = stepper + .client_of(0) + .get::() + .unwrap() + .0; + let server_entity = stepper + .server_app + .world_mut() + .spawn(( + BEIContext, + Replicate::to_clients(NetworkTarget::All), + PredictionTarget::to_clients(NetworkTarget::Single(client_id)), + ControlledBy { + owner: client_of_entity, + lifetime: Default::default(), + }, + )) + .id(); + + let server_action = stepper + .server_app + .world_mut() + .spawn(( + ActionOf::::new(server_entity), + Action::::default(), + ReplicateLike { + root: server_entity, + }, + )) + .id(); + + stepper.frame_step(5); + + let client_action = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(server_action) + .expect("action entity should be replicated to client"); + + stepper + .client_app() + .world_mut() + .entity_mut(client_action) + .insert(( + ActionMock::once(TriggerState::Fired, true), + bindings![KeyCode::Space,], + )); + + stepper.frame_step(5); + + assert!( + stepper + .server_app + .world() + .resource::() + .0, + "server should eventually receive the fired action state via mapped entity input messages" + ); +} + /// Check that ActionStates are stored correctly in the InputBuffer with PreSpawned #[test] fn test_buffer_inputs_with_delay() { diff --git a/crates/tests/src/client_server/prediction/rollback.rs b/crates/tests/src/client_server/prediction/rollback.rs index ef492931a..f52416821 100644 --- a/crates/tests/src/client_server/prediction/rollback.rs +++ b/crates/tests/src/client_server/prediction/rollback.rs @@ -552,6 +552,62 @@ fn test_future_confirmed_insert_is_not_checked_by_unchanged_completed_tick() { ); } +#[test] +fn test_input_timeline_sync_does_not_shift_confirmed_history() { + use lightyear_sync::prelude::InputTimelineConfig; + + let (mut stepper, predicted) = setup(); + + let confirmed_tick = Tick(10); + insert_confirmed( + stepper.client_app().world_mut(), + predicted, + confirmed_tick, + Some(CompFull(10.0)), + ); + + let predicted_tick = Tick(5); + let mut prediction_history = PredictionHistory::::default(); + prediction_history.add_predicted(predicted_tick, Some(CompFull(5.0))); + stepper + .client_app() + .world_mut() + .entity_mut(predicted) + .insert(prediction_history); + + stepper + .client_app() + .world_mut() + .trigger(lightyear_core::timeline::SyncEvent::::new(predicted, 100)); + + let world = stepper.client_app().world(); + let confirmed_history = world + .get::>(predicted) + .expect("confirmed history should still be present"); + assert!( + confirmed_history.get_state_at(confirmed_tick).is_some(), + "confirmed history is already in authoritative server tick space and must not shift" + ); + assert!( + confirmed_history + .get_state_at(confirmed_tick + 100) + .is_none(), + "timeline sync must not move authoritative confirmed samples into the future" + ); + + let prediction_history = world + .get::>(predicted) + .expect("prediction history should still be present"); + assert!( + prediction_history.get_state(predicted_tick).is_none(), + "local prediction samples from before sync should be shifted" + ); + assert!( + prediction_history.get_state(predicted_tick + 100).is_some(), + "prediction history should follow the input timeline sync" + ); +} + /// A completed mutate tick is a global confirmation point. If one entity receives an /// explicit update at that tick and another entity does not, the unchanged entity should /// still be checked against its last confirmed value at the completed tick. diff --git a/examples/bevy_enhanced_inputs/src/automation.rs b/examples/bevy_enhanced_inputs/src/automation.rs index 1348c944e..14c7358fb 100644 --- a/examples/bevy_enhanced_inputs/src/automation.rs +++ b/examples/bevy_enhanced_inputs/src/automation.rs @@ -1,5 +1,6 @@ use bevy::prelude::*; use lightyear::prelude::*; +#[cfg(feature = "client")] use lightyear_examples_common::automation::{env_string, sync_pressed_keys, HeadlessInputPlugin}; use crate::protocol::{PlayerId, PlayerPosition}; diff --git a/examples/bevy_enhanced_inputs/src/client.rs b/examples/bevy_enhanced_inputs/src/client.rs index 1f67087e8..f8bea4488 100644 --- a/examples/bevy_enhanced_inputs/src/client.rs +++ b/examples/bevy_enhanced_inputs/src/client.rs @@ -8,12 +8,10 @@ use crate::automation::AutomationClientPlugin; use crate::protocol::*; use crate::shared; -use bevy::ecs::relationship::Relationship; use bevy::prelude::*; use lightyear::connection::host::HostServer; -use lightyear::input::bei::prelude::{Action, ActionOf, Fire}; +use lightyear::input::bei::prelude::{Action, Actions, Bindings, Cardinal, Fire}; use lightyear::prelude::client::{InputDelayConfig, InputTimelineConfig}; -use lightyear::prelude::input::bei::InputMarker; use lightyear::prelude::*; pub struct ExampleClientPlugin; @@ -23,7 +21,7 @@ impl Plugin for ExampleClientPlugin { app.add_plugins(AutomationClientPlugin); app.add_systems(Startup, configure_input_delay); app.add_observer(handle_predicted_spawn); - app.add_observer(handle_controlled_spawn); + app.add_observer(add_bindings_to_controlled_actions); app.add_observer(handle_interpolated_spawn); app.add_observer(player_movement); } @@ -41,14 +39,18 @@ fn configure_input_delay(client: Single>, mut commands: Com fn player_movement( trigger: On>, synced_client: Query<(), (With, With>)>, - host_server: Query<(), With>, - server_actions: Query<(), (With>, With)>, + _host_server: Query<(), With>, + #[cfg(feature = "server")] server_actions: Query< + (), + (With>, With), + >, mut position_query: Query<&mut PlayerPosition, With>, ) { if synced_client.is_empty() { return; } - if !host_server.is_empty() && server_actions.contains(trigger.action) { + #[cfg(feature = "server")] + if !_host_server.is_empty() && server_actions.contains(trigger.action) { return; } if let Ok(position) = position_query.get_mut(trigger.context) { @@ -74,48 +76,40 @@ pub(crate) fn handle_predicted_spawn( } } -/// Spawn local action entities once the local player is actually controlled by this client. -/// -/// We intentionally key this off `Add`, not `Add`. -/// In dedicated client/server mode the replicated entity often ends up with both markers, -/// but in host-server mode the local entity is created in the same world through local -/// insertion paths, not through Replicon's deferred receive bundle. In that mode there is no -/// reliable guarantee that `Controlled` will already exist when `Predicted` is added, so -/// checking `Has` inside `Add` is brittle. +/// Add local movement bindings when a player becomes controlled by this client. /// -/// `Controlled` is the actual semantic signal we care about for local input setup: once this -/// marker appears, the entity belongs to this local client and it is safe to attach the -/// `InputMarker` and spawn the local BEI action entities. -fn handle_controlled_spawn( +/// The movement action is spawned on the server and replicated with the player. +/// Once the local predicted player is marked `Controlled`, its BEI `Actions` +/// relationship contains the replicated movement action that should receive +/// local-only bindings. Binding from `ActionOf`'s add event is too +/// early for the local prediction/control path: the relationship can already be +/// mapped while the predicted context has not yet been marked as controlled. +fn add_bindings_to_controlled_actions( trigger: On, - controlled_players: Query< - (&PlayerId, Has>, Option<&ControlledBy>), - With, - >, + controlled_players: Query<(Option<&ControlledBy>, Option<&Actions>), With>, + unbound_movement_actions: Query<(), (With>, Without)>, clients: Query<(), With>, - actions: Query<&ActionOf, With>>, mut commands: Commands, ) { - let entity = trigger.entity; - let Ok((player_id, has_input_marker, controlled_by)) = controlled_players.get(entity) else { + let Ok((controlled_by, Some(actions))) = controlled_players.get(trigger.entity) else { return; }; - if let Some(controlled_by) = controlled_by { - if clients.get(controlled_by.owner).is_err() { - return; - } - } - if has_input_marker { + if controlled_by.is_some_and(|controlled_by| clients.get(controlled_by.owner).is_err()) { return; } - commands - .entity(entity) - .insert(InputMarker::::default()); - if !actions.iter().any(|action_of| action_of.get() == entity) { - shared::spawn_action_entities(&mut commands, entity, player_id.0, false); + for action_entity in actions.iter() { + if unbound_movement_actions.contains(action_entity) { + add_bindings(action_entity, &mut commands); + } } } +fn add_bindings(action_entity: Entity, commands: &mut Commands) { + commands + .entity(action_entity) + .insert(Bindings::spawn(Cardinal::wasd_keys())); +} + /// When the predicted copy of the client-owned entity is spawned, do stuff /// - assign it a different saturation /// - keep track of it in the Global resource diff --git a/examples/bevy_enhanced_inputs/src/renderer.rs b/examples/bevy_enhanced_inputs/src/renderer.rs index ead4e003a..5b4f991b0 100644 --- a/examples/bevy_enhanced_inputs/src/renderer.rs +++ b/examples/bevy_enhanced_inputs/src/renderer.rs @@ -7,8 +7,6 @@ pub struct ExampleRendererPlugin; impl Plugin for ExampleRendererPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, init); - #[cfg(feature = "client")] - app.add_systems(Startup, rollback_button); app.add_systems(Update, draw_boxes); } } @@ -28,41 +26,3 @@ pub(crate) fn draw_boxes(mut gizmos: Gizmos, players: Query<(&PlayerPosition, &P ); } } - -#[cfg(feature = "client")] -pub(crate) fn rollback_button(mut commands: Commands) { - use lightyear::prelude::{LocalTimeline, PredictionManager, Rollback}; - commands - .spawn(( - Text("Rollback".to_string()), - TextColor(Color::srgb(0.9, 0.9, 0.9)), - TextFont::from_font_size(20.0), - Node { - width: Val::Px(150.0), - height: Val::Px(65.0), - border: UiRect::all(Val::Px(5.0)), - left: Val::Percent(45.0), - // horizontally center child text - justify_content: JustifyContent::Center, - // vertically center child text - align_items: AlignItems::Center, - ..default() - }, - Button, - )) - .observe( - |_: On>, - mut commands: Commands, - timeline: Res, - client: Single<(Entity, &PredictionManager)>| { - let (client, prediction_manager) = client.into_inner(); - - // rollback the client to 5 ticks before the current tick - let tick = timeline.tick(); - let rollback_tick = tick - 5; - info!("Manual rollback to tick {rollback_tick:?}. Current tick: {tick:?}"); - commands.entity(client).insert(Rollback::FromInputs); - prediction_manager.set_rollback_tick(rollback_tick); - }, - ); -} diff --git a/examples/bevy_enhanced_inputs/src/server.rs b/examples/bevy_enhanced_inputs/src/server.rs index 5bf3b8a60..22375f603 100644 --- a/examples/bevy_enhanced_inputs/src/server.rs +++ b/examples/bevy_enhanced_inputs/src/server.rs @@ -10,7 +10,7 @@ use crate::automation::AutomationServerPlugin; use crate::protocol::*; use crate::shared; use bevy::prelude::*; -use bevy_enhanced_input::prelude::{Action, Fire}; +use bevy_enhanced_input::prelude::{Action, ActionOf, Fire}; use lightyear::connection::client::Connected; use lightyear::connection::host::{HostClient, HostServer}; use lightyear::prelude::server::*; @@ -19,6 +19,9 @@ use lightyear_examples_common::shared::SEND_INTERVAL; pub struct ExampleServerPlugin; +#[derive(Component)] +pub(crate) struct ServerAction; + impl Plugin for ExampleServerPlugin { fn build(&self, app: &mut App) { app.add_plugins(AutomationServerPlugin); @@ -65,7 +68,7 @@ pub(crate) fn handle_connected( // Add the context component on the server; it will be replicated to the client Player, PlayerId(client_id), - PlayerPosition(Vec2::ZERO), + PlayerPosition(initial_player_position(client_id)), PlayerColor(color), // we replicate the Player entity to all clients that are connected to this server Replicate::to_clients(NetworkTarget::All), @@ -81,14 +84,35 @@ pub(crate) fn handle_connected( "Create player entity {:?} for client {:?}", player_entity, client_id ); - shared::spawn_action_entities(&mut commands, player_entity, client_id, true); + spawn_action_entities(&mut commands, player_entity); +} + +fn initial_player_position(client_id: PeerId) -> Vec2 { + let slot = (client_id.to_bits() % 6) as f32; + Vec2::new((slot - 2.5) * 90.0, 0.0) +} + +/// Spawn the BEI action entities for a player. +/// +/// The server owns and replicates action entities so the owning client can +/// target them in input messages and remote clients can receive rebroadcasted +/// input for prediction. +fn spawn_action_entities(commands: &mut Commands, player_entity: Entity) { + commands.spawn(( + ActionOf::::new(player_entity), + Action::::new(), + ReplicateLike { + root: player_entity, + }, + ServerAction, + )); } /// Read client inputs and move players in server therefore giving a basis for other clients fn movement( trigger: On>, host_server: Query<(), With>, - server_actions: Query<(), (With>, With)>, + server_actions: Query<(), (With>, With)>, controlled_by: Query<&ControlledBy>, host_clients: Query<(), With>, mut position_query: Query<&mut PlayerPosition>, diff --git a/examples/bevy_enhanced_inputs/src/shared.rs b/examples/bevy_enhanced_inputs/src/shared.rs index ce3fc03ed..303fc1754 100644 --- a/examples/bevy_enhanced_inputs/src/shared.rs +++ b/examples/bevy_enhanced_inputs/src/shared.rs @@ -4,7 +4,6 @@ //! mispredictions/rollbacks. use crate::protocol::*; use bevy::prelude::*; -use lightyear::input::bei::prelude::{Action, ActionOf, Bindings, Cardinal}; use lightyear::prelude::*; use lightyear_examples_common::shared::SharedSettings; @@ -19,52 +18,6 @@ impl Plugin for SharedPlugin { } } -/// Deterministic hash for PreSpawned action entities. -/// Uses the client's PeerId and a salt to produce the same hash on both client and server, -/// regardless of spawn tick. -pub(crate) fn action_prespawn_hash(client_id: PeerId, salt: u64) -> u64 { - client_id - .to_bits() - .wrapping_mul(6364136223846793005) - .wrapping_add(salt) -} - -#[derive(Component)] -pub(crate) struct ServerAction; - -/// Spawn action entities for a player. Called on both client and server. -pub(crate) fn spawn_action_entities( - commands: &mut Commands, - player_entity: Entity, - client_id: PeerId, - is_server: bool, -) { - let hash = action_prespawn_hash(client_id, 1); - let prespawned = if is_server { - PreSpawned::new(hash) - } else { - // The local action entity uses the hash as a stable input target, but it - // is not a predicted gameplay object that should be cleaned up if the - // server action replication arrived before the local action was spawned. - PreSpawned::new(hash).for_receiver(player_entity) - }; - let mut action = commands.spawn(( - ActionOf::::new(player_entity), - Action::::new(), - Bindings::spawn(Cardinal::wasd_keys()), - prespawned, - )); - if is_server { - #[cfg(feature = "server")] - action.insert(( - Replicate::to_clients(NetworkTarget::Single(client_id)), - ServerAction, - )); - } else { - action.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } -} - pub const SHARED_SETTINGS: SharedSettings = SharedSettings { protocol_id: 0, private_key: [0; 32], diff --git a/examples/projectiles/src/client.rs b/examples/projectiles/src/client.rs index 49b261c81..4c2f6c4fa 100644 --- a/examples/projectiles/src/client.rs +++ b/examples/projectiles/src/client.rs @@ -8,7 +8,6 @@ use bevy::ecs::relationship::Relationship; use bevy::prelude::*; use bevy_enhanced_input::EnhancedInputSystems; use bevy_enhanced_input::action::TriggerState; -use bevy_enhanced_input::bindings; use bevy_enhanced_input::context::ExternallyMocked; use bevy_enhanced_input::prelude::{ ActionMock, ActionValue, ActionValueDim, Binding, Bindings, Cardinal, MockSpan, @@ -27,7 +26,6 @@ impl Plugin for ExampleClientPlugin { app.add_observer(handle_predicted_spawn); app.add_observer(handle_interpolated_spawn); app.add_observer(handle_deterministic_spawn); - app.add_observer(add_global_actions); app.add_systems( FixedPreUpdate, ( @@ -45,13 +43,10 @@ impl Plugin for ExampleClientPlugin { // - add physics components so that its movement can be predicted pub(crate) fn handle_predicted_spawn( trigger: On, - client: Single<&LocalId, With>, - host_client: Option>, mut commands: Commands, - mut player_query: Query<(&PlayerId, &Position, &GameReplicationMode), With>, + mut player_query: Query<&GameReplicationMode, With>, ) { - let client_id = client.into_inner().0; - if let Ok((player_id, pos, mode)) = player_query.get_mut(trigger.entity) { + if let Ok(mode) = player_query.get_mut(trigger.entity) { if mode == &GameReplicationMode::AllInterpolated { return; }; @@ -66,57 +61,29 @@ pub(crate) fn handle_predicted_spawn( } _ => {} }; - if player_id.0 != client_id { - return; - } - // add actions on the local entity (remote predicted entities will have actions propagated by the server) - if should_spawn_client_actions(&host_client) { - info!( - ?pos, - "Adding actions to predicted player {:?}", trigger.entity - ); - shared::spawn_player_actions(&mut commands, trigger.entity, player_id.0, *mode, false); - } } } pub(crate) fn handle_interpolated_spawn( trigger: On, - client: Single<&LocalId, With>, - host_client: Option>, - mut interpolated: Query< - (&PlayerId, &Interpolated, &GameReplicationMode), - (With, With), - >, + mut interpolated: Query<&GameReplicationMode, (With, With)>, mut commands: Commands, ) { - let client_id = client.into_inner(); - if let Ok((player_id, interpolated, mode)) = interpolated.get_mut(trigger.entity) { + if let Ok(mode) = interpolated.get_mut(trigger.entity) { if mode == &GameReplicationMode::ClientSideHitDetection { // add these so we can do hit-detection on the client commands .entity(trigger.entity) .insert((Collider::rectangle(PLAYER_SIZE, PLAYER_SIZE),)); } - // In the interpolated case, the client sends inputs but doesn't apply them. - // Only the server applies the inputs, and the position changes are replicated back - if let GameReplicationMode::AllInterpolated = mode - && client_id.0 == player_id.0 - && should_spawn_client_actions(&host_client) - { - shared::spawn_player_actions(&mut commands, trigger.entity, player_id.0, *mode, false); - } } } pub(crate) fn handle_deterministic_spawn( trigger: On, query: Query<(&PlayerId, &GameReplicationMode)>, - client: Single<&LocalId, With>, - host_client: Option>, mut commands: Commands, ) { - let client_id = client.into_inner(); if let Ok((player_id, mode)) = query.get(trigger.entity) && mode == &GameReplicationMode::OnlyInputsReplicated { @@ -129,32 +96,9 @@ pub(crate) fn handle_deterministic_spawn( }, )); info!("Adding PlayerContext for player {:?}", player_id); - - // add actions for the local client - if player_id.0 == client_id.0 && should_spawn_client_actions(&host_client) { - info!( - "Spawning actions for DeterministicPredicted player {:?}", - player_id - ); - shared::spawn_player_actions(&mut commands, trigger.entity, player_id.0, *mode, false); - } } } -pub(crate) fn add_global_actions( - trigger: On, - host_client: Option>, - mut commands: Commands, -) { - if should_spawn_client_actions(&host_client) { - shared::spawn_global_actions(&mut commands, trigger.entity, false); - } -} - -fn should_spawn_client_actions(host_client: &Option>) -> bool { - host_client.is_none() -} - fn update_active_player_action_markers( client: Query<&LocalId, With>, global_mode: Query<&GameReplicationMode, With>, diff --git a/examples/projectiles/src/renderer.rs b/examples/projectiles/src/renderer.rs index 06fa9061e..c77432960 100644 --- a/examples/projectiles/src/renderer.rs +++ b/examples/projectiles/src/renderer.rs @@ -50,7 +50,15 @@ impl Plugin for ExampleRendererPlugin { // mock the action before BEI evaluates it. BEI evaluated actions mocks in FixedPreUpdate update_cursor_state_from_window, ); - app.add_systems(Update, (display_score, render_hitscan_lines, display_info)); + app.add_systems( + Update, + ( + display_score, + render_hitscan_lines, + display_info, + sync_active_mode_visibility.after(add_player_visuals), + ), + ); } #[cfg(feature = "server")] @@ -153,9 +161,24 @@ fn display_info( ); } +fn is_active_mode(mode: Option<&GameReplicationMode>, active_mode: &GameReplicationMode) -> bool { + mode.is_some_and(|mode| mode == active_mode) +} + #[cfg(feature = "client")] -fn render_hitscan_lines(query: Query<(&HitscanVisual, &ColorComponent)>, mut gizmos: Gizmos) { - for (visual, color) in query.iter() { +fn render_hitscan_lines( + active_mode: Query<&GameReplicationMode, With>, + shooters: Query<&GameReplicationMode, With>, + query: Query<(&HitscanVisual, &ColorComponent, &BulletMarker)>, + mut gizmos: Gizmos, +) { + let Ok(active_mode) = active_mode.single() else { + return; + }; + for (visual, color, marker) in query.iter() { + if !is_active_mode(shooters.get(marker.shooter).ok(), active_mode) { + continue; + } let progress = visual.lifetime / visual.max_lifetime; let alpha = (1.0 - progress).max(0.0); let line_color = color.0.with_alpha(alpha); @@ -163,6 +186,37 @@ fn render_hitscan_lines(query: Query<(&HitscanVisual, &ColorComponent)>, mut giz } } +#[cfg(feature = "client")] +fn sync_active_mode_visibility( + active_mode: Query<&GameReplicationMode, With>, + shooters: Query<&GameReplicationMode, With>, + mut players: Query< + (&GameReplicationMode, &mut Visibility), + (With, With, Without), + >, + mut projectiles: Query<(&BulletMarker, &mut Visibility), Without>, +) { + let Ok(active_mode) = active_mode.single() else { + return; + }; + + for (mode, mut visibility) in &mut players { + *visibility = if mode == active_mode { + Visibility::Visible + } else { + Visibility::Hidden + }; + } + + for (marker, mut visibility) in &mut projectiles { + *visibility = if is_active_mode(shooters.get(marker.shooter).ok(), active_mode) { + Visibility::Visible + } else { + Visibility::Hidden + }; + } +} + #[cfg(feature = "server")] fn draw_aabb_envelope(query: Query<&ColliderAabb, With>, mut gizmos: Gizmos) { query.iter().for_each(|collider_aabb| { diff --git a/examples/projectiles/src/server.rs b/examples/projectiles/src/server.rs index b79df277d..4d4164349 100644 --- a/examples/projectiles/src/server.rs +++ b/examples/projectiles/src/server.rs @@ -22,6 +22,7 @@ use lightyear::crossbeam::CrossbeamIo; use lightyear::input::config::InputConfig; use lightyear::input::server::{InputSystems as ServerInputSystems, ServerInputConfig}; use lightyear::interpolation::plugin::InterpolationDelay; +#[cfg(feature = "client")] use lightyear::netcode::NetcodeClient; use lightyear::prelude::server::*; use lightyear::prelude::*; @@ -70,13 +71,9 @@ pub(crate) fn handle_new_client(trigger: On, mut commands: Commands "Adding ReplicationSender to new ClientOf entity: {:?}", trigger.entity ); - commands.entity(trigger.entity).insert(( - ReplicationSender, - // We need a ReplicationReceiver on the server side because the Action entities are spawned - // on the client and replicated to the server. - ReplicationReceiver, - Name::from("ClientOf"), - )); + commands + .entity(trigger.entity) + .insert((ReplicationSender, Name::from("ClientOf"))); } pub(crate) fn spawn_global_control(mut commands: Commands) { @@ -99,7 +96,7 @@ pub(crate) fn spawn_global_control(mut commands: Commands) { Name::new("ClientContext"), )) .id(); - shared::spawn_global_actions(&mut commands, global, true); + shared::spawn_global_actions(&mut commands, global); } fn apply_initial_input_config( @@ -330,13 +327,7 @@ pub(crate) fn spawn_player( if is_bot { commands.entity(player_entity).insert(Bot); } - shared::spawn_player_actions( - &mut commands, - player_entity, - client_id, - replication_mode, - true, - ); + shared::spawn_player_actions(&mut commands, player_entity, room_id); info!("Spawning player {player_entity:?} for room: {room_id:?}"); } } diff --git a/examples/projectiles/src/shared.rs b/examples/projectiles/src/shared.rs index 6fc8ddb13..f276b1ef0 100644 --- a/examples/projectiles/src/shared.rs +++ b/examples/projectiles/src/shared.rs @@ -5,8 +5,6 @@ use bevy::platform::collections::HashMap; use bevy::prelude::*; use bevy::time::Stopwatch; use bevy_enhanced_input::action::Action; -use bevy_enhanced_input::action::TriggerState; -use bevy_enhanced_input::action::mock::ActionMock; use bevy_enhanced_input::prelude::*; use core::ops::DerefMut; use core::time::Duration; @@ -103,136 +101,54 @@ pub(crate) fn color_from_id(client_id: PeerId) -> Color { Color::hsl(h, s, l) } -/// Deterministic hash for BEI action entities spawned on both client and server. -pub(crate) fn player_action_prespawn_hash( - client_id: PeerId, - replication_mode: GameReplicationMode, - salt: u64, -) -> u64 { - client_id - .to_bits() - .wrapping_mul(6364136223846793005) - .wrapping_add((replication_mode.room_id() as u64).wrapping_mul(31)) - .wrapping_add(salt) -} - -pub(crate) fn global_action_prespawn_hash(salt: u64) -> u64 { - 0xC11E_1175_0000_0000u64.wrapping_add(salt) -} - -pub(crate) fn spawn_global_actions(commands: &mut Commands, context: Entity, is_server: bool) { - let mut projectile_mode = commands.spawn(( +pub(crate) fn spawn_global_actions(commands: &mut Commands, context: Entity) { + commands.spawn(( ActionOf::::new(context), Action::::new(), - bindings![KeyCode::KeyE], - PreSpawned::new(global_action_prespawn_hash(1)), + ReplicateLike { root: context }, )); - if is_server { - projectile_mode.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - projectile_mode - .insert(lightyear::prelude::input::bei::InputMarker::::default()); - } - let mut replication_mode = commands.spawn(( + commands.spawn(( ActionOf::::new(context), Action::::new(), - bindings![KeyCode::KeyR], - PreSpawned::new(global_action_prespawn_hash(2)), + ReplicateLike { root: context }, )); - if is_server { - replication_mode.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - replication_mode - .insert(lightyear::prelude::input::bei::InputMarker::::default()); - } - let mut weapon = commands.spawn(( + commands.spawn(( ActionOf::::new(context), Action::::new(), - bindings![KeyCode::KeyQ], - PreSpawned::new(global_action_prespawn_hash(3)), + ReplicateLike { root: context }, )); - if is_server { - weapon.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - weapon.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } } -/// Spawn the BEI player action entities on both sides so input messages can use PreSpawned targets. -pub(crate) fn spawn_player_actions( - commands: &mut Commands, - player: Entity, - client_id: PeerId, - replication_mode: GameReplicationMode, - is_server: bool, -) { - let mut movement = commands.spawn(( +/// Spawn the server-owned BEI player action entities. +/// +/// Clients receive these through replication and add local-only bindings/input +/// markers to the active local player's actions. The actions carry the same +/// room filter as the player because [`ReplicateLike`] copies replication +/// targets, but not room-based visibility filter components. +#[cfg(feature = "server")] +pub(crate) fn spawn_player_actions(commands: &mut Commands, player: Entity, room_id: RoomId) { + commands.spawn(( ActionOf::::new(player), Action::::new(), - Bindings::spawn(Cardinal::wasd_keys()), - PreSpawned::new(player_action_prespawn_hash(client_id, replication_mode, 1)), + ReplicateLike { root: player }, + Rooms::single(room_id), )); - if is_server { - movement.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - movement.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } - let mut cursor = commands.spawn(( + commands.spawn(( ActionOf::::new(player), Action::::new(), - PreSpawned::new(player_action_prespawn_hash(client_id, replication_mode, 2)), + ReplicateLike { root: player }, + Rooms::single(room_id), )); - if is_server { - cursor.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - cursor.insert(( - ActionMock::new( - TriggerState::Fired, - ActionValue::zero(ActionValueDim::Axis2D), - MockSpan::Manual, - ), - lightyear::prelude::input::bei::InputMarker::::default(), - )); - } - let mut shoot = commands.spawn(( + commands.spawn(( ActionOf::::new(player), Action::::new(), - Bindings::spawn_one((Binding::from(KeyCode::Space), Name::from("Binding"))), - PreSpawned::new(player_action_prespawn_hash(client_id, replication_mode, 3)), + ReplicateLike { root: player }, + Rooms::single(room_id), )); - if is_server { - shoot.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - shoot.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } } fn projectile_prespawn_salt(player_id: PeerId, salt: u64) -> u64 {