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:
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
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:
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