Pagina Principale
đ Benvenuto in gb-asm-tutorial! Questo tutorial ti insegnerĂ come programmare giochi per il Game Boy e per il Game Boy Color.
While the Game Boy and Game Boy Color are almost the same console, the Game Boy Advance is entirely different. However, the GBA is able to run GB and GBC games! If you are looking to program GBC games and run them on a GBA, youâre at the right place; however, if you want to make games specifically for the GBA, please check out the Tonc tutorial instead.
Comandi
In cima alla pagina puoi vedere alcune icone, sempre presenti se ne dovessi avere bisogno!
- The âburgerâ toggles the navigation side panel;
- The brush allows selecting a different color theme;
- The magnifying glass pops up a search bar;
- The world icon lets you change the language of the tutorial;
- The printer gives a single-page version of the entire tutorial, which you can print if you want;
- The GitHub icon links to the tutorialâs source repository;
- The edit button allows you to suggest changes to the tutorial, provided that you have a GitHub account.
Ci sono anche delle frecce a lato della pagina (o in fondo, se sei su cellulare) per passare alla pagina successiva o precedente.
E con questo, per iniziare ti basterĂ premere la freccia a destra :)
Autori
Il tutorial è stato scritto da Eldred âISSOtmâ Habert, Evie, Antonio Vivace, LaroldsJubilantJunkyard e altri collaboratori.
Contribuire
You can provide feedback or send suggestions in the form of Issues on the GitHub repository.
Weâre also looking for help for writing new lessons and improving the existing ones! You can go through the Issues to see what needs to be worked on and send Pull Requests!
You can also help translating the tutorial on Crowdin.
Licenza
In breve:
- Code within the tutorial is essentially public domain, meaning that you are allowed to copy it freely without restrictions.
- You are free to copy the tutorialâs contents (prose, diagrams, etc.), modify them, and share that, but you must give credit and license any copies under the same license.
- This siteâs source code can be freely copied, but you must give a license and copyright notice.
Maggiori Informazioni, per maggiori informazioni sulle rispettive licenze, seguire questi link:
- All the code contained within the tutorial itself is licensed under CC0. To the extent possible under law, all copyright and related or neighboring rights to code presented within GB ASM Tutorial have been waived.
- The contents (prose, images, etc.) of this tutorial are licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
- Code used to display and format the site is licensed under the MIT License unless otherwise specified.
Tabella di marcia
The tutorial is split into three parts. We strongly advise you go through the tutorial in order!
Nella parte â , lanciamo il nostro primo programma âHello World!â, per poi analizzarlo al fine di capire come funziona il Game Boy.
Nella parte â Ą programmiamo il nostro primo gioco, un clone di Arkanoid; impariamo a dare le prime istruzioni allâhardware per ottenere qualcosa che si possa definire âgiocoâ. Lungo il percorso commetteremo molti errori, ma questo servirĂ anche a imparare a risolvere i problemi del nostro codice.
Infine, la Parte â ˘ riguarda lâuso âavanzatoâ dellâhardware, dove impareremo a creare giochi ancora piĂš rifiniti e programmeremo uno âShoot âEm Upâ!
Questo tutorial è ancora in lavorazione.
Aiuto
We hope this tutorial will work for you.
But if it doesnât (the format may not work well for everyone, and thatâs okay), we encourage to look at some other resources, which might work better for you.
Itâs also fine to take a break from time to time; feel free to read at your own pace.
If you are stuck in a certain part of the tutorial, want some advice, or just wish to chat with us, the GBDev community chat is the place to go!
The authors actively participate there so donât be afraid to ask questions!
The #asm
channel should be the most appropriate to discuss the tutorial.
Se preferisci la posta elettronica, puoi raggiungerci allâindirizzo tutorial@<domain>
, devi sostituire <domain>
con il nome di dominio di questo sito.
Si tratta di una misura antispam, spero che tu capisca.
Preparazione
Per prima cosa, dobbiamo configurare il nostro ambiente di sviluppo. Avremo bisogno di:
- A POSIX environment
- RGBDS v0.5.1 (though v0.5.0 should be compatible)
- GNU Make (preferably a recent version)
- A code editor
- A debugging emulator
âđ
The following install instructions are provided on a âbest-effortâ basis, but may be outdated, or not work for you for some reason. Donât worry, weâre here to help: ask away, and weâll help you with installing everything!
Strumenti
Linux e macOS
Buone notizie: hai giĂ completato il primo passo! Dovete solo installare RGBDS e forse aggiornare GNU Make.
macOS
Al momento della stesura di questo articolo, macOS (fino alla 11.0, lâultima release attuale) ha in dotazione un GNU Make molto datato.
Ă possibile verificarlo aprendo un terminale ed eseguendo make --version
, che dovrebbe indicare âGNU Makeâ e una data, tra le altre cose.
Se il vostro Make è troppo vecchio, potete aggiornarlo usando la formula di Homebrew make
.
Al momento in cui scriviamo, dovrebbe essere stampato un avviso che il Make aggiornato è stato installato come gmake
; si può seguire il suggerimento di usarlo come make
âpredefinitoâ, oppure usare gmake
invece di make
in questo tutorial.
Linux
Una volta installato RGBDS, aprite il terminale ed eseguite il comando make --version
per controllare la vostra versione di Make (che probabilmente è GNU Make).
Se make
non è presente, potrebbe essere necessario installare le build-essentials
della propria distribuzione.
Windows
The modern tools weâll be using for Game Boy development have been designed for a Unix environment, so setup on Windows is not fully straightfoward. However, itâs possible to install an environment that will provide everything we need.
On Windows 10 and Windows 11, your best bet is WSL, which is a method for running a Linux distribution within Windows. Install WSL, then a distribution of your choice (pick Ubuntu if unsure), and then follow these steps again, but for the Linux distribution you installed.
In alternativa a WSL, si può usare MSYS2 o Cygwin; per poi consultare le istruzioni per lâinstallazione di Windows di [RGBDS] (https://rgbds.gbdev.io/install). Per quanto ne so, entrambi dovrebbero fornire una versione sufficientemente aggiornata di GNU Make.
Se avete programmato per altre console, come il GBA, controllate che MSYS2 non sia giĂ installato sul vostro computer. Questo perchĂŠ devkitPro, un popolare pacchetto di sviluppo homebrew, include MSYS2.
Editor di codice
Qualsiasi editor di codice va bene; personalmente uso Sublime Text con il suo pacchetto sintassi RGBDS; tuttavia è possibile usare qualsiasi editor di testo, compreso il Blocco Note se si è abbastanza pazzi. Awesome GBDev ha una sezione sui pacchetti per lâevidenziazione della sintassi dove potete controllare se il vostro editor preferito supporta RGBDS.
Emulatore
Using an emulator to play games is one thing; using it to program games is another. The two aspects an emulator must fulfill to allow an enjoyable programming experience are:
- Debugging tools:
When your code goes haywire on an actual console, itâs very difficult to figure out why or how.
There is no console output, no way to
gdb
the program, nothing. However, an emulator can provide debugging tools, allowing you to control execution, inspect memory, etc. These are vital if you want GB dev to be fun, trust me! - Good accuracy:
Accuracy means âhow faithful to the original console something isâ.
Using a bad emulator for playing games can work (to some extent, and even thenâŚ), but using it for developing a game makes it likely to accidentally render your game incompatible with the actual console.
For more info, read this article on Ars Technica (especially the
An emulator for every game
section at the top of page 2). You can compare GB emulator accuracy on Daidâs GB-emulator-shootout.
The emulator I will be using for this tutorial is Emulicious. Users on all OSes can install the Java runtime to be able to run it. Other debugging emulators are available, such as Mesen2, BGB (Windows/Wine only), SameBoy (graphical interface on macOS only); they should have similar capabilities, but accessed through different menu options.
Hello World!
In questa lezione inizieremo ad assemblare il nostro primo programma. Il resto del capitolo sarĂ dedicato a spiegare come e perchĂŠ funziona.
Tenete presente che da ora dovremo lanciare molti comandi, quindi è il momento di aprire una finestra del terminale.
Ă buon norma creare una nuova directory (mkdir gb_hello_world
, per esempio, poi cd gb_hello_world
per entrare nella nuova directory).
Grab the following files (right-click each link, âSave Link AsâŚâ), and place them all in this new directory:
Quindi, sempre dal terminale, allâinterno di quella directory, lanciate i tre comandi seguenti.
CONVENTION
To make it clear where each command begins, they are preceded by a $
symbol. However, do not type it when entering them in your shell!
rgbasm -o hello-world.o hello-world.asm
rgblink -o hello-world.gb hello-world.o
rgbfix -v -p 0xFF hello-world.gb
âźď¸
Fate attenzione agli argomenti! Alcune opzioni, come -o
, usano lâargomento dopo di esse come parametro:
rgbasm -o hello-world.asm hello-world.o
wonât work (and may corrupthello-world.asm
!)rgbasm hello-world.asm -o hello-world.o
will work
In caso di spazi allâinterno di un argomento, è necessario metterlo tra virgolette:
rgbasm -o hello world.o hello world.asm
wonât workrgbasm -o "hello world.o" "hello world.asm"
will work
It should look like this:
(Se riscontrate un errore che non riuscite a risolvere da soli, non abbiate paura di chiedere! Lo risolveremo insieme)
Congrats!
You just assembled your first Game Boy ROM!
Now, we just need to run it; open Emulicious, then go âFileâ, then âOpen Fileâ, and load hello-world.gb
.
Potreste anche prendere una flash cart (cartuccia flash) (io uso lâEverDrive GB X5, ma ci sono molte alternative), caricarci la vostra ROM e farla girare su una console vera e propria!
Bene, ora che abbiamo qualcosa che funziona, è il momento di togliere le tende per scoprire cosa câè sottoâŚ
Strumenti di lavoro
Dunque, nella lezione precedente abbiamo scritto una piccola ROM âHello World!â. Ora è il momento di capire meglio cosa abbiamo fatto.
RGBASM e RGBLINK
Iniziamo spiegando cosa fanno rgbasm
e rgblink
.
RGBASM è un assembler (compilatore).
Il suo compito è leggere il codice sorgente (nel nostro caso hello-world.asm
e hardware.inc
) e generare un file di codice che però è incompleto:
RGBASM non sempre ha tutte le informazioni che gli servono a generare una ROM, quindi produce dei file oggetto che fanno da intermediari (con estensione .o
).
RGBLINK è un linker. Il suo compito è usare le informazioni dei file oggetto (che nel nostro caso è solo uno) ed unirli (in inglese âlinkâ) in una ROM. RGBLINK potrebbe sembrare superfluo, ma è solo perchĂŠ la ROM che abbiamo guardato è davvero piccola: quando nella seconda parte il nostro progetto crescerĂ , la sua utilitĂ sarĂ piĂš apparente.
Quindi: Codice sorgente â rgbasm
â File oggetto â rgblink
â ROM, giusto?
Beh, non esattamente.
RGBFIX
RGBLINK does produce a ROM, but itâs not quite usable yet. See, actual ROMs have whatâs called a header. Itâs a special area of the ROM that contains metadata about the ROM; for example, the gameâs name, Game Boy Color compatibility, and more. For simplicity, we defaulted a lot of these values to 0 for the time being; weâll come back to them in Part â Ą.
However, the header contains three crucial fields:
- The Nintendo logo,
- the ROMâs size,
- and two checksums.
Quando la console viene accesa viene eseguito un programma chiamato ROM di avvio (boot ROM) responsabile, tra lâaltro, dellâanimazione di avvio leggendo il logo di Nintendo dalla ROM. Alla fine dellâanimazione, però, la ROM di avvio controlla che il logo di Nintendo sia corretto, e interrompe lâesecuzione se non lo è: in pratica, se non azzecchiamo il logo il nostro gioco non partirĂ mai⌠đŚ Questo meccanismo era per evitare la pirateria; per nostra fortuna, però, non è piĂš valida perciò non dobbiamo preoccuparci! đ
Allo stesso modo, la ROM di avvio calcola anche un checksum dellâheader, presumibilmente per garantire che non sia corrotto. Lâheader contiene anche una copia di questo checksum; se non corrisponde a quello calcolato dalla ROM di avvio, la ROM di avvio si blocca!
Lâheader contiene anche un checksum dellâintera ROM, ma non viene mai utilizzato. Non costa niente ed è una buona idea, comunque, farlo bene.
Infine, lâheader contiene anche la dimensione della ROM, necessaria per emulatori e dalle flash cart.
RGBFIX serve proprio a compilare lâheader in automatico, in particolare questi tre campi senza i quali il GameBoy non farĂ funzionare il gioco.
Lâopzione -v
dice a RGBFIX di rendere valido lâheader, inserendo il logo e calcolando le checksum.
Lâopzione -p 0xFF
invece aggiunge dei byte alla ROM finchĂŠ non raggiunge una dimensione valida (in inglese padding), per poi scriverla nellâheader.
Perfetto!
Quindi, per riassumere:
codice sorgente â rgbasm
â file oggetto â rgblink
â ROM âveraâ â rgbfix
â ROM funzionante
A questo punto ti potresti chiedere: perchĂŠ non si uniscono tutti questi programmi in uno solo?
Ci sono ragioni nella storia di questi programmi, ma soprattutto RGBLINK può fare altro (per esempio usando -x
), e a volte RGBFIX è usato senza che RGBLINK sia minimamente necessario.
Nomi dei file
A RGBDS, come alla maggior parte dei programmi, non importa come chiami i file nĂŠ lâestensione che gli dai: lâimportante è il contenuto.
Per esempio molti usano lâestensione .s
per il sorgente, oppure .obj
per gli oggetti.
Binario ed esadecimale
Prima di passare al codice dobbiamo introdurre alcuni concetti.
Quando si programma ad un basso livello è fondamentale capire bene i sistemi binario ed esadecimale. Se giĂ conoscessi questi concetti, in fondo alla pagina ci sono delle informazioni specifiche allâuso di RGDBS.
Cosâè il binario? Ă semplicemente un modo alternativo di rappresentare i numeri, in base 2. Noi contiamo in base 10, ovvero con 10 cifre: 0, 1, 2, 3, 4, 5, 6, 7, 8, e 9. Le cifre hanno una funzione ben specifica:
42 = 4 Ă 10 + 2
= 4 Ă 10^1 + 2 Ă 10^0
â â
These tens come from us counting in base 10!
1024 = 1 Ă 1000 + 0 Ă 100 + 2 Ă 10 + 4
= 1 Ă 10^3 + 0 Ă 10^2 + 2 Ă 10^1 + 4 Ă 10^0
â â â â
And here we can see the digits that make up the number!
CONVENTION
^
qui significa âalla potenza diâ, dove X^N
è uguale a moltiplicare X
con se stesso N
volte, e X ^ 0 = 1
.
Con le cifre decimali ogni numero viene scomposto in maniera univoca in potenze di 10 (il decimale è la base 10, ricordi?). Ma perchÊ fermarsi alle potenze di 10? Potremmo invece usare altre basi, come la base 2 (il perchÊ della base 2 sarà spiegato piÚ avanti).
Il sistema binario è in base 2, quindi ha solo due cifre (chiamate bit): 0 e 1. Possiamo quindi generalizzare il principio descritto sopra e riscrivere i due numeri di prima in modo simile:
42 = 1 Ă 32 + 0 Ă 16 + 1 Ă 8 + 0 Ă 4 + 1 Ă 2 + 0
= 1 Ă 2^5 + 0 Ă 2^4 + 1 Ă 2^3 + 0 Ă 2^2 + 1 Ă 2^1 + 0 Ă 2^0
â â â â â â
And since now we're counting in base 2, we're seeing twos instead of tens!
1024 = 1 Ă 1024 + 0 Ă 512 + 0 Ă 256 + 0 Ă 128 + 0 Ă 64 + 0 Ă 32 + 0 Ă 16 + 0 Ă 8 + 0 Ă 4 + 0 Ă 2 + 0
= 1 Ă 2^10 + 0 Ă 2^9 + 0 Ă 2^8 + 0 Ă 2^7 + 0 Ă 2^6 + 0 Ă 2^5 + 0 Ă 2^4 + 0 Ă 2^3 + 0 Ă 2^2 + 0 Ă 2^1 + 0 Ă 2^0
â â â â â â â â â â â
Quindi, applicando lo stesso principio, possiamo dire che in base 2, 42 si scrive come 101010
e 1024 come 10000000000
.
PoichĂŠ non è possibile distinguere tra dieci (10 decimale) e due (10 binario), lâassemblaggio RGBDS prevede numeri binari preceduti da un segno di percentuale: 10 è dieci e %10 è due.
Ok, ma perchĂŠ proprio la base 2? Visto che un bit può essere solo 0 o 1, è molto facile rappresentarlo: può essere âONâ o âOFFâ, vuoto o pieno, ecc! Se ad esempio si vuole creare una memoria da un bit basta prendere una scatola: se è vuota allora simboleggia uno 0; se contiene qualcosa, allora è un 1. I computer manipolano quindi principalmente numeri binari e questo ha molte implicazioni, come vedremo nel corso di questo tutorial.
Esadecimale
Ricapitolando: il decimale non è pratico per i computer, che si affidano invece ai numeri binari (base 2). Ok, ma il binario è davvero difficile da usare per noi programmatori. Prendiamo %10000000000, ovvero 2048; mentre per scriverlo in decimale bastano solo 4 cifre, in binario ne servono 12! E probabilmente non avete neanche notato che ho scritto uno zero di troppo! Per fortuna arriva lâesadecimale a salvare la situazione! đڏ
La base 16 funziona esattamente come tutte le altre basi, ma con 16 cifre (chiamate ânibblesâ in inglese): 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, ed F.
42 = 2 Ă 16 + 10
= 2 Ă 16^1 + A Ă 16^0
1024 = 4 Ă 256 + 0 Ă 16 + 0
= 4 Ă 16^2 + 0 Ă 16^1 + 0 Ă 16^0
Come per il binario useremo un prefisso per indicare lâesadecimale, cioè $
.
Quindi, 42 = $2A e 1024 = $400.
Questo è molto piĂš compatto del binario, e anche un poâ piĂš del decimale; ma ciò che rende lâesadecimale molto interessante è che una sua cifra corrisponde esattamente a 4 bit!
Nibble | Bits |
---|---|
$0 | %0000 |
$1 | %0001 |
$2 | %0010 |
$3 | %0011 |
$4 | %0100 |
$5 | %0101 |
$6 | %0110 |
$7 | %0111 |
$8 | %1000 |
$9 | %1001 |
$A | %1010 |
$B | %1011 |
$C | %1100 |
$D | %1101 |
$E | %1110 |
$F | %1111 |
Ciò rende facilissimo convertire tra binario ed esadecimale, e mantenere una notazione sufficientemente compatta. Per questo, lâesadecimale è molto piĂš utilizzato del binario. E non preoccuparti, puoi ancora usare il decimale đ
(Nota: si potrebbe pensare che anche lâottale, cioè la base 8, funzioni altrettanto bene; tuttavia avremo a che fare perlopiĂš con unitĂ di 8 bit, per le quali lâesadecimale funziona molto meglio dellâottale. Volendo, RGBDS permette di usare lâottale tramite il prefisso &
, ma non lâho mai visto usare nella pratica)
If youâre having trouble converting between decimal and binary/hexadecimal, check whether your favorite calculator program has a âprogrammerâ mode or a way to convert between bases.
Riepilogo
- In RGBDS assembly, the hexadecimal prefix is
$
, and the binary prefix is%
. - Hexadecimal can be used as a âcompact binaryâ notation.
- Using binary or hexadecimal is useful when individual bits matter; otherwise, decimal works just as well.
- For when numbers get a bit too long, RGBASM allows underscores between digits (
123_465
,%10_1010
,$DE_AD_BE_EF
, etc.)
Registri
Alright! Now that we know what bits are, letâs talk about how theyâre used. Donât worry, this is mostly preliminary work for the next section, where we willâfinally!âlook at the code đ
First, if you opened Emulicious, you have been greeted with just the Game Boy screen. So, itâs time we pop the debugger open! Go to âToolsâ, then click âDebuggerâ, or press F1. Then in the debuggerâs menu, click âViewâ, then click âShow Addressesâ
Il debugger potrebbe sembrare incomprensibile allâinizio, ma non ti preoccupare: ti ci abituerai in fretta! Per il momento guarda in alto a destra, dove câè un piccolo spazio intitolato register viewer (visualizzatore dei registri).
â ď¸
Il visualizzatore mostra sia i registri della CPU che alcuni registri hardware. In questa lezione parleremo solo dei registri della CPU, perciò non ti preoccupare se salteremo alcuni nomi.
Ma cosa sono questi registri della CPU? Ti faccio un esempio: immagina di star preparando una torta. Ovviamente avrai una ricetta da seguire, come ad esempio âsciogli 125g di cioccolato e 125g di burro, mescola il tutto con due uovaâ e cosĂŹ via. Dopo aver preso gli ingredienti, non li usi direttamente nel frigo; per comoditĂ , li prenderai e li metterai su un banco da lavoro o un tavolo dove lavorarci piĂš facilmente.
I registri sono questo tavolo, su cui il processore poggia temporaneamente i suoi ingredienti. PiÚ concretamente, sono dei piccoli spazi di memoria (Il GameBoy ne ha solo 10 byte, e anche le CPU moderne hanno meno di un kilobyte se non si contano i registri SIMD). Eseguire le operazioni direttamente sulla memoria è scomodo, sarebbe come rompere le uova nel frigo: per questo le spostiamo sul tavolo, i registri, prima di romperle.
âšď¸
Ovviamente ci sono eccezioni a questa regola, come un poâ tutte le regole che ti spiegheremo nel tutorial; stiamo semplificando di molto le cose per mantenerle ad un livello abbastanza facile da comprendere, perciò non prendere mai queste regole troppo alla lettera.
Registri Generici
A grandi linee ci sono due tipi di registri: registri generici e registri speciali. Un registro generico (abbreviato GPR) può essere usato per dati di qualunque tipo. Qualche GPR ha anche delle funzioni piĂš specifiche, come vedremo piĂš in lĂ ; in generale per distinguerli da quelli speciali chiediti âposso inserirci dati arbitrari?â.
Parleremo poi dei registri speciali; hanno funzioni particolari che non abbiamo ancora spiegato e perciò li spiegheremo quando parleremo della funzione specifica, visto che fino ad allora non ne capiremmo lo scopo.
La CPU del Game Boy ha sette GPR a 8 bit: a
, b
, c
, d
, e
, h
e l
.
â8 bitâ significa che memorizzano 8 bit.
Pertanto, possono memorizzare numeri interi da 0 a 255 (%1111_1111 alias $FF).
`aâ è lâaccumulatore, e come vedremo piĂš avanti può essere usato in modi particolari.
Questi registri hanno unâaltra caratteristica interessante; sono tutti (tranne a
) accoppiati ad un altro registro, e queste coppie possono funzionare da registri a 16 bit: BC
, DE
, e HL
.
Ma tieni sempre a mente che le coppie non sono indipendenti dai registri che li formano; se, ad esempio, D
contiene 192 ($C0) ed E
222 ($DE) allora DE
conterrĂ 49374 ($C0DE) = D Ă 0x100 + E
.
Tutte le altre coppie funzionano allo stesso modo.
Quindi, cambiare il valore di DE
modifica sia quello di D
che di E
allo stesso tempo e modificare D
o E
cambierĂ il valore della coppia DE
.
Ok, ma come facciamo a usare questi registri?
Ă il momento di dare unâocchiata allâAssembly!
Primi passi in Assembly
Bene, ora che sappiamo cosa fanno gli strumenti, vediamo che lingua parla RGBASM.
Prenderò una breve porzione dellâinizio di hello-world.asm
, in modo da essere dâaccordo sui numeri di riga e da avere unâevidenziazione della sintassi anche se il vostro editor non la supporta.
INCLUDE "hardware.inc"
SECTION "Header", ROM0[$100]
jp EntryPoint
ds $150 - @, 0 ; Make room for the header
EntryPoint:
; Shut down audio circuitry
ld a, 0
ld [rNR52], a
Analizziamolo insieme. Sappi che per il momento salteremo molte delle funzionalitĂ di RGBASM; se fossi curioso di saperne di piĂš, dovrai aspettare fino alla seconda o terza parte oppure leggere la documentazione.
Commenti
Iniziamo dalla riga 10, che dovrebbe essere grigia nel riquadro qui sopra.
I punti e virgola ;
indicano un commento.
I commenti (che finiscono alla fine della riga) sono ignorati dallâassembler, indipendentemente dal contenuto.
Come vedi alla riga 7, puoi anche inserire commenti dopo aver scritto altro.
I commenti sono molto importanti in tutti i linguaggi di programmazione: ti aiutano a descrivere la funzione del tuo codice. Ă piĂš o meno la differenza tra âscalda il forno fino a 180°Câ e âscalda il forno a 180°C, se lo scaldassi di piĂš la torta brucerebbeâ. I commenti sono utilissimi in ogni linguaggio di programmazione, ma in Assembly sono ancora piĂš importanti: infatti, il codice Assembly è molto piĂš astratto.
Istruzioni
Assembly is a very line-based language. Each line can contain one of two things:
- a directive, which instructs RGBASM to do something, or
- an instruction1, which is written directly into the ROM.
Parleremo delle direttive piĂš avanti, per ora concentriamoci sulle istruzioni: per esempio, nello snippet qui sopra, ignoreremo le righe 1 (INCLUDE
), 7 (ds
) e 3 (SECTION
).
Per continuare lâanalogia con la preparazione di una torta, le istruzioni sono come i passi di una ricetta. Il processore (CPU) esegue unâistruzione alla volta. Istruzione dopo istruzione⌠dopo un poâ si arriva al risultato! Come cuocere una torta, disegnare âHello Worldâ, oppure mostrarti un tutorial sullâAssembly del GameBoy!
Le istruzioni sono composte da una mnemonica, un nome con cui le puoi invocare, e dei parametri, ovvero su cosa va eseguita lâoperazione. Ad esempio: in âsciogli il cioccolato ed il burro in una padellaâ lâistruzione è tutta la frase; la mnemonica sarebbe lâazione, ovvero sciogli, mentre i parametri sono gli oggetti della frase (cioccolato, burro, padella).
Cominciamo dallâistruzione piĂš importante: ld
.
ld
sta per âcaricaâ, e semplicemente copia i dati contenuti nel secondo parametro (âRHSâ) nel primo (âLHSâ).
Per esempio, guardiamo la riga 11 del nostro programma, ld a, 0
: copia (âcaricaâ) il numero zero nel registro a
2.
Per fare un altro esempio, a riga 33 troviamo ld a, b
: significa semplicemente âcopia il valore di b
in a
.
Instruction | Mnemonic | Effect |
---|---|---|
Load | ld | Copies values around |
âšď¸
Per via delle limitazioni del processore, non tutte le combinazioni di operandi sono valide per ld
e per molte altre istruzioni; ne parleremo in seguito, quando arriverĂ il momento di scrivere il nostro codice.
đ¤
RGBDS ha una lista delle istruzioni del GameBoy che vale la pena tenere tra i preferiti, e che si può anche consultare dal terminale scrivendo man 7 gbz80
se RGBDS è installato sulla propria macchina (tranne su WindowsâŚ).
Le descrizioni che trovate in quella pagina sono piĂš concise: sono intese come un promemoria, non come un tutorial.
Direttive
Quindi, in un certo senso, le istruzioni sono destinate al processore del GameBoy mentre i commenti sono destinati al programmatore. Ma alcune righe non sono nĂŠ lâuna nĂŠ lâaltra cosa, e sono invece dei metadati destinati a RGBDS stesso. Queste sono chiamate direttive e il nostro âHello Worldâ ne contiene tre.
Includere un altro file
INCLUDE "hardware.inc"
La riga 1 include hardware.inc
3.
Include
ndo un file è come se copiassimo il suo contenuto alla riga dove inseriamo la direttiva.
CosÏ facendo, si può riciclare facilmente il codice in diversi file: se, ad esempio, due file a.asm
e b.asm
includono hardware.inc
basta modificare il file perchĂŠ le modifiche si applichino ad a.asm
e b.asm
.
Se invece copiassi a mano il contenuto di hardware.inc
in a.asm
e b.asm
dovresti modificare il contenuto di entrambi ogni volta che vuoi apportare un cambiamento, che non è solo uno spreco di tempo ma aumenta la possibilità di commettere errori.
hardware.inc
definisce alcune costanti molto utili per interfacciarsi con lâhardware del GameBoy.
Le costanti non sono altro che dei nomi a cui è assegnato un valore: scrivere una costante equivale a scrivere il valore che le è assegnato.
Questo torna molto utile: è molto piĂš semplice ricordare il registro âLCD Controlâ (impostazioni dello schermo) col nome rLCDC
piuttosto che ricordare lâindirizzo $FF40
.
Parleremo delle costanti in modo piĂš approfondito nella Parte â Ą.
Sezioni
Spieghiamo innanzitutto che cosâè una âsezioneâ, poi vedremo che cosa fa la riga 3.
Una sezione rappresenta un intervallo contiguo di memoria che, di base, finisce da qualche parte non nota in anticipo.
Se si vuole vedere dove finisce ogni sezione si può chiedere a RGBLINK di generare un âfile mappaâ con lâopzione -m
:
rgblink hello-world.o -m hello-world.map
âŚe possiamo vedere, per esempio, dove è finita la sezione "Tilemap"
:
SECTION: $05a6-$07e5 ($0240 bytes) ["Tilemap"]
Le sezioni non possono essere divise da RGBDS, che è utile ad esempio per il codice poichĂŠ il processore esegue le istruzioni una dopo lâaltra (a parte con i salti, che vedremo piĂš avanti). Va trovato il giusto equilibrio per il numero di sezioni: non troppe ma neanche troppo poche, anche se in genere non ha molta importanza fino a quando non si inizia a parlare di banche di memoria.
Quindi, per ora, assumiamo che una sezione debba contenere cose che âvanno insiemeâ dal punto di vista topico, ed esaminiamo una delle nostre.
SECTION "Header", ROM0[$100]
Quindi!
Cosa fa questa riga?
Altro non è che la dichiarazione di una nuova sezione; tutte le istruzioni e i dati dopo questa riga e fino alla successiva dichiarazione SECTION
saranno inseriti in questa sezione appena creata.
Prima della prima direttiva SECTION
non câè una sezione âattivaâ: scrivere dati o codice al di fuori di una sezione ci darĂ lâerrore Cannot output data outside of a SECTION
.
Il nome della nuova sezione è âHeader
â.
I nomi delle sezioni possono contenere qualsiasi carattere (e anche essere vuoti, se si vuole) e devono essere unici[^nome_sezione].
La parola chiave ROM0
indica a quale âtipo di memoriaâ appartiene la sezione (ecco un elenco).
Ne parleremo nella Parte â
Ą.
Dove dice [100$]
invece è piĂš interessante, perchĂŠ è unâindicazione speciale per questa sezione.
Se ricordi, prima abbiamo detto che:
a section [âŚ] by default, ends up somewhere not known in advance.
Però alcune parti della memoria sono speciali, e quindi a volte è necessario che una sezione specifica copra un intervallo di memoria specifico.
Per permetterlo, RGBASM ha la sintassi [addr]
che forza lâindirizzo iniziale della sezione a essere addr
.
In questo caso, lâintervallo di memoria $100-$14F è speciale perchĂŠ è lâheader della ROM. Parleremo dellâheader tra un paio di lezioni, ma per il momento basta sapere che non dobbiamo inserire nĂŠ codice nĂŠ dati in quello spazio. E come facciamo? Innanzitutto, iniziamo una sezione allâindirizzo $100, dopodichĂŠ riserviamo un poâ di spazio.
Lasciare spazio
jp EntryPoint
ds $150 - @, 0 ; Make room for the header
La riga 7 afferma di âfare spazio per lâheaderâ, di cui ho parlato brevemente poco sopra.
Per ora, concentriamoci su ciò che ds
fa effettivamente.
ds
è usato per allocare staticamente della memoria.
Riserva un certo numero di byte, che sono impostati ad un certo valore dato.
Il primo argomento di ds
, in questo caso $150 - @
, è quanti byte riservare.
Il secondo argomento (che è opzionale), in questo caso 0
, è il valore a cui impostare ogni byte riservato4.
Vedremo perchĂŠ questi byte devono essere riservati in un paio di lezioni.
Ă importante notare che il primo argomento è unâespressione.
RGBDS (fortunatamente!) supporta lâinserimento di espressioni arbitrarie pressochĂŠ ovunque.
Questâespressione è una semplice sottrazione: $150 meno @
, che è un simbolo speciale che sta per âlâindirizzo in memoria attualeâ.
Un simbolo è essenzialmente âun nome associato a un valoreâ, di solito un numero. Nel corso dellâesercitazione esploreremo i diversi tipi di simboli, a partire dalle etichette nella prossima sezione.
Un simbolo numerico utilizzato in unâespressione viene sostituito dal suo valore, che devâessere noto al momento della compilazione della ROM; in particolare, non può dipendere dal contenuto di alcun registro.
Ora vi starete chiedendo cosa siano questi âindirizzi di memoriaâ di cui continuo a parlare. Andiamo subito a scoprirlo!
-
Technically, instructions in RGBASM are implemented as directives, basically writing their encoded form to the ROM; but the distinction between the instructions in the source code and those in the final ROM is not worth bringing up right now. âŠ
-
The curious reader may ask where the value is copied from. The answer is simply that the âimmediateâ byte ($00 in this example) is stored in ROM just after the instructionâs opcode byte, and itâs what gets copied to
a
. We will come back to this when we talk about how instructions are encoded later on. ⊠-
hardware.inc
itself contains more directives, in particular to define a lot of symbols. They will be touched upon much later, so we wonât look intohardware.inc
yet. ⊠-
Actually, since RGBASM 0.5.0,
ds
can accept a list of bytes, and will repeat the pattern for as many bytes as specified. It just complicates the explanation slightly, so I omitted it for now. Also, if the argument is omitted, it defaults to what is specified using the-p
option to RGBASM. âŠ
Memoria
đ
Congratulazioni, avete appena finito le lezioni piĂš difficili del tutorial! Dal momento che avete le basi, dâora in poi vedremo codice sempre piĂš concreto.
Se guardiamo alla riga 29 leggiamo ld a, [de]
.
Stando a quanto abbiamo appena appreso, questo copia un valore nel registro a
⌠ma da dove?
Cosa significano queste parentesi?
Per rispondere dobbiamo parlare della memoria.
Cosâè una memoria?
Lo scopo della memoria è quello di immagazzinare informazioni. Su un foglio di carta o su una lavagna si possono scrivere lettere per memorizzare, ad esempio, la lista della spesa. Ma cosa si può memorizzare nella memoria di un computer? La risposta a questa domanda è corrente[^memoria_magnetica]. La memoria del computer è costituita da piccole celle in grado di immagazzinare corrente. Ma, come abbiamo visto nella lezione sul binario, la presenza o lâassenza di corrente può essere utilizzata per codificare i numeri binari!
Per farla breve: la memoria immagazzina numeri. Difatti la memoria è una lunga lista di numeri, memorizzati in celle. Per identificare in modo univoco ogni cella, le viene assegnato un numero (e che altro!) chiamato indirizzo. Come i numeri civici! La prima cella ha lâindirizzo 0, poi lâindirizzo 1, 2 e cosĂŹ via. Sul Game Boy ogni cella contiene 8 bit, cioè un byte.
Quante cellule ci sono? In realtĂ questa è una domanda trabocchettoâŚ
I tanti tipi di memoria
There are several memory chips in the Game Boy, but we can put them into two categories: ROM and RAM 1. ROM simply designates memory that cannot be written to2, and RAM memory that can be written to.
Per via del loro funzionamento il processore ed i circuiti di memoria possono utilizzare un solo numero per gli indirizzi. Torniamo allâanalogia dei ânumeri civiciâ: ogni circuito di memoria è una strada, con la sua serie di numeri, ma il processore non ha idea di cosa sia una strada, si occupa solo di numeri civici. Per consentire al processore di parlare con piĂš circuiti serve una sorta di âservizio postaleâ, il selezionatore di circuito (âchip selectorâ in inglese), che ha il compito di tradurre i numeri civici del processore in una via ed un numero civico effettivi.
For example, letâs say a convention is established where addresses 0 through 1999 go to chip Aâs addresses 0â1999, 2000â2999 to chip Bâs 0â999, and 3000â3999 to chip Câs 0â999. Then, if the CPU asks for the byte at address 2791, the chip selector will ask chip B for the byte at its own address 791, and forward the reply to the CPU.
PoichĂŠ gli indirizzi trattati dal processore non corrispondono direttamente a quelli dei circuiti si parla di indirizzi logici (qui, quelli del processore) e di indirizzi fisici (qui, quelli dei circuiti), e la corrispondenza è chiamata mappa di memoria (âmemory mapâ in inglese). PoichĂŠ stiamo programmando il processore ci occuperemo solo di indirizzi logici, ma è fondamentale tenere presente che indirizzi diversi possono essere supportati da circuiti di memoria diversi, poichĂŠ ogni circuito ha caratteristiche uniche.
This may sound complicated, so here is a summary:
- Memory stores numbers, each 8-bit on the Game Boy.
- Memory is accessed byte by byte, and the cell being accessed is determined by an address, which is just a number.
- The CPU deals with all memory uniformly, but there are several memory chips each with their own characteristics.
Mappa di memoria del Game Boy
Adesso rispondiamo alla domanda che ci aveva portato a questa sezione: quante celle di memoria ci sono sul Game Boy? Ora possiamo riformulare meglio questa domanda come âquanti indirizzi logici ci sono?â o âquanti indirizzi fisici ci sono in totale?â.
Gli indirizzi logici, che sono solo numeri, sono a 16 bit sul Game Boy. Pertanto, ci sono 2^16 = 65536 indirizzi logici, da $0000 a $FFFF. Ma quanti sono gli indirizzi fisici? Ecco una mappa della memoria per gentile concessione di Pan Docs (anche se la semplificherò un poâ):
Start | End | Name | Description |
---|---|---|---|
$0000 | $7FFF | ROM | The game ROM, supplied by the cartridge. |
$8000 | $9FFF | VRAM | Video RAM, where graphics are stored and arranged. |
$A000 | $BFFF | SRAM | Save RAM, optionally supplied by the cartridge to save data to. |
$C000 | $DFFF | WRAM | Work RAM, general-purpose RAM for the game to store things in. |
$FE00 | $FE9F | OAM | Object Attribute Memory, where âobjectsâ are stored. |
$FF00 | $FF7F | I/O | Neither ROM nor RAM, but this is where you control the console. |
$FF80 | $FFFE | HRAM | High RAM, a tiny bit of general-purpose RAM which can be accessed faster. |
$FFFF | $FFFF | IE | A lone I/O byte thatâs separated from the rest for some reason. |
$8000 + $2000 + $2000 + $2000 + $A0 + $80 + $7F + 1 fanno in totale $E1A0, ovvero 57760 byte di memoria che si possono effettivamente usare. E a questo punto potreste chiedervi: âE i restanti 7776 byte? Cosa succede quando provo ad usarli?â; la risposta è: âDipende, è complicato; evitate di accederviâ.
Etichette
Ok, tutto questo è molto bello, ma non vi aspetterete certo che io tenga in mente tutti questi indirizzi per conto mio, giusto?? Beh, non temere, perchÊ abbiamo le etichette!
Le etichette sono simboli che in pratica permettono di assegnare un nome a un indirizzo di memoria.
Unâetichetta viene dichiarata come alla riga 9 (EntryPoint:
): allâinizio della riga si scrive il nome dellâetichetta, seguito da due punti, ed essa si riferirĂ al byte immediatamente successivo.
Quindi, per esempio, EntryPoint
si riferisce al ld a, 0
subito dopo (piĂš precisamente, al primo byte di quellâistruzione, ma ci arriveremo quando ci arriveremo).
Sbirciando allâinterno di hardware.inc
si vedrĂ che, per esempio, rNR52
non è definito come unâetichetta.
Il motivo è che queste sono costanti, di cui parleremo piÚ in là ; visto che si usano in modo quasi identico alle etichette, per il momento le considereremo uguali.
Scrivere il nome di unâetichetta equivale a scrivere lâindirizzo del byte a cui fa riferimento (con alcune eccezioni che vedremo nella Parte â
Ą).
Per esempio, si consideri la ld de, Tiles
alla riga 25.
Tiles
(riga 64) si riferisce al primo byte dei dati delle mattonelle; se assumiamo che i dati delle mattonelle finiscono per essere memorizzati a partire da $0193, allora ld de, Tiles
è equivalente a ld de, $0193
!
Cosa sono queste parentesi?
Bene, siamo arrivati a questo punto perchĂŠ volevamo sapere cosa significano le parentesi in ld a, [de]
.
In pratica, possono essere lette come âallâindirizzoâŚâ.
Per esempio, ld a, b
può essere letto come âcopia in a
il valore memorizzato in b
â; ld a, [$5414]
si legge come âcopia in a
il valore memorizzato allâindirizzo $5414â, e ld a, [de]
si legge come âcopia in a
il valore memorizzato allâindirizzo de
â.
Aspettate, cosa significa?
Beh, se de
contiene il valore $5414, allora ld a, [de]
farĂ la stessa cosa di ld a, [$5414]
.
Se avete familiaritĂ con il C, queste parentesi sono sostanzialmente il modo in cui viene implementato lâoperatore di dereferenziazione.
hli
I lettori attenti avranno notato il ld [hli], a
appena sotto il ld a, [de]
che abbiamo appena studiato.
[de]
ha senso perchÊ è una delle coppie di registri che abbiamo visto un paio di lezioni fa, ma [hli]
?
In realtà , è una notazione speciale, che può essere scritta anche come [hl+]
.
Funziona come [hl]
, ma hl
viene incrementato subito dopo lâaccesso alla memoria.
[hld]
/[hl-]
è lo specchio di questa, decrementando hl
invece di incrementarlo.
Un esempio
Quindi, se osserviamo le prime due istruzioni di CopyTiles
:
ld a, [de]
ld [hli], a
âŚpossiamo vedere che stiamo copiando il byte in memoria puntato da de
(cioè il cui indirizzo è contenuto in de
) nel byte puntato da hl
.
Qui, a
serve come memoria temporanea, poichÊ la CPU non è in grado di eseguire direttamente ld [hl], [de]
.
GiĂ che ci siamo, esaminiamo il resto di .copyTiles
nelle lezioni successive!
-
There are other types of memory, such as flash memory or EEPROM, but only Flash has been used on the Game Boy, and for only a handful of games; so we can mostly forget about them. âŠ
-
No, really! Mask ROM is created by literally punching holes into a layer of silicon using acid, and e.g. the consoleâs boot ROM is made of hard-wired transitors within the CPU die. Good luck writing to that!
âROMâ is sometimes (mis)used to refer to âpersistent memoryâ chips, such as flash memory, whose write functionality was disabled. Most bootleg / âreproâ Game Boy cartridges you can find nowadays actually contain flash; this is why you can reflash them using specialized hardware, but original cartridges cannot be. âŠ
Header
Torniamo a una certa riga allâinizio di hello-world.asm
.
ds $150 - @, 0 ; Make room for the header
Che cosâè questo misterioso header, detto anche intestazione? PerchĂŠ dobbiamo allocargli uno spazio specifico? Queste e tante altre domande troveranno una risposta in questa lezione!
Cosâè lâheader?
La prima cosa da fare è spiegare cosa è lâheader. Ă la regione di memoria da $0104 a $014F (inclusa). Contiene metadati sulla ROM, come il titolo, la compatibilitĂ con il Game Boy Color, le dimensioni, due checksum e, cosa interessante, il logo Nintendo che viene visualizzato durante lâanimazione di accensione.
Queste e altre informazioni sono disponibili nei documenti Pan.
Ă interessante notare che la maggior parte delle informazioni contenute nellâheader non ha importanza in un hardware reale (la dimensione della ROM è determinata solo dalla capacitĂ del chip ROM nella cartuccia, non dal byte nella intestazione). Difatti, alcuni prototipi di ROM hanno addirittura informazioni errate nellâheader!
La maggior parte dellâheader veniva utilizzata solo dal reparto di produzione di Nintendo per sapere quali componenti inserire nella cartuccia quando si pubblicava una ROM. Pertanto, solo le ROM inviate a Nintendo dovevano avere una intestazione perfettamente accurata; le ROM utilizzate per i test interni dovevano solo superare i controlli della ROM di avvio, che spiegheremo piĂš avanti.
Tuttavia, nella nostra epoca âmodernaâ, lâheader è molto importante. Gli emulatori (compresi gli emulatori hardware, come le flashcard) devono emulare lâhardware presente nella cartuccia e fanno affidamento su alcuni dei valori presenti nellâheader, dato che questa sezione è lâunica fonte di informazioni sullâhardware che la cartuccia della ROM dovrebbe contenere.
ROM di avvio
Lâintestazione è profondamente collegata alla cosiddetta boot ROM (ROM di âavvioâ).
The most observant and/or nostalgic of you may have noticed the lack of the boot-up animation and the Game Boyâs signature âba-ding!â in Emulicious. When the console powers up, the CPU does not begin executing instructions at address $0100 (where our ROMâs entry point is), but at $0000.
Tuttavia, in quel momento, un piccolo programma chiamato boot ROM, masterizzato allâinterno del silicio della CPU, viene âsovrappostoâ alla nostra ROM! La ROM di avvio è responsabile dellâanimazione di avvio, ma controlla anche lâintestazione della ROM! In particolare, verifica che i checksum del logo Nintendo e della intestazione siano corretti; se uno dei due controlli fallisce, la ROM di avvio si blocca intenzionalmente e il nostro gioco non verrĂ mai eseguito :(
For the curious
à possibile trovare una descrizione piÚ dettagliata di ciò che fa la ROM di avvio nei Pan Docs, nonchÊ una spiegazione del controllo del logo. Attenzione, però, perchÊ si tratta di un argomento piuttosto avanzato.
If you want to enable the boot ROMs in Emulicious, you must obtain a copy of the boot ROM(s), whose SHA256 checksums can be found in their disassembly for verification. If you wish, you can also compile SameBoyâs boot ROMs and use those instead, as a free-software substitute.
Then, in Emuliciousâ options, go to the Options
tab, then Emulation
âGame Boy
, and choose which of GB and/or GBC boot roms you want to set.
Finally, set the path(s) to the boot ROM(s) you wish to use, and click Open
.
Now, just reset the emulator, and voilĂ !
Un header viene tipicamente definito âvalidoâ se supera i controlli della ROM di avvio e ânon validoâ in caso contrario.
RGBFIX
RGBFIX è il terzo componente di RGBDS, il cui scopo è scrivere lâheader di una ROM. Ă separato da RGBLINK in modo da poter essere usato come strumento indipendente. Il suo nome deriva dal fatto che RGBLINK di solito non produce una ROM con un header valido, quindi la ROM deve essere âaggiustataâ prima di essere pronta per la produzione.
RGBFIX has a bunch of options to set various parts of the header; but the only two that we are using here are -v
, which produces a valid header (so, correct Nintendo logo and checksums), and -p 0xFF
, which pads the ROM to the next valid size (using $FF as the filler byte), and writes the appropriate value to the ROM size byte.
Guardando altri progetti, si possono trovare invocazioni di RGBFIX con piĂš opzioni, ma queste due dovrebbero essere quasi sempre presenti.
Allora, qual è il problema con questa linea?
Si! Questa linea.
ds $150 - @, 0 ; Make room for the header
Vediamo cosa succede se la rimuoviamo (o la commentiamo).
rgbasm -L -o hello-world.o hello-world.asm
rgblink -o hello-world.gb -n hello-world.sym hello-world.o
(Sto intenzionalmente NON eseguendo RGBFIX; vedremo perchĂŠ tra poco).
Come ho spiegato, RGBFIX è responsabile della scrittura dellâintestazione, quindi dovremmo usarlo per risolvere questa eccezione.
rgbfix -v -p 0xFF hello-world.gb
warning: Overwrote a non-zero byte in the Nintendo logo
warning: Overwrote a non-zero byte in the header checksum
warning: Overwrote a non-zero byte in the global checksum
Sono sicuro che questi avvisi non sono nulla di cui preoccuparsi⌠(A seconda della versione di RGBDS in uso, è possibile che siano stati visualizzati avvisi diversi, o addirittura nessuno).
Letâs run the ROM, click on Console on the debuggerâs bottom window, press F5 a few times, andâŚ

Ok, allora, cosâè successo?
Come si può vedere dallo screenshot, PC si trova a $0105. Cosa sta facendo lÏ?
âŚOh, EntryPoint
è a $0103.
Quindi il jp
a $0100 è andato lÏ e ha iniziato a eseguire le istruzioni (3E CE
è la forma grezza di ld a, $CE
), ma poi $ED non codifica alcuna istruzione valida, quindi la CPU si blocca.
Ma perchĂŠ EntryPoint
è lÏ?
Come avrete capito dagli avvisi stampati da RGBFIX, esso sovrascrive lâarea dellâheader nella ROM.
Tuttavia, RGBLINK non è a conoscenza dellâheader (perchĂŠ RGBLINK non è usato solo per generare ROM!), quindi è necessario riservare esplicitamente spazio per lâarea dellâheader.
đĽ´
Dimenticare di riservare questo spazio può far sÏ che un pezzo di codice o di dati finisca lÏ e poi venga sovrascritto. Questo è un errore comune di chi è alle prime armi che può lasciare perplessi. Fortunatamente, dalla versione 0.5.1 RGBFIX avverte quando rileva questo errore, come mostrato sopra.
CosĂŹ, preveniamo disastri come questo:
SECTION "Header", ROM0[$100]
jp EntryPoint
ds $150 - @, 0 ; Make room for the header
La direttiva ds
sta per âdefine spaceâ e consente di riempire un intervallo di memoria.
Questa riga specifica riempie tutti i byte da $103 a $14F (inclusi) con il valore $00.
PoichĂŠ parti diverse di codice e/o di dati non possono sovrapporsi, questo assicura che lâintervallo di memoria dellâheader possa essere sovrascritto in modo sicuro da RGBFIX e che nessun altro venga accidentalmente sovrascritto.
Potrebbe non essere ovvio come questo ds
finisca per riempire quello specifico intervallo di memoria.
Il jp
a 3 byte copre gli indirizzi di memoria $100, $101 e $102.
(Iniziamo da $100 perchÊ è dove si trova la SECTION
definita manualmente).
Quando RGBASM elabora la direttiva ds
, @
(che è un simbolo speciale che valuta âlâindirizzo correnteâ) ha quindi il valore $103, quindi riempie $150 - $103 = $4D
byte con zeri, quindi $103, $104, âŚ, $14E, $14F.
Bonus: il ciclo infinito
(Questo non è propriamente collegato allâheader, ma devo spiegarlo da qualche parte e questo è un posto come un altro)
Ci si potrebbe anche chiedere a cosa serva il ciclo infinito alla fine del codice.
Done:
jp Done
Semplicemente, la CPU non smette mai di eseguire istruzioni; quindi, quando il nostro piccolo Hello World è finito e non câè piĂš nulla da fare, dobbiamo comunque dare alla CPU un poâ di lavoro: quindi, le facciamo fare nulla, per sempre.
Non possiamo lasciare che la CPU se ne vada, perchĂŠ inizierebbe a eseguire altre parti della memoria come codice, con il rischio di bloccarsi. (Per verificare: rimuovete o commentate queste due righe, compilate nuovamente la ROM e vedete cosa succede)
Operazioni e flag
Va bene, sappiamo come passare i valori, ma copiare semplicemente i numeri non è divertente; vogliamo modificarli!
La CPU GB non fornisce tutte le operazioni sotto il sole (per esempio, non esiste unâistruzione di moltiplicazione), ma possiamo programmarle da soli con ciò che abbiamo. Parliamo di alcune delle operazioni di cui dispone; per ora ne ometterò alcune non utilizzate in Hello World.
Aritmetica
Le istruzioni aritmetiche piĂš semplici supportate dalla CPU sono inc
e dec
, che rispettivamente INCrementano e DECrementano il loro operando.
(Se non siete sicuri, âincrementareâ significa âaggiungere 1â e âdecrementareâ significa âsottrarre 1â).
Quindi, per esempio, il dec bc
alla riga 32 di hello-world.asm
sottrae semplicemente 1 da bc
.
Ok, bene!
Possiamo però andare un poâ piĂš veloci?
Certo che sĂŹ, con add
e sub
!
Questi rispettivamente aggiungono e sottraggono valori arbitrari (una costante o un registro).
Nessuno dei due viene usato nel tutorial, ma câè un parente di sub
: avete notato il piccolo cp
alla riga 17?
cp
permette di confrontare i valori.
Funziona come sub
, ma scarta il risultato invece di riscriverlo.
âAspettate, quindi non fa nulla?â, vi chiederete; beh, aggiorna le flag.
Flag
Ă arrivato il momento di parlare del registro speciale (ve lo ricordate?) f
, per, beh, flag.
Il registro f
contiene 4 bit, chiamati âflagâ, che vengono aggiornati a seconda dei risultati di unâoperazione.
Questi 4 flag sono:
Name | Description |
---|---|
Z | Zero flag |
N | Addition/subtraction |
H | Half-carry |
C | Carry |
SĂŹ, esistono sia un flag chiamato âCâ che un registro chiamato âcâ e sono cose diverse e non correlate. Questo rende la sintassi un poâ confusa allâinizio, ma vengono sempre usati in contesti diversi, quindi basta farci caso.
Per ora dimentichiamo N e H; concentriamoci su Z e C. Z è il flag piĂš semplice: viene impostato quando il risultato di unâoperazione è 0 e viene azzerato altrimenti. C viene impostato quando unâoperazione va in overflow o in underflow.
Che cosâè un overflow?
Prendiamo la semplice istruzione add a, 42
.
Questa istruzione aggiunge semplicemente 42 al contenuto del registro a
e scrive il risultato in a
.
ld a, 200
add a, 42
Alla fine di questo snippet, a
è uguale a 200 + 42 = 242, fantastico!
Ma se invece scrivessi questo?
ld a, 220
add a, 42
Si potrebbe pensare che a
sia uguale a 220 + 42 = 262, ma non sarebbe corretto.
Ricordate che a
è un registro a 8 bit, può memorizzare solo otto bit di informazione!
E se dovessimo scrivere 262 in binario, otterremmo %100000110, che richiede almeno 9 bitâŚ
Quindi cosa succede?
Semplicemente, il nono bit viene perduto e il valore che otteniamo è %00000110 = 6. Questo si chiama overflow.
Questo si chiama overflow: dopo lâaggiunta, otteniamo un valore piĂš piccolo di quello con cui abbiamo iniziato.
We can also do the opposite with sub
, andâfor exampleâsubtract 42 from 6; as we know, for all X
and Y
, X + Y - Y = X
, and we just saw that 220 + 42 = 6 (this is called modulo 256 arithmetic, by the way); so, 6 - 42 = (220 + 42) - 42 = 220.
This is called an underflow: after subtracting, we get a value greater than what we started with.
Quando viene eseguita unâoperazione, imposta il flag di riporto se si è verificato un overflow o un underflow, altrimenti lo azzera. (Vedremo piĂš avanti che non tutte le operazioni aggiornano il flag di riporto)
Summary
- We can add and subtract numbers.
- The Z flag lets us know if the result was 0.
- However, registers can only store a limited range of integers.
- Going outside this range is called an overflow or underflow, for addition and subtraction respectively.
- The C flag lets us know if either occurred.
Confronto
Parliamo ora di come cp
viene utilizzato per confrontare i numeri.
Ecco un ripasso: cp
sottrae il suo operando da a
e aggiorna i flag di conseguenza, ma non scrive il risultato.
Possiamo usare i flag per controllare le proprietĂ dei valori confrontati e vedremo nella prossima lezione come usarli.
Lâinterazione piĂš semplice è quella con il flag Z.
Se è impostato, sappiamo che la sottrazione ha prodotto 0, cioè a - operando == 0
; quindi, a == operando
!
Se non è impostato, allora sappiamo che a != operando
.
Ok, controllare lâuguaglianza è bello, ma potremmo anche voler eseguire dei confronti. Non preoccupatevi, perchĂŠ il flag di riporto serve proprio a questo! Quando si esegue una sottrazione, il flag di riporto viene impostato quando il risultato scende sotto lo 0, ma è solo un modo elegante per dire âdiventa negativoâ!
Quindi, quando il flag di riporto viene impostato, sappiamo che a - operando < 0
e di conseguenza che a < operando
âŚ!
E, al contrario, sappiamo che se non è impostato, a >= operando
.
Ottimo!
Riassunto dellâistruzione
Instruction | Mnemonic | Effect |
---|---|---|
Add | add | Adds values to a |
Subtract | sub | Subtracts values from a |
Compare | cp | Compares values with whatâs contained in a |
Jumps
Una volta terminata questa lezione, saremo in grado di capire tutto di CopyTiles
!
Finora tutto il codice che abbiamo visto era lineare: veniva eseguito dallâalto verso il basso. Ma questo non è sufficiente: a volte è necessario eseguire alcune azioni in base al risultato di altre (âse le crĂŞpes iniziano ad attaccarsi, ungi di nuovo la padellaâ), e a volte è necessario eseguire azioni ripetutamente (âse è rimasta della pastella, ripeti dal passo 5â).
Entrambe le cose implicano una lettura non lineare della ricetta. In assembly, questo si ottiene con i salti.
La CPU dispone di un registro speciale chiamato âPCâ, che sta per Program Counter. Esso contiene lâindirizzo dellâistruzione in corso di esecuzione1, come se si tenesse a mente il numero del passo della ricetta che si sta eseguendo. Il PC aumenta automaticamente quando la CPU legge le istruzioni, quindi âper impostazione predefinitaâ vengono lette in sequenza; tuttavia, le istruzioni di salto consentono di scrivere un valore diverso nel PC, saltando di fatto a unâaltra parte del programma. Da qui il nome.
Ok, allora parliamo di queste istruzioni di salto, che ne dite? Ce ne sono quattro:
Instruction | Mnemonic | Effect |
---|---|---|
Jump | jp | Jump execution to a location |
Jump Relative | jr | Jump to a location close by |
Call | call | Call a subroutine |
Return | ret | Return from a subroutine |
We will focus on jp
for now.
jp
, such as the one line 5, simply sets PC to its argument, jumping execution there.
In other words, after executing jp EntryPoint
(line 5), the next instruction executed is the one below EntryPoint
(line 11).
đ¤
Ci si potrebbe chiedere a cosa serva questo specifico jp
.
Non preoccupatevi, vedremo piÚ avanti perchÊ è necessario.
Salti condizionati
Passiamo ora alla parte davvero interessante. Esaminiamo il ciclo responsabile della copia delle tile:
; Copy the tile data
ld de, Tiles
ld hl, $9000
ld bc, TilesEnd - Tiles
CopyTiles:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jp nz, CopyTiles
Non preoccupatevi se non capite bene quanto segue, perchĂŠ lo vedremo in azione nella prossima lezione. Se avete problemi, provate ad andare alla prossima lezione e guardate il codice eseguito passo per passo; poi, tornando a questo punto, dovrebbe avere tutto piĂš senso.
Per prima cosa, copiamo Tiles
, lâindirizzo del primo byte dei dati delle tile, in de
.
Poi, impostiamo hl
a $9000, che è lâindirizzo in cui inizieremo a copiare i dati delle tile.
ld bc, TilesEnd - Tiles
imposta bc
sulla lunghezza dei dati delle tile: TilesEnd
è lâindirizzo del primo byte dopo i dati delle tile, quindi sottraendogli Tiles
si ottiene la lunghezza.
Quindi, in pratica:
de
contains the address where data will be copied from;hl
contains the address where data will be copied to;bc
contains how many bytes we have to copy.
Arriviamo quindi al ciclo principale.
Leggiamo un byte dalla sorgente (riga 29) e lo scriviamo nella destinazione (riga 30).
Incrementiamo i puntatori alla destinazione (tramite lâimplicito inc hl
fatto da ld [hli], a
) e alla sorgente (riga 31), in modo che la successiva iterazione del ciclo elabori il byte successivo.
Ecco la parte interessante: poichĂŠ abbiamo appena copiato un byte, significa che ne abbiamo uno in meno, quindi dobbiamo fare dec bc
.
(Abbiamo giĂ visto dec
due lezioni fa; per rinfrescare la memoria, si tratta semplicemente di diminuire di uno il valore memorizzato in bc
).
PoichĂŠ bc
contiene la quantitĂ di byte che devono ancora essere copiati, è facile capire che dobbiamo semplicemente ripetere lâoperazione se bc
!= 0.
đ
dec
di solito aggiorna i flag, ma sfortunatamente dec bc
non lo fa, quindi dobbiamo controllare manualmente se bc
ha raggiunto 0.
ld a, b
e or a, c
applicano âbitwise ORâ a b
e c
insieme; per ora è sufficiente sapere che lascia 0 in a
se e solo se bc
== 0.
E or
aggiorna il flag Z!
Quindi, dopo la riga 34, il flag Z è impostato se e solo se bc
== 0, cioè se dobbiamo uscire dal ciclo.
Ed è qui che entrano in gioco i salti condizionati! Ă possibile âprendereâ condizionatamente un salto a seconda dello stato dei flag.
Le âcondizioniâ sono quattro:
Name | Mnemonic | Description |
---|---|---|
Zero | z | Z is set (last operation had a result of 0) |
Non-zero | nz | Z is not set (last operation had a non-zero result) |
Carry | c | C is set (last operation overflowed) |
No carry | nc | C is not set (last operation did not overflow) |
Quindi, jp nz, CopyTiles
può essere letto come âse il flag Z non è impostato, allora salta a CopyTiles
â.
PoichĂŠ stiamo saltando allâindietro, ripeteremo di nuovo le istruzioni: abbiamo appena creato un ciclo!
Ok, abbiamo parlato molto del codice e lo abbiamo visto girare, ma non abbiamo visto come gira. Guardiamo la magia che si svolge al rallentatore nella prossima lezione!
-
Not exactly; instructions may be several bytes long, and PC increments after reading each byte. Notably, this means that when an instruction finishes executing, PC is pointing to the following instruction. Still, itâs pretty much âwhere the CPU is currently reading fromâ, but itâs better to keep it simple and avoid mentioning instruction encoding for now. âŠ
Tracciamento
Avete mai sognato di essere dei maghi? Beh, questo non vi darĂ poteri magici, ma vediamo come gli emulatori possono essere usati per controllare il tempo!
Per prima cosa, assicuratevi di mettere a fuoco la finestra del debugger.
Spieghiamo innanzitutto il layout del debugger:
In alto a sinistra câè il visualizzatore del codice, in basso a sinistra câè il visualizzatore dei dati, in alto a destra ci sono alcuni registri (come abbiamo visto nella lezione sui registri) e in basso a destra câè il visualizzatore dello stack.
Che cosâè lo stack?
Risponderemo a questa domanda un poâ piĂš avanti⌠nella Parte â
Ą đ
Preparazione
For now, letâs focus on the code viewer.
As Emulicious can load our source code, our codeâs labels and comments are automatically shown in the debugger. As we have seen a couple of lessons ago, labels are merely a convenience provided by RGBASM, but they are not part of the ROM itself. In other emulators, it is very much inconvenient to debug without them, and so sym files (for âsymbolsâ) have been developed. Letâs run RGBLINK to generate a sym file for our ROM:
rgblink -n hello-world.sym hello-world.o
âźď¸
The file names matter!
When looking for a ROMâs sym file, emulators take the ROMâs file name, strip the extension (here, .gb
), replace it with .sym
, and look for a file in the same directory with that name.
Passo
Quando si mette in pausa lâesecuzione, il debugger si concentra automaticamente sullâistruzione che la CPU sta per eseguire, come indicato dalla riga evidenziata in blu.
âšď¸
The instruction highlighted in blue is always what the CPU is about to execute, not what it just executed. Keep this in mind.
If we want to watch execution from the beginning, we need to reset the emulator. Go into the emulatorâs âFileâ menu, and select âResetâ, or press Ctrl+Backspace.
La linea blu dovrebbe spostarsi automaticamente allâindirizzo $01001 e ora siamo pronti a tracciare! Tutti i comandi per farlo si trovano nel menu âEseguiâ.
- âResumeâ simply unpauses the emulator.
- âStep Intoâ and âStep Overâ advance the emulator by one instruction.
They only really differ on the
call
instruction, interrupts, and when encountering a conditional jump, neither of which we are using here, so we will use âStep Intoâ. - The other options are not relevant for now.
We will have to âStep Intoâ a bunch of times, so itâs a good idea to use the key shortcut.
If we press F5 once, the jp EntryPoint
is executed.
And if we press it a few more times, can see the instructions being executed, one by one!
Now, you may notice the WaitVBlank
loop runs a lot of times, but what we are interested in is the CopyTiles
loop.
We can easily skip over it in several ways; this time, we will use a breakpoint.
We will place the breakpoint on the ld de, Tiles
at 00:0162
; either double-click on that line, or select it and press Ctrl+Shift+B.
Then you can resume execution by pressing F8. Whenever Emulicious is running, and the (emulated) CPU is about to execute an instruction a breakpoint was placed on, it automatically pauses.
La freccia verde e il valore di PC indicano il punto in cui lâesecuzione è in pausa.
Se tracciamo le tre istruzioni successive, possiamo vedere che i tre argomenti del ciclo CopyTiles
vengono caricati nei registri.
For fun, letâs watch the tiles as theyâre being copied. For that, obviously, we will use the Memory Editor, and position it at the destination. As we can see from the image above, that would be $9000!
Click on âMemoryâ on the bottom window, then âVRAMâ, and press Ctrl+G (for âGotoâ).
Fantastico, vero?
E poi?
Congrats, you have just learned how to use a debugger! We have only scratched the surface, though; we will use more of Emuliciousâ tools to illustrate the next parts. Donât worry, from here on, lessons will go with a lot more imagesâyouâve made it through the hardest part!
-
Why does execution start at $0100? Thatâs because itâs where the boot ROM hands off control to our game once itâs done. âŠ
Tile
đ
Le âtileâ venivano chiamate in modo diverso nella documentazione di un tempo. Di solito venivano chiamate âschemiâ o âcaratteriâ e questâultima ha dato origine allâabbreviazione âCHRâ, che a volte viene usata per riferirsi alle tile.
Ad esempio, sul NES, i dati dei tile sono solitamente forniti dalla cartuccia in CHR ROM o CHR RAM. Il termine âCHRâ non è tipicamente utilizzato sul Game Boy, anche se gli scambi tra le comunitĂ fanno sĂŹ che i termini âtrapelinoâ, per cui alcuni si riferiscono allâarea della VRAM in cui sono memorizzati i tile come âCHR RAMâ o âCHR VRAMâ, ad esempio.
Come per tutti i gerghi di questo tipo, il cui significato può dipendere dallâinterlocutore, per coerenza mi atterrò a âtileâ in tutto il tutorial, essendo ormai lo standard piĂš diffuso nella comunitĂ degli sviluppatori di GB.
Copiare questi dati alla cieca va benissimo, ma perchĂŠ i dati sono âgraficiâ?

Ah, sĂŹ, i pixel.
Vediamo un poâ!
Un Aiuto
Now, figuring out the format with an explanation alone is going to be very confusing; but fortunately, Emulicious got us covered thanks to its Tile Viewer. You can open it either by selecting âToolsâ then âTile Viewerâ, or by clicking on the grid of colored tiles in the debuggerâs toolbar.
You can combine the various VRAM viewers by going to âViewâ, then âCombine Video Viewersâ. We will come to the other viewers in due time. This one shows the tiles present in the Game Boyâs video memory (or âVRAMâ).
đ¤
Vi invito a sperimentare con il visualizzatore di VRAM, a passare il mouse sulle cose, a spuntare e deselezionare le caselle di controllo, a scoprire da soli cosa è cosa. Tutte le domande che vi porrete troveranno risposta a tempo debito, non preoccupatevi! E se quello che vedete in seguito non corrisponde alle mie schermate, assicuratevi di avere le mie stesse caselle spuntate.
Donât mind the âÂŽâ icon in the top-left; we did not put it there ourselves, and we will see why itâs there later.
Primer breve
Potreste aver giĂ sentito parlare di tiles, soprattutto perchĂŠ erano molto popolari nei sistemi a 8 e 16 bit. Non è una coincidenza: le tile sono molto utili. Invece di memorizzare ogni pixel sullo schermo (144 Ă 160 pixel Ă 2 bit/pixel = 46080 bit = 5760 byte, rispetto agli 8192 byte di VRAM della console), i pixel vengono raggruppati in tile, che vengono poi assemblate in vari modi per produrre lâimmagine finale.
In particolare, le tile possono essere riutilizzate molto facilmente e praticamente a costo zero, risparmiando molta memoria! Inoltre, la manipolazione di intere tile in una sola volta è molto piÚ economica rispetto alla manipolazione dei singoli pixel e questo fa risparmiare tempo di elaborazione.
Il concetto di âtileâ è molto generico, ma sul Game Boy le tile sono sempre di 8 per 8 pixel. Spesso le tile hardware vengono raggruppate per essere manipolate come tile piĂš grandi (spesso 16Ă16); per evitare la confusione, queste vengono chiamate meta-tile.
âbppâ?
Vi starete chiedendo da dove provenga la cifra di â2 bit/pixelâ di cui sopra⌠Si tratta della cosiddetta âprofonditĂ di bitâ.
I colori non sono memorizzati nelle tile stesse! Invece, funziona come un libro da colorare: la tile in sĂŠ contiene indici di 8 x 8, non colori; si dĂ allâhardware una tile e un insieme di colori - una tavolozza - e lui li colora! (Questo è anche il motivo per cui le varianti di colore erano molto comuni allâepoca: si potevano creare diverse versioni di un nemico memorizzando piccole tavolozze invece di grandi grafiche diverse.)
In ogni caso, le tavolozze del Game Boy sono grandi 4 colori.1 Ciò significa che gli indici di tali tavolozze, memorizzati nelle tile, possono essere rappresentati con solo due bit! Questo si definisce â2 bit per pixelâ, noto come â2bppâ.
Con queste premesse, siamo pronti a spiegare come questi byte si trasformano in pixel!
Codifica
Come ho spiegato, ogni pixel occupa 2 bit. PoichĂŠ ci sono 8 bit in un byte, ci si potrebbe aspettare che ogni byte contenga 4 pixel⌠e non sarebbe nĂŠ del tutto giusto nĂŠ del tutto sbagliato. Ogni riga di 8 pixel è memorizzata in 2 byte, ma nessuno di questi byte contiene le informazioni per 4 pixel. (Pensate a una banconota da 10 ⏠strappata a metĂ : nessuna delle due metĂ ha valore, ma la banconota intera vale, beh, 10 âŹ).
Per ogni pixel, il bit meno significativo del suo indice è memorizzato nel primo byte, mentre il bit piÚ significativo è memorizzato nel secondo byte. PoichÊ ogni byte è una raccolta di uno dei bit di ciascun pixel, questo insieme viene chiamato bitplane.
Il pixel piÚ a sinistra è memorizzato nel primo bit da sinistra di entrambi i byte, il pixel alla sua destra nel secondo bit da sinistra e cosÏ via. La prima coppia di byte memorizza la riga superiore, il secondo byte quella inferiore e cosÏ via.
Ecco una dimostrazione piĂš visiva:
Questa codifica può sembrare un poâ strana allâinizio, e può esserlo; è fatta per essere piĂš conveniente per lâhardware da decodificare, mantenendo la circuiteria semplice e a basso consumo. Rende possibili anche alcuni trucchi interessanti, come vedremo (molto) piĂš avanti!
Si possono trovare ulteriori informazioni sulla codifica nei Pan Docs e nel sito di ShantyTown.
Nella prossima lezione vedremo come si applicano i colori!
-
Other consoles can have varying bit depths; for example, the SNES has 2bpp, 4bpp, and 8bpp depending on the graphics mode and a few other parameters. âŠ
Tavolozze
In the previous lesson, I briefly mentioned that colors are applied to tiles via palettes, but we havenât talked much about those yet.
Il Game Boy in bianco e nero ha tre tavolozze, una per lo sfondo chiamata BGP
(âBackGround Paletteâ) e due per gli oggetti chiamate OBP0
e OBP1
(âOBject Palette 0/1â).
Se vi state chiedendo cosa siano gli âoggettiâ, dovrete aspettare la Parte â
Ą per scoprirlo; per ora, concentriamoci sullo sfondo.
đ
Il Game Boy Color ha introdotto, ovviamente, i colori e questo è stato fatto principalmente rielaborando il modo in cui vengono gestite le tavolozze. Non parleremo delle caratteristiche del Game Boy Color nella Parte â per semplicitĂ , ma lo faremo nelle parti successive.
If you chose to combine the video viewers in the previous chapter, the palette viewer should show up on the bottom right of the video viewer.
Otherwise, please select Emuliciousâ âToolsâ tab, then select Palette Viewer
.
Daremo unâocchiata alla linea âBGPâ. Come ho spiegato in precedenza, le tile memorizzano gli âindici di coloreâ per ciascun pixel, i quali vengono utilizzati per indicizzare la tavolozza. Il colore numero 01 è quello piĂš a sinistra in questa riga e il numero 3 è quello piĂš a destra.
Quindi, nel nostro caso, il colore numero 0 è âbiancoâ, il numero 1 è âgrigio chiaroâ, il numero 2 è âgrigio scuroâ e il numero 3 è âneroâ. Ho messo le virgolette perchĂŠ âneroâ non è nero puro e âbiancoâ non è bianco puro. Inoltre, notate che il Game Boy originale aveva sfumature di verde, mentre lo schermo del successivo Game Boy Pocket produceva sfumature di grigio. E, meglio ancora, il Game Boy Color è in grado di colorare automaticamente i giochi che non supportano il Game Boy Color!
Tutto questo per dire che non ci si deve aspettare colori specifici da un gioco per Game Boy2, ma solo quattro colori piĂš o meno luminosi.
Sporchiamoci le mani
In questo tutorial, finora, a parte lâesecuzione di Hello World siamo stati piuttosto passivi, osservando il suo svolgimento. Che ne dite se iniziamo a smuovere un poâ la ROM?
In Emuliciousâ debugger, select the âVariablesâ tab on the left to show the IO registers.
While the VRAM viewer offers a visual representation of the palette, the IO map shows the nitty-gritty: how itâs encoded. The IO map also lets us modify BGP easily; but to do so, we need to understand how values we write are turned into colors.
Codifica
Fortunatamente, la codifica è molto semplice. La spiegherò e allo stesso tempo fornirò un esempio con la tavolozza che abbiamo a portata di mano, $E4.
Take the byte, break its 8 bits into 4 groups of 2.
[BGP] = $E4
$E4 = %11100100 (refresh your memory in the "Binary and hexadecimal" lesson if needed!)
That gets broken down into %11, %10, %01, %00
Il colore numero 0 è il âgruppoâ piĂš a destra, il colore numero 3 è quello piĂš a sinistra. Semplice! E questo corrisponde a ciò che ci mostra il visualizzatore VRAM: il colore numero 0, quello piĂš a destra, è quello piĂš chiaro (%00), fino al colore numero 3, che è quello piĂš a sinistra e il piĂš scuro (%11).
Luci spente
Per divertimento, rendiamo lo schermo completamente nero.
Lo si può fare facilmente impostando tutti i colori della tavolozza sul nero (%11).
Questo si traduce in %11 %11 %11 %11 = $FF
.
In the âVariablesâ tab in the debugger, click on the byte to the right of BGP, erase the âE4â, type âFFâ, and hit Enter. BGP immediately updates, turning the screen black!

E se volessimo prendere la tavolozza originale, ma invertirla? %11 diventerebbe %00, %01 diventerebbe %10, %10 diventerebbe %01 e %00 diventerebbe %11. Otterremmo cosĂŹ:
%11_10_01_00
â â â â
%00_01_10_11
(Non fornirò il valore in esadecimale, usate questa opportunità per esercitare le vostre conversioni da bin a esadecimale!)

If you go to the Tile Viewer and change âPaletteâ to âGrayâ, you will notice that the tile data stays the same regardless of how the palette is modified! This is an advantage of using palettes: fading the screen in and out is very cheap, just modifying a single byte, instead of having to update every single on-screen pixel.
Avete capito tutto? Allora diamo unâocchiata allâultimo pezzo mancante del puzzle nel processo di rendering di Hello World, la tilemap!
-
Numbering often starts at 0 when working with computers. We will understand why later, but for now, please bear with it! âŠ
-
Well, it is possible to detect these different models and account for them, but this would require taking plenty of corner cases into consideration, so itâs probably not worth the effort. âŠ
Tilemap
đ§
Alcuni li chiamano âtile mapâ, altri âtilemapâ.
Io userò questâultimo di preferenza, ma lo userò anche nel codice (Tilemap
e non TileMap
), cosĂŹ come piĂš avanti quando parleremo di mappe di attributi (âattrmapâ e Attrmap
invece di AttrMap
).
Ci siamo quasi. Abbiamo visto come la grafica del Game Boy sia composta da âtessereâ 8Ă8 e come il colore venga aggiunto al mix.
Ma non abbiamo ancora visto come queste tessere vengono disposte in unâimmagine finale!
Tiles are basically a grid of pixels; well, the tilemaps are basically a grid of tiles! To allow for cheap reuse, tiles arenât stored in the tilemap directly; instead, tiles are referred to by an ID, which you can see in Emuliciousâ Tile Viewer.

Naturalmente gli ID delle tessere sono numeri, come tutti i computer. Gli ID sono memorizzati in byte, quindi ci sono 256 possibili ID di piastrelle. Tuttavia, il lettore piÚ attento avrà notato che ci sono 384 tessere in totale[^blocchi_di_tile]! In virtÚ del principio della piccionaia, ciò significa che alcuni ID si riferiscono a piÚ piastrelle contemporaneamente.
Indeed, Emulicious reports that the first 128 tiles have the same IDs as the last 128. There exists a mechanism to select whether IDs 0â127 reference the first or last 128 tiles, but for simplicityâs sake, we will overlook this for now, so please ignore the first (topmost) 128 tiles for the time being.
Now, please turn your attention to Emuliciousâ Tilemap Viewer, pictured below.
You may notice that the image shown is larger than what is displayed on-screen. Only part of the tilemap, outlined by a thicker border in the Tilemap Viewer, is displayed on-screen at a given time. We will explain this in more detail in Part â Ą.
Qui potremo vedere la potenza del riutilizzo dei tile in tutta la sua forza. Per comoditĂ e per rinfrescare la memoria, ecco le tessere che il nostro Hello World carica nella VRAM:
Si può notare che abbiamo caricato una sola tessera âvuotaâ ($00, la prima a sinistra in alto), ma può essere ripetuta per coprire lâintero sfondo senza costi aggiuntivi!
La ripetizione può essere piĂš sottile: ad esempio, la tessera $01 è utilizzata per lâangolo superiore sinistro di H, E, L, L e W (linee rosse in basso)! Anche la R, la L e la D condividono la loro tessera in alto a sinistra ($2D, linee blu in basso); e cosĂŹ via. Ă possibile confermarlo passando il mouse sulle tessere nella scheda BG map, che mostra lâID della tessera in quella posizione.
Tutto sommato, possiamo dedurre che la visualizzazione della grafica sul Game Boy consiste nel caricare i âmodelliâ (le tessere) e nel dire alla console quale tessera visualizzare per ciascuna posizione.
In conclusione
Congratulazioni! Avete superato la prima parte di questa guida. A questo punto, avete una conoscenza di base della console tale da sapere come visualizzare unâimmagine. Non sembra molto, ma considerate tutto ciò che avete visto finora: câè stato molto da fare!
đĽł
Sinceramente, congratulazioni per essere arrivati fino a questo punto: molte persone si sono arrese prima. Quindi potete darvi una pacca sulla spalla, ve la meritate davvero! Se state leggendo tutto questo in un solo tratto, potrebbe essere un buon momento per fare una pausa. Vi incoraggio a dare un poâ di tempo per assimilare il tutto e magari a tornare alle lezioni su cui avete fatto piĂš fatica. Forse una seconda lettura può aiutarvi.
E sÏ, si poteva semplicemente lasciare che fosse una libreria a gestire tutto questo. Tuttavia, i dettagli trapelano sempre alla fine, quindi conoscerli è utile, anche solo per il debug.
Inoltre, capire cosa succede davvero sotto il cofano vi renderĂ programmatori migliori, anche se alla fine non userete ASM. Ă divertente notare che anche i sistemi moderni funzionano in modo simile a quelli piĂš vecchi in punti inaspettati, quindi alcune cose che avete appena imparato si ripeteranno! Fidatevi, tutto ciò che avete imparato e imparerete ne vale la pena! â
That said, right now, you may have a lot of questions.
- Why do we turn off the LCD?
- We know how to make a static picture, but how to we add motion into the mix?
- Also, how do I get input from the player?
- The code mentions shutting down audio, but how do I play some of those famed beeps and bloops?
- Writing graphics in that way sound tedious, is there no other way?
- Actually, wait, how do we make a game out of all this??
⌠Tutte queste risposte, e altre ancora, nella Parte â Ą! đ
Come iniziare
In questa lezione inizieremo un nuovo progetto da zero. Creeremo un clone di Breakout / Arkanoid, che chiameremo âUnbrickedâ! (Anche se siete liberi di dargli qualsiasi altro nome, perchĂŠ sarĂ il vostro progetto).
Aprire un terminale e creare una nuova directory (mkdir unbricked
), quindi entrarvi (cd unbricked
), proprio come si è fatto per âHello, world!â.
Si inizia creando un file chiamato main.asm
e si include hardware.inc
nel codice.
INCLUDE "hardware.inc"
You may be wondering what purpose hardware.inc
serves.
Well, the code we write only really affects the CPU, but does not do anything with the rest of the console (not directly, anyway).
To interact with other components (like the graphics system, say), Memory-Mapped I/O (MMIO) is used: basically, memory in a certain range (addresses $FF00âFF7F) does special things when accessed.
Essendo questi byte di memoria interfacce per lâhardware, sono chiamati registri hardware (da non confondere con i registri della CPU).
Ad esempio, il registro âstato PPUâ si trova allâindirizzo $FF41.
La lettura di questo indirizzo riporta varie informazioni sul sistema grafico e la scrittura consente di modificare alcuni parametri.
Ma dover ricordare tutti i numeri (elenco non esaustivo) sarebbe molto noioso, ed è qui che entra in gioco hardware.inc
!
hardware.inc
definisce una costante per ciascuno di questi registri (per esempio, rSTAT
per il giĂ citato registro âstato della PPUâ), piĂš alcune costanti aggiuntive per i valori letti o scritti in questi registri.
Non preoccupatevi se tutto questo vi è sfuggito di mano: di seguito vedremo un esempio con rLCDC
e LCDCF_ON
.
A proposito, la r
sta per âregistroâ e la F
in LCDCF
sta per âflagâ.
Quindi, fare spazio per lâintestazione. Ricordiamo dalla Parte â che lâintestazione è il luogo in cui vengono memorizzate alcune informazioni su cui il Game Boy fa affidamento, quindi non bisogna lasciarla fuori per sbaglio.
SECTION "Header", ROM0[$100]
jp EntryPoint
ds $150 - @, 0 ; Make room for the header
Lâintestazione salta a EntryPoint
, quindi scriviamola ora:
EntryPoint:
; Do not turn the LCD off outside of VBlank
WaitVBlank:
ld a, [rLY]
cp 144
jp c, WaitVBlank
; Turn the LCD off
ld a, 0
ld [rLCDC], a
Le righe successive attendono fino a âVBlankâ, che è lâunico momento in cui è possibile spegnere lo schermo in modo sicuro (farlo nel momento sbagliato potrebbe danneggiare un vero Game Boy, quindi è molto importante). Spiegheremo cosâè il VBlank e ne parleremo piĂš avanti nel corso dellâesercitazione.
Spegnere lo schermo è importante perchÊ il caricamento di nuove tessere a schermo acceso è complicato, come vedremo nella terza parte.
A proposito di tessere, ora ne caricheremo alcune nella VRAM, utilizzando il seguente codice:
; Copy the tile data
ld de, Tiles
ld hl, $9000
ld bc, TilesEnd - Tiles
CopyTiles:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jp nz, CopyTiles
Questo ciclo potrebbe essere che ricorda la parte â
.
Copia a partire da Tiles
fino a $9000
, che è la parte di VRAM in cui verrà memorizzato il nostro tiles.
Ricordiamo che $9000
è il luogo in cui si trovano i dati del tile di sfondo $00, e i dati dei tile successivi seguono subito dopo.
Per ottenere il numero di byte da copiare, faremo come nella parte â
: usando unâaltra etichetta alla fine, chiamata TilesEnd
, la differenza tra questa (= lâindirizzo dopo lâultimo byte dei dati delle tile) e Tiles
(= lâindirizzo del primo byte) sarĂ esattamente quella lunghezza.
Detto questo, non abbiamo ancora scritto Tiles
nĂŠ i relativi dati.
Ci arriveremo piĂš tardi!
Quasi finito ora - il prossimo, scrivere un altro ciclo, questa volta per copiare la mappa delle piastrelle.
; Copy the tilemap
ld de, Tilemap
ld hl, $9800
ld bc, TilemapEnd - Tilemap
CopyTilemap:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jp nz, CopyTilemap
Si noti che, mentre il corpo di questo ciclo è esattamente lo stesso di CopyTiles
, i 3 valori caricati in de
, hl
e bc
sono diversi.
Questi determinano rispettivamente lâorigine, la destinazione e la dimensione della copia.
"Don't Repeat Yourself"
Se pensate che tutto ciò sia superfluo, non avete torto: vedremo piĂš avanti come scrivere delle vere e proprie funzioni riutilizzabili. Ma câè molto di piĂš di quello che sembra, quindi inizieremo ad affrontarle molto piĂš avanti.
Infine, riaccendiamo lo schermo e impostiamo una palette di sfondo.
Invece di scrivere il numero non descritto %10000001
(o $81 o 129, a seconda dei gusti), usiamo due costanti gentilmente fornite da hardware.inc
: LCDCF_ON
e LCDCF_BGON
.
Quando vengono scritte su rLCDC
, la prima fa sĂŹ che la PPU e lo schermo si riaccendano, mentre la seconda permette di disegnare lo sfondo.
(Ci sono altri elementi che potrebbero essere disegnati, ma non li abilitiamo ancora).
La combinazione di queste costanti deve essere fatta usando |
, lâoperatore binario âo â; vedremo perchĂŠ piĂš avanti.
; Turn the LCD on
ld a, LCDCF_ON | LCDCF_BGON
ld [rLCDC], a
; During the first (blank) frame, initialize display registers
ld a, %11100100
ld [rBGP], a
Done:
jp Done
Câè unâultima cosa di cui abbiamo bisogno prima di costruire la ROM: la grafica. Disegneremo la seguente schermata:
In hello-world.asm
, tile data had been written out by hand in hexadecimal; this was to let you see how the sausage is made at the lowest level, but boy is it impractical to write!
This time, we will employ a more friendly way, which will let us write each row of pixels more easily.
For each row of pixels, instead of writing the bitplanes directly, we will use a backtick (```) followed by 8 characters.
Each character defines a single pixel, intuitively from left to right; it must be one of 0, 1, 2, and 3, representing the corresponding color index in the palette.
Se la selezione dei caratteri non è di vostro gradimento, potete usare lâopzione -g
di RGBASM o OPT g
per sceglierne altri.
Per esempio, rgbasm -g '.xXO' (...)
o OPT g.xXO
scambiano i quattro caratteri rispettivamente con .
, x
, X
e O
.
Ad esempio:
dw `01230123 ; This is equivalent to `db $55,$33`
Avrete notato che stiamo usando dw
invece di db
; la differenza tra questi due elementi sarĂ spiegata piĂš avanti.
Abbiamo giĂ delle piastrelle per questo progetto, quindi potete copiare questo file premade e incollarlo alla fine del vostro codice.
Quindi copiare la mappa delle piastrelle da [questo file] (https://github.com/gbdev/gb-asm-tutorial/raw/master/unbricked/getting-started/tilemap.asm) e incollarla dopo lâetichetta TilesEnd
.
Ă possibile creare la ROM ora, eseguendo i seguenti comandi nel terminale:
rgbasm -o main.o main.asm
rgblink -o unbricked.gb main.o
rgbfix -v -p 0xFF unbricked.gb
Se si esegue questa operazione nellâemulatore, si dovrebbe vedere quanto segue:
Quel quadrato bianco sembra essere scomparso! Forse avete notato questo commento in precedenza, da qualche parte nei dati delle piastrelle:
dw `22322232
dw `23232323
dw `33333333
; Paste your logo here:
TilesEnd:
Le tessere del logo sono state lasciate intenzionalmente vuote, in modo che possiate scegliere il vostro. Potete utilizzare uno dei seguenti loghi giĂ pronti, oppure provare a crearne uno vostro!
Aggiungete i dati del logo scelto (cliccate su uno dei link âSourceâ qui sopra) dopo il commento, create di nuovo il gioco e dovreste vedere il logo scelto in basso a destra!
Oggetti
Lo sfondo è molto utile quando lâintero schermo deve muoversi contemporaneamente, ma non è lâideale per tutto. Ad esempio, il cursore in un menu, i PNG e il giocatore in un RPG, i proiettili in uno shmup o le palline in un clone di Arkanoid⌠devono tutti muoversi indipendentemente dallo sfondo. Fortunatamente, il Game Boy ha una funzione perfetta per queste situazioni! In questa lezione parleremo di oggetti (talvolta chiamati âOBJâ).
La descrizione precedente potrebbe avervi fatto pensare al termine âspriteâ invece che a âoggettoâ. Il termine âspriteâ ha molti significati a seconda del contesto, quindi, per evitare confusione, questo tutorial cerca di usare alternative specifiche, come oggetto, metasprite, attore, ecc.
Ogni oggetto permette di disegnare una o due piastrelle (rispettivamente 8Ă8 o 8Ă16 pixel) in qualsiasi posizione sullo schermo, a differenza dello sfondo, dove tutte le piastrelle sono disegnate in una griglia. Pertanto, un oggetto è composto dalla sua posizione sullo schermo, da un ID tile (come con la tilemap) e da alcune proprietĂ extra chiamate âattributiâ. Queste proprietĂ extra consentono, ad esempio, di visualizzare la piastrella capovolta. Ne parleremo piĂš avanti.
Just like how the tilemap is stored in VRAM, objects live in a region of memory called OAM, meaning Object Attribute Memory. Recall from above that an object consists of:
- Its on-screen position
- A tile ID
- The âattributesâ
Questi sono memorizzati in 4 byte: uno per la coordinata Y, uno per la coordinata X, uno per lâID della piastrella e uno per gli attributi. LâOAM è lungo 160 byte e poichĂŠ 160 â 4 = 40, il Game Boy memorizza un totale di 40 oggetti in qualsiasi momento.
Câè però un problema: i byte delle coordinate Y e X di un oggetto in OAM non memorizzano la sua posizione sullo schermo! Invece, la posizione X sullo schermo è la posizione X memorizzata meno 8, e la posizione Y sullo schermo è la posizione Y memorizzata meno 16. Per interrompere la visualizzazione di un oggetto, è sufficiente metterlo fuori dallo schermo, ad esempio impostando la sua posizione Y a 0.
Questi offset non sono arbitrari! Si consideri la dimensione massima di un oggetto: 8 x 16 pixel. Questi offset consentono agli oggetti di essere tagliati dai bordi sinistro e superiore dello schermo. Il NES, ad esempio, non dispone di tali offset, per cui si noterĂ che gli oggetti scompaiono sempre dopo aver toccato il bordo sinistro o superiore dello schermo.
Scopriamo gli oggetti sperimentandoli!
Innanzitutto, allâaccensione del Game Boy, lâOAM si riempie di valori semicasuali, che possono coprire lo schermo di spazzatura casuale.
Risolviamo questo problema cancellando lâOAM prima di attivare gli oggetti per la prima volta.
Aggiungiamo quanto segue subito dopo il ciclo CopyTilemap
:
ld a, 0
ld b, 160
ld hl, _OAMRAM
ClearOam:
ld [hli], a
dec b
jp nz, ClearOam
Questo è un buon momento per farlo, poichÊ proprio come la VRAM, lo schermo deve essere spento per accedere in modo sicuro alla OAM.
Una volta che lâOAM è svuotato, possiamo disegnare un oggetto scrivendo le sue proprietĂ .
ld hl, _OAMRAM
ld a, 128 + 16
ld [hli], a
ld a, 16 + 8
ld [hli], a
ld a, 0
ld [hli], a
ld [hli], a
Si ricordi che ogni oggetto in OAM è composto da 4 byte, nellâordine Y, X, Tile ID, Attributi. Quindi, il pixel in alto a sinistra dellâoggetto si trova a 128 pixel dalla parte superiore dello schermo e a 16 da quella sinistra. LâID tessera e gli attributi sono entrambi impostati a 0.
Come si ricorderĂ dalla lezione precedente, stiamo giĂ usando lâID 0, che è lâinizio della grafica del nostro sfondo. Tuttavia, per impostazione predefinita, gli oggetti e gli sfondi utilizzano un insieme diverso di piastrelle, almeno per i primi 128 ID. Le mattonelle con ID 128-255 sono condivise da entrambi, il che è utile se si ha una mattonella che viene utilizzata sia dallo sfondo che da un oggetto.
If you go to âToolsâ, then âTile Viewerâ in Emuliciousâ debugger, you should see three distinct sections.
PoichĂŠ dobbiamo caricarla in unâarea diversa, useremo lâindirizzo $8000 e caricheremo una grafica per la paletta del gioco.
Lo faremo subito dopo CopyTilemap
:
; Copy the paddle tile
ld de, Paddle
ld hl, $8000
ld bc, PaddleEnd - Paddle
CopyPaddle:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jp nz, CopyPaddle
E non dimenticate di aggiungere Paddle
alla fine del codice.
Paddle:
dw `13333331
dw `30000003
dw `13333331
dw `00000000
dw `00000000
dw `00000000
dw `00000000
dw `00000000
PaddleEnd:
Infine, abilitiamo gli oggetti e vediamo il risultato.
Gli oggetti devono essere abilitati dal noto registro rLCDC
, altrimenti non vengono visualizzati.
(Questo è il motivo per cui non abbiamo dovuto cancellare lâOAM nelle lezioni precedenti).
Dobbiamo anche inizializzare una delle tavolozze degli oggetti, rOBP0
.
In realtĂ ci sono due tavolozze di oggetti, ma ne useremo solo una.
; Turn the LCD on
ld a, LCDCF_ON | LCDCF_BGON | LCDCF_OBJON
ld [rLCDC], a
; During the first (blank) frame, initialize display registers
ld a, %11100100
ld [rBGP], a
ld a, %11100100
ld [rOBP0], a
Movimento
Ora che abbiamo un oggetto sullo schermo, spostiamolo.
In precedenza, il ciclo Done
non faceva nulla; rinominiamolo in Main
e usiamolo per spostare il nostro oggetto.
Aspetteremo il VBlank prima di cambiare OAM, proprio come abbiamo fatto prima di spegnere lo schermo.
Main:
; Wait until it's *not* VBlank
ld a, [rLY]
cp 144
jp nc, Main
WaitVBlank2:
ld a, [rLY]
cp 144
jp c, WaitVBlank2
; Move the paddle one pixel to the right.
ld a, [_OAMRAM + 1]
inc a
ld [_OAMRAM + 1], a
jp Main
đ¤¨
In questo caso, si accede allâOAM senza spegnere lo schermo LCD, ma è comunque sicuro. Per spiegarne il motivo è necessaria una spiegazione piĂš approfondita del rendering del Game Boy, quindi per ora ignoriamolo.
Now you should see the paddle moving⌠very quickly. Because it moves by a pixel every frame, itâs going at a speed of 60 pixels per second! To slow this down, weâll use a variable.
Finora abbiamo lavorato solo con i registri della CPU, ma è possibile creare anche variabili globali!
Per farlo, creiamo unâaltra sezione, ma mettiamola in WRAM0
invece che in ROM0
.
A differenza della ROM (âRead-Only Memoryâ, memoria di sola lettura), la RAM (âRandom-Access Memoryâ, memoria ad accesso casuale) può essere scritta; quindi, la WRAM, o Work RAM, è il luogo in cui possiamo memorizzare le variabili del nostro gioco.
Aggiungete questo in fondo al vostro file:
SECTION "Counter", WRAM0
wFrameCounter: db
Now weâll use the wFrameCounter
variable to count how many frames have passed since we last moved the paddle.
Every 15th frame, weâll move the paddle by one pixel, slowing it down to 4 pixels per second.
Donât forget that RAM is filled with garbage values when the Game Boy starts, so we need to initialize our variables before first using them.
; Initialize global variables
ld a, 0
ld [wFrameCounter], a
Main:
ld a, [rLY]
cp 144
jp nc, Main
WaitVBlank2:
ld a, [rLY]
cp 144
jp c, WaitVBlank2
ld a, [wFrameCounter]
inc a
ld [wFrameCounter], a
cp a, 15 ; Every 15 frames (a quarter of a second), run the following code
jp nz, Main
; Reset the frame counter back to 0
ld a, 0
ld [wFrameCounter], a
; Move the paddle one pixel to the right.
ld a, [_OAMRAM + 1]
inc a
ld [_OAMRAM + 1], a
jp Main
Bene! Il prossimo passo è prendere il controllo della tessera.
Funzioni
Finora abbiamo scritto un solo âflussoâ di codice, ma possiamo giĂ individuare alcuni frammenti che sembrano ridondanti. Usiamo le funzioni per âsfoltireâ il codice!
Per esempio, in tre punti stiamo copiando pezzi di memoria.
Scriviamo una funzione sotto la jp Main
e chiamiamola Memcpy
, come lâanaloga funzione C:
; Copy bytes from one area to another.
; @param de: Source
; @param hl: Destination
; @param bc: Length
Memcopy:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jp nz, Memcopy
ret
La nuova istruzione ret' dovrebbe catturare immediatamente l'attenzione. Ă, senza sorpresa, quella che fa sĂŹ che l'esecuzione *ritorni* al punto in cui la funzione è stata *chiamata*. Ă importante notare che molti linguaggi prevedono una "fine" precisa per una funzione: in C o Rust, è la parentesi graffa di chiusura
}; in Pascal o Lua, la parola chiave
end, e cosÏ via; la funzione ritorna implicitamente quando l'esecuzione raggiunge la sua fine. Tuttavia, **non è cosÏ in assembly**, quindi bisogna ricordarsi di aggiungere un'istruzione
ret` alla fine della funzione per ritornare da essa!
Altrimenti, i risultati sono imprevedibili.
Notate il commento sopra la funzione, che spiega quali registri prende in ingresso. Questo commento è importante per sapere come interfacciarsi con la funzione; lâassembly non ha parametri formali, quindi i commenti che li spiegano sono ancora piĂš importanti che in altri linguaggi. Ne vedremo altri man mano che procediamo.
Ci sono tre punti nel codice di inizializzazione in cui possiamo usare la funzione Memcpy
.
Trovate ognuno di questi cicli di copia e sostituiteli con una chiamata a Memcpy
; per questo, utilizziamo lâistruzione call
.
I registri servono come parametri alla funzione, quindi li lasceremo cosĂŹ come sono.
Prima di | Dopo |
---|---|
|
|
|
|
|
|
Nel prossimo capitolo, scriveremo unâaltra funzione, questa volta per leggere gli input del giocatore.
Input Giocatore
Abbiamo le fondamenta di un gioco, ma manca ancora lâinput del giocatore. Un gioco che si gioca da solo non è molto divertente, quindi cerchiamo di risolvere questo problema.
Incollare questo codice sotto il ciclo Main
.
Come Memcpy
, questa è una funzione che può essere riutilizzata da diversi punti, utilizzando lâistruzione call
.
UpdateKeys:
; Poll half the controller
ld a, P1F_GET_BTN
call .onenibble
ld b, a ; B7-4 = 1; B3-0 = unpressed buttons
; Poll the other half
ld a, P1F_GET_DPAD
call .onenibble
swap a ; A7-4 = unpressed directions; A3-0 = 1
xor a, b ; A = pressed buttons + directions
ld b, a ; B = pressed buttons + directions
; And release the controller
ld a, P1F_GET_NONE
ldh [rP1], a
; Combine with previous wCurKeys to make wNewKeys
ld a, [wCurKeys]
xor a, b ; A = keys that changed state
and a, b ; A = keys that changed to pressed
ld [wNewKeys], a
ld a, b
ld [wCurKeys], a
ret
.onenibble
ldh [rP1], a ; switch the key matrix
call .knownret ; burn 10 cycles calling a known ret
ldh a, [rP1] ; ignore value while waiting for the key matrix to settle
ldh a, [rP1]
ldh a, [rP1] ; this read counts
or a, $F0 ; A7-4 = 1; A3-0 = unpressed keys
.knownret
ret
Sfortunatamente, la lettura degli input sul Game Boy è piuttosto complessa (come potete vedere!) e sarebbe piuttosto difficile spiegare cosa fa questa funzione in questo momento. Vi chiedo quindi di fare unâeccezione e di fidarvi del fatto che questa funzione legge lâinput. Va bene? Bene!
Ora che sappiamo come usare le funzioni, chiamiamo la funzione UpdateKeys
nel nostro ciclo principale per leggere lâinput dellâutente.
La funzione UpdateKeys
scrive i pulsanti tenuti in una posizione in memoria che abbiamo chiamato wCurKeys
, da cui possiamo leggere dopo il ritorno della funzione.
Per questo motivo, è sufficiente chiamare UpdateKeys
una sola volta per ogni fotogramma.
Questo è importante, perchÊ non solo è piÚ veloce ricaricare gli input che abbiamo già elaborato, ma significa anche che agiremo sempre sugli stessi input, anche se il giocatore preme o rilascia un pulsante a metà fotogramma.
Per prima cosa, mettiamo da parte un poâ di spazio per le due variabili che UpdateKeys
utilizzerĂ ; incollate questo alla fine del file main.asm
:
SECTION "Input Variables", WRAM0
wCurKeys: db
wNewKeys: db
Ogni variabile deve risiedere nella RAM e non nella ROM, perchĂŠ la ROM è âRead-Onlyâ (quindi non può essere modificata).
Inoltre, ogni variabile deve essere grande solo un byte, quindi si usa db
(âDefine Byteâ) per riservare un byte di RAM a ciascuna.
Before we read these variables we will also want to initialize them.
We can do that below our initialization of wFrameCounter
.
; Initialize global variables
ld a, 0
ld [wFrameCounter], a
ld [wCurKeys], a
ld [wNewKeys], a
Utilizzeremo lâopcode and
, che può essere utilizzato per impostare il flag di zero (z
) sul valore del bit.
Possiamo usare questo insieme alle costanti PADF
in hardware.inc per leggere un particolare tasto.
Main:
ld a, [rLY]
cp 144
jp nc, Main
WaitVBlank2:
ld a, [rLY]
cp 144
jp c, WaitVBlank2
; Check the current keys every frame and move left or right.
call UpdateKeys
; First, check if the left button is pressed.
CheckLeft:
ld a, [wCurKeys]
and a, PADF_LEFT
jp z, CheckRight
Left:
; Move the paddle one pixel to the left.
ld a, [_OAMRAM + 1]
dec a
; If we've already hit the edge of the playfield, don't move.
cp a, 15
jp z, Main
ld [_OAMRAM + 1], a
jp Main
; Then check the right button.
CheckRight:
ld a, [wCurKeys]
and a, PADF_RIGHT
jp z, Main
Right:
; Move the paddle one pixel to the right.
ld a, [_OAMRAM + 1]
inc a
; If we've already hit the edge of the playfield, don't move.
cp a, 105
jp z, Main
ld [_OAMRAM + 1], a
jp Main
Ora, se compilate il progetto, dovreste essere in grado di muovere la racchetta a destra e a sinistra usando il d-pad!!! UrrĂ , abbiamo lâinizio di un gioco!
Collisioni
Potersi muovere è fantastico, ma câè ancora un oggetto di cui abbiamo bisogno per questo gioco: una palla! Come per la racchetta, il primo passo è creare un riquadro per la palla e caricarlo nella VRAM.
Grafica
Add this to the bottom of your file along with the other graphics:
Ball:
dw `00033000
dw `00322300
dw `03222230
dw `03222230
dw `00322300
dw `00033000
dw `00000000
dw `00000000
BallEnd:
Now copy it to VRAM somewhere in your initialization code, e.g. after copying the paddleâs tile.
; Copy the ball tile
ld de, Ball
ld hl, $8010
ld bc, BallEnd - Ball
call Memcopy
In addition, we need to initialize an entry in OAM, following the code that initializes the paddle.
; Initialize the paddle sprite in OAM
ld hl, _OAMRAM
ld a, 128 + 16
ld [hli], a
ld a, 16 + 8
ld [hli], a
ld a, 0
ld [hli], a
ld [hli], a
; Now initialize the ball sprite
ld a, 100 + 16
ld [hli], a
ld a, 32 + 8
ld [hli], a
ld a, 1
ld [hli], a
ld a, 0
ld [hli], a
As the ball bounces around the screen its momentum will change, sending it in different directions.
Letâs create two new variables to track the ballâs momentum in each axis: wBallMomentumX
and wBallMomentumY
.
SECTION "Counter", WRAM0
wFrameCounter: db
SECTION "Input Variables", WRAM0
wCurKeys: db
wNewKeys: db
SECTION "Ball Data", WRAM0
wBallMomentumX: db
wBallMomentumY: db
We will need to initialize these before entering the game loop, so letâs do so right after we write the ball to OAM. By setting the X momentum to 1, and the Y momentum to -1, the ball will start out by going up and to the right.
; Now initialize the ball sprite
ld a, 100 + 16
ld [hli], a
ld a, 32 + 8
ld [hli], a
ld a, 1
ld [hli], a
ld a, 0
ld [hli], a
; The ball starts out going up and to the right
ld a, 1
ld [wBallMomentumX], a
ld a, -1
ld [wBallMomentumY], a
Lavoro di preparazione
Now for the fun part!
Add a bit of code at the beginning of your main loop that adds the momentum to the OAM positions.
Notice that since this is the second OAM entry, we use + 4
for Y and + 5
for X.
This can get pretty confusing, but luckily we only have two objects to keep track of.
In the future, weâll go over a much easier way to use OAM.
Main:
ld a, [rLY]
cp 144
jp nc, Main
WaitVBlank2:
ld a, [rLY]
cp 144
jp c, WaitVBlank2
; Add the ball's momentum to its position in OAM.
ld a, [wBallMomentumX]
ld b, a
ld a, [_OAMRAM + 5]
add a, b
ld [_OAMRAM + 5], a
ld a, [wBallMomentumY]
ld b, a
ld a, [_OAMRAM + 4]
add a, b
ld [_OAMRAM + 4], a
Si consiglia di compilare nuovamente il gioco per vedere cosa fa. Se lo fate, dovreste vedere la palla muoversi, ma passerĂ attraverso i muri e poi volerĂ fuori dallo schermo.
Per risolvere questo problema, dobbiamo aggiungere il rilevamento delle collisioni, in modo che la palla possa rimbalzare. Dovremo ripetere il controllo delle collisioni un paio di volte, quindi utilizzeremo due funzioni per farlo.
Non perdetevi nei dettagli di questa funzione, perchĂŠ utilizza alcune tecniche e istruzioni che non abbiamo ancora discusso. Lâidea di base è che converte la posizione dello sprite in una posizione sulla mappa delle piastrelle. In questo modo, possiamo controllare quale piastrella sta toccando la nostra palla, in modo da sapere quando rimbalzare!
; Convert a pixel position to a tilemap address
; hl = $9800 + X + Y * 32
; @param b: X
; @param c: Y
; @return hl: tile address
GetTileByPixel:
; First, we need to divide by 8 to convert a pixel position to a tile position.
; After this we want to multiply the Y position by 32.
; These operations effectively cancel out so we only need to mask the Y value.
ld a, c
and a, %11111000
ld l, a
ld h, 0
; Now we have the position * 8 in hl
add hl, hl ; position * 16
add hl, hl ; position * 32
; Convert the X position to an offset.
ld a, b
srl a ; a / 2
srl a ; a / 4
srl a ; a / 8
; Add the two offsets together.
add a, l
ld l, a
adc a, h
sub a, l
ld h, a
; Add the offset to the tilemap's base address, and we are done!
ld bc, $9800
add hl, bc
ret
The next function is called IsWallTile
, and itâs going to contain a list of tiles which the ball can bounce off of.
; @param a: tile ID
; @return z: set if a is a wall.
IsWallTile:
cp a, $00
ret z
cp a, $01
ret z
cp a, $02
ret z
cp a, $04
ret z
cp a, $05
ret z
cp a, $06
ret z
cp a, $07
ret
Questa funzione può sembrare un poâ strana allâinizio.
Invece di restituire il risultato in un registro, come a
, lo restituisce in un flag: Z
!
Se in qualsiasi punto una piastrella corrisponde, la funzione ha trovato un muro ed esce con Z
impostato.
Se lâID della piastrella di destinazione (in a
) corrisponde a uno degli ID delle piastrelle del muro, il corrispondente cp
lascerĂ Z
impostato; in tal caso, si ritorna immediatamente (tramite ret z
), con Z
impostato.
Ma se, dopo aver effettuato lâultimo confronto, Z
non viene ancora impostato, sapremo che non abbiamo colpito un muro e non abbiamo bisogno di rimbalzare.
Unificare il tutto
Time to use these new functions to add collision detection! Add the following after the code that updates the ballâs position:
BounceOnTop:
; Remember to offset the OAM position!
; (8, 16) in OAM coordinates is (0, 0) on the screen.
ld a, [_OAMRAM + 4]
sub a, 16 + 1
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8
ld b, a
call GetTileByPixel ; Returns tile address in hl
ld a, [hl]
call IsWallTile
jp nz, BounceOnRight
ld a, 1
ld [wBallMomentumY], a
Vedrete che quando carichiamo le posizioni dello sprite, le sottraiamo prima di chiamare GetTileByPixel
.
Forse ricorderete dallâultimo capitolo che le posizioni OAM sono leggermente sfalsate; cioè, (0, 0) in OAM è in realtĂ completamente fuori dallo schermo.
Queste istruzioni sub
annullano questo offset.
Tuttavia, câè dellâaltro: avrete notato che abbiamo sottratto un pixel in piĂš dalla posizione Y. Questo perchĂŠ (come suggerisce lâetichetta) questo codice controlla la presenza di una piastrella sopra la palla. Questo perchĂŠ (come suggerisce lâetichetta), il codice controlla la presenza di una piastrella sopra la palla. In realtĂ abbiamo bisogno di controllare tutti e quattro i lati della palla, in modo da sapere come cambiare la quantitĂ di moto a seconda del lato che si è scontrato, quindi⌠aggiungiamo il resto!
BounceOnRight:
ld a, [_OAMRAM + 4]
sub a, 16
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8 - 1
ld b, a
call GetTileByPixel
ld a, [hl]
call IsWallTile
jp nz, BounceOnLeft
ld a, -1
ld [wBallMomentumX], a
BounceOnLeft:
ld a, [_OAMRAM + 4]
sub a, 16
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8 + 1
ld b, a
call GetTileByPixel
ld a, [hl]
call IsWallTile
jp nz, BounceOnBottom
ld a, 1
ld [wBallMomentumX], a
BounceOnBottom:
ld a, [_OAMRAM + 4]
sub a, 16 - 1
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8
ld b, a
call GetTileByPixel
ld a, [hl]
call IsWallTile
jp nz, BounceDone
ld a, -1
ld [wBallMomentumY], a
BounceDone:
Era molto, ma ora la palla rimbalza sullo schermo! Câè solo unâultima cosa da fare prima della fine di questo capitolo: la collisione tra palla e paddle.
Rimbalzo della pagaia
A differenza di quanto accade con la tilemap, qui non è necessario effettuare conversioni di posizione, ma solo confronti diretti.
Tuttavia, per questi ultimi, avremo bisogno del flag carry.
Il flag di riporto è indicato con C
, come il flag zero è indicato con Z
, ma non bisogna confonderlo con il registro c
!
A refresher on comparisons
Proprio come Z
, è possibile utilizzare il flag di riporto per fare salti condizionati.
Tuttavia, mentre Z
viene utilizzato per verificare se due numeri sono uguali, C
può essere utilizzato per verificare se un numero è maggiore o minore di un altro.
Per esempio, cp a, b
imposta C
se a < b
e lo azzera se a >= b
.
(Per verificare a <= b
o a > b
si possono usare Z
e C
in tandem con due istruzioni jp
)
Armed with this knowledge, letâs work through the paddle bounce code:
; First, check if the ball is low enough to bounce off the paddle.
ld a, [_OAMRAM]
ld b, a
ld a, [_OAMRAM + 4]
cp a, b
jp nz, PaddleBounceDone ; If the ball isn't at the same Y position as the paddle, it can't bounce.
; Now let's compare the X positions of the objects to see if they're touching.
ld a, [_OAMRAM + 5] ; Ball's X position.
ld b, a
ld a, [_OAMRAM + 1] ; Paddle's X position.
sub a, 8
cp a, b
jp nc, PaddleBounceDone
add a, 8 + 16 ; 8 to undo, 16 as the width.
cp a, b
jp c, PaddleBounceDone
ld a, -1
ld [wBallMomentumY], a
PaddleBounceDone:
Il controllo della posizione Y è semplice, poichĂŠ la nostra racchetta è piatta. Tuttavia, la posizione X ha due controlli che ampliano lâarea in cui la palla può rimbalzare. Innanzitutto aggiungiamo 16 alla posizione della pallina; se la pallina si trova a piĂš di 16 pixel a destra della racchetta, non dovrebbe rimbalzare. Poi annulliamo questa operazione sottraendo 16 e, giĂ che ci siamo, sottraiamo altri 8 pixel; se la palla si trova a piĂš di 8 pixel a sinistra della racchetta, non dovrebbe rimbalzare.
Paddle width
Ci si potrebbe chiedere perchĂŠ abbiamo controllato 16 pixel a destra ma solo 8 pixel a sinistra. Ricordate che le posizioni OAM rappresentano lâangolo superiore sinistro di uno sprite, quindi il centro della nostra paletta è in realtĂ 4 pixel a destra della posizione in OAM. Se si considera questo, in realtĂ stiamo controllando 12 pixel su entrambi i lati dal centro della paletta.
12 pixel possono sembrare molti, ma danno un poâ di tolleranza al giocatore nel caso in cui il suo posizionamento sia sbagliato. Se si preferisce rendere il tutto piĂš facile o piĂš difficile, è possibile regolare i valori!
BONUS: modifica dellâaltezza di rimbalzo
Si può notare che la pallina sembra âaffondareâ un poâ nella paletta prima di rimbalzare. Questo perchĂŠ la pallina rimbalza quando la sua riga superiore di pixel si allinea con la riga superiore della paletta (si veda lâimmagine sopra). Se volete, provate a regolare questo aspetto in modo che la pallina rimbalzi quando la sua fila di pixel inferiore tocca quella superiore della paletta.
Suggerimento: è possibile farlo con una sola istruzione!
Risposta:
ld a, [_OAMRAM]
ld b, a
ld a, [_OAMRAM + 4]
+ add a, 6
cp a, b
Alternatively, you can add sub a, 6
just after ld a, [_OAMRAM]
.
In entrambi i casi, provate a giocare con il valore 6
; vedete cosa vi sembra giusto!
Bricks
Up until this point our ball hasnât done anything but bounce around, but now weâre going to make it destroy the bricks.
Before we start, letâs go over a new concept: constants.
Weâve already used some constants, like rLCDC
from hardware.inc
, but we can also create our own for anything we want.
Letâs make three constants at the top of our file, representing the tile IDs of left bricks, right bricks, and blank tiles.
INCLUDE "hardware.inc"
DEF BRICK_LEFT EQU $05
DEF BRICK_RIGHT EQU $06
DEF BLANK_TILE EQU $08
Constants are a kind of symbol (which is to say, âa thing with a nameâ).
Writing a constantâs name in an expression is equivalent to writing the number the constant is equal to, so ld a, BRICK_LEFT
is the same as ld a, $05
.
But I think we can all agree that the former is much clearer, right?
Destroying bricks
Now weâll write a function that checks for and destroys bricks.
Our bricks are two tiles wide, so when we hit one weâll have to remove the adjacent tile as well.
If we hit the left side of a brick (represented by BRICK_LEFT
), we need to remove it and the tile to its right (which should be the right side).
If we instead hit the right side, we need to remove the left!
; Checks if a brick was collided with and breaks it if possible.
; @param hl: address of tile.
CheckAndHandleBrick:
ld a, [hl]
cp a, BRICK_LEFT
jr nz, CheckAndHandleBrickRight
; Break a brick from the left side.
ld [hl], BLANK_TILE
inc hl
ld [hl], BLANK_TILE
CheckAndHandleBrickRight:
cp a, BRICK_RIGHT
ret nz
; Break a brick from the right side.
ld [hl], BLANK_TILE
dec hl
ld [hl], BLANK_TILE
ret
Just insert this function into each of your bounce checks now. Make sure you donât miss any! It should go right before the momentum is modified.
BounceOnTop:
; Remember to offset the OAM position!
; (8, 16) in OAM coordinates is (0, 0) on the screen.
ld a, [_OAMRAM + 4]
sub a, 16 + 1
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8
ld b, a
call GetTileByPixel ; Returns tile address in hl
ld a, [hl]
call IsWallTile
jp nz, BounceOnRight
+ call CheckAndHandleBrick
ld a, 1
ld [wBallMomentumY], a
BounceOnRight:
ld a, [_OAMRAM + 4]
sub a, 16
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8 - 1
ld b, a
call GetTileByPixel
ld a, [hl]
call IsWallTile
jp nz, BounceOnLeft
+ call CheckAndHandleBrick
ld a, -1
ld [wBallMomentumX], a
BounceOnLeft:
ld a, [_OAMRAM + 4]
sub a, 16
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8 + 1
ld b, a
call GetTileByPixel
ld a, [hl]
call IsWallTile
jp nz, BounceOnBottom
+ call CheckAndHandleBrick
ld a, 1
ld [wBallMomentumX], a
BounceOnBottom:
ld a, [_OAMRAM + 4]
sub a, 16 - 1
ld c, a
ld a, [_OAMRAM + 5]
sub a, 8
ld b, a
call GetTileByPixel
ld a, [hl]
call IsWallTile
jp nz, BounceDone
+ call CheckAndHandleBrick
ld a, -1
ld [wBallMomentumY], a
BounceDone:
Tutto qui! Piuttosto semplice, no?
Decimal Numbers
Now that we can make the bricks disappear on impact, we should probably get some reward, like points! Weâll start off with a score of 0 and then increase the score by 1 point each time a brick gets destroyed. Then we can display the score on a scoreboard.
BCD
As weâre stingy when it comes to memory use, we will only use one byte. There are different ways of saving and retrieving numbers as decimals, but this time we will choose something called âPacked Binary Coded Decimalâ or packed BCD for short.
BCD is a way of storing decimal numbers in bytes, not using A-F, so $A would be 10 which consists of the digits 1 and 0.
Remember how bits, nibbles and bytes work? Go and have a look at the Hexadeciamal section if you need a reminder.
The âpackedâ part means that we pack 2 digits into one byte. A byte contains 8 bits and inside 4 bits we can already store numbers between $0
(%0000
) and $F
(%1111
), which is more than sufficent to store a number between 0 and 9.
For example the number 35 (my favorite PokĂŠmon) contains the number 3 %0011
and 5 %0101
and as a packed BCD this is %00110101
Calculating the score
Now letâs start by defining a global variable (memory location) for the score:
SECTION "Score", WRAM0
wScore: db
And weâll set this to zero when initializing the other global variables.
; Initialize global variables
ld a, 0
ld [wFrameCounter], a
ld [wCurKeys], a
ld [wNewKeys], a
ld [wScore], a
Now weâll write a function to increase the score, right behind the IsWallTile
function.
Donât worry about the call to UpdateScoreBoard
, weâll get into that in a bit.
; Increase score by 1 and store it as a 1 byte packed BCD number
; changes A and HL
IncreaseScorePackedBCD:
xor a ; clear carry flag and a
inc a ; a = 1
ld hl, wScore ; load score
adc [hl] ; add 1
daa ; convert to BCD
ld [hl], a ; store score
call UpdateScoreBoard
ret
Letâs have a look at whatâs going on there:
We set A to 1 and clear the carry flag
We add the score variable (contents of memory location wScore
) to a, so now A has our increased score.
So far so good, but what if the score was 9 and we add 1? The processor thinks in binary only and will do the following math:
%00001001
+ %00000001
= %00001010
= $A
Thatâs a hexadecimal representation of 10, and we need to adjust it to become decimal. DAA
or âDecimal Adjust after Addition,â does just that.
After executing DAA
our accumulator will be adjusted from %00001010
to %00010000
; a 1 in the left nibble and a 0 in the right one. A more detailed article about DAA
on the Game Boy can be found here.
Then we store the score back into wScore
and finally, we call a function that will update the score board, which we will implement next.
Of course, we still need to call it on impact. To do this, we add a call to IncreaseScorePackedBCD
after each collision handler (we had a left and a right collision) in CheckAndHandleBrick
; Checks if a brick was collided with and breaks it if possible.
; @param hl: address of tile.
CheckAndHandleBrick:
ld a, [hl]
cp a, BRICK_LEFT
jr nz, CheckAndHandleBrickRight
; Break a brick from the left side.
ld [hl], BLANK_TILE
inc hl
ld [hl], BLANK_TILE
call IncreaseScorePackedBCD
CheckAndHandleBrickRight:
cp a, BRICK_RIGHT
ret nz
; Break a brick from the right side.
ld [hl], BLANK_TILE
dec hl
ld [hl], BLANK_TILE
call IncreaseScorePackedBCD
ret
Digit tiles
Before we can display the score weâll need to add some graphics for the numbers 0-9. We already have some ready-made digits for this project, so you can copy this premade file, and paste it at the end of your tile set, just before the TilesEnd
label. Your tile set will look like this:
So we can easily remember where the digits start, letâs add a constant called DIGIT_OFFSET
to point us to where the digits are relative to the start of the tile set: $1A
DEF BRICK_LEFT EQU $05
DEF BRICK_RIGHT EQU $06
DEF BLANK_TILE EQU $08
DEF DIGIT_OFFSET EQU $1A
Letâs make an assumption, that we cannot get a score higher than 99 (what could possibly go wrong) so two digits are enough.
We can start with showing two zeroes (the tile at offset $1A
) on our initial map. Letâs put them on row 3, starting 4 tiles to the left.
You can copy-paste the tile set from this file
This should make the tile set look like this on start up:
Tip: You can find the address in VRAM in your emulatorâs tile map viewer by selecting the tile and looking at the index. The screenshot above is from emulucious.
Letâs remember their positions by defining a constant for VRAM location of the 10s and the 1s at the top of our file, behind the other constants.
DEF SCORE_TENS EQU $9870
DEF SCORE_ONES EQU $9871
Displaying the score
Now we need to write the missing UpdateScoreBoard
function that will update the score board:
; Read the packed BCD score from wScore and updates the score display
UpdateScoreBoard:
ld a, [wScore] ; Get the Packed score
and %11110000 ; Mask the lower nibble
rrca ; Move the upper nibble to the lower nibble (divide by 16)
rrca
rrca
rrca
add a, DIGIT_OFFSET ; Offset + add to get the digit tile
ld [SCORE_TENS], a ; Show the digit on screen
ld a, [wScore] ; Get the packed score again
and %00001111 ; Mask the upper nibble
add a, DIGIT_OFFSET ; Offset + add to get the digit tile again
ld [SCORE_ONES], a ; Show the digit on screen
ret
First we load the score (stored in the wScore memory location) into register A. Recall that the score is stored in packed BCD format, where the upper nibble contains the tens digit and the lower nibble contains the ones digit.
The and %11110000
operation masks the lower nibble (the ones digit) so that only the upper nibble (the tens digit) remains in A
.
The rrca
instructions perform a rotate right operation on A
four times. This effectively shifts the tens digit to the lower nibble, making it ready to map to a digit tile.
We then add the DIGIT_OFFSET
constant to the tens digit to calculate the tile address for the digit. This address is stored in the SCORE_TENS
VRAM location, which updates the display to show the tens digit.
Finally, we repeat the process for the ones digit: We mask the tens digit from A
using and %00001111
, no need to rotate this time.
Now we can display the score on the screen! Weâll need to call UpdateScoreBoard
after each time the score is updated. Weâve already done this in the IncreaseScorePackedBCD
function, so weâre all set!
Lavori in corso
đ§ đ§ đ§ đ§ đ§ đ§ đ§
Come spiegato nella presentazione iniziale del tutorial, la Parte ⠥ consiste nella costruzione di un gioco Arkanoid. Tuttavia, questo non è ancora finito; le lezioni vengono caricate man mano che vengono realizzate, quindi il tutorial si interrompe bruscamente a un certo punto. Mi dispiace!
Tenete duro mentre ci lavoriamo, seguiteci su Twitter per gli aggiornamenti e andate alla pagina successiva per scoprire cosa potete fare nel frattempo!
Grazie per la vostra pazienza đ e ci vediamo su GBDev!
Introducing Galactic Armada

This guide will help you create a classic shoot-em-up in RGBDS. This guide builds on knowledge from the previous tutorials, so some basic (or previously explained) concepts will not be explained.
Feature set
Hereâs a list of features that will be included in the final product.
- Vertical Scrolling Background
- Basic HUD (via Window) & Score
- 4-Directional Player Movement
- Enemies
- Bullets
- Enemy/Bullet Collision
- Enemy/Player Collision
- Smooth Movement via Scaled Integers - Instead of using counters, smoother motion can be achieved using 16-bit (scaled) integers.
- Multiple Game States: Title Screen, Gameplay, Story State
- STAT Interrupts - used to properly draw the HUD at the top of gameplay.
- RGBGFX & INCBIN
- Writing Text
Struttura progetti
This page is going to give you an idea of how the Galactic Armada project is structured. This includes the folders, resources, tools, entry point, and compilation process.
The code can be found at https://github.com/gbdev/gb-asm-tutorial/tree/master/galactic-armada.
Struttura della cartella
For organizational purposes, many parts of the logic are separated into reusable functions. This is to reduce duplicate code, and make logic more clear.
Hereâs a basic look at how the project is structured:
Generated files should never be included in VCS repositories. It unneccessarily bloats the repo. The folders below marked with * contains assets generated from running the Makefile and are not included in the repository.
libs
- Two assembly files for input and sprites are located here.src
generated
- the results of RGBGFX are stored here. *resources
- Here exist some PNGs and Aseprite files for usage with RGBGFXmain
- All assembly files are located here, or in subfoldersstates
gameplay
- for gameplay related filesobjects
- for gameplay objects like the player, bullets, and enemies- collision - for collision among objects
story
- for our story stateâs related filestitle-screen
- for our title screenâs related files
utils
- Extra functions includes to assist with developmentmacros
dist
- The final ROM file will be created here. *obj
- Intermediate files from the compile process. *Makefile
- used to create the final ROM file and intermediate files
Background & Sprite Resources
The following backgrounds and sprites are used in Galactic Armada:
- Backgrounds
- Star Field
- Title Screen
- Text Font (Tiles only)
- Sprites
- Enemy Ship
- Player Ship
- Bullet






These images were originally created in Aseprite. The original templates are also included in the repository. They were exported as a PNG with a specific color palette. Ater being exported as a PNG, when you run make
, they are converted into .2bpp
and .tilemap
files via the RGBDS tool: RGBGFX.
TheÂ
rgbgfx
 program converts PNG images into data suitable for display on the Game Boy and Game Boy Color, or vice-versa.The main function ofÂ
rgbgfx
 is to divide the input PNG into 8Ă8 pixel squares, convert each of those squares into 1bpp or 2bpp tile data, and save all of the tile data in a file. It also has options to generate a tile map, attribute map, and/or palette set as well; more on that and how the conversion process can be tweaked below.
RGBGFX can be found here: https://rgbds.gbdev.io/docs/v0.6.1/rgbgfx.1
Weâll use it to convert all of our graphics to .2bpp, and .tilemap formats (binary files)
NEEDED_GRAPHICS = \
$(GENSPRITES)/player-ship.2bpp \
$(GENSPRITES)/enemy-ship.2bpp \
$(GENSPRITES)/bullet.2bpp \
$(GENBACKGROUNDS)/text-font.2bpp \
$(GENBACKGROUNDS)/star-field.tilemap \
$(GENBACKGROUNDS)/title-screen.tilemap
# Generate sprites, ensuring the containing directories have been created.
$(GENSPRITES)/%.2bpp: $(RESSPRITES)/%.png | $(GENSPRITES)
$(GFX) -c "#FFFFFF,#cfcfcf,#686868,#000000;" --columns -o $@ $<
# Generate background tile set, ensuring the containing directories have been created.
$(GENBACKGROUNDS)/%.2bpp: $(RESBACKGROUNDS)/%.png | $(GENBACKGROUNDS)
$(GFX) -c "#FFFFFF,#cbcbcb,#414141,#000000;" -o $@ $<
# Generate background tile map *and* tile set, ensuring the containing directories
# have been created.
$(GENBACKGROUNDS)/%.tilemap: $(RESBACKGROUNDS)/%.png | $(GENBACKGROUNDS)
$(GFX) -c "#FFFFFF,#cbcbcb,#414141,#000000;" \
--tilemap $@ \
--unique-tiles \
-o $(GENBACKGROUNDS)/$*.2bpp \
$<
From there, INCBIN commands are used to store reference the binary tile data.
; in src/main/states/gameplay/objects/player.asm
playerShipTileData: INCBIN "src/generated/sprites/player-ship.2bpp"
playerShipTileDataEnd:
; in src/main/states/gameplay/objects/enemies.asm
enemyShipTileData:: INCBIN "src/generated/sprites/enemy-ship.2bpp"
enemyShipTileDataEnd::
; in src/main/states/gameplay/objects/bullets.asm
bulletTileData:: INCBIN "src/generated/sprites/bullet.2bpp"
bulletTileDataEnd::
Including binary files
You probably have some graphics, level data, etc. youâd like to include. Use INCBIN
 to include a raw binary file as it is. If the file isnât found in the current directory, the include-path list passed to rgbasm(1) (see the -i
 option) on the command line will be searched.
INCBIN "titlepic.bin"
INCBIN "sprites/hero.bin"
You can also include only part of a file with INCBIN
. The example below includes 256 bytes from data.bin, starting from byte 78.
INCBIN "data.bin",78,256
The length argument is optional. If only the start position is specified, the bytes from the start position until the end of the file will be included.
Compilazione
Compilation is done via a Makefile. This Makefile can be run using the make
command. Make should be preinstalled on Linux and Mac systems. For Windows users, check out cygwin.
Without going over everything in detail, hereâs what the Makefile does:
- Clean generated folders
- Recreate generated folders
- Convert PNGs in src/resources to
.2bpp
, and.tilemap
formats - Convert
.asm
files to.o
- Use the
.o
files to build the ROM file - Apply the RGBDS âfixâ utility.
Punto di ingresso
Weâll start this tutorial out like the previous, with our âheaderâ section (at address: $100). Weâre also going to declare some global variables that will be used throughout the game.
wLastKeys
andwCurKeys
are used for joypad inputwGameState
will keep track what our current game state is
INCLUDE "src/main/utils/hardware.inc"
SECTION "GameVariables", WRAM0
wLastKeys:: db
wCurKeys:: db
wNewKeys:: db
wGameState::db
SECTION "Header", ROM0[$100]
jp EntryPoint
ds $150 - @, 0 ; Make room for the header
EntryPoint:
dopo lâetichetta EntryPoint
, faremo quanto segue:
- set our default game state
- initiate gb-sprobj-lib, the sprite library weâre going to use
- setup our display registers
- load tile data for our font into VRAM.
The tile data we are going to load is used by all game states, which is why weâll do it here & now, for them all to use.

This character-set is called âArea51â. It, and more 8x8 pixel fonts can ne found here: https://damieng.com/typography/zx-origins/ . These 52 tiles will be placed at the beginning of our background/window VRAM region.
One important thing to note. Character maps for each letter must be defined. This letâs RGBDS know what byte value to give a specific letter.
For the Galactic Armada space mapping, weâre going off the âtext-font.pngâ image. Our space character is the first character in VRAM. Our alphabet starts at 26. Special additions could be added if desired. For now, this is all that weâll need. Weâll define that map in âsrc/main/utils/macros/text-macros.incâ.
; The character map for the text-font
CHARMAP " ", 0
CHARMAP ".", 24
CHARMAP "-", 25
CHARMAP "a", 26
CHARMAP "b", 27
CHARMAP "c", 28
CHARMAP "d", 29
CHARMAP "e", 30
CHARMAP "f", 31
CHARMAP "g", 32
CHARMAP "h", 33
CHARMAP "i", 34
CHARMAP "j", 35
CHARMAP "k", 36
CHARMAP "l", 37
CHARMAP "m", 38
CHARMAP "n", 39
CHARMAP "o", 40
CHARMAP "p", 41
CHARMAP "q", 42
CHARMAP "r", 43
CHARMAP "s", 44
CHARMAP "t", 45
CHARMAP "u", 46
CHARMAP "v", 47
CHARMAP "w", 48
CHARMAP "x", 49
CHARMAP "y", 50
CHARMAP "z", 51
Getting back to our entry point. Were going to wait until a vertical blank begins to do all of this. Weâll also turn the LCD off before loading our tile data into VRAM..
; Shut down audio circuitry
xor a
ld [rNR52], a
; We don't actually need another xor a here, because the value of A doesn't change between these two instructions
ld [wGameState], a
; Wait for the vertical blank phase before initiating the library
call WaitForOneVBlank
; from: https://github.com/eievui5/gb-sprobj-lib
; The library is relatively simple to get set up. First, put the following in your initialization code:
; Initilize Sprite Object Library.
call InitSprObjLibWrapper
; Turn the LCD off
xor a
ld [rLCDC], a
; Load our common text font into VRAM
call LoadTextFontIntoVRAM
; Turn the LCD on
ld a, LCDCF_ON | LCDCF_BGON|LCDCF_OBJON | LCDCF_OBJ16 | LCDCF_WINON | LCDCF_WIN9C00
ld [rLCDC], a
; During the first (blank) frame, initialize display registers
ld a, %11100100
ld [rBGP], a
ld [rOBP0], a
Even though we havenât specifically defined a color palette. The emulicious emulator may automatically apply a default color palette if in âAutomaticâ or âGameboy Colorâ mode.
Instead of ld a, 0
, we can use xor a
to set a
to 0. It takes one byte less, which matters a lot on the Game Boy.
In the above snippet you saw use of a function called WaitFOrOneVBLank
. Weâve setup some vblank utility functions in the âsrc/main/utils/vblank-utils.asmâ file:
INCLUDE "src/main/utils/hardware.inc"
SECTION "VBlankVariables", WRAM0
wVBlankCount:: db
SECTION "VBlankFunctions", ROM0
WaitForOneVBlank::
; Wait a small amount of time
; Save our count in this variable
ld a, 1
ld [wVBlankCount], a
WaitForVBlankFunction::
WaitForVBlankFunction_Loop::
ld a, [rLY] ; Copy the vertical line to a
cp 144 ; Check if the vertical line (in a) is 0
jp c, WaitForVBlankFunction_Loop ; A conditional jump. The condition is that 'c' is set, the last operation overflowed
ld a, [wVBlankCount]
sub 1
ld [wVBlankCount], a
ret z
WaitForVBlankFunction_Loop2::
ld a, [rLY] ; Copy the vertical line to a
cp 144 ; Check if the vertical line (in a) is 0
jp nc, WaitForVBlankFunction_Loop2 ; A conditional jump. The condition is that 'c' is set, the last operation overflowed
jp WaitForVBlankFunction_Loop
In the next section, weâll go on next to setup our NextGameState
label. Which is used for changing game states.
Changing Game States
In our GalacticArmada.asm file, weâll define label called âNextGameStateâ. Our game will have 3 game states:
- Title Screen
- Story Screen
- Gameplay
Ecco come scorreranno:
When one game state wants to go to another, it will need to change our previously declared âwGameStateâ variable and then jump to the âNextGameStateâ label. There are some common things we want to accomplish when changing game states:
(during a Vertical Blank)
- Turn off the LCD
- Reset our Background & Window positions
- Clear the Background
- Disable Interrupts
- Clear All Sprites
- Initiate our NEXT game state
- Jump to our NEXT game stateâs (looping) update logic
It will be the responsibility of the âinitâ function for each game state to turn the LCD back on.
NextGameState::
; Do not turn the LCD off outside of VBlank
call WaitForOneVBlank
call ClearBackground
; Turn the LCD off
xor a
ld [rLCDC], a
ld [rSCX], a
ld [rSCY], a
ld [rWX], a
ld [rWY], a
; disable interrupts
call DisableInterrupts
; Clear all sprites
call ClearAllSprites
; Initiate the next state
ld a, [wGameState]
cp 2 ; 2 = Gameplay
call z, InitGameplayState
ld a, [wGameState]
cp 1 ; 1 = Story
call z, InitStoryState
ld a, [wGameState]
and a ; 0 = Menu
call z, InitTitleScreenState
; Update the next state
ld a, [wGameState]
cp 2 ; 2 = Gameplay
jp z, UpdateGameplayState
cp 1 ; 1 = Story
jp z, UpdateStoryState
jp UpdateTitleScreenState
The goal here is to ( as much as possible) give each new game state a blank slate to start with.
Thatâs it for the GalacticArmada.asm file.
Schermata del titolo
The title screen shows a basic title image using the background and draws text asking the player to press A. Once the user presses A, it will go to the story screen.

Our title screen has 3 pieces of data:
- The âPress A to playâ text
- The title screen tile data
- The title screen tilemap
INCLUDE "src/main/utils/hardware.inc"
INCLUDE "src/main/utils/macros/text-macros.inc"
SECTION "TitleScreenState", ROM0
PressPlayText:: db "press a to play", 255
titleScreenTileData: INCBIN "src/generated/backgrounds/title-screen.2bpp"
titleScreenTileDataEnd:
titleScreenTileMap: INCBIN "src/generated/backgrounds/title-screen.tilemap"
titleScreenTileMapEnd:
Avvio della schermata del titolo
In our title screenâs âInitTitleScreenâ function, weâll do the following:
- draw the title screen graphic
- draw our âPress A to playâ
- turn on the LCD.
Here is what our âInitTitleScreenStateâ function looks like
InitTitleScreenState::
call DrawTitleScreen
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Draw the press play text
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Call Our function that draws text onto background/window tiles
ld de, $99C3
ld hl, PressPlayText
call DrawTextTilesLoop
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Turn the LCD on
ld a, LCDCF_ON | LCDCF_BGON|LCDCF_OBJON | LCDCF_OBJ16
ld [rLCDC], a
ret
In order to draw text in our game, weâve created a function called âDrawTextTilesLoopâ. Weâll pass this function which tile to start on in de
, and the address of our text in hl
.
DrawTextTilesLoop::
; Check for the end of string character 255
ld a, [hl]
cp 255
ret z
; Write the current character (in hl) to the address
; on the tilemap (in de)
ld a, [hl]
ld [de], a
inc hl
inc de
; move to the next character and next background tile
jp DrawTextTilesLoop
The âDrawTitleScreenâ function puts the tiles for our title screen graphic in VRAM, and draws its tilemap to the background:
NOTE: Because of the text font, weâll add an offset of 52 to our tilemap tiles. Weâve created a function that adds the 52 offset, since weâll need to do so more than once.
DrawTitleScreen::
; Copy the tile data
ld de, titleScreenTileData ; de contains the address where data will be copied from;
ld hl, $9340 ; hl contains the address where data will be copied to;
ld bc, titleScreenTileDataEnd - titleScreenTileData ; bc contains how many bytes we have to copy.
call CopyDEintoMemoryAtHL
; Copy the tilemap
ld de, titleScreenTileMap
ld hl, $9800
ld bc, titleScreenTileMapEnd - titleScreenTileMap
jp CopyDEintoMemoryAtHL_With52Offset
The âCopyDEintoMemoryAtHLâ and âCopyDEintoMemoryAtHL_With52Offsetâ functions are defined in âsrc/main/utils/memory-utils.asmâ:
SECTION "MemoryUtilsSection", ROM0
CopyDEintoMemoryAtHL::
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or c
jp nz, CopyDEintoMemoryAtHL ; Jump to CopyTiles if the last operation had a non zero result.
ret
CopyDEintoMemoryAtHL_With52Offset::
ld a, [de]
add a, 52
ld [hli], a
inc de
dec bc
ld a, b
or c
jp nz, CopyDEintoMemoryAtHL_With52Offset ; Jump to COpyTiles, if the z flag is not set. (the last operation had a non zero result)
ret
Updating the Title Screen
The title screenâs update logic is the simplest of the 3. All we are going to do is wait until the A button is pressed. Afterwards, weâll go to the story screen game state.
UpdateTitleScreenState::
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Wait for A
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Save the passed value into the variable: mWaitKey
; The WaitForKeyFunction always checks against this vriable
ld a, PADF_A
ld [mWaitKey], a
call WaitForKeyFunction
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld a, 1
ld [wGameState],a
jp NextGameState
Our âWaitForKeyFunctionâ is defined in âsrc/main/utils/input-utils.asmâ. Weâll poll for input and infinitely loop until the specified button is pressed down.
SECTION "InputUtilsVariables", WRAM0
mWaitKey:: db
SECTION "InputUtils", ROM0
WaitForKeyFunction::
; Save our original value
push bc
WaitForKeyFunction_Loop:
; save the keys last frame
ld a, [wCurKeys]
ld [wLastKeys], a
; This is in input.asm
; It's straight from: https://gbdev.io/gb-asm-tutorial/part2/input.html
; In their words (paraphrased): reading player input for gameboy is NOT a trivial task
; So it's best to use some tested code
call Input
ld a, [mWaitKey]
ld b, a
ld a, [wCurKeys]
and b
jp z, WaitForKeyFunction_NotPressed
ld a, [wLastKeys]
and b
jp nz, WaitForKeyFunction_NotPressed
; restore our original value
pop bc
ret
WaitForKeyFunction_NotPressed:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Wait a small amount of time
; Save our count in this variable
ld a, 1
ld [wVBlankCount], a
; Call our function that performs the code
call WaitForVBlankFunction
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
jp WaitForKeyFunction_Loop
Thatâs it for our title screen. Next up is our story screen.
Schermata della storia
The story screen shows a basic story on 2 pages. Afterwards, it sends the player to the gameplay game state.


Initiating up the Story Screen
In the InitStoryState
weâll just going to turn on the LCD. Most of the game stateâs logic will occur in its update function.
The text macros file is included so our story text has the proper character maps.
INCLUDE "src/main/utils/hardware.inc"
INCLUDE "src/main/utils/macros/text-macros.inc"
SECTION "StoryStateASM", ROM0
InitStoryState::
; Turn the LCD on
ld a, LCDCF_ON | LCDCF_BGON|LCDCF_OBJON | LCDCF_OBJ16
ld [rLCDC], a
ret
Updating the Story Screen
Hereâs the data for our story screen. We have this defined just above our UpdateStoryState
function:
Story:
.Line1 db "the galatic empire", 255
.Line2 db "rules the galaxy", 255
.Line3 db "with an iron", 255
.Line4 db "fist.", 255
.Line5 db "the rebel force", 255
.Line6 db "remain hopeful of", 255
.Line7 db "freedoms light", 255
The story text is shown using a typewriter effect. This effect is done similarly to the âpress a to playâ text that was done before, but here we wait for 3 vertical blank phases between writing each letter, giving some additional delay.
You could bind this to a variable and make it configurable via an options screen too!
For this effect, weâve defined a function in our âsrc/main/utils/text-utils.asmâ file:
DrawText_WithTypewriterEffect::
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Wait a small amount of time
; Save our count in this variable
ld a, 3
ld [wVBlankCount], a
; Call our function that performs the code
call WaitForVBlankFunction
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Check for the end of string character 255
ld a, [hl]
cp 255
ret z
; Write the current character (in hl) to the address
; on the tilemap (in de)
ld a, [hl]
ld [de], a
; move to the next character and next background tile
inc hl
inc de
jp DrawText_WithTypewriterEffect
Weâll call the DrawText_WithTypewriterEffect
function exactly how we called the DrawTextTilesLoop
function. Weâll pass this function which tile to start on in de, and the address of our text in hl.
Weâll do that four times for the first page, and then wait for the A button to be pressed:
UpdateStoryState::
; Call Our function that typewrites text onto background/window tiles
ld de, $9821
ld hl, Story.Line1
call DrawText_WithTypewriterEffect
; Call Our function that typewrites text onto background/window tiles
ld de, $9861
ld hl, Story.Line2
call DrawText_WithTypewriterEffect
; Call Our function that typewrites text onto background/window tiles
ld de, $98A1
ld hl, Story.Line3
call DrawText_WithTypewriterEffect
; Call Our function that typewrites text onto background/window tiles
ld de, $98E1
ld hl, Story.Line4
call DrawText_WithTypewriterEffect
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Wait for A
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Save the passed value into the variable: mWaitKey
; The WaitForKeyFunction always checks against this vriable
ld a, PADF_A
ld [mWaitKey], a
call WaitForKeyFunction
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Once the user presses the A button, we want to show the second page. To avoid any lingering âleftoverâ letters, weâll clear the background. All this function does is turn off the LCD, fill our background tilemap with the first tile, then turn back on the lcd. Weâve defined this function in the âsrc/main/utils/background.utils.asmâ file:
include "src/main/utils/hardware.inc"
SECTION "Background", ROM0
ClearBackground::
; Turn the LCD off
xor a
ld [rLCDC], a
ld bc, 1024
ld hl, $9800
ClearBackgroundLoop:
xor a
ld [hli], a
dec bc
ld a, b
or c
jp nz, ClearBackgroundLoop
; Turn the LCD on
ld a, LCDCF_ON | LCDCF_BGON|LCDCF_OBJON | LCDCF_OBJ16
ld [rLCDC], a
ret
Getting back to our Story Screen: After weâve shown the first page and cleared the background, weâll do the same thing for page 2:
; Call Our function that typewrites text onto background/window tiles
ld de, $9821
ld hl, Story.Line5
call DrawText_WithTypewriterEffect
; Call Our function that typewrites text onto background/window tiles
ld de, $9861
ld hl, Story.Line6
call DrawText_WithTypewriterEffect
; Call Our function that typewrites text onto background/window tiles
ld de, $98A1
ld hl, Story.Line7
call DrawText_WithTypewriterEffect
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Wait for A
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Save the passed value into the variable: mWaitKey
; The WaitForKeyFunction always checks against this vriable
ld a, PADF_A
ld [mWaitKey], a
call WaitForKeyFunction
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
With our story full shown, weâre ready to move onto the next game state: Gameplay. Weâll end our UpdateStoryState
function by updating our game state variable and jump back to the NextGameState
label like previously discussed.
ld a, 2
ld [wGameState],a
jp NextGameState
Gameplay State
In this game state, the player will control a spaceship. Flying over a vertically scrolling space background. Theyâll be able to freely move in 4 directions , and shoot oncoming alien ships. As alien ships are destroyed by bullets, the playerâs score will increase.
Gameplay is the core chunk of the source code. It also took the most time to create. Because of such, this game state has to be split into multiple sub-pages. Each page will explain a different gameplay concept.
Our gameplay state defines the following data and variables:
INCLUDE "src/main/utils/hardware.inc"
INCLUDE "src/main/utils/macros/text-macros.inc"
SECTION "GameplayVariables", WRAM0
wScore:: ds 6
wLives:: db
SECTION "GameplayState", ROM0
wScoreText:: db "score", 255
wLivesText:: db "lives", 255
For simplicity reasons, our score uses 6 bytes. Each byte repesents one digit in the score.
Initiating the Gameplay Game State:
When gameplay starts we want to do all of the following:
- reset the playerâs score to 0
- reset the playerâs lives to 3.
- Initialize all of our gameplay elements ( background, player, bullets, and enemies)
- Enable STAT interrupts for the HUD
- Draw our âscoreâ & âlivesâ on the HUD.
- Reset the windowâs position back to 7,0
- Turn the LCD on with the window enabled at $9C00
InitGameplayState::
ld a, 3
ld [wLives], a
xor a
ld [wScore], a
ld [wScore+1], a
ld [wScore+2], a
ld [wScore+3], a
ld [wScore+4], a
ld [wScore+5], a
call InitializeBackground
call InitializePlayer
call InitializeBullets
call InitializeEnemies
; Initiate STAT interrupts
call InitStatInterrupts
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Call Our function that draws text onto background/window tiles
ld de, $9c00
ld hl, wScoreText
call DrawTextTilesLoop
; Call Our function that draws text onto background/window tiles
ld de, $9c0d
ld hl, wLivesText
call DrawTextTilesLoop
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
call DrawScore
call DrawLives
ld a, 0
ld [rWY], a
ld a, 7
ld [rWX], a
; Turn the LCD on
ld a, LCDCF_ON | LCDCF_BGON|LCDCF_OBJON | LCDCF_OBJ16 | LCDCF_WINON | LCDCF_WIN9C00|LCDCF_BG9800
ld [rLCDC], a
ret
The initialization logic for our the background, the player, the enemies, the bullets will be explained in later pages. Every game state is responsible for turning the LCD back on. The gameplay game state needs to use the window layer, so weâll make sure thatâs enabled before we return.
Updating the Gameplay Game State
Our âUpdateGameplayStateâ function doesnât have very complicated logic. Most of the logic has been split into separate files for the background, player, enemies, and bullets.
During gameplay, we do all of the following:
- Poll for input
- Reset our Shadow OAM
- Reset our current shadow OAM sprite
- Update our gameplay elements (player, background, enemies, bullets, background)
- Remove any unused sprites from the screen
- End gameplay if weâve lost all of our lives
- inside of the vertical blank phase
- Apply shadow OAM sprites
- Update our background tilemapâs position
Weâll poll for input like in the previous tutorial. Weâll always save the previous state of the gameboyâs buttons in the âwLastKeysâ variable.
UpdateGameplayState::
; save the keys last frame
ld a, [wCurKeys]
ld [wLastKeys], a
; This is in input.asm
; It's straight from: https://gbdev.io/gb-asm-tutorial/part2/input.html
; In their words (paraphrased): reading player input for gameboy is NOT a trivial task
; So it's best to use some tested code
call Input
Next, weâll reset our Shadow OAM and reset current Shadow OAM sprite address.
; from: https://github.com/eievui5/gb-sprobj-lib
; hen put a call to ResetShadowOAM at the beginning of your main loop.
call ResetShadowOAM
call ResetOAMSpriteAddress
Because we are going to be dealing with a lot of sprites on the screen, we will not be directly manipulating the gameboyâs OAM sprites. Weâll define a set of âshadowâ (copyâ) OAM sprites, that all objects will use instaed. At the end of the gameplay looop, weâll copy the shadow OAM sprite objects into the hardware.
Each object will use a random shadow OAM sprite. We need a way to keep track of what shadow OAM sprite is being used currently. For this, weâve created a 16-bit pointer called âwLastOAMAddressâ. Defined in âsrc/main/utils/sprites.asmâ, this points to the data for the next inactive shadow OAM sprite.
When we reset our current Shadow OAM sprite address, we just set the âmLastOAMAddressâ RAM variable to point to the first shadow OAM sprite.
NOTE: We also keep a counter on how many shadow OAM sprites are used. In our âResetOAMSpriteAddressâ function, weâll reset that counter too.
ResetOAMSpriteAddress::
xor a
ld [wSpritesUsed], a
ld a, LOW(wShadowOAM)
ld [wLastOAMAddress], a
ld a, HIGH(wShadowOAM)
ld [wLastOAMAddress+1], a
ret
Next weâll update our gameplay elements:
call UpdatePlayer
call UpdateEnemies
call UpdateBullets
call UpdateBackground
After all of that, at this point in time, the majority of gameplay is done for this iteration. Weâll clear any remaining spirtes. This is very necessary becaus the number of active sprites changes from frame to frame. If there are any visible OAM sprites left onscreen, they will look weird and/or mislead the player.
; Clear remaining sprites to avoid lingering rogue sprites
call ClearRemainingSprites
The clear remaining sprites function, for all remaining shadow OAM sprites, moves the sprite offscreen so they are no longer visible. This function starts at wherever the âwLastOAMAddressâ variable last left-off.
End of The Gameplay loop
At this point in time, we need to check if gameplay needs to continue. When the vertical blank phase starts, we check if the player has lost all of their lives. If so, we end gameplay. We end gameplay similar to how we started it, weâll update our âwGameStateâ variable and jump to âNextGameStateâ.
If the player hasnât lost all of their lives, weâll copy our shadow OAM sprites over to the actual hardware OAM sprites and loop background.
ld a, [wLives]
cp 250
jp nc, EndGameplay
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Call our function that performs the code
call WaitForOneVBlank
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; from: https://github.com/eievui5/gb-sprobj-lib
; Finally, run the following code during VBlank:
ld a, HIGH(wShadowOAM)
call hOAMDMA
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Call our function that performs the code
call WaitForOneVBlank
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
jp UpdateGameplayState
EndGameplay:
ld a, 0
ld [wGameState],a
jp NextGameState
Scrolling Background
Scrolling the background is an easy task. However, for a SMOOTH slow scrolling background: scaled integers1 will be used.
â ď¸ Scaled Integers1 are a way to provide smooth âsub-pixelâ movement. They are slightly more difficult to understand & implement than implementing a counter, but they provide smoother motion.
Initializing the Background
At the start of the gameplay game state we called the initialize background function. This function shows the star field background, and resets our background scroll variables:
Just like with our title screen graphic, because our text font tiles are at the beginning of VRAM: we offset the tilemap values by 52
INCLUDE "src/main/utils/hardware.inc"
INCLUDE "src/main/utils/macros/text-macros.inc"
SECTION "BackgroundVariables", WRAM0
mBackgroundScroll:: dw
SECTION "GameplayBackgroundSection", ROM0
starFieldMap: INCBIN "src/generated/backgrounds/star-field.tilemap"
starFieldMapEnd:
starFieldTileData: INCBIN "src/generated/backgrounds/star-field.2bpp"
starFieldTileDataEnd:
InitializeBackground::
; Copy the tile data
ld de, starFieldTileData ; de contains the address where data will be copied from;
ld hl, $9340 ; hl contains the address where data will be copied to;
ld bc, starFieldTileDataEnd - starFieldTileData ; bc contains how many bytes we have to copy.
call CopyDEintoMemoryAtHL
; Copy the tilemap
ld de, starFieldMap
ld hl, $9800
ld bc, starFieldMapEnd - starFieldMap
call CopyDEintoMemoryAtHL_With52Offset
xor a
ld [mBackgroundScroll], a
ld [mBackgroundScroll+1], a
ret
To scroll the background in a gameboy game, we simply need to gradually change the SCX
or SCX
registers. Our code is a tiny bit more complicated because of scaled integer usage. Our backgroundâs scroll position is stored in a 16-bit integer called mBackgroundScroll
. Weâl increase that 16-bit integer by a set amount.
; This is called during gameplay state on every frame
UpdateBackground::
; Increase our scaled integer by 5
; Get our true (non-scaled) value, and save it for later usage in bc
ld a, [mBackgroundScroll]
add a, 5
ld b, a
ld [mBackgroundScroll], a
ld a, [mBackgroundScroll+1]
adc 0
ld c, a
ld [mBackgroundScroll+1], a
We wonât directly draw the background using this value. De-scaling a scaled integer simulates having a (more precise and useful for smooth movement) floating-point number. The value we draw our background at will be the de-scaled version of that 16-bit integer. To get that non-scaled version, weâll simply shift all of itâs bit rightward 4 places. The final result will saved for when we update our backgroundâs y position.
; Descale our scaled integer
; shift bits to the right 4 spaces
srl c
rr b
srl c
rr b
srl c
rr b
srl c
rr b
; Use the de-scaled low byte as the backgrounds position
ld a, b
ld [rSCY], a
ret
Heads Up Interface
The gameboy normally draws sprites over both the window and background, and the window over the background. In Galactic Armada, The background is vertically scrolling. This means the HUD (the score text and number) needs to be draw on the window, which is separate from the background.
On our HUD, weâll draw both our score and our lives. Weâll also use STAT interrupts to make sure nothing covers the HUD.
STAT Interrupts & the window
The window is not enabled by default. We can enable the window using the LCDC
register. RGBDS comes with constants that will help us.
â ď¸ NOTE: The window can essentially be a copy of the background. The
LCDCF_WIN9C00|LCDCF_BG9800
portion makes the background and window use different tilemaps when drawn. Thereâs only one problem. Since the window is drawn between sprites and the background. Without any extra effort, our scrolling background tilemap will be covered by our window. In addition, our sprites will be drawn over our hud. For this, weâll need STAT interrupts. Fore more information on STAT interrupts, check out the pandocs: https://gbdev.io/pandocs/Interrupt_Sources.html
Using the STAT interrupt
One very popular use is to indicate to the user when the video hardware is about to redraw a given LCD line. This can be useful for dynamically controlling the SCX/SCY registers ($FF43/$FF42) to perform special video effects.
Example application: set LYC to WY, enable LY=LYC interrupt, and have the handler disable sprites. This can be used if you use the window for a text box (at the bottom of the screen), and you want sprites to be hidden by the text box.
With STAT interrupts, we can implement raster effects. in our case, weâll enable the window and stop drawing sprites on the first 8 scanlines. Afterwards, weâll show sprites and disable the window layer for the remaining scanlines. This makes sure nothing overlaps our HUD, and that our background is fully shown also.
Initiating & Disabling STAT interrupts
In our gameplay game state, at different points in time, we initialized and disabled interrupts. Hereâs the logic for those functions in our âsrc/main/states/gameplay/hud.asmâ file:
INCLUDE "src/main/utils/hardware.inc"
SECTION "Interrupts", ROM0
DisableInterrupts::
xor a
ldh [rSTAT], a
di
ret
InitStatInterrupts::
ld a, IEF_STAT
ldh [rIE], a
xor a
ldh [rIF], a
ei
; This makes our stat interrupts occur when the current scanline is equal to the rLYC register
ld a, STATF_LYC
ldh [rSTAT], a
; We'll start with the first scanline
; The first stat interrupt will call the next time rLY = 0
xor a
ldh [rLYC], a
ret
Defining STAT interrupts
Our actual STAT interrupts must be located at $0048. Weâll define different paths depending on what our LYC variableâs value is when executed.
; Define a new section and hard-code it to be at $0048.
SECTION "Stat Interrupt", ROM0[$0048]
StatInterrupt:
push af
; Check if we are on the first scanline
ldh a, [rLYC]
and a
jp z, LYCEqualsZero
LYCEquals8:
; Don't call the next stat interrupt until scanline 8
xor a
ldh [rLYC], a
; Turn the LCD on including sprites. But no window
ld a, LCDCF_ON | LCDCF_BGON | LCDCF_OBJON | LCDCF_OBJ16 | LCDCF_WINOFF | LCDCF_WIN9C00
ldh [rLCDC], a
jp EndStatInterrupts
LYCEqualsZero:
; Don't call the next stat interrupt until scanline 8
ld a, 8
ldh [rLYC], a
; Turn the LCD on including the window. But no sprites
ld a, LCDCF_ON | LCDCF_BGON | LCDCF_OBJOFF | LCDCF_OBJ16| LCDCF_WINON | LCDCF_WIN9C00
ldh [rLCDC], a
EndStatInterrupts:
pop af
reti;
That should be all it takes to get a properly drawn HUD. For more details, check out the code in the repo or ask questions on the gbdev discord server.
Keeping Score and Drawing Score on the HUD
To keep things simple, back in our gameplay game state, we used 6 different bytes to hold our score.Each byte will hold a value between 0 and 9, and represents a specific digit in the score. So itâs easy to loop through and edit the score number on the HUD: The First byte represents the left-most digit, and the last byte represents the right-most digit.
When the score increases, weâll increase digits on the right. As they go higher than 9, weâll reset back to 0 and increase the previous byte .
IncreaseScore::
; We have 6 digits, start with the right-most digit (the last byte)
ld c, 0
ld hl, wScore+5
IncreaseScore_Loop:
; Increase the digit
ld a, [hl]
inc a
ld [hl], a
; Stop if it hasn't gone past 0
cp 9
ret c
; If it HAS gone past 9
IncreaseScore_Next:
; Increase a counter so we can not go out of our scores bounds
inc c
ld a, c
; Check if we've gone over our scores bounds
cp 6
ret z
; Reset the current digit to zero
; Then go to the previous byte (visually: to the left)
ld a, 0
ld [hl], a
ld [hld], a
jp IncreaseScore_Loop
We can call that score whenever a bullet hits an enemy. This function however does not draw our score on the background. We do that the same way we drew text previously:
DrawScore::
; Our score has max 6 digits
; We'll start with the left-most digit (visually) which is also the first byte
ld c, 6
ld hl, wScore
ld de, $9C06 ; The window tilemap starts at $9C00
DrawScore_Loop:
ld a, [hli]
add 10 ; our numeric tiles start at tile 10, so add to 10 to each bytes value
ld [de], a
; Decrease how many numbers we have drawn
dec c
; Stop when we've drawn all the numbers
ret z
; Increase which tile we are drawing to
inc de
jp DrawScore_Loop
Because weâll only ever have 3 lives, drawing our lives is much easier. The numeric characters in our text font start at 10, so we just need to put on the window, our lives plus 10.
DrawLives::
ld hl, wLives
ld de, $9C13 ; The window tilemap starts at $9C00
ld a, [hl]
add 10 ; our numeric tiles start at tile 10, so add 10 to each bytes value
ld [de], a
ret
Sprites & Metasprites
Before we dive into the player, bullets, and enemies; how they are drawn using metasprites should be explained.
For sprites, the following library is used: https://github.com/eievui5/gb-sprobj-lib
This is a small, lightweight library meant to facilitate the rendering of sprite objects, including Shadow OAM and OAM DMA, single-entry âsimpleâ sprite objects, and Q12.4 fixed-point position metasprite rendering.
All objects are drawn using âmetaspritesâ, or groups of sprites that define one single object. A custom âmetaspriteâ implementation is used in addition. Metasprite definitions should a multiple of 4 plus one additional byte for the end.
- Relative Y offset ( relative to the previous sprite, or the actual metaspriteâs draw position)
- Relative X offset ( relative to the previous sprite, or the actual metaspriteâs draw position)
- Tile to draw
- Tile Props (not used in this project)
The logic stops drawing when it reads 128.
An example of metasprite is the enemy ship:
enemyShipMetasprite::
.metasprite1 db 0,0,4,0
.metasprite2 db 0,8,6,0
.metaspriteEnd db 128
The Previous snippet draws two sprites. One that the objectâs actual position, which uses tile 4 and 5. The second sprite is 8 pixels to the right, and uses tile 6 and 7
â ď¸ NOTE: Sprites are in 8x16 mode for this project.
I can later draw such metasprite by calling the âDrawMetaspriteâ function that
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; call the 'DrawMetasprites function. setup variables and call
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Save the address of the metasprite into the 'wMetaspriteAddress' variable
; Our DrawMetasprites functoin uses that variable
ld a, LOW(enemyShipMetasprite)
ld [wMetaspriteAddress+0], a
ld a, HIGH(enemyShipMetasprite)
ld [wMetaspriteAddress+1], a
; Save the x position
ld a, [wCurrentEnemyX]
ld [wMetaspriteX], a
; Save the y position
ld a, [wCurrentEnemyY]
ld [wMetaspriteY], a
; Actually call the 'DrawMetasprites function
call DrawMetasprites
We previously mentioned a variable called âwLastOAMAddressâ. The âDrawMetaspritesâ function can be found in the âsrc/main/utils/metasprites.asmâ file:
include "src/main/utils/constants.inc"
SECTION "MetaSpriteVariables", WRAM0
wMetaspriteAddress:: dw
wMetaspriteX:: db
wMetaspriteY::db
SECTION "MetaSprites", ROM0
DrawMetasprites::
; get the metasprite address
ld a, [wMetaspriteAddress+0]
ld l, a
ld a, [wMetaspriteAddress+1]
ld h, a
; Get the y position
ld a, [hli]
ld b, a
; stop if the y position is 128
ld a, b
cp 128
ret z
ld a, [wMetaspriteY]
add b
ld [wMetaspriteY], a
; Get the x position
ld a, [hli]
ld c, a
ld a, [wMetaspriteX]
add c
ld [wMetaspriteX], a
; Get the tile position
ld a, [hli]
ld d, a
; Get the flag position
ld a, [hli]
ld e, a
; Get our offset address in hl
ld a,[wLastOAMAddress+0]
ld l, a
ld a, HIGH(wShadowOAM)
ld h, a
ld a, [wMetaspriteY]
ld [hli], a
ld a, [wMetaspriteX]
ld [hli], a
ld a, d
ld [hli], a
ld a, e
ld [hli], a
call NextOAMSprite
; increase the wMetaspriteAddress
ld a, [wMetaspriteAddress]
add a, METASPRITE_BYTES_COUNT
ld [wMetaspriteAddress], a
ld a, [wMetaspriteAddress+1]
adc 0
ld [wMetaspriteAddress+1], a
jp DrawMetasprites
When we call the âDrawMetaspritesâ function, the âwLastOAMAddressâ variable will be advanced to point at the next available shadow OAM sprite. This is done using the âNextOAMSpriteâ function in âsrc/main/utils/sprites-utils.asmâ
NextOAMSprite::
ld a, [wSpritesUsed]
inc a
ld [wSpritesUsed], a
ld a,[wLastOAMAddress]
add sizeof_OAM_ATTRS
ld [wLastOAMAddress], a
ld a, HIGH(wShadowOAM)
ld [wLastOAMAddress+1], a
ret
Object Pools
Galactic Armada will use âobject poolsâ for bullets and enemies. A fixed amount of bytes representing a specific maximum amount of objects. Each pool is just a collection of bytes. The number of bytes per âpoolâ is the maximum number of objects in the pool, times the number of bytes needed for data for each object.
Constants are also created for the size of each object, and what each byte is. These constants are in the âsrc/main/utils/constants.incâ file and utilize RGBDS offset constants (a really cool feature)
; from https://rgbds.gbdev.io/docs/v0.6.1/rgbasm.5#EXPRESSIONS
; The RS group of commands is a handy way of defining structure offsets:
RSRESET
DEF bullet_activeByte RB 1
DEF bullet_xByte RB 1
DEF bullet_yLowByte RB 1
DEF bullet_yHighByte RB 1
DEF PER_BULLET_BYTES_COUNT RB 0
The two object types that we need to loop through are Enemies and Bullets.
Bytes for an Enemy:
- Active - Are they active
- X - Position: horizontal coordinate
- Y (low) - The lower byte of their 16-bit (scaled) y position
- Y (high) - The higher byte of their 16-bit (scaled) y position
- Speed - How fast they move
- Health - How many bullets they can take
; Bytes: active, x , y (low), y (high), speed, health
wEnemies:: ds MAX_ENEMY_COUNT*PER_ENEMY_BYTES_COUNT
Bytes for a Bullet:
- Active - Are they active
- X - Position: horizontal coordinate
- Y (low) - The lower byte of their 16-bit (scaled) y position
- Y (high) - The higher byte of their 16-bit (scaled) y position
; Bytes: active, x , y (low), y (high)
wBullets:: ds MAX_BULLET_COUNT*PER_BULLET_BYTES_COUNT
â ď¸ NOTE: Scaled integers are used for only the y positions of bullets and enemies. Scaled Integers are a way to provide smooth âsub-pixelâ movement. They only move vertically, so the x position can be 8-bit.
When looping through an object pool, weâll check if an object is active. If itâs active, weâll run the logic for that object. Otherwise, weâll skip to the start of the next objectâs bytes.
Both bullets and enemies do similar things. They move vertically until they are off the screen. In addition, enemies will check against bullets when updating. If they are found to be colliding, the bullet is destroyed and so is the enemy.
âActivatingâ a pooled object
To Activate a pooled object, we simply loop through each object. If the first byte, which tells us if itâs active or not, is 0: then weâll add the new item at that location and set that byte to be 1. If we loop through all possible objects and nothing is inactive, nothing happens.
The Player
The playerâs logic is pretty simple. The player can move in 4 directions and fire bullets. We update the player by checking our input directions and the A button. Weâll move in the proper direction if its associated d-pad button is pressed. If the A button is pressed, weâll spawn a new bullet at the playerâs position.
Our player will have 3 variables:
- wePlayerPositionX - a 16-bit scaled integer
- wePlayerPositionY - a 16-bit scaled integer
- wPlayerFlash - a 16-bit integer used when the player gets damaged
â ď¸ NOTE: The player can move vertically AND horizontally. So, unlike bullets and enemies, itâs x position is a 16-bit scaled integer.
These are declared at the top of the âsrc/main/states/gameplay/objects/player.asmâ file
include "src/main/utils/hardware.inc"
include "src/main/utils/hardware.inc"
include "src/main/utils/constants.inc"
SECTION "PlayerVariables", WRAM0
; first byte is low, second is high (little endian)
wPlayerPositionX:: dw
wPlayerPositionY:: dw
mPlayerFlash: dw
Well draw our player, a simple ship, using the previously discussed metasprites implementation. Here is what we have for the players metasprites and tile data:
SECTION "Player", ROM0
playerShipTileData: INCBIN "src/generated/sprites/player-ship.2bpp"
playerShipTileDataEnd:
playerTestMetaSprite::
.metasprite1 db 0,0,0,0
.metasprite2 db 0,8,2,0
.metaspriteEnd db 128
Initializing the Player
Initializing the player is pretty simple. Hereâs a list of things we need to do:
- Reset oir wPlayerFlash variable
- Reset our wPlayerPositionX variable
- Reset our wPlayerPositionU variable
- Copy the playerâs ship into VRAM
Weâll use a constant we declared in âsrc/main/utils/constants.incâ to copy the player shipâs tile data into VRAM. Our enemy ship and player ship both have 4 tiles (16 bytes for each tile). In the snippet below, we can define where weâll place the tile data in VRAM relative to the _VRAM constant:
RSRESET
DEF spriteTilesStart RB _VRAM
DEF PLAYER_TILES_START RB 4*16
DEF ENEMY_TILES_START RB 4*16
DEF BULLET_TILES_START RB 0
Hereâs what our âInitializePlayerâ function looks like. Recall, this was called when initiating the gameplay game state:
InitializePlayer::
xor a
ld [mPlayerFlash], a
ld [mPlayerFlash+1], a
; Place in the middle of the screen
xor a
ld [wPlayerPositionX], a
ld [wPlayerPositionY], a
ld a, 5
ld [wPlayerPositionX+1], a
ld [wPlayerPositionY+1], a
CopyPlayerTileDataIntoVRAM:
; Copy the player's tile data into VRAM
ld de, playerShipTileData
ld hl, PLAYER_TILES_START
ld bc, playerShipTileDataEnd - playerShipTileData
call CopyDEintoMemoryAtHL
ret
Updating the Player
We can break our playerâs update logic into 2 parts:
- Check for joypad input, move with the d-pad, shoot with A
- Depending on our âwPlayerFlashâ variable: Draw our metasprites at our location
Checking the joypad is done like the previous tutorials, weâll perform bitwise âandâ operations with constants for each d-pad direction.
UpdatePlayer::
UpdatePlayer_HandleInput:
ld a, [wCurKeys]
and PADF_UP
call nz, MoveUp
ld a, [wCurKeys]
and PADF_DOWN
call nz, MoveDown
ld a, [wCurKeys]
and PADF_LEFT
call nz, MoveLeft
ld a, [wCurKeys]
and PADF_RIGHT
call nz, MoveRight
ld a, [wCurKeys]
and PADF_A
call nz, TryShoot
For player movement, our X & Y are 16-bit integers. These both require two bytes. There is a little endian ordering, the first byte will be the low byte. The second byte will be the high byte. To increase/decrease these values, we add/subtract our change amount to/from the low byte. Then afterwards, we add/subtract the remainder of that operation to/from the high byte.
MoveUp:
; decrease the player's y position
ld a, [wPlayerPositionY]
sub PLAYER_MOVE_SPEED
ld [wPlayerPositionY], a
ld a, [wPlayerPositionY]
sbc 0
ld [wPlayerPositionY], a
ret
MoveDown:
; increase the player's y position
ld a, [wPlayerPositionY]
add PLAYER_MOVE_SPEED
ld [wPlayerPositionY], a
ld a, [wPlayerPositionY+1]
adc 0
ld [wPlayerPositionY+1], a
ret
MoveLeft:
; decrease the player's x position
ld a, [wPlayerPositionX]
sub PLAYER_MOVE_SPEED
ld [wPlayerPositionX], a
ld a, [wPlayerPositionX+1]
sbc 0
ld [wPlayerPositionX+1], a
ret
MoveRight:
; increase the player's x position
ld a, [wPlayerPositionX]
add PLAYER_MOVE_SPEED
ld [wPlayerPositionX], a
ld a, [wPlayerPositionX+1]
adc 0
ld [wPlayerPositionX+1], a
ret
When the player wants to shoot, we first check if the A button previously was down. If it was, we wonât shoot a new bullet. This avoids bullet spamming a little. For spawning bullets, we have a function called âFireNextBulletâ. This function will need the new bulletâs 8-bit X coordinate and 16-bit Y coordinate, both set in a variable it uses called âwNextBulletâ
TryShoot:
ld a, [wLastKeys]
and PADF_A
ret nz
jp FireNextBullet
After weâve potentially moved the player and/or shot a new bullet. We need to draw our player. However, to create the âflashingâ effect when damaged, weâll conditionally NOT draw our player sprite. We do this based on the âwPlayerFlashâ variable.
- If the âwPlayerFlashâ variable is 0, the player is not damaged, weâll skip to drawing our player sprite.
- Otherwise, decrease the âwPlayerFlashâ variable by 5.
- Weâll shift all the bits of the âwPlayerFlashâ variable to the right 4 times
- If the result is less than 5, weâll stop flashing and draw our player metasprite.
- Otherwise, if the first bit of the decscaled âwPlayerFLashâ variable is 1, weâll skip drawing the player.
*NOTE: The following resumes from where the âUpdatePlayer_HandleInputâ label ended above.
ld a, [mPlayerFlash+0]
ld b, a
ld a, [mPlayerFlash+1]
ld c, a
UpdatePlayer_UpdateSprite_CheckFlashing:
ld a, b
or c
jp z, UpdatePlayer_UpdateSprite
; decrease bc by 5
ld a, b
sub 5
ld b, a
ld a, c
sbc 0
ld c, a
UpdatePlayer_UpdateSprite_DecreaseFlashing:
ld a, b
ld [mPlayerFlash], a
ld a, c
ld [mPlayerFlash+1], a
; descale bc
srl c
rr b
srl c
rr b
srl c
rr b
srl c
rr b
ld a, b
cp 5
jp c, UpdatePlayer_UpdateSprite_StopFlashing
bit 0, b
jp z, UpdatePlayer_UpdateSprite
UpdatePlayer_UpdateSprite_Flashing:
ret
UpdatePlayer_UpdateSprite_StopFlashing:
xor a
ld [mPlayerFlash],a
ld [mPlayerFlash+1],a
If we get past all of the âwPlayerFlashâ logic, weâll draw our player using the âDrawMetaspriteâ function we previously discussed.
UpdatePlayer_UpdateSprite:
; Get the unscaled player x position in b
ld a, [wPlayerPositionX+0]
ld b, a
ld a, [wPlayerPositionX+1]
ld d, a
srl d
rr b
srl d
rr b
srl d
rr b
srl d
rr b
; Get the unscaled player y position in c
ld a, [wPlayerPositionY+0]
ld c, a
ld a, [wPlayerPositionY+1]
ld e, a
srl e
rr c
srl e
rr c
srl e
rr c
srl e
rr c
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Drawing the palyer metasprite
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Save the address of the metasprite into the 'wMetaspriteAddress' variable
; Our DrawMetasprites functoin uses that variable
ld a, LOW(playerTestMetaSprite)
ld [wMetaspriteAddress+0], a
ld a, HIGH(playerTestMetaSprite)
ld [wMetaspriteAddress+1], a
; Save the x position
ld a, b
ld [wMetaspriteX], a
; Save the y position
ld a, c
ld [wMetaspriteY], a
; Actually call the 'DrawMetasprites function
call DrawMetasprites;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ret
Thatâs the end our our âUpdatePlayerâ function. The final bit of code for our player handles when they are damaged. When an enemy damages the player, we want to decrease our lives by one. Weâll also start flashing by giving our âmPlayerFlashâ variable a non-zero value. In the gameplay game state, if weâve lost all lives, gameplay will end.
DamagePlayer::
xor a
ld [mPlayerFlash], a
inc a
ld [mPlayerFlash+1], a
ld a, [wLives]
dec a
ld [wLives], a
ret
Thatâs everything for our player. Next, weâll go over bullets and then onto the enemies.
Bullets
Bullets are relatively simple, logic-wise. They all travel straight-forward, and de-activate themselves when they leave the screen.
At the top of our âsrc/main/states/gameplay/objects/bullets.asmâ file weâll setup some variables for bullets and include our tile data.
include "src/main/utils/hardware.inc"
include "src/main/utils/constants.inc"
SECTION "BulletVariables", WRAM0
wSpawnBullet: db
; how many bullets are currently active
wActiveBulletCounter:: db
; how many bullet's we've updated
wUpdateBulletsCounter: db
; Bytes: active, x , y (low), y (high)
wBullets:: ds MAX_BULLET_COUNT*PER_BULLET_BYTES_COUNT
SECTION "Bullets", ROM0
bulletMetasprite::
.metasprite1 db 0,0,8,0
.metaspriteEnd db 128
bulletTileData:: INCBIN "src/generated/sprites/bullet.2bpp"
bulletTileDataEnd::
Weâll need to loop through the bullet object pool in the following sections.
Initiating Bullets
In our âInitializeBulletsâ function, weâll copy the tile data for the bullet sprites into VRAM, and set every bullet as inactive. Each bullet is 4 bytes, the first byte signaling if the bullet is active or not.
Weâll iterate through bullet object pool, named âwBulletsâ, and activate the first of the the four bytes. Then skipping the next 3 bytes, to go onto the next bullet. Weâll do this until weâve looped for each bullet in our pool.
InitializeBullets::
xor a
ld [wSpawnBullet], a
; Copy the bullet tile data intto vram
ld de, bulletTileData
ld hl, BULLET_TILES_START
ld bc, bulletTileDataEnd - bulletTileData
call CopyDEintoMemoryAtHL
; Reset how many bullets are active to 0
xor a
ld [wActiveBulletCounter],a
ld b, a
ld hl, wBullets
ld [hl], a
InitializeBullets_Loop:
; Increase the address
ld a, l
add PER_BULLET_BYTES_COUNT
ld l, a
ld a, h
adc 0
ld h, a
; Increase how many bullets we have initailized
ld a, b
inc a
ld b, a
cp MAX_BULLET_COUNT
ret z
jp InitializeBullets_Loop
Updating Bullets
When we want to update each of bullets, first we should check if any bullets are active. If no bullets are active we can stop early.
UpdateBullets::
; Make sure we have SOME active enemies
ld a, [wSpawnBullet]
ld b, a
ld a, [wActiveBulletCounter]
or b
cp 0
ret z
; Reset our counter for how many bullets we have checked
xor a
ld [wUpdateBulletsCounter], a
; Get the address of the first bullet in hl
ld a, LOW(wBullets)
ld l, a
ld a, HIGH(wBullets)
ld h, a
jp UpdateBullets_PerBullet
If we have active bullets, weâll reset how many bullets weâve checked and set our âhlâ registers to point to the first bullets address.
When were updating each bullet, weâll check each byte, changing hl (the byte we want to read) as we go. At the start, âhlâ should point to the first byte. âhlâ should point to the first byte at the end too:
HL should point to the first byte at the end so we can easily do one of two things:
- deactivate the bullet
- jump to the next bullet (by simply adding 4 to hl)
For we each bullet, weâll do the following:
- Check if active
- Get our x position, save into b
- Get our y scaled positon, save into c (low byte), and d (high byte)
- Decrease our y position to move the bullet upwards
- Reset HL to the first byte of our bullet
- Descale the y position we have in c & d, and jump to our deactivation code if c (the low byte) is high enough
- Draw our bullet metasprit, if it wasnât previously deactivated
UpdateBullets_PerBullet:
; The first byte is if the bullet is active
; If it's NOT zero, it's active, go to the normal update section
ld a, [hl]
and a
jp nz, UpdateBullets_PerBullet_Normal
; Do we need to spawn a bullet?
; If we dont, loop to the next enemy
ld a, [wSpawnBullet]
and a
jp z, UpdateBullets_Loop
UpdateBullets_PerBullet_SpawnDeactivatedBullet:
; reset this variable so we don't spawn anymore
xor a
ld [wSpawnBullet], a
; Increase how many bullets are active
ld a, [wActiveBulletCounter]
inc a
ld [wActiveBulletCounter], a
push hl
; Set the current bullet as active
ld a, 1
ld [hli], a
; Get the unscaled player x position in b
ld a, [wPlayerPositionX]
ld b, a
ld a, [wPlayerPositionX+1]
ld d, a
; Descale the player's x position
; the result will only be in the low byt
srl d
rr b
srl d
rr b
srl d
rr b
srl d
rr b
; Set the x position to equal the player's x position
ld a, b
ld [hli], a
; Set the y position (low)
ld a, [wPlayerPositionY]
ld [hli], a
; Set the y position (high)
ld a, [wPlayerPositionY+1]
ld [hli], a
pop hl
UpdateBullets_PerBullet_Normal:
; Save our active byte
push hl
inc hl
; Get our x position
ld a, [hli]
ld b, a
; get our 16-bit y position
ld a, [hl]
sub BULLET_MOVE_SPEED
ld [hli], a
ld c, a
ld a, [hl]
sbc 0
ld [hl], a
ld d, a
pop hl; go to the active byte
; Descale our y position
srl d
rr c
srl d
rr c
srl d
rr c
srl d
rr c
; See if our non scaled low byte is above 160
ld a, c
cp 178
; If it's below 160, deactivate
jp nc, UpdateBullets_DeActivateIfOutOfBounds
Drawing the Bullets
Weâll draw our bullet metasprite like we drew the player, using our âDrawMetaspritesâ function. This function may alter the âhâ or âlâ registers, so weâll push the hl register onto the stack before hand. After drawing, weâll pop the hl register off of the stack to restore itâs value.
push hl
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Drawing a metasprite
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Save the address of the metasprite into the 'wMetaspriteAddress' variable
; Our DrawMetasprites functoin uses that variable
ld a, LOW(bulletMetasprite)
ld [wMetaspriteAddress], a
ld a, HIGH(bulletMetasprite)
ld [wMetaspriteAddress+1], a
; Save the x position
ld a, b
ld [wMetaspriteX], a
; Save the y position
ld a, c
ld [wMetaspriteY], a
; Actually call the 'DrawMetasprites function
call DrawMetasprites
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
pop hl
jp UpdateBullets_Loop
Deactivating the Bullets
If a bullet needs to be deactivated, we simply set itâs first byte to 0. At this point in time, the âhlâ registers should point at our bullets first byte. This makes deactivation a really simple task. In addition to changing the first byte, weâll decrease how many bullets we have that are active.
UpdateBullets_DeActivateIfOutOfBounds:
; if it's y value is grater than 160
; Set as inactive
xor a
ld [hl], a
; Decrease counter
ld a,[wActiveBulletCounter]
dec a
ld [wActiveBulletCounter], a
jp UpdateBullets_Loop
Updating the next bullet
After weâve updated a single bullet, weâll increase how many bulletâs weâve updated. If weâve updated all the bullets, we can stop our âUpdateBulletsâ function. Otherwise, weâll add 4 bytes to the addressed stored in âhlâ, and update the next bullet.
UpdateBullets_Loop:
; Check our counter, if it's zero
; Stop the function
ld a, [wUpdateBulletsCounter]
inc a
ld [wUpdateBulletsCounter], a
; Check if we've already
ld a, [wUpdateBulletsCounter]
cp MAX_BULLET_COUNT
ret nc
; Increase the bullet data our address is pointingtwo
ld a, l
add PER_BULLET_BYTES_COUNT
ld l, a
ld a, h
adc 0
ld h, a
Firing New Bullets
During the âUpdatePlayerâ function previously, when use pressed A we called the âFireNextBulletâ function.
This function will loop through each bullet in the bullet object pool. When it finds an inactive bullet, it will activate it and set itâs position equal to the players.
Our bullets only use one 8-bit integer for their x position, so need to de-scale the playerâs 16-bit scaled x position
FireNextBullet::
; Make sure we don't have the max amount of enmies
ld a, [wActiveBulletCounter]
cp MAX_BULLET_COUNT
ret nc
; Set our spawn bullet variable to true
ld a, 1
ld [wSpawnBullet], a
ret
Thatâs it for bullets logic. Next weâll cover enemies, and after that weâll step back into the world of bullets with âBullet vs Enemyâ Collision.
Enemies
Enemies in SHMUPS often come in a variety of types, and travel also in a variety of patterns. To keep things simple for this tutorial, weâll have one enemy that flys straight downward. Because of this decision, the logic for enemies is going to be similar to bullets in a way. They both travel vertically and disappear when off screeen. Some differences to point out are:
- Enemies are not spawned by the player, so we need logic that spawns them at random times and locations.
- Enemies must check for collision against the player
- Weâll check for collision against bullets in the enemy update function.
Here are the RAM variables weâll use for our enemies:
- wCurrentEnemyX & wCurrentEnemyY - When we check for collisions, weâll save the current enemyâs position in these two variables.
- wNextEnemyXPosition - When this variable has a non-zero value, weâll spawn a new enemy at that position
- wSpawnCounter - Weâll decrease this, when it reaches zero weâll spawn a new enemy (by setting âwNextEnemyXPositionâ to a non-zero value).
- wActiveEnemyCounter - This tracks how many enemies we have on screen
- wUpdateEnemiesCounter - This is used when updating enemies so we know how many we have updated.
- wUpdateEnemiesCurrentEnemyAddress - When we check for enemy v. bullet collision, weâll save the address of our current enemy here.
include "src/main/utils/hardware.inc"
include "src/main/utils/constants.inc"
SECTION "EnemyVariables", WRAM0
wCurrentEnemyX:: db
wCurrentEnemyY:: db
wSpawnCounter: db
wNextEnemyXPosition: db
wActiveEnemyCounter::db
wUpdateEnemiesCounter:db
wUpdateEnemiesCurrentEnemyAddress::dw
; Bytes: active, x , y (low), y (high), speed, health
wEnemies:: ds MAX_ENEMY_COUNT*PER_ENEMY_BYTES_COUNT
Just like with bullets, weâll setup ROM data for our enemies tile data and metasprites.
SECTION "Enemies", ROM0
enemyShipTileData:: INCBIN "src/generated/sprites/enemy-ship.2bpp"
enemyShipTileDataEnd::
enemyShipMetasprite::
.metasprite1 db 0,0,4,0
.metasprite2 db 0,8,6,0
.metaspriteEnd db 128
Initializing Enemies
When initializing the enemies (at the start of gameplay), weâll copy the enemy tile data into VRAM. Also, like with bullets, weâll loop through and make sure each enemy is set to inactive.
InitializeEnemies::
ld de, enemyShipTileData
ld hl, ENEMY_TILES_START
ld bc, enemyShipTileDataEnd - enemyShipTileData
call CopyDEintoMemoryAtHL
xor a
ld [wSpawnCounter], a
ld [wActiveEnemyCounter], a
ld [wNextEnemyXPosition], a
ld b, a
ld hl, wEnemies
InitializeEnemies_Loop:
; Set as inactive
ld [hl], 0
; Increase the address
ld a, l
add PER_ENEMY_BYTES_COUNT
ld l, a
ld a, h
adc 0
ld h, a
inc b
ld a, b
cp MAX_ENEMY_COUNT
ret z
jp InitializeEnemies_Loop
Updating Enemies
When âUpdateEnemiesâ is called from gameplay, the first thing we try to do is spawn new enemies. After that, if we have no active enemies (and are not trying to spawn a new enemy), we stop the âUpdateEnemiesâ function. From here, like with bullets, weâll save the address of our first enemy in hl and start looping through.
UpdateEnemies::
call TryToSpawnEnemies
; Make sure we have active enemies
; or we want to spawn a new enemy
ld a, [wNextEnemyXPosition]
ld b, a
ld a, [wActiveEnemyCounter]
or b
and a
ret z
xor a
ld [wUpdateEnemiesCounter], a
ld a, LOW(wEnemies)
ld l, a
ld a, HIGH(wEnemies)
ld h, a
jp UpdateEnemies_PerEnemy
When we are looping through our enemy object pool, letâs check if the current enemy is active. If itâs active, weâll update it like normal. If it isnât active, the game checks if we want to spawn a new enemy. We specify we want to spawn a new enemy by setting âwNextEnemyXPositionâ to a non-zero value. If we donât want to spawn a new enemy, weâll move on to the next enemy.
If we want to spawn a new enemy, weâll set the current inactive enemy to active. Afterwards, weâll set itâs y position to zero, and itâs x position to whatever was in the âwNextEnemyXPositionâ variable. After that, weâll increase our active enemy counter, and go on to update the enemy like normal.
UpdateEnemies_PerEnemy:
; The first byte is if the current object is active
; If it's not zero, it's active, go to the normal update section
ld a, [hl]
and a
jp nz, UpdateEnemies_PerEnemy_Update
UpdateEnemies_SpawnNewEnemy:
; If this enemy is NOT active
; Check If we want to spawn a new enemy
ld a, [wNextEnemyXPosition]
and a
; If we don't want to spawn a new enemy, we'll skip this (deactivated) enemy
jp z, UpdateEnemies_Loop
push hl
; If they are deactivated, and we want to spawn an enemy
; activate the enemy
ld a, 1
ld [hli], a
; Put the value for our enemies x position
ld a, [wNextEnemyXPosition]
ld [hli], a
; Put the value for our enemies y position to equal 0
xor a
ld [hli], a
ld [hld], a
ld [wNextEnemyXPosition], a
pop hl
; Increase counter
ld a, [wActiveEnemyCounter]
inc a
ld [wActiveEnemyCounter], a
When We are done updating a single enemy, weâll jump to the âUpdateEnemies_Loopâ label. Here weâll increase how many enemies weâve updated, and end if weâve done them all. If we still have more enemies left, weâll increase the address stored in hl by 6 and update the next enemy.
The âhlâ registers should always point to the current enemies first byte when this label is reached.
UpdateEnemies_Loop:
; Check our coutner, if it's zero
; Stop the function
ld a, [wUpdateEnemiesCounter]
inc a
ld [wUpdateEnemiesCounter], a
; Compare against the active count
cp MAX_ENEMY_COUNT
ret nc
; Increase the enemy data our address is pointingtwo
ld a, l
add PER_ENEMY_BYTES_COUNT
ld l, a
ld a, h
adc 0
ld h, a
For updating enemies, weâll first get the enemies speed. Afterwards weâll increase the enemies 16-bit y position. Once weâve done that, weâll descale the y position so we can check for collisions and draw the ennemy.
UpdateEnemies_PerEnemy_Update:
; Save our first bytye
push hl
; Get our move speed in e
ld bc, enemy_speedByte
add hl, bc
ld a, [hl]
ld e, a
; Go back to the first byte
; put the address toe the first byte back on the stack for later
pop hl
push hl
inc hl
; Get our x position
ld a, [hli]
ld b, a
ld [wCurrentEnemyX], a
; get our 16-bit y position
; increase it (by e), but also save it
ld a, [hl]
add 10
ld [hli], a
ld c, a
ld a, [hl]
adc 0
ld [hl], a
ld d, a
pop hl
; Descale the y psoition
srl d
rr c
srl d
rr c
srl d
rr c
srl d
rr c
ld a, c
ld [wCurrentEnemyY], a
Player & Bullet Collision
One of the differences between enemies and bullets is that enemies must check for collision against the player and also against bullets. For both of these cases, weâll use a simple Axis-Aligned Bounding Box test. Weâll cover the specific logic in a later section.
If we have a collison against the player we need to damage the player, and redraw how many lives they have. In addition, itâs optional, but weâll deactivate the enemy too when they collide with the player.
Our âhlâ registers should point to the active byte of the current enemy. We push and pop our âhlâ registers to make sure we get back to that same address for later logic.
UpdateEnemies_PerEnemy_CheckPlayerCollision:
push hl
call CheckCurrentEnemyAgainstBullets
call CheckEnemyPlayerCollision
pop hl
ld a, [wResult]
and a
jp z, UpdateEnemies_NoCollisionWithPlayer
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
push hl
call DamagePlayer
call DrawLives
pop hl
jp UpdateEnemies_DeActivateEnemy
If there is no collision with the player, weâll draw the enemies. This is done just as we did the player and bullets, with the âDrawMetaspritesâ function.
UpdateEnemies_NoCollisionWithPlayer::
; See if our non scaled low byte is above 160
ld a, [wCurrentEnemyY]
cp 160
jp nc, UpdateEnemies_DeActivateEnemy
push hl
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; call the 'DrawMetasprites function. setup variables and call
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Save the address of the metasprite into the 'wMetaspriteAddress' variable
; Our DrawMetasprites functoin uses that variable
ld a, LOW(enemyShipMetasprite)
ld [wMetaspriteAddress+0], a
ld a, HIGH(enemyShipMetasprite)
ld [wMetaspriteAddress+1], a
; Save the x position
ld a, [wCurrentEnemyX]
ld [wMetaspriteX], a
; Save the y position
ld a, [wCurrentEnemyY]
ld [wMetaspriteY], a
; Actually call the 'DrawMetasprites function
call DrawMetasprites
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
pop hl
jp UpdateEnemies_Loop
Deactivating Enemies
Deactivating an enemy is just like with bullets. Weâll set itâs first byte to 0, and decrease our counter variable.
Here, we can just use the current address in HL. This is the second reason we wanted to keep the address of our first byte on the stack.
UpdateEnemies_DeActivateEnemy:
; Set as inactive
xor a
ld [hl], a
; Decrease counter
ld a, [wActiveEnemyCounter]
dec a
ld [wActiveEnemyCounter], a
jp UpdateEnemies_Loop
Spawning Enemies
Randomly, we want to spawn enemies. Weâll increase a counter called âwEnemyCounterâ. When it reaches a preset maximum value, weâll maybe try to spawn a new enemy.
Firstly, We need to make sure we arenât at maximum enemy capacity, if so, we will not spawn enemy more enemies. If we are not at maximum capacity, weâll try to get a x position to spawn the enemy at. If our x position is below 24 or above 150, weâll also NOT spawn a new enemy.
All enemies are spawned with y position of 0, so we only need to get the x position.
If we have a valid x position, weâll reset our spawn counter, and save that x position in the âwNextEnemyXPositionâ variable. With this variable set, Weâll later activate and update a enemy that we find in the inactive state.
TryToSpawnEnemies::
; Increase our spwncounter
ld a, [wSpawnCounter]
inc a
ld [wSpawnCounter], a
; Check our spawn acounter
; Stop if it's below a given value
ld a, [wSpawnCounter]
cp ENEMY_SPAWN_DELAY_MAX
ret c
; Check our next enemy x position variable
; Stop if it's non zero
ld a, [wNextEnemyXPosition]
cp 0
ret nz
; Make sure we don't have the max amount of enmies
ld a, [wActiveEnemyCounter]
cp MAX_ENEMY_COUNT
ret nc
GetSpawnPosition:
; Generate a semi random value
call rand
; make sure it's not above 150
ld a, b
cp 150
ret nc
; make sure it's not below 24
ld a, b
cp 24
ret c
; reset our spawn counter
xor a
ld [wSpawnCounter], a
ld a, b
ld [wNextEnemyXPosition], a
ret
Collision Detection
Collision Detection is cruical to games. It can be a very complicated topic. In Galactic Armada, things will be kept super simple. Weâre going to perform a basic implementation of âAxis-Aligned Bounding Box Collision Detectionâ:
One of the simpler forms of collision detection is between two rectangles that are axis aligned â meaning no rotation. The algorithm works by ensuring there is no gap between any of the 4 sides of the rectangles. Any gap means a collision does not exist.1
The easiest way to check for overlap, is to check the difference bewteen their centers. If the absolute value of their x & y differences (Iâll refer to as âthe absolute differenceâ) are BOTH smaller than the sum of their half widths, we have a collision. This collision detection is run for bullets against enemies, and enemies against the player. Hereâs a visualization with bullets and enemies.
For this, weâve created a basic function called âCheckObjectPositionDifferenceâ. This function will help us check for overlap on the x or y axis. When the (absolute) difference between the first two values passed is greater than the third value passed, it jumpâs to the label passed in the fourth parameter.
Hereâs an example of how to call this function:
We have the playerâs Y position in the
d
register. Weâll check itâs value against the y value of the current enemy, which we have in a variable namedwCurrentEnemyY
.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Check the y distances. Jump to 'NoCollisionWithPlayer' on failure
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld a, [wCurrentEnemyY]
ld [wObject1Value], a
ld a, d
ld [wObject2Value], a
; Save if the minimum distance
ld a, 16
ld [wSize], a
call CheckObjectPositionDifference
ld a, [wResult]
and a
jp z, NoCollisionWithPlayer
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
When checking for collision, weâll use that function twice. Once for the x-axis, and again for the y-axis.
NOTE: We donât need to test the y-axis if the x-axis fails.
The source code for that function looks like this:
include "src/main/utils/constants.inc"
include "src/main/utils/hardware.inc"
SECTION "CollisionUtilsVariables", WRAM0
wResult:: db
wSize:: db
wObject1Value:: db
wObject2Value:: db
SECTION "CollisionUtils", ROM0
CheckObjectPositionDifference::
; at this point in time; e = enemy.y, b =bullet.y
ld a, [wObject1Value]
ld e, a
ld a, [wObject2Value]
ld b, a
ld a, [wSize]
ld d, a
; subtract bullet.y, (aka b) - (enemy.y+8, aka e)
; carry means e<b, means enemy.bottom is visually above bullet.y (no collision)
ld a, e
add d
cp b
; carry means no collision
jp c, CheckObjectPositionDifference_Failure
; subtract enemy.y-8 (aka e) - bullet.y (aka b)
; no carry means e>b, means enemy.top is visually below bullet.y (no collision)
ld a, e
sub d
cp b
; no carry means no collision
jp nc, CheckObjectPositionDifference_Failure
ld a, 1
ld [wResult], a
ret
CheckObjectPositionDifference_Failure:
ld a,0
ld [wResult], a
ret;
Enemy-Player Collision
Our enemy versus player collision detection starts with us getting our playerâs unscaled x position. Weâll store that value in d.
CheckEnemyPlayerCollision::
; Get our player's unscaled x position in d
ld a, [wPlayerPositionX]
ld d, a
ld a, [wPlayerPositionX+1]
ld e, a
srl e
rr d
srl e
rr d
srl e
rr d
srl e
rr d
With our playerâs x position in d, weâll compare it against a previously saved enemy x position variable. If they are more than 16 pixels apart, weâll jump to the âNoCollisionWithPlayerâ label.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Check the x distances. Jump to 'NoCollisionWithPlayer' on failure
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld a, [wCurrentEnemyX]
ld [wObject1Value], a
ld a, d
ld [wObject2Value], a
; Save if the minimum distance
ld a, 16
ld [wSize], a
call CheckObjectPositionDifference
ld a, [wResult]
and a
jp z, NoCollisionWithPlayer
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
After checking the x axis, if the code gets this far there was an overlap. Weâll do the same for the y axis next.
Weâll get the playerâs unscaled y position. Weâll store that value in d for consistency.
; Get our player's unscaled y position in d
ld a, [wPlayerPositionY+0]
ld d, a
ld a, [wPlayerPositionY+1]
ld e, a
srl e
rr d
srl e
rr d
srl e
rr d
srl e
rr d
Just like before, weâll compare our playerâs unscaled y position (stored in d) against a previously saved enemy y position variable. If they are more than 16 pixels apart, weâll jump to the âNoCollisionWithPlayerâ label.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Check the y distances. Jump to 'NoCollisionWithPlayer' on failure
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
ld a, [wCurrentEnemyY]
ld [wObject1Value], a
ld a, d
ld [wObject2Value], a
; Save if the minimum distance
ld a, 16
ld [wSize], a
call CheckObjectPositionDifference
ld a, [wResult]
and a
jp z, NoCollisionWithPlayer
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
The âNoCollisionWithPlayerâ, just setâs the âwResultâ to 0 for failure. If overlap occurs on both axis, weâll isntead set 1 for success.
ld a, 1
ld [wResult], a
ret
NoCollisionWithPlayer::
xor a
ld [wResult], a
ret
Thatâs the enemy-player collision logic. Callers of the function can simply check the âwResultâ variable to determine if there was collision.
Enemy-Bullet Collision
When we are udating enemies, weâll call a function called âCheckCurrentEnemyAgainstBulletsâ. This will check the current enemy against all active bullets.
This fuction needs to loop through the bullet object pool, and check if our current enemy overlaps any bullet on both the x and y axis. If so, weâll deactivate the enemy and bullet.
Our âCheckCurrentEnemyAgainstBulletsâ function starts off in a manner similar to how we updated enemies & bullets.
This function expects âhlâ points to the curent enemy. Weâll save that in a variable for later usage.
include "src/main/utils/hardware.inc"
include "src/main/utils/constants.inc"
include "src/main/utils/hardware.inc"
SECTION "EnemyBulletCollisionVariables", WRAM0
wEnemyBulletCollisionCounter: db
wBulletAddresses: dw
SECTION "EnemyBulletCollision", ROM0
; called from enemies.asm
CheckCurrentEnemyAgainstBullets::
ld a, l
ld [wUpdateEnemiesCurrentEnemyAddress], a
ld a, h
ld [wUpdateEnemiesCurrentEnemyAddress+1], a
xor a
ld [wEnemyBulletCollisionCounter], a
; Copy our bullets address into wBulletAddress
ld a, LOW(wBullets)
ld l, a
ld a, HIGH(wBullets)
ld h, a
jp CheckCurrentEnemyAgainstBullets_PerBullet
As we loop through the bullets, we need to make sure we only check active bullets. Inactive bullets will be skipped.
CheckCurrentEnemyAgainstBullets_PerBullet:
ld a, [hl]
cp 1
jp nz, CheckCurrentEnemyAgainstBullets_Loop
First, we need to check if the current enemy and current bullet are overlapping on the x axis. Weâll get the enemyâs x position in e, and the bulletâs x position in b. From there, weâll again call our âCheckObjectPositionDifferenceâ function. If it returns a failure (wResult=0), weâll start with the next bullet.
We add an offset to the x coordinates so they measure from their centers. That offset is half itâs respective objectâs width.
CheckCurrentEnemyAgainstBullets_Check_X_Overlap:
; Save our first byte address
push hl
inc hl
; Get our x position
ld a, [hli]
add 4
ld b, a
push hl
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Start: Checking the absolute difference
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; The first value
ld a, b
ld [wObject1Value], a
; The second value
ld a, [wCurrentEnemyX]
add 8
ld [wObject2Value], a
; Save if the minimum distance
ld a, 12
ld [wSize], a
call CheckObjectPositionDifference
ld a, [wResult]
and a
jp z, CheckCurrentEnemyAgainstBullets_Check_X_Overlap_Fail
pop hl
jp CheckCurrentEnemyAgainstBullets_PerBullet_Y_Overlap
CheckCurrentEnemyAgainstBullets_Check_X_Overlap_Fail:
pop hl
pop hl
jp CheckCurrentEnemyAgainstBullets_Loop
Next we restore our hl variable so we can get the y position of our current bullet. Once we have that y position, weâll get the current enemyâs y position and check for an overlap on the y axis. If no overlap is found, weâll loop to the next bullet. Otherwise, we have a collision.
CheckCurrentEnemyAgainstBullets_PerBullet_Y_Overlap:
; get our bullet 16-bit y position
ld a, [hli]
ld b, a
ld a, [hli]
ld c, a
; Descale our 16 bit y position
srl c
rr b
srl c
rr b
srl c
rr b
srl c
rr b
; preserve our first byte addresss
pop hl
push hl
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Start: Checking the absolute difference
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; The first value
ld a, b
ld [wObject1Value], a
; The second value
ld a, [wCurrentEnemyY]
ld [wObject2Value], a
; Save if the minimum distance
ld a, 16
ld [wSize], a
call CheckObjectPositionDifference
pop hl
ld a, [wResult]
and a
jp z, CheckCurrentEnemyAgainstBullets_Loop
jp CheckCurrentEnemyAgainstBullets_PerBullet_Collision
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; End: Checking the absolute difference
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
If a collision was detected (overlap on x and y axis), weâll set the current active byte for that bullet to 0. Also , weâll set the active byte for the current enemy to zero. Before we end the function, weâll increase and redraw the score, and decrease how many bullets & enemies we have by one.
CheckCurrentEnemyAgainstBullets_PerBullet_Collision:
; set the active byte and x value to 0 for bullets
xor a
ld [hli], a
ld [hl], a
ld a, [wUpdateEnemiesCurrentEnemyAddress+0]
ld l, a
ld a, [wUpdateEnemiesCurrentEnemyAddress+1]
ld h, a
; set the active byte and x value to 0 for enemies
xor a
ld [hli], a
ld [hl], a
call IncreaseScore
call DrawScore
; Decrease how many active enemies their are
ld a, [wActiveEnemyCounter]
dec a
ld [wActiveEnemyCounter], a
; Decrease how many active bullets their are
ld a, [wActiveBulletCounter]
dec a
ld [wActiveBulletCounter], a
ret
If no collision happened, weâll continue our loop through the enemy bullets. When weâve checked all the bullets, weâll end the function.
CheckCurrentEnemyAgainstBullets_Loop:
; increase our counter
ld a, [wEnemyBulletCollisionCounter]
inc a
ld [wEnemyBulletCollisionCounter], a
; Stop if we've checked all bullets
cp MAX_BULLET_COUNT
ret nc
; Increase the data our address is pointing to
ld a, l
add PER_BULLET_BYTES_COUNT
ld l, a
ld a, h
adc 0
ld h, a
Conclusione
If you liked this tutorial, and you want to take things to the next level, here are some ideas:
- Add an options menu (for typewriter speed, difficulty, disable audio)
- Add Ship Select and different player ships
- Add the ability to upgrade your bullet type
- Add dialogue and âwavesâ of enemies
- Add different types of enemies
- Add a boss
- Add a level select
Prossimi passi
Oh.
Well, youâve reached the end of the tutorial⌠And yes, as you can see, itâs not finished yet.
Weâre actively working on new content (and improvement of the existing one).
In the meantime, the best course of action is to peruse the resources in the next section, and experiment by yourself. Well, given that, it may be a good idea to ask around for advice. A lot of the problems and questions you will be encountering have already been solved, so others canâand will!âhelp you getting started faster.
If you enjoyed the tutorial, please consider contributing, donating to our OpenCollective or simply share the link to this book.
RGBDS Cheatsheet
The purpose of this page is to provide concise explanations and code snippets for common tasks. For extra depth, clarity, and understanding, itâs recommended you read through the Hello World, Part II - Our first game, and Part III - Our second game tutorials.
Assembly syntax & CPU Instructions will not be explained, for more information see the RGBDS Language Reference
Is there something common you think is missing? Check the github repository to open an Issue or contribute to this page. Alternatively, you can reach out on one of the @gbdev community channels.
Table of Contents
Display
The rLCDC
register controls all of the following:
- The screen
- The background
- The window
- Sprite objects
For more information on LCD control, refer to the Pan Docs
Wait for the vertical blank phase
To check for the vertical blank phase, use the rLY
register. Compare that registerâs value against the height of the Game Boy screen in pixels: 144.
WaitUntilVerticalBlankStart:
ldh a, [rLY]
cp 144
jp c, WaitUntilVerticalBlankStart
Turn on/off the LCD
You can turn the LCD on and off by altering the most significant bit of the rLCDC
register. hardware.inc a constant for this: LCDCF_ON
.
To turn the LCD on:
ld a, LCDCF_ON
ldh [rLCDC], a
To turn the LCD off:
â ď¸
Do not turn the LCD off outside of the Vertical Blank Phase. See âWait for the vertical blank phaseâ.
; Turn the LCD off
ld a, LCDCF_OFF
ldh [rLCDC], a
Turn on/off the background
To turn the background layer on and off, alter the least significant bit of the rLCDC
register. You can use the LCDCF_BGON
constant for this.
To turn the background on:
; Turn the background on
ldh a, [rLCDC]
or a, LCDCF_BGON
ldh [rLCDC], a
To turn the background off:
; Turn the background off
ldh a, [rLCDC]
and a, ~LCDCF_BGON
ldh [rLCDC], a
Turn on/off the window
To turn the window layer on and off, alter the least significant bit of the rLCDC
register. You can use the LCDCF_WINON
and LCDCF_WINOFF
constants for this.
To turn the window on:
; Turn the window on
ldh a, [rLCDC]
or a, LCDCF_WINON
ldh [rLCDC], a
To turn the window off:
; Turn the window off
ldh a, [rLCDC]
and a, LCDCF_WINOFF
ldh [rLCDC], a
Switch which tilemaps are used by the window and/or background
By default, the window and background layer will use the same tilemap.
For the window and background, there are 2 memory regions they can use: $9800
and $9C00
. For more information, refer to the Pan Docs
Which region the background uses is controlled by the 4th bit of the rLCDC
register. Which region the window uses is controlled by the 7th bit.
You can use one of the 4 constants to specify which layer uses which region:
- LCDCF_WIN9800
- LCDCF_WIN9C00
- LCDCF_BG9800
- LCDCF_BG9C00
Note
You still need to make sure the window and background are turned on when using these constants.
Turn on/off sprites
Sprites (or objects) can be toggled on and off using the 2nd bit of the rLCDC
register. You can use the LCDCF_OBJON
and LCDCF_OBJOFF
constants for this.
To turn sprite objects on:
; Turn the sprites on
ldh a, [rLCDC]
or a, LCDCF_OBJON
ldh [rLCDC], a
To turn sprite objects off:
; Turn the sprites off
ldh a, [rLCDC]
and a, LCDCF_OBJOFF
ldh [rLCDC], a
Sprites are in 8x8 mode by default.
Turn on/off tall (8x16) sprites
Once sprites are enabled, you can enable tall sprites using the 3rd bit of the rLCDC
register: LCDCF_OBJ16
You can not have some 8x8 sprites and some 8x16 sprites. All sprites must be of the same size.
; Turn tall sprites on
ldh a, [rLCDC]
or a, LCDCF_OBJ16
ldh [rLCDC], a
Backgrounds
Put background/window tile data into VRAM
The region in VRAM dedicated for the background/window tilemaps is from $9000 to $97FF. hardware.inc defines a _VRAM9000
constant you can use for that.
MyBackground:
INCBIN "src/path/to/my-background.2bpp"
.end
CopyBackgroundWindowTileDataIntoVram:
; Copy the tile data
ld de, myBackground
ld hl, \_VRAM
ld bc, MyBackground.end - MyBackground
.loop:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jr nz, .Loop
Draw on the Background/Window
The Game Boy has 2 32x32 tilemaps, one at $9800
and another at $9C00
. Either can be used for the background or window. By default, they both use the tilemap at $9800
.
Drawing on the background or window is as simple as copying bytes starting at one of those addresses:
CopyTilemapTo
; Copy the tilemap
ld de, Tilemap
ld hl, $9800
ld bc, TilemapEnd - Tilemap
CopyTilemap:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jp nz, CopyTilemap
Make sure the layer youâre targetting has been turned on. See âTurn on/off the windowâ and âTurn on/off the backgroundâ
In terms of tiles, The background/window tilemaps are 32x32. The Game Boyâs screen is 20x18. When copying tiles, understand that RGBDS or the Game Boy wonât automatically jump to the next visible row after youâve reached the 20th column.
Move the background
You can move the background horizontally & vertically using the $FF43
and $FF42
registers, respectively. Hardware.inc defines two constants for that: rSCX
and rSCY
.
How to change the backgroundâs X Position:
ld a,64
ld [rSCX], a
How to change the backgroundâs Y Position:
ld a,64
ld [rSCY], a
Check out the Pan Docs for more info on the Background viewport Y position, X position
Move the window
Moving the window is the same as moving the background, except using the $FF4B
and $FF4A
registers. Hardware.inc defines two constants for that: rWX
and rWY
.
The window layer has a -7 pixel horizontal offset. This means setting rWX
to 7 places the window at the left side of the screen, and setting rWX
to 87 places the window with its left side halfway across the screen.
How to change the windowâs X Position:
ld a,64
ld [rWX], a
How to change the windowâs Y Position:
ld a,64
ld [rWY], a
Check out the Pan Docs for more info on the WY, WX: Window Y position, X position plus 7
Joypad Input
Reading joypad input is not a trivial task. For more info see Tutorial #2, or the Joypad Input Page in the Pan Docs. Paste this code somewhere in your project:
UpdateKeys:
; Poll half the controller
ld a, P1F_GET_BTN
call .onenibble
ld b, a ; B7-4 = 1; B3-0 = unpressed buttons
; Poll the other half
ld a, P1F_GET_DPAD
call .onenibble
swap a ; A7-4 = unpressed directions; A3-0 = 1
xor a, b ; A = pressed buttons + directions
ld b, a ; B = pressed buttons + directions
; And release the controller
ld a, P1F_GET_NONE
ldh [rP1], a
; Combine with previous wCurKeys to make wNewKeys
ld a, [wCurKeys]
xor a, b ; A = keys that changed state
and a, b ; A = keys that changed to pressed
ld [wNewKeys], a
ld a, b
ld [wCurKeys], a
ret
.onenibble
ldh [rP1], a ; switch the key matrix
call .knownret ; burn 10 cycles calling a known ret
ldh a, [rP1] ; ignore value while waiting for the key matrix to settle
ldh a, [rP1]
ldh a, [rP1] ; this read counts
or a, $F0 ; A7-4 = 1; A3-0 = unpressed keys
.knownret
ret
Next setup 2 variables in working ram:
SECTION "Input Variables", WRAM0
wCurKeys: db
wNewKeys: db
Finally, during your game loop, be sure to call the UpdateKeys
function during the Vertical Blank phase.
; Check the current keys every frame and move left or right.
call UpdateKeys
Check if a button is down
You can check if a button is down using any of the following constants from hardware.inc:
- PADF_DOWN
- PADF_UP
- PADF_LEFT
- PADF_RIGHT
- PADF_START
- PADF_SELECT
- PADF_B
- PADF_A
You can check if the associataed button is down using the wCurKeys
variable:
ld a, [wCurKeys]
and a, PADF_LEFT
jp nz, LeftIsPressedDown
Check if a button was JUST pressed
You can tell if a button was JUST pressed using the wNewKeys
variable
ld a, [wNewKeys]
and a, PADF_A
jp nz, AWasJustPressed
Wait for a button press
To wait indefinitely for a button press, create a loop where you:
- check if the button has JUST been pressed
- If not:
- Wait until the next vertical blank phase completes
- call the
UpdateKeys
function again - Loop background to the beginning
This will halt all other logic (outside of interrupts), be careful if you need any logic running simultaneously.
WaitForAButtonToBePressed:
ld a, [wNewKeys]
and a, PADF_A
ret nz
WaitUntilVerticalBlankStart:
ld a, [rLY]
cp 144
jp nc, WaitUntilVerticalBlankStart
WaitUntilVerticalBlankEnd:
ld a, [rLY]
cp 144
jp c, WaitUntilVerticalBlankEnd
call UpdateKeys
jp WaitForAButtonToBePressed
HUD
Heads Up Displays, or HUDs; are commonly used to present extra information to the player. Good examples are: Score, Health, and the current level. The window layer is drawn on top of the background, and cannot move like the background. For this reason, commonly the window layer is used for HUDs. See âHow to Draw on the Background/Windowâ.
Draw text
Drawing text on the window is essentially drawing tiles (with letters/numbers/punctuation on them) on the window and/or background layer.
To simplify the process you can define constant strings.
These constants end with a literal 255, which our code will read as the end of the string.
SECTION "Text ASM", ROM0
wScoreText:: db "score", 255
RGBDS has a character map functionality. You can read more in the RGBDS Assembly Syntax Documentation. This functionality, tells the compiler how to map each letter:
You need to have your text font tiles in VRAM at the locations specified in the map. See How to put background/window tile data in VRAM
CHARMAP " ", 0
CHARMAP ".", 24
CHARMAP "-", 25
CHARMAP "a", 26
CHARMAP "b", 27
CHARMAP "c", 28
CHARMAP "d", 29
CHARMAP "e", 30
CHARMAP "f", 31
CHARMAP "g", 32
CHARMAP "h", 33
CHARMAP "i", 34
CHARMAP "j", 35
CHARMAP "k", 36
CHARMAP "l", 37
CHARMAP "m", 38
CHARMAP "n", 39
CHARMAP "o", 40
CHARMAP "p", 41
CHARMAP "q", 42
CHARMAP "r", 43
CHARMAP "s", 44
CHARMAP "t", 45
CHARMAP "u", 46
CHARMAP "v", 47
CHARMAP "w", 48
CHARMAP "x", 49
CHARMAP "y", 50
CHARMAP "z", 51
The above character mapping would convert (by the compiler) our wScoreText
text to:
- s => 44
- c => 28
- o => 40
- r => 43
- e => 30
- 255
With that setup, we would loop though the bytes of wScoreText
and copy each byte to the background/window layer. After we copy each byte, weâll increment where we will copy to, and which byte in wScoreText
we are reading. When we read 255, our code will end.
This example implies that your font tiles are located in VRAM at the locations specified in the character mapping.
Drawing âscoreâ on the window
DrawTextTiles::
ld hl, wScoreText
ld de, $9C00 ; The window tilemap starts at $9C00
DrawTextTilesLoop::
; Check for the end of string character 255
ld a, [hl]
cp 255
ret z
; Write the current character (in hl) to the address
; on the tilemap (in de)
ld a, [hl]
ld [de], a
inc hl
inc de
; move to the next character and next background tile
jp DrawTextTilesLoop
Draw a bottom HUD
- Enable the window (with a different tilemap than the background)
- Move the window downwards, so only 1 or 2 rows show at the bottom of the screen
- Draw your text, score, and icons on the top of the window layer.
Sprites will still show over the window. To fully prevent that, you can use STAT interrupts to hide sprites where the bottom HUD will be shown.
Sprites
Put sprite tile data in VRAM
The region in VRAM dedicated for sprites is from $8000
to $87F0
. Hardware.inc defines a _VRAM
constant you can use for that. To copy sprite tile data into VRAM, you can use a loop to copy the bytes.
mySprite: INCBIN "src/path/to/my/sprite.2bpp"
mySpriteEnd:
CopySpriteTileDataIntoVram:
; Copy the tile data
ld de, Paddle
ld hl, _VRAM
ld bc, mySpriteEnd - mySprite
CopySpriteTileDataIntoVram_Loop:
ld a, [de]
ld [hli], a
inc de
dec bc
ld a, b
or a, c
jp nz, CopySpriteTileDataIntoVram_Loop
Manipulate hardware OAM sprites
Each hardware sprite has 4 bytes: (in this order)
- Y position
- X Position
- Tile ID
- Flags/Props (priority, y flip, x flip, palette 0 [DMG], palette 1 [DMG], bank 0 [GBC], bank 1 [GBC])
Check out the Pan Docs page on Object Attribute Memory (OAM) for more info.
The bytes controlling hardware OAM sprites start at $FE00
, for which hardware.inc has defined a constant as _OAMRAM
.
Moving (the first) OAM sprite, one pixel downwards:
ld a, [_OAMRAM]
inc a
ld [_OAMRAM], a
Moving (the first) OAM sprite, one pixel to the right:
ld a, [_OAMRAM + 1]
inc a
ld [_OAMRAM + 1], a
Setting the tile for the first OAM sprite:
ld a, 3
ld [_OAMRAM+2], a
Moving (the fifth) OAM sprite, one pixel downwards:
ld a, [_OAMRAM + 20]
inc a
ld [_OAMRAM + 20], a
TODO - Explanation on limitations of direct OAM manipulation.
Itâs recommended that developers implement a shadow OAM, like @eievui5âs Sprite Object Library
Implement a Shadow OAM using @eievui5âs Sprite Object Library
GitHub URL: https://github.com/eievui5/gb-sprobj-lib
This is a small, lightweight library meant to facilitate the rendering of sprite objects, including Shadow OAM and OAM DMA, single-entry âsimpleâ sprite objects, and Q12.4 fixed-point position metasprite rendering.
Usage
The library is relatively simple to get set up. First, put the following in your initialization code:
; Initilize Sprite Object Library.
call InitSprObjLib
; Reset hardware OAM
xor a, a
ld b, 160
ld hl, _OAMRAM
.resetOAM
ld [hli], a
dec b
jr nz, .resetOAM
Then put a call to ResetShadowOAM
at the beginning of your main loop.
Finally, run the following code during VBlank:
ld a, HIGH(wShadowOAM)
call hOAMDMA
Manipulate Shadow OAM OAM sprites
Once youâve set up @eievui5âs Sprite Object Library, you can manipulate shadow OAM sprites the exact same way you would manipulate normal hardware OAM sprites. Except, this time you would use the libraryâs wShadowOAM
constant instead of the _OAMRAM
register.
Moving (the first) OAM sprite, one pixel downwards:
ld a,LOW(wShadowOAM)
ld l, a
ld a, HIGH(wShadowOAM)
ld h, a
ld a, [hl]
inc a
ld [wShadowOAM], a
Miscellaneous
Save Data
If you want to save data in your game, your gameâs header needs to specify the correct MBC/cartridge type, and it needs to have a non-zero SRAM size. This should be done in your makefile by passing special parameters to rgbfix.
- Use the
-m
or--mbc-type
parameters to set the mbc/cartidge type, 0x147, to a given value from 0 to 0xFF. More Info - Use the
-r
or--ram-size
parameters to set the RAM size, 0x149, to a given value from 0 to 0xFF. More Info.
To save data you need to store variables in Static RAM. This is done by creating a new SRAM âSECTIONâ. More Info
SECTION "SaveVariables", SRAM
wCurrentLevel:: db
To access SRAM, you need to write CART_SRAM_ENABLE
to the rRAMG
register. When done, you can disable SRAM using the CART_SRAM_DISABLE
constant.
To enable read/write access to SRAM:
ld a, CART_SRAM_ENABLE
ld [rRAMG], a
To disable read/write access to SRAM:
ld a, CART_SRAM_DISABLE
ld [rRAMG], a
Initiating Save Data
By default, save data for your game may or may not exist. When the save data does not exist, the value of the bytes dedicated for saving will be random.
You can dedicate a couple bytes towards creating a pseudo-checksum. When these bytes have a very specific value, you can be somewhat sure the save data has been initialized.
SECTION "SaveVariables", SRAM
wCurrentLevel:: db
wCheckSum1:: db
wCheckSum2:: db
wCheckSum3:: db
When initializing your save data, youâll need to
- enable SRAM access
- set your checksum bytes
- give your other variables default values
- disable SRAM access
;; Setup our save data
InitSaveData::
ld a, CART_SRAM_ENABLE
ld [rRAMG], a
ld a, 123
ld [wCheckSum1], a
ld a, 111
ld [wCheckSum2], a
ld a, 222
ld [wCheckSum3], a
ld a, 0
ld [wCurrentLevel], a
ld a, CART_SRAM_DISABLE
ld [rRAMG], a
ret
Once your save file has been initialized, you can access any variable normally once SRAM is enabled.
;; Setup our save data
StartNextLevel::
ld a, CART_SRAM_ENABLE
ld [rRAMG], a
ld a, [wCurrentLevel]
cp a, 3
call z, StartLevel3
ld a, CART_SRAM_DISABLE
ld [rRAMG], a
ret
Generate random numbers
Random number generation is a complex task in software. What you can implement is a âpseudorandomâ generator, giving you a very unpredictable sequence of values. Hereâs a rand
function (from Damian Yerrick) you can use.
SECTION "MathVariables", WRAM0
randstate:: ds 4
SECTION "Math", ROM0
;; From: https://github.com/pinobatch/libbet/blob/master/src/rand.z80#L34-L54
; Generates a pseudorandom 16-bit integer in BC
; using the LCG formula from cc65 rand():
; x[i + 1] = x[i] * 0x01010101 + 0xB3B3B3B3
; @return A=B=state bits 31-24 (which have the best entropy),
; C=state bits 23-16, HL trashed
rand::
; Add 0xB3 then multiply by 0x01010101
ld hl, randstate+0
ld a, [hl]
add a, $B3
ld [hl+], a
adc a, [hl]
ld [hl+], a
adc a, [hl]
ld [hl+], a
ld c, a
adc a, [hl]
ld [hl], a
ld b, a
ret
Risorse
Canali di aiuto
Altri tutorial
- evieâs interrupts tutorial should help you understand how to use interrupts, and what they are useful for.
- tbspâs âSimple GB ASM examplesâ is a collection of ROMs, each built from a single, fairly short source file. If you found this tutorial too abstract and/or want to get your feet wet, this is a good place to go to!
- GB assembly by example, Daidâs collection of code snippets. Consider this a continuation of the tutorial, but without explanations; itâs still useful to peruse them and ask about it, they are overall good quality.
Ringraziamenti
Vi è piaciuto il tutorial o uno dei precedenti? Quanto segue dovrebbe esservi utile per il resto del vostro viaggio!
- RGBDSâ online documentation is always useful! Notably, youâll find an instruction reference and the reference on RGBASMâs syntax and features.
- Pan Docs are the reference for all Game Boy hardware. Itâs a good idea to consult it if you aare unsure how a register works, or if youâre wondering how to do something.
- gb-optables is a more compact instruction table, it becomes more useful when you stop needing the instructionsâ descriptions.
Ringraziamenti speciali
Un grande ringraziamento a Twoflower/Triad per aver realizzato la grafica di Hello World.
Non ringrazierò mai abbastanza ChloÊ e molti altri per il loro continuo sostegno.
Grazie alla comunitĂ GBDev per essere stata cosĂŹ gentile nel corso degli anni.
Siete tutti fantastici. Grazie mille.
Si ringrazia il team di Rust language per aver realizzato mdBook, che alimenta questo libro (questo design, onestamente elegante, è quello di serie!!!).
Un saluto a AYCE, Phantasy, TPPDevs/RainbowDevs, Plutiedev, lft/kryo :)
Un saluto a Eievui, Rangi, MarkSixtyFour, ax6, BaĹto, bbbbbr e bitnenfer!
The Italian translation is curated by Antonio Guido Leoni, Antonio Vivace, Mattia Fortunati, Matilde Della Morte and Daniele Scasciafratte.