use super::{ACHIEVEMENTS, Stats, Unlocked};
use crate::app::i18n::fill;
use crate::app::menu_ui;
use crate::app::palette;
use crate::app::settings::GameSettings;
use bevy::prelude::*;
#[derive(Component)]
pub struct Toast {
age: f32,
rung: bool,
}
const TOAST_LIFE: f32 = 4.0;
const TOAST_TOP: f32 = 52.0;
const TOAST_GAP: f32 = 6.0;
const TOAST_GUESS: f32 = 64.0;
pub(super) fn spawn_toast(commands: &mut Commands, name: &str, desc: &str) {
commands
.spawn((
Toast {
age: 0.0,
rung: false,
},
GlobalZIndex(menu_ui::layer::TOAST),
Node {
position_type: PositionType::Absolute,
top: Val::Px(TOAST_TOP),
right: Val::Px(12.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(2.0),
padding: UiRect::axes(Val::Px(14.0), Val::Px(10.0)),
..default()
},
BackgroundColor(palette::TOAST_FILL),
))
.with_children(|toast| {
toast.spawn((
Text::new(format!("* {name}")),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::GOLD),
));
toast.spawn((
Text::new(desc.to_string()),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.9)),
));
});
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Chime<'w> {
settings: Res<'w, GameSettings>,
sounds: Option<Res<'w, crate::app::audio::Sounds>>,
muted: Res<'w, crate::app::audio::Muted>,
}
pub fn update_toasts(
time: Res<Time>,
mut commands: Commands,
menu: Res<crate::app::match_setup::MatchMenu>,
chime: Chime,
mut toasts: Query<(Entity, &mut Toast, &mut Node, &ComputedNode)>,
) {
let held = crate::app::next_map::page_up(&menu);
let display = match held {
true => Display::None,
false => Display::Flex,
};
let mut top = TOAST_TOP;
for (entity, mut toast, mut node, computed) in &mut toasts {
if node.display != display {
node.display = display;
}
if held {
continue;
}
if !std::mem::replace(&mut toast.rung, true)
&& let Some(sounds) = &chime.sounds
{
let gain = crate::app::audio::sfx_gain(&chime.settings, &chime.muted);
crate::app::audio::play_chime(&mut commands, sounds, gain);
}
toast.age += time.delta_secs();
if toast.age >= TOAST_LIFE {
commands.entity(entity).despawn();
continue;
}
let target = Val::Px(top);
if node.top != target {
node.top = target;
}
let height = computed.size().y * computed.inverse_scale_factor();
top += if height > 0.0 { height } else { TOAST_GUESS } + TOAST_GAP;
}
}
#[derive(Component)]
pub struct AchievementsUi;
fn category_ink(id: &str) -> Color {
match id {
_ if id.starts_with("win_") || id.starts_with("series_") => palette::INK_RAID,
_ if id.starts_with("online_") || id.starts_with("rounds_") => palette::INK_RAID,
_ if id.starts_with("crowd_") || id.starts_with("hosted_") => palette::INK_RAID,
"dry_castle" | "raids_25" | "all_seats" => palette::INK_RAID,
_ if id.starts_with("puzzle_") => palette::INK_SURGE,
"campaign_done" => palette::INK_SURGE,
_ if id.starts_with("under_par") || id.starts_with("clean_") => palette::INK_SURGE,
_ if id.starts_with("deep_") => palette::INK_SURGE,
_ if id.starts_with("daily_") => palette::INK_TIDE,
_ if id.starts_with("built_") => palette::INK_LURE,
"shared_1" | "taken_1" => palette::INK_LURE,
"fed_25" => palette::PARCHMENT,
_ => palette::GOLD,
}
}
fn unlock_mark(done: bool, id: &str) -> (Node, BackgroundColor, BorderColor) {
let ink = category_ink(id);
(
Node {
width: Val::Px(14.0),
height: Val::Px(14.0),
flex_shrink: 0.0,
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(7.0)),
..default()
},
BackgroundColor(if done { ink } else { Color::NONE }),
BorderColor::all(if done { ink } else { ink.with_alpha(0.35) }),
)
}
const COLS: usize = 1;
pub(super) const ROW_PX: f32 = 1176.0;
pub(super) const NAME_PX: f32 = 300.0;
const TILE_HEIGHT: f32 = 35.0;
const TILE_GAP: f32 = 2.0;
const ROW_PITCH: f32 = TILE_HEIGHT + TILE_GAP;
const SHELF_ROWS: f32 = 13.0;
const SHELF_HEIGHT: f32 = SHELF_ROWS * ROW_PITCH - TILE_GAP;
#[derive(Component)]
pub struct Shelf;
#[derive(Component)]
pub struct ShelfBar;
fn spawn_trophy(
column: &mut ChildSpawnerCommands,
tr: &crate::app::i18n::Tr,
index: usize,
done: bool,
now: u32,
goal: u32,
) {
let ink = if done {
palette::SELECTED_ROW
} else {
palette::IDLE_ROW.with_alpha(0.55)
};
column
.spawn((
Node {
width: Val::Px(ROW_PX),
height: Val::Px(TILE_HEIGHT),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(3.0),
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(if done {
palette::GOLD.with_alpha(0.10)
} else {
Color::NONE
}),
))
.with_children(|tile| {
tile.spawn(Node {
column_gap: Val::Px(8.0),
align_items: AlignItems::Center,
width: Val::Percent(100.0),
..default()
})
.with_children(|line| {
line.spawn(unlock_mark(done, ACHIEVEMENTS[index].id));
line.spawn((
Node {
width: Val::Px(NAME_PX),
flex_shrink: 0.0,
overflow: Overflow::clip_x(),
..default()
},
children![(
Text::new(tr.ach_names[index].to_string()),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextLayout::no_wrap(),
TextColor(ink),
)],
));
line.spawn((
Node {
flex_grow: 1.0,
flex_basis: Val::Px(0.0),
min_width: Val::Px(0.0),
overflow: Overflow::clip_x(),
..default()
},
children![(
Text::new(tr.ach_descs[index].to_string()),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::FINE),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT.with_alpha(if done { 0.7 } else { 0.45 })),
)],
));
line.spawn((
Node {
flex_shrink: 0.0,
..default()
},
children![(
Text::new(format!("{now}/{goal}")),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::FINE),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT.with_alpha(if done { 0.7 } else { 0.4 })),
)],
));
});
let filled = if goal == 0 {
100.0
} else {
(now.min(goal) as f32 / goal as f32) * 100.0
};
tile.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Px(3.0),
border_radius: BorderRadius::all(Val::Px(2.0)),
..default()
},
BackgroundColor(palette::BAR_TRACK),
children![(
Node {
width: Val::Percent(filled),
height: Val::Percent(100.0),
border_radius: BorderRadius::all(Val::Px(2.0)),
..default()
},
BackgroundColor(if done {
palette::GOLD
} else {
palette::GOLD.with_alpha(0.45)
}),
)],
));
});
}
pub fn enter_achievements(
mut commands: Commands,
settings: Res<GameSettings>,
stats: Res<Stats>,
unlocked: Res<Unlocked>,
) {
let tr = settings.tr();
let per_column = ACHIEVEMENTS.len().div_ceil(COLS);
commands
.spawn((
AchievementsUi,
menu_ui::between_bars(),
))
.with_children(|wrap| {
let (mark, mut node, bg, edge, shadow) = menu_ui::screen_card();
node.row_gap = Val::Px(8.0);
wrap.spawn((mark, node, bg, edge, shadow))
.with_children(|card| {
let earned = ACHIEVEMENTS
.iter()
.filter(|a| unlocked.0.contains(a.id))
.count();
card.spawn((
Text::new(fill(
tr.ach_progress,
&[
("a", &earned.to_string()),
("b", &ACHIEVEMENTS.len().to_string()),
],
)),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.75)),
));
card.spawn(Node {
column_gap: Val::Px(menu_ui::scroll_bar::BAR_GAP),
..default()
})
.with_children(|band| {
band.spawn((
Shelf,
Node {
max_height: Val::Px(SHELF_HEIGHT),
overflow: Overflow::scroll_y(),
..default()
},
ScrollPosition::default(),
))
.with_children(|shelf| {
shelf
.spawn(Node {
column_gap: Val::Px(16.0),
..default()
})
.with_children(|grid| {
for chunk in 0..COLS {
grid.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(2.0),
..default()
})
.with_children(
|column| {
let first = chunk * per_column;
let last =
(first + per_column).min(ACHIEVEMENTS.len());
for (index, achievement) in ACHIEVEMENTS
.iter()
.enumerate()
.take(last)
.skip(first)
{
let done = unlocked.0.contains(achievement.id);
let (now, goal) = achievement.progress(&stats);
spawn_trophy(
column, tr, index, done, now, goal,
);
}
},
);
}
});
});
menu_ui::scroll_bar::spawn(band, ShelfBar);
});
let footer = fill(
tr.stats_footer,
&[
("banked", &stats.banked.to_string()),
("golden", &stats.golden.to_string()),
("wins", &stats.wins.to_string()),
("rounds", &stats.rounds.to_string()),
("puzzles", &stats.puzzles.to_string()),
],
);
card.spawn((
Text::new(footer),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.6)),
));
});
});
}
pub fn achievements_input(
keys: Res<ButtonInput<KeyCode>>,
mut next_screen: ResMut<NextState<crate::app::Screen>>,
mut shelf: Query<(&mut ScrollPosition, &ComputedNode), With<Shelf>>,
) {
if keys.just_pressed(KeyCode::Escape) || menu_ui::enter(&keys) {
next_screen.set(crate::app::Screen::Menu);
return;
}
let pressed = |a, b| keys.just_pressed(a) || keys.just_pressed(b);
let mut step = 0.0;
if pressed(KeyCode::KeyS, KeyCode::ArrowDown) {
step += ROW_PITCH;
}
if pressed(KeyCode::KeyW, KeyCode::ArrowUp) {
step -= ROW_PITCH;
}
if step == 0.0 {
return;
}
for (mut position, computed) in &mut shelf {
let scale = computed.inverse_scale_factor();
position.0.y = scrolled(
position.0.y,
step,
computed.size().y * scale,
computed.content_size().y * scale,
);
}
}
pub fn update_shelf_scrollbar(
shelf: Query<(&ScrollPosition, &ComputedNode), With<Shelf>>,
mut bars: menu_ui::scroll_bar::ScrollBars<ShelfBar>,
) {
let Ok((position, computed)) = shelf.single() else {
return;
};
let scale = computed.inverse_scale_factor();
bars.show(menu_ui::scroll_bar::ScrollWindow {
first: position.0.y,
shown: computed.size().y * scale,
total: computed.content_size().y * scale,
});
}
fn scrolled(from: f32, step: f32, viewport: f32, content: f32) -> f32 {
(from + step).clamp(0.0, (content - viewport).max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shelf_stops_at_both_ends() {
let viewport = 4.0 * ROW_PITCH;
let content = 10.0 * ROW_PITCH;
let bottom = content - viewport;
assert_eq!(scrolled(0.0, -ROW_PITCH, viewport, content), 0.0, "at rest");
assert_eq!(scrolled(0.0, ROW_PITCH, viewport, content), ROW_PITCH);
assert_eq!(
scrolled(bottom, ROW_PITCH, viewport, content),
bottom,
"the last row is the last row"
);
assert_eq!(
scrolled(bottom, -ROW_PITCH, viewport, content),
bottom - ROW_PITCH
);
assert_eq!(scrolled(0.0, ROW_PITCH, viewport, viewport - 10.0), 0.0);
}
#[test]
fn the_bar_follows_the_shelf_viewport() {
use crate::app::menu_ui::scroll_bar::{self, reading};
use bevy::ecs::system::RunSystemOnce;
let mut app = App::new();
app.init_resource::<ButtonInput<KeyCode>>();
let shelf = app
.world_mut()
.spawn((
Shelf,
ScrollPosition::default(),
ComputedNode {
size: Vec2::new(400.0, 4.0 * ROW_PITCH),
content_size: Vec2::new(400.0, 10.0 * ROW_PITCH),
inverse_scale_factor: 1.0,
..default()
},
))
.id();
app.world_mut()
.run_system_once(|mut commands: Commands| {
commands.spawn(Node::default()).with_children(|band| {
scroll_bar::spawn(band, ShelfBar);
});
})
.expect("the bar is built");
app.add_systems(Update, update_shelf_scrollbar);
let bar = |app: &mut App| {
app.update();
reading::<ShelfBar>(app.world_mut())
};
assert_eq!(bar(&mut app), Some([0.0, 0.4, 0.6]), "at the top");
let bottom = scrolled(1000.0, 0.0, 4.0 * ROW_PITCH, 10.0 * ROW_PITCH);
app.world_mut()
.entity_mut(shelf)
.insert(ScrollPosition(Vec2::new(0.0, bottom)));
let [above, thumb, below] = bar(&mut app).expect("still overflows");
assert!((above - 0.6).abs() < 1e-5 && thumb == 0.4 && below.abs() < 1e-5);
app.world_mut().entity_mut(shelf).insert(ComputedNode {
size: Vec2::new(400.0, 4.0 * ROW_PITCH),
content_size: Vec2::new(400.0, 3.0 * ROW_PITCH),
inverse_scale_factor: 1.0,
..default()
});
assert_eq!(bar(&mut app), None);
}
#[test]
fn the_viewport_is_whole_rows() {
assert_eq!(
SHELF_HEIGHT,
SHELF_ROWS * TILE_HEIGHT + (SHELF_ROWS - 1.0) * TILE_GAP
);
}
#[test]
fn a_toast_waits_out_the_map_page() {
use bevy::ecs::system::RunSystemOnce;
use std::time::Duration;
let mut app = App::new();
app.insert_resource(Time::<()>::default());
app.insert_resource(GameSettings::default());
app.init_resource::<crate::app::audio::Muted>();
app.insert_resource(crate::app::match_setup::MatchMenu {
online: true,
step: crate::app::match_setup::Step::Map,
..Default::default()
});
app.add_systems(Update, update_toasts);
app.world_mut()
.run_system_once(|mut commands: Commands| spawn_toast(&mut commands, "A", "B"))
.expect("spawned");
let toast = |app: &mut App| {
let world = app.world_mut();
world
.query::<(&Toast, &Node)>()
.iter(world)
.map(|(toast, node)| (toast.age, node.display))
.next()
};
for _ in 0..3 {
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs(2));
app.update();
}
assert_eq!(toast(&mut app), Some((0.0, Display::None)), "held");
app.world_mut()
.resource_mut::<crate::app::match_setup::MatchMenu>()
.online = false;
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs(1));
app.update();
let (age, display) = toast(&mut app).expect("still up");
assert_eq!(display, Display::Flex, "shown once the page is down");
assert!(age > 0.0 && age < TOAST_LIFE, "{age}");
}
}