Sniff is a "Scratch-like" programming language that's designed to help Scratchers move gently from Scratch to more conventional languages. They can start writing programs, without having to learn a new language because Sniff is based on Scratch. They learn a little more about variables, compiling, syntax errors (!), and they can have fun controlling real hardware while they're doing it.

Thursday, 3 July 2014

Text Adventures (Part 2)

A few months ago, I wrote about how text adventures could be used to make maps, walk around them, and link computing into a range of project based activities. I introduced SAE - the Scratch Adventure Engine, and explained how you could make use it to implement a simple world with objects.

The map for the SAE demo.
(The woods in the top right are surprisingly confusing)

However one of the great things about text adventures is that a lot of the "puzzles" come down to that basic computer science concept: IF (I have item X) AND (I'm in location Y) THEN (make Z happen). That means that once you've figured out how a problem works in the game, its pretty easy to translate it into code. But before we can put any puzzles into the game, we need a basic understanding of how the engine works.

SAE is pretty long for a Sniff program - about 300 lines. However its pretty simple if you break it down. It all starts with the "when start"!

when start
.set currentRoom to 1
.broadcast setupObjects and wait
.broadcast doLook and wait
..say ""
..ask "What now?" and wait
..broadcast doAction and wait

I'm a big fan of "programming in the problem domain" - what that means is that your code at the top level should be about things in the game. Even someone who has never programmed, should be able to see what's going on here: we set things up and then loop forever, asking what to do, then doing it.

when doAction
.broadcast splitAnswer and wait
.if verb = "look" or verb="l"
..broadcast doLook and wait
..stop script
.if verb = "take" or verb="get" or verb="pick"
..broadcast doTake and wait
..stop script

doAction is the longest script in the game, as it needs to understand all the different commands. However most of this is just repeating the same kind of code over and over. Game commands are typically in the form of a "verb" followed by a "noun", as in "get lamp", so the first thing doAction does is split "answer' into a verb (the first word), and a noun (the last word) - this also conveniently handles things like "fight the big dragon".

Another trick in doAction is that we probably want to be able to move around by just entering directions, so the game considers "north" to be a verb. To make this work when we actually enter "go north", we recognise the verb "go" and when we see it we replace the verb with the direction! It's a bit of a hack, but it makes the code a lot easier. doAction then just does a whole load of test, and runs the appropriate script.

.if verb = "go"
..set verb to noun
..set noun to ""
.if verb = "north" or verb="n"
..set direction to 1
..broadcast doMove and wait
..stop script

To move around we set a direction then call the doMove script:

make direction number
when doMove
.set fileData to join join "rooms/" [ currentRoom ] ".dat"
.tell nativeFile to "startRead"
.if not fileOK
..say "Can't find room data"
..stop script
.repeat direction
..tell nativeFile to "readString"
.tell nativeFile to "endRead"
.if value of fileData = 0
..say "You can't got that way"
..set currentRoom to value of fileData
..broadcast doLook and wait

In the last post I explained that theres a "dat" file for each room which contains the exits. We read that in, and read in lines till we get to the write one. However remember that we've read in a string, and one of the differences between Scratch and Sniff is that Sniff treats numbers and strings differently, so we need to convert that to a number, using "value of". If the value is 0 that means we can't go that way, other wise we move to that room, and call the doLook script.

when doLook
.set fileData to join join "rooms/" [ currentRoom ] ".txt"
.tell nativeFile to "startRead"
.repeat until not fileOK
..tell nativeFile to "readString"
..if fileOK
...say fileData
.tell nativeFile to "endRead"
.set counter to 1
.repeat until counter > length of objectLocations
..if item counter of objectLocations = currentRoom
...say join "You can see a " item counter of objectNames
..change counter by 1

The look script starts by printing out the current rooms description from the text file. However the interesting part is the second part where it looks for objects that are in the room. Objects are held in two lists - one holding their names, the other holding their locations. Here we go through the locations lists and check if the objects location is the current room - in which case we print out that the object is visible.

The "inventory" command works the same way, but checks location 0, as this is used to indicate that the player is carrying an object.

And finally we have any number of further scripts which implement a single command (and get called from doAction). For example if we consider doTake:

when doTake
.broadcast findNounNum and wait
.if nounNumber = 0
..stop script
.if not currentRoom = item nounNumber of objectLocations
..say "I can't see that here"
..stop script
.if noun = "dragon"
..say "He looks a little too heavy to carry!"
..stop script
.say join "You take the " noun
.replace item nounNumber of objectLocations with 0

Most of the commands act on an object. We know its name, but we're probably going to need its number, so findNounNum does this for us. It prints a message and returns 0 if the object is unknown, so  this is the first thing we check.

If we're going to "take" an object it has to be in the current location, so that's the next thing we check. If everything is Ok, then we print a message and set the objects location to 0, to show that we're carrying it.

However we're now more or less ready to start thinking about game logic. This is stuff that's specific to the game. The standard engine (adv. sniff) contains some basic logic, to illustrate how it works, and also because it will probably come in handy. Here we note that dragons are (generally) heavy and you probably don't want to pick them up, so we simply check if the noun is "dragon" and if it is then we print out a suitable message, and stop the script (so we don't pick him up).

