use super::brush::Brush;
use super::card::{self, Row};
use super::{ARROWS, EditorCard, EditorKeys, EditorState, HELP_KEY, Mode, block_order, shifted};
use crate::app::gamepad::LastDevice;
use crate::app::gamepad::scheme::{self, Bind, Does, Face, Key, Pad};
use crate::app::i18n::{Tr, fill};
use crate::app::keycaps::KeyCaps;
use crate::app::menu_ui;
use crate::app::settings::GameSettings;
use crate::sim::Direction;
use bevy::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Line {
pub(super) keys: String,
pub(super) pad: String,
pub(super) does: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Section {
pub(super) title: String,
pub(super) lines: Vec<Line>,
}
#[derive(Default)]
struct Gather {
lines: Vec<Gathered>,
}
type Gathered = (String, Vec<String>, Vec<(String, Option<Direction>)>);
impl Gather {
fn add(
&mut self,
does: String,
keys: Option<String>,
pad: Option<(String, Option<Direction>)>,
) {
let at = match self.lines.iter().position(|(said, ..)| *said == does) {
Some(at) => at,
None => {
self.lines.push((does, Vec::new(), Vec::new()));
self.lines.len() - 1
}
};
let (_, key_list, pad_list) = &mut self.lines[at];
if let Some(keys) = keys.filter(|keys| !keys.is_empty() && !key_list.contains(keys)) {
key_list.push(keys);
}
if let Some(pad) = pad.filter(|pad| !pad_list.contains(pad)) {
pad_list.push(pad);
}
}
fn lines(self) -> Vec<Line> {
self.lines
.into_iter()
.map(|(does, keys, pads)| {
let mut names: Vec<String> = Vec::new();
for (name, way) in &pads {
let ways: String = pads
.iter()
.filter(|(other, _)| other == name)
.filter_map(|(_, way)| way.map(super::validate::arrow))
.collect();
let said = match way {
Some(_) => format!("{name} {ways}"),
None => name.clone(),
};
if !names.contains(&said) {
names.push(said);
}
}
Line {
keys: match keys.is_empty() {
true => "-".to_string(),
false => keys.join("/"),
},
pad: match names.is_empty() {
true => "-".to_string(),
false => names.join("/"),
},
does,
}
})
.collect()
}
}
struct Spell<'a> {
tr: &'a Tr,
caps: &'a KeyCaps,
keys: &'a EditorKeys,
}
impl Spell<'_> {
fn code(&self, key: KeyCode) -> String {
const SAID: [(KeyCode, &str); 3] = [
(KeyCode::Equal, "+"),
(KeyCode::Minus, "-"),
(KeyCode::Escape, "Esc"),
];
if key == KeyCode::Space {
return self.tr.ed_space_key.to_string();
}
match SAID.iter().find(|(said, _)| *said == key) {
Some((_, cap)) => cap.to_string(),
None => self.caps.label(key),
}
}
fn block(&self, group: [(KeyCode, Direction); 4]) -> String {
match group == ARROWS {
true => self.tr.ed_arrow_keys.to_string(),
false => super::spell_block(self.caps, block_order(group)),
}
}
fn moves(&self) -> String {
let [up, down, left, right] = self.keys.moves;
super::spell_block(self.caps, [up, left, down, right])
}
fn key(&self, row: &Bind) -> Option<String> {
Some(match row.key {
Key::Code(key) => self.code(key),
Key::Shifted(key) => shifted(self.caps, key),
Key::Cap(letter) => letter.to_string(),
Key::Wall(_) => self.block(self.keys.walls),
Key::Command(command) => self.code(self.keys.command(command)),
Key::ShiftedCommand(command) => shifted(self.caps, self.keys.command(command)),
Key::Direct if row.does == Does::Move => self.moves(),
Key::Direct if matches!(row.does, Does::Place(_)) => self.block(self.keys.place),
Key::Direct if row.does == Does::Remove => self.code(self.keys.remove),
Key::Direct if row.does == Does::Start => self.code(super::playtest::RUN_KEY),
Key::Direct if row.does == Does::Reset => super::playtest::RETRY_CAP.to_string(),
Key::Direct if row.does == Does::EndTest => self.code(super::playtest::END_KEY),
Key::Direct | Key::Pick | Key::Step(_) | Key::Layer => return None,
})
}
}
fn gather_face(gather: &mut Gather, spell: &Spell, face: &Face, keep: impl Fn(Does) -> bool) {
let tr = spell.tr;
let layer = *face == (Face::Editor { layer: true });
for row in scheme::binds(face) {
if !keep(row.does) || row.key == Key::Layer {
continue;
}
let way = match row.pad {
Pad::Flick(dir) => Some(dir),
Pad::A
| Pad::B
| Pad::X
| Pad::Y
| Pad::LB
| Pad::RB
| Pad::LT
| Pad::RT
| Pad::Start
| Pad::Select
| Pad::RS
| Pad::Up
| Pad::Down
| Pad::Left
| Pad::Right
| Pad::Move => None,
};
let name = row.pad.name(tr).to_string();
let name = match layer {
true => format!("{}+{name}", Pad::Select.name(tr)),
false => name,
};
gather.add(
row.does.label(tr).to_string(),
spell.key(&row),
Some((name, way)),
);
}
}
pub(super) fn sections(tr: &Tr, keys: &EditorKeys, caps: &KeyCaps) -> [Vec<Section>; 2] {
let spell = Spell { tr, caps, keys };
let any = |_: Does| true;
let mut bench = Gather::default();
gather_face(&mut bench, &spell, &Face::Editor { layer: false }, any);
gather_face(&mut bench, &spell, &Face::Editor { layer: true }, any);
let letters: Vec<String> = Brush::ALL.iter().map(|b| caps.label(b.key())).collect();
bench.add(
tr.ed_help_brushes.to_string(),
Some(letters.join(" ")),
None,
);
for row in card::ROWS {
let key = row.key(LastDevice::Keyboard, keys, caps);
if row != Row::Size && !key.is_empty() {
bench.add(row.label(tr).to_string(), Some(key), None);
}
}
let mut list = Gather::default();
gather_face(&mut list, &spell, &Face::LevelList, any);
let mut test = Gather::default();
for phase in crate::app::editor::TestPhase::ALL {
gather_face(&mut test, &spell, &Face::Playtest(phase), any);
}
let mut stages = Gather::default();
gather_face(
&mut stages,
&spell,
&Face::StageSelect { custom: true },
|does| matches!(does, Does::Edit | Does::CopyCode),
);
let section = |title: &str, gather: Gather| Section {
title: title.to_string(),
lines: gather.lines(),
};
[
vec![section(tr.ed_help_sections[0], bench)],
vec![
section(tr.ed_levels_title, list),
section(tr.ed_help_sections[1], test),
section(tr.ed_help_sections[2], stages),
],
]
}
pub(super) fn tips(tr: &Tr, device: LastDevice, caps: &KeyCaps) -> Vec<String> {
let help = scheme::spelled(
tr,
device,
&Face::Editor { layer: true },
Does::Help,
&shifted(caps, HELP_KEY),
);
tr.ed_tips
.iter()
.map(|tip| fill(tip, &[("help", &help), ("mirror", tr.ed_set_mirror)]))
.collect()
}
pub fn editor_card_input(
keyboard: crate::app::keycaps::Keyboard,
mut settings: ResMut<GameSettings>,
mut state: ResMut<EditorState>,
) {
let Some(card) = state.card() else {
return;
};
if keyboard.keys.get_just_pressed().next().is_none() {
return;
}
state.mode = Mode::JustClosed;
if card == EditorCard::Tips && settings.editor_tips {
settings.editor_tips = false;
settings.save(&keyboard.caps);
}
}
#[derive(Component)]
pub struct CardShown {
card: EditorCard,
device: LastDevice,
lang: crate::app::i18n::Lang,
}
const CARD_W: f32 = 1240.0;
const CARD_PAD: f32 = 18.0;
const COLUMN_GAP: f32 = 28.0;
const COLUMN_W: f32 = (CARD_W - 2.0 * CARD_PAD - 4.0 - COLUMN_GAP) / 2.0;
const KEYS_W: f32 = 160.0;
const PAD_W: f32 = 220.0;
const CELL_GAP: f32 = 8.0;
const DOES_W: f32 = COLUMN_W - KEYS_W - PAD_W - 2.0 * CELL_GAP;
const TIPS_W: f32 = 640.0;
pub fn show_editor_card(
mut commands: Commands,
state: Res<EditorState>,
(settings, device, caps): (Res<GameSettings>, Res<LastDevice>, Res<KeyCaps>),
shown: Query<(Entity, &CardShown)>,
) {
let want = state.card();
let lang = settings.language;
for (entity, card) in &shown {
if Some(card.card) != want || card.device != *device || card.lang != lang {
commands.entity(entity).despawn();
} else {
return;
}
}
let Some(card) = want else {
return;
};
let tr = settings.tr();
let keys = EditorKeys::of(&settings);
let root = commands
.spawn((
super::EditorUi,
CardShown {
card,
device: *device,
lang,
},
GlobalZIndex(menu_ui::layer::CARD),
menu_ui::centred_overlay(),
BackgroundColor(Color::BLACK.with_alpha(0.35)),
))
.id();
let width = match card {
EditorCard::Help => CARD_W,
EditorCard::Tips => TIPS_W,
};
let panel = commands
.spawn((
Node {
width: Val::Px(width),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(CARD_PAD)),
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BorderColor::all(crate::app::palette::CARD_EDGE),
BackgroundColor(crate::app::palette::CARD_FILL),
menu_ui::card_shadow(),
))
.id();
commands.entity(root).add_child(panel);
let title = match card {
EditorCard::Help => tr.ed_help_title,
EditorCard::Tips => tr.ed_tips_title,
};
commands.entity(panel).with_children(|card_body| {
card_body.spawn((
Text::new(title),
menu_ui::display_font(26.0),
TextColor(crate::app::palette::GOLD),
Node {
margin: UiRect::bottom(Val::Px(6.0)),
..default()
},
));
match card {
EditorCard::Help => spawn_help(card_body, tr, &keys, &caps),
EditorCard::Tips => {
for tip in tips(tr, *device, &caps) {
card_body.spawn((
Text::new(tip),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextColor(crate::app::palette::PARCHMENT),
Node {
margin: UiRect::bottom(Val::Px(4.0)),
..default()
},
));
}
}
}
});
}
fn fine(text: String, color: Color, width: f32) -> impl Bundle {
(
Text::new(text),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::FINE),
..default()
},
TextLayout::no_wrap(),
TextColor(color),
Node {
width: Val::Px(width),
..default()
},
)
}
fn spawn_help(body: &mut ChildSpawnerCommands, tr: &Tr, keys: &EditorKeys, caps: &KeyCaps) {
let columns = sections(tr, keys, caps);
body.spawn(Node {
column_gap: Val::Px(COLUMN_GAP),
align_items: AlignItems::FlexStart,
..default()
})
.with_children(|row| {
for column in columns {
row.spawn(Node {
width: Val::Px(COLUMN_W),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(1.0),
..default()
})
.with_children(|col| {
let line = |col: &mut ChildSpawnerCommands, cells: [(String, Color); 3]| {
col.spawn(Node {
column_gap: Val::Px(CELL_GAP),
..default()
})
.with_children(|line| {
let [(does, a), (keys, b), (pad, c)] = cells;
line.spawn(fine(does, a, DOES_W));
line.spawn(fine(keys, b, KEYS_W));
line.spawn(fine(pad, c, PAD_W));
});
};
let dim = crate::app::palette::PARCHMENT.with_alpha(0.55);
line(
col,
[
(String::new(), dim),
(tr.ed_help_heads[0].to_string(), dim),
(tr.ed_help_heads[1].to_string(), dim),
],
);
for section in column {
col.spawn((
Text::new(section.title),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextColor(crate::app::palette::GOLD),
Node {
margin: UiRect::top(Val::Px(6.0)),
..default()
},
));
for Line { keys, pad, does } in section.lines {
line(
col,
[
(does, crate::app::palette::PARCHMENT),
(keys, crate::app::palette::SELECTED_ROW),
(pad, crate::app::palette::IDLE_ROW),
],
);
}
}
});
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::i18n::metrics::text_px;
fn stock() -> (EditorKeys, KeyCaps) {
(EditorKeys::of(&GameSettings::default()), KeyCaps::default())
}
fn all_lines(columns: &[Vec<Section>; 2]) -> Vec<Line> {
columns
.iter()
.flatten()
.flat_map(|section| section.lines.clone())
.collect()
}
#[test]
fn the_card_lists_every_control_of_the_editors_faces() {
let tr = &crate::app::i18n::EN;
let (keys, caps) = stock();
let lines = all_lines(§ions(tr, &keys, &caps));
let mut faces = vec![
Face::Editor { layer: false },
Face::Editor { layer: true },
Face::LevelList,
];
faces.extend(crate::app::editor::TestPhase::ALL.map(Face::Playtest));
for face in faces {
for row in scheme::binds(&face) {
if row.key == Key::Layer {
continue;
}
let words = row.does.label(tr);
let line = lines
.iter()
.find(|line| line.does == words)
.unwrap_or_else(|| panic!("{face:?}: no line for {row:?}"));
assert_ne!(line.keys, "-", "{face:?}: {row:?} has no key");
assert!(
line.pad.contains(row.pad.name(tr)),
"{face:?}: {row:?} not on {line:?}"
);
}
}
let said = |words: &str| {
lines
.iter()
.find(|line| line.does == words)
.unwrap_or_else(|| panic!("no line for {words}"))
.keys
.clone()
};
assert_eq!(said(Does::Undo.label(tr)), "Z/Shift+Z");
assert_eq!(said(Does::Size.label(tr)), "F5/Shift+F5");
let size = lines
.iter()
.find(|line| line.does == Does::Size.label(tr))
.expect("a size line");
assert_eq!(size.pad, "Select+right stick ↑↓");
assert_eq!(said(Does::PrevSetting.label(tr)), "Shift+N/N");
assert_eq!(said(Does::Help.label(tr)), "Shift+F1");
assert_eq!(said(Does::LoadLevel.label(tr)), "F4");
assert_eq!(said(tr.ed_help_brushes), "X R C H L E P B G");
assert_eq!(said(tr.ed_set_gulls), "K");
assert_eq!(said(tr.ed_set_wrap), "O");
let columns = sections(tr, &keys, &caps);
let stages: Vec<(String, String)> = columns[1][2]
.lines
.iter()
.map(|line| (line.keys.clone(), line.pad.clone()))
.collect();
assert_eq!(
stages,
[("E".into(), "X".into()), ("F3".into(), "Select".into())]
);
}
#[test]
fn the_card_follows_p1s_bindings() {
use crate::app::binds::Action;
let tr = &crate::app::i18n::EN;
let mut settings = GameSettings::default();
for (action, key) in [
(Action::MoveUp, KeyCode::ArrowUp),
(Action::MoveDown, KeyCode::ArrowDown),
(Action::MoveLeft, KeyCode::ArrowLeft),
(Action::MoveRight, KeyCode::ArrowRight),
(Action::PlaceUp, KeyCode::KeyW),
(Action::PlaceDown, KeyCode::KeyK),
(Action::PlaceLeft, KeyCode::KeyA),
(Action::PlaceRight, KeyCode::KeyD),
(Action::Remove, KeyCode::KeyX),
] {
settings.binds[0].set(action, key);
}
let keys = EditorKeys::of(&settings);
let caps = KeyCaps::default();
let lines = all_lines(§ions(tr, &keys, &caps));
let said = |words: &str| {
lines
.iter()
.find(|line| line.does == words)
.map(|line| line.keys.clone())
.unwrap_or_default()
};
let [up, down, left, right] = keys.moves;
assert_eq!(
said(Does::Move.label(tr)),
super::super::spell_block(&caps, [up, left, down, right])
);
assert_eq!(said(Does::Walls.label(tr)), "WAKD");
assert_eq!(said(Does::Place(Direction::Up).label(tr)), "WAKD");
assert_eq!(said(Does::Remove.label(tr)), "X");
assert_eq!(said(tr.ed_set_gulls), "F7", "K is a wall now");
assert_eq!(said(Does::Turn.label(tr)), "T");
}
#[test]
fn every_line_fits_its_cell_in_every_language() {
let font = menu_ui::type_scale::FINE;
let mut over = Vec::new();
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
for commit in [
crate::app::settings::CommitScheme::default(),
crate::app::settings::CommitScheme::Ijkl,
] {
let settings = GameSettings {
commit,
..GameSettings::default()
};
let keys = EditorKeys::of(&settings);
let caps = KeyCaps::default();
let columns = sections(tr, &keys, &caps);
for line in all_lines(&columns) {
for (cell, room) in [
(&line.keys, KEYS_W),
(&line.pad, PAD_W),
(&line.does, DOES_W),
] {
let used = text_px(cell, font);
if used > room {
over.push(format!("{lang:?}: {used:.0}px of {room:.0}: {cell}"));
}
}
}
let rows: usize = columns
.iter()
.map(|column| {
column
.iter()
.map(|section| section.lines.len() + 1)
.sum::<usize>()
+ 1
})
.max()
.unwrap_or(0);
assert!(rows <= 26, "{lang:?}: {rows} rows in a column");
}
for device in [LastDevice::Keyboard, LastDevice::Pad] {
for tip in tips(tr, device, &KeyCaps::default()) {
let used = text_px(&tip, menu_ui::type_scale::BODY);
if used > 2.0 * (TIPS_W - 2.0 * CARD_PAD - 4.0) {
over.push(format!("{lang:?} tip: {used:.0}px: {tip}"));
}
}
}
for heading in tr.ed_help_heads {
if text_px(heading, font) > KEYS_W {
over.push(format!("{lang:?}: {heading}"));
}
}
}
assert!(over.is_empty(), "too wide:\n{}", over.join("\n"));
}
fn card_app() -> App {
let mut app = super::super::tests::bench_app();
let mut cursor = crate::app::cursor::Cursor::seated(0);
(cursor.x, cursor.y) = (1, 1);
app.world_mut().spawn((cursor, Transform::default()));
app.add_systems(
Update,
(
editor_card_input.run_if(super::super::editor_on_card),
super::super::editor_input,
super::super::editor_commands,
)
.chain(),
);
app
}
#[test]
fn shift_f1_shows_every_key_and_any_key_puts_it_away() {
let mut app = card_app();
{
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::ShiftLeft);
keys.press(HELP_KEY);
}
app.update();
let card = |app: &App| app.world().resource::<EditorState>().card();
assert_eq!(card(&app), Some(EditorCard::Help));
super::super::tests::tap(&mut app, KeyCode::Space);
assert_eq!(card(&app), None, "a key puts it away");
assert_eq!(
app.world().resource::<crate::app::Sim>().0.tile_at(1, 1),
crate::sim::TileKind::Empty,
"and paints nothing"
);
super::super::tests::tap(&mut app, KeyCode::F1);
assert!(app.world().resource::<EditorState>().is_naming());
}
#[test]
fn the_tips_are_shown_until_put_away_once() {
let mut app = card_app();
app.world_mut().resource_mut::<EditorState>().mode = Mode::Card(EditorCard::Tips);
assert!(app.world().resource::<GameSettings>().editor_tips);
super::super::tests::tap(&mut app, KeyCode::Enter);
assert!(!app.world().resource::<GameSettings>().editor_tips);
let state = app.world().resource::<EditorState>();
assert_eq!(state.card(), None);
assert!(
!state.is_testing(),
"the key that put it away is the card's"
);
}
}