Designing a UI Screen for Games


Designing a UI Screen

Undoubtedly you've seen some type of UI before while using your computer. Windows has plenty of resources built into the GDI, which ships as part of the operating system to build rich graphical user interfaces (GUIs). However, these interfaces don't translate well into the full-screen DirectX applications. When running your game in full screen, you don't want an extra window (that looks nothing like the rest of your application) popping up. At best, it's not appealing; at worst, it can cause your main application to minimize or have other problems.
To present a consistent look and feel throughout an application, most game developers actually design a completely new UI to meld with the rest of the games they are developing. Because the goal of this book is for you to learn to be a 3D game developer, you should do what they do. For this game, you will design some overly simple UI screens, consisting only of buttons and a background image. Because the game will have multiple screens (the main screen to start a new game, a select character screen, and a quit confirmation screen), create a base class that encompasses the major functionality of your screens. To do so, add a new code file to your project named gui.cs. This code file will contain all your UI screens when the game is complete.
Because this first class you are writing in this file will be the base for all subsequent UI screens, you should make it abstract so you don't attempt to accidentally create an instance of it yourself. Add the code in Listing 5.6 to your new code file now.
Listing 5.6. The Abstract UI Class
public abstract class UiScreen : IDisposable
{
    // Constants
    public static readonly Vector3 ObjectCenter = new Vector3();
    public static readonly int SpriteColor = unchecked((int)0xffffffff);

    protected const int SmallButtonWidth = 128;
    protected const int SmallButtonHeight = 64;
    protected const int LargeButtonWidth = 256;
    protected const int LargeButtonHeight = 64;

    protected Sprite renderSprite = null;
    protected Texture backgroundTexture = null;
    protected Rectangle backgroundSource;

    // Screen size
    protected int screenWidth = 0;
    protected int screenHeight = 0;

    // Should the object be centered?
    private bool isCentered = false;
    protected Vector3 centerUpper;

    #region IDisposable Members
    /// 
    /// Clean up in case dispose isn't called
    /// 
    ~UiScreen()
    {
        Dispose();
    }
    /// 
    /// Clean up any resources
    /// 
    public virtual void Dispose()
    {
        GC.SuppressFinalize(this);
        // Dispose the sprite
        if (renderSprite != null)
        {
            renderSprite.Dispose();
        }
        renderSprite = null;
    }

    #endregion
}

You will need to add more to this class soon, but this section is the initial code that can actually be compiled successfully. You'll notice that first you need to declare two constants. They will be used during the rendering of your UI. The first one is the rotation center of your texture, but because you do not need to rotate it, an empty vector will suffice. The next parameter is the color you want to render the texture toa slightly misleading description because it's not the only factor determining color.
Construction Cue

The value specified in the color constant is equivalent to the solid color white; however, this doesn't mean that when you render your texture it will appear entirely in white.
The integer value of this constant has four separate color components, each a single byte, ranging from 0x0 to 0xff. The components are alpha, red, blue, and green, and in this constant, you are specifying "full power" to each. When the texture is rendered, the colors are not affected at all because you are specifying the full power for each. Say, however, that you declare the constant as 0xff00ffff instead. In this case, there is no red component, so when you render your textures, they have no red in them either. You can use this constant to add varying effects, but for this game, you want the images rendering as they are, with no changes.

