Merge pull request #1581 from 1Jamie/feat/pikachu-surf-and-gold-gamecorner

feat: surfing minigame overhaul + authentic Game Corner rendering
This commit is contained in:
bryanthaboi
2026-08-20 09:46:43 -04:00
committed by GitHub
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
@@ -5231,6 +5231,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
+544 -198
View File
File diff suppressed because it is too large Load Diff
+388 -57
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
self:sfx(SFX_CHOOSE) local won = CardFlip.payout(self.cursorX, self.cursorY, card)
self:tabulate() self.payoutLeft = won
self.payoutTick = 0
self.phase = "flipping"
self.flipTimer = 0
self.targetCard = card
end
function CardFlip:updateFlipping()
self.flipTimer = (self.flipTimer or 0) + 1
if self.flipTimer == 4 then
self:sfx(SFX_CHOOSE)
elseif self.flipTimer >= 12 then
if (self.payoutLeft or 0) > 0 then
self.phase = "payout"
self.lines = CardFlip.TEXTS.yeah
self:sfx(SFX_WIN)
else
self.phase = "result"
self.lines = CardFlip.TEXTS.darn
self:sfx(SFX_WRONG)
end
end
end 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()
for row = 0, CardFlip.HANDS_PER_DECK - 1 do local G = love.graphics
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
end -- Green background fill
for x = 2, 5 do G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW) G.rectangle("fill", 0, 0, 160, 144)
-- 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 not tm or not s2:available() then
end -- Fallback simple board
for y = 2, 7 do for row = 0, CardFlip.HANDS_PER_DECK - 1 do
local row = LEVEL_ROW[y] Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
Chrome.print(tostring(y - 1), LEVEL_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 Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
-- lands. It cannot be '#': that is charmap.asm $54, the text command if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
-- that places "POKé", not a one-tile glyph. end
local card = CardFlip.card(y - 2, x - 2) for y = 2, 7 do
Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row) local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local row = 3 + pair * 3 + (isBottom and 1 or 0)
Chrome.print(tostring(y - 1), LEVEL_COL, row)
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
for x = 2, 5 do
local card = CardFlip.card(y - 2, x - 2)
Chrome.print(self.discarded[card] and " " or "?", MON_COL[x], row)
end
end
return
end
-- Draw the 11x12 board tilemap at (9, 0)
for ty = 0, 11 do
for tx = 0, 10 do
local idx = ty * 11 + tx + 1
local tileId = tm[idx]
local screenX = 9 + tx
local screenY = ty
-- Attribute palette
local pal = 0
if screenY >= 1 and screenY <= 2 then
if screenX == 12 or screenX == 13 then pal = 1 -- Pikachu
elseif screenX == 14 or screenX == 15 then pal = 2 -- Jigglypuff
elseif screenX == 16 or screenX == 17 then pal = 3 -- Poliwag
elseif screenX == 18 or screenX == 19 then pal = 4 -- Oddish
end
elseif screenX == 9 then
pal = 1 -- Lights
end
local colors = CARDFLIP_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
s3.palette = colors
if screenX == 9 then
-- Column 9: Light buttons
if screenY == self.played then
sOn.palette = colors
sOn:draw(0, screenX, screenY)
else
sOff.palette = colors
sOff:draw(0, screenX, screenY)
end
elseif tileId >= 0x3e then
-- Board graphics from card_flip_2 (using accurate 2x2 header tile mapping)
local mappedId = HEADER_TILE_MAP[tileId] or (tileId - 0x3e)
s2:draw(mappedId, screenX, screenY)
elseif tileId < 0x3e then
-- Graphics from card_flip_1
s1:draw(tileId, screenX, screenY)
end
end
end
-- Draw discarded card blanking covers (each 2-tile wide stacked card cell is 16x12 px)
for y = 2, 7 do
local level = y - 2
local pair = math.floor(level / 2)
local isBottom = (level % 2 == 1)
local py = 24 + pair * 24 + (isBottom and 12 or 0)
for x = 2, 5 do
local mon = x - 2
local card = CardFlip.card(level, mon)
if self.discarded[card] then
-- Discarded cover over full 16x12 stacked card cell
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
G.rectangle("fill", MON_COL[x] * 8, py, 16, 12)
end
end end
end end
end end
function CardFlip:cursorCell() 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
+535 -80
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
end end
return building, building == SlotMachine.SEVEN 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.
@@ -617,7 +624,7 @@ SlotMachine.TEXTS = TEXTS
-- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the -- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the
-- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout. -- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout.
local function linedUpLines(payout) local function linedUpLines(payout)
return { "lined up!", ("Won %d coins!"):format(payout) } return { " lined up!", ("Won %d coins!"):format(payout) }
end end
-- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what -- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what
@@ -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,32 +813,54 @@ 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.
if self.stops[i] == self.positions[i] then -- Only trigger immediate halt if no active pause tell or special animation is running.
self.rate[i] = 0 if not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
self.stopped = self.stopped or {} if self.stops[i] == self.positions[i] then
self.stopped[i] = self:reelWindow(i) self.rate[i] = 0
self:sfx(SFX_STOP) self.distance[i] = 0
self:reelStopped(i) self.stopped = self.stopped or {}
self.stopped[i] = self:reelWindow(i)
self:sfx(SFX_STOP)
self:reelStopped(i)
end
end end
end end
@@ -823,6 +869,9 @@ function SlotMachine:reelStopped(i)
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 local TileSheet = require("src.ui.gen2.TileSheet")
-- this to the real tiles without any other change. local GbcPalette = require("src.render.GbcPalette")
function SlotMachine:sheet()
if self.sheetCache == nil then local GBC_PALS = {
local data = self.game and self.game.data bg = {
local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots [0] = { { 255, 255, 255 }, { 198, 206, 231 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 0: Base Frame
if gfx and gfx.image then [1] = { { 255, 255, 255 }, { 247, 82, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 1: Vileplume / Active Lights
local TileSheet = require("src.ui.gen2.TileSheet") [2] = { { 255, 255, 255 }, { 123, 255, 0 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 2: Bet 3 Indicators
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16, [3] = { { 255, 255, 255 }, { 255, 123, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 3: Bet 2 Indicators
firstTile = gfx.firstTile or 0 }) [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 end
for _, row in ipairs({ 2, 4, 6, 8, 10 }) do return self.actorsLoaded or nil
for _, col in ipairs(LIGHT_COLS) do end
Chrome.print(lit[row] and "*" or "-", col, row)
-- Redraw the top Vileplume row (rows 2..3) and bottom frame brackets (rows 10..11)
-- over the reels with solid backdrop to naturally mask any sprite overhang like the Game Boy hardware does.
function SlotMachine:drawOverlays()
local s1, s2 = self:sheets()
local tm = getTilemap()
if not tm or not s1:available() then return end
local G = love.graphics
-- Solid backdrop over header (rows 0..3) and footer (rows 10..11) between columns 4..15
G.setColor(198 / 255, 198 / 255, 74 / 255, 1)
G.rectangle("fill", 4 * 8, 2 * 8, 12 * 8, 2 * 8)
G.rectangle("fill", 4 * 8, 10 * 8, 12 * 8, 2 * 8)
G.setColor(1, 1, 1, 1)
for _, ty in ipairs({ 2, 3, 10, 11 }) do
for tx = 4, 15 do
local idx = ty * 20 + tx + 1
local tileId = tm[idx]
local pal = (ty <= 3) and 1 or 0
local colors = GBC_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
if tileId < 0x25 then
s1:draw(tileId, tx, ty)
else
s2:draw(tileId - 0x25, tx, ty)
end
end
end
-- Draw Golem sprite animation
if self.golemAnim then
local actors = self:actorsImage()
local g = self.golemAnim
if actors then
local quad = self.quadGolemBall
local scaleX = 1
local scaleY = 1
if g.state == "falling" then
quad = self.quadGolemBall
elseif g.state == "rolling" then
-- Frameset_SlotsGolem: 0=Standing, 1=Ball, 2=StandingYFlip, 3=BallXFlip
local rotFrame = (g.animFrame or 0) % 4
if rotFrame == 0 then
quad = self.quadGolemStand
scaleX = 1
scaleY = 1
elseif rotFrame == 1 then
quad = self.quadGolemBall
scaleX = 1
scaleY = 1
elseif rotFrame == 2 then
quad = self.quadGolemStand
scaleX = 1
scaleY = -1
elseif rotFrame == 3 then
quad = self.quadGolemBall
scaleX = -1
scaleY = 1
end
end
G.setColor(1, 1, 1, 1)
local function drawGolem()
-- Draw rotated around center (ox=12, oy=16)
G.draw(actors, quad,
math.floor(g.x + 12),
math.floor(g.y + 16),
0, scaleX, scaleY, 12, 16)
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[5], drawGolem)
else
drawGolem()
end
end
end
-- Draw Chansey & Egg sprite animation
if self.chanseyAnim then
local actors = self:actorsImage()
local c = self.chanseyAnim
if actors then
local quad = self.quadChansey1
if c.state == "walking" then
local walkCycle = { self.quadChansey1, self.quadChansey2, self.quadChansey3, self.quadChansey4 }
quad = walkCycle[((c.animPose or 0) % 4) + 1] or self.quadChansey1
elseif c.state == "egg_pause" then
quad = self.quadChansey4
elseif c.state == "egg_drop" or c.state == "spinning" then
quad = self.quadChanseyDrop
end
G.setColor(1, 1, 1, 1)
local function drawChansey()
G.draw(actors, quad, math.floor(c.x), math.floor(c.y or 44))
if c.eggVisible and c.eggX and c.eggY then
G.draw(actors, self.quadEgg, math.floor(c.eggX), math.floor(c.eggY))
end
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[6], drawChansey)
else
drawChansey()
end
end end
end 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)