Custom drawn 2D controls: player HUD

Player HUD we are going to create
fps_game demo player HUD showing inventory
"The Castle" inventory

1. Introduction

While you can compose user interface from existing UI controls, sometimes it's more flexible to create a control that renders what you want using lower-level utilities for 2D drawing.

An example that we will consider in this chapter is designing a HUD (heads-up display) that displays some player information, like current life and inventory. In the chapter about "Player" we shown how to easily use the standard TPlayer class to automatically store and update your player's life and inventory (see TPlayer and ancestors properties, like T3DAliveWithInventory.Inventory and T3DAlive.Life). But this information is not displayed automatically in any way, since various games have wildly different needs. Let's draw the information about player ourselves.

There are two places where you can draw:

  1. You can just place the appropriate drawing code in OnRender event (see TCastleWindowCustom.OnRender, TCastleControlCustom.OnRender). This is simple to use, and works OK for simple applications.

  2. In the long-term, it's usually better to create your own TUIControl descendant. This way you wrap the rendering (and possibly other processing) inside your own class. You can draw anything you want in the overridden TUIControl.Render method.

2. Initial code

Here's a simple start of a 2D control class definition and usage. It shows the player health by simply writing it out as text.

uses SysUtils, CastleColors, CastleVectors, CastleWindow, CastleUIControls,
  CastleControls, CastlePlayer, CastleRectangles, CastleGLUtils;
 
var
  Window: TCastleWindow;
  Player: TPlayer;
 
type
  TMyPlayerHUD = class(TUIControl)
  public
    procedure Render; override;
  end;
 
procedure TMyPlayerHUD.Render;
begin
  inherited;
  UIFont.Print(20, 20, Yellow,
    Format('Player life: %f / %f', [Player.Life, Player.MaxLife]));
end;
 
var
  PlayerHUD: TMyPlayerHUD;
begin
  Window := TCastleWindow.Create(Application);
 
  Player := TPlayer.Create(Window.SceneManager);
  Player.Life := 75; // just to make things interesting
  Window.SceneManager.Items.Add(Player);
  Window.SceneManager.Player := Player;
 
  { When starting your game, create TMyPlayerHUD instance
    and add it to Window.Controls }
  PlayerHUD := TMyPlayerHUD.Create(Application);
  Window.Controls.InsertFront(PlayerHUD);
 
  Window.OpenAndRun;
end.

3. Drawing stuff

Inside TMyPlayerHUD.Render you can draw using our 2D drawing API.

3.1. Text

To draw a text, you can use ready global font UIFont (in CastleControls unit). This is an instance of TCastleFont. For example, you can show player's health like this:

UIFont.Print(10, 10, Yellow,
  Format('Player life: %f / %f', [Player.Life, Player.MaxLife]));

You can also create your own instances of TCastleFont to have more fonts. See the manual chapter about "Text and fonts" for more.

Note: Drawing a text this way means that you manually do something similar to the TCastleLabel control.

3.2. Rectangles, circles, other shapes

To draw a rectangle use the DrawRectangle method. Blending is automatically used if you pass color with alpha < 1.

For example, we can show a nice health bar showing the player's life:

procedure TMyPlayerHUD.Render;
var
  R: TRectangle;
begin
  inherited;
 
  R := Rectangle(10, 10, 400, 50);
  { draw background of health bar with a transparent red }
  DrawRectangle(R, Vector4Single(1, 0, 0, 0.5));
  { calculate smaller R, to only include current life }
  R := R.Grow(-3);
  R.Width := Round(R.Width * Player.Life / Player.MaxLife);
  { draw the inside of health bar with an opaque red }
  DrawRectangle(R, Vector4Single(1, 0, 0, 1));
 
  UIFont.Print(20, 20, Yellow,
    Format('Player life: %f / %f', [Player.Life, Player.MaxLife]));
end;

Note: Drawing a rectangle this way means that you manually do something similar to the TCastleRectangleControl control.

