1716 lines
60 KiB
JavaScript
1716 lines
60 KiB
JavaScript
// ---------------------------------------------------------------------------
|
|
// UNDUM game library. Games and other modules using it should require it:
|
|
// var undum = require('undum');
|
|
//
|
|
// Version: 3.2.0-oreolek
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var Random = require('./random.js');
|
|
var languages = require('./localize.coffee');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Infrastructure implementations
|
|
|
|
/* Crockford's inherit function */
|
|
/* (Exported globally) */
|
|
|
|
Function.prototype.inherits = function(Parent) {
|
|
var d = {}, p = (this.prototype = new Parent());
|
|
this.prototype.uber = function(name) {
|
|
if (!(name in d)) d[name] = 0;
|
|
var f, r, t = d[name], v = Parent.prototype;
|
|
if (t) {
|
|
while (t) {
|
|
v = v.constructor.prototype;
|
|
t -= 1;
|
|
}
|
|
f = v[name];
|
|
} else {
|
|
f = p[name];
|
|
if (f == this[name]) {
|
|
f = v[name];
|
|
}
|
|
}
|
|
d[name] += 1;
|
|
r = f.apply(this, Array.prototype.slice.apply(arguments, [1]));
|
|
d[name] -= 1;
|
|
return r;
|
|
};
|
|
return this;
|
|
};
|
|
|
|
// Feature detection
|
|
|
|
var hasLocalStorage = function() {
|
|
var hasStorage = false;
|
|
try {
|
|
hasStorage = ('localStorage' in window) &&
|
|
window.localStorage !== null &&
|
|
window.localStorage !== undefined;
|
|
}
|
|
catch (err) {
|
|
// Firefox with the "Always Ask" cookie accept setting
|
|
// will throw an error when attempting to access localStorage
|
|
hasStorage = false;
|
|
}
|
|
return hasStorage;
|
|
};
|
|
|
|
// Assertion
|
|
|
|
var AssertionError = function(message) {
|
|
this.message = message;
|
|
this.name = AssertionError;
|
|
};
|
|
AssertionError.inherits(Error);
|
|
|
|
var assert = function(expression, message) {
|
|
if (!expression) {
|
|
throw new AssertionError(message);
|
|
}
|
|
};
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Types for Author Use
|
|
// -----------------------------------------------------------------------
|
|
|
|
/* The game is split into situations, which respond to user
|
|
* choices. Situation is the base type. It has three methods:
|
|
* enter, act and exit, which you implement to perform any
|
|
* processing and output any content. The default implementations
|
|
* do nothing.
|
|
*
|
|
* You can either create your own type of Situation, and add
|
|
* enter, act and/or exit functions to the prototype (see
|
|
* SimpleSituation in this file for an example of that), or you
|
|
* can give those functions in the opts parameter. The opts
|
|
* parameter is an object. So you could write:
|
|
*
|
|
* var situation = Situation({
|
|
* entering: function(character, system, from) {
|
|
* ... your implementation ...
|
|
* }
|
|
* });
|
|
*
|
|
* If you pass in enter, act and/or exit through these options,
|
|
* then they should have the same function signature as the full
|
|
* function definitions, below.
|
|
*
|
|
* Note that SimpleSituation, a derived type of Situation, calls
|
|
* passed in enter, act and exit functions AS WELL AS their normal
|
|
* action. This is most often what you want: the normal behavior
|
|
* plus a little extra custom behavior. If you want to override
|
|
* the behavior of a SimpleSituation, you'll have to create a
|
|
* derived type and set the enter, act and/or exit function on
|
|
* their prototypes. In most cases, however, if you want to do
|
|
* something completely different, it is better to derive your
|
|
* type from this type: Situation, rather than one of its
|
|
* children.
|
|
*
|
|
* In addition to enter, exit and act, the following options
|
|
* related to implicit situation selection are available:
|
|
*
|
|
* optionText: a string or a function(character, system,
|
|
* situation) which should return the label to put in an
|
|
* option block where a link to this situation can be
|
|
* chosen. The situation passed in is the situation where the
|
|
* option block is being displayed.
|
|
*
|
|
* canView: a function(character, system, situation) which should
|
|
* return true if this situation should be visible in an
|
|
* option block in the given situation.
|
|
*
|
|
* canChoose: a function(character, system, situation) which should
|
|
* return true if this situation should appear clickable in an
|
|
* option block. Returning false allows you to present the
|
|
* option but prevent it being selected. You may want to
|
|
* indicate to the player that they need to collect some
|
|
* important object before the option is available, for
|
|
* example.
|
|
*
|
|
* tags: a list of tags for this situation, which can be used for
|
|
* implicit situation selection. The tags can also be given as
|
|
* space, tab or comma separated tags in a string. Note that,
|
|
* when calling `getSituationIdChoices`, tags are prefixed with
|
|
* a hash, but that should not be the case here. Just use the
|
|
* plain tag name.
|
|
*
|
|
* priority: a numeric priority value (default = 1). When
|
|
* selecting situations implicitly, higher priority situations
|
|
* are considered first.
|
|
*
|
|
* frequency: a numeric relative frequency (default = 1), so 100
|
|
* would be 100 times more frequent. When there are more
|
|
* options that can be displayed, situations will be selected
|
|
* randomly based on their frequency.
|
|
*
|
|
* displayOrder: a numeric ordering value (default = 1). When
|
|
* situations are selected implicitly, the results are ordered
|
|
* by increasing displayOrder.
|
|
*/
|
|
|
|
var Situation = function(opts) {
|
|
if (opts) {
|
|
if (opts.entering) this._entering = opts.entering;
|
|
if (opts.act) this._act = opts.act;
|
|
|
|
// Options related to this situation being automatically
|
|
// selected and displayed in a list of options.
|
|
this._optionText = opts.optionText;
|
|
this._canView = opts.canView || true;
|
|
this._canChoose = opts.canChoose || true;
|
|
this._priority = (opts.priority !== undefined) ? opts.priority : 1;
|
|
this._frequency =
|
|
(opts.frequency !== undefined) ? opts.frequency : 1;
|
|
this._displayOrder =
|
|
(opts.displayOrder !== undefined) ? opts.displayOrder : 1;
|
|
|
|
// Tag are not stored with an underscore, because they are
|
|
// accessed directy. They should not be context sensitive
|
|
// (use the canView function to do context sensitive
|
|
// manipulation).
|
|
if (opts.tags !== undefined) {
|
|
if ($.isArray(opts.tags)) {
|
|
this.tags = opts.tags;
|
|
} else {
|
|
this.tags = opts.tags.split(/[ \t,]+/);
|
|
}
|
|
} else {
|
|
this.tags = [];
|
|
}
|
|
} else {
|
|
this._canView = true;
|
|
this._canChoose = true;
|
|
this._priority = 1;
|
|
this._frequency = 1;
|
|
this._displayOrder = 1;
|
|
this.tags = [];
|
|
}
|
|
};
|
|
|
|
/* A function that takes action when we enter a situation. The
|
|
* last parameter indicates the situation we have just left: it
|
|
* may be null if this is the starting situation. Unlike the
|
|
* exit() method, this method cannot prevent the transition
|
|
* happening: its return value is ignored. */
|
|
Situation.prototype.entering = function(character, system, from) {
|
|
if (this._entering) this._entering(character, system, from);
|
|
};
|
|
/* A function that takes action when we carry out some action in a
|
|
* situation that isn't intended to lead to a new situation. */
|
|
Situation.prototype.act = function(character, system, action) {
|
|
if (this._act) this._act(character, system, action);
|
|
};
|
|
/* Determines whether this situation should be contained within a
|
|
* list of options generated automatically by the given
|
|
* situation. */
|
|
Situation.prototype.canView = function(character, system, situation) {
|
|
if ($.isFunction(this._canView)) {
|
|
return this._canView(character, system, situation);
|
|
} else {
|
|
return this._canView;
|
|
}
|
|
};
|
|
/* Determines whether this situation should be clickable within a
|
|
* list of options generated automatically by the given situation. */
|
|
Situation.prototype.canChoose = function(character, system, situation) {
|
|
if ($.isFunction(this._canChoose)) {
|
|
return this._canChoose(character, system, situation);
|
|
} else {
|
|
return this._canChoose;
|
|
}
|
|
};
|
|
/* Returns the text that should be used to display this situation
|
|
* in an automatically generated list of choices. */
|
|
Situation.prototype.optionText = function(character, system, situation) {
|
|
if ($.isFunction(this._optionText)) {
|
|
return this._optionText(character, system, situation);
|
|
} else {
|
|
return this._optionText;
|
|
}
|
|
};
|
|
/* Returns the priority, frequency and displayOrder for this situation,
|
|
* when being selected using `system.getSituationIdChoices`. */
|
|
Situation.prototype.choiceData = function(character, system, situation) {
|
|
return {
|
|
priority: this._priority,
|
|
frequency: this._frequency,
|
|
displayOrder: this._displayOrder
|
|
};
|
|
};
|
|
|
|
/* A simple situation has a block of content that it displays when
|
|
* the situation is entered. The content must be valid "Display
|
|
* Content" (see `System.prototype.write` for a definition). This
|
|
* constructor has options that control its behavior:
|
|
*
|
|
* heading: The optional `heading` will be used as a section title
|
|
* before the content is displayed. The heading can be any
|
|
* HTML string, it doesn't need to be "Display Content". If
|
|
* the heading is not given, no heading will be displayed. If
|
|
* a heading is given, and no optionText is specified (see
|
|
* `Situation` for more information on `optionText`), then the
|
|
* heading will also be used for the situation's option text.
|
|
*
|
|
* actions: This should be an object mapping action Ids to a
|
|
* response. The response should either be "Display Content"
|
|
* to display if this action is carried out, or it should be a
|
|
* function(character, system, action) that will process the
|
|
* action.
|
|
*
|
|
* choices: A list of situation ids and tags that, if given, will
|
|
* be used to compile an implicit option block using
|
|
* `getSituationIdChoices` (see that function for more details
|
|
* of how this works). Tags in this list should be prefixed
|
|
* with a hash # symbol, to distinguish them from situation
|
|
* ids. If just a single tag or id is needed, it can be passed
|
|
* in as a string without wrapping into a list.
|
|
*
|
|
* minChoices: If `choices` is given, and an implicit choice block
|
|
* should be compiled, set this option to require at least
|
|
* this number of options to be displayed. See
|
|
* `getSituationIdChoices` for a description of the algorithm by
|
|
* which this happens. If you do not specify the `choices`
|
|
* option, then this option will be ignored.
|
|
*
|
|
* maxChoices: If `choices` is given, and an implicit choice block
|
|
* should be compiled, set this option to require no more than
|
|
* this number of options to be displayed. See
|
|
* `getSituationIdChoices` for a description of the algorithm
|
|
* by which this happens. If you do not specify the `choices`
|
|
* option, then this option will be ignored.
|
|
*
|
|
* The remaining options in the `opts` parameter are the same as for
|
|
* the base Situation.
|
|
*/
|
|
var SimpleSituation = function(content, opts) {
|
|
Situation.call(this, opts);
|
|
this.content = content;
|
|
this.heading = opts && opts.heading;
|
|
this.actions = opts && opts.actions;
|
|
|
|
this.choices = opts && opts.choices;
|
|
this.minChoices = opts && opts.minChoices;
|
|
this.maxChoices = opts && opts.maxChoices;
|
|
};
|
|
SimpleSituation.inherits(Situation);
|
|
SimpleSituation.prototype.entering = function(character, system, from) {
|
|
if (this.heading) {
|
|
if ($.isFunction(this.heading)) {
|
|
system.writeHeading(this.heading());
|
|
} else {
|
|
system.writeHeading(this.heading);
|
|
}
|
|
}
|
|
if (this._entering) this._entering(character, system, from);
|
|
if (this.content) {
|
|
if ($.isFunction(this.content)) {
|
|
system.write(this.content());
|
|
} else {
|
|
system.write(this.content);
|
|
}
|
|
}
|
|
if (this.choices) {
|
|
var choices = system.getSituationIdChoices(this.choices,
|
|
this.minChoices,
|
|
this.maxChoices);
|
|
system.writeChoices(choices);
|
|
}
|
|
};
|
|
SimpleSituation.prototype.act = function(character, system, action) {
|
|
var response = this.actions[action];
|
|
try {
|
|
response(character, system, action);
|
|
} catch (err) {
|
|
if (response) system.write(response);
|
|
}
|
|
if (this._act) this._act(character, system, action);
|
|
};
|
|
SimpleSituation.prototype.optionText = function(character, system, sitn) {
|
|
var parentResult = Situation.prototype.optionText.call(this, character,
|
|
system, sitn);
|
|
if (parentResult === undefined) {
|
|
return this.heading;
|
|
} else {
|
|
return parentResult;
|
|
}
|
|
};
|
|
|
|
/* Instances of this class define the qualities that characters
|
|
* may possess. The title should be a string, and can contain
|
|
* HTML. Options are passed in in the opts parameter. The
|
|
* following options are available.
|
|
*
|
|
* priority - A string used to sort qualities within their
|
|
* groups. When the system displays a list of qualities they
|
|
* will be sorted by this string. If you don't give a
|
|
* priority, then the title will be used, so you'll get
|
|
* alphabetic order. Normally you either don't give a
|
|
* priority, or else use a priority string containing 0-padded
|
|
* numbers (e.g. "00001").
|
|
*
|
|
* group - The Id of a group in which to display this
|
|
* parameter. The corresponding group must be defined in
|
|
* your `undum.game.qualityGroups` property.
|
|
*
|
|
* extraClasses - These classes will be attached to the <div> tag
|
|
* that surrounds the quality when it is displayed. A common
|
|
* use for this is to add icons representing the quality. In
|
|
* your CSS define a class for each icon, then pass those
|
|
* classes into the appropriate quality definitions.
|
|
*
|
|
* One key purpose of QualityDefinition is to format the quality
|
|
* value for display. Quality values are always stored as numeric
|
|
* values, but may be displayed in words or symbols. A number of
|
|
* sub-types of QualityDefinition are given that format their
|
|
* values in different ways.
|
|
*/
|
|
var QualityDefinition = function(title, opts) {
|
|
var myOpts = $.extend({
|
|
priority: title,
|
|
group: null,
|
|
extraClasses: null
|
|
}, opts);
|
|
this.title = title;
|
|
this.priority = myOpts.priority;
|
|
this.group = myOpts.group;
|
|
this.extraClasses = myOpts.extraClasses;
|
|
};
|
|
/* Formats the value (which is always numeric) into the value to
|
|
* be displayed. The result should be HTML (but no tags are
|
|
* needed). If null is returned, then the quality definition will
|
|
* not be displayed, so if you want an empty value return an empty
|
|
* string. */
|
|
QualityDefinition.prototype.format = function(character, value) {
|
|
return value.toString();
|
|
};
|
|
|
|
/* A quality that is always displayed as the nearest integer of
|
|
* the current value, rounded down. Options (in the opts
|
|
* parameter) are the same as for QualityDefinition. */
|
|
var IntegerQuality = function(title, opts) {
|
|
QualityDefinition.call(this, title, opts);
|
|
};
|
|
IntegerQuality.inherits(QualityDefinition);
|
|
IntegerQuality.prototype.format = function(character, value) {
|
|
return Math.floor(value).toString();
|
|
};
|
|
|
|
/* A quality that displays as an IntegerQuality, unless it is
|
|
* zero, when it is omitted. Options (in the opts * parameter) are
|
|
* the same as for QualityDefinition. */
|
|
var NonZeroIntegerQuality = function(title, opts) {
|
|
IntegerQuality.call(this, title, opts);
|
|
};
|
|
NonZeroIntegerQuality.inherits(IntegerQuality);
|
|
NonZeroIntegerQuality.prototype.format = function(character, value) {
|
|
if (value === 0) {
|
|
return null;
|
|
} else {
|
|
return IntegerQuality.prototype.format.call(
|
|
this, character, value
|
|
);
|
|
}
|
|
};
|
|
|
|
/* A quality that displays its full numeric value, including
|
|
* decimal component. This is actually a trivial wrapper around
|
|
* the QualityDefinition class, which formats in the same
|
|
* way. Options (in the opts parameter) are the same as for
|
|
* QualityDefinition. */
|
|
var NumericQuality = function(title, opts) {
|
|
QualityDefinition.call(this, title, opts);
|
|
};
|
|
NumericQuality.inherits(QualityDefinition);
|
|
|
|
/* A quality that displays its values as one of a set of
|
|
* words. The quality value is first rounded down to the nearest
|
|
* integer, then this value is used to select a word to
|
|
* display. The offset parameter (optionally passed in as part of
|
|
* the opts object) controls what number maps to what word.
|
|
*
|
|
* The following options (in the opts parameter) are available:
|
|
*
|
|
* offset - With offset=0 (the default), the quantity value of 0
|
|
* will map to the first word, and so on. If offset is
|
|
* non-zero then the value given will correspond to the first
|
|
* word in the list. So if offset=4, then the first word in
|
|
* the list will be used for value=4.
|
|
*
|
|
* useBonuses - If this is true (the default), then values outside
|
|
* the range of words will be construced from the word and a
|
|
* numeric bonus. So with offset=0 and five words, the last of
|
|
* which is 'amazing', a score of six would give 'amazing+1'.
|
|
* if this is false, then the bonus would be omitted, so
|
|
* anything beyond 'amazing' is still 'amazing'.
|
|
*
|
|
* Other options are the same as for QualityDefinition.
|
|
*/
|
|
var WordScaleQuality = function(title, values, opts) {
|
|
var myOpts = $.extend({
|
|
offset: null,
|
|
useBonuses: true
|
|
}, opts);
|
|
QualityDefinition.call(this, title, opts);
|
|
this.values = values;
|
|
this.offset = myOpts.offset;
|
|
this.useBonuses = myOpts.useBonuses;
|
|
};
|
|
WordScaleQuality.inherits(QualityDefinition);
|
|
WordScaleQuality.prototype.format = function(character, value) {
|
|
var val = Math.floor(value - this.offset);
|
|
var mod = "";
|
|
if (val < 0) {
|
|
mod = val.toString();
|
|
val = 0;
|
|
} else if (val >= this.values.length) {
|
|
mod = "+" + (val - this.values.length + 1).toString();
|
|
val = this.values.length - 1;
|
|
}
|
|
if (!this.useBonuses) mod = "";
|
|
if (this.values[val] === null) return null;
|
|
return this.values[val] + mod; // Type coercion
|
|
};
|
|
|
|
/* A specialization of WordScaleQuality that uses the FUDGE RPG's
|
|
* adjective scale (from 'terrible' at -3 to 'superb' at +3). The
|
|
* options are as for WordScaleQuality. In particular you can use
|
|
* the offset option to control where the scale starts. So you
|
|
* could model a quality that everyone starts off as 'terrible'
|
|
* (such as Nuclear Physics) with an offset of 0, while another that
|
|
* is more common (such as Health) could have an offset of -5 so
|
|
* everyone starts with 'great'.
|
|
*/
|
|
var FudgeAdjectivesQuality = function(title, opts) {
|
|
WordScaleQuality.call(this, title, [
|
|
"terrible".l(), "poor".l(), "mediocre".l(),
|
|
"fair".l(), "good".l(), "great".l(), "superb".l()
|
|
], opts);
|
|
if (!('offset' in opts)) this.offset = -3;
|
|
};
|
|
FudgeAdjectivesQuality.inherits(WordScaleQuality);
|
|
|
|
/* An boolean quality that removes itself from the quality list if
|
|
* it has a zero value. If it has a non-zero value, its value
|
|
* field is usually left empty, but you can specify your own
|
|
* string to display as the `onDisplay` parameter of the opts
|
|
* object. Other options (in the opts parameter) are the same as
|
|
* for QualityDefinition. */
|
|
var OnOffQuality = function(title, opts) {
|
|
var myOpts = $.extend({
|
|
onDisplay: ""
|
|
}, opts);
|
|
QualityDefinition.call(this, title, opts);
|
|
this.onDisplay = myOpts.onDisplay;
|
|
};
|
|
OnOffQuality.inherits(QualityDefinition);
|
|
OnOffQuality.prototype.format = function(character, value) {
|
|
if (value) return this.onDisplay;
|
|
else return null;
|
|
};
|
|
|
|
/* A boolean quality that has different output text for zero or
|
|
* non-zero quality values. Unlike OnOffQuality, this definition
|
|
* doesn't remove itself from the list when it is 0. The options
|
|
* are as for QualityDefinition, with the addition of options
|
|
* 'yesDisplay' and 'noDisplay', which contain the HTML fragments
|
|
* used to display true and false values. If not given, these
|
|
* default to 'yes' and 'no'.
|
|
*/
|
|
var YesNoQuality = function(title, opts) {
|
|
var myOpts = $.extend({
|
|
yesDisplay: "yes".l(),
|
|
noDisplay: "no".l()
|
|
}, opts);
|
|
QualityDefinition.call(this, title, opts);
|
|
this.yesDisplay = myOpts.yesDisplay;
|
|
this.noDisplay = myOpts.noDisplay;
|
|
};
|
|
YesNoQuality.inherits(QualityDefinition);
|
|
YesNoQuality.prototype.format = function(character, value) {
|
|
if (value) return this.yesDisplay;
|
|
else return this.noDisplay;
|
|
};
|
|
|
|
/* Defines a group of qualities that should be displayed together,
|
|
* under the given optional title. These should be defined in the
|
|
* `undum.game.qualityGroups` parameter. */
|
|
var QualityGroup = function(title, opts) {
|
|
var myOpts = $.extend({
|
|
priority: title,
|
|
extraClasses: null
|
|
}, opts);
|
|
this.title = title;
|
|
this.priority = myOpts.priority;
|
|
this.extraClasses = myOpts.extraClasses;
|
|
};
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Types Passed to Situations
|
|
// -----------------------------------------------------------------------
|
|
|
|
/* A system object is passed into the enter, act and exit
|
|
* functions of each situation. It is used to interact with the
|
|
* UI.
|
|
*/
|
|
var System = function() {
|
|
this.rnd = null;
|
|
this.time = 0;
|
|
};
|
|
|
|
/* 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.
|
|
*/
|
|
System.prototype.clearContent = function(elementSelector) {
|
|
var $element;
|
|
if (elementSelector) $element = $(elementSelector);
|
|
if (!$element) $element = $("#content");
|
|
$element.empty();
|
|
};
|
|
|
|
/* Outputs regular content to the page. The content supplied must
|
|
* be valid "Display Content".
|
|
*
|
|
* "Display Content" is any HTML string that begins with a HTML
|
|
* start tag, ends with either an end or a closed tag, and is a
|
|
* valid and self-contained snippet of HTML. Note that the string
|
|
* doesn't have to consist of only one HTML tag. You could have
|
|
* several paragraphs, for example, as long as the content starts
|
|
* with the <p> of the first paragraph, and ends with the </p> of
|
|
* the last. So "<p>Foo</p><img src='bar'>" is valid, but "foo<img
|
|
* src='bar'>" is not.
|
|
*
|
|
* The content goes to the end of the page, unless you supply the
|
|
* optional selector argument. If you do, the content appears
|
|
* after the element that matches that selector.
|
|
*/
|
|
System.prototype.write = function(content, elementSelector) {
|
|
doWrite(content, elementSelector, 'append', 'after');
|
|
};
|
|
|
|
/* Outputs the given content in a heading on the page. The content
|
|
* supplied must be valid "Display Content".
|
|
*
|
|
* The content goes to the end of the page, unless you supply the
|
|
* optional selector argument. If you do, the content appears
|
|
* after the element that matches that selector.
|
|
*/
|
|
System.prototype.writeHeading = function(headingContent, elementSelector) {
|
|
var heading = $("<h1>").html(headingContent);
|
|
doWrite(heading, elementSelector, 'append', 'after');
|
|
};
|
|
|
|
/* Outputs regular content to the page. The content supplied must
|
|
* be valid "Display Content".
|
|
*
|
|
* The content goes to the beginning of the page, unless you
|
|
* supply the optional selector argument. If you do, the content
|
|
* appears after the element that matches that selector.
|
|
*/
|
|
System.prototype.writeBefore = function(content, elementSelector) {
|
|
doWrite(content, elementSelector, 'prepend', 'before');
|
|
};
|
|
|
|
/* Outputs regular content to the page. The content supplied must
|
|
* be valid "Display Content".
|
|
*
|
|
* When a selector is not specified, this behaves identically to
|
|
* System.prototype.write. If you supply a selector, the content
|
|
* appears as a child node at the end of the content of the
|
|
* element that matches that selector.
|
|
*/
|
|
|
|
System.prototype.writeInto = function(content, elementSelector) {
|
|
doWrite(content, elementSelector, 'append', 'append');
|
|
};
|
|
|
|
/* Replaces content with the content supplied, which must be valid
|
|
* "Display Content".
|
|
*
|
|
* When a selector is not specified, this replaces the entire
|
|
* content of the page. Otherwise, it replaces the element matched
|
|
* with the selector. This replaces the entire element, including
|
|
* the matched tags, so ideally the content supplied should fit
|
|
* in its place in the DOM with the same kind of display element.
|
|
*/
|
|
|
|
System.prototype.replaceWith = function(content, elementSelector) {
|
|
doWrite(content, elementSelector, 'replaceWith', 'replaceWith');
|
|
};
|
|
|
|
/* Carries out the given situation change or action, as if it were
|
|
* in a link that has been clicked. This allows you to do
|
|
* procedural transitions. You might have an action that builds up
|
|
* the character's strength, and depletes their magic. When the
|
|
* magic is all gone, you can force a situation change by calling
|
|
* this method. */
|
|
System.prototype.doLink = function(code) {
|
|
processLink(code);
|
|
};
|
|
|
|
/* 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. */
|
|
System.prototype.clearLinks = function(code) {
|
|
$("a[href='" + code + "']").each(function(index, element) {
|
|
var a = $(element);
|
|
a.replaceWith($("<span>").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.
|
|
*/
|
|
System.prototype.writeChoices = function(listOfIds, elementSelector) {
|
|
if (listOfIds.length === 0) return;
|
|
|
|
var currentSituation = getCurrentSituation();
|
|
var $options = $("<ul>").addClass("options");
|
|
for (var i = 0; i < listOfIds.length; ++i) {
|
|
var situationId = listOfIds[i];
|
|
var situation = game.situations[situationId];
|
|
assert(situation, "unknown_situation".l({id:situationId}));
|
|
if (situation === currentSituation) {
|
|
continue;
|
|
}
|
|
|
|
var optionText = situation.optionText.fcall(this, character, this,
|
|
currentSituation);
|
|
if (!optionText) optionText = "choice".l({number:i+1});
|
|
var $option = $("<li>");
|
|
var $a;
|
|
if (situation.canChoose(character, this, currentSituation)) {
|
|
$a = $("<a>").attr({href: situationId});
|
|
} else {
|
|
$a = $("<span>");
|
|
}
|
|
$a.html(optionText);
|
|
$option.html($a);
|
|
$options.append($option);
|
|
}
|
|
doWrite($options, elementSelector, 'append', 'after');
|
|
};
|
|
|
|
/*
|
|
* 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; }
|
|
|
|
/* Returns a list of situation ids to choose from, given a set of
|
|
* specifications.
|
|
*
|
|
* This function is a complex and powerful way of compiling
|
|
* implicit situation choices. You give it a list of situation ids
|
|
* and situation tags (if a single id or tag is needed just that
|
|
* string can be given, it doesn't need to be wrapped in a
|
|
* list). Tags should be prefixed with a hash # to differentiate
|
|
* them from situation ids. The function then considers all
|
|
* matching situations in descending priority order, calling their
|
|
* canView functions and filtering out any that should not be
|
|
* shown, given the current state. Without additional parameters
|
|
* the function returns a list of the situation ids at the highest
|
|
* level of priority that has any valid results. So, for example,
|
|
* if a tag #places matches three situations, one with priority 2,
|
|
* and two with priority 3, and all of them can be viewed in the
|
|
* current context, then only the two with priority 3 will be
|
|
* returned. This allows you to have high-priority situations that
|
|
* trump any lower situations when they are valid, such as
|
|
* situations that force the player to go to one destination if
|
|
* the player is out of money, for example.
|
|
*
|
|
* If a minChoices value is given, then the function will attempt
|
|
* to return at least that many results. If not enough results are
|
|
* available at the highest priority, then lower priorities will
|
|
* be considered in turn, until enough situations are found. In
|
|
* the example above, if we had a minChoices of three, then all
|
|
* three situations would be returned, even though they have
|
|
* different priorities. If you need to return all valid
|
|
* situations, regardless of their priorities, set minChoices to a
|
|
* large number, such as `Number.MAX_VALUE`, and leave maxChoices
|
|
* undefined.
|
|
*
|
|
* If a maxChoices value is given, then the function will not
|
|
* return any more than the given number of results. If there are
|
|
* more than this number of results possible, then the highest
|
|
* priority resuls will be guaranteed to be returned, but the
|
|
* lowest priority group will have to fight it out for the
|
|
* remaining places. In this case, a random sample is chosen,
|
|
* taking into account the frequency of each situation. So a
|
|
* situation with a frequency of 100 will be chosen 100 times more
|
|
* often than a situation with a frequency of 1, if there is one
|
|
* space available. Often these frequencies have to be taken as a
|
|
* guideline, and the actual probabilities will only be
|
|
* approximate. Consider three situations with frequencies of 1,
|
|
* 1, 100, competing for two spaces. The 100-frequency situation
|
|
* will be chosen almost every time, but for the other space, one
|
|
* of the 1-frequency situations must be chosen. So the actual
|
|
* probabilities will be roughly 50%, 50%, 100%. When selecting
|
|
* more than one result, frequencies can only be a guide.
|
|
*
|
|
* Before this function returns its result, it sorts the
|
|
* situations in increasing order of their displayOrder values.
|
|
*/
|
|
System.prototype.getSituationIdChoices = function(listOfOrOneIdsOrTags,
|
|
minChoices, maxChoices)
|
|
{
|
|
var datum;
|
|
var i;
|
|
|
|
// First check if we have a single string for the id or tag.
|
|
if ($.type(listOfOrOneIdsOrTags) == 'string') {
|
|
listOfOrOneIdsOrTags = [listOfOrOneIdsOrTags];
|
|
}
|
|
|
|
// First we build a list of all candidate ids.
|
|
var allIds = {};
|
|
for (i = 0; i < listOfOrOneIdsOrTags.length; ++i) {
|
|
var tagOrId = listOfOrOneIdsOrTags[i];
|
|
if (tagOrId.substr(0, 1) == '#') {
|
|
var ids = getSituationIdsWithTag(tagOrId.substr(1));
|
|
for (var j = 0; j < ids.length; ++j) {
|
|
allIds[ids[j]] = true;
|
|
}
|
|
} else {
|
|
allIds[tagOrId] = true;
|
|
}
|
|
}
|
|
|
|
// Filter out anything that can't be viewed right now.
|
|
var currentSituation = getCurrentSituation();
|
|
var viewableSituationData = [];
|
|
for (var situationId in allIds) {
|
|
var situation = game.situations[situationId];
|
|
assert(situation, "unknown_situation".l({id:situationId}));
|
|
|
|
if (situation.canView(character, system, currentSituation)) {
|
|
// While we're here, get the selection data.
|
|
var viewableSituationDatum =
|
|
situation.choiceData(character, system, currentSituation);
|
|
viewableSituationDatum.id = situationId;
|
|
viewableSituationData.push(viewableSituationDatum);
|
|
}
|
|
}
|
|
|
|
// Then we sort in descending priority order.
|
|
viewableSituationData.sort(function(a, b) {
|
|
return b.priority - a.priority;
|
|
});
|
|
|
|
var committed = [];
|
|
var candidatesAtLastPriority = [];
|
|
var lastPriority;
|
|
// In descending priority order.
|
|
for (i = 0; i < viewableSituationData.length; ++i) {
|
|
datum = viewableSituationData[i];
|
|
if (datum.priority != lastPriority) {
|
|
if (lastPriority !== undefined) {
|
|
// We've dropped a priority group, see if we have enough
|
|
// situations so far, and stop if we do.
|
|
if (minChoices === undefined || i >= minChoices) break;
|
|
}
|
|
// Continue to acccumulate more options.
|
|
committed.push.apply(committed, candidatesAtLastPriority);
|
|
candidatesAtLastPriority = [];
|
|
lastPriority = datum.priority;
|
|
}
|
|
candidatesAtLastPriority.push(datum);
|
|
}
|
|
|
|
// So the values in committed we're committed to, because without
|
|
// them we wouldn't hit our minimum. But those in
|
|
// candidatesAtLastPriority might take us over our maximum, so
|
|
// figure out how many we should choose.
|
|
var totalChoices = committed.length + candidatesAtLastPriority.length;
|
|
if (maxChoices === undefined || maxChoices >= totalChoices) {
|
|
// We can use all the choices.
|
|
committed.push.apply(committed, candidatesAtLastPriority);
|
|
} else if (maxChoices >= committed.length) {
|
|
// We can only use the commited ones.
|
|
// NO-OP
|
|
} else {
|
|
// We have to sample the candidates, using their relative frequency.
|
|
var candidatesToInclude = maxChoices - committed.length;
|
|
for (i = 0; i < candidatesAtLastPriority.length; ++i) {
|
|
datum = candidatesAtLastPriority[i];
|
|
datum._frequencyValue = this.rnd.random() / datum.frequency;
|
|
}
|
|
candidatesToInclude.sort(function(a, b) {
|
|
return a._frequencyValue - b._frequencyValue;
|
|
});
|
|
var chosen = candidatesToInclude.slice(0, candidatesToInclude);
|
|
committed.push.apply(committed, chosen);
|
|
}
|
|
|
|
// Now sort in ascending display order.
|
|
committed.sort(function(a, b) {
|
|
return a.displayOrder - b.displayOrder;
|
|
});
|
|
|
|
// And return as a list of ids only.
|
|
var result = [];
|
|
for (i = 0; i < committed.length; ++i) {
|
|
result.push(committed[i].id);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/* Call this to change the character text: the text in the right
|
|
* toolbar before the qualities list. This text is designed to be
|
|
* a short description of the current state of your character. The
|
|
* content you give should be "Display Content" (see
|
|
* `System.prototype.write` for the definition).
|
|
*/
|
|
System.prototype.setCharacterText = function(content) {
|
|
var block = $("#character_text_content");
|
|
var oldContent = block.html();
|
|
var newContent = augmentLinks(content);
|
|
if (interactive && block.is(':visible')) {
|
|
block.fadeOut(250, function() {
|
|
block.html(newContent);
|
|
block.fadeIn(750);
|
|
});
|
|
showHighlight($("#character_text"));
|
|
} else {
|
|
block.html(newContent);
|
|
}
|
|
};
|
|
|
|
/* Call this to change the value of a character quality. Don't
|
|
* directly change quality values, because that will not update
|
|
* the UI. (You can change any data in the character's sandbox
|
|
* directly, however, since that isn't displayed). */
|
|
System.prototype.setQuality = function(quality, newValue) {
|
|
var oldValue = character.qualities[quality];
|
|
character.qualities[quality] = newValue;
|
|
if (!interactive) return;
|
|
|
|
// Work out how to display the values.
|
|
var newDisplay;
|
|
var qualityDefinition = game.qualities[quality];
|
|
if (qualityDefinition) {
|
|
newDisplay = qualityDefinition.format(character, newValue);
|
|
} else {
|
|
// We shouldn't display qualities that have no definition.
|
|
return;
|
|
}
|
|
|
|
// Add the data block, if we need it.
|
|
var qualityBlock = $("#q_"+quality);
|
|
if (qualityBlock.length <= 0) {
|
|
if (newDisplay === null) return;
|
|
qualityBlock = addQualityBlock(quality).hide().fadeIn(500);
|
|
} else {
|
|
// Do nothing if there's nothing to do.
|
|
if (oldValue == newValue) return;
|
|
|
|
// Change the value.
|
|
if (newDisplay === null) {
|
|
// Remove the block, and possibly the whole group, if
|
|
// it is the last quality in the group.
|
|
var toRemove = null;
|
|
var groupBlock = qualityBlock.parents('.quality_group');
|
|
if (groupBlock.find('.quality').length <= 1) {
|
|
toRemove = groupBlock;
|
|
} else {
|
|
toRemove = qualityBlock;
|
|
}
|
|
|
|
toRemove.fadeOut(1000, function() {
|
|
toRemove.remove();
|
|
});
|
|
} else {
|
|
var valBlock = qualityBlock.find("[data-attr='value']");
|
|
valBlock.fadeOut(250, function() {
|
|
valBlock.html(newDisplay);
|
|
valBlock.fadeIn(750);
|
|
});
|
|
}
|
|
}
|
|
showHighlight(qualityBlock);
|
|
};
|
|
|
|
/* The character that is passed into each situation is of this
|
|
* form.
|
|
*
|
|
* The `qualities` data member maps the Ids of each quality to its
|
|
* current value. When implementing enter, act or exit functions,
|
|
* you should consider this to be read-only. Make all
|
|
* modifications through `System.prototype.setQuality`.
|
|
* In your `init` function, you can set these values directly.
|
|
*
|
|
* The `sandbox` data member is designed to allow your code to
|
|
* track any data it needs to. The only proviso is that the data
|
|
* structure should be serializable into JSON (this means it must
|
|
* only consist of primitive types [objects, arrays, numbers,
|
|
* booleans, strings], and it must not contain circular series of
|
|
* references). The data in the sandbox is not displayed in the
|
|
* UI, although you are free to use it to create suitable output
|
|
* for the player..
|
|
*/
|
|
var Character = function() {
|
|
this.qualities = {};
|
|
this.sandbox = {
|
|
inventory: []
|
|
};
|
|
};
|
|
|
|
/* The data structure holding the content for the game. By default
|
|
* this holds nothing. It is this data structure that is populated
|
|
* in the `.game.js` file. Each element in the structure is
|
|
* commented, below.
|
|
*
|
|
* This should be static data that never changes through the
|
|
* course of the game. It is never saved, so anything that might
|
|
* change should be stored in the character.
|
|
*/
|
|
var game = {
|
|
|
|
// Situations
|
|
|
|
/* An object mapping from the unique id of each situation, to
|
|
* the situation object itself. This is the heart of the game
|
|
* specification. */
|
|
situations: {},
|
|
|
|
/* The unique id of the situation to enter at the start of a
|
|
* new game. */
|
|
start: "start",
|
|
|
|
// Quality display definitions
|
|
|
|
/* An object mapping the unique id of each quality to its
|
|
* QualityDefinition. You don't need definitions for every
|
|
* quality, but only qualities in this mapping will be
|
|
* displayed in the character box of the UI. */
|
|
qualities: {},
|
|
|
|
/* Qualities can have an optional group Id. This maps those
|
|
* Ids to the group definitions that says how to format its
|
|
* qualities.
|
|
*/
|
|
qualityGroups: {},
|
|
|
|
|
|
// 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. It can be used to implement
|
|
* timed triggers, but is totally optional. If this function
|
|
* is given it should have the signature:
|
|
*
|
|
* function(character, system, oldSituationId, newSituationId);
|
|
*/
|
|
entering: null,
|
|
|
|
/* Hook for when the situation has already been carried out
|
|
* and printed. The signature is:
|
|
*
|
|
* function(character, system, oldSituationId, newSituationId);
|
|
*/
|
|
afterEnter: null,
|
|
|
|
/* 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 this optional
|
|
* function is given it should have the signature:
|
|
*
|
|
* function(character, system, situationId, actionId);
|
|
*
|
|
* 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: null,
|
|
|
|
/* This function is called after carrying out any action in
|
|
* any situation. It is called after the corresponding
|
|
* situation has its `act` method called. If this optional
|
|
* function is given it should have the signature:
|
|
*
|
|
* function(character, system, situationId, actionId);
|
|
*/
|
|
afterAction: null,
|
|
|
|
/* This function is called after leaving any situation. It is
|
|
* called after the corresponding situation has its `exit`
|
|
* method called. If this optional function is given it should
|
|
* have the signature:
|
|
*
|
|
* function(character, system, oldSituationId, newSituationId);
|
|
*/
|
|
exit: null
|
|
};
|
|
|
|
// =======================================================================
|
|
|
|
// Code below doesn't form part of the public API for UNDUM, so
|
|
// you shouldn't find you need to use it.
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Internal Data
|
|
// -----------------------------------------------------------------------
|
|
|
|
/* The global system object. */
|
|
var system = new System();
|
|
|
|
/* This is the data on the player's progress that gets saved. */
|
|
var progress = {
|
|
// A random seed string, used internally to make random
|
|
// sequences predictable.
|
|
seed: null,
|
|
// Keeps track of the links clicked, and when.
|
|
sequence: [],
|
|
// The time when the progress was saved.
|
|
saveTime: null
|
|
};
|
|
|
|
/* The Id of the current situation the player is in. */
|
|
var current = null;
|
|
|
|
/* This is the current character. It should be reconstructable
|
|
* from the above progress data. */
|
|
var character = null;
|
|
|
|
/* Tracks whether we're in interactive mode or batch mode. */
|
|
var interactive = true;
|
|
|
|
/* The system time when the game was initialized. */
|
|
var startTime;
|
|
|
|
/* The stack of links, resulting from the last action, still be to
|
|
* resolved. */
|
|
var linkStack = null;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Utility Functions
|
|
// -----------------------------------------------------------------------
|
|
|
|
var getCurrentSituation = function() {
|
|
if (current) {
|
|
return game.situations[current];
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
var parse = function(str) {
|
|
if (str === undefined) {
|
|
return str;
|
|
} else {
|
|
return parseFloat(str);
|
|
}
|
|
};
|
|
|
|
var parseList = function(str, canBeUndefined) {
|
|
if (str === undefined) {
|
|
if (canBeUndefined) {
|
|
return undefined;
|
|
} else {
|
|
return [];
|
|
}
|
|
} else {
|
|
return str.split(/[ ,\t]+/);
|
|
}
|
|
};
|
|
|
|
var parseFn = function(str) {
|
|
if (str === undefined) {
|
|
return str;
|
|
} else {
|
|
var fstr = "(function(character, system, situation) {\n" +
|
|
str + "\n})";
|
|
var fn = eval(fstr);
|
|
return fn;
|
|
}
|
|
};
|
|
|
|
/* Outputs regular content to the page. Used by write and
|
|
* writeBefore, the last two arguments control what jQuery methods
|
|
* are used to add the content.
|
|
*/
|
|
var doWrite = function(content, selector, addMethod, appendMethod) {
|
|
var output = augmentLinks(content).addClass('new');
|
|
var element;
|
|
if (selector) {
|
|
element = $(selector);
|
|
}
|
|
if (!element) {
|
|
$('#content')[addMethod](output);
|
|
}
|
|
else {
|
|
element[appendMethod](output);
|
|
}
|
|
};
|
|
|
|
/* Gets the unique id used to identify saved games. */
|
|
var getSaveId = function() {
|
|
return 'undum_'+game.id+"_"+game.version;
|
|
};
|
|
|
|
/* Adds the quality blocks to the character tools. */
|
|
var showQualities = function() {
|
|
$("#qualities").empty();
|
|
for (var qualityId in character.qualities) {
|
|
addQualityBlock(qualityId);
|
|
}
|
|
};
|
|
|
|
/* Fades in and out a highlight on the given element. */
|
|
var showHighlight = function(domElement) {
|
|
var highlight = domElement.find(".highlight");
|
|
if (highlight.length <= 0) {
|
|
highlight = $('<div>').addClass('highlight');
|
|
domElement.append(highlight);
|
|
}
|
|
highlight.fadeIn(250);
|
|
setTimeout(function() {
|
|
highlight.fadeOut(1000);
|
|
}, 2000);
|
|
};
|
|
|
|
/* Finds the correct location and inserts a particular DOM element
|
|
* fits into an existing list of DOM elements. This is done by
|
|
* priority order, so all elements (existing and new) must have
|
|
* their data-priority attribute set. */
|
|
var insertAtCorrectPosition = function(parent, newItem) {
|
|
var newPriority = newItem.attr('data-priority');
|
|
var children = parent.children();
|
|
for (var i = 0; i < children.length; i++) {
|
|
var child = children.eq(i);
|
|
if (newPriority < child.attr('data-priority')) {
|
|
child.before(newItem);
|
|
return;
|
|
}
|
|
}
|
|
parent.append(newItem);
|
|
};
|
|
|
|
/* Adds a new group to the correct location in the quality list. */
|
|
var addGroupBlock = function(groupId) {
|
|
var groupDefinition = game.qualityGroups[groupId];
|
|
|
|
// Build the group div with appropriate heading.
|
|
var groupBlock = $("#ui_library #quality_group").clone();
|
|
groupBlock.attr("id", "g_"+groupId);
|
|
groupBlock.attr("data-priority", groupDefinition.priority);
|
|
|
|
var titleElement = groupBlock.find("[data-attr='title']");
|
|
if (groupDefinition.title) {
|
|
titleElement.html(groupDefinition.title);
|
|
} else {
|
|
titleElement.remove();
|
|
}
|
|
|
|
if (groupDefinition.extraClasses) {
|
|
for (var i = 0; i < groupDefinition.extraClasses.length; i++) {
|
|
groupBlock.addClass(groupDefinition.extraClasses[i]);
|
|
}
|
|
}
|
|
|
|
// Add the block to the correct place.
|
|
var qualities = $("#qualities");
|
|
insertAtCorrectPosition(qualities, groupBlock);
|
|
return groupBlock;
|
|
};
|
|
|
|
/* Adds a new quality to the correct location in the quality list. */
|
|
var addQualityBlock = function(qualityId) {
|
|
// Make sure we want to display this quality.
|
|
var qualityDefinition = game.qualities[qualityId];
|
|
if (!qualityDefinition) {
|
|
throw new Error("Can't display a quality that hasn't been defined: "+
|
|
qualityId);
|
|
}
|
|
|
|
// Work out how the value should be displayed.
|
|
var name = qualityDefinition.title;
|
|
var val = qualityDefinition.format(
|
|
character, character.qualities[qualityId]
|
|
);
|
|
if (val === null) return null;
|
|
|
|
// Create the quality output.
|
|
var qualityBlock = $("#ui_library #quality").clone();
|
|
qualityBlock.attr("id", "q_"+qualityId);
|
|
qualityBlock.attr("data-priority", qualityDefinition.priority);
|
|
qualityBlock.find("[data-attr='name']").html(name);
|
|
qualityBlock.find("[data-attr='value']").html(val);
|
|
if (qualityDefinition.extraClasses) {
|
|
for (var i = 0; i < qualityDefinition.extraClasses.length; i++) {
|
|
qualityBlock.addClass(qualityDefinition.extraClasses[i]);
|
|
}
|
|
}
|
|
|
|
// Find or create the group block.
|
|
var groupBlock;
|
|
var groupId = qualityDefinition.group;
|
|
if (groupId) {
|
|
var group = game.qualityGroups[groupId];
|
|
assert(group, "no_group_definition".l({id: groupId}));
|
|
groupBlock = $("#g_"+groupId);
|
|
if (groupBlock.length <= 0) {
|
|
groupBlock = addGroupBlock(groupId);
|
|
}
|
|
}
|
|
|
|
// Position it correctly.
|
|
var groupQualityList = groupBlock.find(".qualities_in_group");
|
|
insertAtCorrectPosition(groupQualityList, qualityBlock);
|
|
return qualityBlock;
|
|
};
|
|
|
|
/* We use a CSS class, .new, to track which elements have just been added
|
|
* to the story roll. That allows authors to define special styling for
|
|
* newly-added elements (such as a fade-in effect), but we also use it to
|
|
* intelligently figure out where to place the scroll point at the top
|
|
* of each one. */
|
|
|
|
/* Clears .new from every object with that class, in preparation for adding
|
|
* new ones. */
|
|
var startOutputTransaction = function() {
|
|
$('.new').removeClass('new');
|
|
};
|
|
|
|
/* Scrolls the top of the screen to the specified point */
|
|
var scrollTopTo = function (value) {
|
|
$('html,body').animate({scrollTop: value}, 500);
|
|
};
|
|
|
|
/* Scrolls the bottom of the screen to the specified point */
|
|
var scrollBottomTo = function (value) {
|
|
scrollTopTo(value - $(window).height());
|
|
};
|
|
|
|
/* Scrolls all the way to the bottom of the screen */
|
|
var scrollToBottom = function () {
|
|
scrollTopTo($('html').height() - $(window).height());
|
|
};
|
|
|
|
/* At last, we scroll the view so that .new objects are in view. */
|
|
var endOutputTransaction = function () {
|
|
if (!interactive) return; // We're loading a save; do nothing at all.
|
|
var
|
|
$new = $('.new'),
|
|
viewHeight = $(window).height(),
|
|
newTop, newBottom, newHeight, optionHeight = 0;
|
|
|
|
if ($new.length === 0) return; /* Somehow, there's nothing new. */
|
|
|
|
newTop = $new.first().offset().top,
|
|
newBottom = $new.last().offset().top + $new.last().height(),
|
|
newHeight = newBottom - newTop;
|
|
|
|
/* We take the options list into account, because we don't want the new
|
|
* content to scroll offscreen when the list disappears. So we calculate
|
|
* scroll points as though the option list was already gone. */
|
|
if ($('.options').not('.new').length)
|
|
optionHeight = $('.options').not('new').height();
|
|
|
|
if (newHeight > (viewHeight - optionHeight - 50)) {
|
|
/* The new content is too long for our viewport, so we scroll the
|
|
* top of the new content to roughly 75% of the way up the viewport's
|
|
* height. */
|
|
scrollTopTo(newTop-(viewHeight*0.25) - optionHeight);
|
|
}
|
|
else {
|
|
|
|
if (newTop > $('body').height() - viewHeight) {
|
|
/* If we scroll right to the bottom, the new content will be in
|
|
* view. So we do that.*/
|
|
scrollToBottom();
|
|
}
|
|
else {
|
|
/* Our new content is too far up the page. So we scroll to place
|
|
* it somewhere near the bottom. */
|
|
scrollBottomTo(newBottom+100 - optionHeight);
|
|
}
|
|
}
|
|
};
|
|
|
|
/* This gets called when a link needs to be followed, regardless
|
|
* of whether it was user action that initiated it. */
|
|
var linkRe = /^([a-z0-9_-]+|\.)(\/([0-9a-z_-]+))?$/;
|
|
var processLink = function(code) {
|
|
// Check if we should do this now, or if processing is already
|
|
// underway.
|
|
if (linkStack !== null) {
|
|
linkStack.push(code);
|
|
return;
|
|
}
|
|
|
|
// Track where we're about to add new content.
|
|
startOutputTransaction();
|
|
|
|
// We're processing, so make the stack available.
|
|
linkStack = [];
|
|
|
|
// Handle each link in turn.
|
|
processOneLink(code);
|
|
while (linkStack.length > 0) {
|
|
code = linkStack.shift();
|
|
processOneLink(code);
|
|
}
|
|
|
|
// We're done, so remove the stack to prevent future pushes.
|
|
linkStack = null;
|
|
|
|
// Scroll to the top of the new content.
|
|
endOutputTransaction();
|
|
|
|
// We're able to save, if we weren't already.
|
|
$("#save").removeClass('disabled');
|
|
};
|
|
|
|
/* This gets called to actually do the work of processing a code.
|
|
* When one doLink is called (or a link is clicked), this may set call
|
|
* code that further calls doLink, and so on. This method processes
|
|
* each one, and processLink manages this.
|
|
*/
|
|
var processOneLink = function(code) {
|
|
var match = code.match(linkRe);
|
|
assert(match, "link_not_valid".l({link:code}));
|
|
|
|
var situation = match[1];
|
|
var action = match[3];
|
|
|
|
// Change the situation
|
|
if (situation !== '.') {
|
|
if (situation !== current) {
|
|
doTransitionTo(situation);
|
|
}
|
|
} else {
|
|
// We should have an action if we have no situation change.
|
|
assert(action, "link_no_action".l());
|
|
}
|
|
|
|
// Carry out the action
|
|
if (action) {
|
|
situation = getCurrentSituation();
|
|
if (situation) {
|
|
if (game.beforeAction) {
|
|
// Try the global act handler, and see if we need
|
|
// to notify the situation.
|
|
var consumed = game.beforeAction(
|
|
character, system, current, action
|
|
);
|
|
if (consumed !== true) {
|
|
situation.act(character, system, action);
|
|
}
|
|
} else {
|
|
// We have no global act handler, always notify
|
|
// the situation.
|
|
situation.act(character, system, action);
|
|
}
|
|
if (game.afterAction) {
|
|
game.afterAction(character, system, current, action);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/* This gets called when the user clicks a link to carry out an
|
|
* action. */
|
|
var processClick = function(code) {
|
|
var now = (new Date()).getTime() * 0.001;
|
|
system.time = now - startTime;
|
|
progress.sequence.push({link:code, when:system.time});
|
|
processLink(code);
|
|
};
|
|
|
|
/* Transitions between situations. */
|
|
var doTransitionTo = function(newSituationId) {
|
|
var oldSituationId = current;
|
|
var oldSituation = getCurrentSituation();
|
|
var newSituation = game.situations[newSituationId];
|
|
|
|
assert(newSituation, "unknown_situation".l({id:newSituationId}));
|
|
|
|
// We might not have an old situation if this is the start of
|
|
// the game.
|
|
if (oldSituation) {
|
|
if (game.exit) {
|
|
game.exit(character, system, oldSituationId, newSituationId);
|
|
}
|
|
|
|
// Remove links and transient sections.
|
|
$('#content a').each(function(index, element) {
|
|
var a = $(element);
|
|
if (a.hasClass('sticky') || a.attr("href").match(/[?&]sticky[=&]?/))
|
|
return;
|
|
a.replaceWith($("<span>").addClass("ex_link").html(a.html()));
|
|
});
|
|
var contentToHide = $('#content .transient, #content ul.options');
|
|
contentToHide.add($("#content a").filter(function(){
|
|
return $(this).attr("href").match(/[?&]transient[=&]?/);
|
|
}));
|
|
if (interactive) {
|
|
contentToHide.
|
|
animate({opacity: 0}, 350).
|
|
slideUp(500, function() {
|
|
$(this).remove();
|
|
});
|
|
} else {
|
|
contentToHide.remove();
|
|
}
|
|
}
|
|
|
|
// Move the character.
|
|
current = newSituationId;
|
|
|
|
// Notify the incoming situation.
|
|
if (game.enter) {
|
|
game.enter(character, system, oldSituationId, newSituationId);
|
|
}
|
|
newSituation.entering(character, system, oldSituationId);
|
|
|
|
// additional hook for when the situation text has already been printed
|
|
if (game.afterEnter) {
|
|
game.afterEnter(character, system, oldSituationId, newSituationId);
|
|
}
|
|
};
|
|
|
|
/* Returns HTML from the given content with the non-raw links
|
|
* wired up. */
|
|
var augmentLinks = function(content) {
|
|
var output = $(content);
|
|
|
|
// Wire up the links for regular <a> tags.
|
|
output.find("a").each(function(index, element) {
|
|
var a = $(element);
|
|
var href = a.attr('href');
|
|
if (!a.hasClass("raw")|| href.match(/[?&]raw[=&]?/)) {
|
|
if (href.match(linkRe)) {
|
|
a.click(function(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;
|
|
};
|
|
|
|
/* Set up the screen from scratch to reflect the current game
|
|
* state. */
|
|
var initGameDisplay = function() {
|
|
// Transition into the first situation,
|
|
$("#content").empty();
|
|
|
|
var situation = getCurrentSituation();
|
|
assert(situation, "no_current_situation".l());
|
|
|
|
showQualities();
|
|
};
|
|
|
|
/* 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);
|
|
showQualities();
|
|
|
|
// 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();
|
|
showQualities();
|
|
|
|
// 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;
|
|
|
|
// Because we did the run through non-interactively, now we
|
|
// need to update the UI.
|
|
showQualities();
|
|
};
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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();
|
|
}
|
|
});
|
|
};
|
|
|
|
/* Export our API. */
|
|
module.exports = {
|
|
Situation: Situation,
|
|
SimpleSituation: SimpleSituation,
|
|
|
|
QualityDefinition: QualityDefinition,
|
|
IntegerQuality: IntegerQuality,
|
|
NonZeroIntegerQuality: NonZeroIntegerQuality,
|
|
NumericQuality: NumericQuality,
|
|
WordScaleQuality: WordScaleQuality,
|
|
FudgeAdjectivesQuality: FudgeAdjectivesQuality,
|
|
OnOffQuality: OnOffQuality,
|
|
YesNoQuality: YesNoQuality,
|
|
|
|
QualityGroup: QualityGroup,
|
|
|
|
game: game,
|
|
begin: begin,
|
|
|
|
language: languages,
|
|
|
|
processClick: processClick, // you need this if you want to have some additional link blocks
|
|
|
|
isInteractive: function() { return interactive; },
|
|
};
|