Animations

Overview

Have you ever attempted to draw sprites in your QuickBASIC games only to end up with code that looked like this?

  IF (frame = ANIM_STILL) THEN
    IF (direction = DIR_LEFT) THEN
      PUT(x, y), lookLeftStill, PSET
    ELSEIF (direction = DIR_RIGHT) THEN
      PUT(x, y), lookRightStill, PSET
    END IF
  ELSEIF (frame = ANIM_WALK1) THEN
    IF (direction = DIR_LEFT) THEN
      PUT(x, y), lookLeftWalk1, PSET
    ELSEIF (direction = DIR_RIGHT) THEN
      PUT(x, y), lookRightWalk1, PSET
    END IF
  ELSEIF (frame = ANIM_WALK2) THEN
    IF (direction = DIR_LEFT) THEN
      PUT(x, y), lookLeftWalk2, PSET
    ELSEIF (direction = DIR_RIGHT) THEN
      PUT(x, y), lookRightWalk2, PSET
    END IF
  ELSEIF (frame = ANIM_WALK3) THEN
    ...

If so, this article is for you: there IS another way to handle animation without adding an increasing number of variables and condition blocks! I hope you read the GET/PUT article before this one but if you haven't, here's the short version: "you can store multiple images in the same array, and it can be tricky to determine the amount of memory required for certain images" ...and that's about it. You don't NEED to read it, but it might clarify a few things.

This article (this one right here, not that other GET/PUT article) covers how to organize data to create a flexible "action" system to display every actor in your game (animated or not, seen from different angles or not) in a unified fashion (without exceptions or interminable condition blocks: everything goes through the same pipe!)

Before we dive in, I want to make a tiny little distinction between "action" and "animation." An animation is a group of frames (still images) that depicts movement, seen from a specific angle. An action is a group of animations that depicts movement, seen through multiple angles. It's subtle and if you don't get it now, don't worry: it'll become clear later...

...or I'm a failure of a teacher.

The image collection

We're going to need two things: a large buffer to store our images and a structure that will help us retrieve each individual image within the buffer. That structure will also give us the information we need to properly align the image according to the actor's origin point. First, let's look at what we need:

CONST gBufferSize = 2048 ' buffer size, in INTEGERS
CONST gImageCount = 128  ' collection size, in images

TYPE imageType
  ofs AS INTEGER  ' location in gBuffer()
  x AS INTEGER    ' origin point, horizontal coordinate
  y AS INTEGER    ' origin point, vertical coordinate
END TYPE

DIM SHARED gBuffer(gBufferSize - 1) AS INTEGER  ' image data buffer
DIM SHARED gImage(gImageCount - 1) AS imageType ' image collection

Now, why do we need to preserve the origin point of each image?...well, consider your run-of-the-mill top-down perspective action-RPG: a walking animation is usually done with a set of three or four 16x16 images. But a 16x16 image is too small to show the hero swinging his sword around with all his might... which leads to a problem: in QuickBASIC, when we PUT an image somewhere on the screen, we provide the upper-left pixel as the origin point. What would happen if we kept the same origin point to display a 16x16 walking frame, and then a 24x16 sword swing frame? The character would jerk around and it wouldn't be pretty. The solution is to assign each image its own origin point (seen below as a red dot.)

It's cool and all, but what do we do EXACTLY with that origin point? We just subtract its coordinates from the drawing coordinates like this:

DIM x AS INTEGER, y AS INTEGER, img AS INTEGER

x = 320 \ 2 ' center of the screen
y = 200 \ 2 ' center of the screen
img = 0     ' image index

PUT (x - gImage(img).x, y - gImage(img).y), gBuffer(gImage(img).ofs), PSET

Alright, now we need an easy way to GET graphics from a video page, store the pixel data into gBuffer() and register the new image in our gImage() collection. We'll need two more variables for that: one that points to the next free element in gBuffer() and another for the next free element in gImage() (which is also the number of images currently defined.) To know how many elements we wrote in gBuffer(), we have to compute the size (in INTEGERS) required to store the image (the GET/PUT article explains how that works.) The following code, tuned for mode 7, will do it for us (change the "bpp" and "planes" constants in "getImage" for other modes - that info can also be found in the GET/PUT article.)

CONST gBufferSize = 2048 ' buffer size, in INTEGERS
CONST gImageCount = 128  ' collection size, in images

TYPE imageType
  ofs AS INTEGER  ' location in gBuffer()
  x AS INTEGER    ' origin point, horizontal coordinate
  y AS INTEGER    ' origin point, vertical coordinate
END TYPE

DECLARE SUB getImage (x AS INTEGER, y AS INTEGER, w AS INTEGER, h AS INTEGER, oX AS INTEGER, oY AS INTEGER)

DIM SHARED gBuffer(gBufferSize - 1) AS INTEGER  ' image data buffer
DIM SHARED gImage(gImageCount - 1) AS imageType ' image collection

DIM SHARED gBufferOfs AS INTEGER ' writing position in gBuffer()
DIM SHARED gImageCnt AS INTEGER  ' writing position in gImage()

