1
0
Fork 0
mirror of https://gitlab.com/Oreolek/black_phone.git synced 2024-06-28 21:05:09 +03:00

Salet update

This commit is contained in:
Alexander Yakovlev 2016-02-10 20:38:27 +07:00
parent 074f78d1ab
commit 5435ce886f
5 changed files with 642 additions and 617 deletions

21
lib/character.coffee Normal file
View file

@ -0,0 +1,21 @@
class Character
constructor: (spec) ->
@inventory = []
@take = (thing) =>
@inventory.push thing
@drop = (thing) =>
for i in @inventory
if i.name == thing
index = @objects.indexOf(thing)
@inventory.splice(index, 1)
for index, value of spec
this[index] = value
return this
character = (spec) ->
spec ?= {}
return( new Character(spec) )
module.exports = character

View file

@ -20,32 +20,36 @@ class SaletObj
unless spec.name?
console.error("Trying to create an object with no name")
return null
@level = 0 # if > 0 it's hidden
@order = 0 # you can use this to sort the descriptions
@look = (system, f) =>
if @dsc and @dsc != ""
text = markdown(@dsc.fcall(this, system, f).toString())
text = system.view.wrapLevel(text, @level)
# 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}." # object action
@dsc = (system) => "You see a {{#{@name}}} here." # object 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 an object #{@name}")
@delete = (salet, location = false) =>
if location == false
location = @location
salet.rooms[location].drop(this)
for key, value of spec
this[key] = value
level: 0 # if > 0 it's hidden
order: 0 # you can use this to sort the descriptions
look: (system, f) =>
if @dsc and @dsc != ""
text = markdown(@dsc.fcall(this, system, f).toString())
text = system.view.wrapLevel(text, @level)
# 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}." # object action
dsc: (system) => "You see a {{#{@name}}} here." # object description
inv: (system) => "It's a {{#{@name}.}}" # inventory description
location: ""
put: (location) =>
@level = 0 # this is scenery
if salet.rooms[location]?
salet.rooms[location].take(this)
@location = location
delete: (location = false) =>
if location == false
location = @location
salet.rooms[location].drop(this)
obj = (name, spec) ->
spec ?= {}
spec.name = name

View file

