1
0
Fork 0
mirror of https://github.com/doukutsu-rs/doukutsu-rs synced 2025-09-20 19:44:00 +00:00

Compare commits

...

7 commits

Author SHA1 Message Date
biroder 7081b5616f
Merge e58b1af514 into f4b5df3640 2025-07-23 14:25:45 +00:00
biroder e58b1af514 Implement export of the profile [ci skip] 2025-07-23 17:21:51 +03:00
biroder fc902d71a8 Fix music fading out too quickly 2025-07-23 17:21:50 +03:00
biroder 02a87bbe9e Reduce log file size and fix the error window on Windows doesn't appear
Info that NPC is creating or a script successfuly compiled can be useful for debugging, but on a regular run it just clogs up the log file. If we need such specific info from users, they'll need to reproduce the issue using `--log-level debug` program argument.
2025-07-23 17:21:50 +03:00
biroder 91c35a25f9 Implement more or less valid saving in CS+ format [ci skip]
Saving whether eggfish is killed is not implemented. Also saving of empty slots is not accurately implemented, but this shouldn't cause problems.
2025-07-02 16:57:41 +03:00
biroder 21c8e92501 Implement deletion of freeware format saves 2025-07-02 12:35:11 +03:00
biroder d5f6089cdc Add a basis for working with all possible save formats [ci skip] 2025-07-02 12:35:01 +03:00
16 changed files with 2366 additions and 162 deletions

958
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -84,10 +84,12 @@ pelite = { version = ">=0.9.2", default-features = false, features = ["std"] }
sdl2 = { git = "https://github.com/doukutsu-rs/rust-sdl2.git", rev = "244ae85833cff4f97ab4b58331741be20e422bd7", optional = true, features = ["unsafe_textures", "bundled", "static-link"] }
sdl2-sys = { git = "https://github.com/doukutsu-rs/rust-sdl2.git", rev = "244ae85833cff4f97ab4b58331741be20e422bd7", optional = true, features = ["bundled", "static-link"] }
rc-box = "1.2.0"
rfd = "0.15.4"
serde = { version = "1", features = ["derive"] }
serde_derive = "1"
serde_cbor = { version = "0.11", optional = true }
serde_json = "1.0"
serde_json = { version = "1.0", features = ["alloc", "unbounded_depth"] }
serde_with = { version = "3.11", default_features = false, features = ["macros"] }
strum = "0.24"
strum_macros = "0.24"
# remove and replace with extract_if, when our MSRV is 1.87

75
drsandroid/Cargo.lock generated
View file

