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)
}
#[cfg(test)]
pub fn rows(builtins: usize, total: usize) -> Vec<std::ops::Range<usize>> {
let builtins = builtins.min(total);
rows_of(&[0..builtins, builtins..total])
}
pub fn rows_of(sections: &[std::ops::Range<usize>]) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
for section in sections {
for first in section.clone().step_by(COLS) {
out.push(first..(first + COLS).min(section.end));
}
}
out
}
fn sections(campaign: &Campaign) -> [(crate::app::campaign::Section, std::ops::Range<usize>); 3] {
use crate::app::campaign::Section;
[Section::TidePool, Section::Coop, Section::Custom].map(|s| (s, campaign.section(s)))
}
pub fn is_custom(campaign: &Campaign, index: usize) -> bool {
index >= campaign.builtins
}
pub fn tile_number(campaign: &Campaign, index: usize) -> usize {
campaign.place_of(index).at
}
#[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;
type HintLine<'w, 's> =
Query<'w, 's, (&'static mut Text, &'static mut Node), (With<StageHint>, Without<StageCaption>)>;
#[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 => palette::INK_ONE_POST,
2 => palette::GOLD,
3 => palette::INK_THREE_POSTS,
4 => palette::INK_RAID,
_ => palette::INK_LURE,
}
}
pub const KEY_POSTS: std::ops::RangeInclusive<u8> = 1..=5;
impl TileState {
fn of(progress: &Progress, campaign: &Campaign, index: usize) -> TileState {
if progress.level_cleared(&campaign.levels[index]) {
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), palette::TILE_CLEARED_INK),
TileState::Open => (palette::TILE_OPEN_FILL, palette::IDLE_ROW),
TileState::Locked => (palette::TILE_LOCKED_FILL, palette::TILE_LOCKED_INK),
}
}
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 if let Some([left, right]) = level.hands() => fill(
&crate::app::name_the_pair(tr, tr.stage_open_hands),
&[("a", &left.to_string()), ("b", &right.to_string())],
),
TileState::Open => fill(tr.stage_open, &[("n", &level.posts.to_string())]),
TileState::Locked => locked_tail(tr, campaign, index),
};
let shelf = match campaign.section_of(index) {
crate::app::campaign::Section::Custom => format!("{} ", tr.stage_custom),
crate::app::campaign::Section::Coop => format!("{} ", tr.stage_coop),
crate::app::campaign::Section::TidePool => String::new(),
};
format!(
"{shelf}{}. {} - {tail}",
tile_number(campaign, index),
campaign.shown_name(index, lang)
)
}
fn locked_tail(tr: &i18n::Tr, campaign: &Campaign, index: usize) -> String {
use crate::app::campaign::Section;
let before = campaign.place_of(index.saturating_sub(1));
let template = match before.section {
Section::Coop => tr.stage_locked_coop,
Section::TidePool | Section::Custom => tr.stage_locked,
};
fill(template, &[("n", &before.at.to_string())])
}
pub fn enter_stage_select(
mut commands: Commands,
mut campaign: ResMut<Campaign>,
mut progress: ResMut<Progress>,
settings: Res<GameSettings>,
mut list: ResMut<StageList>,
mut notice: ResMut<crate::app::RoundNotice>,
) {
notice.0.clear();
if campaign.holds_a_code() {
campaign.reset(crate::app::campaign::stage_list());
}
if progress.adopt_legacy(&campaign) {
crate::app::progress::save(&mut progress);
}
list.selected = progress.furthest_open(&campaign);
list.rows = rows_of(§ions(&campaign).map(|(_, range)| range));
let tr = settings.tr();
commands
.spawn((
StageSelectUi,
Node {
row_gap: Val::Px(12.0),
..menu_ui::between_bars()
},
))
.with_children(|wrap| {
wrap.spawn(Node {
align_items: AlignItems::Center,
..default()
})
.with_children(|line| {
let (mark, mut node, bg, edge, shadow) = menu_ui::screen_card();
node.row_gap = Val::Px(12.0);
node.padding = UiRect::axes(Val::Px(28.0), Val::Px(20.0));
line.spawn((mark, node, bg, edge, shadow))
.with_children(|column| {
let shipped = campaign.section(crate::app::campaign::Section::TidePool);
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(menu_ui::type_scale::ROW),
..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(palette::BAR_TRACK),
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 shelves: Vec<_> = sections(&campaign)
.into_iter()
.filter(|(section, range)| {
*section != crate::app::campaign::Section::TidePool
&& !range.is_empty()
})
.collect();
let heading = HEADING_ROOM * shelves.len() as f32;
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 let Some((section, range)) =
shelves.iter().find(|(_, range)| range.start == row.start)
{
let label = match section {
crate::app::campaign::Section::Coop => tr.stage_coop,
crate::app::campaign::Section::Custom
| crate::app::campaign::Section::TidePool => {
tr.stage_custom
}
};
let count = fill(
tr.stage_progress,
&[
(
"a",
&progress
.cleared_in(&campaign, range.clone())
.to_string(),
),
("b", &range.len().to_string()),
],
);
spawn_shelf_heading(grid, label, count);
}
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(menu_ui::type_scale::FINE),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.45)),
));
for posts in KEY_POSTS {
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(menu_ui::type_scale::FINE),
..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 [
(30.0, 20.0, palette::SELECTED_ROW, Caption::Stage),
(
26.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),
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(11.0)),
..default()
},
BackgroundColor(palette::PILL_FILL),
));
match tag {
Caption::Stage => text.insert(StageCaption),
Caption::Hint => text.insert(StageHint),
};
});
}
});
});
}
fn spawn_shelf_heading(grid: &mut ChildSpawnerCommands, label: &str, count: 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(label.to_string()),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::FINE),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.60)),
));
line.spawn((
Text::new(count),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::FINE),
..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());
let mark = campaign.levels[index].is_coop().then(|| CoopMark::on(size));
line.spawn((
StageTile(index),
Node {
width: Val::Px(size),
height: Val::Px(size),
border: UiRect::all(Val::Px(TILE_BORDER)),
padding: UiRect::top(Val::Px(mark.map_or(0.0, CoopMark::room))),
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(number_px(size)),
..default()
},
TextColor(ink),
));
let paint = palette::seat_paint(1).with_alpha(0.95);
match mark {
Some(CoopMark::Badge(px)) => {
tile.spawn((
Text::new(COOP_BADGE),
TextFont {
font_size: FontSize::Px(px),
..default()
},
TextColor(paint),
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
right: Val::Px(2.0),
..default()
},
));
}
Some(CoopMark::Strip(height)) => {
tile.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
left: Val::Percent(20.0),
width: Val::Percent(60.0),
height: Val::Px(height),
..default()
},
BackgroundColor(paint),
));
}
None => {}
}
});
}
const TILE_BORDER: f32 = 2.0;
const LINE: f32 = 1.2;
const BADGE_MIN_PX: f32 = 8.0;
const STRIP_PX: f32 = 2.0;
fn number_px(size: f32) -> f32 {
(size * 0.4).min(20.0)
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum CoopMark {
Badge(f32),
Strip(f32),
}
impl CoopMark {
fn on(size: f32) -> CoopMark {
let badge = (size * 0.24).clamp(BADGE_MIN_PX, 12.0);
let inner = size - 2.0 * TILE_BORDER;
let number = LINE * number_px(size);
match LINE * badge + number <= inner {
true => CoopMark::Badge(badge),
false => CoopMark::Strip(((inner - number) / 2.0).clamp(0.0, STRIP_PX)),
}
}
fn room(self) -> f32 {
match self {
CoopMark::Badge(px) => LINE * px,
CoopMark::Strip(_) => 0.0,
}
}
}
pub const COOP_BADGE: &str = "2P";
#[derive(bevy::ecs::system::SystemParam)]
pub struct StageUi<'w, 's> {
tiles: Query<
'w,
's,
(
&'static StageTile,
&'static mut BorderColor,
&'static mut BackgroundColor,
),
>,
numbers: Query<'w, 's, (&'static StageNumber, &'static mut TextColor)>,
captions: Query<'w, 's, (&'static mut Text, &'static mut Node), With<StageCaption>>,
hints: HintLine<'w, 's>,
}
pub fn update_stage_tiles(
list: Res<StageList>,
campaign: Res<Campaign>,
progress: Res<Progress>,
settings: Res<GameSettings>,
coop: Res<crate::app::Coop>,
(caps, device): (
Res<crate::app::keycaps::KeyCaps>,
Res<crate::app::gamepad::LastDevice>,
),
ui: StageUi,
) {
let StageUi {
mut tiles,
mut numbers,
mut captions,
mut hints,
} = ui;
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
};
menu_ui::set_bg(&mut fill, 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, mut node)) = captions.single_mut() {
let line = caption(
settings.tr(),
settings.language,
&campaign,
state,
list.selected,
);
menu_ui::set_text(&mut text, &line);
menu_ui::set_shown(&mut node, !line.is_empty());
}
if let Ok((mut text, mut node)) = hints.single_mut() {
let hint = match state {
TileState::Locked => String::new(),
TileState::Cleared | TileState::Open => caps.legend(
&campaign
.shown_hint(list.selected, &settings, *coop, *device)
.unwrap_or_default(),
),
};
menu_ui::set_text(&mut text, &hint);
menu_ui::set_shown(&mut node, !hint.is_empty());
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct OwnStage<'w> {
opening: ResMut<'w, crate::app::editor::EditorOpening>,
clipboard: ResMut<'w, Clipboard>,
notice: ResMut<'w, crate::app::RoundNotice>,
settings: Res<'w, GameSettings>,
shared: MessageWriter<'w, crate::app::CodeShared>,
}
pub fn exit_stage_select(mut notice: ResMut<crate::app::RoundNotice>) {
notice.0.clear();
}
pub fn stage_select_input(
keyboard: crate::app::keycaps::Keyboard,
mut ladder: crate::app::progress::Ladder,
mut coop: ResMut<crate::app::Coop>,
mut list: ResMut<StageList>,
mut denied: MessageWriter<PlacementDenied>,
mut next_screen: ResMut<NextState<Screen>>,
mut own: OwnStage,
) {
let keys = &keyboard.keys;
let at = step(list.selected, &list.rows, keys);
if at != list.selected {
own.notice.0.clear();
}
list.selected = at;
if keyboard.global('C') {
coop.0 = !coop.0;
own.notice.0.clear();
}
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
return;
}
let edit = keyboard.just('E');
let copy = keys.just_pressed(KeyCode::F3);
if (edit || copy) && is_custom(&ladder.campaign, at) {
own_stage(
&ladder.campaign.levels[at],
(edit, copy),
own,
&mut next_screen,
);
return;
}
if !menu_ui::enter(keys) {
return;
}
if ladder.campaign.needs_partner(list.selected, *coop) {
let tr = own.settings.tr();
own.notice.0 = crate::app::name_the_pair(tr, tr.stage_needs_two);
denied.write(PlacementDenied {
player: 0,
out_of_signposts: false,
});
return;
}
if ladder.climb_to(list.selected) {
next_screen.set(Screen::Puzzle);
} else {
denied.write(PlacementDenied {
player: 0,
out_of_signposts: false,
});
}
}
fn own_stage(
level: &crate::sim::Level,
(edit, copy): (bool, bool),
own: OwnStage,
next_screen: &mut NextState<Screen>,
) {
let OwnStage {
mut opening,
mut clipboard,
mut notice,
settings,
mut shared,
} = own;
let tr = settings.tr();
let opened = crate::app::campaign::filed_path(level)
.and_then(|path| crate::app::editor::opening_from_file(&path));
let Some(opened) = opened else {
notice.0 = fill(tr.code_level_bad, &[("e", &level.name)]);
return;
};
if edit {
opening.0 = Some(opened);
next_screen.set(Screen::Editor);
} else if copy {
let (said, took) = crate::app::codes::copy_counted(
&mut clipboard,
tr,
crate::share::Kind::Level,
opened.level.to_text().as_bytes(),
tr.code_copied,
);
notice.0 = said;
if took {
shared.write(crate::app::CodeShared);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::i18n::EN;
fn campaign() -> Campaign {
Campaign::tide_pool()
}
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 a_stage_from_a_code_leaves_the_list() {
use bevy::ecs::system::RunSystemOnce;
let mut campaign = campaign();
let mut pasted = campaign.levels[0].clone();
pasted.name = "Pasted Probe".into();
pasted.filing = None;
campaign.levels.push(pasted);
campaign.index = campaign.levels.len() - 1;
assert!(campaign.holds_a_code());
let mut app = App::new();
app.insert_resource(campaign);
app.init_resource::<Progress>();
app.init_resource::<StageList>();
app.init_resource::<GameSettings>();
app.init_resource::<crate::app::RoundNotice>();
let _ = app.world_mut().run_system_once(enter_stage_select);
let campaign = app.world().resource::<Campaign>();
assert!(!campaign.holds_a_code());
assert!(
campaign
.levels
.iter()
.all(|level| level.name != "Pasted Probe")
);
}
fn own_stage_resources(app: &mut App) {
app.init_resource::<crate::app::editor::EditorOpening>();
app.init_resource::<Clipboard>();
app.init_resource::<crate::app::RoundNotice>();
app.init_resource::<GameSettings>();
app.add_message::<crate::app::CodeShared>();
}
#[test]
fn e_edits_a_stage_of_the_players_own() {
let path = crate::app::editor::custom_dir().join("stage-edit-probe.txt");
let mut board = crate::sim::Board::new(6, 5, 2);
board.set_tile(4, 2, crate::sim::TileKind::Castle(0));
board.spawn_crab(
1,
2,
crate::sim::Direction::Right,
crate::sim::Handedness::Left,
crate::sim::CrabKind::Common,
);
let mut level = crate::sim::Level::from_board("Stage Edit Probe", 1, board);
crate::app::paths::write_atomic(&path, level.to_text()).expect("wrote");
level.name = "Stage Edit Probe (2)".into();
level.filing = Some(crate::sim::Filing {
key: "file:custom/stage-edit-probe.txt".into(),
legacy: None,
});
let mut campaign = campaign();
campaign.levels.truncate(3);
campaign.builtins = 3;
campaign.levels.push(level);
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(campaign);
app.init_resource::<Progress>();
app.init_resource::<StageList>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.init_resource::<crate::app::Coop>();
own_stage_resources(&mut app);
app.add_message::<PlacementDenied>();
app.add_systems(Update, stage_select_input);
let press_e = |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::KeyE);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.update();
};
press_e(&mut app, 0);
let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
assert_eq!(screen(&app), Screen::Menu, "a shipped stage is not edited");
press_e(&mut app, 3);
assert_eq!(screen(&app), Screen::Editor);
let opening = app.world().resource::<crate::app::editor::EditorOpening>();
let opened = opening.0.as_ref().expect("the editor opens on it");
assert_eq!(
opened.level.name, "Stage Edit Probe",
"as its file names it"
);
assert_eq!(opened.path.as_deref(), Some(path.as_path()));
let _ = std::fs::remove_file(&path);
}
#[test]
fn c_toggles_coop() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(campaign());
app.init_resource::<Progress>();
app.init_resource::<StageList>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.init_resource::<crate::app::Coop>();
own_stage_resources(&mut app);
app.add_message::<PlacementDenied>();
app.add_systems(Update, stage_select_input);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyC);
app.update();
assert!(app.world().resource::<crate::app::Coop>().0);
assert!(EN.prompt_stages.contains("C: co-op"));
}
#[test]
fn the_needs_two_notice_goes_when_p2_sits_down_or_the_list_is_left() {
let mut campaign = campaign();
let first = campaign.levels.len();
let coop = crate::sim::coop_levels();
campaign.coop = coop.len();
campaign.levels.extend(coop);
campaign.builtins = campaign.levels.len();
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.insert_state(Screen::StageSelect);
app.insert_resource(campaign);
app.init_resource::<Progress>();
app.init_resource::<StageList>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.init_resource::<crate::app::Coop>();
own_stage_resources(&mut app);
app.add_message::<PlacementDenied>();
app.add_systems(Update, stage_select_input);
app.add_systems(OnExit(Screen::StageSelect), exit_stage_select);
app.world_mut().resource_mut::<StageList>().selected = first;
let tap = |app: &mut App, key: KeyCode| {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.update();
};
let notice = |app: &App| app.world().resource::<crate::app::RoundNotice>().0.clone();
tap(&mut app, KeyCode::Enter);
assert!(!notice(&app).is_empty(), "the stage needs two");
tap(&mut app, KeyCode::KeyC);
assert!(app.world().resource::<crate::app::Coop>().0);
assert_eq!(notice(&app), "", "P2 is seated now");
tap(&mut app, KeyCode::KeyC);
tap(&mut app, KeyCode::Enter);
assert!(!notice(&app).is_empty(), "alone again");
tap(&mut app, KeyCode::Escape);
assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
assert_eq!(notice(&app), "", "not carried onto the menu");
}
#[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.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.init_resource::<crate::app::Coop>();
own_stage_resources(&mut app);
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.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}");
}
#[test]
fn a_locked_coop_stage_names_the_coop_stage_before_it() {
let mut campaign = campaign();
let first = campaign.levels.len();
let coop = crate::sim::coop_levels();
campaign.coop = coop.len();
campaign.levels.extend(coop);
campaign.builtins = campaign.levels.len();
let locked = caption(&EN, i18n::Lang::En, &campaign, TileState::Locked, first + 1);
assert!(locked.starts_with("Co-op 2. "), "{locked}");
assert!(
locked.ends_with("locked - clear co-op stage 1 first"),
"{locked}"
);
}
#[test]
fn the_coop_mark_clears_the_number_at_every_tile_size() {
let shipped = rows_of(&[0..100, 100..120]).len();
let worst = tile(shipped + 10, 2.0 * HEADING_ROOM);
assert!(worst < 20.0, "the worst case is a small tile: {worst}");
let mut size = worst;
while size <= 52.0 {
let inner = size - 2.0 * TILE_BORDER;
let number = LINE * number_px(size);
match CoopMark::on(size) {
CoopMark::Badge(px) => {
assert!(px >= BADGE_MIN_PX);
assert!(LINE * px + number <= inner, "badge on number at {size}");
}
CoopMark::Strip(height) => {
assert!(
height > 0.0 && height <= (inner - number) / 2.0,
"bar at {size}"
);
}
}
size += 0.25;
}
let shelf = tile(shipped, HEADING_ROOM);
assert!(matches!(CoopMark::on(shelf), CoopMark::Badge(_)), "{shelf}");
}
#[test]
fn the_key_reaches_the_hardest_stage() {
let hardest = campaign().levels.iter().map(|l| l.posts).max().unwrap();
assert!(
KEY_POSTS.contains(&hardest),
"the campaign grants {hardest} signposts but the key stops at {}",
KEY_POSTS.end()
);
let inks: Vec<_> = KEY_POSTS.map(|p| difficulty_ink(p).to_srgba()).collect();
for (i, a) in inks.iter().enumerate() {
for b in &inks[i + 1..] {
assert_ne!(a, b, "two signpost counts share a colour");
}
}
}
}