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,
}
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 },
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)),
));
});
}
pub fn update_toasts(
time: Res<Time>,
mut commands: Commands,
mut toasts: Query<(Entity, &mut Toast, &mut Node, &ComputedNode)>,
) {
let mut top = TOAST_TOP;
for (entity, mut toast, mut node, computed) in &mut toasts {
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" | "beach_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 ShelfThumb;
const BAR_WIDTH: f32 = 6.0;
const BAR_GAP: f32 = 8.0;
const MIN_THUMB: f32 = 24.0;
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(BAR_GAP),
align_items: AlignItems::FlexStart,
..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,
);
}
},
);
}
});
});
band.spawn((
Node {
width: Val::Px(BAR_WIDTH),
height: Val::Px(SHELF_HEIGHT),
flex_shrink: 0.0,
border_radius: BorderRadius::all(Val::Px(BAR_WIDTH / 2.0)),
..default()
},
BackgroundColor(palette::PARCHMENT.with_alpha(0.12)),
))
.with_children(|track| {
track.spawn((
ShelfThumb,
Node {
position_type: PositionType::Absolute,
width: Val::Px(BAR_WIDTH),
height: Val::Px(SHELF_HEIGHT),
border_radius: BorderRadius::all(Val::Px(BAR_WIDTH / 2.0)),
..default()
},
BackgroundColor(palette::GOLD.with_alpha(0.7)),
));
});
});
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 thumb: Query<&mut Node, With<ShelfThumb>>,
) {
let Ok((position, computed)) = shelf.single() else {
return;
};
let scale = computed.inverse_scale_factor();
let (height, top) = thumb_bounds(
computed.size().y * scale,
computed.content_size().y * scale,
position.0.y,
);
for mut node in &mut thumb {
let (height, top) = (Val::Px(height), Val::Px(top));
if node.height != height {
node.height = height;
}
if node.top != top {
node.top = top;
}
}
}
fn thumb_bounds(viewport: f32, content: f32, scroll: f32) -> (f32, f32) {
if content <= viewport || viewport <= 0.0 {
return (SHELF_HEIGHT, 0.0);
}
let height = (SHELF_HEIGHT * (viewport / content)).max(MIN_THUMB);
let travel = (SHELF_HEIGHT - height).max(0.0);
let progress = (scroll / (content - viewport)).clamp(0.0, 1.0);
(height, travel * progress)
}
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_thumb_reports_how_much_is_hidden() {
let (height, top) = thumb_bounds(100.0, 200.0, 0.0);
assert_eq!(height, SHELF_HEIGHT / 2.0);
assert_eq!(top, 0.0, "at the top");
let (height, top) = thumb_bounds(100.0, 200.0, 100.0);
assert_eq!(top, SHELF_HEIGHT - height, "flush with the bottom");
let (_, top) = thumb_bounds(100.0, 200.0, 50.0);
assert_eq!(top, (SHELF_HEIGHT - SHELF_HEIGHT / 2.0) / 2.0);
assert_eq!(thumb_bounds(100.0, 80.0, 0.0), (SHELF_HEIGHT, 0.0));
let (height, top) = thumb_bounds(100.0, 100_000.0, 100_000.0);
assert_eq!(height, MIN_THUMB);
assert!(top + height <= SHELF_HEIGHT, "{top} + {height}");
assert_eq!(thumb_bounds(0.0, 0.0, 0.0), (SHELF_HEIGHT, 0.0));
}
#[test]
fn the_viewport_is_whole_rows() {
assert_eq!(
SHELF_HEIGHT,
SHELF_ROWS * TILE_HEIGHT + (SHELF_ROWS - 1.0) * TILE_GAP
);
}
}