; Grid Collision Example for the Nintendo Game Boy
; by Dave VanEe 2022
; Tested with RGBDS 1.0.0
; License: CC0 (https://creativecommons.org/publicdomain/zero/1.0/)
; This example builds on the following examples:
; - background-tilemap (except using a manually constructed tilemap)
; - vblank/oamdma/sprite
; - joypad
; The main additions in this example are moving a player in response to input (ProcessInput), and checking the tilemap
; entries to determine if attempted moves are into valid spaces based on tile ID (GetTileID). Note that instead of using an
; automatically generated tilemap as in the background-filemap example, the tilemap is pre-constructed with tiles organized
; such that walkable tiles start at 0 and are sequential to simplify the collision check.
include "hardware.inc" ; Include hardware definitions so we can use nice names for things
;============================================================================================================================
; Game Constants
;============================================================================================================================
def MAX_WALKABLE_TILE_ID equ 8 ; All tiles from 0 to this tile ID will be considered walkable for the purposes of collision
def OBJ_Y_OFFSET equ -9 ; Since we're using two objects to draw the player larger than 8x8, but we're still moving
def OBJ_X_OFFSET equ -4 ; on an 8x8 grid, we offset things slightly to center the player on the current tile
rsreset ; Reset the _RS counter to 0 for a new set of defines
def FACE_LEFT rb 1 ; Define FACE_LEFT as 0
def FACE_RIGHT rb 1 ; Define FACE_RIGHT as 1
def FACE_UP rb 1 ; Define FACE_UP as 2
def FACE_DOWN rb 1 ; Define FACE_DOWN as 3
;============================================================================================================================
; Game State Variables
;============================================================================================================================
SECTION "Game State Variables", WRAM0
wPlayer:
.y ds 1 ; Player's Y coordinate (in grid space)
.x ds 1 ; Player's X coordinate (in grid space)
.facing ds 1 ; Player's facing direction (0=left, 1=right, 2=up, 3=down)
;============================================================================================================================
; Interrupts
;============================================================================================================================
; The VBlank vector is where execution is passed when the VBlank interrupt fires
SECTION "VBlank Vector", ROM0[$40]
; We only have 8 bytes here, so push all the registers to the stack and jump to the rest of the handler
; Note: Since the VBlank handler used here only affects A and F, we don't have to push/pop BC, DE, and HL,
; but it's done here for demonstration purposes.
VBlank:
push af ; Push AF to the stack
ld a, HIGH(wShadowOAM) ; Load the high byte of our Shadow OAM buffer into A
jp VBlankHandler ; Jump to the rest of the handler
; The rest of the handler is contained in ROM0 to ensure it's always accessible without banking
SECTION "VBlank Handler", ROM0
VBlankHandler:
call hOAMDMA ; Call our OAM DMA routine (in HRAM), quickly copying from wShadowOAM to OAMRAM
pop af ; Pop AF off the stack
reti ; Return and enable interrupts (ret + ei)
;============================================================================================================================
; Initialization
;============================================================================================================================
; Define a section that starts at the point the bootrom execution ends
SECTION "Start", ROM0[$0100]
jp EntryPoint ; Jump past the header space to our actual code
ds $150-@, 0 ; Allocate space for RGBFIX to insert our ROM header by allocating
; the number of bytes from our current location (@) to the end of the
; header ($150)
EntryPoint:
di ; Disable interrupts as we won't be using them
ld sp, $e000 ; Set the stack pointer to the end of WRAM
; Turn off the LCD when it's safe to do so (during VBlank)
.waitVBlank
ldh a, [rLY] ; Read the LY register to check the current scanline
cp SCREEN_HEIGHT_PX ; Compare the current scanline to the first scanline of VBlank
jr c, .waitVBlank ; Loop as long as the carry flag is set
xor a ; Once we exit the loop we're safely in VBlank
ldh [rLCDC], a ; Disable the LCD (must be done during VBlank to protect the LCD)
; Copy the OAMDMA routine to HRAM, since during DMA we're limited on which
; memory the CPU can access (but HRAM is safe)
ld hl, OAMDMA ; Load the source address of our routine into HL
ld b, OAMDMA.end - OAMDMA ; Load the length of the OAMDMA routine into B
ld c, LOW(hOAMDMA) ; Load the low byte of the destination into C
.oamdmaCopyLoop
ld a, [hli] ; Load a byte from the address HL points to into the register A, increment HL
ldh [c], a ; Load the byte in the A register to the address in HRAM with the low byte stored in C
inc c ; Increment the low byte of the HRAM pointer in C
dec b ; Decrement the loop counter in B
jr nz, .oamdmaCopyLoop ; If B isn't zero, continue looping
; Copy our sprite and background tiles to VRAM
ld hl, SpriteTileData ; Load the source address of our tiles into HL
ld de, STARTOF(VRAM) ; Load the destination address in VRAM into DE
ld bc, SpriteTileData.end - SpriteTileData ; Load the number of bytes to copy into BC
call MemCopy ; Call our general-purpose memory copy routine
ld hl, BackgroundTileData ; Load the source address of our tiles into HL
ld de, STARTOF(VRAM)+$1000 ; Load the destination address in VRAM into DE
ld bc, BackgroundTileData.end - BackgroundTileData ; Load the number of bytes to copy into BC
call MemCopy ; Call our general-purpose memory copy routine
; Copy our 20x18 tilemap to VRAM
ld de, TilemapData ; Load the source address of our tilemap into DE
ld hl, TILEMAP0 ; Point HL to the first byte of the tilemap ($9800)
ld b, SCREEN_HEIGHT ; Load the height of the screen in tiles into B (18 tiles)
.tilemapLoop
ld c, SCREEN_WIDTH ; Load the width of the screen in tiles into C (20 tiles)
.rowLoop
ld a, [de] ; Load a byte from the address DE points to into the A register
ld [hli], a ; Load the byte in the A register to the address HL points to and increment HL
inc de ; Increment the source pointer in DE
dec c ; Decrement the loop counter in C (tiles per row)
jr nz, .rowLoop ; If C isn't zero, continue copying bytes for this row
push de ; Push the contents of the register pair DE to the stack
ld de, TILEMAP_WIDTH - SCREEN_WIDTH ; Load the number of tiles remaining in the row into DE
add hl, de ; Add the remaining row length to HL, advancing the destination pointer to the next row
pop de ; Recover the former contents of the the register pair DE
dec b ; Decrement the loop counter in B (total rows)
jr nz, .tilemapLoop ; If B isn't zero, continue copying rows
; Setup palettes and scrolling
ld a, %11100100 ; Define a 4-shade palette from darkest (11) to lightest (00)
ldh [rBGP], a ; Set the background palette
ld a, %11010000 ; Define a 4-shade palette which omits the 10 value to increase player contrast
ldh [rOBP0], a ; Set an object palette
xor a ; Set A to zero
ldh [rSCX], a ; Set the background scroll registers to show the top-left
ldh [rSCY], a ; corner of the background in the top-left corner of the screen
ldh [hCurrentKeys], a ; Zero our current keys just to be safe (A is already zero from earlier)
; Initialize shadow OAM to zero
ld hl, wShadowOAM ; Point HL to the start of shadow OAM
ld b, wShadowOAM.end - wShadowOAM ; Load the size of shadow OAM into B (it's less than 256 so we can use a single byte)
.clearOAM
ld [hli], a ; Zero this OAM byte
dec b ; Decrement the loop counter in B (bytes of OAM)
jr nz, .clearOAM ; If B isn't zero, continue zeroing bytes
; Perform OAM DMA once to ensure OAM doesn't contain garbage
ld a, HIGH(wShadowOAM) ; Load the high byte of our Shadow OAM buffer into A
call hOAMDMA ; Call our OAM DMA routine (in HRAM), quickly copying from wShadowOAM to OAMRAM
; Setup the world state
ld hl, wPlayer ; Point HL to the start of the player's state in WRAM
ld a, 4 ; Load the starting Y coordinate into A
ld [hli], a ; Set the starting wPlayer.y value in WRAM
ld a, 2 ; Load the starting X coordinate into A
ld [hli], a ; Set the starting wPlayer.x value in WRAM
ld a, FACE_DOWN ; Load the starting facing direction into A
ld [hli], a ; Set the starting wPlayer.facing value in WRAM
; Setup the VBlank interrupt
ld a, IE_VBLANK ; Load the flag to enable the VBlank interrupt into A
ldh [rIE], a ; Load the prepared flag into the interrupt enable register
xor a ; Set A to zero
ldh [rIF], a ; Clear any lingering flags from the interrupt flag register to avoid false interrupts
ei ; enable interrupts!
; Combine flag constants defined in hardware.inc into a single value with logical ORs and load it into A
ld a, LCDC_ON | LCDC_BLOCK21 | LCDC_BG_ON | LCDC_OBJ_16 | LCDC_OBJ_ON | LCDC_WIN_OFF
ldh [rLCDC], a ; Enable and configure the LCD to show the background and objects
;============================================================================================================================
; Main Loop
;============================================================================================================================
LoopForever:
halt ; Halt the CPU, waiting until an interrupt fires (this will sync our loop with VBlank)
call UpdateJoypad ; Poll the joypad and store the state in HRAM
call ProcessInput ; Update the game state in response to user input
call PopulateShadowOAM ; Update the sprite locations for the next frame
jr LoopForever ; Loop forever
;============================================================================================================================
; Main Routines
;============================================================================================================================
SECTION "Main Routines", ROMX
; Process the user's inputs and update the game state accordingly
ProcessInput:
ldh a, [hNewKeys] ; Load the newly pressed keys byte into A
bit B_PAD_LEFT, a ; Check the state of the LEFT bit in A
ld bc, $00ff ; Preload B/C with dy/dx for left movement (0, -1)
ld d, FACE_LEFT ; Preload D with the facing value for LEFT
jr nz, .attemptMove ; If the bit was set, jump to attempt movement in that direction
bit B_PAD_RIGHT, a ; Check the state of the RIGHT bit in A
ld bc, $0001 ; Preload B/C with dy/dx for left movement (0, +1)
ld d, FACE_RIGHT ; Preload D with the facing value for RIGHT
jr nz, .attemptMove ; If the bit was set, jump to attempt movement in that direction
bit B_PAD_UP, a ; Check the state of the UP bit in A
ld bc, $ff00 ; Preload B/C with dy/dx for left movement (-1, 0)
ld d, FACE_UP ; Preload D with the facing value for UP
jr nz, .attemptMove ; If the bit was set, jump to attempt movement in that direction
bit B_PAD_DOWN, a ; Check the state of the DOWN bit in A
ld bc, $0100 ; Preload B/C with dy/dx for left movement (+1, 0)
ld d, FACE_DOWN ; Preload D with the facing value for DOWN
jr nz, .attemptMove ; If the bit was set, jump to attempt movement in that direction
ret ; No inputs to handle, return to main loop
; Attempt a move in a direction defined by the contents of BC and D
; @param: B Delta Y to apply to current player position
; @param: C Delta X to apply to current player position
; @param: D New facing direction value to apply
.attemptMove
ld a, d ; Move new facing direction from D to A
ld [wPlayer.facing], a ; Store new facing direction regardless of move success
; Calculate the destination coordinates by applying the deltas
ld a, [wPlayer.y] ; Load the current player Y coordinate into A
add b ; Add the dY value from B to get the new Y coordinate
ld b, a ; Store the new Y coordinate back in B
ld a, [wPlayer.x] ; Load the current player X coordinate into A
add c ; Add the dX value from C to get the new X coordinate
ld c, a ; Store the new Y coordinate back in C
; Check if the attempted move is valid
call GetTileID ; Call a routine to get the tile ID at the B=y, C=x coordinates
cp MAX_WALKABLE_TILE_ID ; Compare the tile ID from TilemapData to the maximum walkable tile ID
ret nc ; If the tile ID is greater than the maximum walkable tile ID, return
; Store the new coordinates
ld a, b ; Load the new Y coordinate into A
ld [wPlayer.y], a ; Store the new Y coordinate in memory
ld a, c ; Load the new X coordinate into A
ld [wPlayer.x], a ; Store the new X coordinate in memory
ret
; Return the tile ID in TilemapData at provided coordinates
; @param B: Y coordinate in tilemap
; @param C: X coordinate in tilemap
; @return A: Tile ID at coordinates given
GetTileID:
push bc ; Store the input coordinates on the stack
ld hl, TilemapData ; Load the start address of the TilemapData into HL
ld a, b ; Load the Y coordinate into A
or a ; Check if the Y coordinate is zero
jr z, .yZero ; If zero, skip the Y seeking code
ld de, SCREEN_WIDTH ; Load the number of tiles per row of TilemapData into DE
.yLoop
add hl, de ; Add the number of tiles per row to the pointer in HL
dec b ; Decrease the loop counter in B
jr nz, .yLoop ; Loop until we've offset to the correct row
.yZero
ld a, c ; Load the X coordinate into A
; Add the X coordinate offset to HL (this is a common way to add A to a 16-bit register)
add l ; Add the X coordinate to the low byte of the pointer in HL
ld l, a ; Store the new low byte of the pointer in L
adc h ; Add H plus the carry flag to the contents of A
sub l ; Subtract the contents of L from A
ld h, a ; Store the new high byte of the pointer in H
ld a, [hl] ; Read the value of TilemapData at the coordinates of interest into A
pop bc ; Recover the original input coordinates from the stack
ret
; Populate ShadowOAM with sprites based on the game state
PopulateShadowOAM:
ld hl, wShadowOAM ; Point HL at the beginning of wShadowOAM
; First sprite
ld a, [wPlayer.y] ; Load the player's Y coordinate into A
add a ; To convert the Y grid coordinate into screen coordinates we have to multiply
add a ; by 8, which can be done quickly by adding A to itself 3 times
add a ; ...
add $10+OBJ_Y_OFFSET ; Add the sprite offset ($10), plus the centering offset
ld [hli], a ; Store the sprite's Y coordinate in shadow OAM
ld b, a ; Cache the Y coordinate in B for use by the second sprite
ld a, [wPlayer.x] ; Load the player's X coordinate into A
add a ; Multiply the X coordinate by 8 the same as we did for Y above
add a ; ...
add a ; ...
add $08+OBJ_X_OFFSET ; Add the sprite offset ($08), plus the centering offset
ld [hli], a ; Store the sprite's X coordinate in shadow OAM
add $08 ; Add 8 to the X coordinate for the second sprite
ld c, a ; Cache the X coordinate in C for use by the second sprite
ld a, [wPlayer.facing] ; Load the player's facing direction into A
add a ; The player tiles have been stored in VRAM such that the facing direction multiplied
add a ; by 4 will yield the tile ID for the first sprite, so multiply by 4 using adds
ld [hli], a ; Store the sprite's tile ID in shadow OAM
add 2 ; Add 2 to the tile ID for the second sprite
ld d, a ; Cache the tile ID in D for use by the second sprite
xor a ; Set A to zero
ld [hli], a ; Store the sprite's attributes in shadow OAM
; Second sprite
ld a, b ; Load the prepared Y coordinate from B to A
ld [hli], a ; Store the sprite's Y coordinate in shadow OAM
ld a, c ; Load the prepared X coordinate from C to A
ld [hli], a ; Store the sprite's X coordinate in shadow OAM
ld a, d ; Load the prepared tile ID from D to A
ld [hli], a ; Store the sprite's tile ID in shadow OAM
xor a ; Set A to zero
ld [hli], a ; Store the sprite's attributes in shadow OAM
; Zero the remaning shadow OAM entries
; Note: Since we're only using 2/40 sprites, we could just loop 38 times, but the following approach will scale better if
; additional sprites are added. This will also clear previously used entires in cases where the number of sprites used
; each frame varies (which isn't the case here).
ld b, a ; Load zero (from the prior use) into B, since A will be used to check loop completion
.clearOAM
ld [hl], b ; Set the Y coordinate of this OAM entry to zero to hide it
ld a, l ; Load the low byte of the shadow OAM pointer into A
add OBJ_SIZE ; Advance 4 bytes to the next OAM entry
ld l, a ; Store the new low byte of the pointer in L
cp LOW(wShadowOAM.end) ; Compare the low byte to the end of wShadowoAM
jr nz, .clearOAM ; Loop until we've hidden every unused sprite
ret
;============================================================================================================================
; Utility Routines
;============================================================================================================================
SECTION "MemCopy Routine", ROM0
; Since we're copying data few times, we'll define a reusable memory copy routine
; Copy BC bytes of data from HL to DE
; @param HL: Source address to copy from
; @param DE: Destination address to copy to
; @param BC: Number of bytes to copy
MemCopy:
ld a, [hli] ; Load a byte from the address HL points to into the register A, increment HL
ld [de], a ; Load the byte in the A register to the address DE points to
inc de ; Increment the destination pointer in DE
dec bc ; Decrement the loop counter in BC
ld a, b ; Load the value in B into A
or c ; Logical OR the value in A (from B) with C
jr nz, MemCopy ; If B and C are both zero, OR B will be zero, otherwise keep looping
ret ; Return back to where the routine was called from
;============================================================================================================================
; Joypad Handling
;============================================================================================================================
SECTION "Joypad Variables", HRAM
; Reserve space in HRAM to track the joypad state
hCurrentKeys: ds 1
hNewKeys: ds 1
SECTION "Joypad Routine", ROM0
; Update the newly pressed keys (hNewKeys) and the held keys (hCurrentKeys) in memory
; Note: This routine is written to be easier to understand, not to be optimized for speed or size
UpdateJoypad:
; Poll half the controller
ld a, JOYP_GET_BUTTONS ; Load a flag into A to select reading the buttons
ldh [rP1], a ; Write the flag to P1 to select which buttons to read
ldh a, [rP1] ; Perform a few dummy reads to allow the inputs to stabilize
ldh a, [rP1] ; ...
ldh a, [rP1] ; ...
ldh a, [rP1] ; ...
ldh a, [rP1] ; ...
ldh a, [rP1] ; The final read of the register contains the key state we'll use
or $f0 ; Set the upper 4 bits, and leave the action button states in the lower 4 bits
ld b, a ; Store the state of the action buttons in B
ld a, JOYP_GET_CTRL_PAD ; Load a flag into A to select reading the dpad
ldh [rP1], a ; Write the flag to P1 to select which buttons to read
call .knownRet ; Call a known `ret` instruction to give the inputs to stabilize
ldh a, [rP1] ; Perform a few dummy reads to allow the inputs to stabilize
ldh a, [rP1] ; ...
ldh a, [rP1] ; ...
ldh a, [rP1] ; ...
ldh a, [rP1] ; ...
ldh a, [rP1] ; The final read of the register contains the key state we'll use
or $f0 ; Set the upper 4 bits, and leave the dpad state in the lower 4 bits
swap a ; Swap the high/low nibbles, putting the dpad state in the high nibble
xor b ; A now contains the pressed action buttons and dpad directions
ld b, a ; Move the key states to B
ld a, JOYP_GET_NONE ; Load a flag into A to read nothing
ldh [rP1], a ; Write the flag to P1 to disable button reading
ldh a, [hCurrentKeys] ; Load the previous button+dpad state from HRAM
xor b ; A now contains the keys that changed state
and b ; A now contains keys that were just pressed
ldh [hNewKeys], a ; Store the newly pressed keys in HRAM
ld a, b ; Move the current key state back to A
ldh [hCurrentKeys], a ; Store the current key state in HRAM
.knownRet
ret
;============================================================================================================================
; OAM Handling
;============================================================================================================================
SECTION "Shadow OAM", WRAM0, ALIGN[8]
; Reserve page-aligned space for a Shadow OAM buffer, to which we can safely write OAM data at any time,
; and then use our OAM DMA routine to copy it quickly to OAMRAM when desired. OAM DMA can only operate
; on a block of data that starts at a page boundary, which is why we use ALIGN[8].
wShadowOAM:
ds OAM_SIZE
.end
SECTION "OAM DMA Routine", ROMX
; Initiate OAM DMA and then wait until the operation is complete, then return
; @param A High byte of the source data to DMA to OAM
OAMDMA:
ldh [rDMA], a
ld a, OAM_COUNT
.waitLoop
dec a
jr nz, .waitLoop
ret
.end
SECTION "OAM DMA", HRAM
; Reserve space in HRAM for the OAMDMA routine, equal in length to the routine
hOAMDMA:
ds OAMDMA.end - OAMDMA
;============================================================================================================================
; Tile/Tilemap Data
;============================================================================================================================
SECTION "Tile/Tilemap Data", ROMX
; Obj tiles based on "Micro Character Bases" by Kacper Woźniak (https://thkaspar.itch.io/micro-character-bases)
; Licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
; Skeleton tiles adjusted to 3-shade, and additional facing directions created based on the original art
SpriteTileData:
incbin "grid-collision-obj-ztiles.2bpp"
.end
; BG tiles based on "Dungeon Package" tileset by nyk-nck (https://nyknck.itch.io/dungeonpack)
; License for original assets not clearly specified, but not CC0. Attribution/link included here for completness.
BackgroundTileData:
incbin "grid-collision-bg-tiles.2bpp" ; Include binary tile data inline using incbin
.end ; The .end label is used to let the assembler calculate the length of the data
TilemapData:
incbin "grid-collision.tilemap" ; Include tilemap built using Tilemap Studio and the grid-collision-bg-tiles tileset