use crate::app::cursor::Cursor;
use crate::app::i18n::fill;
use crate::app::session::BoardSprites;
use crate::app::settings::GameSettings;
use crate::app::{Screen, Sim};
use crate::sim::MAX_PLAYERS;
use crate::sim::{
Board, CrabKind, Direction, Handedness, Level, LevelKind, SolveOutcome, Spawner, TileKind,
solve,
};
use bevy::prelude::*;
use std::sync::{Arc, Mutex};
type SolverSlot = Arc<Mutex<Option<SolveOutcome>>>;
const EDITOR_BOARD: (u8, u8) = (12, 9);
const EDITOR_SIZES: [(u8, u8); 4] = [(9, 7), (12, 9), (16, 11), (20, 13)];
const SPAWNER_PERIOD: u32 = 60;
const GULL_PERIODS: [u32; 4] = [0, 480, 240, 120];
pub fn legacy_save_path() -> std::path::PathBuf {
crate::app::paths::data_dir().join("levels/custom.txt")
}
pub fn custom_dir() -> std::path::PathBuf {
crate::app::paths::data_dir().join("levels/custom")
}
pub fn save_path(name: &str) -> std::path::PathBuf {
custom_dir().join(format!(
"{}.txt",
crate::app::paths::safe_stem(name, 40, "level")
))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Brush {
#[default]
Sand,
Rock,
Castle,
Hole,
Log,
Weed,
Pool,
Crab,
Gull,
}
impl Brush {
pub const ALL: [Brush; 9] = [
Brush::Sand,
Brush::Rock,
Brush::Castle,
Brush::Hole,
Brush::Log,
Brush::Weed,
Brush::Pool,
Brush::Crab,
Brush::Gull,
];
pub fn key(self) -> KeyCode {
match self {
Brush::Sand => KeyCode::KeyX,
Brush::Rock => KeyCode::KeyR,
Brush::Castle => KeyCode::KeyC,
Brush::Hole => KeyCode::KeyH,
Brush::Log => KeyCode::KeyL,
Brush::Weed => KeyCode::KeyE,
Brush::Pool => KeyCode::KeyP,
Brush::Crab => KeyCode::KeyB,
Brush::Gull => KeyCode::KeyG,
}
}
pub fn letter(self) -> &'static str {
match self {
Brush::Sand => "X",
Brush::Rock => "R",
Brush::Castle => "C",
Brush::Hole => "H",
Brush::Log => "L",
Brush::Weed => "E",
Brush::Pool => "P",
Brush::Crab => "B",
Brush::Gull => "G",
}
}
pub fn label(self, tr: &crate::app::i18n::Tr) -> &'static str {
tr.ed_brushes[Brush::ALL.iter().position(|b| *b == self).unwrap_or(0)]
}
pub fn icon(self, art: &crate::app::art::Art) -> Handle<Image> {
match self {
Brush::Sand => art.sand_a.clone(),
Brush::Rock => art.rock.clone(),
Brush::Castle => art.castle.clone(),
Brush::Hole => art.hole.clone(),
Brush::Log => art.log.clone(),
Brush::Weed => art.kelp.clone(),
Brush::Pool => art.pool.clone(),
Brush::Crab => art.crab.clone(),
Brush::Gull => art.gull.clone(),
}
}
}
#[derive(Resource, Default)]
pub struct EditorState {
pub posts: u8,
pub kind: LevelKind,
pub name: String,
pub naming: bool,
pub named: bool,
pub brush: Brush,
pub gull_period_idx: usize,
pub testing: Option<Board>,
pub feedback: String,
solver: Option<SolverSlot>,
dirty: bool,
}
fn type_a_name(
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
keys: &ButtonInput<KeyCode>,
state: &mut EditorState,
tr: &crate::app::i18n::Tr,
) {
for event in typed.read() {
if !event.state.is_pressed() {
continue;
}
let done = matches!(
event.key_code,
KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Escape
);
if matches!(event.key_code, KeyCode::Backspace | KeyCode::Delete) {
state.name.pop();
} else if !done {
for ch in event.text.iter().flat_map(|text| text.chars()) {
if !ch.is_control() && state.name.chars().count() < NAME_MAX {
state.name.push(ch);
}
}
}
}
if keys.just_pressed(KeyCode::Enter)
|| keys.just_pressed(KeyCode::NumpadEnter)
|| keys.just_pressed(KeyCode::Escape)
{
let tidy = state.name.trim().to_string();
state.name = if tidy.is_empty() {
tr.ed_default_name.to_string()
} else {
tidy
};
state.naming = false;
state.named = true;
state.feedback.clear();
}
}
const NAME_MAX: usize = 28;
pub fn editor_naming(state: Res<EditorState>) -> bool {
state.naming
}
pub fn enter_editor(
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut state: ResMut<EditorState>,
) {
*state = EditorState {
posts: 3,
name: settings.tr().ed_default_name.to_string(),
feedback: settings.tr().ed_fresh_sand.into(),
dirty: true, ..default()
};
sim.0 = Board::new(EDITOR_BOARD.0, EDITOR_BOARD.1, 0xED17);
}
pub fn exit_editor(mut state: ResMut<EditorState>) {
*state = EditorState::default();
}
pub fn editor_testing(state: Res<EditorState>) -> bool {
state.testing.is_some()
}
fn cycle_castle(board: &mut Board, x: u8, y: u8) {
let kind = match board.tile_at(x, y) {
TileKind::Castle(owner) if usize::from(owner) + 1 < MAX_PLAYERS => {
TileKind::Castle(owner + 1)
}
TileKind::Castle(_) => TileKind::Empty,
TileKind::Empty
| TileKind::Rock
| TileKind::Spawner(_)
| TileKind::Turnstile { .. }
| TileKind::Kelp
| TileKind::Pool => {
board.remove_crabs_at(x, y);
board.remove_gulls_at(x, y);
TileKind::Castle(0)
}
};
board.set_tile(x, y, kind);
}
fn replace_board(
board: Board,
sim: &mut Sim,
state: &mut EditorState,
commands: &mut Commands,
sprites: BoardSprites,
cursors: &mut Query<(&mut Cursor, &mut Transform)>,
) {
sim.0 = board;
state.dirty = true;
crate::app::session::despawn_board_sprites(commands.reborrow(), sprites);
crate::app::cursor::center_on(&sim.0, cursors);
}
#[allow(clippy::too_many_arguments)]
pub fn editor_input(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
settings: Res<GameSettings>,
mut sim: ResMut<Sim>,
mut state: ResMut<EditorState>,
sprites: BoardSprites,
mut cursors: Query<(&mut Cursor, &mut Transform)>,
) {
if state.naming {
type_a_name(&mut typed, &keys, &mut state, settings.tr());
return;
}
typed.clear();
for (key, forward) in [(KeyCode::F5, true)] {
if keys.just_pressed(key) {
let board = &sim.0;
let now = (board.width(), board.height());
let at = EDITOR_SIZES.iter().position(|&s| s == now).unwrap_or(1);
let n = EDITOR_SIZES.len();
let (w, h) = EDITOR_SIZES[(at + if forward { 1 } else { n - 1 }) % n];
replace_board(
Board::new(w, h, 0xED17),
&mut sim,
&mut state,
&mut commands,
sprites,
&mut cursors,
);
state.feedback = fill(
settings.tr().ed_resized,
&[("w", &w.to_string()), ("h", &h.to_string())],
);
return;
}
}
if keys.just_pressed(KeyCode::F1) {
state.naming = true;
state.feedback = settings.tr().ed_naming.to_string();
return;
}
let Some((cursor, _)) = cursors.iter().next() else {
return;
};
let (x, y) = (cursor.x, cursor.y);
let board = &mut sim.0;
for (key, dir) in [
(KeyCode::ArrowUp, Direction::Up),
(KeyCode::ArrowDown, Direction::Down),
(KeyCode::ArrowLeft, Direction::Left),
(KeyCode::ArrowRight, Direction::Right),
] {
if keys.just_pressed(key) {
let present = board.wall_at(x, y, dir);
board.set_wall(x, y, dir, !present);
state.dirty = true;
}
}
for brush in Brush::ALL {
if keys.just_pressed(brush.key()) {
state.brush = brush;
paint(board, x, y, brush);
state.dirty = true;
}
}
if keys.just_pressed(KeyCode::Tab) {
let at = Brush::ALL
.iter()
.position(|b| *b == state.brush)
.unwrap_or(0);
let step = if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
Brush::ALL.len() - 1
} else {
1
};
state.brush = Brush::ALL[(at + step) % Brush::ALL.len()];
}
if keys.just_pressed(KeyCode::Space) {
paint(board, x, y, state.brush);
state.dirty = true;
}
}
fn paint(board: &mut Board, x: u8, y: u8, brush: Brush) {
match brush {
Brush::Castle => return cycle_castle(board, x, y),
Brush::Hole => {
let kind = match board.tile_at(x, y) {
TileKind::Spawner(s) => match next_dir(s.dir) {
Some(dir) => TileKind::Spawner(Spawner {
dir,
period: SPAWNER_PERIOD,
}),
None => TileKind::Empty,
},
TileKind::Empty
| TileKind::Rock
| TileKind::Castle(_)
| TileKind::Turnstile { .. }
| TileKind::Kelp
| TileKind::Pool => {
board.remove_crabs_at(x, y);
board.remove_gulls_at(x, y);
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: SPAWNER_PERIOD,
})
}
};
return board.set_tile(x, y, kind);
}
Brush::Crab => return cycle_crab(board, x, y),
Brush::Gull => {
let tile = board.index_of(x, y);
if board.gulls().iter().any(|g| g.tile == tile) {
board.remove_gulls_at(x, y);
} else if board.tile_at(x, y) != TileKind::Rock {
board.spawn_gull(x, y, Direction::Right);
}
return;
}
Brush::Sand | Brush::Rock | Brush::Log | Brush::Weed | Brush::Pool => {}
}
board.remove_crabs_at(x, y);
board.remove_gulls_at(x, y);
board.set_tile(
x,
y,
match brush {
Brush::Rock => TileKind::Rock,
Brush::Log => TileKind::Turnstile { next_right: true },
Brush::Weed => TileKind::Kelp,
Brush::Pool => TileKind::Pool,
Brush::Sand | Brush::Castle | Brush::Hole | Brush::Crab | Brush::Gull => {
TileKind::Empty
}
},
);
}
fn level_from(
pasted: Option<(crate::share::Kind, Vec<u8>)>,
tr: &crate::app::i18n::Tr,
) -> Result<Level, String> {
let text =
crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Level, tr.code_level_bad)?;
Level::parse(&text).map_err(|e| fill(tr.code_level_bad, &[("e", &e)]))
}
fn level_here(state: &EditorState, board: &Board, name: &str) -> Level {
let mut snapshot = board.clone();
if state.kind == LevelKind::Puzzle {
snapshot.set_signpost_rule(state.posts, crate::sim::CapPolicy::Reject);
}
Level::from_board(name, state.posts, snapshot).with_kind(state.kind)
}
fn arena_report(board: &Board, tr: &crate::app::i18n::Tr) -> String {
let seats = board.castle_seats();
let holes = board
.tiles()
.filter(|(_, _, kind)| matches!(kind, TileKind::Spawner(_)))
.count();
if seats < 2 {
return fill(tr.ed_arena_needs_seats, &[("n", &seats.to_string())]);
}
if holes == 0 && board.crabs().is_empty() {
return tr.ed_arena_no_crabs.to_string();
}
fill(
tr.ed_arena_ok,
&[("n", &seats.to_string()), ("h", &holes.to_string())],
)
}
fn orphan_warning(state: &EditorState, level: &Level, tr: &crate::app::i18n::Tr) -> Option<String> {
match state.kind {
LevelKind::Arena if level.seats() < 2 => Some(fill(
tr.ed_arena_needs_seats,
&[("n", &level.seats().to_string())],
)),
LevelKind::Puzzle if level.crab_count() == 0 => Some(tr.ed_puzzle_no_crabs.to_string()),
LevelKind::Arena | LevelKind::Puzzle => None,
}
}
fn start_validation(state: &mut EditorState, level: Level) {
let slot: SolverSlot = Arc::new(Mutex::new(None));
let thread_slot = Arc::clone(&slot);
std::thread::spawn(move || {
*thread_slot.lock().unwrap() = Some(solve(&level));
});
state.solver = Some(slot);
}
#[allow(clippy::too_many_arguments)]
pub fn editor_commands(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut clipboard: ResMut<Clipboard>,
mut state: ResMut<EditorState>,
mut saved: MessageWriter<crate::app::LevelSaved>,
mut next_screen: ResMut<NextState<Screen>>,
sprites: BoardSprites,
mut cursors: Query<(&mut Cursor, &mut Transform)>,
) {
if std::mem::take(&mut state.named) {
return;
}
let tr = settings.tr();
if keys.just_pressed(KeyCode::F3) {
let level = level_here(&state, &sim.0, &state.name);
state.feedback = crate::app::codes::copy_feedback(
&mut clipboard,
tr,
crate::share::Kind::Level,
level.to_text().as_bytes(),
tr.code_copied,
);
}
if keys.just_pressed(KeyCode::F4) {
match level_from(crate::app::codes::paste(&mut clipboard), tr) {
Ok(level) => {
state.posts = level.posts;
state.kind = level.kind;
replace_board(
level.board(),
&mut sim,
&mut state,
&mut commands,
sprites,
&mut cursors,
);
if level.kind == LevelKind::Arena {
state.feedback = arena_report(&sim.0, tr);
} else {
start_validation(&mut state, level);
state.feedback = tr.code_level_checking.into();
}
}
Err(complaint) => state.feedback = complaint,
}
}
let board = &mut sim.0;
if state.kind == LevelKind::Puzzle {
if keys.just_pressed(KeyCode::Equal) {
state.posts = (state.posts + 1).min(9);
}
if keys.just_pressed(KeyCode::Minus) {
state.posts = state.posts.saturating_sub(1);
}
}
if keys.just_pressed(KeyCode::KeyO) {
let wrap = !board.wrap();
board.set_wrap(wrap);
state.dirty = true;
state.feedback = if wrap {
tr.ed_wrap_on.into()
} else {
tr.ed_wrap_off.into()
};
}
if keys.just_pressed(KeyCode::KeyK) {
state.gull_period_idx = (state.gull_period_idx + 1) % GULL_PERIODS.len();
let period = GULL_PERIODS[state.gull_period_idx];
board.set_gull_period(period);
state.feedback = if period == 0 {
tr.ed_gulls_off.into()
} else {
fill(tr.ed_gulls_every, &[("period", &period.to_string())])
};
}
if keys.just_pressed(KeyCode::KeyV) {
if state.kind == LevelKind::Arena {
state.feedback = arena_report(board, tr);
} else if state.solver.is_some() {
state.feedback = tr.ed_already_validating.into();
} else {
let level = level_here(&state, board, "Custom");
start_validation(&mut state, level);
state.feedback = tr.ed_validating.into();
}
}
if keys.just_pressed(KeyCode::F6) {
state.kind = match state.kind {
LevelKind::Puzzle => LevelKind::Arena,
LevelKind::Arena => LevelKind::Puzzle,
};
state.feedback = match state.kind {
LevelKind::Puzzle => tr.ed_now_puzzle.into(),
LevelKind::Arena => tr.ed_now_arena.into(),
};
}
if keys.just_pressed(KeyCode::F2) {
let level = level_here(&state, board, &state.name);
let path = save_path(&state.name);
state.feedback = match crate::app::paths::write_atomic(&path, level.to_text()) {
Ok(()) => {
saved.write(crate::app::LevelSaved);
let filed = fill(tr.ed_saved_to, &[("path", &path.display().to_string())]);
match orphan_warning(&state, &level, tr) {
Some(complaint) => format!("{filed} - {complaint}"),
None => filed,
}
}
Err(e) => fill(tr.ed_save_failed, &[("e", &e.to_string())]),
};
}
if keys.just_pressed(KeyCode::Enter) {
let snapshot = board.clone();
if state.kind == LevelKind::Puzzle {
board.set_signpost_rule(state.posts, crate::sim::CapPolicy::Reject);
}
state.testing = Some(snapshot);
state.feedback = tr.ed_playtest_prompt.into();
}
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
}
}
pub fn editor_test_input(
keys: Res<ButtonInput<KeyCode>>,
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut state: ResMut<EditorState>,
cursors: Query<&Cursor>,
) {
let Some(cursor) = cursors.iter().next() else {
return;
};
for (key, dir) in [
(KeyCode::ArrowUp, Direction::Up),
(KeyCode::ArrowDown, Direction::Down),
(KeyCode::ArrowLeft, Direction::Left),
(KeyCode::ArrowRight, Direction::Right),
] {
if keys.just_pressed(key) {
let _ = sim.0.place_signpost(0, cursor.x, cursor.y, dir);
}
}
if keys.just_pressed(KeyCode::Space) {
let _ = sim.0.remove_signpost(0, cursor.x, cursor.y);
}
if keys.just_pressed(KeyCode::Escape)
&& let Some(snapshot) = state.testing.take()
{
sim.0 = snapshot;
state.dirty = true;
state.feedback = settings.tr().ed_back.into();
}
}
pub fn poll_solver(settings: Res<GameSettings>, mut state: ResMut<EditorState>) {
let tr = settings.tr();
let Some(slot) = &state.solver else {
return;
};
let result = slot.lock().unwrap().take();
match result {
None => return, Some(SolveOutcome::Found(solution)) => {
let described: Vec<String> = solution
.iter()
.map(|(x, y, dir)| format!("({x},{y} {dir:?})"))
.collect();
state.feedback = if solution.is_empty() {
tr.ed_solvable_free.into()
} else {
fill(tr.ed_solvable, &[("placements", &described.join(" "))])
};
}
Some(SolveOutcome::Unsolvable) => {
state.feedback = fill(tr.ed_not_solvable, &[("n", &state.posts.to_string())]);
}
Some(SolveOutcome::GaveUp) => {
state.feedback = tr.ed_solver_gave_up.into();
}
}
state.solver = None;
}
pub fn rebuild_statics(
mut commands: Commands,
sim: Res<Sim>,
art: Res<crate::app::art::Art>,
mut state: ResMut<EditorState>,
statics: Query<Entity, With<crate::app::board_render::BoardStatic>>,
) {
if !state.dirty {
return;
}
state.dirty = false;
for entity in &statics {
commands.entity(entity).despawn();
}
crate::app::board_render::spawn_static_board(&mut commands, &sim.0, &art);
}
fn next_dir(dir: Direction) -> Option<Direction> {
match dir {
Direction::Right => Some(Direction::Down),
Direction::Down => Some(Direction::Left),
Direction::Left => Some(Direction::Up),
Direction::Up => None,
}
}
fn cycle_crab(board: &mut Board, x: u8, y: u8) {
const CYCLE: [(CrabKind, Handedness); 12] = [
(CrabKind::Common, Handedness::Left),
(CrabKind::Common, Handedness::Right),
(CrabKind::Juvenile, Handedness::Left),
(CrabKind::Juvenile, Handedness::Right),
(CrabKind::Giant, Handedness::Left),
(CrabKind::Giant, Handedness::Right),
(CrabKind::Molting, Handedness::Left),
(CrabKind::Molting, Handedness::Right),
(CrabKind::Golden, Handedness::Left),
(CrabKind::Golden, Handedness::Right),
(CrabKind::Sparkling, Handedness::Left),
(CrabKind::Sparkling, Handedness::Right),
];
if board.tile_at(x, y) != TileKind::Empty {
return; }
let tile = board.index_of(x, y);
let current = board
.crabs()
.iter()
.find(|c| c.tile == tile)
.map(|c| (c.kind, c.handed));
board.remove_crabs_at(x, y);
let next = match current {
None => Some(CYCLE[0]),
Some(cur) => CYCLE
.iter()
.position(|&e| e == cur)
.and_then(|i| CYCLE.get(i + 1))
.copied(),
};
if let Some((kind, handed)) = next {
board.spawn_crab(x, y, Direction::Right, handed, kind);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::Board;
#[test]
fn no_brush_takes_a_movement_key() {
use bevy::prelude::KeyCode;
let movement = [KeyCode::KeyW, KeyCode::KeyA, KeyCode::KeyS, KeyCode::KeyD];
let controls = [
KeyCode::KeyK, KeyCode::KeyO, KeyCode::KeyV, KeyCode::Tab, KeyCode::Space, KeyCode::Enter, KeyCode::Escape, KeyCode::F6, ];
for brush in Brush::ALL {
let key = brush.key();
assert!(!movement.contains(&key), "{brush:?} is on a movement key");
assert!(!controls.contains(&key), "{brush:?} is on a control key");
}
let mut keys: Vec<_> = Brush::ALL
.iter()
.map(|b| format!("{:?}", b.key()))
.collect();
keys.sort();
let before = keys.len();
keys.dedup();
assert_eq!(keys.len(), before, "two brushes on one key");
}
#[test]
fn letters_name_their_keys() {
for brush in Brush::ALL {
assert_eq!(
format!("{:?}", brush.key()),
format!("Key{}", brush.letter()),
"{brush:?} shows a letter that is not its key"
);
}
}
fn sand() -> Board {
Board::new(6, 5, 3)
}
#[test]
fn different_names_are_different_files() {
assert_ne!(save_path("First Beach"), save_path("Second Beach"));
assert!(
save_path("Gull Alley").ends_with("gull-alley.txt"),
"{:?}",
save_path("Gull Alley")
);
}
#[test]
fn a_saved_level_carries_the_name_it_was_given() {
let level = Level::from_board("Gull Alley", 3, sand());
assert_eq!(level.name, "Gull Alley");
let back = Level::parse(&level.to_text()).expect("round trip");
assert_eq!(back.name, "Gull Alley");
}
#[test]
fn the_toggle_sets_the_kind_and_the_rule() {
use crate::sim::CapPolicy;
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(5, 4, TileKind::Castle(1));
let state = |kind| EditorState {
posts: 3,
kind,
..EditorState::default()
};
let puzzle = level_here(&state(LevelKind::Puzzle), &board, "Stage");
assert_eq!(puzzle.kind, LevelKind::Puzzle, "two castles, still a stage");
assert_eq!(
puzzle.board().signpost_rule(),
(3, CapPolicy::Reject),
"a stage grants what it grants"
);
let arena = level_here(&state(LevelKind::Arena), &board, "Beach");
assert_eq!(arena.kind, LevelKind::Arena);
assert_eq!(
arena.board().signpost_rule(),
board.signpost_rule(),
"a beach keeps the versus rule"
);
assert_eq!(arena.board().signpost_rule().1, CapPolicy::Evict);
let back = Level::parse(&arena.to_text()).expect("round trip");
assert_eq!(back.kind, LevelKind::Arena);
assert_eq!(back.board().signpost_rule(), arena.board().signpost_rule());
}
#[test]
fn a_save_that_lands_nowhere_says_so() {
use crate::app::i18n::EN;
let mut board = sand();
let state = |kind| EditorState {
posts: 3,
kind,
..EditorState::default()
};
board.set_tile(0, 0, TileKind::Castle(0));
let arena = state(LevelKind::Arena);
let level = level_here(&arena, &board, "Half a Beach");
let complaint = orphan_warning(&arena, &level, &EN).expect("nowhere to go");
assert!(complaint.contains('1'), "{complaint}");
let puzzle = state(LevelKind::Puzzle);
let level = level_here(&puzzle, &board, "Empty Stage");
assert_eq!(
orphan_warning(&puzzle, &level, &EN).as_deref(),
Some(EN.ed_puzzle_no_crabs)
);
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
let level = level_here(&puzzle, &board, "A Stage");
assert_eq!(orphan_warning(&puzzle, &level, &EN), None);
board.set_tile(5, 4, TileKind::Castle(1));
let level = level_here(&arena, &board, "A Beach");
assert_eq!(orphan_warning(&arena, &level, &EN), None);
}
#[test]
fn the_posts_dial_is_inert_on_a_beach() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.add_message::<crate::app::LevelSaved>();
app.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_systems(Update, editor_commands);
app.world_mut().resource_mut::<EditorState>().posts = 3;
let tap = |app: &mut App, key: KeyCode| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(key);
app.update();
};
tap(&mut app, KeyCode::Equal);
assert_eq!(app.world().resource::<EditorState>().posts, 4, "a stage");
tap(&mut app, KeyCode::F6); tap(&mut app, KeyCode::Equal);
tap(&mut app, KeyCode::Minus);
assert_eq!(app.world().resource::<EditorState>().posts, 4, "untouched");
tap(&mut app, KeyCode::F6); tap(&mut app, KeyCode::Equal);
assert_eq!(app.world().resource::<EditorState>().posts, 5, "waiting");
}
#[test]
fn committing_a_name_does_not_start_a_playtest() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.add_message::<crate::app::LevelSaved>();
app.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_systems(Update, editor_commands);
app.world_mut().resource_mut::<EditorState>().named = true;
let press = |app: &mut App| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::Enter);
app.update();
};
press(&mut app);
let state = app.world().resource::<EditorState>();
assert!(state.testing.is_none(), "the naming Enter began a playtest");
assert!(!state.named, "the flag is for one frame");
press(&mut app);
assert!(
app.world().resource::<EditorState>().testing.is_some(),
"the next Enter is a real one"
);
}
#[test]
fn f6_flips_the_kind() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.add_message::<crate::app::LevelSaved>();
app.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_systems(Update, editor_commands);
let press = |app: &mut App| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::F6);
app.update();
};
assert_eq!(
app.world().resource::<EditorState>().kind,
LevelKind::Puzzle
);
press(&mut app);
let state = app.world().resource::<EditorState>();
assert_eq!(state.kind, LevelKind::Arena);
assert_eq!(state.feedback, crate::app::i18n::EN.ed_now_arena);
press(&mut app);
assert_eq!(
app.world().resource::<EditorState>().kind,
LevelKind::Puzzle
);
}
#[test]
fn checking_a_beach_reads_its_castles() {
use crate::app::i18n::EN;
let mut board = sand();
assert!(arena_report(&board, &EN).contains('0'), "bare sand seats 0");
board.set_tile(0, 0, TileKind::Castle(0));
let one = arena_report(&board, &EN);
assert!(one.contains('1'), "one castle is not a match: {one}");
board.set_tile(5, 4, TileKind::Castle(1));
assert_eq!(
arena_report(&board, &EN),
EN.ed_arena_no_crabs,
"castles but nothing to fight over"
);
board.set_tile(
2,
2,
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: SPAWNER_PERIOD,
}),
);
let ok = arena_report(&board, &EN);
assert!(ok.contains('2'), "two seats: {ok}");
assert!(!ok.contains("wants"), "no complaint left: {ok}");
}
#[test]
fn the_castle_key_walks_the_seats_then_clears() {
let mut board = sand();
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
for owner in 0..MAX_PLAYERS as u8 {
cycle_castle(&mut board, 2, 2);
assert_eq!(board.tile_at(2, 2), TileKind::Castle(owner));
}
assert!(board.crabs().is_empty(), "the crab was swept aside");
cycle_castle(&mut board, 2, 2);
assert_eq!(board.tile_at(2, 2), TileKind::Empty, "and round to nothing");
}
#[test]
fn a_brush_replaces_and_sand_erases() {
let mut board = sand();
paint(&mut board, 1, 1, Brush::Weed);
assert_eq!(board.tile_at(1, 1), TileKind::Kelp);
paint(&mut board, 1, 1, Brush::Weed);
assert_eq!(board.tile_at(1, 1), TileKind::Kelp, "and again is the same");
paint(&mut board, 1, 1, Brush::Pool);
assert_eq!(board.tile_at(1, 1), TileKind::Pool, "another replaces it");
paint(&mut board, 1, 1, Brush::Sand);
assert_eq!(board.tile_at(1, 1), TileKind::Empty, "and sand clears it");
board.spawn_crab(3, 3, Direction::Right, Handedness::Left, CrabKind::Common);
paint(&mut board, 3, 3, Brush::Rock);
assert_eq!(board.tile_at(3, 3), TileKind::Rock);
assert!(board.crabs().is_empty());
}
#[test]
fn the_palette_is_one_list() {
use std::collections::HashSet;
let keys: HashSet<KeyCode> = Brush::ALL.iter().map(|b| b.key()).collect();
assert_eq!(keys.len(), Brush::ALL.len(), "two brushes share a key");
let letters: HashSet<&str> = Brush::ALL.iter().map(|b| b.letter()).collect();
assert_eq!(
letters.len(),
Brush::ALL.len(),
"two brushes share a letter"
);
assert_eq!(
crate::app::i18n::EN.ed_brushes.len(),
Brush::ALL.len(),
"the palette and its labels have drifted apart"
);
}
#[test]
fn spawner_directions_run_out_after_four() {
let dirs: Vec<Direction> =
std::iter::successors(Some(Direction::Right), |d| next_dir(*d)).collect();
assert_eq!(
dirs,
[
Direction::Right,
Direction::Down,
Direction::Left,
Direction::Up
]
);
assert_eq!(next_dir(Direction::Up), None, "the cycle ends, and removes");
}
#[test]
fn a_level_travels_as_a_code_and_comes_back() {
let mut board = sand();
board.set_tile(2, 2, TileKind::Rock);
board.set_tile(4, 3, TileKind::Castle(0));
board.set_wall(1, 1, Direction::Right, true);
board.set_wrap(true);
let level = Level::from_board("Custom", 5, board.clone());
let code = crate::share::encode(crate::share::Kind::Level, level.to_text().as_bytes());
let back = level_from(crate::share::decode(&code), &crate::app::i18n::EN)
.expect("our own level reads back");
assert_eq!(back.posts, 5, "the signpost budget travels with it");
let rebuilt = back.board();
assert_eq!(rebuilt.tile_at(2, 2), TileKind::Rock);
assert_eq!(rebuilt.tile_at(4, 3), TileKind::Castle(0));
assert!(rebuilt.wall_at(1, 1, Direction::Right));
assert!(rebuilt.wrap(), "an open-ocean level stays open");
}
#[test]
fn a_started_validation_leaves_a_verdict_in_the_slot() {
fn verdict(level: Level) -> SolveOutcome {
let mut state = EditorState::default();
start_validation(&mut state, level);
let slot = state.solver.expect("a validation is running");
loop {
if let Some(outcome) = slot.lock().unwrap().take() {
return outcome;
}
std::thread::yield_now();
}
}
let mut easy = sand();
easy.set_tile(4, 2, TileKind::Castle(0));
easy.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
assert_eq!(
verdict(Level::from_board("Custom", 1, easy)),
SolveOutcome::Found(Vec::new())
);
let mut sealed = sand();
sealed.set_tile(4, 2, TileKind::Castle(0));
sealed.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
for dir in Direction::ALL {
sealed.set_wall(4, 2, dir, true);
}
assert_eq!(
verdict(Level::from_board("Custom", 1, sealed)),
SolveOutcome::Unsolvable
);
}
#[test]
fn what_is_not_a_level_says_which_way_it_is_not() {
let tr = &crate::app::i18n::EN;
assert_eq!(
level_from(None, tr).err(),
Some(tr.code_none_pasted.to_string())
);
let round = crate::share::encode(crate::share::Kind::Round, b"replay-v1\n");
let complaint = level_from(crate::share::decode(&round), tr).expect_err("not a level");
assert!(
complaint.contains("a round") && complaint.contains("a level"),
"{complaint}"
);
let junk = crate::share::encode(crate::share::Kind::Level, b"not a level at all");
assert!(level_from(crate::share::decode(&junk), tr).is_err());
}
#[test]
fn the_crab_key_cycles_twelve_then_clears() {
let mut board = sand();
let mut seen = Vec::new();
for _ in 0..12 {
cycle_crab(&mut board, 3, 3);
let crab = board.crabs().first().copied().expect("a crab is standing");
seen.push((crab.kind, crab.handed));
}
seen.dedup();
assert_eq!(seen.len(), 12, "every kind, both hands: {seen:?}");
cycle_crab(&mut board, 3, 3);
assert!(board.crabs().is_empty(), "the thirteenth press clears it");
board.set_tile(4, 3, TileKind::Rock);
cycle_crab(&mut board, 4, 3);
assert!(board.crabs().is_empty(), "crabs start on open sand only");
}
}
#[derive(Component)]
pub struct EditorUi;
#[derive(Component)]
pub struct PaletteRow(usize);
#[derive(Component)]
pub struct PaletteLabel(usize);
#[derive(Component)]
pub struct KindRow(LevelKind);
#[derive(Component)]
pub struct UnderCursor;
pub fn spawn_editor_ui(
mut commands: Commands,
art: Res<crate::app::art::Art>,
settings: Res<GameSettings>,
) {
let tr = settings.tr();
commands
.spawn((
EditorUi,
Node {
position_type: PositionType::Absolute,
top: Val::Px(70.0),
right: Val::Px(16.0),
width: Val::Px(190.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(2.0),
padding: UiRect::all(Val::Px(10.0)),
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),
crate::app::menu_ui::card_shadow(),
))
.with_children(|panel| {
panel.spawn((
Text::new(tr.ed_kind_row.to_string()),
TextFont {
font_size: FontSize::Px(13.0),
..default()
},
TextColor(crate::app::palette::PARCHMENT.with_alpha(0.55)),
));
for kind in [LevelKind::Puzzle, LevelKind::Arena] {
panel
.spawn((
KindRow(kind),
Node {
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(6.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|row| {
row.spawn((
Text::new(match kind {
LevelKind::Puzzle => tr.ed_kind_puzzle.to_string(),
LevelKind::Arena => tr.ed_kind_arena.to_string(),
}),
TextFont {
font_size: FontSize::Px(15.0),
..default()
},
TextColor(crate::app::palette::PARCHMENT),
));
});
}
panel.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
margin: UiRect::axes(Val::Px(0.0), Val::Px(6.0)),
..default()
},
BackgroundColor(crate::app::palette::PARCHMENT.with_alpha(0.14)),
));
for (i, brush) in Brush::ALL.iter().enumerate() {
panel
.spawn((
PaletteRow(i),
Node {
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
padding: UiRect::axes(Val::Px(6.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|row| {
row.spawn((
ImageNode::new(brush.icon(&art)),
Node {
width: Val::Px(22.0),
height: Val::Px(22.0),
..default()
},
));
row.spawn((
PaletteLabel(i),
Text::new(format!("{} {}", brush.letter(), brush.label(tr))),
TextFont {
font_size: FontSize::Px(16.0),
..default()
},
TextColor(crate::app::palette::PARCHMENT),
));
});
}
panel.spawn((
UnderCursor,
Text::new(String::new()),
TextFont {
font_size: FontSize::Px(15.0),
..default()
},
TextColor(crate::app::palette::IDLE_ROW),
Node {
margin: UiRect::top(Val::Px(8.0)),
..default()
},
));
});
}
#[allow(clippy::too_many_arguments)]
pub fn update_editor_palette(
state: Res<EditorState>,
sim: Res<Sim>,
settings: Res<GameSettings>,
cursors: Query<&Cursor>,
mut rows: Query<(&PaletteRow, &mut BackgroundColor)>,
mut kinds: Query<(&KindRow, &mut BackgroundColor), Without<PaletteRow>>,
mut labels: Query<(&PaletteLabel, &mut TextColor)>,
mut under: Query<&mut Text, With<UnderCursor>>,
) {
let tr = settings.tr();
for (row, mut fill) in &mut kinds {
let want = match row.0 == state.kind {
true => crate::app::palette::SELECTED_ROW.with_alpha(0.22),
false => Color::NONE,
};
crate::app::menu_ui::set_bg(&mut fill, want);
}
let at = Brush::ALL.iter().position(|b| *b == state.brush);
for (row, mut fill) in &mut rows {
let want = match Some(row.0) == at {
true => crate::app::palette::SELECTED_ROW.with_alpha(0.22),
false => Color::NONE,
};
crate::app::menu_ui::set_bg(&mut fill, want);
}
for (label, mut color) in &mut labels {
let want = match Some(label.0) == at {
true => crate::app::palette::SELECTED_ROW,
false => crate::app::palette::PARCHMENT,
};
crate::app::menu_ui::set_color(&mut color, want);
}
let Some(cursor) = cursors.iter().next() else {
return;
};
let board = &sim.0;
let standing = standing_on(board, cursor.x, cursor.y).label(tr);
for mut text in &mut under {
crate::app::menu_ui::set_text(&mut text, &fill(tr.ed_under, &[("t", standing)]));
}
}
fn standing_on(board: &Board, x: u8, y: u8) -> Brush {
let tile = board.index_of(x, y);
if board.gulls().iter().any(|g| g.tile == tile) {
return Brush::Gull;
}
if board.crabs().iter().any(|c| c.tile == tile) {
return Brush::Crab;
}
match board.tile_at(x, y) {
TileKind::Rock => Brush::Rock,
TileKind::Castle(_) => Brush::Castle,
TileKind::Spawner(_) => Brush::Hole,
TileKind::Turnstile { .. } => Brush::Log,
TileKind::Kelp => Brush::Weed,
TileKind::Pool => Brush::Pool,
TileKind::Empty => Brush::Sand,
}
}