At the end of part 5, I promised to make our sprite move around the screen this time.
To that end, in the code for part 6, I’ve added a call to new
MovePlayer routine to the main loop.
17 18 19 20 21 22 23
; main.asm Loop: ; of a game and the start of the next one halt ; Wait until the next 50th second frame call AnimateDemo ; Animate our monster call MovePlayer ; Move up/down/left/right jp Loop ; Go into an endless loop (for now...)
MovePlayer routine is fairly long. Let’s split it into two parts:
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
; sprites.asm MovePlayer proc ld de, 0 ; d (vertical) and e (horizontal) will hold -2/0/+2 movement offsets ld bc, zeuskeyaddr("OP") ; Get the I/O port address for O (left) and P (right) in a, (c) ; Read keys ld b, a ; Save value for Right check and zeuskeymask("O") ; Mask out everything but O jp nz, Right ; If result is non-zero O was not pressed, so check Left key ld e, -2 ; otherwise set horizontal offset to -2 jp Up ; and skip Left key check Right: ld a, b ; Retrieve Left/Right keypress reading and zeuskeymask("P") ; Mask out everything but P jp nz, Up ; If result is non-zero P was not pressed, so check Up key ld e, +2 ; otherwise set horizontal offset to +2 Up: ld bc, zeuskeyaddr("Q") ; Get the I/O port address for Q (up) in a, (c) ; Read keys and zeuskeymask("Q") ; Mask out everything but Q jp nz, Down ; If result is non-zero Q was not pressed, so check Down key, ld d, -2 ; otherwise set vetical offset to -2. Down: ld bc, zeuskeyaddr("A") ; Get the I/O port address for A (down) in a, (c) ; Read keys and zeuskeymask("A") ; Mask out everything but A jp nz, Move ; If result is non-zero Q was not pressed, so go ahead ld d, +2 ; otherwise set vetical offset to +2 Move:
Hopefully the code is commented well enough to understand it. All you really need to get is that the QAOP keys are read, then some vertical and horizontal directional offsets are calculated, having values -2, 0 or +2 depending on which keys were pressed.
This means we will be moving around the screen in steps of two pixels. Why not steps of one pixel, you ask? Well, the nature of NIRVANA+ with its 8×2 attributes makes it harder to move vertically in one pixel steps—we could do it, but the colours of the sprite tile would probably get messed up in the odd-numbered pixel lines. This is essentially attribute clash, but on a two pixel grid instead of the classic eight pixel grid.
We can certainly move horizontally in one pixel steps, but it would create an imbalance in most games to move at half the speed horizontally than you did vertically. So, as a design decision, I’ve made both axes move in steps of two pixels.
The second half of the routine is concerned with calculating the coordinates for the player sprite (now sprite B). It also introduces a second blank sprite (sprite A).
It’s important to know that NIRVANA sprites are drawn every frame, in their last position. But they’re never undrawn—you have to do that part yourself. It’s a pretty flexible way of doing things, and opens up a few techniques for doubling or tripling the effective number of sprites you have available. In our case, though, we want to blank out the trails our sprite leaves as it moves around. In this demonstation, the simplest way to do that is with a second blanking sprite.
Remember earlier I said we have eight available “for free” as part of standard NIRVANA+)? In fact that was for square btiles; only five wtiles can be drawn “for free”. For now this doesn’t matter, as we’re still exploring concepts and options. We will look at some ways to break past this limit later. Here’s the code1:
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
; sprites.asm Move: Y equ $+1: ld a, SMC ; <SMC Read the previous player vertical position ld (Sprites.ALine), a ; and set the blank sprite vertical position to this. add d ; Then add the vertical offset (-2/0/+2) cp -2 ; Check if we went negative jp nz, MaxY ; If not, carry on, xor a ; otherwise set vertical position to 0 (the minimum). MaxY: cp 200 ; Check if we went below the bottom of the screen jp c, SetY ; If not, carry on, ld a, 200 ; otherwise set vertical position to 200 (the maximum). SetY: ld (Sprites.BLine), a ; Set the player sprite to the new verified vertical position ld (Y), a ; and also save it for next time (for the blank sprite ; to be drawn underneath the player sprite) X equ $+1: ld a, SMC ; <SMC Read the previous player horizontal position call CalculatePlayerX ; Call the routine to calculate the column and tile offset ex af, af' ; The column is returned in a' ld (Sprites.AColumn), a ; Set column for blank sprite (based on previous X position) ld a, b ; CalculatePlayerX saved the previous X position in b, restore it add a, e ; then add the horizontal offset (-2/0/+2) cp -2 ; Check if we went negative jp nz, MaxX ; If not, carry on, xor a ; otherwise set vertical position to 0 (the minimum). MaxX: cp 238 ; Check if we went beyond the right of the screen jp c, SetX ; If not, carry on, ld a, 238 ; otherwise set vertical position to 238 (the maximum). SetX: ld (X), a ; Save it for next time (for the blank sprite underneath) call CalculatePlayerX ; Call the routine again for the new player X position AnimOffset equ $+1: add a, SMC ; The X offset is returned in a, add the animation offset from AnimateDemo ld (Sprites.BIndex), a ; Set tile index for player sprite (current position) ex af, af' ; The column is returned in a' ld (Sprites.BColumn), a ; Set column for player sprite (current position) ret pend
This second half of the routine does bounds checks to stop the sprite going off the screen. If we didn’t, we’d crash the program quite badly! The reason for this is that NIRVANA+’s multicolour attribute data is interspersed tightly with the code that draws the attributes. If we start going off the map, we will overwrite important code pretty soon.
This routine calls another routine called
CalculatePlayerX, twice—once for the blanking sprite and once for the player sprite. I’ve done this to try and reduce duplicated code, and the calculations for both are more or less the same (except the blanking sprite always uses the same tile index). This is the routine:
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
; sprites.asm CalculatePlayerX proc ; X coordinate is passed in a (0.255) ld b, a ; Save the X coordinate for later and %11111000 ; Round it to the nearest eight rra ; then rra ; divide rra ; by eight to get the column (0..31). ex af, af' ; Column is returned in a' ld a, b ; Get the X coordinate back and %00000110 ; Get the remainder after rounding it to the nearest eight rra ; then divide that by two to get the tile index (0/2/4/6)/ add a, WTile.Monster ; Tile index is returned in a ret pend
This does something interesting. Remember that we made eight monster tiles—four for each animation cycle in part 5?
Notice that we’re storing our X coordinates in the range from 0 to 255. But NIRVANA+ actually takes a column number in the range 0 to 31 for all its sprite and tile operations. One way to look at this is like a fraction—the column is the whole number part, and the pixel position within the column (between 0 and 7) is the fractional part. Actually, this is a fraction, albeit a binary2 one!
It turns out that the bottom-most three bits of the X coordinate hold the fractional part—the maximum value three bits can hold is
%111, or decimal
7, which is our maximum pixel position within the column. This means that the top-most five bits contain the column number.
In order to separate out these two numbers from the X coordinate byte, all we need are a few bitwise operations. The column number can be obtained by dividing by eight. Dividing by powers of two is easy in Z80, using one of the shift-right opcodes. We can also do it, faster, with the rotate-right opcodes—provided none of the bits being shifted out contain
1s (otherwise they’ll be shifted back in at the left hand side, giving a number larger than 31!) I’m ensuring this doesn’t happen with and
%11111000, which clears the bottom-most three bits before rotating them.
Obtaining the fractional part is easier—we just have to mask the X coordinate with
and %00000110, to clear all the bits apart from the bottom three3. If we had seven tiles, each shifted a single pixel, this would be perfect. But we’re moving in two pixel steps, so we need to divide this by two with
rra. Notice that there isn’t any remainder4 when dividing by a power of two in Z80—we’re effectively rounding down after dividing, which is exactly what we need here!
This preshifting technique is quite common in 8-bit graphics programming. Generally, it makes a compromise between speed (all those shifted versions could be calculated on the fly, but it would be slow) and size (four or eight times the number of tiles!). R-Tape has written a more comprehensive tutorial about preshifting and sprite animation. You may find this interesting—it doesn’t relate specifically to NIRVANA, but the techniques are the same.
With the column number (0..31) and tile offset (0/2/4/6) we have everything we need to calculate the correct tile to make it look as if we’ve moving in two pixel steps. The only thing remains is to add in the animation cycle offset (0 or 4). I’ve modified the
AnimateDemo routine (which gets called before
MovePlayer every frame) to calculate this offset and write it directy into the
3 4 5 6 7 8 9 10 11 12 13 14 15
; sprites.asm AnimateDemo proc ld a, (FRAMES) ; Read the LSB of the ROM frame counter (0.255) and %00000111 ; Take the lowest 3 bits (effectively FRAMES modulus 8), ret nz ; and return 7 out of every 8 frames. ld a, (MovePlayer.AnimOffset) ; For every 8th frame, read player's tile offset, xor %00000100 ; alternate between (0 => 4 => 0 => 4 => etc), ld (MovePlayer.AnimOffset), a ; SMC> then save it back. ret pend
You should be able to see that the place it writes it into isn’t strictly speaking a piece of data; it’s the operand of a
ld a, N opcode:
75 76 77
; sprites.asm AnimOffset equ $+1: add a, SMC ; The X offset is returned in a, add the animation offset from AnimateDemo
For this reason, the technique is usually called self-modifying code. I try and maintain the convention that code doing the modifying is marked with
SMC> in the comments, and the code getting modified is marked with
<SMC. The technique doesn’t work everywhere (in ROM code, for example), and it can make debugging much harder. But it’s very useful when well-controlled. I’ve defined a
SMC equ 0 constant, which lets me semantically mark the code too. If I’ve written things correctly, the value always gets modified to it’s correct starting value during setup, or else having it start at zero is harmless.
The only other change I’ve made is to add the blank tile:
21 22 23 24
; database.asm Blank equ ($-WTile)/Sprites.BTileLen import_bin "..\tiles\blank.wtile" ; 008-008 1 x 1 Blank
This is, as you’d expect, a tile with no pixels, having black ink and paper colours. How does this so neatly erase the previous position of the player sprite? Well, NIRVANA+ sprites have an order of precedence, or z-order. Sprite A appears underneath the other sprites, and Sprite E (or H when we’re using btiles) appears on top of everything else. Sprites can partially or fully overlap, so this takes care of the messiness. It’s not a particularly good use for a scarce resource, but we’re still just exploring techniques here. When we find we’ve used all the sprites and still need more, let’s revisit this.
If you Assemble Then Emulate the part 6 code, you should be able to move our monster around the screen with the QAOP keys:
I’ve set the vertical limits (0 and 200) to let the sprite to go completely off the top and bottom of the screen. NIRVANA+ allows this because it allocates a small buffer of bytes either side of the attribute data. You can’t do the same thing with the horizontal axis, however NIRVANA (without the plus) has an 8 pixel buffer on the left and right sides. It does this at the expense of only having 30 columns of multicolour data instead of 32. This works really well for some game designs, so the choice is yours which to use. In the main, NIRVANA works pretty much the same as NIRVANA+.
If you want to keep the sprite onscreen at all times, you can adjust the vertical limits by 16:
46 47 48 49 50 51 52 53 54 55 56 57 58
; sprites.asm Y equ $+1: ld a, SMC ; <SMC Read the previous player vertical position ld (Sprites.ALine), a ; and set the blank sprite vertical position to this. add d ; Then add the vertical offset (-2/0/+2) cp 14 ; Check if we went negative jp nz, MaxY ; If not, carry on, ld a, 16 ; otherwise set vertical position to 0 (the minimum). MaxY: cp 184 ; Check if we went below the bottom of the screen jp c, SetY ; If not, carry on, ld a, 184 ; otherwise set vertical position to 200 (the maximum). SetY:
That’s it for part 6! Next time I will be giving some thought to the type of game we will make (the clue just might be in the name…)
- I should have said earlier, please excuse the syntax highlighter I’m using. It’s not quite good enough to do Z80 properly, and not nearly good enough to capture some of the possible Zeus syntax. Anyway, I guess it’s still better to read than uniform black assembly language code, so please try and ignore the anomalies!
- Or even a base 8 fraction…
- Actually just the even-numbered part of the bottom three bits, because we’re just about to divide the result by two anyway, and this stops any fractional remainder getting into the Carry flag—see the next footnote.
- Actually there is a remainder, it’s called the Carry flag! With binary division, there are only two possible remainder values,
1, so it can be represented in a flag bit. For our purposes, the remainder is very easy to ignore.