1
0
Fork 0
mirror of https://gitlab.com/Oreolek/salet.git synced 2024-07-07 01:04:25 +03:00
salet/lib/salet.coffee

448 lines
13 KiB
CoffeeScript
Raw Normal View History

2016-01-22 17:07:34 +02:00
markdown = require('./markdown.coffee')
2016-01-25 18:05:32 +02:00
SaletView = require('./view.coffee')
2016-01-24 09:53:03 +02:00
Random = require('./random.js')
languages = require('./localize.coffee')
2016-01-22 17:07:34 +02:00
###
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
2016-01-24 08:33:39 +02:00
String.prototype.fcall = () ->
return this
2016-01-22 17:07:34 +02:00
2016-01-24 09:53:03 +02:00
assert = (msg, assertion) -> console.assert assertion, msg
class Character
2016-02-01 10:51:48 +02:00
inventory: []
2016-01-22 17:07:34 +02:00
###
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.
2016-01-22 17:07:34 +02:00
###
2016-01-24 09:53:03 +02:00
class Salet
# REDEFINE THIS IN YOUR GAME
game_id: null
game_version: "1.0"
2016-02-01 15:28:05 +02:00
autosave: true
2016-01-24 09:53:03 +02:00
2016-01-22 17:07:34 +02:00
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_-]+))?$/
2016-02-01 15:39:16 +02:00
character: new Character
2016-01-22 17:07:34 +02:00
###
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
2016-01-24 09:53:03 +02:00
enter function.
2016-01-22 17:07:34 +02:00
###
2016-02-01 15:23:20 +02:00
init: () ->
2016-01-22 17:07:34 +02:00
###
This function is called before entering any new
situation. It is called before the corresponding situation
2016-01-24 08:33:39 +02:00
has its `enter` method called.
2016-01-22 17:07:34 +02:00
###
2016-01-25 18:05:32 +02:00
enter: (oldSituationId, newSituationId) ->
2016-01-22 17:07:34 +02:00
###
Hook for when the situation has already been carried out
and printed.
2016-01-24 08:33:39 +02:00
###
2016-01-25 18:05:32 +02:00
afterEnter: (oldSituationId, newSituationId) ->
2016-01-22 17:07:34 +02:00
###
This function is called before carrying out any action in
any situation. It is called before the corresponding
situation has its `act` method called.
2016-01-24 08:33:39 +02:00
2016-01-22 17:07:34 +02:00
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.
###
2016-01-25 18:05:32 +02:00
beforeAction: (situationId, actionId) ->
2016-01-22 17:07:34 +02:00
###
This function is called after carrying out any action in
any situation. It is called after the corresponding
situation has its `act` method called.
###
2016-01-25 18:05:32 +02:00
afterAction: (situationId, actionId) ->
2016-01-22 17:07:34 +02:00
###
This function is called after leaving any situation. It is
called after the corresponding situation has its `exit`
method called.
###
2016-01-25 18:05:32 +02:00
exit: (oldSituationId, newSituationId) ->
2016-01-22 17:07:34 +02:00
###
Returns a list of situation ids to choose from, given a set of
specifications.
2016-01-24 08:33:39 +02:00
2016-01-22 17:07:34 +02:00
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.
2016-01-24 08:33:39 +02:00
2016-01-22 17:07:34 +02:00
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.
2016-01-24 08:33:39 +02:00
2016-01-22 17:07:34 +02:00
Before this function returns its result, it sorts the
situations in increasing order of their displayOrder values.
###
2016-02-08 14:12:12 +02:00
getSituationIdChoices: (listOfOrOneIdsOrTags, maxChoices) =>
2016-01-22 17:07:34 +02:00
datum = null
i = 0
# First check if we have a single string for the id or tag.
if (typeof(listOfOrOneIdsOrTags) == 'string')
2016-01-22 17:07:34 +02:00
listOfOrOneIdsOrTags = [listOfOrOneIdsOrTags]
# First we build a list of all candidate ids.
allIds = []
2016-01-24 08:33:39 +02:00
for tagOrId in listOfOrOneIdsOrTags
if (tagOrId.substr(0, 1) == '#')
ids = @getRoomsTagged(tagOrId.substr(1))
2016-01-24 08:33:39 +02:00
for id in ids
allIds.push(id)
else #it's an id, not a tag
allIds.push(tagOrId)
2016-01-22 17:07:34 +02:00
#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, currentRoom))
viewableRoomData.push({
priority: room.priority
id: roomId
displayOrder: room.displayOrder
})
2016-01-22 17:07:34 +02:00
# Then we sort in descending priority order.
viewableRoomData.sort((a, b) ->
2016-01-22 17:07:34 +02:00
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
})
2016-01-22 17:07:34 +02:00
# Now sort in ascending display order.
committed.sort((a, b) ->
return a.displayOrder - b.displayOrder
2016-01-22 17:07:34 +02:00
)
# And return as a list of ids only.
result = []
2016-01-24 08:33:39 +02:00
for i in committed
result.push(i.id)
2016-01-22 17:07:34 +02:00
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;
# 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
2016-01-24 09:53:03 +02:00
getCurrentRoom: () =>
2016-01-22 17:07:34 +02:00
if (@current)
return @rooms[@current]
return null
# Gets the unique id used to identify saved games.
2016-02-08 14:12:12 +02:00
getSaveId: (slot = "") =>
2016-02-01 15:23:20 +02:00
return 'salet_'+@game_id+'_'+@game_version#+'_'+slot
2016-01-22 17:07:34 +02:00
# 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.
2016-01-24 08:33:39 +02:00
if @linkStack != null
2016-01-22 17:07:34 +02:00
@linkStack.push(code)
return
2016-01-25 18:05:32 +02:00
@view.mark_all_links_old
2016-01-22 17:07:34 +02:00
# We're processing, so make the stack available.
@linkStack = []
# Handle each link in turn.
@processOneLink(code);
2016-01-22 17:07:34 +02:00
while (@linkStack.length > 0)
2016-02-01 10:51:48 +02:00
code = @linkStack.shift()
@processOneLink(code)
2016-01-22 17:07:34 +02:00
# We're done, so remove the stack to prevent future pushes.
@linkStack = null;
# Scroll to the top of the new content.
@view.endOutputTransaction()
2016-01-22 17:07:34 +02:00
# We're able to save, if we weren't already.
@view.enableSaving()
2016-01-22 17:07:34 +02:00
2016-02-08 14:12:12 +02:00
goTo: (roomId) =>
return @processLink(roomId)
2016-01-23 13:31:03 +02:00
###
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.
###
2016-02-08 14:12:12 +02:00
processOneLink: (code) =>
match = code.match(@linkRe)
2016-01-23 13:31:03 +02:00
assert(match, "link_not_valid".l({link:code}))
2016-01-22 17:07:34 +02:00
2016-01-23 13:31:03 +02:00
situation = match[1]
action = match[3]
2016-01-22 17:07:34 +02:00
2016-01-23 13:31:03 +02:00
# Change the situation
if situation != '.' and situation != @current_room
@doTransitionTo(situation)
2016-01-23 13:31:03 +02:00
else
# We should have an action if we have no situation change.
assert(action, "link_no_action".l())
# Carry out the action
if (action)
2016-02-01 10:51:48 +02:00
room = @getCurrentRoom()
if (room and @beforeAction)
# Try the global act handler
consumed = @beforeAction(room, action)
if (consumed != true)
room.act(this, action)
if (@afterAction)
@afterAction(this, room, action)
2016-01-23 13:31:03 +02:00
# This gets called when the user clicks a link to carry out an action.
2016-02-08 14:12:12 +02:00
processClick: (code) =>
2016-01-23 13:31:03 +02:00
now = (new Date()).getTime() * 0.001
@time = now - @startTime
2016-01-24 09:53:03 +02:00
@progress.sequence.push({link:code, when:@time})
@processLink(code)
2016-01-23 13:31:03 +02:00
# Transitions between situations.
2016-02-08 14:12:12 +02:00
doTransitionTo: (newRoomId) =>
2016-01-25 18:05:32 +02:00
oldRoomId = @current
oldRoom = @getCurrentRoom()
newRoom = @rooms[newRoomId]
2016-01-23 13:31:03 +02:00
2016-01-25 18:05:32 +02:00
assert(newRoom, "unknown_situation".l({id:newRoomId}))
2016-01-23 13:31:03 +02:00
# We might not have an old situation if this is the start of the game.
2016-01-25 18:05:32 +02:00
if (oldRoom and @exit)
@exit(oldRoomId, newRoomId)
2016-01-25 18:05:32 +02:00
@current = newRoomId
2016-01-23 13:31:03 +02:00
# Remove links and transient sections.
@view.removeTransient(@interactive)
2016-01-22 17:07:34 +02:00
2016-01-23 13:31:03 +02:00
# Notify the incoming situation.
2016-01-24 09:53:03 +02:00
if (@enter)
2016-01-25 18:05:32 +02:00
@enter(oldRoomId, newRoomId)
newRoom.entering(this, oldRoomId)
2016-01-22 17:07:34 +02:00
2016-01-23 13:31:03 +02:00
# additional hook for when the situation text has already been printed
2016-01-24 09:53:03 +02:00
if (@afterEnter)
2016-01-25 18:05:32 +02:00
@afterEnter(oldRoomId, newRoomId)
2016-01-23 13:31:03 +02:00
###
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
2016-01-24 08:33:39 +02:00
no longer has to be the end-all be-all repository of game state.
2016-01-23 13:31:03 +02:00
###
eraseSave: (force = false) =>
2016-02-01 15:23:20 +02:00
saveId = @getSaveId() # save slot
2016-01-23 13:31:03 +02:00
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) =>
2016-01-23 13:31:03 +02:00
result = []
for id, room of @rooms
for i in room.tags
2016-01-24 08:33:39 +02:00
if (i == tag)
result.push(id)
2016-01-23 13:31:03 +02:00
break
return result
2016-02-01 15:23:20 +02:00
# Saves the character and the walking history to local storage.
2016-02-08 14:12:12 +02:00
saveGame: () =>
2016-01-23 13:31:03 +02:00
# 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
2016-02-01 15:23:20 +02:00
@progress.saveTime = now - @startTime
2016-01-23 13:31:03 +02:00
# Save the game.
2016-02-01 15:23:20 +02:00
window.localStorage.setItem(@getSaveId(), JSON.stringify({
progress: @progress,
character: @character
}))
2016-01-23 13:31:03 +02:00
# Switch the button highlights.
2016-01-25 18:05:32 +02:00
@view.disableSaving()
@view.enableErasing()
@view.enableLoading()
2016-01-23 13:31:03 +02:00
# Loads the game from the given data
2016-02-08 14:12:12 +02:00
loadGame: (saveFile) =>
2016-02-01 15:23:20 +02:00
@progress = saveFile.progress
@character = saveFile.character
2016-01-23 13:31:03 +02:00
2016-01-24 09:53:03 +02:00
@rnd = new Random(@progress.seed)
2016-01-23 13:31:03 +02:00
2016-02-01 17:16:36 +02:00
# 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()
2016-01-23 13:31:03 +02:00
# Now play through the actions so far:
2016-01-24 09:53:03 +02:00
if (@init)
2016-02-01 15:23:20 +02:00
@init()
2016-01-23 13:31:03 +02:00
# Run through all the player's history.
interactive = false
2016-01-24 09:53:03 +02:00
for step in @progress.sequence
2016-01-23 13:31:03 +02:00
# The action must be done at the recorded time.
2016-01-24 09:53:03 +02:00
@time = step.when
2016-02-01 15:23:20 +02:00
@processLink(step.link)
2016-01-23 13:31:03 +02:00
interactive = true
# Reverse engineer the start time.
now = new Date().getTime() * 0.001
2016-01-24 09:53:03 +02:00
startTime = now - @progress.saveTime
2016-01-23 13:31:03 +02:00
2016-01-25 18:05:32 +02:00
view: new SaletView
2016-02-08 14:12:12 +02:00
beginGame: () =>
@view.fixClicks()
2016-01-23 13:31:03 +02:00
# Handle storage.
2016-02-01 15:23:20 +02:00
saveFile = false
2016-01-25 18:05:32 +02:00
if (@view.hasLocalStorage())
2016-02-01 15:23:20 +02:00
saveFile = localStorage.getItem(@getSaveId())
2016-01-23 13:31:03 +02:00
2016-02-01 15:23:20 +02:00
if (saveFile)
2016-01-23 13:31:03 +02:00
try
2016-02-01 15:23:20 +02:00
@loadGame(JSON.parse(saveFile))
2016-01-25 18:05:32 +02:00
@view.disableSaving()
@view.enableErasing()
2016-01-24 08:33:39 +02:00
catch err
2016-02-01 15:23:20 +02:00
console.log "There was an error loading your save. The save is deleted."
2016-02-01 17:16:36 +02:00
console.error err
@eraseSave(true)
2016-01-23 13:31:03 +02:00
else
2016-01-24 09:53:03 +02:00
@progress.seed = new Date().toString()
2016-01-23 13:31:03 +02:00
character = new Character()
2016-01-24 09:53:03 +02:00
@rnd = new Random(@progress.seed)
@progress.sequence = [{link:@start, when:0}]
2016-01-23 13:31:03 +02:00
# Start the game
2016-01-25 18:05:32 +02:00
@startTime = new Date().getTime() * 0.001
2016-01-24 09:53:03 +02:00
if (@init)
@init(character)
2016-01-23 13:31:03 +02:00
# Do the first state.
2016-01-25 18:05:32 +02:00
@doTransitionTo(@start)
2016-01-23 13:31:03 +02:00
2016-02-08 14:12:12 +02:00
getRoom: (name) =>
2016-01-25 18:05:32 +02:00
return @rooms[name]
2016-01-22 17:07:34 +02:00
2016-01-25 18:05:32 +02:00
# Just an alias for getCurrentRoom
2016-02-08 14:12:12 +02:00
here: () => @getCurrentRoom()
2016-01-25 18:05:32 +02:00
2016-02-08 14:12:12 +02:00
isVisited: (name) =>
2016-01-25 18:05:32 +02:00
place = @getRoom(name)
if place
return Boolean place.visited
return 0
2016-01-22 17:07:34 +02:00
module.exports = Salet