Did a crude MVC structure.

This commit is contained in:
Alexander Yakovlev 2016-01-24 13:33:39 +07:00
parent c48c1ff11e
commit 5f7f142028
6 changed files with 273 additions and 260 deletions

View file

@ -4,7 +4,7 @@ obj = require('../../lib/obj.coffee')
dialogue = require('../../lib/dialogue.coffee')
oneOf = require('../../lib/oneOf.coffee')
require('../../lib/interface.coffee')
undum = require('../../lib/undum.js')
salet = require('../../lib/salet.coffee')
undum.game.id = "your-game-id-here"
undum.game.version = "1.0"

View file

@ -1,15 +0,0 @@
###
Salet interface configuration.
###
$(document).ready(() ->
$("#ways").on("click", "a", (event) ->
event.preventDefault()
undum.processClick($(this).attr("href"))
)
$("#inventory").on("click", "a", (event) ->
event.preventDefault()
)
$("#load").on("click", "a", (event) ->
window.location.reload()
)
)

View file

@ -1,5 +1,5 @@
markdown = require('./markdown.coffee')
undum = require('./undum.js')
salet = require('./salet.coffee')
objlink = (content, ref) ->
return "<a href='./_act_#{ref}' class='once'>#{content}</a>"
@ -25,7 +25,7 @@ class SaletObj
level: 0
look: (character, system, f) =>
if @dsc
text = markdown(@dsc.fcall(this, character, system, f))
text = markdown(@dsc.fcall(this, character, system, f))
text = "<span class='look lvl#{@level}'>" + text + "</span>"
# replace braces {{}} with link to _act_
return parsedsc(text, @name)

View file

@ -6,19 +6,6 @@ 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
@ -40,16 +27,14 @@ cls = (system) ->
update_ways = (ways, name) ->
content = ""
distances = []
if ways
document.querySelector(".ways h2").style.display = "block"
for way in ways
if undum.game.situations[way]?
title = undum.game.situations[way].title.fcall(this, name)
content += way_to(title, way)
distances.push({
key: way
distance: undum.game.situations[way].distance
})
if ways then for way in ways
if undum.game.situations[way]?
title = undum.game.situations[way].title.fcall(this, name)
content += way_to(title, way)
distances.push({
key: way
distance: undum.game.situations[way].distance
})
else
document.querySelector(".ways h2").style.display = "none"
document.getElementById("ways").innerHTML = content
@ -85,7 +70,7 @@ class SaletRoom extends undum.Situation
visited: 0
title: "Room"
objects: {}
# room illustration image, VN-style. Can be a GIF or WEBM. Can be a function.
pic: false
@ -103,7 +88,7 @@ class SaletRoom extends undum.Situation
distance: Infinity # distance to the destination
clear: true # clear the screen on entering the room?
entering: (character, system, from) =>
entering: (character, system, from) =>
###
I call SaletRoom.exit every time the player exits to another room.
@ -148,13 +133,13 @@ class SaletRoom extends undum.Situation
current_situation = ""
if not @extendSection
classes = if @classes then ' ' + @classes.join(' ') else ''
situation = document.getElementById('current-situation')
situation = document.getElementById('current-room')
if situation?
situation.removeAttribute('id')
# Javascript DOM manipulation functions like jQuery's append() or document.createElement
# don't work like a typical printLn - they create *DOM nodes*.
# You can't leave an unclosed tag just like that. So we have to buffer the output.
current_situation = "<section id='current-situation' data-situation='#{@name}' class='situation-#{@name}#{classes}'>"
current_situation = "<section id='current-room' data-situation='#{@name}' class='situation-#{@name}#{classes}'>"
if f != @name and @before?
current_situation += markdown(@before.fcall(this, character, system, f))
@ -236,7 +221,7 @@ class SaletRoom extends undum.Situation
writer: (ref) ->
content = that.writers[ref].fcall(that, character, system, action)
output = markdown(content)
system.writeInto(output, '#current-situation')
system.writeInto(output, '#current-room')
replacer: (ref) ->
content = that.writers[ref].fcall(that, character, system, action)
output = "<span>"+content+"</span>" # <p> tags are usually bad for replacers

View file