''
'' capture graphics from active video page. X and Y are the upper-left
'' corner of the image to capture, W and H the width and height in pixels,
'' and OX and OY the origin point of the captured image so we can adjust
'' the PUT statement coordinates.
''
SUB getImage (x AS INTEGER, y AS INTEGER, w AS INTEGER, h AS INTEGER, oX AS INTEGER, oY AS INTEGER)
  DIM imgSize AS INTEGER

  ' bits per plane and number of planes for
  ' mode 7 (mode 13 is bpp = 8, planes = 1)
  CONST bpp = 1
  CONST planes = 4

  ' get the image size, in INTEGERS
  imgSize = 4 + INT((w * bpp + 7) / 8) * planes * h
  imgSize = (imgSize + (imgSize AND 1)) \ 2

  'capture image to gBuffer(), starting at element gBufferOfs
  GET (x, y)-STEP(w - 1, h - 1), gBuffer(gBufferOfs)

  ' register image in the collection
  gImage(gImageCnt).ofs = gBufferOfs
  gImage(gImageCnt).x = oX
  gImage(gImageCnt).y = oY

  ' advance cursor in gImage() and gBuffer()
  gImageCnt = gImageCnt + 1
  gBufferOfs = gBufferOfs + imgSize
END SUB

This thing is super handy: we can now refer to images using an index number in gImage() rather than manually computing the offset in gBuffer(), something that would become impossible if we loaded images with different dimensions and different memory requirements. But why would we need to compute offsets? To easily animate stuff of course!

Proper timing (timestepping)

Let's quickly open a tiny parenthesis to cover something that deserves its own article... but we need to talk about it now.

Have you ever noticed how your program is slower or faster depending on the machine it runs on? Or the speed difference between the QuickBASIC IDE and the compiled code? Well, there's a way to "lock" the execution to a certain speed so it works the same way on an 80386 or an i7.

Basically we split the code in two parts: the game logic (like updating gravity, inflicting damage, processing animations) and the renderer (drawing the screen.) The idea is that we want each "step" in the game logic to represent a certain amount of time. So if we spend too much time drawing the screen, we have to "catch up" by going through the game logic as many times as necessary before we can render the next frame.

The game logic processing is always behind and always has to catch up... thus, we tell the program that every time it completes a lap, it caught up a certain amount of time. Real time is only important to determine how much time was elapsed between screen refreshes. In practice, it looks like this:

DIM tCurrent AS SINGLE, tPrevious AS SINGLE
DIM tElapsed AS SINGLE, tLag AS SINGLE

' main loop
tPrevious = TIMER
DO
  ' update timer
  tCurrent = TIMER
  tElapsed = tCurrent - tPrevious
  tLag = tLag + tElapsed
  tPrevious = tCurrent

  ' update game logic until we're all caught up
  WHILE (tLag > .01)

    ' here's where the magic happen: process game
    ' logic such as actor movement, collisions,
    ' gravity, animations, user input, etc.
    CALL processGameLogic

    ' congrats, you caught up by .01 seconds!
    tLag = tLag - .01
  WEND

  ' render here
  CALL drawScreen
LOOP

It may take a while getting used to structure your code like this, but it's really worth it. In the code above, we used TIMER as the time reference, but we can also use the PIT (Programmable Interval Timer) chip for a higher resolution (that deserves another article for real.) Anyway, we had to have a quick look at this concept because the animation system will need it to properly time how long each frame of animation must remain on screen before another one is displayed.

Actions

At their core, animations are just a bunch of images displayed one after the other, on repeat. Since we got a way to store images one after the other in a buffer, we might as well use it: we start at a specific image, display the following three or four images, then go back to the initial image. That's essentially where the action() array steps into the scene: as the backbone of our system, the array contains how frames should be read and displayed, how they cycle, and how fast. The idea is that each actor can be assigned an action and it will change the way they look, according to their angle. It sounds like a lot of work, but it really only requires a small structure of 5 variables (and a few handy constants for one of the fields:)

CONST actionCount = 4   ' reserve memory for four actions

CONST animLoop = 0      ' animation loops back to 0
CONST animOscil = 1     ' animation goes back and forth
CONST animFreeze = 2    ' animation freezes on last frame

TYPE actionType
  viewAngle AS INTEGER  ' angle coverage, in degrees
  frameFirst AS INTEGER ' index of the first frame in gImage()
  frameCount AS INTEGER ' number of animation frames per angle
  animMode AS INTEGER   ' forward, back-and-forth, stop
  animDelay AS INTEGER  ' delay before next frame
END TYPE

DIM SHARED action(actionCount - 1) AS actionType

Let's go through each variable to see what they do:

"viewAngle" contains the angle spread of each view, in degrees. In other words, it contains 360 degrees divided by the number of points of view. A value of 360 means the same animation will be used, no matter the orientation of the actor. If the value is 180 there are two different points of view, if the value is 90 there are four different points of view, etc. We'll get back to that.

"frameFirst" contains the index of the first animation frame in gImage().

"frameCount" contains the number of frames of animation per angle. For example, if this number is 4, and viewAngle is 90 (4 points of view,) then there are 16 frames of animation total for this action. It means that the order in which we store images in gImage() is of the utmost importance: we contiguously store the animation for each view (clockwise and starting from the East,) so we can easily retrieve the frame to display with a simple formula. More about that later.

