use crate::app::layout::{self, TILE};
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::{
Campaign, LoadLevel, Paused, PendingActions, Phase, PlacementDenied, Screen, Sim,
};
use crate::sim::{Direction, PlayerAction};
use bevy::prelude::*;
#[derive(Component)]
pub struct Cursor {
pub player: u8,
pub x: u8,
pub y: u8,
repeat: Timer,
pub flash: f32,
}
pub struct KeyMap {
pub moves: [(KeyCode, i16, i16); 4],
pub places: [(KeyCode, Direction); 4],
pub remove: KeyCode,
pub clear_all: KeyCode,
}
const FLASH_SECS: f32 = 0.25;
pub fn keymap(settings: &GameSettings, player: u8, ijkl_commits: bool) -> KeyMap {
use crate::app::binds::Action;
let seat = usize::from(player).min(crate::app::binds::BOUND_SEATS - 1);
let binds = &settings.binds[seat];
let places = if player == 0 && ijkl_commits {
[
(KeyCode::KeyI, Direction::Up),
(KeyCode::KeyK, Direction::Down),
(KeyCode::KeyJ, Direction::Left),
(KeyCode::KeyL, Direction::Right),
]
} else {
[
(binds.key(Action::PlaceUp), Direction::Up),
(binds.key(Action::PlaceDown), Direction::Down),
(binds.key(Action::PlaceLeft), Direction::Left),
(binds.key(Action::PlaceRight), Direction::Right),
]
};
KeyMap {
moves: [
(binds.key(Action::MoveUp), 0, -1),
(binds.key(Action::MoveDown), 0, 1),
(binds.key(Action::MoveLeft), -1, 0),
(binds.key(Action::MoveRight), 1, 0),
],
places,
remove: binds.key(Action::Remove),
clear_all: binds.key(Action::ClearAll),
}
}
fn bracket_color(player: u8) -> Color {
palette::player_color(player).lighter(0.08).with_alpha(0.9)
}
pub fn flash_cursors(time: Res<Time>, mut cursors: Query<(&mut Cursor, &mut Sprite)>) {
for (mut cursor, mut sprite) in &mut cursors {
if cursor.flash > 0.0 {
cursor.flash -= time.delta_secs();
sprite.color = Color::srgba(1.0, 0.25, 0.2, 0.95);
} else {
sprite.color = bracket_color(cursor.player);
}
}
}
fn cursor_sprite(commands: &mut Commands, art: &crate::app::art::Art, player: u8) {
let rotation = if player % 2 == 1 {
Quat::from_rotation_z(std::f32::consts::FRAC_PI_4)
} else {
Quat::IDENTITY
};
commands.spawn((
Cursor {
player,
x: 0,
y: 0,
repeat: Timer::from_seconds(0.0, TimerMode::Once),
flash: 0.0,
},
Sprite {
image: art.bracket.clone(),
color: bracket_color(player),
custom_size: Some(Vec2::splat(TILE * if player % 2 == 1 { 0.80 } else { 1.0 })),
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, layout::z::CURSOR)).with_rotation(rotation),
));
}
pub fn spawn_cursors(commands: &mut Commands, art: &crate::app::art::Art, players: u8) {
for player in 0..players {
cursor_sprite(commands, art, player);
}
}
pub fn spawn_puzzle_cursor(mut commands: Commands, art: Res<crate::app::art::Art>) {
spawn_cursors(&mut commands, &art, 1);
}
pub fn spawn_versus_cursors(
mut commands: Commands,
art: Res<crate::app::art::Art>,
online: Res<crate::app::net::Online>,
playback: Res<crate::app::Playback>,
config: Res<crate::app::match_setup::MatchConfig>,
pads: Query<&Gamepad>,
) {
if playback.0.is_some() {
return;
}
if online.0.is_none() && config.armed {
spawn_cursors(&mut commands, &art, config.seats - config.bots);
return;
}
if let Some(session) = &online.0 {
if let Some(seat) = session.session.seat() {
cursor_sprite(&mut commands, &art, seat);
}
return;
}
spawn_cursors(&mut commands, &art, (pads.iter().count() as u8).max(2));
}
pub fn center_cursors(sim: Res<Sim>, mut cursors: Query<(&mut Cursor, &mut Transform)>) {
center_on(&sim.0, &mut cursors);
}
pub fn center_on(board: &crate::sim::Board, cursors: &mut Query<(&mut Cursor, &mut Transform)>) {
for (mut cursor, mut transform) in cursors {
cursor.x = board.width() / 2;
cursor.y = board.height() / 2;
transform.translation =
layout::tile_center(board, cursor.x, cursor.y).extend(layout::z::CURSOR);
}
}
pub fn move_cursor(
keys: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
sim: Res<Sim>,
settings: Res<GameSettings>,
screen: Res<State<Screen>>,
online: Res<crate::app::net::Online>,
mut cursors: Query<(&mut Cursor, &mut Transform)>,
) {
let board = &sim.0;
let ijkl = settings.ijkl_commits && *screen.get() != Screen::Versus;
for (mut cursor, mut transform) in &mut cursors {
let map = if online.0.is_some() {
keymap(&settings, 0, ijkl)
} else if cursor.player < 2 {
keymap(&settings, cursor.player, ijkl)
} else {
continue;
};
let any_just = map.moves.iter().any(|(k, ..)| keys.just_pressed(*k));
let any_held = map.moves.iter().any(|(k, ..)| keys.pressed(*k));
let mut step = false;
if any_just {
step = true;
cursor.repeat = Timer::from_seconds(settings.repeat_delay, TimerMode::Once);
} else if any_held {
cursor.repeat.tick(time.delta());
if cursor.repeat.is_finished() {
step = true;
cursor.repeat = Timer::from_seconds(settings.repeat_interval, TimerMode::Once);
}
}
if step {
let (mut dx, mut dy) = (0i16, 0i16);
for (key, kx, ky) in map.moves {
if keys.pressed(key) {
dx += kx;
dy += ky;
}
}
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;
transform.translation = layout::tile_center(board, nx, ny).extend(layout::z::CURSOR);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn setup_input(
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
progress: Res<crate::app::progress::Progress>,
mut sim: ResMut<Sim>,
mut campaign: ResMut<Campaign>,
mut next_phase: ResMut<NextState<Phase>>,
mut load: MessageWriter<LoadLevel>,
mut denied: MessageWriter<PlacementDenied>,
mut cursors: Query<&mut Cursor>,
) {
let Some(mut cursor) = cursors.iter_mut().find(|c| c.player == 0) else {
return;
};
let map = keymap(&settings, 0, settings.ijkl_commits);
for (key, dir) in map.places {
if keys.just_pressed(key) {
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 = FLASH_SECS;
denied.write(PlacementDenied {
player: 0,
out_of_signposts: spent,
});
}
}
}
if keys.just_pressed(map.remove) {
let _ = sim.0.remove_signpost(0, cursor.x, cursor.y);
}
if keys.just_pressed(KeyCode::Enter) {
next_phase.set(Phase::Running);
}
if settings.keycaps.just_pressed(&keys, 'N') {
let next = (campaign.index + 1) % campaign.levels.len();
if progress.unlocked(&campaign, next) {
campaign.index = next;
load.write(LoadLevel { keep_posts: false });
} else {
cursor.flash = FLASH_SECS;
denied.write(PlacementDenied {
player: 0,
out_of_signposts: false,
});
}
}
if settings.keycaps.just_pressed(&keys, 'P') {
campaign.index = (campaign.index + campaign.levels.len() - 1) % campaign.levels.len();
load.write(LoadLevel { keep_posts: false });
}
}
pub fn running_input(
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
mut paused: ResMut<Paused>,
mut load: MessageWriter<LoadLevel>,
) {
if keys.just_pressed(KeyCode::Escape) {
paused.0 = !paused.0;
}
if settings.keycaps.just_pressed(&keys, 'R') {
load.write(LoadLevel { keep_posts: true });
}
}
pub fn done_input(
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
phase: Res<State<Phase>>,
mut campaign: ResMut<Campaign>,
mut next_screen: ResMut<NextState<Screen>>,
mut load: MessageWriter<LoadLevel>,
) {
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::StageSelect);
return;
}
if keys.just_pressed(KeyCode::Enter) {
if *phase.get() != Phase::Won {
load.write(LoadLevel { keep_posts: true });
return;
}
if campaign.index + 1 == campaign.levels.len() {
next_screen.set(Screen::Menu);
return;
}
campaign.index += 1;
load.write(LoadLevel { keep_posts: false });
}
if settings.keycaps.just_pressed(&keys, 'R') {
load.write(LoadLevel { keep_posts: true });
}
}
pub fn versus_input(
keys: Res<ButtonInput<KeyCode>>,
sim: Res<Sim>,
settings: Res<GameSettings>,
online: Res<crate::app::net::Online>,
mut pending: ResMut<PendingActions>,
mut denied: MessageWriter<PlacementDenied>,
mut cursors: Query<&mut Cursor>,
) {
let board = &sim.0;
for mut cursor in &mut cursors {
let p = cursor.player as usize;
let map = if online.0.is_some() {
keymap(&settings, 0, false)
} else if cursor.player < 2 {
keymap(&settings, cursor.player, false)
} else {
continue;
};
let seat = cursor.player;
for (key, dir) in map.places {
if keys.just_pressed(key) {
if !board.can_place_signpost(seat, cursor.x, cursor.y) {
cursor.flash = FLASH_SECS;
denied.write(PlacementDenied {
player: seat,
out_of_signposts: board.out_of_signposts(seat, cursor.x, cursor.y),
});
continue;
}
pending.0[p] = PlayerAction::Place {
x: cursor.x,
y: cursor.y,
dir,
};
}
}
if keys.just_pressed(map.remove) {
pending.0[p] = PlayerAction::Remove {
x: cursor.x,
y: cursor.y,
};
}
if keys.pressed(map.clear_all)
&& let Some((x, y)) = board.first_signpost_of(cursor.player)
{
pending.0[p] = PlayerAction::Remove { x, y };
}
}
}
pub fn versus_over_input(
keys: Res<ButtonInput<KeyCode>>,
mut tournament: ResMut<crate::app::tournament::Tournament>,
mut online: ResMut<crate::app::net::Online>,
mut homecoming: ResMut<crate::app::lobby::Homecoming>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let series_on = tournament.active && !tournament.finished;
let Some(session) = &mut online.0 else {
if keys.just_pressed(KeyCode::Enter) {
match series_on {
true => next_screen.set(Screen::Interlude),
false => next_screen.set(Screen::Menu),
}
}
return;
};
if session.next_round {
next_screen.set(Screen::Interlude);
return;
}
if !keys.just_pressed(KeyCode::Enter) {
return;
}
if session.is_host() && series_on {
let (round, wins) = session.call_next_round(
crate::app::match_setup::next_round_terms(
session.terms,
session.seats,
crate::app::clock::fresh_seed(),
),
tournament.round,
tournament.wins,
);
tournament.round = round;
tournament.wins = wins;
next_screen.set(Screen::Interlude);
return;
}
if series_on || !session.from_lobby {
next_screen.set(Screen::Menu);
return;
}
let session = online.0.take().expect("matched Some above");
homecoming.0 = Some(session.back_to_the_lobby());
next_screen.set(Screen::Lobby);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::settings::GameSettings;
use crate::sim::{TileKind, classic_arena};
use bevy::prelude::KeyCode;
fn beach(seats: u8) -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(Screen::Versus));
app.insert_resource(Sim(classic_arena(false, seats.max(2))));
app.insert_resource(GameSettings::default());
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<Time>();
app.init_resource::<PendingActions>();
app.init_resource::<crate::app::net::Online>();
app.add_message::<PlacementDenied>();
for player in 0..seats {
app.world_mut().spawn((
Cursor {
player,
x: 5,
y: 4,
repeat: Timer::from_seconds(0.0, TimerMode::Once),
flash: 0.0,
},
Transform::default(),
));
}
app
}
fn tap(app: &mut App, key: KeyCode) {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(key);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
}
fn cursor_at(app: &mut App, player: u8) -> (u8, u8) {
app.world_mut()
.query::<&Cursor>()
.iter(app.world())
.find(|c| c.player == player)
.map(|c| (c.x, c.y))
.expect("seat has a cursor")
}
#[test]
fn the_keyboard_seats_two_and_stops() {
let mut app = beach(4);
app.add_systems(Update, move_cursor);
tap(&mut app, KeyCode::KeyW);
assert_eq!(cursor_at(&mut app, 0), (5, 3), "seat 1 moved up");
assert_eq!(cursor_at(&mut app, 1), (5, 4), "and only seat 1");
tap(&mut app, KeyCode::KeyI);
assert_eq!(cursor_at(&mut app, 1), (5, 3), "seat 2 moved up");
assert_eq!(
(cursor_at(&mut app, 2), cursor_at(&mut app, 3)),
((5, 4), (5, 4)),
"seats 3 and 4 are pad seats and ignored the keyboard"
);
}
#[test]
fn an_online_seat_plays_on_the_primary_keys() {
let mut app = beach(0);
app.world_mut().spawn((
Cursor {
player: 3,
x: 5,
y: 4,
repeat: Timer::from_seconds(0.0, TimerMode::Once),
flash: 0.0,
},
Transform::default(),
));
app.add_systems(Update, move_cursor);
tap(&mut app, KeyCode::KeyA);
assert_eq!(cursor_at(&mut app, 3), (5, 4));
let transport = crate::transport::UdpTransport::host(0).expect("ephemeral port");
let lockstep = crate::sim::Lockstep::new(3, vec![0, 3], crate::sim::DEFAULT_DELAY);
app.insert_resource(crate::app::net::Online(Some(
crate::app::net::OnlineSession::new(
transport,
lockstep,
4,
crate::transport::MatchTerms::default(),
),
)));
tap(&mut app, KeyCode::KeyA);
assert_eq!(cursor_at(&mut app, 3), (4, 4), "the same key now moves it");
}
#[test]
fn a_refused_placement_flashes_instead_of_queueing() {
let mut app = beach(2);
app.add_systems(Update, versus_input);
app.world_mut()
.resource_mut::<Sim>()
.0
.set_tile(5, 4, TileKind::Empty);
tap(&mut app, KeyCode::ArrowUp);
assert_eq!(
app.world().resource::<PendingActions>().0[0],
PlayerAction::Place {
x: 5,
y: 4,
dir: Direction::Up
}
);
assert_eq!(
app.world().resource::<PendingActions>().0[1],
PlayerAction::None,
"seat 2 pressed nothing"
);
app.world_mut().resource_mut::<PendingActions>().0[0] = PlayerAction::None;
app.world_mut()
.resource_mut::<Sim>()
.0
.set_tile(5, 4, TileKind::Rock);
tap(&mut app, KeyCode::ArrowUp);
assert_eq!(
app.world().resource::<PendingActions>().0[0],
PlayerAction::None,
"nothing was queued onto a rock"
);
let flash = app
.world_mut()
.query::<&Cursor>()
.iter(app.world())
.find(|c| c.player == 0)
.map(|c| c.flash)
.unwrap();
assert!(flash > 0.0, "the cursor flashed red");
assert_eq!(
app.world()
.resource::<Messages<PlacementDenied>>()
.iter_current_update_messages()
.count(),
1
);
}
#[test]
fn default_keyboard_seats_do_not_collide() {
let collect = |player: u8| -> Vec<KeyCode> {
let map = keymap(&GameSettings::default(), player, false);
let mut keys: Vec<KeyCode> = map.moves.iter().map(|(k, ..)| *k).collect();
keys.extend(map.places.iter().map(|(k, _)| *k));
keys.push(map.remove);
keys.push(map.clear_all);
keys
};
let p1 = collect(0);
for key in collect(1) {
assert!(!p1.contains(&key), "{key:?} is bound for both seats");
}
}
fn results_card(seat: u8, from_lobby: bool, series_on: bool) -> App {
use crate::app::net::{Online, OnlineSession};
use crate::app::tournament::Tournament;
use crate::sim::{DEFAULT_DELAY, Lockstep};
use crate::transport::{Announcer, MatchTerms, UdpTransport};
let mut app = beach(2);
app.init_resource::<Tournament>();
app.init_resource::<crate::app::lobby::Homecoming>();
app.add_systems(Update, versus_over_input);
let host = seat == 0;
let transport = match host {
true => UdpTransport::host(0).expect("game socket"),
false => UdpTransport::join(("127.0.0.1", 47999)).expect("join"),
};
let mut session = OnlineSession::new(
transport,
Lockstep::new(seat, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
session.from_lobby = from_lobby;
if host && from_lobby {
session.stay_on_air(Announcer::new(0xB0A7).expect("announcer"));
}
if series_on {
*app.world_mut().resource_mut::<Tournament>() =
Tournament::start(crate::app::tournament::SeriesLength::BestOfFive);
}
app.world_mut().resource_mut::<Online>().0 = Some(session);
app
}
fn pressing_enter(app: &mut App) -> Screen {
tap(app, KeyCode::Enter);
app.update();
*app.world().resource::<State<Screen>>().get()
}
#[test]
fn the_end_of_a_lobby_match_walks_the_table_back_to_the_lobby() {
use crate::app::lobby::Homecoming;
use crate::app::net::Online;
for seat in [0, 1] {
let mut app = results_card(seat, true, false);
assert_eq!(pressing_enter(&mut app), Screen::Lobby, "seat {seat}");
let homecoming = app.world().resource::<Homecoming>();
let returned = homecoming.0.as_ref().expect("the table came home");
assert_eq!(returned.host, seat == 0);
assert!(
app.world().resource::<Online>().0.is_none(),
"the session was handed over whole, not left behind"
);
if seat == 0 {
assert!(
returned.announcer.is_some(),
"and the beacon came with it, to announce the beach open again"
);
}
}
}
#[test]
fn a_direct_pair_has_no_lobby_to_go_back_to() {
use crate::app::lobby::Homecoming;
let mut app = results_card(0, false, false);
assert_eq!(pressing_enter(&mut app), Screen::Menu);
assert!(app.world().resource::<Homecoming>().0.is_none());
}
#[test]
fn a_running_series_still_owns_enter() {
use crate::app::lobby::Homecoming;
let mut app = results_card(1, true, true);
assert_eq!(
pressing_enter(&mut app),
Screen::Menu,
"leaving a series is leaving"
);
assert!(app.world().resource::<Homecoming>().0.is_none());
let mut app = results_card(0, true, true);
assert_eq!(
pressing_enter(&mut app),
Screen::Interlude,
"the host calls the next round instead"
);
assert!(app.world().resource::<Homecoming>().0.is_none());
}
#[test]
fn one_press_is_only_ever_read_by_one_screen() {
use bevy::input::ButtonState;
use bevy::input::keyboard::{Key, KeyboardInput};
#[derive(Resource, Default)]
struct SeenInTheLobby(bool);
let mut app = App::new();
app.add_plugins((bevy::state::app::StatesPlugin, bevy::input::InputPlugin));
app.init_state::<Screen>();
app.insert_resource(State::new(Screen::Versus));
app.init_resource::<SeenInTheLobby>();
app.add_systems(
Update,
(
(|keys: Res<ButtonInput<KeyCode>>, mut next: ResMut<NextState<Screen>>| {
if keys.just_pressed(KeyCode::Enter) {
next.set(Screen::Lobby);
}
})
.run_if(in_state(Screen::Versus)),
(|keys: Res<ButtonInput<KeyCode>>, mut seen: ResMut<SeenInTheLobby>| {
seen.0 |= keys.just_pressed(KeyCode::Enter);
})
.run_if(in_state(Screen::Lobby)),
),
);
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Enter,
logical_key: Key::Enter,
state: ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Lobby
);
assert!(
!app.world().resource::<SeenInTheLobby>().0,
"the lobby read the Enter that was meant for the results card"
);
}
}