The last of the constants are the sizes (both width and height) of the buttons you need for your UI. The two different sizes of buttons, large and small, are represented via these constants. The screens you create will have buttons and will use these constants to determine where (and how) to place them onscreen.
The first three nonconstant variable declarations deal directly with the items you will be rendering for the UI screens. The first is a Sprite object, which is a built-in object to Managed DirectX that greatly simplifies the process of rendering 2D images (sprites) in a 3D scene. This object is generic enough that it can render many different sprites, from many different textures in a single scene. Therefore, you need to include a texture that will be the background of your UI screen. If you do not want one, it can obviously be null. Finally, you might want more than one texture inside the same texture file. The rectangle variable stored here allows you to specify the location of the texture inside the main texture file. For UI screens, it will almost certainly be the same size as the main texture itself. The object is listed as protected, however, so any of the deriving classes can change it if they need to.
You also need to know the screen width and height for your UI screens. This information is mainly so you can calculate the position of various items on the screen (such as buttons) and have them appear in a similar location regardless of the resolution.
The last two variables determine whether the background of the UI screen should be centered onscreen or "stretched" to encompass the entire screen. You only need to know whether the centering should happen (the Boolean variable) and, if so, where the upper-left corner of the texture should go onscreen. This value is calculated during the object's construction, which you get to in just a moment.
Finally, this object needs a way to release the objects that it has created. Notice that the object implements the IDisposable interface, which is a convenient way to mark an object has resources it needs to clean up at a deterministic time. The only object that you actually create for this object is the Sprite class, so that is the only object you need to clean up when you call the Dispose method. You also notice that a SuppressFinalize method is called when you clean up the object. In C#, the Finalize method is declared with the destructor syntax from C/C++, as you saw in Listing 5.6 earlier. The only thing the destructor does is call the Dispose method. When an object goes out of scope and it is ready to be collected by the garbage collector, it first detects whether that object needs to be "finalized." If it does, it places the object on a separate queue, and the object survives the collection. Calling the SuppressFinalize method eliminates this scenario and is more efficient. A good rule of thumb is that if you implement IDisposable, you must always implement a destructor that calls your Dispose method and always call SuppressFinalize in your Dispose method.
Now then, how should you actually create an instance of this class? Well, because it is marked as abstract, you won't be able to do so directly; however, you should still add a constructor because your derived classes will call into the constructor of its base class. Add the constructor (and supporting method) in Listing 5.7 to your class.
Listing 5.7. Creating the UI Screen Class
/// 
/// Create a new UI Screen
/// 
public UiScreen(Device device, int width, int height)
{
    // Create the sprite object
    renderSprite = new Sprite(device);

    // Hook the device events to 'fix' the sprite if needed
    device.DeviceLost += new EventHandler(OnDeviceLost);
    device.DeviceReset += new EventHandler(OnDeviceReset);

    StoreTexture(null, width, height, false);
}
/// 
/// Store the texture for the background
/// 
protected void StoreTexture(Texture background, int width, int height,
    bool centerTexture)
{
    // Store the background texture
    backgroundTexture = background;

    if (backgroundTexture != null)
    {
        // Get the background texture
        using (Surface s = backgroundTexture.GetSurfaceLevel(0))
        {
            SurfaceDescription desc = s.Description;
            backgroundSource = new Rectangle(0, 0,
                desc.Width, desc.Height);
        }
    }

    // Store the width/height
    screenWidth = width;
    screenHeight = height;

    // Store the centered texture
    isCentered = centerTexture;

    if (isCentered)
    {
        centerUpper = new Vector3((float)(width - backgroundSource.Width) / 2.0f,
            (float)(height - backgroundSource.Height) / 2.0f, 0.0f);
    }
}

The constructor for the object takes the device you are rendering with as well as the size of the screen. The rendering device is needed to create the sprite object and to hook some of the events. Before the device is lost, and after the device has been reset, you need to call certain methods on the sprite object so the sprite behaves correctly. You can add these event-handling methods to your class now:
private void OnDeviceLost(object sender, EventArgs e)
{
    // The device has been lost, make sure the sprite cleans itself up
    if (renderSprite != null)
        renderSprite.OnLostDevice();
}
private void OnDeviceReset(object sender, EventArgs e)
{
    // The device has been reset, make sure the sprite fixes itself up
    if (renderSprite != null)
        renderSprite.OnResetDevice();
}

