// SaveManager.js
// https://eligrey.com/demos/FileSaver.js/
// https://www.dropzonejs.com/
// https://gist.github.com/liabru/11263260#file-save-file-local-js
(function () {
/*global adventurejs A*/
/**
*
* @class adventurejs.SaveManager
* @ajsnavheading FrameworkReference
* @param {Game} game A reference to the game instance.
* @summary Manages the process of saving games.
* @todo Save to adventurejs.com web server.
* @classdesc
* <p>
* <strong>SaveManager</strong> manages the job of saving games.
* It contains all the methods needed to create the
* Save pop-up screen. SaveManager can save a game to
* a local save file, to browser cookies, or to the
* <a href="https://adventurejs.com">adventurejs.com</a>
* web server.
* </p>
* <p>
* SaveManager is created automatically
* by {@link adventurejs.Game|Game}. This is an internal class that
* authors should not need to construct or modify. However, if
* you'd like to try, you can find styles for the Save & Restore
* pop-ups in <a href="/css/adventurejs.css">adventurejs.css</a>.
* All relevant styles are prefixed with '.save_' or '.restore_'.
* </p>
*/
class SaveManager {
constructor(game) {
/**
* A reference back to the main {@link adventurejs.Game|Game} object.
* @var {Object} adventurejs.SaveManager#game
* @default {}
*/
this.game = game;
/**
* Collection of HTML elements: the action buttons for
* the different save methods.
* @var {Array} adventurejs.SaveManager#saveButtons
* @default []
*/
this.saveButtons = [];
/**
* Div element to contain the Save dialog.
* @var {HTMLElement} adventurejs.SaveManager#save_dialog
* @default {}
*/
this.save_dialog = document.createElement("div");
this.save_dialog.classList.add("ajs-save-dialog", "ajs-dialog");
this.save_dialog.setAttribute("aria-hidden", true);
this.save_dialog.setAttribute("aria-modal", true);
this.save_dialog.role = "dialog";
this.save_dialog.style.display = "none";
this.save_dialog.tabIndex = "1";
this.save_dialog.setAttribute(
"aria-labelledby",
`${this.game.game_name}-save-dialog-title`
);
this.game.display.dialogsEl.appendChild(this.save_dialog);
// Source HTML is stored in saveManager.html
this.save_dialog.innerHTML = `
<div class="ajs-save-dialog-outer ajs-dialog-outer">
<div class="ajs-save-dialog-inner ajs-dialog-inner">
<h2 id="${this.game.game_name}-save-dialog-title" class="ajs-save-dialog-title ajs-dialog-title">SAVE GAME</h2>
<div class="ajs-save-dialog-content ajs-dialog-content">
<div id="${this.game.game_name}-save-radiogroup" role="radiogroup" aria-labelledby="${this.game.game_name}-save-radiogroup-label">
<p id="${this.game.game_name}-save-radiogroup-label" class="ajs-hidden-label">Select an option:</p>
<div class="ajs-dialog-radio-tab-container">
<label class="ajs-dialog-radio-tab">
<input
type="radio"
name="saveoptions"
value="save_to_file"
aria-describedby="${this.game.game_name}-save-descriptions"
checked
/>
Save to File
</label>
<label class="ajs-dialog-radio-tab">
<input
type="radio"
name="saveoptions"
value="save_to_browser"
aria-describedby="${this.game.game_name}-save-descriptions"
/>
Save to Browser
</label>
<label class="ajs-dialog-radio-tab">
<input
type="radio"
name="saveoptions"
value="save_to_server"
aria-describedby="${this.game.game_name}-save-descriptions"
/>
Save to Server
</label>
</div>
<!-- /ajs-dialog-radio-tab-container -->
</div>
<!-- /radiogroup -->
<!-- CONTEXTUAL DESCRIPTIONS -->
<div id="${this.game.game_name}-save-descriptions" class="ajs-dialog-option-descriptions" aria-live="polite">
<!-- Select an option to see more details. -->
</div>
<div id="${this.game.game_name}-save-dialog-input-container" class="ajs-save-dialog-input-container">
<label class="ajs-save-dialog-input-label"
>Enter a name for your save file: <br /><input
id="${this.game.game_name}-save-dialog-input"
class="ajs-save-dialog-input"
pattern="[_A-Za-z0-9-]{64}"
/>
</label>
</div>
<div class="ajs-save-dialog-buttons ajs-dialog-button-container">
<button
id="${this.game.game_name}-save-dialog-cancel-button"
class="ajs-save-dialog-cancel-button ajs-cancel-button ajs-dialog-button button-secondary"
type="button"
value="Cancel"
name="saveCancel"
>
Cancel
</button>
<button
id="${this.game.game_name}-save-dialog-submit-button"
class="ajs-save-dialog-submit-button ajs-save-button ajs-dialog-button button-primary"
type="button"
value="submit"
name="save_submit"
>
Save
</button>
</div>
<!-- /ajs-save-dialog-buttons -->
</div>
<!-- /ajs-save-dialog-content -->
</div>
<!-- /ajs-save-dialog-inner -->
</div>
<!-- /ajs-save-dialog-outer -->
`;
// --------------------------------------------------
// RADIO BUTTONS
// --------------------------------------------------
this.radiogroup = this.game.display.displayEl.querySelector(
`#${this.game.game_name}-save-radiogroup`
);
const radios = this.game.display.displayEl.querySelectorAll(
'input[name="saveoptions"]'
);
const descriptions = this.game.display.displayEl.querySelector(
`#${this.game.game_name}-save-descriptions`
);
const optionDescriptions = {
save_to_file: `
<div class="ajs-dialog-option-description">
<p class="">
The <span class="ajs-dialog-emphasis">Save to File</span>
option will download a saved game file to your computer's hard
drive.
</p>
<p class="">
<strong>PRO:</strong> You can restore these save files in any browser
that can run this game, and copy them to other computers.
</p>
<p class="">
<strong>CON:</strong> For reasons of security, the browser won't open a
file dialog to let you choose where to put it. It should go to your
browser's default Download folder.
</p>
<div
class="ajs-file-reader-unsupported"
>
<div class="alert alert-danger">
<p class="">
Unfortunately, your web browser doesn't appear to support this
save/restore method. Please try one of the other methods, or try
playing with a different web browser.
</p>
</div>
</div>
</div>
`,
save_to_browser: `
<div class="ajs-dialog-option-description">
<p class="">
The <span class="ajs-dialog-emphasis">Save to Browser</span>
option will save your game to your web browser's
<span class="ajs-dialog-emphasis">Local Storage</span>. It's like a
cookie, but bigger.
</p>
<p class="">
<strong>PRO:</strong> Easy to manage saves if you
only play the game in this browser on this computer. Best for short
games that you're unlikely to replay.
</p>
<p class="">
<strong>CON:</strong> Storage is
limited. You can only access these saves from this browser on this
computer. If you clear your browser's cache, you may erase your
saved games.
</p>
</div>
`,
save_to_server: `
<div class="save_server_pane ajs-dialog-option-description active">
<p class="">
The <span class="ajs-dialog-emphasis">Save to Server</span>
option will save your game to the adventurejs.com web
server.
</p>
<p class="">
<strong>PRO:</strong> It's easy to manage your saved games, and you can
restore them to any browser on any computer that runs this game.
</p>
<p class="">
<strong>CON:</strong> Saved games will be unavailable while you are
offline.
</p>
</div>
`,
};
descriptions.innerHTML =
optionDescriptions[Object.keys(optionDescriptions)[0]];
radios.forEach((radio) => {
radio.addEventListener("change", (event) => {
const selectedOption = event.target.value;
descriptions.innerHTML =
optionDescriptions[selectedOption] ||
"Select an option to see more details.";
});
});
// ensure that the first radio is checked
setTimeout(() => {
// Select all radio buttons within the group
const radioGroup = this.game.display.displayEl.querySelectorAll(
`#${this.game.game_name}-save-radiogroup input[name="saveoptions"]`
);
// Check if any radio button is selected
const isChecked = Array.from(radioGroup).some((radio) => radio.checked);
// If none are checked, check the first option
if (!isChecked && radioGroup.length > 0) {
radioGroup[0].checked = true; // Check the first radio button
}
}, 50);
// --------------------------------------------------
// CANCEL BUTTON
// --------------------------------------------------
/**
* Button element to dispel the Save dialog.
* @var {HTMLElement} adventurejs.SaveManager#saveCancel
* @default {}
*/
this.saveCancel = this.game.display.displayEl.querySelector(
`#${this.game.game_name}-save-dialog-cancel-button`
);
this.saveCancel.manager = this;
this.saveCancel.addEventListener("click", function () {
this.manager.clickClose();
});
// --------------------------------------------------
// SAVE BUTTON
// --------------------------------------------------
this.save_submit = this.game.display.displayEl.querySelector(
`#${this.game.game_name}-save-dialog-submit-button`
);
this.save_submit.manager = this;
this.save_submit.addEventListener("click", function () {
this.manager.clickSubmit();
});
// --------------------------------------------------
// FILENAME INPUT
// --------------------------------------------------
/**
* Div element to contain the Save row input.
* @var {HTMLElement} adventurejs.SaveManager#saveRowInput
* @default {}
*/
this.save_input = document.createElement("input");
this.save_input = this.game.display.displayEl.querySelector(
`#${this.game.game_name}-save-dialog-input`
);
this.save_input.pattern = "[_A-Za-z0-9\\-]{64}";
this.save_input.manager = this;
this.save_input.addEventListener("input", function () {
this.manager.saveInputOninput(this);
});
this.save_input.addEventListener("onkeydown", function () {
this.manager.saveInputOninput(this);
});
this.handleEscape = this.handleEscape.bind(this); // Bind once
//
}
/**
* Open the Save modal dialog.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#openDialog
* @kind function
*/
openDialog() {
// this.resetDialog();
// deactivate main content
document.activeElement.blur();
this.game.display.contentEl.blur();
this.game.display.contentEl.setAttribute("aria-hidden", true);
this.game.display.contentEl.setAttribute("inert", "");
// activate dialog
this.save_dialog.style.display = "block";
this.save_dialog.classList.add("active");
this.save_dialog.setAttribute("aria-hidden", false);
this.save_dialog.focus();
// set a default save name
this.save_input.value =
this.game.titleSerialized + "_" + new Date().getTime().toString();
document.addEventListener("keyup", this.handleEscape);
// delay because the select doesn't work without it
setTimeout(
function (saveManager) {
saveManager.save_input.select();
},
50,
this
);
}
/**
* Close the Save modal dialog.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#closeDialog
* @kind function
*/
closeDialog() {
document.removeEventListener("keyup", this.handleEscape);
document.activeElement.blur();
this.save_dialog.classList.remove("active");
this.save_dialog.setAttribute("aria-hidden", true);
setTimeout(() => {
this.save_dialog.style.display = "none";
// set focus on input
this.game.display.contentEl.setAttribute("aria-hidden", false);
this.game.display.contentEl.removeAttribute("inert");
this.game.display.inputEl.focus();
}, 250);
}
/**
* Function that closes the dialog on escape keyup.
* @param {*} event
*/
handleEscape(event) {
var key = event.which || event.keyCode;
if (key === 27) {
event.stopPropagation();
this.clickClose(this);
}
}
/**
* Function that gets called by the Close button.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#clickClose
* @kind function
*/
clickClose() {
console.log("clickClose");
this.closeDialog();
var msg = "Save cancelled.";
this.game.print(msg);
return;
}
/**
* Save.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#clickSubmit
* @kind function
*/
clickSubmit() {
console.warn("clickSubmit", this.radiogroup);
// Get the selected radio button in the group
const selectedRadio = this.radiogroup.querySelector(
'input[name="saveoptions"]:checked'
);
// Check if a radio button is selected
if (selectedRadio) {
console.log(`Selected value: ${selectedRadio.value}`);
if (selectedRadio.value === "save_to_file") this.saveToFile();
if (selectedRadio.value === "save_to_browser") this.saveToBrowser();
if (selectedRadio.value === "save_to_server") this.saveToServer();
} else {
console.log("No radio button is selected.");
}
}
/**
* Save to file.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#saveToFile
* @kind function
* @todo Investigate FileSaver.js
*/
saveToFile() {
console.log("saveToFile");
var inputFileName = this.sanitizeInputFileName();
// get diff between baseline and current
var text = A.getBaselineDiff.call(this.game);
var blob = new Blob([text], { type: "application/json" });
console.warn("blob", blob);
var anchor = document.createElement("a");
anchor.download = inputFileName + ".json";
anchor.href = (window.webkitURL || window.URL).createObjectURL(blob);
anchor.dataset.downloadurl = [
"application/json",
anchor.download,
anchor.href,
].join(":");
console.warn("anchor", anchor);
anchor.click();
//URL.revokeObjectURL(anchor.href);
setTimeout(function () {
URL.revokeObjectURL(anchor.href);
}, 200);
this.closeDialog();
var msg =
"Game saved! " +
"<span class='text-success'>" +
inputFileName +
".json" +
"</span> " +
"has been saved to your Downloads folder.";
this.game.print(msg);
return true;
}
/**
* Save game to browser cookie.
* <br><br>
* Player can name their save however they want. However, we always
* prepend the game name because local storage applies
* to all pages on a given domain, meaning that all saves for all
* games played at, i.e., adventurejs.com will be saved in the same
* local storage. And, local storage appears not to have any method
* for nesting data, so we can't have a parent object or 'folder'.
* Instead, we need to name them in such a way that we
* can identify them by game.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#saveToBrowser
* @kind function
*/
saveToBrowser() {
console.log("saveToBrowser");
var inputFileName = this.sanitizeInputFileName();
//if( this.game.titleSerialized !== inputFileName.substr( 0, this.game.titleSerialized.length ) ) // @deprecated
if (
this.game.titleSerialized !==
inputFileName.substring(0, this.game.titleSerialized.length)
) {
inputFileName = this.game.titleSerialized + "_" + inputFileName;
}
var storage = window.localStorage;
// get diff between baseline and current
storage.setItem(inputFileName, A.getBaselineDiff.call(this.game));
this.closeDialog();
var msg = "Game saved to your browser's Local Storage!";
this.game.print(msg);
return true;
}
/**
* Save game to server.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#saveToServer
* @kind function
* @todo Everything.
*/
saveToServer() {
console.log("saveToServer");
var inputFileName = this.sanitizeInputFileName();
// TODO ...
}
/**
* OnInput or OnKeyDown, activate or deactivate save buttons
* depending on state of input field. Deactivate if empty,
* otherwise activate.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#
* @kind function
*/
saveInputOninput(save_input) {
var activeState = save_input.value === "" ? "add" : "remove";
this.save_submit.classList[activeState]("inactive");
this.save_submit.disabled = save_input.value === "" ? true : false;
}
/**
* Sanitize input file name. Permitted characters are
* a-z, A-Z, 0-9, accented vowels áéíóúñü, _underscore, and -hyphen.
* @memberOf adventurejs.SaveManager
* @method adventurejs.SaveManager#sanitizeInputFileName
* @kind function
*/
sanitizeInputFileName() {
console.log("validate input");
console.log("this.save_input.value", this.save_input.value);
var inputFileName = this.save_input.value;
inputFileName = inputFileName.replace(/ /gim, "_");
inputFileName = inputFileName.replace(/[^a-zA-Z0-9áéíóúñü_-]/gim, "");
if (!inputFileName) {
inputFileName =
this.game.titleSerialized + "_" + new Date().getTime().toString();
}
return inputFileName;
}
/**
* Provides a chainable shortcut method for setting a number of properties on the instance.
* @method adventurejs.SaveManager#set
* @param {Object} props A generic object containing properties to copy to the DisplayObject instance.
* @returns {adventurejs.SaveManager} Returns the instance the method is called on (useful for chaining calls.)
* @chainable
*/
set(props) {
return A.deepSet.call(this.game, props, this);
}
}
adventurejs.SaveManager = SaveManager;
})();