Commit 1fac8b93 authored by Leon Arnott's avatar Leon Arnott
Browse files

Now, State automatically stores a serialisation of the game state in...

Now, State automatically stores a serialisation of the game state in SessionStorage, to be restored once the page reloads.

This means that reloading the page (without closing the window or tab) should also automatically reload the player's position in the story, as if by (load-game:). This does not apply when using (reload:), however, which always returns the story to the beginning.

Closes #152.
parent 1898ebddfdf7
......@@ -86,7 +86,7 @@ dist/format.js: build/harlowe-min.js build/twinemarkup-min.js css
| $(call node_replace, $(css)) \
> dist/format.js
examplestory = "{{STORY_DATA}}", "<tw-storydata startnode=1><tw-passagedata pid=1 name=Start>**Success!**</tw-passagedata></tw-storydata>"
examplestory = "{{STORY_DATA}}", "<tw-storydata startnode=1><tw-passagedata pid=1 name=Start>[[Next]]</tw-passagedata><tw-passagedata pid=2 name=Next>[[Last]]</tw-passagedata><tw-passagedata pid=3 name=Last>**Success**</tw-passagedata></tw-storydata>"
examplename = "{{STORY_NAME}}", "Example Output File"
engine_raw = "{{HARLOWE}}", "<script title=\"Twine engine code\" data-main=\"harlowe\">" + read("build/harlowe-min.js") + "</script>\n"
css_raw = "{{CSS}}", "<style title=\"Twine CSS\">" + read("build/harlowe-css.css") + "</style>"
......
......@@ -6,6 +6,7 @@ Rough documentation is at http://twine2.neocities.org/. See below for compilatio
####Bugfixes
* Fixed a bug where the story crashes on startup if the page's URL contains a hash identifier that doesn't have "stories" in it (such as in `story.html#what`).
* Fixed a bug where same-precedence arithmetic operators (`+` and `-`, or `/` and `*`) had incorrect associativity (so, `3 - 5 + 2` was interpreted as `3 - (5 + 2)` instead of `(3 - 5) + 2`).
* Temp variables finally work correctly with changers that defer a hook until some event occurs, like `(link:)`, `(click:)` and such. Now, you can reference temp variables inside the hook, such as in `(link:"Read")[It reads: _engraving]`, just as you can with other kinds of changers.
* Fixed a bug where supplying multiple shortened `is` or `is not` comparisons, in a form such as `$a is $b and $c`, would produce an incorrect result.
......@@ -16,6 +17,7 @@ Rough documentation is at http://twine2.neocities.org/. See below for compilatio
####Alterations
* Now, when playing, the current game session will attempt to preserve itself across browser reloads and back-forward navigation using browser SessionStorage. This means that reloading the page (without closing the window or tab) should also automatically reload the player's position in the story, as if by `(load-game:)`. This does not apply when using `(reload:)`, however, which always returns the story to the beginning.
* The `(replace:)`, `(append:)` and `(prepend:)` macros now no longer target any hooks or text that haven't been rendered yet - so, `(replace: "cool")[hot] cool water` won't work because the `(replace:)` runs before "cool water" has rendered, but `cool water (replace: "cool")[hot]` and something like `(link: "heat")[(replace: "cool")[hot]] cool water` will. This finally normalises what was formerly very inconsistent behaviour across these three macros - `(replace:)` couldn't target forthcoming hooks but could target later text, and `(append:)` and `(prepend:)` would do the others' behaviour on forthcoming hooks.
* The `(text:)` macro now has another alias, `(str:)`. This alias will now be the preferred name for this macro in the documentation, mainly due to the arrival of other string macros that begin with "str-", and additionally to avoid semantic conflict with the various "text-" changer macros like `(text-style:)`.
* To more clearly separate the concepts of "printing data" and "running commands" in Harlowe, the `(print:)` macro will no longer run commands passed to it (that is, `(print:(go-to:"Foo"))` and `(go-to:"Foo")` will no longer do the same thing - the former will just print out a descriptive string, as if printing out a changer). Commands can now only be run by placing them directly in the passage (either as plain calls, inside variables, or wrapped in strings that (print:) receives).
......
......@@ -145,13 +145,20 @@ require(['jquery', 'debugmode', 'renderer', 'state', 'engine', 'passages', 'util
$(document.head).append('<style data-title="Story stylesheet ' + (i + 1) + '">' + $(this).html());
});
// Load the hash if it's present
if (window.location.hash && !window.location.hash.includes("stories")) {
if (State.load(window.location.hash)) {
Engine.showPassage(State.passage);
// Load the sessionStorage if it's present
const sessionData = State.hasSessionStorage && sessionStorage.getItem("Saved Session");
if (sessionData) {
try {
State.deserialise(sessionData);
// This is copied from (load-game:).
Engine.showPassage(State.passage, false /* stretchtext value */);
return;
} catch(e) {
// Currently, failing to deserialise session storage is a silent error,
// owing to how temporary sessions are.
}
}
// Show first passage!
Engine.goToPassage(startPassage);
});
......
......@@ -21,21 +21,6 @@ define(['jquery', 'requestAnimationFrame', 'macros', 'utils', 'utils/selectors',
*/
const {Any, rest, either, optional} = Macros.TypeSignature;
const {assign} = Object;
const hasStorage = !!localStorage
&& (() => {
/*
This is, to my knowledge, the only surefire way of measuring localStorage's
availability - on some browsers, setItem() will throw in Private Browsing mode.
*/
try {
localStorage.setItem("test", '1');
localStorage.removeItem("test");
return true;
} catch (e) {
return false;
}
})();
/*
As localstorage keys are shared across domains, this prefix, using the current story's IFID,
......@@ -736,6 +721,9 @@ define(['jquery', 'requestAnimationFrame', 'macros', 'utils', 'utils/selectors',
`(click:"Restart")[(reload:)]`
Details:
Normally, Harlowe stories will attempt to preserve their current game state across browser page reloads.
This macro will suppress this behaviour, guaranteeing that the story restarts from the beginning.
If the first passage in the story contains this macro, the story will be caught in a "reload
loop", and won't be able to proceed. No error will be reported in this case.
......@@ -749,6 +737,9 @@ define(['jquery', 'requestAnimationFrame', 'macros', 'utils', 'utils/selectors',
if (State.pastLength < 1) {
return TwineError.create("infinite", "I mustn't (reload:) the page in the starting passage.");
}
if (State.hasSessionStorage) {
sessionStorage.removeItem("Saved Session");
}
window.location.reload();
},
[], false)
......@@ -824,9 +815,9 @@ define(['jquery', 'requestAnimationFrame', 'macros', 'utils', 'utils/selectors',
are likely to play this game - at an exhibition, for instance.
Giving the saved game a file name is optional, but allows that name to be displayed by finding it in the
$Saves datamap. This can be combined with a (load-game:)(link:) to clue the players into the save's contents:
(saved-games:) datamap. This can be combined with a (load-game:)(link:) to clue the players into the save's contents:
```
(link: "Load game: " + ("Slot 1") of Saves)[
(link: "Load game: " + ("Slot 1") of (saved-games:))[
  (load-game: "Slot 1")
]
```
......@@ -848,7 +839,7 @@ define(['jquery', 'requestAnimationFrame', 'macros', 'utils', 'utils/selectors',
*/
fileName = fileName || "";
if (!hasStorage) {
if (!State.hasStorage) {
/*
If storage isn't available, that's the unfortunate fault of the
browser. Return false, signifying that the save failed, and
......@@ -864,6 +855,14 @@ define(['jquery', 'requestAnimationFrame', 'macros', 'utils', 'utils/selectors',
*/
return serialisation;
}
/*
serialise() returns a TwineError if the state can't be serialised, and
false if it could but threw. In the latter case, pass the false to
the author, in keeping with below.
*/
else if (serialisation === false) {
return false;
}
/*
In case setItem() fails, let's run this in a try block.
*/
......
......@@ -1182,6 +1182,10 @@ define([
Iterate over all the localStorage keys using this somewhat clunky do-loop.
*/
do {
// I don't believe any browsers with storage disabled will throw on .key(), but...
if (!State.hasStorage) {
break;
}
key = localStorage.key(i);
i += 1;
const prefix = storagePrefix("Saved Game");
......
......@@ -6,6 +6,26 @@ define(['utils', 'passages', 'datatypes/changercommand', 'internaltypes/twineerr
Singleton controlling the running game state.
*/
/*
A browser compatibility check for localStorage and sessionStorage.
*/
const hasStorage = ["localStorage","sessionStorage"].map(name => {
return !!window[name]
&& (() => {
/*
This is, to my knowledge, the only surefire way of measuring localStorage's
availability - on some browsers, setItem() will throw in Private Browsing mode.
*/
try {
window[name].setItem("test", '1');
window[name].removeItem("test");
return true;
} catch (e) {
return false;
}
})();
});
/*
The root prototype for every Moment's variables collection.
*/
......@@ -102,18 +122,42 @@ define(['utils', 'passages', 'datatypes/changercommand', 'internaltypes/twineerr
load: [],
};
let State;
/*
This enables session storage to preserve the game state across reloads, even when the browser
(such as that of a phone) doesn't naturally preserve it using something like FF's bfcache.
*/
function saveSession() {
if (State.hasSessionStorage) {
const serialisation = State.serialise();
/*
Since we're in the middle of navigating to another Moment, just silently disregard errors.
*/
if (typeof serialisation === "string") {
try {
sessionStorage.setItem("Saved Session", serialisation);
} catch(e) {
// Again, silently disregard errors.
return;
}
}
}
}
/*
A private method to create a new present after altering the state.
@param {String} The name of the passage the player is now currently at.
*/
function newPresent(newPassageName) {
present = (timeline[recent] || Moment).create(newPassageName);
saveSession();
}
/*
The current game's state.
*/
const State = Object.assign({
State = Object.assign({
/*
Getters/setters
*/
......@@ -310,6 +354,9 @@ define(['utils', 'passages', 'datatypes/changercommand', 'internaltypes/twineerr
serialiseProblem = undefined;
eventHandlers.load.forEach(fn => fn(timeline));
},
hasStorage: hasStorage[0],
hasSessionStorage: hasStorage[1],
},
/*
In addition to the above simple methods, two serialisation methods are also present.
......
describe("browser sessions", function() {
'use strict';
function retrieveStoredStateString() {
var storedItem = sessionStorage.getItem("Saved Session");
expect(function() {
JSON.parse(storedItem);
}).not.toThrow();
expect(storedItem).not.toBe(null);
return storedItem;
}
it("are automatically saved to sessionStorage in JSON format", function() {
runPassage("(set:$foo to 1)", "corge");
runPassage("(set:$foo to it + 1)","garply");
retrieveStoredStateString();
});
it("can be restored from sessionStorage", function(done) {
runPassage("uno", "uno");
runPassage("dos(set:$foo to 1)", "dos");
runPassage("tres(set:$bar to (dm:'X',(font:'Skia')))", "tres");
runPassage("cuatro(set:$foo to it + 1)","cuatro");
deserialiseState(retrieveStoredStateString());
setTimeout(function() {
expect("(history:)").markupToPrint("uno,dos,tres,cuatro");
expect("$foo").markupToPrint("2");
expect("(print: $bar's X is (font:'Skia'))").markupToPrint("true");
done();
}, 20);
});
afterAll(function() {
sessionStorage.clear();
});
});
......@@ -39,7 +39,7 @@ window.createPassage = function(text, name, tags) {
Passages.set(name, new Map([
["source", text],
["name", name],
["tags", tags ? [tags] : undefined],
["tags", Array.isArray(tags) ? tags : tags ? [tags] : undefined],
]));
};
window.goToPassage = function(text, name) {
......@@ -125,6 +125,10 @@ window.clearState = function() {
State.reset();
Passages.clear();
};
window.deserialiseState = function(str) {
State.deserialise(str);
Engine.showPassage(State.passage, false /* stretchtext value */);
}
window.Engine = Engine;
</script>
<script>
......@@ -337,6 +341,8 @@ afterAll(function() {
<script src="spec/command_macros/live.js"></script>
<script src="spec/command_macros/saving.js"></script>
<script src="spec/browser/sessions.js"></script>
<tw-story style='visibility:hidden'></tw-story>
<tw-storydata options="debug" ifid="107370AE-1D5A-4A33-A52C-D1180BA69750" startnode="1">
<tw-passagedata pid="1" name="1">
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment