Entity Creation and Messaging in a VB.NET Text-Based Game
The player can't be alone in the world. Obviously, he's going to have to have some non-human company. Let's put some non-human characters into the game. In this ninth and final part of our series teaching VB.NET through the creation of a text-based game, we're also going to create a messaging system.
The non-human characters will be derived from Entity, just as the player character is, because they're going to share some of the same properties. They are going to have names, heatlh, attack, defense, a character symbol, a position on the map, and so forth.
Let's go ahead and implement an entity who will pace back and forth. This entity is completely pointless as far as gameplay is concerned, but implementing him will help us with non-human entity functionality because we have to deal with drawing him and moving him around.
Create a class called PacingManEntity (in the file PacingManEntity.vb) to house our new entity:
PublicClass PacingManEntity
EndClass
The first thing we need to do with this new class is inherit from Entity and then set up an appropriate constructor, which will call Entity's constructor and pass the appropriate values:
PublicClass PacingManEntity
Inherits Entity
SubNew(ByVal x AsInteger, ByVal y AsInteger)
MyBase.New("Pacing Man", "P", ConsoleColor.Red, _
x, y, 10, 1, 1)
EndSub
EndClass
As you can see above, his name is Pacing Man, and his symbol is a red “P.” His starting location is, of course, not hard-coded into the class. Rather, the starting location is accepted as a parameter in the constructor.
We have an entity, and now we need to draw him. Drawing is done the exact same way as with the player—through DrawEntity. However, we need a way to draw all of the non-player entities at once. The best way to do this is to store the entities in a collection and then create a new method that will loop through the collection and call DrawEntity for each entity. Plus, with a collection, we can keep track of all the entities.
We'll use a List(Of T) to store the entities because that is what's most appropriate for the situation. Create the collection as a field of the Game module, right under the player and map definitions:
Dim entities AsNew List(Of Entity)
Notice how we also instantiate the collection.
Next, we need to add a PacingManEntity to the collection. Put the following line at about the location that we create the Adventurer object (the line where we create the PacingMan and add it to the entities collection needs to be before the line where we draw the entities):
entities.Add(New PacingManEntity(10, 10))
Now we need a method that will loop through entities and draw everything. We'll call this procedure DrawEntities, and it will use a simple For Each loop to get the job done:
Sub DrawEntities()
ForEach toBeDrawn As Entity In entities
DrawEntity(toBeDrawn)
Next
EndSub
Next, simply call the new procedure at around the location that the player is drawn through DrawEntity:
DrawEntities()
Run the program, and you should see a PacingManEntity somewhere off to the bottom right of the player.
The next step is to add some life to PacingManEntity. Each time the player makes a move, PacingManEntity (and other entities, for that matter) needs to make a move as well. The easiest way to implement this is to add a Move method to Entity. In Entity, this method will be blank. Then, entities can override this method with their own behaviors. After each move of the player, the Move method will be called for each entity, allowing each entity to perform some sort of action.
The first step, then, is to add a Move procedure to the Entity class. Since the intent here is to have the method be overridden by subclasses, we need to modify it with the Overridable keyword:
PublicOverridableSub Move()
EndSub
In the PacingManEntity class, we need to override this procedure and provide functionality. Let's make a Pacing Man pace back and forth, from right to left. He will reverse direction whenever he hits an obstruction. This will require a way to keep track of what direction a PacingManEntity object is currently going. So, we'll need to first add a field to the PacingManEntity class. A Boolean called goingRight should do the job. If it's set to True, then the entity is moving to the right. If it's set to False, then the entity is moving to the left. Let's set it to True initially, causing him to move right from the start:
Private goingRight AsBoolean = True
Now it's time to override Move. In order to override a procedure, the Overrides keyword is used:
PublicOverridesSub Move()
MyBase.Move()
EndSub
Visual Studio will automatically call the parent class's method first. In our case, the parent class's method has nothing in it, though.
Remember, we need to change directions if there is an obstruction in the way (if TryMove returns False). However, since a Pacing Man only moves from right to left, if there are obstructions in both directions, then we'll have him simply forfeit his turn. In this situation, he can wait until the next turn to see if one of the obstructions has cleared. Here are the contents of PacingManEntity's Move procedure:
' Determine the change in X based on direction
Dim changeX AsInteger = 1
IfNot goingRight Then
changeX = -1
EndIf
' Move, or else change direction and move, or else quit
IfNot TryMove(Me, changeX, 0) Then
changeX *= -1
goingRight = Not goingRight
TryMove(Me, changeX, 0)
EndIf
One of the things you should notice is the Me keyword. The Me keyword in Visual Basic refers to the current object. It's equivalent to the “this” or “self” operator in other languages.
Now we just need to call this new method from within Main. Entities should only move if the player has made a move. So, after this:
If Not playerMoved Then
Continue While
End If
Place this:
For Each toMove As Entity In entities
toMove.Move()
Next
This will loop over all of the entities and give each an opportunity to make a move.
Run the program. Each time the player makes a move, the pacing entity should make a move. When the entity hits a wall, he should reverse direction.
You may, however, have noticed a problem with TryMove. If the entity trying to move faces a wall, then TryMove returns False as it should since no move can be made. However, if the entity trying to move faces another entity, then TryMove will return True.
You can test this out by getting in the way of the PacingManEntity. He'll walk right through you. Clearly, this is not what we want, but, thankfully, this can be easily fixed by modifying TryMove. TryMove simply needs to loop through all of the entities to determine if the destination tile is inhabited by another entity, or if the player inhabits the destination tile.
All of this is easy, though. We simply need to compare the X and Y properties of each entity to the x- and y-coordinates of the destination tile. Here's the entire TryMove method, rewritten:
Function TryMove(ByVal toBeMoved As Entity, ByVal xChange AsInteger, ByVal ychange AsInteger) AsBoolean
Dim x AsInteger = toBeMoved.X + xChange
Dim y AsInteger = toBeMoved.Y + ychange
ForEach gameEntity As Entity In entities
If gameEntity.X = x And gameEntity.Y = y Then
ReturnFalse
EndIf
Next
If map(x, y).Passable = True _
And (player.X <> x Or player.Y <> y) Then
RedrawTile(toBeMoved.X, toBeMoved.Y)
toBeMoved.X = x
toBeMoved.Y = y
DrawEntity(toBeMoved)
ReturnTrue
EndIf
ReturnFalse
EndFunction
Now TryMove works properly, checking not only the Passable property of the destination tile but also the location of the player and all of the other entities. The player and the PacingManEntity should no longer walk over each other. Instead, they should stop, blocked.
When the player bumps into another entity, it might be a good idea to display a short message to the user telling him that someone is in the way. Besides, a messaging system would be nice for other purposes as well. So, let's create one.
First, we need something in which to store our messages. A collection will do the job. However, I'd like to introduce a new kind of collection especially suited for the purpose: the Queue(Of T).
Picture a line (a queue) of people waiting to buy a movie ticket. The first person who gets into the line is the first person to be removed from the line, and the last person who gets into the line is the last person to be removed from the line. A Queue(Of T) works just like this. It's a first-in-first out collection.
Unlike other collections, it has no index associated with it. Instead, we enqueue and dequeue elements. The first element to be enqueued is the first element to be dequeued. This works for our purposes because we have limited screen space to work with it, and we'll need to remove the oldest messages (the first ones to be added) when we run out of room.
Let's go ahead and create a Queue(Of T) as a field of the Game module:
Dim messages AsNew Queue(OfString)
We don't want to add messages directly to the the collection because we want to be able to limit the size of it. If it gets too large, we need to remove some messages. Speaking of size, let's define a ReadOnly field containing the maximum number of messages in messages at a time:
ReadOnly MessageLimit AsInteger = 10
Next, we need to create a method that will add messages. This method needs to check the size of messages at first, and if the number of messages has reached MessageLimit, then an element needs to be dequeued and discarded using the Dequeue method. Then, if the message is too long, it needs to be split up into multiple messages. That way, a message doesn't automatically run to the next line and mess up everything (it will be written over or will run past the allowed height of the message area, possibly causing the screen to scroll). The message then needs to be enqueued using the Enqueue method, and all of the messages need to be redrawn. Here's WriteMessage:
Sub WriteMessage(ByVal message AsString)
' Is it too big? If so, split it up.
If message.Length > 70 Then
WriteMessage(message.Substring(0, 70))
WriteMessage(message.Substring(70))
Else
' Do we have too many messages?
If messages.Count = MessageLimit Then
messages.Dequeue()
EndIf
' Add it and draw the messages
messages.Enqueue(message)
DrawMessages()
EndIf
EndSub
Now we need to create a method that will draw the messages out to the screen, below the map and user statistics, with one space of padding to the left. This involves two things. First, we need to erase the old messages by writing blank spaces over them. Second, we need to actually write out the current messages. Here's DrawMessages:
Sub DrawMessages()
' Erase old messages by writing blank spaces over them
' Write out ten spaces at a time to minimize flickering
For y AsInteger = 1 To MessageLimit
Console.SetCursorPosition(1, 20 + y)
For x AsInteger = 1 To 7
Console.Write(" ")
Next
Next
Console.SetCursorPosition(0, 21)
ForEach message AsStringIn messages
Console.CursorLeft = 1
Console.WriteLine(message)
Next
EndSub
We now have a working messaging system. You can test it out by writing a welcome message before the game's While loop:
WriteMessage("Welcome to VB Quest!")
Let's modify TryMove to alert the player if he's trying to walk into another entity. This only involves rewriting the For Each loop to check to see if its the player trying to move. If it is, then we need to write a message:
ForEach gameEntity As Entity In entities
If gameEntity.X = x And gameEntity.Y = y Then
If toBeMoved Is player Then
WriteMessage(gameEntity.Name & " is in the way.")
EndIf
ReturnFalse
EndIf
Next
Notice the Is operator and the & (concatenation) operator. The Is operator checks to see if two variables point to the same instance. Here, we're checking to see if the entity to be moved (toBeMoved) is actually the player. The & operator concatenates too strings. The + operator will also work, but it's not generally recommended when concatenating to strings because it's not exclusive to strings as & is.