make lampIsOn boolean

when doLight
.broadcast findNounNum and wait
.if nounNumber = 0
..stop script
.if not nounAvailable
..say "You can't see that."
..stop script
.if noun="lamp"
..say "Done! That'll make it easier to see things in dark places"
..set lampIsOn to yes
..stop script
.say "That doesn't really make sense..."

The other main piece of game logic implemented in the default engine is a lamp - there's always a lamp! There's a boolean variable lampIsOn - these hold either yes or no, and when the game starts up the value is set to no. To light the lamp we use the "light" command, and like doTake is uses findNounNum to check that the thing you're trying to light is a valid object. findNounNum also sets up a variable nounAvailable which checks if the chosen object is either in the current location or being carried by the player. Then we simply check if the object is the lamp, and if it is then we set lampIsOn to be yes.

There's some slightly more complex game logic in the file demo.sniff, which is specific to the demo game. In the doLook script there's an extra test:

.#it's too dark to see at the bottom of the well so check we have the lamp
.if currentRoom = 4
..if lampIsOn and (item 1 of objectLocations=4 or item 1 of objectLocations=0)
...say "That lamp is really helping"
...say "It's really dark in here"
...stop script

Room 4 is the bottom of the well. If we have the lamp, and its turned on, then we say something about how useful the lamp is, and then continue to display the normal room description.  However if we're in that room and we don't have a lit lamp, then we say that its really dark. There's a sword hidden at the bottom of the well, but you can't see it until you have the lamp with you.

Some slightly more complex game logic in the demo concerns the dragon. He lives in location 3, but won't let you go south from there. In the doMove script we have:

.if currentRoom=3 and dragonIsEnemy and direction=3
..say "The dragon is in the way, and looks as if he doesn't want disturbed"
..stop script


This is at the beginning of the doMove script, so stops anything happening. However there's some additional logic at the end of doMove, which means it happens as you enter a room:

.if currentRoom=3 and dragonIsEnemy and item 2 of objectLocations = 0
..say "The dragon gets excited!"
..say "'You've found my sword! Thank you so much!"
..say "The dragon takes the sword and runs off into the woods"
..replace item 2 of objectLocations with -1
..replace item 3 of objectLocations with -1
..set dragonIsEnemy to no

If we've just entered room 3, and the dragon is still an enemy, and we have the sword (item 2), then the dragon takes the sword and runs away with it (we set the location to -1 as we can never get to location -1, removing the object from the game). Now when we try to move south it'll work fine.

While I haven't gone through every script in the engine, most of the rest follow a similar pattern. It should be fairly straight forwards to add new commands, and add hooks into existing ones to make special stuff happen.

The code for adv. sniff (a generic version of the code), and demo.sniff (with a few simple game hooks in specific to the demo world) can be downloaded from the previous blog post.

No comments:

Post a Comment