New syntax with NPM module

This commit is contained in:
Alexander Yakovlev 2016-09-10 11:38:07 +07:00
parent bda0b84260
commit 5618f05a8f
15 changed files with 11 additions and 1443 deletions

View file

@ -1,14 +1,10 @@
room = require("../../lib/room.coffee")
unit = require('../../lib/unit.coffee')
dialogue = require('../../lib/util/dialogue.coffee')
phrase = require('../../lib/util/phrase.coffee')
dialogue = require('../../lib/dialogue.coffee')
phrase = require('../../lib/phrase.coffee')
oneOf = require('../../lib/oneOf.coffee')
Salet = require('../../lib/salet.coffee')
salet = require('salet')
salet = Salet({
game_id: "your-game-id-here"
game_version: "1.2"
})
salet.game_id = "your-game-id-here"
salet.game_version = "1.3"
$(document).ready(() ->
window.addEventListener('popstate', (event) ->
salet.goBack()

View file

@ -76,8 +76,8 @@
</div> <!-- End of div.page -->
<!-- CDN JS Libraries -->
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/marked/0.3.5/marked.min.js" integrity="sha384-QXBtGc4014gU26HdCwzgy8TVO+FHSSE4+EvPPiSTpdE9w0KyJy1ocfiIbBl1HLq7" crossorigin="anonymous"></script>
<script type="text/javascript" src="//code.jquery.com/jquery-3.0.0.min.js"
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.5/marked.min.js" integrity="sha384-QXBtGc4014gU26HdCwzgy8TVO+FHSSE4+EvPPiSTpdE9w0KyJy1ocfiIbBl1HLq7" crossorigin="anonymous"></script>
<script type="text/javascript" src="https://code.jquery.com/jquery-3.0.0.min.js"
integrity="sha384-THPy051/pYDQGanwU6poAc/hOdQxjnOEXzbT+OuUAFqNqFjL+4IGLBgCJC3ZOShY" crossorigin="anonymous"></script>
<script type="text/javascript" src="game/bundle.js"></script>

View file

@ -1,40 +0,0 @@
invlink = (content, ref) ->
return "<a href='./_inv_#{ref}' class='once'>#{content}</a>"
class Character
constructor: (spec) ->
@inventory = []
@take = (thing) =>
@inventory.push thing
@drop = (thing) =>
for i in @inventory
if i.name == thing
index = @inventory.indexOf(thing)
@inventory.splice(index, 1)
@has = (thing) =>
for i in @inventory
if i.name == thing
return true
return false
@listinv = (thing) =>
for i in @inventory
if i.name == thing
return invlink(i.display, i.name)
@inv = (thing) =>
for i in @inventory
if i.name == thing
return i.inv.fcall(i)
for index, value of spec
this[index] = value
return this
character = (spec) ->
spec ?= {}
return( new Character(spec) )
module.exports = character

View file

@ -1,4 +1,3 @@
room = require("../room.coffee")
###
A dialogue shortcut.
Usage:

View file

@ -1,62 +0,0 @@
# Internationalization support
languages = {}
# Default Messages
en = {
terrible: "terrible",
poor: "poor",
mediocre: "mediocre",
fair: "fair",
good: "good",
great: "great",
superb: "superb",
yes: "yes",
no: "no",
choice: "Choice {number}",
no_group_definition: "Couldn't find a group definition for {id}.",
link_not_valid: "The link '{link}' doesn't appear to be valid.",
link_no_action: "A link with a situation of '.', must have an action.",
unknown_situation: "You can't move to an unknown situation: {id}.",
existing_situation: "You can't override situation {id} in HTML.",
erase_message: "This will permanently delete this character and immediately return you to the start of the game. Are you sure?",
no_current_situation: "I can't display, because we don't have a current situation.",
no_local_storage: "No local storage available.",
random_seed_error: "You must provide a valid random seed.",
random_error: "Initialize the Random with a non-empty seed before use.",
dice_string_error: "Couldn't interpret your dice string: '{string}'."
}
# Set this data as both the default fallback language, and the english preferred language.
languages[""] = en
languages["en"] = en
languageCodes = Object.keys(languages)
localize = (languageCode, message) ->
for thisCode in languageCodes
if languages[languageCode]?
localized = languages[languageCode][message]
if localized
return localized
return message
# API
String.prototype.l = (args) ->
# Get lang attribute from html tag.
lang = document.getElementsByTagName("html")[0].getAttribute("lang") || ""
# Find the localized form.
localized = localize(lang, this)
if typeof(localized) == "function"
localized = localized(args)
else # Merge in any replacement content.
if args
for name in args
localized = localized.replace(
new RegExp("\\{"+name+"\\}"), args[name]
)
return localized
module.exports = languages;

View file

@ -1,39 +0,0 @@
###
Indent normalization. Removes tabs AND spaces from every line beginning.
Implies that you don't mix up your tabs and spaces.
Copyright 2015 Bruno Dias
###
normaliseTabs = (text) ->
if not text? or typeof(text) != "string" or text == ""
return ""
lines = text.split('\n');
indents = lines
.filter((l) => l != '')
.map((l) => l.match(/^\s+/))
.map((m) ->
if (m == null)
return ''
return m[0]
)
smallestIndent = indents.reduce((max, curr) ->
if (curr.length < max.length)
return curr
return max
)
if smallestIndent == ""
return text
return lines.map((l) ->
return l.replace(new RegExp('^' + smallestIndent), '')
).join('\n')
markdown = (text) ->
unless text?
return ""
if typeof text is Function
text = text()
text = text.toString()
return marked(normaliseTabs(text), {
smartypants: true
})
module.exports = markdown

View file

@ -169,4 +169,4 @@ Array.prototype.oneOf = () ->
String.prototype.oneOf = () ->
return this
module.exports = oneOf;
module.exports = oneOf

View file

@ -1,4 +1,3 @@
room = require("../room.coffee")
###
A phrase shortcut.
Usage:

View file

@ -1,207 +0,0 @@
// Random Number generation based on seedrandom.js code by David Bau.
// Copyright 2010 David Bau, all rights reserved.
//
// Redistribution and use in source and binary forms, with or
// without modification, are permitted provided that the following
// conditions are met:
//
// 1. Redistributions of source code must retain the above
// copyright notice, this list of conditions and the
// following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the
// following disclaimer in the documentation and/or other
// materials provided with the distribution.
//
// 3. Neither the name of this module nor the names of its
// contributors may be used to endorse or promote products
// derived from this software without specific prior written
// permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
// CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
// NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
// EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
var width = 256;
var chunks = 6;
var significanceExponent = 52;
var startdenom = Math.pow(width, chunks);
var significance = Math.pow(2, significanceExponent);
var overflow = significance * 2;
var Random = (function () {
var Random = function(seed) {
this.random = null;
if (!seed) throw {
name: "RandomSeedError",
message: "random_seed_error".l()
};
var key = [];
mixkey(seed, key);
var arc4 = new ARC4(key);
this.random = function() {
var n = arc4.g(chunks);
var d = startdenom;
var x = 0;
while (n < significance) {
n = (n + x) * width;
d *= width;
x = arc4.g(1);
}
while (n >= overflow) {
n /= 2;
d /= 2;
x >>>= 1;
}
return (n + x) / d;
};
};
// Helper type.
var ARC4 = function(key) {
var t, u, me = this, keylen = key.length;
var i = 0, j = me.i = me.j = me.m = 0;
me.S = [];
me.c = [];
if (!keylen) { key = [keylen++]; }
while (i < width) { me.S[i] = i++; }
for (i = 0; i < width; i++) {
t = me.S[i];
j = lowbits(j + t + key[i % keylen]);
u = me.S[j];
me.S[i] = u;
me.S[j] = t;
}
me.g = function getnext(count) {
var s = me.S;
var i = lowbits(me.i + 1); var t = s[i];
var j = lowbits(me.j + t); var u = s[j];
s[i] = u;
s[j] = t;
var r = s[lowbits(t + u)];
while (--count) {
i = lowbits(i + 1); t = s[i];
j = lowbits(j + t); u = s[j];
s[i] = u;
s[j] = t;
r = r * width + s[lowbits(t + u)];
}
me.i = i;
me.j = j;
return r;
};
me.g(width);
};
// Helper functions.
var mixkey = function(seed, key) {
seed += '';
var smear = 0;
for (var j = 0; j < seed.length; j++) {
var lb = lowbits(j);
smear ^= key[lb];
key[lb] = lowbits(smear*19 + seed.charCodeAt(j));
}
seed = '';
for (j in key) {
seed += String.fromCharCode(key[j]);
}
return seed;
};
var lowbits = function(n) {
return n & (width - 1);
};
return Random;
})();
/* Returns a random floating point number between zero and
* one. NB: The prototype implementation below just throws an
* error, it will be overridden in each Random object when the
* seed has been correctly configured. */
Random.prototype.random = function() {
throw {
name:"RandomError",
message: "random_error".l()
};
};
/* Returns an integer between the given min and max values,
* inclusive. */
Random.prototype.randomInt = function(min, max) {
return min + Math.floor((max-min+1)*this.random());
};
/* Returns the result of rolling n dice with dx sides, and adding
* plus. */
Random.prototype.dice = function(n, dx, plus) {
var result = 0;
for (var i = 0; i < n; i++) {
result += this.randomInt(1, dx);
}
if (plus) result += plus;
return result;
};
/* Returns the result of rolling n averaging dice (i.e. 6 sided dice
* with sides 2,3,3,4,4,5). And adding plus. */
Random.prototype.aveDice = (function() {
var mapping = [2,3,3,4,4,5];
return function(n, plus) {
var result = 0;
for (var i = 0; i < n; i++) {
result += mapping[this.randomInt(0, 5)];
}
if (plus) result += plus;
return result;
};
})();
/* Returns a dice-roll result from the given string dice
* specification. The specification should be of the form xdy+z,
* where the x component and z component are optional. This rolls
* x dice of with y sides, and adds z to the result, the z
* component can also be negative: xdy-z. The y component can be
* either a number of sides, or can be the special values 'F', for
* a fudge die (with 3 sides, +,0,-), '%' for a 100 sided die, or
* 'A' for an averaging die (with sides 2,3,3,4,4,5).
*/
Random.prototype.diceString = (function () {
var diceRe = /^([1-9][0-9]*)?d([%FA]|[1-9][0-9]*)([-+][1-9][0-9]*)?$/;
return function(def) {
var match = def.match(diceRe);
if (!match) {
throw new Error(
"dice_string_error".l({string:def})
);
}
var num = match[1]?parseInt(match[1], 10):1;
var sides;
var bonus = match[3]?parseInt(match[3], 10):0;
switch (match[2]) {
case 'A':
return this.aveDice(num, bonus);
case 'F':
sides = 3;
bonus -= num*2;
break;
case '%':
sides = 100;
break;
default:
sides = parseInt(match[2], 10);
break;
}
return this.dice(num, sides, bonus);
};
})();
module.exports = Random;

View file

@ -1,259 +0,0 @@
unit = require('./unit.coffee')
markdown = require('./markdown.coffee')
assert = (msg, assertion) -> console.assert assertion, msg
Function.prototype.fcall = Function.prototype.call
Boolean.prototype.fcall = () ->
return this.valueOf()
String.prototype.fcall = () ->
return this.toString()
way_to = (content, ref) ->
return "<a href='#{ref}' class='way' id='waylink-#{ref}'>#{content}</a>"
Array::remove = (e) -> @[t..t] = [] if (t = @indexOf(e)) > -1
class SaletRoom
constructor: (spec) ->
@visited = 0
@title = "Room"
@units = {}
@canView = true
@canChoose = true
@priority = 1
@displayOrder = 1
@canSave = true
@canExit = true
@tags = []
@choices = ""
@optionText = "Choice"
# room illustration image, VN-style. Can be a GIF or WEBM. Can be a function.
@pic = false
@dsc = false # room description
@extendSection = false
@distance = Infinity # distance to the destination
@clear = true # clear the screen on entering the room?
###
I call SaletRoom.exit every time the player exits to another room.
Unlike @after this gets called after the section is closed.
It's a styling difference.
###
@exit = (system, to) =>
return true
###
I call SaletRoom.enter every time the player enters this room but before the section is opened.
Unlike @before this gets called before the current section is opened.
It's a styling difference.
The upstream Undum version does not allow you to redefine @enter function easily but allows custom @exit one.
It was renamed as @entering to achieve API consistency.
###
@enter = (system, from) =>
return true
###
Salet's Undum version calls Situation.entering 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).
My version of `enter` splits the location description from the effects.
Also if f == this.name (we're in the same location) the `before` and `after` callbacks are ignored.
###
@entering = (system, f) =>
if (
f != @name and
f? and
system.rooms[f].canExit? and
system.rooms[f].canExit == false
)
return system.goTo(f, f)
if @clear and f?
system.view.clearContent()
else
system.view.removeTransient()
if f != @name and f?
@visited++
if system.rooms[f].exit?
system.rooms[f].exit system, @name
history.pushState(@name, @title)
if @enter
@enter system, f
if not @extendSection
classes = if @classes then ' ' + @classes.join(' ') else ''
room = document.getElementById('current-room')
if room?
room.removeAttribute('id')
system.view.append "<section id='current-room' data-room='#{@name}' class='room-#{@name}#{classes}'></section>"
if f != @name and @before?
system.view.write markdown(@before.fcall(this, system, f))
system.view.write @look system, f
if f != @name and @after?
system.view.write markdown(@after.fcall(this, system, f))
if @beforeChoices?
@beforeChoices.fcall(this, system, f)
if @choices
system.view.writeChoices(system, system.getSituationIdChoices(@choices, @maxChoices))
if @afterChoices?
@afterChoices.fcall(this, system, f)
if system.autosave and @canSave
system.saveGame()
###
An internal function to get the room's description and the descriptions of
every unit in this room.
###
@look = (system, f) =>
system.view.updateWays(system, @ways, @name)
retval = ""
if @pic
retval += '<div class="pic">'+system.view.pictureTag(@pic.fcall(this, system, f))+'</div>'
# Print the room description
if @dsc and @dsc != ""
dsc = @dsc.fcall(this, system, f).toString()
retval += markdown(dsc)
unitDescriptions = []
for thing in @units
if thing.name and typeof(thing.look) == "function" and thing.look(system, f)
unitDescriptions.push ({
order: thing.order,
content: thing.look(system, f)
})
unitDescriptions.sort((a, b) ->
return a.order - b.order
)
for description in unitDescriptions
retval += description.content
return retval
###
Places a unit in this room.
###
@take = (thing) =>
@units.push(thing)
@drop = (name) =>
for thing in @units
if thing.name == name
@units.splice(@units.indexOf(thing), 1)
return @units
###
Unit action. A function or a string which comes when you click on a link in unit description.
You could interpret this as an EXAMINE verb or USE one, it's your call.
###
@act = (system, action) =>
if (link = action.match(/^_(act|cycle|inv)_(.+)$/)) #unit action
if link[1] == "inv"
return system.view.write system.character.inv(link[2])
for thing in @units
if thing.name == link[2]
if link[1] == "act"
# If it's takeable, the player can take this unit.
# If not, we check the "act" function.
if thing.takeable
system.character.take(thing)
@drop link[2]
return system.view.write(thing.take.fcall(thing, system).toString())
else if thing.act?
return system.view.write thing.act.fcall(thing, system)
# the loop is done but no return came - match not found
console.error("Could not find #{link[2]} in current room.")
# we're done with units, now check the regular actions
actionClass = action.match(/^_(\w+)_(.+)$/)
that = this
responses = {
writer: (ref) ->
content = that.writers[ref].fcall(that, system, action)
output = markdown(content)
system.view.write(output)
replacer: (ref) ->
content = that.writers[ref].fcall(that, system, action)
system.view.replace(content, '#'+ref)
inserter: (ref) ->
content = that.writers[ref].fcall(that, system, action)
output = markdown(content)
system.view.write(output, '#'+ref)
}
if (actionClass)
# Matched a special action class
[responder, ref] = [actionClass[1], actionClass[2]]
if(!@writers.hasOwnProperty(actionClass[2]))
throw new Error("Tried to call undefined writer: #{action}");
responses[responder](ref);
else if (@actions.hasOwnProperty(action))
@actions[action].call(this, system, action);
else
throw new Error("Tried to call undefined action: #{action}");
# Marks every room in the game with distance to this room
@destination = () =>
@distance = 0
candidates = [this]
while candidates.length > 0
current_room = candidates.shift()
if current_room.ways
for node in current_room.ways
if node.distance == Infinity
node.distance = current_room.distance + 1
candidates.push(node)
@register = (salet) =>
if not @name?
console.error("Situation has no name")
return this
salet.rooms[@name] = this
return this
@writers = {
cyclewriter: (salet) =>
responses = @cycle
if typeof responses == "function"
responses = responses()
cycleIndex = window.localStorage.getItem("cycleIndex")
cycleIndex ?= 0
response = responses[cycleIndex]
cycleIndex++
if cycleIndex == responses.length
cycleIndex = 0
window.localStorage.setItem("cycleIndex", cycleIndex)
return salet.view.cycleLink(response)
}
for index, value of spec
this[index] = value
return this
room = (name, salet, spec) ->
spec ?= {}
spec.name = name
return new SaletRoom(spec).register(salet)
module.exports = room

View file

@ -1,460 +0,0 @@
markdown = require('./markdown.coffee')
SaletView = require('./view.coffee')
Random = require('./random.js')
Character = require('./character.coffee')
require('./localize.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
Boolean.prototype.fcall = () ->
return this
String.prototype.fcall = () ->
return this
assert = (msg, assertion) -> console.assert assertion, msg
###
This is the control structure, it has minimal amount of data and
this data is volatile anyway (as in, it won't get saved).
There is only one instance of this class.
###
class Salet
constructor: (spec) ->
@character = new Character
# REDEFINE THIS IN YOUR GAME
@game_id = null
@game_version = "1.0"
@autosave = true
@rnd = null
@time = 0
# Corresponding room names to room objects.
@rooms = {}
# The unique id of the starting room.
@start = "start"
# 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_-]+))?$/
###
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.
###
@init = () ->
###
This function is called before entering any new
situation. It is called before the corresponding situation
has its `enter` method called.
###
@enter = (oldSituationId, newSituationId) ->
###
Hook for when the situation has already been carried out
and printed.
###
@afterEnter = (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 = (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 = (situationId, actionId) ->
###
This function is called after leaving any situation. It is
called after the corresponding situation has its `exit`
method called.
###
@exit = (oldSituationId, newSituationId) ->
###
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 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.
Before this function returns its result, it sorts the
situations in increasing order of their displayOrder values.
###
@getSituationIdChoices = (listOfOrOneIdsOrTags, maxChoices) =>
datum = null
i = 0
# First check if we have a single string for the id or tag.
if (typeof(listOfOrOneIdsOrTags) == 'string')
listOfOrOneIdsOrTags = [listOfOrOneIdsOrTags]
# First we build a list of all candidate ids.
allIds = []
for tagOrId in listOfOrOneIdsOrTags
if (tagOrId.substr(0, 1) == '#')
ids = @getRoomsTagged(tagOrId.substr(1))
for id in ids
allIds.push(id)
else #it's an id, not a tag
allIds.push(tagOrId)
#Filter out anything that can't be viewed right now.
currentRoom = @getCurrentRoom()
viewableRoomData = []
for roomId in allIds
room = @rooms[roomId]
assert(room, "unknown_situation".l({id:roomId}))
if (room.canView.fcall(this, this, currentRoom, room))
viewableRoomData.push({
priority: room.priority
id: roomId
displayOrder: room.displayOrder
})
# Then we sort in descending priority order.
viewableRoomData.sort((a, b) ->
return b.priority - a.priority
)
committed = []
# if we need to filter out the results
if (maxChoices? && viewableRoomData.length > maxChoices)
viewableRoomData = viewableRoomData[-maxChoices..]
for candidateRoom in viewableRoomData
committed.push({
id: candidateRoom.id
displayOrder: candidateRoom.displayOrder
})
# Now sort in ascending display order.
committed.sort((a, b) ->
return a.displayOrder - b.displayOrder
)
# And return as a list of ids only.
result = []
for i in committed
result.push(i.id)
return result
# This is the data on the player's progress that gets saved.
@progress = {
# A random seed string, used internally to make random
# sequences predictable.
seed: null
# Keeps track of the links clicked, and when.
sequence: [],
# Keeps track of the rooms visited, for when we want to "go back"
path: [],
# The time when the progress was saved.
saveTime: null
}
# The Id of the current room the player is in.
@current = null
# Tracks whether we're in interactive mode or batch mode.
@interactive = true
# The system time when the game was initialized.
@startTime = null
# The stack of links, resulting from the last action, still be to resolved.
@linkStack = null
@getCurrentRoom = () =>
if (@current)
return @rooms[@current]
return null
# Gets the unique id used to identify saved games.
@getSaveId = (slot = "") =>
return 'salet_'+@game_id+'_'+@game_version#+'_'+slot
# This gets called when a link needs to be followed, regardless
# of whether it was user action that initiated it.
@processLink = (code) =>
# Check if we should do this now, or if processing is already underway.
if @linkStack != null
@linkStack.push(code)
return
@view.mark_all_links_old
# 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.
@view.endOutputTransaction()
# We're able to save, if we weren't already.
@view.enableSaving()
@goTo = (roomId) =>
return @processClick(roomId)
###
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.
###
@processOneLink = (code) =>
match = code.match(@linkRe)
if not match
console.error "link_not_valid".l()
console.error code
situation = match[1]
action = match[3]
# Change the situation
if situation != '.' and situation != @current_room
@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)
room = @getCurrentRoom()
if room
consumed = false
if @beforeAction
# Try the global act handler
consumed = @beforeAction(room, action)
if consumed != true
room.act(this, action)
if @afterAction
@afterAction(room, action)
# This gets called when the user clicks a link to carry out an action.
@processClick = (code) =>
now = (new Date()).getTime() * 0.001
@time = now - @startTime
@progress.sequence.push({link:code, when:@time})
if @getRoom(code)? # if it's a room
@progress.path.push(code)
@processLink(code)
# Go back N rooms. It's not an UNDO.
# Also, steps = 1 is the current room
@goBack = (steps = 2) =>
window.history.back()
if @progress.path.length == 1
location = @start
else if @progress.path.length > steps
location = @progress.path[@progress.path.length - steps]
else
return @goBack(steps - 1)
@processClick(location)
# Transition between rooms.
@doTransitionTo = (newRoomId) =>
oldRoomId = @current
oldRoom = @getCurrentRoom()
newRoom = @rooms[newRoomId]
assert(newRoom, "unknown_situation".l({id:newRoomId}))
# We might not have an old situation if this is the start of the game.
if (oldRoom and @exit)
@exit(oldRoomId, newRoomId)
@current = newRoomId
# Remove links and transient sections.
@view.removeTransient(@interactive)
# Notify the incoming situation.
if (@enter)
@enter(oldRoomId, newRoomId)
newRoom.entering(this, oldRoomId)
# additional hook for when the situation text has already been printed
if (@afterEnter)
@afterEnter(oldRoomId, newRoomId)
###
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.
###
@eraseSave = (force = false) =>
saveId = @getSaveId() # save slot
if (localStorage.getItem(saveId) and (force or confirm("erase_message".l())))
localStorage.removeItem(saveId)
window.location.reload()
# Find and return a list of ids for all situations with the given tag.
@getRoomsTagged = (tag) =>
result = []
for id, room of @rooms
for i in room.tags
if (i == tag)
result.push(id)
break
return result
# Saves the character and the walking history to local storage.
@saveGame = () =>
# Store when we're saving the game, to avoid exploits where a
# player loads their file to gain extra time.
now = (new Date()).getTime() * 0.001
@progress.saveTime = now - @startTime
# Save the game.
localStorage.setItem(@getSaveId(), JSON.stringify({
progress: @progress
}))
# Switch the button highlights.
@view.disableSaving()
@view.enableErasing()
@view.enableLoading()
# Loads the game from the given data
@loadGame = (saveFile) =>
@progress = saveFile.progress
@character = new Character
@rnd = new Random(@progress.seed)
# Start the game
@init()
# Run through all the player's history.
@interactive = false
for step in @progress.sequence
# The action must be done at the recorded time.
@time = step.when
@processLink(step.link)
@interactive = true
# Reverse engineer the start time.
now = new Date().getTime() * 0.001
startTime = now - @progress.saveTime
@view = new SaletView
@beginGame = () =>
@view.fixClicks()
# Handle storage.
saveFile = false
if (@view.hasLocalStorage())
saveFile = localStorage.getItem(@getSaveId())
if (saveFile)
try
@loadGame(JSON.parse(saveFile))
@view.disableSaving()
@view.enableErasing()
catch err
console.log "There was an error loading your save. The save will be overwritten."
console.error err
else
@progress.seed = new Date().toString()
@rnd = new Random(@progress.seed)
@progress.sequence = [{link:@start, when:0}]
# Start the game
@startTime = new Date().getTime() * 0.001
@init()
# Do the first state.
@doTransitionTo(@start)
@getRoom = (name) =>
if @rooms[name]?
return @rooms[name]
return undefined
# Just an alias for getCurrentRoom
@here = () => @getCurrentRoom()
@isVisited = (name) =>
place = @getRoom(name)
if place
return Boolean place.visited
return 0
for index, value of spec
this[index] = value
return this
salet = (spec) ->
spec ?= {}
retval = new Salet(spec)
retval.view.init(retval)
return retval
module.exports = salet

View file

@ -1,56 +0,0 @@
markdown = require('./markdown.coffee')
#require('./salet.coffee')
unitlink = (content, ref) ->
return "<a href='./_act_#{ref}' class='once'>#{content}</a>"
Array::remove = (e) -> @[t..t] = [] if (t = @indexOf(e)) > -1
parsedsc = (text, name) ->
window.unitname = name
text = text.replace /\{\{(.+)\}\}/g, (str, p1) ->
name = window.unitname
window.unitname = undefined
return unitlink(p1, name)
return text
# A unit class.
# A unit cannot be in several locations at once, you must clone the variable.
class SaletUnit
constructor: (spec) ->
unless spec.name?
console.error("Trying to create a unit with no name")
return null
@order = 0 # you can use this to sort the descriptions
@visible = true
@look = (system, f) =>
if @dsc and @dsc != "" and @visible
text = markdown(@dsc.fcall(this, system, f).toString())
# replace braces {{}} with link to _act_
return parsedsc(text, @name)
@takeable = false
@take = (system) => "You take the #{@name}." # taking to inventory
@act = (system) => "You don't find anything extraordinary about the #{@name}." # unit action
@dsc = (system) => "You see a {{#{@name}}} here." # unit description
@inv = (system) => "It's a #{@name}." # inventory description
@location = ""
@put = (salet, location) =>
@level = 0 # this is scenery
if salet.rooms[location]?
@location = location
salet.rooms[location].take(this)
else
console.log("Could not find location #{location} for a unit #{@name}")
@delete = (salet, location = false) =>
if location == false
location = @location
salet.rooms[location].drop(this)
for key, value of spec
this[key] = value
unit = (name, spec) ->
spec ?= {}
spec.name = name
return new SaletUnit(spec)
module.exports = unit

View file

@ -1,305 +0,0 @@
markdown = require('./markdown.coffee')
###
Salet interface configuration.
In a typical MVC structure, this is the View.
Only it knows about the DOM structure.
Other modules just use its API or prepare the HTML for insertion.
You don't need to call this module from the game directly.
The abstraction goal here is to provide the author with a freedom to style his
game as he wants to. The save and erase buttons are not necessary buttons,
but they could be something else entirely. (That's why IDs are hardcoded.)
There is only one instance of this class, and it's stored as `salet.view`.
###
assert = (msg, assertion) -> console.assert assertion, msg
way_to = (content, ref) ->
return "<a href='#{ref}' class='way'>#{content}</a>"
addClass = (element, className) ->
if (element.classList)
element.classList.add(className)
else
element.className += ' ' + className
class SaletView
init: (salet) =>
$("#page").on("click", "a", (event) ->
event.preventDefault()
a = $(this)
href = a.attr('href')
if a.hasClass("once") || href.match(/[?&]once[=&]?/)
salet.view.clearLinks(href)
if href.match(salet.linkRe)
salet.processClick(href)
)
$("#load").on("click", "a", (event) ->
window.location.reload()
)
if (@hasLocalStorage())
$("#erase").click((event) ->
event.preventDefault()
return salet.eraseSave()
)
$("#save").click((event) ->
event.preventDefault()
return salet.saveGame()
)
disableSaving: () ->
$("#save").addClass('disabled')
enableSaving: () ->
$("#save").removeClass('disabled')
enableErasing: () ->
$("#erase").removeClass('disabled')
disableErasing: () ->
$("#erase").addClass('disabled')
enableLoading: () ->
$("#load").removeClass('disabled')
disableLoading: () ->
$("#load").addClass('disabled')
# 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());
###
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 @write.
###
clearContent: (elementSelector = "#content") ->
if (elementSelector == "#content") # empty the intro with the content
intro = document.getElementById("intro")
if intro?
intro.innerHTML = ""
document.querySelector(elementSelector).innerHTML = ""
prepareContent: (content) ->
if typeof content == "function"
content = content()
if content instanceof jQuery
content = content[0].outerHTML
return content.toString()
# Write content to current room
write: (content, elementSelector = "#current-room") =>
if not content? or content == ""
return
content = @prepareContent(content)
block = document.querySelector(elementSelector)
if block
block.innerHTML = block.innerHTML + markdown(content)
else
# most likely this is the starting room
block = document.getElementById("content")
block.innerHTML = content
# Append content to a block. Does not replace the old content.
append: (content, elementSelector = "#content") =>
if content == ""
return
content = @prepareContent(content)
block = document.querySelector(elementSelector)
block.innerHTML = block.innerHTML + markdown(content)
# Replaces the text in the given block with the given text.
# !! Does not call markdown on the provided text. !!
replace: (content, elementSelector) =>
if content == ""
return
content = @prepareContent(content)
block = document.querySelector(elementSelector)
block.innerHTML = content
###
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) ->
for a in $("#page").find("a[href='" + code + "']")
html = a.innerHTML
a = $(a)
a.replaceWith($("<span>").addClass("ex_link").html(html))
return true
###
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: (salet, listOfIds) =>
if (not listOfIds? or listOfIds.length == 0)
return
currentRoom = salet.getCurrentRoom()
$options = $("<ul>").addClass("options")
for roomId in listOfIds
room = salet.rooms[roomId]
assert(room, "unknown_situation".l({id:roomId}))
if (room == currentRoom)
continue
optionText = room.optionText.fcall(salet, currentRoom)
if (!optionText)
optionText = "choice".l({number:i+1})
$option = $("<li>")
$a = $("<span>")
if (room.canChoose.fcall(this, salet, currentRoom))
$a = $("<a>").attr({href: roomId})
$a.html(optionText)
$option.html($a)
$options.append($option)
@write($options)
# Marks all links as old. This gets called in a `processLink` function.
mark_all_links_old: () ->
$('.new').removeClass('new')
# Removes links and transient sections.
# Arguments:
# interactive - if we're working in interactive mode (or we're loading a save)
removeTransient: (interactive = false) ->
for a in $('#content').find('a')
a = $(a)
if (a.hasClass('sticky') || a.attr("href").match(/[?&]sticky[=&]?/))
return
a.replaceWith($("<span>").addClass("ex_link").html(a.html()))
contentToHide = $('#content .transient, #content ul.options')
contentToHide.add($("#content a").filter(() ->
return $(this).attr("href").match(/[?&]transient[=&]?/)
))
if (interactive)
contentToHide.animate({opacity: 0}, 350).
slideUp(500, () ->
$(this).remove()
)
else
contentToHide.remove()
# At last, we scroll the view so that .new objects are in view.
endOutputTransaction: () =>
if !@interactive
return; # We're loading a save; do nothing at all.
$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);
# Feature detection
hasLocalStorage: () ->
return window.localStorage?
# Any point that an option list appears, its options are its first links.
fixClicks: () ->
$("body").on('click', "ul.options li", (event) ->
# Make option clicks pass through to their first link.
link = $("a", this)
if (link.length > 0)
$(link.get(0)).click()
)
showBlock: (selector) ->
block = document.querySelector(selector)
if block
block.style.display = "block"
hideBlock: (selector) ->
block = document.querySelector(selector)
if block
block.style.display = "none"
updateWays: (salet, ways, name) ->
if document.getElementById("ways") == null
return
content = ""
distances = []
if ways then for way in ways
if salet.rooms[way]?
title = salet.rooms[way].title.fcall(this, name)
content += "<li class='nav-item'><a class='nav-link' href='#{way}'>#{title}</a></li>"
distances.push({
key: way
distance: salet.rooms[way].distance
})
@showBlock(".ways #ways_hint")
else
@hideBlock(".ways #ways_hint")
document.getElementById("ways").innerHTML = content
min = Infinity
min_key = []
for node in distances
if node.distance < min
min = node.distance
min_key = [node.key]
if node.distance == min
min_key.push(node.key)
if min < Infinity
for node in min_key
waylink = document.getElementById("waylink-#{node}")
if waylink
addClass(waylink, "destination")
pictureTag: (picture) ->
extension = picture.substr((~-picture.lastIndexOf(".") >>> 0) + 2)
if (extension == "webm")
return """
<video src="#{picture}" controls>
Your browser does not support the video tag for some reason.
You won't be able to view this video in this browser.
</video>
"""
return "<img class='img-responsive' src='#{picture}' alt='Room illustration'>"
cycleLink: (content) ->
return "<a href='./_replacer_cyclewriter' class='cycle' id='cyclewriter'>#{content}</a>"
module.exports = SaletView

View file

@ -1,5 +1,7 @@
{
"dependencies": {},
"dependencies": {
"salet": "^1.3.3"
},
"private": true,
"devDependencies": {
"babelify": "^7.2.0",