@ -19,239 +19,237 @@ Array::remove = (e) -> @[t..t] = [] if (t = @indexOf(e)) > -1
class SaletRoom
constructor: (spec) ->
@visited = 0
@title = "Room"
@objects = {}
@canView = true
@canChoose = true
@priority = 1
@displayOrder = 1
@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?
@entering = (system, from) =>
###
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 @clear and f?
system.view.clearContent()
if f != @name and f?
@visited++
if system.rooms[f].exit?
system.rooms[f].exit system, @name
if @enter
@enter system, f
room_content = ""
if not @extendSection
classes = if @classes then ' ' + @classes.join(' ') else ''
room = document.getElementById('current-room')
if room?
room.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.
room_content = "<section id='current-room' data-room='#{@name}' class='room-#{@name}#{classes}'>"
if f != @name and @before?
room_content += markdown(@before.fcall(this, system, f))
room_content += @look system, f
if f != @name and @after?
room_content += markdown(@after.fcall(this, system, f))
if not @extendSection
room_content += "</section>"
system.view.write(room_content)
if @choices
system.view.writeChoices(system, system.getSituationIdChoices(@choices, @maxChoices))
if system.autosave
system.saveGame()
###
An internal function to get the room's description and the descriptions of
every object 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)
objDescriptions = []
for thing in @objects
if thing.name and typeof(thing.look) == "function" and thing.level == 0 and thing.look(system, f)
objDescriptions.push ({
order: thing.order,
content: thing.look(system, f)
})
objDescriptions.sort((a, b) ->
return a.order - b.order
)
for description in objDescriptions
retval += description.content
return retval
###
Puts an object in this room.
###
@take = (thing) =>
@objects.push(thing)
@drop = (name) =>
for thing in @objects
if thing.name == name
index = @objects.indexOf(thing)
@objects.splice(index, 1)
###
Object action. A function or a string which comes when you click on the object link.
You could interpret this as an EXAMINE verb or USE one, it's your call.
###
@act = (system, action) =>
if (link = action.match(/^_(act|cycle)_(.+)$/)) #object action
for thing in @objects
if thing.name == link[2]
if link[1] == "act"
# If it's takeable, the player can take this object.
# If not, we check the "act" function.
if thing.takeable
console.log system
system.character.take(thing)
@drop name
system.view.clearContent()
@entering.fcall(this, system, @name)
return system.view.write(thing.take.fcall(thing, system).toString())
if thing.act
system.view.changeLevel(thing.level)
return system.view.write(
system.view.wrapLevel(
thing.act.fcall(thing, system).toString(),
thing.level
)
)
# 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 objects, 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
visited: 0
title: "Room"
objects: {}
# room illustration image, VN-style. Can be a GIF or WEBM. Can be a function.
pic: false
canView: true
canChoose: true
priority: 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: (system, from) =>
###
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 @clear and f?
system.view.clearContent()
if f != @name and f?
@visited++
if system.rooms[f].exit?
system.rooms[f].exit system, @name
if @enter
@enter system, f
room_content = ""
if not @extendSection
classes = if @classes then ' ' + @classes.join(' ') else ''
room = document.getElementById('current-room')
if room?
room.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.
room_content = "<section id='current-room' data-room='#{@name}' class='room-#{@name}#{classes}'>"
if f != @name and @before?
room_content += markdown(@before.fcall(this, system, f))
room_content += @look system, f
if f != @name and @after?
room_content += markdown(@after.fcall(this, system, f))
if not @extendSection
room_content += "</section>"
system.view.write(room_content)
if @choices
system.view.writeChoices(system, system.getSituationIdChoices(@choices, @maxChoices))
if system.autosave
system.saveGame()
###
An internal function to get the room's description and the descriptions of
every object 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)
objDescriptions = []
for thing in @objects
console.log thing
if thing.name and typeof(thing.look) == "function" and thing.level == 0 and thing.look(system, f)
objDescriptions.push ({
order: thing.order,
content: thing.look(system, f)
})
objDescriptions.sort((a, b) ->
return a.order - b.order
)
for description in objDescriptions
retval += description.content
return retval
###
Puts an object in this room.
###
take: (thing) =>
@objects[thing.name] = thing
# BUG: for some really weird reason if the call is made in init function or
# during the initialization, this ALSO puts the thing in the start room.
salet.rooms["start"].objects = {}
drop: (name) =>
delete @objects[name]
###
Object action. A function or a string which comes when you click on the object link.
You could interpret this as an EXAMINE verb or USE one, it's your call.
###
act: (system, action) =>
if (link = action.match(/^_(act|cycle)_(.+)$/)) #object action
for thing in @objects
if thing.name == link[2]
if link[1] == "act"
# If it's takeable, the player can take this object.
# If not, we check the "act" function.
if thing.takeable
system.character.inventory.push thing
@drop name
system.view.clearContent()
@entering.fcall(this, system, @name)
return system.view.write(thing.take.fcall(thing, system).toString())
if thing.act
system.view.changeLevel(thing.level)
return system.view.write(
system.view.wrapLevel(
thing.act.fcall(thing, system).toString(),
thing.level
)
)
# 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 objects, 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)
room = (name, salet, spec) ->
spec ?= {}
spec.name = name

View file

