Compare commits

..
Author SHA1 Message Date
EIREXE 11fc7ba704 Add the ability to debug draw blastzones & update blastzone formatting
CI / build-and-test (pull_request) Failing after 1m30s
CI / production-build (pull_request) Failing after 2m49s
2026-09-26 18:31:26 +02:00
EIREXE 90412fa80d Add the ability to pause singleplayer matches (#68)
CI / build-and-test (push) Successful in 1m54s
CI / production-build (push) Successful in 11m11s
Reviewed-on: #68
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-26 16:25:36 +00:00
EIREXE 94e292026e Fix fighter visuals after landing snap (#67)
CI / build-and-test (push) Successful in 1m47s
CI / production-build (push) Successful in 11m8s
## Summary

- Mark landing position corrections as presentation discontinuities.
- Render and frame fighters from the current position for the snap frame.
- Keep previous translation history unchanged for gameplay collision and attack logic.

Reviewed-on: #67
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-26 15:54:25 +00:00
8 changed files with 189 additions and 18 deletions
+13
View File
@@ -109,6 +109,7 @@ impl FighterDebugRowKind {
enum DebugFlag {
Network,
Ecb,
BlastZones,
StageCollision,
AnimationBones,
AttackHitboxes,
@@ -150,6 +151,10 @@ const FIGHTER_MENU: &[DebugMenuEntry] = &[
label: "ECB",
flag: DebugFlag::Ecb,
},
DebugMenuEntry::Toggle {
label: "Blast Zones",
flag: DebugFlag::BlastZones,
},
DebugMenuEntry::Toggle {
label: "Animation bones",
flag: DebugFlag::AnimationBones,
@@ -243,6 +248,7 @@ pub struct DebugSettings {
pub frame_stepping: bool,
pub network: bool,
pub ecb: bool,
pub blast_zones: bool,
pub stage_collision: bool,
pub animation_bones: bool,
pub attack_hitboxes: bool,
@@ -264,6 +270,7 @@ impl Default for DebugSettings {
frame_stepping: false,
network: false,
ecb: false,
blast_zones: true,
stage_collision: false,
animation_bones: false,
attack_hitboxes: false,
@@ -328,6 +335,7 @@ impl DebugSettings {
match flag {
DebugFlag::Network => self.network,
DebugFlag::Ecb => self.ecb,
DebugFlag::BlastZones => self.blast_zones,
DebugFlag::StageCollision => self.stage_collision,
DebugFlag::AnimationBones => self.animation_bones,
DebugFlag::AttackHitboxes => self.attack_hitboxes,
@@ -342,6 +350,7 @@ impl DebugSettings {
match flag {
DebugFlag::Network => self.network = !self.network,
DebugFlag::Ecb => self.ecb = !self.ecb,
DebugFlag::BlastZones => self.blast_zones = !self.blast_zones,
DebugFlag::StageCollision => self.stage_collision = !self.stage_collision,
DebugFlag::AnimationBones => self.animation_bones = !self.animation_bones,
DebugFlag::AttackHitboxes => self.attack_hitboxes = !self.attack_hitboxes,
@@ -884,6 +893,10 @@ pub fn stage_collision_enabled(settings: Res<DebugSettings>) -> bool {
settings.stage_collision
}
pub fn blast_zones_enabled(settings: Res<DebugSettings>) -> bool {
settings.blast_zones
}
pub fn animation_bones_enabled(settings: Res<DebugSettings>) -> bool {
settings.animation_bones
}
+59 -3
View File
@@ -1,9 +1,14 @@
//! Rollback-simulated state for an active match.
use bevy::input::InputSystems;
use bevy::prelude::*;
use bevy_ggrs::prelude::*;
use bevy_ggrs::{RunGgrsSystems, prelude::*};
use crate::match_loading::{MatchConfig, MatchRules};
use crate::{
AppState,
match_loading::{MatchConfig, MatchRules},
netcode::session::SessionConfig,
};
#[derive(Resource, Clone, Debug, Hash, PartialEq, Eq)]
pub struct MatchState {
@@ -19,6 +24,11 @@ pub struct PlayerMatchState {
pub deaths: u32,
}
#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct PauseState {
pub paused: bool,
}
impl MatchState {
pub fn from_config(config: &MatchConfig) -> Self {
let stocks_remaining = match &config.rules {
@@ -46,10 +56,56 @@ pub struct MatchRuntimePlugin;
impl Plugin for MatchRuntimePlugin {
fn build(&self, app: &mut App) {
app.rollback_resource_with_clone::<MatchState>()
.checksum_resource_with_hash::<MatchState>();
.checksum_resource_with_hash::<MatchState>()
.init_resource::<PauseState>()
.configure_sets(PreUpdate, RunGgrsSystems.run_if(ggrs_should_run))
.add_systems(OnEnter(AppState::InMatch), reset_pause)
.add_systems(OnExit(AppState::InMatch), reset_pause)
.add_systems(
PreUpdate,
toggle_pause
.after(InputSystems)
.before(RunGgrsSystems)
.run_if(in_state(AppState::InMatch)),
);
}
}
pub fn ggrs_should_run(pause: Option<Res<PauseState>>) -> bool {
!pause.is_some_and(|pause| pause.paused)
}
fn toggle_pause(
key: Res<ButtonInput<KeyCode>>,
gamepads: Query<&Gamepad>,
session: Res<SessionConfig>,
mut pause: ResMut<PauseState>,
mut virtual_time: ResMut<Time<Virtual>>,
) {
if session.is_multiplayer() {
return;
}
let pressed = key.just_pressed(KeyCode::Escape)
|| gamepads
.iter()
.any(|gamepad| gamepad.just_pressed(GamepadButton::Start));
if !pressed {
return;
}
pause.paused = !pause.paused;
virtual_time.toggle();
}
fn reset_pause(
mut pause: ResMut<PauseState>,
mut virtual_time: ResMut<Time<Virtual>>,
) {
pause.paused = false;
virtual_time.unpause();
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -226,7 +226,7 @@ fn gamepad_input(
input.accept.accumulate(&status);
}
MenuInputMapAction::Start => {
input.accept.accumulate(&status);
input.start.accumulate(&status);
}
}
}
+17
View File
@@ -42,6 +42,15 @@ pub enum SessionConfigError {
}
impl SessionConfig {
/// Returns whether this match has more than one remote participant.
pub fn is_multiplayer(&self) -> bool {
self.players.len() > 1
|| self
.players
.iter()
.any(|player| matches!(player, PlayerConnection::Remote(_)))
}
pub fn from_args(args: &Args, player_count: usize) -> Result<Self, SessionConfigError> {
if player_count == 0 {
return Err(SessionConfigError::NoPlayers);
@@ -196,6 +205,13 @@ mod tests {
config.players,
[PlayerConnection::Local, PlayerConnection::Local]
);
assert!(config.is_multiplayer());
}
#[test]
fn single_local_player_is_not_multiplayer() {
let config = SessionConfig::from_args(&args(&["game", "--players", "1"]), 1).unwrap();
assert!(!config.is_multiplayer());
}
#[test]
@@ -207,6 +223,7 @@ mod tests {
.unwrap();
assert!(matches!(config.players[0], PlayerConnection::Local));
assert!(matches!(config.players[1], PlayerConnection::Remote(_)));
assert!(config.is_multiplayer());
}
#[test]
+29 -1
View File
@@ -1,6 +1,12 @@
use bevy::{color::palettes::css::RED, prelude::*};
use crate::stage::line::StageCollision;
use crate::{
match_loading::MatchConfig,
stage::{
line::StageCollision,
manifest::{StageManifest, StageManifestRegistry},
},
};
pub fn debug_draw_scene(mut gizmos: Gizmos, stage_collision: Res<StageCollision>) {
for poly in stage_collision.stage_polys.iter() {
@@ -16,3 +22,25 @@ pub fn debug_draw_scene(mut gizmos: Gizmos, stage_collision: Res<StageCollision>
}
}
}
pub fn debug_draw_blast_zones(
mut gizmos: Gizmos,
stage_registry: Res<StageManifestRegistry>,
stage_manifests: Res<Assets<StageManifest>>,
match_config: Res<MatchConfig>,
) {
let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else {
return;
};
let Some(stage_manifest) = stage_manifests.get(&stage_manifest_handle) else {
return;
};
let top_left = stage_manifest.blast_zone_top_left.to_vec2();
let bottom_right = stage_manifest.blast_zone_bottom_right.to_vec2();
let center = (top_left + bottom_right) / 2.0;
let size = (bottom_right - top_left).abs();
gizmos.rect(Vec3::new(center.x, center.y, 0.0), size, bevy::color::palettes::css::BLUE);
}
+7 -6
View File
@@ -7,14 +7,10 @@ use strum::IntoEnumIterator;
use strum_macros::EnumIter;
use crate::{
AppState,
game_settings::GameSettings,
math::vec::FGVec2,
stage::{
AppState, game_settings::GameSettings, math::{int::FGi32, vec::FGVec2}, stage::{
StagePoly,
line::{StageCollision, StagePolyLineSegmentType, StagePolyType},
},
texture_reference::TextureReference,
}, texture_reference::TextureReference,
};
use bevy::prelude::*;
@@ -87,6 +83,11 @@ pub struct StageManifest {
pub icon: TextureReference,
pub camera: StageCameraProfile,
pub polygons: Vec<StagePolyDefinition>,
#[reflect(ignore)]
pub blast_zone_top_left: FGVec2,
#[reflect(ignore)]
pub blast_zone_bottom_right: FGVec2,
}
#[derive(Resource)]
+9 -4
View File
@@ -10,7 +10,7 @@ pub mod scene;
pub use line::{StagePlaneIntersectResult, StagePoly};
use crate::stage::{line::StageCollision, manifest::StageCameraProfile};
use crate::{match_loading::MatchConfig, stage::{line::StageCollision, manifest::StageCameraProfile}};
/// Owns the stage geometry: which scene gets spawned and how it is drawn.
#[derive(Default)]
@@ -20,9 +20,14 @@ impl Plugin for StagePlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
debug::debug_draw_scene
.run_if(resource_exists::<line::StageCollision>)
.run_if(crate::debug_tools::stage_collision_enabled),
(
(debug::debug_draw_scene
.run_if(resource_exists::<line::StageCollision>)
.run_if(crate::debug_tools::stage_collision_enabled)),
(debug::debug_draw_blast_zones
.run_if(resource_exists::<MatchConfig>)
.run_if(crate::debug_tools::blast_zones_enabled),),
),
);
}
}
+54 -3
View File
@@ -3,7 +3,7 @@ use bevy::prelude::*;
use crate::{
AppState,
match_loading::{MatchConfig, MatchRules},
match_runtime::MatchState,
match_runtime::{MatchState, PauseState},
};
pub mod battle_ui;
@@ -11,6 +11,9 @@ pub mod errors;
pub mod fighter_info;
pub mod stock_icon;
#[derive(Component)]
pub struct PauseOverlay;
#[derive(Default)]
pub struct InGameUIPlugin;
@@ -30,8 +33,56 @@ impl Plugin for InGameUIPlugin {
} else {
false
}
}),
}),
),
);
)
.add_systems(Update, sync_pause_overlay);
}
}
pub fn sync_pause_overlay(
pause: Res<PauseState>,
overlay: Query<Entity, With<PauseOverlay>>,
mut commands: Commands,
) {
if !pause.is_changed() {
return;
}
if pause.paused {
if overlay.is_empty() {
commands.spawn((
PauseOverlay,
Node {
position_type: PositionType::Absolute,
left: px(0),
right: px(0),
top: px(0),
bottom: px(0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)),
ZIndex(100),
children![
(
Text::new("Paused"),
TextFont::from_font_size(crate::menus::style::H1_SIZE as f32),
TextColor(Color::WHITE),
),
(
Text::new("Press Esc or Start to resume"),
TextFont::from_font_size(crate::menus::style::TEXT_SIZE as f32),
TextColor(Color::WHITE),
),
],
));
}
} else {
for entity in &overlay {
commands.entity(entity).despawn();
}
}
}