use super::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ListRows(pub usize);
impl Default for ListRows {
fn default() -> Self {
Self(12)
}
}
pub const LIST_ROW_SLOTS: usize = 40;
const FEWEST_LIST_ROWS: usize = 3;
const LIST_GAP: f32 = 4.0;
const LIST_NAME_PX: f32 = 21.0;
const LIST_ROW_PAD: f32 = 4.0;
const LIST_ROW_H: f32 = LIST_NAME_PX * crate::app::menu_ui::LINE_HEIGHT + 2.0 * LIST_ROW_PAD;
#[derive(Component)]
pub struct LobbyListRoom;
impl crate::app::menu_ui::scroll_bar::Scrolls for LobbyState {
fn window(&self) -> crate::app::menu_ui::scroll_bar::ScrollWindow {
use crate::app::menu_ui::scroll_bar::ScrollWindow;
match self.standing().at_a_beach() {
true => ScrollWindow::NONE,
false => ScrollWindow::rows(self.scroll, self.rows_shown(), self.hosts.len()),
}
}
}
pub fn fit_lobby_list(
mut state: ResMut<LobbyState>,
room: Query<&ComputedNode, With<LobbyListRoom>>,
rows: Query<&ComputedNode, With<LobbyRow>>,
) {
let Ok(room) = room.single() else {
return;
};
let row = rows
.iter()
.map(|node| node.unrounded_size.y)
.fold(0.0, f32::max);
let room_y = room.unrounded_size.y;
if room_y <= 0.0 || row <= 0.0 {
return;
}
let gap = LIST_GAP / room.inverse_scale_factor;
let fit = crate::app::menu_ui::scroll_bar::rows_that_fit(room_y, row, gap)
.clamp(FEWEST_LIST_ROWS, LIST_ROW_SLOTS);
if state.rows_shown() != fit {
state.fit_rows(fit);
}
}
#[derive(Component)]
pub struct LobbyUi;
#[derive(Component)]
pub struct LobbyRow(pub usize);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ListCol {
Name,
Host,
Where,
Table,
}
#[derive(Component)]
pub struct LobbyCell(pub usize, pub ListCol);
pub const CHAT_LINES: usize = 14;
#[derive(Component)]
pub struct ChatRow(pub usize);
#[derive(Component)]
pub struct ChatSaid(pub usize);
#[derive(Component)]
pub struct ChatEntryBox;
#[derive(Component)]
pub struct BrowseView;
#[derive(Component)]
pub struct TableView;
#[derive(Component)]
pub struct PlayerRowName(pub usize);
#[derive(Component)]
pub struct PlayerRowCell(pub usize);
#[derive(Component)]
pub struct PlayerRowRobot(pub usize);
const TABLE_ROBOT_PX: f32 = 18.0;
const TABLE_NAME_PX: f32 = 21.0;
#[derive(Component)]
pub struct ListRowRobot(pub usize);
#[derive(Component)]
pub struct DialRow(pub usize);
#[derive(Component)]
pub struct DialName(pub usize);
#[derive(Component)]
pub struct DialValue(pub usize);
#[derive(Component)]
pub struct DialBeachNote;
#[derive(Clone, Copy)]
pub enum LobbyCard {
Beaches,
Table,
Round,
Chat,
}
#[derive(Default)]
pub struct LobbyArt {
robot: Handle<Image>,
boat: Handle<Image>,
castle: Handle<Image>,
star: Handle<Image>,
crab: Handle<Image>,
foam: Handle<Image>,
}
impl LobbyArt {
pub fn from_art(art: &crate::app::art::Art) -> Self {
Self {
robot: art.robot.clone(),
boat: art.boat.clone(),
castle: art.castle.clone(),
star: art.star.clone(),
crab: art.crab.clone(),
foam: art.foam.clone(),
}
}
fn icon(&self, card: LobbyCard) -> &Handle<Image> {
match card {
LobbyCard::Beaches => &self.boat,
LobbyCard::Table => &self.castle,
LobbyCard::Round => &self.star,
LobbyCard::Chat => &self.crab,
}
}
}
pub(super) fn spawn_lobby_ui(commands: &mut Commands, tr: &crate::app::i18n::Tr, art: &LobbyArt) {
let full = || Node {
position_type: PositionType::Absolute,
top: Val::Px(60.0),
left: Val::Px(28.0),
right: Val::Px(28.0),
bottom: Val::Px(116.0),
flex_direction: FlexDirection::Row,
column_gap: Val::Px(14.0),
..default()
};
spawn_browse_face(commands, &full, tr, art);
spawn_table_face(commands, &full, tr, art);
spawn_entry_bar(commands);
}
type EntryBarOnly = (With<ChatEntryBox>, Without<BrowseView>, Without<TableView>);
pub fn update_lobby_view(
state: Res<LobbyState>,
mut browse: Query<&mut Node, (With<BrowseView>, Without<TableView>)>,
mut table: Query<&mut Node, (With<TableView>, Without<BrowseView>)>,
mut entry: Query<&mut Node, EntryBarOnly>,
) {
let aboard = state.standing().at_a_beach();
let picking = state
.hosted()
.is_some_and(|hosted| hosted.picking.is_some());
for mut node in &mut browse {
crate::app::menu_ui::set_shown(&mut node, !aboard);
}
for mut node in &mut table {
crate::app::menu_ui::set_shown(&mut node, aboard && !picking);
}
for mut node in &mut entry {
crate::app::menu_ui::set_shown(&mut node, !picking);
}
}
fn seat_tone(seat: usize) -> Color {
palette::seat_text(seat as u8)
}
fn name_ink(table: &[String], seated: &Seated, who: &str) -> Color {
match table.iter().position(|seat| seat == who) {
Some(place) => seat_tone(usize::from(seated.seat_of(place))),
None => palette::IDLE_ROW,
}
}
fn line_at(chat: &[Said], row: usize) -> Option<&Said> {
let back = (CHAT_LINES - 1).checked_sub(row)?;
chat.len().checked_sub(back + 1).map(|at| &chat[at])
}
pub fn update_lobby_robots(
state: Res<LobbyState>,
settings: Res<GameSettings>,
mut at_table: Query<(&PlayerRowRobot, &mut InlineImage)>,
mut on_list: Query<(&ListRowRobot, &mut Visibility, &mut ImageNode)>,
) {
let shown = seats_shown(&state, settings.tr());
for (row, mut image) in &mut at_table {
let seat = shown.get(row.0);
let px = match seat.is_some_and(|seat| seat.kind == crate::sim::SeatKind::Bot) {
true => TABLE_ROBOT_PX,
false => 0.0,
};
let want = InlineImage {
color: seat_tone(seat.map_or(row.0, |seat| usize::from(seat.seat))),
width: Some(px),
height: Some(px),
..image.clone()
};
if *image != want {
*image = want;
}
}
for (row, mut shown, mut image) in &mut on_list {
let want = match state.listed(row.0) {
Some(host) if host.bots => Visibility::Inherited,
Some(_) | None => Visibility::Hidden,
};
if *shown != want {
*shown = want;
}
let tint = palette::PARCHMENT.with_alpha(0.8);
if image.color != tint {
image.color = tint;
}
}
}
pub fn update_lobby_players(
state: Res<LobbyState>,
settings: Res<GameSettings>,
mut rows: Query<(&PlayerRowName, &mut TextSpan, &mut TextColor)>,
mut cells: Query<(&PlayerRowCell, &mut Node, &ComputedNode)>,
) {
let shown = seats_shown(&state, settings.tr());
let mut rooms = [None; MAX_PLAYERS];
for (cell, _, laid) in &cells {
let inner = laid.size.x - laid.padding.min_inset.x - laid.padding.max_inset.x;
if let Some(room) = rooms.get_mut(cell.0)
&& inner > 0.0
{
*room = Some(inner * laid.inverse_scale_factor);
}
}
for (row, mut text, mut color) in &mut rows {
let (line, tone) = match shown.get(row.0) {
Some(seat) => {
let bot = seat.kind == crate::sim::SeatKind::Bot;
let line = crate::app::seating::beside_the_robot(
bot,
&format!("{}. {}", seat.seat + 1, seat.name),
);
let line = match rooms.get(row.0).copied().flatten() {
Some(room) => {
let robot = if bot { TABLE_ROBOT_PX } else { 0.0 };
crate::app::match_setup::cut_to(&line, room - robot, TABLE_NAME_PX)
}
None => line,
};
(line, seat_tone(usize::from(seat.seat)))
}
None => (String::new(), Color::NONE),
};
if text.0 != line {
text.0 = line;
}
crate::app::menu_ui::set_color(&mut color, tone);
}
let width = Val::Percent(match state.seated.teams {
crate::app::teams::TeamMode::Trios => 100.0 / 3.0,
crate::app::teams::TeamMode::Solo | crate::app::teams::TeamMode::Pairs => 50.0,
});
for (_, mut node, _) in &mut cells {
if node.width != width {
node.width = width;
}
}
}
fn card(
screen: &mut RelatedSpawnerCommands<ChildOf>,
art: &LobbyArt,
which: LobbyCard,
heading: &str,
width: Val,
rows: impl FnOnce(&mut RelatedSpawnerCommands<ChildOf>),
) {
screen
.spawn((
Node {
flex_direction: FlexDirection::Column,
width,
flex_grow: if width == Val::Auto { 1.0 } else { 0.0 },
row_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(crate::app::menu_ui::CARD_PAD))
.with_bottom(Val::Px(crate::app::menu_ui::FOAM_DEPTH + 6.0)),
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BorderColor::all(palette::CARD_EDGE),
BackgroundColor(palette::CARD_FILL),
crate::app::menu_ui::card_shadow(),
crate::app::menu_ui::Framed,
))
.with_children(|panel| {
crate::app::menu_ui::tide_line(panel, &art.foam);
crate::app::menu_ui::heading_row(panel, heading, Some(art.icon(which)));
rows(panel);
});
}
fn row_font(px: f32) -> TextFont {
TextFont {
font_size: FontSize::Px(px),
..default()
}
}
fn row_text(px: f32) -> (Text, TextFont, TextColor) {
(Text::new(""), row_font(px), TextColor(palette::PARCHMENT))
}
fn fixed_row_text(px: f32) -> (Text, TextFont, TextColor, TextLayout) {
let (text, font, color) = row_text(px);
(text, font, color, TextLayout::no_wrap())
}
fn list_cell(px: f32, width: Val, grow: f32) -> impl Bundle {
(
Text::new(""),
row_font(px),
TextColor(palette::PARCHMENT),
TextLayout::no_wrap(),
Node {
width,
flex_grow: grow,
overflow: Overflow::clip_x(),
..default()
},
)
}
const NAME_COL: f32 = 380.0;
const HOST_COL: f32 = 250.0;
const WHERE_COL: f32 = 215.0;
const TABLE_COL: f32 = 190.0;
fn row_ink(picked: bool, room: bool) -> (Color, Color) {
match (picked, room) {
(true, _) => (palette::SELECTED_ROW, palette::PARCHMENT),
(false, true) => (palette::PARCHMENT, palette::IDLE_ROW),
(false, false) => (palette::IDLE_ROW.darker(0.1), palette::IDLE_ROW.darker(0.2)),
}
}
pub fn update_lobby_list(
state: Res<LobbyState>,
settings: Res<GameSettings>,
mut rows: Query<(&LobbyRow, &mut BackgroundColor)>,
mut cells: Query<(&LobbyCell, &mut Text, &mut TextColor)>,
) {
let tr = settings.tr();
let at = state.selected_index();
let host_at = |row: usize| state.listed(row);
for (row, mut fill) in &mut rows {
let picked = host_at(row.0).is_some() && Some(state.scroll + row.0) == at;
let want = crate::app::menu_ui::band(picked);
crate::app::menu_ui::set_bg(&mut fill, want);
}
for (cell, mut text, mut color) in &mut cells {
let LobbyCell(row, col) = *cell;
let (line, tone) = match host_at(row) {
Some(host) => {
let (name_ink, side_ink) = row_ink(Some(state.scroll + row) == at, host.has_room());
match col {
ListCol::Name => (
format!("{}. {}", state.scroll + row + 1, host.who()),
name_ink,
),
ListCol::Host => (host.creator().to_string(), side_ink),
ListCol::Where => (host.addr.to_string(), side_ink),
ListCol::Table => (host.table(tr), host.table_tone()),
}
}
None => (String::new(), Color::NONE),
};
crate::app::menu_ui::set_text(&mut text, &line);
crate::app::menu_ui::set_color(&mut color, tone);
}
}
pub fn update_lobby_beach_note(
settings: Res<GameSettings>,
state: Res<LobbyState>,
mut note: Query<&mut Text, With<DialBeachNote>>,
) {
let Ok(mut text) = note.single_mut() else {
return;
};
let line = dials_note(&state, settings.tr());
crate::app::menu_ui::set_text(&mut text, &line);
}
pub fn update_lobby_terms(
state: Res<LobbyState>,
settings: Res<GameSettings>,
config: Res<MatchConfig>,
mut rows: Query<(&DialRow, &mut BackgroundColor)>,
mut names: Query<(&DialName, &mut Text, &mut TextColor), Without<DialValue>>,
mut values: Query<(&DialValue, &mut Text, &mut TextColor), Without<DialName>>,
) {
let tr = settings.tr();
let card = card_of(&state, &config, settings.team_mode);
let host = matches!(card, super::terms::TermsCard::Hosting { .. });
for (row, mut fill) in &mut rows {
let under = Dial::ALL.get(row.0) == Dial::TURNS.get(state.dial);
let want = crate::app::menu_ui::band(host && under);
crate::app::menu_ui::set_bg(&mut fill, want);
}
for (row, mut text, mut color) in &mut names {
let line = Dial::ALL
.get(row.0)
.map(|dial| dial.label(tr, &card).0)
.unwrap_or_default();
crate::app::menu_ui::set_text(&mut text, line);
crate::app::menu_ui::set_color(&mut color, palette::IDLE_ROW);
}
for (row, mut text, mut color) in &mut values {
let line = Dial::ALL
.get(row.0)
.map(|dial| dial.label(tr, &card).1)
.unwrap_or_default();
let turns = Dial::ALL.get(row.0).is_some_and(|dial| dial.turns());
let tone = match host && turns {
true => palette::PARCHMENT,
false => palette::IDLE_ROW,
};
crate::app::menu_ui::set_text(&mut text, &line);
crate::app::menu_ui::set_color(&mut color, tone);
}
}
fn set_slant(font: &mut Mut<TextFont>, slanted: bool) {
let want = match slanted {
true => FontSource::Handle(crate::app::boot::ITALIC_FONT),
false => FontSource::default(),
};
if font.font != want {
font.font = want;
}
}
pub fn update_lobby_chat(
state: Res<LobbyState>,
settings: Res<GameSettings>,
face: Res<crate::app::gamepad::PadFace>,
device: Res<crate::app::gamepad::LastDevice>,
mut rows: Query<(&ChatRow, &mut Text, &mut TextColor), Without<ChatSaid>>,
mut said: Query<(&ChatSaid, &mut TextSpan, &mut TextColor, &mut TextFont), Without<ChatRow>>,
mut entry: Query<&mut BackgroundColor, With<ChatEntryBox>>,
) {
let tr = settings.tr();
let box_lit = state.typing.is_some() || state.can_chat();
for mut fill in &mut entry {
let want = match box_lit {
true => palette::PILL_FILL,
false => Color::NONE,
};
crate::app::menu_ui::set_bg(&mut fill, want);
}
for (row, mut text, mut color) in &mut rows {
let prompt_row = row.0 == CHAT_LINES;
let (line, tone) = match (prompt_row, state.typing.as_ref()) {
(true, Some(open)) => {
let asked = match open.what {
Entry::PlayerName => tr.lobby_ask_player_name,
Entry::GameName => tr.lobby_ask_game_name,
Entry::Address => tr.lobby_ask_address,
Entry::Chat => "",
};
(format!("{asked}{}_", open.text), palette::GOLD)
}
(true, None) if state.can_chat() => {
let pad = crate::app::gamepad::scheme::button_name(
&face.0,
crate::app::gamepad::scheme::Does::Talk,
tr,
)
.filter(|_| *device == crate::app::gamepad::LastDevice::Pad);
let line = match pad {
Some(button) => fill(tr.pad.bind, &[("b", &button), ("a", tr.pad.talk)]),
None => tr.lobby_chat_hint.to_string(),
};
(line, palette::IDLE_ROW)
}
(true, None) => (String::new(), Color::NONE),
(false, _) => match line_at(&state.chat, row.0) {
Some(said) if !said.is_notice() => (
format!("{}: ", said.who),
name_ink(&state.table, &state.seated, &said.who),
),
_ => (String::new(), Color::NONE),
},
};
crate::app::menu_ui::set_text(&mut text, &line);
crate::app::menu_ui::set_color(&mut color, tone);
}
for (row, mut span, mut color, mut font) in &mut said {
let (line, tone, slanted) = match line_at(&state.chat, row.0) {
Some(said) => (said.line.as_str(), palette::PARCHMENT, said.is_notice()),
None => ("", Color::NONE, false),
};
crate::app::menu_ui::set_text(&mut span, line);
crate::app::menu_ui::set_color(&mut color, tone);
set_slant(&mut font, slanted);
}
}
fn spawn_browse_face(
commands: &mut Commands,
full: &dyn Fn() -> Node,
tr: &crate::app::i18n::Tr,
art: &LobbyArt,
) {
commands
.spawn((LobbyUi, BrowseView, full()))
.with_children(|screen| {
card(
screen,
art,
LobbyCard::Beaches,
tr.lobby_card_beaches,
Val::Auto,
|body| {
let bar = crate::app::menu_ui::scroll_bar::BarOf::<LobbyState>::default();
let room = LobbyListRoom;
crate::app::menu_ui::scroll_bar::filling(body, LIST_GAP, bar, room, |body| {
for row in 0..LIST_ROW_SLOTS {
body.spawn((
LobbyRow(row),
Node {
align_items: AlignItems::Center,
column_gap: Val::Px(16.0),
height: Val::Px(LIST_ROW_H),
flex_shrink: 0.0,
padding: UiRect::axes(Val::Px(12.0), Val::Px(LIST_ROW_PAD)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|line| {
line.spawn((
LobbyCell(row, ListCol::Name),
list_cell(LIST_NAME_PX, Val::Px(NAME_COL), 0.0),
));
line.spawn((
ListRowRobot(row),
ImageNode::new(art.robot.clone()),
Node {
width: Val::Px(20.0),
height: Val::Px(20.0),
flex_shrink: 0.0,
..default()
},
Visibility::Hidden,
));
line.spawn((
LobbyCell(row, ListCol::Host),
list_cell(17.0, Val::Px(HOST_COL), 0.0),
));
line.spawn((
LobbyCell(row, ListCol::Where),
list_cell(17.0, Val::Px(WHERE_COL), 1.0),
));
line.spawn((
LobbyCell(row, ListCol::Table),
list_cell(19.0, Val::Px(TABLE_COL), 0.0),
));
});
}
});
},
);
});
}
fn spawn_table_face(
commands: &mut Commands,
full: &dyn Fn() -> Node,
tr: &crate::app::i18n::Tr,
art: &LobbyArt,
) {
commands
.spawn((LobbyUi, TableView, full()))
.with_children(|screen| {
screen
.spawn(Node {
width: Val::Percent(42.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(14.0),
..default()
})
.with_children(|column| {
card(
column,
art,
LobbyCard::Table,
tr.lobby_card_players,
Val::Auto,
|body| {
body.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
..default()
})
.with_children(|grid| {
for row in 0..MAX_PLAYERS {
grid.spawn((
PlayerRowCell(row),
Node {
width: Val::Percent(50.0),
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
overflow: Overflow::clip_x(),
..default()
},
))
.with_children(|line| {
let (text, font, ink) = row_text(TABLE_NAME_PX);
line.spawn((
text,
font.clone(),
ink,
TextLayout::no_wrap(),
))
.with_children(
|text| {
text.spawn((
PlayerRowRobot(row),
InlineImage {
image: art.robot.clone(),
width: Some(0.0),
height: Some(0.0),
..default()
},
));
text.spawn((
PlayerRowName(row),
TextSpan::default(),
font,
ink,
));
},
);
});
}
});
},
);
card(
column,
art,
LobbyCard::Round,
tr.lobby_card_terms,
Val::Auto,
|body| {
for row in 0..Dial::ALL.len() {
body.spawn((
DialRow(row),
Node {
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(10.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|line| {
line.spawn((
Node {
flex_shrink: 0.0,
..default()
},
children![(DialName(row), fixed_row_text(18.0))],
));
line.spawn((
Node {
flex_grow: 1.0,
flex_basis: Val::Px(0.0),
min_width: Val::Px(0.0),
justify_content: JustifyContent::End,
overflow: Overflow::clip_x(),
..default()
},
children![(DialValue(row), fixed_row_text(18.0))],
));
});
}
let (text, font, _, layout) = fixed_row_text(14.0);
body.spawn((
Node {
height: Val::Px(20.0),
padding: UiRect::horizontal(Val::Px(10.0)),
overflow: Overflow::clip_x(),
..default()
},
children![(
DialBeachNote,
text,
font,
layout,
TextColor(palette::PARCHMENT.with_alpha(0.55)),
)],
));
},
);
});
card(
screen,
art,
LobbyCard::Chat,
tr.lobby_card_chat,
Val::Auto,
|body| {
body.spawn(Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
justify_content: JustifyContent::FlexEnd,
row_gap: Val::Px(3.0),
..default()
})
.with_children(|feed| {
for row in 0..CHAT_LINES {
feed.spawn((ChatRow(row), row_text(19.0)))
.with_children(|line| {
line.spawn((
ChatSaid(row),
TextSpan::new(""),
row_font(19.0),
TextColor(palette::PARCHMENT),
));
});
}
});
},
);
});
}
fn spawn_entry_bar(commands: &mut Commands) {
commands
.spawn((
LobbyUi,
ChatEntryBox,
Node {
position_type: PositionType::Absolute,
left: Val::Px(28.0),
right: Val::Px(28.0),
bottom: Val::Px(76.0),
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
border_radius: BorderRadius::all(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::NONE),
ZIndex(1),
))
.with_children(|entry| {
entry.spawn((ChatRow(CHAT_LINES), row_text(20.0)));
});
}
#[cfg(test)]
mod list_row_tests {
use super::*;
#[test]
fn a_joiners_card_says_the_map_is_picked_at_the_start() {
let mut app = App::new();
app.insert_resource(GameSettings::default());
app.init_resource::<LobbyState>();
app.init_resource::<MatchConfig>();
{
let mut state = app.world_mut().resource_mut::<LobbyState>();
let mut joined = Joined::dialled(
UdpTransport::join(("127.0.0.1", 47997)).expect("join"),
false,
);
joined.host_terms = Some(HostTerms {
terms: MatchTerms {
map: crate::app::match_setup::MapChoice::Custom.index() as u8,
..MatchTerms::default()
},
});
state.standing = Standing::Joining(joined);
}
app.add_systems(
Startup,
|mut commands: Commands, settings: Res<GameSettings>| {
spawn_lobby_ui(&mut commands, settings.tr(), &LobbyArt::default());
},
);
app.add_systems(Update, update_lobby_terms);
app.update();
let map_value = |app: &mut App| {
let world = app.world_mut();
let mut values = world.query::<(&DialValue, &Text)>();
values
.iter(world)
.find(|(row, _)| Dial::ALL.get(row.0) == Some(&Dial::Map))
.map(|(_, text)| text.0.clone())
.expect("a map dial")
};
assert_eq!(map_value(&mut app), crate::app::i18n::EN.lobby_map_at_start);
if let Some(joined) = app.world_mut().resource_mut::<LobbyState>().joined_mut() {
joined.host_terms = None;
}
app.update();
let world = app.world_mut();
let mut values = world.query::<(&DialValue, &Text)>();
assert!(
values.iter(world).all(|(_, text)| text.0.is_empty()),
"a joiner's card shows nothing of its own"
);
}
fn beach(last: u8, name: &str, host: &str, taken: u8, running: bool) -> HostEntry {
HostEntry {
addr: format!("10.0.0.{last}:47777").parse().expect("addr"),
id: u64::from(last),
name: name.to_string(),
host: host.to_string(),
taken,
seats: 6,
running,
bots: false,
full: false,
age: 0.0,
heard: 1,
}
}
fn painted(hosts: Vec<HostEntry>, cursor: Option<SocketAddr>) -> Vec<[(String, Color); 4]> {
let mut app = App::new();
app.insert_resource(GameSettings::default());
app.init_resource::<LobbyState>();
{
let mut state = app.world_mut().resource_mut::<LobbyState>();
state.hosts = hosts;
state.selected = cursor;
}
app.add_systems(
Startup,
|mut commands: Commands, settings: Res<GameSettings>| {
spawn_lobby_ui(&mut commands, settings.tr(), &LobbyArt::default());
},
);
app.add_systems(Update, update_lobby_list);
app.update();
let mut rows = vec![[const { (String::new(), Color::NONE) }; 4]; LIST_ROW_SLOTS];
let world = app.world_mut();
let mut cells = world.query::<(&LobbyCell, &Text, &TextColor)>();
for (cell, text, color) in cells.iter(world) {
let LobbyCell(row, col) = *cell;
let at = match col {
ListCol::Name => 0,
ListCol::Host => 1,
ListCol::Where => 2,
ListCol::Table => 3,
};
rows[row][at] = (text.0.clone(), color.0);
}
rows
}
#[test]
fn a_row_says_which_game_whose_and_where() {
let hosts = vec![
beach(1, "Room 3", "Anna", 2, false),
beach(2, "The Pier", "Bo", 6, true),
];
let there = hosts[0].addr;
let rows = painted(hosts, Some(there));
let [name, who, at, table] = &rows[0];
assert_eq!(name.0, "1. Room 3", "the beach, behind the number shouted");
assert_eq!(who.0, "Anna", "whose it is, which its name never says");
assert_eq!(at.0, "10.0.0.1:47777", "and where, to the letter");
assert_eq!(table.0, "2/6");
assert_eq!(
at.0.parse::<SocketAddr>().ok(),
Some(there),
"what is written is what Enter dials: read out to a friend \
across the room, it has to reach this beach and no other"
);
let [name, who, at, table] = &rows[1];
assert_eq!((name.0.as_str(), who.0.as_str()), ("2. The Pier", "Bo"));
assert_eq!(at.0, "10.0.0.2:47777");
assert!(table.0.contains(crate::app::i18n::EN.lobby_full_tag));
for row in &rows[2..] {
assert!(
row.iter().all(|(line, _)| line.is_empty()),
"a row with no beach on it says nothing: {row:?}"
);
}
}
#[test]
fn the_details_take_the_colour_of_the_row_they_are_on() {
let hosts = vec![
beach(1, "Room 3", "Anna", 2, false),
beach(2, "Full", "Bo", 6, false),
];
let rows = painted(hosts, Some("10.0.0.1:47777".parse().expect("addr")));
let (lit_name, lit_side) = row_ink(true, true);
assert_eq!(rows[0][0].1, lit_name, "the cursor's row is the lit one");
assert_eq!(rows[0][1].1, lit_side);
assert_eq!(rows[0][2].1, lit_side, "the address with it");
let (dim_name, dim_side) = row_ink(false, false);
assert_eq!(rows[1][0].1, dim_name, "a full beach is not an offer");
assert_eq!(rows[1][2].1, dim_side, "and neither is its address");
assert_ne!(lit_side, dim_side, "the two say different things");
}
}
#[cfg(test)]
mod feed_layout_tests {
use super::*;
fn rows(said: &[&str]) -> Vec<String> {
let mut state = LobbyState::default();
for line in said {
state.say("", line);
}
(0..CHAT_LINES)
.map(|row| match line_at(&state.chat, row) {
Some(said) => said.line.clone(),
None => String::new(),
})
.collect()
}
#[test]
fn the_newest_line_is_always_on_the_last_row() {
let empty = rows(&[]);
assert!(
empty.iter().all(String::is_empty),
"nothing said, nothing shown"
);
let one = rows(&["ready?"]);
assert_eq!(one.last().unwrap(), "ready?", "on the bottom, not the top");
assert!(one[..CHAT_LINES - 1].iter().all(String::is_empty));
let two = rows(&["ready?", "wait for me"]);
assert_eq!(two[CHAT_LINES - 2], "ready?");
assert_eq!(two[CHAT_LINES - 1], "wait for me");
let many: Vec<String> = (0..CHAT_LINES + 1).map(|n| format!("line {n}")).collect();
let full = rows(&many.iter().map(String::as_str).collect::<Vec<_>>());
assert_eq!(full[0], "line 1", "line 0 has gone");
assert_eq!(full[CHAT_LINES - 1], format!("line {CHAT_LINES}"));
assert!(
full.iter().all(|line| !line.is_empty()),
"no gaps once full"
);
assert!(line_at(&[], CHAT_LINES).is_none());
}
#[test]
fn a_name_reads_in_its_seats_colour() {
let table = vec!["Anna".to_string(), "Bo".to_string()];
let seated = Seated::default();
assert_eq!(name_ink(&table, &seated, "Anna"), seat_tone(0));
assert_eq!(name_ink(&table, &seated, "Bo"), seat_tone(1));
assert_ne!(seat_tone(0), seat_tone(1), "one colour per seat");
assert_eq!(name_ink(&table, &seated, "Cy"), palette::IDLE_ROW);
assert_eq!(name_ink(&[], &seated, "Anna"), palette::IDLE_ROW);
let moved = Seated {
order: [0, 2, 1, 3, 4, 5],
..Seated::default()
};
assert_eq!(name_ink(&table, &moved, "Bo"), seat_tone(2));
}
}
#[cfg(test)]
mod list_fit_tests {
use super::*;
use bevy::camera::{ComputedCameraValues, RenderTargetInfo, Viewport};
fn laid_out(width: u32, height: u32, ui_scale: u8, beaches: u8) -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
TransformPlugin,
AssetPlugin::default(),
ImagePlugin::default(),
bevy::text::TextPlugin,
bevy::ui::UiPlugin,
));
app.add_plugins((
bevy::input::InputPlugin,
WindowPlugin {
primary_window: None,
exit_condition: bevy::window::ExitCondition::DontExit,
..default()
},
bevy::a11y::AccessibilityPlugin,
DefaultPickingPlugins,
));
app.init_asset::<TextureAtlasLayout>();
let settings = GameSettings {
ui_scale,
..GameSettings::default()
};
app.insert_resource(UiScale(crate::app::settings::applied_ui_ratio(
settings.ui_ratio(),
width as f32,
height as f32,
)));
app.insert_resource(settings);
app.init_resource::<LobbyState>();
app.world_mut().spawn((
Camera2d,
Camera {
computed: ComputedCameraValues {
target_info: Some(RenderTargetInfo {
physical_size: UVec2::new(width, height),
scale_factor: 1.0,
}),
..default()
},
viewport: Some(Viewport {
physical_size: UVec2::new(width, height),
..default()
}),
..default()
},
));
{
let mut state = app.world_mut().resource_mut::<LobbyState>();
state.hosts = (1..=beaches)
.map(|n| HostEntry {
addr: format!("10.0.0.{n}:47777").parse().expect("addr"),
id: u64::from(n),
name: format!("Beach {n}"),
host: "Anna".to_string(),
taken: 1,
seats: 6,
running: false,
bots: false,
full: false,
age: 0.0,
heard: 1,
})
.collect();
state.settle_cursor();
}
app.add_systems(
Startup,
|mut commands: Commands, settings: Res<GameSettings>| {
spawn_lobby_ui(&mut commands, settings.tr(), &LobbyArt::default());
},
);
app.add_systems(
Update,
(fit_lobby_list, update_lobby_list, update_lobby_view).chain(),
);
for _ in 0..4 {
app.update();
}
app
}
fn resize(app: &mut App, width: u32, height: u32, ui_scale: u8) {
let world = app.world_mut();
let ratio = f32::from(ui_scale) / 100.0;
world.resource_mut::<UiScale>().0 =
crate::app::settings::applied_ui_ratio(ratio, width as f32, height as f32);
let mut cameras = world.query::<&mut Camera>();
for mut camera in cameras.iter_mut(world) {
camera.computed.target_info = Some(RenderTargetInfo {
physical_size: UVec2::new(width, height),
scale_factor: 1.0,
});
camera.viewport = Some(Viewport {
physical_size: UVec2::new(width, height),
..default()
});
}
for _ in 0..4 {
app.update();
}
}
fn bottom(world: &World, entity: Entity) -> f32 {
let node = world.get::<ComputedNode>(entity).expect("laid out");
let at = world.get::<UiGlobalTransform>(entity).expect("placed");
at.translation.y + node.size.y / 2.0
}
fn card_floor(world: &mut World) -> f32 {
let mut room = world.query_filtered::<Entity, With<LobbyListRoom>>();
let room = room.single(world).expect("one list");
let band = world.get::<ChildOf>(room).expect("in a band").parent();
let card = world.get::<ChildOf>(band).expect("in a card").parent();
let node = world.get::<ComputedNode>(card).expect("laid out");
bottom(world, card) - node.border.max_inset.y - node.padding.max_inset.y
}
fn painted_rows(world: &mut World) -> Vec<(usize, Entity)> {
let mut cells = world.query::<(&LobbyCell, &Text, &ChildOf)>();
let mut rows: Vec<(usize, Entity)> = cells
.iter(world)
.filter(|(cell, text, _)| cell.1 == ListCol::Name && !text.0.is_empty())
.map(|(cell, _, row)| (cell.0, row.parent()))
.collect();
rows.sort_by_key(|(slot, _)| *slot);
rows
}
#[test]
fn the_list_paints_the_rows_its_card_fits() {
use crate::app::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
let sizes = [
(1280, 720, UI_SCALE_MAX),
(1280, 800, UI_SCALE_MAX),
(1920, 1200, UI_SCALE_MAX),
(2560, 1080, UI_SCALE_MAX),
(180, 120, UI_SCALE_MAX),
(1280, 720, UI_SCALE_MIN),
(1280, 800, UI_SCALE_MIN),
(1920, 1200, UI_SCALE_MIN),
];
let mut shown = Vec::new();
for (width, height, dial) in sizes {
let mut app = laid_out(width, height, dial, 60);
let world = app.world_mut();
let floor = card_floor(world);
let rows = painted_rows(world);
let fit = world.resource::<LobbyState>().rows_shown();
let at = format!("{width}x{height} at {dial}%");
assert_eq!(rows.len(), fit, "{at}: paints the rows it says it shows");
let (_, last) = *rows.last().expect("rows");
let last_bottom = bottom(world, last);
assert!(
last_bottom <= floor + 0.5,
"{at}: row {fit} ends at {last_bottom}, past the card's foot at {floor}"
);
let mut slots = world.query::<(Entity, &LobbyRow)>();
let next = slots
.iter(world)
.find(|(_, row)| row.0 == fit)
.map(|(entity, _)| entity)
.expect("a slot below the last row shown");
let next_bottom = bottom(world, next);
assert!(
next_bottom > floor + 0.5,
"{at}: row {} would have fit, ending at {next_bottom} above {floor}",
fit + 1
);
shown.push((at, fit));
}
let rows_at = |want: &str| {
shown
.iter()
.find(|(at, _)| at == want)
.map(|(_, fit)| *fit)
.expect("measured")
};
assert_eq!(rows_at("1280x720 at 100%"), 12, "the design size");
assert_eq!(
rows_at("1280x720 at 100%"),
ListRows::default().0,
"the guess before the first layout is the design size's count"
);
assert!(
rows_at("1280x800 at 100%") > 12,
"a taller window shows more"
);
assert_eq!(
rows_at("1920x1200 at 100%"),
rows_at("1280x800 at 100%"),
"16:10 is one interface at any size"
);
assert!(
rows_at("1280x720 at 60%") > rows_at("1280x720 at 100%"),
"a smaller interface shows more"
);
}
#[test]
fn a_resize_keeps_the_cursor_in_view_and_the_thumb_on_the_rows() {
use crate::app::menu_ui::scroll_bar::{BarOf, Scrolls, reading};
let mut app = laid_out(1280, 800, 100, 30);
app.add_systems(
Update,
crate::app::menu_ui::scroll_bar::follow::<LobbyState>.after(update_lobby_list),
);
let thumb = |app: &mut App| {
app.update();
let window = app.world().resource::<LobbyState>().window();
let grows = reading::<BarOf<LobbyState>>(app.world_mut()).expect("a bar");
let share = window.shown / window.total;
let at = window.first / (window.total - window.shown);
let want = [(1.0 - share) * at, share, (1.0 - share) * (1.0 - at)];
for (got, want) in grows.iter().zip(want) {
assert!((got - want).abs() < 1e-5, "{grows:?} for {window:?}");
}
};
app.world_mut()
.resource_mut::<LobbyState>()
.point_at(Some(29));
thumb(&mut app);
let tall = app.world().resource::<LobbyState>().rows_shown();
assert_eq!(app.world().resource::<LobbyState>().scroll, 30 - tall);
resize(&mut app, 1280, 720, 100);
let state = app.world().resource::<LobbyState>();
assert_eq!(state.rows_shown(), 12);
assert_eq!(state.selected_index(), Some(29), "the cursor stays put");
assert_eq!(state.scroll, 30 - 12, "and in view");
thumb(&mut app);
app.world_mut()
.resource_mut::<LobbyState>()
.point_at(Some(25));
resize(&mut app, 1920, 1200, 100);
let state = app.world().resource::<LobbyState>();
assert_eq!(state.rows_shown(), tall);
assert_eq!(
state.scroll,
30 - tall,
"no blank rows under the last beach"
);
assert_eq!(state.selected_index(), Some(25));
thumb(&mut app);
resize(&mut app, 1280, 720, crate::app::settings::UI_SCALE_MIN);
let fit = app.world().resource::<LobbyState>().rows_shown();
app.world_mut()
.resource_mut::<LobbyState>()
.hosts
.truncate(fit);
app.world_mut().resource_mut::<LobbyState>().fit_rows(fit);
app.update();
assert_eq!(reading::<BarOf<LobbyState>>(app.world_mut()), None);
}
#[test]
fn a_long_name_at_the_table_is_cut_to_its_cell() {
use crate::app::match_setup::line_px;
use crate::app::teams::TeamMode;
use crate::transport::Air;
for (width, height) in [(1280, 720), (1280, 800)] {
for teams in TeamMode::ALL {
for name in ["W".repeat(12), "あいうえおかきくけこさし".to_string()] {
let mut app = laid_out(width, height, 100, 0);
app.add_systems(Update, (update_lobby_players, update_lobby_robots));
{
let mut state = app.world_mut().resource_mut::<LobbyState>();
state.standing = Standing::hosting(
Announcer::on(0x9A9B, Air::loopback(&[])).expect("announcer"),
UdpTransport::host(0).expect("game socket"),
);
state.table = vec![name.clone(); MAX_PLAYERS];
state.table_kinds = vec![crate::sim::SeatKind::Bot; MAX_PLAYERS];
state.seated = Seated {
teams,
..Seated::default()
};
}
for _ in 0..4 {
app.update();
}
let world = app.world_mut();
let mut cells =
world.query_filtered::<(&ComputedNode, &Children), With<PlayerRowCell>>();
let mut checked = 0;
for (cell, children) in cells.iter(world) {
let inner =
(cell.size.x - cell.padding.min_inset.x - cell.padding.max_inset.x)
* cell.inverse_scale_factor;
for &text in children {
let Some(spans) = world.get::<Children>(text) else {
continue;
};
for &span in spans {
let Some(line) = world.get::<TextSpan>(span) else {
continue;
};
let drawn = line_px(&line.0, TABLE_NAME_PX) + TABLE_ROBOT_PX;
let at = format!("{width}x{height} {teams:?} {:?}", line.0);
assert!(drawn <= inner, "{at}: {drawn}px in a {inner}px cell");
assert!(line.0.ends_with('…'), "{at}: cut");
checked += 1;
}
}
}
assert_eq!(checked, MAX_PLAYERS);
}
}
}
}
}