@ -1,6 +1,7 @@
markdown = require('./markdown.coffee')
SaletView = require('./view.coffee')
Random = require('./random.js')
character = require('./character.coffee')
languages = require('./localize.coffee')
###
@ -17,429 +18,427 @@ String.prototype.fcall = () ->
assert = (msg, assertion) -> console.assert assertion, msg
class Character
inventory: []
###
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
# REDEFINE THIS IN YOUR GAME
game_id: null
game_version: "1.0"
autosave: true
constructor: (spec) ->
@character = character()
# REDEFINE THIS IN YOUR GAME
@game_id = null
@game_version = "1.0"
@autosave = true
@rnd = null
@time = 0
rnd: null
time: 0
# Corresponding room names to room objects.
@rooms = {}
# Corresponding room names to room objects.
rooms: {}
# The unique id of the starting room.
@start = "start"
# 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_-]+))?$/
# 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 = () ->
character: new Character
###
This function is called before entering any new
situation. It is called before the corresponding situation
has its `enter` method called.
###
@enter = (oldSituationId, newSituationId) ->
###
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: () ->
###
Hook for when the situation has already been carried out
and printed.
###
@afterEnter = (oldSituationId, newSituationId) ->
###
This function is called before entering any new
situation. It is called before the corresponding situation
has its `enter` method called.
###
enter: (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.
###
Hook for when the situation has already been carried out
and printed.
###
afterEnter: (oldSituationId, newSituationId) ->
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 before carrying out any action in
any situation. It is called before the corresponding
situation has its `act` method called.
###
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) ->
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 leaving any situation. It is
called after the corresponding situation has its `exit`
method called.
###
@exit = (oldSituationId, newSituationId) ->
###
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) ->
###
Returns a list of situation ids to choose from, given a set of
specifications.
###
This function is called after leaving any situation. It is
called after the corresponding situation has its `exit`
method called.
###
exit: (oldSituationId, newSituationId) ->
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.
###
Returns a list of situation ids to choose from, given a set of
specifications.
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.
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.
Before this function returns its result, it sorts the
situations in increasing order of their displayOrder values.
###
@getSituationIdChoices = (listOfOrOneIdsOrTags, maxChoices) =>
datum = null
i = 0
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.
# First check if we have a single string for the id or tag.
if (typeof(listOfOrOneIdsOrTags) == 'string')
listOfOrOneIdsOrTags = [listOfOrOneIdsOrTags]
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 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)
# First check if we have a single string for the id or tag.
if (typeof(listOfOrOneIdsOrTags) == 'string')
listOfOrOneIdsOrTags = [listOfOrOneIdsOrTags]
#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}))
# 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)
if (room.canView.fcall(this, currentRoom))
viewableRoomData.push({
priority: room.priority
id: roomId
displayOrder: room.displayOrder
})
#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}))
# Then we sort in descending priority order.
viewableRoomData.sort((a, b) ->
return b.priority - a.priority
)
if (room.canView.fcall(this, currentRoom))
viewableRoomData.push({
priority: room.priority
id: roomId
displayOrder: room.displayOrder
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
})
# Then we sort in descending priority order.
viewableRoomData.sort((a, b) ->
return b.priority - a.priority
)
# Now sort in ascending display order.
committed.sort((a, b) ->
return a.displayOrder - b.displayOrder
)
committed = []
# And return as a list of ids only.
result = []
for i in committed
result.push(i.id)
# if we need to filter out the results
if (maxChoices? && viewableRoomData.length > maxChoices)
viewableRoomData = viewableRoomData[-maxChoices..]
return result
for candidateRoom in viewableRoomData
committed.push({
id: candidateRoom.id
displayOrder: candidateRoom.displayOrder
})
# 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
}
# Now sort in ascending display order.
committed.sort((a, b) ->
return a.displayOrder - b.displayOrder
)
# The Id of the current room the player is in.
@current = null
# And return as a list of ids only.
result = []
for i in committed
result.push(i.id)
# Tracks whether we're in interactive mode or batch mode.
@interactive = true
return result
# The system time when the game was initialized.
@startTime = null
# 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 stack of links, resulting from the last action, still be to resolved.
@linkStack = null
# The Id of the current situation the player is in.
current: null;
@getCurrentRoom = () =>
if (@current)
return @rooms[@current]
return null
# Tracks whether we're in interactive mode or batch mode.
interactive: true
# Gets the unique id used to identify saved games.
@getSaveId = (slot = "") =>
return 'salet_'+@game_id+'_'+@game_version#+'_'+slot
# The system time when the game was initialized.
startTime: null
# 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
# The stack of links, resulting from the last action, still be to resolved.
linkStack: null
@view.mark_all_links_old
# We're processing, so make the stack available.
@linkStack = []
getCurrentRoom: () =>
if (@current)
return @rooms[@current]
return null
# Handle each link in turn.
@processOneLink(code);
while (@linkStack.length > 0)
code = @linkStack.shift()
@processOneLink(code)
# Gets the unique id used to identify saved games.
getSaveId: (slot = "") ->
return 'salet_'+@game_id+'_'+@game_version#+'_'+slot
# We're done, so remove the stack to prevent future pushes.
@linkStack = null;
# 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
# Scroll to the top of the new content.
@view.endOutputTransaction()
@view.mark_all_links_old
# We're able to save, if we weren't already.
@view.enableSaving()
# We're processing, so make the stack available.
@linkStack = []
@goTo = (roomId) =>
return @processLink(roomId)
# Handle each link in turn.
@processOneLink(code);
while (@linkStack.length > 0)
code = @linkStack.shift()
@processOneLink(code)
###
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)
assert(match, "link_not_valid".l({link:code}))
# We're done, so remove the stack to prevent future pushes.
@linkStack = null;
situation = match[1]
action = match[3]
# Scroll to the top of the new content.
@view.endOutputTransaction()
# 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())
# We're able to save, if we weren't already.
@view.enableSaving()
# Carry out the action
if (action)
room = @getCurrentRoom()
if (room and @beforeAction)
# Try the global act handler
consumed = @beforeAction(room, action)
if (consumed != true)
room.act(this, action)
goTo: (roomId) ->
return @processLink(roomId)
if (@afterAction)
@afterAction(this, room, action)
###
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)
assert(match, "link_not_valid".l({link:code}))
# 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})
@processLink(code)
situation = match[1]
action = match[3]
# Transition between rooms.
@doTransitionTo = (newRoomId) =>
oldRoomId = @current
oldRoom = @getCurrentRoom()
newRoom = @rooms[newRoomId]
# 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())
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)
# Carry out the action
if (action)
room = @getCurrentRoom()
if (room and @beforeAction)
# Try the global act handler
consumed = @beforeAction(room, action)
if (consumed != true)
room.act(this, action)
@current = newRoomId
# Remove links and transient sections.
@view.removeTransient(@interactive)
if (@afterAction)
@afterAction(this, room, action)
# Notify the incoming situation.
if (@enter)
@enter(oldRoomId, newRoomId)
newRoom.entering(this, oldRoomId)
# 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})
@processLink(code)
# additional hook for when the situation text has already been printed
if (@afterEnter)
@afterEnter(oldRoomId, newRoomId)
# Transitions between situations.
doTransitionTo: (newRoomId) ->
oldRoomId = @current
oldRoom = @getCurrentRoom()
newRoom = @rooms[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()
assert(newRoom, "unknown_situation".l({id:newRoomId}))
# 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
# We might not have an old situation if this is the start of the game.
if (oldRoom and @exit)
@exit(oldRoomId, newRoomId)
# 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
@current = newRoomId
# Save the game.
window.localStorage.setItem(@getSaveId(), JSON.stringify({
progress: @progress,
character: @character
}))
# Remove links and transient sections.
@view.removeTransient(@interactive)
# Switch the button highlights.
@view.disableSaving()
@view.enableErasing()
@view.enableLoading()
# Notify the incoming situation.
if (@enter)
@enter(oldRoomId, newRoomId)
newRoom.entering(this, oldRoomId)
# Loads the game from the given data
@loadGame = (saveFile) =>
@progress = saveFile.progress
@character = saveFile.character
# 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.
window.localStorage.setItem(@getSaveId(), JSON.stringify({
progress: @progress,
character: @character
}))
# Switch the button highlights.
@view.disableSaving()
@view.enableErasing()
@view.enableLoading()
# Loads the game from the given data
loadGame: (saveFile) ->
@progress = saveFile.progress
@character = saveFile.character
@rnd = new Random(@progress.seed)
# Don't load the save if it's an autosave at the first room (start).
# We don't need to clear the screen.
if saveFile.progress? and saveFile.progress.sequence.length > 1
@view.clearContent()
# Now play through the actions so far:
if (@init)
@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 is deleted."
console.error err
@eraseSave(true)
else
@progress.seed = new Date().toString()
character = new Character()
@rnd = new Random(@progress.seed)
@progress.sequence = [{link:@start, when:0}]
# Start the game
@startTime = new Date().getTime() * 0.001
# Don't load the save if it's an autosave at the first room (start).
# We don't need to clear the screen.
if saveFile.progress? and saveFile.progress.sequence.length > 1
@view.clearContent()
# Now play through the actions so far:
if (@init)
@init(character)
@init()
# Do the first state.
@doTransitionTo(@start)
# 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
getRoom: (name) ->
return @rooms[name]
# Reverse engineer the start time.
now = new Date().getTime() * 0.001
startTime = now - @progress.saveTime
# Just an alias for getCurrentRoom
here: () -> @getCurrentRoom()
@view = new SaletView
isVisited: (name) ->
place = @getRoom(name)
if place
return Boolean place.visited
return 0
@beginGame = () =>
@view.fixClicks()
module.exports = Salet
# 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 is deleted."
console.error err
@eraseSave(true)
else
@progress.seed = new Date().toString()
character = new Character()
@rnd = new Random(@progress.seed)
@progress.sequence = [{link:@start, when:0}]
# Start the game
@startTime = new Date().getTime() * 0.001
if (@init)
@init(character)
# Do the first state.
@doTransitionTo(@start)
@getRoom = (name) => @rooms[name]
# 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

@ -9,6 +9,8 @@ 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
@ -23,7 +25,7 @@ addClass = (element, className) ->
element.className += ' ' + className
class SaletView
init: (salet) ->
init: (salet) =>
$("#content, #ways").on("click", "a", (event) ->
event.preventDefault()
a = $(this)
@ -95,7 +97,7 @@ class SaletView
return content.toString()
# Write content to current room
write: (content, elementSelector = "#current-room") ->
write: (content, elementSelector = "#current-room") =>
if content == ""
return
content = @prepareContent(content)
@ -109,7 +111,7 @@ class SaletView
# Replaces the text in the given block with the given text.
# !! Does not call markdown on the provided text. !!
replace: (content, elementSelector) ->
replace: (content, elementSelector) =>
if content == ""
return
content = @prepareContent(content)
@ -143,7 +145,7 @@ class SaletView
manually, ot else use the `getSituationIdChoices` method to
return an ordered list of valid viewable situation ids.
###
writeChoices: (salet, listOfIds) ->
writeChoices: (salet, listOfIds) =>
if (not listOfIds? or listOfIds.length == 0)
return
@ -195,7 +197,7 @@ class SaletView
# Remove every section marked as a different level.
# For a link level 0, we hide every link of level 1 and above.
# It's for the player to focus.
changeLevel: (level) ->
changeLevel: (level) =>
maxLevel = 6
if level < maxLevel
i = level + 1
@ -206,7 +208,7 @@ class SaletView
directive = hideArray.join(", ")
$(directive).fadeOut("slow")
wrapLevel: (text, level) ->
wrapLevel: (text, level) =>
return "<span class='lvl#{level}'>"+text+'</span>'
# At last, we scroll the view so that .new objects are in view.
@ -269,6 +271,7 @@ class SaletView
key: way
distance: salet.rooms[way].distance
})
document.querySelector(".ways h2").style.display = "block"
else
document.querySelector(".ways h2").style.display = "none"
document.getElementById("ways").innerHTML = content