From bb238d2cbcba225599230d2c7fe8f0a7ab17a102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Sun, 27 Sep 2026 10:06:55 +0000 Subject: [PATCH] Add spawn points (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.eirexe.moe/EIREXE/shinespark/pulls/71 Co-authored-by: Álex Román Núñez --- src/match_loading.rs | 70 +++++++++++++++++++++++++++++---------- src/match_runtime.rs | 7 +++- src/replay.rs | 17 ++++++++-- src/stage/manifest.rs | 76 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 148 insertions(+), 22 deletions(-) diff --git a/src/match_loading.rs b/src/match_loading.rs index 0dc630e..4d0411f 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -21,7 +21,6 @@ use crate::{ }, input::{LocalInputAssignments, LocalInputSource}, match_runtime::MatchState, - math::{int::FGi32, vec::FGVec2}, netcode::{ GGRSCfg, session::{ConnectionStatus, SessionConfig, create_session}, @@ -277,7 +276,7 @@ pub fn prepare_match( if !valid_player_handles(&config.players) { fail_match( &mut next_state, - "player handles must be unique and cover 0..player_count", + "player handles must be unique and non-empty", ); return; } @@ -287,6 +286,20 @@ pub fn prepare_match( return; } + let stage_handle = stage_registry + .get(config.stage) + .expect("Initialization check should ensure all stages exist"); + let stage_manifest = stage_manifests + .get(&stage_handle) + .expect("Initialization check should ensure stage manifests are kept alive"); + let spawn_positions = match stage_manifest.neutral_spawn_positions(config.players.len()) { + Ok(positions) => positions, + Err(error) => { + fail_match(&mut next_state, &error); + return; + } + }; + if assets.fighters.len() != config.players.len() { fail_match( &mut next_state, @@ -449,7 +462,8 @@ pub fn prepare_match( )); } - let session_config = match SessionConfig::from_args(&args, config.players.len()) { + let session_player_count = player_count_for_handles(&config.players); + let session_config = match SessionConfig::from_args(&args, session_player_count) { Ok(config) => config, Err(error) => { fail_match( @@ -459,7 +473,7 @@ pub fn prepare_match( return; } }; - let session = match create_session(&args, config.players.len()) { + let session = match create_session(&args, session_player_count) { Ok(session) => session, Err(error) => { fail_match( @@ -472,12 +486,6 @@ pub fn prepare_match( let root = commands.spawn((Name::new("Match"), MatchRoot)).id(); - let stage_handle = stage_registry - .get(config.stage) - .expect("Initialization check should ensure all stages exist"); - let stage_manifest = stage_manifests - .get(&stage_handle) - .expect("Initialization check should ensure stage manifests are kept alive"); stage::spawn_stage( &mut commands, root, @@ -487,14 +495,15 @@ pub fn prepare_match( ); let mut fighter_roster = Vec::with_capacity(config.players.len()); - for (index, (player, (scene, animations, attack_scripts, manifest_handle, model_scale, ecb_bone_indices))) in + + for (spawn_index, (player, (scene, animations, attack_scripts, manifest_handle, model_scale, ecb_bone_indices))) in config.players.iter().zip(prepared).enumerate() { - let spawn_x = (index as i32 * 2 + 1 - config.players.len() as i32) * 5; + let spawn_point = spawn_positions[spawn_index]; fighter_roster.push(spawn_fighter( &mut commands, player.handle, - FGVec2::new(FGi32::from_num(spawn_x), FGi32::lit("12.5")), + spawn_point, manifest_handle.clone(), model_scale, animations, @@ -518,12 +527,22 @@ fn fail_match(next_state: &mut NextState, message: &str) { } fn valid_player_handles(players: &[MatchPlayer]) -> bool { - let mut handles = players + if players.is_empty() { + return false; + } + + let mut handles = players.iter().map(|player| player.handle).collect::>(); + handles.sort_unstable(); + handles.windows(2).all(|handles| handles[0] != handles[1]) + && handles.last().is_none_or(|handle| *handle != usize::MAX) +} + +fn player_count_for_handles(players: &[MatchPlayer]) -> usize { + players .iter() .map(|player| player.handle) - .collect::>(); - handles.sort_unstable(); - handles == (0..players.len()).collect::>() + .max() + .map_or(0, |handle| handle + 1) } pub fn cleanup_match( @@ -590,6 +609,23 @@ mod tests { assert!(!valid_player_handles(&players)); } + #[test] + fn player_handles_may_have_gaps() { + let players = [ + MatchPlayer { + handle: 0, + fighter: FighterId::TestFighter, + }, + MatchPlayer { + handle: 2, + fighter: FighterId::TestFighter, + }, + ]; + + assert!(valid_player_handles(&players)); + assert_eq!(player_count_for_handles(&players), 3); + } + #[test] fn match_rules_reject_zero_limits() { assert!( diff --git a/src/match_runtime.rs b/src/match_runtime.rs index 677c943..c2d9875 100644 --- a/src/match_runtime.rs +++ b/src/match_runtime.rs @@ -40,7 +40,12 @@ impl MatchState { Self { elapsed_frames: 0, - players: (0..config.players.len()) + players: (0..config + .players + .iter() + .map(|player| player.handle) + .max() + .map_or(0, |handle| handle + 1)) .map(|_| PlayerMatchState { stocks_remaining, score: 0, diff --git a/src/replay.rs b/src/replay.rs index d3f37c8..1440b90 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -140,7 +140,7 @@ fn validate(replay: &Replay) -> Result<(), String> { )); } if replay.players_are_invalid() { - return Err("replay player handles must cover 0..player_count".into()); + return Err("replay player handles must be unique".into()); } if replay.frames.is_empty() { return Err("replay must contain at least one frame".into()); @@ -169,7 +169,16 @@ fn validate(replay: &Replay) -> Result<(), String> { if replay .frames .iter() - .any(|frame| frame.inputs.len() != replay.match_config.players.len()) + .any(|frame| { + frame.inputs.len() + != replay + .match_config + .players + .iter() + .map(|player| player.handle) + .max() + .map_or(0, |handle| handle + 1) + }) { return Err("replay frame input count does not match player count".into()); } @@ -185,7 +194,9 @@ impl Replay { .map(|player| player.handle) .collect(); handles.sort_unstable(); - handles != (0..handles.len()).collect::>() + handles.is_empty() + || handles.windows(2).any(|handles| handles[0] == handles[1]) + || handles.last() == Some(&usize::MAX) } fn frame(&self, frame: i32) -> Option<&ReplayFrame> { diff --git a/src/stage/manifest.rs b/src/stage/manifest.rs index 5871d6e..e1eb7ff 100644 --- a/src/stage/manifest.rs +++ b/src/stage/manifest.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use bevy_asset_loader::asset_collection::AssetCollection; use ron_asset_manager::prelude::RonAsset; @@ -88,6 +88,10 @@ pub struct StageManifest { pub blast_zone_top_left: FGVec2, #[reflect(ignore)] pub blast_zone_bottom_right: FGVec2, + + #[reflect(ignore)] + pub spawn_points: Vec, + pub neutral_spawn_point_sets: Vec> } #[derive(Resource)] @@ -163,6 +167,13 @@ pub fn prepare_stage_manifests( ); return; } + if let Err(error) = manifest.validate_spawn_points() { + fail( + &mut next_state, + &format!("{:?} has invalid spawn points: {error}", manifest.id), + ); + return; + } if registry.insert(manifest.id, handle.clone()).is_some() { fail( &mut next_state, @@ -187,6 +198,69 @@ pub fn prepare_stage_manifests( } impl StageManifest { + pub fn neutral_spawn_positions(&self, player_count: usize) -> Result, String> { + if player_count == 0 { + return Err("a match must contain at least one player".to_owned()); + } + + let layout = self + .neutral_spawn_point_sets + .get(player_count - 1) + .ok_or_else(|| format!("no spawn layout for {player_count} players"))?; + + if layout.len() != player_count { + return Err(format!( + "the {player_count}-player layout contains {} positions", + layout.len() + )); + } + + layout + .iter() + .map(|&index| { + self.spawn_points.get(index).copied().ok_or_else(|| { + format!("the {player_count}-player layout references spawn point {index}") + }) + }) + .collect() + } + + fn validate_spawn_points(&self) -> Result<(), String> { + if self.spawn_points.is_empty() { + return Err("no spawn points are configured".to_owned()); + } + + if self.neutral_spawn_point_sets.is_empty() { + return Err("no neutral spawn layouts are configured".to_owned()); + } + + for (layout_index, layout) in self.neutral_spawn_point_sets.iter().enumerate() { + let player_count = layout_index + 1; + if layout.len() != player_count { + return Err(format!( + "the {player_count}-player layout contains {} positions", + layout.len() + )); + } + + let mut unique_indices = HashSet::with_capacity(layout.len()); + for &spawn_point_index in layout { + if spawn_point_index >= self.spawn_points.len() { + return Err(format!( + "the {player_count}-player layout references spawn point {spawn_point_index}" + )); + } + if !unique_indices.insert(spawn_point_index) { + return Err(format!( + "the {player_count}-player layout uses spawn point {spawn_point_index} more than once" + )); + } + } + } + + Ok(()) + } + pub fn to_collision(&self) -> StageCollision { let stage_polys: Vec = self .polygons