381 lines
12 KiB
Rust
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,
|
||
|
|
}
|
||
|
|
}
|