diff --git a/Cargo.lock b/Cargo.lock index 215b5f7..6e66f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,6 +737,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.3" @@ -1433,6 +1439,16 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1774,9 +1790,24 @@ checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "num-traits", "png", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -2727,6 +2758,12 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.30.0" @@ -3416,6 +3453,7 @@ dependencies = [ "egui_extras", "env_logger", "hex", + "image", "keyvalues-parser", "log", "regex", @@ -4626,6 +4664,21 @@ dependencies = [ "syn", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/Cargo.toml b/Cargo.toml index 986de6e..0379823 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,18 @@ egui-modal = { path = "./egui-modal" } chrono-humanize = "0.2.3" chrono = "0.4.40" egui_extras = "0.31.1" +image = { version = "0.25.6", default-features = false, features = ["gif", "jpeg", "png", "webp", "bmp"] } [workspace] members = ["egui-modal"] + +[profile.special] +inherits = "release" +opt-level = 3 +strip = true +lto = "fat" +debug-assertions = false +overflow-checks = false +panic = "abort" +incremental = false +debug = false diff --git a/flake.nix b/flake.nix index 0f78760..fc7f3e5 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ inherit overlays; }; native-deps = with pkgs; [ - pkg-config pipewire lld clang libclang alsa-lib openssl zlib libxkbcommon libGL wayland mangohud + pkg-config pipewire lld clang libclang alsa-lib openssl zlib libxkbcommon libGL wayland mangohud upx ]; in pkgs.mkShell { diff --git a/src/app_logic.rs b/src/app_logic.rs index 2244be8..f20572a 100644 --- a/src/app_logic.rs +++ b/src/app_logic.rs @@ -1,16 +1,18 @@ -use std::{f32::consts::E, path::PathBuf, sync::Arc, thread, time::Duration}; +use std::{path::PathBuf, sync::Arc}; use crossbeam_channel::Receiver; -use egui::{mutex::Mutex}; +use egui::mutex::Mutex; use log::{debug, info}; -use crate::{config::{self, ConfigProvider}, omori_locator}; +use crate::{config::{self, ConfigProvider}, mod_builder::ModConfiguration, omori_locator}; #[derive(Clone)] pub enum UiState { Loading, KeyRequired(String), PickGame(Result, Vec<(PathBuf, u64)>), + PickPlaytest(Vec<(PathBuf, u64)>), + Configure(ModConfiguration), Error(String) } @@ -22,7 +24,8 @@ pub struct UiStateHolder { pub enum UiEvent { SetKey(Vec), UsePath(PathBuf), - UseSteamPath + UseSteamPath, + UseConfiguration(ModConfiguration) } pub struct AppThread { @@ -36,9 +39,11 @@ impl AppThread { fn commit(&self, s: UiState) { let mut state = self.ui_state.state.lock(); *state = s; + drop(state); + self.context.request_repaint(); } - fn get_key(&mut self, reason: String) -> anyhow::Result> { + fn get_key(&self, reason: String) -> anyhow::Result> { self.commit(UiState::KeyRequired(reason)); loop { match self.ui_event_channel.recv()? { @@ -51,7 +56,7 @@ impl AppThread { } } - fn pick_game(&mut self, provider: &mut ConfigProvider) -> anyhow::Result { + fn pick_game(&self, provider: &mut ConfigProvider) -> anyhow::Result { let steam_location = match omori_locator::get_omori_path() { Ok(l) => Ok(l), Err(e) => Err(String::from(format!("{:#}", e))) @@ -77,6 +82,38 @@ impl AppThread { } } + fn pick_playtest(&self, provider: &mut ConfigProvider) -> anyhow::Result { + let playtest_history = provider.get_playtest_paths()?; + self.commit(UiState::PickPlaytest(playtest_history.clone())); + + loop { + match self.ui_event_channel.recv()? { + UiEvent::UsePath(path) => { + provider.set_playtest_path_used(&path)?; + self.commit(UiState::Loading); + return Ok(path); + }, + _ => {} + } + } + } + + fn configure_bundling(&self, provider: &mut ConfigProvider, path: &PathBuf) -> anyhow::Result { + let configuration = provider.get_configuration_for_playtest(path); + self.commit(UiState::Configure(configuration)); + + loop { + match self.ui_event_channel.recv()? { + UiEvent::UseConfiguration(config) => { + provider.set_configuration_for_playtest(&path, &config)?; + self.commit(UiState::Loading); + return Ok(config); + }, + _ => {} + } + } + } + pub fn create(state_holder: &UiStateHolder, context: &egui::Context, ui_event_channel: &Receiver) -> AppThread { AppThread { ui_state: state_holder.clone(), @@ -105,6 +142,12 @@ impl AppThread { let game_path = self.pick_game(&mut config_provider)?; info!("Will use {:?}", game_path); + let playtest_path = self.pick_playtest(&mut config_provider)?; + info!("Playtest {:?}", playtest_path); + + let config = self.configure_bundling(&mut config_provider, &playtest_path)?; + info!("Config {:?}", config); + Ok(()) } diff --git a/src/config.rs b/src/config.rs index 02d918a..0d61082 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,21 +1,34 @@ -use std::{fs::{self, File}, path::PathBuf, time::{Instant, SystemTime}}; +use std::{fs::{self, File}, path::PathBuf, time::SystemTime}; use anyhow::Context; use egui::ahash::HashMap; use serde::{Deserialize, Serialize}; use serde; +use crate::{mod_builder::ModConfiguration, omori_locator}; + #[derive(Deserialize, Serialize, Debug)] struct Config { key: Option>, #[serde(default)] - recently_used_game_paths: HashMap + recently_used_game_paths: HashMap, + + #[serde(default)] + recently_used_playtest_paths: HashMap, + + #[serde(default)] + configuration_history: HashMap } impl Default for Config { fn default() -> Self { - Self { key: Default::default(), recently_used_game_paths: Default::default() } + Self { + key: Default::default(), + recently_used_game_paths: Default::default(), + recently_used_playtest_paths: Default::default(), + configuration_history: Default::default() + } } } @@ -70,12 +83,61 @@ impl ConfigProvider { let mut paths: Vec<(&PathBuf, &u64)> = self.config.recently_used_game_paths.iter().collect(); paths.sort_by(|a, b| a.1.cmp(b.1).reverse()); - Ok(paths.iter().map(|v| (v.0.clone(), *v.1)).collect()) + Ok(paths + .iter() + .filter(|p| omori_locator::validate_omori_installation(p.0)) + .map(|v| (v.0.clone(), *v.1)) + .collect() + ) } pub fn set_game_path_used(&mut self, which: &PathBuf) -> anyhow::Result<()> { let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?.as_secs(); self.config.recently_used_game_paths.insert(which.clone(), current_time); + self.config.recently_used_game_paths = self.config.recently_used_game_paths + .clone() + .into_iter() + .filter(|p| omori_locator::validate_omori_installation(&p.0)) + .collect(); + self.commit()?; + + Ok(()) + } + + pub fn get_playtest_paths(&self) -> anyhow::Result> { + let mut paths: Vec<(&PathBuf, &u64)> = self.config.recently_used_playtest_paths.iter().collect(); + paths.sort_by(|a, b| a.1.cmp(b.1).reverse()); + + Ok(paths + .iter() + .filter(|p| omori_locator::validate_playtest(p.0)) + .map(|v| (v.0.clone(), *v.1)) + .collect() + ) + } + + pub fn set_playtest_path_used(&mut self, which: &PathBuf) -> anyhow::Result<()> { + let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?.as_secs(); + self.config.recently_used_playtest_paths.insert(which.clone(), current_time); + self.config.recently_used_playtest_paths = self.config.recently_used_playtest_paths + .clone() + .into_iter() + .filter(|p| omori_locator::validate_playtest(&p.0)) + .collect(); + self.commit()?; + + Ok(()) + } + + pub fn get_configuration_for_playtest(&self, which: &PathBuf) -> ModConfiguration { + match self.config.configuration_history.get(which) { + Some(v) => v.clone(), + None => Default::default() + } + } + + pub fn set_configuration_for_playtest(&mut self, which: &PathBuf, config: &ModConfiguration) -> anyhow::Result<()> { + self.config.configuration_history.insert(which.clone(), config.clone()); self.commit()?; Ok(()) diff --git a/src/main.rs b/src/main.rs index 3375f80..79f9842 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,18 @@ mod omori_locator; mod app_logic; mod config; +mod mod_builder; -use std::{sync::Arc, thread, time::{Duration, SystemTime}}; +use std::{process::exit, sync::Arc, thread, time::SystemTime, default::Default}; use app_logic::{AppThread, UiEvent, UiState, UiStateHolder}; use crossbeam_channel::{Receiver, Sender}; use eframe::egui; -use egui::{mutex::{Mutex, RwLock}, Align, Layout, RichText, ThemePreference}; -use egui_alignments::{center_horizontal, center_vertical, top_horizontal, Aligner}; +use egui::{mutex::Mutex, Align, Layout, RichText, TextStyle, ThemePreference}; +use egui_alignments::{center_vertical, top_horizontal}; use egui_extras::{Column, TableBuilder}; use egui_modal::Modal; +use mod_builder::ModConfiguration; use sha2::Digest; fn main() -> anyhow::Result<()> { @@ -30,6 +32,7 @@ fn main() -> anyhow::Result<()> { viewport: egui::ViewportBuilder::default() .with_min_inner_size([640.0, 480.0]), ..Default::default() + }; let app_state = UiStateHolder { @@ -60,7 +63,9 @@ fn main() -> anyhow::Result<()> { struct Application { state: UiStateHolder, sender: Sender, - key_input: String + key_input: String, + configuration: ModConfiguration, + did_fill_configuration: bool } impl Application { @@ -68,7 +73,9 @@ impl Application { Application { sender, state, - key_input: "".to_string() + key_input: "".to_string(), + configuration: Default::default(), + did_fill_configuration: false } } } @@ -76,10 +83,15 @@ impl Application { const GAME_KEY_HASH: &[u8; 32] = include_bytes!("keyhash"); impl eframe::App for Application { - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + exit(0); // TODO: Prompt the user to maybe please consider not actually exiting the app while it's working + } + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let state = self.state.state.lock(); let state = state.clone(); + ctx.request_repaint_after_secs(10.0); + match state { UiState::Loading => { egui::CentralPanel::default().show(ctx, |ui| { @@ -154,7 +166,7 @@ impl eframe::App for Application { invalid_path_modal.button(ui, "OK"); }); }); - egui::TopBottomPanel::top("key_prompt_title_bar").show(ctx, |ui| { + egui::TopBottomPanel::top("game_prompt_title_bar").show(ctx, |ui| { ui.with_layout(Layout::top_down(Align::Center), |ui| { ui.label(RichText::new("Select your base game").size(32.0)); }); @@ -200,7 +212,8 @@ impl eframe::App for Application { TableBuilder::new(ui) .striped(true) .column(Column::remainder()) - .column(Column::auto()) + .column(Column::auto().at_least(100.0)) + .sense(egui::Sense::hover() | egui::Sense::click()) .body(|body| { body.rows(20.0, others.len(), |mut row| { let item = &others[row.index()]; @@ -211,12 +224,25 @@ impl eframe::App for Application { let dt = chrono::Duration::from(chrono::TimeDelta::seconds(dt as i64)); row.col(|ui| { - ui.label(format!("{}", item.0.display())); + ui.with_layout(Layout::right_to_left(Align::Center).with_main_align(Align::Min).with_main_justify(true), |ui| { + ui.label(format!("{}", item.0.display())); + }); }); - row.col(|ui| { - ui.label(format!("{}", chrono_humanize::HumanTime::from(dt))); + ui.with_layout(Layout::right_to_left(Align::Center).with_main_align(Align::Max), |ui| { + ui.label(format!("{}", chrono_humanize::HumanTime::from(dt))); + }); }); + if row.response().clicked() { + if omori_locator::validate_omori_installation(&item.0) { + self.sender.send(UiEvent::UsePath(item.0.clone())).expect("Failed to send"); + } else { + invalid_path_modal.open(); + } + } + if row.response().hovered() { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + } }); }); @@ -226,7 +252,144 @@ impl eframe::App for Application { }); }); }); + }, + UiState::PickPlaytest(playtest_history) => { + let invalid_path_modal = Modal::new(ctx, "invalid_playtest_modal"); + invalid_path_modal.show(|ui| { + invalid_path_modal.title(ui, "Invalid path"); + invalid_path_modal.frame(ui, |ui| { + invalid_path_modal.body(ui, "Please pick a valid, Oneloader-generated playtest."); + }); + invalid_path_modal.buttons(ui, |ui| { + invalid_path_modal.button(ui, "OK"); + }); + }); + egui::TopBottomPanel::top("playtest_prompt_title_bar").show(ctx, |ui| { + ui.with_layout(Layout::top_down(Align::Center), |ui| { + ui.label(RichText::new("Select your playtest").size(32.0)); + }); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + ui.with_layout(Layout::right_to_left(Align::Min).with_main_justify(true).with_main_align(Align::Center), |ui| { + if ui.button(RichText::new("Pick playtest").size(16.0)).clicked() { + match rfd::FileDialog::new().pick_folder() { + Some(path) => { + if omori_locator::validate_playtest(&path) { + self.sender.send(UiEvent::UsePath(path)).expect("Failed to send"); + } else { + invalid_path_modal.open(); + } + }, + None => {} + } + } + }); + + if playtest_history.len() > 0 { + ui.label("History of playtests"); + ui.separator(); + TableBuilder::new(ui) + .striped(true) + .column(Column::remainder()) + .column(Column::auto().at_least(100.0)) + .sense(egui::Sense::hover() | egui::Sense::click()) + .body(|body| { + body.rows(20.0, playtest_history.len(), |mut row| { + let item = &playtest_history[row.index()]; + let dt = + (item.1 as i64) - + (SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).expect("Failed to get the time").as_secs() as i64); + + let dt = chrono::Duration::from(chrono::TimeDelta::seconds(dt as i64)); + + row.col(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center).with_main_align(Align::Min).with_main_justify(true), |ui| { + ui.label(format!("{}", item.0.display())); + }); + }); + row.col(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center).with_main_align(Align::Max), |ui| { + ui.label(format!("{}", chrono_humanize::HumanTime::from(dt))); + }); + }); + if row.response().clicked() { + if omori_locator::validate_playtest(&item.0) { + self.sender.send(UiEvent::UsePath(item.0.clone())).expect("Failed to send"); + } else { + invalid_path_modal.open(); + } + } + if row.response().hovered() { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + }); + } else { + ui.label("Any playtests you've used before will be remembered here."); + } + }); + }, + UiState::Configure(initial_configuration) => { + if !self.did_fill_configuration { + self.did_fill_configuration = true; + self.configuration = initial_configuration; + } + + let mut font = TextStyle::Body.resolve(&ctx.style()); + font.size = 16.0; + + egui::TopBottomPanel::top("configure_title_bar").show(ctx, |ui| { + ui.with_layout(Layout::top_down(Align::Center), |ui| { + ui.label(RichText::new("Configure your mod").size(32.0)); + }); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + top_horizontal(ui, |ui| { + TableBuilder::new(ui) + .column(Column::auto().at_least(100.0)) + .column(Column::exact(300.0)) + .body(|mut body| { + body.row(24.0, |mut row| { + row.col(|ui| { + ui.label(RichText::new("Mod ID").size(16.0)); + }); + row.col(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.configuration.mod_id).font(font.clone())); + }); + }); + body.row(24.0, |mut row| { + row.col(|ui| { + ui.label(RichText::new("Mod Name").size(16.0)); + }); + row.col(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.configuration.mod_name).font(font.clone())); + }); + }); + body.row(24.0, |mut row| { + row.col(|ui| { + ui.label(RichText::new("Mod Description").size(16.0)); + }); + row.col(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.configuration.mod_description).font(font.clone())); + }); + }); + body.row(24.0, |mut row| { + row.col(|ui| { + ui.label(RichText::new("Mod Version").size(16.0)); + }); + row.col(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.configuration.mod_version).font(font.clone())); + }); + }); + }); + }); + + ui.separator(); + }); } } } -} \ No newline at end of file +} + diff --git a/src/mod_builder.rs b/src/mod_builder.rs new file mode 100644 index 0000000..d200695 --- /dev/null +++ b/src/mod_builder.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ModConfiguration { + pub mod_id: String, + pub mod_name: String, + pub mod_description: String, + pub mod_version: String, + + pub include_audio: bool, + pub include_data: bool, + pub include_fonts: bool, + pub include_icon: bool, + pub include_img: bool, + pub include_plugins: bool, + pub include_languages: bool, + pub include_maps: bool, + pub include_movies: bool +} \ No newline at end of file diff --git a/src/omori_locator.rs b/src/omori_locator.rs index 8b08a1c..4cc0912 100644 --- a/src/omori_locator.rs +++ b/src/omori_locator.rs @@ -144,7 +144,7 @@ pub fn get_omori_key() -> anyhow::Result> { } -const EXPECTED_PATHS: [&str; 32] = [ +const EXPECTED_PATHS_BASE: [&str; 32] = [ "index.html", "editor.json", "audio/bgm/AMB_forest.rpgmvo", @@ -181,7 +181,43 @@ const EXPECTED_PATHS: [&str; 32] = [ pub fn validate_omori_installation(base: &PathBuf) -> bool { info!("Validating {:?}", base); - for path in EXPECTED_PATHS { + for path in EXPECTED_PATHS_BASE { + trace!("Checking {path}"); + let mut real = base.clone(); + real.push(path); + + if let Ok(r) = fs::exists(&real) { + if !r { return false; } + } else { return false; } + } + info!("Validation passed"); + + true +} + +const EXPECTED_PATHS_PLAYTEST: [&str; 17] = [ + "index.html", + "Game.rpgproject", + "img", + "img/pictures", + "audio", + "audio/bgm", + "js", + "languages", + "js/plugins", + "js/plugins/Omori BASE.js", + "package.json", + "data/Actors.json", + "data/System.json", + "data/Atlas.yaml", + "data/Troops.json", + "img/system/Window.png", + "img/atlases/battleATLAS.png", +]; + +pub fn validate_playtest(base: &PathBuf) -> bool { + info!("Validating {:?}", base); + for path in EXPECTED_PATHS_PLAYTEST { trace!("Checking {path}"); let mut real = base.clone(); real.push(path);