"animMode" contains a code value from 0 to 2. 0 makes the animation loop (when the last frame is reached, we start back at frame 0 again.) 1 makes the animation go back and forth (when the last frame is reached, we go back to frame 0, one frame at a time, then forth again.) 2 freezes the animation when the last frame is reached. To keep the code intuitive, we'll use the constants animLoop (0,) animOscil (1,) and animFreeze (2) instead of numbers.

"animDelay" defines the animation speed. The bigger the number, the longer a frame stays on screen. This value is used to reset a countdown unique to every actor in the game. When the countdown reaches zero, it is reset and the frame counter of the actor is modified. The countdown is given in game "ticks," that is, the number of times we're going through the game logic.

Let's write a utility routine to initialize the action structure, just to keep things clean:

''
'' initialize action(id)
''
SUB actionSet (id AS INTEGER, first AS INTEGER, count AS INTEGER, views AS INTEGER, mode AS INTEGER, delay AS INTEGER)
  action(id).viewAngle = 360 \ views
  action(id).frameFirst = first
  action(id).frameCount = count
  action(id).animMode = mode
  action(id).animDelay = delay
END SUB

Now let's shift focus toward the actors' needs and then we're done.

Actors

The actor type contains all the usual stuff you need in your game: the X and Y coordinates, a health counter, maybe some flags for the inventory, a counter for the number of lives remaining, an id that describes the type of behavior the character has in-game (goblin, spider, medkit, life up, player...) whatever your game requires... right now however, we'll only focus on the animation system and the variables we need to get it to work. There are a few (it could be a good idea to put them in their own structure so the animation system is isolated:)

CONST actorCount = 1      ' one actor

TYPE actorType
  actIndex AS INTEGER     ' current action index
  anmFrame AS INTEGER     ' current frame
  anmFrameNext AS INTEGER ' countdown to next frame
  anmFrameVec AS INTEGER  ' frame increase/decrease value
  anmFacing AS INTEGER    ' facing direction, simplified

  angle AS INTEGER        ' facing direction, in degrees
  x AS INTEGER            ' horizontal location
  y AS INTEGER            ' vertical location
END TYPE

DIM SHARED actor(actorCount - 1) AS actorType

"actIndex" points to an action in the action() array. If the value is below 0, the animation system (and the rendering code) must ignore the actor altogether to avoid crashes. Several variables need to be adjusted when the action changes, so we'll use a dedicated routine for that:

''
'' let actor(id) perform action(actId)
''
SUB actorSetAction (id AS INTEGER, actId AS INTEGER)
  ' prevent action from resetting itself
  IF (actId = actor(id).actIndex) THEN EXIT SUB

  ' change action
  actor(id).actIndex = actId
  IF (actId < 0) THEN EXIT SUB

  ' return to the first frame
  actor(id).anmFrame = 0

  ' restart countdown
  actor(id).anmFrameNext = action(actId).animDelay

  ' animate "forward"
  actor(id).anmFrameVec = 1

  ' force new facing index
  actorSetAngle id, actor(id).angle
END SUB

You may ignore the "actorSetAngle" routine for now, it's coming right up. A thing worth mentioning though is the early EXIT SUB if actId = actor(id).actIndex. This line exists to prevent an animation from cancelling itself. Say we invoke actorSetAction to "walk" for as long as a key is pressed, then only the 1st frame of animation will ever be drawn, because we keep resetting it. This may cause some issues when we assign an actor the action 0 right after actor's initialization. Consider one of the alternatives:

  1. Test if the actor is not already performing that action before calling actorSetAction.
  2. Force all actors to initialize with actor(id).actIndex set to -1.
  3. Skip the first element of the action() array and start at index 1 (this also requires to change the skip in other bits of code from -1 to 0.)
  4. Create another actorSetAction routine that doesn't include the check, provide a flag in the routine arguments to always pass the check, or assign a special flag to actors so the test always passes the first time.

"anmFrame" is the actor frame counter. This variable is inside the actorType structure and not inside the actionType structure because we want multiple actors to be able to perform the same action without all being in-sync. The value starts at 0 and ends at frameCount minus 1 (in the actionType structure.) It can be used for special programming tricks too! Check the actor's current action, then check the currently displayed anmFrame... now you know when the bullet should exit the gun, when the sword should deal damage, etc. It doesn't matter what angle the actor is facing, this value is always the same.

"anmFrameNext" is the actor frame countdown. When it reaches zero, the anmFrame value is increased (or decreased,) and the countdown is reset to the action's animDelay value. Whether anmFrame is increased or decreased depends entirely on the next field:

"anmFrameVec" contains 1 (increases anmFrame,) -1 (decreases anmFrame,) or 0 (keeps anmFrame the same.) This value is modified according to the action's animMode value.

"angle" is the direction the actor is facing, in degrees. Using degrees might seem overkill, but it covers the most cases and offers a simple way to move the character using SIN() and COS() if needed. Reminder: 0 degree is pointing East, 90 degrees is South, 180 degrees is West and 270 degrees is North.

"anmFacing" is the actual value we're using in the animation system and obtained from "angle:" it contains a value between 0 and 360 \ action's viewAngle minus 1. Since it requires some heavy computation to obtain, we only adjust this value when "angle" is modified and store it here for later reference. The following routine adjusts the actor's angle and computes the anmFacing value:

