diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f31b3e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +*.swp diff --git a/game_engine.coffee b/game_engine.coffee new file mode 100644 index 0000000..8d649f1 --- /dev/null +++ b/game_engine.coffee @@ -0,0 +1,420 @@ +class GameLog + + constructor: (onChange=(->))-> + @content = "" + @onChange = onChange + + echoInput: (text)-> + @content += "
> " + text + "
" + @onChange(this) + + writeln: (text="")-> + @content += text.replace(/\n/g, "
") + "
" + @onChange(this) + + +######################################################################################################################## + +class Inventory + + constructor: -> + @items = [] + + add: (item)-> + @items.push(item) + + describe: -> + result = [] + + needsDelimiter = false + for item in @items + if needsDelimiter + result.push("\n") + result.push("There is a #{item.name} here.") + needsDelimiter = true + + return result.join("\n") + + eachItem: (withItem=(->))-> + for item in @items + withItem(item) + + isEmpty: -> + return @items.length is 0 + + +######################################################################################################################## + +class Item + + constructor: (@name)-> + @description = "non-descript item" + + toString: -> + return @name + + +######################################################################################################################## + +class Location + + constructor: (@name)-> + @description = "Non-descript Place" + @destinations = {} + @inventory = new Inventory() + @transitions = [] + @visited = false + + addTransition: (direction, toLocation, locked=false)-> + @transitions[direction] = new Transition(direction, toLocation, locked) + + addItem: (item)-> + @inventory.add(item) + return this + + describe: -> + result = [@name] + + if not @visited + result.push("\n") + result.push(@description) + + if not @inventory.isEmpty() + result.push("") + result.push(@inventory.describe()) + + return result.join("\n") + + getTransitionTo: (location)-> + for direction, transition of @transitions + if transition.toLocation is location + return transition + + return undefined + + + removeItem: (item)-> + @items.remove(item) + return this + + toString: -> + return @name + + +######################################################################################################################## + +class ParseError extends Error + + +######################################################################################################################## + +class Parser + + constructor: (@story)-> + @aliases = {} + @directions = [] + @verbs = {} + @fillerWords = new Set() + + addAliases: (aliasMap)-> + for alias, meaning of aliasMap + @aliases[alias] = meaning + + addDirections: (words...)-> + for word in words + @directions.push(word) + + addFillerWords: (words...)-> + for word in words + @fillerWords.add(word) + + addVerb: (verb)-> + @verbs[verb] = verb + + interpret: (userInput)-> + sentence = new Sentence + for rawWord in userInput.split(/\s\s*/) + rawWord = @_resolveAliases(rawWord) + + if @_useAsFiller(rawWord, sentence) then continue + if @_useAsVerb(rawWord, sentence) then continue + if @_useAsItem(rawWord, sentence) then continue + if @_useAsLocation(rawWord, sentence) then continue + + throw new ParseError("I'm not sure what you meant by #{rawWord}... can you re-phrase that?") + + @_normalizeSentence(sentence) + @_validateSentence(sentence) + return sentence + + # Private Methods ############################################################################## + + _normalizeSentence: (sentence)-> + if sentence.has(verb: 0, location: 1) + sentence.addWord(new WordToken("go", "verb")) + else if sentence.has(verb: 1, item: 0) and (sentence.verb is "take") + sentence.addWord(new WordToken("all", "item")) + else if sentence.has(verb: 1, item: 0) and (sentence.verb is "drop") + sentence.addWord(new WordToken("all", "item")) + + return sentence + + _resolveAliases: (rawWord)-> + meaning = @aliases[rawWord] + if meaning + return @_resolveAliases(meaning) + return rawWord + + _useAsFiller: (rawWord)-> + return @fillerWords.has(rawWord) + + _useAsItem: (rawWord, sentence)-> + candidates = {} + + considerItem = (item)-> + if item.name.indexOf(rawWord) isnt -1 + candidate = candidates[item.name] ?= {item: item; count: 0} + candidate.count += 1 + + @story.player.inventory.eachItem(considerItem) + @story.currentLocation.inventory.eachItem(considerItem) + + candidates = (item for name, item of candidates) + return false if candidates.length is 0 + + highestCount = 0 + mostPopular = [] + for candidate in candidates.values() + if candidate.count is highestCount + mostPopular.push(candidate) + else if candidate.count > highestCount + highestCount = candidate.count + mostPopular = [candidate] + + if mostPopular.length > 1 + throw new ParseError( + "I'm not sure what you meant by #{rawWord}... which did you mean: #{mostPopular.join(", ")}" + ) + + sentence.addWord(new WordToken(rawWord, "item", mostPopular[0])) + return true + + _useAsVerb: (rawWord, sentence)-> + if not @verbs[rawWord] + return false + + sentence.addWord(new WordToken(rawWord, "verb")) + return true + + _useAsLocation: (rawWord, sentence)-> + candidates = new Set() + + for directionWord in @directions + if rawWord is directionWord + transition = @story.currentLocation.transitions[directionWord] + if transition + candidates.add(transition.toLocation) + else + throw new ParseError("You can't go #{rawWord} from here.") + + for direction, transition of @story.currentLocation.transitions + if rawWord is direction + candidates.add(transition.toLocation) + else if transition.toLocation.name.indexOf(rawWord) isnt -1 + candidates.add(transition.toLocation) + + if @story.currentLocation.name.indexOf(rawWord) isnt -1 + candidates.add(@story.currentLocation) + + candidates = Array.from(candidates) + + if candidates.length > 1 + throw new ParseError("I'm not sure what you meant by #{rawWord}... did you mean: #{candidates.join(", ")}") + else if candidates.length is 0 + return false + + if candidates[0] is @story.currentLocation + throw new ParseError("You're already there!") + + sentence.addWord(new WordToken(rawWord, "location", candidates[0])) + return true + + _validateSentence: (sentence)-> + if sentence.has(verb: 0) + throw new ParseError("I'm not sure what you wanted to do there.") + + +######################################################################################################################## + +class Player + + constructor: (@story)-> + @inventory = new Inventory() + @verbs = {} + + addVerb: (verb, onVerb)-> + @verbs[verb] = onVerb + @story.parser.addVerb(verb) + + enact: (sentence)-> + onVerb = @verbs[sentence.verb] + if onVerb + onVerb(sentence) + else + throw new ParseError("I'm not sure how to #{sentence.verb}, to be honest.") + + move: (location)-> + transition = @story.currentLocation.getTransitionTo(location) + if not transition + throw new ParseError("You can't get to #{location.name} from here.") + else if transition.locked + throw new ParseError(transition.lockDescription) + + @story.arrive(location) + + +######################################################################################################################## + +class Sentence + + constructor: -> + @tokens = item: [], location: [], verb: [] + + addWord: (wordToken)-> + @tokens[wordToken.type].push(wordToken) + + has: (patternMap)-> + patternMap ?= {} + + for type, count of patternMap + if @tokens[type].length isnt count + return false + + return true + + toString: -> + return ( + "{" + + "items: [#{@tokens.item.join(", ")}], " + + "locations: [#{@tokens.location.join(", ")}], " + + "verbs: [#{@tokens.verb.join(", ")}]" + + "}" + ) + + Object.defineProperties @prototype, + "item": + get: -> return @tokens.item[0].referant + "location": + get: -> return @tokens.location[0].referant + "verb": + get: -> return @tokens.verb[0].rawText + + +######################################################################################################################## + +class Story + + constructor: (@title)-> + @currentLocation = null + @items = [] + @locations = [] + @log = new GameLog() + @parser = new Parser(this) + @player = new Player(this) + + @_configure() + + # Configuration Methods ###########3############################################################ + + addItem: (name)-> + item = new Item(name) + @items.push(item) + return item + + addLocation: (name)-> + location = new Location(name) + @locations.push(location) + if @initialLocation is null + @initialLocation = location + return location + + # Game Action Methods ########################################################################## + + arrive: (location)-> + @currentLocation = location + @log.writeln(location.describe()) + @log.writeln() + + location.visited = true + + begin: -> + @log.writeln(@title) + @log.writeln() + @arrive(@currentLocation) + + interpret: (userInput)-> + try + @log.echoInput(userInput) + sentence = @parser.interpret(userInput) + @player.enact(sentence) + catch e + if e instanceof ParseError + @log.writeln(e.message) + else + throw e + + # Private Methods ############################################################################## + + _configure: -> + @parser.addDirections( + "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "up", "down" + ) + @parser.addFillerWords("of", "the", "a", "an", "to") + @parser.addAliases({ + "d": "down", + "e": "east", + "everything": "all", + "g": "go" + "n": "north", + "ne": "northeast", + "nw": "northwest", + "s": "south", + "se": "southeast", + "sw": "southwest", + "u": "up", + "w": "west", + }) + + player = @player + @player.addVerb "go", (sentence)-> + if sentence.has(location: 1) + player.move(sentence.location) + else + throw new ParseError("I'm not sure where you want to go...") + + +######################################################################################################################## + +class Transition + + constructor: (@direction, @toLocation, @locked=false)-> + # do nothing + + toString: -> + return "#{@direction} to #{@toLocation.name}" + + +######################################################################################################################## + +class WordToken + + constructor: (@rawText, @type, @referant=undefined)-> + # do nothing + + toString: -> + return "{rawText: #{@rawText}, type: #{@type}, referant: #{@referant}}" + + +######################################################################################################################## + +window.Story = Story diff --git a/index.html b/index.html new file mode 100644 index 0000000..1a77aed --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + +
+

