Design Practices You Shouldn t Live Without


Design Practices You Shouldn't Live Without

Isaac Asimov's Foundation series invented an interesting discipline called psycho-history, a social science that could predict societal trends and macro events with great certainty. Each historian in the story was required to contribute new formulas and extend the science. As a programmer, your job is similar. Every new module or class that you create gives you the opportunity to extend the abilities and usefulness of the code base. But to do this effectively, you must learn how to think ahead and design code with the goal of keeping it in use for many projects and many years.

Designing good code in an object-oriented language is actually much more difficult than in a procedural language like C or PASCAL. Why? The power and flexibility of the object-oriented language allows you to create extremely complicated systems that look quite simple. This is both good and bad. In other words, it's easy to get yourself into trouble without realizing it.

If you design your software properly your work can be extremely efficient. With a few keystrokes you can create interesting adaptations of existing systems. There's nothing like having such command and control over a body of code. It makes you more artist than programmer.

A different programmer might view your masterpiece entirely differently, however. For example, intricate relationships inside a class hierarchy could be difficult or impossible to understand without your personal guidance. Documentation, usually written in haste, is almost always inadequate or even misleading.

To help you avoid some of the common design practice pitfalls I'm going to spend some time in this chapter up-front discussing how you can:

  • Avoid hidden code that performs nontrivial operations

  • Keep your class hierarchies clear and simple

  • Be aware of the difference between inheritance and containment

  • Avoid abusing virtual functions

  • Use interface classes and factories

  • Separate user interface code from game logic

  • Use stream constructors

Avoiding Hidden Code and Nontrivial Operations

Copy constructors, operator overloads, and destructors are all party to the "nasty" hidden code problem which plague game developers. This kind of code is terrible unless it is completely obvious. You should avoid copy constructors and operator overloads that perform non-trivial operations. If something looks simple, it should be simple and not something deceptive. For example, most programmers would assume that if they encountered some code that contained a simple equals sign or multiplication symbol that the it would not invoke some kind of near-infinite Taylor series. They would assume that the code under the hood would be as straightforward as it looked—a basic calculation between two floats or at worst two doubles.

Game programmers love playing with neat technology, and sometimes their sense of elegance drives them to push non-trivial algorithms and calculations into C++ constructs such as copy constructors or overloaded operators. They like it because the high level code performs complicated actions in a few lines of code, and on the surface it seems like the right design choice. It isn't.

Any operation with some meat to it should be called explicitly. This might annoy your sense of cleanliness if you are the kind of programmer that likes to use C++ constructs at each and every opportunity. Get over it. Of course there are exceptions. One is when every operation on a particular class is comparatively expensive, such as a 4x4 matrix class. Overloaded operators are perfectly fine for classes like this because the clarity of the resulting code is especially important and useful.

A recurring theme I'll present throughout this book is that you should avoid surprises. Programmers don't like surprises because most of them are incredibly bad surprises. Don't add to the problem by tucking some crazy piece of code away in a destructor or similar mechanism.

Class Hierarchies: Keep Them Simple

One of the biggest mistakes game programmers make is that they either over-design or under-design their classes and class hierarchies. Getting your class structure just right takes some real practice. Unfortunately, most of my experience came the hard way through trial and error. But you can learn from some of my mistakes and unique techniques that I've picked up along the way.

A Tale from the Pixel Mines

My first project at Origin developed with C++ was Ultima VII. This project turned out to be a poster child for insane C++. I was so impressed by the power of constructors, virtual functions, inheritance, and everything else that once I got the basics down I went nuts and made sure to use at least three C++ constructs on every line of code. What a horrible mistake! Some Ultima VII classes were seven or eight levels of inheritance deep. Some classes added only one data member to the parent—our impotent attempt at extending base classes.

We created so many classes in Ultima VII that we ran out of good names to use. The compiler was so taxed by the end of the project that we couldn't add any more variables to the namespace. We used global variables to store more than one piece of data by encoding it in the high and low words rather than creating two new variables. By the end of the project I was terrified of adding any new code, because the compiler would fail to build the project having hit some crazy limitation.

