### 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("

#{@story.jStory.attr('title')}

#{@story.jStory.attr('author')}

") for wst in @RgwstRun() @jDiv.append($("
").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 = $("
").hide() for verb in rgverb jMenuItem = $("#{verb.stDisplay}
").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 + "

[[error interpreting markup: #{error}]]

" 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 = $("
") 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("#{stDisplay}") 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")