From e54585cdbc982eae8c1a32b803cd7625510b244d Mon Sep 17 00:00:00 2001 From: Alexander Yakovlev Date: Fri, 22 Jan 2016 22:07:34 +0700 Subject: [PATCH] Rewriting Undum WIP. --- lib/interface.coffee | 1 - lib/room.coffee | 42 +- lib/salet.coffee | 702 ++++++++++++++++++ lib/undum.js | 1617 ------------------------------------------ 4 files changed, 731 insertions(+), 1631 deletions(-) create mode 100644 lib/salet.coffee delete mode 100644 lib/undum.js diff --git a/lib/interface.coffee b/lib/interface.coffee index db3a2de..9cef3a0 100644 --- a/lib/interface.coffee +++ b/lib/interface.coffee @@ -1,7 +1,6 @@ ### Salet interface configuration. ### -undum = require('./undum.js') $(document).ready(() -> $("#ways").on("click", "a", (event) -> event.preventDefault() diff --git a/lib/room.coffee b/lib/room.coffee index b3af465..927612b 100644 --- a/lib/room.coffee +++ b/lib/room.coffee @@ -1,25 +1,30 @@ # I confess that this world model heavily borrows from INSTEAD engine. - A.Y. -undum = require('./undum.js') obj = require('./obj.coffee') markdown = require('./markdown.coffee') cycle = require('./cycle.coffee') +Random = require('./random.js') +languages = require('./localize.coffee') + +# Feature detection +hasLocalStorage = () -> + hasStorage = false + try { + hasStorage = ('localStorage' in window) && + window.localStorage !== null && + window.localStorage !== undefined; + } + catch (err) { + hasStorage = false + } + return hasStorage + +# Assertion +assert = console.assert way_to = (content, ref) -> return "#{content}" -# jQuery was confused by this point where's the context so I did it vanilla-way -print = (content) -> - if content == "" - return - if typeof content == "function" - content = content() - block = document.getElementById("current-situation") - if block - block.innerHTML = block.innerHTML + markdown(content) - else - console.error("No current situation found.") - Array::remove = (e) -> @[t..t] = [] if (t = @indexOf(e)) > -1 addClass = (element, className) -> @@ -84,11 +89,22 @@ class SaletRoom extends undum.Situation # room illustration image, VN-style. Can be a GIF or WEBM. Can be a function. pic: false + canView: true + canChoose: true + priority: 1 + frequency: 1 + displayOrder: 1 + tags: [] + choices: "" + optionText: "Choice" + dsc: false # room description extendSection: false distance: Infinity # distance to the destination clear: true # clear the screen on entering the room? + entering: (character, system, from) => + ### I call SaletRoom.exit every time the player exits to another room. Unlike @after this gets called after the section is closed. diff --git a/lib/salet.coffee b/lib/salet.coffee new file mode 100644 index 0000000..3a01d64 --- /dev/null +++ b/lib/salet.coffee @@ -0,0 +1,702 @@ +markdown = require('./markdown.coffee') + +### +fcall() (by analogy with fmap) is added to the prototypes of both String and +Function. When called on a Function, it's an alias for Function#call(); +when called on a String, it only returns the string itself, discarding any input. +### + +Function.prototype.fcall = Function.prototype.call; +String.prototype.fcall = function() { return this; } + +# Utility functions + +parseFn = (str) -> + unless str? + return str + + fstr = """ +(function(character, system, situation) { +#{str} +#}) +""" + return eval(fstr); + +# Scrolls the top of the screen to the specified point +scrollTopTo = (value) -> + $('html,body').animate({scrollTop: value}, 500) + +# Scrolls the bottom of the screen to the specified point +scrollBottomTo = (value) -> + scrollTopTo(value - $(window).height()) + +# Scrolls all the way to the bottom of the screen +scrollToBottom = () -> + scrollTopTo($('html').height() - $(window).height()); + +# Regular expression to catch every link action. +# Salet's default is a general URL-safe expression. +linkRe = /^([0-9A-Za-z_-]+|\.)(\/([0-9A-Za-z_-]+))?$/ + +# Returns HTML from the given content with the non-raw links wired up. +augmentLinks = (content) -> + output = $(content) + + # Wire up the links for regular tags. + output.find("a").each((index, element) -> + a = $(element) + href = a.attr('href') + if (!a.hasClass("raw")|| href.match(/[?&]raw[=&]?/)) + if (href.match(linkRe)) + a.click((event) -> + event.preventDefault() + + # If we're a once-click, remove all matching links. + if (a.hasClass("once") || href.match(/[?&]once[=&]?/)) + system.clearLinks(href) + + processClick(href) + return false + ) + else + a.addClass("raw") + ) + return output + +/* Erases the character in local storage. This is permanent! +/* To restart the game afterwards, we perform a simple page refresh. +/* This guarantees authors don't have to care about "tainting" the +/* game state across save/erase cycles, meaning that character.sandbox +/* no longer has to be the end-all be-all repository of game state. */ +var doErase = function(force) { + var saveId = getSaveId(); + if (localStorage.getItem(saveId)) { + if (force || confirm("erase_message".l())) { + localStorage.removeItem(saveId); + window.location.reload(); + } + } +}; + +/* Find and return a list of ids for all situations with the given tag. */ +var getSituationIdsWithTag = function(tag) { + var result = []; + for (var situationId in game.situations) { + var situation = game.situations[situationId]; + + for (var i = 0; i < situation.tags.length; ++i) { + if (situation.tags[i] == tag) { + result.push(situationId); + break; + } + } + } + return result; +}; + +/* Clear the current game output and start again. */ +var startGame = function() { + progress.seed = new Date().toString(); + + character = new Character(); + system.rnd = new Random(progress.seed); + progress.sequence = [{link:game.start, when:0}]; + + // Empty the display + $("#content").empty(); + + // Start the game + startTime = new Date().getTime() * 0.001; + system.time = 0; + if (game.init) game.init(character, system); + + // Do the first state. + doTransitionTo(game.start); +}; + +/* Saves the character to local storage. */ +var saveGame = function() { + // Store when we're saving the game, to avoid exploits where a + // player loads their file to gain extra time. + var now = (new Date()).getTime() * 0.001; + progress.saveTime = now - startTime; + + // Save the game. + localStorage.setItem(getSaveId(), JSON.stringify(progress)); + + // Switch the button highlights. + $("#erase").removeClass('disabled'); + $("#load").removeClass('disabled'); + $("#save").addClass('disabled'); +}; + +/* Loads the game from the given data */ +var loadGame = function(characterData) { + progress = characterData; + + character = new Character(); + system.rnd = new Random(progress.seed); + + // Empty the display + $("#content").empty(); + $("#intro").empty(); + + // Now play through the actions so far: + if (game.init) game.init(character, system); + + // Run through all the player's history. + interactive = false; + for (var i = 0; i < progress.sequence.length; i++) { + var step = progress.sequence[i]; + // The action must be done at the recorded time. + system.time = step.when; + processLink(step.link); + } + interactive = true; + + // Reverse engineer the start time. + var now = new Date().getTime() * 0.001; + startTime = now - progress.saveTime; +}; + +// ----------------------------------------------------------------------- +// Setup +// ----------------------------------------------------------------------- + +var begin = function () { +/* Set up the game when everything is loaded. */ + + // Handle storage. + if (hasLocalStorage()) { + var erase = $("#erase").click(function() { + doErase(); + }); + var save = $("#save").click(saveGame); + + var storedCharacter = localStorage.getItem(getSaveId()); + if (storedCharacter) { + try { + loadGame(JSON.parse(storedCharacter)); + save.addClass('disabled') + erase.removeClass('disabled') + } catch(err) { + doErase(true); + } + } else { + startGame(); + } + } else { + startGame(); + } + // Any point that an option list appears, its options are its + // first links. + $("body").on('click', "ul.options li, #menu li", function(event) { + // Make option clicks pass through to their first link. + var link = $("a", this); + if (link.length > 0) { + $(link.get(0)).click(); + } + }); +}; + + +### +This is the control structure, it has minimal amount of data and +this data is volatile anyway (as in, it won't get saved). +### +Salet = { + rnd: null + time: 0 + + # Corresponding room names to room objects. + rooms: {} + + # The unique id of the starting room. + start: "start" + + # --- Hooks --- + + ### + This function is called at the start of the game. It is + normally overridden to provide initial character creation + (setting initial quality values, setting the + character-text. This is optional, however, as set-up + processing could also be done by the first situation's + enter function. If this function is given it should have + the signature function(character, system). + ### + init: null + + ### + This function is called before entering any new + situation. It is called before the corresponding situation + has its `enter` method called. + ### + entering: (character, system, oldSituationId, newSituationId) -> + + ### + Hook for when the situation has already been carried out + and printed. + ### + afterEnter: (character, system, oldSituationId, newSituationId) -> + + ### + This function is called before carrying out any action in + any situation. It is called before the corresponding + situation has its `act` method called. + + If the function returns true, then it is indicating that it + has consumed the action, and the action will not be passed + on to the situation. Note that this is the only one of + these global handlers that can consume the event. + ### + beforeAction: (character, system, situationId, actionId) -> + + ### + This function is called after carrying out any action in + any situation. It is called after the corresponding + situation has its `act` method called. + ### + afterAction: (character, system, situationId, actionId) -> + + ### + This function is called after leaving any situation. It is + called after the corresponding situation has its `exit` + method called. + ### + exit: (character, system, oldSituationId, newSituationId) -> + + ### + Removes all content from the page, clearing the main content area. + + If an elementSelector is given, then only that selector will be + cleared. Note that all content from the cleared element is removed, + but the element itself remains, ready to be filled again using + System.write. + ### + clearContent: (elementSelector) -> + document.querySelector(elementSelector).innerHTML = "" + + # jQuery was confused by this point where's the context so I did it vanilla-way + write: (content, elementSelector) -> + if content == "" + return + if typeof content == "function" + content = content() + block = document.getElementById("current-situation") + if block + block.innerHTML = block.innerHTML + markdown(content) + else + console.error("No current situation found.") + + ### + Turns any links that target the given href into plain + text. This can be used to remove action options when an action + is no longer available. It is used automatically when you give + a link the 'once' class. + ### + clearLinks: (code) -> + $("a[href='" + code + "']").each((index, element) -> + a = $(element) + a.replaceWith($("").addClass("ex_link").html(a.html())) + ) + + ### + Given a list of situation ids, this outputs a standard option + block with the situation choices in the given order. + + The contents of each choice will be a link to the situation, + the text of the link will be given by the situation's + outputText property. Note that the canChoose function is + called, and if it returns false, then the text will appear, but + the link will not be clickable. + + Although canChoose is honored, canView and displayOrder are + not. If you need to honor these, you should either do so + manually, ot else use the `getSituationIdChoices` method to + return an ordered list of valid viewable situation ids. + ### + writeChoices: (listOfIds, elementSelector) -> + if (listOfIds.length == 0) + return + + currentSituation = getCurrentSituation(); + $options = $("