use crate::app::cursor::Cursor;
use crate::app::settings::{GameSettings, SeatInput};
use crate::app::{PendingActions, Phase, PlacementDenied, Screen, Sim};
use crate::sim::{Direction, MAX_PLAYERS, PlayerAction};
use bevy::input::gamepad::{GamepadRumbleIntensity, GamepadRumbleRequest};
use bevy::prelude::*;
#[derive(Resource, Default)]
pub struct PadSeats(pub Vec<Entity>);
fn nth_pad_entity(
pads: &Query<(Entity, &Gamepad)>,
claims: &PadSeats,
index: usize,
) -> Option<Entity> {
if claims.0.is_empty() {
return pads.iter().nth(index).map(|(entity, _)| entity);
}
let entity = *claims.0.get(index)?;
pads.contains(entity).then_some(entity)
}
fn nth_pad<'a>(
pads: &'a Query<(Entity, &Gamepad)>,
claims: &PadSeats,
index: usize,
) -> Option<&'a Gamepad> {
let entity = nth_pad_entity(pads, claims, index)?;
pads.get(entity).ok().map(|(_, pad)| pad)
}
fn pad_index_of(settings: &GameSettings, players: &[u8], player: u8) -> Option<usize> {
match seat_choice(settings, player) {
Some(SeatInput::Keys) => return None,
Some(SeatInput::Pad(n)) => return Some(usize::from(n)),
Some(SeatInput::Auto) | None => {}
}
let mut autos: Vec<u8> = players
.iter()
.copied()
.filter(|&p| {
!matches!(
seat_choice(settings, p),
Some(SeatInput::Pad(_) | SeatInput::Keys)
)
})
.collect();
autos.sort_unstable();
let rank = autos.iter().rev().position(|&p| p == player)?;
(0..).filter(|index| !named(settings, *index)).nth(rank)
}
fn seat_choice(settings: &GameSettings, player: u8) -> Option<SeatInput> {
settings.seat_input.get(usize::from(player)).copied()
}
fn named(settings: &GameSettings, index: usize) -> bool {
settings
.seat_input
.iter()
.any(|choice| matches!(choice, SeatInput::Pad(n) if usize::from(*n) == index))
}
const KEYBOARD_SEATS: usize = 2;
const PAD_SEATS: usize = MAX_PLAYERS - KEYBOARD_SEATS;
#[derive(Component)]
pub struct PadRepeat(Timer);
const PLACES: [(GamepadButton, Direction); 4] = [
(GamepadButton::North, Direction::Up),
(GamepadButton::South, Direction::Down),
(GamepadButton::West, Direction::Left),
(GamepadButton::East, Direction::Right),
];
fn held_direction(pad: &Gamepad, deadzone: f32) -> (i16, i16) {
let mut dx = 0i16;
let mut dy = 0i16;
if pad.pressed(GamepadButton::DPadUp) {
dy -= 1;
}
if pad.pressed(GamepadButton::DPadDown) {
dy += 1;
}
if pad.pressed(GamepadButton::DPadLeft) {
dx -= 1;
}
if pad.pressed(GamepadButton::DPadRight) {
dx += 1;
}
let stick_x = pad.get(GamepadAxis::LeftStickX).unwrap_or(0.0);
let stick_y = pad.get(GamepadAxis::LeftStickY).unwrap_or(0.0);
if stick_x < -deadzone {
dx -= 1;
}
if stick_x > deadzone {
dx += 1;
}
if stick_y > deadzone {
dy -= 1;
}
if stick_y < -deadzone {
dy += 1;
}
(dx.signum(), dy.signum())
}
pub fn pad_move_cursor(
pads: Query<(Entity, &Gamepad)>,
seats: Res<PadSeats>,
time: Res<Time>,
sim: Res<Sim>,
settings: Res<GameSettings>,
mut commands: Commands,
mut cursors: Query<(Entity, &mut Cursor, Option<&mut PadRepeat>)>,
) {
let board = &sim.0;
let deadzone = settings.deadzone();
let mut players: Vec<u8> = cursors.iter().map(|(_, c, ..)| c.player).collect();
players.sort_unstable();
for (entity, mut cursor, repeat) in &mut cursors {
let Some(pad) = pad_index_of(&settings, &players, cursor.player)
.and_then(|i| nth_pad(&pads, &seats, i))
else {
continue;
};
let (dx, dy) = held_direction(pad, deadzone);
let Some(mut repeat) = repeat else {
commands
.entity(entity)
.insert(PadRepeat(Timer::from_seconds(0.0, TimerMode::Once)));
continue;
};
if dx == 0 && dy == 0 {
repeat.0 = Timer::from_seconds(0.0, TimerMode::Once);
continue;
}
repeat.0.tick(time.delta());
if !repeat.0.is_finished() {
continue;
}
let first_step = repeat.0.duration().is_zero();
repeat.0 = Timer::from_seconds(
if first_step {
settings.repeat_delay
} else {
settings.repeat_interval
},
TimerMode::Once,
);
let nx = (i16::from(cursor.x) + dx).clamp(0, i16::from(board.width()) - 1) as u8;
let ny = (i16::from(cursor.y) + dy).clamp(0, i16::from(board.height()) - 1) as u8;
cursor.x = nx;
cursor.y = ny;
}
}
pub fn pad_versus_input(
pads: Query<(Entity, &Gamepad)>,
seats: Res<PadSeats>,
settings: Res<GameSettings>,
sim: Res<Sim>,
mut pending: ResMut<PendingActions>,
mut denied: MessageWriter<PlacementDenied>,
mut cursors: Query<&mut Cursor>,
) {
let board = &sim.0;
let mut players: Vec<u8> = cursors.iter().map(|c| c.player).collect();
players.sort_unstable();
for mut cursor in &mut cursors {
let Some(pad) = pad_index_of(&settings, &players, cursor.player)
.and_then(|i| nth_pad(&pads, &seats, i))
else {
continue;
};
let p = cursor.player as usize;
for (button, dir) in PLACES {
if pad.just_pressed(button) {
if !board.can_place_signpost(cursor.player, cursor.x, cursor.y) {
cursor.flash = 0.25;
denied.write(PlacementDenied {
player: cursor.player,
out_of_signposts: board.out_of_signposts(cursor.player, cursor.x, cursor.y),
});
continue;
}
pending.0[p] = PlayerAction::Place {
x: cursor.x,
y: cursor.y,
dir,
};
}
}
if pad.just_pressed(GamepadButton::LeftTrigger) {
pending.0[p] = PlayerAction::Remove {
x: cursor.x,
y: cursor.y,
};
}
if pad.pressed(GamepadButton::RightTrigger)
&& let Some((x, y)) = board.first_signpost_of(cursor.player)
{
pending.0[p] = PlayerAction::Remove { x, y };
}
}
}
pub fn pad_setup_input(
pads: Query<(Entity, &Gamepad)>,
seats: Res<PadSeats>,
settings: Res<GameSettings>,
mut sim: ResMut<Sim>,
mut next_phase: ResMut<NextState<Phase>>,
mut denied: MessageWriter<PlacementDenied>,
mut cursors: Query<&mut Cursor>,
) {
let mut players: Vec<u8> = cursors.iter().map(|c| c.player).collect();
players.sort_unstable();
for mut cursor in &mut cursors {
let Some(pad) = pad_index_of(&settings, &players, cursor.player)
.and_then(|i| nth_pad(&pads, &seats, i))
else {
continue;
};
for (button, dir) in PLACES {
if pad.just_pressed(button) {
let spent = sim.0.out_of_signposts(0, cursor.x, cursor.y);
if !sim.0.place_signpost(0, cursor.x, cursor.y, dir) {
cursor.flash = 0.25;
denied.write(PlacementDenied {
player: 0,
out_of_signposts: spent,
});
}
}
}
if pad.just_pressed(GamepadButton::LeftTrigger) {
let _ = sim.0.remove_signpost(0, cursor.x, cursor.y);
}
if pad.just_pressed(GamepadButton::Start) {
next_phase.set(Phase::Running);
}
}
}
pub fn pad_menu_bridge(
pads: Query<&Gamepad>,
screen: Res<State<Screen>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
) {
const MAP: [(GamepadButton, KeyCode, KeyCode); 6] = [
(GamepadButton::DPadUp, KeyCode::KeyW, KeyCode::ArrowUp),
(GamepadButton::DPadDown, KeyCode::KeyS, KeyCode::ArrowDown),
(GamepadButton::DPadLeft, KeyCode::KeyA, KeyCode::ArrowLeft),
(GamepadButton::DPadRight, KeyCode::KeyD, KeyCode::ArrowRight),
(GamepadButton::South, KeyCode::Enter, KeyCode::Enter),
(GamepadButton::East, KeyCode::Escape, KeyCode::Escape),
];
let quits = *screen.get() == Screen::Menu;
let lobby = *screen.get() == Screen::Lobby;
for pad in &pads {
for (button, key, lobby_key) in MAP {
let muted = quits && button == GamepadButton::East;
if pad.just_pressed(button) && !muted {
keys.press(if lobby { lobby_key } else { key });
}
if pad.just_released(button) {
keys.release(key);
keys.release(lobby_key);
}
}
}
}
pub fn pad_claim_seats(
pads: Query<(Entity, &Gamepad)>,
mut seats: ResMut<PadSeats>,
mut config: ResMut<crate::app::match_setup::MatchConfig>,
) {
seats.0.retain(|&entity| pads.get(entity).is_ok());
for (entity, pad) in &pads {
if pad.just_pressed(GamepadButton::Start)
&& !seats.0.contains(&entity)
&& seats.0.len() < KEYBOARD_SEATS + PAD_SEATS
{
seats.0.push(entity);
let needed = (KEYBOARD_SEATS + seats.0.len()) as u8;
config.seats = config.seats.max(needed).min(MAX_PLAYERS as u8);
config.bots = config.bots.min(config.seats - 1);
}
}
}
const RAID_RUMBLE: GamepadRumbleIntensity = GamepadRumbleIntensity {
strong_motor: 0.85,
weak_motor: 0.45,
};
const RAID_RUMBLE_SECS: f32 = 0.32;
pub fn rumble_on_raid(
mut events: MessageReader<crate::app::sim_events::SimEvent>,
settings: Res<GameSettings>,
claims: Res<PadSeats>,
pads: Query<(Entity, &Gamepad)>,
cursors: Query<&Cursor>,
mut seated: Local<Vec<u8>>,
mut rumble: MessageWriter<GamepadRumbleRequest>,
) {
use crate::app::sim_events::SimEvent;
if !settings.rumble || events.is_empty() {
return;
}
seated.clear();
seated.extend(cursors.iter().map(|cursor| cursor.player));
seated.sort_unstable();
for event in events.read() {
let SimEvent::CastleRaided { owner, .. } = event else {
continue;
};
if seated.binary_search(owner).is_err() {
continue;
}
let Some(pad) = pad_index_of(&settings, &seated, *owner)
.and_then(|index| nth_pad_entity(&pads, &claims, index))
else {
continue;
};
rumble.write(GamepadRumbleRequest::Add {
duration: std::time::Duration::from_secs_f32(RAID_RUMBLE_SECS),
intensity: RAID_RUMBLE,
gamepad: pad,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::settings::{GameSettings, SeatInput};
use crate::app::sim_events::SimEvent;
use crate::sim::classic_arena;
fn with(choices: [SeatInput; 2]) -> GameSettings {
GameSettings {
seat_input: choices,
..GameSettings::default()
}
}
#[test]
fn pads_map_top_down() {
let auto = with([SeatInput::Auto; 2]);
assert_eq!(pad_index_of(&auto, &[0, 1], 1), Some(0));
assert_eq!(pad_index_of(&auto, &[0, 1], 0), Some(1));
assert_eq!(pad_index_of(&auto, &[0, 1, 2, 3], 3), Some(0));
assert_eq!(pad_index_of(&auto, &[0, 1, 2, 3], 0), Some(3));
assert_eq!(pad_index_of(&auto, &[2], 2), Some(0), "online single seat");
assert_eq!(pad_index_of(&auto, &[0, 1], 2), None, "no cursor, no pad");
}
#[test]
fn a_named_controller_belongs_to_the_seat_that_named_it() {
let mine = with([SeatInput::Pad(0), SeatInput::Auto]);
assert_eq!(pad_index_of(&mine, &[0, 1], 0), Some(0), "P1 asked for it");
assert_eq!(
pad_index_of(&mine, &[0, 1], 1),
Some(1),
"P2 takes the next one free, not the claimed one"
);
}
#[test]
fn keyboard_only_gives_its_pad_up() {
let quiet = with([SeatInput::Auto, SeatInput::Keys]);
assert_eq!(pad_index_of(&quiet, &[0, 1], 1), None);
assert_eq!(pad_index_of(&quiet, &[0, 1], 0), Some(0));
}
#[test]
fn two_seats_may_name_the_same_controller() {
let both = with([SeatInput::Pad(1), SeatInput::Pad(1)]);
assert_eq!(pad_index_of(&both, &[0, 1], 0), Some(1));
assert_eq!(pad_index_of(&both, &[0, 1], 1), Some(1));
assert_eq!(pad_index_of(&both, &[0, 1, 2], 2), Some(0));
}
fn table(seats: u8, pads: usize) -> App {
let mut app = App::new();
app.insert_resource(Sim(classic_arena(false, seats.max(2))));
app.insert_resource(GameSettings::default());
app.init_resource::<PadSeats>();
app.add_message::<SimEvent>();
app.add_message::<GamepadRumbleRequest>();
for player in 0..seats {
app.world_mut().spawn(Cursor::seated(player));
}
for _ in 0..pads {
app.world_mut().spawn(Gamepad::default());
}
app.add_systems(Update, rumble_on_raid);
app
}
fn buzzed(app: &mut App) -> Vec<Entity> {
let mut messages = app
.world_mut()
.resource_mut::<Messages<GamepadRumbleRequest>>();
let out: Vec<Entity> = messages.drain().map(|request| request.gamepad()).collect();
out
}
fn raid(app: &mut App, owner: u8) -> Vec<Entity> {
app.world_mut().write_message(SimEvent::CastleRaided {
owner,
pos: Vec2::ZERO,
lost: 4,
});
app.update();
buzzed(app)
}
#[test]
fn a_raid_is_felt_by_the_seat_that_was_raided() {
let mut app = table(2, 1);
let pad = app
.world_mut()
.query_filtered::<Entity, With<Gamepad>>()
.single(app.world())
.expect("one pad");
assert_eq!(raid(&mut app, 1), vec![pad], "seat two holds the pad");
assert!(
raid(&mut app, 0).is_empty(),
"seat one is on the keyboard and has nothing to buzz"
);
}
#[test]
fn two_pads_are_told_apart() {
let mut app = table(2, 2);
let top = raid(&mut app, 1);
let next = raid(&mut app, 0);
assert_eq!(top.len(), 1);
assert_eq!(next.len(), 1);
assert_ne!(top, next, "one raid must not buzz both hands");
}
#[test]
fn a_claimed_pad_beats_the_order_it_was_plugged_in() {
let mut app = table(2, 2);
let plugged: Vec<Entity> = app
.world_mut()
.query_filtered::<Entity, With<Gamepad>>()
.iter(app.world())
.collect();
assert_eq!(plugged.len(), 2);
let claimed: Vec<Entity> = plugged.iter().copied().rev().collect();
app.world_mut().resource_mut::<PadSeats>().0 = claimed.clone();
assert_eq!(
raid(&mut app, 1),
vec![claimed[0]],
"the top seat holds whichever pad claimed first"
);
}
#[test]
fn a_bound_pad_still_only_answers_for_a_seat_that_is_here() {
let mut app = table(1, 2);
app.world_mut().resource_mut::<GameSettings>().seat_input[1] = SeatInput::Pad(0);
assert!(
raid(&mut app, 1).is_empty(),
"seat two is bound to a pad here but is not sitting here"
);
assert_eq!(
raid(&mut app, 0).len(),
1,
"and seat one still feels its own, on the pad seat two did not claim"
);
}
#[test]
fn a_seat_that_is_not_here_buzzes_nothing() {
let mut app = table(2, 1);
assert!(
raid(&mut app, 3).is_empty(),
"seat four is not at this table"
);
}
#[test]
fn the_setting_turns_it_off() {
let mut app = table(2, 1);
app.world_mut().resource_mut::<GameSettings>().rumble = false;
assert!(raid(&mut app, 1).is_empty());
}
fn bridged(screen: Screen) -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(screen));
app.init_resource::<ButtonInput<KeyCode>>();
app.world_mut().spawn(Gamepad::default());
app.add_systems(Update, pad_menu_bridge);
app
}
fn press(app: &mut App, button: GamepadButton) {
let pad = app
.world_mut()
.query_filtered::<Entity, With<Gamepad>>()
.single(app.world())
.expect("one pad");
app.world_mut()
.get_mut::<Gamepad>(pad)
.expect("the pad")
.digital_mut()
.press(button);
app.update();
}
#[test]
fn east_does_not_quit_from_the_menu() {
let mut app = bridged(Screen::Menu);
press(&mut app, GamepadButton::East);
assert!(
!app.world()
.resource::<ButtonInput<KeyCode>>()
.pressed(KeyCode::Escape),
"B on the menu must not synthesize the key that leaves the game"
);
}
#[test]
fn east_still_goes_back_from_a_sub_screen() {
for screen in [Screen::Settings, Screen::Controls, Screen::StageSelect] {
let mut app = bridged(screen);
press(&mut app, GamepadButton::East);
assert!(
app.world()
.resource::<ButtonInput<KeyCode>>()
.pressed(KeyCode::Escape),
"B is the way back from {screen:?}"
);
}
}
#[test]
fn a_press_that_changes_screen_still_releases_its_key() {
let mut app = bridged(Screen::Settings);
let pad = app
.world_mut()
.query_filtered::<Entity, With<Gamepad>>()
.single(app.world())
.expect("one pad");
app.world_mut()
.get_mut::<Gamepad>(pad)
.expect("the pad")
.digital_mut()
.press(GamepadButton::East);
app.update();
assert!(
app.world()
.resource::<ButtonInput<KeyCode>>()
.just_pressed(KeyCode::Escape),
"B goes back from a sub-screen"
);
app.insert_resource(State::new(Screen::Menu));
app.world_mut()
.get_mut::<Gamepad>(pad)
.expect("the pad")
.digital_mut()
.release(GamepadButton::East);
app.update();
assert!(
!app.world()
.resource::<ButtonInput<KeyCode>>()
.pressed(KeyCode::Escape),
"the synthesized Escape must not be left held on the menu"
);
}
#[test]
fn the_menu_keeps_the_rest_of_the_bridge() {
for (button, key) in [
(GamepadButton::DPadUp, KeyCode::KeyW),
(GamepadButton::DPadDown, KeyCode::KeyS),
(GamepadButton::DPadLeft, KeyCode::KeyA),
(GamepadButton::DPadRight, KeyCode::KeyD),
(GamepadButton::South, KeyCode::Enter),
] {
let mut app = bridged(Screen::Menu);
press(&mut app, button);
assert!(
app.world().resource::<ButtonInput<KeyCode>>().pressed(key),
"{button:?} still drives the menu"
);
}
}
#[test]
fn the_lobby_dpad_is_the_arrows() {
for (button, key) in [
(GamepadButton::DPadUp, KeyCode::ArrowUp),
(GamepadButton::DPadDown, KeyCode::ArrowDown),
(GamepadButton::DPadLeft, KeyCode::ArrowLeft),
(GamepadButton::DPadRight, KeyCode::ArrowRight),
(GamepadButton::South, KeyCode::Enter),
(GamepadButton::East, KeyCode::Escape),
] {
let mut app = bridged(Screen::Lobby);
press(&mut app, button);
let keys = app.world().resource::<ButtonInput<KeyCode>>();
assert!(keys.pressed(key), "{button:?} is {key:?} in the lobby");
assert!(
!keys.pressed(KeyCode::KeyW),
"{button:?} must not toggle watching"
);
}
}
#[test]
fn a_dpad_press_carried_into_the_lobby_is_released() {
let mut app = bridged(Screen::Menu);
press(&mut app, GamepadButton::DPadUp);
assert!(
app.world()
.resource::<ButtonInput<KeyCode>>()
.pressed(KeyCode::KeyW)
);
app.insert_resource(State::new(Screen::Lobby));
let pad = app
.world_mut()
.query_filtered::<Entity, With<Gamepad>>()
.single(app.world())
.expect("one pad");
app.world_mut()
.get_mut::<Gamepad>(pad)
.expect("the pad")
.digital_mut()
.release(GamepadButton::DPadUp);
app.update();
assert!(
!app.world()
.resource::<ButtonInput<KeyCode>>()
.pressed(KeyCode::KeyW),
"the W pressed on the menu must not be left held in the lobby"
);
}
}