On the opposite end of the spectrum a common problem found in C++ programs is the "kitchen sink" class—the one that has everything (including the kitchen sink). Ultima VII actually had a C++ file named ksink.cpp, and sure enough it had a little bit of everything. I'll admit to creating such a class on one of the Microsoft Casino projects that I worked on that would have made intelligent programmers sick to their stomachs. My class was supposed to encapsulate the data and methods of a screen, but it ended up looking a little like MFC's CWnd class. It was huge, unwieldy, and simply threw everything into one gigantic bucket of semi colons and braces.

Don't make the same mistake. Good class architecture is not like a Swiss Army Knife; it should be more like a well balanced throwing knife.

Inheritance vs. Containment

Game programmers love to debate the topics of inheritance and containment. Inheritance is used when an object is evolved from another object, or when a child object is a version of the parent object. Containment is used when an object is composed of multiple discrete components, or when an aggregate object has a version of the contained object.

A good example of this relationship is found in user interface code. A screen class might have the methods and data to contain multiple controls such as buttons or check boxes. The classes that implement buttons and check boxes probably inherit from a base control class.

When you make a choice about inheritance or containment your goal is to communicate the right message to other programmers. The resulting assembly code is almost exactly the same, barring the oddities of virtual function tables. This means the CPU doesn't give a damn if you inherit or contain. Your fellow programmers will care, so be careful and clear.

Virtual Functions Gone Bad

Virtual functions are powerful creatures that are abused by most programmers. I've found that programmers create virtual functions when they don't need them or they create long chains of overloaded virtual functions that make it difficult to maintain base classes.

Take a look at MFC's class hierarchy. Most of the classes in the hierarchy contain virtual functions which are overloaded by inherited classes, or by new classes created by application programmers. Imagine for a moment the massive effort involved if some assumptions at the top of the hierarchy were changed. This isn't a problem for MFC because it's a stable code base, but your game code isn't a stable code base. Not yet.

An insidious bug is often one that is created innocently by a programmer mucking around in a base class. A seemingly benign change to a virtual function can have unexpected results. Some programmers might count on the oddities of the behavior of the base class that, if they were fixed, will actually break any child classes. Maybe one of these days someone will write an IDE that graphically shows the code that will be affected by any change to a virtual function. Without this aid, any programmer changing a base class must learn (the hard way) for themselves what hell they are about to unleash.

Best Practice

If you ever change the nature of anything that is currently in wide use, virtual functions included, I suggest you actually change its name. The compiler will find each and every use of the code and you'll be forced to look at how the original was put to use. It's up to you if you want to keep the new name. I suggest you do, even if it means changing every source file.

From one point of view, a programmer overloads a virtual function because the child class has more processing to accomplish in the same "chain of thought." This concept is incredibly useful and I've used it for nearly ten years. It's funny that I never thought how wrong it can be.

An overloaded virtual function changes the behavior of an object, and gains control over whether to invoke the original behavior. If the new object doesn't invoke the original function at all, the object is essentially different from the original. What makes this problem even worse it that everything about the object screams to programmers that it is just an extension of the original. If you have a different object, make a different object. Consider containing the original class instead of inheriting from it. It's much clearer in the code when you explicitly refer to a method attached to a contained object rather than calling a virtual function.

What happens to code reuse? Yes, have some. I hate duplicating code; I'm a lazy typist and I'm very unlucky when it comes to cutting and pasting code. It also offends me.

Try to look at classes and their relationships like appliances and electrical cords. Always seek to minimize the length of the extension cords, minimize the appliances that plug into one another, and don't make a nasty tangle that you have to figure out every time you want to turn something on.

Use Interface Classes

Interface classes are those that contain nothing but pure virtual functions. Here's an example:

 class IAnimation { public:    virtual void VAdvance(const int deltaMilliseconds) = 0;    virtual bool const VAtEnd() const = 0;    virtual int const VGetPosition() const = 0; }; 

