Files
gen1recomp/src/ui/SurfingMinigame.lua
T
2026-08-24 17:30:09 -05:00

1785 lines
65 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Surfing Pikachu minigame (engine/minigame/surfing_pikachu.asm): the
-- Summer Beach House wave run. Pikachu accelerates automatically down
-- the wave face, launches off the crest, spins in the air with buffered
-- D-pad controls, and lands to match the wave slope for points.
--
-- 1:1 Love2D port of the authentic Pokémon Yellow disassembly:
-- - Exact 13-stage routine state machine (Start, Run, Coast, Outro, Tally, Hi-Score, Game Over)
-- - Rigid 16.8 fixed-point integer physics (no float drift; speed += 2 per frame, 512 max)
-- - 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 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
-- - HP stamina timer counting down from 6000 BCD (60.00s)
-- - HUD progress track with mini-Pikachu marker advancing across 24 sections
-- - Animated "START" and "Oh no.." banners, floating trick score popups, and water sprays
-- - Dynamic 5-tier music tempo tracking Pikachu's speed
-- - Full beach outro results scene with step-by-step tally animation and high-score fanfare
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local bit = require("bit")
local SurfingMinigame = {}
SurfingMinigame.__index = function(t, k)
if k == "phase" then
local r = rawget(t, "routine")
if r and r >= 4 then
return "results"
elseif rawget(t, "pikaState") == 1 then
return "air"
elseif rawget(t, "pikaState") == 3 then
return "wipeout"
else
return "ride"
end
elseif k == "speed" then
return (rawget(t, "speedFixed") or 64) / 256
elseif k == "distance" then
return (rawget(t, "distanceFixed") or 0) / 256
elseif k == "score" then
local tot = rawget(t, "totalScore") or 0
if tot > 0 then return tot end
return (rawget(t, "radness") or 0) + (rawget(t, "hp") or 0)
end
return SurfingMinigame[k]
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 (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 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 -- 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
local ROUTINE_START_GAME = 0
local ROUTINE_RUN_GAME = 1
local ROUTINE_WAIT_RESULTS = 2
local ROUTINE_SCROLL_RESULTS = 3
local ROUTINE_DRAW_RESULTS = 4
local ROUTINE_WRITE_HP_LEFT = 5
local ROUTINE_WRITE_RADNESS = 6
local ROUTINE_WRITE_TOTAL = 7
local ROUTINE_ADD_HP_TOTAL = 8
local ROUTINE_ADD_RAD_TOTAL = 9
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
local PIKA_STATE_LANDING = 2
local PIKA_STATE_CRASHED = 3
local PIKA_STATE_GAME_END = 4
local PIKA_STATE_INIT_RESULTS = 5
local PIKA_STATE_RESULTS = 6
-- Metatile lookup (2x2 tiles each)
local BG_METATILES = {
[0x00] = { 0x00, 0x00, 0x00, 0x00 }, -- sky block (blank)
[0x01] = { 0x0b, 0x0b, 0x0b, 0x0b }, -- open water
[0x02] = { 0x0b, 0x02, 0x02, 0x06 },
[0x03] = { 0x03, 0x0b, 0x07, 0x03 },
[0x04] = { 0x06, 0x06, 0x06, 0x06 },
[0x05] = { 0x07, 0x07, 0x07, 0x07 },
[0x06] = { 0x06, 0x04, 0x04, 0x08 },
[0x07] = { 0x05, 0x07, 0x08, 0x05 },
[0x08] = { 0x0b, 0x0b, 0x11, 0x12 },
[0x09] = { 0x0b, 0x0b, 0x13, 0x03 },
[0x0a] = { 0x14, 0x12, 0x04, 0x08 },
[0x0b] = { 0x13, 0x07, 0x08, 0x05 },
[0x0c] = { 0x06, 0x14, 0x06, 0x14 },
[0x0d] = { 0x13, 0x07, 0x13, 0x07 },
[0x0e] = { 0x08, 0x08, 0x08, 0x08 }, -- solid blue
[0x0f] = { 0x14, 0x12, 0x14, 0x12 },
[0x10] = { 0x0b, 0x11, 0x02, 0x14 },
[0x11] = { 0x06, 0x14, 0x06, 0x14 },
[0x12] = { 0x0c, 0x0c, 0x0d, 0x0d }, -- beach top block
[0x13] = { 0x0d, 0x0d, 0x0d, 0x0d }, -- beach sand block
[0x14] = { 0x0e, 0x0f, 0x10, 0x0b }, -- beach shore block
[0x15] = { 0x12, 0x13, 0x12, 0x13 },
}
-- Wave pattern slices (8 metatiles each)
local WAVE_PATTERNS = {
[0x00] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01 },
[0x01] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x02] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e },
[0x03] = { 0x00, 0x00, 0x00, 0x10, 0x11, 0x06, 0x0e, 0x0e },
[0x04] = { 0x00, 0x00, 0x00, 0x15, 0x15, 0x0e, 0x0e, 0x0e },
[0x05] = { 0x00, 0x00, 0x00, 0x03, 0x05, 0x07, 0x0e, 0x0e },
[0x06] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x07] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x08] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x09] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e },
[0x0a] = { 0x00, 0x00, 0x00, 0x08, 0x0f, 0x0a, 0x0e, 0x0e },
[0x0b] = { 0x00, 0x00, 0x00, 0x09, 0x0d, 0x0b, 0x0e, 0x0e },
[0x0c] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x0d] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x0e] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x0f] = { 0x00, 0x00, 0x00, 0x01, 0x10, 0x11, 0x06, 0x0e },
[0x10] = { 0x00, 0x00, 0x00, 0x01, 0x15, 0x15, 0x0e, 0x0e },
[0x11] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x12] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x13] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x14] = { 0x00, 0x00, 0x00, 0x01, 0x08, 0x0f, 0x0a, 0x0e },
[0x15] = { 0x00, 0x00, 0x00, 0x01, 0x09, 0x0d, 0x0b, 0x0e },
[0x16] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x17] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x10, 0x11, 0x06 },
[0x18] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x15, 0x15, 0x0e },
[0x19] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x1a] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x08, 0x0f, 0x0a },
[0x1b] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x09, 0x0d, 0x0b },
[0x1c] = { 0x00, 0x00, 0x00, 0x14, 0x14, 0x14, 0x14, 0x14 },
beach = { 0x00, 0x00, 0x00, 0x12, 0x13, 0x13, 0x13, 0x13 },
}
local ADV, RESET, STAY = 0, 1, 2
local WAVE_STEPS = {
[0x01] = { 0x13, 116, 108, ADV }, [0x02] = { 0x14, 100, 92, ADV },
[0x03] = { 0x15, 92, 92, ADV }, [0x04] = { 0x16, 100, 108, ADV },
[0x05] = { 0x00, 116, 116, ADV }, [0x06] = { 0x17, 116, 108, ADV },
[0x07] = { 0x18, 100, 100, ADV }, [0x08] = { 0x19, 100, 108, ADV },
[0x09] = { 0x00, 116, 116, ADV }, [0x0a] = { 0x00, 116, 116, ADV },
[0x0b] = { 0x00, 116, 116, ADV }, [0x0c] = { 0x00, 116, 116, ADV },
[0x0d] = { 0x00, 116, 116, RESET },
[0x0e] = { 0x08, 116, 108, ADV }, [0x0f] = { 0x09, 100, 92, ADV },
[0x10] = { 0x0a, 84, 76, ADV }, [0x11] = { 0x0b, 76, 76, ADV },
[0x12] = { 0x0c, 84, 92, ADV }, [0x13] = { 0x0d, 100, 108, ADV },
[0x14] = { 0x00, 116, 116, ADV }, [0x15] = { 0x00, 116, 116, ADV },
[0x16] = { 0x00, 116, 116, ADV }, [0x17] = { 0x00, 116, 116, ADV },
[0x18] = { 0x00, 116, 116, ADV }, [0x19] = { 0x00, 116, 116, RESET },
[0x1a] = { 0x0e, 116, 108, ADV }, [0x1b] = { 0x0f, 100, 92, ADV },
[0x1c] = { 0x10, 84, 84, ADV }, [0x1d] = { 0x11, 84, 92, ADV },
[0x1e] = { 0x12, 100, 108, ADV }, [0x1f] = { 0x0e, 116, 108, ADV },
[0x20] = { 0x0f, 100, 92, ADV }, [0x21] = { 0x10, 84, 84, ADV },
[0x22] = { 0x11, 84, 92, ADV }, [0x23] = { 0x12, 100, 108, ADV },
[0x24] = { 0x00, 116, 116, ADV }, [0x25] = { 0x00, 116, 116, ADV },
[0x26] = { 0x00, 116, 116, ADV }, [0x27] = { 0x00, 116, 116, ADV },
[0x28] = { 0x00, 116, 116, RESET },
[0x29] = { 0x13, 116, 108, ADV }, [0x2a] = { 0x14, 100, 92, ADV },
[0x2b] = { 0x15, 92, 92, ADV }, [0x2c] = { 0x16, 100, 108, ADV },
[0x2d] = { 0x00, 116, 116, ADV }, [0x2e] = { 0x00, 116, 116, ADV },
[0x2f] = { 0x00, 116, 116, ADV }, [0x30] = { 0x00, 116, 116, ADV },
[0x31] = { 0x00, 116, 116, RESET },
[0x32] = { 0x17, 116, 108, ADV }, [0x33] = { 0x18, 100, 100, ADV },
[0x34] = { 0x19, 100, 108, ADV }, [0x35] = { 0x17, 116, 108, ADV },
[0x36] = { 0x18, 100, 100, ADV }, [0x37] = { 0x19, 100, 108, ADV },
[0x38] = { 0x17, 116, 108, ADV }, [0x39] = { 0x18, 100, 100, ADV },
[0x3a] = { 0x19, 100, 108, ADV }, [0x3b] = { 0x00, 116, 116, ADV },
[0x3c] = { 0x00, 116, 116, ADV }, [0x3d] = { 0x00, 116, 116, ADV },
[0x3e] = { 0x00, 116, 116, ADV }, [0x3f] = { 0x00, 116, 116, RESET },
[0x40] = { 0x1a, 116, 108, ADV }, [0x41] = { 0x1b, 108, 108, ADV },
[0x42] = { 0x0e, 116, 108, ADV }, [0x43] = { 0x0f, 100, 92, ADV },
[0x44] = { 0x10, 84, 84, ADV }, [0x45] = { 0x11, 84, 92, ADV },
[0x46] = { 0x12, 100, 108, ADV }, [0x47] = { 0x1a, 116, 108, ADV },
[0x48] = { 0x1b, 108, 108, ADV }, [0x49] = { 0x00, 116, 116, ADV },
[0x4a] = { 0x00, 116, 116, ADV }, [0x4b] = { 0x00, 116, 116, ADV },
[0x4c] = { 0x00, 116, 116, RESET },
[0x4d] = { 0x08, 116, 108, ADV }, [0x4e] = { 0x09, 100, 92, ADV },
[0x4f] = { 0x0a, 84, 76, ADV }, [0x50] = { 0x0b, 76, 76, ADV },
[0x51] = { 0x0c, 84, 92, ADV }, [0x52] = { 0x0d, 100, 108, ADV },
[0x53] = { 0x00, 116, 116, ADV }, [0x54] = { 0x1a, 116, 108, ADV },
[0x55] = { 0x1b, 108, 108, ADV }, [0x56] = { 0x1a, 116, 108, ADV },
[0x57] = { 0x1b, 108, 108, ADV }, [0x58] = { 0x00, 116, 116, ADV },
[0x59] = { 0x00, 116, 116, ADV }, [0x5a] = { 0x00, 116, 116, ADV },
[0x5b] = { 0x00, 116, 116, RESET },
[0x5c] = { 0x0e, 116, 108, ADV }, [0x5d] = { 0x0f, 100, 92, ADV },
[0x5e] = { 0x10, 84, 84, ADV }, [0x5f] = { 0x11, 84, 92, ADV },
[0x60] = { 0x12, 100, 108, ADV }, [0x61] = { 0x13, 116, 108, ADV },
[0x62] = { 0x14, 100, 92, ADV }, [0x63] = { 0x15, 92, 92, ADV },
[0x64] = { 0x16, 100, 108, ADV }, [0x65] = { 0x00, 116, 116, ADV },
[0x66] = { 0x00, 116, 116, ADV }, [0x67] = { 0x00, 116, 116, ADV },
[0x68] = { 0x00, 116, 116, ADV }, [0x69] = { 0x00, 116, 116, RESET },
[0x6a] = { 0x01, 116, 108, ADV }, [0x6b] = { 0x02, 100, 92, ADV },
[0x6c] = { 0x03, 84, 76, ADV }, [0x6d] = { 0x04, 68, 68, ADV },
[0x6e] = { 0x05, 68, 76, ADV }, [0x6f] = { 0x06, 84, 92, ADV },
[0x70] = { 0x07, 100, 108, ADV }, [0x71] = { 0x00, 116, 116, STAY },
[0x72] = { 0x00, 116, 116, ADV }, [0x73] = { 0x1c, 116, 116, ADV },
[0x74] = { "beach", 116, 116, ADV }, [0x75] = { "beach", 116, 116, ADV },
[0x76] = { "beach", 116, 116, ADV }, [0x77] = { "beach", 116, 116, ADV },
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET },
}
local SEQ_STARTS = {
0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c
}
SurfingMinigame.BG_METATILES = BG_METATILES
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
SurfingMinigame.WAVE_STEPS = WAVE_STEPS
-- Pikachu base frames: 7 visual angles x 2 animation toggle frames each
local ANGLE_BASES = {
[1] = { 0x00, 0x36 }, -- Angle 00 (nose up steep / backflip apex)
[2] = { 0x03, 0x39 }, -- Angle 01 (nose up moderate)
[3] = { 0x06, 0x3c }, -- Angle 02 (nose up slight)
[4] = { 0x09, 0x60 }, -- Angle 03 (flat horizontal ride)
[5] = { 0x0c, 0x63 }, -- Angle 04 (nose down slight)
[6] = { 0x30, 0x66 }, -- Angle 05 (nose down moderate)
[7] = { 0x33, 0x69 }, -- Angle 06 (nose down steep / frontflip apex)
}
-- OAM Definitions from surfing_pikachu_oam.asm
-- .WaterSpray (3 tiles, relative to PIKA_X = 68, y = self.pikaY)
local OAM_WATER_SPRAY = {
{ dy = -4, dx = 11, tile = 0xa7, xflip = false },
{ dy = 4, dx = 3, tile = 0xb6, xflip = false },
{ dy = 4, dx = 11, tile = 0xb7, xflip = false },
}
-- .SmallSplash (6 tiles, relative to cx = 80, cy = self.pikaY + 4)
local OAM_SMALL_SPLASH = {
{ dy = -4, dx = -16, tile = 0xa7, xflip = true },
{ dy = -4, dx = 8, tile = 0xa7, xflip = false },
{ dy = 4, dx = -16, tile = 0xb7, xflip = true },
{ dy = 4, dx = -8, tile = 0xb6, xflip = true },
{ dy = 4, dx = 0, tile = 0xb6, xflip = false },
{ dy = 4, dx = 8, tile = 0xb7, xflip = false },
}
-- .LargeSplash (12 tiles, relative to cx = 80, cy = self.pikaY + 4)
local OAM_LARGE_SPLASH = {
{ dy = -12, dx = -16, tile = 0xa8, xflip = false },
{ dy = -12, dx = -8, tile = 0xa9, xflip = false },
{ dy = -12, dx = 0, tile = 0xa9, xflip = true },
{ dy = -12, dx = 8, tile = 0xa8, xflip = true },
{ dy = -4, dx = -16, tile = 0xb8, xflip = false },
{ dy = -4, dx = -8, tile = 0xb9, xflip = false },
{ dy = -4, dx = 0, tile = 0xb9, xflip = true },
{ dy = -4, dx = 8, tile = 0xb8, xflip = true },
{ dy = 4, dx = -16, tile = 0xc8, xflip = false },
{ dy = 4, dx = -8, tile = 0xc9, xflip = false },
{ dy = 4, dx = 0, tile = 0xc9, xflip = true },
{ 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 },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
{ 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x0d, 0x10, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
}
-- Authentic SGB / GBC Pikachus Beach Palette (Shade 0=White, Shade 1=Pikachu Yellow, Shade 2=Sea Blue, Shade 3=Black)
local PIKACHUS_BEACH_PAL = {
{ 255, 255, 255 }, -- 0: White foam/highlights
{ 255, 224, 0 }, -- 1: Vibrant Pikachu Yellow
{ 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_RUN_GAME or ROUTINE_TITLE
self.pikaState = PIKA_STATE_RIDING
self.t = 0
self.routineTimer = 0
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
self.totalScore = 0 -- tallied total score
self.hiScore = (game and game.save and game.save.surfingHighScore) or 0
self.newRecord = false
self.currentPitch = 1.0
self.isMinigame = true
self.isFixedSpeed = true
-- Hardware RNG simulation (pret never resets hRandomAdd/hRandomSub on minigame entry)
self.rDiv = 0
self.rAdd = 0x55
self.rSub = 0xaa
-- 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 }
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 -- 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
self.boardAngleDecreasing = false
self.boardAngleTimer = 0
self.crashTimer = 0
self.landingTimer = 0
-- 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)
self.trickFlags = 0 -- bit 0 = right flip (front), bit 1 = left flip (back)
-- 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.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
self.tallyTimer = 0
-- Load sheets safely across all GameVersion prefix paths
local function sheet(path)
if not (love and love.graphics and love.graphics.newImage) then return nil end
local GameVersion = require("src.core.GameVersion")
local prefix = (GameVersion and GameVersion.cachePrefix and GameVersion.cachePrefix(game and game.version)) or "yellow/"
local filename = path:match("([^/]+)$") or path
local paths = {
prefix .. path,
path,
"yellow/assets/generated/minigame/" .. filename,
"assets/generated/minigame/" .. filename,
}
for _, p in ipairs(paths) do
local ok, img = pcall(love.graphics.newImage, p)
if ok and img then return img end
end
return nil
end
self.bg = sheet("assets/generated/minigame/surf_1a.png")
self.ob = sheet("assets/generated/minigame/surf_1b.png")
self.intro = sheet("assets/generated/minigame/surf_1c.png")
self.titleBg = sheet("assets/generated/minigame/title_bg.png")
if self.bg then
self.tq = {}
local bgW, bgH = self.bg:getDimensions()
for n = 0, 64 do
self.tq[n] = love.graphics.newQuad((n % 5) * 8, math.floor(n / 5) * 8, 8, 8, bgW, bgH)
end
end
if self.ob then
self.oq = {}
local obW, obH = self.ob:getDimensions()
for n = 0, 255 do
self.oq[n] = love.graphics.newQuad((n % 16) * 8, math.floor(n / 16) * 8, 8, 8, obW, obH)
end
end
if self.intro then
self.iq = {}
local iW, iH = self.intro:getDimensions() -- 96x96 tile sheet (12x12 tiles)
for n = 0, 143 do
self.iq[n] = love.graphics.newQuad((n % 12) * 8, math.floor(n / 12) * 8, 8, 8, iW, iH)
end
-- 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)
self.introTextQuad = love.graphics.newQuad(0, 64, 96, 32, iW, iH)
end
-- GPU Shader & Canvas for authentic HBlank wave distortion
if love and love.graphics and love.graphics.newShader then
local shaderCode = [[
extern float u_time;
extern float u_water_line;
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
vec2 uv = texture_coords;
if (uv.y >= u_water_line) {
float pixel_y = uv.y * 144.0;
float wave_px = sin((pixel_y * 0.39269908) + (u_time * 6.0)) * (1.5 / 160.0);
uv.x = fract(uv.x + wave_px);
}
return Texel(texture, uv) * color;
}
]]
local ok, shader = pcall(love.graphics.newShader, shaderCode)
if ok then self.waveShader = shader end
end
if love and love.graphics and love.graphics.newCanvas then
local ok, canvas = pcall(love.graphics.newCanvas, 160, 144)
if ok then self.bgCanvas = canvas end
end
Music.play(game and game.data, "Music_SurfingPikachu")
return self
end
-- Transition cleanly from Title Screen to active run, flushing input bleed-through
function SurfingMinigame:startFromTitle()
self.routine = ROUTINE_START_GAME
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
end
-- Authentic Game Boy LFSR Random Number Generator (engine/math/random.asm Random_)
function SurfingMinigame:getGBRandom()
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 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]
end
end
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
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
pat, hl, hr = self:chooseSequence()
else
local step = WAVE_STEPS[self.waveFn]
if not step then
self.waveFn = 0
pat, hl, hr = WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
else
pat, hl, hr = WAVE_PATTERNS[step[1]], step[2], step[3]
if step[4] == ADV then self.waveFn = self.waveFn + 1
elseif step[4] == RESET then self.waveFn = 0 end
end
end
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()
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)
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
-- 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 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 0x0b end
local mt = BG_METATILES[col.pat[i]]
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
function SurfingMinigame:getWaveTileUnderPika()
return self.bgMapReadTile or 0x0b
end
function SurfingMinigame:spawnTrickPopup(text)
table.insert(self.trickPopups, {
text = text,
x = PIKA_X,
y = self.pikaY - 14,
timer = 32,
})
end
function SurfingMinigame:calculateStuntPoints()
if self.radnessMeter <= 0 then return end
local pts = 0
local popup = "+50"
if self.trickFlags == 3 then
-- Mixed front and back flips
if self.radnessMeter >= 3 then
pts = 500
popup = "+500"
else
pts = 180
popup = "+180"
end
else
-- Same direction flips
if self.radnessMeter == 1 then
pts = 50
popup = "+50"
elseif self.radnessMeter == 2 then
pts = 150
popup = "+150"
else
pts = 350
popup = "+350"
end
end
self.radness = self.radness + pts
self:spawnTrickPopup(popup)
end
-- Slope/tile interaction matrix upon landing (SurfingMinigame_TileInteraction)
function SurfingMinigame:evaluateLanding()
local f = self.frameSet
if f >= 8 or f < 1 then
return "wipeout"
end
local tile = self:getWaveTileUnderPika()
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 == 6 then return "clean"
else return "wipeout" end
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
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"
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 (every other metatile id)
if f == 1 or f == 7 then return "wipeout"
elseif f == 2 or f == 6 then return "hard"
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 = pretTempoTier(self.speedFixed or SPEED_INITIAL)
local targetPitch = TEMPO_TIERS[tier] or 1.0
if self.currentPitch ~= targetPitch then
self.currentPitch = targetPitch
if Music and Music.setPitch then
Music.setPitch(self.currentPitch)
end
end
end
function SurfingMinigame:resetTempo()
self.currentPitch = 1.0
if Music and Music.setPitch then
Music.setPitch(1.0)
end
end
-- 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
return true
end
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 513); hide parallax clouds on the beach card
self.cloudSpriteX = nil
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
-- 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.pikaYOffset = 0
end
end
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
self.jumpArcMagnitude = getSpeedDividedBy32(self.speedFixed)
self.jumpArcFraction = 0
self.pikaSubY = 0
self.jumpDescending = false
self.radnessMeter = 0
self.trickFlags = 0
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
function SurfingMinigame:handleLanding()
local result = self:evaluateLanding()
if result == "wipeout" then
self.pikaState = PIKA_STATE_CRASHED
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 = reduceSpeedBy64(self.speedFixed)
elseif result == "hard" then
self.speedFixed = reduceSpeedBy128(self.speedFixed)
end
if self.routine == ROUTINE_RUN_GAME then
self:calculateStuntPoints()
end
self.pikaState = PIKA_STATE_LANDING
self.landingTimer = LANDING_SPLASH_FRAMES
self.frameSet = 4
Sound.play(self.game.data, "Cut")
end
end
function SurfingMinigame:updateJumping()
-- SurfingMinigame_DPadAction (hJoy5 sampled every 2 frames via GetJoypad)
if self.routine == ROUTINE_RUN_GAME then
if self.joy5Left then
self.rotCountRight = 0
self.rotCountLeft = self.rotCountLeft + 1
if self.rotCountLeft >= FLIP_LEFT_FRAMES then
self.rotCountLeft = 0
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
end
end
end
-- SurfingMinigame_UpdatePikachuHeight (arc delta before velocity each phase)
if not self.jumpDescending then
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
local waveY = pikaSpriteYFromObjectHeight(self.pikaObjectHeightPx)
if self.pikaY >= waveY then
self.pikaY = waveY
self.pikaSubY = 0
self:handleLanding()
return
end
applyJumpArcDelta(self, 128) -- +0.5 px/frame in 8.8 fixed
applyJumpVerticalDelta(self, 1)
if self.pikaY >= waveY then
self.pikaY = waveY
self.pikaSubY = 0
self:handleLanding()
end
end
end
function SurfingMinigame:updateLanding()
self.landingTimer = (self.landingTimer or 0) - 1
-- pikaY already updated by SetPikachuHeight in tick (pre-scroll object height).
-- 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 })
end
if self.landingTimer <= 0 then
self.pikaYOffset = 0
self.pikaState = PIKA_STATE_RIDING
self.frameSet = 4
end
end
function SurfingMinigame:updateCrashed()
self.crashTimer = self.crashTimer - 1
self:resetTempo()
-- pikaY already updated by SetPikachuHeight in tick.
if self.crashTimer <= 0 then
self.pikaState = PIKA_STATE_RIDING
self.frameSet = 4
end
end
-- Single Game Boy hardware VBlank cycle tick (59.7275 Hz)
function SurfingMinigame:tick()
local input = self.game and self.game.input
self.t = self.t + 1
self.rDiv = (self.rDiv + RDIV_PER_FRAME) % 256
-- Title Screen State (auto-advances when intro Pikachu walks off-screen)
if self.routine == ROUTINE_TITLE then
if self.t % 2 == 0 then
self.introPikaX = self.introPikaX + 1
end
if self.introPikaX >= 192 then
self:startFromTitle()
end
return
end
-- 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 (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.joy5Left = false
self.joy5Right = false
self.joyFrameCounter = 0
end
-- Update trick popups
for i = #self.trickPopups, 1, -1 do
local p = self.trickPopups[i]
p.y = p.y - 0.5
p.timer = p.timer - 1
if p.timer <= 0 then table.remove(self.trickPopups, i) end
end
-- Update water sprays
for i = #self.waterSprays, 1, -1 do
local s = self.waterSprays[i]
s.timer = s.timer - 1
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
-- SurfingMinigame_StartGame: spawn banner, inc routine; RunGame begins next frame
self.routine = ROUTINE_RUN_GAME
return
elseif self.routine == ROUTINE_RUN_GAME then
-- 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 = GAME_OVER_DELAY
self.speedFixed = 0
self.ohNoBanner = true
Sound.play(self.game and self.game.data, "Faint_Fall")
return
end
self.waveRandomValue = self:getGBRandom()
-- 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
self:updateRiding()
elseif self.pikaState == PIKA_STATE_JUMPING then
self:updateJumping()
elseif self.pikaState == PIKA_STATE_LANDING then
self:updateLanding()
elseif self.pikaState == PIKA_STATE_CRASHED then
self:updateCrashed()
end
elseif self.routine == ROUTINE_WAIT_RESULTS then
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
if self.pikaState == PIKA_STATE_JUMPING then
self:updateJumping()
elseif self.pikaState == PIKA_STATE_LANDING then
self:updateLanding()
elseif self.pikaState == PIKA_STATE_CRASHED then
self:updateCrashed()
else
local targetY = self:seaY(80)
self.pikaY = math.floor(targetY) - 16
self.pikaSubY = 0
self.frameSet = 4
end
if self.routineTimer <= 0 then
-- pret .doneDelay: enter results scroll immediately (no wait for landing/crash)
self.routine = ROUTINE_SCROLL_RESULTS
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
if (self.hScx or 0) <= 0 then
self.routine = ROUTINE_DRAW_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
-- 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
if outroDelayExpired(self) then
self.outroLines.hp = true
self.routineTimer = 64
self.routine = ROUTINE_WRITE_RADNESS
end
elseif self.routine == ROUTINE_WRITE_RADNESS then
if outroDelayExpired(self) then
self.outroLines.rad = true
self.routineTimer = 64
self.routine = ROUTINE_WRITE_TOTAL
end
elseif self.routine == ROUTINE_WRITE_TOTAL then
if outroDelayExpired(self) then
self.outroLines.total = true
self.routineTimer = 64
self.routine = ROUTINE_ADD_HP_TOTAL
end
elseif self.routine == ROUTINE_ADD_HP_TOTAL 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
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.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
if outroDelayExpired(self) then
self.routine = ROUTINE_EXIT_ON_PRESS_A
end
elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then
if input and input:wasPressed("a") then
if self.game and self.game.stack then self.game.stack:pop() end
if self.onDone then self.onDone(self.totalScore) end
end
elseif self.routine == ROUTINE_GAME_OVER then
self.routineTimer = self.routineTimer - 1
if self.routineTimer <= 0 and input and input:wasPressed("a") then
if self.game and self.game.stack then self.game.stack:pop() end
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
function SurfingMinigame:update(dt)
if not dt then
self:tick()
return
end
-- Accumulate real-world time
self.tickAccumulator = (self.tickAccumulator or 0) + dt
-- Exact Game Boy Color framerate: 59.7275 Hz (approx 0.0167427 seconds per frame)
local gbTickRate = 1 / 59.7275
-- Process as many physical hardware frames as necessary
while self.tickAccumulator >= gbTickRate do
self:tick()
self.tickAccumulator = self.tickAccumulator - gbTickRate
end
end
-- Draw scrolling wave background with authentic GPU shader HBlank wave distortion
function SurfingMinigame:drawBackground()
local scx = displayScx(self)
local function renderTiles()
love.graphics.setColor(1, 1, 1, 1)
if not self.bg then return end
for i = 0, 10 do
local col = self.bgMap[mapColScreen(scx, i)]
local x = i * 16 - (scx % 16)
if col then
for row = 1, 8 do
local mt = BG_METATILES[col.pat[row]]
if mt then
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 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()
love.graphics.setCanvas(self.bgCanvas)
-- 2. Clear with transparency (0 alpha) so the sky remains empty
love.graphics.clear(0, 0, 0, 0)
renderTiles()
-- 3. Restore the framework's canvas before drawing!
love.graphics.setCanvas(prevCanvas)
-- 4. Safely capture and restore the shader
local prevShader = love.graphics.getShader and love.graphics.getShader()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(self.waveShader)
if self.waveShader.hasUniform and self.waveShader:hasUniform("u_time") then
self.waveShader:send("u_time", self.t / 60.0)
end
if self.waveShader.hasUniform and self.waveShader:hasUniform("u_water_line") then
self.waveShader:send("u_water_line", 48.0 / 144.0)
end
love.graphics.draw(self.bgCanvas, 0, 0)
love.graphics.setShader(prevShader)
else
renderTiles()
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
local sy = flipY and -1 or 1
for r = 0, 2 do
for c = 0, 2 do
local tileId = baseTile + r * 16 + c
local q = self.oq[tileId]
if q then
local colIdx = flipX and (2 - c) or c
local rowIdx = flipY and (2 - r) or r
local dx = (cx - 12) + colIdx * 8 + (flipX and 8 or 0)
local dy = (cy - 12) + rowIdx * 8 + (flipY and 8 or 0)
love.graphics.draw(self.ob, q, dx, dy, 0, sx, sy)
end
end
end
end
-- Draw HUD status bar with inverted progress marker (moving right-to-left towards beach)
function SurfingMinigame:drawHUD()
-- White background in bottom 16 rows
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 16)
-- 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
love.graphics.draw(self.bg, self.tq[0x15], 8, BG_HEIGHT)
love.graphics.draw(self.bg, self.tq[0x16], 16, BG_HEIGHT)
love.graphics.draw(self.bg, self.tq[0x17], 8, BG_HEIGHT + 8)
love.graphics.draw(self.bg, self.tq[0x18], 16, 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)
end
-- Mini-Pikachu progress marker (tile $fe)
-- Game Boy: initial OAM X = $50 (80) = screen X 72, decrements by 2 per section.
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 (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], 128 + (i - 1) * 8, BG_HEIGHT + 8)
else
Font.draw(tostring(d), 128 + (i - 1) * 8, BG_HEIGHT + 8)
end
end
end
-- Draw beach outro results scene
function SurfingMinigame:drawResultsOutro()
-- Draw beach outro tilemap in rows 6..15
for r = 1, 10 do
for c = 1, 20 do
local tId = BEACH_OUTRO[r][c]
if tId and self.tq and self.tq[tId] then
love.graphics.draw(self.bg, self.tq[tId], (c - 1) * 8, (r + 5) * 8)
end
end
end
-- Fill interior with white
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 16, 16, 128, 56)
-- Draw the textbox frame using original Game Boy border tiles (rows 1..9, Y=8..72)
local function drawBoxRow(rowIdx, leftTile, interiorTile, rightTile)
local y = rowIdx * 8
if self.tq[leftTile] then
love.graphics.draw(self.bg, self.tq[leftTile], 8, y)
end
if interiorTile and self.tq[interiorTile] then
for colIdx = 2, 17 do
love.graphics.draw(self.bg, self.tq[interiorTile], colIdx * 8, y)
end
end
if self.tq[rightTile] then
love.graphics.draw(self.bg, self.tq[rightTile], 144, y)
end
end
drawBoxRow(1, 0x3b, 0x40, 0x3c)
for r = 2, 8 do
drawBoxRow(r, 0x3f, nil, 0x3f)
end
drawBoxRow(9, 0x3d, 0x40, 0x3e)
-- 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.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.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.outroLines.hiScore then
Font.draw(Strings("Hi-Score!!"), 48, 64)
end
love.graphics.setColor(1, 1, 1, 1)
end
-- Draw minigame Title Screen ("Pikachu's Beach")
function SurfingMinigame:drawTitleScreen()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- 1. Draw authentic 160x144 composite title background from ROM
if self.titleBg then
love.graphics.draw(self.titleBg, 0, 0)
else
Font.draw("PIKACHU'S BEACH", 20, 32)
end
-- 2. Intro Pikachu paddling out (SurfingPikachu1Graphics3 / surf_1c.png)
self:drawIntroPikachu(self.introPikaX, FLAT_WATER_Y)
end
function SurfingMinigame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.routine == ROUTINE_TITLE then
self:drawTitleScreen()
return
end
if self.showResultsCard then
self:drawResultsOutro()
self:drawResultsPikachu()
self:drawHUD()
return
end
-- Draw scrolling BG waves
self:drawBackground()
-- 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
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
function self:drawOAMSprites(sprites, ox, oy)
if not (self.ob and self.oq) then return end
love.graphics.setColor(0.5, 0.5, 0.5, 1) -- OAM_PAL1 maps shade 1 to shade 2 (Sea Blue)
for _, sp in ipairs(sprites) do
local q = self.oq[sp.tile]
if q then
local x = ox + sp.dx
local y = oy + sp.dy
if sp.xflip then
love.graphics.draw(self.ob, q, x + 8, y, 0, -1, 1)
else
love.graphics.draw(self.ob, q, x, y)
end
end
end
love.graphics.setColor(1, 1, 1, 1)
end
-- Draw water spray sprites on trailing edge of surfboard
for _, s in ipairs(self.waterSprays) do
self:drawOAMSprites(OAM_WATER_SPRAY, s.x, s.y)
end
-- Draw Pikachu
local cx = 80
local cy = self.pikaY + (self.pikaYOffset or 0)
self.pikaScreenY = cy
if self.pikaState == PIKA_STATE_CRASHED then
-- Empty surfboard (3 tiles at bottom)
if self.ob and self.oq then
love.graphics.draw(self.ob, self.oq[0x98], cx - 12, cy + 4)
love.graphics.draw(self.ob, self.oq[0x99], cx - 4, cy + 4)
love.graphics.draw(self.ob, self.oq[0x9a], cx + 4, cy + 4)
end
-- Multi-tile splash animation (.SmallSplash then .LargeSplash)
local elapsed = 60 - (self.crashTimer or 0)
if elapsed < 16 then
self:drawOAMSprites(OAM_SMALL_SPLASH, cx, cy + 4)
else
self:drawOAMSprites(OAM_LARGE_SPLASH, cx, cy + 4)
end
else
local angleIdx = ((self.frameSet - 1) % 7) + 1
local isFlipped = self.frameSet > 7
local toggle = math.floor(self.t / 8) % 2 + 1
local base = ANGLE_BASES[angleIdx][toggle]
self:draw3x3(base, cx, cy, isFlipped, isFlipped)
end
-- Draw trick popups
for _, p in ipairs(self.trickPopups) do
Font.draw(p.text, p.x, p.y)
end
-- "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
local tid = 0xe0 + r * 16 + c
love.graphics.draw(self.ob, self.oq[tid], self.startBannerX - 24 + c * 8, 64 + r * 8)
end
end
end
end
-- "Oh no.." banner on Game Over
if self.ohNoBanner then
if self.ob and self.oq then
for r = 0, 1 do
for c = 0, 5 do
local tid = 0xca + r * 16 + c
love.graphics.draw(self.ob, self.oq[tid], 80 - 24 + c * 8, 64 + r * 8)
end
end
end
end
-- Draw HUD (Progress track, HP digits)
self:drawHUD()
end
function SurfingMinigame:sgbPalettes(game)
local P = require("src.render.PaletteFX")
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