diff --git a/Cargo.lock b/Cargo.lock index 578d3781f91bf453160bd6ec0a9702f0d6cb03e3..b44149f9d5a0922784aafe49e0dcf6a744767f98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1330,6 +1330,26 @@ dependencies = [ "parking_lot_core 0.9.5", ] +[[package]] +name = "directories" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "discard" version = "1.0.4" @@ -1601,6 +1621,7 @@ dependencies = [ "bevy", "bevy_ecs_tilemap", "bevy_tweening", + "directories", "fake", "fastrand", "iyes_loopless", @@ -3118,6 +3139,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom 0.2.8", + "redox_syscall", + "thiserror", +] + [[package]] name = "regex" version = "1.7.0" diff --git a/game_core/Cargo.toml b/game_core/Cargo.toml index 190e7f60ddaf758c2732b6da3141c087777ca5ad..0ce21e8922f2f69c0d4411c53e7a4d1863bfaad1 100644 --- a/game_core/Cargo.toml +++ b/game_core/Cargo.toml @@ -32,6 +32,7 @@ ldtk_rust = "0.6.0" kayak_ui.workspace = true kayak_font.workspace = true fake = "2.5.0" +directories = "4.0.1" #remote_events = { git = "https://lab.lcr.gr/microhacks/micro-bevy-remote-events.git", rev = "be0c6b43a73e4c5e7ece20797e3d6f59340147b4"} diff --git a/game_core/src/lib.rs b/game_core/src/lib.rs index 97f8201f9a76fef42fbb3884b22251854ab5f70e..93c9644d3e6a73c914adc6bf0e543a3bd0d57789 100644 --- a/game_core/src/lib.rs +++ b/game_core/src/lib.rs @@ -1,6 +1,9 @@ +#![feature(result_option_inspect)] + pub mod assets; pub mod graphics; pub mod multiplayer; +pub mod persistance; pub mod splash_screen; pub mod states; pub mod system; diff --git a/game_core/src/main.rs b/game_core/src/main.rs index a8a988f7c1af0b495623a483e55028b59d76e517..3202daa3da84910b24465324d52dc9f2099b45c2 100644 --- a/game_core/src/main.rs +++ b/game_core/src/main.rs @@ -23,5 +23,6 @@ fn main() { .add_plugin(game_core::graphics::GraphicsPlugin) .add_plugins(micro_banimate::BanimatePluginGroup) .add_plugins(AdventUIPlugins) + .add_plugin(game_core::persistance::PersistencePlugin) .run(); } diff --git a/game_core/src/persistance/mod.rs b/game_core/src/persistance/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..07c7842479a2e42e88c159045f77049dc6c64c2e --- /dev/null +++ b/game_core/src/persistance/mod.rs @@ -0,0 +1,35 @@ +mod save_file; + +mod __plugin { + use bevy::prelude::*; + use iyes_loopless::prelude::ConditionSet; + + use crate::persistance::save_file::{handle_save_event, sync_state_to_persistence}; + use crate::persistance::{LoadFileEvent, SaveFileEvent}; + use crate::system::flow::AppState; + + pub struct PersistencePlugin; + impl Plugin for PersistencePlugin { + fn build(&self, app: &mut App) { + app.add_event::<SaveFileEvent>() + .add_event::<LoadFileEvent>() + .add_system_set( + ConditionSet::new() + .run_in_state(AppState::InGame) + .with_system(sync_state_to_persistence) + .with_system(handle_save_event) + .with_system( + |input: Res<Input<KeyCode>>, mut events: EventWriter<SaveFileEvent>| { + if input.just_released(KeyCode::Space) { + events.send(SaveFileEvent { filename: None }); + } + }, + ) + .into(), + ); + } + } +} + +pub use __plugin::PersistencePlugin; +pub use save_file::{LoadFileEvent, PersistenceState, SaveFileEvent}; diff --git a/game_core/src/persistance/save_file.rs b/game_core/src/persistance/save_file.rs new file mode 100644 index 0000000000000000000000000000000000000000..a537eddece3aaf16fed6ecf56f7695aeb5cbcbf7 --- /dev/null +++ b/game_core/src/persistance/save_file.rs @@ -0,0 +1,118 @@ +use std::fs; +use std::io::Write; +use std::ops::Deref; +use std::path::Path; + +use bevy::math::Vec3; +use bevy::prelude::*; +use bevy::utils::HashMap; +use serde::{Deserialize, Serialize}; + +use crate::states::Player; +use crate::world::{ + CurrentResidence, EncounterState, HungerState, TradingState, TravelPath, TravelTarget, +}; + +#[derive(Serialize, Deserialize, Debug, Resource)] +pub struct PersistenceState { + pub player_location: Vec3, + pub player_inventory: TradingState, + pub player_hunger: HungerState, + pub travel_path: Option<TravelPath>, + pub travel_target: Option<TravelTarget>, + pub previous_location: CurrentResidence, + pub encounter_state: EncounterState, + pub town_states: HashMap<String, (TradingState, HungerState)>, +} + +#[derive(Clone, Debug)] +pub struct SaveFileEvent { + pub filename: Option<String>, +} + +#[derive(Clone, Debug)] +pub struct LoadFileEvent { + pub filename: Option<String>, +} + +pub fn sync_state_to_persistence( + mut commands: Commands, + mut state: Option<ResMut<PersistenceState>>, + player_query: Query< + ( + &Transform, + Option<&TravelPath>, + Option<&TravelTarget>, + &CurrentResidence, + ), + With<Player>, + >, + encounters: Res<EncounterState>, + hunger: Option<Res<HungerState>>, + trading: Option<Res<TradingState>>, +) { + match state { + Some(mut state) => { + if let Some((transform, maybe_travel, maybe_target, residence)) = + player_query.iter().next() + { + *state = PersistenceState { + player_location: transform.translation, + player_inventory: trading.map(|r| r.clone()).unwrap_or_default(), + player_hunger: hunger.map(|r| r.clone()).unwrap_or_default(), + travel_path: maybe_travel.cloned(), + travel_target: maybe_target.cloned(), + previous_location: residence.clone(), + encounter_state: encounters.clone(), + town_states: Default::default(), + }; + } + } + None => { + if let Some((transform, maybe_travel, maybe_target, residence)) = + player_query.iter().next() + { + commands.insert_resource(PersistenceState { + player_location: transform.translation, + player_inventory: trading.map(|r| r.clone()).unwrap_or_default(), + player_hunger: hunger.map(|r| r.clone()).unwrap_or_default(), + travel_path: maybe_travel.cloned(), + travel_target: maybe_target.cloned(), + previous_location: residence.clone(), + encounter_state: encounters.clone(), + town_states: Default::default(), + }) + } + } + } +} + +pub fn handle_save_event( + mut events: ResMut<Events<SaveFileEvent>>, + state: Option<Res<PersistenceState>>, +) { + let root_data_dir = directories::ProjectDirs::from("com", "microhacks", "TraderTales") + .expect("Failed to get project dir"); + + if let Some(state) = state { + for event in events.drain() { + std::fs::create_dir_all(root_data_dir.data_dir()).expect("Failed to create data dir"); + + match fs::File::create(match event.filename { + Some(name) => root_data_dir.data_dir().join(name), + None => root_data_dir.data_dir().join("autosave.json"), + }) { + Ok(file) => { + serde_json::to_writer_pretty(file, &*state) + .inspect_err(|err| { + log::error!("{}", err); + }) + .expect("Failed to create save data"); + } + Err(e) => { + log::error!("{}", e); + } + } + } + } +} diff --git a/game_core/src/states/game_state.rs b/game_core/src/states/game_state.rs index 6ffe00fa7b214c0c3bdc6769388de9b072664c09..25fc138656f6f5f12f14e08d9cb2941b297cb1cd 100644 --- a/game_core/src/states/game_state.rs +++ b/game_core/src/states/game_state.rs @@ -1,4 +1,16 @@ +use std::time::Duration; + use bevy::prelude::Component; +use micro_musicbox::prelude::{AudioEasing, AudioTween, MusicBox}; + +use crate::assets::AssetHandles; #[derive(Component, Debug, Default, Copy, Clone)] pub struct Player; + +pub fn on_enter_game(mut musicbox: MusicBox<AssetHandles>) { + musicbox.fade_in_music( + "bgm", + AudioTween::new(Duration::from_secs(2), AudioEasing::Linear), + ); +} diff --git a/game_core/src/states/menu_state.rs b/game_core/src/states/menu_state.rs index f6a1012c3f3826a07d6557148d3ccdb55c3fb56e..bc37c7906051a2b225e2db43dbc84da779f1c51b 100644 --- a/game_core/src/states/menu_state.rs +++ b/game_core/src/states/menu_state.rs @@ -17,11 +17,6 @@ pub fn spawn_menu_entities( assets: Res<AssetHandles>, mut musicbox: MusicBox<AssetHandles>, ) { - musicbox.fade_in_music( - "bgm", - AudioTween::new(Duration::from_secs(2), AudioEasing::Linear), - ); - commands.spawn(( SpriteBundle { texture: assets.image("menu_background"), diff --git a/game_core/src/states/mod.rs b/game_core/src/states/mod.rs index 01a9f906629e6a20cfc43a3491d57e0ab1c8803d..5a5214ce80f59f6306a8029b34b3bce165255e40 100644 --- a/game_core/src/states/mod.rs +++ b/game_core/src/states/mod.rs @@ -12,6 +12,7 @@ impl Plugin for StatesPlugin { fn build(&self, app: &mut App) { app.add_enter_system(AppState::Menu, menu_state::spawn_menu_entities) .add_exit_system(AppState::Menu, menu_state::despawn_menu_entities) + .add_enter_system(AppState::InGame, game_state::on_enter_game) .add_system_set( ConditionSet::new() .run_in_state(AppState::Menu) diff --git a/game_core/src/ui/components/inset_icon.rs b/game_core/src/ui/components/inset_icon.rs index ac4b68c49b0c819b37e3b2fb99472c7192a12391..89814c20538f55b165fd44f2508194ffad1924fc 100644 --- a/game_core/src/ui/components/inset_icon.rs +++ b/game_core/src/ui/components/inset_icon.rs @@ -1,12 +1,13 @@ use bevy::prelude::*; use kayak_ui::prelude::*; use kayak_ui::widgets::{KImage, KImageBundle, TextureAtlasBundle, TextureAtlasProps}; +use serde::{Deserialize, Serialize}; use crate::assets::AssetHandles; use crate::parent_widget; use crate::ui::prelude::*; -#[derive(Clone, Default, Eq, PartialEq)] +#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum IconContent { Image(String), Atlas(String, usize), diff --git a/game_core/src/world/encounters.rs b/game_core/src/world/encounters.rs index ac3e67dfb24815c4c6924447161801d609573977..4964ff5591db679de8a0536085d98d181ff8f1c9 100644 --- a/game_core/src/world/encounters.rs +++ b/game_core/src/world/encounters.rs @@ -5,6 +5,7 @@ use fake::faker::lorem::en::{Paragraph, Paragraphs}; use fake::Fake; use ldtk_rust::{EntityInstance, FieldInstance}; use num_traits::AsPrimitive; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::states::Player; @@ -114,7 +115,7 @@ impl From<Vec<EncounterZone>> for WorldZones { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum EncounterOutcome { GainResource { resource_type: String, @@ -133,13 +134,13 @@ pub enum EncounterOutcome { }, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct EncounterOption { pub label: String, pub outcome: Vec<EncounterOutcome>, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Encounter { pub title: String, pub description: String, @@ -185,7 +186,7 @@ pub fn gen_encounter() -> Encounter { } } -#[derive(Clone, Default, Resource, Debug)] +#[derive(Clone, Default, Resource, Debug, Serialize, Deserialize)] pub enum EncounterState { #[default] NoEncounter, diff --git a/game_core/src/world/mod.rs b/game_core/src/world/mod.rs index 96ca28d423581da9ed8473e97717b298e4f2a56d..addfc269f2905ef6cdc81ddf1c8e8fbfd7f047c6 100644 --- a/game_core/src/world/mod.rs +++ b/game_core/src/world/mod.rs @@ -10,6 +10,7 @@ mod encounters; mod generators; mod spawning; mod towns; +mod trading; mod travel; mod utils; mod world_query; @@ -20,6 +21,7 @@ impl Plugin for WorldPlugin { app.init_resource::<TownPaths>() .init_resource::<WorldZones>() .init_resource::<EncounterState>() + .add_event::<PopulateWorldEvent>() .add_enter_system(AppState::InGame, |mut commands: Commands| { commands.insert_resource(ActiveLevel::new("Grantswaith")); }) @@ -28,6 +30,7 @@ impl Plugin for WorldPlugin { .run_in_state(AppState::InGame) // .with_system(debug::create_tombstones) .with_system(spawning::spawn_world_data) + .with_system(spawning::populate_world) .with_system(travel::tick_travelling_merchant) .with_system(encounters::notify_new_zone) @@ -38,4 +41,7 @@ impl Plugin for WorldPlugin { pub use encounters::{EncounterState, WorldZones}; pub use towns::{CurrentResidence, PathingResult, TownPaths, TravelPath, TravelTarget}; +pub use trading::{HungerState, ItemName, TradeGood, TradingState}; pub use world_query::{CameraBounds, MapQuery}; + +use crate::world::spawning::PopulateWorldEvent; diff --git a/game_core/src/world/spawning.rs b/game_core/src/world/spawning.rs index ce1448848c8b2b203d3b81b7fccd8d5ec302d104..8ffe127fab49669d0602f561336502b26e351032 100644 --- a/game_core/src/world/spawning.rs +++ b/game_core/src/world/spawning.rs @@ -5,12 +5,17 @@ use num_traits::AsPrimitive; use serde_json::Value; use crate::assets::{AssetHandles, LdtkProject, LevelIndex, TilesetIndex}; +use crate::persistance::PersistenceState; use crate::states::Player; use crate::system::camera::ChaseCam; use crate::world::encounters::WorldZones; use crate::world::towns::{CurrentResidence, TownPaths}; use crate::world::utils::{grid_to_px, px_to_grid, ActiveLevel, WorldLinked, TILE_SCALE_F32}; use crate::world::world_query::MapQuery; +use crate::world::{TravelPath, TravelTarget}; + +#[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] +pub struct PopulateWorldEvent; pub fn spawn_world_data( mut commands: Commands, @@ -20,6 +25,7 @@ pub fn spawn_world_data( level_index: Res<LevelIndex>, tileset_index: Res<TilesetIndex>, mut last_spawned_level: Local<String>, + mut events: EventWriter<PopulateWorldEvent>, ) { let mut active_level = match active_level { Some(l) => l, @@ -149,41 +155,116 @@ pub fn spawn_world_data( )); }); + events.send(PopulateWorldEvent); + } +} + +#[derive(Resource)] +pub struct PendingLoadState(PersistenceState); + +pub fn populate_world( + mut commands: Commands, + mut events: ResMut<Events<PopulateWorldEvent>>, + mut active_level: Option<ResMut<ActiveLevel>>, + assets: Res<AssetHandles>, + level_index: Res<LevelIndex>, + existing_player: Query<Entity, With<Player>>, + pending_load: Option<Res<PendingLoadState>>, +) { + let should_populate = !events + .drain() + .collect::<Vec<PopulateWorldEvent>>() + .is_empty(); + + if should_populate { + let mut active_level = match active_level { + Some(l) => l, + None => return, + }; + + let level = match level_index.get(&active_level.map) { + Some(l) => l, + None => return, + }; + let trade_routes = TownPaths::from(MapQuery::get_entities_of(level)); - let random_start = trade_routes.routes.get(&String::from("The Royal Lampoon")); - - if let Some(start) = random_start { - if let Some(route) = start.routes.values().next() { - let point = route.nodes[0]; - - commands.spawn(( - SpriteSheetBundle { - transform: Transform::from_xyz( - grid_to_px(point.tile_x), - level.px_hei as f32 - grid_to_px(point.tile_y), - 400.0, - ), - texture_atlas: assets.atlas("icons"), - sprite: TextureAtlasSprite { - index: 0, - ..Default::default() - }, + + let start = trade_routes + .routes + .get(&String::from("The Royal Lampoon")) + .unwrap(); + + if let Some(route) = start.routes.values().next() { + let point = route.nodes[0]; + + let mut cmds = commands.spawn(( + SpriteSheetBundle { + transform: pending_load + .as_ref() + .map(|pending| Transform::from_translation(pending.0.player_location)) + .unwrap_or_else(|| { + Transform::from_xyz( + grid_to_px(point.tile_x), + level.px_hei as f32 - grid_to_px(point.tile_y), + 400.0, + ) + }), + texture_atlas: assets.atlas("icons"), + sprite: TextureAtlasSprite { + index: 0, ..Default::default() }, - start - .create_route_bundle_for( - start - .routes - .keys() - .nth(fastrand::usize(0..start.routes.len())) - .cloned() - .unwrap(), - level, - ) - .unwrap(), - Player, - ChaseCam, - )); + ..Default::default() + }, + start + .create_route_bundle_for( + start + .routes + .keys() + .nth(fastrand::usize(0..start.routes.len())) + .cloned() + .unwrap(), + level, + ) + .unwrap(), + Player, + ChaseCam, + )); + + match &pending_load { + Some(ref state) => { + match ( + &state.0.previous_location, + &state.0.travel_path, + &state.0.travel_target, + ) { + (res, Some(path), Some(target)) => { + cmds.insert((res.clone(), path.clone(), target.clone())); + } + (res, None, Some(target)) => { + cmds.insert((res.clone(), target.clone())); + } + (res, Some(path), None) => { + cmds.insert((res.clone(), path.clone())); + } + (res, None, None) => { + cmds.insert(res.clone()); + } + } + } + None => { + if let Some(bundle) = start.create_route_bundle_for( + start + .routes + .keys() + .nth(fastrand::usize(0..start.routes.len())) + .cloned() + .unwrap(), + level, + ) { + cmds.insert(bundle); + } + } } } @@ -191,5 +272,20 @@ pub fn spawn_world_data( commands.insert_resource(trade_routes); commands.insert_resource(world_zones); + + commands.insert_resource( + pending_load + .as_ref() + .map(|pending| &pending.0.player_inventory) + .cloned() + .unwrap_or_default(), + ); + commands.insert_resource( + pending_load + .as_ref() + .map(|pending| &pending.0.player_hunger) + .cloned() + .unwrap_or_default(), + ); } } diff --git a/game_core/src/world/towns.rs b/game_core/src/world/towns.rs index 67705f35b18fceac8cce7d5a81860f15df67a5f7..a2a0cb8bb375d48839a8d2a0194f2e37287e222a 100644 --- a/game_core/src/world/towns.rs +++ b/game_core/src/world/towns.rs @@ -11,7 +11,7 @@ pub type Town = String; /// Stores the ID of the most recent town that has been inhabited, /// as well as the current travel state -#[derive(Component)] +#[derive(Component, Serialize, Deserialize, Debug, Clone)] pub enum CurrentResidence { TravellingFrom(String), RestingAt(String), @@ -31,10 +31,10 @@ impl CurrentResidence { } } -#[derive(Component)] +#[derive(Component, Serialize, Deserialize, Debug, Clone)] pub struct TravelTarget(pub String); -#[derive(Component)] +#[derive(Component, Serialize, Deserialize, Debug, Clone)] pub struct TravelPath { pub path: Vec<Vec2>, last_index: usize, diff --git a/game_core/src/world/trading.rs b/game_core/src/world/trading.rs new file mode 100644 index 0000000000000000000000000000000000000000..212ebc20cdef8844a7459cb629303a2a461da87d --- /dev/null +++ b/game_core/src/world/trading.rs @@ -0,0 +1,96 @@ +use bevy::prelude::Resource; +use bevy::utils::hashbrown::hash_map::Entry; +use bevy::utils::HashMap; +use num_traits::AsPrimitive; +use serde::{Deserialize, Serialize}; + +use crate::ui::components::IconContent; + +pub type ItemName = String; + +#[derive(Resource, Clone, Debug, Default, Serialize, Deserialize)] +pub struct TradingState { + pub items: HashMap<ItemName, usize>, + pub gold: isize, +} + +#[derive(Resource, Clone, Debug, Default, Serialize, Deserialize)] +pub struct HungerState { + pub sustenance: usize, + pub starvation_ticks: f32, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct TradeGood { + pub name: String, + pub icon: IconContent, +} + +impl TradingState { + pub fn spend_gold(&mut self, amount: impl AsPrimitive<isize>) -> bool { + if self.gold < amount.as_() { + false + } else { + self.adjust_gold(amount); + true + } + } + + pub fn adjust_gold(&mut self, adjustment: impl AsPrimitive<isize>) { + self.gold += adjustment.as_(); + } + + pub fn remove_items( + &mut self, + identifier: impl ToString, + amount: impl AsPrimitive<usize>, + ) -> bool { + match self.items.entry(identifier.to_string()) { + Entry::Occupied(mut e) => { + let amount = amount.as_(); + if e.get() >= &amount { + *e.get_mut() -= amount; + if e.get() == &0 { + e.remove(); + } + true + } else { + false + } + } + Entry::Vacant(_) => false, + } + } + + pub fn add_items(&mut self, identifier: impl ToString, amount: impl AsPrimitive<usize>) { + *self.items.entry(identifier.to_string()).or_insert(0) += amount.as_() + } + + pub fn try_buy_items( + &mut self, + cost: impl AsPrimitive<isize>, + identifier: impl ToString, + amount: impl AsPrimitive<usize>, + ) -> bool { + if self.spend_gold(cost) { + self.add_items(identifier, amount); + true + } else { + false + } + } + + pub fn try_sell_items( + &mut self, + value: impl AsPrimitive<isize>, + identifier: impl ToString, + amount: impl AsPrimitive<usize>, + ) -> bool { + if self.remove_items(identifier, amount) { + self.adjust_gold(value); + true + } else { + false + } + } +}