mod clock;
mod event_timer;
mod feed;
pub use clock::update_side_clock;
pub use event_timer::update_event_timer;
pub use feed::{EventLog, collect_chat, collect_log, reach_the_board, update_log};
use crate::app::seating::{Seating, beside_the_robot, robot_inline};
use crate::app::settings::GameSettings;
use crate::app::{Seats, Sim};
use crate::app::{menu_ui, palette};
use crate::sim::MAX_PLAYERS;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
pub const SIDEBAR_W: f32 = 250.0;
const SIDEBAR_FOOT: f32 = 44.0;
#[derive(Component)]
pub struct SidePanelRoot;
#[derive(Component)]
pub struct SidePanel(pub u8);
#[derive(Component)]
pub struct SideScore {
pub seat: u8,
bump: f32,
}
#[derive(Component)]
pub struct ArrowDot {
seat: u8,
index: u8,
}
#[derive(Component)]
pub struct ArrowFill {
seat: u8,
index: u8,
}
const MAX_DOTS: u8 = 10;
#[derive(Component)]
pub struct LeaderCrown(pub u8);
#[derive(Component)]
pub struct RankMedal(pub u8);
#[derive(Component)]
pub struct RankDigit(pub u8);
#[derive(Component)]
pub struct ChipInk(pub u8, pub bool);
pub fn chip_fill(seat: u8, leading: bool) -> Color {
match leading {
true => palette::leader_chip(seat),
false => palette::seat_chip(seat),
}
}
fn pip_on(fill: Color) -> Color {
palette::ink_on(fill).with_alpha(0.95)
}
fn pip_off(fill: Color) -> Color {
palette::ink_on(fill).with_alpha(0.25)
}
fn chip_ink(fill: Color, named: bool) -> Color {
match named {
true => palette::name_ink_on(fill),
false => palette::ink_on(fill),
}
}
const CHIP_TOPS: [f32; MAX_PLAYERS] = [10.0, 106.0, 172.0, 234.0, 294.0, 352.0];
const CHIP_HEIGHTS: [f32; MAX_PLAYERS] = [88.0, 58.0, 54.0, 52.0, 50.0, 48.0];
const SCORE_PX: [f32; MAX_PLAYERS] = [46.0, 28.0, 24.0, 21.0, 19.0, 18.0];
const MEDALS: [Color; MAX_PLAYERS] = [
palette::MEDAL_GOLD,
palette::MEDAL_SILVER,
palette::MEDAL_BRONZE,
palette::MEDAL_DRIFTWOOD,
palette::MEDAL_DRIFTWOOD,
palette::MEDAL_DRIFTWOOD,
];
const PLACES: [&str; MAX_PLAYERS] = ["1", "2", "3", "4", "5", "6"];
const LEADER_BORDER: Color = palette::GOLD;
const LOG_TOP: f32 = clock::CLOCK_TOP + clock::CLOCK_H + 14.0;
const CARD_BORDER: f32 = 2.0;
fn ranks(scores: &[u32; MAX_PLAYERS], seats: u8) -> [usize; MAX_PLAYERS] {
let count = usize::from(seats.max(2));
let mut order: Vec<usize> = (0..count).collect();
order.sort_by_key(|&seat| (std::cmp::Reverse(scores[seat]), seat));
let mut rank = [0usize; MAX_PLAYERS];
for (place, &seat) in order.iter().enumerate() {
rank[seat] = place;
}
rank
}
fn sidebar(left: bool) -> (SidePanelRoot, Node) {
(
SidePanelRoot,
Node {
position_type: PositionType::Absolute,
left: if left { Val::Px(0.0) } else { Val::Auto },
right: if left { Val::Auto } else { Val::Px(0.0) },
top: Val::Px(menu_ui::UNDER_HEADER),
bottom: Val::Px(SIDEBAR_FOOT),
width: Val::Px(SIDEBAR_W),
..default()
},
)
}
fn card(top: f32, height: Option<f32>) -> (Node, BorderColor, BackgroundColor, BoxShadow) {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(top),
bottom: if height.is_some() {
Val::Auto
} else {
Val::Px(0.0)
},
left: Val::Px(10.0),
right: Val::Px(10.0),
height: height.map_or(Val::Auto, Val::Px),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(CARD_BORDER)),
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BorderColor::all(palette::CARD_EDGE),
BackgroundColor(palette::CARD_BG),
menu_ui::card_shadow(),
)
}
fn spawn_score_chip(
root: &mut ChildSpawnerCommands,
art: &crate::app::art::Art,
seat: u8,
(name, tag): (String, &'static str),
arrows: u8,
bot: bool,
) {
root.spawn((
SidePanel(seat),
Node {
position_type: PositionType::Absolute,
top: Val::Px(CHIP_TOPS[seat as usize]),
left: Val::Px(10.0),
right: Val::Px(10.0),
height: Val::Px(CHIP_HEIGHTS[seat as usize]),
align_items: AlignItems::Center,
column_gap: Val::Px(10.0),
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BorderColor::all(palette::seat_paint(seat)),
BackgroundColor(chip_fill(seat, false)),
))
.with_children(|chip| {
chip.spawn((
RankMedal(seat),
Node {
width: Val::Px(26.0),
height: Val::Px(26.0),
flex_shrink: 0.0,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(13.0)),
..default()
},
BackgroundColor(MEDALS[seat as usize]),
))
.with_children(|medal| {
medal.spawn((
RankDigit(seat),
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextColor(palette::ink_on(MEDALS[seat as usize])),
));
});
let fill = chip_fill(seat, false);
chip.spawn(Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
min_width: Val::Px(0.0),
row_gap: Val::Px(3.0),
..default()
})
.with_children(|mid| {
mid.spawn(Node {
column_gap: Val::Px(5.0),
align_items: AlignItems::Center,
..default()
})
.with_children(|line| {
let font = TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
};
line.spawn(Node {
min_width: Val::Px(0.0),
overflow: Overflow::clip_x(),
..default()
})
.with_children(|clip| {
clip.spawn((
Text::new(""),
font.clone(),
TextLayout::no_wrap(),
ChipInk(seat, true),
TextColor(chip_ink(fill, true)),
))
.with_children(|text| {
if bot {
text.spawn(robot_inline(art, seat, 18.0));
}
text.spawn((
TextSpan::new(beside_the_robot(bot, &name)),
font.clone(),
ChipInk(seat, true),
TextColor(chip_ink(fill, true)),
));
});
});
if !tag.is_empty() {
line.spawn((
Text::new(tag.trim_start()),
font,
TextLayout::no_wrap(),
ChipInk(seat, true),
TextColor(chip_ink(fill, true)),
Node {
flex_shrink: 0.0,
..default()
},
));
}
});
mid.spawn(Node {
column_gap: Val::Px(3.0),
..default()
})
.with_children(|dots| {
for index in 0..arrows.min(MAX_DOTS) {
dots.spawn((
ArrowDot { seat, index },
Node {
width: Val::Px(6.0),
height: Val::Px(9.0),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(2.0)),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexEnd,
overflow: Overflow::clip(),
..default()
},
BorderColor::all(Color::NONE),
BackgroundColor(pip_off(fill)),
))
.with_children(|dot| {
dot.spawn((
ArrowFill { seat, index },
Node {
width: Val::Percent(100.0),
height: Val::Percent(0.0),
..default()
},
BackgroundColor(pip_on(fill)),
));
});
}
});
});
chip.spawn((
SideScore { seat, bump: 0.0 },
Text::new("0"),
menu_ui::display_font(SCORE_PX[seat as usize]),
ChipInk(seat, false),
TextColor(chip_ink(fill, false)),
));
chip.spawn((
LeaderCrown(seat),
Visibility::Hidden,
ImageNode::new(art.crown.clone()),
Node {
position_type: PositionType::Absolute,
top: Val::Px(-13.0),
right: Val::Px(6.0),
width: Val::Px(24.0),
height: Val::Px(24.0),
..default()
},
));
});
}
fn spawn_clock_and_feed(commands: &mut Commands) {
commands.spawn(sidebar(false)).with_children(|root| {
clock::spawn_clock(root);
feed::spawn_feed(root);
});
}
pub fn spawn_side_panels(
mut commands: Commands,
seating: Seating,
kinds: Res<crate::app::SeatKinds>,
settings: Res<GameSettings>,
art: Res<crate::app::art::Art>,
sim: Res<Sim>,
mut log: ResMut<EventLog>,
) {
log.0.clear();
let tr = settings.tr();
let labels: Vec<(String, &'static str)> = (0..seating.seats.0.max(2))
.map(|seat| seating.name_and_tag(tr, seat))
.collect();
let arrows = sim.0.signpost_rule().0;
commands.spawn(sidebar(true)).with_children(|root| {
for (seat, label) in labels.into_iter().enumerate() {
let seat = seat as u8;
spawn_score_chip(root, &art, seat, label, arrows, kinds.bot(seat));
}
});
spawn_clock_and_feed(&mut commands);
}
pub fn next_to_go_pulse(secs: f32, reduced_motion: bool) -> f32 {
if reduced_motion {
return 1.0;
}
0.55 + 0.45 * (secs * std::f32::consts::TAU * 1.2).sin().abs()
}
type ChipFrame = (
&'static SidePanel,
&'static mut Node,
&'static mut BackgroundColor,
&'static mut BorderColor,
);
type ChipScore = (
&'static mut SideScore,
&'static mut Text,
&'static mut TextFont,
&'static mut UiTransform,
);
type ChipDigit = (
&'static RankDigit,
&'static mut Text,
&'static mut TextColor,
);
#[derive(SystemParam)]
pub struct Chips<'w, 's> {
frames: Query<'w, 's, ChipFrame>,
scores: Query<'w, 's, ChipScore>,
crowns: Query<'w, 's, (&'static LeaderCrown, &'static mut Visibility)>,
medals: Query<'w, 's, (&'static RankMedal, &'static mut BackgroundColor), Without<SidePanel>>,
digits: Query<'w, 's, ChipDigit, (Without<SideScore>, Without<ChipInk>)>,
inks: Query<'w, 's, (&'static ChipInk, &'static mut TextColor)>,
}
pub fn update_side_panels(
scoring: crate::app::teams::Scoring,
sim: Res<Sim>,
seats: Res<Seats>,
time: Res<Time>,
mut chips: Chips,
mut value: Local<String>,
) {
let Chips {
frames: panels,
scores,
crowns,
medals,
digits,
inks,
} = &mut chips;
let board_scores = sim.0.scores();
let leaders = scoring.leaders(board_scores, seats.0);
let rank = ranks(board_scores, seats.0);
for (panel, mut node, mut bg, mut border) in panels {
let seat = panel.0 as usize;
let r = rank[seat];
let (top, height) = (Val::Px(CHIP_TOPS[r]), Val::Px(CHIP_HEIGHTS[r]));
if node.top != top {
node.top = top;
}
if node.height != height {
node.height = height;
}
menu_ui::set_bg(&mut bg, chip_fill(panel.0, leaders[seat]));
let edge = if leaders[seat] {
LEADER_BORDER
} else {
palette::seat_paint(panel.0)
};
let edge = BorderColor::all(edge);
border.set_if_neq(edge);
}
for (medal, mut bg) in medals {
menu_ui::set_bg(&mut bg, MEDALS[rank[medal.0 as usize]]);
}
for (digit, mut text, mut ink) in digits {
let r = rank[digit.0 as usize];
menu_ui::set_text(&mut text, PLACES[r]);
menu_ui::set_color(&mut ink, palette::ink_on(MEDALS[r]));
}
for (chip, mut ink) in inks {
let fill = chip_fill(chip.0, leaders[usize::from(chip.0)]);
menu_ui::set_color(&mut ink, chip_ink(fill, chip.1));
}
for (mut chip, mut text, mut font, mut transform) in scores {
use std::fmt::Write;
value.clear();
let _ = write!(&mut *value, "{}", board_scores[chip.seat as usize]);
if text.0 != *value {
text.0.clear();
text.0.push_str(&value);
chip.bump = if scoring.local.settings.reduced_motion {
0.0
} else {
1.0
};
}
chip.bump = (chip.bump - time.delta_secs() * 4.0).max(0.0);
let base = FontSize::Px(SCORE_PX[rank[chip.seat as usize]]);
if font.font_size != base {
font.font_size = base;
}
let pop = Vec2::splat(1.0 + 0.22 * chip.bump);
if transform.scale != pop {
transform.scale = pop;
}
}
for (crown, mut visibility) in crowns {
let target = if leaders[crown.0 as usize] {
Visibility::Inherited
} else {
Visibility::Hidden
};
visibility.set_if_neq(target);
}
}
#[derive(SystemParam)]
pub struct ArrowSlots<'w, 's> {
chips: Query<'w, 's, ChipFill, (Without<ArrowFill>, Without<ArrowDot>)>,
dots: Query<'w, 's, SlotDot, (Without<ArrowFill>, Without<SidePanel>)>,
fills: Query<'w, 's, SlotFill, (Without<SidePanel>, Without<ArrowDot>)>,
}
type ChipFill = (&'static SidePanel, &'static BackgroundColor);
type SlotDot = (
&'static ArrowDot,
&'static mut BorderColor,
&'static mut BackgroundColor,
);
type SlotFill = (
&'static ArrowFill,
&'static mut Node,
&'static mut BackgroundColor,
);
pub fn update_arrow_dots(
sim: Res<Sim>,
seating: Seating,
settings: Res<GameSettings>,
clock: Res<crate::app::round_state::RoundClock>,
slots: ArrowSlots,
mut lives: Local<[Vec<f32>; MAX_PLAYERS]>,
) {
let ArrowSlots {
chips,
mut dots,
mut fills,
} = slots;
let board = &sim.0;
let mut chip = [Color::WHITE; MAX_PLAYERS];
for (panel, fill) in &chips {
chip[usize::from(panel.0)] = fill.0;
}
let mut marked = [false; MAX_PLAYERS];
for (seat, out) in lives.iter_mut().enumerate() {
board.signpost_lives(seat as u8, out);
marked[seat] = seating.played_here(seat as u8) && board.next_to_go(seat as u8).is_some();
}
let pulse = next_to_go_pulse(clock.0, settings.reduced_motion);
let is_next = |seat: u8, index: u8| index == 0 && marked[usize::from(seat)];
for (dot, mut border, mut pip) in &mut dots {
let ink = chip[usize::from(dot.seat)];
let standing = usize::from(dot.index) < lives[usize::from(dot.seat)].len();
let edge = BorderColor::all(if is_next(dot.seat, dot.index) {
palette::GOLD
} else if standing {
pip_on(ink)
} else {
Color::NONE
});
border.set_if_neq(edge);
menu_ui::set_bg(
&mut pip,
match standing {
true => Color::NONE,
false => pip_off(ink),
},
);
}
for (fill, mut node, mut bg) in &mut fills {
let life = lives[usize::from(fill.seat)]
.get(usize::from(fill.index))
.copied()
.unwrap_or(0.0);
let height = Val::Percent(100.0 * life);
if node.height != height {
node.height = height;
}
let ink = match is_next(fill.seat, fill.index) {
true => palette::GOLD.with_alpha(pulse),
false => pip_on(chip[usize::from(fill.seat)]),
};
menu_ui::set_bg(&mut bg, ink);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_character_a_chip_writes_is_in_its_font() {
use crate::app::i18n::metrics::{characters_in_font, display_covers};
let mut rows =
characters_in_font(include_bytes!("../../../assets/fonts/DejaVuSansMono.ttf"));
rows.extend(characters_in_font(include_bytes!(
"../../../assets/fonts/NotoSansMonoCJKjp-Subset.otf"
)));
let display = display_covers();
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let mut written: String = [tr.tag_you, tr.tag_ai].concat();
written.push_str(&crate::app::seat_label(tr, 5));
written.push_str("0123456789");
for ch in written.chars().filter(|ch| !ch.is_whitespace()) {
assert!(rows.contains(&ch), "{lang:?}: {ch:?} has no glyph");
}
}
for ch in "0123456789".chars() {
assert!(display.contains(&ch), "{ch:?}");
}
}
#[test]
fn every_medal_digit_reads() {
for (rank, disc) in MEDALS.iter().enumerate() {
let seen = palette::contrast(palette::ink_on(*disc), *disc);
assert!(seen >= palette::TEXT_CONTRAST, "rank {rank}: {seen:.2}");
}
}
#[test]
fn the_score_pop_is_scale_and_never_a_new_font_size() {
let mut app = App::new();
app.init_resource::<Time>();
app.init_resource::<crate::app::net::Online>();
app.init_resource::<crate::app::Playback>();
app.init_resource::<Seats>();
app.insert_resource(GameSettings::default());
app.init_resource::<crate::app::Daily>();
app.insert_resource(Sim(crate::sim::Board::new(4, 4, 0)));
app.world_mut().resource_mut::<Seats>().0 = 2;
app.add_systems(Update, update_side_panels);
let chip = app
.world_mut()
.spawn((
SideScore { seat: 0, bump: 0.0 },
Text::new("0"),
TextFont {
font_size: FontSize::Px(SCORE_PX[0]),
..default()
},
UiTransform::IDENTITY,
))
.id();
app.world_mut().resource_mut::<Sim>().0.set_score(0, 7);
app.update();
let scale_at_peak = app.world().get::<UiTransform>(chip).expect("chip").scale;
let size_at_peak = app.world().get::<TextFont>(chip).expect("chip").font_size;
assert!(
scale_at_peak.x > 1.0,
"the pop should be scale, got {scale_at_peak:?}"
);
assert!(
app.world().get::<SideScore>(chip).expect("chip").bump > 0.0,
"the bump should be running"
);
for _ in 0..5 {
app.update();
}
let scale_later = app.world().get::<UiTransform>(chip).expect("chip").scale;
let size_later = app.world().get::<TextFont>(chip).expect("chip").font_size;
assert!(
scale_later.x <= scale_at_peak.x,
"the pop should decay: {scale_at_peak:?} then {scale_later:?}"
);
assert_eq!(size_at_peak, FontSize::Px(SCORE_PX[0]));
assert_eq!(size_later, FontSize::Px(SCORE_PX[0]), "size never animates");
}
#[test]
fn ranks_sort_by_score_then_seat() {
assert_eq!(ranks(&[4, 4, 9, 1, 0, 0], 4), [1, 2, 0, 3, 0, 0]);
assert_eq!(ranks(&[0, 7, 0, 0, 0, 0], 2), [1, 0, 0, 0, 0, 0]);
assert_eq!(ranks(&[1, 6, 2, 5, 3, 4], 6), [5, 0, 4, 1, 3, 2]);
}
}