''
'' change actor(id)'s angle
''
SUB actorSetAngle (id AS INTEGER, degrees AS INTEGER)
  DIM viewAngle AS INTEGER, angle AS INTEGER

  ' keep angle in range 0 to 359
  angle = degrees
  IF (angle > 359) THEN
    angle = angle MOD 360
  ELSE
    WHILE (angle < 0)
      angle = angle + 360
    WEND
  END IF

  ' get animation angle coverage
  viewAngle = action(actor(id).actIndex).viewAngle

  ' assign new angle, update facing index
  actor(id).angle = angle
  actor(id).anmFacing = ((angle + viewAngle \ 2) \ viewAngle) MOD (360 \ viewAngle)

  ' if we had a movement vector, now would be the right time to update it:
  ' actor(id).moveX = COS(angle * 0.01745329#)
  ' actor(id).moveY = SIN(angle * 0.01745329#)
END SUB

Note that we're not simply dividing the actor angle by the action viewAngle because we want the East view to be used when the actor is facing anywhere between -45 and 44 degrees, not 0 to 89 degrees.

There's one case this design won't handle though: what if we only want two view points, one for South and another for North? Right now, we assume the angle offset is viewAngle \ 2 (if viewAngle is 90 degrees then the offset is 45 degrees.) If we wanted another offset, we would have to provide it manually and store the variable in actionType, and then replace the "viewAngle \ 2" operation by that variable. It can be implemented in a matter of seconds if needed (although it would be very very niche.)

I think the "X" and "Y" fields are self-explanatory... so let's skip that.

Before we stitch the bits together, let's look at the two final pieces of the puzzle. First, the formula that will return the image index in the gImage() collection according to the angle and animation:

DIM id AS INTEGER, tempAct AS actionType, tempImg AS imageType

' select actor by index
id = 0

' if the action index is below 0, skip
IF NOT (actor(id).actIndex < 0) THEN

  ' shorthand to the action in action()
  tempAct = action(actor(id).actIndex)

  ' shorthand to the image data in gImage()
  tempImg = gImage(tempAct.frameFirst + tempAct.frameCount * actor(id).anmFacing + actor(id).anmFrame)

  ' draw the actor
  PUT (actor(id).x - tempImg.x, actor(id).y - tempImg.y), gBuffer(tempImg.ofs), PSET
END IF

The formula is really simple: we start at .frameFirst, which is the index in gImage() to the first frame of animation for the first view angle. Since we know that each animation is .frameCount long, we multiply that by .anmFacing to find the first frame of the animation for a certain angle. Then we add .anmFrame that is the current animation frame for the actor. If you need visual support, check this out:

As long as the data is properly organized (as a reminder: contiguously, clockwise, starting from East,) we don't need to resort to endless conditional branching. One last bit before we assemble it all: the actual frame cycling.

DIM id AS INTEGER, tempAct AS actionType

' select actor by index
id = 0

' if the action index is below 0, skip
IF NOT (actor(id).actIndex < 0) THEN

  ' if the countdown is not yet 0...
  IF (actor(id).anmFrameNext) then
    ' ...decrease countdown
    actor(id).anmFrameNext = actor(id).anmFrameNext - 1

  ' if the countdown reached 0...
  ELSE
    ' shorthand to the action in action()
    tempAct = action(actor(id).actIndex)

    ' ...reset countdown
    actor(id).anmFrameNext = tempAct.animDelay

    ' ...increase (or decrease) the frame number
    actor(id).anmFrame = actor(id).anmFrame + actor(id).anmFrameVec

    ' if the current frame is under 0 (we went back too far)
    IF (actor(id).anmFrame = -1) THEN
      actor(id).anmFrame = 0    ' reset to 0
      actor(id).anmFrameVec = 1 ' go forth again

    ' if the current frame is over the frame count for this action...
    ELSEIF (actor(id).anmFrame = tempAct.frameCount) THEN
      SELECT CASE tempAct.animMode
      ' restart to 0 if the action mode is animLoop
      CASE animLoop
        actor(id).anmFrame = 0

      ' cap frame number and go backward if action mode is animOscil
      CASE animOscil
        actor(id).anmFrame = actor(id).anmFrame - 1
        actor(id).anmFrameVec = -1

      ' cap frame number and stop if action mode is animFreeze
      CASE ELSE
        actor(id).anmFrame = actor(id).anmFrame - 1
        actor(id).anmFrameVec = 0
      END SELECT
    END IF
  END IF
END IF

That's it. We've got all the pieces necessary to display absolutely any actor, whether animated or not, using multiple frames, with different animation speed, from any angle! If we shove all our actors (enemies, player, items and props) through the same pipe, we'll never have to worry about animating anything ever again!

Putting the pieces together

Here's what the "clean" code looks like. Obviously, if you need more actions, actors, images, or a larger buffer, you can simply modify the constants at the beginning of the program. I thought it looked big until my wife pointed out every other line is a comment. But she assured me size doesn't matter. Although if you ask me, the shorter, the better. It runs faster, right? Well, not always. But anyway, here's the code:

CONST gBufferSize = 2048 ' buffer size, in INTEGERS
CONST gImageCount = 128  ' collection size, in images

CONST actionCount = 4   ' reserve memory for four actions
CONST actorCount = 1    ' one actor

CONST animLoop = 0      ' animation loops back to 0
CONST animOscil = 1     ' animation goes back and forth
CONST animFreeze = 2    ' animation freezes on last frame

TYPE imageType
  ofs AS INTEGER  ' location in gBuffer()
  x AS INTEGER    ' origin point, horizontal coordinate
  y AS INTEGER    ' origin point, vertical coordinate
END TYPE

TYPE actionType
  viewAngle AS INTEGER  ' angle coverage, in degrees
  frameFirst AS INTEGER ' index of the first frame in gImage()
  frameCount AS INTEGER ' number of animation frames per angle
  animMode AS INTEGER   ' forward, back-and-forth, stop
  animDelay AS INTEGER  ' delay before next frame
END TYPE

TYPE actorType
  actIndex AS INTEGER     ' current action index
  anmFrame AS INTEGER     ' current frame
  anmFrameNext AS INTEGER ' countdown to next frame
  anmFrameVec AS INTEGER  ' frame increase/decrease value
  anmFacing AS INTEGER    ' facing direction, simplified

  angle AS INTEGER        ' facing direction, in degrees
  x AS INTEGER            ' horizontal location
  y AS INTEGER            ' vertical location
END TYPE

DECLARE SUB getImage (x AS INTEGER, y AS INTEGER, w AS INTEGER, h AS INTEGER, oX AS INTEGER, oY AS INTEGER)
DECLARE SUB actionSet (id AS INTEGER, first AS INTEGER, count AS INTEGER, views AS INTEGER, mode AS INTEGER, delay AS INTEGER)
DECLARE SUB actorSetAction (id AS INTEGER, actId AS INTEGER)
DECLARE SUB actorSetAngle (id AS INTEGER, degrees AS INTEGER)
DECLARE SUB main ()

DIM SHARED gBuffer(gBufferSize - 1) AS INTEGER  ' image data buffer
DIM SHARED gImage(gImageCount - 1) AS imageType ' image collection

DIM SHARED gBufferOfs AS INTEGER ' writing position in gBuffer()
DIM SHARED gImageCnt AS INTEGER  ' writing position in gImage()

DIM SHARED action(actionCount - 1) AS actionType
DIM SHARED actor(actorCount - 1) AS actorType

' enter graphic mode 7 (320x200, 1 bit per plane, 4 planes)
SCREEN 7

' this is where you should load graphics, setup the various actions and
' initialize actors... right now, since we have no action defined and no
' graphics loaded, we'll just set actor()'s action to -1
FOR i% = 0 TO actorCount - 1
  actorSetAction i%, -1
NEXT i%

' run main loop
main

''
'' main routine, where the magic happens
''
SUB main
  DIM tCurrent AS SINGLE, tPrevious AS SINGLE, tElapsed AS SINGLE, tLag AS SINGLE
  DIM id AS INTEGER, tempAct AS actionType, tempImg AS imageType

  tPrevious = TIMER
  DO

    ' update timer
    tCurrent = TIMER
    tElapsed = tCurrent - tPrevious
    tLag = tLag + tElapsed
    tPrevious = tCurrent

    ' update game logic until we're all caught up
    WHILE (tLag > .01)

      '' GAME LOGIC ''

      ' parse every actor
      FOR id = 0 TO actorCount - 1

        ' if the action index is below 0, skip
        IF NOT (actor(id).actIndex < 0) THEN

          ' if the countdown is not yet 0...
          IF (actor(id).anmFrameNext) then
            ' ...decrease countdown
            actor(id).anmFrameNext = actor(id).anmFrameNext - 1

          ' if the countdown reached 0...
          ELSE
            ' shorthand to the action in action()
            tempAct = action(actor(id).actIndex)

            ' ...reset countdown
            actor(id).anmFrameNext = tempAct.animDelay

            ' ...increase (or decrease) the frame number
            actor(id).anmFrame = actor(id).anmFrame + actor(id).anmFrameVec

            ' if the current frame is under 0 (we went back too far)
            IF (actor(id).anmFrame = -1) THEN
              actor(id).anmFrame = 0    ' reset to 0
              actor(id).anmFrameVec = 1 ' go forth again

            ' if the current frame is over the frame count for this action...
            ELSEIF (actor(id).anmFrame = tempAct.frameCount) THEN
              SELECT CASE tempAct.animMode
              ' restart to 0 if the action mode is animLoop
              CASE animLoop
                actor(id).anmFrame = 0

              ' cap frame number and go backward if action mode is animOscil
              CASE animOscil
                actor(id).anmFrame = actor(id).anmFrame - 1
                actor(id).anmFrameVec = -1

              ' cap frame number and stop if action mode is animFreeze
              CASE ELSE
                actor(id).anmFrame = actor(id).anmFrame - 1
                actor(id).anmFrameVec = 0
              END SELECT
            END IF
          END IF
        END IF
      NEXT id

      ' congrats, you caught up by 0.01 seconds!
      tLag = tLag - .01
    WEND

    '' RENDER ''

    ' parse every actor
    FOR id = 0 TO actorCount - 1

      ' if the action index is below 0, skip
      IF NOT (actor(id).actIndex < 0) THEN

        ' shorthand to the action in action()
        tempAct = action(actor(id).actIndex)

        ' shorthand to the image data in gImage()
        tempImg = gImage(tempAct.frameFirst + tempAct.frameCount * actor(id).anmFacing + actor(id).anmFrame)

        ' draw the actor
        PUT (actor(id).x - tempImg.x, actor(id).y - tempImg.y), gBuffer(tempImg.ofs), PSET
      END IF
    NEXT id
  LOOP