To draw a circle use the DrawCircle. There are also procedures to draw only an outline: DrawRectangleOutline DrawCircleOutline.

Note: Drawing shapes this way means that you manually do something similar to the TCastleShape control.

To draw an arbitrary 2D primitive use the DrawRectangle method. Blending is automatically used if you pass color with alpha < 1.

3.3. Images

To draw an image, use the TGLImage class. It has methods Draw and Draw3x3 to draw the image, intelligently stretching it, optionally preserving unstretched corners.

Here's a simple example of TGLImage usage to display a hero's face. You can use an image below, if you're old enough to recognize it:) (Source.)

DOOM hero face

uses ..., Classes, CastleFilesUtils, CastleGLImages;
 
type
  TMyPlayerHUD = class(TUIControl)
  private
    FMyImage: TGLImage;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;
    procedure Render; override;
  end;
 
constructor TMyPlayerHUD.Create(AOwner: TComponent);
begin
  inherited;
  FMyImage := TGLImage.Create(ApplicationData('face.png'));
end;
 
destructor TMyPlayerHUD.Destroy;
begin
  FreeAndNil(FMyImage);
  inherited;
end;
 
procedure TMyPlayerHUD.Render;
begin
  inherited;
 
  // ... previous TMyPlayerHUD.Render contents ...
 
  FMyImage.Draw(420, 10);
end;

Note: Drawing images this way means that you manually do something similar to the TCastleImageControl control.

3.4. Complete example code showing above features

Here's a complete source code that shows the above features. You can download and compile it right now!

uses SysUtils, CastleColors, CastleVectors, CastleWindow, CastleUIControls,
  CastleControls, CastlePlayer, CastleRectangles, CastleGLUtils,
  Classes, CastleFilesUtils, CastleGLImages;
 
var
  Window: TCastleWindow;
  Player: TPlayer;
 
type
  TMyPlayerHUD = class(TUIControl)
  private
    FMyImage: TGLImage;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;
    procedure Render; override;
  end;
 
constructor TMyPlayerHUD.Create(AOwner: TComponent);
begin
  inherited;
  FMyImage := TGLImage.Create(ApplicationData('face.png'));
end;
 
destructor TMyPlayerHUD.Destroy;
begin
  FreeAndNil(FMyImage);
  inherited;
end;
 
procedure TMyPlayerHUD.Render;
var
  R: TRectangle;
begin
  inherited;
 
  R := Rectangle(10, 10, 400, 50);
  { draw background of health bar with a transparent red }
  DrawRectangle(R, Vector4Single(1, 0, 0, 0.5));
  { calculate smaller R, to only include current life }
  R := R.Grow(-3);
  R.Width := Round(R.Width * Player.Life / Player.MaxLife);
  { draw the inside of health bar with an opaque red }
  DrawRectangle(R, Vector4Single(1, 0, 0, 1));
 
  UIFont.Print(20, 20, Yellow,
    Format('Player life: %f / %f', [Player.Life, Player.MaxLife]));
 
  FMyImage.Draw(420, 10);
end;
 
var
  PlayerHUD: TMyPlayerHUD;
begin
  Window := TCastleWindow.Create(Application);
 
  Player := TPlayer.Create(Window.SceneManager);
  Player.Life := 75; // just to make things interesting
  Window.SceneManager.Items.Add(Player);
  Window.SceneManager.Player := Player;
 
  { When starting your game, create TMyPlayerHUD instance
    and add it to Window.Controls }
  PlayerHUD := TMyPlayerHUD.Create(Application);
  Window.Controls.InsertFront(PlayerHUD);
 
  Window.OpenAndRun;
end.

3.5. Animations from images (movies, sprite sheets)

If you would like to display a series of images, not a static image, you can use TGLVideo2D (show image sequence from many separate images or a video) or TSprite (show image sequence from a sprite sheet — one large image containing many animation frames).

