diff options
| author | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-03-11 16:39:44 +0100 |
|---|---|---|
| committer | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-03-11 16:39:44 +0100 |
| commit | f05ce5d5ff7d650a439b363993f8b38b817d3efe (patch) | |
| tree | 6d324e019650b1936d84e3c0935192e1fb7cbb72 /scroll | |
| parent | 4795fcad304087a9308e024a25233873c43501be (diff) | |
| download | code.nes-f05ce5d5ff7d650a439b363993f8b38b817d3efe.tar.gz code.nes-f05ce5d5ff7d650a439b363993f8b38b817d3efe.zip | |
scroll: Provide an example with sprite 0 collision
Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
Diffstat (limited to 'scroll')
| -rw-r--r-- | scroll/README.md | 62 | ||||
| -rw-r--r-- | scroll/include/all.s | 1 | ||||
| -rw-r--r-- | scroll/include/background.s | 17 | ||||
| -rw-r--r-- | scroll/level.s | 10 | ||||
| -rw-r--r-- | scroll/sprite0.s | 261 |
5 files changed, 328 insertions, 23 deletions
diff --git a/scroll/README.md b/scroll/README.md index 5bbfa9d..e29186a 100644 --- a/scroll/README.md +++ b/scroll/README.md @@ -1,15 +1,19 @@ ## A primer to scrolling Scrolling is a big topic and it's something that evolved with the NES hardware. -This set of examples try to cover it as much as possible while being +These set of examples try to cover it as much as possible while being approachable. But before diving into some more realistic examples, let's first try to understand the concept of scrolling in NES/Famicom programming. -The very basic concepts of scrolling can be seen in `toggle.s`, which gives you -this as a result: +The very basic concepts of scrolling can be seen in [toggle.s](./toggle.s), +which gives you this as a result: TBD +That is, we only have filled the two nametable available, and we are modifying +the [PPU scroll register](https://www.nesdev.org/wiki/PPU_registers#PPUSCROLL) +to move between one or the other. + This looks rather simplistic but some games used this technique. For example, in Dropzone it was used to perform some effects on the title screen. Hence, performing a simple scroll between two nametables is not just for learning @@ -18,9 +22,9 @@ purposes, it was also used in real life games. ## Scrolling multiple screens to the right With the basics covered, now let's see how a game can scroll past two screens -worth of data. This is delivered on the `level.s` example, and pressing "Select" -allows you to toggle between different "levels". This gives you the following -results: +worth of data. This is delivered on the [level.s](./level.s) example, and +pressing "Select" allows you to toggle between different "levels". This gives +you the following results: <div align="center"> <img src="../docs/level.gif" alt="level.gif" /> @@ -32,26 +36,46 @@ up to 16x16 pixel blocks. These blocks are the ones being continuously loaded when the player moves, and they are the ones being considered for collision checks. This is all better explained and with all the gory details inside of the [./include](./include) directory, which is somewhat of a library for the rest of -the scrolling examples. The concepts at display here and more complex than they -look, so take your time. Also note that different games had different ways on -how to handle metatiles, so don't go out from these examples thinking "oh, so -this is how *all* games mapped things on screen!". This is just one way to do -so, every game came with its own engine with its own quirks. Consider, for -example, how Megaman games had "meta-metatiles" (a concept also used in modern -games like [Micro Mages](https://youtu.be/ZWQ0591PAxM?si=kE69LfgpaW6t-Sr3)). +the scrolling examples. The concepts at display here are more complex than they +look, so take your time. + +Also note that different games had different ways on how to handle metatiles, so +don't go out from these examples thinking "oh, so this is how *all* games mapped +things on screen!". This is just one way to do so, every game came with its own +engine and with its own quirks. Consider, for example, how Megaman games had +"meta-metatiles" (a concept also used in modern games like [Micro +Mages](https://youtu.be/ZWQ0591PAxM?si=kE69LfgpaW6t-Sr3)). -Last but not least, bear in mind that this "engine" comes with some big -limitations, like the inability to scroll to the left. +Last but not least, bear in mind that this "[engine](./include)" comes with some +big limitations, like the inability to scroll to the left. ## Detecting collision on sprite 0 -Another limitation from the `level.s` example is how *everything* scrolls. This +Another limitation from the `level.s` example is that *everything* scrolls. This would be a bummer for most games from the era since they would've wanted to -reserve some space on screen to show a "status" bar: how many lifes you have, -score, etc. +reserve some space on screen to show the HUD: a section at the top of the screen +where the game shows how many lifes you have, score, etc. In games like Super Marios Bros. or Punch-out, this was achieved thanks to the -"sprite 0 hit" detection. TBD +"sprite 0 hit" detection, which was a special feature from the PPU in which it +would flip a bit on the [PPU status +register](https://www.nesdev.org/wiki/PPU_registers#PPUSTATUS) whenever a +background element was found to collide with the first sprite in OAM. That being +said, both the sprite and the background element need to be opaque (that is, not +using the first color from the palette), and there shouldn't be in a special +scenario like the PPU being disabled or the sprite being on a hidden margin. + +Because all of this, both Super Mario Bros. and Punch-out (and many other +games), hid the first sprite to have the same color as the last background +element being displayed from the HUD. This way, the sprite was not apparent to +the player but the PPU would detect it anyways. This has also been done on +[sprite0.s](./sprite0.s), which uses the same engine as `level.s`, but this time +the code on `nmi` has been modified to watch out for sprite 0 collision. This +gives us this result: + +<div align="center"> + <img src="../docs/sprite0.gif" alt="sprite0.gif" /> +</div> ## Bringing the status bar down below diff --git a/scroll/include/all.s b/scroll/include/all.s index e9f059d..763feb4 100644 --- a/scroll/include/all.s +++ b/scroll/include/all.s @@ -6,6 +6,7 @@ .include "ppu.s" .include "../../shared/asm.s" .include "../../shared/joypad.s" +.include "../../shared/ppu.s" .include "reset.s" .include "palettes.s" diff --git a/scroll/include/background.s b/scroll/include/background.s index f392792..69725fd 100644 --- a/scroll/include/background.s +++ b/scroll/include/background.s @@ -27,6 +27,15 @@ DISABLED_BACKGROUND_CYCLE = $FE BACKGROUND_CYCLE_MAX = $02 + ;; The offset for metatile rows. That is, from where should the engine start + ;; counting rows of metatiles. By default it's 0, but it could be set to + ;; something else to reserve so top space for a HUD or something similar. + ;; + ;; NOTE: configurable. + .ifndef BACKGROUND_ROW_OFFSET + BACKGROUND_ROW_OFFSET = 0 + .endif + ;; Current column (or the next to be loaded by functions like ;; `load_column`). zp_cur_column = $75 @@ -51,9 +60,11 @@ lda #$00 sta zp_scroll sta Buffer::zp_vram_idx - sta zp_cur_row sta zp_cur_column + lda #BACKGROUND_ROW_OFFSET + sta zp_cur_row + rts .endproc @@ -112,7 +123,7 @@ ;; Increase the column being used and loop if we are not at the end of ;; the screen yet. - lda #0 + lda #BACKGROUND_ROW_OFFSET sta zp_cur_row inc zp_cur_column lda zp_cur_column @@ -418,7 +429,7 @@ ;; screen/level by poking the right flags. .proc prepare_next_column ;; The row index always has to be set to zero. - lda #0 + lda #BACKGROUND_ROW_OFFSET sta zp_cur_row ;; Is this the last column? If not, just quit. diff --git a/scroll/level.s b/scroll/level.s index 3bca454..de386db 100644 --- a/scroll/level.s +++ b/scroll/level.s @@ -1,3 +1,13 @@ +;;; +;; Allow the player to scroll a level which spans more than two screens wide. +;; The heavy lifting is pulled by the engine contained in `include`, which even +;; if it has some big limitations, it's good enough for showing how this can be +;; achieved on the NES/Famicom. Read the comments along this file, but you will +;; have to dig deeper into `include` to better grasp how any of this works. +;; +;; As a final touch, you can press "Select" to switch between different levels, +;; even if I was lazy enough to only provide a second level. + .segment "HEADER" .byte 'N', 'E', 'S', $1A diff --git a/scroll/sprite0.s b/scroll/sprite0.s index 215a491..4bd940d 100644 --- a/scroll/sprite0.s +++ b/scroll/sprite0.s @@ -1 +1,260 @@ -;; TODO: Through sprite0 hit +;;; +;; Show like `level.s` but at the very top of the screen we have a "This is a +;; message" being shown. This message is part of the background but it does not +;; scroll like the rest of the screen, but it stays at the same coordinates all +;; the time. This is done through sprite 0 collision detection, which is a +;; technique is quite often for early games on the NES/Famicom library (e.g. +;; Super Mario Bros.). +;; +;; The code is really similar to what we had in `level.s`, so I have removed all +;; comments from sections that are identical to those of `level.s`. That is, you +;; can just read the comments to get a "diff" between this one and `level.s`. + +.segment "HEADER" + .byte 'N', 'E', 'S', $1A + + .byte $02 + .byte $01 + + .byte $01 + .byte $00 + +.segment "VECTORS" + .addr nmi, reset, irq + +.segment "CODE" + +;; We include the engine as in `level.s`, but we tweak the row offset so we have +;; at least one empty row in order to fit the HUD from this example. +BACKGROUND_ROW_OFFSET = 1 +.include "include/all.s" + +;; Clear out the first two rows of tiles from the nametable identified by the +;; `x` register. +.proc clear_row_x + bit PPU::STATUS + + stx PPU::ADDRESS + lda #$00 + sta PPU::ADDRESS + + ldx #$40 +@loop: + sta PPU::DATA + dex + bne @loop + + rts +.endproc + +;; Show our awesome HUD, which simply shows a background message with "This is a +;; message". Note that the first sprite on OAM will be initialized also here, +;; which if you look into the CHR file you will realize it's merely two dots put +;; together. The trick is to put this simple sprite right into the final "E" of +;; "message", so it's hidden there. Whenever the PPU detects the collision (i.e. +;; "sprite 0 collision"), we will be able to react accordingly. +;; +;; Note also that because of the limitations from the engine in `include` on not +;; being able to have multiple palettes, it really stands out. This is actually +;; useful on this example, but in a real game you'd want to dedicate a palette +;; which matches the same color from the background one. That is, you'd really +;; want to hide it. +.proc show_hud + ;; Clear the first two rows of tiles for both nametables as this will be + ;; where we will place the HUD. + ldx #$20 + jsr clear_row_x + ldx #$24 + jsr clear_row_x + + ;; Set the background for the HUD. Note that we need it in both nametables, + ;; as the engine will actually flip the nametable being used on the + ;; PPU::CONTROL register. + + ;; This + WRITE_PPU_DATA $2028, $23 + WRITE_PPU_DATA $2029, $17 + WRITE_PPU_DATA $202A, $18 + WRITE_PPU_DATA $202B, $22 + WRITE_PPU_DATA $2428, $23 + WRITE_PPU_DATA $2429, $17 + WRITE_PPU_DATA $242A, $18 + WRITE_PPU_DATA $242B, $22 + + ;; is + WRITE_PPU_DATA $202D, $18 + WRITE_PPU_DATA $202E, $22 + WRITE_PPU_DATA $242D, $18 + WRITE_PPU_DATA $242E, $22 + + ;; a + WRITE_PPU_DATA $2030, $10 + WRITE_PPU_DATA $2430, $10 + + ;; message + WRITE_PPU_DATA $2032, $1C + WRITE_PPU_DATA $2033, $14 + WRITE_PPU_DATA $2034, $22 + WRITE_PPU_DATA $2035, $22 + WRITE_PPU_DATA $2036, $10 + WRITE_PPU_DATA $2037, $16 + WRITE_PPU_DATA $2038, $14 + WRITE_PPU_DATA $2432, $1C + WRITE_PPU_DATA $2433, $14 + WRITE_PPU_DATA $2434, $22 + WRITE_PPU_DATA $2435, $22 + WRITE_PPU_DATA $2436, $10 + WRITE_PPU_DATA $2437, $16 + WRITE_PPU_DATA $2438, $14 + + ;; Initialize sprite 0, which won't change across the run. + lda #$06 + sta $200 + lda #$03 + sta $201 + lda #$00 + sta $202 + lda #$C0 + sta $203 + + rts +.endproc + +;; From level.s the only thing changed is the call to `show_hud`. +.proc main + lda #$00 + sta PPU::zp_mask + sta PPU::MASK + + jsr Palettes::init + jsr Metatile::init + jsr Driver::init + jsr Background::init + jsr Player::init + + ;; Show HUD. + jsr show_hud + + cli + + lda #%10001000 + sta PPU::zp_control + sta PPU::CONTROL + +@main_game_loop: + jsr joypad_read + jsr Player::update + jsr Driver::update + + lda #%10000000 + ora Globals::zp_flags + sta Globals::zp_flags + +@wait_for_render: + bit Globals::zp_flags + bmi @wait_for_render + + jmp @main_game_loop +.endproc + +;; It's mostly as in `level.s`, but: 1. the scroll register is always set; 2. we +;; have to handle sprite 0 collision. +.proc nmi + bit Globals::zp_flags + bpl @next + + pha + txa + pha + tya + pha + + jsr Player::update_sprite + + OAM_WRITE_SPRITES + + FLUSH_PENDING_VRAM_BUFFER + + ;; Should we update PPU registers? If not, then we can go down to the next + ;; section. Otherwise we need to update the PPU registers that have been + ;; buffered. Note that in stark difference with `level.s` the scroll + ;; register is not included inside of the code below. That's because it has + ;; to be set unconditionally or the whole sprite 0 detection trick would + ;; flicker whenever the player stops scrolling the screen. + bit Globals::zp_flags + bvc @scroll + + lda #%10111111 + and Globals::zp_flags + sta Globals::zp_flags + + bit PPU::STATUS + + lda PPU::zp_control + sta PPU::CONTROL + lda PPU::zp_mask + sta PPU::MASK + +@scroll: + ;; Is the level actually loaded? If not then it's pointless to mess with the + ;; scroll register or waiting for anything: skip all of this. + lda Globals::zp_flags + and #%00100000 + beq @unset_render_flag + + ;; First of all, reset the scroll register to zero for the HUD. + bit PPU::STATUS + lda #$00 + sta PPU::SCROLL + lda #$00 + sta PPU::SCROLL + + ;; To be safe, wait until the sprite 0 bit is unset. This will happen + ;; whenever the PPU starts rendering the screen. In other words, we wait + ;; until VBlank has been consumed because this whole trick has to happen + ;; mid-frame rendering. +@wait_sprite0_unset: + bit PPU::STATUS + bvs @wait_sprite0_unset + + ;; And now everything will be rendered as usual, with scroll = 0. So wait + ;; until the PPU detects a collision between a background element and sprite + ;; 0. This will happen at the last point of the "E" in "MESSAGE", where our + ;; sprite 0 has been "hidden". Whenever that happens, the PPU will set the + ;; proper bit on the PPU::STATUS register. +@wait_sprite0_set: + bit PPU::STATUS + bvc @wait_sprite0_set + + ;; NOTE: after this some games like Super Mario Bros. set up a small delay, + ;; but through testing both on emulators (FCEUX and Mesen) and on real + ;; hardware, I haven't seen any problems without this delay. + + ;; Update the scroll register again to its real value. + lda Background::zp_scroll + sta PPU::SCROLL + lda #$00 + sta PPU::SCROLL + + ;; And continue as in `level.s`. + +@unset_render_flag: + lda #%01111111 + and Globals::zp_flags + sta Globals::zp_flags + + pla + tay + pla + tax + pla +@next: + rti +.endproc + +.proc irq + rti +.endproc + +.segment "CHARS" + .incbin "../assets/diskun.chr" |
