The todo list for making the game complete is relatively short. I won't be showing every single code change this time, as things are, by now, rather repetitive and tedious, but overall the changes are as follows:
Getting the Gems
The most interesting changes orbit around getting the gem and entering the door. Let's start with the gems.
moveok_gem:
ld (playerpos), hl
ld hl, snd_gemget
call playsound
Let's start with a sound effect. Note that we deal with the HL register first, so as not to lose its value.
ld a, (gems)
inc a
ld (gems), a
ld l, a
ld a, (map.gemgoal)
cp l
jp nz, movedone
Next we increment the gem count and check if it matches the goal. If not, carry on.
ld hl, snd_door
call playsound
ld hl, map
ld b, 16*12
ld a, 7
doorloop:
cp (hl)
jr nz, notdoor
ld (hl), 0x88
notdoor:
inc hl
djnz doorloop
jp movedone
If we have a match, we play a sound effect and replace all doors with open ones (along with the dirty bit). In retrospect I should have made a "replace all X with Y" function, as we'll be doing this a couple of times.
In the loop we use a new opcode - DJNZ - which decrements B and stops looping if B hits zero. DJNZ is cheaper than DEC B + JR Z.
Entering the Door
Going through the door is very similar to the gem getting code.
moveok_door:
ld (playerpos), hl
ld de, map
add hl, de
ld (hl), 0x87 ; closed door
ld hl, snd_exit
call playsound
We close the door behind us and play a sound effect. The player's sprite was removed at the start of the function so we don't need to clean it up; we simply have to take care not to draw it back.
; Remove all sand
ld hl, map
ld b, 16*12
ld a, 2
sandloop:
cp (hl)
jr nz, notsand
ld (hl), 0x80
notsand:
inc hl
djnz sandloop
And this is literally the same code as the door opening code, replacing all sand with emptiness.
; Increase level
ld a, 1
ld (playerdone), a
ld a, (level)
ld hl, (maxlevel)
cp l
ret z
inc a
ld (level),a
ret
Finally we check if we're at the maximum level, like we do in the level select, and if not, we increment the level. This way, when player hits space, they are shown the next level.
Altered Goo-Water Behaviour
I was disappointed with how the water and goo interacted, so I made it quite bit more dramatic. I could have used the same replace-everything code I have used above, but instead I made it more complex.
Since it's possible there's several separate clumps of goo, we don't want to turn every single tile to gems. What we need is a flood fill. And showing the player the process of the flood is neat. And spending time to do the flood all at once would take way too much frame time anyway.
First, I added a new tile for "crystalizing goo". Next, the water-goo behaviour no longer turns into gems; this is a trivial change (all 0x85:s in the water physics change to 0x80 + 17). Next, there's a new physics handler.
jp z, physics_peopleeater_down
cp 14
jp z, physics_peopleeater_left
cp 17
jp z, physics_crystalize
The crystalize handler fills all goo around with more crystalize tiles and turns the current tile to a gem.
physics_crystalize:
ld hl, snd_gemfall
call playsound
ld (ix), 0x85
ld a, ixl
and 15
jr z, crystalize_right
ld a, (ix-1)
cp 4
jr nz, crystalize_right
ld (ix-1), 0x80 + 17
crystalize_right:
After a little sound effect we mark the current tile as a gem, and then start checking the cardinal directions. Assuming we're not at the edge of the map, if there's goo on the left, we turn that into a crystalize tile. We then repeat the process for right, up and down directions, like we did with the goo physics. The result is a quite pleasing effect.
Randomizing Sound a Bit
Since playing the exact same "gem falls" sound effect every time a gem drops doesn't sound nice, let's add a little bit of variation to the pitch of the sounds.
ld bc, 10
push de
ldir
; Little bit of random for livelier sound
pop hl
ld e, (hl)
inc hl
ld d, (hl)
ex de,hl
ld a, r
and 0x1f
ld c, a
ld b, 0
add hl, bc
ex de,hl
ld (hl),d
dec hl
ld (hl),e
This code replaces the LDIR in playsound. We preserve DE, which points at the channel LDIR writes over, and POP it into HL. We grab the current pitch to DE, swap DE and HL around because we can ADD to HL but not to DE.
Next we grab the instruction counter R to register A (because that's the only place we can grab it to), AND a few bits off the top, place result into BC so we can ADD it to HL, and then we go through the motions of writing it back to the live channel.
Phew.
I'm sure some z80 expert would do all of the above in, like, five instructions, but this isn't performance critical code.
That's All Folks
And that covers all the interesting code changes. There's also a bunch of calls to playsound here and there, pressing up in level select plays the test sound (easter egg!), and we have a bunch of new levels.
Speaking of bunch of levels - each level takes 256 bytes, so 4 levels takes 1kB. This is quite a lot in our scale, but since we have (way) over 10kB space left and I don't think I'm going to make 40 levels, we're doing fine.
If we did want a lot more levels, it would be trivial to just offline compress them with zx0 and decompress on the fly - doing the compression in batches of four levels would give enough for zx0 to munch on. As an experiment I fed the first 4 levels to zx0 and the size went down to exactly 256 bytes from 1024. The compression ratio would naturally fluctuate but let's take that as an average, in which case we could fit about 4 times more levels. About, because we'd still need space for the 4 decompressed levels, as well as support code for handling the decompression.
Other further development ideas include music in the menus and possibly in-game, locking levels until player has collected enough gems in total, with some kind of passcode system to let player "save" their progress as save games on a tape-based spectrum are way too much of a hassle.
Since we are not using most of our frame time, we could also animate some of the tiles, like having the gems glimmer or something. This could be done by adding more copies of the animated tile and changing the physics so it cycles through the states.
Looking back, if this had been a C project I would have been struggling with space towards the end of the project. Writing everything in assembler made everything really compact. I don't think performance would have been an issue, as long as the most critical bits (tile draws and map loops) were in assembler. Compared to C code, I hardly used stack at all, while the C code spams stack like crazy.
After this experience, do I prefer writing code in C or assembler for the spectrum? I think I still prefer C, just because it's easier to experiment. Let's say I wanted to reverse the map drawing loop - in C this would be trivial. Depending on the optimizations made in assembler, it might mean rewriting the whole thing from scratch.
I had a friend in high school (back when 80386 machines were still relevant) who preferred writing programs in assembler because he said he was faster in it than in writing some higher level language. I kind of understand his point now.
This tutorial was written pretty much exactly as I wrote the game, with no detours removed. There were a few places where I hit a bug I had to hunt down, and these turned out to be typos where I'd use a wrong register somewhere, or forget to CP a value before JR Z.
At 14 levels (level data taking 3584 bytes) the binary size is 9941 bytes, so ignoring the added levels the binary size grew 720 bytes. We could easily add 80 more levels and still fit.
The final code package (including all the resources and tools) can be downloaded here. The package also contains exporter extension for tiled to make map authoring slightly less painful.
If you just want to play the game, the tap file is here - load it up to a 128k spectrum (or, more likely, emulator, or the Spectrum Next). It should work on a 48k spectrum too, just without sound.
And that's it! I hope this has been useful, or at least interesting.
Any comments etc. can be emailed to me.