See e.g. our game "Muuu" for a demo of using sprite animations.

3.6. Player inventory

The TPlayer class manages the player inventory. Each inventory item may already have a default image associated with it. It is defined in the resource.xml file of the item, see the chapter about using creatures / items and see the chapter about defining creatures / items resource.xml files and see the examples/fps_game/data/item_medkit/ for an example item definition.

The image is available as a TGLImage instance ready for drawing. For example, you can iterate over the inventory list and show the items like this:

for I := 0 to Player.Inventory.Count - 1 do
  Player.Inventory[I].Resource.GLImage.Draw(I * 100, 0);

See the examples/fps_game/ for a working example of this.

3.7. Screen fade effects

For simple screen fade effects, you have procedures inside the CastleGLUtils unit called GLFadeRectangleDark and GLFadeRectangleLight. These allow you to draw a rectangle representing fade out (when player is in pain). And TPlayer instance already has properties Player.FadeOutColor, Player.FadeOutIntensity representing when player is in pain (and the pain color). Player.Dead says when player is dead (this is simply when Life <= 0).

For example you can visualize pain and dead states like this:

if Player.Dead then
  GLFadeRectangleDark(ContainerRect, Red, 1.0) else
  GLFadeRectangleDark(ContainerRect, Player.FadeOutColor, Player.FadeOutIntensity);

Note that Player.FadeOutIntensity will be 0 when there is no pain, which cooperates nicely with GLFadeRectangleDark definition that will do nothing when 4th parameter is 0. That is why we carelessly always call GLFadeRectangleDark — when player is not dead, and is not in pain (Player.FadeOutIntensity = 0) then nothing will actually happen.

Note: There is also a full-featured UI control that draws an effect with blending (possibly modulated by an image): TCastleFlashEffect.

4. Coordinates and window (container) sizes

To adjust your code to window size, note that our projection has (0,0) in lower-left corner (as is standard for 2D OpenGL). You can look at the size, in pixels, of the current OpenGL container (window, control) in ContainerWidth x ContainerHeight or (as a rectangle) as ContainerRect. The container size is also available as container properties, like TCastleWindow.Width x TCastleWindow.Height or (as a rectangle) TCastleWindow.Rect.

5. Take into account UI scaling and anchors

So far, we have simply carelessly drawn our contents over the window.

  • We used absolute pixel positions to draw.
  • We did not use the control position (Left and Bottom). Nor did we take into account parent control position.
  • We did not use the control size. In fact, our control has always empty size. The TUIControl.Rect is by default empty. We should override it, or descend from something like TUIControlSizeable.
  • We do not honor the anchors set by TUIControl.Anchor.
  • We do not honor UI scaling set by the Window.UIScaling.

Note that it is OK to ignore (some) of these issues, if you design a UI control specifically for your game, and you know that it's only going to be used in a specific way.

To have more full-featured UI control, we could solve these issues "one by one", but as you can see there are quite a few features that are missing. The easiest way to handle all the features listed above is to get inside the Render method the values of ScreenRect and UIScale. Just scale your drawn contents to always fit within the ScreenRect rectangle. And scale all user size properties by UIScale before applying to pixels.

You have to also define a size for your control, by overriding the Rect method of TUIControl. (Alternatively, if you want the size to be configurable by user, derive your control from the TUIControlSizeable.)

Like this:

function TMyImageControl.Rect: TRectangle;
begin
  Result := Rectangle(Left, Bottom, FMyImage.Width, FMyImage.Height);
  Result := Result.ScaleAround0(UIScale);
end;
 
procedure TMyImageControl.Render;
begin
  inherited;
  FMyImage.Draw(ScreenRect);
end;

6. Examples

See examples/fps_game for a working and fully-documented demo of such TMyPlayerHUD implementation. See "The Castle" sources (unit GamePlay) for an example implementation that shows more impressive player's life indicator and inventory and other things on the screen.