Client/Server Feature Split (#63)

This commit is contained in:
PROMETHIA-27
2025-09-26 21:59:08 -04:00
committed by GitHub
parent 7f6c00b5d6
commit 2f5d154d26
30 changed files with 674 additions and 271 deletions

View File

@@ -5,12 +5,15 @@ edition = "2024"
build = "build.rs"
[features]
default = ["lightyear/client"]
default = ["shared/client"]
dbg = ["avian3d/debug-plugin", "dep:bevy-inspector-egui", "shared/dbg"]
[dependencies]
avian3d = { workspace = true }
bevy = { workspace = true }
bevy = { workspace = true, default-features = false, features = [
"bevy_window",
"bevy_winit",
] }
bevy-inspector-egui = { workspace = true, optional = true }
bevy-steamworks = { workspace = true }
bevy-ui-gradients = { workspace = true }
@@ -19,7 +22,7 @@ bevy_ballistic = { workspace = true }
bevy_common_assets = { workspace = true }
bevy_debug_log = { workspace = true }
bevy_sprite3d = { workspace = true }
bevy_trenchbroom = { workspace = true }
bevy_trenchbroom = { workspace = true, features = ["client"] }
happy_feet = { workspace = true }
lightyear = { workspace = true }
nil = { workspace = true }

View File

@@ -1,26 +1,48 @@
use bevy::prelude::*;
use bevy::{prelude::*, utils::synccell::SyncCell};
use lightyear::{
connection::client::ClientState,
netcode::Key,
prelude::{client::NetcodeConfig, input::native::InputMarker, *},
};
use shared::{
GameState, control::ControlState, global_observer, heads_database::HeadsDatabase,
player::Player, tb_entities::SpawnPoint,
use nil::prelude::Mutex;
use shared::{GameState, control::ControlState, global_observer, player::Player};
use std::{
env::current_exe,
io::{BufRead, BufReader},
net::{IpAddr, Ipv4Addr, SocketAddr},
process::Stdio,
sync::{LazyLock, mpsc},
};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// 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(Startup, temp_connect_on_startup);
app.add_systems(OnEnter(GameState::Connecting), attempt_connection);
app.add_systems(
FixedUpdate,
spawn_disconnected_player.run_if(in_state(GameState::Playing)),
Update,
parse_local_server_stdout.run_if(resource_exists::<LocalServerStdout>),
);
app.add_systems(Last, close_server_processes);
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);
}
fn temp_connect_on_startup(mut commands: Commands) -> Result {
fn close_server_processes(mut app_exit: EventReader<AppExit>) {
if app_exit.read().next().is_some() {
let mut lock = SERVER_PROCESSES.lock();
for mut process in lock.drain(..) {
if let Err(err) = process.wait() {
error!("{err}");
}
}
}
}
fn attempt_connection(mut commands: Commands) -> Result {
let mut args = std::env::args();
let client_port = loop {
match args.next().as_deref() {
@@ -44,9 +66,9 @@ fn temp_connect_on_startup(mut commands: Commands) -> Result {
.spawn((
Name::from("Client"),
Client::default(),
Link::new(None),
LocalAddr(client_addr),
PeerAddr(server_addr),
Link::new(None),
ReplicationReceiver::default(),
client::NetcodeClient::new(
auth,
@@ -62,15 +84,104 @@ fn temp_connect_on_startup(mut commands: Commands) -> Result {
Ok(())
}
fn spawn_disconnected_player(
disconnected: Single<&Client, Changed<Client>>,
commands: Commands,
asset_server: Res<AssetServer>,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
fn on_connection_succeeded(
_trigger: Trigger<OnAdd, Connected>,
state: Res<State<GameState>>,
mut change_state: ResMut<NextState<GameState>>,
) {
if disconnected.state == ClientState::Disconnected {
shared::player::spawn(commands, Entity::PLACEHOLDER, query, asset_server, heads_db)
if *state == GameState::Connecting {
change_state.set(GameState::Playing);
}
}
/// 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: Trigger<OnAdd, Connecting>, mut commands: Commands) {
commands.entity(trigger.target()).insert(ClientActive);
}
#[derive(Resource)]
struct LocalServerStdout(SyncCell<mpsc::Receiver<String>>);
fn on_connection_failed(
trigger: Trigger<OnAdd, Disconnected>,
disconnected: Query<&Disconnected>,
mut commands: Commands,
client_active: Query<&ClientActive>,
mut opened_server: Local<bool>,
) {
let disconnected = disconnected.get(trigger.target()).unwrap();
if *opened_server {
panic!(
"failed to connect to local server: {:?}",
disconnected.reason
);
}
let client = trigger.target();
if client_active.contains(client) {
commands.entity(client).remove::<ClientActive>();
// 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 mut server_process = std::process::Command::new(exe_path)
.args(["--timeout", "60", "--close-on-client-disconnect"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to start server");
let server_stdout = server_process.stdout.take().unwrap();
SERVER_PROCESSES.lock().push(server_process);
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}`");
}
}
}
});
commands.insert_resource(LocalServerStdout(SyncCell::new(rx)));
*opened_server = true;
}
}
#[derive(Event)]
struct LocalServerStarted;
fn parse_local_server_stdout(mut commands: Commands, mut stdout: ResMut<LocalServerStdout>) {
let stdout: &mut LocalServerStdout = &mut stdout;
while let Ok(line) = stdout.0.get().try_recv() {
if let "hedz.server_started" = &line[..] {
commands.trigger(LocalServerStarted);
}
}
}
fn connect_on_local_server_started(
_trigger: Trigger<LocalServerStarted>,
state: Res<State<GameState>>,
mut commands: Commands,
client: Single<Entity, With<Client>>,
) {
if *state == GameState::Connecting {
commands.entity(*client).trigger(Connect);
}
}

View File

@@ -0,0 +1,14 @@
use bevy::prelude::*;
use shared::{GameState, tb_entities::EnemySpawn};
pub fn plugin(app: &mut App) {
app.add_systems(OnExit(GameState::MapLoading), despawn_enemy_spawns);
}
/// Despawn enemy spawners because only the server will ever spawn enemies with them, and they have a
/// collider.
fn despawn_enemy_spawns(mut commands: Commands, enemy_spawns: Query<Entity, With<EnemySpawn>>) {
for spawner in enemy_spawns.iter() {
commands.entity(spawner).despawn();
}
}

View File

@@ -1,5 +1,6 @@
mod client;
mod debug;
mod enemy;
mod steam;
mod ui;
@@ -47,7 +48,7 @@ fn main() {
..default()
})
.set(bevy::log::LogPlugin {
filter: "info,lightyear_replication=off".into(),
filter: "info,lightyear_replication=off,bevy_ecs::hierarchy=off".into(),
level: bevy::log::Level::INFO,
// provide custom log layer to receive logging events
custom_layer: bevy_debug_log::log_capture_layer,
@@ -90,6 +91,7 @@ fn main() {
app.add_plugins(animation::plugin);
app.add_plugins(character::plugin);
app.add_plugins(cash::plugin);
app.add_plugins(enemy::plugin);
app.add_plugins(player::plugin);
app.add_plugins(gates::plugin);
app.add_plugins(platforms::plugin);

View File

@@ -4,26 +4,22 @@ version = "0.1.0"
edition = "2024"
[features]
default = ["lightyear/server"]
default = ["shared/server"]
dbg = ["avian3d/debug-plugin", "dep:bevy-inspector-egui", "shared/dbg"]
[dependencies]
avian3d = { workspace = true }
bevy = { workspace = true }
bevy = { workspace = true, default-features = false }
bevy-inspector-egui = { workspace = true, optional = true }
bevy-steamworks = { workspace = true }
bevy-ui-gradients = { workspace = true }
bevy_asset_loader = { workspace = true }
bevy_ballistic = { workspace = true }
bevy_common_assets = { workspace = true }
bevy_debug_log = { workspace = true }
bevy_sprite3d = { workspace = true }
bevy_trenchbroom = { workspace = true }
clap = { version = "=4.5.47", features = ["derive"] }
happy_feet = { workspace = true }
lightyear = { workspace = true }
lightyear_avian3d = { workspace = true }
nil = { workspace = true }
rand = { workspace = true }
ron = { workspace = true }
serde = { workspace = true }
shared = { workspace = true }

View File

@@ -0,0 +1,20 @@
use bevy::prelude::*;
use clap::Parser;
pub fn plugin(app: &mut App) {
let config = ServerConfig::parse();
app.insert_resource(config);
}
/// The server for HEDZ Reloaded
#[derive(Resource, Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct ServerConfig {
#[arg(long, default_value_t = f32::INFINITY)]
/// How long to wait for a client to connect before closing, in seconds
pub timeout: f32,
#[arg(long, default_value_t = false)]
/// Whether to close when a client disconnects
pub close_on_client_disconnect: bool,
}

View File

@@ -1,6 +1,12 @@
use crate::utils::{auto_rotate, explosions};
use avian3d::prelude::*;
use bevy::{audio::Volume, core_pipeline::tonemapping::Tonemapping, prelude::*};
use bevy::{
app::plugin_group,
audio::Volume,
core_pipeline::tonemapping::Tonemapping,
log::{BoxedLayer, tracing_subscriber::Layer},
prelude::*,
};
use bevy_common_assets::ron::RonAssetPlugin;
use bevy_sprite3d::Sprite3dPlugin;
use bevy_trenchbroom::prelude::*;
@@ -11,8 +17,61 @@ use shared::*;
use std::time::Duration;
use utils::{billboards, sprite_3d_animation, squish_animation, trail};
mod config;
mod server;
plugin_group! {
pub struct DefaultPlugins {
bevy::app:::PanicHandlerPlugin,
bevy::log:::LogPlugin,
bevy::app:::TaskPoolPlugin,
bevy::diagnostic:::FrameCountPlugin,
bevy::time:::TimePlugin,
bevy::transform:::TransformPlugin,
bevy::diagnostic:::DiagnosticsPlugin,
bevy::input:::InputPlugin,
bevy::app:::ScheduleRunnerPlugin,
bevy::window:::WindowPlugin,
bevy::a11y:::AccessibilityPlugin,
bevy::app:::TerminalCtrlCHandlerPlugin,
bevy::asset:::AssetPlugin,
bevy::scene:::ScenePlugin,
bevy::render:::RenderPlugin,
bevy::render::texture:::ImagePlugin,
bevy::render::pipelined_rendering:::PipelinedRenderingPlugin,
bevy::core_pipeline:::CorePipelinePlugin,
bevy::sprite:::SpritePlugin,
bevy::text:::TextPlugin,
bevy::ui:::UiPlugin,
bevy::pbr:::PbrPlugin,
bevy::gltf:::GltfPlugin,
bevy::audio:::AudioPlugin,
bevy::gilrs:::GilrsPlugin,
bevy::animation:::AnimationPlugin,
bevy::gizmos:::GizmoPlugin,
bevy::state::app:::StatesPlugin,
#[plugin_group]
bevy::picking:::DefaultPickingPlugins,
}
}
pub fn log_to_file_layer(_app: &mut App) -> Option<BoxedLayer> {
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open("server.log")
.ok()?;
Some(
bevy::log::tracing_subscriber::fmt::layer()
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.with_file(true)
.with_line_number(true)
.boxed(),
)
}
fn main() {
let mut app = App::new();
@@ -26,28 +85,13 @@ fn main() {
cam_follow: true,
});
app.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "HEDZ Reloaded".into(),
// resolution: (1024., 768.).into(),
..default()
}),
..default()
})
.set(bevy::log::LogPlugin {
filter: "info".into(),
level: bevy::log::Level::INFO,
// provide custom log layer to receive logging events
custom_layer: bevy_debug_log::log_capture_layer,
}),
);
app.add_plugins(DefaultPlugins.set(bevy::log::LogPlugin {
filter: "info,lightyear_replication=off".into(),
level: bevy::log::Level::INFO,
// provide custom log layer to receive logging events
custom_layer: log_to_file_layer,
}));
app.add_plugins(
bevy_debug_log::LogViewerPlugin::default()
.auto_open_threshold(bevy::log::tracing::level_filters::LevelFilter::OFF),
);
app.add_plugins(ServerPlugins {
tick_duration: Duration::from_secs_f32(1.0 / 60.0),
});
@@ -78,6 +122,7 @@ fn main() {
app.add_plugins(animation::plugin);
app.add_plugins(character::plugin);
app.add_plugins(cash::plugin);
app.add_plugins(config::plugin);
app.add_plugins(player::plugin);
app.add_plugins(gates::plugin);
app.add_plugins(platforms::plugin);

View File

@@ -1,16 +1,25 @@
use crate::config::ServerConfig;
use bevy::prelude::*;
use lightyear::prelude::{
server::{NetcodeConfig, NetcodeServer, ServerUdpIo},
server::{NetcodeConfig, NetcodeServer, ServerUdpIo, Started},
*,
};
use shared::{heads_database::HeadsDatabase, tb_entities::SpawnPoint, utils::commands::IsServer};
use shared::{GameState, global_observer, heads_database::HeadsDatabase, tb_entities::SpawnPoint};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
pub fn plugin(app: &mut App) {
app.init_resource::<IsServer>();
app.add_systems(Startup, (start_server, setup_timeout_timer));
app.add_systems(
Update,
(
notify_started.run_if(in_state(GameState::Playing)),
run_timeout,
),
);
app.add_systems(Startup, start_server);
app.add_observer(handle_new_client);
global_observer!(app, handle_new_client);
global_observer!(app, close_on_disconnect);
global_observer!(app, cancel_timeout);
}
fn handle_new_client(
@@ -34,17 +43,52 @@ fn handle_new_client(
Ok(())
}
fn close_on_disconnect(
_trigger: Trigger<OnRemove, Connected>,
config: Res<ServerConfig>,
mut writer: EventWriter<AppExit>,
) {
if config.close_on_client_disconnect {
writer.write(AppExit::Success);
}
}
fn start_server(mut commands: Commands) -> Result {
let server_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 25565);
commands
.spawn((
Name::from("Server"),
LocalAddr(server_addr),
ServerUdpIo::default(),
NetcodeServer::new(NetcodeConfig::default()),
))
.trigger(server::Start);
let mut commands = commands.spawn((
Name::from("Server"),
LocalAddr(server_addr),
ServerUdpIo::default(),
NetcodeServer::new(NetcodeConfig::default()),
));
commands.trigger(server::Start);
Ok(())
}
fn notify_started(started: Query<&Started>, mut notified: Local<bool>) {
if !*notified && !started.is_empty() {
println!("hedz.server_started");
*notified = true;
}
}
#[derive(Resource)]
struct TimeoutTimer(f32);
fn setup_timeout_timer(mut commands: Commands, config: Res<ServerConfig>) {
commands.insert_resource(TimeoutTimer(config.timeout));
}
fn run_timeout(mut timer: ResMut<TimeoutTimer>, mut writer: EventWriter<AppExit>, time: Res<Time>) {
timer.0 -= time.delta_secs();
if timer.0 <= 0.0 {
writer.write(AppExit::Success);
}
}
fn cancel_timeout(_trigger: Trigger<OnAdd, Connected>, mut timer: ResMut<TimeoutTimer>) {
timer.0 = f32::INFINITY;
}

View File

@@ -4,11 +4,13 @@ version = "0.1.0"
edition = "2024"
[features]
client = ["lightyear/client"]
server = ["lightyear/server"]
dbg = []
[dependencies]
avian3d = { workspace = true }
bevy = { workspace = true }
bevy = { workspace = true, default-features = false }
bevy-inspector-egui = { workspace = true, optional = true }
bevy-steamworks = { workspace = true }
bevy-ui-gradients = { workspace = true }

View File

@@ -6,14 +6,11 @@ use crate::{
physics_layers::GameLayer,
protocol::GltfSceneRoot,
tb_entities::EnemySpawn,
utils::{
auto_rotate::AutoRotation,
commands::{CommandExt, EntityCommandExt},
global_observer,
},
utils::{auto_rotate::AutoRotation, commands::CommandExt, global_observer},
};
use avian3d::prelude::*;
use bevy::prelude::*;
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use std::f32::consts::PI;
@@ -63,29 +60,30 @@ fn on_trigger_missile(
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.forward().as_vec3() * 2.0;
commands
.spawn((
Name::new("projectile-missile"),
CurverProjectile {
time: time.elapsed_secs(),
damage: head.damage,
},
Collider::capsule_endpoints(0.4, Vec3::new(0., 0., 2.), Vec3::new(0., 0., -2.)),
CollisionLayers::new(
LayerMask(GameLayer::Projectile.to_bits()),
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
),
Sensor,
CollisionEventsEnabled,
Visibility::default(),
transform,
children![(
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse()),
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
GltfSceneRoot::Projectile(head.projectile.clone()),
),],
))
.insert_server(Replicate::to_clients(NetworkTarget::All));
let mut _projectile = commands.spawn((
Name::new("projectile-missile"),
CurverProjectile {
time: time.elapsed_secs(),
damage: head.damage,
},
Collider::capsule_endpoints(0.4, Vec3::new(0., 0., 2.), Vec3::new(0., 0., -2.)),
CollisionLayers::new(
LayerMask(GameLayer::Projectile.to_bits()),
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
),
Sensor,
CollisionEventsEnabled,
Visibility::default(),
transform,
children![(
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse()),
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
GltfSceneRoot::Projectile(head.projectile.clone()),
),],
));
#[cfg(feature = "server")]
_projectile.insert(Replicate::to_clients(NetworkTarget::All));
}
fn enemy_hit(

View File

@@ -6,15 +6,11 @@ use crate::{
physics_layers::GameLayer,
protocol::GltfSceneRoot,
sounds::PlaySound,
utils::{
commands::{CommandExt, EntityCommandExt},
explosions::Explosion,
global_observer,
trail::Trail,
},
utils::{commands::CommandExt, explosions::Explosion, global_observer, trail::Trail},
};
use avian3d::prelude::*;
use bevy::prelude::*;
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use std::f32::consts::PI;
@@ -63,46 +59,46 @@ fn on_trigger_missile(
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.forward().as_vec3() * 2.0;
commands
.spawn((
Name::new("projectile-missile"),
MissileProjectile {
time: time.elapsed_secs(),
damage: head.damage,
},
Collider::capsule_endpoints(0.4, Vec3::new(0., 0., 2.), Vec3::new(0., 0., -2.)),
CollisionLayers::new(
LayerMask(GameLayer::Projectile.to_bits()),
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
let mut _projectile = commands.spawn((
Name::new("projectile-missile"),
MissileProjectile {
time: time.elapsed_secs(),
damage: head.damage,
},
Collider::capsule_endpoints(0.4, Vec3::new(0., 0., 2.), Vec3::new(0., 0., -2.)),
CollisionLayers::new(
LayerMask(GameLayer::Projectile.to_bits()),
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
),
Sensor,
CollisionEventsEnabled,
Visibility::default(),
transform,
children![
(
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse()),
GltfSceneRoot::Projectile("missile".to_string()),
),
Sensor,
CollisionEventsEnabled,
Visibility::default(),
transform,
children![
(
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse()),
GltfSceneRoot::Projectile("missile".to_string()),
),
(
Trail::new(
12,
LinearRgba::rgb(1., 0.0, 0.),
LinearRgba::rgb(0.9, 0.9, 0.)
)
.with_pos(transform.translation),
Gizmo {
handle: gizmo_assets.add(GizmoAsset::default()),
line_config: GizmoLineConfig {
width: 10.,
..default()
},
(
Trail::new(
12,
LinearRgba::rgb(1., 0.0, 0.),
LinearRgba::rgb(0.9, 0.9, 0.)
)
.with_pos(transform.translation),
Gizmo {
handle: gizmo_assets.add(GizmoAsset::default()),
line_config: GizmoLineConfig {
width: 10.,
..default()
},
)
],
))
.insert_server(Replicate::to_clients(NetworkTarget::All));
..default()
},
)
],
));
#[cfg(feature = "server")]
_projectile.insert(Replicate::to_clients(NetworkTarget::All));
}
fn update(mut query: Query<&mut Transform, With<MissileProjectile>>) {

View File

@@ -18,7 +18,7 @@ use crate::{
physics_layers::GameLayer,
player::{Player, PlayerBodyMesh},
sounds::PlaySound,
utils::{billboards::Billboard, commands::IsServer, sprite_3d_animation::AnimationTimer},
utils::{billboards::Billboard, sprite_3d_animation::AnimationTimer},
};
use bevy::{pbr::NotShadowCaster, prelude::*};
use bevy_sprite3d::{Sprite3dBuilder, Sprite3dParams};
@@ -126,11 +126,10 @@ fn on_trigger_state(
player: Query<(&ActiveHead, &ActionState<ControlState>), With<Player>>,
headdb: Res<HeadsDatabase>,
time: Res<Time>,
is_server: Option<Res<IsServer>>,
client: Query<&Client>,
) {
if let Ok(client) = client.single()
&& (client.state == ClientState::Connected && is_server.is_none())
&& (client.state == ClientState::Connected && cfg!(not(feature = "server")))
{
return;
}

View File

@@ -7,15 +7,13 @@ use crate::{
protocol::GltfSceneRoot,
sounds::PlaySound,
utils::{
auto_rotate::AutoRotation,
commands::{CommandExt, EntityCommandExt},
explosions::Explosion,
global_observer,
auto_rotate::AutoRotation, commands::CommandExt, explosions::Explosion, global_observer,
},
};
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_ballistic::launch_velocity;
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use serde::{Deserialize, Serialize};
use std::f32::consts::PI;
@@ -62,31 +60,32 @@ fn on_trigger_thrown(
//TODO: projectile db?
let explosion_animation = !matches!(state.head, 8 | 16);
commands
.spawn((
Transform::from_translation(pos),
Name::new("projectile-thrown"),
ThrownProjectile {
impact_animation: explosion_animation,
damage: head.damage,
},
Collider::sphere(0.4),
CollisionLayers::new(
LayerMask(GameLayer::Projectile.to_bits()),
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
),
RigidBody::Dynamic,
CollisionEventsEnabled,
Mass(0.01),
LinearVelocity(vel),
Visibility::default(),
Sensor,
))
.insert_server(Replicate::to_clients(NetworkTarget::All))
.with_child((
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
GltfSceneRoot::Projectile(head.projectile.clone()),
));
let mut projectile = commands.spawn((
Transform::from_translation(pos),
Name::new("projectile-thrown"),
ThrownProjectile {
impact_animation: explosion_animation,
damage: head.damage,
},
Collider::sphere(0.4),
CollisionLayers::new(
LayerMask(GameLayer::Projectile.to_bits()),
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
),
RigidBody::Dynamic,
CollisionEventsEnabled,
Mass(0.01),
LinearVelocity(vel),
Visibility::default(),
Sensor,
));
projectile.with_child((
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
GltfSceneRoot::Projectile(head.projectile.clone()),
));
#[cfg(feature = "server")]
projectile.insert(Replicate::to_clients(NetworkTarget::All));
}
fn shot_collision(

View File

@@ -4,7 +4,6 @@ use crate::{
animation::AnimationFlags,
control::{controller_common::MovementSpeedFactor, controls::ControllerSettings},
player::PlayerBodyMesh,
utils::commands::IsServer,
};
use bevy::prelude::*;
use happy_feet::prelude::{Grounding, KinematicVelocity, MoveInput};
@@ -50,7 +49,6 @@ fn apply_controls(
&ActionState<ControlState>,
)>,
rig_transform_q: Option<Single<&GlobalTransform, With<PlayerBodyMesh>>>,
is_server: Option<Res<IsServer>>,
) {
let Ok((
mut move_input,
@@ -80,7 +78,7 @@ fn apply_controls(
move_input.set(direction * move_factor.0);
if controls.jump && grounding.is_grounded() {
if is_server.is_some() {
if cfg!(feature = "server") {
flags.jumping = true;
flags.jump_count += 1;
}

View File

@@ -1,8 +1,9 @@
use super::{ControlState, Controls};
#[cfg(feature = "client")]
use crate::player::Player;
use crate::{
GameState,
control::{CharacterInputEnabled, ControllerSet},
player::Player,
};
use bevy::{
input::{
@@ -12,7 +13,8 @@ use bevy::{
},
prelude::*,
};
use lightyear::prelude::{client::input::InputSet::WriteClientInputs, input::native::ActionState};
#[cfg(feature = "client")]
use lightyear::prelude::input::native::ActionState;
use serde::{Deserialize, Serialize};
pub fn plugin(app: &mut App) {
@@ -38,7 +40,14 @@ pub fn plugin(app: &mut App) {
),
);
app.add_systems(FixedPreUpdate, buffer_inputs.in_set(WriteClientInputs));
#[cfg(feature = "client")]
{
use lightyear::prelude::client::input::InputSet;
app.add_systems(
FixedPreUpdate,
buffer_inputs.in_set(InputSet::WriteClientInputs),
);
}
app.add_systems(
Update,
@@ -53,6 +62,7 @@ pub struct ControllerSettings {
pub jump_force: f32,
}
#[cfg(feature = "client")]
/// Write inputs from combined keyboard/gamepad state into the networked input buffer
/// for the local player.
fn buffer_inputs(

View File

@@ -52,5 +52,6 @@ pub enum GameState {
#[default]
AssetLoading,
MapLoading,
Connecting,
Playing,
}

View File

@@ -15,9 +15,17 @@ fn setup_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
))
.observe(
|_t: Trigger<SceneCollidersReady>,
mut next_game_state: ResMut<NextState<GameState>>| {
#[cfg(any(feature = "client", feature = "server"))] mut next_game_state: ResMut<
NextState<GameState>,
>| {
info!("map loaded");
assert!(cfg!(feature = "client") ^ cfg!(feature = "server"));
#[cfg(feature = "client")]
next_game_state.set(GameState::Connecting);
#[cfg(feature = "server")]
next_game_state.set(GameState::Playing);
},
);

View File

@@ -12,13 +12,14 @@ use crate::{
loading_assets::GameAssets,
sounds::PlaySound,
tb_entities::EnemySpawn,
utils::{
billboards::Billboard,
commands::{EntityCommandExt, IsServer},
},
utils::billboards::Billboard,
};
use bevy::{pbr::NotShadowCaster, prelude::*};
use lightyear::prelude::{Client, Connected, Disconnected, NetworkTarget, Replicate};
use lightyear::prelude::Disconnected;
#[cfg(feature = "client")]
use lightyear::prelude::{Client, Connected};
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -56,20 +57,26 @@ pub fn plugin(app: &mut App) {
fn setup(
mut commands: Commands,
is_server: Option<Res<IsServer>>,
client: Query<(Option<&Connected>, Option<&Disconnected>), With<Client>>,
#[cfg(feature = "client")] client: Query<
(Option<&Connected>, Option<&Disconnected>),
With<Client>,
>,
mut spawned: Local<bool>,
) {
if *spawned {
return;
}
if is_server.is_some() {
#[cfg(feature = "server")]
{
commands.init_resource::<NpcSpawning>();
commands.trigger(OnCheckSpawns { on_client: false });
*spawned = true;
} else if let Ok((connected, disconnected)) = client.single()
}
#[cfg(feature = "client")]
if let Ok((connected, disconnected)) = client.single()
&& (connected.is_some() || disconnected.is_some())
{
commands.init_resource::<NpcSpawning>();
@@ -87,9 +94,8 @@ fn on_spawn_check(
query: Query<(Entity, &EnemySpawn, &Transform), Without<Npc>>,
heads_db: Res<HeadsDatabase>,
spawning: Res<NpcSpawning>,
is_server: Option<Res<IsServer>>,
) {
if is_server.is_none() && !trigger.event().on_client {
if cfg!(not(feature = "server")) && !trigger.event().on_client {
return;
}
@@ -107,8 +113,8 @@ fn on_spawn_check(
}
let id = names[&spawn.head];
commands
.entity(e)
let mut ecommands = commands.entity(e);
ecommands
.insert((
Hitpoints::new(100),
Npc,
@@ -122,9 +128,10 @@ fn on_spawn_check(
]),
))
.insert_if(Ai, || !spawn.disable_ai)
.insert_server(Replicate::to_clients(NetworkTarget::All))
.with_child((Name::from("body-rig"), AnimatedCharacter::new(id)))
.observe(on_kill);
#[cfg(feature = "server")]
ecommands.insert(Replicate::to_clients(NetworkTarget::All));
commands.trigger(SpawnCharacter(transform.translation));
commands.trigger(PlaySound::Beaming);
@@ -133,7 +140,6 @@ fn on_spawn_check(
fn on_kill(
trigger: Trigger<Kill>,
is_server: Option<Res<IsServer>>,
disconnected: Option<Single<&Disconnected>>,
mut commands: Commands,
query: Query<(&Transform, &EnemySpawn, &ActiveHead)>,
@@ -150,7 +156,7 @@ fn on_kill(
commands.trigger(HeadDrops::new(transform.translation, head.0));
commands.trigger(OnCheckSpawns {
on_client: is_server.is_some() || disconnected.is_some(),
on_client: cfg!(feature = "server") || disconnected.is_some(),
});
commands.entity(trigger.target()).despawn();

View File

@@ -15,7 +15,6 @@ use crate::{
npc::SpawnCharacter,
sounds::PlaySound,
tb_entities::SpawnPoint,
utils::commands::EntityCommandExt,
};
use avian3d::prelude::*;
use bevy::{
@@ -23,9 +22,9 @@ use bevy::{
prelude::*,
window::{CursorGrabMode, PrimaryWindow},
};
use lightyear::prelude::{
ControlledBy, Lifetime, NetworkTarget, PredictionTarget, Replicate, input::native::ActionState,
};
use lightyear::prelude::input::native::ActionState;
#[cfg(feature = "server")]
use lightyear::prelude::{ControlledBy, Lifetime, NetworkTarget, PredictionTarget, Replicate};
use serde::{Deserialize, Serialize};
#[derive(Component, Default, Serialize, Deserialize, PartialEq)]
@@ -53,7 +52,7 @@ pub fn plugin(app: &mut App) {
pub fn spawn(
mut commands: Commands,
owner: Entity,
#[cfg(feature = "server")] owner: Entity,
query: Query<&Transform, With<SpawnPoint>>,
asset_server: Res<AssetServer>,
heads_db: Res<HeadsDatabase>,
@@ -64,17 +63,18 @@ pub fn spawn(
let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.));
commands
.spawn(player_bundle(transform, &heads_db))
.insert_server((
Replicate::to_clients(NetworkTarget::All),
PredictionTarget::to_clients(NetworkTarget::All),
ControlledBy {
owner,
lifetime: Lifetime::SessionBased,
},
))
.observe(on_kill);
let mut player = commands.spawn(player_bundle(transform, &heads_db));
player.observe(on_kill);
#[cfg(feature = "server")]
player.insert((
Replicate::to_clients(NetworkTarget::All),
PredictionTarget::to_clients(NetworkTarget::All),
ControlledBy {
owner,
lifetime: Lifetime::SessionBased,
},
));
commands.spawn((
AudioPlayer::new(asset_server.load("sfx/heads/angry demonstrator.ogg")),

View File

@@ -1,15 +1,10 @@
use bevy::ecs::{
bundle::Bundle,
event::Event,
resource::Resource,
system::{Commands, EntityCommands},
world::{EntityWorldMut, World},
};
use lightyear::prelude::Disconnected;
#[derive(Default, Resource)]
pub struct IsServer;
pub trait CommandExt {
fn trigger_server(&mut self, event: impl Event) -> &mut Self;
}
@@ -18,7 +13,7 @@ impl<'w, 's> CommandExt for Commands<'w, 's> {
fn trigger_server(&mut self, event: impl Event) -> &mut Self {
self.queue(|world: &mut World| {
let mut query_state = world.query::<&Disconnected>();
if world.contains_resource::<IsServer>() || !query_state.query(world).is_empty() {
if cfg!(feature = "server") || !query_state.query(world).is_empty() {
world.trigger(event);
}
});
@@ -27,23 +22,14 @@ impl<'w, 's> CommandExt for Commands<'w, 's> {
}
pub trait EntityCommandExt {
fn insert_server(&mut self, bundle: impl Bundle) -> &mut Self;
fn trigger_server(&mut self, event: impl Event) -> &mut Self;
}
impl<'w> EntityCommandExt for EntityCommands<'w> {
fn insert_server(&mut self, bundle: impl Bundle) -> &mut Self {
self.queue(|mut entity: EntityWorldMut| {
if entity.world().contains_resource::<IsServer>() {
entity.insert(bundle);
}
})
}
fn trigger_server(&mut self, event: impl Event) -> &mut Self {
self.queue(|mut entity: EntityWorldMut| {
if entity.world().contains_resource::<IsServer>() {
let mut query_state = entity.world_scope(|world| world.query::<&Disconnected>());
if cfg!(feature = "server") || !query_state.query(entity.world()).is_empty() {
entity.trigger(event);
}
})

View File

@@ -1,4 +1,3 @@
use crate::utils::commands::IsServer;
use bevy::{ecs::system::SystemParam, prelude::*};
use lightyear::prelude::{AppTriggerExt, Channel, NetworkDirection, RemoteTrigger, TriggerSender};
use serde::{Deserialize, Serialize};
@@ -6,12 +5,11 @@ use serde::{Deserialize, Serialize};
#[derive(SystemParam)]
pub struct ServerMultiTriggerSender<'w, 's, M: Event + Clone> {
senders: Query<'w, 's, &'static mut TriggerSender<M>>,
is_server: Option<Res<'w, IsServer>>,
}
impl<'w, 's, M: Event + Clone> ServerMultiTriggerSender<'w, 's, M> {
pub fn server_trigger_targets<C: Channel>(&mut self, trigger: M, target: &[Entity]) {
if self.is_server.is_none() {
if cfg!(not(feature = "server")) {
return;
}