feat: surfing minigame overhaul + authentic Game Corner rendering

Surfing minigame:
- Add title screen (ROUTINE_TITLE) with Pikachu intro, logo banner, instructions
- Add GLSL HBlank wave distortion shader, OAM water spray/splash sprites
- Add multi-path asset loader, isMinigame/isFixedSpeed flags, crash recovery fixes
- Extract SurfingPikachu graphics + composite title_bg.png from ROM

Game Corner (built visual layer from ROM assets; logic existed, rendering was placeholder):
- SlotMachine: GBC tilemap background, authentic reel symbol sprites, Golem/Chansey
  near-miss animations, lit/unlit lights, payout panel
- CardFlip: GBC tilemap board, hardware-accurate card flip sequence, OAM cursor frame
- RomExtractorGen2: extract slots + card_flip sprite sheets and tilemaps

Tests: SurfingMinigame units 8-13; 500-spin SlotMachine and 500-hand CardFlip stress tests
This commit is contained in:
1jamie
2026-08-19 18:06:18 -05:00
parent 9713977755
commit f0d3c014a7
9 changed files with 1841 additions and 342 deletions
+12
View File
@@ -326,6 +326,9 @@ function Game:logicSpeed()
if self.linkSession or (self.linkNet and not self.linkNet.closed) then if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1 return 1
end end
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's -- 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 -- core.logic_speed hook can return anything (0, negative, nil, NaN) and
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
return "menu" return "menu"
end 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 -- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like -- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states -- everything else here: the text box and YES/NO a battle puts up are states
+1 -1
View File
@@ -230,7 +230,7 @@ end
function Music.play(data, song, loop, ctx) function Music.play(data, song, loop, ctx)
if not song then return end 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 {} ctx = ctx or {}
song = selectSong(song, ctx) song = selectSong(song, ctx)
+76
View File
@@ -5136,6 +5136,82 @@ function RomExtractorGen2:extractMenuGfx()
end end
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch 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:write("menu_gfx", out)
self:tick("Menu graphics", 1, 1) self:tick("Menu graphics", 1, 1)
return out return out
+490 -144
View File
@@ -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) local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144)
-- Routine numbers (wSurfingMinigameRoutineNumber) -- Routine numbers (wSurfingMinigameRoutineNumber)
local ROUTINE_TITLE = -1
local ROUTINE_START_GAME = 0 local ROUTINE_START_GAME = 0
local ROUTINE_RUN_GAME = 1 local ROUTINE_RUN_GAME = 1
local ROUTINE_WAIT_RESULTS = 2 local ROUTINE_WAIT_RESULTS = 2
@@ -216,7 +217,9 @@ local WAVE_STEPS = {
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV }, [0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET }, [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.BG_METATILES = BG_METATILES
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
@@ -233,6 +236,40 @@ local ANGLE_BASES = {
[7] = { 0x33, 0x69 }, -- Angle 06 (nose down steep / frontflip apex) [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) -- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10)
local BEACH_OUTRO = { local BEACH_OUTRO = {
{ 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, { 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) -- 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 } 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) 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.pikaState = PIKA_STATE_RIDING
self.t = 0 self.t = 0
self.routineTimer = 0 self.routineTimer = 0
@@ -269,9 +306,11 @@ function SurfingMinigame.new(game, onDone)
self.hp = 6000 -- starts at 6000 (60.00 seconds) self.hp = 6000 -- starts at 6000 (60.00 seconds)
self.radness = 0 -- accumulated trick stunt points self.radness = 0 -- accumulated trick stunt points
self.totalScore = 0 -- tallied total score 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.newRecord = false
self.currentPitch = 1.0 self.currentPitch = 1.0
self.isMinigame = true
self.isFixedSpeed = true
-- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV) -- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV)
self.rDiv = 0 self.rDiv = 0
@@ -297,6 +336,7 @@ function SurfingMinigame.new(game, onDone)
self.boardAngleDecreasing = false self.boardAngleDecreasing = false
self.boardAngleTimer = 0 self.boardAngleTimer = 0
self.crashTimer = 0 self.crashTimer = 0
self.landingTimer = 0
-- 3-frame buffered D-Pad rotation & input accumulator -- 3-frame buffered D-Pad rotation & input accumulator
self.joyCounter = 0 self.joyCounter = 0
@@ -318,14 +358,28 @@ function SurfingMinigame.new(game, onDone)
self.tallyStep = 0 self.tallyStep = 0
self.tallyTimer = 0 self.tallyTimer = 0
-- Load sheets safely -- Load sheets safely across all GameVersion prefix paths
local function sheet(path) local function sheet(path)
if not (love and love.graphics and love.graphics.newImage) then return nil end if not (love and love.graphics and love.graphics.newImage) then return nil end
local ok, img = pcall(love.graphics.newImage, path) local GameVersion = require("src.core.GameVersion")
return ok and img or nil 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 end
self.bg = sheet("assets/generated/minigame/surf_1a.png") self.bg = sheet("assets/generated/minigame/surf_1a.png")
self.ob = sheet("assets/generated/minigame/surf_1b.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 if self.bg then
self.tq = {} self.tq = {}
@@ -343,32 +397,97 @@ function SurfingMinigame.new(game, onDone)
end end
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 return self
end 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() function SurfingMinigame:getGBRandom()
self.rDiv = (self.rDiv + 7 + (self.inputAccum or 0)) % 256 self.rDiv = (self.rDiv + 13) % 256
self.rAdd = (self.rAdd + self.rDiv) % 256 local tempAdd = self.rAdd + self.rDiv
self.rSub = (self.rSub - self.rDiv + 256) % 256 self.rAdd = tempAdd % 256
return (self.rAdd + self.rSub) % 256 local tempSub = self.rSub - self.rDiv
self.rSub = (tempSub + 256) % 256
return self.rAdd
end end
function SurfingMinigame:chooseSequence() function SurfingMinigame:chooseSequence()
local distPx = math.floor(self.distanceFixed / 256) 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 self.waveFn = 0x6a
else elseif section < 0x16 then
local r = self:getGBRandom() 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 end
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
end end
function SurfingMinigame:pushColumn() function SurfingMinigame:pushColumn()
local pat, hl, hr 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() pat, hl, hr = self:chooseSequence()
else else
local step = WAVE_STEPS[self.waveFn] local step = WAVE_STEPS[self.waveFn]
@@ -396,30 +515,40 @@ function SurfingMinigame:seaY(x)
local distPx = math.floor(self.distanceFixed / 256) local distPx = math.floor(self.distanceFixed / 256)
local tile = math.floor((distPx + x) / 8) local tile = math.floor((distPx + x) / 8)
local col = self.cols[math.floor(tile / 2)] local col = self.cols[math.floor(tile / 2)]
if not col then return FLAT_WATER_Y - 16 end if not col then return FLAT_WATER_Y end
return (tile % 2 == 0 and col.hl or col.hr) - 16 return tile % 2 == 0 and col.hl or col.hr
end 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() function SurfingMinigame:getWaveTileUnderPika()
local distPx = math.floor(self.distanceFixed / 256) local distPx = math.floor(self.distanceFixed / 256)
local tile = math.floor((distPx + 80) / 8) local tile_col = math.floor((distPx + 72) / 8)
local col = self.cols[math.floor(tile / 2)] 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 if not col or not col.pat then return 0x01 end
local pat = col.pat
for i = 4, 8 do local i = math.floor(tile_row / 2) + 1
local mt = pat[i] if i < 1 or i > 8 then return 0x01 end
if mt then local mt = BG_METATILES[col.pat[i]]
if mt == 0x02 or mt == 0x06 or mt == 0x0a or mt == 0x11 then 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 return 0x06 -- rising slope
elseif mt == 0x03 or mt == 0x07 or mt == 0x0b or mt == 0x0d then 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 return 0x07 -- falling slope
elseif mt == 0x08 or mt == 0x09 or mt == 0x0f or mt == 0x10 or mt == 0x14 or mt == 0x15 then 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 return 0x14 -- wave crest / face
end end
end return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.)
end
return 0x01 -- flat open water
end end
function SurfingMinigame:spawnTrickPopup(text) function SurfingMinigame:spawnTrickPopup(text)
@@ -497,21 +626,51 @@ function SurfingMinigame:evaluateLanding()
end end
end end
function SurfingMinigame:updateTempo()
local tier = 1
if self.speedFixed >= 416 then
tier = 5
elseif self.speedFixed >= 320 then
tier = 4
elseif self.speedFixed >= 224 then
tier = 3
elseif self.speedFixed >= 128 then
tier = 2
else
tier = 1
end
local 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() function SurfingMinigame:updateRiding()
-- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0) -- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0)
if self.speedFixed < SPEED_MAX then if self.speedFixed < SPEED_MAX then
self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL)
end end
self:updateTempo()
-- Follow wave surface height -- Follow wave surface height
local targetY = self:seaY(80) local targetY = self:seaY(80)
self.pikaY = math.floor(targetY) self.pikaY = math.floor(targetY) - 16
self.pikaSubY = 0 self.pikaSubY = 0
-- Water spray every 4 frames -- Water spray every 4 frames
self.sprayTimer = self.sprayTimer + 1 self.sprayTimer = self.sprayTimer + 1
if self.sprayTimer % 4 == 0 then 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 end
-- Board angle wobbling every 8 frames -- Board angle wobbling every 8 frames
@@ -532,14 +691,14 @@ function SurfingMinigame:updateRiding()
end end
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() local tile = self:getWaveTileUnderPika()
if tile == 0x06 or tile == 0x14 then if tile == 0x06 or tile == 0x14 then
self.frameSet = 6 + (self.boardAngleOffset - 1) self.frameSet = 6 + (self.boardAngleOffset - 1)
elseif tile == 0x07 then elseif tile == 0x07 then
self.frameSet = 2 + (self.boardAngleOffset - 1) self.frameSet = 2 + (self.boardAngleOffset - 1)
else else
self.frameSet = 4 + (self.boardAngleOffset - 1) self.frameSet = 4 -- flat open water: steady horizontal ride
end end
self.frameSet = math.max(1, math.min(14, self.frameSet)) self.frameSet = math.max(1, math.min(14, self.frameSet))
@@ -560,43 +719,71 @@ function SurfingMinigame:updateRiding()
end end
end 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() function SurfingMinigame:updateJumping()
-- Process accumulated input on the 3-frame buffer boundary -- 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 self.joyCounter = (self.joyCounter + 1) % 3
if self.joyCounter == 0 then if self.joyCounter == 0 then
local rightHeld = bit.band(self.inputAccum, 1) ~= 0 local rightHeld = bit.band(self.inputAccum, 1) ~= 0
local leftHeld = bit.band(self.inputAccum, 2) ~= 0 local leftHeld = bit.band(self.inputAccum, 2) ~= 0
self.inputAccum = 0 self.inputAccum = 0
if rightHeld then -- Game Boy priority: Left D-pad checked first, then Right D-pad
self.rotCountLeft = 0 if leftHeld then
self.rotCountRight = self.rotCountRight + 1
if self.rotCountRight >= 11 then
self.rotCountRight = 0 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.radnessMeter = math.min(3, self.radnessMeter + 1)
self.trickFlags = bit.bor(self.trickFlags, 1) self.trickFlags = bit.bor(self.trickFlags, 1)
Sound.play(self.game.data, "Tink") Sound.play(self.game.data, "Tink")
end end
-- Increment frame set (frontflip forward)
self.frameSet = (self.frameSet % 14) + 1 self.frameSet = (self.frameSet % 14) + 1
elseif leftHeld then elseif rightHeld then
self.rotCountRight = 0
self.rotCountLeft = self.rotCountLeft + 1
if self.rotCountLeft >= 13 then
self.rotCountLeft = 0 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.radnessMeter = math.min(3, self.radnessMeter + 1)
self.trickFlags = bit.bor(self.trickFlags, 2) self.trickFlags = bit.bor(self.trickFlags, 2)
Sound.play(self.game.data, "Tink") Sound.play(self.game.data, "Tink")
end end
-- Decrement frame set (backflip backward)
self.frameSet = self.frameSet - 1 self.frameSet = self.frameSet - 1
if self.frameSet < 1 then self.frameSet = 14 end if self.frameSet < 1 then self.frameSet = 14 end
end end
end end
else
self.inputAccum = 0
end
-- Authentic Game Boy collision boundary & integer fixed-point jump physics -- Authentic Game Boy collision boundary & integer fixed-point jump physics
if not self.jumpDescending then 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) local intDelta = math.floor(self.pikaSubY / 256)
self.pikaSubY = self.pikaSubY % 256 self.pikaSubY = self.pikaSubY % 256
self.pikaY = self.pikaY - intDelta self.pikaY = self.pikaY - intDelta
@@ -608,34 +795,16 @@ function SurfingMinigame:updateJumping()
end end
else else
-- Hardware execution order: evaluate boundary before adding velocity -- 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 if self.pikaY >= waveY then
self.pikaY = waveY self.pikaY = waveY
self.pikaSubY = 0 self.pikaSubY = 0
-- Evaluate landing angle vs wave slope self: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
self:calculateStuntPoints()
self.pikaState = PIKA_STATE_LANDING
self.routineTimer = 32
self.frameSet = 4
Sound.play(self.game.data, "Cut")
end
return return
end 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) local intDelta = math.floor(self.pikaSubY / 256)
self.pikaSubY = self.pikaSubY % 256 self.pikaSubY = self.pikaSubY % 256
self.pikaY = self.pikaY + intDelta self.pikaY = self.pikaY + intDelta
@@ -644,57 +813,66 @@ function SurfingMinigame:updateJumping()
if self.pikaY >= waveY then if self.pikaY >= waveY then
self.pikaY = waveY self.pikaY = waveY
self.pikaSubY = 0 self.pikaSubY = 0
local result = self:evaluateLanding() self:handleLanding()
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
end end
end end
end end
function SurfingMinigame:updateLanding() 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 -- Sine wave splash offset
self.pikaYOffset = math.floor(math.sin((32 - self.routineTimer) / 32 * math.pi * 2) * 4) self.pikaYOffset = math.floor(math.sin((32 - math.max(0, self.landingTimer)) / 32 * math.pi * 2) * 4)
if self.routineTimer % 4 == 0 then if self.landingTimer % 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 end
if self.routineTimer <= 0 then
if self.landingTimer <= 0 then
self.pikaYOffset = 0 self.pikaYOffset = 0
self.pikaState = PIKA_STATE_RIDING self.pikaState = PIKA_STATE_RIDING
self.frameSet = 4
end end
end end
function SurfingMinigame:updateCrashed() function SurfingMinigame:updateCrashed()
self.crashTimer = self.crashTimer - 1 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 if self.crashTimer <= 0 then
self.pikaState = PIKA_STATE_RIDING self.pikaState = PIKA_STATE_RIDING
self.frameSet = 4 self.frameSet = 4
end end
end end
function SurfingMinigame:update() -- Single Game Boy hardware VBlank cycle tick (59.7275 Hz)
local input = self.game.input function SurfingMinigame:tick()
local input = self.game and self.game.input
self.t = self.t + 1 self.t = self.t + 1
self.rDiv = (self.rDiv + 1) % 256
-- Accumulate physical button presses on every frame (eliminates polling blind spot) -- Title Screen State
if input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end if self.routine == ROUTINE_TITLE then
if input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end 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 -- Update trick popups
for i = #self.trickPopups, 1, -1 do for i = #self.trickPopups, 1, -1 do
@@ -728,7 +906,7 @@ function SurfingMinigame:update()
self.routineTimer = 128 self.routineTimer = 128
self.speedFixed = 0 self.speedFixed = 0
self.ohNoBanner = true self.ohNoBanner = true
Sound.play(self.game.data, "Faint_Fall") Sound.play(self.game and self.game.data, "Faint_Fall")
return return
end end
@@ -758,20 +936,44 @@ function SurfingMinigame:update()
end end
elseif self.routine == ROUTINE_WAIT_RESULTS then 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.distanceFixed = self.distanceFixed + (2 * 256)
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25)
self:generateAhead() self:generateAhead()
self.pikaY = math.floor(self:seaY(80))
-- 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 self.routineTimer = self.routineTimer - 1
if self.routineTimer <= 0 then 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.routine = ROUTINE_SCROLL_RESULTS
self.routineTimer = 36 self.routineTimer = 36
self.pikaState = PIKA_STATE_GAME_END
end end
elseif self.routine == ROUTINE_SCROLL_RESULTS then elseif self.routine == ROUTINE_SCROLL_RESULTS then
self.distanceFixed = self.distanceFixed + (1 * 256) self.distanceFixed = self.distanceFixed + (1 * 256)
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((1 * 256) * 0.25)
self:generateAhead() 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 self.routineTimer = self.routineTimer - 1
if self.routineTimer <= 0 then if self.routineTimer <= 0 then
self.routine = ROUTINE_DRAW_RESULTS self.routine = ROUTINE_DRAW_RESULTS
@@ -813,7 +1015,7 @@ function SurfingMinigame:update()
local step = math.min(self.hp, 99) local step = math.min(self.hp, 99)
self.hp = self.hp - step self.hp = self.hp - step
self.totalScore = self.totalScore + step self.totalScore = self.totalScore + step
Sound.play(self.game.data, "Press_AB") Sound.play(self.game and self.game.data, "Press_AB")
else else
self.routine = ROUTINE_ADD_RAD_TOTAL self.routine = ROUTINE_ADD_RAD_TOTAL
end end
@@ -824,18 +1026,18 @@ function SurfingMinigame:update()
local step = math.min(self.radness, 99) local step = math.min(self.radness, 99)
self.radness = self.radness - step self.radness = self.radness - step
self.totalScore = self.totalScore + step self.totalScore = self.totalScore + step
Sound.play(self.game.data, "Press_AB") Sound.play(self.game and self.game.data, "Press_AB")
else else
self.routine = ROUTINE_WAIT_LAST self.routine = ROUTINE_WAIT_LAST
self.routineTimer = 64 self.routineTimer = 64
-- High score check -- 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 if self.newRecord then
self.game.save.surfingHighScore = self.totalScore if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end
Sound.play(self.game.data, "Get_Item1") Sound.play(self.game and self.game.data, "Get_Item1")
Sound.playPikaCry(self.game.data, 34) Sound.playPikaCry(self.game and self.game.data, 34)
else else
Sound.playPikaCry(self.game.data, 28) Sound.playPikaCry(self.game and self.game.data, 28)
end end
end end
@@ -846,24 +1048,48 @@ function SurfingMinigame:update()
end end
elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then
if input:wasPressed("a") or input:wasPressed("b") then if input and input:wasPressed("a") then
self.game.stack:pop() if self.game and self.game.stack then self.game.stack:pop() end
if self.onDone then self.onDone(self.totalScore) end if self.onDone then self.onDone(self.totalScore) end
end end
elseif self.routine == ROUTINE_GAME_OVER then elseif self.routine == ROUTINE_GAME_OVER then
self.routineTimer = self.routineTimer - 1 self.routineTimer = self.routineTimer - 1
if self.routineTimer <= 0 and (input:wasPressed("a") or input:wasPressed("b")) then if self.routineTimer <= 0 and input and input:wasPressed("a") then
self.game.stack:pop() if self.game and self.game.stack then self.game.stack:pop() end
if self.onDone then self.onDone(0) end if self.onDone then self.onDone(0) end
end end
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() function SurfingMinigame:drawBackground()
local scx = math.floor(self.distanceFixed / 256) local scx = math.floor(self.distanceFixed / 256)
local first = math.floor(scx / 16) local first = math.floor(scx / 16)
local function renderTiles()
love.graphics.setColor(1, 1, 1, 1)
if not self.bg then return end
for c = first, first + 10 do for c = first, first + 10 do
local col = self.cols[c] local col = self.cols[c]
if col then if col then
@@ -882,28 +1108,57 @@ function SurfingMinigame:drawBackground()
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) -- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy)
function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY) 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 r = 0, 2 do
for c = 0, 2 do for c = 0, 2 do
local tileId = baseTile + r * 16 + c local tileId = baseTile + r * 16 + c
local q = self.oq[tileId] local q = self.oq[tileId]
if q then if q then
if not flipX and not flipY then local colIdx = flipX and (2 - c) or c
local dx = (cx - 12) + c * 8 local rowIdx = flipY and (2 - r) or r
local dy = (cy - 12) + r * 8 local dx = (cx - 12) + colIdx * 8 + (flipX and 8 or 0)
love.graphics.draw(self.ob, q, dx, dy) local dy = (cy - 12) + rowIdx * 8 + (flipY and 8 or 0)
else love.graphics.draw(self.ob, q, dx, dy, 0, sx, sy)
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
end end
end end
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() function SurfingMinigame:drawHUD()
-- White background in bottom 16 rows -- White background in bottom 16 rows
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -931,9 +1186,14 @@ function SurfingMinigame:drawHUD()
end end
-- Mini-Pikachu progress marker (tile $fe) -- 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 distPx = math.floor(self.distanceFixed / 256)
local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128))) 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 if self.ob and self.oq and self.oq[0xfe] then
love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6) love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6)
end end
@@ -956,46 +1216,100 @@ function SurfingMinigame:drawResultsOutro()
for r = 1, 10 do for r = 1, 10 do
for c = 1, 20 do for c = 1, 20 do
local tId = BEACH_OUTRO[r][c] 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) love.graphics.draw(self.bg, self.tq[tId], (c - 1) * 8, (r + 5) * 8)
end end
end 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.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 8, 8, 144, 64) love.graphics.rectangle("fill", 16, 16, 128, 56)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 8.5, 8.5, 143, 63)
-- 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) Font.draw(Strings("HP Left"), 16, 16)
if self.routine >= ROUTINE_WRITE_HP_LEFT then 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 end
if self.routine >= ROUTINE_WRITE_RADNESS then if self.routine >= ROUTINE_WRITE_RADNESS then
Font.draw(Strings("Radness"), 16, 32) 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 end
if self.routine >= ROUTINE_WRITE_TOTAL then if self.routine >= ROUTINE_WRITE_TOTAL then
Font.draw(Strings("Total"), 16, 48) 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 end
if self.routine >= ROUTINE_WAIT_LAST then if self.routine >= ROUTINE_WAIT_LAST then
if self.newRecord then if self.newRecord then
Font.draw(Strings("Hi-Score!!"), 48, 60) Font.draw(Strings("Hi-Score!!"), 48, 64)
end end
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end 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() function SurfingMinigame:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) 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 if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then
self:drawResultsOutro() self:drawResultsOutro()
return return
@@ -1004,10 +1318,11 @@ function SurfingMinigame:draw()
-- Draw scrolling BG waves -- Draw scrolling BG waves
self:drawBackground() 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 cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256)
local c1x = (32 - cloudOffsetPx * 2) % 200 - 40 local c1x = (160 - (cloudOffsetPx % 200)) - 40
local c2x = (128 - cloudOffsetPx * 3) % 200 - 40 local c2x = (240 - (cloudOffsetPx % 200)) - 40
if self.ob and self.oq then
-- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef) -- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef)
for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do
love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12) love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12)
@@ -1016,10 +1331,29 @@ function SurfingMinigame:draw()
for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do
love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20) love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20)
end end
end
-- Helper function to draw OAM multi-sprite composite objects with palette and X-flipping
function self:drawOAMSprites(sprites, ox, oy)
if not (self.ob and self.oq) then return end
love.graphics.setColor(0.5, 0.5, 0.5, 1) -- OAM_PAL1 maps shade 1 to shade 2 (Sea Blue)
for _, sp in ipairs(sprites) do
local q = self.oq[sp.tile]
if q then
local x = ox + sp.dx
local y = oy + sp.dy
if sp.xflip then
love.graphics.draw(self.ob, q, x + 8, y, 0, -1, 1)
else
love.graphics.draw(self.ob, q, x, y)
end
end
end
love.graphics.setColor(1, 1, 1, 1)
end
-- Draw water spray sprites -- Draw water spray sprites on trailing edge of surfboard
for _, s in ipairs(self.waterSprays) do 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 end
-- Draw Pikachu -- Draw Pikachu
@@ -1028,11 +1362,19 @@ function SurfingMinigame:draw()
self.pikaScreenY = cy self.pikaScreenY = cy
if self.pikaState == PIKA_STATE_CRASHED then if self.pikaState == PIKA_STATE_CRASHED then
-- Empty surfboard + splash animation -- 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[0x98], cx - 12, cy + 4)
love.graphics.draw(self.ob, self.oq[0x99], cx - 4, 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[0x9a], cx + 4, cy + 4)
love.graphics.draw(self.ob, self.oq[0xa8], cx - 12, cy - 8) 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 else
local angleIdx = ((self.frameSet - 1) % 7) + 1 local angleIdx = ((self.frameSet - 1) % 7) + 1
local isFlipped = self.frameSet > 7 local isFlipped = self.frameSet > 7
@@ -1048,6 +1390,7 @@ function SurfingMinigame:draw()
-- "START" banner -- "START" banner
if self.routine == ROUTINE_START_GAME then if self.routine == ROUTINE_START_GAME then
if self.ob and self.oq then
for r = 0, 1 do for r = 0, 1 do
for c = 0, 5 do for c = 0, 5 do
local tid = 0xe0 + r * 16 + c local tid = 0xe0 + r * 16 + c
@@ -1055,9 +1398,11 @@ function SurfingMinigame:draw()
end end
end end
end end
end
-- "Oh no.." banner on Game Over -- "Oh no.." banner on Game Over
if self.ohNoBanner then if self.ohNoBanner then
if self.ob and self.oq then
for r = 0, 1 do for r = 0, 1 do
for c = 0, 5 do for c = 0, 5 do
local tid = 0xca + r * 16 + c local tid = 0xca + r * 16 + c
@@ -1065,6 +1410,7 @@ function SurfingMinigame:draw()
end end
end end
end end
end
-- Draw HUD (Progress track, HP digits) -- Draw HUD (Progress track, HP digits)
self:drawHUD() self:drawHUD()
+376 -45
View File
@@ -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 -- 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. -- script bytecode the extractor walks points at them.
CardFlip.TEXTS = { CardFlip.TEXTS = {
playWithThree = { "Play with three", "coins?" }, playWithThree = { "Play with", "3 coins?" },
notEnough = { "Not enough coins…" }, notEnough = { "Not enough", "coins." },
chooseACard = { "Choose a card." }, chooseACard = { "Choose a", "card." },
placeYourBet = { "Place your bet." }, placeYourBet = { "Place", "your bet" },
playAgain = { "Want to play", "again?" }, playAgain = { "Play", "again?" },
shuffled = { "The cards have", "been shuffled." }, shuffled = { "The cards", "shuffled." },
yeah = { "Yeah!" }, yeah = { "Yeah!" },
darn = { "Darn…" }, darn = { "Darn…" },
} }
@@ -401,14 +401,34 @@ function CardFlip:enterBet()
self.lines = CardFlip.TEXTS.placeYourBet self.lines = CardFlip.TEXTS.placeYourBet
end end
-- .CheckTheCard: the dealt card is turned face up and marked on the discard -- .CheckTheCard: trigger hardware-accurate discrete tile flip sequence
-- pile, which is what blanks its cell on the odds board.
function CardFlip:flip() function CardFlip:flip()
local card = CardFlip.dealt(self.deck, self.played, self.which) local card = CardFlip.dealt(self.deck, self.played, self.which)
self.faceUp = card self.faceUp = card
self.discarded[card] = true self.discarded[card] = true
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) self:sfx(SFX_CHOOSE)
self:tabulate() 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 end
function CardFlip:tabulate() function CardFlip:tabulate()
@@ -472,7 +492,21 @@ function CardFlip:quit()
if self.onClose then self.onClose() end if self.onClose then self.onClose() end
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 local input = self.game and self.game.input
if not input then return end if not input then return end
local phase = self.phase local phase = self.phase
@@ -539,6 +573,11 @@ function CardFlip:update(_dt)
return return
end end
if phase == "flipping" then
self:updateFlipping()
return
end
if phase == "payout" then if phase == "payout" then
self:updatePayout() self:updatePayout()
return return
@@ -552,79 +591,370 @@ function CardFlip:update(_dt)
end end
-- ------------------------------------------------------------------- draw -- ------------------------------------------------------------------- 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() function CardFlip:drawBoard()
-- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand local s1, s2, s3, sOn, sOff = self:sheets()
-- being played and every one before it stays off. 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 for row = 0, CardFlip.HANDS_PER_DECK - 1 do
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row) Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
end end
for x = 2, 5 do for x = 2, 5 do
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW) 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 if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
end end
for y = 2, 7 do for y = 2, 7 do
local row = LEVEL_ROW[y] 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) Chrome.print(tostring(y - 1), LEVEL_COL, row)
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
for x = 2, 5 do 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) local card = CardFlip.card(y - 2, x - 2)
Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row) 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 end
end end
function CardFlip:cursorCell() -- 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:cursorBounds()
local x, y = self.cursorX, self.cursorY local x, y = self.cursorX, self.cursorY
local col if y == 0 then
if x == 0 then col = LEVEL_PAIR_COL -- Pokemon Pair: spans 4 columns (32px), 1 row (8px)
elseif x == 1 then col = LEVEL_COL local px = (MON_COL[x] or 12) * 8
else col = MON_COL[x] end local py = MON_PAIR_ROW * 8
local row return px, py, 32, 8
if y == 0 then row = MON_PAIR_ROW elseif y == 1 then
elseif y == 1 then row = MON_ROW -- Single Pokemon: 2x2 tiles (16x16 px)
else row = LEVEL_ROW[y] end local px = (MON_COL[x] or 12) * 8
return col, row 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 end
-- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the local FACE_DOWN_TILES = {
-- 3x3 Pokepic one row further down. { 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() function CardFlip:drawCards()
local s1, s2 = self:sheets()
for slot = 1, 2 do for slot = 1, 2 do
local box = CARD_BOX[slot] 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 local chosen = (slot - 1) == self.which
if self.faceUp and chosen then local isFlipping = (self.phase == "flipping") and chosen
Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3, local isFaceUp = (self.faceUp and chosen)
box.y + 1)
Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)], if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 4) then
box.x + 1, box.y + 3) local activeCard = self.faceUp or self.targetCard or 0
elseif self.phase == "choose" and chosen then local lvl = CardFlip.level(activeCard) + 1
Chrome.cursor(box.x, box.y + 3) 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 end
end end
function CardFlip:drawPanel() function CardFlip:drawPanel()
Chrome.clear()
self:drawBoard() self:drawBoard()
self:drawCards() 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) -- Dialogue / Message box at (0, 12), 10 wide, 6 tall (interior 8x4)
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
if self.lines then 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 for i, line in ipairs(self.lines) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end end
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 if self.phase == "bet" then
local col, row = self:cursorCell() self.betBlink = (self.betBlink or 0) + 1
Chrome.cursor(col - 1, row) if (self.betBlink % 32) < 24 then
local px, py, w, h = self:cursorBounds()
self:drawOamBox(px, py, w, h)
end end
end
if self.phase == "ask" or self.phase == "again" then 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). -- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
Chrome.textbox(14, 7, 4, 3) Chrome.textbox(14, 7, 4, 3)
@@ -652,3 +982,4 @@ function CardFlip:drawWidescreen(winW, winH)
end end
return CardFlip return CardFlip
+527 -72
View File
@@ -219,10 +219,16 @@ local TWO_LINES = {
function SlotMachine.matchFirstTwo(bet, r1, r2) function SlotMachine.matchFirstTwo(bet, r1, r2)
local building = SlotMachine.NO_MATCH local building = SlotMachine.NO_MATCH
local matchingSevens = false
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do 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
return building, building == SlotMachine.SEVEN end
end
return building, matchingSevens
end end
-- ------------------------------------------------------------------- bias -- ------------------------------------------------------------------- bias
@@ -403,13 +409,14 @@ end
-- matching SEVENs on a line this bet buys. -- matching SEVENs on a line this bet buys.
function SlotMachine.spinReel2ToSevens(position, bet, stopped1) function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
local strip = SlotMachine.REELS[2] local strip = SlotMachine.REELS[2]
local pos = SlotMachine.advance(position)
for _ = 1, SEARCH_LIMIT do 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) local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
if building ~= SlotMachine.NO_MATCH and sevens then return position end if building ~= SlotMachine.NO_MATCH and sevens then return pos end
position = SlotMachine.advance(position) pos = SlotMachine.advance(pos)
end end
return position return pos
end end
-- ------------------------------------------------------- reel 3's theatre -- ------------------------------------------------------- reel 3's theatre
@@ -580,8 +587,8 @@ end
-- ------------------------------------------------------------------ layout -- ------------------------------------------------------------------ layout
local COINS_X, COINS_Y = 5, 1 local COINS_X, COINS_Y = 5, 1
local PAYOUT_X, PAYOUT_Y = 11, 1 local PAYOUT_X, PAYOUT_Y = 11, 1
-- REEL_X_COORD / TILE_WIDTH. -- REEL_X_COORD / TILE_WIDTH (5, 9, 13) for the 3 reel apertures (columns 5-6, 9-10, 13-14)
local REEL_X = { 6, 10, 14 } local REEL_X = { 5, 9, 13 }
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which -- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the -- sits 16px above the screen) to tile rows: bottom, middle, top, and the
-- fourth symbol that only half shows. -- fourth symbol that only half shows.
@@ -729,30 +736,41 @@ function SlotMachine:startSpin()
self.reel = 1 self.reel = 1
self.delay = 32 self.delay = 32
self.message = TEXTS.start 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 for i = 1, 3 do
self.rate[i] = 4 -- ReelAction_NormalRate self.rate[i] = 4 -- ReelAction_NormalRate
self.stops[i] = nil self.stops[i] = nil
self.distance[i] = 0
end end
self:sfx(SFX_START) self:sfx(SFX_START)
end end
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot -- 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() function SlotMachine:spinReels()
for i = 1, 3 do for i = 1, 3 do
local rate = self.rate[i] local rate = self.rate[i]
if rate > 0 then if rate > 0 then
self.distance[i] = (self.distance[i] + rate) % 256 self.distance[i] = (self.distance[i] or 0) + rate
if self.distance[i] % 16 == 0 then while self.distance[i] >= 16 do
self.distance[i] = self.distance[i] - 16
self.positions[i] = SlotMachine.advance(self.positions[i]) self.positions[i] = SlotMachine.advance(self.positions[i])
-- A reel with a resting slot chosen stops the moment it reaches it. -- 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 if self.stops[i] and self.positions[i] == self.stops[i] then
self.rate[i] = 0 self.rate[i] = 0
self.distance[i] = 0
self.stopped = self.stopped or {} self.stopped = self.stopped or {}
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i], self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
self.positions[i]) } self.positions[i]) }
self:sfx(SFX_STOP) self:sfx(SFX_STOP)
self:reelStopped(i) self:reelStopped(i)
break
end end
end end
end end
@@ -775,11 +793,17 @@ function SlotMachine:pressStop()
self.stops[1] = SlotMachine.stopReel1(here, self.bias) self.stops[1] = SlotMachine.stopReel1(here, self.bias)
elseif i == 2 then elseif i == 2 then
local r1 = self.stopped[1] 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 -- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
-- fast-spins it at double rate; the pause is the tell. -- fast-spins it at double rate; the pause is the tell.
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1) 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 else
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1) self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
end end
@@ -789,40 +813,65 @@ function SlotMachine:pressStop()
self.matchingSevens = sevens self.matchingSevens = sevens
local action = SlotMachine.reel3Action(sevens, self.bias, self.random) local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
self.reel3Action = action 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) 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.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
self.rate[3] = 1 -- ReelAction_QuarterRate self.rate[3] = 1 -- ReelAction_QuarterRate slow crawl
elseif action == SlotMachine.REEL3_GOLEM then elseif action == SlotMachine.REEL3_GOLEM or action == "golem" then
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2, local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
self.random) self.random)
if count == 0 then count = 3 end
local target = here local target = here
for _ = 1, count do target = SlotMachine.advance(target) end for _ = 1, count do target = SlotMachine.advance(target) end
self.stops[3] = target self.stops[3] = target
self.golems = count 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 else
local target = SlotMachine.eggDrops(here, self.bet, r1, r2) local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
self.stops[3] = target 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
end end
-- A reel already sitting on its resting slot has nowhere to turn. -- A reel already sitting on its resting slot has nowhere to turn.
-- 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 if self.stops[i] == self.positions[i] then
self.rate[i] = 0 self.rate[i] = 0
self.distance[i] = 0
self.stopped = self.stopped or {} self.stopped = self.stopped or {}
self.stopped[i] = self:reelWindow(i) self.stopped[i] = self:reelWindow(i)
self:sfx(SFX_STOP) self:sfx(SFX_STOP)
self:reelStopped(i) self:reelStopped(i)
end end
end end
end
function SlotMachine:reelStopped(i) function SlotMachine:reelStopped(i)
if i < 3 then if i < 3 then
self.reel = i + 1 self.reel = i + 1
return return
end end
self.golemAnim = nil
self.chanseyAnim = nil
self.reel2Pause = nil
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames -- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
-- before the payout is counted out; a loss skips straight past it. -- 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] 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 if phase == "spinning" then
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the -- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
-- bet menu cannot stop reel one. -- bet menu cannot stop reel one.
if self.delay > 0 then if (self.delay or 0) > 0 then
self.delay = self.delay - 1 self.delay = self.delay - 1
self:spinReels() self:spinReels()
return return
end 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 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() self:spinReels()
return return
end end
@@ -959,57 +1129,302 @@ end
-- ------------------------------------------------------------------- draw -- ------------------------------------------------------------------- draw
-- --
-- The cart's reel art is unextracted (see the header), so a symbol draws as its -- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
-- 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") local TileSheet = require("src.ui.gen2.TileSheet")
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16, local GbcPalette = require("src.render.GbcPalette")
firstTile = gfx.firstTile or 0 })
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 else
self.sheetCache = false TILEMAP = false
end end
end end
return self.sheetCache or nil return TILEMAP or nil
end end
local function cell(tx, ty, label) function SlotMachine:sheets()
local G = love.graphics if self.sheet1 == nil then
G.setColor(0, 0, 0, 1) self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 })
G.rectangle("line", tx * 8, ty * 8, 16, 16) self.sheet2 = TileSheet.new({ path = "assets/generated/slots/gold_slots_2.png", wide = 2, firstTile = 0 })
Chrome.print(label, tx, ty + 1) 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 end
function SlotMachine:drawReels() function SlotMachine:drawReels()
local _, s2 = self:sheets()
local G = love.graphics
for i = 1, 3 do for i = 1, 3 do
local strip = SlotMachine.REELS[i] local strip = SlotMachine.REELS[i]
local position = self.positions[i] local pos = self.positions[i]
-- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays local a = pos
-- them bottom upward, which is what the three repeated entries at the end if a == 0 then a = 0x0f end
-- of each strip are for: position 14 reads indices 14, 15, 16 and 17 a = (a - 1) % 16
-- without wrapping. Only the lower three are on a pay line; the fourth local rx = REEL_X[i] * 8
-- half-shows at the top of the window. local dy = math.floor(self.distance[i] or 0)
for row = 1, 4 do
local symbol = strip[position + row] -- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?") 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 end
end end
function SlotMachine:drawLights() function SlotMachine:actorsImage()
local lit = {} if self.actorsLoaded == nil then
-- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller local Assets = require("src.render.Assets")
-- one: `dec a / jr z` falls through from three to two to one. local ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_actors.png")
for bet = 1, (self.bet or 0) do if not (ok and img) then
for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end 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
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
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)
end end
end end
end end
@@ -1022,39 +1437,79 @@ function SlotMachine:drawMessage()
end end
if self.matched and self.matched ~= SlotMachine.NO_MATCH if self.matched and self.matched ~= SlotMachine.NO_MATCH
and self.phase == "payoutText" then and self.phase == "payoutText" then
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, local _, s2 = self:sheets()
SlotMachine.LABELS[self.matched] or "?") 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
end end
function SlotMachine:drawPanel() function SlotMachine:drawPanel()
Chrome.clear() self:drawBackground()
self:drawLights() self:drawReels()
self:drawOverlays()
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters. -- 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:coins(), 4, true), COINS_X, COINS_Y)
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y) Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
self:drawReels()
if self.phase == "bet" then 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 for i, label in ipairs(BET_ROWS) do
local ty = BET_LABEL_Y + (i - 1) * BET_SPACING local ty = 12 + (i - 1) * 2
if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end if i == self.betIndex then Chrome.cursor(15, ty) end
Chrome.print(label, BET_LABEL_X, ty) Chrome.print(label, 16, ty)
end end
if not self.message then
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) if self.message then
for i, line in ipairs(TEXTS.betHowMany) do -- 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) Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end end
end end
end elseif self.phase == "again" then
self:drawMessage() -- Speech box: "Play again?"
if self.phase == "again" then Chrome.textbox(0, 12, 18, 4)
-- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13). 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.textbox(14, 12, 4, 3)
Chrome.print("YES", 16, 13) Chrome.print("YES", 16, 13)
Chrome.print("NO", 16, 15) Chrome.print("NO", 16, 15)
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2) Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
else
self:drawMessage()
end end
end end
+112
View File
@@ -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), check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true),
"0050") "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)) 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 -- 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. -- here would take the whole tier down and silently skip every suite after it.
+131 -4
View File
@@ -40,14 +40,16 @@ local mockGame = {
print("Running SurfingMinigame unit tests...") print("Running SurfingMinigame unit tests...")
-- Test 1: Initialization -- Test 1: Initialization & Title Screen transition
local mg = SurfingMinigame.new(mockGame) 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.hp, 6000, "Initial HP must be 6000 (60.00s)")
assert_eq(mg.speed, 0.25, "Initial speed must be 0.25") 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.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)") 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 -- Test 2: Start banner transition to RunGame
for _ = 1, 40 do 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") print("✓ Auto acceleration and HP countdown test passed")
-- Test 4: Landing Evaluation Matrix -- Test 4: Landing Evaluation Matrix
local old_getWaveTile = mg.getWaveTileUnderPika
mg.getWaveTileUnderPika = function() return 0x01 end -- force open water
mg.frameSet = 5 mg.frameSet = 5
assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing") assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing")
mg.frameSet = 6 mg.frameSet = 6
@@ -77,6 +81,7 @@ for f = 8, 14 do
mg.frameSet = f mg.frameSet = f
assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout") assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout")
end end
mg.getWaveTileUnderPika = old_getWaveTile
print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)") print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)")
-- Test 5: Stunt Scoring -- Test 5: Stunt Scoring
@@ -145,6 +150,128 @@ end
assert_eq(mg.radness, 0, "Radness should be tallied down to 0") 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.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)")
assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)") 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!") print("All SurfingMinigame unit tests passed successfully!")
+42 -2
View File
@@ -2037,8 +2037,48 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
_decode_2bpp(bytes(reordered), 40, 16), _decode_2bpp(bytes(reordered), 40, 16),
os.path.join(assets_dir, "credits/the_end.png")) os.path.join(assets_dir, "credits/the_end.png"))
raw_2bpp( if _has_symbol(symbols, "SurfingPikachu1Graphics1"):
"WorldMapTileGraphics", 32, 32, "townmap/tiles.png") 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( raw_1bpp(
"TownMapCursor", 16, 16, "townmap/cursor.png", "TownMapCursor", 16, 16, "townmap/cursor.png",
transparent=True) transparent=True)