12 Commits

Author SHA1 Message Date
a620f18a55 run netcode host for dedicated server 2025-12-20 12:12:40 -05:00
38f7f5c30a dx 2025-12-20 12:11:05 -05:00
e9f53c11e9 allow local netcode multiplayer
* fix crashing when steam client not available
* allow more than 1 connected player
* disable settings persistence when in dbg
2025-12-20 12:08:52 -05:00
12c3cdc87b allow to choosing renet_netcode vs steam 2025-12-20 11:33:23 -05:00
930753170f cleanup 2025-12-19 22:30:21 -05:00
2c20b1efea fix plugin for server 2025-12-19 22:18:19 -05:00
1a632e729e set_rich_presence, more logging 2025-12-19 21:56:36 -05:00
22674822cc wrong condition 2025-12-19 20:31:10 -05:00
0f5a21995a Merge branch 'master' 2025-12-19 20:12:41 -05:00
7ea9046414 remove obsolete ignore 2025-12-19 20:04:06 -05:00
4fb37e27c5 switch out renet netcode with renet_steam 2025-12-19 20:03:45 -05:00
PROMETHIA-27
f6fa9ce1e4 implement player id allocation (#90)
* implement player id allocation

* move `bevy/debug` to `dbg`
2025-12-19 17:41:16 -05:00
66 changed files with 256 additions and 86 deletions

1
.gitignore vendored
View File

@@ -5,4 +5,3 @@ build/steamos/hedz_reloaded
build/steamos/.env
build/macos/src/HEDZReloaded.app/Contents/MacOS
build/macos/src/Applications
server.log

13
Cargo.lock generated
View File

@@ -1519,6 +1519,7 @@ dependencies = [
"bevy_time",
"renet",
"renet_netcode",
"renet_steam",
]
[[package]]
@@ -5564,6 +5565,18 @@ dependencies = [
"renetcode",
]
[[package]]
name = "renet_steam"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f6018afe469d3d2d49fab8fd1cecc46c588ac61498cad879d5781c44b277421"
dependencies = [
"bevy_ecs",
"log",
"renet",
"steamworks",
]
[[package]]
name = "renetcode"
version = "1.0.0"

View File

@@ -61,7 +61,8 @@ bevy_pkv = { version = "0.14", default-features = false, features = [
"redb",
] }
bevy_replicon = "0.37.1"
bevy_replicon_renet = "0.13.0"
# TODO: i dont think we need this in dedicated server mode
bevy_replicon_renet = { version = "0.13.0", features = ["renet_steam"] }
bevy_sprite3d = "7.0.0"
bevy_trenchbroom = { version = "0.10", default-features = false, features = [
"physics-integration",
@@ -76,6 +77,7 @@ rand = "=0.8.5"
ron = "0.8"
serde = { version = "1.0.219", features = ["derive"] }
shared = { path = "crates/shared" }
# TODO: i dont think we need this in dedicated server mode
steamworks = "0.12"
[profile.dev.package."*"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -20,7 +20,7 @@ client = [
"bevy_replicon_renet/client",
"bevy_trenchbroom/client",
]
dbg = ["avian3d/debug-plugin", "dep:bevy-inspector-egui"]
dbg = ["avian3d/debug-plugin", "bevy/debug", "dep:bevy-inspector-egui"]
[dependencies]
avian3d = { workspace = true }

View File

@@ -1,6 +1,6 @@
use crate::{
GameState,
config::NetworkingConfig,
config::NetConfig,
protocol::{
ClientEnteredPlaying, TbMapEntityId, TbMapEntityMapping, messages::DespawnTbMapEntity,
},
@@ -17,14 +17,10 @@ use bevy_replicon::{
};
use bevy_replicon_renet::{
RenetChannelsExt,
netcode::{ClientAuthentication, NetcodeClientTransport, NetcodeError},
renet::{ConnectionConfig, RenetClient},
};
use bevy_steamworks::Client;
use bevy_trenchbroom::geometry::Brushes;
use std::{
net::{Ipv4Addr, UdpSocket},
time::SystemTime,
};
pub mod audio;
pub mod backpack;
@@ -55,7 +51,7 @@ pub fn plugin(app: &mut App) {
app.add_systems(
OnEnter(GameState::Connecting),
connect_to_server.run_if(|config: Res<NetworkingConfig>| config.server.is_some()),
connect_to_server.run_if(|config: Res<NetConfig>| config.is_client()),
);
app.add_systems(Update, despawn_absent_map_entities);
app.add_systems(
@@ -89,8 +85,9 @@ fn on_disconnect() {
fn connect_to_server(
mut commands: Commands,
config: Res<NetworkingConfig>,
config: Res<NetConfig>,
channels: Res<RepliconChannels>,
steam_client: Option<Res<Client>>,
) -> Result {
let server_channels_config = channels.server_configs();
let client_channels_config = channels.client_configs();
@@ -102,32 +99,47 @@ fn connect_to_server(
});
commands.insert_resource(client);
commands.insert_resource(client_transport(&config)?);
if let NetConfig::SteamClient(host_steam_id) = &*config {
let Some(steam_client) = steam_client else {
return Err("Steam client not found".into());
};
info!("connecting to steam host: {host_steam_id:?}");
let transport = bevy_replicon_renet::steam::SteamClientTransport::new(
(**steam_client).clone(),
host_steam_id,
)?;
commands.insert_resource(transport);
} else if let NetConfig::NetcodeClient(host_addr) = &*config {
use std::time::SystemTime;
info!("connecting to netcode host: {host_addr:?}");
let current_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap();
let client_id = current_time.as_millis() as u64;
let socket = std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))?;
let authentication = bevy_replicon_renet::netcode::ClientAuthentication::Unsecure {
client_id,
protocol_id: 0,
server_addr: host_addr.clone(),
user_data: None,
};
let transport = bevy_replicon_renet::netcode::NetcodeClientTransport::new(
current_time,
authentication,
socket,
)?;
commands.insert_resource(transport);
}
Ok(())
}
fn client_transport(config: &NetworkingConfig) -> 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, 0))?;
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,
};
info!("attempting connection to {server_addr}");
NetcodeClientTransport::new(current_time, authentication, socket)
}
#[allow(clippy::type_complexity)]
fn migrate_remote_entities(
query: Query<(Entity, &TbMapEntityId), (Added<TbMapEntityId>, With<ConfirmHistory>)>,

View File

@@ -4,10 +4,11 @@ use bevy_pkv::prelude::*;
use crate::{client::audio::SoundSettings, utils::Debounce};
pub fn plugin(app: &mut App) {
#[cfg(not(feature = "dbg"))]
app.insert_resource(PkvStore::new("Rustunit", "HEDZ"));
app.add_systems(Update, persist_settings);
app.add_systems(Startup, load_settings);
app.add_systems(Update, persist_settings.run_if(resource_exists::<PkvStore>));
app.add_systems(Startup, load_settings.run_if(resource_exists::<PkvStore>));
}
fn persist_settings(

View File

@@ -61,6 +61,14 @@ fn test_steam_system(steam_client: Res<Client>) {
},
);
let id = steam_client.user().steam_id();
info!("Steam ID: {:?}", id);
steam_client
.friends()
.set_rich_presence("connect", Some(id.raw().to_string().as_str()));
for friend in steam_client.friends().get_friends(FriendFlags::IMMEDIATE) {
info!(
"Steam Friend: {:?} - {}({:?})",

View File

@@ -1,25 +1,92 @@
use std::net::SocketAddr;
use bevy::prelude::*;
use clap::Parser;
use std::net::SocketAddr;
use steamworks::SteamId;
pub fn plugin(app: &mut App) {
let config = NetworkingConfig::parse();
let config: NetConfig = config.into();
info!("net config: {:?}", config);
app.insert_resource(config);
}
#[derive(Resource, Parser, Debug)]
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct NetworkingConfig {
/// 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.
/// Does nothing on the server.
struct NetworkingConfig {
/// Steam id of the host to connect to
#[arg(long)]
pub server: Option<Option<SocketAddr>>,
/// Whether or not to open a port when opening the client, for other clients
/// to connect. Does nothing if `server` is set.
pub steam_host_id: Option<String>,
/// Act as steam host
#[arg(long)]
pub host: bool,
pub steam_host: bool,
/// Act as host using netcode, so we have to define our port
#[arg(long)]
pub netcode_host: Option<Option<u16>>,
/// Host address we connect to as a client
#[arg(long)]
pub netcode_client: Option<Option<String>>,
}
#[derive(Resource, Debug)]
pub enum NetConfig {
Singleplayer,
SteamHost,
NetcodeHost { port: u16 },
SteamClient(SteamId),
NetcodeClient(SocketAddr),
}
impl NetConfig {
pub fn is_client(&self) -> bool {
matches!(
self,
NetConfig::SteamClient(_) | NetConfig::NetcodeClient(_)
)
}
pub fn is_host(&self) -> bool {
matches!(self, NetConfig::SteamHost | NetConfig::NetcodeHost { .. })
}
pub fn is_singleplayer(&self) -> bool {
!self.is_client() && !self.is_host()
}
}
impl From<NetworkingConfig> for NetConfig {
fn from(config: NetworkingConfig) -> Self {
match (
config.steam_host,
config.steam_host_id,
config.netcode_host,
config.netcode_client,
) {
(false, None, None, None) => Self::Singleplayer,
(true, None, None, None) => Self::SteamHost,
(false, Some(id), None, None) => Self::SteamClient(parse_steam_id(id)),
(false, None, Some(port), None) => Self::NetcodeHost {
port: port.unwrap_or(31111),
},
(false, None, None, Some(addr)) => Self::NetcodeClient(parse_addr(addr)),
_ => panic!("Invalid configuration"),
}
}
}
fn parse_addr(addr: Option<String>) -> SocketAddr {
addr.map(|addr| addr.parse().ok())
.flatten()
.unwrap_or_else(|| "127.0.0.1:31111".parse().unwrap())
}
fn parse_steam_id(id: String) -> SteamId {
let id: u64 = id.parse().unwrap();
SteamId::from_raw(id)
}

View File

@@ -34,9 +34,9 @@ pub mod utils;
pub mod water;
use crate::{
config::NetworkingConfig,
config::NetConfig,
heads_database::{HeadDatabaseAsset, HeadsDatabase},
protocol::{PlayerId, messages::AssignClientPlayer},
protocol::{PlayerIdCounter, messages::AssignClientPlayer},
tb_entities::SpawnPoint,
};
use avian3d::{PhysicsPlugins, prelude::TransformInterpolation};
@@ -47,6 +47,7 @@ use bevy_common_assets::ron::RonAssetPlugin;
use bevy_replicon::{RepliconPlugins, prelude::ClientId};
use bevy_replicon_renet::RepliconRenetPlugins;
use bevy_sprite3d::Sprite3dPlugin;
use bevy_steamworks::SteamworksEvent;
use bevy_trenchbroom::{
TrenchBroomPlugins, config::TrenchBroomConfig, prelude::TrenchBroomPhysicsPlugin,
};
@@ -148,21 +149,21 @@ pub fn plugin(app: &mut App) {
if cfg!(feature = "client") {
app.add_systems(
OnEnter(GameState::Waiting),
start_solo_client
.run_if(|config: Res<NetworkingConfig>| config.server.is_none() && !config.host),
start_solo_client.run_if(|config: Res<NetConfig>| config.is_singleplayer()),
);
app.add_systems(
OnEnter(GameState::Waiting),
start_listen_server
.run_if(|config: Res<NetworkingConfig>| config.server.is_none() && config.host),
start_listen_server.run_if(|config: Res<NetConfig>| config.is_host()),
);
app.add_systems(
OnEnter(GameState::Waiting),
start_client.run_if(|config: Res<NetworkingConfig>| config.server.is_some()),
start_client.run_if(|config: Res<NetConfig>| config.is_client()),
);
} else {
app.add_systems(OnEnter(GameState::Waiting), start_dedicated_server);
}
app.add_systems(Update, log_steam_events);
}
#[derive(Resource, Reflect, Debug)]
@@ -192,18 +193,33 @@ pub enum GameState {
Playing,
}
fn log_steam_events(events: Option<MessageReader<SteamworksEvent>>) {
let Some(mut events) = events else {
return;
};
for event in events.read() {
let SteamworksEvent::CallbackResult(result) = event;
info!("steam: {:?}", result);
}
}
fn start_solo_client(
commands: Commands,
mut next: ResMut<NextState<GameState>>,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
mut assign_player_id: MessageWriter<AssignClientPlayer>,
mut ids: ResMut<PlayerIdCounter>,
) {
next.set(GameState::Playing);
player::spawn(commands, ClientId::Server, query, heads_db);
ids.reset();
let id = ids.alloc();
assign_player_id.write(AssignClientPlayer(PlayerId { id: 0 }));
player::spawn(commands, ClientId::Server, id, query, heads_db);
assign_player_id.write(AssignClientPlayer(id));
}
fn start_listen_server(
@@ -212,12 +228,16 @@ fn start_listen_server(
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
mut assign_player_id: MessageWriter<AssignClientPlayer>,
mut ids: ResMut<PlayerIdCounter>,
) {
next.set(GameState::Hosting);
player::spawn(commands, ClientId::Server, query, heads_db);
ids.reset();
let id = ids.alloc();
assign_player_id.write(AssignClientPlayer(PlayerId { id: 0 }));
player::spawn(commands, ClientId::Server, id, query, heads_db);
assign_player_id.write(AssignClientPlayer(id));
}
fn start_client(mut next: ResMut<NextState<GameState>>) {

View File

@@ -65,6 +65,7 @@ pub fn plugin(app: &mut App) {
pub fn spawn(
mut commands: Commands,
owner: ClientId,
id: PlayerId,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
) -> Option<Entity> {
@@ -91,7 +92,7 @@ pub fn spawn(
transform,
Visibility::default(),
PlayerCharacterController,
PlayerId { id: 0 },
id,
),
Backpack::default(),
BackpackUiState::default(),

View File

@@ -76,6 +76,23 @@ impl From<NetworkedCollider> for Collider {
}
}
#[derive(Resource, Default)]
pub struct PlayerIdCounter {
next: u8,
}
impl PlayerIdCounter {
pub fn reset(&mut self) {
self.next = 0;
}
pub fn alloc(&mut self) -> PlayerId {
let id = PlayerId { id: self.next };
self.next += 1;
id
}
}
/// 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)]

View File

@@ -74,6 +74,7 @@ pub fn plugin(app: &mut App) {
app.register_type::<TbMapIdCounter>();
app.register_type::<TbMapEntityMapping>();
app.init_resource::<PlayerIdCounter>();
app.init_resource::<PlayerIdMap>();
app.init_resource::<TbMapIdCounter>();
app.init_resource::<TbMapEntityMapping>();

View File

@@ -1,8 +1,10 @@
use crate::{
GameState, global_observer,
GameState,
config::NetConfig,
global_observer,
heads_database::HeadsDatabase,
player::ClientPlayerId,
protocol::{ClientEnteredPlaying, PlayerId, SetGameTick, messages::AssignClientPlayer},
protocol::{ClientEnteredPlaying, PlayerIdCounter, SetGameTick, messages::AssignClientPlayer},
tb_entities::SpawnPoint,
tick::GameTick,
};
@@ -16,12 +18,8 @@ use bevy_replicon::{
};
use bevy_replicon_renet::{
RenetChannelsExt,
netcode::{NetcodeServerTransport, ServerAuthentication},
renet::{ConnectionConfig, RenetServer},
};
use std::{
net::{Ipv4Addr, UdpSocket},
time::SystemTime,
steam::SteamServerTransport,
};
pub fn plugin(app: &mut App) {
@@ -38,12 +36,14 @@ pub fn plugin(app: &mut App) {
fn on_client_playing(
trigger: On<FromClient<ClientEnteredPlaying>>,
commands: Commands,
clients: Query<&ClientPlayerId>,
query: Query<&Transform, With<SpawnPoint>>,
heads_db: Res<HeadsDatabase>,
) -> Result {
info!("client has entered playing gamestate");
crate::player::spawn(commands, trigger.client_id, query, heads_db)
let id = clients.get(trigger.client_id.entity().unwrap()).unwrap();
crate::player::spawn(commands, trigger.client_id, id.0, query, heads_db)
.ok_or("failed to spawn player")?;
Ok(())
@@ -57,6 +57,8 @@ fn open_renet_server(
mut commands: Commands,
channels: Res<RepliconChannels>,
mut next: ResMut<NextState<GameState>>,
steam_client: Option<Res<bevy_steamworks::Client>>,
config: Res<NetConfig>,
) -> Result<(), BevyError> {
info!("opening server");
@@ -69,22 +71,48 @@ fn open_renet_server(
..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)?;
if let NetConfig::SteamHost = *config {
let Some(steam_client) = steam_client else {
return Err("Steam client not found".into());
};
commands.insert_resource(server);
commands.insert_resource(transport);
let steam_config = bevy_replicon_renet::steam::SteamServerConfig {
access_permission: bevy_replicon_renet::steam::AccessPermission::FriendsOnly,
max_clients: 16,
};
info!("hosting a server on port {port}");
let client = (**steam_client).clone();
let transport = SteamServerTransport::new(client, steam_config)?;
commands.queue(|w: &mut World| {
w.insert_resource(server);
w.insert_non_send_resource(transport);
});
info!("hosting server: steam");
} else if let NetConfig::NetcodeHost { port } = *config {
use std::time::SystemTime;
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let socket = std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, port))?;
let server_config = bevy_replicon_renet::netcode::ServerConfig {
current_time,
max_clients: 8,
protocol_id: 0,
authentication: bevy_replicon_renet::netcode::ServerAuthentication::Unsecure,
public_addresses: Default::default(),
};
let transport =
bevy_replicon_renet::netcode::NetcodeServerTransport::new(server_config, socket)?;
commands.insert_resource(server);
commands.insert_resource(transport);
info!("hosting server: netcode on port {port}");
} else {
return Err("Invalid configuration, choose either steam or netcode".into());
}
next.set(GameState::Playing);
@@ -100,16 +128,17 @@ fn on_connected(
game_tick: Res<GameTick>,
mut commands: Commands,
mut assign_id: MessageWriter<ToClients<AssignClientPlayer>>,
mut ids: ResMut<PlayerIdCounter>,
) {
let client = trigger.event_target();
info!("{client} connected to server!");
let id = ClientPlayerId(PlayerId { id: 0 });
commands.entity(client).insert(id);
let id = ids.alloc();
commands.entity(client).insert(ClientPlayerId(id));
assign_id.write(ToClients {
mode: SendMode::Direct(ClientId::Client(trigger.entity)),
message: AssignClientPlayer(id.0),
message: AssignClientPlayer(id),
});
commands.server_trigger(ToClients {

View File

@@ -13,7 +13,7 @@ run *args:
RUST_BACKTRACE=1 cargo r {{ client_args }} -- {{ args }}
server:
RUST_BACKTRACE=1 cargo r {{ server_args }}
RUST_BACKTRACE=1 cargo r {{ server_args }} -- --netcode-host
dbg *args:
RUST_BACKTRACE=1 cargo r {{ client_args }} --features dbg -- {{ args }}
@@ -22,7 +22,7 @@ dbg-server:
RUST_BACKTRACE=1 cargo r {{ server_args }} --features dbg
sort:
cargo sort --check --workspace
cargo sort --workspace
check:
cargo sort --check --workspace