These methods are entirely self-explanatory. When the device is lost, you call the OnLostDevice method on the sprite. After the device is reset, you call the OnResetDevice method. If you were allowing Managed DirectX to handle the event handling for you, this would happen automatically. Because you are not, you need to ensure that it happens. Without this code, when you try to switch from full-screen mode or return back to full-screen mode after switching, an exception is thrown because the sprite object is not cleaned up correctly.
The call to StoreTexture in the constructor isn't actually necessary because each derived class needs to call this method on its ownbut I use it so you can visualize the process. Obviously, the first thing you want to do in this method is store the texture. Then (assuming you really do have a texture), you want to calculate the full size of the texture. The default for the background textures of a UI screen is to use the entire texture. Notice here that you get the actual surface the texture is occupying and then use the width and height from the surface description to create a new rectangle. Next, you simply store the remaining variables and calculate the upper-left corner of the screen if you will be centering the background texture. You calculate the center by taking the full size of the screen, subtracting the size of the texture, and dividing that in half.
The last thing you do to finish your UI class is to have a method actually render the screen. Add the code in Listing 5.8 to your class to handle the rendering.
Listing 5.8. Rendering the UI Screen
/// 
/// Start drawing with this sprite
/// 
protected void BeginSprite()
{
    renderSprite.Begin(SpriteFlags.AlphaBlend);
}
/// 
/// Stop drawing with this sprite
/// 
protected void EndSprite()
{
    renderSprite.End();
}
/// 
/// Render the button in the correct state
/// 
public virtual void Draw()
{
    // Render the background if it exists
    if (backgroundTexture != null)
    {
        if (isCentered)
        {
            // Render to the screen centered
            renderSprite.Draw(backgroundTexture, backgroundSource,
                ObjectCenter, centerUpper, SpriteColor);
        }
        else
        {
            // Scale the background to the right size
            renderSprite.Transform = Matrix.Scaling(
                (float)screenWidth / (float)backgroundSource.Width,
                (float)screenHeight / (float)backgroundSource.Height,
                0);

            // Render it to the screen
            renderSprite.Draw(backgroundTexture, backgroundSource,
                ObjectCenter, ObjectCenter, SpriteColor);
            // Reset the transform
            renderSprite.Transform = Matrix.Identity;
        }
    }
}

The first thing you'll probably notice here is that the BeginSprite and EndSprite methods are separate and not part of the main Draw call. I did this intentionally because the sprite object is shared with the derived UI screens, as well as the buttons. Rather than let each separate object have its own sprite class and do its own begin and end calls (which isn't overly efficient), each UI screen will share the same sprite and do all the drawing between a single begin/end block. To facilitate this step, you need these protected methods so the derived classes can control the beginning and ending of the sprite drawing. The begin call ensures that the sprites will be rendered with alpha blendingwhich means that the backgrounds of your UI and the buttons will be rendered with transparency. You'll see this effect in action in later chapters.
The Draw call itself isn't overly complicated either. Assuming you have a texture to render, you simply need to draw the sprite onscreen, either centered or not. If the sprite should be centered, it's one simple call to the Draw method on the sprite. The prototype for this method is as follows:
public void Draw ( Microsoft.DirectX.Direct3D.Texture srcTexture ,
    System.Drawing.Rectangle srcRectangle ,
    Microsoft.DirectX.Vector3 center ,
    Microsoft.DirectX.Vector3 position ,
    System.Drawing.Color color )

The first parameter is the texture you want to render, in this case the background of the screen. The second parameter is the location of the data inside that texture. As you can see, you are passing in the rectangle that was calculated in the StoreTexture method, and it encompasses the entire texture. If there were multiple items per texture (as you will see with the buttons soon), this rectangle would only cover the data needed for that texture. The third parameter is the "center" of the sprite, and it is only used for calculating rotation, which is why it uses the constant you declared earlier. You won't be rotating your sprites. The position vector is where you want this sprite to be rendered onto the screen, in screen coordinates, and the last parameter is the "color" of the sprite. (I already discussed this parameter when you declared the constants earlier in this chapter.)
For the centered case, you simply use the "default" parameters for the Draw call, and for the position, you use the vector you calculated earlier. The Draw call for the stretched case is virtually identical; the exception is that the position vector you are using is the same constant you used for the center vector. That vector resides at 0,0,0, which is where you want the upper-left corner of the sprite to be rendered for your stretched image. Why create a whole new instance of the same data when you can simply reuse the existing one?
The stretched case has an extra call before the Draw call, howevernamely, setting the transform that should be used when rendering this sprite. Because you want to ensure that the sprite is "stretched" across the entire screen, you want to scale the image. Notice that the calculation takes the size of the texture you will be rendering and divides it by the actual size of the screen to determine the correct scaling factor. Each of these items is first cast to a float before the calculations are performed. The question here is "Do you know why?"
Caution


You do so because the items are originally integers, which can produce strange results. For example, in C#, what do you think the following statement will display?
Console.WriteLine(2/3);

If you didn't guess 0, well, this caution is for you because you would be wrong. Both operands are integers, so the runtime will take 2/3 (0.3333) and then cast it back to an integer (0). To preserve the fraction, you need to ensure that both of the operands are floats, which is why you do the cast.

