diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7fd22d5b..a9b480a34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Keybinds for zooming the camera (Defaults: ']' for zooming in and '[' for zooming out) - Added the ability to make pets sit, they wont follow nor defend you in this state - Portals that spawn in place of the last staircase at old style dungeons to prevent stair cheesing +- Mutliple singleplayer worlds and map generation UI. ### Changed diff --git a/Cargo.lock b/Cargo.lock index a5d2f155ee..7f6ccad40a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7231,6 +7231,7 @@ dependencies = [ "egui_wgpu_backend", "egui_winit_platform", "enum-iterator 1.4.1", + "enum-map", "etagere", "euc", "gilrs", diff --git a/assets/voxygen/i18n/en/main.ftl b/assets/voxygen/i18n/en/main.ftl index e3eb4d30a1..7c7ef8a1c8 100644 --- a/assets/voxygen/i18n/en/main.ftl +++ b/assets/voxygen/i18n/en/main.ftl @@ -32,6 +32,23 @@ main-login_process = You can create an account over at https://veloren.net/account/. +main-singleplayer-new = New +main-singleplayer-delete = Delete +main-singleplayer-regenerate = Regenerate +main-singleplayer-create_custom = Create Custom +main-singleplayer-invalid_name = Error: Invalid name +main-singleplayer-seed = Seed +main-singleplayer-random_seed = Random +main-singleplayer-size_lg = Logarithmic size +main-singleplayer-map_large_warning = Warning: Large worlds will take a long time to start for the first time +main-singleplayer-world_name = World name +main-singleplayer-map_scale = Vertical scaling +main-singleplayer-map_erosion_quality = Erosion quality +main-singleplayer-map_shape = Shape +main-singleplayer-play = Play +main-singleplayer-generate_and_play = Generate & Play +menu-singleplayer-confirm_delete = Are you sure you want to delete "{ $world_name }" +menu-singleplayer-confirm_regenerate = Are you sure you want to regenerate "{ $world_name }" main-login-server_not_found = Server not found main-login-authentication_error = Auth error on server main-login-internal_error = Internal error on client (most likely, player character was deleted) diff --git a/common/src/resources.rs b/common/src/resources.rs index 30ca808b66..e5bc2d646a 100644 --- a/common/src/resources.rs +++ b/common/src/resources.rs @@ -75,6 +75,24 @@ impl PlayerPhysicsSetting { pub fn client_authoritative(&self) -> bool { !self.server_authoritative() } } +/// Describe how the map should be generated. +#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq, enum_map::Enum)] +pub enum MapKind { + /// The normal square map, with oceans beyond the map edge + Square, + /// A more circular map, might have more islands + Circle, +} + +impl std::fmt::Display for MapKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MapKind::Square => f.write_str("Square"), + MapKind::Circle => f.write_str("Circle"), + } + } +} + /// List of which players are using client-authoratative vs server-authoratative /// physics, as a stop-gap until we can use server-authoratative physics for /// everyone diff --git a/rtsim/src/gen/site.rs b/rtsim/src/gen/site.rs index 2a903bef17..4baaaf4d0d 100644 --- a/rtsim/src/gen/site.rs +++ b/rtsim/src/gen/site.rs @@ -55,7 +55,11 @@ impl Site { .get(*faction) .map_or(false, |f| f.good_or_evil == good_or_evil) }) - .min_by_key(|(faction_wpos, _)| faction_wpos.distance_squared(wpos)) + .min_by_key(|(faction_wpos, _)| { + faction_wpos + .as_::() + .distance_squared(wpos.as_::()) + }) .map(|(_, faction)| *faction) }), population: Default::default(), diff --git a/rtsim/src/rule/sync_npcs.rs b/rtsim/src/rule/sync_npcs.rs index 90f7dac625..76d55b1a0f 100644 --- a/rtsim/src/rule/sync_npcs.rs +++ b/rtsim/src/rule/sync_npcs.rs @@ -46,7 +46,7 @@ fn on_setup(ctx: EventCtx) { // Only include sites in the list if they're not the current one and they're more populus .filter(|(other_id, _, other_site2)| *other_id != site_id && other_site2.plots().len() > site2.plots().len()) .collect::>(); - other_sites.sort_by_key(|(_, other, _)| other.wpos.distance_squared(site.wpos) as i64); + other_sites.sort_by_key(|(_, other, _)| other.wpos.as_::().distance_squared(site.wpos.as_::())); let mut max_size = 0; // Remove sites that aren't in increasing order of size (Stalin sort?!) other_sites.retain(|(_, _, other_site2)| { diff --git a/server/src/lib.rs b/server/src/lib.rs index 5cb4ad5123..6eb6739eb3 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -131,8 +131,8 @@ use { use crate::persistence::character_loader::CharacterScreenResponseKind; use common::comp::Anchor; #[cfg(feature = "worldgen")] -use world::{ - sim::{FileOpts, WorldOpts, DEFAULT_WORLD_MAP}, +pub use world::{ + sim::{FileOpts, GenOpts, WorldOpts, DEFAULT_WORLD_MAP}, IndexOwned, World, }; diff --git a/server/src/settings.rs b/server/src/settings.rs index 1dfcd68f7d..caf7a968d9 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -363,6 +363,7 @@ const MIGRATION_UPGRADE_GUARANTEE: &str = "Any valid file of an old verison shou successfully migrate to the latest version."; /// Combines all the editable settings into one struct that is stored in the ecs +#[derive(Clone)] pub struct EditableSettings { pub whitelist: Whitelist, pub banlist: Banlist, diff --git a/voxygen/Cargo.toml b/voxygen/Cargo.toml index af5d3da4d8..e5f670f2bb 100644 --- a/voxygen/Cargo.toml +++ b/voxygen/Cargo.toml @@ -134,6 +134,7 @@ itertools = { workspace = true } # Discord RPC discord-sdk = { version = "0.3.0", optional = true } +enum-map = "2.5.0" [target.'cfg(target_os = "macos")'.dependencies] dispatch = "0.1.4" diff --git a/voxygen/src/lib.rs b/voxygen/src/lib.rs index ac2775b5cc..07b3f806eb 100644 --- a/voxygen/src/lib.rs +++ b/voxygen/src/lib.rs @@ -42,6 +42,8 @@ pub mod window; #[cfg(feature = "singleplayer")] use crate::singleplayer::Singleplayer; +#[cfg(feature = "singleplayer")] +use crate::singleplayer::SingleplayerState; #[cfg(feature = "egui-ui")] use crate::ui::egui::EguiState; use crate::{ @@ -74,7 +76,7 @@ pub struct GlobalState { pub info_message: Option, pub clock: Clock, #[cfg(feature = "singleplayer")] - pub singleplayer: Option, + pub singleplayer: SingleplayerState, // TODO: redo this so that the watcher doesn't have to exist for reloading to occur pub i18n: LocalizationHandle, pub clipboard: iced_winit::Clipboard, @@ -102,7 +104,7 @@ impl GlobalState { #[cfg(feature = "singleplayer")] pub fn paused(&self) -> bool { self.singleplayer - .as_ref() + .as_running() .map_or(false, Singleplayer::is_paused) } @@ -110,10 +112,10 @@ impl GlobalState { pub fn paused(&self) -> bool { false } #[cfg(feature = "singleplayer")] - pub fn unpause(&self) { self.singleplayer.as_ref().map(|s| s.pause(false)); } + pub fn unpause(&self) { self.singleplayer.as_running().map(|s| s.pause(false)); } #[cfg(feature = "singleplayer")] - pub fn pause(&self) { self.singleplayer.as_ref().map(|s| s.pause(true)); } + pub fn pause(&self) { self.singleplayer.as_running().map(|s| s.pause(true)); } } // TODO: appears to be currently unused by playstates diff --git a/voxygen/src/main.rs b/voxygen/src/main.rs index e68bae2282..b8a65f6458 100644 --- a/voxygen/src/main.rs +++ b/voxygen/src/main.rs @@ -19,6 +19,8 @@ static GLOBAL: common_base::tracy_client::ProfiledAllocator common_base::tracy_client::ProfiledAllocator::new(std::alloc::System, 128); use i18n::{self, LocalizationHandle}; +#[cfg(feature = "singleplayer")] +use veloren_voxygen::singleplayer::SingleplayerState; use veloren_voxygen::{ audio::AudioFrontend, panic_handler, @@ -223,7 +225,7 @@ fn main() { settings, info_message: None, #[cfg(feature = "singleplayer")] - singleplayer: None, + singleplayer: SingleplayerState::None, i18n, clipboard, clear_shadows_next_frame: false, diff --git a/voxygen/src/menu/char_selection/ui/mod.rs b/voxygen/src/menu/char_selection/ui/mod.rs index b11bf86690..fac2523333 100644 --- a/voxygen/src/menu/char_selection/ui/mod.rs +++ b/voxygen/src/menu/char_selection/ui/mod.rs @@ -1325,36 +1325,27 @@ impl Controls { ]; let right_column_content = if character_id.is_none() { - let site_slider = starter_slider( - i18n.get_msg("char_selection-starting_site").into_owned(), - 30, - &mut sliders.starting_site, - self.possible_starting_sites.len() as u32 - 1, - *start_site_idx as u32, - |x| Message::StartingSite(x as usize), - imgs, - ); let map_sz = Vec2::new(500, 500); let map_img = Image::new(self.map_img) .height(Length::Units(map_sz.x)) .width(Length::Units(map_sz.y)); - let site_name = Text::new( - self.possible_starting_sites[*start_site_idx] - .name - .as_deref() - .unwrap_or("Unknown") - ) - .horizontal_alignment(HorizontalAlignment::Left) - .color(Color::from_rgb(131.0, 102.0, 0.0)) /* .stroke(Stroke { color: Color::WHITE, width: 1.0, - }) */; + }) */ //TODO: Add text-outline here whenever we updated iced to a version supporting // this let map = if let Some(info) = self.possible_starting_sites.get(*start_site_idx) { + let site_name = Text::new( + self.possible_starting_sites[*start_site_idx] + .name + .as_deref() + .unwrap_or("Unknown"), + ) + .horizontal_alignment(HorizontalAlignment::Left) + .color(Color::from_rgb(131.0, 102.0, 0.0)); let pos_frac = info .wpos .map2(self.world_sz * TerrainChunkSize::RECT_SIZE, |e, sz| { @@ -1388,6 +1379,15 @@ impl Controls { if self.possible_starting_sites.is_empty() { vec![map] } else { + let site_slider = starter_slider( + i18n.get_msg("char_selection-starting_site").into_owned(), + 30, + &mut sliders.starting_site, + self.possible_starting_sites.len() as u32 - 1, + *start_site_idx as u32, + |x| Message::StartingSite(x as usize), + imgs, + ); let site_buttons = Row::with_children(vec![ neat_button( prev_starting_site_button, @@ -1972,9 +1972,9 @@ impl CharSelectionUi { let fonts = Fonts::load(i18n.fonts(), &mut ui).expect("Impossible to load fonts"); #[cfg(feature = "singleplayer")] - let default_name = match global_state.singleplayer { - Some(_) => String::new(), - None => global_state.settings.networking.username.clone(), + let default_name = match global_state.singleplayer.is_running() { + true => String::new(), + false => global_state.settings.networking.username.clone(), }; #[cfg(not(feature = "singleplayer"))] diff --git a/voxygen/src/menu/main/mod.rs b/voxygen/src/menu/main/mod.rs index cfbea304fe..cf01b5baea 100644 --- a/voxygen/src/menu/main/mod.rs +++ b/voxygen/src/menu/main/mod.rs @@ -4,7 +4,7 @@ mod ui; use super::char_selection::CharSelectionState; #[cfg(feature = "singleplayer")] -use crate::singleplayer::Singleplayer; +use crate::singleplayer::SingleplayerState; use crate::{ render::{Drawer, GlobalsBindGroup}, settings::Settings, @@ -73,7 +73,7 @@ impl PlayState for MainMenuState { // Reset singleplayer server if it was running already #[cfg(feature = "singleplayer")] { - global_state.singleplayer = None; + global_state.singleplayer = SingleplayerState::None; } // Updated localization in case the selected language was changed @@ -97,7 +97,7 @@ impl PlayState for MainMenuState { // Poll server creation #[cfg(feature = "singleplayer")] { - if let Some(singleplayer) = &global_state.singleplayer { + if let Some(singleplayer) = global_state.singleplayer.as_running() { match singleplayer.receiver.try_recv() { Ok(Ok(())) => { // Attempt login after the server is finished initializing @@ -113,7 +113,7 @@ impl PlayState for MainMenuState { }, Ok(Err(e)) => { error!(?e, "Could not start server"); - global_state.singleplayer = None; + global_state.singleplayer = SingleplayerState::None; self.init = InitState::None; self.main_menu_ui.cancel_connection(); let server_err = match e { @@ -332,7 +332,7 @@ impl PlayState for MainMenuState { // blocking TODO fix when the network rework happens #[cfg(feature = "singleplayer")] { - global_state.singleplayer = None; + global_state.singleplayer = SingleplayerState::None; } self.init = InitState::None; self.main_menu_ui.cancel_connection(); @@ -351,9 +351,32 @@ impl PlayState for MainMenuState { }, #[cfg(feature = "singleplayer")] MainMenuEvent::StartSingleplayer => { - let singleplayer = Singleplayer::new(&global_state.tokio_runtime); - - global_state.singleplayer = Some(singleplayer); + global_state.singleplayer.run(&global_state.tokio_runtime); + }, + #[cfg(feature = "singleplayer")] + MainMenuEvent::InitSingleplayer => { + global_state.singleplayer = SingleplayerState::init(); + }, + #[cfg(feature = "singleplayer")] + MainMenuEvent::SinglePlayerChange(change) => { + if let SingleplayerState::Init(ref mut init) = global_state.singleplayer { + match change { + ui::WorldsChange::SetActive(world) => init.current = world, + ui::WorldsChange::Delete(world) => init.remove(world), + ui::WorldsChange::Regenerate(world) => init.delete_map_file(world), + ui::WorldsChange::AddNew => init.new_world(), + ui::WorldsChange::CurrentWorldChange(change) => { + if let Some(world) = init + .current + .map(|i| &mut init.worlds[i]) + .filter(|map| !map.is_generated) + { + change.apply(world); + init.save_current_meta(); + } + }, + } + } }, MainMenuEvent::Quit => return PlayStateResult::Shutdown, // Note: Keeping in case we re-add the disclaimer diff --git a/voxygen/src/menu/main/ui/login.rs b/voxygen/src/menu/main/ui/login.rs index b924f4889a..9d6540667d 100644 --- a/voxygen/src/menu/main/ui/login.rs +++ b/voxygen/src/menu/main/ui/login.rs @@ -1,4 +1,4 @@ -use super::{Imgs, LoginInfo, Message, FILL_FRAC_ONE, FILL_FRAC_TWO}; +use super::{Imgs, LoginInfo, Message, Showing, FILL_FRAC_ONE, FILL_FRAC_TWO}; use crate::ui::{ fonts::IcedFonts as Fonts, ice::{ @@ -11,6 +11,7 @@ use crate::ui::{ Element, }, }; + use i18n::{LanguageMetadata, Localization}; use iced::{ button, scrollable, text_input, Align, Button, Column, Container, Length, Row, Scrollable, @@ -22,6 +23,7 @@ const INPUT_WIDTH: u16 = 230; const INPUT_TEXT_SIZE: u16 = 20; /// Login screen for the main menu +#[derive(Default)] pub struct Screen { quit_button: button::State, // settings_button: button::State, @@ -36,21 +38,6 @@ pub struct Screen { } impl Screen { - pub fn new() -> Self { - Self { - servers_button: Default::default(), - credits_button: Default::default(), - // settings_button: Default::default(), - quit_button: Default::default(), - language_select_button: Default::default(), - - error_okay_button: Default::default(), - - banner: LoginBanner::new(), - language_selection: LanguageSelectBanner::new(), - } - } - pub(super) fn view( &mut self, fonts: &Fonts, @@ -59,7 +46,7 @@ impl Screen { login_info: &LoginInfo, error: Option<&str>, i18n: &Localization, - is_selecting_language: bool, + show: &Showing, selected_language_index: Option, language_metadatas: &[LanguageMetadata], button_style: style::button::Style, @@ -171,24 +158,25 @@ impl Screen { .height(Length::Units(180)) .padding(20) .into() - } else if is_selecting_language { - self.language_selection.view( - fonts, - imgs, - i18n, - language_metadatas, - selected_language_index, - button_style, - ) } else { - self.banner.view( - fonts, - imgs, - server_field_locked, - login_info, - i18n, - button_style, - ) + match show { + Showing::Login => self.banner.view( + fonts, + imgs, + server_field_locked, + login_info, + i18n, + button_style, + ), + Showing::Languages => self.language_selection.view( + fonts, + imgs, + i18n, + language_metadatas, + selected_language_index, + button_style, + ), + } }; let central_column = Container::new(central_content) @@ -222,6 +210,7 @@ impl Screen { } } +#[derive(Default)] pub struct LanguageSelectBanner { okay_button: button::State, language_buttons: Vec, @@ -230,14 +219,6 @@ pub struct LanguageSelectBanner { } impl LanguageSelectBanner { - fn new() -> Self { - Self { - okay_button: Default::default(), - language_buttons: Default::default(), - selection_list: Default::default(), - } - } - fn view( &mut self, fonts: &Fonts, @@ -336,6 +317,7 @@ impl LanguageSelectBanner { } } +#[derive(Default)] pub struct LoginBanner { pub username: text_input::State, pub password: text_input::State, @@ -349,20 +331,6 @@ pub struct LoginBanner { } impl LoginBanner { - fn new() -> Self { - Self { - username: Default::default(), - password: Default::default(), - server: Default::default(), - - multiplayer_button: Default::default(), - #[cfg(feature = "singleplayer")] - singleplayer_button: Default::default(), - - unlock_server_field_button: Default::default(), - } - } - fn view( &mut self, fonts: &Fonts, diff --git a/voxygen/src/menu/main/ui/mod.rs b/voxygen/src/menu/main/ui/mod.rs index eb514c3c22..06fe475b82 100644 --- a/voxygen/src/menu/main/ui/mod.rs +++ b/voxygen/src/menu/main/ui/mod.rs @@ -4,6 +4,8 @@ mod connecting; mod credits; mod login; mod servers; +#[cfg(feature = "singleplayer")] +mod world_selector; use crate::{ credits::Credits, @@ -53,6 +55,12 @@ image_ids_ice! { selection: "voxygen.element.ui.generic.frames.selection", selection_hover: "voxygen.element.ui.generic.frames.selection_hover", selection_press: "voxygen.element.ui.generic.frames.selection_press", + + #[cfg(feature = "singleplayer")] + slider_range: "voxygen.element.ui.generic.slider.track", + #[cfg(feature = "singleplayer")] + slider_indicator: "voxygen.element.ui.generic.slider.indicator", + unlock: "voxygen.element.ui.generic.buttons.unlock", unlock_hover: "voxygen.element.ui.generic.buttons.unlock_hover", unlock_press: "voxygen.element.ui.generic.buttons.unlock_press", @@ -77,6 +85,47 @@ const BG_IMGS: [&str; 14] = [ "voxygen.background.bg_14", ]; +#[cfg(feature = "singleplayer")] +#[derive(Clone)] +pub enum WorldChange { + Name(String), + Seed(u32), + SizeX(u32), + SizeY(u32), + Scale(f64), + MapKind(common::resources::MapKind), + ErosionQuality(f32), + DefaultGenOps, +} + +#[cfg(feature = "singleplayer")] +impl WorldChange { + pub fn apply(self, world: &mut crate::singleplayer::SingleplayerWorld) { + let mut def = Default::default(); + let gen_opts = world.gen_opts.as_mut().unwrap_or(&mut def); + match self { + WorldChange::Name(name) => world.name = name, + WorldChange::Seed(seed) => world.seed = seed, + WorldChange::SizeX(s) => gen_opts.x_lg = s, + WorldChange::SizeY(s) => gen_opts.y_lg = s, + WorldChange::Scale(scale) => gen_opts.scale = scale, + WorldChange::MapKind(kind) => gen_opts.map_kind = kind, + WorldChange::ErosionQuality(q) => gen_opts.erosion_quality = q, + WorldChange::DefaultGenOps => world.gen_opts = Some(Default::default()), + } + } +} + +#[cfg(feature = "singleplayer")] +#[derive(Clone)] +pub enum WorldsChange { + SetActive(Option), + Delete(usize), + Regenerate(usize), + AddNew, + CurrentWorldChange(WorldChange), +} + pub enum Event { LoginAttempt { username: String, @@ -87,6 +136,10 @@ pub enum Event { ChangeLanguage(LanguageMetadata), #[cfg(feature = "singleplayer")] StartSingleplayer, + #[cfg(feature = "singleplayer")] + InitSingleplayer, + #[cfg(feature = "singleplayer")] + SinglePlayerChange(WorldsChange), Quit, // Note: Keeping in case we re-add the disclaimer //DisclaimerAccepted, @@ -127,6 +180,26 @@ enum Screen { screen: connecting::Screen, connection_state: ConnectionState, }, + #[cfg(feature = "singleplayer")] + WorldSelector { + screen: world_selector::Screen, + }, +} + +#[derive(PartialEq, Eq)] +enum Showing { + Login, + Languages, +} + +impl Showing { + fn toggle(&mut self, other: Showing) { + if *self == other { + *self = Showing::Login; + } else { + *self = other; + } + } } struct Controls { @@ -147,7 +220,7 @@ struct Controls { selected_server_index: Option, login_info: LoginInfo, - is_selecting_language: bool, + show: Showing, selected_language_index: Option, time: f64, @@ -163,6 +236,14 @@ enum Message { ShowCredits, #[cfg(feature = "singleplayer")] Singleplayer, + #[cfg(feature = "singleplayer")] + SingleplayerPlay, + #[cfg(feature = "singleplayer")] + WorldChanged(WorldsChange), + #[cfg(feature = "singleplayer")] + WorldCancelConfirmation, + #[cfg(feature = "singleplayer")] + WorldConfirmation(world_selector::Confirmation), Multiplayer, UnlockServerField, LanguageChanged(usize), @@ -202,7 +283,7 @@ impl Controls { } } else { */ Screen::Login { - screen: Box::new(login::Screen::new()), + screen: Box::default(), error: None, }; //}; @@ -237,7 +318,7 @@ impl Controls { selected_server_index, login_info, - is_selecting_language: false, + show: Showing::Login, selected_language_index, time: 0.0, @@ -251,6 +332,7 @@ impl Controls { settings: &Settings, key_layout: &Option, dt: f32, + #[cfg(feature = "singleplayer")] worlds: &crate::singleplayer::SingleplayerWorlds, ) -> Element { self.time += dt as f64; @@ -306,7 +388,7 @@ impl Controls { &self.login_info, error.as_deref(), &self.i18n.read(), - self.is_selecting_language, + &self.show, self.selected_language_index, &language_metadatas, button_style, @@ -334,6 +416,14 @@ impl Controls { &settings.controls, key_layout, ), + #[cfg(feature = "singleplayer")] + Screen::WorldSelector { screen } => screen.view( + &self.fonts, + &self.imgs, + worlds, + &self.i18n.read(), + button_style, + ), }; Container::new( @@ -360,7 +450,7 @@ impl Controls { Message::Quit => events.push(Event::Quit), Message::Back => { self.screen = Screen::Login { - screen: Box::new(login::Screen::new()), + screen: Box::default(), error: None, }; }, @@ -380,12 +470,52 @@ impl Controls { }, #[cfg(feature = "singleplayer")] Message::Singleplayer => { + self.screen = Screen::WorldSelector { + screen: world_selector::Screen::default(), + }; + events.push(Event::InitSingleplayer); + }, + #[cfg(feature = "singleplayer")] + Message::SingleplayerPlay => { self.screen = Screen::Connecting { screen: connecting::Screen::new(ui), connection_state: ConnectionState::InProgress, }; events.push(Event::StartSingleplayer); }, + #[cfg(feature = "singleplayer")] + Message::WorldChanged(change) => { + match change { + WorldsChange::Delete(_) | WorldsChange::Regenerate(_) => { + if let Screen::WorldSelector { + screen: world_selector::Screen { confirmation, .. }, + } = &mut self.screen + { + *confirmation = None; + } + }, + _ => {}, + } + events.push(Event::SinglePlayerChange(change)) + }, + #[cfg(feature = "singleplayer")] + Message::WorldCancelConfirmation => { + if let Screen::WorldSelector { + screen: world_selector::Screen { confirmation, .. }, + } = &mut self.screen + { + *confirmation = None; + } + }, + #[cfg(feature = "singleplayer")] + Message::WorldConfirmation(new_confirmation) => { + if let Screen::WorldSelector { + screen: world_selector::Screen { confirmation, .. }, + } = &mut self.screen + { + *confirmation = Some(new_confirmation); + } + }, Message::Multiplayer => { self.screen = Screen::Connecting { screen: connecting::Screen::new(ui), @@ -403,7 +533,7 @@ impl Controls { Message::LanguageChanged(new_value) => { events.push(Event::ChangeLanguage(language_metadatas.remove(new_value))); }, - Message::OpenLanguageMenu => self.is_selecting_language = !self.is_selecting_language, + Message::OpenLanguageMenu => self.show.toggle(Showing::Languages), Message::Password(new_value) => self.login_info.password = new_value, Message::Server(new_value) => { self.login_info.server = new_value; @@ -451,7 +581,7 @@ impl Controls { if let Screen::Disclaimer { .. } = &self.screen { events.push(Event::DisclaimerAccepted); self.screen = Screen::Login { - screen: login::Screen::new(), + screen: login::Screen::default(), error: None, }; } @@ -463,7 +593,7 @@ impl Controls { fn exit_connect_screen(&mut self) { if matches!(&self.screen, Screen::Connecting { .. }) { self.screen = Screen::Login { - screen: Box::new(login::Screen::new()), + screen: Box::default(), error: None, } } @@ -491,7 +621,7 @@ impl Controls { || matches!(&self.screen, Screen::Login { .. }) { self.screen = Screen::Login { - screen: Box::new(login::Screen::new()), + screen: Box::default(), error: Some(error), } } else { @@ -626,11 +756,21 @@ impl MainMenuUi { pub fn maintain(&mut self, global_state: &mut GlobalState, dt: Duration) -> Vec { let mut events = Vec::new(); + #[cfg(feature = "singleplayer")] + let worlds_default = crate::singleplayer::SingleplayerWorlds::default(); + #[cfg(feature = "singleplayer")] + let worlds = global_state + .singleplayer + .as_init() + .unwrap_or(&worlds_default); + let (messages, _) = self.ui.maintain( self.controls.view( &global_state.settings, &global_state.window.key_layout, dt.as_secs_f32(), + #[cfg(feature = "singleplayer")] + worlds, ), global_state.window.renderer_mut(), None, diff --git a/voxygen/src/menu/main/ui/world_selector.rs b/voxygen/src/menu/main/ui/world_selector.rs new file mode 100644 index 0000000000..1bff0c83db --- /dev/null +++ b/voxygen/src/menu/main/ui/world_selector.rs @@ -0,0 +1,630 @@ +use common::resources::MapKind; +use i18n::Localization; +use iced::{ + button, scrollable, slider, text_input, Align, Button, Column, Container, Length, Row, + Scrollable, Slider, Space, Text, TextInput, +}; +use rand::Rng; +use vek::Rgba; + +use crate::{ + menu::main::ui::{WorldsChange, FILL_FRAC_TWO}, + ui::{ + fonts::IcedFonts, + ice::{ + component::neat_button, + style, + widget::{ + compound_graphic::{CompoundGraphic, Graphic}, + BackgroundContainer, Image, Overlay, Padding, + }, + Element, + }, + }, +}; + +use super::{Imgs, Message}; + +const INPUT_TEXT_SIZE: u16 = 20; + +#[derive(Clone)] +pub enum Confirmation { + Regenerate(usize), + Delete(usize), +} + +#[derive(Default)] +pub struct Screen { + back_button: button::State, + play_button: button::State, + new_button: button::State, + yes_button: button::State, + no_button: button::State, + + worlds_buttons: Vec, + + selection_list: scrollable::State, + + world_name: text_input::State, + map_seed: text_input::State, + random_seed_button: button::State, + world_size_x: slider::State, + world_size_y: slider::State, + + map_vertical_scale: slider::State, + shape_buttons: enum_map::EnumMap, + map_erosion_quality: slider::State, + + delete_world: button::State, + regenerate_map: button::State, + generate_map: button::State, + + pub confirmation: Option, +} + +impl Screen { + pub(super) fn view( + &mut self, + fonts: &IcedFonts, + imgs: &Imgs, + worlds: &crate::singleplayer::SingleplayerWorlds, + i18n: &Localization, + button_style: style::button::Style, + ) -> Element { + let input_text_size = fonts.cyri.scale(INPUT_TEXT_SIZE); + + let worlds_count = worlds.worlds.len(); + if self.worlds_buttons.len() != worlds_count { + self.worlds_buttons = vec![Default::default(); worlds_count]; + } + + let title = Text::new(i18n.get_msg("gameinput-map")) + .size(fonts.cyri.scale(35)) + .horizontal_alignment(iced::HorizontalAlignment::Center); + + let mut list = Scrollable::new(&mut self.selection_list) + .spacing(8) + .height(Length::Fill) + .align_items(Align::Start); + + let list_items = self + .worlds_buttons + .iter_mut() + .zip( + worlds + .worlds + .iter() + .enumerate() + .map(|(i, w)| (Some(i), &w.name)), + ) + .map(|(state, (i, map))| { + let color = if i == worlds.current { + (97, 255, 18) + } else { + (97, 97, 25) + }; + let button = Button::new( + state, + Row::with_children(vec![ + Space::new(Length::FillPortion(5), Length::Units(0)).into(), + Text::new(map) + .width(Length::FillPortion(95)) + .size(fonts.cyri.scale(25)) + .vertical_alignment(iced::VerticalAlignment::Center) + .into(), + ]), + ) + .style( + style::button::Style::new(imgs.selection) + .hover_image(imgs.selection_hover) + .press_image(imgs.selection_press) + .image_color(Rgba::new(color.0, color.1, color.2, 192)), + ) + .min_height(56) + .on_press(Message::WorldChanged(super::WorldsChange::SetActive(i))); + Row::with_children(vec![ + Space::new(Length::FillPortion(3), Length::Units(0)).into(), + button.width(Length::FillPortion(92)).into(), + Space::new(Length::FillPortion(5), Length::Units(0)).into(), + ]) + }); + + for item in list_items { + list = list.push(item); + } + + let new_button = Container::new(neat_button( + &mut self.new_button, + i18n.get_msg("main-singleplayer-new"), + FILL_FRAC_TWO, + button_style, + Some(Message::WorldChanged(super::WorldsChange::AddNew)), + )) + .center_x() + .max_width(200); + + let back_button = Container::new(neat_button( + &mut self.back_button, + i18n.get_msg("common-back"), + FILL_FRAC_TWO, + button_style, + Some(Message::Back), + )) + .center_x() + .max_width(200); + + let content = Column::with_children(vec![ + title.into(), + list.into(), + new_button.into(), + back_button.into(), + ]) + .spacing(8) + .width(Length::Fill) + .height(Length::FillPortion(38)) + .align_items(Align::Center) + .padding(iced::Padding { + bottom: 25, + ..iced::Padding::new(0) + }); + + let selection_menu = BackgroundContainer::new( + CompoundGraphic::from_graphics(vec![ + Graphic::image(imgs.banner_top, [138, 17], [0, 0]), + Graphic::rect(Rgba::new(0, 0, 0, 230), [130, 300], [4, 17]), + // TODO: use non image gradient + Graphic::gradient(Rgba::new(0, 0, 0, 230), Rgba::zero(), [130, 50], [4, 182]), + ]) + .fix_aspect_ratio() + .height(Length::Fill) + .width(Length::Fill), + content, + ) + .padding(Padding::new().horizontal(5).top(15)); + let mut items = vec![selection_menu.into()]; + + if let Some(i) = worlds.current { + let world = &worlds.worlds[i]; + let can_edit = !world.is_generated; + let message = |m| Message::WorldChanged(super::WorldsChange::CurrentWorldChange(m)); + + use super::WorldChange; + + const SLIDER_TEXT_SIZE: u16 = 20; + const SLIDER_CURSOR_SIZE: (u16, u16) = (9, 21); + const SLIDER_BAR_HEIGHT: u16 = 9; + const SLIDER_BAR_PAD: u16 = 0; + // Height of interactable area + const SLIDER_HEIGHT: u16 = 30; + + let mut gen_content = vec![ + BackgroundContainer::new( + Image::new(imgs.input_bg) + .width(Length::Units(230)) + .fix_aspect_ratio(), + if can_edit { + Element::from( + TextInput::new( + &mut self.world_name, + &i18n.get_msg("main-singleplayer-world_name"), + &world.name, + move |s| message(WorldChange::Name(s)), + ) + .size(input_text_size), + ) + } else { + Text::new(&world.name) + .size(input_text_size) + .width(Length::Fill) + .height(Length::Shrink) + .into() + }, + ) + .padding(Padding::new().horizontal(7).top(5)) + .into(), + ]; + + let seed = world.seed; + let seed_str = i18n.get_msg("main-singleplayer-seed"); + let mut seed_content = vec![ + Column::with_children(vec![ + Text::new(seed_str.to_string()) + .size(SLIDER_TEXT_SIZE) + .horizontal_alignment(iced::HorizontalAlignment::Center) + .into(), + ]) + .padding(iced::Padding::new(5)) + .into(), + BackgroundContainer::new( + Image::new(imgs.input_bg) + .width(Length::Units(190)) + .fix_aspect_ratio(), + if can_edit { + Element::from( + TextInput::new( + &mut self.map_seed, + &seed_str, + &seed.to_string(), + move |s| { + if let Ok(seed) = if s.is_empty() { + Ok(0) + } else { + s.parse::() + } { + message(WorldChange::Seed(seed)) + } else { + message(WorldChange::Seed(seed)) + } + }, + ) + .size(input_text_size), + ) + } else { + Text::new(world.seed.to_string()) + .size(input_text_size) + .width(Length::Fill) + .height(Length::Shrink) + .into() + }, + ) + .padding(Padding::new().horizontal(7).top(5)) + .into(), + ]; + + if can_edit { + seed_content.push( + Container::new(neat_button( + &mut self.random_seed_button, + i18n.get_msg("main-singleplayer-random_seed"), + FILL_FRAC_TWO, + button_style, + Some(message(WorldChange::Seed(rand::thread_rng().gen()))), + )) + .max_width(200) + .into(), + ) + } + + gen_content.push(Row::with_children(seed_content).into()); + + if let Some(gen_opts) = world.gen_opts.as_ref() { + gen_content.push( + Text::new(format!( + "{}: x: {}, y: {}", + i18n.get_msg("main-singleplayer-size_lg"), + gen_opts.x_lg, + gen_opts.y_lg + )) + .size(SLIDER_TEXT_SIZE) + .horizontal_alignment(iced::HorizontalAlignment::Center) + .into(), + ); + + if can_edit { + gen_content.push( + Row::with_children(vec![ + Slider::new(&mut self.world_size_x, 4..=13, gen_opts.x_lg, move |s| { + message(WorldChange::SizeX(s)) + }) + .height(SLIDER_HEIGHT) + .style(style::slider::Style::images( + imgs.slider_indicator, + imgs.slider_range, + SLIDER_BAR_PAD, + SLIDER_CURSOR_SIZE, + SLIDER_BAR_HEIGHT, + )) + .into(), + Slider::new(&mut self.world_size_y, 4..=13, gen_opts.y_lg, move |s| { + message(WorldChange::SizeY(s)) + }) + .height(SLIDER_HEIGHT) + .style(style::slider::Style::images( + imgs.slider_indicator, + imgs.slider_range, + SLIDER_BAR_PAD, + SLIDER_CURSOR_SIZE, + SLIDER_BAR_HEIGHT, + )) + .into(), + ]) + .into(), + ); + let height = Length::Units(56); + if gen_opts.x_lg + gen_opts.y_lg >= 19 { + gen_content.push( + Text::new(i18n.get_msg("main-singleplayer-map_large_warning")) + .size(SLIDER_TEXT_SIZE) + .height(height) + .color([0.914, 0.835, 0.008]) + .horizontal_alignment(iced::HorizontalAlignment::Center) + .into(), + ); + } else { + gen_content.push(Space::new(Length::Units(0), height).into()); + } + } + + gen_content.push( + Text::new(format!( + "{}: {}", + i18n.get_msg("main-singleplayer-map_scale"), + gen_opts.scale + )) + .size(SLIDER_TEXT_SIZE) + .horizontal_alignment(iced::HorizontalAlignment::Center) + .into(), + ); + + if can_edit { + gen_content.push( + Slider::new( + &mut self.map_vertical_scale, + 0.0..=160.0, + gen_opts.scale * 10.0, + move |s| message(WorldChange::Scale(s / 10.0)), + ) + .height(SLIDER_HEIGHT) + .style(style::slider::Style::images( + imgs.slider_indicator, + imgs.slider_range, + SLIDER_BAR_PAD, + SLIDER_CURSOR_SIZE, + SLIDER_BAR_HEIGHT, + )) + .into(), + ); + } + + if can_edit { + gen_content.extend([ + Text::new(i18n.get_msg("main-singleplayer-map_shape")) + .size(SLIDER_TEXT_SIZE) + .horizontal_alignment(iced::HorizontalAlignment::Center) + .into(), + Row::with_children( + self.shape_buttons + .iter_mut() + .map(|(shape, state)| { + let color = if gen_opts.map_kind == shape { + (97, 255, 18) + } else { + (97, 97, 25) + }; + Button::new( + state, + Row::with_children(vec![ + Space::new(Length::FillPortion(5), Length::Units(0)) + .into(), + Text::new(shape.to_string()) + .width(Length::FillPortion(95)) + .size(fonts.cyri.scale(14)) + .vertical_alignment(iced::VerticalAlignment::Center) + .into(), + ]) + .align_items(Align::Center), + ) + .style( + style::button::Style::new(imgs.selection) + .hover_image(imgs.selection_hover) + .press_image(imgs.selection_press) + .image_color(Rgba::new(color.0, color.1, color.2, 192)), + ) + .width(Length::FillPortion(1)) + .min_height(18) + .on_press(Message::WorldChanged( + super::WorldsChange::CurrentWorldChange( + WorldChange::MapKind(shape), + ), + )) + .into() + }) + .collect(), + ) + .into(), + ]); + } else { + gen_content.push( + Text::new(format!( + "{}: {}", + i18n.get_msg("main-singleplayer-map_shape"), + gen_opts.map_kind, + )) + .size(SLIDER_TEXT_SIZE) + .horizontal_alignment(iced::HorizontalAlignment::Center) + .into(), + ); + } + + gen_content.push( + Text::new(format!( + "{}: {}", + i18n.get_msg("main-singleplayer-map_erosion_quality"), + gen_opts.erosion_quality + )) + .size(SLIDER_TEXT_SIZE) + .horizontal_alignment(iced::HorizontalAlignment::Center) + .into(), + ); + + if can_edit { + gen_content.push( + Slider::new( + &mut self.map_erosion_quality, + 0.0..=20.0, + gen_opts.erosion_quality * 10.0, + move |s| message(WorldChange::ErosionQuality(s / 10.0)), + ) + .height(SLIDER_HEIGHT) + .style(style::slider::Style::images( + imgs.slider_indicator, + imgs.slider_range, + SLIDER_BAR_PAD, + SLIDER_CURSOR_SIZE, + SLIDER_BAR_HEIGHT, + )) + .into(), + ); + } + } + + let mut world_buttons = vec![]; + + if world.gen_opts.is_none() && can_edit { + let create_custom = Container::new(neat_button( + &mut self.regenerate_map, + i18n.get_msg("main-singleplayer-create_custom"), + FILL_FRAC_TWO, + button_style, + Some(Message::WorldChanged( + super::WorldsChange::CurrentWorldChange(WorldChange::DefaultGenOps), + )), + )) + .center_x() + .width(Length::FillPortion(1)) + .max_width(200); + world_buttons.push(create_custom.into()); + } + + if world.is_generated { + let regenerate = Container::new(neat_button( + &mut self.generate_map, + i18n.get_msg("main-singleplayer-regenerate"), + FILL_FRAC_TWO, + button_style, + Some(Message::WorldConfirmation(Confirmation::Regenerate(i))), + )) + .center_x() + .width(Length::FillPortion(1)) + .max_width(200); + world_buttons.push(regenerate.into()) + } + let delete = Container::new(neat_button( + &mut self.delete_world, + i18n.get_msg("main-singleplayer-delete"), + FILL_FRAC_TWO, + button_style, + Some(Message::WorldConfirmation(Confirmation::Delete(i))), + )) + .center_x() + .width(Length::FillPortion(1)) + .max_width(200); + + world_buttons.push(delete.into()); + + gen_content.push(Row::with_children(world_buttons).into()); + + let play_button = Container::new(neat_button( + &mut self.play_button, + i18n.get_msg(if world.is_generated || world.gen_opts.is_none() { + "main-singleplayer-play" + } else { + "main-singleplayer-generate_and_play" + }), + FILL_FRAC_TWO, + button_style, + Some(Message::SingleplayerPlay), + )) + .center_x() + .max_width(200); + + gen_content.push(play_button.into()); + + let gen_opts = Column::with_children(gen_content).align_items(Align::Center); + + let opts_menu = BackgroundContainer::new( + CompoundGraphic::from_graphics(vec![ + Graphic::image(imgs.banner_top, [138, 17], [0, 0]), + Graphic::rect(Rgba::new(0, 0, 0, 230), [130, 300], [4, 17]), + // TODO: use non image gradient + Graphic::gradient(Rgba::new(0, 0, 0, 230), Rgba::zero(), [130, 50], [4, 182]), + ]) + .fix_aspect_ratio() + .height(Length::Fill) + .width(Length::Fill), + gen_opts, + ) + .padding(Padding::new().horizontal(5).top(15)); + + items.push(opts_menu.into()); + } + + let all = Row::with_children(items) + .height(Length::Fill) + .width(Length::Fill); + + if let Some(confirmation) = self.confirmation.as_ref() { + const FILL_FRAC_ONE: f32 = 0.77; + + let (text, yes_msg, index) = match confirmation { + Confirmation::Regenerate(i) => ( + "menu-singleplayer-confirm_regenerate", + Message::WorldChanged(WorldsChange::Regenerate(*i)), + i, + ), + Confirmation::Delete(i) => ( + "menu-singleplayer-confirm_delete", + Message::WorldChanged(WorldsChange::Delete(*i)), + i, + ), + }; + + if let Some(name) = worlds.worlds.get(*index).map(|world| &world.name) { + let over_content = Column::with_children(vec![ + Text::new(i18n.get_msg_ctx(text, &i18n::fluent_args! { "world_name" => name })) + .size(fonts.cyri.scale(24)) + .into(), + Row::with_children(vec![ + neat_button( + &mut self.no_button, + i18n.get_msg("common-no").into_owned(), + FILL_FRAC_ONE, + button_style, + Some(Message::WorldCancelConfirmation), + ), + neat_button( + &mut self.yes_button, + i18n.get_msg("common-yes").into_owned(), + FILL_FRAC_ONE, + button_style, + Some(yes_msg), + ), + ]) + .height(Length::Units(28)) + .spacing(30) + .into(), + ]) + .align_items(Align::Center) + .spacing(10); + + let over = Container::new(over_content) + .style( + style::container::Style::color_with_double_cornerless_border( + (0, 0, 0, 200).into(), + (3, 4, 4, 255).into(), + (28, 28, 22, 255).into(), + ), + ) + .width(Length::Shrink) + .height(Length::Shrink) + .max_width(400) + .max_height(500) + .padding(24) + .center_x() + .center_y(); + + Overlay::new(over, all) + .width(Length::Fill) + .height(Length::Fill) + .center_x() + .center_y() + .into() + } else { + self.confirmation = None; + all.into() + } + } else { + all.into() + } + } +} diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs index 990ad2a2b1..3f8dac82fd 100644 --- a/voxygen/src/session/mod.rs +++ b/voxygen/src/session/mod.rs @@ -500,7 +500,7 @@ impl PlayState for SessionState { { // Update the Discord activity on client initialization #[cfg(feature = "singleplayer")] - let singleplayer = global_state.singleplayer.is_some(); + let singleplayer = global_state.singleplayer.is_running(); #[cfg(not(feature = "singleplayer"))] let singleplayer = false; diff --git a/voxygen/src/session/settings_change.rs b/voxygen/src/session/settings_change.rs index c80c0eb2cf..0ecf0c464d 100644 --- a/voxygen/src/session/settings_change.rs +++ b/voxygen/src/session/settings_change.rs @@ -742,7 +742,7 @@ impl SettingsChange { global_state.discord = Discord::start(&global_state.tokio_runtime); #[cfg(feature = "singleplayer")] - let singleplayer = global_state.singleplayer.is_some(); + let singleplayer = global_state.singleplayer.is_running(); #[cfg(not(feature = "singleplayer"))] let singleplayer = false; diff --git a/voxygen/src/singleplayer.rs b/voxygen/src/singleplayer.rs deleted file mode 100644 index c1937ffdf3..0000000000 --- a/voxygen/src/singleplayer.rs +++ /dev/null @@ -1,162 +0,0 @@ -use common::clock::Clock; -use crossbeam_channel::{bounded, unbounded, Receiver, Sender, TryRecvError}; -use server::{ - persistence::{DatabaseSettings, SqlLogMode}, - Error as ServerError, Event, Input, Server, -}; -use std::{ - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - thread::{self, JoinHandle}, - time::Duration, -}; -use tokio::runtime::Runtime; -use tracing::{info, trace, warn}; - -const TPS: u64 = 30; - -/// Used to start and stop the background thread running the server -/// when in singleplayer mode. -pub struct Singleplayer { - _server_thread: JoinHandle<()>, - stop_server_s: Sender<()>, - pub receiver: Receiver>, - // Wether the server is stopped or not - paused: Arc, - // Settings that the server was started with - settings: server::Settings, -} - -impl Singleplayer { - pub fn new(runtime: &Arc) -> Self { - let (stop_server_s, stop_server_r) = unbounded(); - - // Determine folder to save server data in - let server_data_dir = { - let mut path = common_base::userdata_dir_workspace!(); - path.push("singleplayer"); - path - }; - - // Create server - let settings = server::Settings::singleplayer(&server_data_dir); - let editable_settings = server::EditableSettings::singleplayer(&server_data_dir); - - let settings2 = settings.clone(); - - // Relative to data_dir - const PERSISTENCE_DB_DIR: &str = "saves"; - - let database_settings = DatabaseSettings { - db_dir: server_data_dir.join(PERSISTENCE_DB_DIR), - sql_log_mode: SqlLogMode::Disabled, /* Voxygen doesn't take in command-line arguments - * so SQL logging can't be enabled for - * singleplayer without changing this line - * manually */ - }; - - let paused = Arc::new(AtomicBool::new(false)); - let paused1 = Arc::clone(&paused); - - let (result_sender, result_receiver) = bounded(1); - - let builder = thread::Builder::new().name("singleplayer-server-thread".into()); - let runtime = Arc::clone(runtime); - let thread = builder - .spawn(move || { - trace!("starting singleplayer server thread"); - - let (server, init_result) = match Server::new( - settings2, - editable_settings, - database_settings, - &server_data_dir, - runtime, - ) { - Ok(server) => (Some(server), Ok(())), - Err(err) => (None, Err(err)), - }; - - match (result_sender.send(init_result), server) { - (Err(e), _) => warn!( - ?e, - "Failed to send singleplayer server initialization result. Most likely \ - the channel was closed by cancelling server creation. Stopping Server" - ), - (Ok(()), None) => (), - (Ok(()), Some(server)) => run_server(server, stop_server_r, paused1), - } - - trace!("ending singleplayer server thread"); - }) - .unwrap(); - - Singleplayer { - _server_thread: thread, - stop_server_s, - receiver: result_receiver, - paused, - settings, - } - } - - /// Returns reference to the settings the server was started with - pub fn settings(&self) -> &server::Settings { &self.settings } - - /// Returns wether or not the server is paused - pub fn is_paused(&self) -> bool { self.paused.load(Ordering::SeqCst) } - - /// Pauses if true is passed and unpauses if false (Does nothing if in that - /// state already) - pub fn pause(&self, state: bool) { self.paused.store(state, Ordering::SeqCst); } -} - -impl Drop for Singleplayer { - fn drop(&mut self) { - // Ignore the result - let _ = self.stop_server_s.send(()); - } -} - -fn run_server(mut server: Server, stop_server_r: Receiver<()>, paused: Arc) { - info!("Starting server-cli..."); - - // Set up an fps clock - let mut clock = Clock::new(Duration::from_secs_f64(1.0 / TPS as f64)); - - loop { - // Check any event such as stopping and pausing - match stop_server_r.try_recv() { - Ok(()) => break, - Err(TryRecvError::Disconnected) => break, - Err(TryRecvError::Empty) => (), - } - - // Wait for the next tick. - clock.tick(); - - // Skip updating the server if it's paused - if paused.load(Ordering::SeqCst) && server.number_of_players() < 2 { - continue; - } else if server.number_of_players() > 1 { - paused.store(false, Ordering::SeqCst); - } - - let events = server - .tick(Input::default(), clock.dt()) - .expect("Failed to tick server!"); - - for event in events { - match event { - Event::ClientConnected { .. } => info!("Client connected!"), - Event::ClientDisconnected { .. } => info!("Client disconnected!"), - Event::Chat { entity: _, msg } => info!("[Client] {}", msg), - } - } - - // Clean up the server after a tick. - server.cleanup(); - } -} diff --git a/voxygen/src/singleplayer/mod.rs b/voxygen/src/singleplayer/mod.rs new file mode 100644 index 0000000000..95f67401b7 --- /dev/null +++ b/voxygen/src/singleplayer/mod.rs @@ -0,0 +1,210 @@ +use common::clock::Clock; +use crossbeam_channel::{bounded, unbounded, Receiver, Sender, TryRecvError}; +use server::{ + persistence::{DatabaseSettings, SqlLogMode}, + Error as ServerError, Event, Input, Server, +}; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::{self, JoinHandle}, + time::Duration, +}; +use tokio::runtime::Runtime; +use tracing::{error, info, trace, warn}; + +mod singleplayer_world; +pub use singleplayer_world::{SingleplayerWorld, SingleplayerWorlds}; + +const TPS: u64 = 30; + +/// Used to start and stop the background thread running the server +/// when in singleplayer mode. +pub struct Singleplayer { + _server_thread: JoinHandle<()>, + stop_server_s: Sender<()>, + pub receiver: Receiver>, + // Wether the server is stopped or not + paused: Arc, +} + +impl Singleplayer { + /// Returns wether or not the server is paused + pub fn is_paused(&self) -> bool { self.paused.load(Ordering::SeqCst) } + + /// Pauses if true is passed and unpauses if false (Does nothing if in that + /// state already) + pub fn pause(&self, state: bool) { self.paused.store(state, Ordering::SeqCst); } +} + +impl Drop for Singleplayer { + fn drop(&mut self) { + // Ignore the result + let _ = self.stop_server_s.send(()); + } +} + +#[derive(Default)] +pub enum SingleplayerState { + #[default] + None, + Init(SingleplayerWorlds), + Running(Singleplayer), +} + +impl SingleplayerState { + pub fn init() -> Self { + let dir = common_base::userdata_dir_workspace!(); + + Self::Init(SingleplayerWorlds::load(&dir)) + } + + pub fn run(&mut self, runtime: &Arc) { + if let Self::Init(worlds) = self { + let Some(world) = worlds.current() else { + error!("Failed to get the current world."); + return; + }; + let server_data_dir = world.path.clone(); + + let mut settings = server::Settings::singleplayer(&server_data_dir); + let editable_settings = server::EditableSettings::singleplayer(&server_data_dir); + + let file_opts = if let Some(gen_opts) = &world.gen_opts && !world.is_generated { + server::FileOpts::Save( + world.map_path.clone(), + gen_opts.clone(), + ) + } else { + if !world.is_generated && world.gen_opts.is_none() { + world.copy_default_world(); + } + server::FileOpts::Load(world.map_path.clone()) + }; + + settings.map_file = Some(file_opts); + settings.world_seed = world.seed; + + let (stop_server_s, stop_server_r) = unbounded(); + + // Create server + + // Relative to data_dir + const PERSISTENCE_DB_DIR: &str = "saves"; + + let database_settings = DatabaseSettings { + db_dir: server_data_dir.join(PERSISTENCE_DB_DIR), + sql_log_mode: SqlLogMode::Disabled, /* Voxygen doesn't take in command-line + * arguments + * so SQL logging can't be enabled for + * singleplayer without changing this line + * manually */ + }; + + let paused = Arc::new(AtomicBool::new(false)); + let paused1 = Arc::clone(&paused); + + let (result_sender, result_receiver) = bounded(1); + + let builder = thread::Builder::new().name("singleplayer-server-thread".into()); + let runtime = Arc::clone(runtime); + let thread = builder + .spawn(move || { + trace!("starting singleplayer server thread"); + + let (server, init_result) = match Server::new( + settings, + editable_settings, + database_settings, + &server_data_dir, + runtime, + ) { + Ok(server) => (Some(server), Ok(())), + Err(err) => (None, Err(err)), + }; + + match (result_sender.send(init_result), server) { + (Err(e), _) => warn!( + ?e, + "Failed to send singleplayer server initialization result. Most \ + likely the channel was closed by cancelling server creation. \ + Stopping Server" + ), + (Ok(()), None) => (), + (Ok(()), Some(server)) => run_server(server, stop_server_r, paused1), + } + + trace!("ending singleplayer server thread"); + }) + .unwrap(); + + *self = SingleplayerState::Running(Singleplayer { + _server_thread: thread, + stop_server_s, + receiver: result_receiver, + paused, + }); + } else { + error!("SingleplayerState::run was called, but singleplayer is already running!"); + } + } + + pub fn as_running(&self) -> Option<&Singleplayer> { + match self { + SingleplayerState::Running(s) => Some(s), + _ => None, + } + } + + pub fn as_init(&self) -> Option<&SingleplayerWorlds> { + match self { + SingleplayerState::Init(s) => Some(s), + _ => None, + } + } + + pub fn is_running(&self) -> bool { matches!(self, SingleplayerState::Running(_)) } +} + +fn run_server(mut server: Server, stop_server_r: Receiver<()>, paused: Arc) { + info!("Starting server-cli..."); + + // Set up an fps clock + let mut clock = Clock::new(Duration::from_secs_f64(1.0 / TPS as f64)); + + loop { + // Check any event such as stopping and pausing + match stop_server_r.try_recv() { + Ok(()) => break, + Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => (), + } + + // Wait for the next tick. + clock.tick(); + + // Skip updating the server if it's paused + if paused.load(Ordering::SeqCst) && server.number_of_players() < 2 { + continue; + } else if server.number_of_players() > 1 { + paused.store(false, Ordering::SeqCst); + } + + let events = server + .tick(Input::default(), clock.dt()) + .expect("Failed to tick server!"); + + for event in events { + match event { + Event::ClientConnected { .. } => info!("Client connected!"), + Event::ClientDisconnected { .. } => info!("Client disconnected!"), + Event::Chat { entity: _, msg } => info!("[Client] {}", msg), + } + } + + // Clean up the server after a tick. + server.cleanup(); + } +} diff --git a/voxygen/src/singleplayer/singleplayer_world.rs b/voxygen/src/singleplayer/singleplayer_world.rs new file mode 100644 index 0000000000..9a9d885b64 --- /dev/null +++ b/voxygen/src/singleplayer/singleplayer_world.rs @@ -0,0 +1,341 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use common::assets::ASSETS_PATH; +use serde::{Deserialize, Serialize}; +use server::{FileOpts, GenOpts, DEFAULT_WORLD_MAP}; +use tracing::error; + +#[derive(Clone, Deserialize, Serialize)] +struct World0 { + name: String, + gen_opts: Option, + seed: u32, +} + +pub struct SingleplayerWorld { + pub name: String, + pub gen_opts: Option, + pub seed: u32, + pub is_generated: bool, + pub path: PathBuf, + pub map_path: PathBuf, +} + +impl SingleplayerWorld { + pub fn copy_default_world(&self) { + if let Err(e) = fs::copy(asset_path(DEFAULT_WORLD_MAP), &self.map_path) { + println!("Error when trying to copy default world: {e}"); + } + } +} + +fn load_map(path: &Path) -> Option { + let meta_path = path.join("meta.ron"); + + let Ok(f) = fs::File::open(&meta_path) else { + error!("Failed to open {}", meta_path.to_string_lossy()); + return None; + }; + + version::try_load(&f, path) +} + +fn write_world_meta(world: &SingleplayerWorld) { + let path = &world.path; + + if let Err(e) = fs::create_dir_all(path) { + error!("Failed to create world folder: {e}"); + } + + match fs::File::create(path.join("meta.ron")) { + Ok(file) => { + if let Err(e) = ron::ser::to_writer_pretty( + file, + &version::Current::from_world(world), + ron::ser::PrettyConfig::new(), + ) { + error!("Failed to create world meta file: {e}") + } + }, + Err(e) => error!("Failed to create world meta file: {e}"), + } +} + +fn asset_path(asset: &str) -> PathBuf { + let mut s = asset.replace('.', "/"); + s.push_str(".bin"); + ASSETS_PATH.join(s) +} + +fn migrate_old_singleplayer(from: &Path, to: &Path) { + if fs::metadata(from).map_or(false, |meta| meta.is_dir()) { + if let Err(e) = fs::rename(from, to) { + error!("Failed to migrate singleplayer: {e}"); + return; + } + + let mut seed = 0; + let (map_file, gen_opts) = fs::read_to_string(to.join("server_config/settings.ron")) + .ok() + .and_then(|settings| { + let settings: server::Settings = ron::from_str(&settings).ok()?; + seed = settings.world_seed; + Some(match settings.map_file? { + FileOpts::LoadOrGenerate { name, opts, .. } => { + (Some(PathBuf::from(name)), Some(opts)) + }, + FileOpts::Generate(opts) => (None, Some(opts)), + FileOpts::LoadLegacy(_) => return None, + FileOpts::Load(path) => (Some(path), None), + FileOpts::LoadAsset(asset) => (Some(asset_path(&asset)), None), + FileOpts::Save(_, gen_opts) => (None, Some(gen_opts)), + }) + }) + .unwrap_or((Some(asset_path(DEFAULT_WORLD_MAP)), None)); + + let map_path = to.join("map.bin"); + if let Some(map_file) = map_file { + if let Err(err) = fs::copy(map_file, &map_path) { + error!("Failed to copy map file to singleplayer world: {err}"); + } + } + + write_world_meta(&SingleplayerWorld { + name: "singleplayer world".to_string(), + gen_opts, + seed, + path: to.to_path_buf(), + // Isn't persisted so doesn't matter what it's set to. + is_generated: false, + map_path, + }); + } +} + +fn load_worlds(path: &Path) -> Vec { + let Ok(paths) = fs::read_dir(path) else { + let _ = fs::create_dir_all(path); + return Vec::new(); + }; + + paths + .filter_map(|entry| { + let entry = entry.ok()?; + if entry.file_type().ok()?.is_dir() { + let path = entry.path(); + load_map(&path) + } else { + None + } + }) + .collect() +} + +#[derive(Default)] +pub struct SingleplayerWorlds { + pub worlds: Vec, + pub current: Option, + worlds_folder: PathBuf, +} + +impl SingleplayerWorlds { + pub fn load(userdata_folder: &Path) -> SingleplayerWorlds { + let worlds_folder = userdata_folder.join("singleplayer_worlds"); + + if let Err(e) = fs::create_dir_all(&worlds_folder) { + error!("Failed to create singleplayer worlds folder: {e}"); + } + + migrate_old_singleplayer( + &userdata_folder.join("singleplayer"), + &worlds_folder.join("singleplayer"), + ); + + let worlds = load_worlds(&worlds_folder); + + SingleplayerWorlds { + worlds, + current: None, + worlds_folder, + } + } + + pub fn delete_map_file(&mut self, map: usize) { + let w = &mut self.worlds[map]; + if w.is_generated { + // We don't care about the result here since we aren't sure the file exists. + let _ = fs::remove_file(&w.map_path); + } + w.is_generated = false; + } + + pub fn remove(&mut self, idx: usize) { + if let Some(ref mut i) = self.current { + match (*i).cmp(&idx) { + std::cmp::Ordering::Less => {}, + std::cmp::Ordering::Equal => self.current = None, + std::cmp::Ordering::Greater => *i -= 1, + } + } + let _ = fs::remove_dir_all(&self.worlds[idx].path); + self.worlds.remove(idx); + } + + fn world_folder_name(&self) -> String { + use chrono::{Datelike, Timelike}; + let now = chrono::Local::now().naive_local(); + let name = format!( + "world-{}-{}-{}-{}_{}_{}_{}", + now.year(), + now.month(), + now.day(), + now.hour(), + now.minute(), + now.second(), + now.timestamp_subsec_millis() + ); + + let mut test_name = name.clone(); + let mut i = 0; + 'fail: loop { + for world in self.worlds.iter() { + if world.path.ends_with(&test_name) { + test_name = name.clone(); + test_name.push('_'); + test_name.push_str(&i.to_string()); + i += 1; + continue 'fail; + } + } + break; + } + test_name + } + + pub fn current(&self) -> Option<&SingleplayerWorld> { + self.current.and_then(|i| self.worlds.get(i)) + } + + pub fn new_world(&mut self) { + let folder_name = self.world_folder_name(); + let path = self.worlds_folder.join(folder_name); + + let new_world = SingleplayerWorld { + name: "New World".to_string(), + gen_opts: None, + seed: 0, + is_generated: false, + map_path: path.join("map.bin"), + path, + }; + + write_world_meta(&new_world); + + self.worlds.push(new_world) + } + + pub fn save_current_meta(&self) { + if let Some(world) = self.current() { + write_world_meta(world); + } + } +} + +mod version { + use std::any::{type_name, Any}; + + use serde::de::DeserializeOwned; + + use super::*; + + pub type Current = V1; + + type LoadWorldFn = + fn(R, &Path) -> Result; + fn loaders<'a, R: std::io::Read + Clone>() -> &'a [LoadWorldFn] { + // Step [4] + &[load_raw::] + } + + #[derive(Deserialize, Serialize)] + pub struct V1 { + #[serde(deserialize_with = "version::<_, 1>")] + version: u64, + name: String, + gen_opts: Option, + seed: u32, + } + + impl V1 { + /// This function is only needed for the current version + pub fn from_world(world: &SingleplayerWorld) -> Self { + V1 { + version: 1, + name: world.name.clone(), + gen_opts: world.gen_opts.clone(), + seed: world.seed, + } + } + } + + impl ToWorld for V1 { + fn to_world(self, path: PathBuf) -> SingleplayerWorld { + let map_path = path.join("map.bin"); + let is_generated = fs::metadata(&map_path).is_ok_and(|f| f.is_file()); + + SingleplayerWorld { + name: self.name, + gen_opts: self.gen_opts, + seed: self.seed, + is_generated, + path, + map_path, + } + } + } + + // Utilities + fn version<'de, D: serde::Deserializer<'de>, const V: u64>(de: D) -> Result { + u64::deserialize(de).and_then(|x| { + if x == V { + Ok(x) + } else { + Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Unsigned(x), + &"incorrect magic/version bytes", + )) + } + }) + } + + trait ToWorld { + fn to_world(self, path: PathBuf) -> SingleplayerWorld; + } + + fn load_raw( + reader: R, + path: &Path, + ) -> Result { + ron::de::from_reader::<_, RawWorld>(reader) + .map(|s| s.to_world(path.to_path_buf())) + .map_err(|e| (type_name::(), e)) + } + + pub fn try_load(reader: R, path: &Path) -> Option { + loaders() + .iter() + .find_map(|load_raw| match load_raw(reader.clone(), path) { + Ok(chunk) => Some(chunk), + Err((raw_name, e)) => { + error!( + "Attempt to load chunk with raw format `{}` failed: {:?}", + raw_name, e + ); + None + }, + }) + } +} diff --git a/voxygen/src/ui/img_ids.rs b/voxygen/src/ui/img_ids.rs index 24c9f9d2fe..4c5852aad5 100644 --- a/voxygen/src/ui/img_ids.rs +++ b/voxygen/src/ui/img_ids.rs @@ -166,17 +166,17 @@ macro_rules! image_ids { } #[macro_export] macro_rules! image_ids_ice { - ($($v:vis struct $Ids:ident { $( <$T:ty> $( $name:ident: $specifier:expr ),* $(,)? )* })*) => { + ($($v:vis struct $Ids:ident { $( <$T:ty> $( $(#[$($attribute:tt)*])? $name:ident: $specifier:expr ),* $(,)? )* })*) => { $( $v struct $Ids { - $($( $v $name: $crate::ui::GraphicId, )*)* + $($( $(#[$($attribute)*])? $v $name: $crate::ui::GraphicId, )*)* } impl $Ids { pub fn load(ui: &mut $crate::ui::ice::IcedUi) -> Result { use $crate::ui::img_ids::GraphicCreator; Ok(Self { - $($( $name: ui.add_graphic(<$T as GraphicCreator>::new_graphic($specifier)?), )*)* + $($( $(#[$($attribute)*])? $name: ui.add_graphic(<$T as GraphicCreator>::new_graphic($specifier)?), )*)* }) } } diff --git a/world/src/sim/erosion.rs b/world/src/sim/erosion.rs index 31d5d8adfa..c2ec8fe09b 100644 --- a/world/src/sim/erosion.rs +++ b/world/src/sim/erosion.rs @@ -7,7 +7,7 @@ use common::{ }, vol::RectVolSize, }; -use tracing::{debug, error, warn}; +use tracing::{debug, error, info, warn}; // use faster::*; use itertools::izip; use noise::NoiseFn; @@ -2640,6 +2640,13 @@ pub fn do_erosion( (0..n_steps).for_each(|i| { debug!("Erosion iteration #{:?}", i); + + // Print out the percentage complete. Do this at most 20 times. + if i % std::cmp::max(n_steps / 20, 1) == 0 { + let pct = (i as f64 / n_steps as f64) * 100.0; + info!("{:.2}% complete", pct); + } + erode( map_size_lg, &mut h, diff --git a/world/src/sim/mod.rs b/world/src/sim/mod.rs index 3846504821..22d5d6397f 100644 --- a/world/src/sim/mod.rs +++ b/world/src/sim/mod.rs @@ -43,6 +43,7 @@ use common::{ calendar::Calendar, grid::Grid, lottery::Lottery, + resources::MapKind, spiral::Spiral2d, store::{Id, Store}, terrain::{ @@ -71,7 +72,7 @@ use std::{ sync::Arc, }; use strum::IntoEnumIterator; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; use vek::*; /// Default base two logarithm of the world size, in chunks, per dimension. @@ -138,22 +139,22 @@ pub(crate) struct GenCtx { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] -pub struct SizeOpts { - x_lg: u32, - y_lg: u32, - scale: f64, +pub struct GenOpts { + pub x_lg: u32, + pub y_lg: u32, + pub scale: f64, + pub map_kind: MapKind, + pub erosion_quality: f32, } -impl SizeOpts { - pub fn new(x_lg: u32, y_lg: u32, scale: f64) -> Self { Self { x_lg, y_lg, scale } } -} - -impl Default for SizeOpts { +impl Default for GenOpts { fn default() -> Self { Self { x_lg: 10, y_lg: 10, scale: 2.0, + map_kind: MapKind::Square, + erosion_quality: 1.0, } } } @@ -162,17 +163,17 @@ impl Default for SizeOpts { pub enum FileOpts { /// If set, generate the world map and do not try to save to or load from /// file (default). - Generate(SizeOpts), + Generate(GenOpts), /// If set, generate the world map and save the world file (path is created /// the same way screenshot paths are). - Save(SizeOpts), + Save(PathBuf, GenOpts), /// Combination of Save and Load. /// Load map if exists or generate the world map and save the /// world file. LoadOrGenerate { name: String, #[serde(default)] - opts: SizeOpts, + opts: GenOpts, #[serde(default)] overwrite: bool, }, @@ -192,13 +193,15 @@ pub enum FileOpts { } impl Default for FileOpts { - fn default() -> Self { Self::Generate(SizeOpts::default()) } + fn default() -> Self { Self::Generate(GenOpts::default()) } } impl FileOpts { - fn load_content(&self) -> (Option, MapSizeLg, f64) { + fn load_content(&self) -> (Option, MapSizeLg, GenOpts) { let parsed_world_file = self.try_load_map(); + let mut gen_opts = self.gen_opts().unwrap_or_default(); + let map_size_lg = if let Some(map) = &parsed_world_file { MapSizeLg::new(map.map_size_lg) .expect("World size of loaded map does not satisfy invariants.") @@ -214,21 +217,26 @@ impl FileOpts { // // FIXME: This is a hack! At some point we will have a more principled way of // dealing with this. - let default_continent_scale_hack = 2.0/*4.0*/; - let continent_scale_hack = if let Some(map) = &parsed_world_file { - map.continent_scale_hack - } else { - self.continent_scale_hack() - .unwrap_or(default_continent_scale_hack) + if let Some(map) = &parsed_world_file { + gen_opts.scale = map.continent_scale_hack; }; - (parsed_world_file, map_size_lg, continent_scale_hack) + (parsed_world_file, map_size_lg, gen_opts) + } + + fn gen_opts(&self) -> Option { + match self { + Self::Generate(opts) | Self::Save(_, opts) | Self::LoadOrGenerate { opts, .. } => { + Some(opts.clone()) + }, + _ => None, + } } // TODO: this should return Option so that caller can choose fallback fn map_size(&self) -> MapSizeLg { match self { - Self::Generate(opts) | Self::Save(opts) | Self::LoadOrGenerate { opts, .. } => { + Self::Generate(opts) | Self::Save(_, opts) | Self::LoadOrGenerate { opts, .. } => { MapSizeLg::new(Vec2 { x: opts.x_lg, y: opts.y_lg, @@ -242,15 +250,6 @@ impl FileOpts { } } - fn continent_scale_hack(&self) -> Option { - match self { - Self::Generate(opts) | Self::Save(opts) | Self::LoadOrGenerate { opts, .. } => { - Some(opts.scale) - }, - _ => None, - } - } - // TODO: This should probably return a Result, so that caller can choose // whether to log error fn try_load_map(&self) -> Option { @@ -357,7 +356,9 @@ impl FileOpts { // NOTE: we intentionally use pattern-matching here to get // options, so that when gen opts get another field, compiler // will force you to update following logic - let SizeOpts { x_lg, y_lg, scale } = opts; + let GenOpts { + x_lg, y_lg, scale, .. + } = opts; let map = match map { WorldFile::Veloren0_7_0(map) => map, WorldFile::Veloren0_5_0(_) => { @@ -403,25 +404,16 @@ impl FileOpts { } fn map_path(&self) -> Option { - const MAP_DIR: &str = "./maps"; // TODO: Work out a nice bincode file extension. - let file_name = match self { - Self::Save { .. } => { - use std::time::SystemTime; - - Some(format!( - "map_{}.bin", - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) - )) + match self { + Self::Save(path, _) => Some(PathBuf::from(&path)), + Self::LoadOrGenerate { name, .. } => { + const MAP_DIR: &str = "./maps"; + let file_name = format!("{}.bin", name); + Some(std::path::Path::new(MAP_DIR).join(file_name)) }, - Self::LoadOrGenerate { name, .. } => Some(format!("{}.bin", name)), _ => None, - }; - - file_name.map(|name| std::path::Path::new(MAP_DIR).join(name)) + } } fn save(&self, map: &WorldFile) { @@ -452,6 +444,9 @@ impl FileOpts { if let Err(e) = bincode::serialize_into(writer, map) { warn!(?e, "Couldn't write map"); } + if let Ok(p) = std::fs::canonicalize(path) { + info!("Map saved at {}", p.to_string_lossy()); + } } } @@ -674,13 +669,13 @@ impl WorldSim { let world_file = opts.world_file; // Parse out the contents of various map formats into the values we need. - let (parsed_world_file, map_size_lg, continent_scale_hack) = world_file.load_content(); + let (parsed_world_file, map_size_lg, gen_opts) = world_file.load_content(); // Currently only used with LoadOrGenerate to know if we need to // overwrite world file let fresh = parsed_world_file.is_none(); let mut rng = ChaChaRng::from_seed(seed_expan::rng_state(seed)); - let continent_scale = continent_scale_hack + let continent_scale = gen_opts.scale * 5_000.0f64 .div(32.0) .mul(TerrainChunkSize::RECT_SIZE.x as f64); @@ -688,6 +683,8 @@ impl WorldSim { let uplift_scale = 128.0; let uplift_turb_scale = uplift_scale / 4.0; + info!("Starting world generation"); + // NOTE: Changing order will significantly change WorldGen, so try not to! let gen_ctx = GenCtx { turb_x_nz: SuperSimplex::new().set_seed(rng.gen()), @@ -759,7 +756,7 @@ impl WorldSim { // Suppose the old world has grid spacing Δx' = Δy', new Δx = Δy. // We define grid_scale such that Δx = height_scale * Δx' ⇒ // grid_scale = Δx / Δx'. - let grid_scale = 1.0f64 / (4.0 / continent_scale_hack)/*1.0*/; + let grid_scale = 1.0f64 / (4.0 / gen_opts.scale)/*1.0*/; // Now, suppose we want to generate a world with "similar" topography, defined // in this case as having roughly equal slopes at steady state, with the @@ -801,7 +798,7 @@ impl WorldSim { // grid (when a chunk isn't available). let n_approx = 1.0; let max_erosion_per_delta_t = 64.0 * delta_t_scale(n_approx); - let n_steps = 100; + let n_steps = (100.0 * gen_opts.erosion_quality) as usize; let n_small_steps = 0; let n_post_load_steps = 0; @@ -821,18 +818,44 @@ impl WorldSim { let ((alt_base, _), (chaos, _)) = threadpool.join( || { uniform_noise(map_size_lg, |_, wposf| { - // "Base" of the chunk, to be multiplied by CONFIG.mountain_scale (multiplied - // value is from -0.35 * (CONFIG.mountain_scale * 1.05) to - // 0.35 * (CONFIG.mountain_scale * 0.95), but value here is from -0.3675 to - // 0.3325). - Some( - (gen_ctx - .alt_nz - .get((wposf.div(10_000.0)).into_array()) - .clamp(-1.0, 1.0)) - .sub(0.05) - .mul(0.35), - ) + match gen_opts.map_kind { + MapKind::Square => { + // "Base" of the chunk, to be multiplied by CONFIG.mountain_scale + // (multiplied value is from -0.35 * + // (CONFIG.mountain_scale * 1.05) to + // 0.35 * (CONFIG.mountain_scale * 0.95), but value here is from -0.3675 + // to 0.3325). + Some( + (gen_ctx + .alt_nz + .get((wposf.div(10_000.0)).into_array()) + .min(1.0) + .max(-1.0)) + .sub(0.05) + .mul(0.35), + ) + }, + MapKind::Circle => { + let world_sizef = map_size_lg.chunks().map(|e| e as f64) + * TerrainChunkSize::RECT_SIZE.map(|e| e as f64); + Some( + (gen_ctx + .alt_nz + .get((wposf.div(5_000.0 * gen_opts.scale)).into_array()) + .min(1.0) + .max(-1.0)) + .add( + 0.2 - ((wposf / world_sizef) * 2.0 - 1.0) + .magnitude_squared() + .powf(0.75) + .clamped(0.0, 1.0) + .powf(1.0) + * 0.6, + ) + .mul(0.5), + ) + }, + } }) }, || { @@ -1285,7 +1308,7 @@ impl WorldSim { // Save map, if necessary. // NOTE: We wll always save a map with latest version. let map = WorldFile::new(ModernMap { - continent_scale_hack, + continent_scale_hack: gen_opts.scale, map_size_lg: map_size_lg.vec(), alt, basement, @@ -1415,7 +1438,7 @@ impl WorldSim { let rivers = get_rivers( map_size_lg, - continent_scale_hack, + gen_opts.scale, &water_alt_pos, &water_alt, &dh,