Delphi Sprite Engine – Part 4 – Enhancing the Sprite Sheet

pacman

[If you’re new to this series, you may wish to skip ahead to part-7, where I’ve done a partial ‘start-over’]

At the end of part 3, I said that in part 4 we’d create a scrolling backdrop for our sprite scene. I have, however, decided on a slight change of plan. We’ll still get a scrolling backdrop in a later part, but for this part I wanted to make a few changes and enhancements to the work we’ve already done.

In terms of writing a usable sprite engine, we’ve moved quite slowly so far, having only completed animation at the end of part 3. However, in terms of our goals we’ve moved quite quickly, getting to 50% already.  The design decisions that I’ve made along the way have been entirely focused on achieving the immediate goals, and haven’t necessarily been well considered.

I’m more or less happy with what we have, it certainly functions and it does achieve some of those goals. While I don’t plan to change course or redesign at this stage, there are one or two decisions that I’d like to have made differently, and one or two enhancements that I’d like to have made already. Most of these decisions and enhancements relate to the sprite sheet.

Its Cumbersome to use

Look back at the code we’ve used to build the sprite sheet from the previous part….

var
  i: int32;
begin
  // Populate sprite sheet
  SpriteSheet1.Image.LoadFromFile('pigeon.png');
  i := SpriteSheet1.AddAnimation(TAnimation.Create('flap'));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   1,   165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 1,   165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 1,   165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 1,   165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   158, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   315, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   472, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   629, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 158, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 158, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 158, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 315, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 472, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 629, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 315, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 315, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 472, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 629, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 472, 165, 155));
  SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 629, 165, 155));

It’s pretty ugly!
Do we really have to use the full scope declaration ‘SpriteSheet1.Animation[i].AddFrame’ for each new frame? Do we really have to pass the width and height of the sprite sheet for each new frame? Should we be calling the TAnimationFrame constructor as a parameter for each frame?

Well, we can do away with a lot of this ugly code if we just alter the TAnimation.AddFrame() and TSpriteSheet.AddAnimation() methods. Lets take a look…

function TSpriteSheet.AddAnimation(name: string): TAnimation;
var
  tName: string;
begin
  tName := Trim(name);
  if not assigned(GetAnimationByName(tname)) then begin
    Result := TAnimation.Create(Self,tName);
    fAnimations.Add(Result);
  end else begin
    raise Exception.Create('TSpriteSheet.AddAnimation: Animation name must be unique.');
  end;
end;

This new version of TSpriteSheet.AddAnimation() has an altered declaration. It now takes only a name for the animation as a parameter, instead of a TAnimation instance, and returns the TAnimation instance instead of it’s index in the animations list. The TAnimation instance is created inside the AddAnimation() method. Note that this won’t compile yet, because the constructor for TAnimation has also changed. However, when we’re done resolving that, our call to add an animation to the sprite sheet will alter from this…

i := SpriteSheet1.AddAnimation(TAnimation.Create('flap'));

To This…

A := SpriteSheet1.AddAnimation('flap');

Not a huge difference yet, but good things are coming.
We now need to alter the TAnimation constructor so that it accepts a sprite sheet as a parameter (this is akin to aOwner in the TComponent constructor)…

constructor TAnimation.Create( aSpriteSheet: TSpriteSheet; aName: string );
begin
  inherited Create;
  fSpriteSheet := aSpriteSheet;
  fName := aName;
  fFrames := TList.Create;
end;

In order to compile this you’ll need to add a new private member to the TAnimation class:

  fSpriteSheet: TSpriteSheet;

