//! Start menu: new game, load game, join a server. Screens are layout files in //! `assets/ui/` (`main_menu`, `new_game`, `load_game`, `connect`, `connecting`). use bevy::prelude::*; use crate::{ profile::Profile, saves::{self, SaveEntry}, session::{self, AppState, MenuMessage, SessionEnded, SessionRequest, SessionTarget}, ui::{self, OnClick, UiAction, UiBindings, UiContext, UiForm, WidgetSlot, layout::Theme}, }; pub struct MenuPlugin; impl Plugin for MenuPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .add_systems(OnEnter(AppState::Connecting), show_connecting) .add_systems( Update, (menu_bindings, show_menu, save_list) .chain() .run_if(in_state(AppState::Menu)), ) .add_observer(on_action) .add_observer( // Errors are shown on the screen the session was started from. |_: On, message: Res, mut screen: ResMut| { if message.0.is_empty() { *screen = MenuScreen::Main; } }, ); } } #[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MenuScreen { #[default] Main, NewGame, LoadGame, Connect, } impl MenuScreen { fn layout(self) -> &'static str { match self { MenuScreen::Main => "main_menu", MenuScreen::NewGame => "new_game", MenuScreen::LoadGame => "load_game", MenuScreen::Connect => "connect", } } } /// Map for new worlds (`--map`). #[derive(Resource, Clone, Debug)] pub struct StartMap(pub String); #[derive(Resource, Default)] struct SaveList { saves: Vec, /// Save to delete on the second click. confirm_delete: Option, } #[derive(Component)] struct MenuRoot; fn menu_bindings( message: Res, profile: Res, saves: Res, mut bindings: ResMut, ) { bindings.set("message", message.0.clone()); bindings.set("profile_name", profile.name.clone()); bindings.set("last_address", profile.last_address.clone()); bindings.set("default_port", game_core::DEFAULT_PORT.to_string()); bindings.set("saves_dir", saves::saves_dir().display().to_string()); bindings.set("version", env!("CARGO_PKG_VERSION")); save_bindings(&saves, &mut bindings); } fn save_bindings(saves: &SaveList, bindings: &mut UiBindings) { bindings.set("save_count", saves.saves.len().to_string()); bindings.flag("has_saves", !saves.saves.is_empty()); bindings.set( "suggested_world", (1..) .map(|i| format!("World {i}")) .find(|name| !saves.saves.iter().any(|s| &s.world.name == name)) .expect("unbounded"), ); } #[allow(clippy::too_many_arguments)] fn show_menu( mut commands: Commands, screen: Res, roots: Query>, theme: Res, mut bindings: ResMut, mut form: ResMut, assets: Res, mut saves: ResMut, ) { if !roots.is_empty() && !screen.is_changed() { return; } for root in &roots { commands.entity(root).despawn(); } if matches!(*screen, MenuScreen::LoadGame | MenuScreen::NewGame) { saves.saves = saves::list(); saves.confirm_delete = None; save_bindings(&saves, &mut bindings); } let mut ctx = UiContext { theme: &theme, bindings: &bindings, form: &mut form, assets: &assets, }; let root = ui::spawn_screen(&mut commands, &mut ctx, screen.layout()); commands .entity(root) .insert((MenuRoot, DespawnOnExit(AppState::Menu))); } fn show_connecting( mut commands: Commands, theme: Res, bindings: Res, mut form: ResMut, assets: Res, ) { let mut ctx = UiContext { theme: &theme, bindings: &bindings, form: &mut form, assets: &assets, }; let root = ui::spawn_screen(&mut commands, &mut ctx, "connecting"); commands .entity(root) .insert(DespawnOnExit(AppState::Connecting)); } fn save_list( mut commands: Commands, theme: Res, saves: Res, mut slots: Query<(Entity, &mut WidgetSlot)>, ) { for (entity, mut slot) in &mut slots { if slot.name != "save_list" { continue; } let content: Vec<_> = saves .saves .iter() .map(|s| (s.world.id, s.world.saved_at)) .collect(); if !slot.changed(&(content, saves.confirm_delete)) { continue; } commands.entity(entity).despawn_children(); let text = |text: String, size: f32, color: &str| { ( Text::new(text), TextFont::from_font_size(size), TextColor(theme.color(color)), ) }; for (i, save) in saves.saves.iter().enumerate() { let row = commands .spawn(( ChildOf(entity), Node { width: Val::Percent(100.0), column_gap: Val::Px(8.0), align_items: AlignItems::Center, padding: UiRect::all(Val::Px(6.0)), border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(theme.radius)), ..default() }, BorderColor::all(theme.color("border")), ui::Tooltip(format!( "File: {}\nWorld id: {}", save.path.display(), save.world.id )), )) .id(); commands .spawn(( ChildOf(row), Node { flex_direction: FlexDirection::Column, flex_grow: 1.0, ..default() }, )) .with_children(|info| { info.spawn(text(save.world.name.clone(), theme.font_size, "text")); info.spawn(text( format!( "{} - saved {}", save.world.map, saves::age(save.world.saved_at) ), theme.small_size, "dim", )); }); let ctx_button = |label: &str, action: String| { ( OnClick(action), ui::ThemedButton, Node { padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)), border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(theme.radius)), ..default() }, BackgroundColor(theme.color("button")), BorderColor::all(theme.color("border")), children![text(label.to_owned(), theme.font_size, "text")], ) }; commands.spawn((ChildOf(row), ctx_button("Play", format!("load:{i}")))); let delete = if saves.confirm_delete == Some(i) { "Really delete?" } else { "Delete" }; commands.spawn((ChildOf(row), ctx_button(delete, format!("delete:{i}")))); } } } /// Reads the hosting options of the new/load game screens. fn host_options(form: &UiForm) -> Result, String> { if !form.checked("host") { return Ok(None); } let port = form .get("port") .trim() .parse::() .ok() .filter(|p| *p != 0) .ok_or("The port must be a number between 1 and 65535.")?; Ok(Some((port, form.get("password").to_owned()))) } fn character_name(form: &UiForm) -> Result { let name = form.get("name").trim().to_owned(); if game_server::players::valid_name(&name) { Ok(name) } else { Err("Names may only contain letters, digits, spaces, `_` and `-` (max 24).".into()) } } fn on_action( action: On, mut commands: Commands, state: Res>, mut screen: ResMut, mut message: ResMut, mut saves: ResMut, mut profile: ResMut, form: Res, map: Res, mut next: ResMut>, ) { let action = action.0.as_str(); if *state.get() == AppState::Connecting && action == "cancel" { commands.queue(|world: &mut World| session::leave(world, None)); return; } if *state.get() != AppState::Menu { return; } let (verb, arg) = action.split_once(':').unwrap_or((action, "")); let result: Result, String> = match verb { "screen" => { message.0.clear(); *screen = match arg { "new_game" => MenuScreen::NewGame, "load_game" => MenuScreen::LoadGame, "connect" => MenuScreen::Connect, _ => MenuScreen::Main, }; Ok(None) } "quit" => { commands.write_message(AppExit::Success); Ok(None) } "new_game" => (|| { let world = form.get("world").trim().to_owned(); if world.is_empty() { return Err("Enter a name for the world.".to_owned()); } if saves.saves.iter().any(|s| s.world.name == world) || saves::exists(&world) { return Err(format!("A world called `{world}` already exists.")); } Ok(Some(SessionRequest { name: character_name(&form)?, target: SessionTarget::Local { storage: Some(saves::storage(&world)), map: map.0.clone(), host: host_options(&form)?, }, password: form.get("password").to_owned(), })) })(), "load" => (|| { let save = arg .parse::() .ok() .and_then(|i| saves.saves.get(i)) .ok_or("That save no longer exists.")?; Ok(Some(SessionRequest { name: character_name(&form)?, target: SessionTarget::Local { storage: Some(save.storage()), map: save.world.map.clone(), host: host_options(&form)?, }, password: form.get("password").to_owned(), })) })(), "delete" => (|| { let i = arg.parse::().map_err(|e| e.to_string())?; let save = saves .saves .get(i) .cloned() .ok_or("That save no longer exists.")?; if saves.confirm_delete != Some(i) { saves.confirm_delete = Some(i); return Ok(None); } saves::delete(&save)?; message.0 = format!("Deleted `{}`.", save.world.name); saves.saves = saves::list(); saves.confirm_delete = None; Ok(None) })(), "join" => (|| { let address = form.get("address").trim().to_owned(); session::resolve(&address)?; profile.last_address = address.clone(); Ok(Some(SessionRequest { name: character_name(&form)?, target: SessionTarget::Remote { address }, password: form.get("password").to_owned(), })) })(), _ => Ok(None), }; match result { Ok(Some(request)) => { message.0.clear(); profile.name = request.name.clone(); profile.save(); commands.insert_resource(request); next.set(AppState::Connecting); } Ok(None) => {} Err(e) => message.0 = e, } }