use super::*;
use crate::app::SeatController;
use crate::app::i18n::fill;
use crate::app::menu_ui;
use crate::app::settings::GameSettings;
#[derive(Resource, Default)]
pub struct MatchMenu {
pub selected: usize,
pub naming: Option<u8>,
pub p2_here: bool,
pub launched: bool,
}
pub(super) fn has_an_opponent(config: &MatchConfig, pads_joined: usize, p2_here: bool) -> bool {
config.bots > 0 || pads_joined > 0 || p2_here
}
pub(super) fn p2_pressed(keys: &ButtonInput<KeyCode>, settings: &GameSettings) -> bool {
use crate::app::cursor::{KeyMap, keymap};
let all = |map: KeyMap| {
let KeyMap {
moves,
places,
remove,
clear_all,
} = map;
moves
.into_iter()
.map(|(key, ..)| key)
.chain(places.into_iter().map(|(key, _)| key))
.chain([remove, clear_all])
.collect::<Vec<KeyCode>>()
};
let menu = [
KeyCode::Enter,
KeyCode::NumpadEnter,
KeyCode::Escape,
KeyCode::Tab,
KeyCode::Backspace,
];
let p1 = all(keymap(settings, 0, settings.commit));
all(keymap(settings, 1, settings.commit))
.into_iter()
.filter(|key| !p1.contains(key) && !menu.contains(key))
.any(|key| keys.just_pressed(key))
}
#[derive(Component)]
pub struct MatchRow(pub usize);
#[derive(Component)]
pub struct MatchCell(pub usize, pub Row, pub menu_ui::Half);
#[derive(Component)]
pub struct MatchUi;
#[derive(Component)]
pub struct MatchPadInfo(pub bool);
#[derive(Component)]
pub struct MatchBeachNote;
pub fn enter_match_setup(
mut commands: Commands,
settings: Res<GameSettings>,
mut menu: ResMut<MatchMenu>,
mut config: ResMut<MatchConfig>,
beaches: Res<CustomBeaches>,
) {
menu.selected = 0;
menu.naming = None;
menu.p2_here = false;
settle_map(&mut config, &beaches);
let tr = settings.tr();
commands
.spawn((
MatchUi,
Node {
row_gap: Val::Px(FOOTER_GAP),
..menu_ui::between_bars()
},
))
.with_children(|wrap| {
let (mark, mut node, fill, edge, shadow) = menu_ui::screen_card();
node.row_gap = Val::Px(ROW_GAP);
node.height = Val::Px(card_height());
node.justify_content = JustifyContent::FlexStart;
wrap.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
min_height: Val::Px(0.0),
..default()
})
.with_children(|line| {
line.spawn((mark, node, fill, edge, shadow))
.with_children(|card| {
menu_ui::heading_row(card, tr.match_heading, None);
for row in 0..ROWS {
card.spawn((MatchRow(row), menu_ui::card_row()))
.with_children(|line| {
for (half, width) in [
(menu_ui::Half::Label, LABEL_W),
(menu_ui::Half::Value, VALUE_W),
] {
line.spawn((
MatchCell(row, Row::ALL[row], half),
menu_ui::cell(width, ROW_FONT),
));
}
});
}
});
});
wrap.spawn((
MatchBeachNote,
Node {
height: Val::Px(20.0),
..default()
},
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT.with_alpha(0.65)),
));
for is_list in [false, true] {
wrap.spawn((
MatchPadInfo(is_list),
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextLayout::no_wrap(),
TextColor(footer_ink()),
Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(9.0)),
..default()
},
BackgroundColor(Color::NONE),
));
}
});
}
pub(super) fn card_height() -> f32 {
2.0 * menu_ui::CARD_PAD
+ menu_ui::HEADING_H
+ ROWS as f32 * (menu_ui::row_pitch(ROW_FONT) + ROW_GAP)
}
pub(super) const LABEL_W: f32 = 220.0;
pub(super) const VALUE_W: f32 = 428.0;
pub(super) const ROW_GAP: f32 = 0.0;
pub(super) const FOOTER_GAP: f32 = 4.0;
pub(super) const ROW_FONT: f32 = menu_ui::type_scale::ROW;
fn footer_ink() -> Color {
palette::PARCHMENT.with_alpha(0.40)
}
type FooterLine = (
&'static MatchPadInfo,
&'static mut Text,
&'static mut TextColor,
&'static mut BackgroundColor,
);
pub fn update_match_pad_info(
seats: Res<crate::app::gamepad::PadSeats>,
menu: Res<MatchMenu>,
config: Res<MatchConfig>,
settings: Res<GameSettings>,
beaches: Res<CustomBeaches>,
mut rows: Query<FooterLine>,
mut note: Query<&mut Text, (With<MatchBeachNote>, Without<MatchPadInfo>)>,
) {
let tr = settings.tr();
if let Ok(mut text) = note.single_mut() {
let line = beaches_note(&config, tr, &beaches).unwrap_or_default();
menu_ui::set_text(&mut text, &line);
}
let humans = config.seats - config.bots;
let alone = !has_an_opponent(&config, seats.0.len(), menu.p2_here);
for (info, mut text, mut ink, mut pill) in &mut rows {
let warning = !info.0 && alone;
let (want_ink, want_pill) = if warning {
(palette::GOLD, palette::PILL_FILL)
} else {
(footer_ink(), Color::NONE)
};
menu_ui::set_color(&mut ink, want_ink);
menu_ui::set_bg(&mut pill, want_pill);
let line = if info.0 {
if seats.0.is_empty() {
String::new()
} else {
let list: Vec<String> = (0..seats.0.len())
.map(|i| {
let seat = humans.saturating_sub(1 + i as u8);
crate::app::seat_label(tr, seat)
})
.collect();
fill(tr.match_pad_joined, &[("list", &list.join(", "))])
}
} else if warning {
tr.match_needs_opponent.to_string()
} else {
tr.match_pad_hint.to_string()
};
menu_ui::set_text(&mut text, &line);
}
}
pub(super) fn ai_seat(config: &MatchConfig, slot: u8) -> Option<u8> {
(slot < config.bots).then(|| config.seats - 1 - slot)
}
const OPPONENTS: [SeatController; 4] = [
SeatController::Ai(BotLevel::Easy),
SeatController::Ai(BotLevel::Normal),
SeatController::Ai(BotLevel::Hard),
SeatController::Bot,
];
pub(super) fn cycle_ai_level(config: &mut MatchConfig, slot: u8, turn: Turn) {
if let Some(seat) = ai_seat(config, slot) {
let now = config.controllers[usize::from(seat)];
let at = OPPONENTS.iter().position(|c| *c == now).unwrap_or(1);
let next = crate::app::cycle::dial(at as u8, turn, 1, 0..=OPPONENTS.len() as u8 - 1);
config.controllers[usize::from(seat)] = OPPONENTS[usize::from(next)];
}
}
pub fn bot_seat_list(config: &MatchConfig) -> Vec<u8> {
(0..config.seats)
.filter(|&seat| config.controller(seat) == SeatController::Bot)
.collect()
}
pub(super) fn live_rows(config: &MatchConfig) -> [bool; ROWS] {
std::array::from_fn(|row| match Row::ALL[row] {
Row::BotLevel(slot) => ai_seat(config, slot).is_some(),
Row::Name(seat) => seat < config.seats,
Row::Players | Row::Bots | Row::Map | Row::Gulls | Row::Round | Row::Posts | Row::Mode => {
true
}
})
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct MatchStart<'w> {
tournament: ResMut<'w, crate::app::tournament::Tournament>,
next_screen: ResMut<'w, NextState<Screen>>,
bots: ResMut<'w, crate::app::bot_seats::BotSeats>,
}
impl MatchStart<'_> {
fn launch(&mut self, config: &mut MatchConfig) {
config.armed = true;
*self.tournament = if config.series.is_series() {
crate::app::tournament::Tournament::start(config.series)
} else {
crate::app::tournament::Tournament::default()
};
self.next_screen.set(Screen::Versus);
}
fn bots_in(&mut self, config: &MatchConfig, tr: &crate::app::i18n::Tr) -> bool {
use crate::app::bot_seats::{Doorway, Waiting};
let seats = bot_seat_list(config);
if seats.is_empty() {
self.bots.door = None;
return true;
}
if self.bots.door.is_none() {
match Doorway::open() {
Ok(door) => self.bots.door = Some(door),
Err(e) => {
self.bots.feedback = fill(tr.door_could_not_listen, &[("e", &e.to_string())]);
return false;
}
}
}
let Some(door) = &mut self.bots.door else {
return false;
};
door.seat_bots(&seats);
door.poll();
if door.ready() {
return true;
}
self.bots.waiting = Some(Waiting::Couch);
false
}
}
pub fn match_setup_input(
keys: Res<ButtonInput<KeyCode>>,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
mut menu: ResMut<MatchMenu>,
dials: Dials,
mut settings: ResMut<GameSettings>,
start: MatchStart,
pads: Res<crate::app::gamepad::PadSeats>,
) {
let Dials {
mut config,
beaches,
} = dials;
let mut start = start;
if start.bots.waiting.is_some() {
if start
.bots
.door
.as_ref()
.is_some_and(crate::app::bot_seats::Doorway::ready)
{
start.bots.waiting = None;
start.bots.feedback.clear();
start.launch(&mut config);
}
typed.clear();
return;
}
if let Some(seat) = menu.naming {
type_a_name(seat, &mut typed, &keys, &mut settings, &mut menu);
return;
}
typed.clear();
if keys.just_pressed(KeyCode::Escape) {
start.next_screen.set(Screen::Menu);
return;
}
if keys.just_pressed(KeyCode::Tab)
&& let Row::Name(seat) = Row::ALL[menu.selected]
{
menu.naming = Some(seat);
return;
}
if p2_pressed(&keys, &settings) {
menu.p2_here = true;
}
let hooked = crate::app::dev::launch() && !std::mem::replace(&mut menu.launched, true);
if menu_ui::enter(&keys) || hooked {
if !has_an_opponent(&config, pads.0.len(), menu.p2_here) {
return;
}
if start.bots_in(&config, settings.tr()) {
start.launch(&mut config);
}
return;
}
menu.selected = menu_ui::nav_live(&keys, menu.selected, &live_rows(&config));
let Some(turn) = menu_ui::left_right(&keys) else {
return;
};
match Row::ALL[menu.selected] {
Row::Players => {
config.seats = crate::app::cycle::dial(config.seats, turn, 1, 2..=MAX_PLAYERS as u8);
config.bots = config.bots.min(config.seats - 1);
if config.map.is_a_plain_size() {
config.map = sized_for(config.seats);
}
settle_map(&mut config, &beaches);
}
Row::Bots => {
config.bots = crate::app::cycle::dial(config.bots, turn, 1, 0..=config.seats - 1);
}
Row::BotLevel(slot) => cycle_ai_level(&mut config, slot, turn),
Row::Map => {
cycle_map(&mut config, turn, &beaches);
if config.map.size().0 < WIDE_ENOUGH {
config.seats = config.seats.min(CLASSIC_SEATS);
config.bots = config.bots.min(config.seats - 1);
}
}
Row::Gulls => config.gulls = config.gulls.cycled(turn),
Row::Round => config.round = config.round.cycled(turn),
Row::Posts => config.posts = crate::app::cycle::dial(config.posts, turn, 1, POSTS_RANGE),
Row::Mode => config.series = config.series.cycled(turn),
Row::Name(_) => {}
}
}
fn type_a_name(
seat: u8,
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
keys: &ButtonInput<KeyCode>,
settings: &mut GameSettings,
menu: &mut MatchMenu,
) {
use crate::app::typing::{Keystroke, keystrokes};
let ends = [
KeyCode::Enter,
KeyCode::NumpadEnter,
KeyCode::Escape,
KeyCode::Tab,
];
for stroke in keystrokes(typed, &ends) {
match stroke {
Keystroke::Erase => settings.pop_name_char(seat),
Keystroke::Char(ch) => settings.push_name_char(seat, ch),
Keystroke::Done(_) => {}
}
}
let done = menu_ui::enter(keys)
|| keys.just_pressed(KeyCode::Escape)
|| keys.just_pressed(KeyCode::Tab);
if done {
settings.tidy_name(seat);
menu.naming = None;
}
}
pub(super) fn row_text(
tr: &crate::app::i18n::Tr,
config: &MatchConfig,
settings: &GameSettings,
beaches: &CustomBeaches,
naming: Option<u8>,
row: Row,
) -> (String, String) {
let dial = |value: &str| format!("< {value} >");
match row {
Row::Players => (
tr.match_players.to_string(),
dial(&config.seats.to_string()),
),
Row::Bots => {
let humans = config.seats - config.bots;
let rest = if humans == 1 {
tr.human_one.to_string()
} else {
fill(tr.human_many, &[("n", &humans.to_string())])
};
(
tr.match_ai.to_string(),
format!("{} ({rest})", dial(&config.bots.to_string())),
)
}
Row::BotLevel(slot) => {
let seat = ai_seat(config, slot).unwrap_or(0);
let who = crate::app::seat_label(tr, seat);
let value = match config.controller(seat) {
SeatController::Bot => tr.match_bot,
SeatController::Ai(_) | SeatController::Local | SeatController::Remote => {
tr.bot_levels[config.level(seat).index()]
}
};
(format!("{} {who}", tr.match_ai_level), dial(value))
}
Row::Map => (
tr.match_map.to_string(),
dial(&map_label(config, tr, beaches)),
),
Row::Gulls => (
tr.match_gulls.to_string(),
dial(tr.gull_names[config.gulls.index()]),
),
Row::Round => (
tr.match_round.to_string(),
dial(tr.round_names[config.round.index()]),
),
Row::Posts => (tr.match_posts.to_string(), dial(&config.posts.to_string())),
Row::Mode => (
tr.match_mode.to_string(),
dial(tr.mode_names[config.series.index()]),
),
Row::Name(seat) => {
let who = crate::app::seat_label(tr, seat);
let given = settings.names[usize::from(seat)].clone();
let value = if naming == Some(seat) {
format!("{given}_ {}", tr.match_name_typing)
} else if given.is_empty() {
tr.match_name_empty.to_string()
} else {
given
};
(format!("{} {who}", tr.match_name), value)
}
}
}
pub fn update_match_ui(
config: Res<MatchConfig>,
menu: Res<MatchMenu>,
settings: Res<GameSettings>,
beaches: Res<CustomBeaches>,
mut cells: Query<(&MatchCell, &mut Text, &mut TextColor)>,
mut rows: Query<(&MatchRow, &mut BackgroundColor, &mut Node)>,
) {
let tr = settings.tr();
let live = live_rows(&config);
for (cell, mut text, mut color) in &mut cells {
if !live[cell.0] {
continue;
}
let (label, value) = row_text(tr, &config, &settings, &beaches, menu.naming, cell.1);
let half = match cell.2 {
menu_ui::Half::Label => label,
menu_ui::Half::Value => value,
};
menu_ui::set_text(&mut text, &half);
menu_ui::set_color(
&mut color,
if cell.0 == menu.selected {
Color::WHITE
} else {
palette::PARCHMENT.with_alpha(0.80)
},
);
}
for (row, mut fill, mut node) in &mut rows {
menu_ui::set_shown(&mut node, live[row.0]);
let ground = menu_ui::band(row.0 == menu.selected && live[row.0]);
menu_ui::set_bg(&mut fill, ground);
}
}