See Part Three for
an introduction to class hierarchies and virtual methods
See Part Five for the final stages of this project
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. |
This month I shall be concentrating on some useful,
but confusing, features of the C# language. In particular
I shall be discussing the difference between virtual
and non-virtual methods and how to return multiple values
from a method using ‘out’ parameters. It
isn’t all tedious theory though as I shall also
be returning to our adventure game project in order to
find out how virtual methods and ‘out’ parameters
can be used in practice.
By
the end of this month’s column we’ll
have created the bare bones of an adventure game with
a map of rooms, each of which may contain objects, some
of which (such as the ‘pot’ object shown
in the room above) may contain other objects. We haven’t
yet implemented the ability to take and drop objects
or to save and restore a game - but those features will
be coming soon….
Virtual
Reality
In last month’s column, I touched briefly upon
the mysteries of ‘virtual methods’. Often
useful and sometimes essential, virtual methods can
be quite difficult to understand if you’ve never
used them before. We’ll be using virtual methods
quite a bit in our projects so we shall spend some
time this month looking at them in nit-picking detail.
Note: If you are unfamiliar with virtual methods,
the following explanation will require some concentration.
You may want to grab a cup of coffee (we recommend
freshly ground Peaberry) or tea (green or Assam will
suffice) before continuing… |
In order to understand why virtual methods are needed,
let’s consider how a normal ‘non-virtual’ method
works. Let’s suppose you have defined a class that
has a method with the same name and argument list as
a method of an ancestor class. Normally, the method in
the descendent class will, in effect, replace the method
in the ancestor class. Imagine, for example, that your
program contains an ancestor class, a,
and a descendent class, b.
A descendent class is compatible with its ancestor. You
might say that class b is
a type of class a (though
normally with some additional features).
|
Our Methods.sln solution defines two classes,
a and b. Class b is a descendant of class a (as
you can see from this diagram in the Visual Studio
Class View pane). Not only does it inherit methods
called method1() and method2() from its ancestor
a class but it also defines its own methods with
the same names. |
Now let’s suppose that class a has
a method called method1() and
class b also
has a method called method1(). For simplicity, we’ll
suppose that a.method1() returns the string, “class
a: method1” whereas b.method1() returns “class
b: method1”. Instances of these classes
are created as follows:
a myaOb = new a();
b mybOb
= new b();
Were our code now to call mybOb.method1(),
it would, of course, return the string “class
b: method1”. But now consider what would
happen if we were dealing with a whole batch of mixed
objects, some of which might be b objects,
others a objects and
others c, d and
e objects.
All of the objects in the hierarchy are descended from
class a. We’ve
added these objects, to an ArrayList called al.
Now we write a foreach loop
that iterates through all the objects in the list, al.
This loop has to know which base type of object it is
dealing with (here that’s class a).
Since all descendent objects (b,c,d,e)
are compatible with their ultimate ancestor (a),
it is possible to call method1() (and
other methods of class a,
such as method2()) for
each object encountered, like this:
foreach(a aOb in al)
{
a.method1();
a.method2();
}
This time consider what will happen if the foreach loop
processes our two objects, myaOb and mybOb.
The
foreach loop has been
told it is dealing with instances of the a class. When
mybOb is processed within the loop, will b.method1() or a.method1() be called?
In fact, a.method1() will
be called. So all the objects processed within the loop
will return the string “class
a: method1”, even though some of the objects
may actually be b, c, d or e objects
with their own unique
method1() implementations.
How can we get around this problem? The answer is to
make a.method1() a ‘virtual’ method
and to make all the method1() implementations
in descendent objects ‘overridden’ methods.
You do this by adding the keywords virtual and
override before
the method type and name, like this:
public virtual String method2()
public override String method2()
If we call the virtual method2() inside the foreach loop, C# works out the exact type
of the object being processed before calling the specified
method. So when the b object, mybOb, goes through the
loop, the a.method1() non-virtual method is executed,
but the b.method2() virtual method is executed.
Here
I have declared class b to be a descendant of class a.
In class b, method1() replaces method1() in class a but
method2() overrides the virtual method2() in class a.
Note: When a non-virtual method in a descendent
class which has the same name as a method in an
ancestor class you should precede the method name
with the new modifier rather than the override modifier in order to ‘hide’ the ancestor
method. |
If virtual methods are new to you, you may find this
much easier to understand by running our Methods.sln project
and examining the code. Re-read the explanation given
above and see how each step has been coded. Note that
we have used the new keyword in b.method1().
This is recommended for clarity when an ancestor class
defines a non-virtual method of the same name and argument
list.
All Work, No Play
Right, that’s enough serious stuff for this month.
Now let’s get back to the fun. Over the past couple
of months I’ve been developing an adventure game
system that owes much to the classic exploring games
of days gone by such as Colossal
Cave and Zork. There
is more to this than mere nostalgia, though. My game
will serve to introduce many of the most important features
of C# and .NET.
Load up wombat.sln and look in the AdvThings.cs unit.
You will see that I have already created a moderately
complex hierarchy of objects here. Pay particular attention
to the virtual describe() method of the Thing class and
the overridden describe() method in its descendants such
as ThingHolder and Room. You will see that the implementations
of the describe() method vary dramatically from class
to class, making it vital that the correct method be
called. The ThingList class has its own non-virtual describe() method in which the virtual describe() method of each
Thing in a list is called.
However, the most interesting addition to this month’s
version of the project is the code to let us move the
player (or other characters) from one location to another.
To see this in action, run the program and click the
four direction buttons.
|
This diagram shows the linked structure of room
objects which comprise the map of our game. |
To find your way around,
refer to the diagram of the map above (you’ll also
find this at the top of the Adventure.cs unit).
This shows the six rooms in the map at present and uses
dashes and vertical bars to indicate which rooms are
connected. For example, the initial location, room0,
connects east to room1 and south to room2. If you click
the 'N' button, however, “No Exit!” is
displayed.
Look at the Click event-handler code for each of the
direction buttons in MainForm.cs. This could hardly be
simpler. Each event-handler simply calls the movePlayer() method, passing to it one of the dir class constants,
defined in AdvConsts.cs.
In the design of this game, I had to decide whether
the characters or ‘Actor’ objects, including
the player, are given the freedom to move themselves
from place to place like mice in a maze or whether they
are moved around by some ‘outside’ force
like the pieces in a chess game. I decided on the latter
option for the simple reason that each actor only has
a knowledge of its own location. This is due to the fact
that the Actor class contains a _room field which provides
a reference to the Room object which an Actor object
hypothetically ‘occupies’. The Adventure
object that contains the game, on the other hand, has
the entire map at its disposal. I felt it was simpler
that the Adventure object should be the only object which
can change the state of the game (say, by moving Actor
objects) rather than letting numerous Actor objects take
control of their own actions.
At any rate, it is the Adventure.movePlayerTo() method
that changes the player’s location. This it does
by calling a generic moveTo() method
which can be used to move any Actor object. This takes
an int parameter indicating the direction in which to
move. A switch statement tests this direction against
one of the values defined in the dir class.
If the value is not dir.NOEXIT then
the moveActorTo() method
is called to change the location (the CurrentRoom property)
of the Actor object to the Room found at the index specified
by Map.Item(exit).
So, for instance, if the S (for ‘South’)
property of room0 is 2 (this property is initialised
when the Room object is created in the Adventure() constructor),
then the value of exit is set to 2 when the game-player
attempts to move south from room0 in the Adventure.moveTo() method.
In the call to moveActorTo(a,
Map.Item(exit)),
the call to Map.Item(exit) returns
the Room at index 2 in the map object
(an instance of the RoomList class). As you will see
if you refer back to the Adventure() constructor,
this is the room2 object – that
is, the third Room which we added to the map.
Out and Proud
Let’s now take a look at another novel feature
of the C# language - its three varieties of parameter.
Most languages recognise two types of parameter which
can be passed to a function or subroutine: a parameter
that is passed ‘by value’ is a copy of the
original variable. Alterations made to the value of parameter
inside a function do not affect the value of the original
variable.
Alternatively, a parameter can be passed ‘by
reference’. A by-reference parameter is really
a pointer to the original variable. Alterations made
to the parameter within the function affect the original
variable too.
By default C# parameters are ‘by value’ copies
of the original variables. When you want changes which
are made within a method to affect the original variables,
you should use ‘out’ parameters. These are
parameters preceded by the keyword out both
in the method and the calling code. For example, in my
AdvThings.cs unit, the Room class returns the int values
of its four exits in this method:
public void getDirs( out int aN, out int aS, out
int aW, out int aE )
Now look at the calling code
in testBtn_Click() in
MainForm.cs. Note that this too uses
the out keyword.
If this keyword is omitted, the compiler flags an error:
adv.Player.CurrentRoom.getDirs(out _n, out
_s, out _w, out _e);
You can also use the ref keyword
instead of out when you wish to modify the values
of variables within a method. There is one fundamental
difference between ref and out parameters: whereas ref parameters must be initialised before use, out parameters
can be passed without being initialised.
If you want to make quick decisions,
use the C# Switch..Case statement
If you have never programmed in a C-like language,
you might not understand the block of code starting with
the keyword switch in
the MoveTo() method
of wombat.sln. This is, in essence,
a short-hand way of doing multiple if..else tests.
The argument between brackets is the selector value that
is tested by the case statements.
If the value specified by a case statement
matches that of the selector, the code if that statement
is executed. If no match is made, the default statement
executes. Unlike C++, it is not legal to have one case statement
trickle down to the next one. The compiler insists on
a break statement to break out of the switch block or a goto statement to jump to another case statement.
Load switchtest.sln and find button1_Click(). Run the
program and click button1. Try altering the value of
i to 6 and run it again to see how this causes the default
statement to jump to case –1. Incidentally, the
selector value doesn’t have to be an integer. It
could even be a string. See an example of this in button2_Click().
We’ll
be wrapping up this series with a few ideas on how to
complete the adventure game by adding some puzzles
August 2005
|