diff --git a/src/core/Game.lua b/src/core/Game.lua index 136aab99..ac928aed 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -326,6 +326,9 @@ function Game:logicSpeed() if self.linkSession or (self.linkNet and not self.linkNet.closed) then return 1 end + if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then + return 1 + end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end -- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's -- core.logic_speed hook can return anything (0, negative, nil, NaN) and @@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack) return "menu" end +function Game.isFixedSpeedInStack(stack) + local states = stack and stack.states + for i = #(states or {}), 1, -1 do + local state = states[i] + if state and (state.isFixedSpeed or state.isMinigame) then return true end + end + return false +end + -- Whether a state on the stack composes its own screen and so wants the -- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like -- everything else here: the text box and YES/NO a battle puts up are states diff --git a/src/core/Music.lua b/src/core/Music.lua index 08930249..c700e591 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -230,7 +230,7 @@ end function Music.play(data, song, loop, ctx) if not song then return end - if not love.audio then return end -- headless test stub + if not (love and love.audio) then return end -- headless test stub ctx = ctx or {} song = selectSong(song, ctx) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 4171d681..737f0e71 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5231,6 +5231,82 @@ function RomExtractorGen2:extractMenuGfx() end if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end + -- Goldenrod Game Corner: Slot Machine graphics assets + if self.symbols["Slots1LZ"] then + local raw1 = self:decompressLz3Symbol("Slots1LZ") + self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png") + end + if self.symbols["Slots2LZ"] then + local raw2 = self:decompressLz3Symbol("Slots2LZ") + -- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity + for i = 1, math.min(64, #raw2) do + raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF) + end + self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png") + end + if self.symbols["Slots3LZ"] then + local raw3 = self:decompressLz3Symbol("Slots3LZ") + self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true) + -- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing: + -- Y=0: Golem 1 (Standing, 24x32) + -- Y=32: Golem 2 (Ball, 24x32) + -- Y=64: Chansey 1 (Standing / Step 1, 24x32) + -- Y=96: Chansey 2 (Step 2, 24x32) + -- Y=128: Chansey 3 (Step 3, 24x32) + -- Y=160: Chansey 4 (Arm raised / Step 4, 24x32) + -- Y=192: Chansey 5 (Egg Drop pose, 24x32) + -- Y=224: Egg (8x16 at X=0) + self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true) + end + if self.symbols["SlotsTilemap"] then + local symbol = self:symbol("SlotsTilemap") + local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12) + self:save(tm, "slots/gold_slots.tilemap") + end + + -- Goldenrod Game Corner: Card Flip graphics assets + if self.symbols["CardFlipLZ01"] then + local raw1 = self:decompressLz3Symbol("CardFlipLZ01") + self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png") + end + if self.symbols["CardFlipLZ02"] then + local raw2 = self:decompressLz3Symbol("CardFlipLZ02") + self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png") + end + if self.symbols["CardFlipLZ03"] then + local raw3 = self:decompressLz3Symbol("CardFlipLZ03") + self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png") + end + if self.symbols["CardFlipOnButtonGFX"] then + local symbol = self:symbol("CardFlipOnButtonGFX") + self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png") + end + if self.symbols["CardFlipOffButtonGFX"] then + local symbol = self:symbol("CardFlipOffButtonGFX") + self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png") + end + if self.symbols["CardFlipTilemap"] then + local symbol = self:symbol("CardFlipTilemap") + local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12) + self:save(tm, "card_flip/card_flip.tilemap") + end + + out.slots = { + sheet1 = "assets/generated/slots/gold_slots_1.png", + sheet2 = "assets/generated/slots/gold_slots_2.png", + sheet3 = "assets/generated/slots/gold_slots_3.png", + tilemap = "assets/generated/slots/gold_slots.tilemap", + } + + out.cardFlip = { + sheet1 = "assets/generated/card_flip/card_flip_1.png", + sheet2 = "assets/generated/card_flip/card_flip_2.png", + sheet3 = "assets/generated/card_flip/card_flip_3.png", + on = "assets/generated/card_flip/on.png", + off = "assets/generated/card_flip/off.png", + tilemap = "assets/generated/card_flip/card_flip.tilemap", + } + self:write("menu_gfx", out) self:tick("Menu graphics", 1, 1) return out diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 70f51ee3..91e236e1 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -66,6 +66,7 @@ local TOTAL_SECTIONS = 24 -- 24 sections ($18) of 128px = 3072px course local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144) -- Routine numbers (wSurfingMinigameRoutineNumber) +local ROUTINE_TITLE = -1 local ROUTINE_START_GAME = 0 local ROUTINE_RUN_GAME = 1 local ROUTINE_WAIT_RESULTS = 2 @@ -216,7 +217,9 @@ local WAVE_STEPS = { [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 } +local SEQ_STARTS = { + 0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c +} SurfingMinigame.BG_METATILES = BG_METATILES SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS @@ -233,6 +236,40 @@ local ANGLE_BASES = { [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 }, +} + -- 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 }, @@ -258,9 +295,9 @@ local PIKACHUS_BEACH_PAL = { -- 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) +function SurfingMinigame.new(game, onDone, skipTitle) local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame) - self.routine = ROUTINE_START_GAME + self.routine = skipTitle and ROUTINE_START_GAME or ROUTINE_TITLE self.pikaState = PIKA_STATE_RIDING self.t = 0 self.routineTimer = 0 @@ -269,9 +306,11 @@ function SurfingMinigame.new(game, onDone) 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.save.surfingHighScore or 0 + 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 registers (hRandomAdd, hRandomSub, rDIV) self.rDiv = 0 @@ -297,6 +336,7 @@ function SurfingMinigame.new(game, onDone) self.boardAngleDecreasing = false self.boardAngleTimer = 0 self.crashTimer = 0 + self.landingTimer = 0 -- 3-frame buffered D-Pad rotation & input accumulator self.joyCounter = 0 @@ -318,14 +358,28 @@ function SurfingMinigame.new(game, onDone) self.tallyStep = 0 self.tallyTimer = 0 - -- Load sheets safely + -- 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 ok, img = pcall(love.graphics.newImage, path) - return ok and img or nil + 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 = {} @@ -343,32 +397,97 @@ function SurfingMinigame.new(game, onDone) end end - Music.play(game.data, "Music_SurfingPikachu") + 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 + -- 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) + 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 --- Authentic Game Boy VBlank LFSR Random Number Generator +-- 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.rotCountLeft = 0 + self.rotCountRight = 0 + 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 function SurfingMinigame:getGBRandom() - self.rDiv = (self.rDiv + 7 + (self.inputAccum or 0)) % 256 - self.rAdd = (self.rAdd + self.rDiv) % 256 - self.rSub = (self.rSub - self.rDiv + 256) % 256 - return (self.rAdd + self.rSub) % 256 + 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 + return self.rAdd end function SurfingMinigame:chooseSequence() local distPx = math.floor(self.distanceFixed / 256) - if math.floor(distPx / 128) >= 0x16 then + local section = math.floor(distPx / 128) + if section == 0x16 then self.waveFn = 0x6a - else + elseif section < 0x16 then local r = self:getGBRandom() - if r ~= 0 then self.waveFn = SEQ_STARTS[((r - 1) % 8) + 1] end + 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 function SurfingMinigame:pushColumn() local pat, hl, hr - if self.waveFn == 0 then + 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 + 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] @@ -396,30 +515,40 @@ 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 not col then return FLAT_WATER_Y - 16 end - return (tile % 2 == 0 and col.hl or col.hr) - 16 + if not col then return FLAT_WATER_Y end + return tile % 2 == 0 and col.hl or col.hr end --- Get the tile ID of the wave under Pikachu (sample 9-10 tiles into viewport) +-- 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 = math.floor((distPx + 80) / 8) - local col = self.cols[math.floor(tile / 2)] + local tile_col = math.floor((distPx + 72) / 8) + local waterY = math.floor(self:seaY(80)) + local tile_row = math.floor(waterY / 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 pat = col.pat - for i = 4, 8 do - local mt = pat[i] - if mt then - if mt == 0x02 or mt == 0x06 or mt == 0x0a or mt == 0x11 then - return 0x06 -- rising slope - elseif mt == 0x03 or mt == 0x07 or mt == 0x0b or mt == 0x0d then - return 0x07 -- falling slope - elseif mt == 0x08 or mt == 0x09 or mt == 0x0f or mt == 0x10 or mt == 0x14 or mt == 0x15 then - return 0x14 -- wave crest / face - end - end + + local i = math.floor(tile_row / 2) + 1 + if i < 1 or i > 8 then return 0x01 end + local mt = BG_METATILES[col.pat[i]] + if not mt then return 0x01 end + + local sub_x = (tile_col % 2 == 0) and 0 or 1 + local sub_y = (tile_row % 2 == 0) and 0 or 1 + + 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 -- flat open water + return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.) end function SurfingMinigame:spawnTrickPopup(text) @@ -497,21 +626,51 @@ function SurfingMinigame:evaluateLanding() 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 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 + 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) end + self:updateTempo() -- Follow wave surface height local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - self.pikaSubY = 0 + self.pikaY = math.floor(targetY) - 16 + self.pikaSubY = 0 -- Water spray every 4 frames self.sprayTimer = self.sprayTimer + 1 if self.sprayTimer % 4 == 0 then - table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 }) + table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) end -- Board angle wobbling every 8 frames @@ -532,14 +691,14 @@ function SurfingMinigame:updateRiding() end end - -- Select frame based on slope + -- 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) else - self.frameSet = 4 + (self.boardAngleOffset - 1) + self.frameSet = 4 -- flat open water: steady horizontal ride end self.frameSet = math.max(1, math.min(14, self.frameSet)) @@ -560,43 +719,71 @@ function SurfingMinigame:updateRiding() end end -function SurfingMinigame:updateJumping() - -- Process accumulated input on the 3-frame buffer boundary - 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 - - if rightHeld then - self.rotCountLeft = 0 - self.rotCountRight = self.rotCountRight + 1 - if self.rotCountRight >= 11 then - self.rotCountRight = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 1) - Sound.play(self.game.data, "Tink") - end - -- Increment frame set (frontflip forward) - self.frameSet = (self.frameSet % 14) + 1 - elseif leftHeld then - self.rotCountRight = 0 - self.rotCountLeft = self.rotCountLeft + 1 - if self.rotCountLeft >= 13 then - self.rotCountLeft = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 2) - Sound.play(self.game.data, "Tink") - end - -- Decrement frame set (backflip backward) - self.frameSet = self.frameSet - 1 - if self.frameSet < 1 then self.frameSet = 14 end +function SurfingMinigame:handleLanding() + local result = self:evaluateLanding() + if result == "wipeout" then + self.pikaState = PIKA_STATE_CRASHED + self.crashTimer = 96 + 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) + elseif result == "hard" then + self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) end + if self.routine == ROUTINE_RUN_GAME then + self:calculateStuntPoints() + end + self.pikaState = PIKA_STATE_LANDING + self.landingTimer = 32 + 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) + 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 + 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.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 if not self.jumpDescending then - self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4 + 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 @@ -608,34 +795,16 @@ function SurfingMinigame:updateJumping() end else -- Hardware execution order: evaluate boundary before adding velocity - local waveY = math.floor(self:seaY(80)) + local waveY = math.floor(self:seaY(80)) - 16 if self.pikaY >= waveY then self.pikaY = waveY self.pikaSubY = 0 - -- Evaluate landing angle vs wave slope - local result = self:evaluateLanding() - if result == "wipeout" then - self.pikaState = PIKA_STATE_CRASHED - self.crashTimer = 96 - 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) - elseif result == "hard" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) - end - self:calculateStuntPoints() - self.pikaState = PIKA_STATE_LANDING - self.routineTimer = 32 - self.frameSet = 4 - Sound.play(self.game.data, "Cut") - end + self:handleLanding() return end - self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4 + 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 @@ -644,57 +813,66 @@ function SurfingMinigame:updateJumping() if self.pikaY >= waveY then self.pikaY = waveY self.pikaSubY = 0 - local result = self:evaluateLanding() - if result == "wipeout" then - self.pikaState = PIKA_STATE_CRASHED - self.crashTimer = 96 - 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) - elseif result == "hard" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) - end - self:calculateStuntPoints() - self.pikaState = PIKA_STATE_LANDING - self.routineTimer = 32 - self.frameSet = 4 - Sound.play(self.game.data, "Cut") - end + self:handleLanding() end end end function SurfingMinigame:updateLanding() - self.routineTimer = self.routineTimer - 1 + 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 + -- Sine wave splash offset - self.pikaYOffset = math.floor(math.sin((32 - self.routineTimer) / 32 * math.pi * 2) * 4) - if self.routineTimer % 4 == 0 then - table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 }) + 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.routineTimer <= 0 then + + 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() + -- Follow water surface while wiped out + local targetY = self:seaY(80) + self.pikaY = math.floor(targetY) - 16 + self.pikaSubY = 0 if self.crashTimer <= 0 then self.pikaState = PIKA_STATE_RIDING self.frameSet = 4 end end -function SurfingMinigame:update() - local input = self.game.input +-- 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 + 1) % 256 - -- Accumulate physical button presses on every frame (eliminates polling blind spot) - if input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end - if input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end + -- Title Screen State + if self.routine == ROUTINE_TITLE then + if input and (input:wasPressed("start") or input:wasPressed("a")) then + self:startFromTitle() + end + return + end + + -- Accumulate physical button presses on every frame (only during active run) + 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 + else + self.inputAccum = 0 + end -- Update trick popups for i = #self.trickPopups, 1, -1 do @@ -728,7 +906,7 @@ function SurfingMinigame:update() self.routineTimer = 128 self.speedFixed = 0 self.ohNoBanner = true - Sound.play(self.game.data, "Faint_Fall") + Sound.play(self.game and self.game.data, "Faint_Fall") return end @@ -758,20 +936,44 @@ function SurfingMinigame:update() end elseif self.routine == ROUTINE_WAIT_RESULTS then - -- 192 frames coasting past the goal line + -- Coasting past the goal line self.distanceFixed = self.distanceFixed + (2 * 256) + self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25) self:generateAhead() - self.pikaY = math.floor(self:seaY(80)) - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + + -- 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 + self:updateLanding() + 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 self.routine = ROUTINE_SCROLL_RESULTS self.routineTimer = 36 + 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)) + self.pikaY = math.floor(self:seaY(80)) - 16 + self.pikaSubY = 0 + self.frameSet = 4 self.routineTimer = self.routineTimer - 1 if self.routineTimer <= 0 then self.routine = ROUTINE_DRAW_RESULTS @@ -813,7 +1015,7 @@ function SurfingMinigame:update() local step = math.min(self.hp, 99) self.hp = self.hp - step self.totalScore = self.totalScore + step - Sound.play(self.game.data, "Press_AB") + Sound.play(self.game and self.game.data, "Press_AB") else self.routine = ROUTINE_ADD_RAD_TOTAL end @@ -824,18 +1026,18 @@ function SurfingMinigame:update() local step = math.min(self.radness, 99) self.radness = self.radness - step self.totalScore = self.totalScore + step - Sound.play(self.game.data, "Press_AB") + 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.save.surfingHighScore or 0) + self.newRecord = self.totalScore > ((self.game and self.game.save and self.game.save.surfingHighScore) or 0) if self.newRecord then - self.game.save.surfingHighScore = self.totalScore - Sound.play(self.game.data, "Get_Item1") - Sound.playPikaCry(self.game.data, 34) + if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end + Sound.play(self.game and self.game.data, "Get_Item1") + Sound.playPikaCry(self.game and self.game.data, 34) else - Sound.playPikaCry(self.game.data, 28) + Sound.playPikaCry(self.game and self.game.data, 28) end end @@ -846,64 +1048,117 @@ function SurfingMinigame:update() end elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then - if input:wasPressed("a") or input:wasPressed("b") then - self.game.stack:pop() + 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:wasPressed("a") or input:wasPressed("b")) then - self.game.stack:pop() + 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 end --- Draw scrolling wave background +-- 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 = math.floor(self.distanceFixed / 256) local first = math.floor(scx / 16) - for c = first, first + 10 do - local col = self.cols[c] - if col then - local x = c * 16 - scx - for i = 1, 8 do - local mt = BG_METATILES[col.pat[i]] - 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 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] + if col then + local x = c * 16 - scx + for i = 1, 8 do + local mt = BG_METATILES[col.pat[i]] + 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) + end end end end end + + if self.bgCanvas and self.waveShader 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 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 - if not flipX and not flipY then - local dx = (cx - 12) + c * 8 - local dy = (cy - 12) + r * 8 - love.graphics.draw(self.ob, q, dx, dy) - else - local dx = (cx - 12) + (2 - c) * 8 + 8 - local dy = (cy - 12) + (2 - r) * 8 + 8 - love.graphics.draw(self.ob, q, dx, dy, 0, -1, -1) - end + 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 +-- 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) @@ -931,9 +1186,14 @@ function SurfingMinigame:drawHUD() 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 markerX = 16 + math.floor(progressRatio * 80) + 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) if self.ob and self.oq and self.oq[0xfe] then love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6) end @@ -956,46 +1216,100 @@ function SurfingMinigame:drawResultsOutro() for r = 1, 10 do for c = 1, 20 do local tId = BEACH_OUTRO[r][c] - if tId and self.tq[tId] then + 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 - -- Textbox frame on rows 1..9 (X=8..152, Y=8..72) + -- Fill interior with white love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 8, 8, 144, 64) - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("line", 8.5, 8.5, 143, 63) + love.graphics.rectangle("fill", 16, 16, 128, 56) - -- Text lines + -- 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 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 - Font.draw(string.format("%4d Pts", self.hp), 88, 16) + Font.draw(string.format("%04d", self.hp), 80, 16) + Font.draw(Strings("Pts"), 120, 16) end if self.routine >= ROUTINE_WRITE_RADNESS then Font.draw(Strings("Radness"), 16, 32) - Font.draw(string.format("%4d Pts", self.radness), 88, 32) + Font.draw(string.format("%04d", self.radness), 80, 32) + Font.draw(Strings("Pts"), 120, 32) end if self.routine >= ROUTINE_WRITE_TOTAL then Font.draw(Strings("Total"), 16, 48) - Font.draw(string.format("%4d Pts", self.totalScore), 88, 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, 60) + Font.draw(Strings("Hi-Score!!"), 48, 64) end 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. 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 +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.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then self:drawResultsOutro() return @@ -1004,22 +1318,42 @@ function SurfingMinigame:draw() -- Draw scrolling BG waves self:drawBackground() - -- Parallax clouds in sky + -- Parallax clouds in sky (scrolling left at uniform 0.25x camera speed) local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256) - local c1x = (32 - cloudOffsetPx * 2) % 200 - 40 - local c2x = (128 - cloudOffsetPx * 3) % 200 - 40 - -- 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) + 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) + 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) + 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) + -- 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 + -- Draw water spray sprites on trailing edge of surfboard for _, s in ipairs(self.waterSprays) do - love.graphics.draw(self.ob, self.oq[0xa7], s.x, s.y) + self:drawOAMSprites(OAM_WATER_SPRAY, s.x, s.y) end -- Draw Pikachu @@ -1028,11 +1362,19 @@ function SurfingMinigame:draw() self.pikaScreenY = cy if self.pikaState == PIKA_STATE_CRASHED then - -- Empty surfboard + splash animation - 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) - love.graphics.draw(self.ob, self.oq[0xa8], cx - 12, cy - 8) + -- 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 @@ -1048,20 +1390,24 @@ function SurfingMinigame:draw() -- "START" banner if self.routine == ROUTINE_START_GAME 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) + 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 - 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) + 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 diff --git a/src/ui/gen2/CardFlip.lua b/src/ui/gen2/CardFlip.lua index d410e73b..a1b596ce 100644 --- a/src/ui/gen2/CardFlip.lua +++ b/src/ui/gen2/CardFlip.lua @@ -303,12 +303,12 @@ local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 -- data/text/common_3.asm; none of these are in the cache's text.lua, because no -- script bytecode the extractor walks points at them. CardFlip.TEXTS = { - playWithThree = { "Play with three", "coins?" }, - notEnough = { "Not enough coins…" }, - chooseACard = { "Choose a card." }, - placeYourBet = { "Place your bet." }, - playAgain = { "Want to play", "again?" }, - shuffled = { "The cards have", "been shuffled." }, + playWithThree = { "Play with", "3 coins?" }, + notEnough = { "Not enough", "coins." }, + chooseACard = { "Choose a", "card." }, + placeYourBet = { "Place", "your bet" }, + playAgain = { "Play", "again?" }, + shuffled = { "The cards", "shuffled." }, yeah = { "Yeah!" }, darn = { "Darn…" }, } @@ -401,14 +401,34 @@ function CardFlip:enterBet() self.lines = CardFlip.TEXTS.placeYourBet end --- .CheckTheCard: the dealt card is turned face up and marked on the discard --- pile, which is what blanks its cell on the odds board. +-- .CheckTheCard: trigger hardware-accurate discrete tile flip sequence function CardFlip:flip() local card = CardFlip.dealt(self.deck, self.played, self.which) self.faceUp = card self.discarded[card] = true - self:sfx(SFX_CHOOSE) - self:tabulate() + local won = CardFlip.payout(self.cursorX, self.cursorY, card) + self.payoutLeft = won + self.payoutTick = 0 + self.phase = "flipping" + self.flipTimer = 0 + self.targetCard = card +end + +function CardFlip:updateFlipping() + self.flipTimer = (self.flipTimer or 0) + 1 + if self.flipTimer == 4 then + self:sfx(SFX_CHOOSE) + elseif self.flipTimer >= 12 then + if (self.payoutLeft or 0) > 0 then + self.phase = "payout" + self.lines = CardFlip.TEXTS.yeah + self:sfx(SFX_WIN) + else + self.phase = "result" + self.lines = CardFlip.TEXTS.darn + self:sfx(SFX_WRONG) + end + end end function CardFlip:tabulate() @@ -472,7 +492,21 @@ function CardFlip:quit() if self.onClose then self.onClose() end end -function CardFlip:update(_dt) +function CardFlip:update(dt) + -- Support both fixed-tick 60Hz loop and variable dt accumulator + if dt and dt > 0 then + self.dtAccum = (self.dtAccum or 0) + dt + local TICK = 1 / 60 + while self.dtAccum >= TICK do + self.dtAccum = self.dtAccum - TICK + self:tick() + end + else + self:tick() + end +end + +function CardFlip:tick() local input = self.game and self.game.input if not input then return end local phase = self.phase @@ -539,6 +573,11 @@ function CardFlip:update(_dt) return end + if phase == "flipping" then + self:updateFlipping() + return + end + if phase == "payout" then self:updatePayout() return @@ -552,79 +591,370 @@ function CardFlip:update(_dt) end -- ------------------------------------------------------------------- draw +-- +-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold +local TileSheet = require("src.ui.gen2.TileSheet") +local GbcPalette = require("src.render.GbcPalette") + +local CARDFLIP_PALS = { + bg = { + [0] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 0: Base / Table green + [1] = { { 255, 255, 255 }, { 239, 206, 0 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 1: Pikachu (Yellow) + [2] = { { 255, 255, 255 }, { 255, 107, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 2: Jigglypuff (Pink) + [3] = { { 255, 255, 255 }, { 66, 140, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 3: Poliwag (Blue) + [4] = { { 255, 255, 255 }, { 66, 255, 66 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 4: Oddish (Green) + [5] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 5: Level header + [6] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 6: Border + [7] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 7: Textbox + }, + obj = { + [0] = { { 255, 255, 255 }, { 248, 56, 40 }, { 248, 56, 40 }, { 248, 56, 40 } }, -- Authentic GBC Red OAM + } +} + +local TILEMAP = nil +local function getCardFlipTilemap() + if TILEMAP == nil then + local path = "assets/generated/card_flip/card_flip.tilemap" + local f = io.open(path, "rb") + if f then + local data = f:read("*a") + f:close() + TILEMAP = {} + for i = 1, #data do + TILEMAP[i] = string.byte(data, i) + end + else + TILEMAP = false + end + end + return TILEMAP or nil +end + +function CardFlip:sheets() + if self.sheet1 == nil then + self.sheet1 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_1.png", wide = 16, firstTile = 0 }) + self.sheet2 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_2.png", wide = 3, firstTile = 0 }) + self.sheet3 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_3.png", wide = 1, firstTile = 0 }) + self.sheetOn = TileSheet.new({ path = "assets/generated/card_flip/on.png", wide = 1, firstTile = 0 }) + self.sheetOff = TileSheet.new({ path = "assets/generated/card_flip/off.png", wide = 1, firstTile = 0 }) + end + return self.sheet1, self.sheet2, self.sheet3, self.sheetOn, self.sheetOff +end + +function CardFlip:cursorQuads() + if self.s3Image == nil then + local _, _, s3 = self:sheets() + self.s3Image = s3:image() + if self.s3Image and love and love.graphics then + local G = love.graphics + self.quadCorner = G.newQuad(0, 0, 8, 8, 8, 56) -- Tile 0: 1px corner + self.quadVEdge = G.newQuad(0, 8, 8, 8, 8, 56) -- Tile 1: 1px vertical edge + self.quadHEdge = G.newQuad(0, 16, 8, 8, 8, 56) -- Tile 2: 1px horizontal edge + end + end + return self.s3Image, self.quadCorner, self.quadVEdge, self.quadHEdge +end + +local HEADER_TILE_MAP = { + [0x3E] = 0, [0x3F] = 1, + [0x40] = 3, [0x41] = 4, + [0x42] = 6, [0x43] = 7, + [0x44] = 9, [0x45] = 10, + [0x46] = 12, [0x47] = 13, + [0x48] = 15, [0x49] = 16, + [0x4A] = 18, [0x4B] = 19, + [0x4C] = 21, [0x4D] = 22, +} + +-- Draw an authentic GBC red cursor bounding frame using OAM sprite tiles. +-- On real GBC hardware OAM color 0 is always transparent, so only the 1px +-- dark edges of tiles 0-2 are visible; the board shows through the middle. +-- We replicate this by drawing in "multiply" blend mode: white (255,255,255) +-- pixels multiply to the board colour unchanged, black (0,0,0) pixels tinted +-- to GBC red draw the border, and nothing fills the interior. +function CardFlip:drawOamBox(px, py, w, h) + local img, qCorner, qVEdge, qHEdge = self:cursorQuads() + local G = love.graphics + + if not (img and qCorner and qVEdge and qHEdge) then + -- Fallback: plain 1px red outline + G.setColor(248 / 255, 56 / 255, 40 / 255, 1) + G.rectangle("fill", px, py, w, 1) + G.rectangle("fill", px, py + h - 1, w, 1) + G.rectangle("fill", px, py, 1, h) + G.rectangle("fill", px + w - 1, py, 1, h) + G.setColor(1, 1, 1, 1) + return + end + + local prevBlend, prevAlpha = G.getBlendMode() + G.setBlendMode("multiply", "premultiplied") + -- Tint: black (0) → GBC red, white (255) → white (passthrough = transparent) + G.setColor(248 / 255, 56 / 255, 40 / 255, 1) + + -- 4 Corners + G.draw(img, qCorner, px, py, 0, 1, 1) + G.draw(img, qCorner, px + w, py, 0, -1, 1) + G.draw(img, qCorner, px, py + h, 0, 1, -1) + G.draw(img, qCorner, px + w, py + h, 0, -1, -1) + + -- Top & Bottom horizontal edges + if w > 16 then + for x = px + 8, px + w - 16, 8 do + G.draw(img, qHEdge, x, py, 0, 1, 1) + G.draw(img, qHEdge, x, py + h, 0, 1, -1) + end + end + + -- Left & Right vertical edges + if h > 16 then + for y = py + 8, py + h - 16, 8 do + G.draw(img, qVEdge, px, y, 0, 1, 1) + G.draw(img, qVEdge, px + w, y, 0, -1, 1) + end + end + + G.setBlendMode(prevBlend, prevAlpha) + G.setColor(1, 1, 1, 1) +end + function CardFlip:drawBoard() - -- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand - -- being played and every one before it stays off. - for row = 0, CardFlip.HANDS_PER_DECK - 1 do - Chrome.print(row == self.played and "o" or ".", LIGHT_X, row) - end - for x = 2, 5 do - Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW) - -- The pair headers sit above the two Pokemon they cover. - if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end - end - for y = 2, 7 do - local row = LEVEL_ROW[y] - Chrome.print(tostring(y - 1), LEVEL_COL, row) - if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end + local s1, s2, s3, sOn, sOff = self:sheets() + local tm = getCardFlipTilemap() + local G = love.graphics + + -- Green background fill + G.setColor(49 / 255, 156 / 255, 66 / 255, 1) + G.rectangle("fill", 0, 0, 160, 144) + + if not tm or not s2:available() then + -- Fallback simple board + for row = 0, CardFlip.HANDS_PER_DECK - 1 do + Chrome.print(row == self.played and "o" or ".", LIGHT_X, row) + end for x = 2, 5 do - -- A still-in-the-deck cell stands in for the card back until the art - -- lands. It cannot be '#': that is charmap.asm $54, the text command - -- that places "POKé", not a one-tile glyph. - local card = CardFlip.card(y - 2, x - 2) - Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row) + Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW) + if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end + end + for y = 2, 7 do + local pair = math.floor((y - 2) / 2) + local isBottom = ((y - 2) % 2 == 1) + local row = 3 + pair * 3 + (isBottom and 1 or 0) + Chrome.print(tostring(y - 1), LEVEL_COL, row) + if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end + for x = 2, 5 do + local card = CardFlip.card(y - 2, x - 2) + Chrome.print(self.discarded[card] and " " or "?", MON_COL[x], row) + end + end + return + end + + -- Draw the 11x12 board tilemap at (9, 0) + for ty = 0, 11 do + for tx = 0, 10 do + local idx = ty * 11 + tx + 1 + local tileId = tm[idx] + local screenX = 9 + tx + local screenY = ty + + -- Attribute palette + local pal = 0 + if screenY >= 1 and screenY <= 2 then + if screenX == 12 or screenX == 13 then pal = 1 -- Pikachu + elseif screenX == 14 or screenX == 15 then pal = 2 -- Jigglypuff + elseif screenX == 16 or screenX == 17 then pal = 3 -- Poliwag + elseif screenX == 18 or screenX == 19 then pal = 4 -- Oddish + end + elseif screenX == 9 then + pal = 1 -- Lights + end + + local colors = CARDFLIP_PALS.bg[pal] + s1.palette = colors + s2.palette = colors + s3.palette = colors + + if screenX == 9 then + -- Column 9: Light buttons + if screenY == self.played then + sOn.palette = colors + sOn:draw(0, screenX, screenY) + else + sOff.palette = colors + sOff:draw(0, screenX, screenY) + end + elseif tileId >= 0x3e then + -- Board graphics from card_flip_2 (using accurate 2x2 header tile mapping) + local mappedId = HEADER_TILE_MAP[tileId] or (tileId - 0x3e) + s2:draw(mappedId, screenX, screenY) + elseif tileId < 0x3e then + -- Graphics from card_flip_1 + s1:draw(tileId, screenX, screenY) + end + end + end + + -- Draw discarded card blanking covers (each 2-tile wide stacked card cell is 16x12 px) + for y = 2, 7 do + local level = y - 2 + local pair = math.floor(level / 2) + local isBottom = (level % 2 == 1) + local py = 24 + pair * 24 + (isBottom and 12 or 0) + for x = 2, 5 do + local mon = x - 2 + local card = CardFlip.card(level, mon) + if self.discarded[card] then + -- Discarded cover over full 16x12 stacked card cell + G.setColor(49 / 255, 156 / 255, 66 / 255, 1) + G.rectangle("fill", MON_COL[x] * 8, py, 16, 12) + end end end end -function CardFlip:cursorCell() +function CardFlip:cursorBounds() local x, y = self.cursorX, self.cursorY - local col - if x == 0 then col = LEVEL_PAIR_COL - elseif x == 1 then col = LEVEL_COL - else col = MON_COL[x] end - local row - if y == 0 then row = MON_PAIR_ROW - elseif y == 1 then row = MON_ROW - else row = LEVEL_ROW[y] end - return col, row + if y == 0 then + -- Pokemon Pair: spans 4 columns (32px), 1 row (8px) + local px = (MON_COL[x] or 12) * 8 + local py = MON_PAIR_ROW * 8 + return px, py, 32, 8 + elseif y == 1 then + -- Single Pokemon: 2x2 tiles (16x16 px) + local px = (MON_COL[x] or 12) * 8 + local py = MON_ROW * 8 + return px, py, 16, 16 + elseif x == 0 then + -- Level Pair: 1 column (8px), spans 24px across the full pair + local pair = math.floor((y - 2) / 2) + local px = LEVEL_PAIR_COL * 8 + local py = 24 + pair * 24 + return px, py, 8, 24 + elseif x == 1 then + -- Single Level: 1 column (8px), 12px tall for each stacked card + local pair = math.floor((y - 2) / 2) + local isBottom = ((y - 2) % 2 == 1) + local px = LEVEL_COL * 8 + local py = 24 + pair * 24 + (isBottom and 12 or 0) + return px, py, 8, 12 + else + -- Exact Card: 2 columns (16px), 12px tall for each stacked card + local pair = math.floor((y - 2) / 2) + local isBottom = ((y - 2) % 2 == 1) + local px = (MON_COL[x] or 12) * 8 + local py = 24 + pair * 24 + (isBottom and 12 or 0) + return px, py, 16, 12 + end end --- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the --- 3x3 Pokepic one row further down. +local FACE_DOWN_TILES = { + { 0x08, 0x09, 0x09, 0x09, 0x0a }, + { 0x0b, 0x28, 0x2b, 0x28, 0x0c }, + { 0x0b, 0x2c, 0x2d, 0x2e, 0x0c }, + { 0x0b, 0x2f, 0x30, 0x31, 0x0c }, + { 0x0b, 0x32, 0x33, 0x34, 0x0c }, + { 0x0d, 0x0e, 0x0e, 0x0e, 0x0f }, +} + +local FACE_UP_TILES = { + { 0x18, 0x19, 0x19, 0x19, 0x1a }, + { 0x1b, 0x35, 0x28, 0x28, 0x1c }, + { 0x0b, 0x28, 0x28, 0x28, 0x0c }, + { 0x0b, 0x28, 0x28, 0x28, 0x0c }, + { 0x0b, 0x28, 0x28, 0x28, 0x0c }, + { 0x1d, 0x1e, 0x1e, 0x1e, 0x1f }, +} + +local MON_ANCHORS = { + [0] = 24, -- Pikachu (tiles 24..32 in card_flip_2) + [1] = 33, -- Jigglypuff (tiles 33..41 in card_flip_2) + [2] = 42, -- Poliwag (tiles 42..50 in card_flip_2) + [3] = 51, -- Oddish (tiles 51..59 in card_flip_2) +} + +-- Draw face-down, flipping, or face-up card at (2, 0) or (2, 6) function CardFlip:drawCards() + local s1, s2 = self:sheets() + for slot = 1, 2 do local box = CARD_BOX[slot] - Chrome.box(box.x, box.y, CARD_BOX_W, CARD_BOX_H) + local bx, by = box.x * 8, box.y * 8 local chosen = (slot - 1) == self.which - if self.faceUp and chosen then - Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3, - box.y + 1) - Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)], - box.x + 1, box.y + 3) - elseif self.phase == "choose" and chosen then - Chrome.cursor(box.x, box.y + 3) + local isFlipping = (self.phase == "flipping") and chosen + local isFaceUp = (self.faceUp and chosen) + + if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 4) then + local activeCard = self.faceUp or self.targetCard or 0 + local lvl = CardFlip.level(activeCard) + 1 + local mon = CardFlip.mon(activeCard) + local monPal = CARDFLIP_PALS.bg[mon + 1] or CARDFLIP_PALS.bg[1] + + s1.palette = CARDFLIP_PALS.bg[0] + for cy = 1, 6 do + for cx = 1, 5 do + local tid = FACE_UP_TILES[cy][cx] + s1:draw(tid, box.x + cx - 1, box.y + cy - 1) + end + end + + -- Level digit at (box.x + 3, box.y + 1) + if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 8) then + Chrome.print(tostring(lvl), box.x + 3, box.y + 1) + + -- Draw 3x3 Pokemon pic from card_flip_2 (s2) at (box.x + 1, box.y + 2) + s2.palette = monPal + local anchor = MON_ANCHORS[mon] or 24 + for py = 0, 2 do + for px = 0, 2 do + local tid = anchor + py * 3 + px + s2:draw(tid, box.x + 1 + px, box.y + 2 + py) + end + end + end + else + s1.palette = CARDFLIP_PALS.bg[0] + for cy = 1, 6 do + for cx = 1, 5 do + local tid = FACE_DOWN_TILES[cy][cx] + s1:draw(tid, box.x + cx - 1, box.y + cy - 1) + end + end + + if self.phase == "choose" and chosen then + -- Authentic OAM red selection box around 5x6 card (40x48 px) + self:drawOamBox(bx, by, 40, 48) + end end end end function CardFlip:drawPanel() - Chrome.clear() self:drawBoard() self:drawCards() - Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2) - Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y) - Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y) + + -- Dialogue / Message box at (0, 12), 10 wide, 6 tall (interior 8x4) if self.lines then - Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) + Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, 8, 4) for i, line in ipairs(self.lines) do Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) end end + + -- Coin box at (9, 15), 11 wide, 3 tall (interior 9x1) + Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, 9, 1) + Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y) + Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y) + if self.phase == "bet" then - local col, row = self:cursorCell() - Chrome.cursor(col - 1, row) + self.betBlink = (self.betBlink or 0) + 1 + if (self.betBlink % 32) < 24 then + local px, py, w, h = self:cursorBounds() + self:drawOamBox(px, py, w, h) + end end + if self.phase == "ask" or self.phase == "again" then -- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10). Chrome.textbox(14, 7, 4, 3) @@ -652,3 +982,4 @@ function CardFlip:drawWidescreen(winW, winH) end return CardFlip + diff --git a/src/ui/gen2/SlotMachine.lua b/src/ui/gen2/SlotMachine.lua index 46716336..83bd38fb 100644 --- a/src/ui/gen2/SlotMachine.lua +++ b/src/ui/gen2/SlotMachine.lua @@ -219,10 +219,16 @@ local TWO_LINES = { function SlotMachine.matchFirstTwo(bet, r1, r2) local building = SlotMachine.NO_MATCH + local matchingSevens = false for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do - if r1[line[1]] == r2[line[2]] then building = r1[line[1]] end + if r1[line[1]] == r2[line[2]] then + building = r1[line[1]] + if building == SlotMachine.SEVEN then + matchingSevens = true + end + end end - return building, building == SlotMachine.SEVEN + return building, matchingSevens end -- ------------------------------------------------------------------- bias @@ -403,13 +409,14 @@ end -- matching SEVENs on a line this bet buys. function SlotMachine.spinReel2ToSevens(position, bet, stopped1) local strip = SlotMachine.REELS[2] + local pos = SlotMachine.advance(position) for _ = 1, SEARCH_LIMIT do - local window = { SlotMachine.window(strip, position) } + local window = { SlotMachine.window(strip, pos) } local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window) - if building ~= SlotMachine.NO_MATCH and sevens then return position end - position = SlotMachine.advance(position) + if building ~= SlotMachine.NO_MATCH and sevens then return pos end + pos = SlotMachine.advance(pos) end - return position + return pos end -- ------------------------------------------------------- reel 3's theatre @@ -580,8 +587,8 @@ end -- ------------------------------------------------------------------ layout local COINS_X, COINS_Y = 5, 1 local PAYOUT_X, PAYOUT_Y = 11, 1 --- REEL_X_COORD / TILE_WIDTH. -local REEL_X = { 6, 10, 14 } +-- REEL_X_COORD / TILE_WIDTH (5, 9, 13) for the 3 reel apertures (columns 5-6, 9-10, 13-14) +local REEL_X = { 5, 9, 13 } -- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which -- sits 16px above the screen) to tile rows: bottom, middle, top, and the -- fourth symbol that only half shows. @@ -617,7 +624,7 @@ SlotMachine.TEXTS = TEXTS -- _SlotsLinedUpText: "lined up!" / "Won @ coins!", with the -- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout. local function linedUpLines(payout) - return { "lined up!", ("Won %d coins!"):format(payout) } + return { " lined up!", ("Won %d coins!"):format(payout) } end -- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what @@ -729,30 +736,41 @@ function SlotMachine:startSpin() self.reel = 1 self.delay = 32 self.message = TEXTS.start + self.stopped = nil + self.matched = SlotMachine.NO_MATCH + self.matchingSevens = false + self.reel3Action = nil + self.golemAnim = nil + self.chanseyAnim = nil + self.reel2Pause = nil for i = 1, 3 do self.rate[i] = 4 -- ReelAction_NormalRate self.stops[i] = nil + self.distance[i] = 0 end self:sfx(SFX_START) end -- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot --- boundary, and the position advances when the distance's low nibble wraps. +-- boundary, and the position advances whenever 16 pixels are traversed. function SlotMachine:spinReels() for i = 1, 3 do local rate = self.rate[i] if rate > 0 then - self.distance[i] = (self.distance[i] + rate) % 256 - if self.distance[i] % 16 == 0 then + self.distance[i] = (self.distance[i] or 0) + rate + while self.distance[i] >= 16 do + self.distance[i] = self.distance[i] - 16 self.positions[i] = SlotMachine.advance(self.positions[i]) -- A reel with a resting slot chosen stops the moment it reaches it. if self.stops[i] and self.positions[i] == self.stops[i] then self.rate[i] = 0 + self.distance[i] = 0 self.stopped = self.stopped or {} self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i], self.positions[i]) } self:sfx(SFX_STOP) self:reelStopped(i) + break end end end @@ -775,11 +793,17 @@ function SlotMachine:pressStop() self.stops[1] = SlotMachine.stopReel1(here, self.bias) elseif i == 2 then local r1 = self.stopped[1] - if SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) then + local hereWindow = { SlotMachine.window(SlotMachine.REELS[2], here) } + local _, hereSevens = SlotMachine.matchFirstTwo(self.bet, r1, hereWindow) + local doSkip = SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) + if hereSevens and not doSkip then + self.stops[2] = here + elseif doSkip then -- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then -- fast-spins it at double rate; the pause is the tell. self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1) - self.rate[2] = 8 + self.reel2Pause = 32 + self.rate[2] = 0 -- paused during the 32-frame tell else self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1) end @@ -789,32 +813,54 @@ function SlotMachine:pressStop() self.matchingSevens = sevens local action = SlotMachine.reel3Action(sevens, self.bias, self.random) self.reel3Action = action - if action == SlotMachine.REEL3_STOP then + if action == SlotMachine.REEL3_STOP or action == "stop" then self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2) - elseif action == SlotMachine.REEL3_SLOW then + elseif action == SlotMachine.REEL3_SLOW or action == "slowAdvance" then self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2) - self.rate[3] = 1 -- ReelAction_QuarterRate - elseif action == SlotMachine.REEL3_GOLEM then + self.rate[3] = 1 -- ReelAction_QuarterRate slow crawl + elseif action == SlotMachine.REEL3_GOLEM or action == "golem" then local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2, self.random) + if count == 0 then count = 3 end local target = here for _ = 1, count do target = SlotMachine.advance(target) end self.stops[3] = target self.golems = count - self.rate[3] = 8 + self.golemAnim = { + count = count, + state = "falling", + var1 = 48, + x = 100, + y = 44 - 112, + animFrame = 0, + animTimer = 0, + } + self.rate[3] = 0 -- reel 3 stepped by each golem impact else local target = SlotMachine.eggDrops(here, self.bet, r1, r2) self.stops[3] = target - self.rate[3] = 16 -- ReelAction_QuadrupleRate, the egg drop + self.chanseyAnim = { + state = "walking", + xcoord = 0, + x = -24, + y = 44, + animTimer = 0, + animPose = 0, + } + self.rate[3] = 0 -- paused until Chansey drops egg end end -- A reel already sitting on its resting slot has nowhere to turn. - if self.stops[i] == self.positions[i] then - self.rate[i] = 0 - self.stopped = self.stopped or {} - self.stopped[i] = self:reelWindow(i) - self:sfx(SFX_STOP) - self:reelStopped(i) + -- Only trigger immediate halt if no active pause tell or special animation is running. + if not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then + if self.stops[i] == self.positions[i] then + self.rate[i] = 0 + self.distance[i] = 0 + self.stopped = self.stopped or {} + self.stopped[i] = self:reelWindow(i) + self:sfx(SFX_STOP) + self:reelStopped(i) + end end end @@ -823,6 +869,9 @@ function SlotMachine:reelStopped(i) self.reel = i + 1 return end + self.golemAnim = nil + self.chanseyAnim = nil + self.reel2Pause = nil -- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames -- before the payout is counted out; a loss skips straight past it. local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3] @@ -906,13 +955,134 @@ function SlotMachine:update(_dt) if phase == "spinning" then -- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the -- bet menu cannot stop reel one. - if self.delay > 0 then + if (self.delay or 0) > 0 then self.delay = self.delay - 1 self:spinReels() return end + if self.reel2Pause and self.reel2Pause > 0 then + self.reel2Pause = self.reel2Pause - 1 + if self.reel2Pause <= 0 then + self.reel2Pause = nil + self.rate[2] = 8 -- resume fast-spin after the 32-frame tell + end + end + + if self.golemAnim then + local g = self.golemAnim + -- Cycle animation frames every 8 ticks (~7.5 fps) matching the original pacing + g.animTimer = (g.animTimer or 0) + 1 + if g.animTimer >= 8 then + g.animTimer = 0 + g.animFrame = ((g.animFrame or 0) + 1) % 4 + end + + if g.state == "falling" then + if g.var1 > 32 then + g.var1 = g.var1 - 1 + local angle = (g.var1 * math.pi) / 32 + local yOffset = math.floor(112 * math.sin(angle) + 0.5) + g.y = 44 + yOffset + g.x = 100 + else + -- Landed on Reel 3! + g.y = 44 + g.x = 100 + g.state = "rolling" + g.xoffset = 0 + g.animTimer = 0 + g.animFrame = 0 + self:sfx("Sfx_PlacePuzzlePieceDown") + -- Advance reel 3 by 1 slot per Golem impact + self.positions[3] = SlotMachine.advance(self.positions[3]) + self.distance[3] = 0 + end + elseif g.state == "rolling" then + g.xoffset = (g.xoffset or 0) + 1 + g.x = 100 - g.xoffset + + if g.xoffset >= 88 then + -- Rolled past reel 1 (100 - 88 = 12px) off the screen -> restart or end + g.count = g.count - 1 + if g.count > 0 then + g.state = "falling" + g.var1 = 48 + g.x = 100 + g.y = 44 - 112 + else + -- All Golems finished; halt reel 3 at target + self.golemAnim = nil + self.rate[3] = 0 + self.distance[3] = 0 + self.stopped = self.stopped or {} + self.stopped[3] = self:reelWindow(3) + self:sfx(SFX_STOP) + self:reelStopped(3) + end + end + end + end + + if self.chanseyAnim then + local c = self.chanseyAnim + if c.state == "walking" then + c.xcoord = (c.xcoord or 0) + 1 + c.x = c.xcoord - 24 + c.y = 44 + + -- Cycle walking poses 0->1->2->3 (maps to Chansey 1->2->3->4) every 6 frames + c.animTimer = (c.animTimer or 0) + 1 + if c.animTimer >= 6 then + c.animTimer = 0 + c.animPose = ((c.animPose or 0) + 1) % 4 + end + + if c.xcoord % 16 == 0 then self:sfx("Sfx_JumpOverLedge") end + + if c.x >= 88 then + -- Reached reel 3! Switch to tell pause (pose 4 = arm raised) + c.x = 88 + c.state = "egg_pause" + c.delay = 14 + c.animPose = 3 + end + elseif c.state == "egg_pause" then + c.delay = (c.delay or 14) - 1 + if c.delay <= 0 then + -- Switch to Chansey 5 (egg drop pose) and spawn egg + c.animPose = 4 + c.state = "egg_drop" + c.eggTimer = 0 + c.eggStartX = c.x + 14 + c.eggStartY = c.y + 8 + c.eggX = c.eggStartX + c.eggY = c.eggStartY + c.eggVisible = true + self:sfx("Sfx_Present") + end + elseif c.state == "egg_drop" then + c.eggTimer = (c.eggTimer or 0) + 1 + local t = c.eggTimer / 16 + if t > 1 then t = 1 end + c.eggX = c.eggStartX + t * (108 - c.eggStartX) + c.eggY = c.eggStartY + t * (56 - c.eggStartY) - math.sin(t * math.pi) * 8 + + if c.eggTimer >= 16 then + -- Egg landed on Reel 3! + c.eggVisible = false + c.state = "spinning" + self:sfx("Sfx_PlacePuzzlePieceDown") + self.rate[3] = 16 -- fast drop reel 3 to jackpot + end + end + end + self.message = nil - if input:wasPressed("a") then self:pressStop() end + if input:wasPressed("a") then + if not self.stops[self.reel] and not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then + self:pressStop() + end + end self:spinReels() return end @@ -959,57 +1129,302 @@ end -- ------------------------------------------------------------------- draw -- --- The cart's reel art is unextracted (see the header), so a symbol draws as its --- two-letter label inside a 2x2 cell. A `slots` entry in menu_gfx.lua switches --- this to the real tiles without any other change. -function SlotMachine:sheet() - if self.sheetCache == nil then - local data = self.game and self.game.data - local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots - if gfx and gfx.image then - local TileSheet = require("src.ui.gen2.TileSheet") - self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16, - firstTile = gfx.firstTile or 0 }) +-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold +local TileSheet = require("src.ui.gen2.TileSheet") +local GbcPalette = require("src.render.GbcPalette") + +local GBC_PALS = { + bg = { + [0] = { { 255, 255, 255 }, { 198, 206, 231 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 0: Base Frame + [1] = { { 255, 255, 255 }, { 247, 82, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 1: Vileplume / Active Lights + [2] = { { 255, 255, 255 }, { 123, 255, 0 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 2: Bet 3 Indicators + [3] = { { 255, 255, 255 }, { 255, 123, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 3: Bet 2 Indicators + [4] = { { 255, 255, 255 }, { 123, 173, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 4: Bet 1 Indicators + [5] = { { 255, 255, 90 }, { 255, 255, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 5: Yellow Highlights + [6] = { { 255, 255, 255 }, { 132, 156, 239 }, { 206, 181, 0 }, { 0, 0, 0 } }, -- 6: Textbox frame + [7] = { { 255, 255, 255 }, { 173, 173, 173 }, { 107, 107, 107 }, { 0, 0, 0 } }, -- 7: Inactive / Gray + }, + obj = { + [0] = { { 255, 255, 255 }, { 247, 82, 49 }, { 255, 0, 0 }, { 0, 0, 0 } }, -- 0: Seven (Red) + [1] = { { 255, 255, 255 }, { 99, 206, 8 }, { 41, 115, 0 }, { 0, 0, 0 } }, -- 1: Pokeball (Green/Red) + [2] = { { 255, 255, 255 }, { 99, 206, 8 }, { 247, 82, 49 }, { 0, 0, 0 } }, -- 2: Cherry + [3] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 3: Pikachu (Yellow) + [4] = { { 255, 255, 255 }, { 255, 255, 49 }, { 123, 173, 255 }, { 0, 0, 0 } }, -- 4: Squirtle (Blue/Yellow) + [5] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 5: Staryu / Golem (Rock) + [6] = { { 255, 255, 255 }, { 255, 198, 173 }, { 255, 107, 255 }, { 0, 0, 0 } }, -- 6: Chansey (Pink) + [7] = { { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, { 0, 0, 0 } }, -- 7: Flashing + } +} + +local TILEMAP = nil +local function getTilemap() + if TILEMAP == nil then + local path = "assets/generated/slots/gold_slots.tilemap" + local f = io.open(path, "rb") + if f then + local data = f:read("*a") + f:close() + TILEMAP = {} + for i = 1, #data do + TILEMAP[i] = string.byte(data, i) + end else - self.sheetCache = false + TILEMAP = false end end - return self.sheetCache or nil + return TILEMAP or nil end -local function cell(tx, ty, label) - local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("line", tx * 8, ty * 8, 16, 16) - Chrome.print(label, tx, ty + 1) +function SlotMachine:sheets() + if self.sheet1 == nil then + self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 }) + self.sheet2 = TileSheet.new({ path = "assets/generated/slots/gold_slots_2.png", wide = 2, firstTile = 0 }) + self.sheet3 = TileSheet.new({ path = "assets/generated/slots/gold_slots_3.png", wide = 3, firstTile = 0 }) + end + return self.sheet1, self.sheet2, self.sheet3 +end + +function SlotMachine:drawBackground() + local s1, s2 = self:sheets() + local tm = getTilemap() + if not tm or not s1:available() then + -- Fallback simple background if assets unavailable + Chrome.clear() + return + end + + local bet = self.bet or 0 + + for ty = 0, 11 do + for tx = 0, 19 do + local idx = ty * 20 + tx + 1 + local tileId = tm[idx] + + -- Palette attribution matching _CGB_SlotMachine + local pal = 0 + if (tx <= 2 or tx >= 17) and ty >= 2 and ty <= 11 then + if ty >= 6 and ty <= 7 then pal = 4 + elseif ty >= 4 and ty <= 9 then pal = 3 + else pal = 2 end + elseif tx >= 4 and tx <= 15 and ty >= 2 and ty <= 3 then + pal = 1 -- Vileplume + elseif (tx == 3 or tx == 16) and ty >= 2 and ty <= 11 then + local isLit = false + if ty == 6 or ty == 7 then isLit = (bet >= 1) + elseif ty == 4 or ty == 5 or ty == 8 or ty == 9 then isLit = (bet >= 2) + elseif ty == 2 or ty == 3 or ty == 10 or ty == 11 then isLit = (bet >= 3) + end + if isLit then + pal = 1 + -- Use lit lights tile + if tileId == 0x23 then tileId = 0x14 + elseif tileId == 0x24 then tileId = 0x15 end + else + pal = 0 + end + end + + local colors = GBC_PALS.bg[pal] + s1.palette = colors + s2.palette = colors + + if tileId < 0x25 then + s1:draw(tileId, tx, ty) + else + s2:draw(tileId - 0x25, tx, ty) + end + end + end end function SlotMachine:drawReels() + local _, s2 = self:sheets() + local G = love.graphics + for i = 1, 3 do local strip = SlotMachine.REELS[i] - local position = self.positions[i] - -- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays - -- them bottom upward, which is what the three repeated entries at the end - -- of each strip are for: position 14 reads indices 14, 15, 16 and 17 - -- without wrapping. Only the lower three are on a pay line; the fourth - -- half-shows at the top of the window. - for row = 1, 4 do - local symbol = strip[position + row] - cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?") + local pos = self.positions[i] + local a = pos + if a == 0 then a = 0x0f end + a = (a - 1) % 16 + local rx = REEL_X[i] * 8 + local dy = math.floor(self.distance[i] or 0) + + -- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window + for row = 0, 3 do + local sym = strip[a + row + 1] + local py = 64 - (row * 16) + dy + local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] + s2.palette = pal + + -- 2x2 tiles in 2-wide sheet: + -- sym + 0 = top-left (col 0, row 0) + -- sym + 1 = top-right (col 1, row 0) + -- sym + 2 = bottom-left (col 0, row 1) + -- sym + 3 = bottom-right (col 1, row 1) + local t0 = s2:quad(sym + 0) + local t1 = s2:quad(sym + 1) + local t2 = s2:quad(sym + 2) + local t3 = s2:quad(sym + 3) + local img = s2:image() + + if img and t0 and t1 and t2 and t3 then + local function drawSym() + G.draw(img, t0, rx, py) + G.draw(img, t1, rx + 8, py) + G.draw(img, t2, rx, py + 8) + G.draw(img, t3, rx + 8, py + 8) + end + if GbcPalette.available() then + GbcPalette.with(pal, drawSym) + else + drawSym() + end + else + -- Fallback label + cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?") + end end end end -function SlotMachine:drawLights() - local lit = {} - -- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller - -- one: `dec a / jr z` falls through from three to two to one. - for bet = 1, (self.bet or 0) do - for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end +function SlotMachine:actorsImage() + if self.actorsLoaded == nil then + local Assets = require("src.render.Assets") + local ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_actors.png") + if not (ok and img) then + ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_3.png") + end + self.actorsLoaded = (ok and img) or false + if self.actorsLoaded then + local G = love.graphics + -- 24x240 sheet: + -- Y=0: Golem 1 (Standing, 24x32) + -- Y=32: Golem 2 (Ball, 24x32) + -- Y=64: Chansey 1 (Standing / Step 1, 24x32) + -- Y=96: Chansey 2 (Step 2, 24x32) + -- Y=128: Chansey 3 (Step 3, 24x32) + -- Y=160: Chansey 4 (Arm raised / Step 4, 24x32) + -- Y=192: Chansey 5 (Egg Drop pose, 24x32) + -- Y=224: Egg (8x16 at X=0) + self.quadGolemStand = G.newQuad(0, 0, 24, 32, 24, 240) + self.quadGolemBall = G.newQuad(0, 32, 24, 32, 24, 240) + self.quadChansey1 = G.newQuad(0, 64, 24, 32, 24, 240) + self.quadChansey2 = G.newQuad(0, 96, 24, 32, 24, 240) + self.quadChansey3 = G.newQuad(0, 128, 24, 32, 24, 240) + self.quadChansey4 = G.newQuad(0, 160, 24, 32, 24, 240) + self.quadChanseyDrop = G.newQuad(0, 192, 24, 32, 24, 240) + self.quadEgg = G.newQuad(0, 224, 8, 16, 24, 240) + end end - for _, row in ipairs({ 2, 4, 6, 8, 10 }) do - for _, col in ipairs(LIGHT_COLS) do - Chrome.print(lit[row] and "*" or "-", col, row) + return self.actorsLoaded or nil +end + +-- Redraw the top Vileplume row (rows 2..3) and bottom frame brackets (rows 10..11) +-- over the reels with solid backdrop to naturally mask any sprite overhang like the Game Boy hardware does. +function SlotMachine:drawOverlays() + local s1, s2 = self:sheets() + local tm = getTilemap() + if not tm or not s1:available() then return end + + local G = love.graphics + -- Solid backdrop over header (rows 0..3) and footer (rows 10..11) between columns 4..15 + G.setColor(198 / 255, 198 / 255, 74 / 255, 1) + G.rectangle("fill", 4 * 8, 2 * 8, 12 * 8, 2 * 8) + G.rectangle("fill", 4 * 8, 10 * 8, 12 * 8, 2 * 8) + G.setColor(1, 1, 1, 1) + + for _, ty in ipairs({ 2, 3, 10, 11 }) do + for tx = 4, 15 do + local idx = ty * 20 + tx + 1 + local tileId = tm[idx] + local pal = (ty <= 3) and 1 or 0 + local colors = GBC_PALS.bg[pal] + s1.palette = colors + s2.palette = colors + + if tileId < 0x25 then + s1:draw(tileId, tx, ty) + else + s2:draw(tileId - 0x25, tx, ty) + end + end + end + + -- Draw Golem sprite animation + if self.golemAnim then + local actors = self:actorsImage() + local g = self.golemAnim + if actors then + local quad = self.quadGolemBall + local scaleX = 1 + local scaleY = 1 + if g.state == "falling" then + quad = self.quadGolemBall + elseif g.state == "rolling" then + -- Frameset_SlotsGolem: 0=Standing, 1=Ball, 2=StandingYFlip, 3=BallXFlip + local rotFrame = (g.animFrame or 0) % 4 + if rotFrame == 0 then + quad = self.quadGolemStand + scaleX = 1 + scaleY = 1 + elseif rotFrame == 1 then + quad = self.quadGolemBall + scaleX = 1 + scaleY = 1 + elseif rotFrame == 2 then + quad = self.quadGolemStand + scaleX = 1 + scaleY = -1 + elseif rotFrame == 3 then + quad = self.quadGolemBall + scaleX = -1 + scaleY = 1 + end + end + G.setColor(1, 1, 1, 1) + local function drawGolem() + -- Draw rotated around center (ox=12, oy=16) + G.draw(actors, quad, + math.floor(g.x + 12), + math.floor(g.y + 16), + 0, scaleX, scaleY, 12, 16) + end + if GbcPalette.available() then + GbcPalette.with(GBC_PALS.obj[5], drawGolem) + else + drawGolem() + end + end + end + + -- Draw Chansey & Egg sprite animation + if self.chanseyAnim then + local actors = self:actorsImage() + local c = self.chanseyAnim + if actors then + local quad = self.quadChansey1 + if c.state == "walking" then + local walkCycle = { self.quadChansey1, self.quadChansey2, self.quadChansey3, self.quadChansey4 } + quad = walkCycle[((c.animPose or 0) % 4) + 1] or self.quadChansey1 + elseif c.state == "egg_pause" then + quad = self.quadChansey4 + elseif c.state == "egg_drop" or c.state == "spinning" then + quad = self.quadChanseyDrop + end + + G.setColor(1, 1, 1, 1) + local function drawChansey() + G.draw(actors, quad, math.floor(c.x), math.floor(c.y or 44)) + if c.eggVisible and c.eggX and c.eggY then + G.draw(actors, self.quadEgg, math.floor(c.eggX), math.floor(c.eggY)) + end + end + if GbcPalette.available() then + GbcPalette.with(GBC_PALS.obj[6], drawChansey) + else + drawChansey() + end end end end @@ -1022,39 +1437,79 @@ function SlotMachine:drawMessage() end if self.matched and self.matched ~= SlotMachine.NO_MATCH and self.phase == "payoutText" then - cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, - SlotMachine.LABELS[self.matched] or "?") + local _, s2 = self:sheets() + local sym = self.matched + local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] + s2.palette = pal + local t0 = s2:quad(sym + 0) + local t1 = s2:quad(sym + 1) + local t2 = s2:quad(sym + 2) + local t3 = s2:quad(sym + 3) + local img = s2:image() + local G = love.graphics + local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8 + if img and t0 and t1 and t2 and t3 then + local function drawWin() + G.setColor(1, 1, 1, 1) + G.draw(img, t0, px, py) + G.draw(img, t1, px + 8, py) + G.draw(img, t2, px, py + 8) + G.draw(img, t3, px + 8, py + 8) + end + G.setColor(1, 1, 1, 1) + if GbcPalette.available() then + GbcPalette.with(pal, drawWin) + else + drawWin() + end + else + cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?") + end end end function SlotMachine:drawPanel() - Chrome.clear() - self:drawLights() + self:drawBackground() + self:drawReels() + self:drawOverlays() + -- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters. Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y) Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y) - self:drawReels() + if self.phase == "bet" then - Chrome.textbox(BET_BOX_X, BET_BOX_Y, BET_BOX_W - 2, BET_BOX_H - 2) + -- Left speech textbox for "Bet how many coins?" + Chrome.textbox(0, 12, 12, 4) + Chrome.print(TEXTS.betHowMany[1], 1, 14) + Chrome.print(TEXTS.betHowMany[2], 1, 16) + + -- Right menu for bet choices (14, 10 to 19, 17) + Chrome.textbox(14, 10, 4, 6) for i, label in ipairs(BET_ROWS) do - local ty = BET_LABEL_Y + (i - 1) * BET_SPACING - if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end - Chrome.print(label, BET_LABEL_X, ty) + local ty = 12 + (i - 1) * 2 + if i == self.betIndex then Chrome.cursor(15, ty) end + Chrome.print(label, 16, ty) end - if not self.message then - Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) - for i, line in ipairs(TEXTS.betHowMany) do + + if self.message then + -- If "Not enough coins." message is shown, overlay full speech box + Chrome.textbox(0, 12, 18, 4) + for i, line in ipairs(self.message) do Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) end end - end - self:drawMessage() - if self.phase == "again" then - -- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13). + elseif self.phase == "again" then + -- Speech box: "Play again?" + Chrome.textbox(0, 12, 18, 4) + Chrome.print(TEXTS.playAgain[1], TEXT_X, TEXT_Y) + + -- PlaceYesNoBox at (14, 12): 6x5 box with YES at (16,13), NO at (16,15) Chrome.textbox(14, 12, 4, 3) Chrome.print("YES", 16, 13) Chrome.print("NO", 16, 15) Chrome.cursor(15, 13 + (self.againChoice - 1) * 2) + else + self:drawMessage() end end diff --git a/tests/gen2_gamecorner_test.lua b/tests/gen2_gamecorner_test.lua index 5b82fc71..a120e925 100644 --- a/tests/gen2_gamecorner_test.lua +++ b/tests/gen2_gamecorner_test.lua @@ -1233,6 +1233,118 @@ check("a full wallet fills it", Chrome.money(999999), "\xc2\xa5999999") check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true), "0050") +-- ============================================================ multi-game stress tests +-- +-- Verify that 500 consecutive Slot Machine games and 500 consecutive Card Flip hands +-- run to completion with zero softlocks, zero infinite spin loops (issue #1520), +-- and correct coin accounting across all random biases and near-miss theatres. + +local mockSave = { + player = { name = "GOLD", coins = 5000 }, +} + +local mockInput = { + pressed = {}, + wasPressed = function(self, key) + local v = self.pressed[key] + self.pressed[key] = false + return v or false + end, + press = function(self, key) + self.pressed[key] = true + end, +} + +local mockSlotGame = { + save = mockSave, + input = mockInput, + data = {}, +} + +-- Test Slot Machine 500-spin continuous loop +local sm = SlotMachine.new(mockSlotGame, { lucky = true }) +local completedSpins = 0 + +for spin = 1, 500 do + mockSave.player.coins = 5000 -- ensure test player always has coins + if sm.phase == "quit" or sm.phase == "ranOut" then + sm = SlotMachine.new(mockSlotGame, { lucky = true }) + end + + -- Enter bet phase + check("slot machine in bet phase at spin start", sm.phase, "bet") + mockInput:press("a") -- bet 3 coins + sm:update(1/60) + + check("slot machine entered spinning phase", sm.phase, "spinning") + + local frameCount = 0 + local maxFrames = 5000 -- safety bound per spin + + while sm.phase == "spinning" and frameCount < maxFrames do + frameCount = frameCount + 1 + if frameCount % 30 == 0 then + mockInput:press("a") -- press stop button + end + sm:update(1/60) + end + + check(("spin %d must not hang in spinning"):format(spin), frameCount < maxFrames, true) + + -- Resolve flash and payout phases + while (sm.phase == "flash" or sm.phase == "payoutText") and frameCount < maxFrames do + frameCount = frameCount + 1 + if sm.phase == "payoutText" and sm.matched == SlotMachine.NO_MATCH then + mockInput:press("a") + end + sm:update(1/60) + end + + check(("spin %d reached again phase"):format(spin), sm.phase == "again" or sm.phase == "bet", true) + if sm.phase == "again" then + mockInput:press("a") -- choose YES to play again + sm:update(1/60) + completedSpins = completedSpins + 1 + elseif sm.phase == "bet" then + completedSpins = completedSpins + 1 + end +end + +check("all 500 slot machine spins completed without softlock", completedSpins >= 490, true) + +-- Test Card Flip 500-hand continuous loop +local cf = CardFlip.new(mockSlotGame) +local completedHands = 0 + +for hand = 1, 500 do + mockSave.player.coins = 5000 + if cf.phase == "quit" then + cf = CardFlip.new(mockSlotGame) + end + + local frameCount = 0 + local maxFrames = 500 + + while cf.phase ~= "again" and cf.phase ~= "quit" and frameCount < maxFrames do + frameCount = frameCount + 1 + if cf.phase == "ask" or cf.phase == "message" or cf.phase == "result" + or cf.phase == "choose" or cf.phase == "bet" then + mockInput:press("a") + end + cf:update(1/60) + end + + check(("hand %d completed in bounds"):format(hand), frameCount < maxFrames, true) + + if cf.phase == "again" then + mockInput:press("a") -- play again + cf:update(1/60) + completedHands = completedHands + 1 + end +end + +check("all 500 card flip hands completed cleanly", completedHands >= 490, true) + print(("gen2 game corner: %d checks, %d failures"):format(checks, failures)) -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit -- here would take the whole tier down and silently skip every suite after it. diff --git a/tests/test_surfing_minigame.lua b/tests/test_surfing_minigame.lua index 8607a92f..af4cb669 100644 --- a/tests/test_surfing_minigame.lua +++ b/tests/test_surfing_minigame.lua @@ -40,14 +40,16 @@ local mockGame = { print("Running SurfingMinigame unit tests...") --- Test 1: Initialization +-- Test 1: Initialization & Title Screen transition 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.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.routine, 0, "Initial routine must be ROUTINE_START_GAME (0)") assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)") -print("✓ Initial state test passed") +print("✓ Initial state & Title transition test passed") -- Test 2: Start banner transition to RunGame for _ = 1, 40 do @@ -65,6 +67,8 @@ assert_eq(mg.hp, initialHp - 1, "HP should decrease by 1 each frame") 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.frameSet = 5 assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing") mg.frameSet = 6 @@ -77,6 +81,7 @@ for f = 8, 14 do mg.frameSet = f assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout") end +mg.getWaveTileUnderPika = old_getWaveTile print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)") -- Test 5: Stunt Scoring @@ -145,6 +150,128 @@ end assert_eq(mg.radness, 0, "Radness should be tallied down to 0") assert_eq(mg.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)") assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)") -print("✓ Results tally countdown test passed") +-- 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.speedFixed = 512 +mg8.pikaState = 1 -- PIKA_STATE_JUMPING +mg8.frameSet = 11 -- Upside down +mg8.pikaY = 60 +mg8.jumpDescending = true +mg8.jumpArcMagnitude = 4 +mg8.radness = 150 +local preScore = mg8.radness + +-- Update to cross the finish line +mg8:update() +assert_eq(mg8.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2) upon crossing finish") +assert_eq(mg8.pikaState, 1, "Pikachu should remain mid-air immediately after crossing line") + +-- Update until Pikachu lands in water +while mg8.pikaState == 1 do + mg8:update() +end +assert_eq(mg8.pikaState, 3, "Upside-down landing post-finish line must trigger PIKA_STATE_CRASHED (3)") +assert_eq(mg8.radness, preScore, "Radness score must NOT change after crossing finish line") +assert_eq(mg8.crashTimer, 96, "Crash timer must be initialized to 96 frames") + +-- Update while crashed to verify recovery +while mg8.pikaState == 3 do + mg8:update() +end +assert_eq(mg8.pikaState, 0, "Pikachu must recover back to PIKA_STATE_RIDING (0) and right itself on the board") +assert_eq(mg8.frameSet, 4, "Pikachu frameSet must be reset to upright (4)") + +-- Let coasting finish and verify transition to results +while mg8.routine == 2 do + mg8:update() +end +assert_eq(mg8.routine, 3, "Routine should advance to ROUTINE_SCROLL_RESULTS (3) only after Pikachu is upright") +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.speedFixed = 512 +mg9.pikaState = 1 +mg9.frameSet = 4 -- Clean flat +mg9.pikaY = 60 +mg9.jumpDescending = true +mg9.jumpArcMagnitude = 4 +mg9.radness = 200 +preScore = mg9.radness + +mg9:update() +assert_eq(mg9.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)") + +while mg9.pikaState == 1 do + mg9:update() +end +assert_eq(mg9.pikaState, 2, "Upright landing post-finish line must trigger PIKA_STATE_LANDING (2)") +assert_eq(mg9.radness, preScore, "Radness score must NOT change post-finish") + +while mg9.pikaState == 2 do + mg9:update() +end +assert_eq(mg9.pikaState, 0, "Pikachu must return to PIKA_STATE_RIDING (0)") +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.speedFixed = 512 +mg10.pikaState = 3 -- PIKA_STATE_CRASHED +mg10.crashTimer = 50 + +mg10:update() +assert_eq(mg10.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)") +assert_eq(mg10.pikaState, 3, "Pikachu should still be crashed") + +while mg10.pikaState == 3 do + mg10:update() +end +assert_eq(mg10.pikaState, 0, "Pikachu must recover upright before proceeding to results") +print("✓ Pre-crashed finish line crossing recovery test passed") + +-- Test 11: Decoupled timestep accumulator (60Hz and 144Hz framerate consistency) +local mg11_60 = SurfingMinigame.new(mockGame, nil, true) +mg11_60.routine = 1 -- ROUTINE_RUN_GAME +for _ = 1, 60 do + mg11_60:update(1 / 60) +end +assert(mg11_60.t == 59 or mg11_60.t == 60, "60Hz update over 1s must produce approx 60 ticks (got " .. mg11_60.t .. ")") + +local mg11_144 = SurfingMinigame.new(mockGame, nil, true) +mg11_144.routine = 1 -- ROUTINE_RUN_GAME +for _ = 1, 144 do + mg11_144:update(1 / 144) +end +assert(mg11_144.t == 59 or mg11_144.t == 60, "144Hz update over 1s must produce approx 60 ticks (got " .. mg11_144.t .. ")") +print("✓ Decoupled 59.7275Hz timestep accumulator test passed") + +-- Test 12: Landing continuity on slopes (no position jumps while landing) +local mg12 = SurfingMinigame.new(mockGame, nil, true) +mg12.routine = 1 +mg12.pikaState = 2 -- PIKA_STATE_LANDING +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.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 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") +local mockStack = { states = { mg12 } } +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") print("All SurfingMinigame unit tests passed successfully!") diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 8c4a9325..e76c17c2 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -2037,8 +2037,48 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): _decode_2bpp(bytes(reordered), 40, 16), os.path.join(assets_dir, "credits/the_end.png")) - raw_2bpp( - "WorldMapTileGraphics", 32, 32, "townmap/tiles.png") + if _has_symbol(symbols, "SurfingPikachu1Graphics1"): + raw_2bpp("SurfingPikachu1Graphics1", 40, 104, "minigame/surf_1a.png", transparent=False) + 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) + screen = [0xff] * (20 * 18) + for i in range(240): + screen[6 * 20 + i] = beach_intro[i] + for r in range(6): + for c in range(12): + screen[r * 20 + (4 + c)] = title_map[r * 12 + c] + for r in range(3): + for c in range(15): + screen[(7 + r) * 20 + (3 + c)] = 0xff + for i in range(15): + screen[7 * 20 + 3 + i] = use_ctrl_pad[i] + for i in range(13): + screen[9 * 20 + 4 + i] = to_surf_rad[i] + + sym3 = _symbol(symbols, "SurfingPikachu1Graphics3") + raw_gfx3 = rom.bytes(sym3.bank, sym3.address, 144 * 16) + tiles = [_decode_2bpp(raw_gfx3[i*16:(i+1)*16], 8, 8) for i in range(144)] + blank = Image.new("RGBA", (8, 8), (255, 255, 255, 255)) + title_bg = Image.new("RGBA", (160, 144), (255, 255, 255, 255)) + for r in range(18): + for c in range(20): + t_id = screen[r * 20 + c] + if t_id == 0xff: + tile_img = blank + elif t_id >= 0x80: + idx = t_id - 0x80 + tile_img = tiles[idx] if idx < 144 else blank + else: + idx = 128 + t_id + tile_img = tiles[idx] if idx < 144 else blank + title_bg.paste(tile_img, (c * 8, r * 8)) + _save_png(title_bg, os.path.join(assets_dir, "minigame/title_bg.png")) + raw_2bpp("WorldMapTileGraphics", 32, 32, "townmap/tiles.png") raw_1bpp( "TownMapCursor", 16, 16, "townmap/cursor.png", transparent=True)