@ -533,8 +533,18 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
@ -547,21 +557,46 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.10.0",
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.101",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [
"darling_core",
"darling_core 0.13.4",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.101",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
@ -663,6 +698,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"serde_with",
"strum",
"strum_macros",
"vec_mut_scan",
@ -1452,7 +1488,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0df7ac00c4672f9d5aece54ee3347520b7e20f158656c7db2e6de01902eb7a6c"
dependencies = [
"darling",
"darling 0.13.4",
"proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
@ -1962,6 +1998,29 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
dependencies = [
"serde",
"serde_derive",
"serde_with_macros",
]
[[package]]
name = "serde_with_macros"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "shared_library"
version = "0.1.9"
@ -2002,6 +2061,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.24.1"

View file

@ -194,7 +194,7 @@ pub enum FadeState {
FadeOut(i8, FadeDirection),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[repr(u8)]
pub enum Direction {
Left = 0,

View file

@ -8,7 +8,8 @@
"yes": "Yes",
"no": "No",
"on": "ON",
"off": "OFF"
"off": "OFF",
"choose_file": "No file selected…"
},
"menus": {
"main_menu": {
@ -60,6 +61,32 @@
"replay_last": "Replay Last",
"delete_replay": "Delete Best Replay"
},
"save_manage_menu": {
"import_export_save": "Import/Export Save...",
"import_format": "Import format",
"export_format": "Export format",
"action_type": {
"entry": "Action:",
"import": "Import",
"export": "Export"
},
"save_location": {
"import": "Import location",
"export": "Export location"
},
"save_format": {
"entry": "Save format:",
"freeware": "Freeware",
"plus": "Cave Story+ (PC)",
"switch": "Cave Story+ (Switch)",
"auto": "Auto-detect"
},
"file_filters": {
"freeware": "Cave Story save",
"plus": "Cave Story+ save",
"switch": "Cave Story+ save"
}
},
"options_menu": {
"graphics": "Graphics...",
"graphics_menu": {
@ -134,7 +161,13 @@
"auto": "Auto"
},
"discord_rpc": "Discord Rich Presence:",
"allow_strafe": "Allow strafe:"
"allow_strafe": "Allow strafe:",
"save_format": {
"entry": "Save format:",
"freeware": "Freeware",
"plus": "Cave Story+ (PC)",
"switch": "Cave Story+ (Switch)"
}
},
"links": "Links...",
"advanced": "Advanced...",

View file

@ -1,17 +1,35 @@
use std::collections::{HashMap, hash_map::Entry};
use std::ffi::OsString;
use std::fs::File;
use std::io;
use std::io::{Read, Write};
use std::marker::Copy;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use byteorder::{BE, LE, ReadBytesExt, WriteBytesExt};
use num_traits::{clamp, FromPrimitive};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use crate::common::{Direction, FadeState, get_timestamp};
use crate::components::nikumaru::NikumaruCounter;
use crate::framework::context::Context;
use crate::framework::error::GameError::ResourceLoadError;
use crate::framework::error::GameError::{self, ResourceLoadError};
use crate::framework::error::GameResult;
use crate::framework::filesystem::{user_create, user_delete, user_exists, user_open};
use crate::game::player::{ControlMode, TargetPlayer};
use crate::game::shared_game_state::{GameDifficulty, SharedGameState};
use crate::game::shared_game_state::{GameDifficulty, PlayerCount, SharedGameState};
use crate::game::weapon::{WeaponLevel, WeaponType};
use crate::game::inventory::Inventory;
use crate::scene::game_scene::GameScene;
const SIG_Do041115: u64 = 0x446f303431313135;
const SIG_Do041220: u64 = 0x446f303431323230;
const SIG_FLAG: u32 = 0x464c4147;
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct WeaponData {
pub weapon_id: u32,
pub level: u32,
@ -20,11 +38,23 @@ pub struct WeaponData {
pub ammo: u32,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct TeleporterSlotData {
pub index: u32,
pub event_num: u32,
}
trait SaveProfile {
fn apply(&self, state: &mut SharedGameState, game_scene: &mut GameScene, ctx: &mut Context);
fn dump(state: &mut SharedGameState, game_scene: &mut GameScene, target_player: Option<TargetPlayer>) -> Self;
fn write_save<W: io::Write>(&self, data: W, format: SaveFormat) -> GameResult;
fn load_from_save<R: io::Read>(data: R, format: SaveFormat) -> GameResult<GameProfile>;
fn is_empty(&self) -> bool;
}
#[serde_as]
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub struct GameProfile {
pub current_map: u32,
pub current_song: u32,
@ -40,12 +70,48 @@ pub struct GameProfile {
pub control_mode: u32,
pub counter: u32,
pub weapon_data: [WeaponData; 8],
pub weapon_data_p2: Option<[WeaponData; 8]>,
pub items: [u32; 32],
pub teleporter_slots: [TeleporterSlotData; 8],
#[serde_as(as = "[_; 128]")]
pub map_flags: [u8; 128],
#[serde_as(as = "[_; 1000]")]
pub flags: [u8; 1000],
pub timestamp: u64,
pub difficulty: u8,
// CS+ fields
pub eggfish_killed: bool,
}
impl Default for GameProfile {
fn default() -> GameProfile {
GameProfile {
current_map: 0,
current_song: 0,
pos_x: 0,
pos_y: 0,
direction: Direction::Left,
max_life: 0,
stars: 0,
life: 0,
current_weapon: 0,
current_item: 0,
equipment: 0,
control_mode: 0,
counter: 0,
weapon_data: [WeaponData::default(); 8],
weapon_data_p2: None,
items: [0u32; 32],
teleporter_slots: [TeleporterSlotData::default(); 8],
map_flags: [0u8; 128],
flags: [0u8; 1000],
timestamp: 0,
difficulty: 0,
eggfish_killed: false,
}
}
}
impl GameProfile {
@ -58,29 +124,37 @@ impl GameProfile {
game_scene.inventory_player1.current_weapon = self.current_weapon as u16;
game_scene.inventory_player1.current_item = self.current_item as u16;
for weapon in &self.weapon_data {
if weapon.weapon_id == 0 {
continue;
}
let _ = state.mod_requirements.append_weapon(ctx, weapon.weapon_id as u16);
let weapon_type: Option<WeaponType> = FromPrimitive::from_u8(weapon.weapon_id as u8);
fn apply_weapons(ctx: &mut Context, state: &mut SharedGameState, weapons: &[WeaponData], inventory: &mut Inventory) {
for weapon in weapons {
if weapon.weapon_id == 0 {
continue;
}
if let Some(wtype) = weapon_type {
game_scene.inventory_player1.add_weapon_data(
wtype,
weapon.ammo as u16,
weapon.max_ammo as u16,
weapon.exp as u16,
match weapon.level {
2 => WeaponLevel::Level2,
3 => WeaponLevel::Level3,
_ => WeaponLevel::Level1,
},
);
let _ = state.mod_requirements.append_weapon(ctx, weapon.weapon_id as u16);
let weapon_type: Option<WeaponType> = FromPrimitive::from_u8(weapon.weapon_id as u8);
if let Some(wtype) = weapon_type {
inventory.add_weapon_data(
wtype,
weapon.ammo as u16,
weapon.max_ammo as u16,
weapon.exp as u16,
match weapon.level {
2 => WeaponLevel::Level2,
3 => WeaponLevel::Level3,
_ => WeaponLevel::Level1,
},
);
}
}
}
apply_weapons(ctx, state, &self.weapon_data, &mut game_scene.inventory_player1);
if let Some(weapons) = self.weapon_data_p2.as_ref() {
apply_weapons(ctx, state, weapons, &mut game_scene.inventory_player2);
}
for item in self.items.iter().copied() {
let item_id = item as u16;
let _ = state.mod_requirements.append_item(ctx, item_id);
@ -90,7 +164,10 @@ impl GameProfile {
break;
}
// TODO: original save formats can't store inventory for player 2, but we can, so should we save it,
// even if it's equal to the inventroy of player 1?
game_scene.inventory_player1.add_item_amount(item_id, amount + 1);
game_scene.inventory_player2.add_item_amount(item_id, amount + 1);
}
for slot in &self.teleporter_slots {
@ -147,7 +224,7 @@ impl GameProfile {
game_scene.player1.stars = clamp(self.stars, 0, 3) as u8;
game_scene.player2 = game_scene.player1.clone();
game_scene.inventory_player2 = game_scene.inventory_player1.clone();
// game_scene.inventory_player2 = game_scene.inventory_player1.clone();
game_scene.player1.cond.0 = 0x80;
@ -163,6 +240,7 @@ impl GameProfile {
TargetPlayer::Player2 => &game_scene.player2,
};
// TODO: should we store inventory of player 2?
let inventory_player = match target_player.unwrap_or(TargetPlayer::Player1) {
TargetPlayer::Player1 => &game_scene.inventory_player1,
TargetPlayer::Player2 => &game_scene.inventory_player2,
@ -181,38 +259,31 @@ impl GameProfile {
let equipment = player.equip.0 as u32;
let control_mode = player.control_mode as u32;
let counter = 0; // TODO
let mut weapon_data = [
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
];
let mut weapon_data = [WeaponData::default(); 8];
let mut weapon_data_p2 = None;
let mut items = [0u32; 32];
let mut teleporter_slots = [
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
];
let mut teleporter_slots = [TeleporterSlotData::default(); 8];
for (idx, weap) in weapon_data.iter_mut().enumerate() {
if let Some(weapon) = inventory_player.get_weapon(idx) {
weap.weapon_id = weapon.wtype as u32;
weap.level = weapon.level as u32;
weap.exp = weapon.experience as u32;
weap.max_ammo = weapon.max_ammo as u32;
weap.ammo = weapon.ammo as u32;
fn dump_weapons(weapons: &mut [WeaponData], inventory: &Inventory) {
for (idx, weap) in weapons.iter_mut().enumerate() {
if let Some(weapon) = inventory.get_weapon(idx) {
weap.weapon_id = weapon.wtype as u32;
weap.level = weapon.level as u32;
weap.exp = weapon.experience as u32;
weap.max_ammo = weapon.max_ammo as u32;
weap.ammo = weapon.ammo as u32;
}
}
}
dump_weapons(&mut weapon_data, &game_scene.inventory_player1);
if state.player_count == PlayerCount::Two {
let mut weapons_p2 = [WeaponData::default(); 8];
dump_weapons(&mut weapons_p2, &game_scene.inventory_player2);
weapon_data_p2 = Some(weapons_p2);
}
for (idx, item) in items.iter_mut().enumerate() {
if let Some(sitem) = inventory_player.get_item_idx(idx) {
*item = sitem.0 as u32 + (((sitem.1 - 1) as u32) << 16);
@ -240,6 +311,7 @@ impl GameProfile {
let timestamp = get_timestamp();
let difficulty = state.difficulty as u8;
let eggfish_killed = false; // TODO
GameProfile {
current_map,
@ -256,17 +328,19 @@ impl GameProfile {
control_mode,
counter,
weapon_data,
weapon_data_p2,
items,
teleporter_slots,
map_flags,
flags,
timestamp,
difficulty,
eggfish_killed
}
}
pub fn write_save<W: io::Write>(&self, mut data: W) -> GameResult {
data.write_u64::<BE>(0x446f303431323230)?;
pub fn write_save<W: io::Write>(&self, data: &mut W, format: &SaveFormat) -> GameResult {
data.write_u64::<BE>(SIG_Do041220)?;
data.write_u32::<LE>(self.current_map)?;
data.write_u32::<LE>(self.current_song)?;
@ -274,8 +348,20 @@ impl GameProfile {
data.write_i32::<LE>(self.pos_y)?;
data.write_u32::<LE>(self.direction as u32)?;
data.write_u16::<LE>(self.max_life)?;
// TODO: P2 values
if *format == SaveFormat::Switch {
data.write_u16::<LE>(self.max_life)?;
}
data.write_u16::<LE>(self.stars)?;
data.write_u16::<LE>(self.life)?;
// TODO: P2 values
if *format == SaveFormat::Switch {
data.write_u16::<LE>(self.life)?;
}
data.write_u16::<LE>(0)?;
data.write_u32::<LE>(self.current_weapon)?;
data.write_u32::<LE>(self.current_item)?;
@ -290,6 +376,15 @@ impl GameProfile {
data.write_u32::<LE>(weapon.max_ammo)?;
data.write_u32::<LE>(weapon.ammo)?;
}
if *format == SaveFormat::Switch {
for weapon in &self.weapon_data_p2.unwrap_or([WeaponData::default(); 8]) {
data.write_u32::<LE>(weapon.weapon_id)?;
data.write_u32::<LE>(weapon.level)?;
data.write_u32::<LE>(weapon.exp)?;
data.write_u32::<LE>(weapon.max_ammo)?;
data.write_u32::<LE>(weapon.ammo)?;
}
}
for item in self.items.iter().copied() {
data.write_u32::<LE>(item)?;
@ -300,27 +395,36 @@ impl GameProfile {
data.write_u32::<LE>(slot.event_num)?;
}
// Probably map flags, but unused anyway
let something = [0u8; 0x80];
data.write(&something)?;
data.write_u32::<BE>(0x464c4147)?;
data.write_u32::<BE>(SIG_FLAG)?;
data.write(&self.flags)?;
data.write_u32::<LE>(0)?; // unused(?) CS+ space
if *format == SaveFormat::Plus {
data.write_u32::<LE>(0)?; // unused(?) CS+ space
}
data.write_u64::<LE>(self.timestamp)?;
data.write_u8(self.difficulty)?;
if format.is_csp() {
// unused(?) CS+ space
let zeros = [0u8; 15];
data.write(&zeros);
}
Ok(())
}
pub fn load_from_save<R: io::Read>(mut data: R) -> GameResult<GameProfile> {
pub fn load_from_save<R: io::Read>(data: &mut R, format: SaveFormat) -> GameResult<GameProfile> {
/*
let magic = data.read_u64::<BE>()?;
// Do041220, Do041115
if magic != 0x446f303431323230 && magic != 0x446f303431313135 {
if magic != SIG_Do041220 && magic != SIG_Do041115 {
return Err(ResourceLoadError("Invalid magic".to_owned()));
}
*/
let current_map = data.read_u32::<LE>()?;
let current_song = data.read_u32::<LE>()?;
let pos_x = data.read_i32::<LE>()?;
@ -335,34 +439,27 @@ impl GameProfile {
let equipment = data.read_u32::<LE>()?;
let control_mode = data.read_u32::<LE>()?;
let counter = data.read_u32::<LE>()?;
let mut weapon_data = [
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
WeaponData { weapon_id: 0, level: 0, exp: 0, max_ammo: 0, ammo: 0 },
];
let mut weapon_data = [WeaponData::default(); 8];
let mut weapon_data_p2 = None;
let mut items = [0u32; 32];
let mut teleporter_slots = [
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
TeleporterSlotData { index: 0, event_num: 0 },
];
let mut teleporter_slots = [TeleporterSlotData::default(); 8];
for WeaponData { weapon_id, level, exp, max_ammo, ammo } in &mut weapon_data {
*weapon_id = data.read_u32::<LE>()?;
*level = data.read_u32::<LE>()?;
*exp = data.read_u32::<LE>()?;
*max_ammo = data.read_u32::<LE>()?;
*ammo = data.read_u32::<LE>()?;
fn load_weapons<R: io::Read>(data: &mut R, weapons: &mut [WeaponData]) -> GameResult {
for WeaponData { weapon_id, level, exp, max_ammo, ammo } in weapons {
*weapon_id = data.read_u32::<LE>()?;
*level = data.read_u32::<LE>()?;
*exp = data.read_u32::<LE>()?;
*max_ammo = data.read_u32::<LE>()?;
*ammo = data.read_u32::<LE>()?;
}
Ok(())
}
load_weapons(data, &mut weapon_data)?;
if format == SaveFormat::Switch {
weapon_data_p2 = Some([WeaponData::default(); 8]);
load_weapons(data, weapon_data_p2.as_mut().unwrap());
}
for item in &mut items {
@ -377,7 +474,7 @@ impl GameProfile {
let mut map_flags = [0u8; 0x80];
data.read_exact(&mut map_flags)?;
if data.read_u32::<BE>()? != 0x464c4147 {
if data.read_u32::<BE>()? != SIG_FLAG {
return Err(ResourceLoadError("Invalid FLAG signature".to_owned()));
}
@ -388,6 +485,7 @@ impl GameProfile {
let timestamp = data.read_u64::<LE>().unwrap_or(0);
let difficulty = data.read_u8().unwrap_or(0);
let eggfish_killed = false; // TODO
Ok(GameProfile {
current_map,
@ -404,12 +502,533 @@ impl GameProfile {
control_mode,
counter,
weapon_data,
weapon_data_p2,
items,
teleporter_slots,
map_flags,
flags,
timestamp,
difficulty,
eggfish_killed
})
}
pub fn is_empty(&self) -> bool {
self.timestamp == 0
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum SaveSlot {
MainGame(usize), // (save slot)
CSPMod(u8, usize), // (mod save set, save_slot)
Mod(String, usize), // (mod id, save slot)
}
impl SaveSlot {
pub fn into_idx(self) -> Self {
match self {
Self::MainGame(save_slot) => if save_slot == 0 {
Self::MainGame(save_slot)
} else {
Self::MainGame(save_slot - 1)
},
Self::CSPMod(save_set, save_slot) => { Self::CSPMod(save_set - 1, save_slot - 1) },
Self::Mod(mod_id, save_slot) => if save_slot == 0 {
Self::Mod(mod_id, save_slot)
} else {
Self::Mod(mod_id, save_slot - 1)
},
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
pub enum SaveFormat {
Freeware,
Plus,
// TODO: add version (v1.2 or v1.3)
Switch,
Generic,
}
impl SaveFormat {
pub fn recognise(data: &[u8]) -> GameResult<SaveFormat> {
let mut cur = std::io::Cursor::new(data);
let magic = cur.read_u64::<BE>()?;
let original_format = match magic {
// In CS+ a profile signature at the start of the save file is present only
// if the first game slot profile exists. Otherwise it will be filled with zeros.
// TODO: should we handle
SIG_Do041220 =>
match data.len() {
0x604..=0x620 => Some(SaveFormat::Freeware),
0x20020 => Some(SaveFormat::Plus),
// 0x20fb0 — v1.2
// 0x20fb4 — v1.3
0x20fb0 | 0x20fb4 => Some(SaveFormat::Switch),
_ => None
},
SIG_Do041115 => Some(SaveFormat::Freeware),
_ => None
};
if let Some(format) = original_format {
return Ok(format);
}
// Generic save is stored in JSON format, so it must start with '{' character
if data[0] == '{' as u8 {
cur.set_position(0);
if serde_json::from_reader::<_, SaveContainer>(cur).is_ok() {
return Ok(SaveFormat::Generic);
}
}
Err(ResourceLoadError("Unsupported or invalid save file".to_owned()))
}
pub fn is_csp(&self) -> bool {
match self {
SaveFormat::Plus | SaveFormat::Switch => true,
_ => false
}
}
// TODO: compatibility warnings
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct CSPModProfile {
pub profiles: HashMap<usize, GameProfile>,
// TODO: add TimingMode for best times
pub time: usize, // Best time for challenges
}
impl CSPModProfile {
pub fn is_empty(&self) -> bool {
self.profiles.is_empty() && self.time == 0
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum SavePatch {
Added,
Modified,
Deleted
}
#[derive(Clone, Debug)]
pub struct SaveParams {
pub slots: Vec<SaveSlot>,
pub settings: bool,
}
impl Default for SaveParams {
fn default() -> Self {
// Import/export all slots by default
Self {
slots: vec![],
settings: true
}
}
}
// Generic container to store all possible info from original game saves
#[derive(Debug, Deserialize, Serialize)]
pub struct SaveContainer {
pub version: usize,
pub game_profiles: HashMap<usize, GameProfile>,
pub csp_mods: HashMap<u8, CSPModProfile>, // save_set number -> saves & time
#[serde(skip)]
patchset: HashMap<SaveSlot, SavePatch>,
// TODO: engine and mods specific fields
}
impl Default for SaveContainer {
fn default() -> SaveContainer {
SaveContainer {
version: 1,
game_profiles: HashMap::new(),
csp_mods: HashMap::new(),
patchset: HashMap::new(),
}
}
}
impl SaveContainer {
pub fn load(ctx: &mut Context, state: &mut SharedGameState) -> GameResult<SaveContainer> {
log::debug!("DEBUG LOAD SAVE");
if let Ok(mut file) = user_open(ctx, "/save.json") {
log::debug!("DEBUG LOAD SAVE - FILE EXISTS");
// Using of buf significantly speed up the deserialization.
let mut buf: Vec<u8> = Vec::new();
file.read_to_end(&mut buf)?;
log::debug!("DEBUG LOAD SAVE - PREDESERIALIZE. Len: {}", buf.len());
let now = std::time::Instant::now();
match serde_json::from_slice::<SaveContainer>(buf.as_mut_slice()) {
Ok(mut save) => {
log::debug!("DEBUG LOAD SAVE - PREUPGRADE");
save.upgrade();
log::debug!("DEBUG LOAD SAVE - UPGRADED");
log::info!("DEBUG LOAD - {} SECS ELAPSED", now.elapsed().as_secs_f64());
//log::debug!("{:?}", save);
return Ok(save);
},
Err(err) => log::warn!("Failed to deserialize a generic save: {}", err),
}
}
log::debug!("DEBUG LOAD SAVE - DEFAULT CREATED");
let mut container = SaveContainer::default();
//container.write_save(ctx, state, SaveFormat::Generic, None, None, &SaveParams::default())?;
Ok(container)
}
pub fn save(&mut self, ctx: &mut Context, state: &mut SharedGameState, params: SaveParams) -> GameResult {
self.write_save(ctx, state, SaveFormat::Generic, None, None, &params)?;
self.write_save(ctx, state, state.settings.save_format, None, None, &params)?;
self.patchset.clear();
Ok(())
}
pub fn write_save(&mut self, ctx: &mut Context, state: &mut SharedGameState, format: SaveFormat, slot: Option<SaveSlot>, mut out_path: Option<PathBuf>, params: &SaveParams) -> GameResult {
log::debug!("DEBUG WRITE SAVE");
let save_path = Self::get_save_filename(&format, slot.clone());
match format {
SaveFormat::Generic => {
let exists = user_exists(ctx, &save_path);
let mut file = user_create(ctx, &save_path)?;
// Using of buf significantly speed up the serializing.
let buf = serde_json::to_vec(&self)?;
file.write_all(&buf)?;
file.flush()?
},
SaveFormat::Freeware => {
for (save_slot, profile) in &self.game_profiles {
if params.slots.is_empty() || params.slots.contains(&SaveSlot::MainGame(*save_slot)) {
let mut buf = Vec::new();
let mut cur = std::io::Cursor::new(&mut buf);
profile.write_save(&mut cur, &format)?;
let mut filename = Self::get_save_filename(&format, Some(SaveSlot::MainGame(*save_slot)));
if let Some(path) = &mut out_path {
let os_filename = OsString::from_str(filename.split_off(1).as_str()).unwrap();
let _ = path.set_file_name(os_filename);
let mut file = File::create(path)?;
file.write_all(&buf)?;
} else {
let mut file = user_create(ctx, filename)?;
file.write_all(&buf)?;
}
}
}
for (save_set, csp_mod) in &self.csp_mods {
for (slot, profile) in &csp_mod.profiles {
if params.slots.is_empty() || params.slots.contains(&SaveSlot::CSPMod(*save_set, *slot)) {
let mut buf = Vec::new();
let mut cur = std::io::Cursor::new(&mut buf);
profile.write_save(&mut cur, &format)?;
let mut filename = Self::get_save_filename(&format, Some(SaveSlot::CSPMod(*save_set, *slot)));
if let Some(path) = &mut out_path {
let os_filename = OsString::from_str(filename.split_off(1).as_str()).unwrap();
let _ = path.set_file_name(os_filename);
let mut file = File::create(path)?;
file.write_all(&buf)?;
} else {
let mut file = user_create(ctx, filename)?;
file.write_all(&buf)?;
}
}
}
}
for (patch_slot, patch_state) in self.patchset.iter() {
if *patch_state == SavePatch::Deleted {
// TODO: should we unwrap the result?
user_delete(ctx, Self::get_save_filename(&format, Some(patch_slot.clone())))?;
}
}
},
SaveFormat::Plus | SaveFormat::Switch => {
let mut active_slots = [0u8; 32];
// Setting
let bgm_volume = ((state.settings.bgm_volume * 10.0) as u32).min(10);
let sfx_volume = ((state.settings.bgm_volume * 10.0) as u32).min(10);
let seasonal_textures = state.settings.seasonal_textures as u8;
let soundtrack: u8 = match state.settings.soundtrack.as_str() {
"organya" => 2,
"new" => 3,
"remastered" => 4,
"famitracks" => 5,
"ridiculon" => 6,
_ => 2 // Fallback to Organya
};
let graphics = state.settings.original_textures as u8;
let language = (state.settings.locale == "jp") as u8;
let beaten_hell = state.mod_requirements.beat_hell as u8;
let unused = [0u8; 14];
let mut jukebox: [u8; 48] = [
0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80,
0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80,
0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80,
0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80,
0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80,
0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80
]; // TODO
let mut eggfish_killed = [0u8; 3];
let mut nikumaru = NikumaruCounter::new();
nikumaru.load_counter(state, ctx);
let mut buf = Vec::new();
let mut cur = std::io::Cursor::new(&mut buf);
// TODO
// In CS+, only slots up to the last non-empty one are written to the save file.
// E.g., if the user saved only the profile in slot 3, then profiles from slots 1, 2 and 3 will be written to the file.
let default_profile = GameProfile::default();
for save_slot in 1..=3 {
let profile = if params.slots.is_empty() || params.slots.contains(&SaveSlot::MainGame(save_slot)) {
log::debug!("Writing Game profile: {}", save_slot);
if let Some(game_profile) = self.game_profiles.get(&save_slot) {
active_slots[0] |= 1u8 << (save_slot - 1);
game_profile
} else {
&default_profile
}
} else {
&default_profile
};
profile.write_save(&mut cur, &format)?;
eggfish_killed[save_slot - 1] = profile.eggfish_killed as u8;
}
let default_csp_profile = CSPModProfile::default();
for save_set in 1..=26 {
let csp_mod = self.csp_mods.get(&save_set).unwrap_or(&default_csp_profile);
for save_slot in 1..=3 {
let profile = if params.slots.is_empty() || params.slots.contains(&SaveSlot::CSPMod(save_set, save_slot)) {
log::debug!("Writing CSP profile: {} - {}", save_set, save_slot);
if let Some(game_profile) = csp_mod.profiles.get(&save_slot) {
active_slots[save_set as usize] |= 1u8 << (save_slot - 1);
game_profile
} else {
&default_profile
}
} else {
&default_profile
};
profile.write_save(&mut cur, &format)?;
}
}
cur.write(&active_slots)?;
// Settings
cur.write_u32::<LE>(bgm_volume)?;
cur.write_u32::<LE>(sfx_volume)?;
cur.write_u8(seasonal_textures)?;
cur.write_u8(soundtrack)?;
cur.write_u8(graphics)?;
cur.write_u8(language)?;
cur.write_u8(beaten_hell)?;
// TODO
// TODO: write some fields only in the v1.3 version mode
if format == SaveFormat::Switch {
cur.write(&jukebox)?;
cur.write_u8(0)?; // Unlock notifications.
cur.write_u8(0)?; // Shared health bar.
// Something
cur.write_u16::<LE>(0)?;
cur.write_u8(0)?;
} else {
let zeros = [0u8; 7];
cur.write(&zeros)?;
}
// Challange best times
cur.write_u32::<LE>(nikumaru.tick.try_into().unwrap_or(u32::MAX))?;
for save_set in 1..=26 {
let csp_mod = self.csp_mods.get(&save_set).unwrap_or(&default_csp_profile);
cur.write_u32::<LE>(csp_mod.time.try_into().unwrap_or(u32::MAX))?;
}
// TODO
// TODO: write some fields only in the v1.3 version mode
if format == SaveFormat::Switch {
cur.write_u8(0)?;
let something = [0u8; 0x77];
let something2 = [0u8; 6];
cur.write(&something)?;
cur.write_u16::<LE>(0)?; // P2 character unlocks
cur.write(&something2)?;
} else {
cur.write(&eggfish_killed)?;
let something = [0u8; 0x3d];
cur.write(&something)?;
let something2 = [0u8; 0xf20];
cur.write(&something2)?;
}
if let Some(path) = out_path {
// TODO
File::create(path)?.write(&buf)?;
} else {
user_create(ctx, save_path)?.write(&buf)?;
}
},
_ => todo!()
}
Ok(())
}
pub fn get_save_filename(format: &SaveFormat, slot: Option<SaveSlot>) -> String {
match format {
SaveFormat::Generic => "/save.json".to_owned(),
SaveFormat::Plus | SaveFormat::Switch => "/Profile.dat".to_owned(),
SaveFormat::Freeware => {
match slot {
Some(SaveSlot::MainGame(save_slot)) => if save_slot == 1 {
"/Profile.dat".to_owned()
} else {
format!("/Profile{}.dat", save_slot)
},
Some(SaveSlot::CSPMod(save_set, save_slot)) => format!("/Mod{}_Profile{}.dat", save_set, save_slot),
Some(SaveSlot::Mod(mod_id, save_slot)) => unimplemented!(),
_ => "/Profile.dat".to_owned()
}
}
}
}
pub fn upgrade(&mut self) {
log::debug!("DEBUG UPGRADE SAVE");
let initial_version = self.version;
if self.version != initial_version {
log::info!("Upgraded generic save from version {} to {}.", initial_version, self.version);
}
}
pub fn set_profile(&mut self, slot: SaveSlot, profile: GameProfile) {
log::debug!("Debug profile set: {:?}; {}", slot, profile.timestamp);
let prev_save: Option<GameProfile>;
match slot {
SaveSlot::MainGame(save_slot) => {
prev_save = self.game_profiles.insert(save_slot, profile);
},
SaveSlot::CSPMod(save_set, save_slot) => {
prev_save = self.csp_mods.entry(save_set)
.or_insert(CSPModProfile::default())
.profiles
.insert(save_slot, profile);
},
SaveSlot::Mod(ref mod_id, save_slot) => {
// TODO
prev_save = None;
unimplemented!();
}
}
if prev_save.is_none() {
self.patchset.insert(slot, SavePatch::Added);
} else {
self.patchset.insert(slot, SavePatch::Modified);
}
}
pub fn get_profile(&self, slot: SaveSlot) -> Option<&GameProfile> {
log::debug!("Debug profile get: {:?}", slot);
match slot {
SaveSlot::MainGame(save_slot) => self.game_profiles.get(&save_slot),
SaveSlot::CSPMod(save_set, save_slot) => {
if let Some(csp_mod) = self.csp_mods.get(&save_set) {
return csp_mod.profiles.get(&save_slot);
}
None
},
SaveSlot::Mod(mod_id, save_slot) => unimplemented!()
}
}
pub fn delete_profile(&mut self, ctx: &Context, slot: SaveSlot) {
log::debug!("Debug profile delete: {:?}", slot);
match slot {
SaveSlot::MainGame(save_slot) => {
let _ = self.game_profiles.remove(&save_slot);
},
SaveSlot::CSPMod(save_set, save_slot) => {
if let Some(csp_mod) = self.csp_mods.get_mut(&save_set) {
csp_mod.profiles.remove(&save_slot);
if csp_mod.is_empty() {
self.csp_mods.remove(&save_set);
}
}
},
SaveSlot::Mod(mod_id, save_slot) => unimplemented!()
}
self.patchset.insert(slot, SavePatch::Deleted);
}
pub fn is_empty(&self) -> bool {
if !self.game_profiles.is_empty() {
return false;
}
for (_, csp_mod) in self.csp_mods.iter() {
if csp_mod.is_empty() {
return false;
}
}
true
}
pub fn export(&mut self, state: &mut SharedGameState, ctx: &mut Context, format: SaveFormat, params: SaveParams, out_path: PathBuf) -> GameResult {
self.write_save(ctx, state, format, None, Some(out_path.clone()), &params)?;
log::trace!("Export format: {:?}.", format);
log::trace!("Export params: {:?}.", params);
log::trace!("Export path: {:?}.", out_path);
Ok(())
}
}

View file

@ -4,6 +4,7 @@ use crate::framework::filesystem::{user_create, user_open};
use crate::framework::gamepad::{Axis, AxisDirection, Button, PlayerControllerInputType};
use crate::framework::graphics::VSyncMode;
use crate::framework::keyboard::ScanCode;
use crate::game::profile::SaveFormat;
use crate::game::player::TargetPlayer;
use crate::game::shared_game_state::{CutsceneSkipMode, ScreenShakeIntensity, TimingMode, WindowMode};
use crate::input::combined_player_controller::CombinedPlayerController;
@ -87,6 +88,8 @@ pub struct Settings {
pub discord_rpc: bool,
#[serde(default = "default_true")]
pub allow_strafe: bool,
#[serde(default = "default_save_format")]
pub save_format: SaveFormat,
}
fn default_true() -> bool {
@ -95,7 +98,7 @@ fn default_true() -> bool {
#[inline(always)]
fn current_version() -> u32 {
25
26
}
#[inline(always)]
@ -171,6 +174,11 @@ fn default_cutscene_skip_mode() -> CutsceneSkipMode {
CutsceneSkipMode::Hold
}
#[inline(always)]
fn default_save_format() -> SaveFormat {
SaveFormat::Freeware
}
impl Settings {
pub fn load(ctx: &Context) -> GameResult<Settings> {
if let Ok(file) = user_open(ctx, "/settings.json") {
@ -359,6 +367,11 @@ impl Settings {
}
}
if self.version == 25 {
self.version = 26;
self.save_format = default_save_format();
}
if self.version != initial_version {
log::info!("Upgraded configuration file from version {} to {}.", initial_version, self.version);
}
@ -467,6 +480,7 @@ impl Default for Settings {
cutscene_skip_mode: CutsceneSkipMode::Hold,
discord_rpc: true,
allow_strafe: true,
save_format: default_save_format()
}
}
}

View file

@ -17,7 +17,7 @@ use crate::framework::{filesystem, graphics};
use crate::game::caret::{Caret, CaretType};
use crate::game::npc::NPCTable;
use crate::game::player::TargetPlayer;
use crate::game::profile::GameProfile;
use crate::game::profile::{GameProfile, SaveContainer, SaveFormat, SaveParams, SaveSlot};
use crate::game::scripting::tsc::credit_script::{CreditScript, CreditScriptVM};
use crate::game::scripting::tsc::text_script::{
ScriptMode, TextScript, TextScriptEncoding, TextScriptExecutionState, TextScriptVM,
@ -678,48 +678,60 @@ impl SharedGameState {
ctx: &mut Context,
target_player: Option<TargetPlayer>,
) -> GameResult {
/*
if let Some(save_path) = self.get_save_filename(self.save_slot) {
if let Ok(data) = filesystem::open_options(ctx, save_path, OpenOptions::new().write(true).create(true)) {
let profile = GameProfile::dump(self, game_scene, target_player);
profile.write_save(data)?;
//profile.write_save(data)?;
let mut save_container = SaveContainer::load(ctx)?;
save_container.set_profile(None, self.save_slot, profile);
save_container.write_save(ctx, SaveFormat::Generic, None, None);
} else {
log::warn!("Cannot open save file.");
}
} else {
log::info!("Mod has saves disabled.");
}
*/
if let Some(slot) = self.get_save_slot(self.save_slot) {
let profile = GameProfile::dump(self, game_scene, target_player);
let mut save_container = SaveContainer::load(ctx, self)?;
save_container.set_profile(slot, profile);
save_container.save(ctx, self, SaveParams::default())?;
} else {
log::info!("Mod has saves disabled.");
}
Ok(())
}
pub fn load_or_start_game(&mut self, ctx: &mut Context) -> GameResult {
if let Some(save_path) = self.get_save_filename(self.save_slot) {
if let Ok(data) = filesystem::user_open(ctx, save_path) {
match GameProfile::load_from_save(data) {
Ok(profile) => {
self.reset();
let mut next_scene = GameScene::new(self, ctx, profile.current_map as usize)?;
if let Some(slot) = self.get_save_slot(self.save_slot) {
if let Ok(save) = SaveContainer::load(ctx, self) {
if let Some(profile) = save.get_profile(slot) {
self.reset();
let mut next_scene = GameScene::new(self, ctx, profile.current_map as usize)?;
profile.apply(self, &mut next_scene, ctx);
profile.apply(self, &mut next_scene, ctx);
#[cfg(feature = "discord-rpc")]
self.discord_rpc.update_difficulty(self.difficulty)?;
#[cfg(feature = "discord-rpc")]
self.discord_rpc.update_difficulty(self.difficulty)?;
self.next_scene = Some(Box::new(next_scene));
return Ok(());
}
Err(e) => {
log::warn!("Failed to load save game, starting new one: {}", e);
}
self.next_scene = Some(Box::new(next_scene));
return Ok(());
}
} else {
log::warn!("No save game found, starting new one...");
}
} else {
log::info!("Mod has saves disabled.");
log::info!("Mod has saves disabled, starting new game...");
}
self.start_new_game(ctx)
self.start_new_game(ctx)?;
Ok(())
}
pub fn reset(&mut self) {
@ -859,6 +871,30 @@ impl SharedGameState {
}
}
pub fn get_save_slot(&mut self, slot: usize) -> Option<SaveSlot> {
if let Some(mod_path) = &self.mod_path {
if let Some(mod_info) = self.mod_list.get_mod_info_from_path(mod_path.clone()) {
log::debug!("Mod info get save slot: {:?}", mod_info);
if mod_info.id.starts_with(&"csmod_".to_string()) {
if mod_info.save_slot > 0 {
return Some(SaveSlot::CSPMod(mod_info.save_slot.try_into().unwrap(), slot));
} else if mod_info.save_slot < 0 {
// Mods with a negative save set(slot) has saves disabled.
return None;
}
// If mod uses save set 0, saves are stored in the main game set
} else {
return Some(SaveSlot::Mod(mod_info.id.clone(), slot));
}
} else {
return None;
}
}
Some(SaveSlot::MainGame(slot))
}
pub fn get_rec_filename(&self) -> String {
if let Some(mod_path) = &self.mod_path {
let name = self.mod_list.get_name_from_path(mod_path.to_string());

View file

@ -183,8 +183,6 @@ impl Weapon {
self.refire_timer = 4;
}
// todo lua hook
match self.wtype {
WeaponType::None => {}
WeaponType::Snake => self.tick_snake(player, player_id, bullet_manager, state),

View file

@ -86,8 +86,12 @@ impl Locale {
}
}
pub fn ts(&self, key: &str) -> String {
self.t(key).to_owned()
}
pub fn tt(&self, key: &str, args: &[(&str, &str)]) -> String {
let mut string = self.t(key).to_owned();
let mut string = self.ts(key);
for (key, value) in args.iter() {
string = string.replace(&format!("{{{}}}", key), &value);

View file

@ -1,4 +1,6 @@
use std::cell::Cell;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::common::{Color, Rect};
use crate::components::draw_common::{draw_number, Alignment};
@ -9,6 +11,7 @@ use crate::game::shared_game_state::{GameDifficulty, MenuCharacter, SharedGameSt
use crate::graphics::font::Font;
use crate::input::combined_menu_controller::CombinedMenuController;
use crate::menu::save_select_menu::MenuSaveInfo;
use crate::util::file_picker::{open_file_picker, FilePickerParams};
pub mod controls_menu;
pub mod coop_menu;
@ -17,6 +20,7 @@ pub mod save_select_menu;
pub mod settings_menu;
const MENU_MIN_PADDING: f32 = 30.0;
const MENU_DISABLED_COLOR: (u8, u8, u8, u8) = (0xa0, 0xa0, 0xff, 0xff);
#[derive(Clone, Debug)]
pub enum ControlMenuData {
@ -43,6 +47,7 @@ pub enum MenuEntry {
PlayerSkin,
Control(String, ControlMenuData),
Spacer(f64),
FilePicker(String, bool, FilePickerParams, Option<Vec<PathBuf>>), // text, display selected file names, params, selected files/dirs
}
impl MenuEntry {
@ -64,6 +69,7 @@ impl MenuEntry {
MenuEntry::PlayerSkin => 24.0,
MenuEntry::Control(_, _) => 16.0,
MenuEntry::Spacer(height) => *height,
MenuEntry::FilePicker(_, _, _, _) => 16.0,
}
}
@ -85,6 +91,7 @@ impl MenuEntry {
MenuEntry::PlayerSkin => true,
MenuEntry::Control(_, _) => true,
MenuEntry::Spacer(_) => false,
MenuEntry::FilePicker(_, _, _, _) => true,
}
}
}
@ -97,6 +104,7 @@ pub enum MenuSelectionResult<'a, T: std::cmp::PartialEq> {
Right(T, &'a mut MenuEntry, i16),
}
#[derive(Clone)]
pub struct Menu<T: std::cmp::PartialEq> {
pub x: isize,
pub y: isize,
@ -154,6 +162,26 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
}
}
pub fn get_entry(&self, id: T) -> Option<&MenuEntry> {
for i in 0..self.entries.len() {
if self.entries[i].0 == id {
return Some(&self.entries[i].1);
}
}
None
}
pub fn get_entry_mut(&mut self, id: T) -> Option<&mut MenuEntry> {
for i in 0..self.entries.len() {
if self.entries[i].0 == id {
return Some(&mut self.entries[i].1);
}
}
None
}
pub fn update_width(&mut self, state: &SharedGameState) {
let mut width = self.width as f32;
@ -216,7 +244,25 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
MenuEntry::NewSave => {}
MenuEntry::PlayerSkin => {}
MenuEntry::Control(_, _) => {}
MenuEntry::Spacer(_) => {}
MenuEntry::Spacer(_) => {},
MenuEntry::FilePicker(entry, display, _, selection) => {
let mut entry_with_selection = entry.clone();
if *display {
entry_with_selection.push_str(" ");
let filename = selection.as_ref()
.and_then(|files| files.first())
.and_then(|file| file.file_name())
.and_then(|filename| filename.to_str())
.unwrap_or(state.loc.t("common.choose_file"));
entry_with_selection.push_str(filename);
}
let entry_width = state.font.builder().compute_width(&entry_with_selection) + 32.0;
width = width.max(entry_width);
},
}
}
@ -353,21 +399,22 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
batch.draw(ctx)?;
let options_x = if self.center_options {
let mut longest_option_width = 20.0;
let mut longest_entry_width = 20.0;
for (_, entry) in &self.entries {
match entry {
let text_width = match entry {
MenuEntry::Options(text, _, _) | MenuEntry::Active(text) => {
let text_width = state.font.builder().compute_width(text) + 32.0;
if text_width > longest_option_width {
longest_option_width = text_width;
}
state.font.builder().compute_width(text) + 32.0
}
_ => {}
_ => 0.0
};
if text_width > longest_entry_width {
longest_entry_width = text_width;
}
}
(state.canvas_size.0 / 2.0) - (longest_option_width / 2.0)
(state.canvas_size.0 / 2.0) - (longest_entry_width / 2.0)
} else {
self.x as f32
};
@ -471,7 +518,7 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
let mut builder = state.font.builder().position(x, local_y);
if !*is_white {
builder = builder.color((0xa0, 0xa0, 0xff, 0xff));
builder = builder.color(MENU_DISABLED_COLOR);
}
builder.draw(&line, ctx, &state.constants, &mut state.texture_set)?;
@ -482,7 +529,7 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
y += entry.height() as f32 * (lines.len() - 1) as f32;
}
MenuEntry::Disabled(name) => {
state.font.builder().position(self.x as f32 + 20.0, y).color((0xa0, 0xa0, 0xff, 0xff)).draw(
state.font.builder().position(self.x as f32 + 20.0, y).color(MENU_DISABLED_COLOR).draw(
name,
ctx,
&state.constants,
@ -548,7 +595,7 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
.font
.builder()
.position(self.x as f32 + 20.0, y + 16.0)
.color((0xc0, 0xc0, 0xff, 0xff))
.color(MENU_DISABLED_COLOR)
.draw(description_text, ctx, &state.constants, &mut state.texture_set)?;
}
MenuEntry::OptionsBar(name, percent) => {
@ -739,6 +786,42 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
}
}
}
MenuEntry::FilePicker(name, display, _, selection) => {
// TODO: fix text is rendered out of the text box bounds if the filename is too long.
let name_text_len = state.font.builder().compute_width(name);
let value_text = selection.as_ref()
.and_then(|files| files.first())
.and_then(|file| file.file_name())
.and_then(|filename| filename.to_str())
.unwrap_or(state.loc.t("common.choose_file"));
// Draw the entry as disabled on unsupported platforms
let text_color = if cfg!(not(any(target_os = "android", target_os = "horizon"))) {
state.font.builder().get_color()
} else {
MENU_DISABLED_COLOR
};
state.font.builder().position(self.x as f32 + 20.0, y)
.draw(
name,
ctx,
&state.constants,
&mut state.texture_set,
)?;
if *display {
state.font.builder()
.position(self.x as f32 + 25.0 + name_text_len, y)
.draw(
value_text,
ctx,
&state.constants,
&mut state.texture_set,
)?;
}
}
_ => {}
}
@ -882,6 +965,18 @@ impl<T: std::cmp::PartialEq + std::default::Default + Clone> Menu<T> {
return MenuSelectionResult::Selected(idx, entry);
}
}
MenuEntry::FilePicker(_, _, params, selection) => {
if self.selected == idx && controller.trigger_ok()
|| state.touch_controls.consume_click_in(entry_bounds)
{
state.sound_manager.play_sfx(18);
self.selected = idx.clone();
*selection = open_file_picker(params);
return MenuSelectionResult::Selected(idx, entry);
}
}
_ => {}
}
}

View file

@ -1,12 +1,15 @@
use pelite::pe::imports::Import;
use crate::framework::context::Context;
use crate::framework::error::GameResult;
use crate::framework::filesystem;
use crate::game::profile::GameProfile;
use crate::game::profile::{GameProfile, SaveContainer, SaveFormat, SaveParams, SaveSlot};
use crate::game::shared_game_state::{GameDifficulty, SharedGameState};
use crate::input::combined_menu_controller::CombinedMenuController;
use crate::menu::coop_menu::PlayerCountMenu;
use crate::menu::MenuEntry;
use crate::menu::{Menu, MenuSelectionResult};
use crate::util::file_picker::FilePickerParams;
#[derive(Clone, Copy)]
pub struct MenuSaveInfo {
@ -33,12 +36,14 @@ pub enum CurrentMenu {
PlayerCountMenu,
DeleteConfirm,
LoadConfirm,
ImportExport,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum SaveMenuEntry {
Load(usize),
New(usize),
ImportExport,
Back,
}
@ -87,8 +92,143 @@ impl Default for LoadConfirmMenuEntry {
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ImportExportMenuEntry {
Format,
Import,
Export,
Back,
}
#[derive(Clone, Copy)]
enum ImportExportLocation {
Filesystem,
// TODO: add some Switch emulators
}
impl Default for ImportExportLocation {
fn default() -> Self {
Self::Filesystem
}
}
#[derive(Clone)]
pub struct MenuExportInfo {
pub location: ImportExportLocation,
pub format: SaveFormat,
pub picker_params: FilePickerParams,
pub save_params: SaveParams,
}
impl Default for MenuExportInfo {
fn default() -> Self {
Self {
location: ImportExportLocation::Filesystem,
format: SaveFormat::Freeware,
picker_params: FilePickerParams::new(),
save_params: SaveParams::default()
}
}
}
impl MenuExportInfo {
pub fn new() -> Self {
Self::default()
}
pub fn set_picker_params(&mut self, state: &SharedGameState, is_export: bool) {
let filename = SaveContainer::get_save_filename(&self.format, None).split_off(1);
let mut params = match self.format {
SaveFormat::Freeware => {
FilePickerParams::new()
.pick_dirs(true)
}
SaveFormat::Plus | SaveFormat::Switch => {
FilePickerParams::new()
.file_name(Some(filename))
.filter(state.loc.ts("menus.save_manage_menu.file_filters.plus"), vec![
"dat".to_owned()
])
}
SaveFormat::Generic => {
FilePickerParams::new()
.file_name(Some(filename))
.filter(state.loc.ts("menus.save_manage_menu.file_filters.generic"), vec![
"json".to_owned()
])
}
};
if is_export {
params = params.save(true);
};
if let Some(fs_container) = &state.fs_container {
params = params.starting_dir(Some(fs_container.user_path.clone()));
}
self.picker_params = params;
}
}
impl ImportExportMenuEntry {
fn format_from_value(val: usize) -> Option<SaveFormat> {
match val {
0 => None, // Auto
1 => Some(SaveFormat::Freeware),
2 => Some(SaveFormat::Plus),
3 => Some(SaveFormat::Switch),
_ => unreachable!()
}
}
fn fpicker_from_format(state: &SharedGameState, format: SaveFormat, is_export: bool) -> FilePickerParams {
let filename = SaveContainer::get_save_filename(&format, None).split_off(1);
let mut params = match format {
SaveFormat::Freeware => {
FilePickerParams::new()
.pick_dirs(true)
}
SaveFormat::Plus | SaveFormat::Switch => {
FilePickerParams::new()
.file_name(Some(filename))
.filter(state.loc.ts("menus.save_manage_menu.file_filters.plus"), vec![
"dat".to_owned()
])
}
SaveFormat::Generic => {
FilePickerParams::new()
.file_name(Some(filename))
.filter(state.loc.ts("menus.save_manage_menu.file_filters.generic"), vec![
"json".to_owned()
])
}
};
if is_export && format != SaveFormat::Freeware {
params = params.save(true);
};
if let Some(fs_container) = &state.fs_container {
params = params.starting_dir(Some(fs_container.user_path.clone()));
}
params
}
}
impl Default for ImportExportMenuEntry {
fn default() -> Self {
ImportExportMenuEntry::Format
}
}
pub struct SaveSelectMenu {
pub saves: [MenuSaveInfo; 3],
pub export_info: MenuExportInfo,
current_menu: CurrentMenu,
save_menu: Menu<SaveMenuEntry>,
save_detailed: Menu<usize>,
@ -97,12 +237,14 @@ pub struct SaveSelectMenu {
delete_confirm: Menu<DeleteConfirmMenuEntry>,
load_confirm: Menu<LoadConfirmMenuEntry>,
skip_difficulty_menu: bool,
import_export_menu: Menu<ImportExportMenuEntry>,
}
impl SaveSelectMenu {
pub fn new() -> SaveSelectMenu {
SaveSelectMenu {
saves: [MenuSaveInfo::default(); 3],
export_info: MenuExportInfo::default(),
current_menu: CurrentMenu::SaveMenu,
save_menu: Menu::new(0, 0, 230, 0),
coop_menu: PlayerCountMenu::new(),
@ -111,10 +253,11 @@ impl SaveSelectMenu {
delete_confirm: Menu::new(0, 0, 75, 0),
load_confirm: Menu::new(0, 0, 75, 0),
skip_difficulty_menu: false,
import_export_menu: Menu::new(0, 0, 75, 0),
}
}
pub fn init(&mut self, state: &mut SharedGameState, ctx: &Context) -> GameResult {
pub fn init(&mut self, state: &mut SharedGameState, ctx: &mut Context) -> GameResult {
self.save_menu = Menu::new(0, 0, 230, 0);
self.save_detailed = Menu::new(0, 0, 230, 0);
self.coop_menu.on_title = true;
@ -122,26 +265,36 @@ impl SaveSelectMenu {
self.difficulty_menu = Menu::new(0, 0, 130, 0);
self.delete_confirm = Menu::new(0, 0, 75, 0);
self.load_confirm = Menu::new(0, 0, 75, 0);
self.import_export_menu = Menu::new(0, 0, 75, 0);
self.skip_difficulty_menu = false;
let mut should_mutate_selection = true;
let save_container = SaveContainer::load(ctx, state)?;
for (iter, save) in self.saves.iter_mut().enumerate() {
if let Ok(data) = filesystem::user_open(ctx, state.get_save_filename(iter + 1).unwrap_or(String::new())) {
let loaded_save = GameProfile::load_from_save(data)?;
if let Some(slot) = state.get_save_slot(iter + 1) {
if let Some(loaded_profile) = save_container.get_profile(slot) {
log::trace!("Loading save select menu. Iter - {}. {}", iter, loaded_profile.is_empty());
save.current_map = loaded_profile.current_map;
save.max_life = loaded_profile.max_life;
save.life = loaded_profile.life;
save.weapon_count = loaded_profile.weapon_data.iter().filter(|weapon| weapon.weapon_id != 0).count();
save.weapon_id = loaded_profile.weapon_data.map(|weapon| weapon.weapon_id);
save.difficulty = loaded_profile.difficulty;
save.current_map = loaded_save.current_map;
save.max_life = loaded_save.max_life;
save.life = loaded_save.life;
save.weapon_count = loaded_save.weapon_data.iter().filter(|weapon| weapon.weapon_id != 0).count();
save.weapon_id = loaded_save.weapon_data.map(|weapon| weapon.weapon_id);
save.difficulty = loaded_save.difficulty;
self.save_menu.push_entry(SaveMenuEntry::Load(iter), MenuEntry::SaveData(*save));
self.save_menu.push_entry(SaveMenuEntry::Load(iter), MenuEntry::SaveData(*save));
if should_mutate_selection {
should_mutate_selection = false;
self.save_menu.selected = SaveMenuEntry::Load(iter);
}
} else {
self.save_menu.push_entry(SaveMenuEntry::New(iter), MenuEntry::NewSave);
if should_mutate_selection {
should_mutate_selection = false;
self.save_menu.selected = SaveMenuEntry::Load(iter);
if should_mutate_selection {
should_mutate_selection = false;
self.save_menu.selected = SaveMenuEntry::New(iter);
}
}
} else {
self.save_menu.push_entry(SaveMenuEntry::New(iter), MenuEntry::NewSave);
@ -153,6 +306,7 @@ impl SaveSelectMenu {
}
}
self.save_menu.push_entry(SaveMenuEntry::ImportExport, MenuEntry::Active(state.loc.ts("menus.save_manage_menu.import_export_save")));
self.save_menu.push_entry(SaveMenuEntry::Back, MenuEntry::Active(state.loc.t("common.back").to_owned()));
self.difficulty_menu.push_entry(
@ -176,8 +330,6 @@ impl SaveSelectMenu {
self.difficulty_menu.selected = DifficultyMenuEntry::Difficulty(GameDifficulty::Normal);
//self.coop_menu.init(state, ctx);
self.delete_confirm.push_entry(
DeleteConfirmMenuEntry::Title,
MenuEntry::Disabled(state.loc.t("menus.save_menu.delete_confirm").to_owned()),
@ -206,6 +358,37 @@ impl SaveSelectMenu {
self.save_detailed.push_entry(0, MenuEntry::SaveDataSingle(save));
}
self.export_info.format = state.settings.save_format;
//self.export_info.set_picker_params(state, true);
self.import_export_menu.push_entry(
ImportExportMenuEntry::Format,
MenuEntry::Options(
state.loc.ts("menus.save_manage_menu.save_format.entry"),
0,
vec![
state.loc.ts("menus.save_manage_menu.save_format.auto"),
state.loc.ts("menus.save_manage_menu.save_format.freeware"),
state.loc.ts("menus.save_manage_menu.save_format.plus"),
state.loc.ts("menus.save_manage_menu.save_format.switch"),
]
)
);
self.import_export_menu.push_entry(
ImportExportMenuEntry::Import,
MenuEntry::Disabled(state.loc.ts("menus.save_manage_menu.action_type.import"))
);
self.import_export_menu.push_entry(
ImportExportMenuEntry::Export,
MenuEntry::FilePicker(
state.loc.ts("menus.save_manage_menu.action_type.export"),
false,
ImportExportMenuEntry::fpicker_from_format(state, self.export_info.format, true),
None
)
);
self.import_export_menu.push_entry(ImportExportMenuEntry::Back, MenuEntry::Active(state.loc.ts("common.back")));
self.update_sizes(state);
Ok(())
@ -241,6 +424,11 @@ impl SaveSelectMenu {
self.save_detailed.update_height(state);
self.save_detailed.x = ((state.canvas_size.0 - self.save_detailed.width as f32) / 2.0).floor() as isize;
self.save_detailed.y = -40 + ((state.canvas_size.1 - self.save_detailed.height as f32) / 2.0).floor() as isize;
self.import_export_menu.update_width(state);
self.import_export_menu.update_height(state);
self.import_export_menu.x = ((state.canvas_size.0 - self.import_export_menu.width as f32) / 2.0).floor() as isize;
self.import_export_menu.y = ((state.canvas_size.1 - self.import_export_menu.height as f32) / 2.0).floor() as isize;
}
pub fn tick(
@ -267,17 +455,17 @@ impl SaveSelectMenu {
MenuSelectionResult::Selected(SaveMenuEntry::Load(slot), _) => {
state.save_slot = slot + 1;
if let Ok(_) =
filesystem::user_open(ctx, state.get_save_filename(state.save_slot).unwrap_or(String::new()))
{
if let (_, MenuEntry::SaveData(save)) = self.save_menu.entries[slot] {
self.save_detailed.entries.clear();
self.save_detailed.push_entry(0, MenuEntry::SaveDataSingle(save));
}
self.current_menu = CurrentMenu::LoadConfirm;
self.load_confirm.selected = LoadConfirmMenuEntry::Start;
if let (_, MenuEntry::SaveData(save)) = self.save_menu.entries[slot] {
self.save_detailed.entries.clear();
self.save_detailed.push_entry(0, MenuEntry::SaveDataSingle(save));
}
self.current_menu = CurrentMenu::LoadConfirm;
self.load_confirm.selected = LoadConfirmMenuEntry::Start;
}
MenuSelectionResult::Selected(SaveMenuEntry::ImportExport, _) => {
self.current_menu = CurrentMenu::ImportExport;
self.import_export_menu.selected = ImportExportMenuEntry::Back;
}
_ => (),
},
@ -308,7 +496,9 @@ impl SaveSelectMenu {
match self.save_menu.selected {
SaveMenuEntry::Load(slot) => {
state.sound_manager.play_sfx(17); // Player Death sfx
filesystem::user_delete(ctx, state.get_save_filename(slot + 1).unwrap_or(String::new()))?;
let mut save = SaveContainer::load(ctx, state)?;
save.delete_profile(&ctx, state.get_save_slot(slot + 1).unwrap());
save.save(ctx, state, SaveParams::default())?;
}
_ => (),
}
@ -340,6 +530,67 @@ impl SaveSelectMenu {
}
_ => (),
},
CurrentMenu::ImportExport => match self.import_export_menu.tick(controller, state) {
MenuSelectionResult::Selected(ImportExportMenuEntry::Format, toggle)
| MenuSelectionResult::Right(ImportExportMenuEntry::Format, toggle, _) => {
if let MenuEntry::Options(_, value, _) = toggle {
*value = match *value {
0..3 => *value + 1,
3 => 0,
_ => unreachable!(),
};
let format = ImportExportMenuEntry::format_from_value(*value).unwrap_or(state.settings.save_format);
self.export_info.format = format;
self.export_info.picker_params = ImportExportMenuEntry::fpicker_from_format(state, format, true);
let export_file_picker = self.import_export_menu.get_entry_mut(ImportExportMenuEntry::Export).unwrap();
if let MenuEntry::FilePicker(_, _, params, _) = export_file_picker {
*params = self.export_info.picker_params.clone();
}
}
}
MenuSelectionResult::Left(ImportExportMenuEntry::Format, toggle, _) => {
if let MenuEntry::Options(_, value, _) = toggle {
*value = match *value {
1..=3 => *value - 1,
0 => 3,
_ => unreachable!(),
};
let format = ImportExportMenuEntry::format_from_value(*value).unwrap_or(state.settings.save_format);
self.export_info.format = format;
self.export_info.picker_params = ImportExportMenuEntry::fpicker_from_format(state, format, true);
let export_file_picker = self.import_export_menu.get_entry_mut(ImportExportMenuEntry::Export).unwrap();
if let MenuEntry::FilePicker(_, _, params, _) = export_file_picker {
*params = self.export_info.picker_params.clone();
}
}
}
MenuSelectionResult::Selected(ImportExportMenuEntry::Export, entry) => {
let out_path = if let MenuEntry::FilePicker(_, _, _, selection) = entry {
if let Some(location) = selection {
location.first()
} else {
None
}
} else { None };
if out_path.is_none() {
// Export path is not selected, so we break export operation
return Ok(());
}
let mut save_container = SaveContainer::load(ctx, state)?;
save_container.export(state, ctx, self.export_info.format, self.export_info.save_params.clone(), out_path.unwrap().clone())?;
}
MenuSelectionResult::Selected(ImportExportMenuEntry::Back, _) | MenuSelectionResult::Canceled => {
self.current_menu = CurrentMenu::SaveMenu;
}
_ => (),
},
}
Ok(())
@ -364,6 +615,9 @@ impl SaveSelectMenu {
self.save_detailed.draw(state, ctx)?;
self.load_confirm.draw(state, ctx)?;
}
CurrentMenu::ImportExport => {
self.import_export_menu.draw(state, ctx)?;
}
}
Ok(())
}

View file

@ -4,6 +4,7 @@ use crate::framework::context::Context;
use crate::framework::error::GameResult;
use crate::framework::graphics::VSyncMode;
use crate::framework::{filesystem, graphics};
use crate::game::profile::{SaveFormat};
use crate::game::shared_game_state::{CutsceneSkipMode, ScreenShakeIntensity, SharedGameState, TimingMode, WindowMode};
use crate::graphics::font::Font;
use crate::input::combined_menu_controller::CombinedMenuController;
@ -118,6 +119,7 @@ enum BehaviorMenuEntry {
CutsceneSkipMode,
#[cfg(feature = "discord-rpc")]
DiscordRPC,
SaveFormat,
Back,
}
@ -595,6 +597,19 @@ impl SettingsMenu {
),
);
self.behavior.push_entry(
BehaviorMenuEntry::SaveFormat,
MenuEntry::Options(
state.loc.t("menus.options_menu.behavior_menu.save_format.entry").to_owned(),
state.settings.save_format as usize,
vec![
state.loc.t("menus.options_menu.behavior_menu.save_format.freeware").to_owned(),
state.loc.t("menus.options_menu.behavior_menu.save_format.plus").to_owned(),
state.loc.t("menus.options_menu.behavior_menu.save_format.switch").to_owned(),
],
),
);
self.behavior.push_entry(BehaviorMenuEntry::Back, MenuEntry::Active(state.loc.t("common.back").to_owned()));
self.links.push_entry(LinksMenuEntry::Back, MenuEntry::Active(state.loc.t("common.back").to_owned()));
@ -1056,6 +1071,35 @@ impl SettingsMenu {
}
}
}
MenuSelectionResult::Selected(BehaviorMenuEntry::SaveFormat, toggle)
| MenuSelectionResult::Right(BehaviorMenuEntry::SaveFormat, toggle, _) => {
if let MenuEntry::Options(_, value, _) = toggle {
let (new_mode, new_value) = match state.settings.save_format {
SaveFormat::Freeware => (SaveFormat::Plus, 1),
SaveFormat::Plus => (SaveFormat::Switch, 2),
SaveFormat::Switch => (SaveFormat::Freeware, 0),
_ => unreachable!()
};
state.settings.save_format = new_mode;
*value = new_value;
let _ = state.settings.save(ctx);
}
}
MenuSelectionResult::Left(BehaviorMenuEntry::SaveFormat, toggle, _) => {
if let MenuEntry::Options(_, value, _) = toggle {
let (new_mode, new_value) = match state.settings.save_format {
SaveFormat::Freeware => (SaveFormat::Switch, 2),
SaveFormat::Plus => (SaveFormat::Freeware, 0),
SaveFormat::Switch => (SaveFormat::Plus, 1),
_ => unreachable!()
};
state.settings.save_format = new_mode;
*value = new_value;
let _ = state.settings.save(ctx);
}
}
MenuSelectionResult::Selected(BehaviorMenuEntry::Back, _) | MenuSelectionResult::Canceled => {
self.current = CurrentMenu::MainMenu;
}

View file

@ -8,7 +8,7 @@ use crate::framework::error::GameResult;
use crate::framework::filesystem;
use crate::mod_requirements::ModRequirements;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct ModInfo {
pub id: String,
pub requirement: Requirement,
@ -173,6 +173,7 @@ impl ModList {
description = "mod.txt not found".to_string();
}
log::debug!("CSP Mod Loaded: {:?}", ModInfo { id: id.clone(), requirement, priority, save_slot, path: path.clone(), name: name.clone(), description: description.clone(), valid });
mods.push(ModInfo { id, requirement, priority, save_slot, path, name, description, valid })
}
}
@ -182,6 +183,14 @@ impl ModList {
Ok(ModList { mods })
}
pub fn get_mod_info_from_path(&self, mod_path: String) -> Option<ModInfo> {
if let Some(mod_sel) = self.mods.iter().find(|x| x.path == mod_path) {
Some(mod_sel.clone())
} else {
None
}
}
pub fn get_save_from_path(&self, mod_path: String) -> i32 {
if let Some(mod_sel) = self.mods.iter().find(|x| x.path == mod_path) {
mod_sel.save_slot

86
src/util/file_picker.rs Normal file
View file

@ -0,0 +1,86 @@
use std::collections::HashMap;
use std::path::PathBuf;
#[cfg(not(any(target_os = "android", target_os = "horizon")))]
use rfd::FileDialog;
#[derive(Clone, Debug, Default)]
pub struct FilePickerParams {
pub save: bool, /// Opens save file dialog
pub multiple: bool,
pub pick_dirs: bool,
pub file_name: Option<String>, /// Starting file name.
pub starting_dir: Option<PathBuf>,
pub filters: HashMap<String, Vec<String>>, // filter name -> file extensions
}
impl FilePickerParams {
pub fn new() -> Self {
Self::default()
}
pub fn save(mut self, save: bool) -> Self {
self.save = save;
self
}
pub fn multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
pub fn pick_dirs(mut self, pick_dirs: bool) -> Self {
self.pick_dirs = pick_dirs;
self
}
pub fn file_name(mut self, file_name: Option<String>) -> Self {
self.file_name = file_name;
self
}
pub fn starting_dir(mut self, starting_dir: Option<PathBuf>) -> Self {
self.starting_dir = starting_dir;
self
}
pub fn filter(mut self, name: String, ext: Vec<String>) -> Self {
self.filters.insert(name, ext);
self
}
}
#[cfg(not(any(target_os = "android", target_os = "horizon")))]
pub fn open_file_picker(params: &FilePickerParams) -> Option<Vec<PathBuf>> {
log::trace!("Call a file picker dialog with params: {:?}", params.clone());
let mut dialog = FileDialog::new();
if let Some(filename) = params.file_name.clone() {
dialog = dialog.set_file_name(filename);
}
if let Some(dir) = params.starting_dir.clone() {
dialog = dialog.set_directory(dir);
}
for filter in params.filters.iter() {
dialog = dialog.add_filter(filter.0, filter.1);
}
let selected_files = match (params.pick_dirs, params.multiple, params.save) {
(_, _, true) => dialog.save_file().map(|path| vec![path]),
(true, false, _) => dialog.pick_folder().map(|path| vec![path]),
(true, true, _) => dialog.pick_folders(),
(false, false, _) => dialog.pick_file().map(|path| vec![path]),
(false, true, _) => dialog.pick_files(),
};
log::trace!("Selected file entries: {:?}", selected_files.clone());
return selected_files;
}
#[cfg(any(target_os = "android", target_os = "horizon"))]
pub fn open_file_picker(params: &FileChooserParams) -> Option<Vec<PathBuf>> {
None
}

View file

@ -1,3 +1,4 @@
pub mod bitvec;
pub mod browser;
pub mod file_picker;
pub mod rng;