diff --git a/rsrc/dialogs/confirm-overwrite.xml b/rsrc/dialogs/confirm-overwrite.xml new file mode 100644 index 000000000..c497c85d0 --- /dev/null +++ b/rsrc/dialogs/confirm-overwrite.xml @@ -0,0 +1,9 @@ + + + + + + Are you sure you want to overwrite {File}? + + + diff --git a/rsrc/dialogs/pick-save.xml b/rsrc/dialogs/pick-save.xml new file mode 100644 index 000000000..60bb160bb --- /dev/null +++ b/rsrc/dialogs/pick-save.xml @@ -0,0 +1,69 @@ + + + + + Load a party: + Load an autosave from {Folder}: + Save your party: + + + NewParty + .exg + {File} + + + + + + + Avg. Level: {Lv}
{LastSaved}

{Scenario}

{Location}
+ + + + <- Newer! + + {File} + + + + + + + Avg. Level: {Lv}
{LastSaved}

{Scenario}

{Location}
+ + + + <- Newer! + + {File} + + + + + + + Avg. Level: {Lv}
{LastSaved}

{Scenario}

{Location}
+ + + + <- Newer! + + {File} + + + + + + + Avg. Level: {Lv}
{LastSaved}

{Scenario}

{Location}
+ + + + <- Newer! +
+
+ + +
diff --git a/rsrc/dialogs/pref-autosave.xml b/rsrc/dialogs/pref-autosave.xml new file mode 100644 index 000000000..8f2ba63f3 --- /dev/null +++ b/rsrc/dialogs/pref-autosave.xml @@ -0,0 +1,20 @@ + + + + + + Autosave Preferences + + Max autosave files: + + Autosave when: + The party finishes resting + The party spends a long time waiting in town/a dungeon + The party eats a full meal while wandering + The party enters town/a dungeon + The party exits town/a dungeon + The party finishes combat outdoors + + + + diff --git a/rsrc/dialogs/preferences.xml b/rsrc/dialogs/preferences.xml index 7180de883..fed52bf62 100644 --- a/rsrc/dialogs/preferences.xml +++ b/rsrc/dialogs/preferences.xml @@ -49,7 +49,12 @@ (Holding Shift while using directional keys will do the opposite.) Miscellaneous: No Sounds - Show room descriptions more than once + Use in-game save file browser + Autosave + + Show room descriptions more than once Make game easier (monsters much weaker) Fewer wandering monsters Skip splash screen on startup diff --git a/rsrc/schemas/dialog.xsd b/rsrc/schemas/dialog.xsd index a1bafb632..f5274e7ed 100644 --- a/rsrc/schemas/dialog.xsd +++ b/rsrc/schemas/dialog.xsd @@ -386,7 +386,7 @@ - + diff --git a/src/dialogxml/widgets/control.cpp b/src/dialogxml/widgets/control.cpp index 1b38a51ea..e652c4825 100644 --- a/src/dialogxml/widgets/control.cpp +++ b/src/dialogxml/widgets/control.cpp @@ -260,6 +260,7 @@ void cControl::playClickSound(){ } bool cControl::handleClick(location, cFramerateLimiter& fps_limiter){ + if(!visible) return false; sf::Event e; bool done = false, clicked = false; getWindow().setActive(); diff --git a/src/dialogxml/widgets/stack.cpp b/src/dialogxml/widgets/stack.cpp index 56b8cb711..d34ebe5dd 100644 --- a/src/dialogxml/widgets/stack.cpp +++ b/src/dialogxml/widgets/stack.cpp @@ -13,6 +13,7 @@ #include "message.hpp" #include "pict.hpp" #include "scrollbar.hpp" +#include "mathutil.hpp" #include bool cStack::hasChild(std::string id) const { @@ -84,6 +85,17 @@ bool cStack::setPage(size_t n) { return !failed; } +void cStack::changeSelectedPage(int dir, bool loop) { + curPage += dir; + if(loop){ + if(curPage < 0) curPage += nPages; + else if(curPage >= nPages) curPage -= nPages; + }else{ + curPage = minmax(0, nPages - 1, curPage); + } + setPage(curPage); +} + size_t cStack::getPage() const { return curPage; } diff --git a/src/dialogxml/widgets/stack.hpp b/src/dialogxml/widgets/stack.hpp index d8c645ce0..c6d491e34 100644 --- a/src/dialogxml/widgets/stack.hpp +++ b/src/dialogxml/widgets/stack.hpp @@ -57,6 +57,10 @@ class cStack : public cContainer { /// @param The new page number /// @return false if the page could not be changed, usually due to a focus handler bool setPage(size_t n); + /// Page forward or backward in the stack + /// @param dir Usually -1 or 1 + /// @param loop Beyond the first and last page, loop to the other side + void changeSelectedPage(int dir, bool loop = true); /// Get the current page the stack is displaying. /// @return The current page number size_t getPage() const; diff --git a/src/fileio/fileio.cpp b/src/fileio/fileio.cpp index 7558cc5a8..639523aeb 100644 --- a/src/fileio/fileio.cpp +++ b/src/fileio/fileio.cpp @@ -33,7 +33,7 @@ bool mac_is_intel(){ } return _mac_is_intel; } -fs::path progDir, tempDir, scenDir, replayDir; +fs::path progDir, tempDir, scenDir, replayDir, saveDir; // This is here to avoid unnecessarily duplicating it in platform-specific files. cursor_type Cursor::current = sword_curs; @@ -81,6 +81,9 @@ void init_directories(const char* exec_path) { replayDir = tempDir/"Replays"; fs::create_directories(replayDir); + saveDir = tempDir/"Saves"; + fs::create_directories(saveDir); + add_resmgr_paths(tempDir/"data"); tempDir /= "Temporary Files"; diff --git a/src/fileio/fileio.hpp b/src/fileio/fileio.hpp index 1ea9f4481..6cbacd44e 100644 --- a/src/fileio/fileio.hpp +++ b/src/fileio/fileio.hpp @@ -9,8 +9,10 @@ #ifndef BOE_FILEIO_HPP #define BOE_FILEIO_HPP +#include #include #include +#include #include #include #include @@ -27,8 +29,17 @@ bool load_scenario(fs::path file_to_load, cScenario& scenario, bool only_header fs::path nav_get_or_decode_party(); fs::path nav_put_or_temp_party(fs::path def = ""); -bool load_party(fs::path file_to_load, cUniverse& univ); +fs::path os_file_picker(bool saving); +// The game implements a fancy file picker, the editors just call the OS picker. +extern fs::path run_file_picker(bool saving); + +const std::set save_extensions = {".exg", ".boe", ".SAV", ".mac"}; +// Return a directory's files sorted by last modified time +std::vector> sorted_file_mtimes(fs::path dir, std::set valid_extensions = save_extensions); + +bool load_party(fs::path file_to_load, cUniverse& univ, bool record = true); bool save_party(cUniverse& univ, bool save_as = false); +bool save_party_force(cUniverse& univ, fs::path file); void init_directories(const char* exec_path); diff --git a/src/fileio/fileio_party.cpp b/src/fileio/fileio_party.cpp index 3cf8b160b..f32d99b18 100644 --- a/src/fileio/fileio_party.cpp +++ b/src/fileio/fileio_party.cpp @@ -21,6 +21,7 @@ #include "fileio/tagfile.hpp" #include "fileio/tarball.hpp" #include "replay.hpp" +#include "game/boe.dlgutil.hpp" extern bool mac_is_intel(); extern fs::path progDir, tempDir; @@ -39,6 +40,8 @@ fs::path nav_get_or_decode_party() { decode_file(next_action.GetText(), tempDir / "temp.exg"); return tempDir / "temp.exg"; }else{ + // TODO if the save is not in the saves folder, prompt about moving it in, + // and return the moved path? return nav_get_party(); } } @@ -51,7 +54,14 @@ fs::path nav_put_or_temp_party(fs::path def) { } } -bool load_party(fs::path file_to_load, cUniverse& univ){ +fs::path os_file_picker(bool saving) { + if(saving) + return nav_put_or_temp_party(); + else + return nav_get_or_decode_party(); +} + +bool load_party(fs::path file_to_load, cUniverse& univ, bool record){ bool town_restore = false; bool maps_there = false; bool in_scen = false; @@ -157,7 +167,7 @@ bool load_party(fs::path file_to_load, cUniverse& univ){ break; } - if(recording && result){ + if(recording && record && result){ record_action("load_party", encode_file(file_to_load), true); } @@ -304,7 +314,6 @@ bool load_party_v1(fs::path file_to_load, cUniverse& real_univ, bool town_restor return true; } -extern fs::path scenDir; bool load_party_v2(fs::path file_to_load, cUniverse& real_univ){ igzstream zin(file_to_load.string().c_str()); tarball partyIn; @@ -442,9 +451,12 @@ bool load_party_v2(fs::path file_to_load, cUniverse& real_univ){ return true; } -static bool save_party_const(const cUniverse& univ, bool save_as) { +static bool save_party_const(const cUniverse& univ, bool save_as, fs::path dest_file = "") { // Make sure it has the proper file extension - fs::path dest_file = univ.file; + if(dest_file.empty()){ + dest_file = univ.file; + } + if(dest_file.extension() != ".exg"){ dest_file += ".exg"; } @@ -546,9 +558,30 @@ bool save_party(cUniverse& univ, bool save_as) { // univ.file can be empty for prefab parties, so a file browser might be needed // even for a regular save. if(save_as || univ.file.empty()){ - univ.file = nav_put_or_temp_party(); + univ.file = run_file_picker(true); } // A file wasn't chosen if(univ.file.empty()) return false; return save_party_const(univ, save_as); } + +bool save_party_force(cUniverse& univ, fs::path file) { + return save_party_const(univ, false, file); +} + +static bool compare_mtime(std::pair a, std::pair b) { + return std::difftime(a.second, b.second) > 0; +} + +std::vector> sorted_file_mtimes(fs::path dir, std::set valid_extensions){ + std::vector> files; + for(fs::directory_iterator it{dir}; it != fs::directory_iterator{}; ++it) { + fs::path file = *it; + if(valid_extensions.count(file.extension())){ + files.push_back(std::make_pair(file, last_write_time(file))); + } + } + + std::sort(files.begin(), files.end(), compare_mtime); + return files; +} \ No newline at end of file diff --git a/src/game/boe.actions.cpp b/src/game/boe.actions.cpp index 140a46798..dd2569ec3 100644 --- a/src/game/boe.actions.cpp +++ b/src/game/boe.actions.cpp @@ -467,7 +467,7 @@ void handle_rest(bool& need_redraw, bool& need_reprint) { pause(25); univ.party.food -= 6; while(i < 50) { - increase_age(); + increase_age(false); if(get_ran(1,1,2) == 2) do_monsters(); if(get_ran(1,1,70) == 10) @@ -485,6 +485,7 @@ void handle_rest(bool& need_redraw, bool& need_reprint) { if(i == 50) { do_rest(1200, get_ran(5,1,10), 50); add_string_to_buf(" Rest successful."); + try_auto_save("RestComplete"); put_pc_screen(); pause(25); } @@ -1112,7 +1113,7 @@ static void handle_town_wait(bool& need_redraw, bool& need_reprint) { } for(int i = 0; i < 80 && !party_sees_a_monst(); i++){ - increase_age(); + increase_age(false); do_monsters(); do_monster_turn(); int make_wand = get_ran(1,1,160 - univ.town->difficulty); @@ -1132,6 +1133,9 @@ static void handle_town_wait(bool& need_redraw, bool& need_reprint) { redraw_screen(REFRESH_NONE); } put_pc_screen(); + if(!party_sees_a_monst()){ + try_auto_save("TownWaitComplete"); + } } void handle_wait(bool& did_something, bool& need_redraw, bool& need_reprint) { @@ -2900,6 +2904,7 @@ bool handle_scroll(const sf::Event& event) { } void do_load() { + // TODO this needs to be changed/moved because a picker dialog opens now!!! // Edge case: Replay can be cut off before a file is chosen, // or party selection can be canceled, and this will cause // a crash trying to decode a party @@ -2907,7 +2912,7 @@ void do_load() { return; } - fs::path file_to_load = nav_get_or_decode_party(); + fs::path file_to_load = run_file_picker(false); if(file_to_load.empty()) return; if(!load_party(file_to_load, univ)) return; @@ -3060,7 +3065,7 @@ void do_rest(long length, int hp_restore, int mp_restore) { adjust_spell_menus(); } -void increase_age() { +void increase_age(bool eating_trigger_autosave) { short how_many_short = 0,r1; @@ -3188,6 +3193,7 @@ void increase_age() { else { play_sound(6); add_string_to_buf("You eat."); + if(eating_trigger_autosave) try_auto_save("Eat"); } } @@ -3421,7 +3427,7 @@ void handle_death() { return; } else if(choice == "load") { - fs::path file_to_load = nav_get_or_decode_party(); + fs::path file_to_load = run_file_picker(false); if(!file_to_load.empty()){ if(load_party(file_to_load, univ)){ finish_load_party(); diff --git a/src/game/boe.actions.hpp b/src/game/boe.actions.hpp index 215b5ba83..bae9d7084 100644 --- a/src/game/boe.actions.hpp +++ b/src/game/boe.actions.hpp @@ -29,7 +29,7 @@ void do_load(); void post_load(); void do_save(bool save_as = false); void do_abort(); -void increase_age(); +void increase_age(bool eating_trigger_autosave = true); void handle_hunting(); void switch_pc(short which); void handle_drop_pc(); diff --git a/src/game/boe.combat.cpp b/src/game/boe.combat.cpp index 9a7993924..1f68247f4 100644 --- a/src/game/boe.combat.cpp +++ b/src/game/boe.combat.cpp @@ -6,6 +6,7 @@ #include "universe/universe.hpp" #include "boe.monster.hpp" #include "boe.graphics.hpp" +#include "boe.fileio.hpp" #include "boe.locutils.hpp" #include "boe.newgraph.hpp" #include "boe.infodlg.hpp" @@ -4682,8 +4683,10 @@ bool hit_end_c_button() { } } - if(end_ok) + if(end_ok){ end_combat(); + if(which_combat_type == 0) try_auto_save("EndOutdoorCombat"); + } return end_ok; } diff --git a/src/game/boe.dlgutil.cpp b/src/game/boe.dlgutil.cpp index 20ccefa80..f96c09529 100644 --- a/src/game/boe.dlgutil.cpp +++ b/src/game/boe.dlgutil.cpp @@ -1,5 +1,9 @@ #include +#include +#include +#include +#include #include "boe.global.hpp" @@ -30,6 +34,7 @@ #include "fileio/fileio.hpp" #include "fileio/resmgr/res_dialog.hpp" #include "fileio/resmgr/res_strings.hpp" +#include "dialogxml/widgets/field.hpp" #include "dialogxml/widgets/scrollbar.hpp" #include "dialogxml/widgets/button.hpp" #include "dialogxml/widgets/ledgroup.hpp" @@ -1197,6 +1202,69 @@ void save_prefs(){ } } +void autosave_preferences(cDialog* parent); + +static bool prefs_autosave_event_filter(cDialog& me, std::string id, eKeyMod mod) { + if(id == "autosave-toggle"){ + dynamic_cast(me["autosave-toggle"]).defaultClickHandler(me, id, mod); + if(dynamic_cast(me["autosave-toggle"]).getState() != led_off){ + me["autosave-details"].show(); + } + else{ + me["autosave-details"].hide(); + } + }else if(id == "autosave-details"){ + autosave_preferences(&me); + } + // Messy: Using the same handler for the autosave buttons in the main preferences, + // and the buttons in the autosave details window. + else{ + bool did_cancel = false; + bool save_ok = false; + + if(id == "okay") { + save_ok = me.toast(true); + } else if(id == "cancel") { + me.toast(false); + did_cancel = true; + } + + if(!did_cancel && save_ok) { + set_pref("Autosave_Max", std::stoi(dynamic_cast(me["max-files"]).getText())); + for(cDialogIterator iter = me.begin(); iter != me.end(); ++iter){ + std::string id = iter->first; + cControl* ctrl = iter->second; + cLed* led = dynamic_cast(ctrl); + if(led != nullptr){ + set_pref("Autosave_" + id, led->getState() != led_off); + } + } + save_prefs(); + } + } + return true; +} + +void autosave_preferences(cDialog* parent) { + cDialog prefsDlog(*ResMgr::dialogs.get("pref-autosave"), parent); + + int max_autosaves = get_int_pref("Autosave_Max", MAX_AUTOSAVE_DEFAULT); + cTextField& max_files = dynamic_cast(prefsDlog["max-files"]); + max_files.setText(std::to_string(max_autosaves)); + + for(cDialogIterator iter = prefsDlog.begin(); iter != prefsDlog.end(); ++iter){ + std::string id = iter->first; + cControl* ctrl = iter->second; + cLed* led = dynamic_cast(ctrl); + if(led != nullptr){ + led->setState(get_bool_pref("Autosave_" + id, true) ? led_red : led_off); + } + } + prefsDlog.attachClickHandlers(&prefs_autosave_event_filter, {"okay", "cancel"}); + prefsDlog.run(); +} + + static bool prefs_event_filter (cDialog& me, std::string id, eKeyMod) { bool did_cancel = false; @@ -1217,7 +1285,10 @@ static bool prefs_event_filter (cDialog& me, std::string id, eKeyMod) { else if(cur_display_mode == "br") set_pref("DisplayMode", 4); else if(cur_display_mode == "win") set_pref("DisplayMode", 5); set_pref("PlaySounds", dynamic_cast(me["nosound"]).getState() == led_off); + set_pref("DirectionalKeyScrolling", dynamic_cast(me["screen-shift"]).getState() != led_off); + set_pref("FancyFilePicker", dynamic_cast(me["fancypicker"]).getState() != led_off); + set_pref("Autosave", dynamic_cast(me["autosave-toggle"]).getState() != led_off); set_pref("RepeatRoomDescriptions", dynamic_cast(me["repeatdesc"]).getState() != led_off); set_pref("ShowInstantHelp", dynamic_cast(me["nohelp"]).getState() == led_off); @@ -1285,6 +1356,7 @@ void pick_preferences(bool record) { set_cursor(sword_curs); cDialog prefsDlog(*ResMgr::dialogs.get("preferences")); + prefsDlog.attachClickHandlers(&prefs_autosave_event_filter, {"autosave-toggle", "autosave-details"}); prefsDlog.attachClickHandlers(&prefs_event_filter, {"okay", "cancel"}); prefsDlog.attachClickHandlers(&reset_help, {"resethelp"}); @@ -1311,6 +1383,11 @@ void pick_preferences(bool record) { } dynamic_cast(prefsDlog["nosound"]).setState(get_bool_pref("PlaySounds", true) ? led_off : led_red); + dynamic_cast(prefsDlog["fancypicker"]).setState(get_bool_pref("FancyFilePicker", true) ? led_red : led_off); + bool autosave_on = get_bool_pref("Autosave", true); + dynamic_cast(prefsDlog["autosave-toggle"]).setState(autosave_on ? led_red : led_off); + if(!autosave_on) + prefsDlog["autosave-details"].hide(); dynamic_cast(prefsDlog["repeatdesc"]).setState(get_bool_pref("RepeatRoomDescriptions") ? led_red : led_off); dynamic_cast(prefsDlog["nohelp"]).setState(get_bool_pref("ShowInstantHelp", true) ? led_off : led_red); if(overall_mode == MODE_STARTUP && !party_in_memory) { @@ -1599,13 +1676,9 @@ class cChooseScenario { return true; } - bool doSelectPage(int dir) { + bool changeSelectedPage(int dir) { auto& stk = dynamic_cast(me["list"]); - int curPage = stk.getPage(), nPages = stk.getPageCount(); - curPage += dir; - if(curPage < 0) curPage += nPages; - else if(curPage >= nPages) curPage -= nPages; - stk.setPage(curPage); + stk.changeSelectedPage(dir, true); return true; } @@ -1645,8 +1718,8 @@ class cChooseScenario { set_cursor(sword_curs); me["cancel"].attachClickHandler(std::bind(&cChooseScenario::doCancel, this)); - me["next"].attachClickHandler(std::bind(&cChooseScenario::doSelectPage, this, 1)); - me["prev"].attachClickHandler(std::bind(&cChooseScenario::doSelectPage, this, -1)); + me["next"].attachClickHandler(std::bind(&cChooseScenario::changeSelectedPage, this, 1)); + me["prev"].attachClickHandler(std::bind(&cChooseScenario::changeSelectedPage, this, -1)); me["start1"].attachClickHandler(std::bind(&cChooseScenario::doSelectScenario, this, 0)); me["start2"].attachClickHandler(std::bind(&cChooseScenario::doSelectScenario, this, 1)); me["start3"].attachClickHandler(std::bind(&cChooseScenario::doSelectScenario, this, 2)); @@ -1676,3 +1749,348 @@ class cChooseScenario { scen_header_type pick_a_scen() { return cChooseScenario().run(); } + +extern fs::path saveDir; + +class cFilePicker { + const int SLOTS_PER_PAGE = 4; + int parties_per_page; + + fs::path save_folder; + bool picking_auto; + bool saving; + cDialog me; + cStack& get_stack() { return dynamic_cast(me["list"]); } + std::string template_info_str; + + std::vector> save_file_mtimes; + // We have to load the parties to get PC graphics, average level, location, etc. + // But we shouldn't load them all at once, because the amount is unlimited. + std::vector save_files; + int pages_populated = 0; + int saves_loaded = 0; + + void init_pages() { + save_file_mtimes = sorted_file_mtimes(save_folder); + save_files.resize(save_file_mtimes.size()); + + cStack& stk = get_stack(); + int num_pages = ceil((float)save_file_mtimes.size() / parties_per_page); + stk.setPageCount(num_pages); + } + + void empty_slot(int idx) { + std::string suffix = std::to_string(idx+1); + me["file" + suffix].setText(""); + me["pc" + suffix + "a"].hide(); + me["pc" + suffix + "b"].hide(); + me["pc" + suffix + "c"].hide(); + me["pc" + suffix + "d"].hide(); + me["pc" + suffix + "e"].hide(); + me["pc" + suffix + "f"].hide(); + me["info" + suffix].hide(); + me["load" + suffix].hide(); + me["auto" + suffix].hide(); + me["auto" + suffix + "-more-recent"].hide(); + } + + void dummy_slot(int idx) { + std::string suffix = std::to_string(idx+1); + me["file" + suffix].setText(""); + me["pc" + suffix + "a"].hide(); + me["pc" + suffix + "b"].hide(); + me["pc" + suffix + "c"].hide(); + me["pc" + suffix + "d"].hide(); + me["pc" + suffix + "e"].hide(); + me["pc" + suffix + "f"].hide(); + me["info" + suffix].hide(); + me["auto" + suffix + "-more-recent"].hide(); + } + + void populate_slot(int idx, fs::path file, std::time_t mtime, cUniverse& party_univ) { + if(replaying){ + dummy_slot(idx); + return; + } + std::string suffix = std::to_string(idx+1); + me["file" + suffix].setText(file.filename().string()); + + // Populate PC graphics + for(int i = 0; i < 6; ++i){ + std::string key = "pc" + suffix; + key.push_back((char)('a' + i)); + cPict& pict = dynamic_cast(me[key]); + if(party_univ.party[i].main_status != eMainStatus::ABSENT) { + pic_num_t pic = party_univ.party[i].which_graphic; + // TODO Apparently PCs are supposed to be able to have custom graphics and monster graphics, + // but the special node to Create PC only allows choosing preset PC pictures, so I don't + // think it's even possible to get a non-preset-PC graphic into a party without directly + // editing the tagfile. For now, I'm only rendering preset PCs. + if(pic >= 1000){ + }else if(pic >= 100){ + }else{ + pict.setPict(pic, PIC_PC); + } + pict.show(); + }else{ + pict.hide(); + } + } + + // Populate party info + std::string party_info = template_info_str; + + short level_sum = 0; + short num_pc = 0; + for(int i = 0; i < 6; ++i){ + if(party_univ.party[i].main_status != eMainStatus::ABSENT) { + level_sum += party_univ.party[i].level; + ++num_pc; + } + } + short avg_level = round((float)(level_sum / num_pc)); + boost::replace_first(party_info, "{Lv}", std::to_string(avg_level)); + if(num_pc == 1){ + boost::replace_first(party_info, "Avg. ", ""); + } + + auto local_time = *std::localtime(&mtime); + std::stringstream last_modified; + last_modified << std::put_time(&local_time, "%h %d, %Y %I:%M %p"); + boost::replace_first(party_info, "{LastSaved}", last_modified.str()); + + if(!party_univ.party.scen_name.empty()){ + boost::replace_first(party_info, "{Scenario}", party_univ.scenario.scen_name); + boost::replace_first(party_info, "{Location}", get_location(&party_univ)); + }else{ + boost::replace_first(party_info, "{Scenario}||{Location}", ""); + } + + me["info" + suffix].setText(party_info); + + // Set up buttons + if(saving){ + me["file1"].setText(""); // Keep the frame + me["auto" + suffix].hide(); + me["auto" + suffix + "-more-recent"].hide(); + me["save" + suffix].attachClickHandler(std::bind(&cFilePicker::doSave, this, file)); + }else{ + me["load" + suffix].attachClickHandler(std::bind(&cFilePicker::doLoad, this, file)); + + std::vector> auto_mtimes; + fs::path auto_folder = file; + if(!picking_auto){ + auto_folder.replace_extension(".auto"); + if(fs::is_directory(auto_folder)) auto_mtimes = sorted_file_mtimes(auto_folder); + } + if(auto_mtimes.empty()){ + me["auto" + suffix].hide(); + me["auto" + suffix + "-more-recent"].hide(); + }else{ + // If an autosave is newer than the main save, show an indicator + if(std::difftime(mtime, auto_mtimes.front().second) > 0) + me["auto" + suffix + "-more-recent"].show(); + + me["auto" + suffix].attachClickHandler(std::bind(&cFilePicker::showAuto, this, auto_folder)); + } + } + } + + void populate_page(int page) { + int parties_needed = min(save_file_mtimes.size(), (page+1) * parties_per_page); + while(saves_loaded < parties_needed){ + fs::path next_file = save_file_mtimes[saves_loaded].first; + cUniverse party_univ; + if(!load_party(next_file, save_files[saves_loaded], false)){ + // TODO show error, fatal? Show corrupted party? + } + saves_loaded++; + } + + if(saving){ + time_t now; + std::time(&now); + // Populate the first slot with the actual current party + populate_slot(0, "", now, univ); + } + + int start_idx = page * parties_per_page; + for(int party_idx = start_idx; party_idx < start_idx + parties_per_page; ++party_idx){ + int slot_idx = party_idx - start_idx; + if(saving) slot_idx++; + if(party_idx < parties_needed) + populate_slot(slot_idx, save_file_mtimes[party_idx].first, save_file_mtimes[party_idx].second, save_files[party_idx]); + else + empty_slot(party_idx - start_idx); + } + + ++pages_populated; + } + + bool showAuto(fs::path auto_folder) { + fs::path autosave = run_autosave_picker(auto_folder, &me); + if(!autosave.empty()) + doLoad(autosave); + return true; + } + + bool doLoad(fs::path selected_file) { + me.setResult(selected_file); + me.toast(false); + return true; + } + + bool confirm_overwrite(fs::path selected_file) { + cChoiceDlog dlog("confirm-overwrite", {"save","cancel"}, &me); + cDialog& inner = *(dlog.operator->()); + std::string prompt = inner["prompt"].getText(); + boost::replace_first(prompt, "{File}", selected_file.filename().string()); + inner["prompt"].setText(prompt); + std::string choice = dlog.show(); + return choice == "save"; + } + + bool doSave(fs::path selected_file) { + if(selected_file.empty()){ + selected_file = save_folder / me["file1-field"].getText(); + selected_file += ".exg"; + } + if(!fs::exists(selected_file) || confirm_overwrite(selected_file)){ + me.setResult(selected_file); + me.toast(false); + } + return true; + } + + bool doCancel() { + me.toast(false); + return true; + } + + bool changeSelectedPage(int dir) { + auto& stk = dynamic_cast(me["list"]); + // This stack doesn't loop. It's easier to implement loading the files one page at a time + // if I know we're not gonna jump from page 0 to the last page, leaving a gap in the vector. + stk.changeSelectedPage(dir); + me["prev"].show(); + me["next"].show(); + if(stk.getPage() == 0){ + me["prev"].hide(); + } + if(stk.getPage() == stk.getPageCount() - 1){ + me["next"].hide(); + } + + populate_page(stk.getPage()); + return true; + } + + bool doFileBrowser() { + fs::path from_browser = ""; + if(replaying){ + if(has_next_action("load_party")){ + from_browser = "DUMMY"; + } + }else{ + from_browser = nav_get_party(); + } + if(!from_browser.empty()){ + me.setResult(from_browser); + me.toast(false); + } + return true; + } + +public: + cFilePicker(fs::path save_folder, bool saving, cDialog* parent = nullptr, bool picking_auto = false) : + me(*ResMgr::dialogs.get("pick-save"), parent), + save_folder(save_folder), + picking_auto(picking_auto), + saving(saving), + parties_per_page(saving ? SLOTS_PER_PAGE - 1 : SLOTS_PER_PAGE) {} + + fs::path run() { + template_info_str = me["info1"].getText(); + + if(saving){ + me["title-load"].hide(); + me["title-auto"].hide(); + me["file1"].setText(""); // Keep the frame + for(int i = 0; i < SLOTS_PER_PAGE; ++i){ + me["load" + std::to_string(i+1)].hide(); + } + }else{ + if(picking_auto){ + me["title-load"].hide(); + std::string title = me["title-auto"].getText(); + fs::path party_name = save_folder.filename(); + party_name.replace_extension(); + boost::replace_first(title, "{Folder}", party_name.string()); + me["title-auto"].setText(title); + }else{ + me["title-auto"].hide(); + } + me["title-save"].hide(); + me["file1-field"].hide(); + me["file1-extension-label"].hide(); + for(int i = 0; i < SLOTS_PER_PAGE; ++i){ + me["save" + std::to_string(i+1)].hide(); + } + } + + me["cancel"].attachClickHandler(std::bind(&cFilePicker::doCancel, this)); + me["find"].attachClickHandler(std::bind(&cFilePicker::doFileBrowser, this)); + // Since it would be crazy to record and replay the metadata shown on a player's save picker + // dialog (which is what we do for the scenario picker), + // when replaying, basically make Left/Right buttons no-op. + // Load/Save buttons should send a dummy result. + if(!replaying){ + me["next"].attachClickHandler(std::bind(&cFilePicker::changeSelectedPage, this, 1)); + me["prev"].attachClickHandler(std::bind(&cFilePicker::changeSelectedPage, this, -1)); + init_pages(); + }else{ + for(int i = 0; i < SLOTS_PER_PAGE; ++i){ + std::string suffix = std::to_string(i+1); + // When replaying, a click on a load or save button means the dummy file picker can go away: + me["load" + suffix].attachClickHandler(std::bind(&cFilePicker::doLoad, this, "DUMMY")); + me["save" + suffix].attachClickHandler(std::bind(&cFilePicker::doSave, this, "DUMMY")); + // A click on an autosave button means another dummy file picker should open: + me["auto" + suffix].attachClickHandler(std::bind(&cFilePicker::showAuto, this, "")); + } + } + + // Hide the prev button and populate the first page + changeSelectedPage(0); + + me.run(); + if(!me.hasResult()) return ""; + fs::path file = me.getResult(); + return file; + } +}; + +static fs::path run_file_picker(fs::path save_folder, bool saving) { + return cFilePicker(save_folder, saving).run(); +} + +fs::path run_autosave_picker(fs::path auto_folder, cDialog* parent) { + return cFilePicker(auto_folder, false, parent, true).run(); +} + +fs::path fancy_file_picker(bool saving) { + if(recording){ + record_action("fancy_file_picker", bool_to_str(saving)); + } + // TODO this is set up to be configurable, but not yet exposed in preferences. + fs::path save_folder = get_string_pref("SaveFolder", saveDir.string()); + + return run_file_picker(save_folder, saving); +} + +fs::path run_file_picker(bool saving){ + if(has_feature_flag("file-picker-dialog", "V1") && get_bool_pref("FancyFilePicker", true)){ + return fancy_file_picker(saving); + } + + return os_file_picker(saving); +} diff --git a/src/game/boe.dlgutil.hpp b/src/game/boe.dlgutil.hpp index 54f74ad0f..295b52e47 100644 --- a/src/game/boe.dlgutil.hpp +++ b/src/game/boe.dlgutil.hpp @@ -25,5 +25,8 @@ void pick_preferences(bool record = true); void save_prefs(); void tip_of_day(); struct scen_header_type pick_a_scen(); +fs::path fancy_file_picker(bool saving); +// Pick from the autosaves made while playing in a given save file +fs::path run_autosave_picker(fs::path auto_folder, cDialog* parent = nullptr); #endif diff --git a/src/game/boe.fileio.cpp b/src/game/boe.fileio.cpp index 08d53db75..fc5b68b7b 100644 --- a/src/game/boe.fileio.cpp +++ b/src/game/boe.fileio.cpp @@ -482,3 +482,49 @@ bool load_scenario_header(fs::path file,scen_header_type& scen_head){ return true; } + +// Some autosave triggers are more dangerous than others: +std::map autosave_trigger_defaults = { + {"EnterTown", true}, + {"ExitTown", true}, + {"RestComplete", true}, + {"TownWaitComplete", true}, + {"EndOutdoorCombat", true}, + {"Eat", false} +}; + +void try_auto_save(std::string reason) { + if(!get_bool_pref("Autosave", true)) return; + bool reason_default_on = false; + if(autosave_trigger_defaults.find(reason) != autosave_trigger_defaults.end()) + reason_default_on = autosave_trigger_defaults[reason]; + if(!get_bool_pref("Autosave_" + reason, reason_default_on)) return; + if(univ.file.empty()){ + ASB("Autosave: Make a manual save first."); + print_buf(); + return; + } + + fs::path auto_folder = univ.file; + auto_folder.replace_extension(".auto"); + fs::create_directories(auto_folder); + std::vector> auto_mtimes = sorted_file_mtimes(auto_folder); + + int max_autosaves = get_int_pref("Autosave_Max", MAX_AUTOSAVE_DEFAULT); + fs::path target_path; + if(auto_mtimes.size() < max_autosaves){ + target_path = auto_folder / std::to_string(auto_mtimes.size() + 1); + target_path += ".exg"; + } + // Save file buffer is full, so overwrite the oldest autosave + else{ + target_path = auto_mtimes.back().first; + } + + if(save_party_force(univ, target_path)){ + ASB("Autosave: Game saved"); + }else{ + ASB("Autosave: Save not completed"); + } + print_buf(); +} \ No newline at end of file diff --git a/src/game/boe.fileio.hpp b/src/game/boe.fileio.hpp index ae73d53a4..a3f38e354 100644 --- a/src/game/boe.fileio.hpp +++ b/src/game/boe.fileio.hpp @@ -30,4 +30,8 @@ fs::path locate_scenario(std::string scen_name); void alter_rect(rectangle *r); +// The player can configure autosaves on/off globally, or individually +// for a variety of different trigger reasons +void try_auto_save(std::string reason); + #endif diff --git a/src/game/boe.global.hpp b/src/game/boe.global.hpp index 91c683e29..950404840 100644 --- a/src/game/boe.global.hpp +++ b/src/game/boe.global.hpp @@ -19,6 +19,8 @@ const int NUM_ITEM_G = 120; const int NUM_FACE_G = 80; const int NUM_DLOG_G = 28; +const int MAX_AUTOSAVE_DEFAULT = 5; + struct scen_header_type{ int intro_pic; eContentRating rating; diff --git a/src/game/boe.graphics.cpp b/src/game/boe.graphics.cpp index 01353fceb..2cb7d2b2d 100644 --- a/src/game/boe.graphics.cpp +++ b/src/game/boe.graphics.cpp @@ -312,6 +312,8 @@ void draw_startup_stats() { to_rect = party_to; to_rect.offset(pc_rect.left,pc_rect.top); pic_num_t pic = univ.party[i].which_graphic; + // TODO This doesn't make sense. If we're in the startup menu, there are no scenario custom graphics. + // Doesn't this need to find it saved in the party? if(pic >= 1000) { std::shared_ptr gw; graf_pos_ref(gw, from_rect) = spec_scen_g.find_graphic(pic % 1000, pic >= 10000); @@ -596,30 +598,8 @@ std::pair text_bar_text() { std::string text = ""; std::string right_text = ""; - location loc = (is_out()) ? global_to_local(univ.party.out_loc) : univ.party.town_loc; - bool in_area = false; + text = get_location(); - if(is_out()) { - for(short i = 0; i < univ.out->area_desc.size(); i++) - if(loc.in(univ.out->area_desc[i])) { - text = univ.out->area_desc[i].descr; - in_area = true; - } - if(!in_area) { - text = univ.out->name; - } - } - if(is_town()) { - for(short i = 0; i < univ.town->area_desc.size(); i++) - if(loc.in(univ.town->area_desc[i])) { - text = univ.town->area_desc[i].descr; - in_area = true; - } - if(!in_area) { - text = univ.town->name; - } - - } if((is_combat()) && (univ.cur_pc < 6) && !monsters_going) { std::ostringstream sout; diff --git a/src/game/boe.main.cpp b/src/game/boe.main.cpp index af28cff01..b7ed42953 100644 --- a/src/game/boe.main.cpp +++ b/src/game/boe.main.cpp @@ -99,7 +99,9 @@ std::string help_text_rsrc = "help"; std::map> feature_flags = { // Legacy behavior of the T debug action (used by some replays) // does not change the party's outdoors location - {"debug-enter-town", {"move-outdoors"}} + {"debug-enter-town", {"move-outdoors"}}, + // New in-game save file picker + {"file-picker-dialog", {"V1"}} }; struct cParseEntrance { @@ -583,6 +585,9 @@ static void replay_action(Element& action) { new_fps = boost::lexical_cast(action.GetText()); } replay_fps_limit.emplace(new_fps); + }else if(t == "fancy_file_picker"){ + bool saving = str_to_bool(action.GetText()); + fancy_file_picker(saving); }else if(t == "load_party"){ decode_file(action.GetText(), tempDir / "temp.exg"); load_party(tempDir / "temp.exg", univ); diff --git a/src/game/boe.text.cpp b/src/game/boe.text.cpp index 28bf657bb..1e7c842e7 100644 --- a/src/game/boe.text.cpp +++ b/src/game/boe.text.cpp @@ -1220,3 +1220,37 @@ bool day_reached(unsigned short which_day, unsigned short which_event) { return true; else return false; } + +std::string get_location(cUniverse* specific_univ) { + // This function is used to determine text bar text, which may be intended to be blank + // even if the party is technically outdoors, depending on the active game mode. + // I'm just trying to keep the old behavior the same. + bool outdoors = is_out(); + bool town = is_town(); + // For checking a save file's location, it can only be one or the other. + if(specific_univ != nullptr){ + outdoors = specific_univ->party.town_num >= 200; + town = !outdoors; + }else{ + specific_univ = &univ; + } + + std::string loc_str = ""; + + location loc = outdoors ? global_to_local(specific_univ->party.out_loc) : specific_univ->party.town_loc; + if(outdoors) { + loc_str = specific_univ->out->name; + for(short i = 0; i < specific_univ->out->area_desc.size(); i++) + if(loc.in(specific_univ->out->area_desc[i])) { + loc_str = specific_univ->out->area_desc[i].descr; + } + } + if(town){ + loc_str = specific_univ->town->name; + for(short i = 0; i < specific_univ->town->area_desc.size(); i++) + if(loc.in(specific_univ->town->area_desc[i])) { + loc_str = specific_univ->town->area_desc[i].descr; + } + } + return loc_str; +} \ No newline at end of file diff --git a/src/game/boe.text.hpp b/src/game/boe.text.hpp index 2f5d45136..5619f1996 100644 --- a/src/game/boe.text.hpp +++ b/src/game/boe.text.hpp @@ -48,3 +48,5 @@ struct text_label_t { void place_text_label(std::string string, location at, bool centred); void draw_text_label(const text_label_t& label); + +std::string get_location(cUniverse* specific_univ = nullptr); diff --git a/src/game/boe.town.cpp b/src/game/boe.town.cpp index 138b4b10b..f0bad04a0 100644 --- a/src/game/boe.town.cpp +++ b/src/game/boe.town.cpp @@ -510,6 +510,8 @@ void start_town_mode(short which_town, short entry_dir) { // TODO: One problem with this - it paints the terrain after the town entry dialog is dismissed // ... except it actually doesn't, because the town enter special is only queued, not run immediately. draw_terrain(1); + + try_auto_save("EnterTown"); } @@ -632,6 +634,10 @@ location end_town_mode(bool switching_level,location destination, bool debug_lea univ.party.town_num = 200; // should be harmless... + if(!switching_level){ + try_auto_save("ExitTown"); + } + return to_return; } diff --git a/src/pcedit/pc.main.cpp b/src/pcedit/pc.main.cpp index 9a897f3ed..76560f77c 100644 --- a/src/pcedit/pc.main.cpp +++ b/src/pcedit/pc.main.cpp @@ -568,3 +568,6 @@ void pick_preferences() { #endif } +fs::path run_file_picker(bool saving){ + return os_file_picker(saving); +} \ No newline at end of file diff --git a/src/scenedit/scen.core.cpp b/src/scenedit/scen.core.cpp index d4b897a97..9110dae4b 100644 --- a/src/scenedit/scen.core.cpp +++ b/src/scenedit/scen.core.cpp @@ -3776,3 +3776,7 @@ void edit_custom_sounds() { snd_names.swap(scenario.snd_names); } } + +fs::path run_file_picker(bool saving){ + return os_file_picker(saving); +} \ No newline at end of file diff --git a/src/tools/winutil.linux.cpp b/src/tools/winutil.linux.cpp index 433334a72..b5acb1dbd 100644 --- a/src/tools/winutil.linux.cpp +++ b/src/tools/winutil.linux.cpp @@ -134,6 +134,7 @@ void setWindowFloating(sf::Window& win, bool floating) { } } +// TODO this check is only required when trying nav_get_* specifically, now that there's a save file picker void init_fileio(){ // if init_fileio() is called more than once, only check once static bool checked_zenity = false;