Implement direct P2P session scaffolding #39

Merged
EIREXE merged 2 commits from feat/issue-24-p2p-session into master 2026-09-17 15:04:39 +00:00
6 changed files with 321 additions and 42 deletions
+13 -10
View File
@@ -1,12 +1,15 @@
# Game Test # Shinespark
A Bevy game, editable in the [jackdaw](https://github.com/jbuehler23/jackdaw) editor. Fancy heavily advanced platform fighting game
- `cargo run` plays the game standalone; no editor required. ## Direct P2P Debugging
- Open the folder in jackdaw to author scenes, then hit Play. The
editor builds its own artifacts into `.jackdaw/` (gitignored) and ```text
never modifies your manifest or lockfile. # Process 1 owns player 0 and listens on 7000.
- Gameplay lives in `src/lib.rs` (`GamePlugin`); authored scenes live cargo run -- --debug-match --players 2 --local-port 7000 \
in `assets/*.bsn`. --net-player 0=local --net-player 1=127.0.0.1:7001
- Components you derive `Reflect` on appear in the editor's inspector
automatically; no registration code is needed. # Process 2 owns player 1 and listens on 7001.
cargo run -- --debug-match --players 2 --local-port 7001 \
--net-player 0=127.0.0.1:7000 --net-player 1=local
```
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
FIRST="cargo run -- --debug-match --players 2 --local-port 7000 \
--net-player 0=local --net-player 1=127.0.0.1:7001"
SECOND="cargo run -- --debug-match --players 2 --local-port 7001 \
--net-player 0=127.0.0.1:7000 --net-player 1=local"
(trap 'kill 0' SIGINT; $FIRST & $SECOND)
+45
View File
@@ -1,5 +1,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use clap::Parser; use clap::Parser;
use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser, Debug, Clone, Resource)] #[derive(Parser, Debug, Clone, Resource)]
@@ -15,6 +16,12 @@ pub struct Args {
pub check_distance: usize, pub check_distance: usize,
#[clap(long, default_value_t = 1)] #[clap(long, default_value_t = 1)]
pub players: usize, pub players: usize,
/// UDP port to bind for a direct peer-to-peer session.
#[clap(long, default_value_t = 6969)]
pub local_port: u16,
/// Player ownership declarations in the form HANDLE=local or HANDLE=ADDRESS.
#[clap(long = "net-player", value_name = "HANDLE=local|ADDRESS")]
pub net_players: Vec<String>,
/// Record a replay to this path. /// Record a replay to this path.
#[clap(long, conflicts_with = "play_replay")] #[clap(long, conflicts_with = "play_replay")]
pub record_replay: Option<PathBuf>, pub record_replay: Option<PathBuf>,
@@ -25,3 +32,41 @@ pub struct Args {
#[clap(long, value_enum, default_value_t = crate::replay::ReplayFormat::Binary)] #[clap(long, value_enum, default_value_t = crate::replay::ReplayFormat::Binary)]
pub replay_format: crate::replay::ReplayFormat, pub replay_format: crate::replay::ReplayFormat,
} }
pub fn parse_socket_addr(value: &str) -> Result<SocketAddr, String> {
let address = value
.parse::<SocketAddr>()
.map_err(|_| format!("invalid socket address: {value}"))?;
if address.port() == 0 {
return Err(format!("socket address must use a nonzero port: {value}"));
}
Ok(address)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_network_options() {
let args = Args::try_parse_from([
"game",
"--local-port",
"7000",
"--net-player",
"0=local",
"--net-player",
"1=127.0.0.1:7001",
])
.unwrap();
assert_eq!(args.local_port, 7000);
assert_eq!(args.net_players, ["0=local", "1=127.0.0.1:7001"]);
}
#[test]
fn rejects_invalid_socket_addresses() {
assert!(parse_socket_addr("127.0.0.1:0").is_err());
assert!(parse_socket_addr("not-an-address").is_err());
}
}
+1
View File
@@ -71,6 +71,7 @@ pub enum AppState {
LoadingMatch, LoadingMatch,
PreparingMatch, PreparingMatch,
InMatch, InMatch,
MatchDisconnected,
MatchLoadFailed, MatchLoadFailed,
} }
+44 -7
View File
@@ -8,25 +8,28 @@ use bevy_ggrs::prelude::Session;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
AppState,
args::Args, args::Args,
fighter::{ fighter::{
FighterId, FighterRoster,
animation::AnimKind, animation::AnimKind,
attack::{AttackKind, FighterAttackScriptAssets}, attack::{AttackKind, FighterAttackScriptAssets},
baked_animation::{BAKED_ANIMATION_FPS, BakedFighterAnimations}, baked_animation::{BakedFighterAnimations, BAKED_ANIMATION_FPS},
manifest::{FighterManifest, FighterManifestRegistry}, manifest::{FighterManifest, FighterManifestRegistry},
spawn_fighter, spawn_fighter,
visual::FighterAnimations, visual::FighterAnimations,
FighterId, FighterRoster,
}, },
input::{LocalInputAssignments, LocalInputSource}, input::{LocalInputAssignments, LocalInputSource},
math::{int::FGi32, vec::FGVec2}, math::{int::FGi32, vec::FGVec2},
netcode::{GGRSCfg, session::create_session}, netcode::{
session::{create_session, ConnectionStatus, SessionConfig},
GGRSCfg,
},
scripting::FighterAttackScript, scripting::FighterAttackScript,
stage::{ stage::{
self, self,
manifest::{StageId, StageManifest, StageManifestRegistry}, manifest::{StageId, StageManifest, StageManifestRegistry},
}, },
AppState,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -140,6 +143,7 @@ pub fn initiate_match(
next_state: &mut NextState<AppState>, next_state: &mut NextState<AppState>,
request: PendingMatch, request: PendingMatch,
) { ) {
commands.remove_resource::<crate::netcode::session::MatchDisconnected>();
commands.insert_resource(request); commands.insert_resource(request);
next_state.set(AppState::LoadingMatch); next_state.set(AppState::LoadingMatch);
} }
@@ -176,6 +180,14 @@ pub fn initiate_default_match(
} }
return; return;
} }
let session_config = match SessionConfig::from_args(&args, args.players) {
Ok(config) => config,
Err(error) => {
error!("Invalid network configuration: {error}");
next_state.set(AppState::MatchLoadFailed);
return;
}
};
let players = (0..args.players) let players = (0..args.players)
.map(|handle| MatchPlayer { .map(|handle| MatchPlayer {
handle, handle,
@@ -183,10 +195,21 @@ pub fn initiate_default_match(
}) })
.collect(); .collect();
assigments.push((0, LocalInputSource::Keyboard)); assigments.clear();
let local_handles = session_config
.players
.iter()
.enumerate()
.filter_map(|(handle, player)| {
matches!(player, crate::netcode::session::PlayerConnection::Local).then_some(handle)
});
let mut local_handles = local_handles;
if let Some(handle) = local_handles.next() {
assigments.push((handle, LocalInputSource::Keyboard));
}
for (idx, entity) in gamepads.iter().take(args.players - 1).enumerate() { for (entity, handle) in gamepads.iter().zip(local_handles) {
assigments.push((idx + 1, LocalInputSource::Gamepad(entity))); assigments.push((handle, LocalInputSource::Gamepad(entity)));
} }
initiate_match( initiate_match(
@@ -373,6 +396,16 @@ pub fn prepare_match(
)); ));
} }
let session_config = match SessionConfig::from_args(&args, request.players.len()) {
Ok(config) => config,
Err(error) => {
fail_match(
&mut next_state,
&format!("invalid network configuration: {error}"),
);
return;
}
};
let session = match create_session(&args, request.players.len()) { let session = match create_session(&args, request.players.len()) {
Ok(session) => session, Ok(session) => session,
Err(error) => { Err(error) => {
@@ -418,6 +451,8 @@ pub fn prepare_match(
} }
commands.insert_resource(FighterRoster(fighter_roster)); commands.insert_resource(FighterRoster(fighter_roster));
commands.insert_resource(session_config);
commands.insert_resource(ConnectionStatus::default());
commands.insert_resource::<Session<GGRSCfg>>(session); commands.insert_resource::<Session<GGRSCfg>>(session);
next_state.set(AppState::InMatch); next_state.set(AppState::InMatch);
} }
@@ -448,6 +483,8 @@ pub fn cleanup_match(
commands.entity(fighter).despawn(); commands.entity(fighter).despawn();
} }
commands.remove_resource::<Session<GGRSCfg>>(); commands.remove_resource::<Session<GGRSCfg>>();
commands.remove_resource::<SessionConfig>();
commands.remove_resource::<ConnectionStatus>();
commands.remove_resource::<stage::line::StageCollision>(); commands.remove_resource::<stage::line::StageCollision>();
commands.remove_resource::<MatchAssets>(); commands.remove_resource::<MatchAssets>();
commands.remove_resource::<PendingMatch>(); commands.remove_resource::<PendingMatch>();
+210 -25
View File
@@ -1,49 +1,234 @@
//! Bringing up the GGRS session and reporting what it does. //! Bringing up GGRS sessions and reporting their connection lifecycle.
use std::net::SocketAddr;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_ggrs::{ggrs::UdpNonBlockingSocket, prelude::*}; use bevy_ggrs::{ggrs::UdpNonBlockingSocket, prelude::*};
use thiserror::Error;
use crate::args::Args; use crate::args::{parse_socket_addr, Args};
use crate::netcode::GGRSCfg; use crate::netcode::GGRSCfg;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlayerConnection {
Local,
Remote(SocketAddr),
}
#[derive(Clone, Debug, PartialEq, Eq, Resource)]
pub struct SessionConfig {
pub local_port: u16,
pub players: Vec<PlayerConnection>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SessionConfigError {
#[error("a session must have at least one player")]
NoPlayers,
#[error("local UDP port must be nonzero")]
LocalPortZero,
#[error("invalid network player declaration `{0}`; expected HANDLE=local or HANDLE=ADDRESS")]
InvalidPlayerDeclaration(String),
#[error("network player handle {0} is outside the session player range")]
PlayerHandleOutOfRange(usize),
#[error("network player handle {0} was declared more than once")]
DuplicatePlayerHandle(usize),
#[error("network player {0} has an invalid address: {1}")]
InvalidRemoteAddress(usize, String),
#[error("at least one player must be local")]
NoLocalPlayers,
#[error("network player declarations are not supported in sync-test or replay mode")]
UnsupportedOfflineNetworkConfig,
}
impl SessionConfig {
pub fn from_args(args: &Args, player_count: usize) -> Result<Self, SessionConfigError> {
if player_count == 0 {
return Err(SessionConfigError::NoPlayers);
}
if args.local_port == 0 {
return Err(SessionConfigError::LocalPortZero);
}
if (args.synctest || args.play_replay.is_some()) && !args.net_players.is_empty() {
return Err(SessionConfigError::UnsupportedOfflineNetworkConfig);
}
let mut players = vec![PlayerConnection::Local; player_count];
for declaration in &args.net_players {
let Some((handle, address)) = declaration.split_once('=') else {
return Err(SessionConfigError::InvalidPlayerDeclaration(
declaration.clone(),
));
};
let handle = handle
.parse::<usize>()
.map_err(|_| SessionConfigError::InvalidPlayerDeclaration(declaration.clone()))?;
if handle >= player_count {
return Err(SessionConfigError::PlayerHandleOutOfRange(handle));
}
if !matches!(players[handle], PlayerConnection::Local)
|| args.net_players[..]
.iter()
.take_while(|item| *item != declaration)
.any(|item| {
item.split_once('=')
.and_then(|(handle, _)| handle.parse::<usize>().ok())
== Some(handle)
})
{
return Err(SessionConfigError::DuplicatePlayerHandle(handle));
}
players[handle] = if address == "local" {
PlayerConnection::Local
} else {
PlayerConnection::Remote(
parse_socket_addr(address)
.map_err(|error| SessionConfigError::InvalidRemoteAddress(handle, error))?,
)
};
}
if !players
.iter()
.any(|player| matches!(player, PlayerConnection::Local))
{
return Err(SessionConfigError::NoLocalPlayers);
}
Ok(Self {
local_port: args.local_port,
players,
})
}
}
pub fn create_session(args: &Args, player_count: usize) -> Result<Session<GGRSCfg>> { pub fn create_session(args: &Args, player_count: usize) -> Result<Session<GGRSCfg>> {
let config = SessionConfig::from_args(args, player_count)
.map_err(|error| bevy::ecs::error::BevyError::from(error.to_string()))?;
let mut sess_build = SessionBuilder::<GGRSCfg>::new() let mut sess_build = SessionBuilder::<GGRSCfg>::new()
.with_num_players(player_count)? .with_num_players(player_count)?
.with_check_distance(args.check_distance) .with_check_distance(args.check_distance)
.with_input_delay(args.input_delay); // (optional) set input delay for the local player .with_input_delay(args.input_delay);
for i in 0..player_count { for (handle, player) in config.players.iter().enumerate() {
sess_build = sess_build.add_player(PlayerType::Local, i)?; let player_type = match player {
PlayerConnection::Local => PlayerType::Local,
PlayerConnection::Remote(address) => PlayerType::Remote(*address),
};
sess_build = sess_build.add_player(player_type, handle)?;
} }
if args.synctest { if args.synctest {
println!("Start synctest!"); info!("starting GGRS sync test");
let sess = sess_build.start_synctest_session()?; Ok(Session::SyncTest(sess_build.start_synctest_session()?))
Ok(Session::SyncTest(sess))
} else { } else {
println!("Start p2p!"); info!(
let socket = UdpNonBlockingSocket::bind_to_port(6969)?; "starting direct UDP P2P session on port {}",
let sess = sess_build.start_p2p_session(socket)?; config.local_port
Ok(Session::P2P(sess)) );
let socket = UdpNonBlockingSocket::bind_to_port(config.local_port)?;
Ok(Session::P2P(sess_build.start_p2p_session(socket)?))
} }
} }
pub fn print_events_system(session: Option<ResMut<Session<GGRSCfg>>>) { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Resource)]
let Some(mut session) = session else { pub enum ConnectionStatus {
#[default]
Connecting,
Running,
Interrupted,
}
#[derive(Debug, Clone, PartialEq, Eq, Resource)]
pub struct MatchDisconnected {
pub address: SocketAddr,
}
pub fn print_events_system(
mut commands: Commands,
mut next_state: ResMut<NextState<crate::AppState>>,
mut status: Option<ResMut<ConnectionStatus>>,
session: Option<ResMut<Session<GGRSCfg>>>,
) {
let (Some(mut session), Some(status)) = (session, status.as_deref_mut()) else {
return; return;
}; };
match session.as_mut() { let Session::P2P(session) = session.as_mut() else {
Session::P2P(s) => { return;
for event in s.events() { };
match event { for event in session.events() {
GgrsEvent::Disconnected { .. } | GgrsEvent::NetworkInterrupted { .. } => { match event {
warn!("GGRS event: {event:?}") GgrsEvent::NetworkInterrupted { addr, .. } => {
} warn!("GGRS network interrupted: {addr}");
GgrsEvent::DesyncDetected { .. } => error!("GGRS event: {event:?}"), *status = ConnectionStatus::Interrupted;
_ => info!("GGRS event: {event:?}"),
}
} }
GgrsEvent::NetworkResumed { addr } => {
info!("GGRS network resumed: {addr}");
*status = ConnectionStatus::Running;
}
GgrsEvent::Disconnected { addr } => {
error!("GGRS peer disconnected: {addr}");
commands.insert_resource(MatchDisconnected { address: addr });
next_state.set(crate::AppState::MatchDisconnected);
}
GgrsEvent::Synchronized { .. } => {
info!("GGRS session synchronized");
*status = ConnectionStatus::Running;
}
GgrsEvent::DesyncDetected { .. } => error!("GGRS desync detected: {event:?}"),
_ => info!("GGRS event: {event:?}"),
} }
_ => {} }
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn args(values: &[&str]) -> Args {
Args::try_parse_from(values).unwrap()
}
#[test]
fn defaults_to_local_players() {
let config = SessionConfig::from_args(&args(&["game", "--players", "2"]), 2).unwrap();
assert_eq!(
config.players,
[PlayerConnection::Local, PlayerConnection::Local]
);
}
#[test]
fn configures_remote_players_by_handle() {
let config = SessionConfig::from_args(
&args(&["game", "--players", "2", "--net-player", "1=127.0.0.1:7001"]),
2,
)
.unwrap();
assert!(matches!(config.players[0], PlayerConnection::Local));
assert!(matches!(config.players[1], PlayerConnection::Remote(_)));
}
#[test]
fn validates_handles_and_local_ownership() {
let duplicate = args(&[
"game",
"--players",
"2",
"--net-player",
"1=127.0.0.1:7001",
"--net-player",
"1=127.0.0.1:7002",
]);
assert!(matches!(
SessionConfig::from_args(&duplicate, 2),
Err(SessionConfigError::DuplicatePlayerHandle(1))
));
let no_local = args(&["game", "--players", "1", "--net-player", "0=127.0.0.1:7001"]);
assert!(matches!(
SessionConfig::from_args(&no_local, 1),
Err(SessionConfigError::NoLocalPlayers)
));
} }
} }