END SUB

''
'' capture graphics from active video page. X and Y are the upper-left
'' corner of the image to capture, W and H the width and height in pixels,
'' and OX and OY the origin point of the captured image so we can adjust
'' the PUT statement coordinates.
''
SUB getImage (x AS INTEGER, y AS INTEGER, w AS INTEGER, h AS INTEGER, oX AS INTEGER, oY AS INTEGER)
  DIM imgSize AS INTEGER

  ' bits per plane and number of planes for
  ' mode 7 (mode 13 is bpp = 8, planes = 1)
  CONST bpp = 1
  CONST planes = 4

  ' get the image size, in INTEGERS
  imgSize = 4 + INT((w * bpp + 7) / 8) * planes * h
  imgSize = (imgSize + (imgSize AND 1)) \ 2

  'capture image to gBuffer(), starting at element gBufferOfs
  GET (x, y)-STEP(w - 1, h - 1), gBuffer(gBufferOfs)

  ' register image in the collection
  gImage(gImageCnt).ofs = gBufferOfs
  gImage(gImageCnt).x = oX
  gImage(gImageCnt).y = oY

  ' advance cursor in gImage() and gBuffer()
  gImageCnt = gImageCnt + 1
  gBufferOfs = gBufferOfs + imgSize
END SUB

''
'' initialize action(id)
''
SUB actionSet (id AS INTEGER, first AS INTEGER, count AS INTEGER, views AS INTEGER, mode AS INTEGER, delay AS INTEGER)
  action(id).viewAngle = 360 \ views
  action(id).frameFirst = first
  action(id).frameCount = count
  action(id).animMode = mode
  action(id).animDelay = delay
END SUB

''
'' let actor(id) perform action(actId)
''
SUB actorSetAction (id AS INTEGER, actId AS INTEGER)
  ' prevent action from resetting itself
  IF (actId = actor(id).actIndex) THEN EXIT SUB

  ' change action
  actor(id).actIndex = actId
  IF (actId < 0) THEN EXIT SUB

  ' return to the first frame
  actor(id).anmFrame = 0

  ' restart countdown
  actor(id).anmFrameNext = action(actId).animDelay

  ' animate "forward"
  actor(id).anmFrameVec = 1

  ' force new facing index
  actorSetAngle id, actor(id).angle
END SUB

''
'' change actor(id)'s angle
''
SUB actorSetAngle (id AS INTEGER, degrees AS INTEGER)
  DIM viewAngle AS INTEGER, angle AS INTEGER

  ' keep angle in range 0 to 359
  angle = degrees
  IF (angle > 359) THEN
    angle = angle MOD 360
  ELSE
    WHILE (angle < 0)
      angle = angle + 360
    WEND
  END IF

  ' get animation angle coverage
  viewAngle = action(actor(id).actIndex).viewAngle

  ' assign new angle, update facing index
  actor(id).angle = angle
  actor(id).anmFacing = ((angle + viewAngle \ 2) \ viewAngle) MOD (360 \ viewAngle)

  ' if we had a movement vector, now would be the right time to update it:
  ' actor(id).moveX = COS(angle * 0.01745329#)
  ' actor(id).moveY = SIN(angle * 0.01745329#)
END SUB

If you tried to code above, you noticed that... well... nothing happens. Just a blank screen. And that's because I've pulled an April's fool on you! Ah ah! Got'cha! In early March too! I'm such a prankster. No, but seriously. The issue is that we don't have graphics to work with and didn't even set a single action. I just wanted to provide a clean template that could easily be modified rather than having you hunt through the code for lines to gut out.

The missing pieces

The following will display on the screen a selection of tiles from the GameBoy game "Dragon Slayer," then capture each frame individually and configure four actions. I'm sure you can figure out where that code goes. Call the demoCtrl() routine right after the "GAME LOGIC" comment and you're good to go.

CONST actHeroWalk = 0
CONST actHeroStand = 1
CONST actHeroDie = 2
CONST actHeroAttack = 3

DECLARE SUB demoGrfx ()
DECLARE SUB demoCtrl ()

' enter graphic mode 7 (320x200, 1 bit per plane, 4 planes)

SCREEN 7

' draw graphics on screen and set actions
demoGrfx

' set default action for actor(0)
actorSetAction 0, -1          ' set to -1 on first initialization
actorSetAction 0, actHeroWalk ' set action to actHeroWalk (0)
actor(0).x = 320 \ 2          ' center horizontally
actor(0).y = 200 \ 2          ' center vertically

