See Part Four for the ins and outs of out parameters
If you have experience of another programming language
but are new to C#, this series will introduce you
to the fundamental features of the C# language and
the .NET Framework as we develop a simple ‘classic
style’ text adventure game. |
Our adventure game system starts to get really interesting
this month. Now that we’ve built the class hierarchy
and designed a map, it is time to let the player loose
on it. We’ll write some code that will allow the
player to wander around from room to room collecting
treasures en route.
Writing a ‘take object’ routine is pretty
simple. Let’s look at the code. From directory
\a\wombat in our source code, load up the wombat.sln solution. Things, such as treasures, in this game are
stored in ThingList objects. You can find the definition
of the ThingList class in the AdvThings.cs unit. The
ThingList class has a thisOb() method which returns an
object with a specific name if such an object is found
in the list:
public Thing thisOb( string _aName )
{
Thing athing = null;
foreach(Thing t in this)
{
if(t.Name.Trim().ToLower().Equals(_aName.Trim().ToLower()))
athing = t;
}
return athing;
}
This method takes an object’s name as a string
argument, _aName. If
that name matches the Name property
of one of the objects in the current list (this)
then that object is returned. Otherwise null is returned.
Notice that the code uses a few methods to trim the strings,
set them to lower case and test them for equality. Refer
to Stringing Along (below) for more on
string-handling methods.
Now switch to the MainForm unit to see what happens
when the ‘Take’ button is clicked. The code
passes the text from the input box, inputTB, to the takeOb()
method. Find the takeOb() method higher in the MainForm
unit. This starts off by calling the thisOb() method
of CurrentRoom.Things (the ThingList containing any objects
in the current room) to obtain a Thing, t, whose Name
property matches the string, obname. If none is found,
t is null and the string, "no, it's not here!",
is returned. Otherwise, t is instantiated to a Thing
object and this is passed as the first of three arguments
to the transferOb() method. The remaining two arguments
are the list of things in the current room and the list
of things owned by the player. The transferOb() method,
simply removes the Thing, t, from the first list and
adds it to the second list.
Now, implementing a dropOb() method is trivial. In
essence it is the same as takeOb() apart from the fact
that the thisOb() method is used with the player’s
list rather than the room’s list and the order
of the two lists is reversed when transferOb() is called
so that the item is dropped rather than taken.
Restoration Drama
Finally, it would be handy if we could save and restore
games or restart the default game. That’s easily
done. Restarting is simplest of all. You can just recreate
the adv object that defines the game as a new instance
of the Adventure class:
adv = new Adventure();
Saving and loading is a bit more complex. It requires
that we ‘serialize’ objects so that they
can be stored in streams. You will find the code in the
OnClick event-handlers associated with the Save and Load
menu items. Note too that I have had to mark all the
classes I want to save with the [Serializable] attribute.
This has been done in the AdvThings unit.
I have also had to add some appropriate references to
the using section
at the top of the MainForm unit. Once that’s been
done, everything is remarkably straightforward.
I’ve added Load, Save and Restart items to the
File menu. Currently, however, the program only saves
and loads a single predefined file name. We’ll
be adding save and load file dialogs shortly…
As an added user-friendly touch I have added a bit
of code to test whether the save file already exists
(using the File.Exists() method). If so, a dialog is
displayed and the user is asked if the save file should
be overwritten. If the ‘Yes’ button is clicked,
the file is saved, otherwise the save operation is aborted.
The File.Exists() method has been called when attempting
to load a file too. In this case, if the file does not
exist, the code does not attempt to load it.
Programmer’s Playtime!
At last the time has come to start adding some puzzles
to our game! The first thing we need to do in order to
expand the basic game framework into a real game is to
create a bigger map. This is done by creating a series
of room objects and adding them to the map in the Adventure()
constructor. Inevitably this involves entering a great
deal of similar code for each object created. I decided
to save some typing effort by creating a keyboard macro
to do this.
Recording Macros in VS .NET
It’s worth becoming familiar
with Visual Studio .NET’s macros. These
let you record repetitive actions and play them
back instantly.
Now just enter all the keystrokes you want to
record. I entered the following text:
_map.Add(new Room("","",N,S,W,E,new
ThingList()));
|
In the macro record panel which has popped
up click the Stop button |
|
Next, save the macro so that it can be used
later. Select Tools, Macros, Save Temporary
Macro. |
|
The macro will show up in the Macro Explorer
window (if this is not visible, press [ALT]-[F8]
to display it). I named the macro newRoom. |
While you can run a macro by double-clicking
its name in the Macro Explorer, it is usually more
convenient to assign it to a combination of keystrokes.
Select Tools, Options, Environment, Keyboard.
Click
in the pane showing a list of commands and press ‘M’ to
scroll down to the Macros section. If you created
the same macro I did, you should find a macro
here called Macros.MyMacros.RecordingModule.newRoom.
Click the mouse in the ‘Press Shortcut keys’ field
and press whichever combination of keys you would
like to execute the macro. Many key combinations
are already assigned.
You can reassign one of
these or create a new two-key combination. For
example, I entered [CTRL][SHIFT]-/ followed by ‘R’.
From now on, I will always use the [CTRL][SHIFT]-/
hotkey to start a macro. Then, I can press any
of the remaining letters of the alphabet for
26 different macros. Once you’ve selected
a hotkey, click the ‘Assign’ button.
You will be prompted to make a copy of the default
keyboard settings. You may want to name these ‘My
Settings’ and save them so that they can
be reused subsequently whenever you wish.
|
Now, back to the adventure...
When creating a map, it is best to start by
drawing it on a piece of paper. Draw out a grid with a square
for each room. Number each room from 0 upwards and
draw lines between the squares to indicate the exits connecting
one room to another. Normally, the exit-lines will
be straight so that if, for example, Room 0 has an exit
South into Room 1 then Room 1 will have an exit North
into Room 0. This is not an invariable rule, however.
In some circumstances you may want to distort the geography
so that, say, the eastern exit from Room 0 enters Room
1 at the northern side. If you do this, make sure that
the map is internally consistent. In the map of my
game, I have created an apparently enormous meadow (Room 0)
simply by making the exits to the North, West and East
loop back into Room 0. This means that the meadow will
seem to go on forever if the player wanders in those
directions. Similarly, I have created an apparent maze
of passageways from just three rooms (5, 7 and 8) by
the trick of linking these three rooms together and
pointing the northern exit of Room 7 back to itself.
However, the astute player will soon be able to spot
this ‘phoney exit’. All that needs to be
done is to drop a different item in each part of the
passage (that is in each of rooms 5, 7 and 8). Having
exited through the North of room 7, the player will see
that the object that was dropped there is also in the ‘new’ location.
Gateway To Adventure
The time has come to begin work on a real puzzle. Load
up the extended version of this project, wombat.sln,
which you will find in the directory \b\wombat in the
source code. The puzzle I’ve been working on is
found in Room 10, the “Gateway”. The code
which creates all the rooms and adds them to the map
is found in the Adventure.cs unit. Currently Room 10
has no exit to Room 11 (and the remainder the of the
rooms to the North). This is because there is a locked
door to the North. There is a sign on the door that states: “Notice
to all wombats - Squeak once to open door” (this
sign is an object in rm10list. Scroll up the unit to
find the code which creates and initialises this list).
It would seem, then, that we have to find a wombat
in order to make any progress. Before we do that, however,
I’ve just noticed another problem. Up to now, all
objects in the game can be taken by the player. This
includes the door and the sign! Obviously, we must have
some way of distinguishing those objects which can be
taken from those which can’t. My solution is to
add a Boolean CanTake property to the Thing class in
the AdvThings.cs unit.
I might, later on, add other similar properties to
this class such as CanOpen, CanBreak and so on. You need
to be sure to set values for each of these properties
when each object is constructed. Rather than adding a
parameter for each property to the default Thing() constructor,
which would make object creation a chore, I have simply
set a default value for the _cantake field in the constructor.
I have then added a second constructor with the extra
parameter. Only if I specifically want to apply non-default
values do I need to call this alternative constructor.
Note that multiple constructors for a single class are
only legal when the parameter list of each is different.
If you look in the Adventure() constructor in Adventure.cs,
you will see that the door and sign objects are created
using the second version of the Thing() constructor which
takes three arguments, the final one setting the bool
value of _cantake.
All that now remains to be done to ensure that non-takeable
objects aren’t taken is to add a test of the CanTake
property of the specified Thing, t, in the takeOb() method
of MainForm.cs:
if (t.CanTake)
{
transferOb(t, adv.Player.CurrentRoom.Things,adv.Player.Things);
return t.Name + " taken!";
}
else return "You can't take the " +t.Name+"!";
Now I somehow need to get that wombat. Unfortunately,
the beast is inside its kennel in Room 12. The game hasn’t
got a “take something out of something” command,
so I can’t reach in and get the animal. Even if
that command did exist, I would make sure that the puzzle
wasn’t that easy. The kennel would either be too
deep to reach into or the wombat would wriggle out of
your grasp. No, the player has to entice the wombat out
of the kennel some other way. There is a clue here. The
kennel has a sign saying: “Do not feed the wombat!’.
In an adventure game, when you are told not to do something,
obviously you should do it. There was a carrot lying
around in the meadow at the start of the game (Room 0).
Maybe if I take the carrot and then drop it in the kennel
room, (Room 12) the wombat will come out? When the wombat
emerges I can grab it and take it to the Gateway (Room10).
It will squeak and the door will open, leading me to
room 11 (that is, the value specifying Room10’s
North exit will be changed from dir.NOEXIT to 11).
In order that all this shall happen, I need to be able
to add some special coding that executes when the carrot
is dropped in the kennel room. Later in the game, I might
need to have special coding when other crucial objects
are dropped (or taken or looked at) in other specific
locations. I’ve decided to add this coding in a
method called dropObSpecialAction(). I call this method
from dropOb() (in MainForm.cs) and it tests whether specific
conditions are met. In this case, this is the test:
if (( t.Name == "carrot" ) && (
r.Name == "Kennel Room" ))
You might also want to add a test that the wombat is
still in its kennel – that is, you must test that
it is in the kennellist list. If so, you can transfer
the wombat to the current room list in the same way that
I previously transferred an object between the player’s
list and a room’s list when that object is dropped.
From there on, coding the rest of the puzzle is pretty
straightforward. Give it a go. This is where the fun
really begins….
Text based adventures take place in their own virtual
worlds. As with any world, the world of the adventure
game has boundaries. The game I’ve coded thus far
lets you wander around, picking things up and dropping
them. The puzzle I’ve been working on is, however,
incomplete. I need to find the kennel containing the
wombat and lure the animal out by dropping a carrot.
I’ve coded everything up to the point
of dropping the carrot. As the implementer of the game,
what you have to do now is to move the wombat out of
its kennel. This is easy. The wombat is, after all, a
Thing object (just like the carrot) and can be taken
from a list in one location (here, the kennellist object)
and added to another list in another location (such as
rm12list, the list that contains the objects in ‘Room
12’ the “Kennel Room”). If you want
to give the wombat more freedom to move around the map,
you might think about making it an instance of the Actor
class, which defines the player object. In principle,
you could create all kinds of different Actor objects
which, with a bit of clever coding, could move around
the map and interact with other Actor objects or steal
treasures before the player has a chance to get them.
To
take, drop or look at objects, enter the object’s
name - here “carrot” into the text box and
click the appropriate button
To get to the wombat and try to entice him from his
kennel, follow these instructions.
- Load the game, wombat.exe.
- Enter “carrot” into the text box and
click “Take”
- Click the following direction buttons in sequence:
- S
- E
- S
- E
- N
- E
- You are now in the “Kennel Room”
- Enter “sign” into the text box and click “Look
At”
- Read the sign.
- Enter “carrot” into the text box and
click “Drop”
- You have arrived at the boundaries of this game.
From here on in, it’s up to you….
Use the ready-to-run Windows dialogs to save and load
files and browse the disk.
Formerly our game was only able to load a single save
file. It would be much more useful to let the user save
and restore any number of times so that multiple files
could contain the state of the game at different points.
In order to do this, we need to make use of file save
and load dialogs to let the user browse for files on
disk.
Now
the game displays a dialog to let the user save or load
different game files
While you can create dialogs entirely in code (for
an example, see the code in testBtn_Click()), it is simpler
just to drop save and load dialogs from the toolbox.
This makes it easy to set properties in the Property
Inspector. One of the key properties is Filter. This
lets you specify which file extensions will be displayed
in the dialog’s ‘Files of type’ drop-down
list. The filter we use specifies two entries, one with
a *.sav extension and one with any extension, *.*:
sav files (*.sav)|*.sav|All files (*.*)|*.*
Note that each entry takes the form of two pieces of
text separated by an upright bar. The text before the
upright bar is a brief description, while the text after
the bar is the extension itself. Multiple entries are
separated by another upright bar. If you wanted the second
entry to appear by default you would set the FilterIndex
property to 2. We want the first entry to be the default,
so we have set FilterIndex to 1.
I have also set the following properties to the values
shown:
AddExtension = True
DefaultExt
= *.sav
FileName = wombat.sav
InitialDirectory = .
Title = Load a saved game
Since the default extension is *.sav and AddExtension
is True, the extension ‘.sav’ will be automatically
added to a file name such as “wombat”, if
no extension is specified. The FileName “wombat.sav” appears
in the dialog as the default. The single dot in InitialDirectory
opens the dialog in the current directory and the Title
is the one that appears at the top of the dialog.
You can only load a file from disk
if that file exists. In .NET, it’s easy to verify
this Before loading a file, it is essential to check that
the file actually exists. If a user enters a file name
into a dialog, it is quite possible that the file name
will be misspelled. Even if you hard-code a file name
into the source code, there is always the possibility
that the directory may have been changed or the file
might have been renamed, making it impossible to load.
In previous projects, I handled this problem by checking
the file name using the Exists() method of the File class
like this:
string fn = "wombat.sav";
if (File.Exists(fn))
{
}
else
MessageBox.Show(String.Format("File {0} does not exist!", fn));
When using FileOpen and FileSave dialogs, however,
there is a simpler alternative. Just set the dialog’s
CheckFileExists property to True using the Property Inspector.
Then, if the specified file does not exist, a dialog
box will automatically pop up to warn the user. The focus
is then returned to the dialog itself, giving the user
the opportunity either to select a file that does exist
or to cancel the operation.
Similarly, if the CheckPathExists property is set to
true, an error message is displayed if the user enters
a non-existent directory name. Incidentally, if CheckPathExists
is False but CheckFileExists is true, a ‘file name’ error
will appear rather than a ‘path name’ error.
Unless you have a very good reason for choosing otherwise,
it would generally be good practice to have both CheckPathExists
and CheckFileExists set to True.
When using a SaveFileDialog, you might also want to
ensure that the OverwritePrompt property is set to True.
If this is done, a dialog will prompt the user for confirmation
before saving data to a file that already exists.
To make the most of string-handling in C#, it pays
to go to the library!
The .NET framework provides a decent number of ready-to-use
string handling methods and properties in its class library.
I have used a few of these in the thisOb() method of
the Thing class in AdvThings.cs :
t.Name.Trim().ToLower().Equals(_aName.Trim().ToLower())
In the code above, I have trimmed
leading and trailing whitespace from the string, t.Name,
set it to lower case and then tested it for equality
with _aName, which has also been trimmed and set to lowercase.
Most applications need to manipulate strings at some
point. It is worthwhile, therefore, familiarising yourself
with the members of the String class. One of the most
useful properties is Length which returns the numbers
of characters in a string. You can index into a string
using an integer in square brackets. Remember, the first
char is at index 0, so the following displays “char=e”:
string s = "Hello world";
textBox1.Text = "char="+
s[1];
September 2005 |