We're now about to embark on a two part adventure into the mysteries of sprite animation. And as you can imagine, sprite animation is tricky business. In this chapter, we will focus on how sprites are stored. We first looked at the sprite and object structures in Chapter 10, and now we are going to examine them in detail, along with the various arrays and lists that keep them organized. We'll also explore the mechanisms for allocating space to hold the sprite information and detail about how sprite actions are timed. The data structures are complex and a bit convoluted, but they are powerful. They hold all the information needed to perform the high-speed animation in the game. In the next chapter, we will add code to the data structures and finally see how to make a sprite move.
First, though, we need to develop a general strategy for storing and displaying our sprites.
Where shall we store our sprites? Your first impulse may be to put the game sprites in video memory. If so, your second impulse should be to disregard your first one. There's no way we are going find enough video memory to store all of the sprites used in a game. We have already used most of the available video memory for our pages and tiles. There will be some empty corners of video memory left around, but not enough for the sprites. We plan to have a lot of sprites, after all, and we don't want to limit the number of sprites we can use to the tiny amount of video memory we have available.
Our only alternative is to store our sprites in RAM. Unfortunately, we don't have a lot of RAM either. Remember, we are writing this game in DOS real mode. We could choose to use protected mode DOS, in which case we would have plenty of room for sprites, but as of this writing, that is only a semi- practical idea. There are still plenty of computers in the home entertainment market that have 1Mb or less of installed RAM. I expect this to change in the next few years, so if, by the time you read this book, you have access to 8Mb of RAM, then by all means use it. In the mean time, we will design our game to work in 640K DOS memory. We can make it work, we just have to plan carefully, and be conservative in our use of sprites.
In the 256- color modes, a sprite bitmap takes one byte of RAM per pixel. So a bitmap that is 40x40 pixels will require 1,600 bytes of RAM. If a game has a variety of characters in different positions, you can see how bitmaps can eat up RAM in a hurry.
When designing games, remember our golden rule: conserve everything. This especially applies to RAM. Games will expand to fill any amount of RAM you have available. Any room that is left over after the code and sprites have eaten their share will be needed for music and sound effects.
A good way to conserve RAM is to plan your sprites out in advance--before you get too carried away with the details of your game. Try to find ways to optimize the size of your sprites or the number of animation frames you'll need for each of them. For instance, does your menacing scorpion really need a three-inch tail? (Shortening the tail could save you several kilobytes.) Or, does each enemy need a four-stage walk or will a two-stage walk do? (A four-stage walk will look better because the enemy will have more frames of animation and thus appear to be moving more smoothly. In contrast, the two-stage walk won't look as smooth because the enemy will have only two frames of animation.) These are the kinds of sprite design tradeoffs that we need to consider. We can create a few elaborate enemies with smooth motion, or we can have more enemies with choppier motion. These decisions are usually made by trial and error, and that is why it is so important to have a game editor. We can insert and delete sprites into our game conveniently, keeping track of how much space we have, and fine-tune our sprites as we go along.
Stages of Animation
A four-stage walk indicates that a sprite uses four different graphics to achieve a walking effect. Similarly, a two-stage walk indicates only two images are needed to make a sprite walk. In general, a main character sprite uses more stages for each motion, and an enemy sprite can get by with fewer stages. For example, our main character Tommy has a six-stage walk, a four-stage left kick, and a two-stage right kick. The enemy scorpion has a two-stage walk and a one-stage death.
The other way to conserve RAM is to allocate it only as it's needed and free it when we are done with the task. We'll allocate room for our sprites as they appear and we'll only load them when they are used for a level. When we switch levels, we'll free all of the sprite memory and load new sprites for the next level. We'll also allocate objects as we need them. Bullets, for example, can be allocated and freed continuously, since they appear and disappear quite rapidly.
As we saw in Chapter 10, our sprites are stored in structures. But the hierarchy of these structures and the pointers that are used to access them can get a little tricky. Let's work out the details now so that you can see how our coding strategy will allow us to allocate the exact amount of memory at any point during program execution. The Mystery Word "Sprite"
The word sprite actually has two meanings. Generally, a sprite is a character in our story. In our game, Tommy is a sprite, and the pink scorpion he battles with is a sprite. When we're discussing our game code, however, the word sprite has a more precise meaning. It refers to a structure that contains a bitmap. As Figure 12.1 shows, our Tommy character is actually composed of many sprites, one for each stage or frame of animation. For example, to support his six-stage walk, we use six different sprites--one for each frame.
Figure 12.1 Using six different sprites to make Tommy walk.
Since a sprite is essentially a bitmap image, we need to look at how bitmaps are stored and displayed before we move on and implement our structures for storing sprites. Bitmaps are rectangular picture images stored in RAM. But when they show up in a game, they are typically displayed as irregular-shaped objects, such as bullets, walking boys, jumping frogs--you get the idea. So how can we convert our bitmaps from the rectangular world of RAM to the irregular-shaped world of a game? We'll need to use a simple transparency trick.
For example, let's take the case of a bullet bitmap. As Figure 12.2 shows, the bullet bitmap is stored in memory as a rectangular block, however when it is displayed it looks like a bullet and not a square-shaped object. Instead of just storing the bullet as a rectangular set of color pixel values, we include the value 0 to represent a transparent color. When the bitmap is displayed, the non-zero pixels appear and the pixels with a value of 0 are ignored. That means the background art will show through wherever a bitmap has a 0 pixel.
Figure 12.2 Storing a sprite using a transparent color.
In our game, bitmaps are declared as pointers to arrays of type char. They are also declared as far pointers to save room in the default data segment. Here's the declaration from GAMEDEFS.H
char far *bitmap;
This declaration only allocates space for the pointer to a bitmap. The actual space for the data must be allocated at runtime. This is handled in the load_sprite() function, which we'll look at in a minute.
The bitmap data is just part of the information needed to display a sprite. We also need to know the width and height of the bitmap. I find it convenient to store both the bitmap and size specifications together in a data structure. Since this is a data structure we will refer to often, we'll use C's typedef facility to define our SPRITE structure as a data type. Every sprite image in our game will then be stored in a data structure of type SPRITE. The structure actually includes the bitmap pointer (*bitmap) that we just defined as a member. The other members include the width, height, x and y offsets, and extents of the bounding box used for the sprite. Here is the complete structure as defined in GAMEDEFS.H:
typedef struct _sprite { char far *bitmap; int width; int height; int xoffset; int yoffset; int bound_x; int bound_y; int bound_width; int bound_height; } far SPRITE;
Let's look at these members in a little more detail:
To help us access all of the different sprites used in a game, we'll generate an array of pointers to the sprites at compile-time. We're storing the sprite pointers in an array rather than a linked list because we'll be using a fixed number of them, and it is more efficient to address elements of an array than nodes of a linked list. We'll use one large array to hold all of the sprites, which is declared like this:
#define MAXSPRITES 100 SPRITE *sprite[MAXSPRITES];You can find this declaration in the file GAMEDEFS.H. This declaration gives us an array called sprite that holds 100 pointers to structures of type SPRITE. Although the declaration limits us to using 100 sprites or less, this is a reasonably generous number. If you need more sprites, change the value of MAXSPRITES. Don't make the value too big, though. Memory for the sprite array is allocated at compile-time, and it is fixed. There is no reason to use up more RAM than you need to.
The sprite array declaration only allocates room for the pointers to the sprites. It does not allocate room for the spites themselves. Room for the sprite must be dynamically allocated at runtime, in the function load_sprite().
I find it convenient to allocate sprite space when the sprite is read from a file. The function load_sprite() in the file TOMMY.C handles the work of allocating room for bitmaps and sprites, and assigning values to the sprite structure members:
void load_sprite() /* load sprite data from files */ { SPRITE *new_sprite; register int i,j; int n,nbytes; int width,height; int xorg,yorg; int bound_x,bound_y; int bound_width,bound_height; char far *bitmap; if ((stream = fopen(sprite_fname,"rt")) == NULL) { sprintf(abort_string,"%s not found",sprite_fname); terminate_game(); } i = 0; fscanf(stream,"%d",&nspritelists); for (j = 0; j < nspritelists; j++) { fscanf(stream,"%s",list_fname); if ((sprite_stream = fopen(list_fname,"rb")) == NULL) { sprintf(abort_string,"%s not found",list_fname); terminate_game(); } fread(&nsprites,sizeof(int),1,sprite_stream); for (n = 0; n < nsprites; n++) { fread(&width,sizeof(int),1,sprite_stream); fread(&height,sizeof(int),1,sprite_stream); nbytes = width * height; if ((bitmap = (char far *)malloc(nbytes)) == (char *)NULL) { sprintf(abort_string,"out of bitmap memory"); terminate_game(); } if ((new_sprite = (SPRITE *)malloc(sizeof(SPRITE))) == (SPRITE *)NULL) { sprintf(abort_string,"out of sprite memory"); terminate_game(); } sprite[i] = new_sprite; fread(&xorg,sizeof(int),1,sprite_stream); fread(&yorg,sizeof(int),1,sprite_stream); fread(&bound_x,sizeof(int),1,sprite_stream); fread(&bound_y,sizeof(int),1,sprite_stream); fread(&bound_width,sizeof(int),1,sprite_stream); fread(&bound_height,sizeof(int),1,sprite_stream); fread(bitmap,sizeof(char),nbytes,sprite_stream); sprite[i]->bitmap = bitmap; sprite[i]->width = width; sprite[i]->height = height; sprite[i]->bound_x = bound_x; sprite[i]->bound_y = bound_y; sprite[i]->bound_width = bound_width; sprite[i]->bound_height = bound_height; sprite[i]->xoffset = 0; sprite[i]->yoffset = 0; i++; } fclose(sprite_stream); } fclose(stream); /* assign the sprites to some more meaningful names */ j = 0; for (i = 0; i < STANDFRAMES; i++) tom_stand[i] = sprite[j++]; for (i = 0; i < RUNFRAMES; i++) tom_run[i] = sprite[j++]; for (i = 0; i < JUMPFRAMES; i++) tom_jump[i] = sprite[j++]; for (i = 0; i < KICKFRAMES; i++) tom_kick[i] = sprite[j++]; for (i = 0; i < SHOOTFRAMES; i++) tom_shoot[i] = sprite[j++]; for (i = 0; i < SCOREFRAMES; i++) tom_score[i] = sprite[j++]; for (i = 0; i < ENEMYFRAMES; i++) enemy_sprite[i] = sprite[j++]; }
The load_sprite() function opens the sprite list files that were created in the sprite editor. For each sprite in the sprite list, we must allocate room for both the sprite structure and the bitmap. The space needed for the sprite structure is allocated like this:
new_sprite = (SPRITE *)malloc(sizeof(SPRITE));
This call to malloc() only allocates room for the structure, not for the data the structure contains. That is, 20 bytes are allocated to hold the pointer to the bitmap, the sprite's width and height, and so on. To allow enough room for the sprite's bitmap data, we need to call malloc() again. The size of the bitmap is its width multiplied by its height. We must read the width and height from the sprite file, and then use malloc() to allocate memory for the bitmap:
fread(&width,sizeof(int),1,sprite_stream); fread(&height,sizeof(int),1,sprite_stream); nbytes = width * height; if ((bitmap = (char far *)malloc(nbytes)) == (char *)NULL) { sprintf(abort_string,"out of bitmap memory"); terminate_game(); }
If for some reason we are unable to allocate enough room for the bitmap, we exit the program gracefully with a call to terminate_game().
After space for the sprite and the bitmap are allocated, we can read the values from the file and assign the appropriate values to the sprite as shown here:
sprite[i]->bitmap = bitmap; sprite[i]->width = width; sprite[i]->height = height; sprite[i]->bound_x = bound_x; sprite[i]->bound_y = bound_y; sprite[i]->bound_width = bound_width; sprite[i]->bound_height = bound_height; sprite[i]->xoffset = 0; sprite[i]->yoffset = 0; i++;
This process is repeated for each sprite until all the sprite data is stored in structures, which are pointed to by elements in the sprite array.
Since we have several dozen (up to 100) sprites, we will find it helpful to give some of them more meaningful names. For convenience, we'll declare some pointers in GAMEDEFS.H that represent locations into the array so that we can easily reference selected sprites:
#define STANDFRAMES 3 #define RUNFRAMES 6 #define JUMPFRAMES 4 #define KICKFRAMES 8 #define SHOOTFRAMES 7 #define SCOREFRAMES 3 #define ENEMYFRAMES 6 DECLARE SPRITE *tom_stand[STANDFRAMES]; DECLARE SPRITE *tom_run [RUNFRAMES]; DECLARE SPRITE *tom_jump [JUMPFRAMES]; DECLARE SPRITE *tom_kick [KICKFRAMES]; DECLARE SPRITE *tom_shoot[SHOOTFRAMES]; DECLARE SPRITE *tom_score[SCOREFRAMES]; DECLARE SPRITE *enemy_sprite[ENEMYFRAMES];
These declarations correspond to six sprite lists that define Tommy's motions and the sprite list holding Tommy's enemies. Tommy has three standing frames (with different facial expressions), six running frames, four jumping frames (including shooting while jumping), eight kicking frames (including a two-stage forward kick and a six-stage backward spin-kick), and seven shooting frames (including shooting while standing, shooting while walking, recoil, and a bullet sprite). The score sprite list includes sprites for the scoreboard, the energy bar, and the one-ups. The enemy sprite list includes two motion frames and a death frame for both the grasshopper and the scorpion. We give these sprite lists logical names and assign them to the appropriate sprite array elements. We place the code to accomplish this task toward the end of the load_sprite() function:
/* assign the sprites to some more meaningful names */ j = 0; for (i = 0; i < STANDFRAMES; i++) tom_stand[i] = sprite[j++]; for (i = 0; i < RUNFRAMES; i++) tom_run[i] = sprite[j++]; for (i = 0; i < JUMPFRAMES; i++) tom_jump[i] = sprite[j++]; for (i = 0; i < KICKFRAMES; i++) tom_kick[i] = sprite[j++]; for (i = 0; i < SHOOTFRAMES; i++) tom_shoot[i] = sprite[j++]; for (i = 0; i < SCOREFRAMES; i++) tom_score[i] = sprite[j++]; for (i = 0; i < ENEMYFRAMES; i++) enemy_sprite[i] = sprite[j++];
Having multiple names for sprites is not wasteful. We are not declaring duplicate space for the sprites, merely duplicate pointers. The pointers only take a couple of bytes each. This tiny extravagance in wasted space is well worth the savings in terms of more readable (and therefore more easily debugged) code.
The sprite structures for Tommy are allocated once at the beginning of the game (load-time) and are not freed until the end of the game. Certain other sprites are also present throughout the game, including the scoreboard and the bullets. This might sound like a contradiction to what we said earlier--that bullets are constantly being allocated and freed--but it's not. They are two different kinds of structures. The bullet as a sprite is allocated once. The bullet as an object is allocated and freed many times.Recall that the object structure is declared like this in GAMEDEFS.H:
DECLARE struct OBJstruct; /* forward declarations */ typedef struct OBJstruct OBJ, far *OBJp; typedef void near ACTION (OBJp objp); /* pointer to action function */ typedef ACTION near *ACTIONp;typedef struct OBJstruct /* object structure */ { OBJp next; /* linked list next node */ OBJp prev; /* linked list previous node */ int x; /* x coordinate */ int y; /* y coordinate */ int xspeed; /* horizontal speed */ int yspeed; /* vertical speed */ int direction; /* LEFT or RIGHT */ int tile_xmin; /* tile limits */ int tile_xmax; int tile_ymin; int tile_ymax; int frame; /* frame of animation */ unsigned long time; /* time */ SPRITE *sprite; /* pointer to sprite */ ACTIONp action; /* pointer to action function */ };
This is a pretty scary-looking declaration! It involves a forward declaration, a pointer to a near function, a linked list, a pointer to a sprite structure, and a bunch of integer values. Seeing a declaration like this is enough to make you wonder if you should quit programming and join a rock band. But don't let the object declaration throw you off. Well-designed data structures are going to save us a lot of code later on. This is the worst one, and once you've conquered it, the rest are going to be easy.
Let's explore the members of the OBJstruct in more detail:
#define LEFT 0 #define RIGHT 1
The direction of the object determines whether the sprite is displayed in a regular or flipped format. Some sprite motion depends on the direction, such as whether Tommy does a forward kick or a backward spin-kick. Also, when Tommy shoots, his direction determines the initial direction of the bullet.
Figure 12.3 Objects structures and sprite structures.
The OBJstruct structure is declared to be a data type of type OBJstruct using C's typedef facility. This is done in the forward declaration:
DECLARE struct OBJstruct; typedef struct OBJstruct OBJ, far *OBJp;
The OBJp data type is a pointer to a far structure of type OBJ. We will find the OBJp data type to be very useful to us. All our objects are described this way. We will use this label when accessing structure members, when passing objects to action functions, and when traversing the linked list of objects. Though the declaration is cumbersome, using the object pointer is easy. All our objects will be declared as object pointers. Let's look at how Tommy is declared:
DECLARE OBJp player;
That's all there is to it! Tommy is declared. He still needs to be allocated and initialized, but his declaration is handled in one line of code in DEFS.H. This file is where Tommy is born.
Note that I call Tommy "player." This is how we refer to Tommy throughout the source code, as the player or the main player sprite. This was done in the spirit of re-usable code--you may want to use the game engine for a different game, and you won't want to rename all the data structures and functions. In the Quickfire demo, for example, the player is an airplane, but the declarations and some of the action functions are the same.
It is convenient to declare a few other object pointers in DEFS.H. Since we know we are going to be traversing a linked list, let's declare pointers to the top and bottom nodes:
DECLARE OBJp top_node, bottom_node;Also, we know the scoreboard is going to be an object, so we can declare that too:
DECLARE OBJp score;We are going to need some enemies. We know we will have at least two, and we may want to add some later. Let's declare pointers to five.
#define MAXENEMIES 5 DECLARE OBJp enemy[MAXENEMIES];
That takes care of the global object declarations. We will be declaring some other objects later, but those will either be local variables, or they will be dynamically allocated at runtime. We have enough global objects declared to keep us organized.
Like the sprite structures, the object structures must be allocated before they can be used. Enemies and bullets are allocated as needed. We will look at code to launch bullet and enemy objects in Chapter 13. Tommy also needs to be allocated. He gets allocated just once, at the beginning of the game. You will find Tommy's allocation code in function main() in the file TOMMY.C. Here is what it looks like:
player = (OBJp)malloc(sizeof(OBJ)); /* allocate the player */After Tommy is allocated, we can assign values to his structure members. For example, we assign these values to his tile extremities:
player->tile_xmin = 4; player->tile_xmax = 14; player->tile_ymin = 5; player->tile_ymax = 11;
These lines are indicate Tommy's tolerance area. When Tommy is standing on a tile that is closer than 4 tiles away from the left border, or farther than 14 tiles away, it is time to scroll the level either to the left or the right to keep Tommy within his tolerance area. Similarly, when Tommy is fewer than 5 tiles from the top of the screen or more than 11 tiles away, it is time to scroll the level up or down.
Other initializations are done at the beginning of each level:
player->x = 32; /* initialize player */ player->y = 200; player->frame = 0; player->time = 0; player->xspeed = 0; player->yspeed = 0; player->sprite = tom_stand[0]; player->direction = RIGHT; player->action = player_run;
This code tells us Tommy is starting out at a position (x=32, y=200) in world space. His initial speed is 0. His initial image is frame 0 of the tom_stand[] sprite list. His initial action function is player_run().
Sprite animation requires careful timing. We are going to need a precise timing resolution for our game, which means we will need to speed up the system clock. The only practical way to do this is to re-vector one of the PC's interrupts. This involves a little more programming muscle in the form of "lean and mean" assembly code. If you are not an assembly-language programmer, don't worry about it. You should be able to use the assembly code we'll present without modifications. For the purposes of this book, we are more interested in how the timer function can be used in our game than how the assembly- language code actually works.
Usually, when you write a high-speed arcade game, you want it to run at approximately the same speed on many different computers. While it's convenient to base animation speed on the real-time clock, a problem occurs when the clock increments at a rate slower than the frame rate. Suppose, for example, that we are animating at 25 frames per second. Since, by default, the clock only increments 18.2 times per second, we'll have frames in which no clock tick occurs. If we base our animation on incrementing clock ticks, we will have whole frames of animation in which nothing happens.Here's an example to consider. Think of a sprite falling off a ledge. The longer it falls, the more its speed increases as shown in Figure 12.4. First, it falls one pixel per clock tick. Then, it falls two pixels per clock tick and then three pixels per clock tick, and so on. This continues until it reaches a maximum speed of 10 pixels per clock tick. The fall looks realistic because the sprite has acceleration at the top of the fall and terminal velocity at the bottom. But if the clock-tick resolution isn't granular enough, the sprite is going to miss frames and the motion will look jerky. Speeding up the clock allows us to smooth out the curve.
Figure 12.4 Animating a falling sprite.
The BIOS clock-tick interrupt normally occurs 18.2 times per second and is implemented through interrupt 08 hex. The interrupt handler assigned to interrupt 08 hex performs two important tasks. First, it increments the BIOS time-of-day clock value (the time-of-day value represents the number of clock ticks since midnight). Second, it calls interrupt 1C hex just before exiting. Interrupt 1C hex is a user-definable interrupt whose default handler is simply an IRET (interrupt return) instruction. If you supply your own handler for interrupt 1C hex, it will automatically be executed every time interrupt 08 hex is issued (that is, 18.2 times per second). This process is often called hooking the clock-tick handler. Figure 12.5 illustrates the default relationship between these interrupt vectors.
Figure 12.5: Relationship between 1c hex and 08 hex.
< ----------- INT 08h | . | BIOS clock-tick interrupt | . | invoked 18.2 times per second | . | | int 1Ch | | iret | ----------- INT 1Ch ----------- User-definable interrupt vector | iret | called at end of INT 08h -----------It's possible to make the clock-tick interrupt occur at rates faster than 18.2 times per second to achieve a higher timing resolution. Accelerating the clock is itself rather straightforward, but we must re-vector an interrupt if we want the time of day clock to remain accurate.
After we accelerate the clock, interrupt 08 hex will be called more often. For example, if we double the clock-tick rate, interrupt 08 hex will be called 36.4 times per second instead of its usual 18.2 times per second.
As I mentioned earlier, the interrupt 08 hex handler updates the BIOS time-of-day value. If we simply multiply the clock-tick interrupt rate by eight, the value will be incremented eight times as often as before and the time of day clock will be running at eight times its normal speed. This time warp is undesirable because midnight will occur much sooner than we expected, and at midnight our computer turns into a pumpkin, the real-time clock becomes a pair of white mice, and disk accesses become as elusive as a lost glass slipper. Let's avoid this particular trip into the Twilight Zone by managing our clock interrupts carefully.
What we need to do is write a replacement handler for interrupt 08 hex that calls the original BIOS time-of-day interrupt only when needed to update the real- time clock. If we wanted to double the clock-tick rate, for example, our new interrupt handler would call the original time-of-day handler every other invocation and thus keep the clock accurate. The general scheme is to save the address of the original interrupt 08 hex vector and then have our new interrupt 08 hex handler pass control to the original interrupt vector when it must update the time-of- day clock. After doing this, our interrupt vectors will look like the ones shown in Figure 12.6.
Figure 12.6 Re-vectored interrupt.
----------- INT 08h | . | Replacement handler for BIOS clock-tick interrupt | . | invoked 36.4 times per second | . | | call | Every other invocation, call original 08h to | original| update the time of day clock and call INT 1Ch | int 08h | | | | int 1Ch | On alternate invocations, just issue INT 1Ch | iret | ----------- INT 1Ch ----------- User-definable interrupt vector | iret | called at end of original INT 08h ----------- ----------- | . | BIOS clock-tick interrupt | . | originally at INT 08h | . | | int 1Ch | | iret | -----------
Note that if we've defined a handler for interrupt 1C hex, it will now be called 36.4 times per second. Half of these calls will be issued through the original BIOS time-of-day handler, but we must explicitly issue the other interrupt 1C hex calls through our replacement handler. One other point worth mentioning is how the original interrupt 08 hex code is executed. As it is an interrupt routine, the original interrupt 08 hex code ends with an IRET instruction, but the code is no longer callable as an interrupt (because its vector isn't in the DOS interrupt table). Hence, we simulate the execution of an INT instruction by pushing the flags register and then branching to the original interrupt 08 hex code using a far call. In essence, that mimics the behavior of an INT instruction.
The file TIMER.ASM includes an assembly-language function callable from C or C++ to manage the clock-tick interrupt rate and its associated interrupts:
; TIMER.ASM ; ; Copyright (c) 1993-1994 Ted Gruber Software. All rights reserved. ; ; This is a C or C++-callable function illustrating a simple way to ; change the BIOS clock-tick interrupt rate under DOS while maintaining ; an accurate time of day clock value. ; ; To accelerate the clock-tick interrupt rate, use ; ; set_rate(factor); ; ; where "factor" represents the acceleration factor for the clock-tick ; rate. For example, to quadruple the clock-tick rate, specify a factor ; of 4. If the clock-tick rate is already accelerated, nothing further ; happens. ; ; To revert to the normal clock-tick rate, use ; ; set_rate(0); ; ; If the clock is already running at its normal rate, nothing happens. ; You must restore the normal clock-tick rate before your program exits ; to DOS. ; ; This function is written for the medium or large memory models. ; It can be modified to support the small memory model if you do the ; following: ; ; - Change the segment name from "time_TEXT" to "_TEXT". ; - Change the "far" reference in the PROC declaration to "near". ; - Change the arg1 definition from "[bp+6]" to "[bp+4]". ; ;*********************************************************************** rate_TEXT SEGMENT byte public 'CODE' ASSUME cs:rate_TEXT _set_rate PROC far PUBLIC _set_rate arg1 equ [bp+6] ; address of the function's argument chan_0 equ 40h ; port address for channel 0 cmd_reg equ 43h ; port address for command register fp equ <far ptr> ; shorthand for far pointer override push bp ; save caller's BP register mov bp,sp ; make BP point to argument list mov dx,arg1 ; DX = clock acceleration factor cmp dx,1 ; restore clock to normal rate? jle regular ; yes, go do it accel: cmp cs:speed,1 ; clock already accelerated? jg return ; yes, nothing more to do mov cs:speed,dx ; set speed indicator to accelerated mov cs:countdown,dx ; initialize the timer countdown value call fast ; accelerate clock tick interrupt rate jmp short return ; and return to the caller regular: cmp cs:speed,1 ; clock already at normal speed? jle return ; yes, nothing to do mov cs:speed,dx ; set speed indicator to normal call normal ; restore clock to 18.2 ticks/second return: xor ax,ax ; set function return value to zero pop bp ; restore caller's BP register ret ; return to the caller countdown dw ? ; clock-tick interrupt countdown value old_int08 dd ? ; address of original INT 08h handler speed dw 0 ; clock acceleration factor _set_rate ENDP ;----------------------------------------------------------------------- fastclock PROC far ; interrupt handler to replace INT 08h push ax ; save altered registers dec cs:countdown ; decrement the countdown value jz blastoff ; update time when countdown expires int 1Ch ; otherwise just call interrupt 1Ch mov al,20h out 20h,al ; re-enable lower-level interrupts pop ax ; restore altered registers iret ; go back where we came from blastoff: pushf ; simulate next call as an interrupt call cs:[old_int08]; call original clock tick interrupt mov ax,cs:speed ; AX = clock acceleration factor mov cs:countdown,ax; reset countdown value mov al,20h out 20h,al ; re-enable lower-level interrupts pop ax ; restore altered registers iret ; go back where we came from fastclock ENDP ;----------------------------------------------------------------------- fast PROC near ; accelerate the clock by a factor of DX cli ; disable interrupts xor ax,ax ; zero AX mov es,ax ; point ES to start of memory mov bx,08h*4 ; interrupt vector 08h mov ax,es:[bx] ; put the interrupt vector offset in AX mov cx,es:[bx+2] ; put the interrupt vector segment in CX lea bx,old_int08 ; where to save original INT 08h handler mov cs:[bx],ax ; save original INT 08h offset mov cs:[bx+2],cx ; save original INT 08h segment mov bx,08h*4 ; interrupt vector 08h lea ax,fastclock ; CS:AX = addr of accelerated clock handler mov es:[bx],ax mov es:[bx+2],cs ; point INT 08h to the new handler mov al,36h ; initialize channel 0 for mode 3 out cmd_reg,al ; send above byte to command register mov bx,dx ; BX = the clock acceleration factor mov dx,1 xor ax,ax ; DX:AX = 65,536 div bx ; AX = counter for desired acceleration out chan_0,al ; send low byte of counter to channel 0 mov al,ah ; put high byte of counter in AL out chan_0,al ; send high byte sti ; re-enable interrupts ret fast ENDP ;----------------------------------------------------------------------- normal PROC near ; reset clock to 18.2 ticks/second cli ; disable interrupts mov al,36h ; initialize channel 0 for mode 3 out cmd_reg,al ; send above byte to command register xor ax,ax ; counter for 18.2 ticks per second mov es,ax ; point ES to start of memory out chan_0,al ; send low byte of counter to channel 0 out chan_0,al ; send high byte (same as low byte) lea bx,old_int08 ; address of original INT 08h handler mov ax,cs:[bx] ; AX = original INT 08h offset mov cx,cs:[bx+2] ; CX = original INT 08h segment mov bx,08h*4 ; interrupt vector 08h mov es:[bx],ax mov es:[bx+2],cx ; restore original INT 08h vector sti ; re-enable interrupts ret normal ENDP ;----------------------------------------------------------------------- rate_TEXT ENDS END
Now the time of day clock is still updated 18.2 times per second, but interrupt 1C occurs eight times per clock tick. The following code re-vectors interrupt 1C hex to increment our own internal timer, which is available to our program as a global variable.
#include <dos.h> void interrupt increment_timer(void); void interrupt (*oldhandler) (void); long game_time;void main() { unsigned long time1, time2; /* get the current interrupt vector for 0X1C */ oldhandler = _dos_getvect(0x1C); /* re-vector the interrupt to myu function */ _dos_setvect(0x1C,increment_timer); /* initialize the global */ game_time = 0; /* speed up the clock rate */ set_rate(8); /*** do the game ***/ /* slow down the clock rate to normal */ set_rate(0); /* put the interrupt back the way it was */ _dos_setvect(0x1C,oldhandler); exit(0); } /*****************************************************************/ void interrupt increment_timer() { game_time++; }Now we have a global value, game_time, which is incrementing nice and fast. We can determine the elapsed time down to a fine resolution and gauge our animation accordingly. This accuracy is convenient for our game, but there are still some other considerations to watch for. Disk drive accesses are often dependent on the clock interrupts, so you don't want to read or write a file while the clock rate is accelerated. Be sure to return the clock rate to normal before each disk access. Also, some music and sound drivers speed up the clock interrupt. You'll want to coordinate your clock speed with whatever the music or sound library expects. Eight times the normal clock speed is pretty standard for games, but if you're not sure, check the documentation for your music and sound drivers.
We now have our sprites and objects right where we want them. Sprites are stored in SPRITE structures, along with the information that applies to them: bitmap data, width, height, and so on. SPRITE structures are stored in a sprite array and also may be referred to by arrays with convenient logical names like tomstand. Objects are stored in object structures, along with information needed to describe fully describe them: position, image, action function, and so on. These we will keep in a linked list so we can conveniently keep track of a variable number of them and process each object each frame. All the sprites and objects are properly declared and allocated. We have also taken control of the clock interrupt and we are prepared to time our sprite motion with proper precision. Things seem to be well under control. It's time to make our sprites move.
Cover |
Contents |
Downloads
Fastgraph Home Page |
books |
Magazine Reprints
Copyright © 1998 Ted Gruber Software Inc. All Rights Reserved.
Awards |
Acknowledgements |
Introduction
Chapter 1 |
Chapter 2 |
Chapter 3 |
Chapter 4 |
Chapter 5 |
Chapter 6
Chapter 7 |
Chapter 8 |
Chapter 9 |
Chapter 10 |
Chapter 11 |
Chapter 12
Chapter 13 |
Chapter 14 |
Chapter 15 |
Chapter 16 |
Chapter 17 |
Chapter 18
Appendix |
License Agreement |
Glossary |
Installation Notes |
Home Page
So you want to be a Computer Game Developer