@ -1,4 +1,5 @@
markdown = require('./markdown.coffee')
view = require('./view.coffee')
###
fcall() (by analogy with fmap) is added to the prototypes of both String and
@ -7,14 +8,15 @@ 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; }
String.prototype.fcall = () ->
return this
# Utility functions
parseFn = (str) ->
unless str?
return str
fstr = """
(function(character, system, situation) {
#{str}
@ -22,17 +24,16 @@ parseFn = (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());
# Feature detection
hasLocalStorage = () ->
hasStorage = false
try
hasStorage = ('localStorage' in window) &&
window.localStorage != null &&
window.localStorage != undefined;
catch err
hasStorage = false
return hasStorage
# Regular expression to catch every link action.
# Salet's default is a general URL-safe expression.
@ -77,8 +78,6 @@ Salet = {
# 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
@ -93,21 +92,21 @@ Salet = {
###
This function is called before entering any new
situation. It is called before the corresponding situation
has its `enter` method called.
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
@ -129,88 +128,10 @@ Salet = {
###
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
@ -229,7 +150,7 @@ Salet = {
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
@ -240,7 +161,7 @@ Salet = {
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
@ -258,7 +179,7 @@ Salet = {
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.
###
@ -272,27 +193,26 @@ Salet = {
# 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
for tagOrId in listOfOrOneIdsOrTags
if (tagOrId.substr(0, 1) == '#')
ids = getSituationIdsWithTag(tagOrId.substr(1))
for id in ids
allIds[id] = 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}))
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)
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) ->
@ -303,26 +223,25 @@ Salet = {
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);
for datum in viewableSituationData
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)
if (maxChoices == undefined || maxChoices >= totalChoices)
# We can use all the choices.
committed.push.apply(committed, candidatesAtLastPriority)
else if (maxChoices >= committed.length)
@ -331,8 +250,7 @@ Salet = {
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];
for datum in candidatesAtLastPriority
datum._frequencyValue = this.rnd.random() / datum.frequency;
candidatesToInclude.sort((a, b) ->
return a.frequencyValue - b.frequencyValue;
@ -347,8 +265,8 @@ Salet = {
# And return as a list of ids only.
result = []
for (i = 0; i < committed.length; ++i)
result.push(committed[i].id)
for i in committed
result.push(i.id)
return result
# This is the data on the player's progress that gets saved.
@ -387,49 +305,15 @@ Salet = {
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)
if @linkStack != null
@linkStack.push(code)
return
$('.new').removeClass('new')
view.mark_all_links_old
# We're processing, so make the stack available.
@linkStack = []
@ -449,18 +333,13 @@ Salet = {
# We're able to save, if we weren't already.
@enableSaving()
disableSaving: () ->
$("#save").addClass('disabled');
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.
###
processOneLink: = (code) ->
processOneLink: (code) ->
match = code.match(linkRe)
assert(match, "link_not_valid".l({link:code}))
@ -468,8 +347,8 @@ Salet = {
action = match[3]
# Change the situation
if (situation !== '.')
if (situation !== current)
if situation != '.'
if situation != current
doTransitionTo(situation)
else
# We should have an action if we have no situation change.
@ -490,7 +369,7 @@ Salet = {
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)
@ -515,23 +394,7 @@ Salet = {
game.exit(character, system, oldSituationId, newSituationId);
# Remove links and transient sections.
$('#content a').each((index, element) ->
a = $(element);
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()
view.remove_transient(@interactive)
# Move the character.
current = newSituationId
@ -550,7 +413,7 @@ Salet = {
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. */
no longer has to be the end-all be-all repository of game state.
###
erase_save: (force = false) =>
save_id = @getSaveId() # save slot
@ -561,9 +424,9 @@ Salet = {
# Find and return a list of ids for all situations with the given tag.
getSituationIdsWithTag: (tag) =>
result = []
for (situationId, situation of @situations)
for (i = 0; i < situation.tags.length; ++i)
if (situation.tags[i] == tag)
for situationId, situation of @situations
for i in situation.tags
if (i == tag)
result.push(situationId)
break
return result
@ -579,20 +442,18 @@ Salet = {
localStorage.setItem(getSaveId(), JSON.stringify(progress))
# Switch the button highlights.
$("#erase").removeClass('disabled')
$("#load").removeClass('disabled')
@disableSaving()
view.disableSaving()
view.enableErasing()
view.enableLoading()
# Loads the game from the given data
loadGame = (characterData) ->
loadGame: (characterData) ->
progress = characterData
character = new Character()
system.rnd = new Random(progress.seed)
# Empty the display
$("#content").empty()
$("#intro").empty()
view.clearContent()
# Now play through the actions so far:
if (game.init)
@ -600,8 +461,7 @@ Salet = {
# Run through all the player's history.
interactive = false
for (i = 0; i < progress.sequence.length; i++)
step = progress.sequence[i]
for step in progress.sequence
# The action must be done at the recorded time.
system.time = step.when
processLink(step.link)
@ -620,9 +480,9 @@ Salet = {
if (storedCharacter)
try
@loadGame(JSON.parse(storedCharacter))
@disableSaving()
$("#erase").removeClass('disabled')
catch(err)
view.disableSaving()
view.enableErasing()
catch err
@erase_save(true)
else
progress.seed = new Date().toString()
@ -631,8 +491,7 @@ Salet = {
system.rnd = new Random(progress.seed)
progress.sequence = [{link:game.start, when:0}]
# Empty the display
$("#content").empty()
view.clearContent()
# Start the game
startTime = new Date().getTime() * 0.001

184
lib/view.coffee Normal file
View file

@ -0,0 +1,184 @@
###
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.
###
class SaletView
init: () ->
$("#ways").on("click", "a", (event) ->
event.preventDefault()
undum.processClick($(this).attr("href"))
)
$("#inventory").on("click", "a", (event) ->
event.preventDefault()
)
$("#load").on("click", "a", (event) ->
window.location.reload()
)
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
document.findElementById("intro").innerHTML = ""
document.querySelector(elementSelector).innerHTML = ""
# Write content to current room
write: (content) ->
if content == ""
return
if typeof content == "function"
content = content()
block = document.getElementById("current-room")
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) ->
for a in $("a[href='" + code + "']")
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) ->
if (listOfIds.length == 0)
return
currentSituation = getCurrentSituation();
$options = $("<ul>").addClass("options");
for situationId in listOfIds
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)
# 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)
remove_transient: (interactive = false) ->
for a in $('#content 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);
view = new SaletView
$(document).ready(() ->
view.init()
)
module.exports = view