Formatting

This commit is contained in:
2026-09-11 22:01:03 +02:00
parent 1be4b470c6
commit f3701bd8b0
17 changed files with 372 additions and 151 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ use bevy_ggrs::RollbackFrameRate;
use enum_cycling::EnumCycle; use enum_cycling::EnumCycle;
use enum_cycling_derive::EnumCycle; use enum_cycling_derive::EnumCycle;
use crate::{player::Player, replay::StopReplayRecording, AppState}; use crate::{AppState, player::Player, replay::StopReplayRecording};
pub const PLAYER_DEBUG_ROW_COUNT: usize = 6; pub const PLAYER_DEBUG_ROW_COUNT: usize = 6;
+1 -1
View File
@@ -64,7 +64,7 @@ pub fn apply_animation(
} }
#[derive( #[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize, Reflect, EnumIter Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize, Reflect, EnumIter,
)] )]
pub enum AnimKind { pub enum AnimKind {
Wait, Wait,
+34 -22
View File
@@ -6,11 +6,18 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
fighter::{ fighter::{
Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterTranslation, animation::{AnimKind, FighterAnimationFrame}, baked_animation::{FighterBoneMatrices, FixedMat4}, hurtbox::{FixedAffineCapsule, FixedCapsule}, manifest::FighterManifest, Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterTranslation,
}, math::{ animation::{AnimKind, FighterAnimationFrame},
baked_animation::{FighterBoneMatrices, FixedMat4},
hurtbox::{FixedAffineCapsule, FixedCapsule},
manifest::FighterManifest,
},
math::{
int::{FGi32, FGi32Ext}, int::{FGi32, FGi32Ext},
vec3::FGVec3, vec3::FGVec3,
}, schedule::GameplaySet, scripting::FighterAttackScript, },
schedule::GameplaySet,
scripting::FighterAttackScript,
}; };
#[derive(Debug, Default, Clone, Serialize, Deserialize)] #[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct FighterDamage(FGi32); pub struct FighterDamage(FGi32);
@@ -207,18 +214,23 @@ pub fn solve_hitboxes(
&FighterTranslation, &FighterTranslation,
&FighterFacingDirection, &FighterFacingDirection,
&mut FighterHitboxes, &mut FighterHitboxes,
&FighterAnimationFrame &FighterAnimationFrame,
)>, )>,
attack_scripts: Res<Assets<FighterAttackScript>>, attack_scripts: Res<Assets<FighterAttackScript>>,
) { ) {
for (matrices, translation, facing_direction, mut hitboxes, animation_frame) in &mut query { for (matrices, translation, facing_direction, mut hitboxes, animation_frame) in &mut query {
hitboxes.active_hitboxes_solved.clear(); hitboxes.active_hitboxes_solved.clear();
if let Some(ref attack_script) = hitboxes.attack_script { if let Some(ref attack_script) = hitboxes.attack_script {
let attack_script = attack_scripts.get(attack_script).expect("Attack script should be valid"); let attack_script = attack_scripts
.get(attack_script)
.expect("Attack script should be valid");
let fighter_trf = translation.get_3d_transform(facing_direction); let fighter_trf = translation.get_3d_transform(facing_direction);
let active_hitboxes = hitboxes.active_hitboxes.clone(); let active_hitboxes = hitboxes.active_hitboxes.clone();
for hitbox_idx in active_hitboxes { for hitbox_idx in active_hitboxes {
let hitbox = attack_script.hitboxes.get(hitbox_idx).expect("Hitbox IDX should be valid"); let hitbox = attack_script
.hitboxes
.get(hitbox_idx)
.expect("Hitbox IDX should be valid");
let matrices = matrices.get(&hitbox.bone); let matrices = matrices.get(&hitbox.bone);
if let None = matrices { if let None = matrices {
@@ -233,23 +245,19 @@ pub fn solve_hitboxes(
if hitbox.start_frame == animation_frame.frame { if hitbox.start_frame == animation_frame.frame {
// Sphere only // Sphere only
hitboxes.active_hitboxes_solved.push( hitboxes.active_hitboxes_solved.push(FixedCapsule {
FixedCapsule { start: curr_hbox_pos,
start: curr_hbox_pos, end: curr_hbox_pos,
end: curr_hbox_pos, radius: hitbox.radius,
radius: hitbox.radius })
}
)
} else { } else {
let prev_hbox_matrix = fighter_trf.mul(prev_bone_matrix); let prev_hbox_matrix = fighter_trf.mul(prev_bone_matrix);
let prev_hbox_pos = prev_hbox_matrix.transform_point(hitbox.offset); let prev_hbox_pos = prev_hbox_matrix.transform_point(hitbox.offset);
hitboxes.active_hitboxes_solved.push( hitboxes.active_hitboxes_solved.push(FixedCapsule {
FixedCapsule { start: prev_hbox_pos,
start: prev_hbox_pos, end: curr_hbox_pos,
end: curr_hbox_pos, radius: hitbox.radius,
radius: hitbox.radius });
}
);
} }
} }
} }
@@ -303,7 +311,10 @@ pub fn solve_hurtboxes(
} }
} }
pub fn intersect_attacks_with_hurtboxes(query: Query<(Entity, &FighterSolvedHurtboxes, &FighterHitboxes)>, attack_scripts: Res<Assets<FighterAttackScript>>) { pub fn intersect_attacks_with_hurtboxes(
query: Query<(Entity, &FighterSolvedHurtboxes, &FighterHitboxes)>,
attack_scripts: Res<Assets<FighterAttackScript>>,
) {
/*for (entity_hurting, _, hitboxes) in query { /*for (entity_hurting, _, hitboxes) in query {
if hitboxes.attack_script.is_none() { if hitboxes.attack_script.is_none() {
continue; continue;
@@ -338,7 +349,8 @@ impl Plugin for FighterAttackPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems( app.add_systems(
GgrsSchedule, GgrsSchedule,
(solve_hurtboxes, solve_hitboxes).chain() (solve_hurtboxes, solve_hitboxes)
.chain()
.after(crate::fighter::baked_animation::update_fighter_bone_matrices) .after(crate::fighter::baked_animation::update_fighter_bone_matrices)
.in_set(GameplaySet::Animation), .in_set(GameplaySet::Animation),
); );
+19 -15
View File
@@ -49,23 +49,27 @@ pub fn air_collide_with_stage(
for (poly_idx, poly) in state_context.stage_collision.stage_polys.iter().enumerate() { for (poly_idx, poly) in state_context.stage_collision.stage_polys.iter().enumerate() {
if let Some(intersect_result) = poly.intersect_ray(ray_segment) { if let Some(intersect_result) = poly.intersect_ray(ray_segment) {
intersect_results.push((AirCollisionWithStageResult { intersect_results.push((
line_id: { AirCollisionWithStageResult {
StageLineID { line_id: {
polygon: poly_idx, StageLineID {
segment: intersect_result.segment_idx, polygon: poly_idx,
} segment: intersect_result.segment_idx,
}
},
hit_position: intersect_result.position,
hit_normal: intersect_result.normal,
}, },
hit_position: intersect_result.position, intersect_result
hit_normal: intersect_result.normal, .position
}, intersect_result.position.distance_squared(ray_segment.point2()))); .distance_squared(ray_segment.point2()),
));
} }
} }
intersect_results.sort_by(| (_, a), (_, b) | { intersect_results.sort_by(|(_, a), (_, b)| a.cmp(b));
a.cmp(b)
});
intersect_results.iter().last().map(|(val, _)| { intersect_results
val.to_owned().clone() .iter()
}) .last()
.map(|(val, _)| val.to_owned().clone())
} }
+2 -11
View File
@@ -73,7 +73,6 @@ impl FixedAffineCapsule {
// current simplex to fixed-point precision, so conservatively collide. // current simplex to fixed-point precision, so conservatively collide.
true true
} }
} }
trait SupportMap { trait SupportMap {
@@ -243,18 +242,10 @@ mod tests {
#[test] #[test]
fn affine_scale_changes_capsule_intersection() { fn affine_scale_changes_capsule_intersection() {
let sphere = FixedAffineCapsule::new( let sphere = FixedAffineCapsule::new(FixedMat4::IDENTITY, FGi32::ZERO, FGi32::lit("1"));
FixedMat4::IDENTITY,
FGi32::ZERO,
FGi32::lit("1"),
);
let mut squashed_transform = FixedMat4::IDENTITY; let mut squashed_transform = FixedMat4::IDENTITY;
squashed_transform.cols[1][1] = FGi32::lit("0.5"); squashed_transform.cols[1][1] = FGi32::lit("0.5");
let squashed = FixedAffineCapsule::new( let squashed = FixedAffineCapsule::new(squashed_transform, FGi32::ZERO, FGi32::lit("1"));
squashed_transform,
FGi32::ZERO,
FGi32::lit("1"),
);
let target = FixedCapsule::new( let target = FixedCapsule::new(
FGVec3::lit("0", "1.4", "0"), FGVec3::lit("0", "1.4", "0"),
FGVec3::lit("0", "1.4", "0"), FGVec3::lit("0", "1.4", "0"),
+14 -3
View File
@@ -30,8 +30,19 @@ pub use motion::{FighterPreviousTranslation, FighterTranslation, FighterVelocity
use crate::{ use crate::{
fighter::{ fighter::{
attack::{FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHurtboxes}, baked_animation::FighterBoneMatrices, hurtbox::FixedCapsule, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations, attack::{FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHurtboxes},
}, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, stage::line::StageCollision, baked_animation::FighterBoneMatrices,
hurtbox::FixedCapsule,
manifest::FighterManifest,
state::{FighterState, fall::FallState},
visual::FighterAnimations,
},
input::FighterInput,
math::{int::FGi32, vec::FGVec2},
player::Player,
schedule::GameplaySet,
scripting::FighterAttackScript,
stage::line::StageCollision,
}; };
use state::state_interrupt_system; use state::state_interrupt_system;
@@ -221,7 +232,7 @@ pub fn spawn_fighter(
FighterHitboxes { FighterHitboxes {
attack_script: None, attack_script: None,
active_hitboxes: vec![], active_hitboxes: vec![],
active_hitboxes_solved: vec![] active_hitboxes_solved: vec![],
}, },
), ),
( (
+4 -1
View File
@@ -71,7 +71,10 @@ pub fn check_interrupt(
.input .input
.has_command(crate::input::FighterCommands::Attack) .has_command(crate::input::FighterCommands::Attack)
{ {
let attack_kind = state_context.input.get_attack_command_type().to_attack_kind_grounded(); let attack_kind = state_context
.input
.get_attack_command_type()
.to_attack_kind_grounded();
Some(FighterState::GroundAttack(GroundAttackState { Some(FighterState::GroundAttack(GroundAttackState {
attack_kind, attack_kind,
grounded_common: grounded_common.clone(), grounded_common: grounded_common.clone(),
+2 -1
View File
@@ -1,5 +1,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_asset_loader::asset_collection::AssetCollection; use bevy_asset_loader::asset_collection::AssetCollection;
use ron_asset_manager::prelude::RonAsset;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{math::int::FGi32, menus::style::FGUiStyle}; use crate::{math::int::FGi32, menus::style::FGUiStyle};
@@ -23,7 +24,7 @@ pub struct InputSettingsCommon {
pub input_buffer_size: u8, pub input_buffer_size: u8,
} }
#[derive(Reflect, Serialize, Deserialize, Resource, Asset, Clone)] #[derive(Reflect, Serialize, Deserialize, Resource, Asset, Clone, RonAsset)]
pub struct GameSettings { pub struct GameSettings {
pub fighter_common: FighterSettingsCommon, pub fighter_common: FighterSettingsCommon,
pub input_common: InputSettingsCommon, pub input_common: InputSettingsCommon,
+14 -8
View File
@@ -177,7 +177,8 @@ pub fn postprocess_input(
{ {
input.frames_in_x_flick_deadzone = 1; input.frames_in_x_flick_deadzone = 1;
} else { } else {
input.frames_in_x_flick_deadzone = input.frames_in_x_flick_deadzone.saturating_add(1); input.frames_in_x_flick_deadzone =
input.frames_in_x_flick_deadzone.saturating_add(1);
} }
} }
@@ -190,7 +191,8 @@ pub fn postprocess_input(
{ {
input.frames_in_y_flick_deadzone = 1; input.frames_in_y_flick_deadzone = 1;
} else { } else {
input.frames_in_y_flick_deadzone = input.frames_in_y_flick_deadzone.saturating_add(1); input.frames_in_y_flick_deadzone =
input.frames_in_y_flick_deadzone.saturating_add(1);
} }
} }
@@ -228,9 +230,11 @@ pub fn postprocess_input(
input.set_lifetime(command_to_set, game_settings.input_common.input_buffer_size); input.set_lifetime(command_to_set, game_settings.input_common.input_buffer_size);
} }
let mut y_flick_detected = y_abs >= axis_threshold && input.frames_in_y_flick_deadzone <= frame_threshold; let mut y_flick_detected =
let mut x_flick_detected = x_abs >= axis_threshold && input.frames_in_x_flick_deadzone <= frame_threshold; y_abs >= axis_threshold && input.frames_in_y_flick_deadzone <= frame_threshold;
let mut x_flick_detected =
x_abs >= axis_threshold && input.frames_in_x_flick_deadzone <= frame_threshold;
// if both flicks are detected, only one wins // if both flicks are detected, only one wins
if x_flick_detected && y_flick_detected { if x_flick_detected && y_flick_detected {
@@ -239,7 +243,7 @@ pub fn postprocess_input(
} }
let attack_detected = input.current_frame.attack && !input.prev_frame.attack; let attack_detected = input.current_frame.attack && !input.prev_frame.attack;
// Tilt/smash up/down // Tilt/smash up/down
if attack_detected { if attack_detected {
let attack_type = if y_flick_detected { let attack_type = if y_flick_detected {
@@ -270,11 +274,13 @@ pub fn postprocess_input(
FighterAttackCommandType::Neutral FighterAttackCommandType::Neutral
}; };
input.set_lifetime(FighterCommands::Attack, game_settings.input_common.input_buffer_size); input.set_lifetime(
FighterCommands::Attack,
game_settings.input_common.input_buffer_size,
);
input.set_attack_command_type(attack_type); input.set_attack_command_type(attack_type);
} }
if x_flick_detected { if x_flick_detected {
input.clear_command(FighterCommands::SmashMoveLeft); input.clear_command(FighterCommands::SmashMoveLeft);
input.clear_command(FighterCommands::SmashMoveRight); input.clear_command(FighterCommands::SmashMoveRight);
+10 -10
View File
@@ -149,19 +149,19 @@ pub fn gc_event_system(
send_axis!(GamepadAxis::LeftStickY, now.left_stick.1); send_axis!(GamepadAxis::LeftStickY, now.left_stick.1);
} }
if prev.right_stick != now.right_stick { if prev.right_stick != now.right_stick {
send_axis!( send_axis!(GamepadAxis::RightStickX, now.right_stick.0);
GamepadAxis::RightStickX, send_axis!(GamepadAxis::RightStickY, now.right_stick.1);
now.right_stick.0
);
send_axis!(
GamepadAxis::RightStickY,
now.right_stick.1
);
} }
if prev.triggers != now.triggers { if prev.triggers != now.triggers {
// The analog triggers are represented as button-axis values, as we have no GamepadAxis to use. // The analog triggers are represented as button-axis values, as we have no GamepadAxis to use.
send_button!(GamepadButton::LeftTrigger2, unsigned_axis_float(now.triggers.0)); send_button!(
send_button!(GamepadButton::RightTrigger2, unsigned_axis_float(now.triggers.1)); GamepadButton::LeftTrigger2,
unsigned_axis_float(now.triggers.0)
);
send_button!(
GamepadButton::RightTrigger2,
unsigned_axis_float(now.triggers.1)
);
} }
macro_rules! digital { macro_rules! digital {
+2 -2
View File
@@ -1,11 +1,11 @@
pub mod bridge; pub mod bridge;
pub mod poll; pub mod poll;
pub use bridge::{GcPort, GcPorts, GC_PORT_COUNT}; pub use bridge::{GC_PORT_COUNT, GcPort, GcPorts};
pub use poll::{GcAdapterSnapshot, GcPortState}; pub use poll::{GcAdapterSnapshot, GcPortState};
use bevy::input::gamepad::{gamepad_connection_system, gamepad_event_processing_system};
use bevy::input::InputSystems; use bevy::input::InputSystems;
use bevy::input::gamepad::{gamepad_connection_system, gamepad_event_processing_system};
use bevy::prelude::*; use bevy::prelude::*;
#[derive(Default)] #[derive(Default)]
+5 -8
View File
@@ -98,12 +98,9 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
.name("gc-adapter-poll".into()) .name("gc-adapter-poll".into())
.spawn(move || { .spawn(move || {
// Flush whatever the adapter had buffered before we started // Flush whatever the adapter had buffered before we started
if let Err(payload) = if let Err(payload) = panic::catch_unwind(AssertUnwindSafe(|| adapter.refresh_inputs()))
panic::catch_unwind(AssertUnwindSafe(|| adapter.refresh_inputs()))
{ {
error!( error!("GameCube Controller Adapter USB refresh panicked on startup: {payload:?}");
"GameCube Controller Adapter USB refresh panicked on startup: {payload:?}"
);
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -112,8 +109,7 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
let mut rate_window_count: u32 = 0; let mut rate_window_count: u32 = 0;
while !thread_shutdown.load(Ordering::Relaxed) { while !thread_shutdown.load(Ordering::Relaxed) {
let result = let result = panic::catch_unwind(AssertUnwindSafe(|| adapter.read_controllers()));
panic::catch_unwind(AssertUnwindSafe(|| adapter.read_controllers()));
let controllers = match result { let controllers = match result {
Ok(Ok(controllers)) => controllers, Ok(Ok(controllers)) => controllers,
@@ -142,7 +138,8 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
let mut states = [GcPortState::default(); 4]; let mut states = [GcPortState::default(); 4];
for i in 0..4 { for i in 0..4 {
states[i] = GcPortState::make_port_state(&controllers[i], adapter.calibration(i)); states[i] =
GcPortState::make_port_state(&controllers[i], adapter.calibration(i));
} }
*thread_ports.lock().unwrap_or_else(|e| e.into_inner()) = states; *thread_ports.lock().unwrap_or_else(|e| e.into_inner()) = states;
+11 -3
View File
@@ -21,8 +21,16 @@ pub fn sample_gamepad(pad: &Gamepad, input_map: &BaseInputMap) -> FighterInputFr
// Digital buttons driving movement isn't used by the // Digital buttons driving movement isn't used by the
// default bindings (the stick covers that), but stay // default bindings (the stick covers that), but stay
// consistent with the keyboard backend if one is added. // consistent with the keyboard backend if one is added.
InputMapAction::MovementXDir(sign) => if pressed { movement.x += sign as f32 }, InputMapAction::MovementXDir(sign) => {
InputMapAction::MovementYDir(sign) => if pressed { movement.y += sign as f32 }, if pressed {
movement.x += sign as f32
}
}
InputMapAction::MovementYDir(sign) => {
if pressed {
movement.y += sign as f32
}
}
} }
} }
// gamepad does not have a separate axis for left and right, so sign here should be 1 // gamepad does not have a separate axis for left and right, so sign here should be 1
@@ -32,7 +40,7 @@ pub fn sample_gamepad(pad: &Gamepad, input_map: &BaseInputMap) -> FighterInputFr
InputMapAction::MovementXDir(_) => movement.x += value, InputMapAction::MovementXDir(_) => movement.x += value,
InputMapAction::MovementYDir(_) => movement.y += value, InputMapAction::MovementYDir(_) => movement.y += value,
_ => {} _ => {}
} }
} }
} }
} }
-1
View File
@@ -33,7 +33,6 @@ pub fn sample_keyboard(key: &ButtonInput<KeyCode>, input_map: &BaseInputMap) ->
InputMapAction::Jump => input_frame.jump = action_strength != FGi32::ZERO, InputMapAction::Jump => input_frame.jump = action_strength != FGi32::ZERO,
InputMapAction::Shield => input_frame.shield = action_strength != FGi32::ZERO, InputMapAction::Shield => input_frame.shield = action_strength != FGi32::ZERO,
InputMapAction::Attack => input_frame.attack = action_strength != FGi32::ZERO, InputMapAction::Attack => input_frame.attack = action_strength != FGi32::ZERO,
} }
} }
+97 -28
View File
@@ -41,6 +41,10 @@ impl LocalInputAssignments {
} }
None None
} }
pub fn get_from_slot(&self, slot: usize) -> Option<&LocalInputSource> {
self.0.iter().find(|(c_slot, _)| slot == *c_slot).map(|(_, source)| source)
}
} }
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
@@ -92,8 +96,6 @@ impl Plugin for FighterInputPlugin {
key: KeyCode::KeyJ, key: KeyCode::KeyJ,
action: InputMapAction::Attack, action: InputMapAction::Attack,
}, },
// Menu // Menu
], ],
gamepad: vec![ gamepad: vec![
@@ -125,33 +127,92 @@ impl Plugin for FighterInputPlugin {
binding: GamepadBinding::Button(GamepadButton::East), binding: GamepadBinding::Button(GamepadButton::East),
action: InputMapAction::Attack, action: InputMapAction::Attack,
}, },
], ],
}) })
.insert_resource(MenuInputMap { .insert_resource(MenuInputMap {
keyboard: vec![ keyboard: vec![
MenuKeyboardInputMapElement { key: KeyCode::ArrowUp, action: MenuInputMapAction::MovementYDir(1) }, MenuKeyboardInputMapElement {
MenuKeyboardInputMapElement { key: KeyCode::ArrowDown, action: MenuInputMapAction::MovementYDir(-1) }, key: KeyCode::ArrowUp,
MenuKeyboardInputMapElement { key: KeyCode::ArrowLeft, action: MenuInputMapAction::MovementXDir(-1) }, action: MenuInputMapAction::MovementYDir(1),
MenuKeyboardInputMapElement { key: KeyCode::ArrowRight, action: MenuInputMapAction::MovementXDir(1) }, },
MenuKeyboardInputMapElement { key: KeyCode::Enter, action: MenuInputMapAction::Accept }, MenuKeyboardInputMapElement {
MenuKeyboardInputMapElement { key: KeyCode::Escape, action: MenuInputMapAction::Back }, key: KeyCode::ArrowDown,
MenuKeyboardInputMapElement { key: KeyCode::Space, action: MenuInputMapAction::Start }, action: MenuInputMapAction::MovementYDir(-1),
MenuKeyboardInputMapElement { key: KeyCode::KeyW, action: MenuInputMapAction::MovementYDir(1) }, },
MenuKeyboardInputMapElement { key: KeyCode::KeyS, action: MenuInputMapAction::MovementYDir(-1) }, MenuKeyboardInputMapElement {
MenuKeyboardInputMapElement { key: KeyCode::KeyA, action: MenuInputMapAction::MovementXDir(-1) }, key: KeyCode::ArrowLeft,
MenuKeyboardInputMapElement { key: KeyCode::KeyD, action: MenuInputMapAction::MovementXDir(1) }, action: MenuInputMapAction::MovementXDir(-1),
},
MenuKeyboardInputMapElement {
key: KeyCode::ArrowRight,
action: MenuInputMapAction::MovementXDir(1),
},
MenuKeyboardInputMapElement {
key: KeyCode::Enter,
action: MenuInputMapAction::Accept,
},
MenuKeyboardInputMapElement {
key: KeyCode::Escape,
action: MenuInputMapAction::Back,
},
MenuKeyboardInputMapElement {
key: KeyCode::Space,
action: MenuInputMapAction::Start,
},
MenuKeyboardInputMapElement {
key: KeyCode::KeyW,
action: MenuInputMapAction::MovementYDir(1),
},
MenuKeyboardInputMapElement {
key: KeyCode::KeyS,
action: MenuInputMapAction::MovementYDir(-1),
},
MenuKeyboardInputMapElement {
key: KeyCode::KeyA,
action: MenuInputMapAction::MovementXDir(-1),
},
MenuKeyboardInputMapElement {
key: KeyCode::KeyD,
action: MenuInputMapAction::MovementXDir(1),
},
], ],
gamepad: vec![ gamepad: vec![
MenuGamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::DPadUp), action: MenuInputMapAction::MovementYDir(1) }, MenuGamepadInputMapElement {
MenuGamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::DPadDown), action: MenuInputMapAction::MovementYDir(-1) }, binding: GamepadBinding::Button(GamepadButton::DPadUp),
MenuGamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::DPadLeft), action: MenuInputMapAction::MovementXDir(-1) }, action: MenuInputMapAction::MovementYDir(1),
MenuGamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::DPadRight), action: MenuInputMapAction::MovementXDir(1) }, },
MenuGamepadInputMapElement { binding: GamepadBinding::Axis(GamepadAxis::LeftStickX, 1), action: MenuInputMapAction::MovementXDir(1) }, MenuGamepadInputMapElement {
MenuGamepadInputMapElement { binding: GamepadBinding::Axis(GamepadAxis::LeftStickY, 1), action: MenuInputMapAction::MovementYDir(1) }, binding: GamepadBinding::Button(GamepadButton::DPadDown),
MenuGamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::South), action: MenuInputMapAction::Accept }, action: MenuInputMapAction::MovementYDir(-1),
MenuGamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::West), action: MenuInputMapAction::Back }, },
MenuGamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::Start), action: MenuInputMapAction::Start }, MenuGamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::DPadLeft),
action: MenuInputMapAction::MovementXDir(-1),
},
MenuGamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::DPadRight),
action: MenuInputMapAction::MovementXDir(1),
},
MenuGamepadInputMapElement {
binding: GamepadBinding::Axis(GamepadAxis::LeftStickX, 1),
action: MenuInputMapAction::MovementXDir(1),
},
MenuGamepadInputMapElement {
binding: GamepadBinding::Axis(GamepadAxis::LeftStickY, 1),
action: MenuInputMapAction::MovementYDir(1),
},
MenuGamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::South),
action: MenuInputMapAction::Accept,
},
MenuGamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::West),
action: MenuInputMapAction::Back,
},
MenuGamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::Start),
action: MenuInputMapAction::Start,
},
], ],
}) })
.init_resource::<LocalInputAssignments>() .init_resource::<LocalInputAssignments>()
@@ -183,16 +244,24 @@ fn read_local_inputs(
gamepads: Query<&Gamepad>, gamepads: Query<&Gamepad>,
assignments: Res<LocalInputAssignments>, assignments: Res<LocalInputAssignments>,
local_players: Res<LocalPlayers>, local_players: Res<LocalPlayers>,
game_settings: Res<GameSettings> game_settings: Res<GameSettings>,
) { ) {
let mut local_inputs = HashMap::new(); let mut local_inputs = HashMap::new();
for handle in &local_players.0 { for handle in &local_players.0 {
let assigned = assignments.0.iter().find(|(player_handle, _)| player_handle == handle); let assigned = assignments
.0
.iter()
.find(|(player_handle, _)| player_handle == handle);
let gamepad = match assigned { let gamepad = match assigned {
Some((_, LocalInputSource::Gamepad(entity))) => gamepads.get(*entity).ok(), Some((_, LocalInputSource::Gamepad(entity))) => gamepads.get(*entity).ok(),
Some((_, LocalInputSource::Keyboard)) => None, Some((_, LocalInputSource::Keyboard)) => None,
None => gc_ports.entities.get(*handle).copied().flatten().and_then(|entity| gamepads.get(entity).ok()), None => gc_ports
.entities
.get(*handle)
.copied()
.flatten()
.and_then(|entity| gamepads.get(entity).ok()),
}; };
let mut input_frame = match assigned { let mut input_frame = match assigned {
@@ -201,8 +270,8 @@ fn read_local_inputs(
.map(|pad| gamepad::sample_gamepad(pad, &input_map)) .map(|pad| gamepad::sample_gamepad(pad, &input_map))
.unwrap_or_default(), .unwrap_or_default(),
None => match gamepad { None => match gamepad {
Some(pad) => gamepad::sample_gamepad(pad, &input_map), Some(pad) => gamepad::sample_gamepad(pad, &input_map),
None => keyboard::sample_keyboard(&key, &input_map), None => keyboard::sample_keyboard(&key, &input_map),
}, },
}; };
-1
View File
@@ -1,4 +1,3 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::math::{ use crate::math::{
+156 -35
View File
@@ -1,13 +1,24 @@
//! Deterministic input recording and playback. //! Deterministic input recording and playback.
use std::{collections::BTreeMap, fs, path::{Path, PathBuf}}; use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use bevy::prelude::*; use bevy::prelude::*;
use bevy_ggrs::{prelude::*, PlayerInputs, RollbackFrameCount, SaveWorld}; use bevy_ggrs::{PlayerInputs, RollbackFrameCount, SaveWorld, prelude::*};
use clap::ValueEnum; use clap::ValueEnum;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{args::Args, input::FighterInputFrame, match_loading::{MatchPlayer, PendingMatch}, netcode::GGRSCfg, schedule::GameplaySet, AppState}; use crate::{
AppState,
args::Args,
input::FighterInputFrame,
match_loading::{MatchPlayer, PendingMatch},
netcode::GGRSCfg,
schedule::GameplaySet,
};
const MAGIC: &[u8; 8] = b"SPRPLY01"; const MAGIC: &[u8; 8] = b"SPRPLY01";
pub const REPLAY_VERSION: u32 = 1; pub const REPLAY_VERSION: u32 = 1;
@@ -79,7 +90,12 @@ impl Plugin for ReplayPlugin {
.in_set(GameplaySet::Input) .in_set(GameplaySet::Input)
.before(crate::input::buffer::postprocess_input), .before(crate::input::buffer::postprocess_input),
) )
.add_systems(SaveWorld, record_checksum.after(SaveWorldSystems::Checksum).before(SaveWorldSystems::Snapshot)) .add_systems(
SaveWorld,
record_checksum
.after(SaveWorldSystems::Checksum)
.before(SaveWorldSystems::Snapshot),
)
.add_systems(OnExit(AppState::InMatch), finish_recording) .add_systems(OnExit(AppState::InMatch), finish_recording)
.add_systems(Update, stop_recording.run_if(in_state(AppState::InMatch))) .add_systems(Update, stop_recording.run_if(in_state(AppState::InMatch)))
.add_systems(Update, finish_playback.run_if(in_state(AppState::InMatch))); .add_systems(Update, finish_playback.run_if(in_state(AppState::InMatch)));
@@ -87,7 +103,8 @@ impl Plugin for ReplayPlugin {
} }
pub fn load(path: &Path) -> Result<Replay, String> { pub fn load(path: &Path) -> Result<Replay, String> {
let bytes = fs::read(path).map_err(|error| format!("cannot read replay {}: {error}", path.display()))?; let bytes = fs::read(path)
.map_err(|error| format!("cannot read replay {}: {error}", path.display()))?;
let replay = if bytes.starts_with(MAGIC) { let replay = if bytes.starts_with(MAGIC) {
if bytes.len() < MAGIC.len() + 4 { if bytes.len() < MAGIC.len() + 4 {
return Err("replay binary header is truncated".into()); return Err("replay binary header is truncated".into());
@@ -96,7 +113,8 @@ pub fn load(path: &Path) -> Result<Replay, String> {
if version != REPLAY_VERSION { if version != REPLAY_VERSION {
return Err(format!("unsupported replay version {version}")); return Err(format!("unsupported replay version {version}"));
} }
bincode::deserialize(&bytes[12..]).map_err(|error| format!("invalid binary replay: {error}"))? bincode::deserialize(&bytes[12..])
.map_err(|error| format!("invalid binary replay: {error}"))?
} else { } else {
serde_json::from_slice(&bytes).map_err(|error| format!("invalid JSON replay: {error}"))? serde_json::from_slice(&bytes).map_err(|error| format!("invalid JSON replay: {error}"))?
}; };
@@ -110,10 +128,14 @@ pub fn save(path: &Path, format: ReplayFormat, replay: &Replay) -> Result<(), St
ReplayFormat::Binary => { ReplayFormat::Binary => {
let mut bytes = MAGIC.to_vec(); let mut bytes = MAGIC.to_vec();
bytes.extend(REPLAY_VERSION.to_le_bytes()); bytes.extend(REPLAY_VERSION.to_le_bytes());
bytes.extend(bincode::serialize(replay).map_err(|error| format!("cannot encode replay: {error}"))?); bytes.extend(
bincode::serialize(replay)
.map_err(|error| format!("cannot encode replay: {error}"))?,
);
bytes bytes
} }
ReplayFormat::Json => serde_json::to_vec_pretty(replay).map_err(|error| format!("cannot encode replay JSON: {error}"))?, ReplayFormat::Json => serde_json::to_vec_pretty(replay)
.map_err(|error| format!("cannot encode replay JSON: {error}"))?,
}; };
let temporary = path.with_extension("replay.tmp"); let temporary = path.with_extension("replay.tmp");
fs::write(&temporary, bytes).map_err(|error| format!("cannot write replay: {error}"))?; fs::write(&temporary, bytes).map_err(|error| format!("cannot write replay: {error}"))?;
@@ -122,7 +144,10 @@ pub fn save(path: &Path, format: ReplayFormat, replay: &Replay) -> Result<(), St
fn validate(replay: &Replay) -> Result<(), String> { fn validate(replay: &Replay) -> Result<(), String> {
if replay.replay_version != REPLAY_VERSION { if replay.replay_version != REPLAY_VERSION {
return Err(format!("unsupported replay version {}", replay.replay_version)); return Err(format!(
"unsupported replay version {}",
replay.replay_version
));
} }
if replay.players_are_invalid() { if replay.players_are_invalid() {
return Err("replay player handles must cover 0..player_count".into()); return Err("replay player handles must cover 0..player_count".into());
@@ -130,10 +155,18 @@ fn validate(replay: &Replay) -> Result<(), String> {
if replay.frames.is_empty() { if replay.frames.is_empty() {
return Err("replay must contain at least one frame".into()); return Err("replay must contain at least one frame".into());
} }
if replay.frames.windows(2).any(|frames| frames[0].frame >= frames[1].frame) { if replay
.frames
.windows(2)
.any(|frames| frames[0].frame >= frames[1].frame)
{
return Err("replay frames must be strictly ordered".into()); return Err("replay frames must be strictly ordered".into());
} }
if replay.frames.iter().any(|frame| frame.inputs.len() != replay.match_config.players.len()) { if replay
.frames
.iter()
.any(|frame| frame.inputs.len() != replay.match_config.players.len())
{
return Err("replay frame input count does not match player count".into()); return Err("replay frame input count does not match player count".into());
} }
Ok(()) Ok(())
@@ -141,7 +174,12 @@ fn validate(replay: &Replay) -> Result<(), String> {
impl Replay { impl Replay {
fn players_are_invalid(&self) -> bool { fn players_are_invalid(&self) -> bool {
let mut handles: Vec<_> = self.match_config.players.iter().map(|player| player.handle).collect(); let mut handles: Vec<_> = self
.match_config
.players
.iter()
.map(|player| player.handle)
.collect();
handles.sort_unstable(); handles.sort_unstable();
handles != (0..handles.len()).collect::<Vec<_>>() handles != (0..handles.len()).collect::<Vec<_>>()
} }
@@ -149,23 +187,44 @@ impl Replay {
fn start_replay(mut commands: Commands, args: Res<Args>, loaded: Option<Res<LoadedReplay>>) { fn start_replay(mut commands: Commands, args: Res<Args>, loaded: Option<Res<LoadedReplay>>) {
let mode = if let Some(replay) = loaded { let mode = if let Some(replay) = loaded {
ReplayMode::Playback { replay: replay.0.clone(), mismatch: false } ReplayMode::Playback {
replay: replay.0.clone(),
mismatch: false,
}
} else if let Some(path) = args.record_replay.clone() { } else if let Some(path) = args.record_replay.clone() {
ReplayMode::Record { path, format: args.replay_format } ReplayMode::Record {
path,
format: args.replay_format,
}
} else { } else {
return; return;
}; };
commands.insert_resource(ReplayRuntime { mode, frames: BTreeMap::new(), checksums: BTreeMap::new(), finished: false }); commands.insert_resource(ReplayRuntime {
mode,
frames: BTreeMap::new(),
checksums: BTreeMap::new(),
finished: false,
});
} }
fn replay_inputs(mut inputs: ResMut<PlayerInputs<GGRSCfg>>, frame: Res<RollbackFrameCount>, runtime: Option<ResMut<ReplayRuntime>>) { fn replay_inputs(
mut inputs: ResMut<PlayerInputs<GGRSCfg>>,
frame: Res<RollbackFrameCount>,
runtime: Option<ResMut<ReplayRuntime>>,
) {
let Some(mut runtime) = runtime else { return }; let Some(mut runtime) = runtime else { return };
match &mut runtime.mode { match &mut runtime.mode {
ReplayMode::Record { .. } => { ReplayMode::Record { .. } => {
runtime.frames.insert(frame.0, inputs.iter().map(|(input, _)| *input).collect()); runtime
.frames
.insert(frame.0, inputs.iter().map(|(input, _)| *input).collect());
} }
ReplayMode::Playback { replay, mismatch } if !*mismatch => { ReplayMode::Playback { replay, mismatch } if !*mismatch => {
let Some(recorded) = replay.frames.iter().find(|recorded| recorded.frame == frame.0) else { let Some(recorded) = replay
.frames
.iter()
.find(|recorded| recorded.frame == frame.0)
else {
error!("Replay has no input for frame {}", frame.0); error!("Replay has no input for frame {}", frame.0);
*mismatch = true; *mismatch = true;
return; return;
@@ -178,14 +237,26 @@ fn replay_inputs(mut inputs: ResMut<PlayerInputs<GGRSCfg>>, frame: Res<RollbackF
} }
} }
fn record_checksum(frame: Res<RollbackFrameCount>, checksum: Res<Checksum>, runtime: Option<ResMut<ReplayRuntime>>) { fn record_checksum(
frame: Res<RollbackFrameCount>,
checksum: Res<Checksum>,
runtime: Option<ResMut<ReplayRuntime>>,
) {
let Some(mut runtime) = runtime else { return }; let Some(mut runtime) = runtime else { return };
if matches!(&runtime.mode, ReplayMode::Record { .. }) { if matches!(&runtime.mode, ReplayMode::Record { .. }) {
runtime.checksums.insert(frame.0, checksum.0); runtime.checksums.insert(frame.0, checksum.0);
} else if let ReplayMode::Playback { replay, mismatch } = &mut runtime.mode { } else if let ReplayMode::Playback { replay, mismatch } = &mut runtime.mode {
if let Some(expected) = replay.checksums.iter().find(|value| value.frame == frame.0).map(|value| value.checksum) { if let Some(expected) = replay
.checksums
.iter()
.find(|value| value.frame == frame.0)
.map(|value| value.checksum)
{
if expected != checksum.0 { if expected != checksum.0 {
error!("Replay desync at frame {}: expected {:X}, got {:X}", frame.0, expected, checksum.0); error!(
"Replay desync at frame {}: expected {:X}, got {:X}",
frame.0, expected, checksum.0
);
*mismatch = true; *mismatch = true;
} }
} }
@@ -205,7 +276,11 @@ fn stop_recording(
finish_recording_inner(&mut commands, runtime, request); finish_recording_inner(&mut commands, runtime, request);
} }
fn finish_recording(mut commands: Commands, runtime: Option<ResMut<ReplayRuntime>>, request: Option<Res<PendingMatch>>) { fn finish_recording(
mut commands: Commands,
runtime: Option<ResMut<ReplayRuntime>>,
request: Option<Res<PendingMatch>>,
) {
finish_recording_inner(&mut commands, runtime, request); finish_recording_inner(&mut commands, runtime, request);
} }
@@ -215,27 +290,59 @@ fn finish_recording_inner(
request: Option<Res<PendingMatch>>, request: Option<Res<PendingMatch>>,
) { ) {
let Some(mut runtime) = runtime else { return }; let Some(mut runtime) = runtime else { return };
let ReplayMode::Record { path, format } = &runtime.mode else { return }; let ReplayMode::Record { path, format } = &runtime.mode else {
if runtime.finished || runtime.frames.is_empty() { return; } return;
};
if runtime.finished || runtime.frames.is_empty() {
return;
}
let Some(request) = request else { return }; let Some(request) = request else { return };
let path = path.clone(); let path = path.clone();
let format = *format; let format = *format;
let replay = Replay { let replay = Replay {
replay_version: REPLAY_VERSION, replay_version: REPLAY_VERSION,
game_version: env!("CARGO_PKG_VERSION").into(), game_version: env!("CARGO_PKG_VERSION").into(),
match_config: ReplayMatch { stage: request.stage, players: request.players.clone() }, match_config: ReplayMatch {
frames: runtime.frames.iter().map(|(&frame, inputs)| ReplayFrame { frame, inputs: inputs.clone() }).collect(), stage: request.stage,
checksums: runtime.checksums.iter().map(|(&frame, &checksum)| ReplayChecksum { frame, checksum }).collect(), players: request.players.clone(),
},
frames: runtime
.frames
.iter()
.map(|(&frame, inputs)| ReplayFrame {
frame,
inputs: inputs.clone(),
})
.collect(),
checksums: runtime
.checksums
.iter()
.map(|(&frame, &checksum)| ReplayChecksum { frame, checksum })
.collect(),
}; };
if let Err(error) = save(&path, format, &replay) { error!("Failed to save replay: {error}"); } if let Err(error) = save(&path, format, &replay) {
error!("Failed to save replay: {error}");
}
runtime.finished = true; runtime.finished = true;
commands.remove_resource::<ReplayRuntime>(); commands.remove_resource::<ReplayRuntime>();
} }
fn finish_playback(mut commands: Commands, mut next_state: ResMut<NextState<AppState>>, frame: Res<RollbackFrameCount>, runtime: Option<Res<ReplayRuntime>>) { fn finish_playback(
mut commands: Commands,
mut next_state: ResMut<NextState<AppState>>,
frame: Res<RollbackFrameCount>,
runtime: Option<Res<ReplayRuntime>>,
) {
let Some(runtime) = runtime else { return }; let Some(runtime) = runtime else { return };
let ReplayMode::Playback { replay, mismatch } = &runtime.mode else { return }; let ReplayMode::Playback { replay, mismatch } = &runtime.mode else {
if *mismatch || replay.frames.last().is_some_and(|last| frame.0 >= last.frame) { return;
};
if *mismatch
|| replay
.frames
.last()
.is_some_and(|last| frame.0 >= last.frame)
{
commands.remove_resource::<ReplayRuntime>(); commands.remove_resource::<ReplayRuntime>();
commands.remove_resource::<LoadedReplay>(); commands.remove_resource::<LoadedReplay>();
commands.insert_resource(ReplayComplete); commands.insert_resource(ReplayComplete);
@@ -246,7 +353,12 @@ fn finish_playback(mut commands: Commands, mut next_state: ResMut<NextState<AppS
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{fighter::FighterId, math::{int::FGi32, vec::FGVec2}, match_loading::MatchPlayer, stage::manifest::StageId}; use crate::{
fighter::FighterId,
match_loading::MatchPlayer,
math::{int::FGi32, vec::FGVec2},
stage::manifest::StageId,
};
fn sample() -> Replay { fn sample() -> Replay {
Replay { Replay {
@@ -254,13 +366,22 @@ mod tests {
game_version: "test".into(), game_version: "test".into(),
match_config: ReplayMatch { match_config: ReplayMatch {
stage: StageId::TestStage, stage: StageId::TestStage,
players: vec![MatchPlayer { handle: 0, fighter: FighterId::TestFighter }], players: vec![MatchPlayer {
handle: 0,
fighter: FighterId::TestFighter,
}],
}, },
frames: vec![ReplayFrame { frames: vec![ReplayFrame {
frame: 1, frame: 1,
inputs: vec![FighterInputFrame { movement: FGVec2::new(FGi32::from_num(1), FGi32::ZERO), ..default() }], inputs: vec![FighterInputFrame {
movement: FGVec2::new(FGi32::from_num(1), FGi32::ZERO),
..default()
}],
}],
checksums: vec![ReplayChecksum {
frame: 1,
checksum: 42,
}], }],
checksums: vec![ReplayChecksum { frame: 1, checksum: 42 }],
} }
} }