Also notice that after the draw is done, the transform is set once more back to the default Identity. Because the sprite object is shared, any subsequent calls to Draw would have the same scaling effect without it, which isn't the behavior you desire.



Designing a Button

You can keep the code for your button in the same gui.cs code file you've been using up to this point (which is what the code on the included CD does), or you can put it in its own code file, which you would need to add to your project. Either way, add the class in Listing 5.9 to your code file.
Listing 5.9. The UiButton Class
/// 
/// Will hold a 'button' that will be rendered via DX
/// 
public class UiButton
{
    private Sprite renderSprite = null;
    private Texture buttonTextureOff = null;
    private Texture buttonTextureOn = null;
    private Rectangle onSource;
    private Rectangle offSource;
    private Vector3 location;
    private bool isButtonOn = false;
    private Rectangle buttonRect;

    // The click event
    public event EventHandler Click;

    /// 
    /// Create a new instance of the Button class using an existing sprite
    /// 
    public UiButton(Sprite sprite, Texture on, Texture off,
        Rectangle rectOn, Rectangle rectOff, Point buttonLocation)
    {
        // Store the sprite object
        renderSprite = sprite;

        // Store the textures
        buttonTextureOff = off;
        buttonTextureOn = on;

        // Rectangles
        onSource = rectOn;
        offSource = rectOff;

        // Location
        location = new Vector3(buttonLocation.X, buttonLocation.Y, 0);

        // Create a rectangle based on the location and size
        buttonRect = new Rectangle((int)location.X, (int)location.Y,
            onSource.Width, onSource.Height);

    }

    /// 
    /// Render the button in the correct state
    /// 
    public void Draw()
    {
        if (isButtonOn)
        {
            renderSprite.Draw(buttonTextureOn, onSource, UiScreen.ObjectCenter,
                location, UiScreen.SpriteColor);
        }
        else
        {
            renderSprite.Draw(buttonTextureOff, offSource, UiScreen.ObjectCenter,
                location, UiScreen.SpriteColor);
        }
    }
}

You'll notice initially that this class is somewhat similar to the UI abstract class you just wrote. There are some important differences, though. First, you should see that there are two textures to store. One is used to render the button in the "off" state, and the other is used to render the button in the "on" state. It's entirely possible (and in this case, probable) that these textures will be the same file, simply with different source rectangles.
The constructor takes as arguments the sprite used to render the button, the textures for both the on and off states of the button, the rectangle sources of each of these states, and the location onscreen where the button will be rendered. Each of these items is stored for later use in the related class variable. The class itself has three other variables it will need, however. One, it needs to know what state the button is in. Because the button will either be on or off, a Boolean value is the natural selection here, with a default of off. Two, you also need to know the exact rectangle onscreen that the button encompasses. At the end of the constructor, you calculate this rectangle by taking the location onscreen and adding the size of the source rectangle. You'll find out why you need this work in a few moments. Third (and finally), because it is a button, you want an event to be fired when someone clicks the button.
The Draw method is simple. Depending on whether or not the button is on, the appropriate sprite is rendered at the correct location. The only real differences between the two calls are the texture that is passed in and the source rectangle. The last thing you need is a way to actually click the button and have its state change based on the location of the mouse. Add the code in Listing 5.10 to your UiButton class.
Listing 5.10. Handling the Mouse
/// 
/// Update the button if the mouse is over it
/// 
public void OnMouseMove(int x, int y)
{
    // Determine if the button is on or not
    isButtonOn = buttonRect.Contains(x, y);
}
/// 
/// See if the user clicked the button
/// 
public void OnMouseClick(int x, int y)
{
    // Determine if the button is pressed
    if(buttonRect.Contains(x, y))
    {
        if (Click != null)
            Click(this, EventArgs.Empty);
    }
}

These methods should be called as the mouse moves around the screen and when the button is clicked. As the mouse moves around, the button state changes depending on whether the current mouse coordinates are within the rectangle onscreen where the button is rendered. This is the reason that you needed to calculate the exact screen rectangle where the button would be rendered. If the mouse button is clicked, you once again check whether the mouse is in the button's rectangle, and if it is, you fire the Click event so that whatever created this button will know about it.
The UiButton class was small and simple. Combining the UI screen classes with the buttons, you can create simple UIs for your game.

Comments

Popular Posts