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

2056
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -55,26 +55,17 @@ bevy_asset_loader = "=0.24.0-rc.1"
bevy_ballistic = { git = "https://github.com/rustunit/bevy_ballistic.git", rev = "b08ffec" }
bevy_common_assets = { version = "0.14.0", features = ["ron"] }
bevy_debug_log = { git = "https://github.com/rustunit/bevy_debug_log.git", rev = "86051a0" }
bevy_replicon = "0.36.1"
bevy_replicon_renet = "0.12.0"
bevy_sprite3d = "7.0.0"
bevy_trenchbroom = { version = "0.10", default-features = false, features = [
"physics-integration",
] }
bevy_trenchbroom_avian = "0.10"
clap = { version = "=4.5.47", features = ["derive"] }
happy_feet = { git = "https://github.com/PROMETHIA-27/happy_feet.git", rev = "48a96cc", features = [
"serde",
] }
lightyear = { version = "0.25", default-features = false, features = [
"avian3d",
"input_native",
"interpolation",
"netcode",
"prediction",
"replication",
"std",
"udp",
"frame_interpolation",
] }
lightyear_serde = "0.25"
nil = "0.14.0"
rand = "=0.8.5"
ron = "0.8"

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;
}
}

View File

@@ -12,12 +12,13 @@ avian3d = { workspace = true }
bevy = { workspace = true, default-features = false }
bevy-steamworks = { workspace = true }
bevy_common_assets = { workspace = true }
bevy_replicon = { workspace = true, features = ["server"] }
bevy_replicon_renet = { workspace = true, features = ["server"] }
bevy_sprite3d = { workspace = true }
bevy_trenchbroom = { workspace = true }
bevy_trenchbroom_avian = { workspace = true }
clap = { version = "=4.5.47", features = ["derive"] }
clap = { workspace = true }
happy_feet = { workspace = true }
lightyear = { workspace = true }
rand = { workspace = true }
ron = { workspace = true }
serde = { workspace = true }

View File

@@ -1,5 +1,5 @@
use bevy::prelude::*;
use lightyear::prelude::input::native::ActionState;
use bevy_replicon::prelude::{ClientState, FromClient, SendMode, ServerTriggerExt, ToClients};
use shared::{
GameState,
backpack::{
@@ -7,7 +7,7 @@ use shared::{
backpack_ui::{BackpackUiState, HEAD_SLOTS},
},
control::ControlState,
protocol::PlaySound,
protocol::{ClientToController, PlaySound},
};
pub fn plugin(app: &mut App) {
@@ -15,23 +15,34 @@ pub fn plugin(app: &mut App) {
FixedUpdate,
sync_on_change.run_if(in_state(GameState::Playing)),
);
app.add_systems(FixedUpdate, swap_head_inputs);
app.add_systems(
FixedUpdate,
swap_head_inputs.run_if(in_state(ClientState::Disconnected)),
);
}
fn swap_head_inputs(
player: Query<(&ActionState<ControlState>, Ref<Backpack>)>,
backpacks: Query<Ref<Backpack>>,
clients: ClientToController,
mut inputs: MessageReader<FromClient<ControlState>>,
mut commands: Commands,
mut state: Single<&mut BackpackUiState>,
time: Res<Time>,
) {
for (controls, backpack) in player.iter() {
for controls in inputs.read() {
let player = clients.get_controller(controls.client_id);
let backpack = backpacks.get(player).unwrap();
if state.count == 0 {
return;
}
if controls.backpack_toggle {
state.open = !state.open;
commands.trigger(PlaySound::Backpack { open: state.open });
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Backpack { open: state.open },
});
}
if !state.open {
@@ -52,7 +63,10 @@ fn swap_head_inputs(
}
if changed {
commands.trigger(PlaySound::Selection);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Selection,
});
sync(&backpack, &mut state, time.elapsed_secs());
}
}

View File

