mirror of
https://github.com/imjasonh/nescript
synced 2026-07-07 00:22:50 +00:00
platformer: stabilise sprite-0 HUD by splitting below the HUD row
The previous commit parked the sprite-0 hit anchor on top of the visible HUD coin at NT col 2, row 1 — the hit therefore fired at dot 19 of scanline 15 (the HUD's last scanline) and jsnes's PPU model applied the scroll change to the rest of that same scanline, smearing the bottom row of every HUD glyph as `camera_x` drifted. The score "0"/"2" digit, the heart, and the lives "3" each flickered a handful of different pixel patterns per frame as the scroll shifted. An in-harness check for unique HUD states across the 180-frame window saw ~20-40 distinct states. Fix: move both anchors off the HUD row entirely. - `TILE_SPRITE0_ANCHOR` still has its single opaque pixel at row 7, col 3, but now draws at OAM `(248, 8)` so the pixel lands at screen `(251, 16)` — the first scanline of the playfield. - New `TILE_BG_ANCHOR` (tile 28) mirrors it with a single opaque pixel at row 0, col 3; the map pre-paints it at NT `(col 31, row 2)` via the new `a` legend entry. Its one opaque pixel lands at the same `(251, 16)`, so the PPU's sprite-0 hit fires there instead. - An explicit `scroll(0, 0)` right before `sprite_0_split` defensively re-latches scroll to zero in case jsnes has carried stale `$2005` state over from the previous frame. With the hit on scanline 16, `$2005` writes in HBLANK of scanline 16 (or thereabouts) only affect scanline 17 onward; rows 8-15 all render at scroll=0 and the HUD glyphs stay pixel-stable. The unique-state count across the 180-frame harness drops from ~40 to 4 — and those 4 correspond to the legitimate score / lives transitions (initial title, post- stomp-1, post-stomp-2, etc.), not per-frame jitter. Column 31 and OAM x=248 both sit inside jsnes's right-edge overscan so the anchor pixels are invisible in the committed golden. Goldens + gif refreshed.
This commit is contained in:
parent
c5b04c2065
commit
615490fbe5
6 changed files with 93 additions and 42 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 456 KiB After Width: | Height: | Size: 456 KiB |
|
|
@ -317,10 +317,12 @@ sprite Tileset {
|
|||
"..aaaa..",
|
||||
"...aa...",
|
||||
"........",
|
||||
// tile 27: Sprite 0 anchor (single opaque pixel at row 7,
|
||||
// col 3 — the sprite-0 hit fires on the last
|
||||
// HUD scanline so the scroll split lands
|
||||
// cleanly on the playfield below)
|
||||
// tile 27: Sprite 0 anchor — one opaque pixel at
|
||||
// row 7 col 3. Paired with the BG anchor tile
|
||||
// at (NT col 31, row 2) below; the single
|
||||
// opaque dot at screen (251, 16) fires the
|
||||
// sprite-0 hit one scanline *below* the HUD
|
||||
// row, keeping the HUD glyphs pixel-stable.
|
||||
"........",
|
||||
"........",
|
||||
"........",
|
||||
|
|
@ -328,7 +330,20 @@ sprite Tileset {
|
|||
"........",
|
||||
"........",
|
||||
"........",
|
||||
"...c...."
|
||||
"...c....",
|
||||
// tile 28: BG anchor — one opaque pixel at row 0 col 3.
|
||||
// Pre-painted at NT (col 31, row 2) so it
|
||||
// overlaps sprite 0's opaque pixel at screen
|
||||
// (251, 16). Column 31 sits inside jsnes's
|
||||
// right-edge overscan so the dot is invisible.
|
||||
"...c....",
|
||||
"........",
|
||||
"........",
|
||||
"........",
|
||||
"........",
|
||||
"........",
|
||||
"........",
|
||||
"........"
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -346,11 +361,17 @@ const TILE_COIN: u8 = 6
|
|||
// sub-palette.
|
||||
const TILE_DIGIT_0: u8 = 16
|
||||
const TILE_HEART: u8 = 26
|
||||
// OAM slot-0 anchor — a 7-scanline-tall transparent sprite with
|
||||
// one opaque pixel at row 7. Aligns with the coin tile's bottom
|
||||
// row so sprite-0 hit fires at scanline 15, keeping the scroll
|
||||
// split exactly at the HUD-row boundary.
|
||||
// Sprite-0 hit pair. `TILE_SPRITE0_ANCHOR` has one opaque pixel
|
||||
// at row 7, col 3; `TILE_BG_ANCHOR` has its one opaque pixel at
|
||||
// row 0, col 3. Drawing the sprite at OAM (248, 8) and pre-
|
||||
// painting the bg tile at NT (col 31, row 2) puts both opaque
|
||||
// pixels at screen (251, 16) — the first scanline of the
|
||||
// playfield, one scanline *below* the HUD row. The PPU's
|
||||
// sprite-0 hit fires there and `sprite_0_split` flips the
|
||||
// horizontal scroll before scanline 17 renders, so rows 8-15
|
||||
// stay pixel-stable regardless of `camera_x`.
|
||||
const TILE_SPRITE0_ANCHOR: u8 = 27
|
||||
const TILE_BG_ANCHOR: u8 = 28
|
||||
|
||||
// ── Background ──────────────────────────────────────────────
|
||||
// The 32×30 nametable is authored as ASCII art with a `legend`
|
||||
|
|
@ -382,6 +403,9 @@ background Level {
|
|||
"h": 26 // heart icon (HUD lives marker)
|
||||
"0": 16 // digit 0 (initial score tens/ones + fallback)
|
||||
"3": 19 // digit 3 (initial lives)
|
||||
"a": 28 // sprite-0 bg anchor (one opaque pixel at
|
||||
// (251, 16), overlapping sprite 0's slot-0
|
||||
// anchor; sits inside jsnes's overscan)
|
||||
}
|
||||
|
||||
// Row 1 carries the status bar: coin + two score digits on the
|
||||
|
|
@ -401,7 +425,7 @@ background Level {
|
|||
map: [
|
||||
"................................",
|
||||
"..o.00..................h.3.....",
|
||||
"................................",
|
||||
"...............................a",
|
||||
"................................",
|
||||
"................................",
|
||||
"....<>..............<>..........",
|
||||
|
|
@ -563,18 +587,24 @@ var last_lives: u8 = 3
|
|||
// everything below it scrolls with the camera.
|
||||
fun draw_hud() {
|
||||
// Sprite 0 — must be the FIRST draw of the frame so it lands
|
||||
// in OAM slot 0. The anchor tile has one opaque pixel at row
|
||||
// 7, col 3 (a single dot), which when positioned at (16, 7)
|
||||
// in OAM renders at screen (19, 15) and overlaps column 3 of
|
||||
// the coin tile's bottom row in NT row 1. That last-scanline
|
||||
// overlap makes the sprite-0 hit flag set at scanline 15, so
|
||||
// the `sprite_0_split(camera_x, 0)` call below can write
|
||||
// `$2005` before the next HBLANK and the PPU latches the new
|
||||
// horizontal scroll starting at scanline 16 — exactly the
|
||||
// boundary between the HUD row and the playfield. The rest
|
||||
// of the anchor tile is fully transparent, so the sprite is
|
||||
// functionally invisible on top of the coin.
|
||||
draw Tileset at: (16, 7) frame: TILE_SPRITE0_ANCHOR
|
||||
// in OAM slot 0. The anchor tile's single opaque pixel sits
|
||||
// at row 7, col 3 of the tile; positioned at (248, 8) in OAM
|
||||
// it renders at screen (251, 16) — the first scanline of the
|
||||
// playfield, not the last scanline of the HUD. The map
|
||||
// pre-paints a dirt tile (%) at NT (col 31, row 2), whose
|
||||
// uniformly-opaque row 0 covers the same pixel, so the PPU's
|
||||
// sprite-0 hit fires at dot 251 of scanline 16. The
|
||||
// `sprite_0_split` write therefore affects the scanlines
|
||||
// *below* the HUD instead of the last HUD scanline itself,
|
||||
// which keeps rows 8-15 pixel-stable regardless of
|
||||
// `camera_x`. An earlier hit at scanline 15 (with the anchor
|
||||
// sitting on top of the visible coin in NT col 2) would
|
||||
// smear the HUD's bottom row of pixels across the split in
|
||||
// jsnes; moving the hit off the HUD row avoids the glitch
|
||||
// entirely. Column 31 / OAM x=248 are inside jsnes's
|
||||
// right-edge overscan, so the dirt anchor and sprite 0 are
|
||||
// both invisible in the committed PNG goldens.
|
||||
draw Tileset at: (248, 8) frame: TILE_SPRITE0_ANCHOR
|
||||
|
||||
// Score tens / ones — shadow-compare so the VRAM ring only
|
||||
// gets an entry when `stomp_count` actually moved. The
|
||||
|
|
@ -864,13 +894,18 @@ state Playing {
|
|||
}
|
||||
|
||||
// ── Sprite-0 scroll split ────────────────────────────
|
||||
// The VRAM-drain in NMI resets `$2005` to `(0, 0)` for
|
||||
// the top of each frame; `sprite_0_split` busy-waits for
|
||||
// the sprite-0 hit flag (fired mid-HUD-row where OAM
|
||||
// slot 0's coin sprite overlaps the NT-row-1 coin tile)
|
||||
// and then latches `(camera_x, 0)` for the remainder of
|
||||
// the frame. Scanlines above the hit render the HUD
|
||||
// pinned; scanlines below render the playfield scrolled.
|
||||
// Explicitly re-latch scroll to (0, 0) before the hit-
|
||||
// wait so jsnes (whose PPU is scanline-quantised and
|
||||
// will happily apply the previous frame's mid-frame
|
||||
// `$2005` writes to the next frame's scanline 15) starts
|
||||
// every scanline below vblank from a known-zero scroll.
|
||||
// Then `sprite_0_split` busy-waits for the PPU's sprite-0
|
||||
// hit flag — fired at dot 251 of scanline 15 by the
|
||||
// overscan coin anchor — and latches `(camera_x, 0)` for
|
||||
// the remainder of the frame. Scanlines 0-15 render the
|
||||
// HUD pinned; scanlines 16+ render the playfield
|
||||
// scrolled.
|
||||
scroll(0, 0)
|
||||
sprite_0_split(camera_x, 0)
|
||||
|
||||
// Fatal contact this frame → kick the state machine over
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -367,18 +367,22 @@ aaaaaaaa
|
|||
...aa...
|
||||
........",
|
||||
},
|
||||
// Sprite-0 hit anchor — a single opaque pixel at row 7, col 3,
|
||||
// everything else transparent. Sits behind the HUD row's coin
|
||||
// tile as OAM slot 0 in every `on frame`; its one opaque pixel
|
||||
// aligns with column 3 of the coin's row 7 (`...bb...`), so
|
||||
// the PPU sets the sprite-0 hit flag at scanline 15 — the
|
||||
// last scanline of the HUD row. Writing `$2005` the moment
|
||||
// that flag sets means the horizontal scroll flip takes
|
||||
// effect at scanline 16 (the PPU latches horizontal scroll at
|
||||
// the next HBLANK), which pins NT rows 0-1 at scroll=0 and
|
||||
// lets scanlines 16+ render at `camera_x`. A sprite that
|
||||
// hit on its *top* row (e.g. using the coin as sprite 0)
|
||||
// would flip the scroll mid-HUD-row and smear the glyphs.
|
||||
// Sprite-0 hit pair — two single-pixel tiles whose one opaque
|
||||
// pixel sits at row 7 col 3 (sprite side) and row 0 col 3 (bg
|
||||
// side). OAM slot 0 gets `Sprite 0 anchor` at (248, 8) and NT
|
||||
// (col 31, row 2) gets `BG anchor`, so both opaque pixels
|
||||
// land at screen (251, 16) — the first scanline of the
|
||||
// playfield, one scanline below the HUD row. The PPU's
|
||||
// sprite-0 hit fires at dot 251 of scanline 16, `$2005`
|
||||
// writes from `sprite_0_split` land in HBLANK, and the
|
||||
// horizontal-scroll reload for scanline 17 latches the
|
||||
// playfield's `camera_x` without touching scanlines 8-15
|
||||
// (where the HUD glyphs live). A hit on scanline 15 itself
|
||||
// would smear the bottom row of every HUD glyph across the
|
||||
// split in jsnes. Both tiles are transparent everywhere
|
||||
// except that one pixel, so the only BG footprint is a
|
||||
// single dot at (251, 16) — inside jsnes's right-edge
|
||||
// overscan, invisible in the committed golden.
|
||||
Tile {
|
||||
name: "Sprite 0 anchor",
|
||||
art: "\
|
||||
|
|
@ -391,6 +395,18 @@ aaaaaaaa
|
|||
........
|
||||
...c....",
|
||||
},
|
||||
Tile {
|
||||
name: "BG anchor",
|
||||
art: "\
|
||||
...c....
|
||||
........
|
||||
........
|
||||
........
|
||||
........
|
||||
........
|
||||
........
|
||||
........",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Named CHR tile indices used by the nametable layout below ──
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
94d0f184 132084
|
||||
efacd7d5 132084
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.7 KiB |
Loading…
Add table
Add a link
Reference in a new issue