And because the TSpriteSheet class is declared after the TAnimation class, you’ll need to insert a forward declaration for TSpritesheet before the declaration of TAnimation…

  TSpriteSheet = class; // forward declaration for use in TAnimation.

  {# Represents an animation with a list of TAnimationFrame }
  TAnimation = class;

Next up, you should alter the TAnimation.AddFrame() method, again so that the parameters are only those absolutely required. Here’s the new method…

function TAnimation.AddFrame(X1, Y1, X2, Y2: int32): int32;
begin
  Result := fFrames.Add(TAnimationFrame.Create(SpriteSheet.Image.Width,SpriteSheet.Image.Height,X1,Y1,X2,Y2));
end;

Okay, the unitSpriteSheet should now compile again. So lets see how it’s affected the initialization code for the sprite sheet….

// Populate sprite sheet
  SpriteSheet1.Image.LoadFromFile('pigeon.png');
  with SpriteSheet1.AddAnimation('flap') do begin
    AddFrame(1,   1,   165, 155);
    AddFrame(168, 1,   165, 155);
    AddFrame(335, 1,   165, 155);
    AddFrame(502, 1,   165, 155);
    AddFrame(1,   158, 165, 155);
    AddFrame(1,   315, 165, 155);
    AddFrame(1,   472, 165, 155);
    AddFrame(1,   629, 165, 155);
    AddFrame(168, 158, 165, 155);
    AddFrame(335, 158, 165, 155);
    AddFrame(502, 158, 165, 155);
    AddFrame(168, 315, 165, 155);
    AddFrame(168, 472, 165, 155);
    AddFrame(168, 629, 165, 155);
    AddFrame(335, 315, 165, 155);
    AddFrame(502, 315, 165, 155);
    AddFrame(335, 472, 165, 155);
    AddFrame(335, 629, 165, 155);
    AddFrame(502, 472, 165, 155);
    AddFrame(502, 629, 165, 155);
  end;

I think you’ll agree, this is far nicer code than we had previously. The little extra work has made our sprite engine significantly easier to work with.

A little renaming

Now, as I’ve promised we’re going to have a scrolling backdrop feature added to our sprite engine, it’s time to do a little house-keeping to make that possible also. Take a look at the TSpriteSheet class and notice that it has a property named ‘Count’ which indicates the number of animations which the sprite sheet manages.

Well, animations are great for sprites or tiles, but in fact, they’re not good for the background image which we’re going to be scrolling. For that a static image is more suited. So we’re going to add the ability to list static images on our sprite sheet as well as animations.

For this reason, you should rename the ‘Count‘ property to ‘AnimCount‘ and rename it’s getter method from ‘GetCount‘ to ‘GetAnimCount‘. This will make room in our namespace for another counter property later.

Adding pixel coordinates.

Our TAnimationFrame class has a small problem in it too. The constructor accepts the pixel coordinates of a frame of animation within the sprite sheet, but it then translates those values into texture coordinates, discarding the pixel coordinates. This is fine for the engine as it is, however, we’re likely to add some capabilities later that depend on the pixel coordinates, besides, integer math on pixel coordinates is easier and faster than the floating point math that we must perform on texture coordinates.

Lets add pixel coordinates to the TAnimationFrame class. Replace the class definition with the following.

{# Stores the texture coordinates of a frame of animation. }
  TAnimationFrame = class
  private
    fPixCoords: TRect;
    fTexture: TRectf;
  public
    constructor Create( TextureWidth, TextureHeight: int32; spriteX, spriteY, spriteWidth, SpriteHeight: int32 ); reintroduce;
  public
    property PixCoords: TRect read fPixCoords;
    property TexCoords: TRectf read fTexture;
  end;

And the constructor with this…

constructor TAnimationFrame.Create(TextureWidth, TextureHeight: int32; spriteX, spriteY, spriteWidth, SpriteHeight: int32 );
begin
  inherited Create;
  // store pixel coordinates
  fPixCoords.Left := SpriteX;
  fPixCoords.Top := SpriteY;
  fPixCoords.Width := SpriteWidth;
  fPixCoords.Height := SpriteHeight;
  // calcualte texture coordinates.
  fTexture.Left := (1/TextureWidth) * fPixCoords.Left;
  fTexture.Top := (1/TextureHeight) * fPixCoords.Top;
  fTexture.Right := (1/TextureWidth) * (fPixCoords.Left+fPixCoords.Width);
  fTexture.Bottom := (1/TextureHeight) * (fPixCoords.Top+fPixCoords.Height);
end;

We now have both the stored pixel coordinates, and the texture coordinates required for our animation.

Adding static images.

As I mentioned above. It will be useful to us later if we have the ability to pick out static images from our sprite sheet as well as animations. For this purpose, we’re going to add the class TStaticImage to our sprite sheet unit…

{# Stores the texture coordinates of a named static image. }
  TStaticImage = class( TAnimationFrame )
  private
    fName: string;
  public
    constructor Create( aName: string; TextureWidth, TextureHeight: int32; ImageX, ImageY, ImageWidth, ImageHeight: int32 ); reintroduce;
  public
    property Name: string read fName;
    property PixCoords;
    property TexCoords;
  end;

And for the implementation of the constructor…

constructor TStaticImage.Create(aName: string; TextureWidth, TextureHeight, ImageX, ImageY, ImageWidth, ImageHeight: int32);
begin
  inherited Create( TextureWidth, TextureHEight, ImageX, ImageY, ImageWidth, ImageHeight );
  fName := aName;
end;

As you’ll notice, the TStaticImage class is derived from the TAnimationFrame class. It’s essentially the same thing, but with a name property added. The name property will be used as a unique identifier for this image when it is added to the sprite sheet.

Lets enhance the TSpriteSheet class to store a list of these static images. In order to save a little time, I’m going to post the entire new interface and implementations for TSpriteSheet…

{# Represents the texture sheet and a series of images and animations which
     may be derrived from the source image. }
  TSpriteSheet = class( TComponent )
  private
    fSourceImage: TBitmap;
    fAnimations: TList;
    fImages: TList;
    function GetAnimation(idx: int32): TAnimation;
    function GetAnimationByName(name: string): TAnimation;
    function GetAnimCount: int32;
    function GetImageByName(name: string): TStaticImage;
    function GetImageCount: int32;
    function GetImage(idx: int32): TStaticImage;
  public
    constructor Create( aOwner: TComponent ); override;
    destructor Destroy; override;

    procedure Clear;
    function AddAnimation( name: string ): TAnimation;
    function AddImage( name: string; ImageX, ImageY, Width, Height: int32 ): TStaticImage;

  public
    property SourceImage: TBitmap read fSourceImage;
    // animations
    property AnimCount: int32 read GetAnimCount;
    property Animation[ idx: int32 ]: TAnimation read GetAnimation;
    property AnimByName[ name: string ]: TAnimation read GetAnimationByName;
    // static images
    property ImageCount: int32 read GetImageCount;
    property Image[ idx: int32 ]: TStaticImage read GetImage;
    property ImageByName[ name: string ]: TStaticImage read GetImageByName;
  end;

And the implementation…

{ TSpriteSheet }

function TSpriteSheet.AddAnimation(name: string): TAnimation;
var
  tName: string;
begin
  tName := Trim(name);
  if not assigned(GetAnimationByName(tname)) then begin
    Result := TAnimation.Create(Self,tName);
    fAnimations.Add(Result);
  end else begin
    raise Exception.Create('TSpriteSheet.AddAnimation: Animation name must be unique.');
  end;
end;

function TSpriteSheet.AddImage(name: string; ImageX, ImageY, Width, Height: int32): TStaticImage;
var
  tName: string;
begin
  tName := Trim(name);
  if not assigned(GetImageByName(tname)) then begin
    Result := TStaticImage.Create(tName,SourceImage.Width,SourceImage.Height,ImageX,ImageY,Width,Height);
    fImages.Add(Result);
  end else begin
    raise Exception.Create('TSpriteSheet.AddImage: Image name must be unique.');
  end;
end;

procedure TSpriteSheet.Clear;
var
  idx: int32;
begin
  for idx := pred(AnimCount) downto 0 do begin
    TAnimation(fAnimations[idx]).Free;
  end;
  fAnimations.Clear;
  for idx := pred(ImageCount) downto 0 do begin
    TStaticImage(fImages[idx]).Free;
  end;
  fImages.Clear;
end;

constructor TSpriteSheet.Create( aOwner: TComponent );
begin
  inherited Create( aOwner );
  fAnimations := TList.Create;
  fImages := TList.Create;
  fSourceImage := TBitmap.Create;
end;

destructor TSpriteSheet.Destroy;
begin
  Clear;
  fAnimations.Free;
  fImages.Free;
  fSourceImage.Free;
  inherited;
end;

function TSpriteSheet.GetAnimation(idx: int32): TAnimation;
begin
  Result := TAnimation(fAnimations[idx]);
end;

function TSpriteSheet.GetAnimationByName(name: string): TAnimation;
var
  utName: string;
  idx: int32;
  Ref: TAnimation;
begin
  Result := nil;
  utName := Uppercase(Trim(name));
  if utName='' then exit;
  for idx := 0 to pred(AnimCount) do begin
    Ref := TAnimation(fAnimations[idx]);
    if Uppercase(Trim(Ref.Name))=utName then begin
      Result := Ref;
      Exit;
    end;
  end;
end;

function TSpriteSheet.GetAnimCount: int32;
begin
  Result := fAnimations.Count;
end;

function TSpriteSheet.GetImage(idx: int32): TStaticImage;
begin
  Result := TStaticImage(fImages[idx]);
end;

function TSpriteSheet.GetImageByName(name: string): TStaticImage;
var
  utName: string;
  idx: int32;
  Ref: TStaticImage;
begin
  Result := nil;
  utName := Uppercase(Trim(name));
  if utName='' then exit;
  for idx := 0 to pred(ImageCount) do begin
    Ref := TStaticImage(fImages[idx]);
    if Uppercase(Trim(Ref.Name))=utName then begin
      Result := Ref;
      Exit;
    end;
  end;
end;

function TSpriteSheet.GetImageCount: int32;
begin
  Result := fImages.Count;
end;

You’ll notice that I’ve renamed the property ‘Image’ to ‘SourceImage’. This is going to have a knock-on effect in two places where we’ve already used that property. Firstly, in TAnimation.AddFrame() which we updated earlier, we now need to update it once more…

function TAnimation.AddFrame(X1, Y1, X2, Y2: int32): int32;
begin
  Result := fFrames.Add(TAnimationFrame.Create(SpriteSheet.SourceImage.Width,SpriteSheet.SourceImage.Height,X1,Y1,X2,Y2));
end;

And over in unitSprite we need to update the TSprite.SetSpriteSheet() method…

procedure TSprite.SetSpriteSheet(const Value: TSpriteSheet);
begin
  fSpriteSheet := Value;
  if assigned(SpriteSheet) then begin
    fMaterial.Texture := TContext3D.BitmapToTexture(SpriteSheet.SourceImage);
    fAnimation := SpriteSheet.AnimByName[Animation];
  end else begin
    fAnimation := nil;
  end;
  fFrame := 0;
  RecalculateTexture;
end;

Don’t forget to also update this name in your project initialization..

SpriteSheet1.Image.LoadFromFile('pigeon.png');

becomes

SpriteSheet1.SourceImage.LoadFromFile('pigeon.png');

Now everything should compile again. Go ahead and give it a try, but again, don’t worry too much as I’ll be including everything in a download later.

A Note on data integrity.

Thus far, we’ve handled the TSpriteSheet class in precisely the way we intended to. We first load an image into the ‘SourceImage’ property (as it is now named), and then we add animations and animation frames as data structures based on that image. In the above updates however, we have altered the ‘TAnimation.AddFrame()’ and ‘TSpriteSheet.AddImage()’ methods so that they depend on being able to supply the image width and height to the constructors of the objects that they are creating. So what happens if we add a bunch of frames to an animation, and then load a new bitmap into the SourceImage property?

I’ve chosen not to protect against this scenario, but you could protect against it if you wish. You’d simply have to swap out the ‘SourceImage’ property for two methods. ‘GetSourceImage’ which returns the source image as the property does now, and LoadSourceImage() to load the image data from some file or stream.

Conclusion

If you compile the project as it is now, having made all of the above changes, you’ll appear to have gained nothing. The code still does precisely what it did before! I inserted this post because I wanted to make the engine that we’re building a little easier to live with, knowing well that this work would not add any real functionality. Try justifying a re-factoring like that to a development team manager and you’d likely be told you should have written it this way the first time!  It’s a simple fact of life that first drafts are usually not as good as they could be, often several subsequent drafts don’t get you there.

I hope you’ll agree these changes were worth the time. Now, I should have learnt a lesson from stating at the end of part 3 what we’d be doing in part 4, but here I am at it again. In part 5 we’ll look at adding some streaming options to the sprite sheet class, and I’ll share a trick for easing deployment to mobile devices. In part 6 we’ll get back on track and add that scrolling backdrop. Well, those are my intentions anyway.

Download the current sources here: SpriteEngine_r2

Thanks for reading!

 

 

Print Friendly, PDF & Email
Facebooktwittergoogle_plusredditpinterestlinkedintumblrmail

Leave a Reply