' graphics
DATA 5051050050510500005415000054150000504505005045050054150000541500
DATA A4A75A00A4A75A0000AD7A0000AD7A0000A5DA1A00A5DA1A00AD7A0000AD7A00
DATA E9A9E601E9A9E601409BE601409BE601409B6A6B409B6A6B406BE901406BE901
DATA 79A6660679A66606D0996607D099660790999A6D90999A6DD09AA607D09AA607
DATA 7D5AAA1A7D5AAA1AD0AAAA07D0AAAA07A4AAA57DA4AAA57DD0A69A07D0A69A07
DATA 5DAA55155DAA5515D0555507D05555075455AA755455AA75D0A55A07D0A55A07
DATA 446B9B01446B9B01509BE605509BE60540E6E91140E6E911509EB605509EB605
DATA 00DD9A0100DD9A01409AA601409AA60140A6770040A67700409BE601409BE601
DATA 40B6AA0140B5AA0150ABEA0550ABEA0540AA5E0140AA9E01506BE905506BE905
DATA D0D96A1590DA6A00E4AD7A1AA4AD7A1B00A9A70654A96707B4BD7E1AA4BD7E1E
DATA A467556B407DD505B957951D7456D56E50577D01E955D91AB456951FF456951E
DATA A4DD7A6F40EB7A1B7D7D7D7AAD7D7D7DE4ADEB01F9AD771A74AD7A1BE4AD7A1D
DATA 50546A15406B6A0754A75A7AADA5DA15D0A9E90154A9150550FB6F0550F9EF05
DATA 00DD9F0700D57F0140DFA71554DAF70140FD5700D0F67700409796014096D601
DATA 407AE51A00B55A00D07AA906906AAD0700A55E00A45BAD01D07AA906906AAD07
DATA 50A9A55650A56A05946A55155455A91650A95A05955A6A05946A55155455A916
DATA 5051050000541500005045050054150000541500005415000054150000000000
DATA A4A75A0000AD7A0000A5DA1A00AD7A00009D7600009D7600009D760000041400
DATA E9A9E601409BE601409B6A6B406BE901409966014099660140A96A01001D7D00
DATA 79A66606D099660790999A6DD09AA607D0ABEA07D0ABEA07D05B650040195401
DATA 7D5AAA1AD0AAAA07A4AAA57DD0A69A07D0555507D0555507D0AD7904D1050000
DATA 5DAA5515D05555075455AA75D0A55A0750ABEA0550ABEA0550BB6D0050000004
DATA 446B9B01509BE60540E6E911509EB605509BE60550BBEE055059D90510000000
DATA 00DD9A01409AA60140A67700409BE60150AAAA05546AA9155404900500100000
DATA 00D5AA0150ABEA0540AA5700506BE905546BEA15791BE46D7D00D11500000000
DATA 406A6B00E4AD7A1B00E9A901B4BD7E1EF91D756FFD05507FFD01406904000014
DATA 40F5D505B957D56E50575F01B456951EBD17D47E9407D1165400446B1D000074
DATA 00AD7B077D7D7D7DD0ED7A0074AD7A1D547D7D15400550014010401514000074
DATA 00AD690754A7DA15D0697A0050FBEF0540A7DA014017D4010001D00100001014
DATA 00547F0140DFF70140FD15004097D60140DFF701405FF5014017F40100040400
DATA 00B55A00D07AAD0700A55E00D07AAD07D07AAD07D07AAD07D01AAD0750000005
DATA 50A56A05946AA91650A95A05946AA916946AA916A41EB41AA41EB41AE401501E
DATA 5051050000000054150000000050450500000000000000000000000000000000
DATA A4A75A00000000AD7A00000000A5DA1A00004000000000000000000000000000
DATA E9A9E6010000409BE6010000409B6A6B00009001000000000000000000000000
DATA 79A666060000D0996607000090999A6D0000A401000000000000000000000000
DATA 7D5AAA1A0000D0AAAA070000A4AAA57D0000A401000000000000000000000000
DATA 5DAA55150000D055550700005455AA750000A401000000000000000000000000
DATA 446B9B010000509BE605000040E6E9110000A401000000000000000000000000
DATA 00DD9A010000409AA601000040A677000000A401000000000000000000000000
DATA 50B6AA41000050ABEA15000041AA9E0500549501000000000000000000000000
DATA A4DB6A975515B4AD7A6E5455D6A9E71A00AD7A06000000000000000000000000
DATA A477D599AA6A6456956DA9AA6657DD1A406BE901000000000000000000000000
DATA 50D5BA99AA1AE4757D15A4AA66AE5705D09AA607000000000000000000000000
DATA 40B76A95550590A6DA01505556A9DE01D0A69A07000000000000000000000000
DATA 00D97F460000D0D7F701000091FD6700D0A55A07000000000000000000000000
DATA 506695070000946AA5050000D0569905509EB615000000000000000000000000
DATA B55AAD5A0000555AA9160000A57AA55E409BE619000000000000000000000000
DATA 000000000000401A0000000000000000506BE915000000000000000000000000
DATA 000000000000401A0000000000000000B4BD7E06000000000000000000000000
DATA 000000000000401A0000000000000000B957D501000000000000000000000000
DATA 000000000000401A000000000000000069AD7A01000000000000000000000000
DATA 000000000000401A000000000000000054FB9F01000000000000000000000000
DATA 000000000000400600000000000000004097D601000000000000000000000000
DATA 00000000000000010000000000000000D07AAD06000000000000000000000000
DATA 00000000000000000000000000000000946A5515000000000000000000000000
DATA ""

