use super::*;
pub const LIST_ROWS: usize = 14;
#[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 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 {
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 {
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);
}
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>)>,
) {
let aboard = state.standing().at_a_beach();
let show = |node: &mut Node, on: bool| {
let want = if on { Display::Flex } else { Display::None };
if node.display != want {
node.display = want;
}
};
for mut node in &mut browse {
show(&mut node, !aboard);
}
for mut node in &mut table {
show(&mut node, aboard);
}
}
fn seat_tone(seat: usize) -> Color {
crate::app::palette::player_color(seat as u8).lighter(0.15)
}
fn name_ink(table: &[String], who: &str) -> Color {
match table.iter().position(|seat| seat == who) {
Some(seat) => seat_tone(seat),
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_players(
state: Res<LobbyState>,
mut rows: Query<(&PlayerRowName, &mut Text, &mut TextColor)>,
) {
for (row, mut text, mut color) in &mut rows {
let (line, tone) = match state.table.get(row.0) {
Some(who) => (format!("{}. {who}", row.0 + 1), seat_tone(row.0)),
None => (String::new(), Color::NONE),
};
crate::app::menu_ui::set_text(&mut text, &line);
crate::app::menu_ui::set_color(&mut color, tone);
}
}
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(12.0)).with_bottom(Val::Px(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(),
))
.with_children(|panel| {
tide_line(panel, &art.foam);
heading_row(panel, heading, art.icon(which));
rows(panel);
});
}
fn tide_line(panel: &mut RelatedSpawnerCommands<ChildOf>, foam: &Handle<Image>) {
panel.spawn((
ImageNode {
image: foam.clone(),
color: FOAM_LINE,
flip_y: true,
image_mode: NodeImageMode::Stretch,
..default()
},
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
right: Val::Px(0.0),
bottom: Val::Px(0.0),
height: Val::Px(FOAM_DEPTH),
border_radius: BorderRadius::bottom(Val::Px(10.0)),
..default()
},
));
}
fn heading_row(panel: &mut RelatedSpawnerCommands<ChildOf>, heading: &str, icon: &Handle<Image>) {
panel
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
margin: UiRect::bottom(Val::Px(6.0)),
..default()
})
.with_children(|head| {
head.spawn((
ImageNode::new(icon.clone()).with_color(HEADING_ICON),
Node {
width: Val::Px(22.0),
height: Val::Px(22.0),
..default()
},
));
head.spawn((
Text::new(heading),
TextFont {
font_size: FontSize::Px(15.0),
..default()
},
TextColor(palette::IDLE_ROW),
));
head.spawn((
Node {
flex_grow: 1.0,
height: Val::Px(1.0),
margin: UiRect::left(Val::Px(2.0)),
..default()
},
BackgroundColor(palette::CARD_EDGE),
));
});
}
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;
const ROW_PICKED: Color = Color::srgba(0.96, 0.83, 0.35, 0.16);
const HEADING_ICON: Color = Color::srgba(0.96, 0.83, 0.35, 0.85);
const FOAM_LINE: Color = Color::srgba(0.62, 0.82, 0.92, 0.16);
const FOAM_DEPTH: f32 = 20.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 listing = !state.standing().at_a_beach();
let at = state.selected_index();
let host_at = |row: usize| -> Option<&HostEntry> {
listing
.then(|| state.hosts.get(state.scroll + row))
.flatten()
};
for (row, mut fill) in &mut rows {
let picked = host_at(row.0).is_some() && Some(state.scroll + row.0) == at;
let want = if picked { ROW_PICKED } else { Color::NONE };
if fill.0 != want {
fill.0 = 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>,
config: Res<MatchConfig>,
beaches: Res<crate::app::match_setup::CustomBeaches>,
mut note: Query<&mut Text, With<DialBeachNote>>,
) {
let Ok(mut text) = note.single_mut() else {
return;
};
let line =
crate::app::match_setup::beaches_note(&config, settings.tr(), &beaches).unwrap_or_default();
crate::app::menu_ui::set_text(&mut text, &line);
}
pub fn update_lobby_terms(
state: Res<LobbyState>,
settings: Res<GameSettings>,
config: Res<MatchConfig>,
beaches: Res<crate::app::match_setup::CustomBeaches>,
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 host = state.standing() == Standing::Hosting;
let joined = (!host)
.then(|| {
state
.joined_terms
.map(|t| crate::app::match_setup::config_from_terms(&t))
})
.flatten();
let (config, team_mode): (&MatchConfig, _) = match &joined {
Some((cfg, mode)) => (cfg, *mode),
None => (&config, settings.team_mode),
};
for (row, mut fill) in &mut rows {
let want = match host && row.0 == state.dial {
true => ROW_PICKED,
false => Color::NONE,
};
if fill.0 != want {
fill.0 = want;
}
}
for (row, mut text, mut color) in &mut names {
let line = Dial::ALL
.get(row.0)
.map(|dial| dial.label(tr, config, team_mode, &beaches).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, config, team_mode, &beaches).1)
.unwrap_or_default();
let tone = match host {
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>,
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 = if box_lit { ROW_PICKED } else { Color::NONE };
if fill.0 != want {
fill.0 = 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() => (tr.lobby_chat_hint.to_string(), 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, &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| {
for row in 0..LIST_ROWS {
body.spawn((
LobbyRow(row),
Node {
align_items: AlignItems::Center,
column_gap: Val::Px(16.0),
padding: UiRect::axes(Val::Px(12.0), Val::Px(4.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|line| {
line.spawn((
LobbyCell(row, ListCol::Name),
list_cell(21.0, Val::Px(NAME_COL), 0.0),
));
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| {
for row in 0..crate::sim::MAX_PLAYERS {
body.spawn(Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
..default()
})
.with_children(|line| {
line.spawn((PlayerRowName(row), row_text(21.0)));
});
}
},
);
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),
))
.with_children(|entry| {
entry.spawn((ChatRow(CHAT_LINES), row_text(20.0)));
});
}
#[cfg(test)]
mod list_row_tests {
use super::*;
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,
age: 0.0,
}
}
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_ROWS];
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()];
assert_eq!(name_ink(&table, "Anna"), seat_tone(0));
assert_eq!(name_ink(&table, "Bo"), seat_tone(1));
assert_ne!(seat_tone(0), seat_tone(1), "one colour per seat");
assert_eq!(name_ink(&table, "Cy"), palette::IDLE_ROW);
assert_eq!(name_ink(&[], "Anna"), palette::IDLE_ROW);
}
}