0
0
Fork 0
mirror of https://gitlab.com/Oreolek/salet-module.git synced 2024-06-30 22:05:02 +03:00

Rewriting Undum WIP.

This commit is contained in:
Alexander Yakovlev 2016-01-22 22:07:34 +07:00
parent 15792d556c
commit e54585cdbc
4 changed files with 731 additions and 1631 deletions

View file

@ -1,7 +1,6 @@
###
Salet interface configuration.
###
undum = require('./undum.js')
$(document).ready(() ->
$("#ways").on("click", "a", (event) ->
event.preventDefault()

View file

@ -1,25 +1,30 @@
# I confess that this world model heavily borrows from INSTEAD engine. - A.Y.
undum = require('./undum.js')
obj = require('./obj.coffee')
markdown = require('./markdown.coffee')
cycle = require('./cycle.coffee')
Random = require('./random.js')
languages = require('./localize.coffee')
# Feature detection
hasLocalStorage = () ->
hasStorage = false
try {
hasStorage = ('localStorage' in window) &&
window.localStorage !== null &&
window.localStorage !== undefined;
}
catch (err) {
hasStorage = false
}
return hasStorage
# Assertion
assert = console.assert
way_to = (content, ref) ->
return "<a href='#{ref}' class='way' id='waylink-#{ref}'>#{content}</a>"
# jQuery was confused by this point where's the context so I did it vanilla-way
print = (content) ->
if content == ""
return
if typeof content == "function"
content = content()
block = document.getElementById("current-situation")
if block
block.innerHTML = block.innerHTML + markdown(content)
else
console.error("No current situation found.")
Array::remove = (e) -> @[t..t] = [] if (t = @indexOf(e)) > -1
addClass = (element, className) ->
@ -84,11 +89,22 @@ class SaletRoom extends undum.Situation
# room illustration image, VN-style. Can be a GIF or WEBM. Can be a function.
pic: false
canView: true
canChoose: true
priority: 1
frequency: 1
displayOrder: 1
tags: []
choices: ""
optionText: "Choice"
dsc: false # room description
extendSection: false
distance: Infinity # distance to the destination
clear: true # clear the screen on entering the room?
entering: (character, system, from) =>
###
I call SaletRoom.exit every time the player exits to another room.
Unlike @after this gets called after the section is closed.

702
lib/salet.coffee Normal file
View file

@ -0,0 +1,702 @@
markdown = require('./markdown.coffee')
###
fcall() (by analogy with fmap) is added to the prototypes of both String and
Function. When called on a Function, it's an alias for Function#call();
when called on a String, it only returns the string itself, discarding any input.
###
Function.prototype.fcall = Function.prototype.call;
String.prototype.fcall = function() { return this; }
# Utility functions
parseFn = (str) ->
unless str?
return str
fstr = """
(function(character, system, situation) {
#{str}
#})
"""
return eval(fstr);
# Scrolls the top of the screen to the specified point
scrollTopTo = (value) ->
$('html,body').animate({scrollTop: value}, 500)
# Scrolls the bottom of the screen to the specified point
scrollBottomTo = (value) ->
scrollTopTo(value - $(window).height())
# Scrolls all the way to the bottom of the screen
scrollToBottom = () ->
scrollTopTo($('html').height() - $(window).height());
# Regular expression to catch every link action.
# Salet's default is a general URL-safe expression.
linkRe = /^([0-9A-Za-z_-]+|\.)(\/([0-9A-Za-z_-]+))?$/
# Returns HTML from the given content with the non-raw links wired up.
augmentLinks = (content) ->
output = $(content)
# Wire up the links for regular <a> tags.
output.find("a").each((index, element) ->
a = $(element)
href = a.attr('href')
if (!a.hasClass("raw")|| href.match(/[?&]raw[=&]?/))
if (href.match(linkRe))
a.click((event) ->
event.preventDefault()
# If we're a once-click, remove all matching links.
if (a.hasClass("once") || href.match(/[?&]once[=&]?/))
system.clearLinks(href)
processClick(href)
return false
)
else
a.addClass("raw")
)
return output
/* Erases the character in local storage. This is permanent!
/* To restart the game afterwards, we perform a simple page refresh.
/* This guarantees authors don't have to care about "tainting" the
/* game state across save/erase cycles, meaning that character.sandbox
/* no longer has to be the end-all be-all repository of game state. */
var doErase = function(force) {
var saveId = getSaveId();
if (localStorage.getItem(saveId)) {
if (force || confirm("erase_message".l())) {
localStorage.removeItem(saveId);
window.location.reload();
}
}
};
/* Find and return a list of ids for all situations with the given tag. */
var getSituationIdsWithTag = function(tag) {
var result = [];
for (var situationId in game.situations) {
var situation = game.situations[situationId];
for (var i = 0; i < situation.tags.length; ++i) {
if (situation.tags[i] == tag) {
result.push(situationId);
break;
}
}
}
return result;
};
/* Clear the current game output and start again. */
var startGame = function() {
progress.seed = new Date().toString();
character = new Character();
system.rnd = new Random(progress.seed);
progress.sequence = [{link:game.start, when:0}];
// Empty the display
$("#content").empty();
// Start the game
startTime = new Date().getTime() * 0.001;
system.time = 0;
if (game.init) game.init(character, system);
// Do the first state.
doTransitionTo(game.start);
};
/* Saves the character to local storage. */
var saveGame = function() {
// Store when we're saving the game, to avoid exploits where a
// player loads their file to gain extra time.
var now = (new Date()).getTime() * 0.001;
progress.saveTime = now - startTime;
// Save the game.
localStorage.setItem(getSaveId(), JSON.stringify(progress));
// Switch the button highlights.
$("#erase").removeClass('disabled');
$("#load").removeClass('disabled');
$("#save").addClass('disabled');
};
/* Loads the game from the given data */
var loadGame = function(characterData) {
progress = characterData;
character = new Character();
system.rnd = new Random(progress.seed);
// Empty the display
$("#content").empty();
$("#intro").empty();
// Now play through the actions so far:
if (game.init) game.init(character, system);
// Run through all the player's history.
interactive = false;
for (var i = 0; i < progress.sequence.length; i++) {
var step = progress.sequence[i];
// The action must be done at the recorded time.
system.time = step.when;
processLink(step.link);
}
interactive = true;
// Reverse engineer the start time.
var now = new Date().getTime() * 0.001;
startTime = now - progress.saveTime;
};
// -----------------------------------------------------------------------
// Setup
// -----------------------------------------------------------------------
var begin = function () {
/* Set up the game when everything is loaded. */
// Handle storage.
if (hasLocalStorage()) {
var erase = $("#erase").click(function() {
doErase();
});
var save = $("#save").click(saveGame);
var storedCharacter = localStorage.getItem(getSaveId());
if (storedCharacter) {
try {
loadGame(JSON.parse(storedCharacter));
save.addClass('disabled')
erase.removeClass('disabled')
} catch(err) {
doErase(true);
}
} else {
startGame();
}
} else {
startGame();
}
// Any point that an option list appears, its options are its
// first links.
$("body").on('click', "ul.options li, #menu li", function(event) {
// Make option clicks pass through to their first link.
var link = $("a", this);
if (link.length > 0) {
$(link.get(0)).click();
}
});
};
###
This is the control structure, it has minimal amount of data and
this data is volatile anyway (as in, it won't get saved).
###
Salet = {
rnd: null
time: 0
# Corresponding room names to room objects.
rooms: {}
# The unique id of the starting room.
start: "start"
# --- Hooks ---
###
This function is called at the start of the game. It is
normally overridden to provide initial character creation
(setting initial quality values, setting the
character-text. This is optional, however, as set-up
processing could also be done by the first situation's
enter function. If this function is given it should have
the signature function(character, system).
###
init: null
###
This function is called before entering any new
situation. It is called before the corresponding situation
has its `enter` method called.
###
entering: (character, system, oldSituationId, newSituationId) ->
###
Hook for when the situation has already been carried out
and printed.
###
afterEnter: (character, system, oldSituationId, newSituationId) ->
###
This function is called before carrying out any action in
any situation. It is called before the corresponding
situation has its `act` method called.
If the function returns true, then it is indicating that it
has consumed the action, and the action will not be passed
on to the situation. Note that this is the only one of
these global handlers that can consume the event.
###
beforeAction: (character, system, situationId, actionId) ->
###
This function is called after carrying out any action in
any situation. It is called after the corresponding
situation has its `act` method called.
###
afterAction: (character, system, situationId, actionId) ->
###
This function is called after leaving any situation. It is
called after the corresponding situation has its `exit`
method called.
###
exit: (character, system, oldSituationId, newSituationId) ->
###
Removes all content from the page, clearing the main content area.
If an elementSelector is given, then only that selector will be
cleared. Note that all content from the cleared element is removed,
but the element itself remains, ready to be filled again using
System.write.
###
clearContent: (elementSelector) ->
document.querySelector(elementSelector).innerHTML = ""
# jQuery was confused by this point where's the context so I did it vanilla-way
write: (content, elementSelector) ->
if content == ""
return
if typeof content == "function"
content = content()
block = document.getElementById("current-situation")
if block
block.innerHTML = block.innerHTML + markdown(content)
else
console.error("No current situation found.")
###
Turns any links that target the given href into plain
text. This can be used to remove action options when an action
is no longer available. It is used automatically when you give
a link the 'once' class.
###
clearLinks: (code) ->
$("a[href='" + code + "']").each((index, element) ->
a = $(element)
a.replaceWith($("<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.
###
writeChoices: (listOfIds, elementSelector) ->
if (listOfIds.length == 0)
return
currentSituation = getCurrentSituation();
$options = $("<ul>").addClass("options");
for (i = 0; i < listOfIds.length; ++i) {
situationId = listOfIds[i]
situation = game.situations[situationId]
assert(situation, "unknown_situation".l({id:situationId}))
if (situation == currentSituation) {
continue
}
optionText = situation.optionText.fcall(this, character, this, currentSituation)
if (!optionText)
optionText = "choice".l({number:i+1})
$option = $("<li>")
$a = $("<span>")
if (situation.canChoose(character, this, currentSituation)) {
$a = $("<a>").attr({href: situationId})
}
$a.html(optionText)
$option.html($a)
$options.append($option)
}
@write($options, elementSelector)
###
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.
###
getSituationIdChoices: (listOfOrOneIdsOrTags, minChoices, maxChoices) ->
datum = null
i = 0
# 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.
allIds = {}
for (i = 0; i < listOfOrOneIdsOrTags.length; ++i)
tagOrId = listOfOrOneIdsOrTags[i]
if (tagOrId.substr(0, 1) == '#')
ids = getSituationIdsWithTag(tagOrId.substr(1))
for (j = 0; j < ids.length; ++j)
allIds[ids[j]] = true
else
allIds[tagOrId] = true
#Filter out anything that can't be viewed right now.
currentSituation = getCurrentSituation()
viewableSituationData = []
for (situationId in allIds)
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.
viewableSituationDatum = situation.choiceData(character, system, currentSituation)
viewableSituationDatum.id = situationId
viewableSituationData.push(viewableSituationDatum)
# Then we sort in descending priority order.
viewableSituationData.sort((a, b) ->
return b.priority - a.priority
)
committed = []
candidatesAtLastPriority = []
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.
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.
candidatesToInclude = maxChoices - committed.length;
for (i = 0; i < candidatesAtLastPriority.length; ++i)
datum = candidatesAtLastPriority[i];
datum._frequencyValue = this.rnd.random() / datum.frequency;
candidatesToInclude.sort((a, b) ->
return a.frequencyValue - b.frequencyValue;
)
chosen = candidatesToInclude.slice(0, candidatesToInclude)
committed.push.apply(committed, chosen)
# 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 = 0; i < committed.length; ++i)
result.push(committed[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: [],
# The time when the progress was saved.
saveTime: null
}
# The Id of the current situation the player is in.
current: null;
# This is the current character. It should be reconstructable
# from the above progress data.
character: {};
# 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
getCurrentSituation: () =>
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;
# 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);
# 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
$('.new').removeClass('new')
# 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.
@enableSaving()
disableSaving: () ->
enableSaving: () ->
$("#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);
}
};
}

File diff suppressed because it is too large Load diff