use crate::app::i18n::fill;
use crate::app::progress::Progress;
use crate::app::settings::GameSettings;
use crate::app::{Campaign, PlacementDenied, Screen, i18n, menu_ui, palette};
use bevy::prelude::*;
pub const COLS: usize = 10;
const GRID_ROOM: f32 = 430.0;
const GAP: f32 = 6.0;
const HEADING_ROOM: f32 = 6.0 + 1.0 + 4.0 + 18.0 + GAP;
#[derive(Clone, Copy)]
enum Caption {
Stage,
Hint,
}
fn tile(rows: usize, heading: f32) -> f32 {
let rows = rows.max(1) as f32;
((GRID_ROOM - heading - (rows - 1.0) * GAP) / rows).min(52.0)
}
pub fn rows(builtins: usize, total: usize) -> Vec<std::ops::Range<usize>> {
let builtins = builtins.min(total);
let mut out = Vec::new();
for (start, end) in [(0, builtins), (builtins, total)] {
for first in (start..end).step_by(COLS) {
out.push(first..(first + COLS).min(end));
}
}
out
}
pub fn is_custom(campaign: &Campaign, index: usize) -> bool {
index >= campaign.builtins
}
pub fn tile_number(campaign: &Campaign, index: usize) -> usize {
if is_custom(campaign, index) {
index - campaign.builtins + 1
} else {
index + 1
}
}
#[derive(Resource, Default)]
pub struct StageList {
pub selected: usize,
rows: Vec<std::ops::Range<usize>>,
}
#[derive(Component)]
pub struct StageSelectUi;
#[derive(Component)]
pub struct StageTile(usize);
#[derive(Component)]
pub struct StageNumber(usize);
#[derive(Component)]
pub struct StageCaption;
#[derive(Component)]
pub struct StageHint;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TileState {
Cleared,
Open,
Locked,
}
pub fn difficulty_ink(posts: u8) -> Color {
match posts {
0 => palette::INK_TIDE,
1 => Color::srgb(0.52, 0.82, 0.55),
2 => palette::GOLD,
3 => Color::srgb(0.96, 0.62, 0.30),
_ => palette::INK_RAID,
}
}
impl TileState {
fn of(progress: &Progress, campaign: &Campaign, index: usize) -> TileState {
if progress.is_cleared(campaign.kind, &campaign.levels[index].name) {
TileState::Cleared
} else if progress.unlocked(campaign, index) {
TileState::Open
} else {
TileState::Locked
}
}
fn colors(self) -> (Color, Color) {
match self {
TileState::Cleared => (palette::GOLD.with_alpha(0.28), Color::srgb(1.0, 0.96, 0.86)),
TileState::Open => (Color::srgba(0.95, 0.93, 0.84, 0.12), palette::IDLE_ROW),
TileState::Locked => (
Color::srgba(0.06, 0.08, 0.11, 0.45),
Color::srgba(0.95, 0.93, 0.84, 0.22),
),
}
}
fn edge_alpha(self) -> f32 {
match self {
TileState::Cleared | TileState::Open => 0.95,
TileState::Locked => 0.30,
}
}
}
pub fn step(
selected: usize,
rows: &[std::ops::Range<usize>],
keys: &ButtonInput<KeyCode>,
) -> usize {
let Some(last) = rows.last().map(|row| row.end - 1) else {
return selected;
};
let pressed = |a: KeyCode, b: KeyCode| keys.just_pressed(a) || keys.just_pressed(b);
let mut at = selected.min(last);
if pressed(KeyCode::KeyA, KeyCode::ArrowLeft) {
at = at.saturating_sub(1);
}
if pressed(KeyCode::KeyD, KeyCode::ArrowRight) {
at = (at + 1).min(last);
}
let row = rows.iter().position(|row| row.contains(&at)).unwrap_or(0);
let column = at - rows[row].start;
let mut land = |row: usize| {
let row = &rows[row];
at = (row.start + column).min(row.end - 1);
};
if pressed(KeyCode::KeyW, KeyCode::ArrowUp) && row > 0 {
land(row - 1);
}
if pressed(KeyCode::KeyS, KeyCode::ArrowDown) && row + 1 < rows.len() {
land(row + 1);
}
at
}
pub fn caption(
tr: &i18n::Tr,
lang: i18n::Lang,
campaign: &Campaign,
state: TileState,
index: usize,
) -> String {
let level = &campaign.levels[index];
let tail = match state {
TileState::Cleared => tr.stage_cleared.to_string(),
TileState::Open if level.posts == 0 => tr.stage_free.to_string(),
TileState::Open => fill(tr.stage_open, &[("n", &level.posts.to_string())]),
TileState::Locked => fill(tr.stage_locked, &[("n", &index.to_string())]),
};
let shelf = if is_custom(campaign, index) {
format!("{} ", tr.stage_custom)
} else {
String::new()
};
format!(
"{shelf}{}. {} - {tail}",
tile_number(campaign, index),
lang.level_name(&level.name)
)
}
pub fn enter_stage_select(
mut commands: Commands,
campaign: Res<Campaign>,
progress: Res<Progress>,
settings: Res<GameSettings>,
mut list: ResMut<StageList>,
) {
list.selected = progress.furthest_open(&campaign);
list.rows = rows(campaign.builtins, campaign.levels.len());
let tr = settings.tr();
commands
.spawn((
StageSelectUi,
Node {
position_type: PositionType::Absolute,
top: Val::Px(52.0),
bottom: Val::Px(52.0),
left: Val::Px(0.0),
right: Val::Px(0.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
row_gap: Val::Px(12.0),
..default()
},
))
.with_children(|wrap| {
wrap.spawn((
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: Val::Px(12.0),
padding: UiRect::axes(Val::Px(28.0), Val::Px(20.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(16.0)),
..default()
},
BackgroundColor(palette::CARD_FILL),
BorderColor::all(palette::CARD_EDGE),
))
.with_children(|column| {
let shipped = 0..campaign.builtins;
let total = shipped.len();
let done = progress.cleared_in(&campaign, shipped);
column.spawn((
Text::new(fill(
tr.stage_progress,
&[("a", &done.to_string()), ("b", &total.to_string())],
)),
TextFont {
font_size: FontSize::Px(18.0),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.75)),
));
let filled = if total == 0 {
0.0
} else {
done as f32 / total as f32 * 100.0
};
column.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Px(4.0),
border_radius: BorderRadius::all(Val::Px(2.0)),
..default()
},
BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.08)),
children![(
Node {
width: Val::Percent(filled),
height: Val::Percent(100.0),
border_radius: BorderRadius::all(Val::Px(2.0)),
..default()
},
BackgroundColor(palette::GOLD),
)],
));
let layout = &list.rows;
let has_custom = campaign.builtins < campaign.levels.len();
let heading = if has_custom { HEADING_ROOM } else { 0.0 };
let edge = tile(layout.len(), heading);
column
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: Val::Px(GAP),
..default()
})
.with_children(|grid| {
for row in layout {
if has_custom && row.start == campaign.builtins {
spawn_shelf_heading(grid, tr, &campaign, &progress);
}
grid.spawn(Node {
column_gap: Val::Px(GAP),
..default()
})
.with_children(|line| {
for index in row.clone() {
spawn_tile(line, &campaign, &progress, index, edge);
}
});
}
});
column
.spawn(Node {
column_gap: Val::Px(14.0),
align_items: AlignItems::Center,
margin: UiRect::top(Val::Px(4.0)),
..default()
})
.with_children(|key| {
key.spawn((
Text::new(tr.stage_key.to_string()),
TextFont {
font_size: FontSize::Px(13.0),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.45)),
));
for posts in 1..=4u8 {
key.spawn(Node {
column_gap: Val::Px(5.0),
align_items: AlignItems::Center,
..default()
})
.with_children(|item| {
item.spawn((
Node {
width: Val::Px(10.0),
height: Val::Px(10.0),
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(3.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(difficulty_ink(posts)),
));
item.spawn((
Text::new(posts.to_string()),
TextFont {
font_size: FontSize::Px(13.0),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.60)),
));
});
}
});
});
wrap.spawn(Node {
width: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
})
.with_children(|lines| {
for (height, font, color, tag) in [
(26.0, 20.0, palette::SELECTED_ROW, Caption::Stage),
(
22.0,
17.0,
palette::SELECTED_ROW.with_alpha(0.8),
Caption::Hint,
),
] {
lines
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Px(height),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
overflow: Overflow::clip(),
..default()
})
.with_children(|line| {
let mut text = line.spawn((
Text::new(String::new()),
TextFont {
font_size: FontSize::Px(font),
..default()
},
TextLayout::no_wrap(),
TextColor(color),
));
match tag {
Caption::Stage => text.insert(StageCaption),
Caption::Hint => text.insert(StageHint),
};
});
}
});
});
}
fn spawn_shelf_heading(
grid: &mut ChildSpawnerCommands,
tr: &i18n::Tr,
campaign: &Campaign,
progress: &Progress,
) {
let mine = campaign.builtins..campaign.levels.len();
let count = fill(
tr.stage_progress,
&[
(
"a",
&progress.cleared_in(campaign, mine.clone()).to_string(),
),
("b", &mine.len().to_string()),
],
);
grid.spawn(Node {
width: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: Val::Px(4.0),
margin: UiRect::top(Val::Px(6.0)),
..default()
})
.with_children(|shelf| {
shelf.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
..default()
},
BackgroundColor(palette::PARCHMENT.with_alpha(0.14)),
));
shelf
.spawn(Node {
column_gap: Val::Px(8.0),
align_items: AlignItems::Center,
..default()
})
.with_children(|line| {
line.spawn((
Text::new(tr.stage_custom.to_string()),
TextFont {
font_size: FontSize::Px(13.0),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.60)),
));
line.spawn((
Text::new(count),
TextFont {
font_size: FontSize::Px(13.0),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.40)),
));
});
});
}
fn spawn_tile(
line: &mut ChildSpawnerCommands,
campaign: &Campaign,
progress: &Progress,
index: usize,
size: f32,
) {
let state = TileState::of(progress, campaign, index);
let (fill, ink) = state.colors();
let border = difficulty_ink(campaign.levels[index].posts).with_alpha(state.edge_alpha());
line.spawn((
StageTile(index),
Node {
width: Val::Px(size),
height: Val::Px(size),
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(size * 0.17)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(fill),
BorderColor::all(border),
))
.with_children(|tile| {
tile.spawn((
StageNumber(index),
Text::new(tile_number(campaign, index).to_string()),
TextFont {
font_size: FontSize::Px((size * 0.4).min(20.0)),
..default()
},
TextColor(ink),
));
});
}
#[allow(clippy::too_many_arguments)]
pub fn update_stage_tiles(
list: Res<StageList>,
campaign: Res<Campaign>,
progress: Res<Progress>,
settings: Res<GameSettings>,
mut tiles: Query<(&StageTile, &mut BorderColor, &mut BackgroundColor)>,
mut numbers: Query<(&StageNumber, &mut TextColor)>,
mut captions: Query<&mut Text, With<StageCaption>>,
mut hints: Query<&mut Text, (With<StageHint>, Without<StageCaption>)>,
) {
for (tile, mut border, mut fill) in &mut tiles {
let state = TileState::of(&progress, &campaign, tile.0);
let picked = tile.0 == list.selected;
let target = if picked {
palette::SELECTED_ROW
} else {
difficulty_ink(campaign.levels[tile.0].posts).with_alpha(state.edge_alpha())
};
if border.top != target {
*border = BorderColor::all(target);
}
let ground = if picked {
palette::SELECTED_ROW.with_alpha(0.55)
} else {
state.colors().0
};
if fill.0 != ground {
fill.0 = ground;
}
}
for (number, mut color) in &mut numbers {
let target = if number.0 == list.selected {
palette::CARD_FILL.with_alpha(1.0)
} else {
TileState::of(&progress, &campaign, number.0).colors().1
};
menu_ui::set_color(&mut color, target);
}
let state = TileState::of(&progress, &campaign, list.selected);
if let Ok(mut text) = captions.single_mut() {
let line = caption(
settings.tr(),
settings.language,
&campaign,
state,
list.selected,
);
menu_ui::set_text(&mut text, &line);
}
if let Ok(mut text) = hints.single_mut() {
let hint = match state {
TileState::Locked => "",
TileState::Cleared | TileState::Open => settings
.language
.level_hint(&campaign.levels[list.selected].name)
.unwrap_or(""),
};
menu_ui::set_text(&mut text, hint);
}
}
pub fn stage_select_input(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<Progress>,
mut list: ResMut<StageList>,
mut campaign: ResMut<Campaign>,
mut denied: MessageWriter<PlacementDenied>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let at = step(list.selected, &list.rows, &keys);
list.selected = at;
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
return;
}
if !keys.just_pressed(KeyCode::Enter) {
return;
}
if progress.unlocked(&campaign, list.selected) {
campaign.index = list.selected;
next_screen.set(Screen::Puzzle);
} else {
denied.write(PlacementDenied { player: 0 });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::CampaignKind;
use crate::app::i18n::EN;
use crate::sim::campaign_levels;
fn campaign() -> Campaign {
let levels = campaign_levels();
let builtins = levels.len();
Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
}
}
fn press(key: KeyCode) -> ButtonInput<KeyCode> {
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(key);
keys
}
#[test]
fn the_grid_clamps_at_its_edges() {
let len = 25; let grid = rows(len, len);
assert_eq!(step(0, &grid, &press(KeyCode::ArrowLeft)), 0);
assert_eq!(step(0, &grid, &press(KeyCode::ArrowUp)), 0);
assert_eq!(step(0, &grid, &press(KeyCode::ArrowRight)), 1);
assert_eq!(step(0, &grid, &press(KeyCode::ArrowDown)), COLS);
assert_eq!(step(24, &grid, &press(KeyCode::ArrowRight)), 24);
assert_eq!(step(24, &grid, &press(KeyCode::ArrowDown)), 24);
assert_eq!(
step(18, &grid, &press(KeyCode::ArrowDown)),
24,
"ragged row"
);
assert_eq!(step(24, &grid, &press(KeyCode::ArrowUp)), 14);
assert_eq!(
step(99, &grid, &press(KeyCode::ArrowRight)),
24,
"stale index"
);
assert_eq!(
step(3, &rows(0, 0), &press(KeyCode::ArrowRight)),
3,
"no grid to walk"
);
}
#[test]
fn the_two_shelves_never_share_a_row() {
assert_eq!(rows(25, 25), vec![0..10, 10..20, 20..25]);
assert_eq!(rows(12, 15), vec![0..10, 10..12, 12..15]);
assert_eq!(rows(0, 3), vec![0..3], "nothing shipped, all yours");
assert_eq!(rows(5, 5), vec![0..5], "nothing of yours yet");
assert_eq!(rows(9, 4), vec![0..4], "a builtins count past the end");
}
#[test]
fn the_cursor_walks_between_the_shelves() {
let grid = rows(12, 15);
assert_eq!(step(1, &grid, &press(KeyCode::ArrowDown)), 11, "into row 2");
assert_eq!(
step(11, &grid, &press(KeyCode::ArrowDown)),
13,
"onto the custom shelf, column kept"
);
assert_eq!(
step(5, &grid, &press(KeyCode::ArrowDown)),
11,
"ragged row: falls to its end"
);
assert_eq!(
step(14, &grid, &press(KeyCode::ArrowUp)),
11,
"back up into the campaign"
);
assert_eq!(step(13, &grid, &press(KeyCode::ArrowDown)), 13, "last row");
assert_eq!(
step(11, &grid, &press(KeyCode::ArrowRight)),
12,
"right walks on into the shelf below"
);
}
#[test]
fn the_custom_shelf_numbers_itself() {
let mut campaign = campaign();
let builtins = campaign.builtins;
let mut mine = campaign.levels[0].clone();
mine.name = "Driftwood Arena".into();
campaign.levels.push(mine);
assert!(!is_custom(&campaign, builtins - 1));
assert!(is_custom(&campaign, builtins));
assert_eq!(tile_number(&campaign, 0), 1);
assert_eq!(tile_number(&campaign, builtins - 1), builtins);
assert_eq!(tile_number(&campaign, builtins), 1, "yours count from one");
let line = caption(&EN, i18n::Lang::En, &campaign, TileState::Open, builtins);
assert!(line.starts_with(EN.stage_custom), "{line}");
assert!(line.contains("1. Driftwood Arena"), "{line}");
let shipped = caption(&EN, i18n::Lang::En, &campaign, TileState::Open, 0);
assert!(shipped.starts_with("1. Welcome Ashore"), "{shipped}");
}
#[test]
fn enter_plays_an_open_stage_and_refuses_a_locked_one() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.add_message::<PlacementDenied>();
app.insert_resource(campaign());
app.init_resource::<Progress>();
app.init_resource::<StageList>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_systems(Update, stage_select_input);
let enter = |app: &mut App, at: usize| {
app.world_mut().resource_mut::<StageList>().selected = at;
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::Enter);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.update();
};
enter(&mut app, 2);
assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
let denials = app
.world_mut()
.resource_mut::<Messages<PlacementDenied>>()
.drain()
.count();
assert_eq!(denials, 1, "a locked stage says no out loud");
enter(&mut app, 0);
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Puzzle
);
assert_eq!(app.world().resource::<Campaign>().index, 0);
}
#[test]
fn the_caption_reads_the_state_of_the_stage() {
let campaign = campaign();
let mut progress = Progress::default();
let open = caption(&EN, i18n::Lang::En, &campaign, TileState::Open, 0);
assert!(open.starts_with("1. Welcome Ashore"), "{open}");
assert!(open.contains('1'), "its one signpost: {open}");
let locked = caption(&EN, i18n::Lang::En, &campaign, TileState::Locked, 4);
assert!(locked.contains("clear stage 4"), "{locked}");
let watch = caption(&EN, i18n::Lang::En, &campaign, TileState::Open, 4);
assert_eq!(campaign.levels[4].posts, 0, "stage 5 is the watch level");
assert!(watch.ends_with(EN.stage_free), "{watch}");
progress.mark(campaign.kind, &campaign.levels[0].name);
let state = TileState::of(&progress, &campaign, 0);
assert_eq!(state, TileState::Cleared);
assert_eq!(TileState::of(&progress, &campaign, 1), TileState::Open);
assert_eq!(TileState::of(&progress, &campaign, 2), TileState::Locked);
let cleared = caption(&EN, i18n::Lang::En, &campaign, state, 0);
assert!(cleared.ends_with(EN.stage_cleared), "{cleared}");
}
}