station/crates/game_client/src/menu.rs

381 lines
12 KiB
Rust

//! 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::<MenuScreen>()
.init_resource::<SaveList>()
.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<SessionEnded>, message: Res<MenuMessage>, mut screen: ResMut<MenuScreen>| {
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<SaveEntry>,
/// Save to delete on the second click.
confirm_delete: Option<usize>,
}
#[derive(Component)]
struct MenuRoot;
fn menu_bindings(
message: Res<MenuMessage>,
profile: Res<Profile>,
saves: Res<SaveList>,
mut bindings: ResMut<UiBindings>,
) {
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<MenuScreen>,
roots: Query<Entity, With<MenuRoot>>,
theme: Res<Theme>,
mut bindings: ResMut<UiBindings>,
mut form: ResMut<UiForm>,
assets: Res<AssetServer>,
mut saves: ResMut<SaveList>,
) {
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<Theme>,
bindings: Res<UiBindings>,
mut form: ResMut<UiForm>,
assets: Res<AssetServer>,
) {
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<Theme>,
saves: Res<SaveList>,
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<Option<(u16, String)>, String> {
if !form.checked("host") {
return Ok(None);
}
let port = form
.get("port")
.trim()
.parse::<u16>()
.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<String, String> {
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<UiAction>,
mut commands: Commands,
state: Res<State<AppState>>,
mut screen: ResMut<MenuScreen>,
mut message: ResMut<MenuMessage>,
mut saves: ResMut<SaveList>,
mut profile: ResMut<Profile>,
form: Res<UiForm>,
map: Res<StartMap>,
mut next: ResMut<NextState<AppState>>,
) {
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<Option<SessionRequest>, 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::<usize>()
.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::<usize>().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,
}
}