@@ -3,7 +3,7 @@ use bevy::{
ecs::{relationship::RelatedSpawner, spawn::SpawnWith},
prelude::*,
};
use lightyear::prelude::{NetworkTarget, Replicate};
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
use shared::{
global_observer,
head_drop::{HeadCollected, HeadDrop, HeadDropEnableTime, HeadDrops, SecretHeadMarker},
@@ -39,7 +39,10 @@ fn on_head_drop(
};
if drop.impulse {
commands.trigger(PlaySound::HeadDrop);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::HeadDrop,
});
}
let mesh_addr = format!("{:?}", heads_db.head_stats(drop.head_id).ability).to_lowercase();
@@ -70,16 +73,18 @@ fn on_head_drop(
CollisionEventsEnabled,
HeadDrop { head_id },
HeadDropEnableTime(now + 1.2),
Replicated,
))
.observe(on_collect_head);
}
})),
Replicate::to_clients(NetworkTarget::All),
Replicated,
))
.with_child((
Billboard::All,
SquishAnimation(2.6),
GltfSceneRoot::HeadDrop(mesh_addr),
Replicated,
));
Ok(())
@@ -101,9 +106,15 @@ fn on_collect_head(
let is_secret = query_secret.contains(collectable);
if is_secret {
commands.trigger(PlaySound::SecretHeadCollect);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::SecretHeadCollect,
});
} else {
commands.trigger(PlaySound::HeadCollect);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::HeadCollect,
});
}
commands.entity(collider).trigger(|entity| HeadCollected {

View File

@@ -4,9 +4,7 @@ use bevy_common_assets::ron::RonAssetPlugin;
use bevy_sprite3d::Sprite3dPlugin;
use bevy_trenchbroom::prelude::*;
use bevy_trenchbroom_avian::AvianPhysicsBackend;
use lightyear::prelude::server::ServerPlugins;
use shared::{DebugVisuals, GameState, heads_database::HeadDatabaseAsset};
use std::time::Duration;
mod backpack;
mod config;
@@ -74,15 +72,7 @@ fn main() {
..default()
}));
app.add_plugins(
PhysicsPlugins::default()
.build()
// FrameInterpolation handles interpolating Position and Rotation
.disable::<PhysicsInterpolationPlugin>(),
);
app.add_plugins(ServerPlugins {
tick_duration: Duration::from_secs_f32(1.0 / 60.0),
});
app.add_plugins(PhysicsPlugins::default());
app.add_plugins(Sprite3dPlugin);
app.add_plugins(TrenchBroomPlugins(
TrenchBroomConfig::new("hedz").icon(None),
@@ -112,9 +102,9 @@ fn main() {
app.add_plugins(shared::npc::plugin);
app.add_plugins(shared::platforms::plugin);
app.add_plugins(shared::player::plugin);
app.add_plugins(shared::protocol::plugin);
app.add_plugins(shared::steam::plugin);
app.add_plugins(shared::tb_entities::plugin);
app.add_plugins(shared::tick::plugin);
app.add_plugins(shared::utils::auto_rotate::plugin);
app.add_plugins(shared::utils::billboards::plugin);
app.add_plugins(shared::utils::explosions::plugin);
@@ -124,12 +114,16 @@ fn main() {
app.add_plugins(shared::utils::plugin);
app.add_plugins(shared::water::plugin);
// Networking
// The client/server plugin must go before the protocol, or else `ProtocolHasher` will not be available.
app.add_plugins(server::plugin);
app.add_plugins(shared::protocol::plugin);
app.add_plugins(backpack::plugin);
app.add_plugins(config::plugin);
app.add_plugins(head_drop::plugin);
app.add_plugins(platforms::plugin);
app.add_plugins(player::plugin);
app.add_plugins(server::plugin);
app.add_plugins(tb_entities::plugin);
app.add_plugins(utils::plugin);

View File

@@ -1,6 +1,5 @@
use bevy::prelude::*;
use bevy_trenchbroom::prelude::Target;
use lightyear::prelude::{NetworkTarget, PredictionTarget};
use shared::{
GameState,
platforms::ActivePlatform,
@@ -34,8 +33,6 @@ fn init(
target,
};
commands
.entity(e)
.insert((platform, PredictionTarget::to_clients(NetworkTarget::All)));
commands.entity(e).insert(platform);
}
}

View File

@@ -1,11 +1,11 @@
use bevy::prelude::*;
use lightyear::prelude::{input::native::ActionState, *};
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
use shared::{
backpack::{Backpack, backpack_ui::BackpackUiState},
camera::{CameraArmRotation, CameraTarget},
cash::CashResource,
character::AnimatedCharacter,
control::{ControlState, controller_common::PlayerCharacterController},
control::{Inputs, controller_common::PlayerCharacterController},
global_observer,
head::ActiveHead,
head_drop::HeadDrops,
@@ -14,9 +14,7 @@ use shared::{
hitpoints::{Hitpoints, Kill},
npc::SpawnCharacter,
player::{Player, PlayerBodyMesh},
protocol::{
PlaySound, PlayerId, channels::UnorderedReliableChannel, events::ClientHeadChanged,
},
protocol::{PlaySound, PlayerId, events::ClientHeadChanged},
tb_entities::SpawnPoint,
};
@@ -26,7 +24,7 @@ pub fn plugin(app: &mut App) {
pub fn spawn(
mut commands: Commands,
owner: Entity,
_owner: Entity,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
) -> Option<Entity> {
@@ -34,48 +32,53 @@ pub fn spawn(
let transform = Transform::from_translation(spawn.translation + Vec3::new(0., 3., 0.));
let mut player = commands.spawn((
(
Name::from("player"),
Player,
ActiveHead(0),
ActiveHeads::new([
Some(HeadState::new(0, heads_db.as_ref())),
Some(HeadState::new(3, heads_db.as_ref())),
Some(HeadState::new(6, heads_db.as_ref())),
Some(HeadState::new(10, heads_db.as_ref())),
Some(HeadState::new(9, heads_db.as_ref())),
]),
Hitpoints::new(100),
CashResource::default(),
CameraTarget,
transform,
Visibility::default(),
PlayerCharacterController,
PlayerId { id: 0 },
),
ActionState::<ControlState>::default(),
Backpack::default(),
BackpackUiState::default(),
UiActiveHeads::default(),
Replicate::to_clients(NetworkTarget::All),
PredictionTarget::to_clients(NetworkTarget::All),
ControlledBy {
owner,
lifetime: Lifetime::SessionBased,
},
children![(
Name::new("player-rig"),
PlayerBodyMesh,
CameraArmRotation,
children![AnimatedCharacter::new(0)],
)],
));
player.observe(on_kill);
let id = commands
.spawn((
(
Name::from("player"),
Player,
ActiveHead(0),
ActiveHeads::new([
Some(HeadState::new(0, heads_db.as_ref())),
Some(HeadState::new(3, heads_db.as_ref())),
Some(HeadState::new(6, heads_db.as_ref())),
Some(HeadState::new(10, heads_db.as_ref())),
Some(HeadState::new(9, heads_db.as_ref())),
]),
Hitpoints::new(100),
CashResource::default(),
CameraTarget,
transform,
Visibility::default(),
PlayerCharacterController,
PlayerId { id: 0 },
),
Backpack::default(),
BackpackUiState::default(),
UiActiveHeads::default(),
Inputs::default(),
Replicated,
))
.with_children(|c| {
c.spawn((
Name::new("player-rig"),
PlayerBodyMesh,
CameraArmRotation,
Replicated,
))
.with_child((
Name::new("player-animated-character"),
AnimatedCharacter::new(0),
Replicated,
));
})
.observe(on_kill)
.id();
let id = player.id();
commands.trigger(PlaySound::Head("angry demonstrator".to_string()));
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Head("angry demonstrator".to_string()),
});
commands.trigger(SpawnCharacter(transform.translation));
Some(id)
@@ -103,7 +106,6 @@ fn on_update_head_mesh(
trigger: On<HeadChanged>,
mut commands: Commands,
mesh_children: Single<&Children, With<PlayerBodyMesh>>,
mut sender: Single<&mut EventSender<ClientHeadChanged>>,
animated_characters: Query<&AnimatedCharacter>,
mut player: Single<&mut ActiveHead, With<Player>>,
) -> Result {
@@ -118,7 +120,10 @@ fn on_update_head_mesh(
.entity(animated_char)
.insert(AnimatedCharacter::new(trigger.0));
sender.trigger::<UnorderedReliableChannel>(ClientHeadChanged(trigger.0 as u64));
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: ClientHeadChanged(trigger.0 as u64),
});
Ok(())
}

View File

@@ -1,91 +1,58 @@
use crate::config::ServerConfig;
use bevy::{ecs::component::Components, prelude::*};
use lightyear::{
connection::client::PeerMetadata,
link::LinkConditioner,
use bevy::prelude::*;
use bevy_replicon::{
RepliconPlugins,
prelude::{
server::{ClientOf, NetcodeConfig, NetcodeServer, ServerUdpIo, Started},
*,
ClientId, ConnectedClient, FromClient, RepliconChannels, SendMode, ServerState,
ServerTriggerExt, ToClients,
},
server::AuthorizedClient,
};
use bevy_replicon_renet::{
RenetChannelsExt, RepliconRenetPlugins,
netcode::{NetcodeServerTransport, ServerAuthentication},
renet::{ConnectionConfig, RenetServer},
};
use shared::{
GameState, global_observer,
heads_database::HeadsDatabase,
protocol::{
ClientEnteredPlaying, channels::UnorderedReliableChannel, messages::AssignClientPlayer,
},
player::ClientPlayerId,
protocol::{ClientEnteredPlaying, PlayerId, SetGameTick, messages::AssignClientPlayer},
tb_entities::SpawnPoint,
tick::GameTick,
};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration,
net::{Ipv4Addr, UdpSocket},
time::SystemTime,
};
pub fn plugin(app: &mut App) {
app.add_systems(
OnEnter(GameState::Playing),
(start_server, setup_timeout_timer),
);
app.add_systems(
Update,
(notify_started, run_timeout).run_if(in_state(GameState::Playing)),
);
app.add_plugins((RepliconPlugins, RepliconRenetPlugins));
global_observer!(app, handle_new_client);
global_observer!(app, on_client_connected);
global_observer!(app, on_client_playing);
global_observer!(app, close_on_disconnect);
app.add_systems(OnEnter(GameState::Playing), setup_timeout_timer);
app.add_systems(OnEnter(ServerState::Running), notify_started);
app.add_systems(Update, run_timeout.run_if(in_state(GameState::Playing)));
app.add_systems(OnEnter(GameState::Playing), open_renet_server);
// Replicon
global_observer!(app, on_connected);
global_observer!(app, on_disconnected);
// Server logic
global_observer!(app, cancel_timeout);
}
#[derive(Component)]
struct ClientPlayerId(u8);
fn handle_new_client(
trigger: On<Add, Linked>,
mut commands: Commands,
id: Query<&PeerAddr>,
) -> Result {
let Ok(id) = id.get(trigger.event().entity) else {
return Ok(());
};
info!("Client connected on IP: {}", id.ip());
let conditioner = LinkConditioner::new(LinkConditionerConfig {
incoming_latency: Duration::from_millis(10),
incoming_jitter: Duration::from_millis(0),
incoming_loss: 0.0,
});
commands.entity(trigger.event().entity).insert((
ReplicationSender::default(),
Link::new(Some(conditioner)),
ClientPlayerId(0),
));
Ok(())
}
fn on_client_connected(
trigger: On<Add, ClientOf>,
mut assign_player: Query<(&ClientPlayerId, &mut MessageSender<AssignClientPlayer>)>,
) -> Result {
// `Linked` happens before the `ClientOf` and `MessageSender` components are added, so the server can't
// send the client player id until now.
let (id, mut sender) = assign_player.get_mut(trigger.event().entity)?;
sender.send::<UnorderedReliableChannel>(AssignClientPlayer(id.0));
Ok(())
global_observer!(app, on_client_playing);
}
fn on_client_playing(
trigger: On<RemoteEvent<ClientEnteredPlaying>>,
trigger: On<FromClient<ClientEnteredPlaying>>,
commands: Commands,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
peers: Res<PeerMetadata>,
) -> Result {
let Some(&client) = peers.mapping.get(&trigger.from) else {
info!("Client has entered playing gamestate");
let Some(client) = trigger.client_id.entity() else {
return Ok(());
};
@@ -94,51 +61,85 @@ fn on_client_playing(
Ok(())
}
fn close_on_disconnect(
_trigger: On<Remove, Connected>,
//
// Renet
//
fn open_renet_server(
mut commands: Commands,
channels: Res<RepliconChannels>,
) -> Result<(), BevyError> {
let server_channels_config = channels.server_configs();
let client_channels_config = channels.client_configs();
let server = RenetServer::new(ConnectionConfig {
server_channels_config,
client_channels_config,
..Default::default()
});
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let port = 31111;
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
let server_config = bevy_replicon_renet::netcode::ServerConfig {
current_time,
max_clients: 1,
protocol_id: 0,
authentication: ServerAuthentication::Unsecure,
public_addresses: Default::default(),
};
let transport = NetcodeServerTransport::new(server_config, socket)?;
commands.insert_resource(server);
commands.insert_resource(transport);
info!("Hosting a server on port {port}");
Ok(())
}
//
// server logic
//
fn on_connected(
trigger: On<Add, AuthorizedClient>,
game_tick: Res<GameTick>,
mut commands: Commands,
mut assign_id: MessageWriter<ToClients<AssignClientPlayer>>,
) {
let client = trigger.event_target();
info!("{client} connected to server!");
let id = ClientPlayerId(PlayerId { id: 0 });
commands.entity(client).insert(id);
assign_id.write(ToClients {
mode: SendMode::Direct(ClientId::Client(trigger.entity)),
message: AssignClientPlayer(id.0),
});
commands.server_trigger(ToClients {
mode: SendMode::Direct(ClientId::Client(trigger.entity)),
message: SetGameTick(game_tick.0),
});
}
fn on_disconnected(
on: On<Remove, ConnectedClient>,
config: Res<ServerConfig>,
mut writer: MessageWriter<AppExit>,
) {
info!("client {} disconnected", on.entity);
if config.close_on_client_disconnect {
info!("client disconnected, exiting");
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);
let conditioner = LinkConditioner::new(LinkConditionerConfig {
incoming_latency: Duration::from_millis(10),
incoming_jitter: Duration::from_millis(0),
incoming_loss: 0.0,
});
let mut commands = commands.spawn((
Name::from("Server"),
LocalAddr(server_addr),
ServerUdpIo::default(),
NetcodeServer::new(NetcodeConfig {
client_timeout_secs: 3,
..Default::default()
}),
Link::new(Some(conditioner)),
));
commands
.observe(|on: On<Add>, components: &Components| {
for &comp in on.trigger().components {
info!("Added {} to server", components.get_name(comp).unwrap());
}
})
.trigger(|entity| server::Start { entity });
Ok(())
}
fn notify_started(started: Query<&Started>, mut notified: Local<bool>) {
if !*notified && !started.is_empty() {
println!("hedz.server_started");
*notified = true;
}
fn notify_started() {
println!("hedz.server_started");
}
#[derive(Resource)]
@@ -161,7 +162,7 @@ fn run_timeout(
}
}
fn cancel_timeout(_trigger: On<Add, Connected>, mut timer: ResMut<TimeoutTimer>) {
fn cancel_timeout(_trigger: On<Add, ConnectedClient>, mut timer: ResMut<TimeoutTimer>) {
info!("client connected, cancelling timeout");
timer.0 = f32::INFINITY;
}

View File

@@ -1,8 +1,8 @@
use bevy::prelude::*;
use lightyear::prelude::{Connected, MessageSender};
use bevy_replicon::prelude::{ClientId, ConnectedClient, SendMode, ToClients};
use shared::{
GameState, global_observer,
protocol::{TbMapEntityId, channels::UnorderedReliableChannel, messages::DespawnTbMapEntity},
protocol::{TbMapEntityId, messages::DespawnTbMapEntity},
};
pub fn plugin(app: &mut App) {
@@ -32,12 +32,14 @@ fn add_despawned_entities_to_cache(
pub struct DespawnedTbEntityCache(pub Vec<u64>);
fn send_new_client_despawned_cache(
trigger: On<Add, Connected>,
on: On<Add, ConnectedClient>,
cache: Res<DespawnedTbEntityCache>,
mut send: Query<&mut MessageSender<DespawnTbMapEntity>>,
mut send: MessageWriter<ToClients<DespawnTbMapEntity>>,
) {
let mut send = send.get_mut(trigger.event().entity).unwrap();
for &id in cache.0.iter() {
send.send::<UnorderedReliableChannel>(DespawnTbMapEntity(id));
send.write(ToClients {
mode: SendMode::Direct(ClientId::Client(on.entity)),
message: DespawnTbMapEntity(id),
});
}
}

View File

@@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2024"
[features]
client = ["lightyear/client"]
server = ["lightyear/server"]
client = []
server = []
dbg = []
[dependencies]
@@ -17,11 +17,10 @@ bevy_asset_loader = { workspace = true }
bevy_ballistic = { workspace = true }
bevy_common_assets = { workspace = true }
bevy_debug_log = { workspace = true }
bevy_replicon = { workspace = true }
bevy_sprite3d = { workspace = true }
bevy_trenchbroom = { workspace = true }
happy_feet = { workspace = true }
lightyear = { workspace = true }
lightyear_serde = { workspace = true }
nil = { workspace = true }
rand = { workspace = true }
ron = { workspace = true }

View File

@@ -6,6 +6,7 @@ use crate::{
};
use avian3d::prelude::*;
use bevy::{light::NotShadowCaster, prelude::*};
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
use bevy_sprite3d::Sprite3d;
#[derive(Component)]
@@ -44,7 +45,10 @@ fn on_trigger_arrow(
) {
let state = trigger.0;
commands.trigger(PlaySound::Crossbow);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Crossbow,
});
let rotation = if let Some(target) = state.target {
let t = query_transform

View File

@@ -6,12 +6,11 @@ use crate::{
physics_layers::GameLayer,
protocol::GltfSceneRoot,
tb_entities::EnemySpawn,
utils::{auto_rotate::AutoRotation, commands::CommandExt, global_observer},
utils::{auto_rotate::AutoRotation, global_observer},
};
use avian3d::prelude::*;
use bevy::prelude::*;
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
use std::f32::consts::PI;
const MAX_SHOT_AGES: f32 = 15.;
@@ -60,7 +59,7 @@ fn on_trigger_missile(
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.forward().as_vec3() * 2.0;
let mut _projectile = commands.spawn((
commands.spawn((
Name::new("projectile-missile"),
CurverProjectile {
time: time.elapsed_secs(),
@@ -75,15 +74,13 @@ fn on_trigger_missile(
CollisionEventsEnabled,
Visibility::default(),
transform,
Replicated,
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(
@@ -169,10 +166,13 @@ fn shot_collision(
continue;
}
commands.trigger_server(BuildExplosionSprite {
pos: shot_pos,
pixels_per_meter: 128.,
time: 0.01,
commands.server_trigger(ToClients {
message: BuildExplosionSprite {
pos: shot_pos,
pixels_per_meter: 128.,
time: 0.01,
},
mode: SendMode::Broadcast,
});
}
}

View File

@@ -6,6 +6,7 @@ use crate::{
};
use avian3d::prelude::*;
use bevy::{light::NotShadowCaster, prelude::*};
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
use bevy_sprite3d::Sprite3d;
#[derive(Component)]
@@ -91,7 +92,10 @@ fn on_trigger_gun(
) {
let state = trigger.0;
commands.trigger(PlaySound::Gun);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Gun,
});
let rotation = if let Some(t) = state
.target

View File

@@ -9,8 +9,7 @@ use crate::{
};
use avian3d::prelude::*;
use bevy::prelude::*;
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use bevy_replicon::prelude::Replicated;
use std::f32::consts::PI;
const MAX_SHOT_AGES: f32 = 15.;
@@ -61,7 +60,7 @@ fn on_trigger_missile(
let mut transform = Transform::from_translation(state.pos).with_rotation(rotation);
transform.translation += transform.forward().as_vec3() * 2.0;
let mut _projectile = commands.spawn((
commands.spawn((
Name::new("projectile-missile"),
MissileProjectile {
time: time.elapsed_secs(),
@@ -73,13 +72,16 @@ fn on_trigger_missile(
LayerMask(state.target_layer.to_bits() | GameLayer::Level.to_bits()),
),
Sensor,
RigidBody::Kinematic,
CollisionEventsEnabled,
Visibility::default(),
transform,
Replicated,
children![
(
Transform::from_rotation(Quat::from_rotation_x(PI / 2.).inverse()),
GltfSceneRoot::Projectile("missile".to_string()),
Replicated,
),
(
Trail::new(
@@ -99,8 +101,6 @@ fn on_trigger_missile(
)
],
));
#[cfg(feature = "server")]
_projectile.insert(Replicate::to_clients(NetworkTarget::All));
}
fn update(mut query: Query<&mut Transform, With<MissileProjectile>>) {

View File

@@ -10,21 +10,25 @@ use crate::{
loading_assets::GameAssets,
physics_layers::GameLayer,
protocol::PlaySound,
utils::{billboards::Billboard, sprite_3d_animation::AnimationTimer},
utils::{billboards::Billboard, explosions::Explosion, sprite_3d_animation::AnimationTimer},
};
#[cfg(feature = "server")]
use crate::{
aim::AimTarget, character::CharacterHierarchy, control::ControlState, head::ActiveHead,
heads::ActiveHeads, heads_database::HeadsDatabase, player::Player,
utils::explosions::Explosion,
aim::AimTarget,
character::CharacterHierarchy,
control::{ControlState, Inputs},
head::ActiveHead,
heads::ActiveHeads,
heads_database::HeadsDatabase,
player::Player,
protocol::ClientToController,
};
use bevy::{light::NotShadowCaster, prelude::*};
#[cfg(feature = "server")]
use bevy_replicon::prelude::FromClient;
use bevy_replicon::prelude::{ClientState, SendMode, ServerTriggerExt, ToClients};
use bevy_sprite3d::Sprite3d;
pub use healing::Healing;
#[cfg(feature = "server")]
use healing::HealingStateChanged;
#[cfg(feature = "server")]
use lightyear::prelude::input::native::ActionState;
use serde::{Deserialize, Serialize};
#[derive(Debug, Copy, Clone, PartialEq, Reflect, Default, Serialize, Deserialize)]
@@ -143,16 +147,16 @@ pub fn plugin(app: &mut App) {
.chain()
.run_if(in_state(GameState::Playing)),
);
#[cfg(feature = "server")]
app.add_systems(
FixedUpdate,
explode_projectiles.in_set(ExplodingProjectileSet::Explode),
explode_projectiles
.run_if(in_state(ClientState::Disconnected))
.in_set(ExplodingProjectileSet::Explode),
);
global_observer!(app, build_explosion_sprite);
}
#[cfg(feature = "server")]
fn explode_projectiles(mut commands: Commands, query: Query<(Entity, &ExplodingProjectile)>) {
for (shot_entity, projectile) in query.iter() {
if let Ok(mut entity) = commands.get_entity(shot_entity) {
@@ -161,7 +165,10 @@ fn explode_projectiles(mut commands: Commands, query: Query<(Entity, &ExplodingP
continue;
}
commands.trigger(projectile.sound.clone());
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: projectile.sound.clone(),
});
commands.trigger(Explosion {
damage: projectile.damage,
@@ -172,12 +179,13 @@ fn explode_projectiles(mut commands: Commands, query: Query<(Entity, &ExplodingP
//TODO: support different impact animations
if projectile.animation {
use crate::utils::commands::CommandExt;
commands.trigger_server(BuildExplosionSprite {
pos: projectile.position,
pixels_per_meter: projectile.anim_pixels_per_meter,
time: projectile.anim_time,
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: BuildExplosionSprite {
pos: projectile.position,
pixels_per_meter: projectile.anim_pixels_per_meter,
time: projectile.anim_time,
},
});
}
}
@@ -186,11 +194,15 @@ fn explode_projectiles(mut commands: Commands, query: Query<(Entity, &ExplodingP
#[cfg(feature = "server")]
fn on_trigger_state(
mut res: ResMut<TriggerStateRes>,
player: Query<(&ActiveHead, &ActionState<ControlState>), With<Player>>,
players: Query<&ActiveHead, With<Player>>,
clients: ClientToController,
mut controls: MessageReader<FromClient<ControlState>>,
headdb: Res<HeadsDatabase>,
time: Res<Time>,
) {
for (player_head, controls) in player.iter() {
for controls in controls.read() {
let player = clients.get_controller(controls.client_id);
let player_head = players.get(player).unwrap();
res.active = controls.trigger;
if controls.just_triggered {
let head_stats = headdb.head_stats(player_head.0);
@@ -203,7 +215,7 @@ fn on_trigger_state(
fn update(
mut res: ResMut<TriggerStateRes>,
mut commands: Commands,
player_query: Query<(Entity, &AimTarget, &ActionState<ControlState>), With<Player>>,
player_query: Query<(Entity, &AimTarget, &Inputs), With<Player>>,
mut active_heads: Single<&mut ActiveHeads, With<Player>>,
heads_db: Res<HeadsDatabase>,
time: Res<Time>,
@@ -215,11 +227,14 @@ fn update(
};
if !state.has_ammo() {
commands.trigger(PlaySound::Invalid);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Invalid,
});
return;
}
let Some((player, target, actions)) = player_query.iter().next() else {
let Some((player, target, inputs)) = player_query.iter().next() else {
return;
};
@@ -241,7 +256,7 @@ fn update(
res.next_trigger_timestamp = time.elapsed_secs() + (1. / head.aps);
let trigger_state = TriggerData {
dir: actions.look_dir,
dir: Dir3::try_from(inputs.look_dir).unwrap_or(Dir3::NEG_Z),
pos: projectile_origin,
target: target.0,
target_layer: GameLayer::Npc,
@@ -281,11 +296,15 @@ fn update_heal_ability(
use crate::abilities::healing::HealingState;
if res.active {
use crate::abilities::healing::HealingStateChanged;
commands.trigger(HealingStateChanged {
state: HealingState::Started,
entity: player,
});
} else {
use crate::abilities::healing::HealingStateChanged;
commands.trigger(HealingStateChanged {
state: HealingState::Stopped,
entity: player,

View File

@@ -9,8 +9,7 @@ use crate::{
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_ballistic::launch_velocity;
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
use serde::{Deserialize, Serialize};
#[derive(Component, Serialize, Deserialize, PartialEq)]
@@ -36,7 +35,10 @@ fn on_trigger_thrown(
) {
let state = trigger.event().0;
commands.trigger(PlaySound::Throw);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Throw,
});
const SPEED: f32 = 35.;
@@ -57,32 +59,32 @@ fn on_trigger_thrown(
//TODO: projectile db?
let explosion_animation = !matches!(state.head, 8 | 16);
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));
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,
Replicated,
))
.with_child((
AutoRotation(Quat::from_rotation_x(0.4) * Quat::from_rotation_z(0.3)),
GltfSceneRoot::Projectile(head.projectile.clone()),
Replicated,
));
}
fn shot_collision(

View File

@@ -2,12 +2,11 @@ mod marker;
mod target_ui;
use crate::{
GameState, control::ControlState, head::ActiveHead, heads_database::HeadsDatabase,
GameState, control::Inputs, head::ActiveHead, heads_database::HeadsDatabase,
hitpoints::Hitpoints, physics_layers::GameLayer, player::Player, tb_entities::EnemySpawn,
};
use avian3d::prelude::*;
use bevy::prelude::*;
use lightyear::prelude::input::native::ActionState;
use marker::MarkerEvent;
use serde::{Deserialize, Serialize};
use std::f32::consts::PI;
@@ -71,19 +70,13 @@ fn update_player_aim(
mut commands: Commands,
potential_targets: Query<(Entity, &Transform), With<Hitpoints>>,
mut player_aim: Query<
(
Entity,
&AimState,
&mut AimTarget,
&GlobalTransform,
&ActionState<ControlState>,
),
(Entity, &AimState, &mut AimTarget, &GlobalTransform, &Inputs),
With<Player>,
>,
spatial_query: SpatialQuery,
) {
for (player, state, mut aim_target, global_tf, actions) in player_aim.iter_mut() {
let (player_pos, player_forward) = (global_tf.translation(), actions.look_dir);
for (player, state, mut aim_target, global_tf, inputs) in player_aim.iter_mut() {
let (player_pos, player_forward) = (global_tf.translation(), inputs.look_dir);
let mut new_target = None;
let mut target_distance = f32::MAX;

View File

@@ -1,6 +1,3 @@
pub mod backpack_ui;
pub mod ui_head_state;
use crate::{
cash::CashCollectEvent, global_observer, head_drop::HeadCollected, heads::HeadState,
heads_database::HeadsDatabase,
@@ -9,6 +6,9 @@ use bevy::prelude::*;
use serde::{Deserialize, Serialize};
pub use ui_head_state::UiHeadState;
pub mod backpack_ui;
pub mod ui_head_state;
#[derive(Component, Default, Reflect, Serialize, Deserialize, PartialEq)]
#[reflect(Component)]
pub struct Backpack {

View File

@@ -39,6 +39,7 @@ struct CameraUi;
#[derive(Component, Reflect, Debug)]
#[reflect(Component)]
pub struct MainCamera {
pub enabled: bool,
dir: Dir3,
distance: f32,
target_offset: Vec3,
@@ -48,6 +49,7 @@ impl MainCamera {
fn new(arm: Vec3) -> Self {
let (dir, distance) = Dir3::new_and_length(arm).expect("invalid arm length");
Self {
enabled: true,
dir,
distance,
target_offset: Vec3::new(0., 2., 0.),
@@ -64,10 +66,8 @@ pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::Playing), startup);
#[cfg(feature = "client")]
app.add_systems(
RunFixedMainLoop,
(update, update_ui, update_look_around, rotate_view)
.after(RunFixedMainLoopSystems::AfterFixedMainLoop)
.run_if(in_state(GameState::Playing)),
PostUpdate,
(update, update_ui, update_look_around, rotate_view).run_if(in_state(GameState::Playing)),
);
}
@@ -146,6 +146,10 @@ fn update(
return;
};
if !camera.enabled {
return;
}
let target = target_q.translation + camera.target_offset;
let direction = arm_tf.rotation * Quat::from_rotation_y(cam_rotation_input.x) * camera.dir;

View File

@@ -1,6 +1,7 @@
use crate::{GameState, HEDZ_GREEN, loading_assets::UIAssets};
#[cfg(feature = "server")]
use crate::{global_observer, protocol::PlaySound};
use avian3d::prelude::Rotation;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
@@ -38,14 +39,21 @@ fn on_cash_collect(
mut commands: Commands,
mut cash: Single<&mut CashResource>,
) {
commands.trigger(PlaySound::CashCollect);
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::CashCollect,
});
cash.cash += 100;
}
fn rotate(time: Res<Time>, mut query: Query<&mut Transform, With<Cash>>) {
for mut transform in query.iter_mut() {
transform.rotate(Quat::from_rotation_y(time.delta_secs()));
fn rotate(time: Res<Time>, mut query: Query<&mut Rotation, With<Cash>>) {
for mut rotation in query.iter_mut() {
rotation.0 = rotation
.0
.mul_quat(Quat::from_rotation_y(time.delta_secs()));
}
}

View File

@@ -3,7 +3,7 @@ use crate::{
protocol::PlaySound,
};
use bevy::prelude::*;
use lightyear::prelude::input::native::ActionState;
use bevy_replicon::prelude::{FromClient, SendMode, ServerTriggerExt, ToClients};
pub fn plugin(app: &mut App) {
app.add_systems(FixedUpdate, on_heal_trigger);
@@ -16,27 +16,33 @@ struct HealAction {
}
fn on_heal_trigger(
mut cmds: Commands,
mut commands: Commands,
mut cash: Single<&mut CashResource>,
mut query: Query<(&mut Hitpoints, &ActionState<ControlState>), With<Player>>,
mut query: Query<&mut Hitpoints, With<Player>>,
mut controls: MessageReader<FromClient<ControlState>>,
) {
for (mut hp, controls) in query.iter_mut() {
if !controls.cash_heal {
continue;
for controls in controls.read() {
for mut hp in query.iter_mut() {
if !controls.cash_heal {
continue;
}
if hp.max() || cash.cash == 0 {
return;
}
let action = heal(cash.cash, hp.get().1 - hp.get().0);
hp.heal(action.damage_healed);
cash.cash = cash.cash.saturating_sub(action.cost);
//TODO: trigger ui cost animation
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::CashHeal,
});
}
if hp.max() || cash.cash == 0 {
return;
}
let action = heal(cash.cash, hp.get().1 - hp.get().0);
hp.heal(action.damage_healed);
cash.cash = cash.cash.saturating_sub(action.cost);
//TODO: trigger ui cost animation
cmds.trigger(PlaySound::CashHeal);
}
}

View File

@@ -9,7 +9,6 @@ use bevy::{
animation::RepeatAnimation, ecs::system::SystemParam, platform::collections::HashMap,
prelude::*, scene::SceneInstanceReady,
};
use lightyear::prelude::DisableReplicateHierarchy;
use serde::{Deserialize, Serialize};
use std::{f32::consts::PI, time::Duration};
@@ -112,11 +111,7 @@ fn spawn(
commands.entity(entity).despawn_related::<Children>();
commands
.spawn((
SceneRoot(asset.scenes[0].clone()),
DisableReplicateHierarchy,
ChildOf(entity),
))
.spawn((SceneRoot(asset.scenes[0].clone()), ChildOf(entity)))
.observe(find_marker_bones);
commands

View File

@@ -3,7 +3,7 @@ use crate::{
GameState,
abilities::TriggerStateRes,
animation::AnimationFlags,
control::{ControlState, ControllerSettings, SelectedController},
control::{ControllerSettings, Inputs, SelectedController},
head::ActiveHead,
heads_database::{HeadControls, HeadsDatabase},
physics_layers::GameLayer,
@@ -11,12 +11,12 @@ use crate::{
};
use avian3d::{math::*, prelude::*};
use bevy::prelude::*;
use bevy_replicon::client::confirm_history::ConfirmHistory;
use happy_feet::prelude::{
Character, CharacterDrag, CharacterGravity, CharacterMovement, CharacterPlugins,
GroundFriction, Grounding, GroundingConfig, KinematicVelocity, MoveInput, SteppingBehaviour,
SteppingConfig,
};
use lightyear::prelude::{Replicated, input::native::ActionState};
use serde::{Deserialize, Serialize};
pub fn plugin(app: &mut App) {
@@ -47,12 +47,12 @@ pub fn plugin(app: &mut App) {
fn set_animation_flags(
trigger: Res<TriggerStateRes>,
mut player: Query<
(&Grounding, &mut AnimationFlags, &ActionState<ControlState>),
(With<Player>, Without<Replicated>),
(&Grounding, &mut AnimationFlags, &Inputs),
(With<Player>, Without<ConfirmHistory>),
>,
) {
for (grounding, mut flags, controls) in player.iter_mut() {
let direction = controls.move_dir;
for (grounding, mut flags, inputs) in player.iter_mut() {
let direction = inputs.move_dir;
let deadzone = 0.2;
if flags.any_direction {
@@ -79,17 +79,10 @@ pub fn reset_upon_switch(
mut event_controller_switch: MessageReader<ControllerSwitchEvent>,
selected_controller: Res<SelectedController>,
mut rig_transforms: Query<&mut Transform, With<PlayerBodyMesh>>,
mut controllers: Query<
(
&mut KinematicVelocity,
&ActionState<ControlState>,
&Children,
),
With<Character>,
>,
mut controllers: Query<(&mut KinematicVelocity, &Children, &Inputs), With<Character>>,
) {
for &ControllerSwitchEvent { controller } in event_controller_switch.read() {
let (mut velocity, actions, children) = controllers.get_mut(controller).unwrap();
let (mut velocity, children, inputs) = controllers.get_mut(controller).unwrap();
velocity.0 = Vec3::ZERO;
@@ -100,7 +93,7 @@ pub fn reset_upon_switch(
let mut rig_transform = rig_transforms.get_mut(rig_transform).unwrap();
// Reset pitch but keep yaw the same
let flat_look_dir = actions.look_dir.with_y(0.0).normalize();
let flat_look_dir = inputs.look_dir.with_y(0.0).normalize();
rig_transform.look_to(flat_look_dir, Dir3::Y);
match selected_controller.0 {
@@ -190,6 +183,7 @@ pub struct CharacterControllerBundle {
collision_messages: CollisionEventsEnabled,
movement_config: MovementConfig,
layers: CollisionLayers,
interpolation: TransformInterpolation,
}
impl CharacterControllerBundle {
@@ -216,6 +210,7 @@ impl CharacterControllerBundle {
LayerMask(GameLayer::Player.to_bits()),
LayerMask::ALL & !GameLayer::CollectiblePhysics.to_bits(),
),
interpolation: TransformInterpolation,
}
}
}

View File

@@ -1,8 +1,10 @@
use super::{ControlState, ControllerSet};
use crate::{GameState, control::controller_common::MovementSpeedFactor};
use super::ControllerSet;
use crate::{
GameState,
control::{Inputs, controller_common::MovementSpeedFactor},
};
use bevy::prelude::*;
use happy_feet::prelude::MoveInput;
use lightyear::prelude::input::native::ActionState;
pub struct CharacterControllerPlugin;
@@ -17,14 +19,8 @@ impl Plugin for CharacterControllerPlugin {
}
}
pub fn apply_controls(
character: Single<(
&mut MoveInput,
&ActionState<ControlState>,
&MovementSpeedFactor,
)>,
) {
let (mut char_input, actions, factor) = character.into_inner();
pub fn apply_controls(character: Single<(&mut MoveInput, &MovementSpeedFactor, &Inputs)>) {
let (mut char_input, factor, inputs) = character.into_inner();
char_input.set(actions.look_dir * factor.0);
char_input.set(inputs.look_dir * factor.0);
}

View File

@@ -1,42 +1,46 @@
use super::{ControlState, ControllerSet};
use crate::{
GameState,
animation::AnimationFlags,
control::{ControllerSettings, controller_common::MovementSpeedFactor},
control::{ControllerSet, ControllerSettings, Inputs, controller_common::MovementSpeedFactor},
};
#[cfg(feature = "client")]
use crate::{control::LookDirMovement, player::PlayerBodyMesh};
use crate::{
control::{ControlState, LookDirMovement},
player::PlayerBodyMesh,
};
use bevy::prelude::*;
use bevy_replicon::prelude::ClientState;
use happy_feet::prelude::{Grounding, KinematicVelocity, MoveInput};
use lightyear::prelude::input::native::ActionState;
pub struct CharacterControllerPlugin;
impl Plugin for CharacterControllerPlugin {
fn build(&self, app: &mut App) {
#[cfg(feature = "client")]
app.add_systems(
FixedUpdate,
(
#[cfg(feature = "client")]
rotate_view,
apply_controls,
)
.chain()
Update,
rotate_view
.in_set(ControllerSet::ApplyControlsRun)
.run_if(in_state(GameState::Playing)),
);
app.add_systems(
FixedUpdate,
apply_controls
.in_set(ControllerSet::ApplyControlsRun)
.run_if(in_state(GameState::Playing))
.run_if(in_state(ClientState::Disconnected)),
);
}
}
#[cfg(feature = "client")]
fn rotate_view(
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 tr, child_of) in player.iter_mut() {
let controls = actions.get(child_of.parent()).unwrap();
for mut tr in player.iter_mut() {
if controls.view_mode {
continue;
}
@@ -53,24 +57,24 @@ fn apply_controls(
&mut AnimationFlags,
&ControllerSettings,
&MovementSpeedFactor,
&ActionState<ControlState>,
&Inputs,
)>,
) {
let (mut move_input, mut grounding, mut velocity, mut flags, settings, move_factor, controls) =
let (mut move_input, mut grounding, mut velocity, mut flags, settings, move_factor, inputs) =
character.into_inner();
let ground_normal = *grounding.normal().unwrap_or(Dir3::Y);
let mut direction = controls.move_dir.extend(0.0).xzy();
let look_dir_right = controls.look_dir.cross(Vec3::Y);
direction = (controls.look_dir * direction.z) + (look_dir_right * direction.x);
let mut direction = inputs.move_dir.extend(0.0).xzy();
let look_dir_right = inputs.look_dir.cross(Vec3::Y);
direction = (inputs.look_dir * direction.z) + (look_dir_right * direction.x);
let y_projection = direction.project_onto(ground_normal);
direction -= y_projection;
direction = direction.normalize_or_zero();
move_input.set(direction * move_factor.0);
if controls.jump && grounding.is_grounded() {
if inputs.jump && grounding.is_grounded() {
if cfg!(feature = "server") {
flags.jumping = true;
flags.jump_count += 1;

View File

@@ -2,9 +2,14 @@ use crate::{
GameState,
head::ActiveHead,
heads_database::{HeadControls, HeadsDatabase},
player::Player,
player::{LocalPlayerId, Player},
protocol::{ClientToController, PlayerIdMap},
};
use bevy::{ecs::entity::MapEntities, prelude::*};
use bevy_replicon::{
client::ClientSystems,
prelude::{ClientState, FromClient},
};
use serde::{Deserialize, Serialize};
pub mod controller_common;
@@ -25,6 +30,7 @@ pub struct SelectedController(pub ControllerSet);
pub fn plugin(app: &mut App) {
app.register_type::<ControllerSettings>();
app.register_type::<LookDirMovement>();
app.register_type::<Inputs>();
app.init_resource::<ControlState>();
app.init_resource::<LookDirMovement>();
@@ -50,16 +56,29 @@ pub fn plugin(app: &mut App) {
.run_if(in_state(GameState::Playing)),
);
app.add_systems(
PreUpdate,
(
collect_player_inputs.run_if(in_state(ClientState::Disconnected)),
update_local_player_inputs
.run_if(resource_exists::<LocalPlayerId>)
.run_if(in_state(ClientState::Connected)),
)
.chain()
.run_if(in_state(GameState::Playing))
.after(ClientSystems::Receive),
);
app.add_systems(Update, head_change.run_if(in_state(GameState::Playing)));
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
// TODO: Split this into an enum of individual input commands, e.g. `JustJumped`
#[derive(Resource, Debug, Clone, Copy, Message, PartialEq, Serialize, Deserialize, Reflect)]
pub struct ControlState {
/// Movement direction with a maximum length of 1.0
pub move_dir: Vec2,
/// The current direction that the character is facing
/// (i.e. the direction that holding the forward movement key moves)
pub look_dir: Dir3,
pub look_dir: Vec3,
pub jump: bool,
/// Determines if the camera can rotate freely around the player
pub view_mode: bool,
@@ -74,15 +93,11 @@ pub struct ControlState {
pub cash_heal: bool,
}
#[derive(Resource, Default, Reflect)]
#[reflect(Resource)]
pub struct LookDirMovement(pub Vec2);
impl Default for ControlState {
fn default() -> Self {
Self {
move_dir: Default::default(),
look_dir: Dir3::NEG_Z,
look_dir: Vec3::NEG_Z,
jump: Default::default(),
view_mode: Default::default(),
trigger: Default::default(),
@@ -102,6 +117,14 @@ impl MapEntities for ControlState {
fn map_entities<E: EntityMapper>(&mut self, _entity_mapper: &mut E) {}
}
#[derive(Component, Default, Deref, DerefMut, Reflect, Serialize, Deserialize)]
#[reflect(Component, Default)]
pub struct Inputs(pub ControlState);
#[derive(Resource, Default, Reflect)]
#[reflect(Resource)]
pub struct LookDirMovement(pub Vec2);
#[derive(Resource, Debug, PartialEq, Eq)]
pub enum CharacterInputEnabled {
On,
@@ -120,6 +143,33 @@ pub struct ControllerSwitchEvent {
controller: Entity,
}
/// Take incoming client input messages and cache them on the corresponding player controller
fn collect_player_inputs(
mut players: Query<&mut Inputs>,
clients: ClientToController,
mut input_messages: MessageReader<FromClient<ControlState>>,
) {
for msg in input_messages.read() {
let player = clients.get_controller(msg.client_id);
let mut inputs = players.get_mut(player).unwrap();
inputs.0 = msg.message;
}
}
/// Overwrite the input cache replicated to the local player with the actual current inputs
fn update_local_player_inputs(
mut players: Query<&mut Inputs>,
player_ids: Res<PlayerIdMap>,
local_id: Res<LocalPlayerId>,
control_state: Res<ControlState>,
) {
let player = player_ids.get(&local_id.id).unwrap();
let mut inputs = players.get_mut(*player).unwrap();
inputs.0 = *control_state;
}
fn head_change(
//TODO: needs a 'LocalPlayer' at some point for multiplayer
query: Query<(Entity, &ActiveHead), (Changed<ActiveHead>, With<Player>)>,

View File

@@ -3,6 +3,7 @@ use crate::{
protocol::PlaySound,
};
use bevy::{platform::collections::HashSet, prelude::*};
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
pub fn plugin(app: &mut App) {
global_observer!(app, on_key_collected);
@@ -11,22 +12,34 @@ pub fn plugin(app: &mut App) {
fn on_key_collected(trigger: On<KeyCollected>, mut commands: Commands) {
match trigger.event().0.as_str() {
"fence_gate" => {
commands.trigger(StartCutscene("fence_01".to_string()));
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: StartCutscene("fence_01".to_string()),
});
let entities: HashSet<_> = vec!["fence_01", "fence_02"]
.into_iter()
.map(String::from)
.collect();
commands.trigger(PlaySound::Gate);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Gate,
});
commands.trigger(TriggerMovableEvent(entities));
}
"fence_shaft" => {
commands.trigger(StartCutscene("cutscene_02".to_string()));
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: StartCutscene("cutscene_02".to_string()),
});
let entities: HashSet<_> = vec!["fence_shaft"].into_iter().map(String::from).collect();
commands.trigger(PlaySound::Gate);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Gate,
});
commands.trigger(TriggerMovableEvent(entities));
}
_ => {

View File

@@ -115,19 +115,32 @@ fn spawn_head_ui(
..default()
},
BorderRadius::all(Val::Px(9999.)),
BackgroundGradient::from(ConicGradient {
start: 0.,
stops: vec![
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.9), 0.),
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.9), PI * 1.5),
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.0), PI * 1.5),
],
position: UiPosition::CENTER,
color_space: InterpolationColorSpace::Srgba,
}),
ImageNode::default(),
Visibility::Hidden,
HeadImage(head_slot),
children![(
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BorderRadius::all(Val::Px(9999.)),
HeadImage(0),
ImageNode {
color: Color::linear_rgba(0.0, 0.0, 0.0, 0.0),
..default()
},
BackgroundGradient::from(ConicGradient {
start: 0.,
stops: vec![
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.9), 0.),
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.9), PI * 1.5),
AngularColorStop::new(Color::linear_rgba(0., 0., 0., 0.0), PI * 1.5),
],
position: UiPosition::CENTER,
color_space: InterpolationColorSpace::Srgba,
}),
)]
),
(
Node {
@@ -163,7 +176,7 @@ fn spawn_head_ui(
#[cfg(feature = "client")]
fn update(
res: Single<&UiActiveHeads, Changed<UiActiveHeads>>,
res: Single<&UiActiveHeads>,
heads_images: Res<HeadsImages>,
mut head_image: Query<(&HeadImage, &mut Visibility, &mut ImageNode), Without<HeadSelector>>,
mut head_selector: Query<(&HeadSelector, &mut Visibility), Without<HeadImage>>,
@@ -188,9 +201,14 @@ fn update(
#[cfg(feature = "client")]
fn update_ammo(
res: Single<&UiActiveHeads, Changed<UiActiveHeads>>,
mut gradients: Query<(&mut BackgroundGradient, &HeadImage)>,
heads: Query<&HeadImage>,
mut gradients: Query<(&mut BackgroundGradient, &ChildOf)>,
) {
for (mut gradient, HeadImage(head)) in gradients.iter_mut() {
for (mut gradient, child_of) in gradients.iter_mut() {
let Ok(HeadImage(head)) = heads.get(child_of.parent()) else {
continue;
};
if let Some(head) = res.heads[*head] {
let Gradient::Conic(gradient) = &mut gradient.0[0] else {
continue;
@@ -202,7 +220,7 @@ fn update_ammo(
head.ammo_used()
};
let angle = progress * PI * 2.;
let angle = progress * PI * 2.0;
gradient.stops[1].angle = Some(angle);
gradient.stops[2].angle = Some(angle);

View File

@@ -1,5 +1,3 @@
pub mod heads_ui;
#[cfg(feature = "server")]
use crate::animation::AnimationFlags;
use crate::{
@@ -14,9 +12,11 @@ use crate::{
use crate::{control::ControlState, protocol::PlaySound};
use bevy::prelude::*;
#[cfg(feature = "server")]
use lightyear::prelude::input::native::ActionState;
use bevy_replicon::prelude::FromClient;
use serde::{Deserialize, Serialize};
pub mod heads_ui;
pub static HEAD_COUNT: usize = 18;
pub static HEAD_SLOTS: usize = 5;
@@ -232,7 +232,12 @@ fn reload(
if !head.has_ammo() && (head.last_use + head.reload_duration <= time.elapsed_secs()) {
// only for player?
commands.trigger(PlaySound::Reloaded);
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Reloaded,
});
flags.restart_shooting = true;
head.ammo = head.ammo_max;
}
@@ -243,31 +248,39 @@ fn reload(
#[cfg(feature = "server")]
fn on_select_active_head(
mut commands: Commands,
mut query: Query<(&mut ActiveHeads, &mut Hitpoints, &ActionState<ControlState>), With<Player>>,
mut query: Query<(&mut ActiveHeads, &mut Hitpoints), With<Player>>,
mut controls: MessageReader<FromClient<ControlState>>,
) {
for (mut active_heads, mut hp, controls) in query.iter_mut() {
if !controls.select_right && !controls.select_left {
continue;
}
for controls in controls.read() {
for (mut active_heads, mut hp) in query.iter_mut() {
use bevy_replicon::prelude::{SendMode, ServerTriggerExt, ToClients};
if controls.select_right {
active_heads.selected_slot = (active_heads.selected_slot + 1) % HEAD_SLOTS;
}
if !controls.select_right && !controls.select_left {
continue;
}
if controls.select_left {
active_heads.selected_slot =
(active_heads.selected_slot + (HEAD_SLOTS - 1)) % HEAD_SLOTS;
}
if controls.select_right {
active_heads.selected_slot = (active_heads.selected_slot + 1) % HEAD_SLOTS;
}
commands.trigger(PlaySound::Selection);
if controls.select_left {
active_heads.selected_slot =
(active_heads.selected_slot + (HEAD_SLOTS - 1)) % HEAD_SLOTS;
}
if active_heads.head(active_heads.selected_slot).is_some() {
active_heads.current_slot = active_heads.selected_slot;
hp.set_health(active_heads.current().unwrap().health);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Selection,
});
commands.trigger(HeadChanged(
active_heads.heads[active_heads.current_slot].unwrap().head,
));
if active_heads.head(active_heads.selected_slot).is_some() {
active_heads.current_slot = active_heads.selected_slot;
hp.set_health(active_heads.current().unwrap().health);
commands.trigger(HeadChanged(
active_heads.heads[active_heads.current_slot].unwrap().head,
));
}
}
}
}

View File

@@ -5,6 +5,7 @@ use crate::{
protocol::PlaySound,
};
use bevy::prelude::*;
use bevy_replicon::prelude::{ClientState, SendMode, ServerTriggerExt, ToClients};
use serde::{Deserialize, Serialize};
#[derive(EntityEvent, Reflect)]
@@ -65,9 +66,14 @@ impl Hitpoints {
}
pub fn plugin(app: &mut App) {
app.add_systems(Update, on_hp_added).add_systems(
app.add_systems(
Update,
on_hp_added.run_if(in_state(ClientState::Disconnected)),
)
.add_systems(
PreUpdate,
reset_hit_animation_flag.run_if(in_state(GameState::Playing)),
reset_hit_animation_flag
.run_if(in_state(ClientState::Disconnected).and(in_state(GameState::Playing))),
);
}
@@ -89,7 +95,10 @@ fn on_hit(
return;
};
commands.trigger(PlaySound::Hit);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Hit,
});
if let Some(mut flags) = flags {
flags.hit = true;

View File

@@ -8,13 +8,8 @@ use crate::{
utils::one_shot_force::OneShotForce,
};
use avian3d::prelude::*;
use bevy::{
ecs::{relationship::RelatedSpawner, spawn::SpawnWith},
prelude::*,
};
use lightyear::prelude::DisableReplicateHierarchy;
#[cfg(feature = "server")]
use lightyear::prelude::{NetworkTarget, Replicate};
use bevy::prelude::*;
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
use std::f32::consts::PI;
#[derive(Event, Reflect)]
@@ -40,35 +35,37 @@ fn on_spawn_key(trigger: On<KeySpawn>, mut commands: Commands, time: Res<Time<Fi
let spawn_dir = Quat::from_rotation_y(angle) * Vec3::new(0.5, 0.6, 0.).normalize();
let spawn_force = spawn_dir * 180.0 / time.delta_secs();
commands.spawn((
Name::new("key"),
Transform::from_translation(*position),
Position::new(*position),
Visibility::default(),
Collider::sphere(1.5),
LockedAxes::ROTATION_LOCKED,
RigidBody::Dynamic,
OneShotForce(spawn_force),
CollisionLayers::new(GameLayer::CollectiblePhysics, GameLayer::Level),
Restitution::new(0.6),
Children::spawn((
Spawn((Billboard::All, SquishAnimation(2.6), GltfSceneRoot::Key)),
SpawnWith(|parent: &mut RelatedSpawner<ChildOf>| {
parent
.spawn((
Collider::sphere(1.5),
CollisionLayers::new(GameLayer::CollectibleSensors, GameLayer::Player),
Sensor,
CollisionEventsEnabled,
Key(id),
DisableReplicateHierarchy,
))
.observe(on_collect_key);
}),
)),
#[cfg(feature = "server")]
Replicate::to_clients(NetworkTarget::All),
));
commands
.spawn((
Name::new("key"),
Transform::from_translation(*position),
Position::new(*position),
Visibility::default(),
Collider::sphere(1.5),
LockedAxes::ROTATION_LOCKED,
RigidBody::Dynamic,
OneShotForce(spawn_force),
CollisionLayers::new(GameLayer::CollectiblePhysics, GameLayer::Level),
Restitution::new(0.6),
Replicated,
))
.with_children(|c| {
c.spawn((
Billboard::All,
SquishAnimation(2.6),
GltfSceneRoot::Key,
Replicated,
));
c.spawn((
Collider::sphere(1.5),
CollisionLayers::new(GameLayer::CollectibleSensors, GameLayer::Player),
Sensor,
CollisionEventsEnabled,
Key(id),
Replicated,
))
.observe(on_collect_key);
});
}
fn on_collect_key(
@@ -83,7 +80,10 @@ fn on_collect_key(
if query_player.contains(collider) {
let (key, child_of) = query_collectable.get(key).unwrap();
commands.trigger(PlaySound::KeyCollect);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::KeyCollect,
});
commands.trigger(KeyCollected(key.0.clone()));
commands.entity(child_of.parent()).despawn();
}

View File

@@ -26,6 +26,7 @@ pub mod player;
pub mod protocol;
pub mod steam;
pub mod tb_entities;
pub mod tick;
pub mod utils;
pub mod water;

View File

@@ -1,12 +1,8 @@
#[cfg(feature = "server")]
use crate::protocol::TbMapEntityId;
use crate::{GameState, physics_layers::GameLayer};
use crate::{GameState, physics_layers::GameLayer, protocol::TbMapEntityId};
use avian3d::prelude::*;
use bevy::prelude::*;
use bevy_replicon::prelude::Replicated;
use bevy_trenchbroom::physics::SceneCollidersReady;
use lightyear::prelude::ReplicationGroup;
#[cfg(feature = "server")]
use lightyear::prelude::{DisableReplicateHierarchy, NetworkTarget, Replicate};
pub fn plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::MapLoading), setup_scene);
@@ -22,33 +18,20 @@ fn setup_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
.observe(
|t: On<SceneCollidersReady>,
children: Query<&Children>,
#[cfg(feature = "server")] map_entities: Query<&TbMapEntityId>,
map_entities: Query<&TbMapEntityId>,
mut commands: Commands,
#[cfg(any(feature = "client", feature = "server"))] mut next_game_state: ResMut<
NextState<GameState>,
>| {
mut next_game_state: ResMut<NextState<GameState>>| {
info!("map loaded");
for child in children.get(t.event().scene_root_entity).unwrap() {
commands.entity(*child).remove::<ChildOf>();
#[cfg(feature = "server")]
if map_entities.contains(*child) {
commands.entity(*child).insert((
Replicate::to_clients(NetworkTarget::All),
ReplicationGroup::new_id(t.scene_root_entity.to_bits()),
DisableReplicateHierarchy,
));
commands.entity(*child).insert(Replicated);
}
}
commands.entity(t.scene_root_entity).insert((
#[cfg(feature = "server")]
Replicate::to_clients(NetworkTarget::All),
#[cfg(feature = "server")]
DisableReplicateHierarchy,
ReplicationGroup::new_from_entity(),
));
commands.entity(t.scene_root_entity).insert(Replicated);
const { assert!(cfg!(feature = "client") ^ cfg!(feature = "server")) };

View File

@@ -80,6 +80,8 @@ fn on_spawn_check(
}
for (e, spawn, transform) in query.iter() {
use bevy_replicon::prelude::{Replicated, SendMode, ServerTriggerExt, ToClients};
if let Some(order) = spawn.spawn_order
&& order > spawning.spawn_index
{
@@ -100,13 +102,21 @@ fn on_spawn_check(
None,
None,
]),
Replicated,
))
.insert_if(Ai, || !spawn.disable_ai)
.with_child((Name::from("body-rig"), AnimatedCharacter::new(id)))
.with_child((
Name::from("body-rig"),
AnimatedCharacter::new(id),
Replicated,
))
.observe(on_kill);
commands.trigger(SpawnCharacter(transform.translation));
commands.trigger(PlaySound::Beaming);
commands.server_trigger(ToClients {
mode: SendMode::Broadcast,
message: PlaySound::Beaming,
});
}
}

View File

@@ -1,7 +1,6 @@
use crate::GameState;
use crate::{GameState, tick::GameTick};
use avian3d::prelude::{LinearVelocity, Position};
use bevy::{math::ops::sin, prelude::*};
use lightyear::prelude::LocalTimeline;
use serde::{Deserialize, Serialize};
#[derive(Component, Reflect, Default, Debug, Serialize, Deserialize, PartialEq)]
@@ -20,18 +19,17 @@ pub fn plugin(app: &mut App) {
}
fn move_active(
#[cfg(not(feature = "server"))] time: Single<&LocalTimeline>,
#[cfg(feature = "server")] time: Single<&LocalTimeline, With<lightyear::prelude::Server>>,
bevy_time: Res<Time<Fixed>>,
tick: Res<GameTick>,
fixed_time: Res<Time<Fixed>>,
mut platforms: Query<(&Position, &ActivePlatform, &mut LinearVelocity)>,
) {
for (position, active, mut velocity) in platforms.iter_mut() {
let now = time.now.as_duration(bevy_time.delta()).as_secs_f32();
let now = tick.0 as f32 * fixed_time.timestep().as_secs_f32();
let t = (sin(now * 0.4) + 1.) / 2.;
let target = active.start.lerp(active.target, t);
let prev = position.0;
velocity.0 = (target - prev) / bevy_time.delta_secs();
velocity.0 = (target - prev) / fixed_time.timestep().as_secs_f32();
}
}

View File

@@ -2,6 +2,7 @@ use crate::{
GameState,
cash::{Cash, CashCollectEvent},
character::HedzCharacter,
protocol::PlayerId,
};
use avian3d::prelude::*;
use bevy::{
@@ -20,8 +21,22 @@ pub struct Player;
#[require(Transform, Visibility)]
pub struct PlayerBodyMesh;
/// Server-side only; inserted on each `client` (not the controller) to track player ids.
#[derive(Component, Clone, Copy)]
pub struct ClientPlayerId(pub PlayerId);
/// Client-side only; stores this client's id
#[derive(Resource, Reflect)]
#[reflect(Resource)]
pub struct LocalPlayerId {
pub id: PlayerId,
}
pub fn plugin(app: &mut App) {
app.add_systems(Startup, (toggle_cursor_system, cursor_recenter));
app.add_systems(
OnEnter(GameState::Playing),
(toggle_cursor_system, cursor_recenter),
);
app.add_systems(
Update,
(

View File

@@ -1 +0,0 @@
pub struct UnorderedReliableChannel;

View File

@@ -1,21 +1,120 @@
use crate::{
loading_assets::{GameAssets, HeadDropAssets},
player::{ClientPlayerId, LocalPlayerId},
protocol::TbMapEntityMapping,
};
use avian3d::prelude::Collider;
use bevy::{
ecs::{lifecycle::HookContext, world::DeferredWorld},
platform::collections::hash_map,
ecs::{lifecycle::HookContext, system::SystemParam, world::DeferredWorld},
platform::collections::{HashMap, hash_map},
prelude::*,
};
use lightyear::prelude::Replicated;
use bevy_replicon::{client::confirm_history::ConfirmHistory, prelude::ClientId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Component, Reflect, Serialize, Deserialize, PartialEq)]
#[derive(Component)]
pub struct SkipReplicateColliders;
#[derive(Serialize, Deserialize)]
pub enum NetworkedCollider {
Sphere {
radius: f32,
},
Cuboid {
half_extents: Vec3,
},
Capsule {
radius: f32,
length: f32,
},
/// If a collider value wasn't set up to be replicated, it is replicated as unknown
/// and a warning is logged, and unwraps to `sphere(0.1)` on the other side. Likely
/// very incorrect, but good enough to mitigate some bugs before it's fixed.
Unknown,
}
impl From<Collider> for NetworkedCollider {
fn from(value: Collider) -> Self {
if let Some(value) = value.shape().as_ball() {
NetworkedCollider::Sphere {
radius: value.radius,
}
} else if let Some(value) = value.shape().as_cuboid() {
NetworkedCollider::Cuboid {
half_extents: value.half_extents.into(),
}
} else if let Some(value) = value.shape().as_capsule() {
NetworkedCollider::Capsule {
radius: value.radius,
length: value.height(),
}
} else {
warn!(
"unable to serialize collider type {value:?}; must be accounted for in `NetworkedCollider`"
);
NetworkedCollider::Unknown
}
}
}
impl From<NetworkedCollider> for Collider {
fn from(value: NetworkedCollider) -> Self {
match value {
NetworkedCollider::Sphere { radius } => Collider::sphere(radius),
NetworkedCollider::Cuboid { half_extents } => {
Collider::cuboid(half_extents.x, half_extents.y, half_extents.z)
}
NetworkedCollider::Capsule { radius, length } => Collider::capsule(radius, length),
NetworkedCollider::Unknown => Collider::sphere(0.1),
}
}
}
/// An ID, unique per player, inserted on the character controller. The `PlayerIdMap` maintains a mapping of ID -> controller entity
/// on the server
#[derive(Clone, Copy, Component, Hash, Reflect, Serialize, Deserialize, PartialEq, Eq)]
#[reflect(Component)]
#[component(on_insert = PlayerId::on_insert, on_remove = PlayerId::on_remove)]
pub struct PlayerId {
pub id: u8,
}
impl PlayerId {
fn on_insert(mut world: DeferredWorld, ctx: HookContext) {
let id = *world.get::<PlayerId>(ctx.entity).unwrap();
world.resource_mut::<PlayerIdMap>().insert(id, ctx.entity);
}
fn on_remove(mut world: DeferredWorld, ctx: HookContext) {
let id = *world.get::<PlayerId>(ctx.entity).unwrap();
world.resource_mut::<PlayerIdMap>().insert(id, ctx.entity);
}
}
/// A (serverside only) mapping of ID -> controller entity
#[derive(Resource, Default, Deref, DerefMut)]
pub struct PlayerIdMap {
pub map: HashMap<PlayerId, Entity>,
}
#[derive(SystemParam)]
pub struct ClientToController<'w, 's> {
clients: Query<'w, 's, &'static ClientPlayerId>,
players: Res<'w, PlayerIdMap>,
local_id: Option<Res<'w, LocalPlayerId>>,
}
impl ClientToController<'_, '_> {
/// Looks up the character controller owned by the given client
pub fn get_controller(&self, client: ClientId) -> Entity {
let player_id = match client.entity() {
Some(client) => self.clients.get(client).unwrap().0,
None => self.local_id.as_ref().unwrap().id,
};
*self.players.get(&player_id).unwrap()
}
}
/// A unique ID assigned to every entity spawned by the trenchbroom map loader. This allows synchronizing
/// them across the network even when they are spawned initially by both sides.
#[derive(Clone, Copy, Component, Debug, Reflect, Serialize, Deserialize, PartialEq)]
@@ -28,7 +127,9 @@ pub struct TbMapEntityId {
impl TbMapEntityId {
fn insert_id(mut world: DeferredWorld, ctx: HookContext) {
let id = world.get::<TbMapEntityId>(ctx.entity).unwrap().id;
let entity_is_replicated = world.entity(ctx.entity).contains::<Replicated>();
// Under lightyear, this was querying `Replicated`. But in replicon `Replicated` is on both sides, and
// `ConfirmHistory` is only client-side.
let entity_is_replicated = world.entity(ctx.entity).contains::<ConfirmHistory>();
let mut mapping = world.resource_mut::<TbMapEntityMapping>();
if let hash_map::Entry::Vacant(e) = mapping.entry(id) {
if entity_is_replicated {

View File

@@ -27,5 +27,8 @@ pub enum PlaySound {
Head(String),
}
#[derive(Clone, Event, Serialize, Deserialize)]
#[derive(Clone, Default, Event, Serialize, Deserialize)]
pub struct ClientEnteredPlaying;
#[derive(Clone, Event, Serialize, Deserialize)]
pub struct SetGameTick(pub u64);

View File

@@ -1,9 +1,11 @@
use crate::protocol::PlayerId;
use bevy::ecs::message::Message;
use serde::{Deserialize, Serialize};
/// Trenchbroom map entities (spawned during map loading) must be despawned manually if the server
/// has already despawned it but the client has just loaded the map and connected
#[derive(Clone, Copy, Serialize, Deserialize)]
#[derive(Clone, Copy, Message, Serialize, Deserialize)]
pub struct DespawnTbMapEntity(pub u64);
#[derive(Clone, Copy, Serialize, Deserialize)]
pub struct AssignClientPlayer(pub u8);
#[derive(Clone, Copy, Message, Serialize, Deserialize)]
pub struct AssignClientPlayer(pub PlayerId);

View File

@@ -1,8 +1,3 @@
pub mod channels;
pub mod components;
pub mod events;
pub mod messages;
use crate::{
GameState,
abilities::{BuildExplosionSprite, healing::Healing},
@@ -12,7 +7,7 @@ use crate::{
cash::CashResource,
character::{AnimatedCharacter, HedzCharacter},
control::{
ControlState, ControllerSettings,
ControlState, ControllerSettings, Inputs,
controller_common::{MovementSpeedFactor, PlayerCharacterController},
},
cutscene::StartCutscene,
@@ -22,14 +17,18 @@ use crate::{
hitpoints::Hitpoints,
platforms::ActivePlatform,
player::{Player, PlayerBodyMesh},
protocol::channels::UnorderedReliableChannel,
utils::{
auto_rotate::AutoRotation, billboards::Billboard, squish_animation::SquishAnimation,
triggers::TriggerAppExt,
},
tick::GameTick,
utils::{auto_rotate::AutoRotation, billboards::Billboard, squish_animation::SquishAnimation},
};
use avian3d::prelude::{
AngularInertia, AngularVelocity, CenterOfMass, Collider, ColliderDensity, CollisionLayers,
LinearVelocity, LockedAxes, Mass, Position, RigidBody, Rotation,
};
use avian3d::prelude::{AngularVelocity, CollisionLayers, LinearVelocity, Position, Rotation};
use bevy::{platform::collections::HashMap, prelude::*};
use bevy_replicon::prelude::{
AppRuleExt, Channel, ClientEventAppExt, ClientMessageAppExt, ServerEventAppExt,
ServerMessageAppExt, SyncRelatedAppExt,
};
pub use components::*;
pub use events::*;
use happy_feet::{
@@ -39,146 +38,134 @@ use happy_feet::{
GroundingConfig, KinematicVelocity, MoveInput, SteppingConfig,
},
};
use lightyear::prelude::{
AppChannelExt, AppComponentExt, AppMessageExt, AppTriggerExt, ChannelMode, ChannelSettings,
ComponentReplicationConfig, Confirmed, ConfirmedTick, InterpolationRegistrationExt,
NetworkDirection, PredictionHistory, PredictionRegistrationExt, ReliableSettings,
input::native::InputPlugin,
};
use lightyear_serde::{
SerializationError, reader::ReadInteger, registry::SerializeFns, writer::WriteInteger,
};
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub mod components;
pub mod events;
pub mod messages;
pub fn plugin(app: &mut App) {
app.add_plugins(InputPlugin::<ControlState>::default());
app.add_client_message::<ControlState>(Channel::Unreliable);
app.register_type::<ConfirmedTick>();
app.register_type::<Confirmed<Position>>();
app.register_type::<Confirmed<Rotation>>();
app.register_type::<PredictionHistory<Position>>();
app.add_client_event::<ClientEnteredPlaying>(Channel::Ordered);
app.add_server_message::<messages::DespawnTbMapEntity>(Channel::Unordered)
.add_server_message::<messages::AssignClientPlayer>(Channel::Unordered);
app.add_server_event::<ClientHeadChanged>(Channel::Unordered)
.add_server_event::<BuildExplosionSprite>(Channel::Unreliable)
.add_server_event::<StartCutscene>(Channel::Ordered)
.add_server_event::<events::PlaySound>(Channel::Unreliable)
.add_server_event::<events::SetGameTick>(Channel::Ordered);
app.register_type::<PlayerId>();
app.register_type::<TbMapEntityId>();
app.register_type::<TbMapIdCounter>();
app.register_type::<TbMapEntityMapping>();
app.init_resource::<PlayerIdMap>();
app.init_resource::<TbMapIdCounter>();
app.init_resource::<TbMapEntityMapping>();
app.add_channel::<UnorderedReliableChannel>(ChannelSettings {
mode: ChannelMode::UnorderedReliable(ReliableSettings::default()),
send_frequency: Duration::from_millis(100),
priority: 1.0,
})
.add_direction(NetworkDirection::Bidirectional);
app.replicate::<ChildOf>();
app.sync_related_entities::<ChildOf>();
app.register_message::<messages::DespawnTbMapEntity>()
.add_direction(NetworkDirection::ServerToClient);
app.register_message::<messages::AssignClientPlayer>()
.add_direction(NetworkDirection::ServerToClient);
app.replicate::<components::GltfSceneRoot>()
.replicate_once::<components::PlayerId>()
.replicate::<components::TbMapEntityId>()
.replicate::<ActiveHead>()
.replicate::<ActiveHeads>()
.replicate::<ActivePlatform>()
.replicate::<AnimatedCharacter>()
.replicate::<AnimationFlags>()
.replicate_once::<AutoRotation>()
.replicate::<Backpack>()
.replicate::<BackpackUiState>()
.replicate::<Billboard>()
.replicate_once::<CameraArmRotation>()
.replicate_once::<CameraTarget>()
.replicate::<CashResource>()
.replicate_once::<HedzCharacter>()
.replicate_once::<Healing>()
.replicate::<Hitpoints>()
.replicate::<Inputs>()
.replicate::<Name>()
.replicate_once::<Player>()
.replicate_once::<PlayerBodyMesh>()
.replicate::<SquishAnimation>()
.replicate_once::<Transform>()
.replicate::<UiActiveHeads>()
.replicate_as::<Visibility, SerVisibility>();
app.register_component::<components::GltfSceneRoot>();
app.register_component::<components::PlayerId>();
app.register_component::<components::TbMapEntityId>();
app.register_component::<ActiveHead>();
app.register_component::<ActiveHeads>();
app.register_component::<ActivePlatform>();
app.register_component::<AnimatedCharacter>();
app.register_component::<AnimationFlags>();
app.register_component::<AutoRotation>()
.with_replication_config(ComponentReplicationConfig {
replicate_once: true,
..default()
});
app.register_component::<Backpack>();
app.register_component::<BackpackUiState>();
app.register_component::<Billboard>();
app.register_component::<CameraArmRotation>();
app.register_component::<CameraTarget>();
app.register_component::<CashResource>();
app.register_component::<HedzCharacter>();
app.register_component::<Healing>();
app.register_component::<Hitpoints>();
app.register_component::<Name>();
app.register_component::<Player>();
app.register_component::<PlayerBodyMesh>()
.with_replication_config(ComponentReplicationConfig {
replicate_once: true,
..default()
});
app.register_component::<SquishAnimation>();
// Physics components
app.replicate::<AngularInertia>()
.replicate::<AngularVelocity>()
.replicate::<CenterOfMass>()
.replicate_filtered_as::<Collider, NetworkedCollider, Without<SkipReplicateColliders>>()
.replicate::<ColliderDensity>()
.replicate::<CollisionLayers>()
.replicate::<LinearVelocity>()
.replicate::<LockedAxes>()
.replicate::<Mass>()
.replicate::<Position>()
.replicate::<RigidBody>()
.replicate::<Rotation>();
// Physics
app.register_component::<AngularVelocity>()
.add_prediction()
.add_should_rollback(|this, that| this.0.distance_squared(that.0) >= 0.01f32.powf(2.0));
app.register_component::<CollisionLayers>();
app.register_component::<LinearVelocity>()
.add_prediction()
.add_should_rollback(|this, that| this.0.distance_squared(that.0) >= 0.01f32.powf(2.0));
app.register_component::<Position>()
.add_prediction()
.add_should_rollback(|this, that| this.0.distance_squared(that.0) >= 0.01f32.powf(2.0))
.add_linear_correction_fn()
.add_linear_interpolation();
app.register_component::<Rotation>()
.add_prediction()
.add_should_rollback(|this, that| this.angle_between(*that) >= 0.01)
.add_linear_correction_fn()
.add_linear_interpolation();
// Controller
app.register_component::<CharacterDrag>();
app.register_component::<CharacterGravity>();
app.register_component::<CharacterMovement>();
app.register_component::<ControllerSettings>();
app.register_component::<GroundFriction>();
app.register_component::<Grounding>();
app.register_component::<GroundingConfig>();
app.register_component::<GroundingState>();
app.register_component::<KinematicVelocity>();
app.register_component::<MoveInput>();
app.register_component::<MovementSpeedFactor>();
app.register_component::<PlayerCharacterController>()
.with_replication_config(ComponentReplicationConfig {
replicate_once: true,
..default()
});
app.register_component::<SteppingConfig>();
app.register_component::<UiActiveHeads>();
// `Visibility` isn't `(De)Serialize`, so we have to provide custom serde for it.
app.register_component_custom_serde::<Visibility>(SerializeFns {
serialize: |comp, writer| writer.write_u8(*comp as u8).map_err(SerializationError::Io),
deserialize: |reader| {
let byte = reader.read_u8().map_err(SerializationError::Io)?;
Ok(match byte {
0 => Visibility::Inherited,
1 => Visibility::Hidden,
2 => Visibility::Visible,
_ => return Err(SerializationError::InvalidValue),
})
},
});
app.replicate_event::<BuildExplosionSprite, UnorderedReliableChannel>();
app.replicate_event::<StartCutscene, UnorderedReliableChannel>();
app.replicate_event::<events::ClientHeadChanged, UnorderedReliableChannel>();
app.replicate_event::<events::PlaySound, UnorderedReliableChannel>();
app.register_event::<events::ClientEnteredPlaying>()
.add_direction(NetworkDirection::ClientToServer);
// Character controller components
app.replicate::<CharacterDrag>()
.replicate::<CharacterGravity>()
.replicate::<CharacterMovement>()
.replicate::<ControllerSettings>()
.replicate::<GroundFriction>()
.replicate::<Grounding>()
.replicate::<GroundingConfig>()
.replicate::<GroundingState>()
.replicate::<KinematicVelocity>()
.replicate::<MoveInput>()
.replicate::<MovementSpeedFactor>()
.replicate_once::<PlayerCharacterController>()
.replicate::<SteppingConfig>();
app.add_systems(
OnEnter(GameState::MapLoading),
|mut counter: ResMut<TbMapIdCounter>| counter.reset(),
);
global_observer!(app, set_game_tick);
global_observer!(app, components::spawn_gltf_scene_roots);
}
fn set_game_tick(on: On<SetGameTick>, mut tick: ResMut<GameTick>) {
tick.0 = on.event().0;
}
#[derive(Serialize, Deserialize)]
enum SerVisibility {
Inherited,
Hidden,
Visible,
}
impl From<Visibility> for SerVisibility {
fn from(value: Visibility) -> Self {
match value {
Visibility::Inherited => Self::Inherited,
Visibility::Hidden => Self::Hidden,
Visibility::Visible => Self::Visible,
}
}
}
impl From<SerVisibility> for Visibility {
fn from(value: SerVisibility) -> Self {
match value {
SerVisibility::Inherited => Self::Inherited,
SerVisibility::Hidden => Self::Hidden,
SerVisibility::Visible => Self::Visible,
}
}
}
/// A global allocator for `TbMapEntityId` values. Should be reset when a map begins loading.
#[derive(Resource, Reflect, Default)]
#[reflect(Resource)]

View File

@@ -1,6 +1,10 @@
use crate::{
GameState, cash::Cash, loading_assets::GameAssets, physics_layers::GameLayer,
protocol::TbMapIdCounter, utils::global_observer,
GameState,
cash::Cash,
loading_assets::GameAssets,
physics_layers::GameLayer,
protocol::{SkipReplicateColliders, TbMapIdCounter},
utils::global_observer,
};
use avian3d::{
parry::{na::SVector, shape::SharedShape},
@@ -124,18 +128,21 @@ impl EnemySpawn {
let mut this_transform = *this_transform;
this_transform.translation += Vec3::new(0., 1.5, 0.);
let position = Position::new(this_transform.translation);
let rotation = Rotation(this_transform.rotation);
let head = this.head.clone();
world.commands().entity(entity).insert((
this_transform,
position,
rotation,
Name::from(format!("enemy [{head}]")),
Visibility::default(),
RigidBody::Dynamic,
RigidBody::Kinematic,
Collider::capsule(0.6, 2.),
CollisionLayers::new(LayerMask(GameLayer::Npc.to_bits()), LayerMask::ALL),
LockedAxes::new().lock_rotation_z().lock_rotation_x(),
#[cfg(feature = "server")]
lightyear::prelude::Replicate::to_clients(lightyear::prelude::NetworkTarget::All),
));
}
}
@@ -159,6 +166,7 @@ impl CashSpawn {
Cash,
Collider::cuboid(2., 3.0, 2.),
CollisionLayers::new(GameLayer::CollectibleSensors, LayerMask::ALL),
RigidBody::Static,
CollisionEventsEnabled,
Sensor,
));
@@ -222,5 +230,8 @@ fn tb_component_setup<C: Component>(
) {
let id = world.resource_mut::<TbMapIdCounter>().alloc();
commands.entity(trigger.event().entity).insert_if_new(id);
commands
.entity(trigger.event().entity)
.insert_if_new(id)
.insert(SkipReplicateColliders);
}

17
crates/shared/src/tick.rs Normal file
View File

@@ -0,0 +1,17 @@
use crate::GameState;
use bevy::prelude::*;
pub fn plugin(app: &mut App) {
app.init_resource::<GameTick>();
app.add_systems(
FixedLast,
(|mut tick: ResMut<GameTick>| {
tick.0 += 1;
})
.run_if(in_state(GameState::Playing)),
);
}
#[derive(Default, Resource)]
pub struct GameTick(pub u64);

View File

@@ -1,44 +0,0 @@
use bevy::ecs::{
entity::Entity,
event::{EntityEvent, Event},
system::{Commands, EntityCommands},
world::{EntityWorldMut, World},
};
use lightyear::prelude::Disconnected;
pub trait CommandExt {
fn trigger_server<'a, E: Event<Trigger<'a>: Default>>(&mut self, event: E) -> &mut Self;
}
impl<'w, 's> CommandExt for Commands<'w, 's> {
fn trigger_server<'a, E: Event<Trigger<'a>: Default>>(&mut self, event: E) -> &mut Self {
self.queue(|world: &mut World| {
let mut query_state = world.query::<&Disconnected>();
if cfg!(feature = "server") || !query_state.query(world).is_empty() {
world.trigger(event);
}
});
self
}
}
pub trait EntityCommandExt {
fn trigger_server<'a, E: EntityEvent<Trigger<'a>: Default>>(
&mut self,
event: impl FnOnce(Entity) -> E + Send + Sync + 'static,
) -> &mut Self;
}
impl<'w> EntityCommandExt for EntityCommands<'w> {
fn trigger_server<'a, E: EntityEvent<Trigger<'a>: Default>>(
&mut self,
event: impl FnOnce(Entity) -> E + Send + Sync + 'static,
) -> &mut Self {
self.queue(|mut entity: EntityWorldMut| {
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,6 +1,5 @@
pub mod auto_rotate;
pub mod billboards;
pub mod commands;
pub mod explosions;
pub mod observers;
pub mod one_shot_force;
@@ -8,7 +7,6 @@ pub mod run_conditions;
pub mod sprite_3d_animation;
pub mod squish_animation;
pub mod trail;
pub mod triggers;
use bevy::prelude::*;
pub(crate) use observers::global_observer;

View File

@@ -1,60 +0,0 @@
use crate::utils::global_observer;
use bevy::{ecs::system::SystemParam, prelude::*};
use lightyear::prelude::{AppTriggerExt, Channel, EventSender, NetworkDirection, RemoteEvent};
use serde::{Deserialize, Serialize};
#[derive(SystemParam)]
pub struct ServerMultiTriggerSender<'w, 's, M: Event + Clone> {
senders: Query<'w, 's, &'static mut EventSender<M>>,
}
impl<'w, 's, M: Event + Clone> ServerMultiTriggerSender<'w, 's, M> {
pub fn server_trigger_targets<C: Channel>(&mut self, event: M) {
if cfg!(not(feature = "server")) {
return;
}
for mut sender in self.senders.iter_mut() {
sender.trigger::<C>(event.clone());
}
}
}
pub trait TriggerAppExt {
fn replicate_event<
'a,
M: Event<Trigger<'a>: Default> + Clone + Serialize + for<'de> Deserialize<'de>,
C: Channel,
>(
&mut self,
);
}
impl TriggerAppExt for App {
fn replicate_event<
'a,
M: Event<Trigger<'a>: Default> + Clone + Serialize + for<'de> Deserialize<'de>,
C: Channel,
>(
&mut self,
) {
self.register_event::<M>()
.add_direction(NetworkDirection::ServerToClient);
global_observer!(self, replicate_trigger_to_clients::<M, C>);
global_observer!(self, remote_to_local_event::<M>);
}
}
fn replicate_trigger_to_clients<M: Event + Clone, C: Channel>(
on: On<M>,
mut sender: ServerMultiTriggerSender<M>,
) {
sender.server_trigger_targets::<C>(on.event().clone());
}
fn remote_to_local_event<'a, M: Event<Trigger<'a>: Default> + Clone>(
trigger: On<RemoteEvent<M>>,
mut c: Commands,
) {
c.trigger(trigger.event().trigger.clone());
}