Switch to replicon (#80)

This commit is contained in:
PROMETHIA-27
2025-12-08 19:22:17 -05:00
committed by GitHub
parent 7a5d2e6914
commit ff52258ad2
55 changed files with 1212 additions and 2951 deletions

View File

@@ -21,11 +21,13 @@ bevy_asset_loader = { workspace = true }
bevy_ballistic = { workspace = true }
bevy_common_assets = { workspace = true }
bevy_debug_log = { workspace = true }
bevy_replicon = { workspace = true, features = ["client"] }
bevy_replicon_renet = { workspace = true, features = ["client"] }
bevy_sprite3d = { workspace = true }
bevy_trenchbroom = { workspace = true, features = ["client"] }
bevy_trenchbroom_avian = { workspace = true }
clap = { workspace = true }
happy_feet = { workspace = true }
lightyear = { workspace = true }
nil = { workspace = true }
rand = { workspace = true }
ron = { workspace = true }

View File

@@ -1,48 +1,50 @@
use crate::config::ClientConfig;
use avian3d::prelude::{
Collider, ColliderAabb, ColliderDensity, ColliderMarker, ColliderOf, ColliderTransform,
CollisionEventsEnabled, CollisionLayers, Position, Rotation, Sensor,
CollisionEventsEnabled, CollisionLayers, Sensor,
};
use bevy::{
ecs::bundle::BundleFromComponents, platform::cell::SyncCell, prelude::*, scene::SceneInstance,
};
use bevy_trenchbroom::geometry::Brushes;
use lightyear::{
frame_interpolation::FrameInterpolate,
link::{LinkConditioner, prelude::*},
netcode::Key,
prelude::{
client::{Input, InputDelayConfig, NetcodeConfig},
input::native::InputMarker,
*,
},
use bevy_replicon::{
RepliconPlugins,
client::{ClientSystems, confirm_history::ConfirmHistory},
prelude::{ClientState, ClientTriggerExt, RepliconChannels},
};
use bevy_replicon_renet::{
RenetChannelsExt, RepliconRenetPlugins,
netcode::{ClientAuthentication, NetcodeClientTransport, NetcodeError},
renet::{ConnectionConfig, RenetClient},
};
use bevy_trenchbroom::geometry::Brushes;
use nil::prelude::Mutex;
use shared::{
GameState,
control::ControlState,
global_observer,
player::Player,
GameState, global_observer,
protocol::{
ClientEnteredPlaying, TbMapEntityId, TbMapEntityMapping,
channels::UnorderedReliableChannel, messages::DespawnTbMapEntity,
ClientEnteredPlaying, TbMapEntityId, TbMapEntityMapping, messages::DespawnTbMapEntity,
},
tb_entities::{Platform, PlatformTarget},
tb_entities::{Movable, Platform, PlatformTarget},
};
use std::{
env::current_exe,
fs::File,
io::{BufRead, BufReader},
net::{IpAddr, Ipv4Addr, SocketAddr},
net::{Ipv4Addr, UdpSocket},
process::Stdio,
sync::{LazyLock, mpsc},
time::Duration,
time::SystemTime,
};
/// Cache of server processes to be cleared at process exit
static SERVER_PROCESSES: LazyLock<Mutex<Vec<std::process::Child>>> = LazyLock::new(Mutex::default);
pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::Connecting), attempt_connection);
app.add_plugins((RepliconPlugins, RepliconRenetPlugins));
app.add_systems(
OnEnter(GameState::Connecting),
connect_to_server.run_if(|config: Res<ClientConfig>| config.server.is_some()),
);
app.add_systems(
Update,
parse_local_server_stdout.run_if(resource_exists::<LocalServerStdout>),
@@ -53,15 +55,31 @@ pub fn plugin(app: &mut App) {
PreUpdate,
(migrate_remote_entities, ApplyDeferred)
.chain()
.after(ReplicationSystems::Receive),
.after(ClientSystems::Receive),
);
// TODO: migrate this to connect_on_local_server_started
app.add_systems(OnEnter(ClientState::Connected), on_connected_state);
app.add_systems(OnExit(ClientState::Connected), on_disconnect);
app.add_systems(
OnEnter(GameState::Connecting),
host_local_server.run_if(|config: Res<ClientConfig>| config.server.is_none()),
);
global_observer!(app, on_connecting);
global_observer!(app, on_connection_failed);
global_observer!(app, on_connection_succeeded);
global_observer!(app, temp_give_player_marker);
global_observer!(app, connect_on_local_server_started);
global_observer!(app, add_visual_interpolation_components);
}
//
// Client logic
//
fn on_connected_state(mut commands: Commands, mut game_state: ResMut<NextState<GameState>>) {
commands.client_trigger(ClientEnteredPlaying::default());
game_state.set(GameState::Playing);
}
fn on_disconnect() {
info!("disconnected from the server");
}
fn close_server_processes(mut app_exit: MessageReader<AppExit>) {
@@ -75,142 +93,88 @@ fn close_server_processes(mut app_exit: MessageReader<AppExit>) {
}
}
fn attempt_connection(mut commands: Commands) -> Result {
let mut args = std::env::args();
let client_port = loop {
match args.next().as_deref() {
Some("--port") => {
break args.next().unwrap().parse::<u16>().unwrap();
}
Some(_) => (),
None => break 25564,
}
};
//
// Renet
//
let client_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), client_port);
let server_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 25565);
let auth = Authentication::Manual {
server_addr,
client_id: client_port as u64,
private_key: Key::default(),
protocol_id: 0,
};
let sync_config = SyncConfig {
jitter_multiple: 5,
jitter_margin: Duration::from_millis(15),
..default()
};
let conditioner = LinkConditioner::new(LinkConditionerConfig {
incoming_latency: Duration::from_millis(10),
incoming_jitter: Duration::from_millis(0),
incoming_loss: 0.0,
fn connect_to_server(
mut commands: Commands,
config: Res<ClientConfig>,
channels: Res<RepliconChannels>,
) -> Result {
let server_channels_config = channels.server_configs();
let client_channels_config = channels.client_configs();
let client = RenetClient::new(ConnectionConfig {
server_channels_config,
client_channels_config,
..Default::default()
});
commands
.spawn((
Name::from("Client"),
Client::default(),
Link::new(Some(conditioner)),
LocalAddr(client_addr),
PeerAddr(server_addr),
ReplicationReceiver::default(),
client::NetcodeClient::new(
auth,
NetcodeConfig {
client_timeout_secs: 3,
..default()
},
)?,
UdpIo::default(),
PredictionManager::default(),
InputTimeline(Timeline::from(Input::new(
sync_config,
InputDelayConfig::balanced(),
))),
))
.trigger(|entity| Connect { entity });
commands.insert_resource(client);
commands.insert_resource(client_transport(&config)?);
Ok(())
}
fn on_connection_succeeded(
_trigger: On<Add, Connected>,
state: Res<State<GameState>>,
mut change_state: ResMut<NextState<GameState>>,
mut sender: Single<&mut EventSender<ClientEnteredPlaying>>,
) {
if *state == GameState::Connecting {
change_state.set(GameState::Playing);
sender.trigger::<UnorderedReliableChannel>(ClientEnteredPlaying);
}
}
fn client_transport(config: &ClientConfig) -> Result<NetcodeClientTransport, NetcodeError> {
let current_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap();
let client_id = current_time.as_millis() as u64;
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, config.port))?;
let server_addr = config
.server
.flatten()
.unwrap_or_else(|| "127.0.0.1:31111".parse().unwrap());
let authentication = ClientAuthentication::Unsecure {
client_id,
protocol_id: 0,
server_addr,
user_data: None,
};
/// A client starts `Disconnected`, so in order to tell if it *actually* failed to connect/disconnected
/// vs. simply having been created, we need some extra state.
#[derive(Component)]
struct ClientActive;
fn on_connecting(trigger: On<Add, Connecting>, mut commands: Commands) {
commands.entity(trigger.event().entity).insert(ClientActive);
info!("attempting connection to {server_addr}");
NetcodeClientTransport::new(current_time, authentication, socket)
}
#[derive(Resource)]
struct LocalServerStdout(SyncCell<mpsc::Receiver<String>>);
fn on_connection_failed(
trigger: On<Add, Disconnected>,
disconnected: Query<&Disconnected>,
mut commands: Commands,
client_active: Query<&ClientActive>,
mut opened_server: Local<bool>,
) -> Result {
let disconnected = disconnected.get(trigger.event().entity).unwrap();
if *opened_server {
panic!(
"failed to connect to local server: {:?}",
disconnected.reason
);
}
fn host_local_server(mut commands: Commands) -> Result {
// the server executable is assumed to be adjacent to the client executable
let mut exe_path = current_exe().expect("failed to get path of client executable");
exe_path.set_file_name("server");
let server_log_file = File::create("server.log")?;
let mut server_process = std::process::Command::new(exe_path)
.args(["--timeout", "60", "--close-on-client-disconnect"])
.env("NO_COLOR", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(server_log_file)
.spawn()
.expect("failed to start server");
let server_stdout = server_process.stdout.take().unwrap();
SERVER_PROCESSES.lock().push(server_process);
let client = trigger.event().entity;
if client_active.contains(client) {
commands.entity(client).remove::<ClientActive>();
let (tx, rx) = std::sync::mpsc::channel();
// the server executable is assumed to be adjacent to the client executable
let mut exe_path = current_exe().expect("failed to get path of client executable");
exe_path.set_file_name("server");
let server_log_file = File::create("server.log")?;
let mut server_process = std::process::Command::new(exe_path)
.args(["--timeout", "60", "--close-on-client-disconnect"])
.env("NO_COLOR", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(server_log_file)
.spawn()
.expect("failed to start server");
let server_stdout = server_process.stdout.take().unwrap();
SERVER_PROCESSES.lock().push(server_process);
let stdout = BufReader::new(server_stdout).lines();
let (tx, rx) = std::sync::mpsc::channel();
let stdout = BufReader::new(server_stdout).lines();
std::thread::spawn(move || {
for line in stdout {
match line {
Ok(line) => {
tx.send(line).unwrap();
}
Err(error) => {
error!("error reading local server stdout: `{error}`");
}
std::thread::spawn(move || {
for line in stdout {
match line {
Ok(line) => {
tx.send(line).unwrap();
}
Err(error) => {
error!("error reading local server stdout: `{error}`");
}
}
});
}
});
commands.insert_resource(LocalServerStdout(SyncCell::new(rx)));
*opened_server = true;
}
commands.insert_resource(LocalServerStdout(SyncCell::new(rx)));
Ok(())
}
@@ -231,27 +195,22 @@ fn parse_local_server_stdout(mut commands: Commands, mut stdout: ResMut<LocalSer
}
fn connect_on_local_server_started(
_trigger: On<LocalServerStarted>,
_: On<LocalServerStarted>,
commands: Commands,
state: Res<State<GameState>>,
mut commands: Commands,
client: Single<Entity, With<Client>>,
) {
channels: Res<RepliconChannels>,
config: Res<ClientConfig>,
) -> Result<()> {
if *state == GameState::Connecting {
commands
.entity(*client)
.trigger(|entity| Connect { entity });
connect_to_server(commands, config, channels)?;
}
}
fn temp_give_player_marker(trigger: On<Add, Player>, mut commands: Commands) {
commands
.entity(trigger.event().entity)
.insert(InputMarker::<ControlState>::default());
Ok(())
}
#[allow(clippy::type_complexity)]
fn migrate_remote_entities(
query: Query<(Entity, &TbMapEntityId), (Added<TbMapEntityId>, With<Replicated>)>,
query: Query<(Entity, &TbMapEntityId), (Added<TbMapEntityId>, With<ConfirmHistory>)>,
children: Query<&Children>,
mut commands: Commands,
mut mapping: ResMut<TbMapEntityMapping>,
@@ -286,6 +245,7 @@ fn received_remote_map_entity(
move_component::<ColliderOf>(commands, clientside, serverside);
move_component::<ColliderTransform>(commands, clientside, serverside);
move_component::<CollisionEventsEnabled>(commands, clientside, serverside);
move_component::<Movable>(commands, clientside, serverside);
move_component::<Platform>(commands, clientside, serverside);
move_component::<PlatformTarget>(commands, clientside, serverside);
move_component::<SceneInstance>(commands, clientside, serverside);
@@ -316,37 +276,16 @@ fn move_component<B: Bundle + BundleFromComponents>(
fn despawn_absent_map_entities(
mut commands: Commands,
mut messages: Query<&mut MessageReceiver<DespawnTbMapEntity>>,
mut messages: MessageReader<DespawnTbMapEntity>,
mut map: ResMut<TbMapEntityMapping>,
) {
for mut recv in messages.iter_mut() {
for msg in recv.receive() {
// the server may double-send DespawnTbMapEntity for a given ID, so ignore it if the entity
// was already despawned.
let Some(entity) = map.0.remove(&msg.0) else {
continue;
};
for msg in messages.read() {
// the server may double-send DespawnTbMapEntity for a given ID, so ignore it if the entity
// was already despawned.
let Some(entity) = map.0.remove(&msg.0) else {
continue;
};
commands.entity(entity).despawn();
}
commands.entity(entity).despawn();
}
}
fn add_visual_interpolation_components(trigger: On<Add, Predicted>, mut commands: Commands) {
commands.entity(trigger.entity).insert((
FrameInterpolate::<Position> {
// We must trigger change detection on visual interpolation
// to make sure that child entities (sprites, meshes, text)
// are also interpolated
trigger_change_detection: true,
..default()
},
FrameInterpolate::<Rotation> {
// We must trigger change detection on visual interpolation
// to make sure that child entities (sprites, meshes, text)
// are also interpolated
trigger_change_detection: true,
..default()
},
));
}

View File

@@ -0,0 +1,23 @@
use bevy::prelude::*;
use clap::Parser;
use std::net::SocketAddr;
pub fn plugin(app: &mut App) {
let config = ClientConfig::parse();
app.insert_resource(config);
}
#[derive(Resource, Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct ClientConfig {
/// The port to use when connecting.
#[arg(long, default_value_t = 0)]
pub port: u16,
/// The IP/port to connect to.
/// If `None`, host a local server.
/// If Some(None), connect to the default server (`127.0.0.1:31111`)
/// Otherwise, connect to the given server
#[arg(long)]
pub server: Option<Option<SocketAddr>>,
}

View File

@@ -1,5 +1,4 @@
use bevy::prelude::*;
use lightyear::prelude::input::native::ActionState;
use shared::{
GameState,
control::{ControlState, ControllerSet, LookDirMovement},
@@ -18,13 +17,11 @@ pub fn plugin(app: &mut App) {
}
fn rotate_rig(
actions: Query<&ActionState<ControlState>>,
controls: Res<ControlState>,
look_dir: Res<LookDirMovement>,
mut player: Query<(&mut Transform, &ChildOf), With<PlayerBodyMesh>>,
mut player: Query<&mut Transform, With<PlayerBodyMesh>>,
) {
for (mut rig_transform, child_of) in player.iter_mut() {
let controls = actions.get(child_of.parent()).unwrap();
for mut rig_transform in player.iter_mut() {
if controls.view_mode {
continue;
}

View File

@@ -8,10 +8,6 @@ use bevy::{
},
prelude::*,
};
use lightyear::{
input::client::InputSystems,
prelude::input::native::{ActionState, InputMarker},
};
use shared::{
control::{ControllerSet, LookDirMovement},
player::PlayerBodyMesh,
@@ -27,7 +23,7 @@ pub fn plugin(app: &mut App) {
app.add_systems(PreUpdate, (cache_keyboard_state, cache_gamepad_state));
app.add_systems(
FixedPreUpdate,
PreUpdate,
(
reset_lookdir,
gamepad_controls,
@@ -39,18 +35,14 @@ pub fn plugin(app: &mut App) {
get_lookdir,
clear_keyboard_just,
clear_gamepad_just,
send_inputs,
)
.chain()
.in_set(ControllerSet::CollectInputs)
.before(InputSystems::WriteClientInputs)
.run_if(
in_state(GameState::Playing)
.and(resource_exists_and_equals(CharacterInputEnabled::On)),
),
)
.add_systems(
FixedPreUpdate,
buffer_inputs.in_set(InputSystems::WriteClientInputs),
);
app.add_systems(
@@ -61,11 +53,8 @@ pub fn plugin(app: &mut App) {
/// Write inputs from combined keyboard/gamepad state into the networked input buffer
/// for the local player.
fn buffer_inputs(
mut player: Single<&mut ActionState<ControlState>, With<InputMarker<ControlState>>>,
controls: Res<ControlState>,
) {
player.0 = *controls;
fn send_inputs(mut writer: MessageWriter<ControlState>, controls: Res<ControlState>) {
writer.write(*controls);
}
fn reset_lookdir(mut look_dir: ResMut<LookDirMovement>) {
@@ -177,7 +166,7 @@ fn combine_controls(controls: Res<Controls>, mut combined_controls: ResMut<Contr
let keyboard = controls.keyboard_state;
let gamepad = controls.gamepad_state.unwrap_or_default();
combined_controls.look_dir = Dir3::NEG_Z;
combined_controls.look_dir = Vec3::NEG_Z;
combined_controls.move_dir = gamepad.move_dir + keyboard.move_dir;
combined_controls.jump = gamepad.jump | keyboard.jump;
combined_controls.view_mode = gamepad.view_mode | keyboard.view_mode;
@@ -197,9 +186,9 @@ fn get_lookdir(
rig_transform: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
) {
controls.look_dir = if let Some(ref rig_transform) = rig_transform {
rig_transform.forward()
rig_transform.forward().as_vec3()
} else {
Dir3::NEG_Z
Vec3::NEG_Z
};
}
@@ -252,7 +241,7 @@ fn gamepad_controls(
let state = ControlState {
move_dir,
look_dir: Dir3::NEG_Z,
look_dir: Vec3::NEG_Z,
jump: gamepad.pressed(GamepadButton::South),
view_mode: gamepad.pressed(GamepadButton::LeftTrigger2),
trigger: gamepad.pressed(GamepadButton::RightTrigger2),

View File

@@ -1,5 +1,6 @@
use crate::GameState;
use bevy::prelude::*;
use bevy_replicon::client::ClientSystems;
use shared::control::{ControlState, ControllerSet};
mod controller_flying;
@@ -23,7 +24,9 @@ pub fn plugin(app: &mut App) {
app.add_plugins((controller_flying::plugin, controls::plugin));
app.configure_sets(
FixedPreUpdate,
ControllerSet::CollectInputs.run_if(in_state(GameState::Playing)),
PreUpdate,
ControllerSet::CollectInputs
.before(ClientSystems::Receive)
.run_if(in_state(GameState::Playing)),
);
}

View File

@@ -1,5 +1,6 @@
mod backpack;
mod client;
mod config;
mod control;
mod debug;
mod enemy;
@@ -22,13 +23,8 @@ use bevy_trenchbroom::prelude::*;
use bevy_trenchbroom_avian::AvianPhysicsBackend;
use camera::MainCamera;
use heads_database::HeadDatabaseAsset;
use lightyear::{
avian3d::plugin::LightyearAvianPlugin, frame_interpolation::FrameInterpolationPlugin,
prelude::client::ClientPlugins,
};
use loading_assets::AudioAssets;
use shared::*;
use std::time::Duration;
fn main() {
let mut app = App::new();
@@ -68,25 +64,7 @@ fn main() {
bevy_debug_log::LogViewerPlugin::default()
.auto_open_threshold(bevy::log::tracing::level_filters::LevelFilter::OFF),
);
app.add_plugins(
PhysicsPlugins::default()
.build()
// TODO: This plugin is *not* disabled on the server. This is to solve a bug related to collider transform inheritance. See the
// LightyearAvianPlugin below.
// Periwink is looking into it at the moment. This **must** be disabled on the client, or positions break for NPCs and some other things.
.disable::<PhysicsTransformPlugin>()
// FrameInterpolation handles interpolating Position and Rotation
.disable::<PhysicsInterpolationPlugin>(),
);
// TODO: This plugin is *not* inserted on the server. This is to solve a bug related to collider transform inheritance. See the
// `.disable::<PhysicsTransformPlugin>()` above.
// Periwink is looking into it at the moment. This **must** be inserted on the client, or positions break for NPCs and some other things.
app.add_plugins(LightyearAvianPlugin::default());
app.add_plugins(FrameInterpolationPlugin::<Position>::default());
app.add_plugins(FrameInterpolationPlugin::<Rotation>::default());
app.add_plugins(ClientPlugins {
tick_duration: Duration::from_secs_f64(1.0 / 60.0),
});
app.add_plugins(PhysicsPlugins::default());
app.add_plugins(Sprite3dPlugin);
app.add_plugins(TrenchBroomPlugins(
TrenchBroomConfig::new("hedz")
@@ -116,7 +94,6 @@ fn main() {
app.add_plugins(shared::player::plugin);
app.add_plugins(shared::gates::plugin);
app.add_plugins(shared::platforms::plugin);
app.add_plugins(shared::protocol::plugin);
app.add_plugins(shared::movables::plugin);
app.add_plugins(shared::utils::billboards::plugin);
app.add_plugins(shared::aim::plugin);
@@ -140,10 +117,16 @@ fn main() {
app.add_plugins(shared::utils::trail::plugin);
app.add_plugins(shared::utils::auto_rotate::plugin);
app.add_plugins(shared::tb_entities::plugin);
app.add_plugins(shared::tick::plugin);
app.add_plugins(shared::utils::explosions::plugin);
app.add_plugins(backpack::plugin);
// Networking
// The client/server plugin must go before the protocol, or else `ProtocolHasher` will not be available.
app.add_plugins(client::plugin);
app.add_plugins(shared::protocol::plugin);
app.add_plugins(backpack::plugin);
app.add_plugins(config::plugin);
app.add_plugins(control::plugin);
app.add_plugins(debug::plugin);
app.add_plugins(enemy::plugin);

View File

@@ -4,14 +4,13 @@ use crate::{
loading_assets::AudioAssets,
};
use bevy::prelude::*;
use lightyear::prelude::MessageReceiver;
use shared::{
player::PlayerBodyMesh,
player::{LocalPlayerId, PlayerBodyMesh},
protocol::{PlaySound, PlayerId, events::ClientHeadChanged, messages::AssignClientPlayer},
};
pub fn plugin(app: &mut App) {
app.register_type::<ClientPlayerId>();
app.register_type::<LocalPlayerId>();
app.register_type::<LocalPlayer>();
app.init_state::<PlayerAssignmentState>();
@@ -28,35 +27,32 @@ pub fn plugin(app: &mut App) {
global_observer!(app, on_update_head_mesh);
}
#[derive(Resource, Reflect)]
#[reflect(Resource)]
pub struct ClientPlayerId {
id: u8,
}
fn receive_player_id(
mut commands: Commands,
mut recv: Single<&mut MessageReceiver<AssignClientPlayer>>,
mut client_assignments: MessageReader<AssignClientPlayer>,
mut next: ResMut<NextState<PlayerAssignmentState>>,
) {
for AssignClientPlayer(id) in recv.receive() {
commands.insert_resource(ClientPlayerId { id });
for &AssignClientPlayer(id) in client_assignments.read() {
commands.insert_resource(LocalPlayerId { id });
next.set(PlayerAssignmentState::IdReceived);
info!("player id `{id}` received");
info!("player id `{}` received", id.id);
}
}
fn match_player_id(
mut commands: Commands,
players: Query<(Entity, &PlayerId), Changed<PlayerId>>,
client: Res<ClientPlayerId>,
client: Res<LocalPlayerId>,
mut next: ResMut<NextState<PlayerAssignmentState>>,
) {
for (entity, player) in players.iter() {
if player.id == client.id {
for (entity, player_id) in players.iter() {
if *player_id == client.id {
commands.entity(entity).insert(LocalPlayer);
next.set(PlayerAssignmentState::Confirmed);
info!("player entity {entity:?} confirmed with id `{}`", player.id);
info!(
"player entity {entity:?} confirmed with id `{}`",
player_id.id
);
break;
}
}