kliffy/iffy.coffee
2010-12-01 17:04:15 -08:00

450 lines
13 KiB
CoffeeScript

###
Iffy - parserless interactive fiction for the web
(c)2010 Jeremy Penner
###
#lazy enumeration
class Eni
constructor: (@val, @fStarted) ->
Value: () -> @val
EniNext: () -> if not @fStarted then new Eni(@val, true)
EniEmpty = { EniNext: (-> null) }
class EniArray
constructor: (@rg, @i) -> if not @i? then @i = -1
Value: -> @rg[@i]
EniNext: -> if @i + 1 < @rg.length then new EniArray(@rg, @i + 1)
class EniConcat
constructor: (@eni1, @eni2) ->
Value: -> @eni1.Value()
EniNext: (rgarg...) ->
eni1New = @eni1.EniNext(rgarg...)
if eni1New?
new EniConcat(eni1New, @eni2)
else
@eni2.EniNext(rgarg...)
class EniThunk
constructor: (@thunk) ->
# it is always illegal to call Value on this eni, as eninext will never return an eniThunk
EniNext: (rgarg...) ->
# memoize generated eni
if not @eni?
@eni = @thunk(rgarg...)
@eni.EniNext()
class EniCons
constructor: (@value, @thunk, @fStarted) ->
Value: -> @value
EniNext: (rgarg...) ->
if not @fStarted
if not @eniNext?
@eniNext = new EniCons(@value, @thunk, true)
@eniNext
else
if not @eni?
@eni = @thunk(this, rgarg...)
@eni.EniNext()
class EniMap
constructor: (@eni, @dg) ->
Value: -> @dg(@eni.Value())
EniNext: (rgarg...) ->
eniNext = @eni.EniNext()
if eniNext? then new EniMap(eniNext, @dg, rgarg...)
class EniFilter
constructor: (@eni, @dgFilter) ->
Value: -> @eni.Value()
EniNext: (rgarg...) ->
eni = @eni.EniNext(rgarg...)
while eni? and not @dgFilter(eni.Value(), eni, rgarg...)
eni = eni.EniNext()
eni
ArrayFromEni = (eni) ->
rg = []
while (eni = eni.EniNext())?
rg.push(eni.Value())
rg
# world state -- holds the state of the world after every
NewWst = (obj, gst) ->
Ctor = (@gst) ->
@WstNext = (nev) ->
CtorNext = (wstOld) ->
@nev = nev
@wstPrev = wstOld
this
CtorNext.prototype = this
new CtorNext(this)
@WstPrev = () -> @wstPrev
this
Ctor.prototype = obj
new Ctor(gst)
class Respo
constructor: (@enieniNev, @eniNev) ->
if @enieniNev.eni.i < 0 and @eniNev?
@eniNev = @eniNev
@FromEnieniNev: (enieniNev) -> new Respo(enieniNev)
Nev: () -> @eniNev.Value()
Next: (nevgen) ->
enieniNev = @enieniNev
eniNev = @eniNev
loop
if not eniNev?
enieniNev = enieniNev.EniNext()
if enieniNev?
eniNev = enieniNev.Value()
else
return null
eniNev = eniNev.EniNext(nevgen, new Respo(enieniNev, eniNev))
if eniNev? then return new Respo(enieniNev, eniNev)
null
ReplaceEniNev: (eniNevNew) -> new Respo(@enieniNev, eniNevNew)
class Nevgen
constructor: (@gst, @wst, @eniWstResponding, @respo) ->
@EniWst: (gst, wstInit) ->
nevgen = new Nevgen(gst, wstInit, null, null)
new EniCons(wstInit, nevgen.Thunk())
Thunk: () ->
(eniPrev) =>
nevgen = this
if not @eniWstResponding?
nevgen = @RespondTo(@gst, @wst, eniPrev)
nevgen = nevgen.Next()
if nevgen?
new EniCons(nevgen.wst, nevgen.Thunk())
else
EniEmpty
Next: () ->
nevgen = this
while nevgen? and nevgen.wst == @wst
nevgen = nevgen.NextI()
nevgen
NextI: () ->
if @respo?
respoNext = @respo.Next(this)
wst = @wst
if respoNext? and respoNext.Nev().FCanRun(@gst, wst)
wst = @gst.RunNev(wst, respoNext.Nev())
new Nevgen(@gst, wst, @eniWstResponding, respoNext)
else if @wst != @eniWstResponding.Value()
@RespondTo(@gst, @wst, @eniWstResponding.EniNext())
RespondTo: (gst, wst, eniWstResponding) ->
new Nevgen(gst, wst, eniWstResponding, gst.story.RespoResponse(eniWstResponding.Value().nev, wst))
# game state -- holds all the information needed to display the current state of the game in our browser
class Gst
constructor: (@story, @jDiv) ->
@wstInit = @story.WstInit(this)
@igenID = 0
Display: () ->
@mpstId_dgOnClick = []
@ClearMenu(true)
@jDiv.empty()
@jDiv.click(() => @ClearMenu())
@jDiv.append("<div class='title'>
<h1>#{@story.jStory.attr('title')}</h1>
<h2>#{@story.jStory.attr('author')}</h2>
</div>")
for wst in @RgwstRun()
@jDiv.append($("<div class='nev'/>").append(wst.nev.StHtmlDisplay(this, wst)))
for stId_dgOnClick in @mpstId_dgOnClick
@jDiv.find("##{stId_dgOnClick[0]}").click(stId_dgOnClick[1])
ShowMenu: (ev, dLink, rgverb) ->
@ClearMenu(true)
jMenu = $("<div class='iffy-menu'/>").hide()
for verb in rgverb
jMenuItem = $("<a href='javascript:void(0)'>#{verb.stDisplay}</a><br/>").click(() => verb.dgActivate(); @Display())
jMenu.append(jMenuItem)
jMenu.css({
position: "absolute"
top: ev.pageY
left: ev.pageX
})
@jDiv.append(jMenu)
@jMenu = jMenu
@fMenuVisible = false
jMenu.show('blind', {}, 300, () => @fMenuVisible = true)
ClearMenu: (fForce) ->
if @jMenu? and (fForce or @fMenuVisible)
@jMenu.hide('blind', {}, 300, () -> $(this).remove())
@jMenu = null
@fMenuVisible = false
RegOnClick: (dgOnClick) ->
stId = "__gen#{@igenID++}"
@mpstId_dgOnClick.push([stId, dgOnClick])
stId
RgwstRun: () ->
wst = @wstInit.WstNext(@story.NevByName("start"))
eniWst = Nevgen.EniWst(this, wst)
ArrayFromEni(eniWst)
RunNev: (wst, nevResponse) ->
wst = wst.WstNext(nevResponse)
nevResponse.RunAction(this, wst)
wst
FWillNevRun: (wst, eniNevResponseNext, nevgen, respo, nevPretend, nevLater) ->
wstNext = @RunNev(wst, nevPretend)
ThunkWstContinue = (eniWstRespondingOld) ->
() ->
if eniWstRespondingOld?
if eniWstRespondingOld.Value() == wst
return new EniCons(wst, ThunkWstContinue(null))
else
return new EniCons(eniWstRespondingOld.Value(), ThunkWstContinue(eniWstRespondingOld.EniNext()))
else
return new EniConcat(new Eni(wstNext), eniWstNextPre)
eniWstRespondingNew = ThunkWstContinue(nevgen.eniWstResponding)().EniNext()
eniWstNextPre = new Nevgen(this, wstNext, eniWstRespondingNew, respo.ReplaceEniNev(eniNevResponseNext)).Thunk()()
eniWstNext = eniWstNextPre.EniNext()
while eniWstNext?
if eniWstNext.Value().nev.ID() == nevLater.ID()
return true
eniWstNext = eniWstNext.EniNext()
return false
FilterStHtml: (stHtml, wst) ->
for actor in @story.rgactor
try
stHtml = actor.FilterStHtml(stHtml, wst)
catch error
stHtml = stHtml + "<p class='error'>[[error interpreting markup: #{error}]]</p>"
stHtml
# narrative event -- the story is told as a sequence of these. Contains the HTML to display when the
# narrative event happens, the preconditions to make sure the event should be allowed to occur, and
# the resultant effect on the state of the model of the world
class Nev
constructor: (@dNev) ->
ID: () ->
if not @id?
rgstName = $(@dNev).parents("[name]").map(() -> $(this).attr("name")).get()
rgstName.reverse()
rgstName.push($(@dNev).attr("name"))
@id = rgstName.join(".")
@id
FHasRun: (wst) ->
while wst?.nev
if wst.nev.ID() == @ID()
return true
wst = wst.WstPrev()
return false
FCanRun: (gst, wst) -> not @FHasRun(wst)
RunAction: (gst, wst) ->
StHtmlDisplay: (gst, wst) ->
jdivTmp = $("<div/>")
for dHTML in $(@dNev).contents()
jdivTmp.append(document.importNode(dHTML, true))
stHtml = jdivTmp[0].innerHTML
gst.FilterStHtml(stHtml, wst)
#[word text to display] -- links to a word, usually a noun, that the player can interact with in some way.
#[word] == [word word]
# $[if (expr)]some text$[else]some other text$[endif] (else is optional)
# $[(expr)] inserts as a string the result of expr
#[[ outputs [, ]] outputs ]
TemplateFromStNev = (st, wst) ->
class Xpd
constructor: (@st, @fExpr) ->
JsStringLit = (st) ->
"'" + st.replace(/'/g, "\\'").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/, "\\t") + "'"
rgxpd = []
ich = 0
ichLim = 0
ichLimTok = 0
PushText = (fExpr, dgSt) ->
stTok = st.substring(ich, ichLim)
if dgSt? then stTok = dgSt(stTok)
if stTok then rgxpd.push(new Xpd(stTok, fExpr))
ich = ichLimTok
ichLim = ichLimTok
Match = (dgPushNoTok, rgre_dg...) ->
ire = 0
ireMatched = -1
ichMatchFirst = null
for ire in [0...rgre_dg.length] by 2
re = rgre_dg[ire]
re.lastIndex = ichLimTok
rgstMatched = re.exec(st)
if rgstMatched?
ichMatchT = re.lastIndex - rgstMatched[0].length
if not ichMatchFirst? or ichMatchT < ichMatchFirst
ichMatchFirst = ichMatchT
stMatched = rgstMatched[0]
ireMatched = ire
reMatched = rgre_dg[re]
if ireMatched >= 0
ichLim = ichMatchFirst
ichLimTok = ichMatchFirst + stMatched.length
rgre_dg[ireMatched + 1]()
else
ichLim = ichLimTok = st.length
if dgPushNoTok?
dgPushNoTok()
null
MatchInText = () ->
EndText = () ->
PushText(true, (st) -> JsStringLit(st.replace(/\[\[/g, "[").replace(/\]\]/g, "]")))
Match(EndText,
/\[\[/g, () -> MatchInText,
/\]\]/g, () -> MatchInText,
/\[/g, () -> EndText(); MatchInWord,
/\$\[/g, () -> EndText(); MatchInExpr)
MatchBracket = (cbracket, dgPush) ->
() -> Match(dgPush, # error?
/"/g, () -> MatchString(MatchBracket(cbracket, dgPush)),
/\[/g, () -> MatchBracket(cbracket + 1, dgPush),
/\]/g, () -> if cbracket == 0 then dgPush(); MatchInText else MatchBracket(cbracket - 1, dgPush))
MatchString = (matchAfter) ->
() ->
Match(null,
/\\\\/g, () -> MatchString(matchAfter),
/\\"/g, () -> MatchString(matchAfter),
/"/g, () -> matchAfter)
StWord = (st) ->
rgstDisplay = /[ ]*([^ ]+)(.*)/.exec(st)
stWord = rgstDisplay[1]
if rgstDisplay[2]
stDisplay = rgstDisplay[2]
else
stDisplay = rgstDisplay[1].replace(/_/g, " ")
word = wst.gst.story.WordByName(wst.gst, stWord)
if word? and (rgverb = word.Rgverb(wst)).length > 0
dgOnClick = (ev) -> wst.gst.ShowMenu(ev, this, rgverb)
stId = wst.gst.RegOnClick(dgOnClick)
JsStringLit("<a href='javascript:void(0)' id='#{stId}' class='iffy-word'>#{stDisplay}</a>")
else
JsStringLit(stDisplay)
MatchInWord = MatchBracket(0, () -> PushText(true, StWord))
MatchInExpr = MatchBracket(0, () -> PushText(true))
BuildRgxpd = (dgMatch) ->
while dgMatch?
dgMatch = dgMatch()
BuildRgxpd(MatchInText)
rgstJs = ["var __p=[];with(obj){"]
fExpr = false
for xpd in rgxpd
if xpd.fExpr != fExpr
rgstJs.push(if fExpr then ");" else "__p.push(")
else if fExpr
rgstJs.push(",")
fExpr = xpd.fExpr
rgstJs.push(xpd.st)
if fExpr then rgstJs.push(");")
rgstJs.push("}return __p.join('');")
new Function("obj",rgstJs.join(''))
class Word
constructor: (@gst, @jWord) ->
Rgverb: (wst) ->
rgverb = []
for dVerb in @jWord.find("verb")
nev = new Nev(dVerb)
if not nev.FHasRun(wst) and not @gst.story.actorPlayer.FWillAttempt(wst, nev)
stDisplay = $(dVerb).attr("display") or $(dVerb).attr("name")
dgActivate = () => @gst.story.actorPlayer.RespondTo(wst.nev, new Nev(dVerb))
rgverb.push(new Verb(stDisplay, dgActivate))
rgverb
class Verb
constructor: (@stDisplay, @dgActivate) ->
# actors drive the story by responding to nevs with more nevs
# ActorPlayer does its best to perform the actions that the player has told the game to perform.
class ActorPlayer
constructor: (@story) ->
@mpnevID_rgnev = {}
RespondTo: (nev, nevResponse) ->
rgnev = @mpnevID_rgnev[nev.ID()]
if rgnev?
rgnev.push(nevResponse)
else
@mpnevID_rgnev[nev.ID()] = [nevResponse]
FWillAttempt: (wst, nev) ->
rgnev = @mpnevID_rgnev[wst.nev.ID()]
if rgnev?
for nevResponse in rgnev
if nevResponse.ID() == nev.ID()
return true
return false
EninevResponse: (nev, wst) ->
rgnev = @mpnevID_rgnev[nev.ID()]
if rgnev? then new EniArray(rgnev) else EniEmpty
FilterStHtml: (stHtml, wst) ->
TemplateFromStNev(stHtml, wst)(wst)
class Story
constructor: (@jStory) ->
@actorPlayer = new ActorPlayer(this)
@rgactor = [@actorPlayer]
NevByName: (stName) ->
if stName?
rgstName = stName.split('.')
for dNev in @jStory.find("[name=#{rgstName[rgstName.length - 1]}]")
istName = rgstName.length - 2
dParent = dNev
while dParent? and istName >= 0
dParent = $(dParent).parent("[name=#{rgstName[istName]}]").get(0)
istName--
if dParent?
return new Nev(dNev)
null
WordByName: (gst, stName) ->
jWord = @jStory.find("word[name=#{stName}]")
if jWord.length > 0 then new Word(gst, jWord)
RespoResponse: (nev, wst) ->
Respo.FromEnieniNev(new EniMap(new EniArray(@rgactor), (actor) -> actor.EninevResponse(nev, wst)))
WstInit: (gst) ->
NewWst({}, gst)
Rgget = (rgurl, dgAfter, iurl, rgresult) ->
if not iurl? then iurl = 0
if not rgresult then rgresult = []
if iurl == rgurl.length
dgAfter(rgresult)
else
# $.get, called with javascript, results in a "syntax error" in FireFox if the result is not xml. This is OK.
$.get(rgurl[iurl], ((result) -> rgresult.push(result); Rgget(rgurl, dgAfter, iurl + 1, rgresult)), "text")
window.PlayStory = (url, jDiv) ->
$.get(url, (domXml) ->
story = new Story($("story", domXml))
gst = new Gst(story, jDiv)
rgurlExt = []
for domLoad in $("loadmodule", domXml)
rgurlExt.push($(domLoad).attr("url"))
Rgget(rgurlExt, (rgscript) ->
for script in rgscript
eval(script)(gst)
gst.Display()
)
, "xml")