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) && !sim.0.place_signpost(0, cursor.x, cursor.y, dir) {
cursor.flash = FLASH_SECS;
denied.write(PlacementDenied { player: 0 });
}
}
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 keys.just_pressed(KeyCode::KeyN) {
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 });
}
}
if keys.just_pressed(KeyCode::KeyP) {
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>>,
mut paused: ResMut<Paused>,
mut load: MessageWriter<LoadLevel>,
) {
if keys.just_pressed(KeyCode::Escape) {
paused.0 = !paused.0;
}
if keys.just_pressed(KeyCode::KeyR) {
load.write(LoadLevel { keep_posts: true });
}
}
pub fn done_input(
keys: Res<ButtonInput<KeyCode>>,
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 keys.just_pressed(KeyCode::KeyR) {
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 });
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 next_screen: ResMut<NextState<Screen>>,
) {
let series_on = tournament.active && !tournament.finished;
if let Some(session) = &mut online.0 {
if session.next_round {
next_screen.set(Screen::Interlude);
return;
}
if keys.just_pressed(KeyCode::Enter) {
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);
} else {
next_screen.set(Screen::Menu);
}
}
return;
}
if keys.just_pressed(KeyCode::Enter) {
if series_on {
next_screen.set(Screen::Interlude);
} else {
next_screen.set(Screen::Menu);
}
}
}
#[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");
}
}
}