mod clock;
mod feed;
pub use clock::update_side_clock;
pub use feed::{EventLog, collect_log, update_log};
use crate::app::net::Online;
use crate::app::settings::GameSettings;
use crate::app::teams::TeamMode;
use crate::app::{Bots, Playback, Seats, Sim};
use crate::app::{menu_ui, palette};
use crate::sim::MAX_PLAYERS;
use bevy::prelude::*;
pub const SIDEBAR_W: f32 = 250.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 TierPip {
seat: u8,
index: u8,
}
#[derive(Component)]
pub struct LeaderCrown(pub u8);
#[derive(Component)]
pub struct RankMedal(pub u8);
#[derive(Component)]
pub struct RankDigit(pub u8);
const CHIP_TOPS: [f32; MAX_PLAYERS] = [10.0, 108.0, 172.0, 230.0, 284.0, 334.0];
const CHIP_HEIGHTS: [f32; MAX_PLAYERS] = [88.0, 56.0, 50.0, 46.0, 42.0, 40.0];
const SCORE_PX: [f32; MAX_PLAYERS] = [46.0, 28.0, 24.0, 21.0, 19.0, 18.0];
const MEDALS: [Color; MAX_PLAYERS] = [
Color::srgb(0.95, 0.78, 0.25),
Color::srgb(0.76, 0.79, 0.83),
Color::srgb(0.75, 0.52, 0.33),
Color::srgb(0.42, 0.44, 0.5),
Color::srgb(0.42, 0.44, 0.5),
Color::srgb(0.42, 0.44, 0.5),
];
const LEADER_BORDER: Color = palette::GOLD;
const LOG_TOP: f32 = 88.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(46.0),
bottom: Val::Px(44.0),
width: Val::Px(SIDEBAR_W),
..default()
},
)
}
fn card(top: f32, height: Option<f32>) -> (Node, BorderColor, BackgroundColor) {
(
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(2.0)),
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BorderColor::all(Color::srgba(1.0, 1.0, 1.0, 0.14)),
BackgroundColor(palette::CARD_BG),
)
}
fn spawn_score_chip(
root: &mut ChildSpawnerCommands,
art: &crate::app::art::Art,
seat: u8,
label: String,
) {
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::player_color(seat).lighter(0.08)),
BackgroundColor(palette::player_color(seat).darker(0.22)),
))
.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(15.0),
..default()
},
TextColor(Color::srgb(0.12, 0.11, 0.09)),
));
});
chip.spawn(Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
row_gap: Val::Px(4.0),
..default()
})
.with_children(|mid| {
mid.spawn((
Text::new(label),
TextFont {
font_size: FontSize::Px(14.0),
..default()
},
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.92)),
));
mid.spawn(Node {
column_gap: Val::Px(4.0),
..default()
})
.with_children(|pips| {
for index in 0..3u8 {
pips.spawn((
TierPip { seat, index },
Node {
width: Val::Px(11.0),
height: Val::Px(5.0),
border_radius: BorderRadius::all(Val::Px(2.0)),
..default()
},
BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.25)),
));
}
});
});
chip.spawn((
SideScore { seat, bump: 0.0 },
Text::new("0"),
TextFont {
font_size: FontSize::Px(SCORE_PX[seat as usize]),
..default()
},
TextColor(Color::WHITE),
));
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);
});
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_side_panels(
mut commands: Commands,
seats: Res<Seats>,
settings: Res<GameSettings>,
names: Res<crate::app::SeatNames>,
art: Res<crate::app::art::Art>,
bots: Res<Bots>,
online: Res<Online>,
playback: Res<Playback>,
mut log: ResMut<EventLog>,
) {
log.0.clear();
let tr = settings.tr();
let local = online.0.as_ref().and_then(|s| s.session.seat());
let labels: Vec<String> = (0..seats.0.max(2))
.map(|seat| {
let tag = seat_tag(tr, &bots, local, playback.0.is_some(), seat);
format!("{}{tag}", names.label(tr, seat))
})
.collect();
commands.spawn(sidebar(true)).with_children(|root| {
for (seat, label) in labels.into_iter().enumerate() {
spawn_score_chip(root, &art, seat as u8, label);
}
});
spawn_clock_and_feed(&mut commands);
}
pub fn unique_max<T: Copy + Ord + Default>(values: &[T]) -> Option<usize> {
let best = values.iter().max().copied()?;
if best == T::default() || values.iter().filter(|&&v| v == best).count() != 1 {
return None;
}
values.iter().position(|&v| v == best)
}
pub fn leading_seats(
scores: &[u32; MAX_PLAYERS],
seats: u8,
mode: TeamMode,
) -> [bool; MAX_PLAYERS] {
let mut leaders = [false; MAX_PLAYERS];
if mode == TeamMode::Solo {
if let Some(winner) = unique_max(&scores[..usize::from(seats.max(2))]) {
leaders[winner] = true;
}
return leaders;
}
let totals = crate::app::teams::team_scores(scores, seats, mode);
if let Some(team) = unique_max(&totals) {
for seat in mode.seats_of(team as u8, seats) {
leaders[usize::from(seat)] = true;
}
}
leaders
}
pub fn seat_tag(
tr: &crate::app::i18n::Tr,
bots: &Bots,
local: Option<u8>,
playback_active: bool,
seat: u8,
) -> &'static str {
if bots.0[seat as usize].is_some() {
tr.tag_ai
} else if local == Some(seat) || (local.is_none() && !playback_active && seat == 0) {
tr.tag_you
} else {
""
}
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn update_side_panels(
online: Res<Online>,
sim: Res<Sim>,
seats: Res<Seats>,
settings: Res<GameSettings>,
time: Res<Time>,
mut panels: Query<(
&SidePanel,
&mut Node,
&mut BackgroundColor,
&mut BorderColor,
)>,
mut scores: Query<(&mut SideScore, &mut Text, &mut TextFont, &mut UiTransform)>,
mut crowns: Query<(&LeaderCrown, &mut Visibility)>,
mut pips: Query<(&TierPip, &mut BackgroundColor), Without<SidePanel>>,
mut medals: Query<(&RankMedal, &mut BackgroundColor), (Without<SidePanel>, Without<TierPip>)>,
mut digits: Query<(&RankDigit, &mut Text), Without<SideScore>>,
) {
let board_scores = sim.0.scores();
let mode = crate::app::teams::in_play(&settings, &online, seats.0);
let leaders = leading_seats(board_scores, seats.0, mode);
let rank = ranks(board_scores, seats.0);
for (panel, mut node, mut bg, mut border) in &mut 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;
}
let color = if leaders[seat] {
palette::player_color(panel.0)
} else {
palette::player_color(panel.0).darker(0.22)
};
menu_ui::set_bg(&mut bg, color);
let edge = if leaders[seat] {
LEADER_BORDER
} else {
palette::player_color(panel.0).lighter(0.08)
};
let edge = BorderColor::all(edge);
if *border != edge {
*border = edge;
}
}
for (medal, mut bg) in &mut medals {
menu_ui::set_bg(&mut bg, MEDALS[rank[medal.0 as usize]]);
}
for (digit, mut text) in &mut digits {
menu_ui::set_text(&mut text, &(rank[digit.0 as usize] + 1).to_string());
}
for (mut chip, mut text, mut font, mut transform) in &mut scores {
let value = board_scores[chip.seat as usize].to_string();
if text.0 != value {
text.0 = value;
chip.bump = if 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 &mut crowns {
let target = if leaders[crown.0 as usize] {
Visibility::Inherited
} else {
Visibility::Hidden
};
if *visibility != target {
*visibility = target;
}
}
for (pip, mut bg) in &mut pips {
let tier = crate::sim::castle_tier(board_scores[pip.seat as usize]);
let filled = pip.index < tier;
let target = if filled {
Color::srgba(1.0, 1.0, 1.0, 0.95)
} else {
Color::srgba(1.0, 1.0, 1.0, 0.25)
};
menu_ui::set_bg(&mut bg, target);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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::<Online>();
app.init_resource::<Seats>();
app.insert_resource(GameSettings::default());
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 leaders_need_a_unique_nonzero_top() {
assert_eq!(
leading_seats(&[0, 0, 0, 0, 0, 0], 4, TeamMode::Solo),
[false; MAX_PLAYERS]
);
assert_eq!(
leading_seats(&[1, 5, 2, 0, 0, 0], 4, TeamMode::Solo),
[false, true, false, false, false, false]
);
assert_eq!(
leading_seats(&[5, 5, 0, 0, 0, 0], 2, TeamMode::Solo),
[false; MAX_PLAYERS]
);
assert_eq!(
leading_seats(&[1, 2, 4, 0, 0, 0], 4, TeamMode::Pairs),
[false, false, true, true, false, false]
);
assert_eq!(
leading_seats(&[9, 1, 9, 1, 9, 1], 6, TeamMode::Trios),
[true, false, true, false, true, false]
);
}
#[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]);
}
}