diff --git a/rsrc/dialogs/confirm-overwrite.xml b/rsrc/dialogs/confirm-overwrite.xml
new file mode 100644
index 00000000..c497c85d
--- /dev/null
+++ b/rsrc/dialogs/confirm-overwrite.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/src/fileio/fileio.cpp b/src/fileio/fileio.cpp
index 7558cc5a..639523ae 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_party.cpp b/src/fileio/fileio_party.cpp
index d4b4d9e9..73799e2d 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();
}
}
@@ -304,7 +307,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;
@@ -546,7 +548,7 @@ 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;
diff --git a/src/game/boe.actions.cpp b/src/game/boe.actions.cpp
index f8e9f7c5..1a335979 100644
--- a/src/game/boe.actions.cpp
+++ b/src/game/boe.actions.cpp
@@ -2907,6 +2907,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
@@ -2914,7 +2915,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;
@@ -3428,7 +3429,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.dlgutil.cpp b/src/game/boe.dlgutil.cpp
index fa64a81d..f6d317c1 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"
@@ -1716,3 +1720,280 @@ public:
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{*ResMgr::dialogs.get("pick-save")};
+ 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 populate_slot(int idx, fs::path file, std::time_t mtime, cUniverse& party_univ) {
+ 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));
+ // TODO check if a newer autosave exists
+ me["auto" + suffix + "-more-recent"].hide();
+ }
+ }
+
+ 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])){
+ // 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 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 doSelectPage(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.doSelectPage(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 = nav_get_party();
+ if(!from_browser.empty()){
+ me.setResult(from_browser);
+ me.toast(false);
+ }
+ return true;
+ }
+
+public:
+ cFilePicker(fs::path save_folder, bool saving, bool picking_auto = false) :
+ 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["file1"].setText(""); // Keep the frame
+ for(int i = 0; i < SLOTS_PER_PAGE; ++i){
+ me["load" + std::to_string(i+1)].hide();
+ }
+ }else{
+ 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));
+ // 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 every part of the picker no-op except cancel and view autosaves.
+ // Load buttons should do the same thing as cancel.
+ if(!replaying){
+ me["next"].attachClickHandler(std::bind(&cFilePicker::doSelectPage, this, 1));
+ me["prev"].attachClickHandler(std::bind(&cFilePicker::doSelectPage, this, -1));
+ init_pages();
+ me["find"].attachClickHandler(std::bind(&cFilePicker::doFileBrowser, this));
+ }else{
+ me["load"].attachClickHandler(std::bind(&cFilePicker::doCancel, this));
+ }
+
+ // Hide the prev button and populate the first page
+ doSelectPage(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 save_file) {
+ return "";
+}
+
+fs::path run_file_picker(bool saving) {
+ if(!has_feature_flag("file-picker-dialog", "V1") /* TODO check file picker preference */){
+ if(saving)
+ return nav_put_or_temp_party();
+ else
+ return nav_get_or_decode_party();
+ }
+
+ // 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);
+}
+
diff --git a/src/game/boe.dlgutil.hpp b/src/game/boe.dlgutil.hpp
index 3367da60..e97ee5bf 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 run_file_picker(bool saving);
+// Pick from the autosaves made while playing in a given save file
+fs::path run_autosave_picker(fs::path save_file);
#endif
diff --git a/src/game/boe.graphics.cpp b/src/game/boe.graphics.cpp
index 96aedef4..5d96d68e 100644
--- a/src/game/boe.graphics.cpp
+++ b/src/game/boe.graphics.cpp
@@ -341,6 +341,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);
diff --git a/src/game/boe.main.cpp b/src/game/boe.main.cpp
index 97f8d1f1..3d666b31 100644
--- a/src/game/boe.main.cpp
+++ b/src/game/boe.main.cpp
@@ -99,7 +99,9 @@ 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"}},
- {"target-lock", {"V1"}}
+ {"target-lock", {"V1"}},
+ // New in-game save file picker
+ {"file-picker-dialog", {"V1"}}
};
struct cParseEntrance {
diff --git a/src/tools/winutil.linux.cpp b/src/tools/winutil.linux.cpp
index bd5d44c4..1c9aab9d 100644
--- a/src/tools/winutil.linux.cpp
+++ b/src/tools/winutil.linux.cpp
@@ -133,6 +133,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;