Skip to main content

pinch_points/app/match_setup/
screen.rs

1//! The match-setup screen: the rows, the keys that move them, and the seat
2//! names typed into them.
3//!
4//! Split from the model the way settings is, so the dials and their wire
5//! form stay readable without the UI wrapped around them.
6
7use super::*;
8use crate::app::i18n::fill;
9use crate::app::menu_ui;
10use crate::app::settings::GameSettings;
11
12#[derive(Resource, Default)]
13pub struct MatchMenu {
14    pub selected: usize,
15    /// The seat whose name is being typed, if any. While this is set the
16    /// keyboard belongs to the name: navigation and launching wait.
17    pub naming: Option<u8>,
18}
19
20/// A row of the card, by its index. What the row *is* rides on its cells:
21/// the row node itself only ever folds away or takes the cursor's band.
22#[derive(Component)]
23pub struct MatchRow(pub usize);
24
25/// One half of a row's text: the dial's name, or what it is set to.
26#[derive(Component)]
27pub struct MatchCell(pub usize, pub Row, pub menu_ui::Half);
28
29#[derive(Component)]
30pub struct MatchUi;
31
32/// The controller-join footer: hint plus the joined list.
33#[derive(Component)]
34pub struct MatchPadInfo(pub bool);
35
36/// The line under the card saying why the map dial is offering none of the
37/// player's own beaches. Empty the rest of the time, and spawned either
38/// way, so the footer below it does not shift when it appears.
39#[derive(Component)]
40pub struct MatchBeachNote;
41
42pub fn enter_match_setup(
43    mut commands: Commands,
44    settings: Res<GameSettings>,
45    mut menu: ResMut<MatchMenu>,
46    mut config: ResMut<MatchConfig>,
47    beaches: Res<CustomBeaches>,
48) {
49    menu.selected = 0;
50    menu.naming = None;
51    // The shelf may have changed since the last visit (a beach deleted, or
52    // resaved with fewer castles): a config still pointing at it is moved
53    // on, so the dial reads what will be played.
54    crate::app::match_setup::settle_map(&mut config, &beaches);
55    let tr = settings.tr();
56    commands
57        .spawn((MatchUi, menu_ui::between_bars()))
58        .with_children(|wrap| {
59            // A fixed height, because the rows that come and go fold away
60            // rather than blanking: four AI-level rows in the middle of the
61            // card would be four holes if they merely emptied, and a card
62            // that shrinks around them jumps while you turn the dial that
63            // adds them.
64            let (mark, mut node, fill, edge, shadow) = menu_ui::screen_card();
65            node.height = Val::Px(
66                2.0 * menu_ui::CARD_PAD_Y
67                    + menu_ui::HEADING_H
68                    + ROWS as f32 * (menu_ui::ROW_H + menu_ui::ROW_GAP),
69            );
70            node.justify_content = JustifyContent::FlexStart;
71            wrap.spawn(Node {
72                align_items: AlignItems::Center,
73                ..default()
74            })
75            .with_children(|line| {
76                line.spawn((mark, node, fill, edge, shadow))
77                    .with_children(|card| {
78                        menu_ui::heading_row(card, tr.match_heading, None);
79                        for row in 0..ROWS {
80                            card.spawn((MatchRow(row), menu_ui::card_row()))
81                                .with_children(|line| {
82                                    for (half, width) in [
83                                        (menu_ui::Half::Label, LABEL_W),
84                                        (menu_ui::Half::Value, VALUE_W),
85                                    ] {
86                                        line.spawn((
87                                            MatchCell(row, Row::ALL[row], half),
88                                            menu_ui::cell(width, ROW_FONT),
89                                        ));
90                                    }
91                                });
92                        }
93                    });
94            });
95            // The beach note sits first, right under the card and so under
96            // the map row it is about, and in the parchment the rows use
97            // rather than the controller footer's grey: it is about the
98            // choice being made, not a standing hint.
99            wrap.spawn((
100                MatchBeachNote,
101                // A row of its own height whether or not it has anything to
102                // say: an empty text node collapses, and the controller
103                // footer under it would step up the screen every time the
104                // seat count crossed what the beaches can hold.
105                Node {
106                    height: Val::Px(20.0),
107                    ..default()
108                },
109                Text::new(""),
110                TextFont {
111                    font_size: FontSize::Px(menu_ui::type_scale::BODY),
112                    ..default()
113                },
114                TextLayout::no_wrap(),
115                TextColor(palette::PARCHMENT.with_alpha(0.65)),
116            ));
117            for is_list in [false, true] {
118                wrap.spawn((
119                    MatchPadInfo(is_list),
120                    Text::new(""),
121                    TextFont {
122                        font_size: FontSize::Px(menu_ui::type_scale::BODY),
123                        ..default()
124                    },
125                    TextLayout::no_wrap(),
126                    TextColor(palette::PARCHMENT.with_alpha(0.40)),
127                ));
128            }
129        });
130}
131
132/// Label gutter and value gutter, the value including the `< >` the dial
133/// wears. Two cells, the way every other card in the shell does it, rather
134/// than one cell holding a label padded out with spaces.
135///
136/// Padding was `{:<15}`, and Rust counts that width in `char`s. Two scripts
137/// break that. The Latin ones overrun it - "Niveau van de AI S1" is
138/// nineteen characters - and pushed the dial right on those rows alone.
139/// Japanese undershoots it and still comes out wider: the shipped CJK face
140/// draws a full em where DejaVu Sans Mono draws 0.602, so a two-character
141/// label padded to fifteen is 17 columns wide and a six-character one is
142/// 21, and no number of spaces closes a gap that is 1.66 of one. A cell of
143/// a fixed pixel width has no opinion about either.
144///
145/// Wide enough for the longest label and the longest built-in value in any
146/// language, measured against those two faces. What the player types - a
147/// seat name, a handmade beach's name - is not bounded by anything here and
148/// clips, which is the same contract the settings card keeps.
149pub(super) const LABEL_W: f32 = 220.0;
150pub(super) const VALUE_W: f32 = 428.0;
151/// The scale's row size, named rather than repeated: it happened to be
152/// the same number already, which is not the same as saying so.
153pub(super) const ROW_FONT: f32 = menu_ui::type_scale::ROW;
154
155/// Keep the controller footer current: the join hint, and who joined -
156/// and the note about beaches this table has grown too big for.
157pub fn update_match_pad_info(
158    seats: Res<crate::app::gamepad::PadSeats>,
159    config: Res<MatchConfig>,
160    settings: Res<GameSettings>,
161    beaches: Res<CustomBeaches>,
162    mut rows: Query<(&MatchPadInfo, &mut Text)>,
163    mut note: Query<&mut Text, (With<MatchBeachNote>, Without<MatchPadInfo>)>,
164) {
165    let tr = settings.tr();
166    if let Ok(mut text) = note.single_mut() {
167        let line = crate::app::match_setup::beaches_note(&config, tr, &beaches).unwrap_or_default();
168        menu_ui::set_text(&mut text, &line);
169    }
170    let humans = config.seats - config.bots;
171    for (info, mut text) in &mut rows {
172        let line = if info.0 {
173            if seats.0.is_empty() {
174                String::new()
175            } else {
176                // Pad claim i drives the i-th human seat from the top.
177                let list: Vec<String> = (0..seats.0.len())
178                    .map(|i| {
179                        let seat = humans.saturating_sub(1 + i as u8);
180                        crate::app::seat_label(tr, seat)
181                    })
182                    .collect();
183                fill(tr.match_pad_joined, &[("list", &list.join(", "))])
184            }
185        } else {
186            tr.match_pad_hint.to_string()
187        };
188        menu_ui::set_text(&mut text, &line);
189    }
190}
191
192/// The seat the `slot`-th AI row configures: the AI fills the top seats, so
193/// slot 0 is the highest one. `None` once the slot is past the AI count.
194pub(super) fn ai_seat(config: &MatchConfig, slot: u8) -> Option<u8> {
195    (slot < config.bots).then(|| config.seats - 1 - slot)
196}
197
198/// Step the difficulty of the AI seat behind `slot`. A dead slot (the row
199/// is hidden) changes nothing.
200pub(super) fn cycle_ai_level(config: &mut MatchConfig, slot: u8, turn: Turn) {
201    if let Some(seat) = ai_seat(config, slot) {
202        config.bot_levels[seat as usize] = config.bot_levels[seat as usize].cycled(turn);
203    }
204}
205
206/// Which rows are showing right now: the AI-level rows appear one per AI
207/// seat, so a two-human match has none of them.
208pub(super) fn live_rows(config: &MatchConfig) -> [bool; ROWS] {
209    std::array::from_fn(|row| match Row::ALL[row] {
210        Row::BotLevel(slot) => ai_seat(config, slot).is_some(),
211        Row::Name(seat) => seat < config.seats,
212        Row::Players | Row::Bots | Row::Map | Row::Gulls | Row::Round | Row::Mode => true,
213    })
214}
215
216#[allow(clippy::too_many_arguments)]
217pub fn match_setup_input(
218    keys: Res<ButtonInput<KeyCode>>,
219    beaches: Res<CustomBeaches>,
220    mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
221    mut menu: ResMut<MatchMenu>,
222    mut config: ResMut<MatchConfig>,
223    mut settings: ResMut<GameSettings>,
224    mut tournament: ResMut<crate::app::tournament::Tournament>,
225    mut next_screen: ResMut<NextState<Screen>>,
226) {
227    if let Some(seat) = menu.naming {
228        type_a_name(seat, &mut typed, &keys, &mut settings, &mut menu);
229        return;
230    }
231    // Nothing typed while not naming, but the reader must still be drained
232    // or the first keystroke of a rename arrives with a backlog.
233    typed.clear();
234    if keys.just_pressed(KeyCode::Escape) {
235        next_screen.set(Screen::Menu);
236        return;
237    }
238    // Tab opens a name for typing, not Enter. Enter used to do it, and on
239    // a name row that meant Enter could never do the other thing it says
240    // it does: the name rows are last on the list, so a player who had
241    // just named everyone pressed Enter to start and got the name box
242    // again, and again.
243    if keys.just_pressed(KeyCode::Tab)
244        && let Row::Name(seat) = Row::ALL[menu.selected]
245    {
246        menu.naming = Some(seat);
247        return;
248    }
249    if menu_ui::enter(&keys) {
250        config.armed = true;
251        *tournament = if config.series.is_series() {
252            crate::app::tournament::Tournament::start(config.series)
253        } else {
254            crate::app::tournament::Tournament::default()
255        };
256        next_screen.set(Screen::Versus);
257        return;
258    }
259    menu.selected = menu_ui::nav_live(&keys, menu.selected, &live_rows(&config));
260    let Some(turn) = menu_ui::left_right(&keys) else {
261        return;
262    };
263    match Row::ALL[menu.selected] {
264        Row::Players => {
265            config.seats = crate::app::cycle::dial(config.seats, turn, 1, 2..=MAX_PLAYERS as u8);
266            config.bots = config.bots.min(config.seats - 1);
267            // Five and six castles need a beach with room for them, and a
268            // handmade beach only seats as many as it has castles: the map
269            // follows the table.
270            crate::app::match_setup::settle_map(&mut config, &beaches);
271        }
272        Row::Bots => {
273            config.bots = crate::app::cycle::dial(config.bots, turn, 1, 0..=config.seats - 1);
274        }
275        Row::BotLevel(slot) => cycle_ai_level(&mut config, slot, turn),
276        Row::Map => {
277            crate::app::match_setup::cycle_map(&mut config, turn, &beaches);
278            // Stepping down to a small beach drops the seats it cannot hold
279            // rather than starting a match six players cannot all sit at.
280            if config.map.size().0 < WIDE_ENOUGH {
281                config.seats = config.seats.min(CLASSIC_SEATS);
282                config.bots = config.bots.min(config.seats - 1);
283            }
284        }
285        Row::Gulls => config.gulls = config.gulls.cycled(turn),
286        Row::Round => config.round = config.round.cycled(turn),
287        Row::Mode => config.series = config.series.cycled(turn),
288        // A name is typed, not stepped through.
289        Row::Name(_) => {}
290    }
291}
292
293/// The keyboard belongs to one seat's name: characters land in it, Backspace
294/// rubs one out, Enter or Esc hands the keyboard back. Bevy's `text` field
295/// is used rather than the key codes so the player's own layout, and their
296/// shift key, decide what a keystroke means.
297fn type_a_name(
298    seat: u8,
299    typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
300    keys: &ButtonInput<KeyCode>,
301    settings: &mut GameSettings,
302    menu: &mut MatchMenu,
303) {
304    use crate::app::typing::{Keystroke, keystrokes};
305    let ends = [
306        KeyCode::Enter,
307        KeyCode::NumpadEnter,
308        KeyCode::Escape,
309        KeyCode::Tab,
310    ];
311    for stroke in keystrokes(typed, &ends) {
312        match stroke {
313            Keystroke::Erase => settings.pop_name_char(seat),
314            Keystroke::Char(ch) => settings.push_name_char(seat, ch),
315            // The finish is decided below from just_pressed, one branch
316            // for every way out.
317            Keystroke::Done(_) => {}
318        }
319    }
320    // Every way out of the name box puts it away and nothing else: Enter
321    // does not also start the match here, or a player finishing a name
322    // would find the round already running.
323    let done = menu_ui::enter(keys)
324        || keys.just_pressed(KeyCode::Escape)
325        || keys.just_pressed(KeyCode::Tab);
326    if done {
327        settings.tidy_name(seat);
328        menu.naming = None;
329    }
330}
331
332/// The two halves of a row: the dial's name, and what it is set to.
333///
334/// Pure, and separate from the system below, so every language's rows can
335/// be measured against the cells that hold them without a `World`. The
336/// value half carries its own `< >`, since the one row that is typed into
337/// rather than stepped through wears neither.
338pub(super) fn row_text(
339    tr: &crate::app::i18n::Tr,
340    config: &MatchConfig,
341    settings: &GameSettings,
342    beaches: &CustomBeaches,
343    naming: Option<u8>,
344    row: Row,
345) -> (String, String) {
346    let dial = |value: &str| format!("< {value} >");
347    match row {
348        Row::Players => (
349            tr.match_players.to_string(),
350            dial(&config.seats.to_string()),
351        ),
352        Row::Bots => {
353            let humans = config.seats - config.bots;
354            let rest = if humans == 1 {
355                tr.human_one.to_string()
356            } else {
357                fill(tr.human_many, &[("n", &humans.to_string())])
358            };
359            (
360                tr.match_ai.to_string(),
361                format!("{}   ({rest})", dial(&config.bots.to_string())),
362            )
363        }
364        Row::BotLevel(slot) => {
365            let seat = ai_seat(config, slot).unwrap_or(0);
366            let who = crate::app::seat_label(tr, seat);
367            (
368                format!("{} {who}", tr.match_ai_level),
369                dial(tr.bot_levels[config.bot_levels[seat as usize].index()]),
370            )
371        }
372        // What the dial is *not* offering rides under the card, not here:
373        // the value is one fixed-width cell, and the sentence runs off the
374        // end of it in every language.
375        Row::Map => (
376            tr.match_map.to_string(),
377            dial(&crate::app::match_setup::map_label(config, tr, beaches)),
378        ),
379        Row::Gulls => (
380            tr.match_gulls.to_string(),
381            dial(tr.gull_names[config.gulls.index()]),
382        ),
383        Row::Round => (
384            tr.match_round.to_string(),
385            dial(tr.round_names[config.round.index()]),
386        ),
387        Row::Mode => (
388            tr.match_mode.to_string(),
389            dial(tr.mode_names[config.series.index()]),
390        ),
391        Row::Name(seat) => {
392            let who = crate::app::seat_label(tr, seat);
393            let given = settings.names[usize::from(seat)].clone();
394            let value = if naming == Some(seat) {
395                // A caret says the keyboard is being typed into rather than
396                // navigating; the row is its own instructions.
397                format!("{given}_   {}", tr.match_name_typing)
398            } else if given.is_empty() {
399                tr.match_name_empty.to_string()
400            } else {
401                given
402            };
403            (format!("{} {who}", tr.match_name), value)
404        }
405    }
406}
407
408pub fn update_match_ui(
409    config: Res<MatchConfig>,
410    menu: Res<MatchMenu>,
411    settings: Res<GameSettings>,
412    beaches: Res<CustomBeaches>,
413    mut cells: Query<(&MatchCell, &mut Text, &mut TextColor)>,
414    mut rows: Query<(&MatchRow, &mut BackgroundColor, &mut Node)>,
415) {
416    let tr = settings.tr();
417    // One source of truth for which rows apply right now, shared with the
418    // navigation: a row the cursor cannot reach must not be on screen.
419    let live = live_rows(&config);
420    for (cell, mut text, mut color) in &mut cells {
421        if !live[cell.0] {
422            continue;
423        }
424        let (label, value) = row_text(tr, &config, &settings, &beaches, menu.naming, cell.1);
425        let half = match cell.2 {
426            menu_ui::Half::Label => label,
427            menu_ui::Half::Value => value,
428        };
429        menu_ui::set_text(&mut text, &half);
430        menu_ui::set_color(
431            &mut color,
432            if cell.0 == menu.selected {
433                Color::WHITE
434            } else {
435                palette::PARCHMENT.with_alpha(0.80)
436            },
437        );
438    }
439    for (row, mut fill, mut node) in &mut rows {
440        // A row that does not apply leaves no trace: it folds out of the
441        // column entirely. The card is a fixed height, so the ones that
442        // remain do not move when it does.
443        menu_ui::set_shown(&mut node, live[row.0]);
444        let ground = menu_ui::band(row.0 == menu.selected && live[row.0]);
445        menu_ui::set_bg(&mut fill, ground);
446    }
447}