diff --git a/.gitignore b/.gitignore index 16d74675..5053e814 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -# Generated from a user-provided Pokemon Red ROM. -# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean +# Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal). +# Regenerate with: python3 tools/build_data.py --rom /path/to/rom.gb --clean +# Yellow Surfing Pikachu minigame art also lands here on import: +# assets/generated/minigame/surf_1{a,b,c}.png +# assets/generated/minigame/title_bg.png +# assets/generated/minigame/intro_pika_{0,1,2,3}.png data/generated/ assets/generated/ @@ -62,6 +66,9 @@ mobile/dist/ # ROM cache exactly like data/generated/, so it is never committable. /build/tiled/ +# Local visual scratch from minigame development (not wired to CI). +/tests/fixtures/ + # per-machine iOS bundle-id pin (see scripts/build_ios.sh) mobile/ios/bundle_id.local diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 1937cb21..42efbe3e 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -34,6 +34,11 @@ CacheContract.VERSION_REQUIRED_FILES = { "assets/generated/battle/trainers/jessie_james.png", "assets/generated/battle/profoakb.png", "assets/generated/pikachu/pikapic_1.png", + "assets/generated/minigame/surf_1a.png", + "assets/generated/minigame/surf_1b.png", + "assets/generated/minigame/surf_1c.png", + "assets/generated/minigame/title_bg.png", + "assets/generated/minigame/intro_pika_0.png", }, } diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 6c4319cc..a1b59884 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1637,6 +1637,113 @@ function RomExtractor:extractYellowTitleArt() end end +function RomExtractor:extractSurfingPikachuTitleArt() + -- engine/minigame/surfing_pikachu.asm + -- DrawSurfingPikachuMinigameIntroBackground: compose the 160x144 + -- "Pikachu's Beach" title from SurfingMinigame_* tilemaps over + -- SurfingPikachu1Graphics3 tiles (mirrors tools/build_rom_data.py). + if not self.symbols["SurfingPikachu1Graphics3"] then return end + if not self.symbols["SurfingMinigame_BeachIntroTilemap"] then return end + + local beachIntro = self:symbol("SurfingMinigame_BeachIntroTilemap") + local useCtrlPad = self:symbol("SurfingMinigame_UseControlPadTilemap") + local toSurfRad = self:symbol("SurfingMinigame_ToSurfRadTilemap") + local titleMap = self:symbol("SurfingMinigame_TitleTilemap") + + local beachBytes = self.rom:bytes( + beachIntro.bank, beachIntro.address, 12 * 20) + local useCtrlBytes = self.rom:bytes( + useCtrlPad.bank, useCtrlPad.address, 15) + local toSurfRadBytes = self.rom:bytes( + toSurfRad.bank, toSurfRad.address, 13) + local titleMapBytes = self.rom:bytes( + titleMap.bank, titleMap.address, 6 * 12) + + local screen = {} + for _ = 1, 20 * 18 do screen[#screen + 1] = 0xff end + + for i = 1, #beachBytes do + screen[6 * 20 + i] = beachBytes[i] + end + for r = 0, 5 do + for c = 0, 11 do + screen[r * 20 + (4 + c) + 1] = titleMapBytes[r * 12 + c + 1] + end + end + for r = 0, 2 do + for c = 0, 14 do + screen[(7 + r) * 20 + (3 + c) + 1] = 0xff + end + end + for i = 1, #useCtrlBytes do + screen[7 * 20 + 3 + i] = useCtrlBytes[i] + end + for i = 1, #toSurfRadBytes do + screen[9 * 20 + 4 + i] = toSurfRadBytes[i] + end + + local gfx3 = self:symbol("SurfingPikachu1Graphics3") + local rawGfx3 = self.rom:bytes(gfx3.bank, gfx3.address, 144 * 16) + local tiles = {} + for tile = 0, 143 do + local one = {} + for j = 1, 16 do one[j] = rawGfx3[tile * 16 + j] end + tiles[tile + 1] = ImageWriter.decode2bpp(one, 8, 8, false) + end + local blank = ImageWriter.blank(8, 8, 1, 1, 1, 1) + + local titleBg = ImageWriter.blank(160, 144, 1, 1, 1, 1) + for r = 0, 17 do + for c = 0, 19 do + local tileId = screen[r * 20 + c + 1] + local tileImg = blank + if tileId ~= 0xff then + local idx = tileId >= 0x80 and (tileId - 0x80 + 1) or (128 + tileId + 1) + if idx >= 1 and idx <= 144 then tileImg = tiles[idx] end + end + ImageWriter.blit(titleBg, tileImg, c * 8, r * 8) + end + end + self:save(titleBg, "minigame/title_bg.png") + + -- Intro paddling Pikachu frames (surfing_pikachu_oam.asm .IntroPikachu). + local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c } + local INTRO_PIKA_OAM = { + { -12, -16, 0x03, true }, { -12, -8, 0x02, true }, + { -12, 0, 0x01, true }, { -12, 8, 0x00, true }, + { -4, -16, 0x13, true }, { -4, -8, 0x12, true }, + { -4, 0, 0x11, true }, { -4, 8, 0x10, true }, + { 4, -16, 0x23, true }, { 4, -8, 0x22, true }, + { 4, 0, 0x21, true }, { 4, 8, 0x20, true }, + } + local function blitIntroTile(target, tile, tx, ty, flipX) + for y = 0, 7 do + for x = 0, 7 do + local sx = flipX and (7 - x) or x + local r, g, b, a = tile:getPixel(sx, y) + local px, py = tx + x, ty + y + if a ~= 0 and px >= 0 and py >= 0 + and px < target:getWidth() and py < target:getHeight() then + target:setPixel(px, py, r, g, b, a) + end + end + end + end + for frame, vramBase in ipairs(INTRO_PIKA_FRAME_BASE) do + local pose = ImageWriter.blank(32, 24, 1, 1, 1, 0) + local sheetBase = vramBase - 0x80 + for _, sp in ipairs(INTRO_PIKA_OAM) do + local dy, dx, rel, flipX = sp[1], sp[2], sp[3], sp[4] + local tileImg = tiles[sheetBase + rel + 1] + if tileImg then + blitIntroTile(pose, tileImg, + 16 + dx + (flipX and 8 or 0), 12 + dy, flipX) + end + end + self:save(pose, ("minigame/intro_pika_%d.png"):format(frame - 1)) + end +end + function RomExtractor:raw2bpp(label, width, height, relative, options) options = options or {} local expected = width * height / 4 @@ -1978,6 +2085,7 @@ function RomExtractor:extractField() self:save(image, spec[5]) end end + self:extractSurfingPikachuTitleArt() -- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per -- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 91e236e1..5eac657a 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -9,7 +9,7 @@ -- - Input accumulator eliminating the 3-frame polling blind spot -- - GB hardware VBlank execution order (collision boundary checked before position update) -- - Deterministic Game Boy DIV/Random LFSR wave sequence generator --- - 14-angle rotation model with 3-frame buffered input (Right = frontflip, Left = backflip) +-- - 14-angle rotation model with 3-frame joy duty cycle (Right = frontflip, Left = backflip) -- - Stunt scoring: +50 (single), +150 (double same), +350 (triple same), +180 (mixed), +500 (triple mixed) -- - Tile interaction landing matrix (Clean, Rough -64, Hard -128, Crash/Wipeout) -- - Non-fatal crash recovery: Pikachu wipes out for 96 frames, resets speed to 64 (0.25), and continues @@ -52,18 +52,57 @@ end SurfingMinigame.isOpaque = true -- Fixed-point physics constants (256 = 1.0 px/frame) +-- Pret metric audit (surfing_pikachu.asm): +-- Speed: init 0.25, max 2.0 (high byte cp $2), +1/128/frame, jump min GetSpeedDividedBy32 >= $a +-- Penalties: rough -0.25, hard -0.5, wipeout reset 0.25 (underflow guards at 0.25 / 0.5) +-- HP: $6000 BCD, -1/frame; course: 24 sections (distance byte cp $18); Big Kahuna at section $16 +-- BG scroll: 1.5 px/frame; coast: 9.0 px/frame for 192 frames; crash $60 frames; landing splash $20/4 +-- Flips: left cp $b, right cp $d; radness meter caps at $3; trick flags bits 0=left 1=right +-- Joypad: SurfingPikachu_GetJoypad_3FrameBuffer reloads hFrameCounter with $2 (1 sample + 2 blank frames) +-- Music tempo index: high byte of ((speed & $3ff) << 1) → tiers at speed 128/256/384/512 local SPEED_INITIAL = 64 -- 0.25 * 256 -local SPEED_MAX = 512 -- 2.00 * 256 -local SPEED_ACCEL = 2 -- (1/128) * 256 +local SPEED_MAX = 512 -- 2.00 * 256 (SpeedUpPikachu: high byte cp $2) +local SPEED_ACCEL = 2 -- 0.0078125 = 1/128 px/frame in 8.8 fixed local SPEED_ROUGH_PENALTY = 64 -- 0.25 * 256 local SPEED_HARD_PENALTY = 128 -- 0.50 * 256 -local SPEED_JUMP_THRESHOLD = 320 -- 1.25 * 256 (10/32) +local PIKA_SPRITE_OFFSET = 16 -- sprite anchor above wSurfingMinigamePikachuObjectHeight +local SPEED_JUMP_MIN_DIV32 = 10 -- TryStartJump: GetSpeedDividedBy32 cp $a (speed >= 1.25) +local FLIP_LEFT_FRAMES = 11 -- DPadAction .dLeft cp $b +local FLIP_RIGHT_FRAMES = 13 -- DPadAction .dRight cp $d +local RADNESS_METER_MAX = 3 -- IncreaseRadnessMeter cap +local JOY_FRAME_RELOAD = 2 -- GetJoypad_3FrameBuffer: ld a, $2 +local CRASH_FRAMES = 96 -- UpdateCrashedPikachu initial timer $60 +local COAST_FRAMES = 192 -- WaitToShowResults routine delay +local GAME_OVER_DELAY = 128 -- Game over before accepting A ($80) +local LANDING_SPLASH_FRAMES = 8 -- FIELD_C += 4 until cp $20 +local RESULTS_FRAMESET_INIT = 0x0f -- InitResultsPikachu: frameset ID written to ANIM_OBJ_FRAME_SET +local RESULTS_BOB_CYCLE = 64 -- UpdateResultsPikachu FIELD_C & $3f +local RESULTS_BOB_START = 32 -- sine bob only when FIELD_C >= $20 +local RESULTS_BOB_AMP = 2 -- SurfingPikachu_Sine scale ($10 table; ~2px on screen) +local RESULTS_PIKA_BASES = { 0xa0, 0xa3 } -- .ResultsPikachu OAM frame pair -- Original constants (surfing_pikachu.asm) local FLAT_WATER_Y = 116 -- OAM Y 0x74 = screen Y 100 (PIKA_Y = FLAT_WATER_Y - 16) local PIKA_X = 68 -- Fixed screen X while riding (center 80, 24px pose) -local TOTAL_SECTIONS = 24 -- 24 sections ($18) of 128px = 3072px course +local TOTAL_SECTIONS = 24 -- wSurfingMinigameDistance byte 0 reaches $18 (24) +local SECTION_ACC_MAX = 65536 -- 16-bit distance acc overflow (256px per section in 8.8 fixed) +local SECTION_PX = 256 -- pixels advanced per section (65536 >> 8) local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144) +local COAST_SPEED_FIXED = 9 * 256 -- SurfingMinigame_CoastAfterGoal: 9.0 px/frame +local BG_SCROLL_STEP = 384 -- ScrollAndGenerateBGMap: 1.5 px/frame (8.8 fixed) +local RDIV_PER_FRAME = 17 -- rDIV @ 16384 Hz ≈ 273 ticks per 59.7275 Hz frame (mod 256) +local COAST_X_OFFSET = 160 -- $a0 ahead of viewport (CoastAfterGoal) +local OUTRO_SCROLL_X_OFFSET = 224 -- TILEMAP_WIDTH_PX - 32 (ScrollToResultsScreen) +local OUTRO_SCROLL_START = 144 -- hSCX $90 at results transition +local OUTRO_SCROLL_STEP = 4 -- hSCX -= 4 per frame (36 frames total) +local FINISH_DISTANCE_FIXED = TOTAL_SECTIONS * SECTION_PX * 256 +local MAP_COLS = 16 -- vBGMap0 metatile columns (256px / 16) +-- SurfingPikachuMinigame_InitStaticSpriteLayout cloud OAM X coords (9 sprites) +local CLOUD_SPRITE_X_INIT = { 32, 40, 48, 56, 64, 128, 136, 144, 152 } + +local function scrollPx(self) + return math.floor((self.scrollFixed or 0) / 256) +end -- Routine numbers (wSurfingMinigameRoutineNumber) local ROUTINE_TITLE = -1 @@ -81,6 +120,95 @@ local ROUTINE_WAIT_LAST = 10 local ROUTINE_EXIT_ON_PRESS_A = 11 local ROUTINE_GAME_OVER = 12 +local function isOutroScroll(self) + return self.routine == ROUTINE_WAIT_RESULTS or self.routine == ROUTINE_SCROLL_RESULTS +end + +local function displayScx(self) + return self.hScx or 0 +end + +-- SurfingMinigame_UpdateMusicTempo: index = high byte of ((speed & $3ff) << 1) +local function pretTempoTier(speedFixed) + local lo = bit.band(speedFixed, 0xFF) + local hi = bit.band(math.floor(speedFixed / 256), 3) + local shifted = bit.band((lo + hi * 256) * 2, 0xFFFF) + return math.min(4, math.floor(shifted / 256)) + 1 +end + +-- SurfingMinigame_GetSpeedDividedBy32 (speed * 8, high byte) +local function getSpeedDividedBy32(speedFixed) + return math.floor((speedFixed * 8) / 256) +end + +local function pikaSpriteYFromObjectHeight(objectHeightPx) + return math.floor(objectHeightPx or FLAT_WATER_Y) - PIKA_SPRITE_OFFSET +end + +-- SurfingMinigame_ReduceSpeedBy64 / ReduceSpeedBy128 +local function reduceSpeedBy64(speedFixed) + if speedFixed >= 256 then + return speedFixed - SPEED_ROUGH_PENALTY + elseif speedFixed >= SPEED_INITIAL then + return speedFixed - SPEED_ROUGH_PENALTY + end + return 0 +end + +local function reduceSpeedBy128(speedFixed) + if speedFixed >= 256 then + return speedFixed - SPEED_HARD_PENALTY + elseif speedFixed >= SPEED_HARD_PENALTY then + return speedFixed - SPEED_HARD_PENALTY + end + return 0 +end + +local function jumpArcCombined(self) + return (self.jumpArcMagnitude or 0) * 256 + (self.jumpArcFraction or 0) +end + +local function setJumpArcCombined(self, value) + if value < 0 then value = 0 end + self.jumpArcMagnitude = math.floor(value / 256) + self.jumpArcFraction = value % 256 +end + +local function applyJumpArcDelta(self, delta) + setJumpArcCombined(self, jumpArcCombined(self) + delta) +end + +local function applyJumpVerticalDelta(self, sign) + local a = self.jumpArcMagnitude or 0 + if a == 0 and (self.jumpArcFraction or 0) == 0 then return end + self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 + local intDelta = math.floor(self.pikaSubY / 256) + self.pikaSubY = self.pikaSubY % 256 + if sign < 0 then + self.pikaY = self.pikaY - intDelta + else + self.pikaY = self.pikaY + intDelta + end +end + +-- pret GenerateBGMap write column: ((hSCX + XOffset) & $f0) / 16 +local function mapColWrite(self) + local xOffset = COAST_X_OFFSET + if self.routine == ROUTINE_SCROLL_RESULTS then + xOffset = OUTRO_SCROLL_X_OFFSET + end + local sum = (displayScx(self) + xOffset) % 256 + return math.floor(bit.band(sum, 0xF0) / 16) % MAP_COLS +end + +local function mapColScreen(scx, screenCol) + return (math.floor(scx / 16) + screenCol) % MAP_COLS +end + +local function mapColAtX(scx, x) + return math.floor((scx + x) / 16) % MAP_COLS +end + -- Pikachu states (wSurfingMinigamePikachuState) local PIKA_STATE_RIDING = 0 local PIKA_STATE_JUMPING = 1 @@ -270,6 +398,25 @@ local OAM_LARGE_SPLASH = { { dy = 4, dx = 8, tile = 0xc8, xflip = true }, } +-- Intro title Pikachu (surfing_pikachu_oam.asm .IntroPikachu, frames $20-$23). +-- Each animation frame adds four to the VRAM tile base ($80, $84, $88, $8c); +-- relative tile ids use the usual 16-wide OBJ row stride ($10 per row). +local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c } +local INTRO_PIKA_OAM = { + { dy = -12, dx = -16, tile = 0x03, xflip = true }, + { dy = -12, dx = -8, tile = 0x02, xflip = true }, + { dy = -12, dx = 0, tile = 0x01, xflip = true }, + { dy = -12, dx = 8, tile = 0x00, xflip = true }, + { dy = -4, dx = -16, tile = 0x13, xflip = true }, + { dy = -4, dx = -8, tile = 0x12, xflip = true }, + { dy = -4, dx = 0, tile = 0x11, xflip = true }, + { dy = -4, dx = 8, tile = 0x10, xflip = true }, + { dy = 4, dx = -16, tile = 0x23, xflip = true }, + { dy = 4, dx = -8, tile = 0x22, xflip = true }, + { dy = 4, dx = 0, tile = 0x21, xflip = true }, + { dy = 4, dx = 8, tile = 0x20, xflip = true }, +} + -- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10) local BEACH_OUTRO = { { 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, @@ -291,17 +438,28 @@ local PIKACHUS_BEACH_PAL = { { 88, 168, 248 }, -- 2: Ocean Blue Sea Water { 25, 25, 25 }, -- 3: Black Outlines } +-- Title intro uses BG palette slot 1 (PalPacket_PikachusBeachTitle / +-- UnknownPacket_72751) so the logo's shade-2 pixels tint yellow, not sea blue. +local PIKACHUS_BEACH_TITLE_PAL = { + { 255, 255, 255 }, + { 132, 132, 132 }, + { 255, 206, 74 }, + { 25, 25, 25 }, +} -- 5 Tempo tiers (117, 109, 101, 93, 85 from surfing_pikachu.asm) local TEMPO_TIERS = { 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 } function SurfingMinigame.new(game, onDone, skipTitle) local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame) - self.routine = skipTitle and ROUTINE_START_GAME or ROUTINE_TITLE + self.routine = skipTitle and ROUTINE_RUN_GAME or ROUTINE_TITLE self.pikaState = PIKA_STATE_RIDING self.t = 0 self.routineTimer = 0 - self.distanceFixed = 0 -- 16.8 fixed-point (256 = 1 pixel) + self.distanceFixed = 0 -- 16.8 fixed-point course progress (256 = 1 pixel) + self.distanceSection = 0 -- wSurfingMinigameDistance byte 0 (pret big-endian) + self.distanceAcc = 0 -- wSurfingMinigameDistance bytes 1-2 (16-bit, big-endian) + self.scrollFixed = 0 -- BG scroll (hSCX); diverges from distance during outro coast self.speedFixed = SPEED_INITIAL -- 64 = 0.25 px/frame self.hp = 6000 -- starts at 6000 (60.00 seconds) self.radness = 0 -- accumulated trick stunt points @@ -312,24 +470,38 @@ function SurfingMinigame.new(game, onDone, skipTitle) self.isMinigame = true self.isFixedSpeed = true - -- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV) + -- Hardware RNG simulation (pret never resets hRandomAdd/hRandomSub on minigame entry) self.rDiv = 0 self.rAdd = 0x55 self.rSub = 0xaa - -- Wave height tracking & column ring buffer + -- Wave height tracking: pret vBGMap0 (32 metatile columns, circular) self.waveFn = 0 + self.bgMap = {} + for c = 0, MAP_COLS - 1 do + self.bgMap[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } + end + -- Linear cols mirror kept for unit tests only self.cols = {} for c = 0, 10 do - self.cols[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } + self.cols[c] = flatCol end self.colTail = 10 + -- wSurfingMinigameWaveHeight (20 entries, shifted each GenerateBGMap) + self.waveHeight = {} + for i = 1, 20 do + self.waveHeight[i] = FLAT_WATER_Y + end + self.bgMapReadTile = 0x0b -- wSurfingMinigameBGMapReadBuffer (updated each ReadBGMapBuffer) + self.waveRandomValue = 0 -- wSurfingMinigameWaveRandomValue (Random each RunGame frame) + -- Jumping, Arc Physics & Air Rotation self.pikaY = FLAT_WATER_Y - 16 -- integer screen Y of Pikachu (sea waterline) self.pikaSubY = 0 -- 8.8 subpixel carry (0..255) self.pikaYOffset = 0 -- sine offset (splash/bobbing) - self.jumpArcMagnitude = 0 -- SpeedDividedBy32 (range 10..16) + self.jumpArcMagnitude = 0 -- wSurfingMinigameJumpArcMagnitude (GetSpeedDividedBy32, >= 10) + self.jumpArcFraction = 0 -- wSurfingMinigameJumpArcFraction (8.8 sub-byte) self.jumpDescending = false self.frameSet = 4 -- Starts at Frame 4 (flat horizontal ride) self.boardAngleOffset = 0 -- wobbling 0..2 @@ -338,9 +510,18 @@ function SurfingMinigame.new(game, onDone, skipTitle) self.crashTimer = 0 self.landingTimer = 0 - -- 3-frame buffered D-Pad rotation & input accumulator - self.joyCounter = 0 - self.inputAccum = 0 -- bit 0 = Right, bit 1 = Left + -- Outro coast / results card (SurfingMinigame_WaitToShowResults .. WaitLast) + self.hScx = 0 -- hSCX register (8-bit, wraps) + self.scxFrac = 0 -- wSurfingMinigameSCX fractional byte + self.scxHi = 0 -- wSurfingMinigameSCXHi (zero-init WRAM; band 0 until hSCX >= $10) + self.scxLast = 0 -- wSurfingMinigameSCX2 (last hSCX seen by GenerateBGMap) + self.showResultsCard = false + self.outroLines = { hp = false, rad = false, total = false, hiScore = false } + + -- SurfingPikachu_GetJoypad_3FrameBuffer (hJoy5 + hFrameCounter countdown) + self.joyFrameCounter = 0 + self.joy5Left = false + self.joy5Right = false self.rotCountLeft = 0 self.rotCountRight = 0 self.radnessMeter = 0 -- consecutive flips (capped at 3) @@ -348,11 +529,16 @@ function SurfingMinigame.new(game, onDone, skipTitle) -- Sprites & Popups self.startBannerX = 224 -- slides from 224 to 80 (center) + self.introPikaX = 80 -- intro Pikachu walks off-screen before the run self.ohNoBanner = false self.trickPopups = {} -- { text = "+150", x, y, timer } self.waterSprays = {} -- { x, y, timer } self.sprayTimer = 0 - self.cloudOffsetFixed = 0 + self.cloudScrollFrac = 0 -- wSurfingMinigameCloudScrollFraction + self.cloudSpriteX = {} + for i, x in ipairs(CLOUD_SPRITE_X_INIT) do + self.cloudSpriteX[i] = x + end -- Results Tally Animation self.tallyStep = 0 @@ -403,10 +589,6 @@ function SurfingMinigame.new(game, onDone, skipTitle) for n = 0, 143 do self.iq[n] = love.graphics.newQuad((n % 12) * 8, math.floor(n / 12) * 8, 8, 8, iW, iH) end - -- Pika Intro Poses (24x32 px each in top 32px: X=0, 24, 48) - self.introPikaQuad1 = love.graphics.newQuad(0, 0, 24, 32, iW, iH) - self.introPikaQuad2 = love.graphics.newQuad(24, 0, 24, 32, iW, iH) - self.introPikaQuad3 = love.graphics.newQuad(48, 0, 24, 32, iW, iH) -- Title Banner Logo ("PIKACHU'S BEACH") (96x32 px at Y=32..64) self.introLogoQuad = love.graphics.newQuad(0, 32, 96, 32, iW, iH) -- Instruction Text ("Use Control Pad to Surf") (96x32 px at Y=64..96) @@ -445,33 +627,38 @@ end -- Transition cleanly from Title Screen to active run, flushing input bleed-through function SurfingMinigame:startFromTitle() self.routine = ROUTINE_START_GAME - self.inputAccum = 0 - self.joyCounter = 0 + self.joyFrameCounter = 0 + self.joy5Left = false + self.joy5Right = false self.rotCountLeft = 0 self.rotCountRight = 0 + -- Title screen elapsed frames churn rDIV/LFSR like overworld play before entry. + for _ = 1, self.t + math.floor(self.introPikaX / 2) do + self:getGBRandom() + end self:resetTempo() if self.game and self.game.input and self.game.input.clearPressed then self.game.input:clearPressed() end - Sound.play(self.game and self.game.data, "Press_AB") end --- Authentic Game Boy LFSR Random Number Generator +-- Authentic Game Boy LFSR Random Number Generator (engine/math/random.asm Random_) function SurfingMinigame:getGBRandom() - self.rDiv = (self.rDiv + 13) % 256 - local tempAdd = self.rAdd + self.rDiv - self.rAdd = tempAdd % 256 - local tempSub = self.rSub - self.rDiv - self.rSub = (tempSub + 256) % 256 + local div1 = self.rDiv + self.rDiv = (self.rDiv + 1) % 256 + local div2 = self.rDiv + self.rAdd = (self.rAdd + div1) % 256 + self.rSub = (self.rSub - div2 + 256) % 256 return self.rAdd end function SurfingMinigame:chooseSequence() - local distPx = math.floor(self.distanceFixed / 256) - local section = math.floor(distPx / 128) + local section = self.distanceSection or 0 if section == 0x16 then self.waveFn = 0x6a elseif section < 0x16 then + -- Random at selection time (when waveFn==0 column generates), not frame-start + -- waveRandomValue. Fixed LFSR init otherwise phase-locks to two small sequences. local r = self:getGBRandom() if r ~= 0 then self.waveFn = SEQ_STARTS[bit.band(r - 1, 0x07) + 1] @@ -480,11 +667,35 @@ function SurfingMinigame:chooseSequence() return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y end +-- SurfingMinigame_UpdatePikachuDistance (3-byte big-endian distance + section counter) +function SurfingMinigame:updatePikachuDistance() + local acc = (self.distanceAcc or 0) + self.speedFixed + while acc >= SECTION_ACC_MAX do + acc = acc - SECTION_ACC_MAX + self.distanceSection = (self.distanceSection or 0) + 1 + end + self.distanceAcc = acc + self.distanceFixed = self.distanceSection * SECTION_ACC_MAX + acc +end + +function SurfingMinigame:moveClouds() + -- SurfingMinigame_MoveClouds: add speed high byte to each cloud OAM X (8-bit wrap) + local sum = (self.cloudScrollFrac or 0) + (self.speedFixed or 0) + self.cloudScrollFrac = sum % 256 + local delta = math.floor(sum / 256) + if delta == 0 then return end + for i = 1, #CLOUD_SPRITE_X_INIT do + self.cloudSpriteX[i] = (self.cloudSpriteX[i] + delta) % 256 + end +end + function SurfingMinigame:pushColumn() local pat, hl, hr - local distPx = math.floor(self.distanceFixed / 256) - if distPx >= (TOTAL_SECTIONS * 128) or self.waveFn >= 0x74 then - -- Lock finish line to solid beach sand without cycling back to ocean waves + + if self.routine == ROUTINE_WAIT_RESULTS then + -- CoastAfterGoal with wSurfingMinigameWaveRandomValue = 0 → flat slice + pat, hl, hr = WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y + elseif self.waveFn >= 0x74 then pat, hl, hr = WAVE_PATTERNS["beach"], FLAT_WATER_Y, FLAT_WATER_Y self.waveFn = 0x74 elseif self.waveFn == 0 then @@ -500,55 +711,101 @@ function SurfingMinigame:pushColumn() elseif step[4] == RESET then self.waveFn = 0 end end end - self.colTail = self.colTail + 1 - self.cols[self.colTail] = { pat = pat, hl = hl, hr = hr } - self.cols[self.colTail - 24] = nil + + local col = { pat = pat, hl = hl, hr = hr } + self.bgMap[mapColWrite(self)] = col + + -- Shift wSurfingMinigameWaveHeight left, append new column heights + for i = 1, 18 do + self.waveHeight[i] = self.waveHeight[i + 2] + end + self.waveHeight[19] = hl + self.waveHeight[20] = hr + + if not isOutroScroll(self) then + self.colTail = self.colTail + 1 + self.cols[self.colTail] = col + self.cols[self.colTail - 24] = nil + end end +function SurfingMinigame:advanceScx(deltaFixed) + local sum = (self.hScx or 0) * 256 + (self.scxFrac or 0) + deltaFixed + self.hScx = math.floor(sum / 256) % 256 + self.scxFrac = sum % 256 +end + +-- SurfingMinigame_GenerateBGMap: new column only when hSCX changes and $f0 band changes +function SurfingMinigame:generateBgMapIfNeeded() + local h = self.hScx or 0 + if h == self.scxLast then return end + self.scxLast = h + local band = bit.band(h, 0xF0) + if band == self.scxHi then return end + self.scxHi = band + self:pushColumn() +end + +-- Legacy linear buffer kept for unit tests; pret uses generateBgMapIfNeeded only. function SurfingMinigame:generateAhead() - local distPx = math.floor(self.distanceFixed / 256) - while self.colTail * 16 < distPx + 176 do self:pushColumn() end + self:generateBgMapIfNeeded() +end + +local function columnAt(self, x) + local scx = displayScx(self) + return self.bgMap[mapColAtX(scx, x)] +end + +-- SurfingMinigame_SetPikachuHeight: wave height with slope from prior BGMapReadBuffer +function SurfingMinigame:pikaObjectHeight(tileForSlope) + local scx = displayScx(self) + local idx = (bit.band(scx, 8) ~= 0) and 9 or 8 + local h = self.waveHeight[idx] + local tile = tileForSlope or self.bgMapReadTile or 0x0b + if tile == 0x06 or tile == 0x14 then + return h - bit.band(scx, 7) + elseif tile == 0x07 then + return h + bit.band(scx, 7) + end + return h +end + +local function pikaWaterY(self) + return self:pikaObjectHeight(self.bgMapReadTile) end -- Get water surface Y for a screen X coordinate (X=80 under Pikachu center) function SurfingMinigame:seaY(x) - local distPx = math.floor(self.distanceFixed / 256) - local tile = math.floor((distPx + x) / 8) - local col = self.cols[math.floor(tile / 2)] + if x == 80 then + return pikaWaterY(self) + end + local col = columnAt(self, x) if not col then return FLAT_WATER_Y end + local scx = displayScx(self) + local tile = math.floor((scx + x) / 8) return tile % 2 == 0 and col.hl or col.hr end --- Get the tile ID of the wave under Pikachu (sample 9 tiles / 72 pixels into viewport) -function SurfingMinigame:getWaveTileUnderPika() - local distPx = math.floor(self.distanceFixed / 256) - local tile_col = math.floor((distPx + 72) / 8) - local waterY = math.floor(self:seaY(80)) - local tile_row = math.floor(waterY / 8) +-- Chr tile at Pikachu's object height (SurfingMinigame_ReadBGMapBuffer). +function SurfingMinigame:sampleBgTileAt(scx, objectHeightPx) + local tile_col = math.floor((scx + 72) / 8) + local tile_row = math.floor(objectHeightPx / 8) - local c = math.floor(tile_col / 2) - local col = self.cols[c] - if not col or not col.pat then return 0x01 end + local col = self.bgMap[mapColAtX(scx, 72)] + if not col or not col.pat then return 0x0b end local i = math.floor(tile_row / 2) + 1 - if i < 1 or i > 8 then return 0x01 end + if i < 1 or i > 8 then return 0x0b end local mt = BG_METATILES[col.pat[i]] - if not mt then return 0x01 end + if not mt then return 0x0b end local sub_x = (tile_col % 2 == 0) and 0 or 1 local sub_y = (tile_row % 2 == 0) and 0 or 1 + return mt[1 + sub_x + sub_y * 2] or 0x0b +end - local sub_idx = 1 + sub_x + sub_y * 2 - local mt_tile = mt[sub_idx] or 0x01 - - if mt_tile == 0x02 or mt_tile == 0x04 or mt_tile == 0x06 or mt_tile == 0x0a or mt_tile == 0x11 then - return 0x06 -- rising slope - elseif mt_tile == 0x03 or mt_tile == 0x05 or mt_tile == 0x07 or mt_tile == 0x0d or mt_tile == 0x13 then - return 0x07 -- falling slope - elseif mt_tile == 0x08 or mt_tile == 0x09 or mt_tile == 0x0f or mt_tile == 0x10 or mt_tile == 0x12 or mt_tile == 0x14 or mt_tile == 0x15 then - return 0x14 -- wave crest / face - end - return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.) +function SurfingMinigame:getWaveTileUnderPika() + return self.bgMapReadTile or 0x0b end function SurfingMinigame:spawnTrickPopup(text) @@ -593,52 +850,44 @@ end -- Slope/tile interaction matrix upon landing (SurfingMinigame_TileInteraction) function SurfingMinigame:evaluateLanding() local f = self.frameSet - -- Flipped / upside-down frames (8..14) ALWAYS wipeout unconditionally! if f >= 8 or f < 1 then return "wipeout" end local tile = self:getWaveTileUnderPika() - if tile == 0x06 then -- risingSlope - if f == 6 then return "clean" + if tile == 0x06 then -- rising slope + if f <= 3 then return "wipeout" + elseif f == 4 then return "hard" elseif f == 5 or f == 7 then return "rough" - elseif f == 4 then return "hard" - else return "wipeout" end -- 1, 2, 3 + elseif f == 6 then return "clean" + else return "wipeout" end - elseif tile == 0x07 then -- fallingSlope - if f == 2 then return "clean" - elseif f == 1 or f == 3 then return "rough" + elseif tile == 0x07 then -- falling slope + if f == 1 then return "rough" + elseif f == 2 then return "clean" + elseif f == 3 then return "rough" elseif f == 4 then return "hard" - else return "wipeout" end -- 5, 6, 7 + else return "wipeout" end - elseif tile == 0x14 or tile == 0x12 then -- waveCrest / waveFace - if f == 4 or f == 5 then return "clean" - elseif f == 3 or f == 6 then return "rough" + elseif tile == 0x12 or tile == 0x14 then -- wave face / crest + if f == 1 then return "wipeout" elseif f == 2 or f == 7 then return "hard" - else return "wipeout" end -- 1 + elseif f == 3 or f == 6 then return "rough" + elseif f == 4 or f == 5 then return "clean" + else return "wipeout" end - else -- flat open water - if f == 4 then return "clean" - elseif f == 3 or f == 5 then return "rough" + else -- flat open water (every other metatile id) + if f == 1 or f == 7 then return "wipeout" elseif f == 2 or f == 6 then return "hard" - else return "wipeout" end -- 1, 7 + elseif f == 3 or f == 5 then return "rough" + elseif f == 4 then return "clean" + else return "wipeout" end end end function SurfingMinigame:updateTempo() - local tier = 1 - if self.speedFixed >= 416 then - tier = 5 - elseif self.speedFixed >= 320 then - tier = 4 - elseif self.speedFixed >= 224 then - tier = 3 - elseif self.speedFixed >= 128 then - tier = 2 - else - tier = 1 - end + local tier = pretTempoTier(self.speedFixed or SPEED_INITIAL) local targetPitch = TEMPO_TIERS[tier] or 1.0 if self.currentPitch ~= targetPitch then self.currentPitch = targetPitch @@ -655,60 +904,68 @@ function SurfingMinigame:resetTempo() end end -function SurfingMinigame:updateRiding() - -- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0) - if self.speedFixed < SPEED_MAX then - self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) +-- Pret RunDelayTimer: count down routineTimer; return true when it hits zero. +local function outroDelayExpired(self) + if self.routineTimer > 0 then + self.routineTimer = self.routineTimer - 1 + return false end - self:updateTempo() + return true +end - -- Follow wave surface height - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 +function SurfingMinigame:beginResultsCard() + self.showResultsCard = true + self.outroLines = { hp = false, rad = false, total = false, hiScore = false } + self:initResultsPikachu() + self.speedFixed = 0 + -- DrawResultsScreen clears cloud OAM (sprites 5–13); hide parallax clouds on the beach card + self.cloudSpriteX = nil +end - -- Water spray every 4 frames - self.sprayTimer = self.sprayTimer + 1 - if self.sprayTimer % 4 == 0 then - table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) - end +-- SurfingMinigame_InitResultsPikachu: flat results pose at shore Y, reset bob counter +function SurfingMinigame:initResultsPikachu() + self.pikaState = PIKA_STATE_INIT_RESULTS + self.frameSet = 4 -- flat ride (visible pose; pret writes frameset $0f to anim struct) + self.pikaY = FLAT_WATER_Y - PIKA_SPRITE_OFFSET + self.pikaSubY = 0 + self.pikaYOffset = 0 + self.resultsBobTimer = 0 +end - -- Board angle wobbling every 8 frames - self.boardAngleTimer = self.boardAngleTimer + 1 - if self.boardAngleTimer % 8 == 0 then - if self.boardAngleDecreasing then - if self.boardAngleOffset > 0 then - self.boardAngleOffset = self.boardAngleOffset - 1 - else - self.boardAngleDecreasing = false - end - else - if self.boardAngleOffset < 2 then - self.boardAngleOffset = self.boardAngleOffset + 1 - else - self.boardAngleDecreasing = true - end - end - end - - -- Select frame based on slope (lock flat open water to frame 4 without wobble) - local tile = self:getWaveTileUnderPika() - if tile == 0x06 or tile == 0x14 then - self.frameSet = 6 + (self.boardAngleOffset - 1) - elseif tile == 0x07 then - self.frameSet = 2 + (self.boardAngleOffset - 1) +-- SurfingMinigame_UpdateResultsPikachu (hi-score path only sets PIKA_STATE_RESULTS in pret) +function SurfingMinigame:updateResultsPikachu() + self.resultsBobTimer = (self.resultsBobTimer or 0) + 2 + local phase = bit.band(self.resultsBobTimer, RESULTS_BOB_CYCLE - 1) + if phase >= RESULTS_BOB_START then + self.pikaYOffset = math.floor( + math.sin((phase - RESULTS_BOB_START) / (RESULTS_BOB_CYCLE / 2) * math.pi * 2) * RESULTS_BOB_AMP + ) else - self.frameSet = 4 -- flat open water: steady horizontal ride + self.pikaYOffset = 0 end - self.frameSet = math.max(1, math.min(14, self.frameSet)) +end - -- Automatic jump off wave crest ($14) if speed >= 1.25 (SPEED_JUMP_THRESHOLD = 320) - local distPx = math.floor(self.distanceFixed / 256) - local subX = distPx % 8 - if (subX >= 3 and subX <= 4) and tile == 0x14 and self.speedFixed >= SPEED_JUMP_THRESHOLD then +function SurfingMinigame:drawResultsPikachu() + if not (self.ob and self.oq) then return end + local cx, cy = 80, self.pikaY + (self.pikaYOffset or 0) + self.pikaScreenY = cy + local toggle = math.floor(self.t / 8) % 2 + 1 + local base = RESULTS_PIKA_BASES[toggle] + if not self.oq[base] then + base = ANGLE_BASES[4][toggle] + end + self:draw3x3(base, cx, cy, false, false) +end + +function SurfingMinigame:updateRiding() + local tile = self.bgMapReadTile or 0x0b + local subX = bit.band(displayScx(self), 7) + + -- SurfingMinigame_TryStartJump (before SpeedUpPikachu; uses pre-accel speed) + if (subX >= 3 and subX <= 4) and tile == 0x14 and getSpeedDividedBy32(self.speedFixed) >= SPEED_JUMP_MIN_DIV32 then self.pikaState = PIKA_STATE_JUMPING - local spd = self.speedFixed / 256 - self.jumpArcMagnitude = math.min(16, math.max(10, math.floor(spd * 8))) + self.jumpArcMagnitude = getSpeedDividedBy32(self.speedFixed) + self.jumpArcFraction = 0 self.pikaSubY = 0 self.jumpDescending = false self.radnessMeter = 0 @@ -716,6 +973,59 @@ function SurfingMinigame:updateRiding() self.rotCountLeft = 0 self.rotCountRight = 0 Sound.play(self.game.data, "Ledge_Jump") + -- SurfingMinigame_UpdateSurfingFrame still runs on .startedJump + if subX >= 3 and subX <= 4 then + if tile == 0x06 or tile == 0x14 then + self.frameSet = 6 + (self.boardAngleOffset - 1) + elseif tile == 0x07 then + self.frameSet = 2 + (self.boardAngleOffset - 1) + end + self.frameSet = math.max(1, math.min(14, self.frameSet)) + end + return + end + + -- SurfingMinigame_UpdateSurfingFrame (only updates frame when subX is 3..4) + if subX >= 3 and subX <= 4 then + if tile == 0x06 or tile == 0x14 then + self.frameSet = 6 + (self.boardAngleOffset - 1) + elseif tile == 0x07 then + self.frameSet = 2 + (self.boardAngleOffset - 1) + else + self:updateBoardAngle() + self.frameSet = 4 + end + self.frameSet = math.max(1, math.min(14, self.frameSet)) + end + + -- SurfingMinigame_SpeedUpPikachu (+2/256 = +1/128 per frame up to max 512 = 2.0) + if self.speedFixed < SPEED_MAX then + self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) + end + self:updateTempo() + + -- Water spray every 4 frames + self.sprayTimer = self.sprayTimer + 1 + if self.sprayTimer % 4 == 0 then + table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) + end +end + +function SurfingMinigame:updateBoardAngle() + self.boardAngleTimer = (self.boardAngleTimer or 0) + 1 + if self.boardAngleTimer % 8 ~= 0 then return end + if self.boardAngleDecreasing then + if self.boardAngleOffset > 0 then + self.boardAngleOffset = self.boardAngleOffset - 1 + else + self.boardAngleDecreasing = false + end + else + if self.boardAngleOffset < 2 then + self.boardAngleOffset = self.boardAngleOffset + 1 + else + self.boardAngleDecreasing = true + end end end @@ -723,79 +1033,70 @@ function SurfingMinigame:handleLanding() local result = self:evaluateLanding() if result == "wipeout" then self.pikaState = PIKA_STATE_CRASHED - self.crashTimer = 96 + self.crashTimer = CRASH_FRAMES self.speedFixed = SPEED_INITIAL self.frameSet = 4 Sound.play(self.game.data, "Faint_Fall") else if result == "rough" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY) + self.speedFixed = reduceSpeedBy64(self.speedFixed) elseif result == "hard" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) + self.speedFixed = reduceSpeedBy128(self.speedFixed) end if self.routine == ROUTINE_RUN_GAME then self:calculateStuntPoints() end self.pikaState = PIKA_STATE_LANDING - self.landingTimer = 32 + self.landingTimer = LANDING_SPLASH_FRAMES self.frameSet = 4 Sound.play(self.game.data, "Cut") end end function SurfingMinigame:updateJumping() - -- Process accumulated input on the 3-frame buffer boundary (only during active run) + -- SurfingMinigame_DPadAction (hJoy5 sampled every 2 frames via GetJoypad) if self.routine == ROUTINE_RUN_GAME then - self.joyCounter = (self.joyCounter + 1) % 3 - if self.joyCounter == 0 then - local rightHeld = bit.band(self.inputAccum, 1) ~= 0 - local leftHeld = bit.band(self.inputAccum, 2) ~= 0 - self.inputAccum = 0 - - -- Game Boy priority: Left D-pad checked first, then Right D-pad - if leftHeld then - self.rotCountRight = 0 - self.rotCountLeft = self.rotCountLeft + 1 - if self.rotCountLeft >= 11 then - self.rotCountLeft = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 1) - Sound.play(self.game.data, "Tink") - end - self.frameSet = (self.frameSet % 14) + 1 - elseif rightHeld then + if self.joy5Left then + self.rotCountRight = 0 + self.rotCountLeft = self.rotCountLeft + 1 + if self.rotCountLeft >= FLIP_LEFT_FRAMES then self.rotCountLeft = 0 - self.rotCountRight = self.rotCountRight + 1 - if self.rotCountRight >= 13 then - self.rotCountRight = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 2) - Sound.play(self.game.data, "Tink") - end + self.radnessMeter = math.min(RADNESS_METER_MAX, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 1) + Sound.play(self.game.data, "Tink") + end + if self.frameSet >= 14 then + self.frameSet = 1 + else + self.frameSet = self.frameSet + 1 + end + elseif self.joy5Right then + self.rotCountLeft = 0 + self.rotCountRight = self.rotCountRight + 1 + if self.rotCountRight >= FLIP_RIGHT_FRAMES then + self.rotCountRight = 0 + self.radnessMeter = math.min(RADNESS_METER_MAX, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 2) + Sound.play(self.game.data, "Tink") + end + if self.frameSet <= 1 then + self.frameSet = 14 + else self.frameSet = self.frameSet - 1 - if self.frameSet < 1 then self.frameSet = 14 end end end - else - self.inputAccum = 0 end - -- Authentic Game Boy collision boundary & integer fixed-point jump physics + -- SurfingMinigame_UpdatePikachuHeight (arc delta before velocity each phase) if not self.jumpDescending then - local a = math.floor(self.jumpArcMagnitude) - self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 - local intDelta = math.floor(self.pikaSubY / 256) - self.pikaSubY = self.pikaSubY % 256 - self.pikaY = self.pikaY - intDelta - - self.jumpArcMagnitude = self.jumpArcMagnitude - 0.5 - if self.jumpArcMagnitude <= 0 then - self.jumpArcMagnitude = 0 + if (self.jumpArcMagnitude or 0) == 0 and (self.jumpArcFraction or 0) == 0 then self.jumpDescending = true + else + applyJumpArcDelta(self, -128) -- -0.5 px/frame in 8.8 fixed + applyJumpVerticalDelta(self, -1) end else - -- Hardware execution order: evaluate boundary before adding velocity - local waveY = math.floor(self:seaY(80)) - 16 + local waveY = pikaSpriteYFromObjectHeight(self.pikaObjectHeightPx) if self.pikaY >= waveY then self.pikaY = waveY self.pikaSubY = 0 @@ -803,12 +1104,8 @@ function SurfingMinigame:updateJumping() return end - local a = math.floor(self.jumpArcMagnitude) - self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 - local intDelta = math.floor(self.pikaSubY / 256) - self.pikaSubY = self.pikaSubY % 256 - self.pikaY = self.pikaY + intDelta - self.jumpArcMagnitude = self.jumpArcMagnitude + 0.5 + applyJumpArcDelta(self, 128) -- +0.5 px/frame in 8.8 fixed + applyJumpVerticalDelta(self, 1) if self.pikaY >= waveY then self.pikaY = waveY @@ -821,12 +1118,9 @@ end function SurfingMinigame:updateLanding() self.landingTimer = (self.landingTimer or 0) - 1 - -- Follow wave surface height continuously while landing so slopes don't cause position jumps! - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 + -- pikaY already updated by SetPikachuHeight in tick (pre-scroll object height). - -- Sine wave splash offset + -- Sine wave splash offset (FIELD_C 0..$20 by +4/frame) self.pikaYOffset = math.floor(math.sin((32 - math.max(0, self.landingTimer)) / 32 * math.pi * 2) * 4) if self.landingTimer % 4 == 0 then table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) @@ -842,10 +1136,7 @@ end function SurfingMinigame:updateCrashed() self.crashTimer = self.crashTimer - 1 self:resetTempo() - -- Follow water surface while wiped out - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 + -- pikaY already updated by SetPikachuHeight in tick. if self.crashTimer <= 0 then self.pikaState = PIKA_STATE_RIDING self.frameSet = 4 @@ -856,22 +1147,34 @@ end function SurfingMinigame:tick() local input = self.game and self.game.input self.t = self.t + 1 - self.rDiv = (self.rDiv + 1) % 256 + self.rDiv = (self.rDiv + RDIV_PER_FRAME) % 256 - -- Title Screen State + -- Title Screen State (auto-advances when intro Pikachu walks off-screen) if self.routine == ROUTINE_TITLE then - if input and (input:wasPressed("start") or input:wasPressed("a")) then + if self.t % 2 == 0 then + self.introPikaX = self.introPikaX + 1 + end + if self.introPikaX >= 192 then self:startFromTitle() end return end - -- Accumulate physical button presses on every frame (only during active run) + -- SurfingPikachu_GetJoypad_3FrameBuffer: hJoy5 = hJoyHeld when hFrameCounter==0, then reload $2; + -- VBlank decrements counter → 1 sample frame + 2 blank frames (3-frame duty cycle). if self.routine == ROUTINE_RUN_GAME then - if input and input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end - if input and input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end + if (self.joyFrameCounter or 0) == 0 then + self.joy5Left = input and input:isDown("left") + self.joy5Right = input and input:isDown("right") + self.joyFrameCounter = JOY_FRAME_RELOAD + else + self.joy5Left = false + self.joy5Right = false + end else - self.inputAccum = 0 + self.joy5Left = false + self.joy5Right = false + self.joyFrameCounter = 0 end -- Update trick popups @@ -889,40 +1192,59 @@ function SurfingMinigame:tick() if s.timer <= 0 then table.remove(self.waterSprays, i) end end + -- Slide START banner during RunGame (pret animates it while gameplay runs) + if self.routine == ROUTINE_RUN_GAME and self.startBannerX > 80 then + self.startBannerX = math.max(80, self.startBannerX - 4) + end + -- Routine state machine if self.routine == ROUTINE_START_GAME then - if self.startBannerX > 80 then - self.startBannerX = math.max(80, self.startBannerX - 4) - else - self.routine = ROUTINE_RUN_GAME - end + -- SurfingMinigame_StartGame: spawn banner, inc routine; RunGame begins next frame + self.routine = ROUTINE_RUN_GAME + return elseif self.routine == ROUTINE_RUN_GAME then - -- Deduct 1 HP per frame (stamina countdown from 6000 BCD) - if self.hp > 0 then - self.hp = self.hp - 1 - else - -- Game Over when HP hits 0 + -- SurfingMinigame_RunGame: distance check first (cp $18) + if (self.distanceSection or 0) >= TOTAL_SECTIONS then + self.distanceSection = TOTAL_SECTIONS + self.routine = ROUTINE_WAIT_RESULTS + self.routineTimer = COAST_FRAMES + self.scxHi = bit.band(self.hScx or 0, 0xF0) + self.scxLast = self.hScx or 0 + self.waveFn = 0 + self:resetTempo() + return + end + + -- HP dead check before frame logic (pret or [hl] on wSurfingMinigamePikachuHP) + if (self.hp or 0) <= 0 then self.routine = ROUTINE_GAME_OVER - self.routineTimer = 128 + self.routineTimer = GAME_OVER_DELAY self.speedFixed = 0 self.ohNoBanner = true Sound.play(self.game and self.game.data, "Faint_Fall") return end - -- Scroll distance & generate wave columns (authentic 1:1 GB pace: distance += speedFixed) - self.distanceFixed = self.distanceFixed + self.speedFixed - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor(self.speedFixed * 0.25) - self:generateAhead() + self.waveRandomValue = self:getGBRandom() - -- Check if course goal reached (24 sections) - local distPx = math.floor(self.distanceFixed / 256) - if distPx >= (TOTAL_SECTIONS * 128) then - self.routine = ROUTINE_WAIT_RESULTS - self.routineTimer = 192 - self.waveFn = 0x72 - return + -- Pret RunGame order: SetPikachuHeight, ReadBGMapBuffer, Scroll, Distance, Deduct1HP + local objectHeight = self:pikaObjectHeight(self.bgMapReadTile) + self.pikaObjectHeightPx = objectHeight + if self.pikaState ~= PIKA_STATE_JUMPING then + self.pikaY = pikaSpriteYFromObjectHeight(objectHeight) + self.pikaSubY = 0 end + local preScrollScx = displayScx(self) + self.bgMapReadTile = self:sampleBgTileAt(preScrollScx, objectHeight) + + self:advanceScx(BG_SCROLL_STEP) + self:generateBgMapIfNeeded() + + self:updatePikachuDistance() + self.scrollFixed = self.distanceFixed + + -- SurfingMinigame_Deduct1HP (after UpdatePikachuDistance) + self.hp = self.hp - 1 -- Update Pikachu by state if self.pikaState == PIKA_STATE_RIDING then @@ -936,12 +1258,14 @@ function SurfingMinigame:tick() end elseif self.routine == ROUTINE_WAIT_RESULTS then - -- Coasting past the goal line - self.distanceFixed = self.distanceFixed + (2 * 256) - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25) - self:generateAhead() + if self.routineTimer > 0 then + -- RunDelayTimer then CoastAfterGoal (192 coast frames, not 193) + self.routineTimer = self.routineTimer - 1 + self:advanceScx(COAST_SPEED_FIXED) + self:generateBgMapIfNeeded() + self:resetTempo() + end - -- Run Pikachu state machine so mid-air jumps and wipeout crashes complete if self.pikaState == PIKA_STATE_JUMPING then self:updateJumping() elseif self.pikaState == PIKA_STATE_LANDING then @@ -949,101 +1273,101 @@ function SurfingMinigame:tick() elseif self.pikaState == PIKA_STATE_CRASHED then self:updateCrashed() else - -- Riding: follow water surface local targetY = self:seaY(80) self.pikaY = math.floor(targetY) - 16 self.pikaSubY = 0 self.frameSet = 4 end - if self.routineTimer > 0 then - self.routineTimer = self.routineTimer - 1 - end - - -- Only advance to scroll results when delay has finished AND Pikachu is upright - if self.routineTimer <= 0 and self.pikaState == PIKA_STATE_RIDING then + if self.routineTimer <= 0 then + -- pret .doneDelay: enter results scroll immediately (no wait for landing/crash) self.routine = ROUTINE_SCROLL_RESULTS - self.routineTimer = 36 + self.hScx = OUTRO_SCROLL_START + self.scxFrac = 0 + self.scxHi = 0 + self.scxLast = 0 + self.waveFn = 0x72 self.pikaState = PIKA_STATE_GAME_END end elseif self.routine == ROUTINE_SCROLL_RESULTS then - self.distanceFixed = self.distanceFixed + (1 * 256) - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((1 * 256) * 0.25) - self:generateAhead() - self.pikaY = math.floor(self:seaY(80)) - 16 - self.pikaSubY = 0 - self.frameSet = 4 - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if (self.hScx or 0) <= 0 then self.routine = ROUTINE_DRAW_RESULTS - self.routineTimer = 64 - self.pikaState = PIKA_STATE_RESULTS + self.pikaState = PIKA_STATE_INIT_RESULTS + else + self.hScx = self.hScx - OUTRO_SCROLL_STEP + self:generateBgMapIfNeeded() + local targetY = self:seaY(80) + self.pikaY = math.floor(targetY) - 16 + self.pikaSubY = 0 + self.frameSet = 4 end elseif self.routine == ROUTINE_DRAW_RESULTS then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then - self.routine = ROUTINE_WRITE_HP_LEFT - self.routineTimer = 32 - end + -- DrawResultsScreenAndWait: one-shot static beach tilemap + textbox frame + self:beginResultsCard() + self.routineTimer = 32 + self.routine = ROUTINE_WRITE_HP_LEFT elseif self.routine == ROUTINE_WRITE_HP_LEFT then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.hp = true + self.routineTimer = 64 self.routine = ROUTINE_WRITE_RADNESS - self.routineTimer = 32 end elseif self.routine == ROUTINE_WRITE_RADNESS then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.rad = true + self.routineTimer = 64 self.routine = ROUTINE_WRITE_TOTAL - self.routineTimer = 32 end elseif self.routine == ROUTINE_WRITE_TOTAL then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.total = true + self.routineTimer = 64 self.routine = ROUTINE_ADD_HP_TOTAL - self.tallyStep = 0 end elseif self.routine == ROUTINE_ADD_HP_TOTAL then - -- Tally remaining HP into total score (99 pts/frame matching ld c, 99) - if self.hp > 0 then + if not outroDelayExpired(self) then + -- waiting before tally starts + elseif self.hp > 0 then local step = math.min(self.hp, 99) self.hp = self.hp - step self.totalScore = self.totalScore + step Sound.play(self.game and self.game.data, "Press_AB") else + self.routineTimer = 64 self.routine = ROUTINE_ADD_RAD_TOTAL end elseif self.routine == ROUTINE_ADD_RAD_TOTAL then - -- Tally Radness into total score (99 pts/frame matching ld c, 99) - if self.radness > 0 then + if not outroDelayExpired(self) then + -- waiting before tally starts + elseif self.radness > 0 then local step = math.min(self.radness, 99) self.radness = self.radness - step self.totalScore = self.totalScore + step Sound.play(self.game and self.game.data, "Press_AB") else - self.routine = ROUTINE_WAIT_LAST - self.routineTimer = 64 - -- High score check self.newRecord = self.totalScore > ((self.game and self.game.save and self.game.save.surfingHighScore) or 0) if self.newRecord then if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end + self.outroLines.hiScore = true + self.pikaState = PIKA_STATE_RESULTS Sound.play(self.game and self.game.data, "Get_Item1") Sound.playPikaCry(self.game and self.game.data, 34) else Sound.playPikaCry(self.game and self.game.data, 28) end + self.routineTimer = GAME_OVER_DELAY + self.routine = ROUTINE_WAIT_LAST end elseif self.routine == ROUTINE_WAIT_LAST then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then self.routine = ROUTINE_EXIT_ON_PRESS_A end @@ -1060,6 +1384,26 @@ function SurfingMinigame:tick() if self.onDone then self.onDone(0) end end end + + if self.showResultsCard then + if self.pikaState == PIKA_STATE_RESULTS then + self:updateResultsPikachu() + else + self.pikaYOffset = 0 + end + end + + -- Pret SurfingPikachuLoop calls MoveClouds every frame while gameplay is active + if self.routine == ROUTINE_RUN_GAME + or self.routine == ROUTINE_WAIT_RESULTS + or self.routine == ROUTINE_GAME_OVER then + self:moveClouds() + end + + -- hFrameCounter decrements after game logic (VBlank) + if self.routine == ROUTINE_RUN_GAME and (self.joyFrameCounter or 0) > 0 then + self.joyFrameCounter = self.joyFrameCounter - 1 + end end -- Decoupled timestep accumulator for modern multi-refresh-rate displays @@ -1084,31 +1428,36 @@ end -- Draw scrolling wave background with authentic GPU shader HBlank wave distortion function SurfingMinigame:drawBackground() - local scx = math.floor(self.distanceFixed / 256) - local first = math.floor(scx / 16) + local scx = displayScx(self) local function renderTiles() love.graphics.setColor(1, 1, 1, 1) if not self.bg then return end - for c = first, first + 10 do - local col = self.cols[c] + for i = 0, 10 do + local col = self.bgMap[mapColScreen(scx, i)] + local x = i * 16 - (scx % 16) if col then - local x = c * 16 - scx - for i = 1, 8 do - local mt = BG_METATILES[col.pat[i]] + for row = 1, 8 do + local mt = BG_METATILES[col.pat[row]] if mt then - local y = (i - 1) * 16 - love.graphics.draw(self.bg, self.tq[mt[1]], x, y) - love.graphics.draw(self.bg, self.tq[mt[2]], x + 8, y) - love.graphics.draw(self.bg, self.tq[mt[3]], x, y + 8) - love.graphics.draw(self.bg, self.tq[mt[4]], x + 8, y + 8) + local y = (row - 1) * 16 + local function drawTile(tid, tx, ty) + if tid ~= 0x00 and self.tq[tid] then + love.graphics.draw(self.bg, self.tq[tid], tx, ty) + end + end + drawTile(mt[1], x, y) + drawTile(mt[2], x + 8, y) + drawTile(mt[3], x, y + 8) + drawTile(mt[4], x + 8, y + 8) end end end end end - if self.bgCanvas and self.waveShader and love.graphics.setCanvas and love.graphics.getCanvas then + if self.bgCanvas and self.waveShader and not isOutroScroll(self) and not self.showResultsCard + and love.graphics.setCanvas and love.graphics.getCanvas then -- 1. Capture the framework's active canvas local prevCanvas = love.graphics.getCanvas() @@ -1139,6 +1488,27 @@ function SurfingMinigame:drawBackground() end end +-- Draw intro title Pikachu (SurfingPikachu1Graphics3 via .IntroPikachu OAM) +function SurfingMinigame:drawIntroPikachu(cx, cy) + if not (self.intro and self.iq) then return end + local frame = math.floor(self.t / 7) % 4 + 1 + local vramBase = INTRO_PIKA_FRAME_BASE[frame] + local sheetBase = vramBase - 0x80 + for _, sp in ipairs(INTRO_PIKA_OAM) do + local tileId = sheetBase + sp.tile + local q = self.iq[tileId] + if q then + local x = cx + sp.dx + (sp.xflip and 8 or 0) + local y = cy + sp.dy + if sp.xflip then + love.graphics.draw(self.intro, q, x, y, 0, -1, 1) + else + love.graphics.draw(self.intro, q, x, y) + end + end + end +end + -- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy) function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY) local sx = flipX and -1 or 1 @@ -1164,48 +1534,38 @@ function SurfingMinigame:drawHUD() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 16) - -- Track progress line (tiles $15..$1c) + -- Window tilemap (SurfingPikachuMinigame_DrawStaticTilemapLayout): + -- row 0 cols 1-2: $15,$16 island; row 1 cols 1-9: $17,$18,$19×7; + -- row 1 cols 12-13: $1b,$1c "HP:"; digits are OAM sprites at X=$80. if self.bg and self.tq then - -- Top row (Y=128) love.graphics.draw(self.bg, self.tq[0x15], 8, BG_HEIGHT) love.graphics.draw(self.bg, self.tq[0x16], 16, BG_HEIGHT) - -- Bottom row (Y=136) love.graphics.draw(self.bg, self.tq[0x17], 8, BG_HEIGHT + 8) love.graphics.draw(self.bg, self.tq[0x18], 16, BG_HEIGHT + 8) - local trackTiles = { 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19 } - for i = 1, #trackTiles do - love.graphics.draw(self.bg, self.tq[trackTiles[i]], 16 + i * 8, BG_HEIGHT + 8) + for i = 1, 7 do + love.graphics.draw(self.bg, self.tq[0x19], 16 + i * 8, BG_HEIGHT + 8) end love.graphics.draw(self.bg, self.tq[0x1b], 96, BG_HEIGHT + 8) love.graphics.draw(self.bg, self.tq[0x1c], 104, BG_HEIGHT + 8) - - -- "HP:" label tiles on right (X=112, 120) - love.graphics.draw(self.bg, self.tq[0x20], 112, BG_HEIGHT + 8) - love.graphics.draw(self.bg, self.tq[0x21], 120, BG_HEIGHT + 8) end -- Mini-Pikachu progress marker (tile $fe) -- Game Boy: initial OAM X = $50 (80) = screen X 72, decrements by 2 per section. - -- 24 sections × 2 = 48 px total travel, ending at screen X 24. - -- We interpolate continuously across those same 48 pixels. - local distPx = math.floor(self.distanceFixed / 256) - local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128))) - local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset = screen X 72 - local markerTrack = 48 -- 24 sections × 2 px per section - local markerX = markerStartX - math.floor(progressRatio * markerTrack) + local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset + local markerX = markerStartX - (self.distanceSection or 0) * 2 if self.ob and self.oq and self.oq[0xfe] then love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6) end - -- 4 HP countdown digits starting at X=128 (right after HP:) + -- 4 HP countdown digits (OAM X $80..$98) local s = string.format("%04d", math.max(0, math.floor(self.hp))) for i = 1, 4 do local d = tonumber(s:sub(i, i)) or 0 if self.ob and self.oq and self.oq[0xd0 + d] then - love.graphics.draw(self.ob, self.oq[0xd0 + d], 120 + i * 8, BG_HEIGHT + 8) + love.graphics.draw(self.ob, self.oq[0xd0 + d], 128 + (i - 1) * 8, BG_HEIGHT + 8) else - Font.draw(tostring(d), 120 + i * 8, BG_HEIGHT + 8) + Font.draw(tostring(d), 128 + (i - 1) * 8, BG_HEIGHT + 8) end end end @@ -1248,29 +1608,27 @@ function SurfingMinigame:drawResultsOutro() end drawBoxRow(9, 0x3d, 0x40, 0x3e) - -- Text lines matching Game Boy screen memory coordinates (cols 2, 10, 15; rows 2, 4, 6, 8) - Font.draw(Strings("HP Left"), 16, 16) - if self.routine >= ROUTINE_WRITE_HP_LEFT then + -- Text lines appear as each Write* routine fires in pret + if self.outroLines.hp then + Font.draw(Strings("HP Left"), 16, 16) Font.draw(string.format("%04d", self.hp), 80, 16) Font.draw(Strings("Pts"), 120, 16) end - if self.routine >= ROUTINE_WRITE_RADNESS then + if self.outroLines.rad then Font.draw(Strings("Radness"), 16, 32) Font.draw(string.format("%04d", self.radness), 80, 32) Font.draw(Strings("Pts"), 120, 32) end - if self.routine >= ROUTINE_WRITE_TOTAL then + if self.outroLines.total then Font.draw(Strings("Total"), 16, 48) Font.draw(string.format("%04d", self.totalScore), 80, 48) Font.draw(Strings("Pts"), 120, 48) end - if self.routine >= ROUTINE_WAIT_LAST then - if self.newRecord then - Font.draw(Strings("Hi-Score!!"), 48, 64) - end + if self.outroLines.hiScore then + Font.draw(Strings("Hi-Score!!"), 48, 64) end love.graphics.setColor(1, 1, 1, 1) end @@ -1287,18 +1645,8 @@ function SurfingMinigame:drawTitleScreen() Font.draw("PIKACHU'S BEACH", 20, 32) end - -- 2. Draw 3x3 Pikachu intro sprite on the water using authentic OAM tile indices - if self.ob and self.oq then - local animBase = (math.floor(self.t / 32) % 2 == 0) and 0x0c or 0x09 - self:draw3x3(animBase, 80, 100, false, false) - end - - -- 3. High score display at bottom - Font.draw(string.format("Hi-Score %4d Pt", self.hiScore), 16, 120) - - if math.floor(self.t / 30) % 2 == 0 then - Font.draw("PRESS START", 36, 132) - end + -- 2. Intro Pikachu paddling out (SurfingPikachu1Graphics3 / surf_1c.png) + self:drawIntroPikachu(self.introPikaX, FLAT_WATER_Y) end function SurfingMinigame:draw() @@ -1310,26 +1658,31 @@ function SurfingMinigame:draw() return end - if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then + if self.showResultsCard then self:drawResultsOutro() + self:drawResultsPikachu() + self:drawHUD() return end -- Draw scrolling BG waves self:drawBackground() - -- Parallax clouds in sky (scrolling left at uniform 0.25x camera speed) - local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256) - local c1x = (160 - (cloudOffsetPx % 200)) - 40 - local c2x = (240 - (cloudOffsetPx % 200)) - 40 - if self.ob and self.oq then - -- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef) - for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do - love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12) + -- Parallax clouds (SurfingMinigame_MoveClouds: 9 OAM sprites, 8-bit X wrap) + if self.ob and self.oq and self.cloudSpriteX then + local wideTiles = { 0xec, 0xed, 0xed, 0xee, 0xef } + for i, tid in ipairs(wideTiles) do + local x = self.cloudSpriteX[i] + if x < 168 then + love.graphics.draw(self.ob, self.oq[tid], x, 12) + end end - -- Narrow cloud (4 tiles: $ec, $ed, $ee, $ef) - for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do - love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20) + local narrowTiles = { 0xec, 0xed, 0xee, 0xef } + for i, tid in ipairs(narrowTiles) do + local x = self.cloudSpriteX[i + 5] + if x < 168 then + love.graphics.draw(self.ob, self.oq[tid], x, 20) + end end end -- Helper function to draw OAM multi-sprite composite objects with palette and X-flipping @@ -1388,8 +1741,8 @@ function SurfingMinigame:draw() Font.draw(p.text, p.x, p.y) end - -- "START" banner - if self.routine == ROUTINE_START_GAME then + -- "START" banner (slides in during early RunGame frames) + if self.startBannerX > 80 then if self.ob and self.oq then for r = 0, 1 do for c = 0, 5 do @@ -1418,8 +1771,14 @@ end function SurfingMinigame:sgbPalettes(game) local P = require("src.render.PaletteFX") - local pal = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH")) or PIKACHUS_BEACH_PAL - return { P.whole(pal) } + local beach = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH")) or PIKACHUS_BEACH_PAL + if self.routine == ROUTINE_TITLE then + local title = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH_TITLE")) + or PIKACHUS_BEACH_TITLE_PAL + -- SurfingMinigame_TitleTilemap at (4,0), 12x6; ATTR_BLK pal 1 over that rect. + return { P.whole(beach), P.zone(title, 4, 0, 15, 5) } + end + return { P.whole(beach) } end return SurfingMinigame diff --git a/tests/test_surfing_minigame.lua b/tests/test_surfing_minigame.lua index af4cb669..b3d1a10a 100644 --- a/tests/test_surfing_minigame.lua +++ b/tests/test_surfing_minigame.lua @@ -44,18 +44,22 @@ print("Running SurfingMinigame unit tests...") local mg = SurfingMinigame.new(mockGame) assert_eq(mg.routine, -1, "Initial routine must be ROUTINE_TITLE (-1)") mg:startFromTitle() -assert_eq(mg.routine, 0, "Routine must advance to ROUTINE_START_GAME (0) after startFromTitle()") +assert_eq(mg.routine, 0, "Routine must be ROUTINE_START_GAME (0) after startFromTitle()") assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)") assert_eq(mg.speed, 0.25, "Initial speed must be 0.25") assert_eq(mg.distance, 0, "Initial distance must be 0") assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)") print("✓ Initial state & Title transition test passed") --- Test 2: Start banner transition to RunGame -for _ = 1, 40 do +-- Test 2: StartGame one-shot then RunGame; banner slides during play (pret-accurate) +mg:update() +assert_eq(mg.routine, 1, "First tick should advance to ROUTINE_RUN_GAME (1)") +assert_true(mg.startBannerX > 80, "START banner should begin off-screen") +for _ = 1, 36 do mg:update() end -assert_eq(mg.routine, 1, "Routine should advance to ROUTINE_RUN_GAME (1)") +assert_eq(mg.routine, 1, "Routine should remain ROUTINE_RUN_GAME (1) while banner slides") +assert_eq(mg.startBannerX, 80, "START banner should finish centered after 36 frames") print("✓ Start banner transition test passed") -- Test 3: Automatic acceleration and HP countdown @@ -68,7 +72,7 @@ print("✓ Auto acceleration and HP countdown test passed") -- Test 4: Landing Evaluation Matrix local old_getWaveTile = mg.getWaveTileUnderPika -mg.getWaveTileUnderPika = function() return 0x01 end -- force open water +mg.getWaveTileUnderPika = function() return 0x01 end -- force open water (flat branch) mg.frameSet = 5 assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing") mg.frameSet = 6 @@ -82,6 +86,17 @@ for f = 8, 14 do assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout") end mg.getWaveTileUnderPika = old_getWaveTile +-- TileInteraction keys off chr tile ids, not pattern metatile ids +mg.getWaveTileUnderPika = function() return 0x06 end +mg.frameSet = 6 +assert_eq(mg:evaluateLanding(), "clean", "Frame 6 on chr tile $06 rising slope must be clean") +mg.getWaveTileUnderPika = function() return 0x0b end +mg.frameSet = 6 +assert_eq(mg:evaluateLanding(), "hard", "Frame 6 on chr tile $0b open water must be hard") +mg.getWaveTileUnderPika = function() return 0x08 end +mg.frameSet = 6 +assert_eq(mg:evaluateLanding(), "hard", "Frame 6 on chr tile $08 foam must use flat rules (hard)") +mg.getWaveTileUnderPika = old_getWaveTile print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)") -- Test 5: Stunt Scoring @@ -129,13 +144,15 @@ assert_eq(mg.pikaState, 0, "Pikachu should recover and return to PIKA_STATE_RIDI print("✓ Wipeout crash recovery test passed") -- Test 7: Results tally countdown sequence +mg.showResultsCard = true mg.routine = 7 -- ROUTINE_WRITE_TOTAL mg.hp = 100 mg.radness = 200 mg.totalScore = 0 -mg.routineTimer = 1 +mg.routineTimer = 0 mg:update() assert_eq(mg.routine, 8, "Routine should advance to ROUTINE_ADD_HP_TOTAL (8)") +mg.routineTimer = 0 -- pret waits 64 frames before tally; skip for unit test while mg.routine == 8 do mg:update() @@ -143,6 +160,7 @@ end assert_eq(mg.hp, 0, "HP should be tallied down to 0") assert_eq(mg.totalScore, 100, "Total score should include 100 from HP") assert_eq(mg.routine, 9, "Routine should advance to ROUTINE_ADD_RAD_TOTAL (9)") +mg.routineTimer = 0 while mg.routine == 9 do mg:update() @@ -153,7 +171,8 @@ assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)") -- Test 8: Crossing finish line while jumping upside-down crashes into water and rights Pikachu before results local mg8 = SurfingMinigame.new(mockGame, nil, true) mg8.routine = 1 -- ROUTINE_RUN_GAME -mg8.distanceFixed = (24 * 128 - 2) * 256 +mg8.distanceSection = 24 +mg8.distanceAcc = 0 mg8.speedFixed = 512 mg8.pikaState = 1 -- PIKA_STATE_JUMPING mg8.frameSet = 11 -- Upside down @@ -193,7 +212,8 @@ print("✓ Mid-air upside-down finish line crossing crash & recovery test passed -- Test 9: Crossing finish line while upright jumping lands cleanly and proceeds local mg9 = SurfingMinigame.new(mockGame, nil, true) mg9.routine = 1 -mg9.distanceFixed = (24 * 128 - 2) * 256 +mg9.distanceSection = 24 +mg9.distanceAcc = 0 mg9.speedFixed = 512 mg9.pikaState = 1 mg9.frameSet = 4 -- Clean flat @@ -221,7 +241,8 @@ print("✓ Mid-air upright finish line crossing test passed") -- Test 10: Crossing finish line while already crashed recovers before results local mg10 = SurfingMinigame.new(mockGame, nil, true) mg10.routine = 1 -mg10.distanceFixed = (24 * 128 - 2) * 256 +mg10.distanceSection = 24 +mg10.distanceAcc = 0 mg10.speedFixed = 512 mg10.pikaState = 3 -- PIKA_STATE_CRASHED mg10.crashTimer = 50 @@ -260,12 +281,39 @@ mg12.landingTimer = 20 mg12.speedFixed = 256 -- Place on a rising wave pattern mg12.cols[5] = { pat = SurfingMinigame.WAVE_PATTERNS[0x06], hl = 110, hr = 100 } +mg12.bgMap[5] = mg12.cols[5] +mg12.waveHeight[8] = 110 +mg12.waveHeight[9] = 100 +mg12.bgMapReadTile = 0x06 +mg12.advanceScx = function() end +mg12.generateBgMapIfNeeded = function() end mg12.distanceFixed = (5 * 16 - 80) * 256 local startY = mg12.pikaY mg12:update() assert(mg12.pikaY ~= startY, "pikaY must continuously follow wave surface height while in PIKA_STATE_LANDING") print("✓ Landing slope height tracking continuity test passed") +-- Test 12b: Clean flat landing preserves speed; rough/hard use pret penalties +local mg12b = SurfingMinigame.new(mockGame, nil, true) +mg12b.routine = 1 +mg12b.frameSet = 4 +mg12b.bgMapReadTile = 0x0b +mg12b.speedFixed = 320 +local cleanSpeed = mg12b.speedFixed +mg12b:handleLanding() +assert_eq(mg12b.speedFixed, cleanSpeed, "Clean flat landing must not reduce speed") +mg12b.speedFixed = 320 +mg12b.frameSet = 5 +mg12b.bgMapReadTile = 0x0b +mg12b:handleLanding() +assert_eq(mg12b.speedFixed, 320 - 64, "Rough flat landing must reduce speed by 0.25") +mg12b.speedFixed = 96 +mg12b.frameSet = 6 +mg12b.bgMapReadTile = 0x0b +mg12b:handleLanding() +assert_eq(mg12b.speedFixed, 0, "Hard landing below 0.5 must zero speed (pret underflow guard)") +print("✓ Landing speed penalty test passed") + -- Test 13: Fixed speed enforcement (minigames must always run at 1X speed) assert(mg12.isFixedSpeed == true, "SurfingMinigame must have isFixedSpeed flag enabled") assert(mg12.isMinigame == true, "SurfingMinigame must have isMinigame flag enabled") @@ -274,4 +322,91 @@ local Game = require("src.core.Game") assert(Game.isFixedSpeedInStack(mockStack) == true, "Game.isFixedSpeedInStack must return true for SurfingMinigame") print("✓ Minigame fixed speed enforcement test passed") +-- Test 14: Course duration matches pret distance model (~24 sections, 6000 HP cap) +local mg14 = SurfingMinigame.new(mockGame, nil, true) +mg14.routine = 1 +local runFrames = 0 +for _ = 1, 7000 do + mg14:update() + runFrames = runFrames + 1 + if mg14.distanceSection >= 24 then break end +end +assert(runFrames >= 2500 and runFrames <= 5500, + "Full course should finish in pret-like frame window (got " .. runFrames .. ")") +assert(mg14.hp > 0, "Typical run should reach shore before HP timer expires") +print("✓ Course duration window test passed (" .. runFrames .. " frames)") + +-- Test 15: Pret 2-frame joy sampling allows triple flips at max jump arc +mockInput.keysDown = { left = true } +local mg15 = SurfingMinigame.new(mockGame, nil, true) +mg15.routine = 1 +mg15.speedFixed = 512 +mg15.pikaState = 1 +mg15.jumpArcMagnitude = 16 +mg15.jumpArcFraction = 0 +mg15.jumpDescending = false +mg15.pikaY = 84 +mg15.frameSet = 4 +mg15.radnessMeter = 0 +mg15.trickFlags = 0 +local airFrames = 0 +while mg15.pikaState == 1 and airFrames < 200 do + mg15:update() + airFrames = airFrames + 1 +end +assert_true(mg15.radnessMeter >= 3, + "Max-arc jump with held left must register at least 3 flips (got " .. tostring(mg15.radnessMeter) .. ")") +assert_true(airFrames >= 60, "Max-arc jump should stay airborne long enough for triple flips") +print("✓ Triple flip airtime and joy sampling test passed") + +-- Test 16: Pret music tempo tiers (index = high byte of ((speed & $3ff) << 1)) +local mg16 = SurfingMinigame.new(mockGame, nil, true) +mg16.routine = 1 +local tempoCases = { + { speed = 64, tier = 1 }, + { speed = 127, tier = 1 }, + { speed = 128, tier = 2 }, + { speed = 255, tier = 2 }, + { speed = 256, tier = 3 }, + { speed = 383, tier = 3 }, + { speed = 384, tier = 4 }, + { speed = 511, tier = 4 }, + { speed = 512, tier = 5 }, +} +for _, c in ipairs(tempoCases) do + mg16.speedFixed = c.speed + mg16:updateTempo() + local wantPitch = ({ 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 })[c.tier] + assert_eq(mg16.currentPitch, wantPitch, + string.format("Tempo tier %d at speed %d/256", c.tier, c.speed)) +end +print("✓ Pret music tempo tier boundaries test passed") + +-- Test 17: GetJoypad_3FrameBuffer duty cycle (hFrameCounter reload $2 → sample when counter hits 0) +mockInput.keysDown = { left = true } +local mg17 = SurfingMinigame.new(mockGame, nil, true) +mg17.routine = 1 +local samples = 0 +for _ = 1, 9 do + mg17:update() + if mg17.joy5Left then samples = samples + 1 end +end +-- Counter reloads to 2 then VBlank decrements twice → active on frames 1,3,5,7,9 of each 9-frame window +assert_eq(samples, 5, "Held input must register 5 sample frames per 9 ticks (got " .. samples .. ")") +print("✓ Pret joypad 3-frame buffer duty cycle test passed") + +-- Test 18: Results card keeps Pikachu on the beach (pret DrawResultsScreen + UpdateResultsPikachu) +local mg18 = SurfingMinigame.new(mockGame, nil, true) +mg18:beginResultsCard() +assert_eq(mg18.pikaY, 116 - 16, "Results Pikachu Y must anchor at flat waterline") +assert_eq(mg18.frameSet, 4, "Results pose must use flat ride frameset") +assert_true(mg18.showResultsCard, "Results card must be active") +assert_eq(mg18.cloudSpriteX, nil, "Cloud OAM must be cleared on results screen") +mg18.pikaState = 6 -- PIKA_STATE_RESULTS (pret sets this on hi-score) +mg18.resultsBobTimer = 62 +mg18:updateResultsPikachu() +assert_true(mg18.pikaYOffset ~= 0 or mg18.resultsBobTimer >= 64, + "Results bob must run once PIKA_STATE_RESULTS is active") +print("✓ Results beach Pikachu visibility test passed") + print("All SurfingMinigame unit tests passed successfully!") diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 31e06cca..2e1f876f 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -2064,10 +2064,14 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): raw_2bpp("SurfingPikachu1Graphics2", 128, 128, "minigame/surf_1b.png", transparent=True) raw_2bpp("SurfingPikachu1Graphics3", 96, 96, "minigame/surf_1c.png", transparent=True) - beach_intro = rom.bytes(62, 0x50bc, 240) - use_ctrl_pad = rom.bytes(62, 0x51ac, 15) - to_surf_rad = rom.bytes(62, 0x51bb, 13) - title_map = rom.bytes(62, 0x51c8, 72) + beach_sym = _symbol(symbols, "SurfingMinigame_BeachIntroTilemap") + use_ctrl_sym = _symbol(symbols, "SurfingMinigame_UseControlPadTilemap") + to_surf_sym = _symbol(symbols, "SurfingMinigame_ToSurfRadTilemap") + title_sym = _symbol(symbols, "SurfingMinigame_TitleTilemap") + beach_intro = rom.bytes(beach_sym.bank, beach_sym.address, 240) + use_ctrl_pad = rom.bytes(use_ctrl_sym.bank, use_ctrl_sym.address, 15) + to_surf_rad = rom.bytes(to_surf_sym.bank, to_surf_sym.address, 13) + title_map = rom.bytes(title_sym.bank, title_sym.address, 72) screen = [0xff] * (20 * 18) for i in range(240): screen[6 * 20 + i] = beach_intro[i] diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 843f7d36..bcf70752 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -98,6 +98,12 @@ YELLOW_EXTRA_SYMBOLS = ( "SurfingPikachu1Graphics1", "SurfingPikachu1Graphics2", "SurfingPikachu1Graphics3", + # Surfing Pikachu title screen tilemaps + # (engine/minigame/surfing_pikachu.asm DrawSurfingPikachuMinigameIntroBackground) + "SurfingMinigame_BeachIntroTilemap", + "SurfingMinigame_TitleTilemap", + "SurfingMinigame_ToSurfRadTilemap", + "SurfingMinigame_UseControlPadTilemap", # Oak's own battle back pic. LoadPlayerBackPic (engine/battle/core.asm) # picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for # BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). @@ -505,6 +511,41 @@ def derive(red, pokeyellow, symbols_path): if "SUMMER_BEACH_HOUSE" not in locations and "ROUTE_19" in locations: locations["SUMMER_BEACH_HOUSE"] = dict(locations["ROUTE_19"]) + # Surfing Pikachu minigame asset index (src/ui/SurfingMinigame.lua). + yellow["field"]["surfingPikachu"] = { + "music": "Music_SurfingPikachu", + "sheets": { + "bg": { + "path": "assets/generated/minigame/surf_1a.png", + "width": 40, "height": 104, + }, + "oam": { + "path": "assets/generated/minigame/surf_1b.png", + "width": 128, "height": 128, + }, + "intro": { + "path": "assets/generated/minigame/surf_1c.png", + "width": 96, "height": 96, + "introPikaFrames": [ + "assets/generated/minigame/intro_pika_0.png", + "assets/generated/minigame/intro_pika_1.png", + "assets/generated/minigame/intro_pika_2.png", + "assets/generated/minigame/intro_pika_3.png", + ], + "source": "data/sprite_anims/surfing_pikachu_oam.asm .IntroPikachu", + }, + "titleBg": { + "path": "assets/generated/minigame/title_bg.png", + "width": 160, "height": 144, + }, + }, + "source": ( + "engine/minigame/surfing_pikachu.asm " + "(DrawSurfingPikachuMinigameIntroBackground), " + "gfx/surfing_pikachu.asm" + ), + } + meta = { "aliasCount": alias_hits, "omittedIntro": list(OMIT_INTRO_SYMBOLS), diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9fa9e696..ed4d489c 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -8861,6 +8861,39 @@ } ] }, + "surfingPikachu": { + "music": "Music_SurfingPikachu", + "sheets": { + "bg": { + "height": 104, + "path": "assets/generated/minigame/surf_1a.png", + "width": 40 + }, + "intro": { + "height": 96, + "introPikaFrames": [ + "assets/generated/minigame/intro_pika_0.png", + "assets/generated/minigame/intro_pika_1.png", + "assets/generated/minigame/intro_pika_2.png", + "assets/generated/minigame/intro_pika_3.png" + ], + "path": "assets/generated/minigame/surf_1c.png", + "source": "data/sprite_anims/surfing_pikachu_oam.asm .IntroPikachu", + "width": 96 + }, + "oam": { + "height": 128, + "path": "assets/generated/minigame/surf_1b.png", + "width": 128 + }, + "titleBg": { + "height": 144, + "path": "assets/generated/minigame/title_bg.png", + "width": 160 + } + }, + "source": "engine/minigame/surfing_pikachu.asm (DrawSurfingPikachuMinigameIntroBackground), gfx/surfing_pikachu.asm" + }, "tilePairs": { "land": [ { @@ -26288,6 +26321,22 @@ 32, 25380 ], + "SurfingMinigame_BeachIntroTilemap": [ + 62, + 20668 + ], + "SurfingMinigame_TitleTilemap": [ + 62, + 20936 + ], + "SurfingMinigame_ToSurfRadTilemap": [ + 62, + 20923 + ], + "SurfingMinigame_UseControlPadTilemap": [ + 62, + 20908 + ], "SurfingPikachuSprite": [ 63, 28143