diff --git a/.gitignore b/.gitignore index 71b27ad..6d163d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/* !node_modules/undularity -!node_modules/undum devel/build/** game/** *.sublime-workspace \ No newline at end of file diff --git a/lib/situation.js b/lib/situation.js new file mode 100644 index 0000000..ba8ceea --- /dev/null +++ b/lib/situation.js @@ -0,0 +1,417 @@ +var undum = require('undum'), + md = require('markdown-it'), + $ = require('jquery'); + +/* --------------------------------------------------------------------------- + Undularity is a rethought API for Undum, featuring more usable interfaces + which coalesce as a DSL for defining Undum stories. +----------------------------------------------------------------------------*/ + +/* --------------------------------------------------------------------------- + Helper functions +----------------------------------------------------------------------------*/ + +/* + Normalises the whitespace on a string. So the indentation level of the + first line will become 0. FIXME: This isn't quite ideal. Need to figure out + a better way of preventing strings in source code ending up interpreted as +
blocks. +*/ + +String.prototype.normaliseTabs = function () { + let lines = this.split('\n'); + let indent = lines[0].match(/^\s+/) || lines[1].match(/^\s+/); + if (!indent) return this; + return lines.map( s => s.replace(new RegExp('^' + indent), '')).join('\n'); +}; + +/* Agnostic Call */ +/* + Many properties in Undularity can be either a String, or a function that + takes some objects from the game state (character, system, and the current + situation) and returns a String. Or in Haskell terms: + String | (CharacterObject -> SystemObject -> SituationString -> String) + + fcall() is added to the prototypes of both String and Function to handle + these situations. 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;}; + +/* + Markdown renderer, defined with options. +*/ + +var markdown = new md({ + typographer: true, // Use smart quotes. + html: true // Passthrough html. +}); + +/* + Ensures a string is a HTML string, by wrapping it in span tags. +*/ + +String.prototype.spanWrap = function () { + return `${this}`; +}; + +/* + Adds the "fade" class to a htmlString. +*/ + +String.prototype.fade = function () { + return $(this).addClass('fade'); +}; + +/* Situations ---------------------------------------------------------------- + + The prototype UndularitySituation is the basic spec for situations + created with Undularity. It should be able to handle any use case for Undum. + + Properties: + + (In addition to properties inherited from undum.Situation) + + actions :: {key: (character, system, from) -> null} + + An object containing definitions for actions, which are called when an + action without a special marker (writer, inserter, replacer) is called + when the situation is current, usually by clicking an action link. + + after :: (character, system, from) -> null + + A function that is called right after printing the content of the + situation. Useful for housekeeping tasks (Such as changing character + stats) or implementing custom behaviour in general. + + before :: (character, system, from) -> null + + Similar to after, but called first + + choices :: [String] + + A list of situation names and/or tags that can be listed as choices for + this situation. That list will be further filtered by CanView and + CanChoose. + + content :: markdownString | (character, system, from) -> markdownString + + The main content of the situation, printed when the situation is entered. + + visited :: Number + + Defaults to 0. Incremented every time the situation is entered. + + writers :: {key: markdownString | (character, system, from) -> markdownString} + + An object containing definitions for special actions called by inserter, + writer, and replacer links. Note that the content of writer links will be + interpreted as a regular markdownString, while the content of replacer + and inserter links, on the assumption that it's meant to be written into + an existing paragraph, will be interpreted as a inline markdown. + +*/ + +var UndularitySituation = function (spec) { + undum.Situation.call(this, spec); + + // Add all properties of the spec to the object, indiscriminately. + Object.keys(spec).forEach( key => { + if (this[key] === undefined) { + this[key] = spec[key]; + } + }); + + this.visited = 0; + +}; + +UndularitySituation.inherits(undum.Situation); + +/* + Undum calls Situation.enter every time a situation is entered, and + passes it three arguments; The character object, the system object, + and a string referencing the previous situation, or null if there is + none (ie, for the starting situation). + + Undularity's version of enter is set up to fulfill most use cases. +*/ + +UndularitySituation.prototype.enter = function (character, system, f) { + + this.visited++; + + if (this.before) this.before(character, system, f); + + if (this.content) { + system.write( + markdown.render( + this.content.fcall(this, character, system, f).normaliseTabs())); + } + + if (this.after) this.after(character, system, f); + + if (this.choices) { + let choices = system.getSituationIdChoices(this.choices, + this.minChoices, this.maxChoices); + system.writeChoices(choices); + } +}; + +/* + Situation.prototype.act() is called by Undum whenever an action link + (Ie, a link that doesn't point at another situation or an external URL) is + clicked. + + Undularity's version of act() is set up to implement commonly used + functionality: "writer" links, "replacer" links, "inserter" links, and + generic "action" links that call functions which access the underlying + Undum API. +*/ + +UndularitySituation.prototype.act = function (character, system, action) { + var actionClass, + self = this; + + var responses = { + writer: function (ref) { + if (self.writers[ref] === undefined) { + console.log(self); + throw new Error("Tried to call undefined writer:" + ref); + } + system.write( + markdown.render( + self.writers[ref].fcall(self, character, system, action) + ).fade()); + }, + replacer: function (ref) { + if (self.writers[ref] === undefined) { + throw new Error("Tried to call undefined replacer:" + ref); + } + system.replaceWith( + markdown.renderInline( + self.writers[ref].fcall(self, character, system, action) + ).spanWrap().fade(), `#${ref}`); + }, + inserter: function (ref) { + if (self.writers[ref] === undefined) { + throw new Error("Tried to call undefined inserter:" + ref); + } + system.writeInto( + markdown.renderInline( + self.writers[ref].fcall(self, character, system, action) + ).spanWrap().fade(), `#${ref}`); + } + }; + + if (actionClass = action.match(/^_(\w+)_(.+)$/)) { + responses[actionClass[1]](actionClass[2]); + } else if (self.actions.hasOwnProperty(action)) { + self.actions[action].call(self, character, system, action); + } else { + throw new Err(`Action "${action}" attempted with no corresponding` + + 'action in current situation.'); + } + +}; + +/* Element Helpers */ +/* + While you can write HTML elements by hand, those helpers make it easier to + place anchors (especially with special purpose) and spans. + + They define a monadic interface: + + a.id('my-link').content('link').writer('my-ref') + -> link + + span -> + + The object supplies a toString() method that outputs the tag. Element + helpers are immutable, so theoretically this should be safe from side + effects. This interface allows the safe definition of templates which + can be used and modified at will, for instance: + + let mySpanClass = span.class('myclass'); // -> + mySpanClass.content("Hello!"); // -> Hello! + + Methods on an elementHelper return a new elementHelper that inherits from + itself. Since those objects (should be) immutable by being frozen when they + are created, this is semantically equivalent to returning a copy but more + efficient in terms + + Methods: + class :: String -> elementHelper + Returns a new elementHelper with the given class added. + + classes :: [String] -> elementHelper + Returns a new elementHelper with the given classes. + + id :: String -> elementHelper + Returns a new elementHelper with the given id. + + +*/ + + +var elementHelper = function (element) { + this.element = element; + this._classes = []; +}; + +var elementSetterGen = function (prop) { + return function (value) { + return Object.freeze(Object.create(this, { + [prop]: {value} + })); + }; +}; + +elementHelper.prototype.classes = function (newClasses) { + return Object.freeze(Object.create(this, { + _classes: { + value: newClasses + } + })); +}; + +elementHelper.prototype.class = function (newClass) { + return this.classes(this._classes.concat(newClass)); +}; + +elementHelper.prototype.id = elementSetterGen("_id"); +elementHelper.prototype.type = elementSetterGen("_linkType"); +elementHelper.prototype.content = elementSetterGen("_content"); +elementHelper.prototype.ref = elementSetterGen("_ref"); +elementHelper.prototype.url = elementHelper.prototype.ref; +elementHelper.prototype.situation = elementHelper.prototype.ref; +elementHelper.prototype.once = () => this.class('once'); + +var linkTypeGen = function (type) { + return function (ref) { + return this.type(type).ref(ref); + } +}; + +elementHelper.prototype.writer = linkTypeGen("writer"); +elementHelper.prototype.replacer = linkTypeGen("replacer"); +elementHelper.prototype.inserter = linkTypeGen("inserter"); +elementHelper.prototype.action = linkTypeGen("action"); + +elementHelper.prototype.toString = function () { + var classes = "", + classString = "", + idString = "", + hrefString= "", + contentString = ""; + + if (this._classes) { + classes += this._classes.join(' '); + } + if (this._linkType) { + classes += (' ' + this._linkType); + } + if (classes) { + classString = ` class="${classes}"`; + } + + if (this._id) { + idString = ` id="${this._id}"`; + } + + if (this.element === "a") { + if (this._linkType) { + if (this._linkType === "action") { + hrefString = ` href="./${this._ref}"`; + } else { + hrefString = ` href="./_${this._linkType}_${this._ref}"`; + } + } else { + hrefString = ` href="${this._ref}"`; + } + } + + if (this._content) { + contentString = markdown.renderInline(this._content); + } + + return `<${this.element}${classString}${idString}${hrefString}>${contentString}${this.element}>`; +}; + +var a_proto = Object.freeze(new elementHelper("a")); +var span_proto = Object.freeze(new elementHelper("span")); + +var a = function (content) { + if (content) return a_proto.content(content); + return a_proto; +}; + +var span = function (content) { + if (content) return span_proto.content(content); + return span_proto; +}; + +/* + Quality definition function + + Meant to be called only once in the main story source file, this definition + is passed a spec to define qualities. The spec is an object containing quality + groups as objects, which contain qualities that themselves hold definitions. +*/ + +var qualities = function (spec) { + Object.keys(spec).forEach(function(group) { + /* The special "name" and "options" properties are passed on. */ + var groupName = (spec[group].name === undefined) ? null : spec[group].name; + var groupOpts = (spec[group].options === undefined) ? {} : spec[group].options; + undum.game.qualityGroups[group] = new undum.QualityGroup(groupName, groupOpts); + Object.keys(spec[group]).forEach(function(quality) { + if (quality === "name" || quality === "options") return; + undum.game.qualities[quality] = spec[group][quality](group); + }); + }); +}; + +var qualityShim = { + integer: "IntegerQuality", + nonZeroInteger: "NonZeroIntegerQuality", + numeric: "NumericQuality", + fudgeAdjectives: "FudgeAdjectivesQuality", + onOff: "OnOffQuality", + yesNo: "YesNoQuality" +}; + +Object.keys(qualityShim).forEach(function (key) { + qualities[key] = function (title, spec={}) { + return function (group) { + spec.group = group; + return new undum[qualityShim[key]](title, spec); + }; + }; +}); + +/* + WordScaleQuality has a different interface (naughty!) so it has to be + defined by hand. +*/ + +qualities.wordScale = function (title, words, spec={}) { + return function (group) { + spec.group = group; + return new undum.WordScaleQuality(title, words, spec); + }; +}; + +module.exports = function (name, spec) { + spec.name = name; + undum.game.situations[name] = new UndularitySituation(spec); +}; + +module.exports.a = a; +module.exports.span = span; + +module.exports.qualities = qualities; \ No newline at end of file diff --git a/lib/tools.js b/lib/tools.js new file mode 100644 index 0000000..b8c5b37 --- /dev/null +++ b/lib/tools.js @@ -0,0 +1,130 @@ +/* + Undularity Tools + + Those functions are not a core part of Undularity, but provide some + general functionality that relates to adaptive text generation. + + This is provided partly as a helper to less technical users, and as + a convenience for authors. +*/ + +/* Monkey patching */ + +/* Array.prototype.shuffle() */ + +/* + Shuffles an array. It can use Undum's random number generator implementation, + so it expects a System.rnd object to be passed into it. If one isn't + supplied, it will use Math.Random instead. + + This is an implementation of the Fischer-Yates (Knuth) shuffle. + + Returns the shuffled array. +*/ + +Array.prototype.shuffle = function (system) { + var rng = (system) ? system.rnd.random : Math.random; + // slice() clones the array. Object members are copied by reference, + // beware. + var newArr = this.slice(); + var m = newArr.length; + + while (m) { + let i = Math.floor(rng() * m --); + let t = newArr[m]; + newArr[m] = newArr[i]; + newArr[i] = t; + } + + return newArr; +}; + +/* + oneOf() + + Takes an array and returns an object with several methods. Each method + returns an iterator which iterates over the array in a specific way: + + inOrder() + Returns the array items in order. + + cycling() + Returns the array items in order, cycling back to the first item when + it runs out. + + stopping() + Returns the array items in order, then repeats the last item when it + runs out. + + randomly() + Returns the array items at random. Takes a system object, for consistent + randomness. Will never return the same item twice in a row. + + trulyAtRandom() + Returns the array items purely at random. Takes a system object, for + consistent randomness. + + inRandomOrder() + Returns the array items in a random order. Takes a system object, for + consistent randomness. +*/ + +var oneOf = function (ary) { + + return { + inOrder () { + var i = 0; + return function () { + return ary[i++]; + }; + }, + + cycling () { + var i = 0; + return function () { + if (i >= ary.length) i = 0; + return ary[i++]; + }; + }, + + stopping () { + var i = 0; + return function () { + if (i >= ary.length) i = ary.length - 1; + return ary[i++]; + } + }, + + randomly (system) { + var rng = (system) ? system.random : Math.random, + last; + return function () { + var i; + + do { + i = Math.floor(rng() * ary.length); + } while (i === last); + + last = i; + return ary[i]; + }; + }, + + trulyAtRandom (system) { + var rng = (system) ? system.random : Math.random; + return function () { + return ary[Math.floor(rng() * ary.length)]; + }; + }, + + inRandomOrder (system) { + var shuffled = ary.shuffle(system), + i = 0; + return function () { + return shuffled[i++]; + }; + } + }; +}; + +exports.oneOf = oneOf; \ No newline at end of file diff --git a/node_modules/undularity b/node_modules/undularity new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/node_modules/undularity @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6578dd2 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "undularity", + "version": "0.1.0", + "description": "A friendly API framework for building hypertext interactive fiction with Undum", + "license": "MIT", + "author": { "name": "Bruno Dias" }, + "files": ["lib"], + "dependencies": { + "jquery": "~2.1.3", + "markdown-it": "~4.1.0", + "undum": "git://github.com/sequitur/undum.git#commonjs" + }, + "devDependencies": { + "gulp": "~3.8.11", + "browserify": "~9.0.8", + "babelify": "~6.0.2", + "vinyl-source-stream": "~1.1.0", + "gulp-less": "~3.0.2" + } +} \ No newline at end of file