Implement file picker load/save normal

This commit is contained in:
2025-03-01 17:48:46 -06:00
committed by Celtic Minstrel
parent f9695887ec
commit 2396637d23
9 changed files with 310 additions and 6 deletions

View File

@@ -0,0 +1,9 @@
<?xml version='1.0' encoding='UTF-8' standalone='no'?>
<!-- NOTE: This file should be updated to use relative positioning the next time it changes. -->
<?xml-stylesheet href="dialog.xsl" type="text/xsl"?>
<dialog defbtn='cancel'>
<pict type='dlog' num='23' top='9' left='9'/>
<text name='prompt' top='5' left='51' width='255' height='32'>Are you sure you want to overwrite {File}?</text>
<button name='save' type='regular' top='46' left='178'>Save</button>
<button name='cancel' type='regular' def-key='esc' top='46' left='248'>Cancel</button>
</dialog>

View File

@@ -33,7 +33,7 @@ bool mac_is_intel(){
} }
return _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. // This is here to avoid unnecessarily duplicating it in platform-specific files.
cursor_type Cursor::current = sword_curs; cursor_type Cursor::current = sword_curs;
@@ -81,6 +81,9 @@ void init_directories(const char* exec_path) {
replayDir = tempDir/"Replays"; replayDir = tempDir/"Replays";
fs::create_directories(replayDir); fs::create_directories(replayDir);
saveDir = tempDir/"Saves";
fs::create_directories(saveDir);
add_resmgr_paths(tempDir/"data"); add_resmgr_paths(tempDir/"data");
tempDir /= "Temporary Files"; tempDir /= "Temporary Files";

View File

@@ -21,6 +21,7 @@
#include "fileio/tagfile.hpp" #include "fileio/tagfile.hpp"
#include "fileio/tarball.hpp" #include "fileio/tarball.hpp"
#include "replay.hpp" #include "replay.hpp"
#include "game/boe.dlgutil.hpp"
extern bool mac_is_intel(); extern bool mac_is_intel();
extern fs::path progDir, tempDir; extern fs::path progDir, tempDir;
@@ -39,6 +40,8 @@ fs::path nav_get_or_decode_party() {
decode_file(next_action.GetText(), tempDir / "temp.exg"); decode_file(next_action.GetText(), tempDir / "temp.exg");
return tempDir / "temp.exg"; return tempDir / "temp.exg";
}else{ }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(); 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; return true;
} }
extern fs::path scenDir;
bool load_party_v2(fs::path file_to_load, cUniverse& real_univ){ bool load_party_v2(fs::path file_to_load, cUniverse& real_univ){
igzstream zin(file_to_load.string().c_str()); igzstream zin(file_to_load.string().c_str());
tarball partyIn; 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 // univ.file can be empty for prefab parties, so a file browser might be needed
// even for a regular save. // even for a regular save.
if(save_as || univ.file.empty()){ if(save_as || univ.file.empty()){
univ.file = nav_put_or_temp_party(); univ.file = run_file_picker(true);
} }
// A file wasn't chosen // A file wasn't chosen
if(univ.file.empty()) return false; if(univ.file.empty()) return false;

View File

@@ -2907,6 +2907,7 @@ bool handle_scroll(const sf::Event& event) {
} }
void do_load() { 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, // Edge case: Replay can be cut off before a file is chosen,
// or party selection can be canceled, and this will cause // or party selection can be canceled, and this will cause
// a crash trying to decode a party // a crash trying to decode a party
@@ -2914,7 +2915,7 @@ void do_load() {
return; 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(file_to_load.empty()) return;
if(!load_party(file_to_load, univ)) if(!load_party(file_to_load, univ))
return; return;
@@ -3428,7 +3429,7 @@ void handle_death() {
return; return;
} }
else if(choice == "load") { 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(!file_to_load.empty()){
if(load_party(file_to_load, univ)){ if(load_party(file_to_load, univ)){
finish_load_party(); finish_load_party();

View File

@@ -1,5 +1,9 @@
#include <cstring> #include <cstring>
#include <algorithm>
#include <iomanip>
#include <string>
#include <boost/algorithm/string/replace.hpp>
#include "boe.global.hpp" #include "boe.global.hpp"
@@ -1716,3 +1720,280 @@ public:
scen_header_type pick_a_scen() { scen_header_type pick_a_scen() {
return cChooseScenario().run(); 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<cStack&>(me["list"]); }
std::string template_info_str;
std::vector<std::pair<fs::path, std::time_t>> 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<cUniverse> 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<cPict&>(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<cStack&>(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<fs::path>();
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);
}

View File

@@ -25,5 +25,8 @@ void pick_preferences(bool record = true);
void save_prefs(); void save_prefs();
void tip_of_day(); void tip_of_day();
struct scen_header_type pick_a_scen(); 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 #endif

View File

@@ -341,6 +341,8 @@ void draw_startup_stats() {
to_rect = party_to; to_rect = party_to;
to_rect.offset(pc_rect.left,pc_rect.top); to_rect.offset(pc_rect.left,pc_rect.top);
pic_num_t pic = univ.party[i].which_graphic; 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) { if(pic >= 1000) {
std::shared_ptr<const sf::Texture> gw; std::shared_ptr<const sf::Texture> gw;
graf_pos_ref(gw, from_rect) = spec_scen_g.find_graphic(pic % 1000, pic >= 10000); graf_pos_ref(gw, from_rect) = spec_scen_g.find_graphic(pic % 1000, pic >= 10000);

View File

@@ -99,7 +99,9 @@ std::map<std::string,std::vector<std::string>> feature_flags = {
// Legacy behavior of the T debug action (used by some replays) // Legacy behavior of the T debug action (used by some replays)
// does not change the party's outdoors location // does not change the party's outdoors location
{"debug-enter-town", {"move-outdoors"}}, {"debug-enter-town", {"move-outdoors"}},
{"target-lock", {"V1"}} {"target-lock", {"V1"}},
// New in-game save file picker
{"file-picker-dialog", {"V1"}}
}; };
struct cParseEntrance { struct cParseEntrance {

View File

@@ -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(){ void init_fileio(){
// if init_fileio() is called more than once, only check once // if init_fileio() is called more than once, only check once
static bool checked_zenity = false; static bool checked_zenity = false;