Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77cd5033c3 | ||
|
|
cd9c39b70c | ||
|
|
0d96a8aed8 | ||
|
|
cebf1148c2 | ||
|
|
088d594e97 | ||
|
|
868df95cd4 | ||
|
|
504f63be54 | ||
|
|
8c11a01446 | ||
|
|
30477254e5 |
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
@@ -20,7 +20,13 @@ jobs:
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.cargo/bin/rustup" toolchain install nightly --profile minimal
|
||||
"$HOME/.cargo/bin/rustup" toolchain install nightly-2026-09-14 --profile minimal
|
||||
|
||||
- name: Cache Rust dependencies and build artifacts
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
shared-key: linux-nightly
|
||||
cache-bin: false
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
@@ -39,7 +45,7 @@ jobs:
|
||||
pkg-config
|
||||
|
||||
- name: Build
|
||||
run: cargo build --locked -j 4
|
||||
run: RUSTFLAGS="-D warnings" cargo build --locked -j 4
|
||||
|
||||
- name: Run unit tests
|
||||
run: cargo test --locked --lib -j 4
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2026-09-14"
|
||||
|
||||
+371
-116
@@ -3,16 +3,19 @@
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use bevy::{
|
||||
input::InputSystems,
|
||||
prelude::*,
|
||||
settings::{ReflectSettingsGroup, SaveSettingsDeferred, SettingsGroup},
|
||||
};
|
||||
use bevy_egui::{EguiContexts, EguiPrimaryContextPass, egui};
|
||||
use bevy_ggrs::RollbackFrameRate;
|
||||
use bevy_ggrs::{RollbackFrameRate, RunGgrsSystems};
|
||||
use enum_cycling::EnumCycle;
|
||||
use enum_cycling_derive::EnumCycle;
|
||||
|
||||
use crate::{AppState, player::Player, replay::StopReplayRecording};
|
||||
|
||||
mod save_state;
|
||||
|
||||
pub const PLAYER_DEBUG_ROW_COUNT: usize = 6;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Reflect)]
|
||||
@@ -48,7 +51,7 @@ impl PresentationMotionMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(EnumCycle, Reflect)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumCycle, Reflect)]
|
||||
pub enum GameSpeedDebug {
|
||||
Full,
|
||||
Half,
|
||||
@@ -114,6 +117,7 @@ enum DebugFlag {
|
||||
Hurtboxes,
|
||||
Camera,
|
||||
DrawThroughGeometry,
|
||||
FrameStepping,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -174,6 +178,10 @@ const WORLD_MENU: &[DebugMenuEntry] = &[
|
||||
DebugMenuEntry::GameSpeed {
|
||||
label: "Game speed",
|
||||
},
|
||||
DebugMenuEntry::Toggle {
|
||||
label: "Frame stepping",
|
||||
flag: DebugFlag::FrameStepping,
|
||||
},
|
||||
];
|
||||
|
||||
const DIAGNOSTICS_MENU: &[DebugMenuEntry] = &[DebugMenuEntry::Toggle {
|
||||
@@ -233,6 +241,8 @@ pub struct DebugSettings {
|
||||
selections: Vec<usize>,
|
||||
#[reflect(ignore)]
|
||||
pub player_rows: HashMap<usize, [FighterDebugRowKind; PLAYER_DEBUG_ROW_COUNT]>,
|
||||
#[reflect(ignore)]
|
||||
pub frame_stepping: bool,
|
||||
pub network: bool,
|
||||
pub ecb: bool,
|
||||
pub stage_collision: bool,
|
||||
@@ -253,6 +263,7 @@ impl Default for DebugSettings {
|
||||
pages: vec![DebugMenuPage::Static(ROOT_MENU)],
|
||||
selections: vec![0],
|
||||
player_rows: HashMap::new(),
|
||||
frame_stepping: false,
|
||||
network: false,
|
||||
ecb: false,
|
||||
stage_collision: false,
|
||||
@@ -325,6 +336,7 @@ impl DebugSettings {
|
||||
DebugFlag::Hurtboxes => self.hurtboxes,
|
||||
DebugFlag::DrawThroughGeometry => self.draw_through_geometry,
|
||||
DebugFlag::Camera => self.camera,
|
||||
DebugFlag::FrameStepping => self.frame_stepping,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +352,7 @@ impl DebugSettings {
|
||||
self.draw_through_geometry = !self.draw_through_geometry
|
||||
}
|
||||
DebugFlag::Camera => self.camera = !self.camera,
|
||||
DebugFlag::FrameStepping => self.frame_stepping = !self.frame_stepping,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,9 +369,35 @@ impl DebugSettings {
|
||||
|
||||
pub struct DebugToolsPlugin;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct FrameStepState {
|
||||
requested: bool,
|
||||
time_backup: Option<Time>,
|
||||
last_step_time: Time
|
||||
}
|
||||
|
||||
impl Plugin for DebugToolsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<DebugSettings>()
|
||||
.init_resource::<FrameStepState>()
|
||||
.init_resource::<save_state::DebugSaveState>()
|
||||
.add_systems(OnEnter(AppState::InMatch), save_state::clear)
|
||||
.add_systems(OnExit(AppState::InMatch), save_state::clear)
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
prepare_frame_step
|
||||
.after(InputSystems)
|
||||
.before(RunGgrsSystems)
|
||||
.run_if(in_state(AppState::InMatch)),
|
||||
)
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
save_state::save_or_restore
|
||||
.after(InputSystems)
|
||||
.before(RunGgrsSystems)
|
||||
.run_if(in_state(AppState::InMatch)),
|
||||
)
|
||||
.add_systems(PreUpdate, restore_frame_step_time.after(RunGgrsSystems))
|
||||
.add_systems(
|
||||
Update,
|
||||
(handle_palette_input, sync_gizmo_depth_bias)
|
||||
@@ -387,6 +426,141 @@ fn page_len(page: DebugMenuPage, player_handles: &[usize]) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_settings_save(commands: &mut Commands) {
|
||||
commands.queue(SaveSettingsDeferred(Duration::from_secs_f32(0.1)));
|
||||
}
|
||||
|
||||
fn frame_step_duration(frame_rate: usize) -> Duration {
|
||||
Duration::from_nanos(1_000_000_000u64 / frame_rate as u64)
|
||||
}
|
||||
|
||||
fn prepare_frame_step(
|
||||
settings: Res<DebugSettings>,
|
||||
gamepads: Query<&Gamepad>,
|
||||
key: Res<ButtonInput<KeyCode>>,
|
||||
mut state: ResMut<FrameStepState>,
|
||||
mut time: ResMut<Time>,
|
||||
frame_rate: Res<RollbackFrameRate>,
|
||||
) {
|
||||
if !settings.frame_stepping {
|
||||
state.requested = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let pressed = gamepads
|
||||
.iter()
|
||||
.any(|gamepad| gamepad.pressed(GamepadButton::DPadDown));
|
||||
|
||||
let just_pressed = gamepads
|
||||
.iter()
|
||||
.any(|gamepad| gamepad.just_pressed(GamepadButton::DPadDown));
|
||||
|
||||
let just_pressed = key.just_pressed(KeyCode::F4) || just_pressed;
|
||||
let pressed = key.pressed(KeyCode::F4) || pressed;
|
||||
|
||||
if just_pressed {
|
||||
state.last_step_time = time.clone();
|
||||
}
|
||||
|
||||
let can_repress = pressed && (time.elapsed() - state.last_step_time.elapsed()).as_millis() > 500;
|
||||
|
||||
if !settings.menu_open && (just_pressed || can_repress)
|
||||
{
|
||||
state.requested = true;
|
||||
}
|
||||
|
||||
state.time_backup = Some(*time);
|
||||
let delta = if state.requested {
|
||||
frame_step_duration(**frame_rate)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
time.advance_by(delta);
|
||||
}
|
||||
|
||||
fn restore_frame_step_time(mut state: ResMut<FrameStepState>, mut time: ResMut<Time>) {
|
||||
if let Some(backup) = state.time_backup.take() {
|
||||
*time = backup;
|
||||
state.requested = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_selected(
|
||||
settings: &mut DebugSettings,
|
||||
page_len: usize,
|
||||
player_handles: &[usize],
|
||||
commands: &mut Commands,
|
||||
) {
|
||||
if page_len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
match settings.page() {
|
||||
DebugMenuPage::Static(entries) => match entries[settings.selected()] {
|
||||
DebugMenuEntry::StopRecording { .. } => {
|
||||
commands.insert_resource(StopReplayRecording);
|
||||
}
|
||||
DebugMenuEntry::Toggle { flag, .. } => {
|
||||
settings.toggle_flag(flag);
|
||||
if !matches!(flag, DebugFlag::FrameStepping) {
|
||||
queue_settings_save(commands);
|
||||
}
|
||||
}
|
||||
DebugMenuEntry::Submenu { entries, .. } => {
|
||||
settings.push_page(DebugMenuPage::Static(entries));
|
||||
}
|
||||
DebugMenuEntry::PlayerSlots { .. } => settings.push_page(DebugMenuPage::PlayerList),
|
||||
DebugMenuEntry::MotionSampling { .. } => {
|
||||
settings.cycle_motion_sampling();
|
||||
queue_settings_save(commands);
|
||||
}
|
||||
DebugMenuEntry::GameSpeed { .. } => {
|
||||
commands.insert_resource(RollbackFrameRate(settings.game_speed.frame_rate()));
|
||||
queue_settings_save(commands);
|
||||
}
|
||||
},
|
||||
DebugMenuPage::PlayerList => {
|
||||
let handle = player_handles[settings.selected()];
|
||||
settings.rows_mut(handle);
|
||||
settings.push_page(DebugMenuPage::PlayerRows(handle));
|
||||
}
|
||||
DebugMenuPage::PlayerRows(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_selected_value(settings: &mut DebugSettings, direction: i8, commands: &mut Commands) {
|
||||
match settings.page() {
|
||||
DebugMenuPage::Static(entries) => {
|
||||
if let Some(entry) = entries.get(settings.selected()) {
|
||||
match entry {
|
||||
DebugMenuEntry::MotionSampling { .. } => {
|
||||
settings.cycle_motion_sampling();
|
||||
queue_settings_save(commands);
|
||||
}
|
||||
DebugMenuEntry::GameSpeed { .. } => {
|
||||
settings.game_speed = if direction > 0 {
|
||||
settings.game_speed.up()
|
||||
} else {
|
||||
settings.game_speed.down()
|
||||
};
|
||||
commands
|
||||
.insert_resource(RollbackFrameRate(settings.game_speed.frame_rate()));
|
||||
queue_settings_save(commands);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugMenuPage::PlayerRows(handle) => {
|
||||
let selected = settings.selected();
|
||||
let rows = settings.rows_mut(handle);
|
||||
rows[selected] = rows[selected].cycle(direction);
|
||||
queue_settings_save(commands);
|
||||
}
|
||||
DebugMenuPage::PlayerList => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_palette_input(
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
gamepads: Query<(Entity, &Gamepad)>,
|
||||
@@ -398,6 +572,15 @@ fn handle_palette_input(
|
||||
settings.toggle_flag(DebugFlag::DrawThroughGeometry);
|
||||
}
|
||||
|
||||
if keyboard.just_pressed(KeyCode::F3) {
|
||||
settings.menu_open = !settings.menu_open;
|
||||
settings.controller = None;
|
||||
if settings.menu_open {
|
||||
settings.reset_navigation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !settings.menu_open {
|
||||
for (entity, gamepad) in &gamepads {
|
||||
if gamepad.just_pressed(GamepadButton::Start) && gamepad.pressed(GamepadButton::DPadUp)
|
||||
@@ -411,14 +594,15 @@ fn handle_palette_input(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(controller) = settings.controller else {
|
||||
settings.menu_open = false;
|
||||
return;
|
||||
};
|
||||
let Ok((_, gamepad)) = gamepads.get(controller) else {
|
||||
settings.menu_open = false;
|
||||
settings.controller = None;
|
||||
return;
|
||||
let gamepad = if let Some(controller) = settings.controller {
|
||||
let Ok((_, gamepad)) = gamepads.get(controller) else {
|
||||
settings.menu_open = false;
|
||||
settings.controller = None;
|
||||
return;
|
||||
};
|
||||
Some(gamepad)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let player_handles = player_handles(&players);
|
||||
let page = settings.page();
|
||||
@@ -428,95 +612,51 @@ fn handle_palette_input(
|
||||
*settings.selected_mut() = 0;
|
||||
}
|
||||
|
||||
if gamepad.just_pressed(GamepadButton::DPadUp) && page_len != 0 {
|
||||
if (gamepad.is_some_and(|pad| pad.just_pressed(GamepadButton::DPadUp))
|
||||
|| keyboard.just_pressed(KeyCode::ArrowUp)
|
||||
|| keyboard.just_pressed(KeyCode::KeyW))
|
||||
&& page_len != 0
|
||||
{
|
||||
let selected = settings.selected_mut();
|
||||
*selected = selected.checked_sub(1).unwrap_or(page_len - 1);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::DPadDown) && page_len != 0 {
|
||||
if (gamepad.is_some_and(|pad| pad.just_pressed(GamepadButton::DPadDown))
|
||||
|| keyboard.just_pressed(KeyCode::ArrowDown)
|
||||
|| keyboard.just_pressed(KeyCode::KeyS))
|
||||
&& page_len != 0
|
||||
{
|
||||
let selected = settings.selected_mut();
|
||||
*selected = (*selected + 1) % page_len;
|
||||
}
|
||||
|
||||
let direction = if gamepad.just_pressed(GamepadButton::DPadLeft) {
|
||||
let direction = if gamepad.is_some_and(|pad| pad.just_pressed(GamepadButton::DPadLeft))
|
||||
|| keyboard.just_pressed(KeyCode::ArrowLeft)
|
||||
|| keyboard.just_pressed(KeyCode::KeyA)
|
||||
{
|
||||
Some(-1)
|
||||
} else if gamepad.just_pressed(GamepadButton::DPadRight) {
|
||||
} else if gamepad.is_some_and(|pad| pad.just_pressed(GamepadButton::DPadRight))
|
||||
|| keyboard.just_pressed(KeyCode::ArrowRight)
|
||||
|| keyboard.just_pressed(KeyCode::KeyD)
|
||||
{
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(direction) = direction {
|
||||
match page {
|
||||
DebugMenuPage::Static(entries) => {
|
||||
if let Some(entry) = entries.get(settings.selected()) {
|
||||
match entry {
|
||||
DebugMenuEntry::StopRecording { .. } => {}
|
||||
DebugMenuEntry::MotionSampling { .. } => settings.cycle_motion_sampling(),
|
||||
DebugMenuEntry::GameSpeed { .. } => {
|
||||
if direction > 0 {
|
||||
settings.game_speed = settings.game_speed.up();
|
||||
} else {
|
||||
settings.game_speed = settings.game_speed.down();
|
||||
}
|
||||
|
||||
commands.insert_resource(RollbackFrameRate(
|
||||
settings.game_speed.frame_rate(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugMenuPage::PlayerList => todo!(),
|
||||
DebugMenuPage::PlayerRows(_) => {
|
||||
let DebugMenuPage::PlayerRows(handle) = page else {
|
||||
unreachable!("player row navigation must have a player row page")
|
||||
};
|
||||
let selected = settings.selected();
|
||||
let rows = settings.rows_mut(handle);
|
||||
rows[selected] = rows[selected].cycle(direction);
|
||||
|
||||
commands.queue(SaveSettingsDeferred(Duration::from_secs_f32(0.1)));
|
||||
}
|
||||
}
|
||||
cycle_selected_value(&mut settings, direction, &mut commands);
|
||||
}
|
||||
|
||||
if matches!(page, DebugMenuPage::PlayerRows(_)) {
|
||||
let direction = if gamepad.just_pressed(GamepadButton::DPadLeft) {
|
||||
Some(-1)
|
||||
} else if gamepad.just_pressed(GamepadButton::DPadRight) {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(_direction) = direction {}
|
||||
if (gamepad.is_some_and(|pad| pad.just_pressed(GamepadButton::South))
|
||||
|| keyboard.just_pressed(KeyCode::Enter)
|
||||
|| keyboard.just_pressed(KeyCode::Space))
|
||||
&& page_len != 0
|
||||
{
|
||||
activate_selected(&mut settings, page_len, &player_handles, &mut commands);
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::South) {
|
||||
match page {
|
||||
DebugMenuPage::Static(entries) if page_len != 0 => match entries[settings.selected()] {
|
||||
DebugMenuEntry::StopRecording { .. } => {
|
||||
commands.insert_resource(StopReplayRecording);
|
||||
}
|
||||
DebugMenuEntry::Toggle { flag, .. } => {
|
||||
settings.toggle_flag(flag);
|
||||
commands.queue(SaveSettingsDeferred(Duration::from_secs_f32(0.1)));
|
||||
}
|
||||
DebugMenuEntry::Submenu { entries, .. } => {
|
||||
settings.push_page(DebugMenuPage::Static(entries));
|
||||
}
|
||||
DebugMenuEntry::PlayerSlots { .. } => settings.push_page(DebugMenuPage::PlayerList),
|
||||
DebugMenuEntry::MotionSampling { .. } => settings.cycle_motion_sampling(),
|
||||
DebugMenuEntry::GameSpeed { .. } => {}
|
||||
},
|
||||
DebugMenuPage::PlayerList if page_len != 0 => {
|
||||
let handle = player_handles[settings.selected()];
|
||||
settings.rows_mut(handle);
|
||||
settings.push_page(DebugMenuPage::PlayerRows(handle));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if gamepad.just_pressed(GamepadButton::West) && !settings.pop_page() {
|
||||
if (gamepad.is_some_and(|pad| pad.just_pressed(GamepadButton::West))
|
||||
|| keyboard.just_pressed(KeyCode::Escape))
|
||||
&& !settings.pop_page()
|
||||
{
|
||||
settings.menu_open = false;
|
||||
settings.controller = None;
|
||||
}
|
||||
@@ -538,8 +678,9 @@ fn sync_gizmo_depth_bias(settings: Res<DebugSettings>, mut configs: ResMut<Gizmo
|
||||
|
||||
fn draw_palette(
|
||||
mut contexts: EguiContexts,
|
||||
settings: Res<DebugSettings>,
|
||||
mut settings: ResMut<DebugSettings>,
|
||||
players: Query<&Player>,
|
||||
mut commands: Commands,
|
||||
) -> Result {
|
||||
if !settings.menu_open {
|
||||
return Ok(());
|
||||
@@ -547,7 +688,7 @@ fn draw_palette(
|
||||
|
||||
let player_handles = player_handles(&players);
|
||||
let page = settings.page();
|
||||
let selected = settings.selected();
|
||||
let page_len = page_len(page, &player_handles);
|
||||
egui::Window::new("Debug")
|
||||
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0))
|
||||
.resizable(false)
|
||||
@@ -556,38 +697,113 @@ fn draw_palette(
|
||||
match page {
|
||||
DebugMenuPage::Static(entries) => {
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let marker = if index == selected { ">" } else { " " };
|
||||
match entry {
|
||||
DebugMenuEntry::StopRecording { label } => {
|
||||
ui.label(format!("{marker} {label}"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if index == settings.selected() {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
});
|
||||
match entry {
|
||||
DebugMenuEntry::StopRecording { label } => {
|
||||
if ui.button(*label).clicked() {
|
||||
*settings.selected_mut() = index;
|
||||
activate_selected(
|
||||
&mut settings,
|
||||
page_len,
|
||||
&player_handles,
|
||||
&mut commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
DebugMenuEntry::Toggle { label, flag } => {
|
||||
let mut value = settings.flag(*flag);
|
||||
if ui.checkbox(&mut value, *label).changed() {
|
||||
settings.toggle_flag(*flag);
|
||||
if !matches!(flag, DebugFlag::FrameStepping) {
|
||||
queue_settings_save(&mut commands);
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugMenuEntry::Submenu { label, .. }
|
||||
| DebugMenuEntry::PlayerSlots { label } => {
|
||||
if ui.button(format!("{label} >")).clicked() {
|
||||
*settings.selected_mut() = index;
|
||||
activate_selected(
|
||||
&mut settings,
|
||||
page_len,
|
||||
&player_handles,
|
||||
&mut commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
DebugMenuEntry::MotionSampling { label } => {
|
||||
let old = settings.motion_sampling;
|
||||
egui::ComboBox::from_id_salt(("debug-motion", index))
|
||||
.width(180.0)
|
||||
.selected_text(format!("{label}: {}", old.label()))
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(
|
||||
&mut settings.motion_sampling,
|
||||
PresentationMotionMode::Interpolation,
|
||||
"Interpolation",
|
||||
);
|
||||
ui.selectable_value(
|
||||
&mut settings.motion_sampling,
|
||||
PresentationMotionMode::Extrapolation,
|
||||
"Extrapolation",
|
||||
);
|
||||
});
|
||||
if settings.motion_sampling != old {
|
||||
queue_settings_save(&mut commands);
|
||||
}
|
||||
}
|
||||
DebugMenuEntry::GameSpeed { label } => {
|
||||
let old = settings.game_speed;
|
||||
egui::ComboBox::from_id_salt(("debug-speed", index))
|
||||
.width(180.0)
|
||||
.selected_text(format!("{label}: {}", old.label()))
|
||||
.show_ui(ui, |ui| {
|
||||
for speed in [
|
||||
GameSpeedDebug::Full,
|
||||
GameSpeedDebug::Half,
|
||||
GameSpeedDebug::Quarter,
|
||||
] {
|
||||
ui.selectable_value(
|
||||
&mut settings.game_speed,
|
||||
speed,
|
||||
speed.label(),
|
||||
);
|
||||
}
|
||||
});
|
||||
if settings.game_speed != old {
|
||||
commands.insert_resource(RollbackFrameRate(
|
||||
settings.game_speed.frame_rate(),
|
||||
));
|
||||
queue_settings_save(&mut commands);
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugMenuEntry::Toggle { label, flag } => {
|
||||
let state = if settings.flag(*flag) { "on" } else { "off" };
|
||||
ui.label(format!("{marker} {label}: {state}"));
|
||||
}
|
||||
DebugMenuEntry::Submenu { label, .. }
|
||||
| DebugMenuEntry::PlayerSlots { label } => {
|
||||
ui.label(format!("{marker} {label} >"));
|
||||
}
|
||||
DebugMenuEntry::MotionSampling { label } => {
|
||||
ui.label(format!(
|
||||
"{marker} {label}: {}",
|
||||
settings.motion_sampling.label()
|
||||
));
|
||||
}
|
||||
DebugMenuEntry::GameSpeed { label } => {
|
||||
ui.label(format!(
|
||||
"{marker} {label}: {}",
|
||||
settings.game_speed.label()
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
DebugMenuPage::PlayerList => {
|
||||
for (index, handle) in player_handles.iter().enumerate() {
|
||||
let marker = if index == selected { ">" } else { " " };
|
||||
ui.label(format!("{marker} Player {} >", handle + 1));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if index == settings.selected() {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
});
|
||||
if ui.button(format!("Player {} >", handle + 1)).clicked() {
|
||||
*settings.selected_mut() = index;
|
||||
activate_selected(
|
||||
&mut settings,
|
||||
page_len,
|
||||
&player_handles,
|
||||
&mut commands,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if player_handles.is_empty() {
|
||||
ui.label("No players in match");
|
||||
@@ -600,16 +816,30 @@ fn draw_palette(
|
||||
.unwrap_or([FighterDebugRowKind::None; PLAYER_DEBUG_ROW_COUNT]);
|
||||
ui.label(format!("Player {}", handle + 1));
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
let marker = if index == selected { ">" } else { " " };
|
||||
ui.label(format!("{marker} Row {}: {}", index + 1, row.label()));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if index == settings.selected() {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
});
|
||||
egui::ComboBox::from_id_salt(("debug-row", handle, index))
|
||||
.width(180.0)
|
||||
.selected_text(format!("Row {}: {}", index + 1, row.label()))
|
||||
.show_ui(ui, |ui| {
|
||||
let rows = settings.rows_mut(handle);
|
||||
for kind in FighterDebugRowKind::ALL {
|
||||
ui.selectable_value(&mut rows[index], kind, kind.label());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let controls = if matches!(page, DebugMenuPage::PlayerRows(_)) {
|
||||
"D-pad: select/change B: back"
|
||||
"Arrows/WASD: select/change Escape/B: back"
|
||||
} else {
|
||||
"D-pad: select A: enter/toggle Left/right: change B: back"
|
||||
"Arrows/WASD: select Enter/A: activate Escape/B: back"
|
||||
};
|
||||
ui.label(controls);
|
||||
});
|
||||
@@ -699,4 +929,29 @@ mod tests {
|
||||
mode.cycle();
|
||||
assert_eq!(mode, PresentationMotionMode::Interpolation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_speed_has_expected_frame_rates() {
|
||||
assert_eq!(GameSpeedDebug::Full.frame_rate(), 60);
|
||||
assert_eq!(GameSpeedDebug::Half.frame_rate(), 30);
|
||||
assert_eq!(GameSpeedDebug::Quarter.frame_rate(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_stepping_is_disabled_by_default_and_uses_game_speed_timestep() {
|
||||
let settings = DebugSettings::default();
|
||||
|
||||
assert!(!settings.frame_stepping);
|
||||
assert_eq!(frame_step_duration(60), Duration::from_nanos(16_666_666));
|
||||
assert_eq!(frame_step_duration(30), Duration::from_nanos(33_333_333));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_player_list_does_not_activate() {
|
||||
let mut settings = DebugSettings::default();
|
||||
settings.push_page(DebugMenuPage::PlayerList);
|
||||
|
||||
assert_eq!(page_len(settings.page(), &[]), 0);
|
||||
assert_eq!(settings.selected(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{
|
||||
camera::MatchCamera,
|
||||
fighter::{
|
||||
FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop,
|
||||
FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation,
|
||||
FighterVelocity, Grounded,
|
||||
animation::FighterAnimationFrame,
|
||||
attack::{
|
||||
FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction,
|
||||
FighterReceivedAttacks, FighterSolvedHurtboxes, FighterStaleMoveQueue,
|
||||
},
|
||||
baked_animation::FighterBoneMatrices,
|
||||
state::FighterState,
|
||||
},
|
||||
input::FighterInput,
|
||||
player::Player,
|
||||
stage::line::StageCollision,
|
||||
};
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct DebugSaveState {
|
||||
checkpoint: Option<Checkpoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Checkpoint {
|
||||
fighters: Vec<FighterCheckpoint>,
|
||||
stage_collision: StageCollision,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FighterCheckpoint {
|
||||
entity: Entity,
|
||||
handle: usize,
|
||||
ecb: FighterECB,
|
||||
previous_ecb: crate::fighter::ecb::FighterPreviousECB,
|
||||
velocity: FighterVelocity,
|
||||
translation: FighterTranslation,
|
||||
previous_translation: FighterPreviousTranslation,
|
||||
facing: FighterFacingDirection,
|
||||
previous_facing: FighterPreviousFacingDirection,
|
||||
animation: FighterAnimationFrame,
|
||||
state: FighterState,
|
||||
input: FighterInput,
|
||||
damage: FighterDamage,
|
||||
pending_hit_reaction: FighterPendingHitReaction,
|
||||
hitstop: FighterHitstop,
|
||||
knockback_velocity: FighterKnockbackVelocity,
|
||||
stale_moves: FighterStaleMoveQueue,
|
||||
hitboxes: FighterHitboxes,
|
||||
received_attacks: FighterReceivedAttacks,
|
||||
solved_hurtboxes: FighterSolvedHurtboxes,
|
||||
bone_matrices: FighterBoneMatrices,
|
||||
grounded: bool,
|
||||
}
|
||||
|
||||
pub(super) fn save_or_restore(world: &mut World) {
|
||||
if world.resource::<super::DebugSettings>().menu_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let left = world
|
||||
.query::<&Gamepad>()
|
||||
.iter(world)
|
||||
.any(|gamepad| gamepad.just_pressed(GamepadButton::DPadLeft));
|
||||
let right = world
|
||||
.query::<&Gamepad>()
|
||||
.iter(world)
|
||||
.any(|gamepad| gamepad.just_pressed(GamepadButton::DPadRight));
|
||||
|
||||
if left == right {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(roster) = world
|
||||
.get_resource::<crate::fighter::FighterRoster>()
|
||||
.map(|roster| roster.0.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if roster.len() != 1 {
|
||||
warn!("Save states are only available in single-player matches");
|
||||
return;
|
||||
}
|
||||
|
||||
if left {
|
||||
let Some(stage_collision) = world.get_resource::<StageCollision>().cloned() else {
|
||||
return;
|
||||
};
|
||||
let mut checkpoint_fighters = Vec::with_capacity(roster.len());
|
||||
for entity in roster.iter().copied() {
|
||||
let Some(checkpoint) = capture_fighter(world, entity) else {
|
||||
warn!("Could not save the current fighter state");
|
||||
return;
|
||||
};
|
||||
checkpoint_fighters.push(checkpoint);
|
||||
}
|
||||
world.resource_mut::<DebugSaveState>().checkpoint = Some(Checkpoint {
|
||||
fighters: checkpoint_fighters,
|
||||
stage_collision,
|
||||
});
|
||||
info!("Saved debug game state");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(checkpoint) = world.resource::<DebugSaveState>().checkpoint.clone() else {
|
||||
warn!("No debug game state has been saved");
|
||||
return;
|
||||
};
|
||||
if checkpoint.fighters.len() != roster.len()
|
||||
|| checkpoint
|
||||
.fighters
|
||||
.iter()
|
||||
.zip(roster.iter())
|
||||
.any(|(saved, current)| saved.entity != *current)
|
||||
{
|
||||
warn!("Saved debug game state belongs to another match");
|
||||
return;
|
||||
}
|
||||
|
||||
for saved in &checkpoint.fighters {
|
||||
let Some(player) = world.entity(saved.entity).get::<Player>() else {
|
||||
warn!("Could not restore the saved fighter state");
|
||||
return;
|
||||
};
|
||||
if player.handle != saved.handle {
|
||||
warn!("Saved debug game state belongs to another match");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut entity = world.entity_mut(saved.entity);
|
||||
entity.insert(saved.ecb);
|
||||
entity.insert(saved.previous_ecb);
|
||||
entity.insert(saved.velocity);
|
||||
entity.insert(saved.translation);
|
||||
entity.insert(saved.previous_translation);
|
||||
entity.insert(saved.facing);
|
||||
entity.insert(saved.previous_facing);
|
||||
entity.insert(saved.animation);
|
||||
entity.insert(saved.state.clone());
|
||||
entity.insert(saved.input);
|
||||
entity.insert(saved.damage);
|
||||
entity.insert(saved.pending_hit_reaction.clone());
|
||||
entity.insert(saved.hitstop);
|
||||
entity.insert(saved.knockback_velocity);
|
||||
entity.insert(saved.stale_moves.clone());
|
||||
entity.insert(saved.hitboxes.clone());
|
||||
entity.insert(saved.received_attacks.clone());
|
||||
entity.insert(saved.solved_hurtboxes.clone());
|
||||
entity.insert(saved.bone_matrices.clone());
|
||||
if saved.grounded {
|
||||
entity.insert(Grounded);
|
||||
} else {
|
||||
entity.remove::<Grounded>();
|
||||
}
|
||||
}
|
||||
world.insert_resource(checkpoint.stage_collision);
|
||||
for mut match_camera in world.query::<&mut MatchCamera>().iter_mut(world) {
|
||||
match_camera.needs_snap = true;
|
||||
}
|
||||
info!("Restored debug game state");
|
||||
}
|
||||
|
||||
fn capture_fighter(world: &World, entity: Entity) -> Option<FighterCheckpoint> {
|
||||
let fighter = world.entity(entity);
|
||||
Some(FighterCheckpoint {
|
||||
entity,
|
||||
handle: fighter.get::<Player>()?.handle,
|
||||
ecb: *fighter.get::<FighterECB>()?,
|
||||
previous_ecb: *fighter.get::<crate::fighter::ecb::FighterPreviousECB>()?,
|
||||
velocity: *fighter.get::<FighterVelocity>()?,
|
||||
translation: *fighter.get::<FighterTranslation>()?,
|
||||
previous_translation: *fighter.get::<FighterPreviousTranslation>()?,
|
||||
facing: *fighter.get::<FighterFacingDirection>()?,
|
||||
previous_facing: *fighter.get::<FighterPreviousFacingDirection>()?,
|
||||
animation: *fighter.get::<FighterAnimationFrame>()?,
|
||||
state: fighter.get::<FighterState>()?.clone(),
|
||||
input: *fighter.get::<FighterInput>()?,
|
||||
damage: *fighter.get::<FighterDamage>()?,
|
||||
pending_hit_reaction: fighter.get::<FighterPendingHitReaction>()?.clone(),
|
||||
hitstop: *fighter.get::<FighterHitstop>()?,
|
||||
knockback_velocity: *fighter.get::<FighterKnockbackVelocity>()?,
|
||||
stale_moves: fighter.get::<FighterStaleMoveQueue>()?.clone(),
|
||||
hitboxes: fighter.get::<FighterHitboxes>()?.clone(),
|
||||
received_attacks: fighter.get::<FighterReceivedAttacks>()?.clone(),
|
||||
solved_hurtboxes: fighter.get::<FighterSolvedHurtboxes>()?.clone(),
|
||||
bone_matrices: fighter.get::<FighterBoneMatrices>()?.clone(),
|
||||
grounded: fighter.contains::<Grounded>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn clear(mut save_state: ResMut<DebugSaveState>) {
|
||||
save_state.checkpoint = None;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use bevy::{
|
||||
@@ -517,6 +518,7 @@ struct FighterBakeDeclaration {
|
||||
#[derive(Asset, TypePath)]
|
||||
struct FighterBakeSource {
|
||||
model_bytes: Vec<u8>,
|
||||
buffers: Vec<Vec<u8>>,
|
||||
animations: HashMap<AnimKind, String>,
|
||||
}
|
||||
|
||||
@@ -550,8 +552,31 @@ impl AssetLoader for FighterBakeSourceLoader {
|
||||
.read_asset_bytes(&manifest.model_path)
|
||||
.await
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
|
||||
let gltf = gltf::Gltf::from_slice(&model_bytes)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
let mut buffers = Vec::with_capacity(gltf.buffers().len());
|
||||
for buffer in gltf.buffers() {
|
||||
let bytes = match buffer.source() {
|
||||
gltf::buffer::Source::Uri(uri) => load_context
|
||||
.read_asset_bytes(AssetPath::from_path_buf(
|
||||
Path::new(&manifest.model_path)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join(uri),
|
||||
))
|
||||
.await
|
||||
.map_err(|error| io::Error::other(error.to_string()))?,
|
||||
gltf::buffer::Source::Bin => gltf.blob.clone().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "missing glTF binary blob")
|
||||
})?,
|
||||
};
|
||||
buffers.push(bytes);
|
||||
}
|
||||
|
||||
Ok(FighterBakeSource {
|
||||
model_bytes,
|
||||
buffers,
|
||||
animations: manifest.animations,
|
||||
})
|
||||
}
|
||||
@@ -600,7 +625,7 @@ impl AssetTransformer for FighterBakeTransformer {
|
||||
source: TransformedAsset<Self::AssetInput>,
|
||||
_: &'a Self::Settings,
|
||||
) -> Result<TransformedAsset<Self::AssetOutput>, Self::Error> {
|
||||
let baked = bake_gltf(&source.model_bytes, &source.animations)?;
|
||||
let baked = bake_gltf(&source.model_bytes, &source.buffers, &source.animations)?;
|
||||
Ok(source.replace_asset(baked))
|
||||
}
|
||||
}
|
||||
@@ -656,11 +681,36 @@ impl Plugin for BakedAnimationPlugin {
|
||||
|
||||
fn bake_gltf(
|
||||
bytes: &[u8],
|
||||
buffer_bytes: &[Vec<u8>],
|
||||
animations: &HashMap<AnimKind, String>,
|
||||
) -> io::Result<BakedFighterAnimations> {
|
||||
let (document, buffers, _) = gltf::import_slice(bytes)
|
||||
let gltf = gltf::Gltf::from_slice(bytes)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
bake_gltf_document(document, buffers, animations)
|
||||
let buffers = gltf
|
||||
.buffers()
|
||||
.zip(buffer_bytes)
|
||||
.map(|(buffer, bytes)| {
|
||||
if bytes.len() < buffer.length() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"glTF buffer {} is too short: expected {}, got {}",
|
||||
buffer.index(),
|
||||
buffer.length(),
|
||||
bytes.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(gltf::buffer::Data(bytes.clone()))
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
if buffers.len() != gltf.buffers().len() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"glTF buffer data is incomplete",
|
||||
));
|
||||
}
|
||||
bake_gltf_document(gltf.document, buffers, animations)
|
||||
}
|
||||
|
||||
fn bake_gltf_document(
|
||||
|
||||
Reference in New Issue
Block a user