Scripting Solutions for Games - Part I: Rolling your own interpreter (1)
Written by James S. J. Durtka (notthecheatr)
So you're about to write a game and you aren't sure what to do about scripting? Scripting is a nice element that is very useful in the more complex game engines to allow things to be easily changeable. Sure you can code everything into the engine, but then what if you want to add something to the game? External scripts, along with external maps, tilesets, etc., help to separate between the engine and the game, between the art (story, music, and graphics) and the part you program. More importantly, it makes your engine much more general - in theory, at least, the same engine could be used to make an entirely different game!
This is a series that will probably contain at least four parts, and possibly more depending on how much I decide to do. My goal is not to teach you everything you could possibly know about the subject, but to give you some ideas with which to work from - help you start thinking on your own so you can come up with the solution that's best for you.
The first and most obvious scripting solution for games is simply to write your own scripting interpreter. That's what this tutorial is about, and what the next one will also be about. Later we'll look at XML and Lua, a couple of very standard and useful languages. Quite possibly you'll use more than one solution in a single game, and anyways it's good to look at all the possible alternatives so you know which is best. Another thing I'd like to cover if I've got time is a practical look at using these things. The first four tutorials look at how to use various scripting languages, and when to use them. But that's all theory. If I get around to writing a fifth tutorial, I hope to cover a bit of practical use - perhaps writing a simple game that uses what we've covered so far.
Writing a simple interpreter is actually a pretty easy thing to do, thanks to FreeBASIC's nice handling of strings. Depending on your level of expertise you may be able to write something rather complicated, or not. You may just want to start with a very simple interpreter. Either way, I'll get into the basics here which will help you get started. After that, the future is yours!
So how do we write an interpreter? Well, first we have to figure out what kinds of commands we need. For an RPG, a lot of different commands might be good. You'll probably find that complex stories need a lot of commands to be executed well. Just how much you pack into your script interpreter is up to you. We'll start with a very simple PRINT command that does just what its name suggests. In an RPG scripting engine, the PRINT command might display character dialog inside a little message box. In our example, it just uses the FreeBASIC Print command to display the text.
How exactly would we implement all that? Well, you basically have to be able to look for certain strings - usually the keywords you're looking for, such as Print, Sleep, End, etc. For this we use various combinations of Left, Right, and Mid. These give you part of a string, allowing you to cut out the bits you don't need. When possible, if we only need a single character, we use string indexing with []. These treat the string as a byte pointer array, which means the first character of a string called myString would be myString[0]. In fact, it returns a number rather than a character, so you have to do conversions back and forth with Chr() and Asc(). There are also LTrim, RTrim, and Trim, which cut specific characters off the edges only. Then we have LCase and UCase. Normally we convert everything to lowercase so it's case-insensitive. Of course, if you want a case-sensitive language you can remove the uses of these, but it's pretty nice to be able to use any case you like without difficulty. Using all these string manipulation functions, we manage to parse through strings quite easily. Then we just do what is supposed to be done by the commands we find, as we find them.
Back to our interpreter - since it's to be used in a game, you can't be executing the script interpreter all the time. After all, you have to do graphics, input, AI, and all the rest. If you know how to do multithreading, you might do that. More likely you'll have a "next instruction" command for your interpreter. To make things simple, this will be a line-by-line interpreter: each instruction must be on its own line. That way, we can use the FreeBASIC Line Input command. You may have your own way to do this, but that's how I'm going to do it for this simple example. I'm going to do this with OOP because it's much simpler that way. The nice thing is that you can use more than one interpreter at the same time this way, and therefore run more than one script at a time.
Type interpreter Public: Declare Constructor (filename As String) Declare Destructor () Declare Sub nextInstruction () As uInteger ended As uInteger error Private: As String _filename As uInteger _filehandle As uInteger _line_number End Type
This is the interface; the code comes later. As you see, (or not, if you don't understand OOP), if we want to run a script named "someScript.fbsc" then we would simply do this:
Dim As interpreter myInterpreter = interpreter("someScript.fbsc") Do myInterpreter.nextInstruction() Loop Until myInterpreter.ended <> 0 Sleep End
Here's an example script:
someScript.fbsc:'Display "Hello, World!" on the screen Print "Hello, World!" 'Wait for a keypress Sleep 'End the program End
We want this to perform exactly as expected when executed by our interpreter. How do we do that?
Let's start with the constructor. That really just sets everything up in the object, including opening the file specified.
Constructor interpreter (filename As String) this._filename = filename this._filehandle = FreeFile() Open filename For Input As #this._filehandle this._line_number = 1 this.ended = 0 this.error = 0 End Constructor
We store a copy of the name of the script file internally, just in case we ever need it. Then we open it, storing the file handle (which is the next free file handle). We start at the first line number, and the script has neither ended nor had any errors so far.
Next the destructor. This basically undoes anything the Constructor has done, and anything that has happened in between then and now.
Destructor interpreter () this.ended = Not 0 Close this._filehandle this._filehandle = 0 End Destructor
Seems pretty simple. We mark ourselves ended so we don't try to execute any more instructions, close the file, and set the filehandle to null.
Now for the interpreter. As I said before, everything in the script will be pretty much the same as in FreeBASIC. Only End will be different - it will end the script, but not the main program. Print will simply Print the string, though, and Sleep will simply Sleep. Now because this is meant to introduce you to parsing, I do it in a simple and somewhat hackish way, doing everything step-by-step. Later we'll look at better ways to do things, but there's no point my giving you a solution unless you understand it.
Sub interpreter.nextInstruction() Dim As String curLine, tmp Dim As uInteger inQuote = 0 If this.ended <> 0 Then Exit Sub If Eof(this._filehandle) Then this.ended = Not 0 Exit Sub End If 'Read the next line of input Line Input #this._filehandle, curLine 'Trim leading and trailing whitespace off curLine = Trim(curLine) 'There are no commands shorter than three characters, so quit now if we need to If Len(curLine) < 3 Then Exit Sub 'If it's a comment, quit If curLine[0] = Asc("'") Then Exit Sub If LCase(Left(curLine, 3)) = "rem" Then Exit Sub 'If the first five characters are "sleep" then sleep If LCase(Left(curLine, 5)) = "sleep" Then Sleep 'If it's Print then search for the first quotation mark, then go until the second. If LCase(Left(curLine, 5)) = "print" Then 'If there are no more characters afterwards, just do a linefeed If Len(curLine) <= 5 Then Print "" Else For i As uInteger = 5 To Len(curLine)-1 'If it's a quotation mark, begin storing the characters beyond it - or stop, depending on where we are. If curLine[i] = Asc("""") Then inQuote = Not inQuote 'If we were in a quotation before, quit the loop If inQuote = 0 Then Exit For Else If inQuote <> 0 Then tmp = tmp + Chr(curLine[i]) End If End If 'If we happen to hit the end of the line with no end quotation, raise an error. If i = Len(curLine)-1 And curLine[i] <> Asc("""") Then this.error = 1 Next i 'When it's all said and done, print the string Print tmp End If End If 'If the command is to end, simply end the script. If LCase(Left(curLine, 3)) = "end" Then this.ended = Not 0 End If End Sub
If you don't understand it yet, keep trying. Particularly important to understand is the LCase(Left()) lines. For every possible command, we convert the first n characters of the line (after trimming whitespace off) to lowercase (that way it's a case-insensitive language, so you can write PRINT, Print, or print if you like) and check if it's that command). In this case, most things are pretty simple, though the Print command is a little bit complicated to implement. We basically search for the first quotation mark, and after that we keep track of each character. When we hit the second quotation mark, we stop. If we hit the end of the line before the second quotation mark, we note the error but don't do anything about it. After all, it's not a major error.
Here's everything we have so far:
interpreter1.bas:If you compile this and put a script "someScript.fbsc" in the same directory with it, you'll be able to run the script. Nothing very useful can be written with this yet, but it's a start. If you were paying attention, you noticed that I added another command I didn't mention yet: Cls. Here's a demo of all the features so far: someScript2.fbsc:Type interpreter Public: Declare Constructor (filename As String) Declare Destructor () Declare Sub nextInstruction () As uInteger ended As uInteger error Private: As String _filename As uInteger _filehandle As uInteger _line_number End Type Constructor interpreter (filename As String) this._filename = filename this._filehandle = FreeFile() Open filename For Input As #this._filehandle this._line_number = 1 this.ended = 0 this.error = 0 End Constructor Destructor interpreter () this.ended = Not 0 Close this._filehandle this._filehandle = 0 End Destructor Sub interpreter.nextInstruction() Dim As String curLine, tmp Dim As uInteger inQuote = 0 If this.ended <> 0 Then Exit Sub If Eof(this._filehandle) Then this.ended = Not 0 Exit Sub End If 'Read the next line of input Line Input #this._filehandle, curLine 'Trim leading and trailing whitespace off curLine = Trim(curLine) 'There are no commands shorter than three characters, so quit now if we need to If Len(curLine) < 3 Then Exit Sub 'If it's a comment, quit If curLine[0] = Asc("'") Then Exit Sub If LCase(Left(curLine, 3)) = "rem" Then Exit Sub 'If the first five characters are "sleep" then sleep If LCase(Left(curLine, 5)) = "sleep" Then Sleep 'If it's Print then search for the first quotation mark, then go until the second. If LCase(Left(curLine, 5)) = "print" Then 'If there are no more characters afterwards, just do a linefeed If Len(curLine) <= 5 Then Print "" Else For i As uInteger = 5 To Len(curLine)-1 'If it's a quotation mark, begin storing the characters beyond it - or stop, depending on where we are. If curLine[i] = Asc("""") Then inQuote = Not inQuote 'If we were in a quotation before, quit the loop If inQuote = 0 Then Exit For Else If inQuote <> 0 Then tmp = tmp + Chr(curLine[i]) End If End If 'If we happen to hit the end of the line with no end quotation, raise an error. If i = Len(curLine)-1 And curLine[i] <> Asc("""") Then this.error = 1 Next i Print tmp End If End If 'If the command is Cls, Cls! If LCase(Left(curLine, 3)) = "cls" Then Cls End If If LCase(Left(curLine, 3)) = "end" Then this.ended = Not 0 End If End Sub Dim As interpreter myInterpreter = interpreter("someScript.fbsc") Do myInterpreter.nextInstruction() Loop Until myInterpreter.ended <> 0 Print "" Print "SCRIPT ENDED." Sleep End
Print "Hi! This is a script!" Print "Press a key to clear the screen!" Sleep Cls Print "Press a key to end this script!" Sleep End
As you can see, it's pretty easy to add simple commands, but as you add parameters to a command things become more complicated. To simplify this whole process, I created a nice object for line-by-line parsing. I call it the words_list, because that's essentially what it does - convert a string into a list of words. You can get it at http://www.freebasic.net/forum/viewtopic.php?t=10131. It won't work with anything multi-line (though with some basic modifications it probably could) but it's perfect for what we're doing here. No need to search for the quotation marks or whitespace between parameters; it does that for you! It splits strings up based on whitespace, but it handles strings correctly and ignores comments properly as it should. If you'd like, you can read through the source and try to understand how it works; it's very similar to what we do above, only a lot more complicated. Fortunately, you don't have to understand how it works to use it.
Here's how we do our nextInstruction() sub:
Sub interpreter.nextInstruction() Dim As String curLine, tmp, thisWord Dim As words_list wordsList = words_list() If this.ended <> 0 Then Exit Sub If Eof(this._filehandle) Then this.ended = Not 0 Exit Sub End If 'Read the next line of input Line Input #this._filehandle, curLine 'Set the string in the wordslist to the inputted line wordsList.setString(curLine) 'Get the first word, make it lowercase thisWord = LCase(wordsList.getWord(0)) 'Try different things depending on its length Select Case Len(thisWord) 'If it's a 3-character word... Case 3: 'Check if it's END or CLS Select Case thisWord Case "end": this.ended = Not 0 MutexUnlock(this._info_mutex) Exit Sub Case "cls": Cls End Select 'If it's a 5-character word... Case 5: 'Check if it's SLEEP or PRINT Select Case thisWord Case "sleep": 'Variable parameters for Sleep Select Case wordsList.numWords Case 1: Sleep Case 2: Sleep Val(wordsList.getWord(1)) Case 3: Sleep Val(wordsList.getWord(1)), Val(wordsList.getWord(2)) End Select 'For Print, we just have to get the next word and trim the first quotation marks off the end. Case "print": tmp = Mid(wordsList.getWord(1), 2, wordsList.wordLength(1)-2) Print tmp End Select End Select End Sub
You may be surprised to find that this is only two lines shorter than the original nextInstruction(). That's because of our extensive use of Select Case. However, you can also see how much simpler this is, and you can be sure that adding more instructions will take a lot fewer lines of code than it would have with the old method. Already we've added the two optional parameters to Sleep here. You can do the same with Print if you like.
someScript3.fbsc:Print "10" Sleep 500, 1 Cls Print "9" Sleep 500, 1 Cls Print "8" Sleep 500, 1 Cls Print "7" Sleep 500, 1 Cls Print "6" Sleep 500, 1 Cls Print "5" Sleep 500, 1 Cls Print "4" Sleep 500, 1 Cls Print "3" Sleep 500, 1 Cls Print "2" Sleep 500, 1 Cls Print "1" Sleep 500, 1 Cls Print "0" Sleep 500, 1
For all this, we still aren't really doing much of anything yet! What about variables?
As usual, I'm going to take the simple road. That is, only numerical variables will be allowed. In fact, all variables will be of the type Double. You're smart, you can figure out how to do other variables types if you need to.We need some kind of object to handle variables. For every variable that is created using Dim, we need to store the name of the variable and of course the variable itself. Thus, for each variable there will be a String and a Double to deal with. If you wanted other variable types, you'd also need to store the variable type and instead of the double you'd store a Ptr to the variable, whatever it may be.
Type variable As String varName As Double varValue End Type Type variableSpace Public: Declare Constructor () Declare Destructor () Declare Sub addVariable (vname As String, initialVal As Double) Declare Function getVariable (vname As String) As Double Declare Sub setVariable (vname As String, newVal As Double) Private: As variable Ptr _variables End Type
Of course, the first thing is simply to create the variable, using Dim. Since we're only using the Double type, there's no need to check for "As type" - that's implied. We'll use the good old fashioned QBASIC way. Thus, there are only two real complications - multiple variable declarations on a single line, and initial values. If you added types, things would be even more complicated (especially if you allowed a FreeBASIC-like open syntax, where the "As type" could come before or after variable names), since you'd have to handle all that in addition to multiple variables and initializers.
Dim a = 1, b = 2, c = 3, d = 4
Can we do all that? Sure, and it'll be a lot simpler than you think. The main things to remember:
So it's really not that hard. If the first word returned by words_list is "dim" then we look for the variable name. If the next word is an equal sign, then we take the next word and turn it into a number using Val, then assign it to the variable we create by calling varSpace.addVariable(). If either the variable another one coming so we loop through this way. If we ever expect something and don't get it, we'll flag an error, but we can probably ignore it. This being a simple interpreter, very few errors really matter in the grand scheme of things. We'll just warn and keep on going.
variables.bas:Type variable As Double varValue As String varName End Type Type variableSpace Public: Declare Constructor (parentSpace As Any Ptr = 0) Declare Destructor () Declare Sub addVariable (vname As String, initialVal As Double) Declare Function getVariable (vname As String) As Double Declare Sub setVariable (vname As String, newVal As Double) Declare Function accessVariable (vname As String) As Double Ptr Declare Sub destroyAllParents () Private: As variable Ptr _variables As uInteger _num_variables As variableSpace Ptr _parent_space As Any Ptr _info_mutex End Type Constructor variableSpace (parentSpace As Any Ptr = 0) this._num_variables = 0 this._variables = Allocate(1) this._parent_space = CPtr(variableSpace Ptr, parentSpace) this._info_mutex = MutexCreate() End Constructor Destructor variableSpace () MutexLock(this._info_mutex) DeAllocate(this._variables) MutexDestroy(this._info_mutex) End Destructor Sub variableSpace.addVariable (vname As String, initialVal As Double) vname = LCase(vname) 'Thread safety, as usual MutexLock(this._info_mutex) 'Add a new variable this._num_variables += 1 this._variables = ReAllocate(this._variables, (this._num_variables+1)*SizeOf(variable)) this._variables[this._num_variables-1].varValue = initialVal this._variables[this._num_variables-1].varName = vname MutexUnlock(this._info_mutex) End Sub Function variableSpace.getVariable (vname As String) As Double Dim As uInteger found = 0 If this._num_variables = 0 Then Return 0 vname = LCase(vname) 'Special variables - RND and TIMER If vname = "rnd" Then Return Rnd If vname = "timer" Then Return Timer 'Thread safety, as usual MutexLock(this._info_mutex) 'Default value Function = 0 'Check each variable and see if it has the right value For i As uInteger = 0 To this._num_variables-1 'If we find it, return its value If this._variables[i].varName = vname Then Function = this._variables[i].varValue found = Not 0 End If Next i 'If we didn't find it, check our parent space if we have one... If found = 0 Then If this._parent_space <> 0 Then Function = this._parent_space->getVariable(vname) End If End If MutexUnlock(this._info_mutex) End Function Sub variableSpace.setVariable (vname As String, newVal As Double) Dim As uInteger found = 0 Dim As Double Ptr dblPtr If this._num_variables = 0 Then Exit Sub vname = LCase(vname) 'If trying to set RND, set the seed If vname = "rnd" Then Randomize newVal 'Thread safety, as usual MutexLock(this._info_mutex) 'Check each variable and see if it has the right value For i As uInteger = 0 To this._num_variables-1 'If we find it, return its value If this._variables[i].varName = vname Then dblPtr = @(this._variables[i].varValue) found = Not 0 End If Next i 'If we didn't find it, check our parent space if we have one... If found = 0 Then If this._parent_space <> 0 Then dblPtr = this._parent_space->accessVariable(vname) End If End If If dblPtr <> 0 Then *dblPtr = newVal MutexUnlock(this._info_mutex) End Sub Function variableSpace.accessVariable (vname As String) As Double Ptr Dim As uInteger found = 0 If this._num_variables = 0 Then Return 0 vname = LCase(vname) 'Thread safety, as usual MutexLock(this._info_mutex) 'Default value Function = 0 'Check each variable and see if it has the right value For i As uInteger = 0 To this._num_variables-1 'If we find it, return its value If this._variables[i].varName = vname Then Function = @(this._variables[i].varValue) found = Not 0 End If Next i 'If we didn't find it, check our parent space if we have one... If found = 0 Then If this._parent_space <> 0 Then Function = this._parent_space->accessVariable(vname) End If End If MutexUnlock(this._info_mutex) End Function Sub variableSpace.destroyAllParents() If this._parent_space <> 0 Then this._parent_space->destroyAllParents() Delete this._parent_space End If End Sub
You may notice that we allow variable spaces to have parents. This allows for scope, so we can have local scopes. That way a local scope variable always overrides the global one with the same name. Obviously we don't have any scoping implemented yet, but we've made it possible for scoping to be implemented later. If a variable is not found in the local variable space, the next one up is checked.
Another thing we've allowed is to access the variable wherever it is located - you can get its value, with getVariable, or you can get a pointer to it, with accessVariable. The reason for this is actually pretty obvious - your engine might need to access variables used by scripts. For example, a script might create a variable called "player.health" to store the health of the player - and of course the engine only knows what the players health is by accessing this variable directly from the variable space of the script.
Then we have a simple method for telling a local space to destroy all its parents. The simple reason is that we only store a pointer to one local scope at a time. When the interpreter's destructor is called, it will have to destroy the current local scope as well as all the scopes above it all the way up to the global scope. If this is hard to understand, don't worry - it will become clearer.
Notice that as usual we always convert variable names to lowercase - this ensures case-insensitivity. If you want case sensitivity (not standard for FreeBASIC or most other languages, though some interpreted languages such as Lua do it) just remove the line
vname = LCase(vname)
from the beginning of each sub. This will probably speed things up, but it makes things more complicated unless you always remember to use the same case.
Finally, one small thing to note - notice that we have two "special" variables. These are TIMER and RND, which return the FreeBASIC functions of the name name. One thing worth noting is that if we try to set RND to something, we actually set the random seed. So once we implement variable setting, we could do
Rnd = Timer
to perform the equivalent of:
Randomize Timer
Now we have to make a couple of helper functions to determine whether a variable name is valid or not.
Function isNumeric (testStr As String) As uInteger 'Check if the first character is numeric or not For i As uInteger = 0 To Len(testStr)-1 Select Case testStr[i] Case Asc("0"), Asc("1"), Asc("2"), Asc("3"), Asc("4"), Asc("5"), Asc("6"), Asc("7"), Asc("8"), Asc("9") Return Not 0 Case Else: Return 0 End Select Next i Return Not 0 End Function Function isKeyword (testStr As String) As uInteger 'Check if the variable name is a keyword If LCase(teststr) = "dim" Then Return Not 0 If LCase(teststr) = "cls" Then Return Not 0 If LCase(teststr) = "rnd" Then Return Not 0 If LCase(teststr) = "end" Then Return Not 0 If LCase(teststr) = "print" Then Return Not 0 If LCase(teststr) = "sleep" Then Return Not 0 If LCase(teststr) = "timer" Then Return Not 0 Return 0 End Function
This should be pretty easy to understand. We'll just have to be sure to update the isKeyword function each time we add a new keyword.
Now we've got some big changes to make in our interpreter.bas. First, we have to add a local and a global variable space to our interpreter object. The global space is the very top level of scope. Every scope can access variables in the global space so long as there are no local variables with the same name. So when accessing a variable, we check the local space, and it will automatically check everything all the way to the top. But the global space is special; it doesn't have a parent, and it never changes (whereas the local scope can change, at least once we add scope controls).
#Include Once "words_list/words_list.bas" #Include Once "variables.bas" Enum errorType NONE = 0 BAD_VAR_NAME = 1 KEYWORD_VAR_NAME = 2 End Enum Type interpreter Public: Declare Constructor (filename As String) Declare Destructor () Declare Sub nextInstruction () As uInteger ended As uInteger error Private: As String _filename As uInteger _filehandle As uInteger _line_number As variableSpace Ptr _global_var_space As variableSpace Ptr _local_var_space End Type Constructor interpreter (filename As String) this._filename = filename this._filehandle = FreeFile() Open filename For Input As #this._filehandle this._line_number = 1 this.ended = 0 this.error = NONE this._global_var_space = New variableSpace() this._local_var_space = this._global_var_space End Constructor Destructor interpreter () this.ended = Not 0 Close this._filehandle this._filehandle = 0 this._local_var_space->destroyAllParents() Delete this._local_var_space End Destructor
In the beginning, the local variable space is the global space. But if we enter a new scope, then a new local variable space is created, whose parent is the old local variable space. And when we exit the scope, that variable space is destroyed and its parent comes into scope. When the interpreter is destroyed, all scopes are destroyed at once using destroyAllParents().
Now for our ever-growing interpreter.nextInstruction() sub. We just have to add the code for Dim (later we'll do some code for Scope...End Scope). This is quite easy. I won't even put the entire listing here, just the part that has changed. Figure out where this goes:
'Try different things depending on its length Select Case Len(thisWord) 'If it's a 3-character word... Case 3: 'Check if it's END or CLS Select Case thisWord Case "end": this.ended = Not 0 MutexUnlock(this._info_mutex) Exit Sub Case "cls": Cls Case "dim": 'Get the first word after Dim tW = 1 tWord = wordsList.getWord(1) 'Continue so long as there is a comma to the right of a word Do 'If the variable name is invalid, record the error but continue If IsNumeric(Left(tWord, 1)) Then this.error = BAD_VAR_NAME If IsKeyword(tWord) Then this.error = KEYWORD_VAR_NAME 'If the variable already exists, record an error If this._local_var_space->accessVariable(tWord) = 0 Then this.error = DUPLICATED_DEF 'If we find an equal sign next, get the value after the equal sign... If wordsList.getWord(tW+1) = "=" Then tW += 2 'Get the next word If IsNumeric(wordsList.getWord(tW)) Then 'If it's a number, convert it to numeric form tVal = Val(wordsList.getWord(tW)) Else 'Otherwise assume it's a variable tVal = this._local_var_space->getVariable(LCase(wordsList.getWord(tW))) End If Else tVal = 0 End If 'Create the variable with an initial value of the value specified or 0 if none is specified this._local_var_space->addVariable(tWord, 0) tW += 1 'Get the next word tWord = wordsList.getWord(tW) Loop While ((Right(tWord, 1) <> ",") Or (wordsList.getWord(tW+1) = ",")) And tWord <> "" End Select
Now we can create a variable and assign it a value. But we can't do anything with the variable! Let's do the most obvious thing: printing.
Case "print": 'Start with the first word tW = 1 tWord = wordsList.getWord(1) If tWord = "" Then Print "" 'For each argument Do While tWord <> "" 'If it ends with a ;, we trim it off and Print with ; so everything is on the same line If Right(tWord,1) = ";" Then 'If it's a string... If Left(tWord, 1) = """" Then 'Truncate the leading and trailing " off plus the trailing ; tmp = Mid(tWord, 2, Len(tWord)-3) 'Otherwise, assume it's a variable Else 'Get the variable value, removing the trailing ; tmp = Str(this._local_var_space->getVariable(Left(tWord,Len(tWord)-1))) End If Print tmp; 'But if it doesn't end with ;, don't trim it off Else 'If it's a string... If Left(tWord, 1) = """" Then 'Truncate the leading and trailing " off tmp = Mid(tWord, 2, wordsList.wordLength(tW)-2) 'Otherwise, assume it's a variable Else tmp = Str(this._local_var_space->getVariable(tWord)) End If Print tmp 'Since it's the last parameter to print, we exit the loop Exit Do End If 'Next word tW += 1 tWord = wordsList.getWord(tW) 'Forever, until there are no parameters left. Loop
Whew, how's that work?! Well, take it a thing at a time. Like the Dim, there's a loop involved here. Basically the loop only ends when there are no parameters left - either no more ; at the end or else an empty word. During this loop, we go through each parameter to Print. If the parameter ends with ;, we know more are following, so we remove the ; and print with ; so everything stays on the same line. Otherwise, we print without ; and then exit sub. If the parameter starts with a " then we assume it's a string and remove the leading and trailing "; otherwise, we assume it's a variable and remove nothing, using the entire string to get the locallest variable with that name (0 if the variable doesn't actually exist).
It seems complicated, but if you think about it hard enough you'll get it.
Now we'll add this sub to the interpreter object:
Sub interpreter.accessGlobal (vname As String) As Double Ptr Return this._global_var_space->accessVariable(vname) End Sub
Which allows anyone to access global variables.
Now before we quit today, we'll add Input. That should be pretty easy, of course. Input is actually nearly identical to Print, since we print anything in quotation marks. However, variables are inputted instead of printed.
Case "input": 'Start with the first word tW = 1 tWord = wordsList.getWord(1) 'For each argument Do While tWord <> "" 'If it ends with a ;, we trim it off and Print with ; so everything is on the same line If Right(tWord,1) = ";" Then 'If it's a string... If Left(tWord, 1) = """" Then 'Truncate the leading and trailing " off plus the trailing ; tmp = Mid(tWord, 2, Len(tWord)-3) Print tmp; 'Otherwise, assume it's a variable Else 'Get the variable value, removing the trailing ; Input tVal this._local_var_space->setVariable(Left(tWord,Len(tWord)-1), tVal) End If 'But if it doesn't end with ;, don't trim it off Else 'If it's a string... If Left(tWord, 1) = """" Then 'Truncate the leading and trailing " off tmp = Mid(tWord, 2, wordsList.wordLength(tW)-2) Print tmp 'Otherwise, assume it's a variable Else Input tVal this._local_var_space->setVariable(tWord, tVal) End If 'Since it's the last parameter to print, we exit the loop Exit Do End If 'Next word tW += 1 tWord = wordsList.getWord(tW) 'Forever, until there are no parameters left. Loop
Comparing it to the code for Print, you'll see it's very similar but there are of course those few changes mentioned. And now we can do this:
someScript5.fbsc:Dim someVar Input "Enter a number "; someVar Print "You entered: "; someVar
We've come a long way, but we've still got a ways to go. In particular, we need to be able to implement expressions so we can manipulate variables properly and so we can implement conditionals and looping. All that will be covered in the Part II of Scripting Solutions for Games. Once we finish this, we'll move on to using XML and Lua. Finally, we'll bring it all together and build a mini-game with the stuff we've learned.
I hope you've enjoyed this. As usual, any comments, errata, questions, etc. should be sent to TheMysteriousStrangerFromMars@yahoo.com or addressed to me (notthecheatr) on the forums.
Downloads:
This tutorial by itself
This tutorial and the example files included with this tutorial