' action 0: walk (frameCount, viewAngle, animMode, animDelay)
DATA 2, 4, 0, 15
' frames (x, y, w, h, oX, oY)
DATA 0,0,    16,16, 8,15
DATA 16,0,   16,16, 8,15
DATA 32,0,   16,16, 8,15
DATA 48,0,   16,16, 8,15
DATA 64,0,   16,16, 8,15
DATA 80,0,   16,16, 8,15
DATA 96,0,   16,16, 8,15
DATA 112,0,  16,16, 8,15

' action 1: stand still
DATA 1, 4, 2, 400
' frames
DATA 0,16,   16,16, 8,15
DATA 16,16,  16,16, 8,15
DATA 32,16,  16,16, 8,15
DATA 48,16,  16,16, 8,15

' action 2: explode
DATA 4, 1, 1, 5
' frames
DATA 64,16,  16,16, 8,15
DATA 80,16,  16,16, 8,15
DATA 96,16,  16,16, 8,15
DATA 112,16, 16,16, 8,15

' action 3: attack
DATA 1, 4, 0, 400
' frames
DATA 0,32,   24,16, 8,15
DATA 24,32,  16,24, 8,15
DATA 40,32,  24,16, 16,15
DATA 64,32,  16,24, 8,23

''
'' decode graphics(DATA statements, 2 bits per pixel,) configure each action
'' and load frames into the array.
''
SUB demoGrfx
  DIM id AS INTEGER, cnt AS INTEGER, vws AS INTEGER, mde AS INTEGER
  DIM dly AS INTEGER, x AS INTEGER, y AS INTEGER, w AS INTEGER
  DIM h AS INTEGER, oX AS INTEGER, oY AS INTEGER
  DIM s AS STRING, p AS INTEGER, clr(3) AS INTEGER

  ' color lookup
  clr(0) = 0: clr(1) = 8: clr(2) = 15: clr(3) = 7

  ' decode each line (2 bits per pixel)
  DO
    READ s
    IF LEN(s) THEN
      x = 0
      FOR i% = 0 TO LEN(s) \ 2 - 1
        p = VAL("&h" + MID$(s, i% * 2 + 1, 2))
        PSET (x, y), clr(p AND &H3)
        PSET (x + 1, y), clr((p \ 4) AND &H3)
        PSET (x + 2, y), clr((p \ 16) AND &H3)
        PSET (x + 3, y), clr((p \ 64) AND &H3)
        x = x + 4
      NEXT i%
      y = y + 1
    ELSE
      EXIT DO
    END IF
  LOOP

  ' for each action
  FOR id = 0 TO actionCount - 1
    ' read the count, views, mode and delay
    READ cnt, vws, mde, dly
    ' configure
    actionSet id, gImageCnt, cnt, vws, mde, dly
    ' capture all the frames needed for this action
    FOR frm = 0 TO action(id).frameCount * vws - 1
      ' read the coordinates, width, height, and origin
      READ x, y, w, h, oX, oY
      ' store
      getImage x, y, w, h, oX, oY
    NEXT frm
  NEXT id
END SUB

''
'' user input. Call this routine from the game logic loop.
''
SUB demoCtrl
  DIM user AS STRING

  user = INKEY$
  IF (LEN(user) = 1) THEN
    SELECT CASE ASC(user)
    CASE 54 ' 6, look right
      actorSetAngle 0, 0
    CASE 50 ' 2, look down
      actorSetAngle 0, 90
    CASE 52 ' 4, look left
      actorSetAngle 0, 180
    CASE 56 ' 8, look up
      actorSetAngle 0, 270
    CASE 49 ' 1, explode
      actorSetAction 0, actHeroDie
    CASE 53 ' 5, stand still or walk
      IF (actor(0).actIndex = actHeroStand) THEN
        actorSetAction 0, actHeroWalk
      ELSE
        actorSetAction 0, actHeroStand
      END IF
    CASE 48 ' 0, attack
      actorSetAction 0, actHeroAttack
    END SELECT
  END IF
END SUB

And we're finally done. You can download a slightly more advanced demo here featuring multiple actors and animations all at once (it's basically the same code with higher actorCount and actionCount.)

This animation system can be used for anything and covers (almost) every case! Remember that you don't need to create new buffers for images: just shove your static potions, swords, money bags, or dynamic walking zombies, and leaping werewolves in there and configure actions to be either static or dynamic. Identify the behavior of your actors with a variable and process them in the same loop as everything else.

Obviously, the way graphics are loaded and actions are configured is sloppy, you may also need to add an extra variable to the actor structure so you know when an action is done playing (so you can return to another action,) create a routine that uses specific frames to execute certain code blocks, add support for sprites (see GET/PUT article,) or add an angle offset so we can use South and North rather than East and West when only two views exist... but overall, I think this is a solid head start.

The distinction between the game logic and rendering will also make it easier to use double-buffering (drawing on an invisible page of video memory and display it when everything is done drawing,) although it may take some time getting used to.

Have fun!