Add gamecube adapter support

This commit is contained in:
2026-08-27 14:53:42 +02:00
parent e7e744f15e
commit 00d47e9c9b
14 changed files with 719 additions and 68 deletions
-2
View File
@@ -51,8 +51,6 @@ impl FighterAttributes {
let accel = accel + direction * self.base_dash_accel;
let target_vel = input.movement.x * self.max_dash_vel;
info!("ACCEL {}", accel);
(accel, target_vel)
}
+1
View File
@@ -93,6 +93,7 @@ impl FighterStateImpl for JumpSquatState {
state_context.animations.clips[&AnimKind::JumpSquat],
Duration::ZERO,
);
state_context.input.clear_buffer();
}
}
+2 -3
View File
@@ -14,7 +14,7 @@ use crate::input::FighterInputFrame;
use crate::netcode::GGRSCfg;
use crate::player::Player;
#[derive(Clone, Copy, EnumIter)]
#[derive(Clone, Copy, EnumIter, Debug)]
pub enum FighterCommands {
SmashMoveLeft,
SmashMoveRight,
@@ -153,9 +153,8 @@ pub fn postprocess_input(
} else {
FighterCommands::SmashMoveLeft
};
input.set_lifetime(command_to_set, game_settings.input_common.input_buffer_size);
if input.current_frame.movement.x > 0.0 {}
input.set_lifetime(command_to_set, game_settings.input_common.input_buffer_size);
}
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Turns [`GcAdapterSnapshot`] port states into real Bevy `Gamepad`
//! entities, the same way `bevy_gilrs` turns OS gamepad events into them.
use bevy::input::gamepad::{
GamepadAxis, GamepadButton, GamepadConnection, GamepadConnectionEvent, GamepadSettings,
RawGamepadAxisChangedEvent, RawGamepadButtonChangedEvent, RawGamepadEvent,
};
use bevy::prelude::*;
use super::poll::{GcAdapterSnapshot, GcPortState};
/// GameCube adapters report 4 ports.
pub const GC_PORT_COUNT: usize = 4;
/// USB vendor/product ID of the official adapter, reported on the `Gamepad`
/// component so it shows up the same way a real USB gamepad would.
const GC_ADAPTER_VID: u16 = 0x057e;
const GC_ADAPTER_PID: u16 = 0x0337;
/// Marks a `Gamepad` entity as backed by a specific GameCube adapter port
/// (0-3), and is the join key [`crate::input::gamepad::sample_gamepad`]
/// uses to map GGRS handles to ports.
#[derive(Component, Debug, Clone, Copy)]
pub struct GcPort(pub u8);
/// Tracks which entity (if any) represents each of the 4 adapter ports,
/// and the last snapshot seen, so both systems below can diff instead of
/// re-deriving state every frame.
#[derive(Resource, Default)]
pub struct GcPorts {
pub entities: [Option<Entity>; GC_PORT_COUNT],
last: [GcPortState; GC_PORT_COUNT],
}
fn passthrough_gamepad_settings() -> GamepadSettings {
let mut settings = GamepadSettings::default();
for axis in GamepadAxis::all() {
let axis_settings = settings.axis_settings.entry(axis).or_default();
// Order matters: each setter rejects a value that would cross the
// *other* bound it's paired with, so widen the live zones before
// shrinking the dead zones down to zero.
axis_settings.set_livezone_lowerbound(-1.0);
axis_settings.set_livezone_upperbound(1.0);
axis_settings.set_deadzone_lowerbound(0.0);
axis_settings.set_deadzone_upperbound(0.0);
axis_settings.set_threshold(0.0);
}
for button in GamepadButton::all() {
let button_axis_settings = settings.button_axis_settings.entry(button).or_default();
button_axis_settings.low = 0.0;
button_axis_settings.high = 1.0;
button_axis_settings.threshold = 0.0;
}
settings
}
/// Spawns/despawns `Gamepad` entities to match which adapter ports are
/// connected. Must run before `gamepad_connection_system` so the entity it
/// spawns already exists by the time Bevy inserts the `Gamepad` component
/// on it (mirrors what `bevy_gilrs::gilrs_event_system` does).
pub fn gc_connection_system(
mut commands: Commands,
snapshot: Res<GcAdapterSnapshot>,
mut ports: ResMut<GcPorts>,
mut connection_events: MessageWriter<GamepadConnectionEvent>,
) {
let current = snapshot.read();
let ports = &mut *ports;
for i in 0..GC_PORT_COUNT {
let was_connected = ports.last[i].connected;
let is_connected = current[i].connected;
if is_connected && !was_connected {
let entity = ports.entities[i].unwrap_or_else(|| {
let entity = commands
.spawn((GcPort(i as u8), passthrough_gamepad_settings()))
.id();
ports.entities[i] = Some(entity);
entity
});
connection_events.write(GamepadConnectionEvent::new(
entity,
GamepadConnection::Connected {
name: format!("GameCube Controller (Port {})", i + 1),
vendor_id: Some(GC_ADAPTER_VID),
product_id: Some(GC_ADAPTER_PID),
},
));
} else if !is_connected && was_connected {
if let Some(entity) = ports.entities[i] {
connection_events.write(GamepadConnectionEvent::new(
entity,
GamepadConnection::Disconnected,
));
}
}
}
}
/// Converts a GC unsigned trigger byte to the 0.0..=1.0 range
/// `GamepadButton` analog values use.
fn unsigned_axis_float(raw: u8) -> f32 {
(raw as f32) / 255.0
}
/// Diffs the current snapshot against last frame's and writes
/// `RawGamepadEvent`s for anything that changed, which
/// `gamepad_event_processing_system` turns into live `Gamepad` component
/// state. Must run after `gc_connection_system` (so newly-connected ports
/// have an entity/`GamepadSettings`) and before
/// `gamepad_event_processing_system`.
pub fn gc_event_system(
snapshot: Res<GcAdapterSnapshot>,
mut ports: ResMut<GcPorts>,
mut raw_events: MessageWriter<RawGamepadEvent>,
mut axis_events: MessageWriter<RawGamepadAxisChangedEvent>,
mut button_events: MessageWriter<RawGamepadButtonChangedEvent>,
) {
let current = snapshot.read();
for i in 0..GC_PORT_COUNT {
let Some(entity) = ports.entities[i] else {
continue;
};
let prev = &ports.last[i];
let now = &current[i];
if !now.connected {
continue;
}
macro_rules! send_axis {
($axis:expr, $value:expr) => {{
let event = RawGamepadAxisChangedEvent::new(entity, $axis, $value);
axis_events.write(event);
raw_events.write(RawGamepadEvent::Axis(event));
}};
}
macro_rules! send_button {
($button:expr, $value:expr) => {{
let event = RawGamepadButtonChangedEvent::new(entity, $button, $value);
button_events.write(event);
raw_events.write(RawGamepadEvent::Button(event));
}};
}
if prev.left_stick != now.left_stick {
send_axis!(GamepadAxis::LeftStickX, now.left_stick.0);
send_axis!(GamepadAxis::LeftStickY, now.left_stick.1);
}
if prev.right_stick != now.right_stick {
send_axis!(
GamepadAxis::RightStickX,
now.right_stick.0
);
send_axis!(
GamepadAxis::RightStickY,
now.right_stick.1
);
}
if prev.triggers != now.triggers {
// 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!(GamepadButton::RightTrigger2, unsigned_axis_float(now.triggers.1));
}
macro_rules! digital {
($field:ident, $button:expr) => {
if prev.$field != now.$field {
send_button!($button, if now.$field { 1.0 } else { 0.0 });
}
};
}
digital!(a, GamepadButton::South);
digital!(b, GamepadButton::West);
digital!(x, GamepadButton::East);
digital!(y, GamepadButton::North);
digital!(z, GamepadButton::Z);
digital!(start, GamepadButton::Start);
digital!(dpad_up, GamepadButton::DPadUp);
digital!(dpad_down, GamepadButton::DPadDown);
digital!(dpad_left, GamepadButton::DPadLeft);
digital!(dpad_right, GamepadButton::DPadRight);
digital!(left_trigger_digital, GamepadButton::LeftTrigger);
digital!(right_trigger_digital, GamepadButton::RightTrigger);
}
ports.last = current;
}
+35
View File
@@ -0,0 +1,35 @@
pub mod bridge;
pub mod poll;
pub use bridge::{GcPort, GcPorts, GC_PORT_COUNT};
pub use poll::{GcAdapterSnapshot, GcPortState};
use bevy::input::gamepad::{gamepad_connection_system, gamepad_event_processing_system};
use bevy::input::InputSystems;
use bevy::prelude::*;
#[derive(Default)]
pub struct GamecubeAdapterPlugin;
impl Plugin for GamecubeAdapterPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GcPorts>();
let Ok(snapshot) = poll::spawn_poll_thread() else {
// No adapter connected; leave GcPorts empty and skip wiring the
// polling systems.
return;
};
app.insert_resource(snapshot).add_systems(
PreUpdate,
(
bridge::gc_connection_system.before(gamepad_connection_system),
bridge::gc_event_system
.after(gamepad_connection_system)
.before(gamepad_event_processing_system),
)
.in_set(InputSystems),
);
}
}
+167
View File
@@ -0,0 +1,167 @@
use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use bevy::prelude::*;
use gc_adapter_neo::{Controller, GcAdapter};
#[derive(Clone, Copy, PartialEq, Default)]
pub struct GcPortState {
pub connected: bool,
pub a: bool,
pub b: bool,
pub x: bool,
pub y: bool,
pub z: bool,
pub start: bool,
pub dpad_up: bool,
pub dpad_down: bool,
pub dpad_left: bool,
pub dpad_right: bool,
pub left_trigger_digital: bool,
pub right_trigger_digital: bool,
pub left_stick: (f32, f32),
pub right_stick: (f32, f32),
/// Raw trigger bytes, 0 is released, 255 is fully pressed.
pub triggers: (u8, u8),
}
impl GcPortState {
fn make_port_state(c: &Controller, cal: &gc_adapter_neo::PortCalibration) -> Self {
if !c.connected() {
return Self::default();
}
let b = &c.buttons;
Self {
connected: true,
a: b.a(),
b: b.b(),
x: b.x(),
y: b.y(),
z: b.z(),
start: b.start(),
dpad_up: b.dpad_up(),
dpad_down: b.dpad_down(),
dpad_left: b.dpad_left(),
dpad_right: b.dpad_right(),
left_trigger_digital: b.left_trigger(),
right_trigger_digital: b.right_trigger(),
left_stick: cal.left_stick(&c),
right_stick: cal.right_stick(&c),
triggers: (c.triggers.left.raw(), c.triggers.right.raw()),
}
}
}
/// Shared, `Send + Sync` handle to the latest snapshot from the poll
/// thread. Safe to store as an ordinary [`Resource`] (no `NonSend` needed)
/// because the `GcAdapter` itself never leaves its thread.
#[derive(Resource, Clone)]
pub struct GcAdapterSnapshot {
ports: Arc<Mutex<[GcPortState; 4]>>,
shutdown: Arc<AtomicBool>,
}
impl GcAdapterSnapshot {
pub fn read(&self) -> [GcPortState; 4] {
*self.ports.lock().unwrap_or_else(|e| e.into_inner())
}
}
impl Drop for GcAdapterSnapshot {
fn drop(&mut self) {
// Only actually signals shutdown once the last clone (this resource
// is never cloned in practice, but be defensive) is dropped.
if Arc::strong_count(&self.shutdown) <= 1 {
self.shutdown.store(true, Ordering::Relaxed);
}
}
}
/// Connects to the first GameCube adapter found on the USB bus and starts
/// polling it on a dedicated thread. Returns `None` (after logging a
/// warning) if no adapter is plugged in
pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError> {
let mut adapter = match GcAdapter::from_usb()? {
Some(adapter) => adapter,
None => {
warn!("No GameCube Controller Adapter found on USB; gamepad input disabled.");
return Err(gc_adapter_neo::UsbError::Disconnected);
}
};
let ports = Arc::new(Mutex::new([GcPortState::default(); 4]));
let shutdown = Arc::new(AtomicBool::new(false));
let thread_ports = ports.clone();
let thread_shutdown = shutdown.clone();
std::thread::Builder::new()
.name("gc-adapter-poll".into())
.spawn(move || {
// Flush whatever the adapter had buffered before we started
if let Err(payload) =
panic::catch_unwind(AssertUnwindSafe(|| adapter.refresh_inputs()))
{
error!(
"GameCube Controller Adapter USB refresh panicked on startup: {payload:?}"
);
}
#[cfg(debug_assertions)]
let mut rate_window_start = std::time::Instant::now();
#[cfg(debug_assertions)]
let mut rate_window_count: u32 = 0;
while !thread_shutdown.load(Ordering::Relaxed) {
let result =
panic::catch_unwind(AssertUnwindSafe(|| adapter.read_controllers()));
let controllers = match result {
Ok(Ok(controllers)) => controllers,
// Ignore this, for some reason we get a bunch of these when we start reading
Ok(Err(
gc_adapter_neo::AdapterError::Parse(_)
| gc_adapter_neo::AdapterError::ShortRead(_),
)) => continue,
Ok(Err(gc_adapter_neo::AdapterError::Usb(err))) => {
error!(
"GameCube Controller Adapter USB read failed ({err:?}); stopping \
polling. Gamepad input will stop updating until the game is \
restarted."
);
break;
}
Err(payload) => {
error!(
"GameCube Controller Adapter USB read panicked: {payload:?}; \
stopping polling. Gamepad input will stop updating until the \
game is restarted."
);
break;
}
};
let mut states = [GcPortState::default(); 4];
for i in 0..4 {
states[i] = GcPortState::make_port_state(&controllers[i], adapter.calibration(i));
}
*thread_ports.lock().unwrap_or_else(|e| e.into_inner()) = states;
#[cfg(debug_assertions)]
{
rate_window_count += 1;
let elapsed = rate_window_start.elapsed();
if elapsed >= std::time::Duration::from_secs(1) {
debug!(
"GameCube adapter snapshot publish rate: {:.1} Hz",
rate_window_count as f64 / elapsed.as_secs_f64()
);
rate_window_count = 0;
rate_window_start = std::time::Instant::now();
}
}
}
})
.expect("failed to spawn gc-adapter poll thread");
Ok(GcAdapterSnapshot { ports, shutdown })
}
+54
View File
@@ -0,0 +1,54 @@
use bevy::math::Vec2;
use bevy::prelude::*;
use crate::game_settings::GameSettings;
use crate::input::map::GamepadBinding;
use crate::input::{BaseInputMap, FighterInputFrame, InputMapAction};
use crate::math::int::FGi32;
use crate::math::vec::FGVec2;
pub fn sample_gamepad(pad: &Gamepad, input_map: &BaseInputMap, game_settings: &GameSettings) -> FighterInputFrame {
let mut input_frame = FighterInputFrame::default();
let mut movement = Vec2::ZERO;
for gamepad_element in &input_map.gamepad {
match &gamepad_element.binding {
GamepadBinding::Button(button) => {
let pressed = pad.pressed(*button);
match gamepad_element.action {
InputMapAction::Jump => input_frame.jump |= pressed,
InputMapAction::Shield => input_frame.shield |= pressed,
// Digital buttons driving movement isn't used by the
// default bindings (the stick covers that), but stay
// consistent with the keyboard backend if one is added.
InputMapAction::MovementXDir(sign) 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
GamepadBinding::Axis(axis, sign) => {
let value = pad.get(*axis).unwrap_or(0.0) * (*sign as f32);
match gamepad_element.action {
InputMapAction::MovementXDir(_) => movement.x += value,
InputMapAction::MovementYDir(_) => movement.y += value,
_ => {}
}
}
}
}
if movement.x.abs() < game_settings.input_common.stick_deadzone {
movement.x = 0.0;
}
if movement.y.abs() < game_settings.input_common.stick_deadzone {
movement.y = 0.0;
}
if movement.length_squared() > 1.0 {
movement = movement.normalize();
}
input_frame.movement = FGVec2::new(FGi32::from_num(movement.x), FGi32::from_num(movement.y));
input_frame
}
+29 -45
View File
@@ -1,57 +1,41 @@
//! Keyboard backend: samples the keys into the local input frames GGRS sends
//! over the wire.
//! Keyboard backend: samples the keys into a [`FighterInputFrame`].
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy_ggrs::{LocalInputs, LocalPlayers};
use crate::input::{BaseInputMap, FighterInputFrame, InputActionState, InputMapAction};
use crate::math::int::FGi32;
use crate::netcode::GGRSCfg;
pub fn preprocess_keyboard_input(
mut commands: Commands,
key: Res<ButtonInput<KeyCode>>,
input_map: Res<BaseInputMap>,
local_players: Res<LocalPlayers>,
) {
// Keyboard input is only for player 0
pub fn sample_keyboard(key: &ButtonInput<KeyCode>, input_map: &BaseInputMap) -> FighterInputFrame {
let mut input_frame = FighterInputFrame::default();
for keyboard_elment in &input_map.keyboard {
let action_state = if key.just_pressed(keyboard_elment.key) {
InputActionState::JustPressed
} else if key.just_released(keyboard_elment.key) {
InputActionState::JustReleased
} else if key.pressed(keyboard_elment.key) {
InputActionState::Pressed
} else {
InputActionState::Released
};
let mut local_inputs = HashMap::new();
let action_strength = match action_state {
InputActionState::JustPressed | InputActionState::Pressed => FGi32::ONE,
InputActionState::JustReleased | InputActionState::Released => FGi32::ZERO,
};
for handle in &local_players.0 {
let mut input_frame = FighterInputFrame::default();
for keyboard_elment in &input_map.keyboard {
let action_state = if key.just_pressed(keyboard_elment.key) {
InputActionState::JustPressed
} else if key.just_released(keyboard_elment.key) {
InputActionState::JustReleased
} else if key.pressed(keyboard_elment.key) {
InputActionState::Pressed
} else {
InputActionState::Released
};
let action_strength = match action_state {
InputActionState::JustPressed | InputActionState::Pressed => FGi32::ONE,
InputActionState::JustReleased | InputActionState::Released => FGi32::ZERO,
};
match keyboard_elment.action {
InputMapAction::MovementXDir(sign) => {
input_frame.movement.x += (sign) * action_strength
}
InputMapAction::MovementYDir(sign) => {
input_frame.movement.y += (sign) * action_strength
}
InputMapAction::Jump => input_frame.jump = action_strength != FGi32::ZERO,
InputMapAction::Shield => input_frame.shield = action_strength != FGi32::ZERO,
match keyboard_elment.action {
InputMapAction::MovementXDir(sign) => {
input_frame.movement.x += (sign) * action_strength
}
InputMapAction::MovementYDir(sign) => {
input_frame.movement.y += (sign) * action_strength
}
InputMapAction::Jump => input_frame.jump = action_strength != FGi32::ZERO,
InputMapAction::Shield => input_frame.shield = action_strength != FGi32::ZERO,
}
input_frame.movement = input_frame.movement.normalize_or_zero();
input_frame.directional_attack = input_frame.directional_attack.normalize_or_zero();
local_inputs.insert(*handle, input_frame);
}
commands.insert_resource(LocalInputs::<GGRSCfg>(local_inputs));
input_frame.movement = input_frame.movement.normalize_or_zero();
input_frame.directional_attack = input_frame.directional_attack.normalize_or_zero();
input_frame
}
+13 -4
View File
@@ -1,7 +1,3 @@
//! What a physical button means. Device-agnostic: a binding names an
//! [`InputMapAction`], and each device backend (see [`super::keyboard`])
//! decides how to read it.
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
@@ -28,7 +24,20 @@ pub struct KeyboardInputMapElement {
pub action: InputMapAction,
}
#[derive(Debug)]
pub enum GamepadBinding {
Button(GamepadButton),
Axis(GamepadAxis, i32),
}
#[derive(Debug)]
pub struct GamepadInputMapElement {
pub binding: GamepadBinding,
pub action: InputMapAction,
}
#[derive(Resource)]
pub struct BaseInputMap {
pub keyboard: Vec<KeyboardInputMapElement>,
pub gamepad: Vec<GamepadInputMapElement>,
}
+66 -6
View File
@@ -1,23 +1,28 @@
//! Input, from physical device to the buffered per-fighter state that the
//! state machine reads.
//!
//! The pipeline is: [`keyboard`] samples devices into [`FighterInputFrame`]s
//! that GGRS ships over the wire, then [`buffer::postprocess_input`] turns the
//! rolled-back frames into [`FighterInput`].
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy_ggrs::prelude::*;
use bevy_ggrs::{LocalInputs, LocalPlayers};
pub mod buffer;
pub mod control;
pub mod frame;
pub mod gamecube;
pub mod gamepad;
pub mod keyboard;
pub mod map;
pub use buffer::{FighterCommands, FighterInput};
pub use frame::FighterInputFrame;
pub use map::{BaseInputMap, InputActionState, InputMapAction, KeyboardInputMapElement};
pub use map::{
BaseInputMap, GamepadBinding, GamepadInputMapElement, InputActionState, InputMapAction,
KeyboardInputMapElement,
};
use crate::game_settings::GameSettings;
use crate::netcode::GGRSCfg;
use crate::schedule::GameplaySet;
/// Owns the input map, the local-input collection that feeds GGRS, and the
@@ -60,8 +65,34 @@ impl Plugin for FighterInputPlugin {
action: InputMapAction::Shield,
},
],
gamepad: vec![
// left stick
GamepadInputMapElement {
binding: GamepadBinding::Axis(GamepadAxis::LeftStickX, 1),
action: InputMapAction::MovementXDir(1),
},
GamepadInputMapElement {
binding: GamepadBinding::Axis(GamepadAxis::LeftStickY, 1),
action: InputMapAction::MovementYDir(1),
},
// jump
GamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::North),
action: InputMapAction::Jump,
},
// shield/airdodge -- both shoulder buttons, Melee-style
GamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::LeftTrigger),
action: InputMapAction::Shield,
},
GamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::RightTrigger),
action: InputMapAction::Shield,
},
],
})
.add_systems(ReadInputs, keyboard::preprocess_keyboard_input)
.add_plugins(gamecube::GamecubeAdapterPlugin)
.add_systems(ReadInputs, read_local_inputs)
.add_systems(
GgrsSchedule,
buffer::postprocess_input.in_set(GameplaySet::Input),
@@ -69,3 +100,32 @@ impl Plugin for FighterInputPlugin {
.rollback_component_with_clone::<FighterInput>();
}
}
fn read_local_inputs(
mut commands: Commands,
key: Res<ButtonInput<KeyCode>>,
input_map: Res<BaseInputMap>,
gc_ports: Res<gamecube::GcPorts>,
gamepads: Query<&Gamepad>,
local_players: Res<LocalPlayers>,
game_settings: Res<GameSettings>
) {
let mut local_inputs = HashMap::new();
for handle in &local_players.0 {
let gamepad = gc_ports
.entities
.get(*handle)
.copied()
.flatten()
.and_then(|entity| gamepads.get(entity).ok());
let input_frame = match gamepad {
Some(pad) => gamepad::sample_gamepad(pad, &input_map, &game_settings),
None => keyboard::sample_keyboard(&key, &input_map),
};
local_inputs.insert(*handle, input_frame);
}
commands.insert_resource(LocalInputs::<GGRSCfg>(local_inputs));
}
-8
View File
@@ -57,13 +57,6 @@ pub enum AppState {
MatchLoadFailed,
}
pub fn show_gamepads(q: Query<(&Name, &Gamepad)>) {
info!("GP:");
for (name, gp) in q {
info!("Gamepad detected: {}", name);
}
}
/// Your game's Bevy plugin. The editor finds it by this name (override
/// with `plugin = "..."` in jackdaw.toml) and runs it on Play; the
/// standalone binary adds it in `main.rs`.
@@ -106,7 +99,6 @@ impl Plugin for GamePlugin {
.on_failure_continue_to_state(AppState::CommonAssetLoadFailed)
.load_collection::<fighter::manifest::FighterManifestAssets>(),
)
.add_systems(Startup, show_gamepads)
.add_systems(
OnEnter(AppState::PrepareFighterManifests),
fighter::manifest::prepare_fighter_manifests,