This sample interface class defines simple behavior common for a timed animation. We could add other methods such as one to tell how long the animation will run or whether the animation loops; that's purely up to you. The point is that any system that contains a list of objects inheriting and implementing the IAnimation interface can animate them with a few lines of code:

 for(AnimationList::iterator itr = animList.begin(); itr != animList.end();  ++itr) {    (*itr).VAdvance( delta ); } 

Interface classes are a great way to enforce design standards. A programmer writing engine code can create systems that expect a certain interface. Any programmer creating objects that inherit from and implement the interface can be confidant that object will work with the engine code.

Consider Using Factories

Games tend to build screens and other complex objects constructing groups of objects, such as controls or sprites, and storing them in lists or other collections. A common way to do this is to have the constructor of one object, say a certain implementation of a screen class, "new up" all the sprites and controls. In many cases, many types of screens are used in a game, all having different objects inheriting from the same parents.

In the book, Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma et. al., one of the object creation patterns is called a factory. An abstract factory can define the interface for creating objects. Different implementations of the abstract factory carry out the concrete tasks of constructing objects.

Imagine an abstract factory that builds screens. The fictional game engine in this example could define screens as components that have screen elements, a background, and a logic class that accepts control messages. Here's an example:

 class SaveGameScreenFactory : public IScreenFactory { public:    SaveGameScreenFactory();    virtual IScreenElements * const BuildScreenElements() const;    virtual ScreenBackgroundSprite * const BuildScreenBackgroundSprite() const;    virtual IScreenLogic * const BuildScreenLogic() const; }; 

The code that builds screens will call the methods of the IScreenFactory interface, each one returning discrete portions of the screen. As all interface classes tend to enforce design standards, factories tend to enforce orderly construction of complicated objects. Factories are great for screens, animations, AI, or any nontrivial game object.

Separate Your User Interface from the Game Logic

Those programmers "lucky" enough to program in MFC noticed the Document/View architecture. The document is a model for data; the view is a model for the user interface. The view class doesn't have direct access to the raw bits of data in the document. Instead, the view class makes requests to change the data in the document by calling the methods in the document class's definition. This approach of separating "church and state" is actually an excellent design paradigm.

Before you slam this book closed and see if it makes pretty colors in your microwave, let me give you a good reason why you should consider using an approach like this. Let's assume you correctly separate your user interface from your game logic. You can then easily define proxy classes that can send your requests to modify data that actually exists on a server instead of the local machine. You can define AI classes that conform to the user interface API and you'll notice that the AI classes can't "cheat" because they only see information they are supposed to see. Even better, you get the ability to freely swap AI players and human players in your game.

An even greater benefit of this approach is that it can separate your very portable game logic from not so portable user interface and display code. If you ever had to write a game to work on a desktop PC and a handheld PDA you'll appreciate the separation.

Implement Stream Constructors

Any persistent object in your game should implement an overloaded constructor that takes a stream object as a parameter. If the game is loaded from a file, objects can use the stream as a source of parameters. Here's an example to consider:

 class AnimationPath { public:    //Build a path from a set of path points...    AnimationPath(std::vector<AnimationPathPoint> const & srcPath);    //... or construct it from a stream.    AnimationPath(InputStream & stream);    //Of course, lots more code follows. }; 

The first constructor accepts a vector of AnimationPathPoint objects, and the second constructor accepts a stream, which probably includes data that can be used to create the same path objects in the first constructor. Both constructors accomplish the same task, the construction of an AnimationPath. The stream constructor can be used to create the object from a save game file, or even a network communications stream.

Best Practice

Test your stream constructors by loading and saving your game automatically in the DEBUG build at regular intervals. It will have the added side effect of making sure programmers keep the load/save code pretty fast.




Game Coding Complete
Game Coding Complete
ISBN: 1932111751
EAN: 2147483647
Year: 2003
Pages: 139

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net