mod level_card;
mod scenery;
pub use level_card::{LevelCard, answer_level_card, close_level_card, show_level_card};
pub use scenery::{menu_ambience, refit_shore, tend_backdrop};
use crate::app::effects::VisualRng;
use crate::app::gamepad::LastDevice;
use crate::app::i18n::fill;
use crate::app::menu_ui;
use crate::app::menu_ui::card_shadow;
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::{Campaign, Screen};
use bevy::prelude::*;
const TITLE: &str = "PINCH POINTS";
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MenuEntry {
TidePool,
TurfWar,
Driftwood,
Replay,
BeachLobby,
Settings,
Achievements,
DailyChallenge,
}
impl MenuEntry {
pub const ALL: [MenuEntry; 8] = [
MenuEntry::TidePool,
MenuEntry::TurfWar,
MenuEntry::Driftwood,
MenuEntry::Replay,
MenuEntry::BeachLobby,
MenuEntry::Settings,
MenuEntry::Achievements,
MenuEntry::DailyChallenge,
];
}
pub const MENU_ENTRY_COUNT: usize = MenuEntry::ALL.len();
#[derive(Resource, Default)]
pub struct MenuList {
pub selected: usize,
}
#[derive(Component)]
pub struct MenuRow(pub usize);
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MenuCol {
Name,
Blurb,
}
#[derive(Component)]
pub struct MenuCell(pub usize, pub MenuCol);
#[derive(Component)]
pub struct MenuArt;
fn spawn_title(commands: &mut Commands, settings: &GameSettings) {
commands
.spawn((
MenuArt,
Node {
position_type: PositionType::Absolute,
top: Val::Px(44.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|wrap| {
wrap.spawn((
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: Val::Px(6.0),
padding: UiRect::axes(Val::Px(34.0), Val::Px(16.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(18.0)),
..default()
},
BackgroundColor(palette::SIGN_FILL),
BorderColor::all(palette::CARD_EDGE),
card_shadow(),
menu_ui::Framed,
))
.with_children(|card| {
card.spawn((
Text::new(TITLE),
menu_ui::display_font(menu_ui::type_scale::TITLE),
TextColor(palette::TITLE_INK),
TextShadow {
offset: Vec2::new(2.0, 3.0),
color: palette::TITLE_SHADOW,
},
));
card.spawn((
Text::new(settings.tr().tagline),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::PARCHMENT),
));
});
});
}
fn spawn_mode_list(commands: &mut Commands) {
commands
.spawn((
MenuArt,
Node {
position_type: PositionType::Absolute,
top: Val::Px(196.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|wrap| {
wrap.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(2.0),
padding: UiRect::axes(Val::Px(20.0), Val::Px(16.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(18.0)),
..default()
},
BackgroundColor(palette::CARD_FILL),
BorderColor::all(palette::CARD_EDGE),
card_shadow(),
menu_ui::Framed,
))
.with_children(|panel| {
for row in 0..MENU_ENTRY_COUNT {
panel
.spawn((
MenuRow(row),
Node {
align_items: AlignItems::Center,
column_gap: Val::Px(14.0),
padding: UiRect::axes(Val::Px(12.0), Val::Px(4.0)),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|line| {
for (col, width, size) in
[(MenuCol::Name, 216.0, 21.0), (MenuCol::Blurb, 384.0, 17.0)]
{
line.spawn((
Node {
width: Val::Px(width),
flex_shrink: 0.0,
overflow: Overflow::clip_x(),
..default()
},
children![(
MenuCell(row, col),
Text::new(""),
TextFont {
font_size: FontSize::Px(size),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::IDLE_ROW),
)],
));
}
});
}
});
});
}
pub fn enter_menu(mut commands: Commands, settings: Res<GameSettings>) {
spawn_title(&mut commands, &settings);
spawn_mode_list(&mut commands);
spawn_version(&mut commands);
}
fn spawn_version(commands: &mut Commands) {
commands.spawn((
MenuArt,
Text::new(crate::app::update::Version::current().to_string()),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT.with_alpha(0.75)),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(8.0),
right: Val::Px(12.0),
padding: UiRect::axes(Val::Px(12.0), Val::Px(7.0)),
border_radius: BorderRadius::all(Val::Px(11.0)),
..default()
},
BackgroundColor(palette::PILL_FILL),
));
}
pub fn update_menu_rows(
list: Res<MenuList>,
settings: Res<GameSettings>,
mut cells: Query<(&MenuCell, &mut Text, &mut TextColor)>,
mut rows: Query<(&MenuRow, &mut BackgroundColor)>,
) {
let tr = settings.tr();
for (cell, mut text, mut color) in &mut cells {
let picked = cell.0 == list.selected;
let line = {
match cell.1 {
MenuCol::Name => tr.menu_names[cell.0].to_string(),
MenuCol::Blurb if MenuEntry::ALL[cell.0] == MenuEntry::DailyChallenge => {
let (day, month) = crate::app::clock::civil_date(crate::app::Daily::today());
fill(
tr.menu_daily_blurb,
&[("d", &format!("{day:02}")), ("m", &format!("{month:02}"))],
)
}
MenuCol::Blurb => tr.menu_blurbs[cell.0].to_string(),
}
};
let target = match (cell.1, picked) {
(MenuCol::Name, true) => Color::WHITE,
(MenuCol::Name, false) => palette::PARCHMENT.with_alpha(0.92),
(MenuCol::Blurb, true) => palette::PARCHMENT.with_alpha(0.85),
(MenuCol::Blurb, false) => palette::PARCHMENT.with_alpha(0.45),
};
menu_ui::set_text(&mut text, &line);
menu_ui::set_color(&mut color, target);
}
for (row, mut fill) in &mut rows {
let ground = if row.0 == list.selected {
palette::GOLD.with_alpha(0.16)
} else {
Color::NONE
};
menu_ui::set_bg(&mut fill, ground);
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Launch<'w> {
campaign: ResMut<'w, Campaign>,
daily: ResMut<'w, crate::app::Daily>,
next_screen: ResMut<'w, NextState<Screen>>,
}
pub fn paste_round(
keyboard: crate::app::keycaps::Keyboard,
settings: Res<GameSettings>,
mut clipboard: ResMut<Clipboard>,
mut notice: ResMut<crate::app::RoundNotice>,
mut resuming: ResMut<crate::app::Resuming>,
mut next_screen: ResMut<NextState<Screen>>,
(quit, mut card): (Res<QuitCard>, ResMut<LevelCard>),
) {
if quit.open || card.is_open() || !keyboard.just('V') || menu_ui::screen_chosen(&next_screen) {
return;
}
let pasted = crate::app::codes::paste(&mut clipboard);
if matches!(pasted, Some((crate::share::Kind::Level, _))) {
match crate::app::editor::level_from_code(pasted, settings.tr()) {
Ok(level) => {
notice.0.clear();
card.show(level);
}
Err(complaint) => notice.0 = complaint,
}
return;
}
match crate::app::suspend::round_from(pasted, settings.tr()) {
Ok(round) => {
notice.0.clear();
resuming.0 = Some(round);
next_screen.set(Screen::Versus);
}
Err(complaint) => notice.0 = complaint,
}
}
#[derive(Resource, Default)]
pub struct QuitCard {
pub open: bool,
}
#[derive(Component)]
pub struct QuitUi;
#[derive(Component)]
pub struct QuitKeys;
pub fn quit_card_keys(tr: &crate::app::i18n::Tr, device: LastDevice) -> String {
match device {
LastDevice::Keyboard => tr.prompt_quitting.to_string(),
LastDevice::Pad => {
crate::app::gamepad::scheme::legend(tr, &crate::app::gamepad::Face::Quitting)
}
}
}
pub fn show_quit_card(
mut commands: Commands,
card: Res<QuitCard>,
(settings, device): (Res<GameSettings>, Res<LastDevice>),
drawn: Query<Entity, With<QuitUi>>,
mut keys: Query<&mut Text, With<QuitKeys>>,
) {
let tr = settings.tr();
if card.open == !drawn.is_empty() {
for mut text in &mut keys {
menu_ui::set_text(&mut text, &quit_card_keys(tr, *device));
}
return;
}
for entity in &drawn {
commands.entity(entity).despawn();
}
if !card.open {
return;
}
let legend = quit_card_keys(tr, *device);
commands
.spawn((
QuitUi,
GlobalZIndex(menu_ui::layer::CARD),
menu_ui::centred_overlay(),
))
.with_children(|wrap| {
wrap.spawn(menu_ui::screen_card()).with_children(|card| {
card.spawn((
Text::new(tr.quit_ask),
menu_ui::display_font(30.0),
TextColor(palette::GOLD),
));
card.spawn(menu_ui::spacer(10.0));
card.spawn((
QuitKeys,
Text::new(legend),
menu_ui::display_font(menu_ui::type_scale::ROW),
TextColor(palette::PARCHMENT),
));
});
});
}
pub fn close_quit_card(
mut card: ResMut<QuitCard>,
drawn: Query<Entity, With<QuitUi>>,
mut commands: Commands,
) {
card.open = false;
for entity in &drawn {
commands.entity(entity).despawn();
}
}
pub fn menu_input(
keys: Res<ButtonInput<KeyCode>>,
pads: crate::app::gamepad::face::PadsOnFace,
(mut quit, level_card): (ResMut<QuitCard>, Res<LevelCard>),
mut exit: MessageWriter<AppExit>,
mut list: ResMut<MenuList>,
launch: Launch,
) {
let Launch {
mut campaign,
mut daily,
mut next_screen,
} = launch;
use crate::app::gamepad::scheme::Does;
if level_card.is_open() {
return;
}
if quit.open {
if pads.pressed(Does::Quit) || menu_ui::enter(&keys) {
exit.write(AppExit::Success);
} else if pads.pressed(Does::Back) || keys.just_pressed(KeyCode::Escape) {
quit.open = false;
}
return;
}
if keys.just_pressed(KeyCode::Escape) {
exit.write(AppExit::Success);
return;
}
if pads.pressed(Does::Quit) {
quit.open = true;
return;
}
list.selected = menu_ui::nav(&keys, list.selected, MENU_ENTRY_COUNT);
if let Some(row) = menu_ui::number_pressed(&keys, MENU_ENTRY_COUNT) {
list.selected = row;
return;
}
if !menu_ui::enter(&keys) {
return;
}
match MenuEntry::ALL[list.selected] {
MenuEntry::TidePool => {
campaign.reset(crate::app::campaign::stage_list());
next_screen.set(Screen::StageSelect);
}
MenuEntry::TurfWar => next_screen.set(Screen::MatchSetup),
MenuEntry::Driftwood => next_screen.set(Screen::Editor),
MenuEntry::Replay => next_screen.set(Screen::Replays),
MenuEntry::BeachLobby => next_screen.set(Screen::Lobby),
MenuEntry::Settings => next_screen.set(Screen::Settings),
MenuEntry::Achievements => next_screen.set(Screen::Achievements),
MenuEntry::DailyChallenge => {
daily.active = true;
next_screen.set(Screen::Versus);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn menu_app() -> (App, Entity) {
use crate::app::gamepad::{Bridged, PadFace, PadPick, face::menu_face};
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.insert_state(Screen::Menu);
app.add_message::<AppExit>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<MenuList>();
app.init_resource::<QuitCard>();
app.init_resource::<LevelCard>();
app.init_resource::<crate::app::Daily>();
app.insert_resource(Campaign::tide_pool());
app.init_resource::<PadFace>();
app.init_resource::<Bridged>();
app.init_resource::<PadPick>();
app.init_resource::<Time>();
app.init_resource::<LastDevice>();
app.init_resource::<GameSettings>();
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
let face = |quit: Res<QuitCard>, card: Res<LevelCard>, mut face: ResMut<PadFace>| {
face.0 = menu_face(&quit, &card);
};
app.add_systems(
Update,
(
face,
crate::app::gamepad::pad_menu_bridge,
menu_input,
show_quit_card,
crate::app::gamepad::pad_bridge_release,
)
.chain(),
);
let pad = app.world_mut().spawn(Gamepad::default()).id();
(app, pad)
}
fn tap(app: &mut App, pad: Entity, button: GamepadButton) {
let mut gamepad = app.world_mut().get_mut::<Gamepad>(pad).expect("the pad");
gamepad.digital_mut().clear();
gamepad.digital_mut().press(button);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
let mut gamepad = app.world_mut().get_mut::<Gamepad>(pad).expect("the pad");
gamepad.digital_mut().release(button);
gamepad.digital_mut().clear();
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
}
fn quit_asked(app: &App) -> bool {
app.world().resource::<QuitCard>().open
}
fn left(app: &App) -> bool {
!app.world().resource::<Messages<AppExit>>().is_empty()
}
fn cards_drawn(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<Entity, With<QuitUi>>()
.iter(app.world())
.count()
}
#[test]
fn a_pads_start_asks_before_leaving() {
use crate::app::gamepad::scheme::{Face, legend};
let tr = &crate::app::i18n::EN;
assert!(legend(tr, &Face::Menu).contains("Start: quit"));
let (mut app, pad) = menu_app();
app.update();
tap(&mut app, pad, GamepadButton::Start);
assert!(quit_asked(&app), "Start raises the card");
assert!(!left(&app), "and leaves nothing yet");
assert_eq!(cards_drawn(&mut app), 1);
tap(&mut app, pad, GamepadButton::DPadDown);
assert_eq!(app.world().resource::<MenuList>().selected, 0);
tap(&mut app, pad, GamepadButton::East);
assert!(!quit_asked(&app), "B puts it away");
assert_eq!(cards_drawn(&mut app), 0);
assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
tap(&mut app, pad, GamepadButton::Start);
tap(&mut app, pad, GamepadButton::Start);
assert!(!quit_asked(&app), "so does Start again");
assert!(!left(&app));
tap(&mut app, pad, GamepadButton::DPadDown);
assert_eq!(
app.world().resource::<MenuList>().selected,
1,
"the menu walks again"
);
tap(&mut app, pad, GamepadButton::Start);
let mut gamepad = app.world_mut().get_mut::<Gamepad>(pad).expect("the pad");
gamepad.digital_mut().press(GamepadButton::South);
app.update();
assert!(left(&app), "A on the card leaves the game");
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Menu,
"and launches nothing on the way"
);
}
#[test]
fn the_quit_card_answers_the_keyboard_and_says_so() {
let tr = &crate::app::i18n::EN;
assert_eq!(
quit_card_keys(tr, LastDevice::Keyboard),
"Esc: back | Enter: quit"
);
assert_eq!(
quit_card_keys(tr, LastDevice::Pad),
"A: quit | B/Start: back"
);
let (mut app, pad) = menu_app();
app.update();
let key = |app: &mut App, key: KeyCode| {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.update();
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.release(key);
keys.clear();
app.update();
};
let line = |app: &mut App| {
app.world_mut()
.query_filtered::<&Text, With<QuitKeys>>()
.single(app.world())
.map(|text| text.0.clone())
.expect("the card's keys")
};
tap(&mut app, pad, GamepadButton::Start);
assert!(quit_asked(&app));
app.insert_resource(LastDevice::Keyboard);
app.update();
assert_eq!(line(&mut app), "Esc: back | Enter: quit");
app.insert_resource(LastDevice::Pad);
app.update();
assert_eq!(line(&mut app), "A: quit | B/Start: back");
key(&mut app, KeyCode::Escape);
assert!(!quit_asked(&app), "Esc puts the card away");
assert!(!left(&app), "and leaves nothing");
tap(&mut app, pad, GamepadButton::Start);
key(&mut app, KeyCode::Enter);
assert!(left(&app), "Enter on the card leaves the game");
assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
}
#[test]
fn escape_leaves_at_once() {
let (mut app, _) = menu_app();
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
assert!(left(&app));
assert!(!quit_asked(&app), "without asking");
}
#[test]
fn a_digit_points_and_enter_launches() {
let (mut app, _) = menu_app();
let press = |app: &mut App, key: KeyCode| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.press(key);
app.update();
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.release(key);
keys.clear();
app.update();
};
press(&mut app, KeyCode::Digit3);
assert_eq!(app.world().resource::<MenuList>().selected, 2);
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Menu,
"a digit launches nothing"
);
press(&mut app, KeyCode::Enter);
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Editor,
"Enter launches the row pointed at"
);
}
}