+
+ + + + + + + + + + diff --git a/run_game.coffee b/run_game.coffee new file mode 100644 index 0000000..35f345a --- /dev/null +++ b/run_game.coffee @@ -0,0 +1,23 @@ +# Constants ############################################################################################################ + +RETURN_KEY = 13 + + +# Global Data ########################################################################################################## + +$(document).ready -> + $input = $("input.entry") + $input.on "keydown", -> + if event.which isnt RETURN_KEY then return + STORY.interpret($input.val()) + $input.val("") + + $log = $(".log") + $logText = $("p.log-text") + + STORY.log.onChange = (log)-> + $logText.html(log.content) + $log.scrollTop($log[0].scrollHeight) + + STORY.begin() + $input.focus() diff --git a/server.sh b/server.sh new file mode 100755 index 0000000..d06d41e --- /dev/null +++ b/server.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +python -m http.server 8080 diff --git a/story.coffee b/story.coffee new file mode 100644 index 0000000..90cdc10 --- /dev/null +++ b/story.coffee @@ -0,0 +1,35 @@ +window.STORY = s = new Story("A Walk Through My House") + +# Items ################################################################################################################ + +boxOfTile = s.addItem("box of tile") +boxOfTile.description = + "This appears to be a box of flooring tiles. The tiles appear to be a very light color of natural stone. It is + quite heavy." + +hose = s.addItem("garden hose") +hose.description = "It's a pretty ordinary garden hose about 20' long." + +# Locations ############################################################################################################ + +driveway = s.addLocation("Driveway") +driveway.description = + "The driveway extends slightly uphill a short way to the west back to the street. At this part, it's recessed a + little below the level of the lawn with a stone wall defining the boundary. To the east are three garage doors which + make up the entire side of the house. Above the doors are a few windows. The back yard is to the east, and the + front porch is to the south." +driveway.addItem(hose) + +frontPorch = s.addLocation("Front Porch") +frontPorch.description = + "You're standing on the front porch. It's a wooden deck with a slight overhang above, and a railing all about. A + double door is in front of you with a fancy crystal inset in the windows. There's a light on inside the house, but + you can't make out any details." +frontPorch.addItem(boxOfTile) + +# Configure Map ######################################################################################################## + +driveway.addTransition("southwest", frontPorch) +frontPorch.addTransition("northeast", driveway) + +s.currentLocation = frontPorch diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..b1aff92 --- /dev/null +++ b/styles.css @@ -0,0 +1,42 @@ +* { + box-sizing: border-box; +} + +body { + align-items: center; + display: flex; + font-size: 16px; + font-family: monospace; + flex-direction: column; + justify-content: center; +} + +div.log { + background: black; + color: green; + height: 600px; + overflow-y: scroll; + padding: 0.25em 1em; + width: 800px; +} + +input.entry { + background: black; + border: none; + border-top: 1px solid gray; + color: green; + font-family: monospace; + font-size: 16px; + margin: 0; + outline: none; + padding: 0.5em 1em; + width: 800px; +} + +p.log-text { +} + +p.placeholder { + color: gray; +} +