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
return 1
end
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
return "menu"
end
function Game.isFixedSpeedInStack(stack)
local states = stack and stack.states
for i = #(states or {}), 1, -1 do
local state = states[i]
if state and (state.isFixedSpeed or state.isMinigame) then return true end
end
return false
end
-- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states
+1 -1
View File
@@ -230,7 +230,7 @@ end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
if not (love and love.audio) then return end -- headless test stub
ctx = ctx or {}
song = selectSong(song, ctx)
+76
View File
@@ -5136,6 +5136,82 @@ function RomExtractorGen2:extractMenuGfx()
end
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
-- Goldenrod Game Corner: Slot Machine graphics assets
if self.symbols["Slots1LZ"] then
local raw1 = self:decompressLz3Symbol("Slots1LZ")
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
end
if self.symbols["Slots2LZ"] then
local raw2 = self:decompressLz3Symbol("Slots2LZ")
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
for i = 1, math.min(64, #raw2) do
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
end
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
end
if self.symbols["Slots3LZ"] then
local raw3 = self:decompressLz3Symbol("Slots3LZ")
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
-- Y=0: Golem 1 (Standing, 24x32)
-- Y=32: Golem 2 (Ball, 24x32)
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
-- Y=96: Chansey 2 (Step 2, 24x32)
-- Y=128: Chansey 3 (Step 3, 24x32)
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
-- Y=224: Egg (8x16 at X=0)
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
end
if self.symbols["SlotsTilemap"] then
local symbol = self:symbol("SlotsTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
self:save(tm, "slots/gold_slots.tilemap")
end
-- Goldenrod Game Corner: Card Flip graphics assets
if self.symbols["CardFlipLZ01"] then
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
end
if self.symbols["CardFlipLZ02"] then
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
end
if self.symbols["CardFlipLZ03"] then
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
end
if self.symbols["CardFlipOnButtonGFX"] then
local symbol = self:symbol("CardFlipOnButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
end
if self.symbols["CardFlipOffButtonGFX"] then
local symbol = self:symbol("CardFlipOffButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
end
if self.symbols["CardFlipTilemap"] then
local symbol = self:symbol("CardFlipTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
self:save(tm, "card_flip/card_flip.tilemap")
end
out.slots = {
sheet1 = "assets/generated/slots/gold_slots_1.png",
sheet2 = "assets/generated/slots/gold_slots_2.png",
sheet3 = "assets/generated/slots/gold_slots_3.png",
tilemap = "assets/generated/slots/gold_slots.tilemap",
}
out.cardFlip = {
sheet1 = "assets/generated/card_flip/card_flip_1.png",
sheet2 = "assets/generated/card_flip/card_flip_2.png",
sheet3 = "assets/generated/card_flip/card_flip_3.png",
on = "assets/generated/card_flip/on.png",
off = "assets/generated/card_flip/off.png",
tilemap = "assets/generated/card_flip/card_flip.tilemap",
}
self:write("menu_gfx", out)
self:tick("Menu graphics", 1, 1)
return out
+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
-- script bytecode the extractor walks points at them.
CardFlip.TEXTS = {
playWithThree = { "Play with three", "coins?" },
notEnough = { "Not enough coins…" },
chooseACard = { "Choose a card." },
placeYourBet = { "Place your bet." },
playAgain = { "Want to play", "again?" },
shuffled = { "The cards have", "been shuffled." },
playWithThree = { "Play with", "3 coins?" },
notEnough = { "Not enough", "coins." },
chooseACard = { "Choose a", "card." },
placeYourBet = { "Place", "your bet" },
playAgain = { "Play", "again?" },
shuffled = { "The cards", "shuffled." },
yeah = { "Yeah!" },
darn = { "Darn…" },
}
@@ -401,14 +401,34 @@ function CardFlip:enterBet()
self.lines = CardFlip.TEXTS.placeYourBet
end
-- .CheckTheCard: the dealt card is turned face up and marked on the discard
-- pile, which is what blanks its cell on the odds board.
-- .CheckTheCard: trigger hardware-accurate discrete tile flip sequence
function CardFlip:flip()
local card = CardFlip.dealt(self.deck, self.played, self.which)
self.faceUp = card
self.discarded[card] = true
self:sfx(SFX_CHOOSE)
self:tabulate()
local won = CardFlip.payout(self.cursorX, self.cursorY, card)
self.payoutLeft = won
self.payoutTick = 0
self.phase = "flipping"
self.flipTimer = 0
self.targetCard = card
end
function CardFlip:updateFlipping()
self.flipTimer = (self.flipTimer or 0) + 1
if self.flipTimer == 4 then
self:sfx(SFX_CHOOSE)
elseif self.flipTimer >= 12 then
if (self.payoutLeft or 0) > 0 then
self.phase = "payout"
self.lines = CardFlip.TEXTS.yeah
self:sfx(SFX_WIN)
else
self.phase = "result"
self.lines = CardFlip.TEXTS.darn
self:sfx(SFX_WRONG)
end
end
end
function CardFlip:tabulate()
@@ -472,7 +492,21 @@ function CardFlip:quit()
if self.onClose then self.onClose() end
end
function CardFlip:update(_dt)
function CardFlip:update(dt)
-- Support both fixed-tick 60Hz loop and variable dt accumulator
if dt and dt > 0 then
self.dtAccum = (self.dtAccum or 0) + dt
local TICK = 1 / 60
while self.dtAccum >= TICK do
self.dtAccum = self.dtAccum - TICK
self:tick()
end
else
self:tick()
end
end
function CardFlip:tick()
local input = self.game and self.game.input
if not input then return end
local phase = self.phase
@@ -539,6 +573,11 @@ function CardFlip:update(_dt)
return
end
if phase == "flipping" then
self:updateFlipping()
return
end
if phase == "payout" then
self:updatePayout()
return
@@ -552,79 +591,370 @@ function CardFlip:update(_dt)
end
-- ------------------------------------------------------------------- draw
--
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
local TileSheet = require("src.ui.gen2.TileSheet")
local GbcPalette = require("src.render.GbcPalette")
local CARDFLIP_PALS = {
bg = {
[0] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 0: Base / Table green
[1] = { { 255, 255, 255 }, { 239, 206, 0 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 1: Pikachu (Yellow)
[2] = { { 255, 255, 255 }, { 255, 107, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 2: Jigglypuff (Pink)
[3] = { { 255, 255, 255 }, { 66, 140, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 3: Poliwag (Blue)
[4] = { { 255, 255, 255 }, { 66, 255, 66 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 4: Oddish (Green)
[5] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 5: Level header
[6] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 6: Border
[7] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 7: Textbox
},
obj = {
[0] = { { 255, 255, 255 }, { 248, 56, 40 }, { 248, 56, 40 }, { 248, 56, 40 } }, -- Authentic GBC Red OAM
}
}
local TILEMAP = nil
local function getCardFlipTilemap()
if TILEMAP == nil then
local path = "assets/generated/card_flip/card_flip.tilemap"
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
TILEMAP = {}
for i = 1, #data do
TILEMAP[i] = string.byte(data, i)
end
else
TILEMAP = false
end
end
return TILEMAP or nil
end
function CardFlip:sheets()
if self.sheet1 == nil then
self.sheet1 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_1.png", wide = 16, firstTile = 0 })
self.sheet2 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_2.png", wide = 3, firstTile = 0 })
self.sheet3 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_3.png", wide = 1, firstTile = 0 })
self.sheetOn = TileSheet.new({ path = "assets/generated/card_flip/on.png", wide = 1, firstTile = 0 })
self.sheetOff = TileSheet.new({ path = "assets/generated/card_flip/off.png", wide = 1, firstTile = 0 })
end
return self.sheet1, self.sheet2, self.sheet3, self.sheetOn, self.sheetOff
end
function CardFlip:cursorQuads()
if self.s3Image == nil then
local _, _, s3 = self:sheets()
self.s3Image = s3:image()
if self.s3Image and love and love.graphics then
local G = love.graphics
self.quadCorner = G.newQuad(0, 0, 8, 8, 8, 56) -- Tile 0: 1px corner
self.quadVEdge = G.newQuad(0, 8, 8, 8, 8, 56) -- Tile 1: 1px vertical edge
self.quadHEdge = G.newQuad(0, 16, 8, 8, 8, 56) -- Tile 2: 1px horizontal edge
end
end
return self.s3Image, self.quadCorner, self.quadVEdge, self.quadHEdge
end
local HEADER_TILE_MAP = {
[0x3E] = 0, [0x3F] = 1,
[0x40] = 3, [0x41] = 4,
[0x42] = 6, [0x43] = 7,
[0x44] = 9, [0x45] = 10,
[0x46] = 12, [0x47] = 13,
[0x48] = 15, [0x49] = 16,
[0x4A] = 18, [0x4B] = 19,
[0x4C] = 21, [0x4D] = 22,
}
-- Draw an authentic GBC red cursor bounding frame using OAM sprite tiles.
-- On real GBC hardware OAM color 0 is always transparent, so only the 1px
-- dark edges of tiles 0-2 are visible; the board shows through the middle.
-- We replicate this by drawing in "multiply" blend mode: white (255,255,255)
-- pixels multiply to the board colour unchanged, black (0,0,0) pixels tinted
-- to GBC red draw the border, and nothing fills the interior.
function CardFlip:drawOamBox(px, py, w, h)
local img, qCorner, qVEdge, qHEdge = self:cursorQuads()
local G = love.graphics
if not (img and qCorner and qVEdge and qHEdge) then
-- Fallback: plain 1px red outline
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
G.rectangle("fill", px, py, w, 1)
G.rectangle("fill", px, py + h - 1, w, 1)
G.rectangle("fill", px, py, 1, h)
G.rectangle("fill", px + w - 1, py, 1, h)
G.setColor(1, 1, 1, 1)
return
end
local prevBlend, prevAlpha = G.getBlendMode()
G.setBlendMode("multiply", "premultiplied")
-- Tint: black (0) → GBC red, white (255) → white (passthrough = transparent)
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
-- 4 Corners
G.draw(img, qCorner, px, py, 0, 1, 1)
G.draw(img, qCorner, px + w, py, 0, -1, 1)
G.draw(img, qCorner, px, py + h, 0, 1, -1)
G.draw(img, qCorner, px + w, py + h, 0, -1, -1)
-- Top & Bottom horizontal edges
if w > 16 then
for x = px + 8, px + w - 16, 8 do
G.draw(img, qHEdge, x, py, 0, 1, 1)
G.draw(img, qHEdge, x, py + h, 0, 1, -1)
end
end
-- Left & Right vertical edges
if h > 16 then
for y = py + 8, py + h - 16, 8 do
G.draw(img, qVEdge, px, y, 0, 1, 1)
G.draw(img, qVEdge, px + w, y, 0, -1, 1)
end
end
G.setBlendMode(prevBlend, prevAlpha)
G.setColor(1, 1, 1, 1)
end
function CardFlip:drawBoard()
-- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand
-- being played and every one before it stays off.
for row = 0, CardFlip.HANDS_PER_DECK - 1 do
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
end
for x = 2, 5 do
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
-- The pair headers sit above the two Pokemon they cover.
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
end
for y = 2, 7 do
local row = LEVEL_ROW[y]
Chrome.print(tostring(y - 1), LEVEL_COL, row)
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
local s1, s2, s3, sOn, sOff = self:sheets()
local tm = getCardFlipTilemap()
local G = love.graphics
-- Green background fill
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
G.rectangle("fill", 0, 0, 160, 144)
if not tm or not s2:available() then
-- Fallback simple board
for row = 0, CardFlip.HANDS_PER_DECK - 1 do
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
end
for x = 2, 5 do
-- A still-in-the-deck cell stands in for the card back until the art
-- lands. It cannot be '#': that is charmap.asm $54, the text command
-- that places "POKé", not a one-tile glyph.
local card = CardFlip.card(y - 2, x - 2)
Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row)
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
end
for y = 2, 7 do
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local row = 3 + pair * 3 + (isBottom and 1 or 0)
Chrome.print(tostring(y - 1), LEVEL_COL, row)
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
for x = 2, 5 do
local card = CardFlip.card(y - 2, x - 2)
Chrome.print(self.discarded[card] and " " or "?", MON_COL[x], row)
end
end
return
end
-- Draw the 11x12 board tilemap at (9, 0)
for ty = 0, 11 do
for tx = 0, 10 do
local idx = ty * 11 + tx + 1
local tileId = tm[idx]
local screenX = 9 + tx
local screenY = ty
-- Attribute palette
local pal = 0
if screenY >= 1 and screenY <= 2 then
if screenX == 12 or screenX == 13 then pal = 1 -- Pikachu
elseif screenX == 14 or screenX == 15 then pal = 2 -- Jigglypuff
elseif screenX == 16 or screenX == 17 then pal = 3 -- Poliwag
elseif screenX == 18 or screenX == 19 then pal = 4 -- Oddish
end
elseif screenX == 9 then
pal = 1 -- Lights
end
local colors = CARDFLIP_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
s3.palette = colors
if screenX == 9 then
-- Column 9: Light buttons
if screenY == self.played then
sOn.palette = colors
sOn:draw(0, screenX, screenY)
else
sOff.palette = colors
sOff:draw(0, screenX, screenY)
end
elseif tileId >= 0x3e then
-- Board graphics from card_flip_2 (using accurate 2x2 header tile mapping)
local mappedId = HEADER_TILE_MAP[tileId] or (tileId - 0x3e)
s2:draw(mappedId, screenX, screenY)
elseif tileId < 0x3e then
-- Graphics from card_flip_1
s1:draw(tileId, screenX, screenY)
end
end
end
-- Draw discarded card blanking covers (each 2-tile wide stacked card cell is 16x12 px)
for y = 2, 7 do
local level = y - 2
local pair = math.floor(level / 2)
local isBottom = (level % 2 == 1)
local py = 24 + pair * 24 + (isBottom and 12 or 0)
for x = 2, 5 do
local mon = x - 2
local card = CardFlip.card(level, mon)
if self.discarded[card] then
-- Discarded cover over full 16x12 stacked card cell
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
G.rectangle("fill", MON_COL[x] * 8, py, 16, 12)
end
end
end
end
function CardFlip:cursorCell()
function CardFlip:cursorBounds()
local x, y = self.cursorX, self.cursorY
local col
if x == 0 then col = LEVEL_PAIR_COL
elseif x == 1 then col = LEVEL_COL
else col = MON_COL[x] end
local row
if y == 0 then row = MON_PAIR_ROW
elseif y == 1 then row = MON_ROW
else row = LEVEL_ROW[y] end
return col, row
if y == 0 then
-- Pokemon Pair: spans 4 columns (32px), 1 row (8px)
local px = (MON_COL[x] or 12) * 8
local py = MON_PAIR_ROW * 8
return px, py, 32, 8
elseif y == 1 then
-- Single Pokemon: 2x2 tiles (16x16 px)
local px = (MON_COL[x] or 12) * 8
local py = MON_ROW * 8
return px, py, 16, 16
elseif x == 0 then
-- Level Pair: 1 column (8px), spans 24px across the full pair
local pair = math.floor((y - 2) / 2)
local px = LEVEL_PAIR_COL * 8
local py = 24 + pair * 24
return px, py, 8, 24
elseif x == 1 then
-- Single Level: 1 column (8px), 12px tall for each stacked card
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local px = LEVEL_COL * 8
local py = 24 + pair * 24 + (isBottom and 12 or 0)
return px, py, 8, 12
else
-- Exact Card: 2 columns (16px), 12px tall for each stacked card
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local px = (MON_COL[x] or 12) * 8
local py = 24 + pair * 24 + (isBottom and 12 or 0)
return px, py, 16, 12
end
end
-- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the
-- 3x3 Pokepic one row further down.
local FACE_DOWN_TILES = {
{ 0x08, 0x09, 0x09, 0x09, 0x0a },
{ 0x0b, 0x28, 0x2b, 0x28, 0x0c },
{ 0x0b, 0x2c, 0x2d, 0x2e, 0x0c },
{ 0x0b, 0x2f, 0x30, 0x31, 0x0c },
{ 0x0b, 0x32, 0x33, 0x34, 0x0c },
{ 0x0d, 0x0e, 0x0e, 0x0e, 0x0f },
}
local FACE_UP_TILES = {
{ 0x18, 0x19, 0x19, 0x19, 0x1a },
{ 0x1b, 0x35, 0x28, 0x28, 0x1c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x1d, 0x1e, 0x1e, 0x1e, 0x1f },
}
local MON_ANCHORS = {
[0] = 24, -- Pikachu (tiles 24..32 in card_flip_2)
[1] = 33, -- Jigglypuff (tiles 33..41 in card_flip_2)
[2] = 42, -- Poliwag (tiles 42..50 in card_flip_2)
[3] = 51, -- Oddish (tiles 51..59 in card_flip_2)
}
-- Draw face-down, flipping, or face-up card at (2, 0) or (2, 6)
function CardFlip:drawCards()
local s1, s2 = self:sheets()
for slot = 1, 2 do
local box = CARD_BOX[slot]
Chrome.box(box.x, box.y, CARD_BOX_W, CARD_BOX_H)
local bx, by = box.x * 8, box.y * 8
local chosen = (slot - 1) == self.which
if self.faceUp and chosen then
Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3,
box.y + 1)
Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)],
box.x + 1, box.y + 3)
elseif self.phase == "choose" and chosen then
Chrome.cursor(box.x, box.y + 3)
local isFlipping = (self.phase == "flipping") and chosen
local isFaceUp = (self.faceUp and chosen)
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 4) then
local activeCard = self.faceUp or self.targetCard or 0
local lvl = CardFlip.level(activeCard) + 1
local mon = CardFlip.mon(activeCard)
local monPal = CARDFLIP_PALS.bg[mon + 1] or CARDFLIP_PALS.bg[1]
s1.palette = CARDFLIP_PALS.bg[0]
for cy = 1, 6 do
for cx = 1, 5 do
local tid = FACE_UP_TILES[cy][cx]
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
end
end
-- Level digit at (box.x + 3, box.y + 1)
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 8) then
Chrome.print(tostring(lvl), box.x + 3, box.y + 1)
-- Draw 3x3 Pokemon pic from card_flip_2 (s2) at (box.x + 1, box.y + 2)
s2.palette = monPal
local anchor = MON_ANCHORS[mon] or 24
for py = 0, 2 do
for px = 0, 2 do
local tid = anchor + py * 3 + px
s2:draw(tid, box.x + 1 + px, box.y + 2 + py)
end
end
end
else
s1.palette = CARDFLIP_PALS.bg[0]
for cy = 1, 6 do
for cx = 1, 5 do
local tid = FACE_DOWN_TILES[cy][cx]
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
end
end
if self.phase == "choose" and chosen then
-- Authentic OAM red selection box around 5x6 card (40x48 px)
self:drawOamBox(bx, by, 40, 48)
end
end
end
end
function CardFlip:drawPanel()
Chrome.clear()
self:drawBoard()
self:drawCards()
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2)
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
-- Dialogue / Message box at (0, 12), 10 wide, 6 tall (interior 8x4)
if self.lines then
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, 8, 4)
for i, line in ipairs(self.lines) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end
end
-- Coin box at (9, 15), 11 wide, 3 tall (interior 9x1)
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, 9, 1)
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
if self.phase == "bet" then
local col, row = self:cursorCell()
Chrome.cursor(col - 1, row)
self.betBlink = (self.betBlink or 0) + 1
if (self.betBlink % 32) < 24 then
local px, py, w, h = self:cursorBounds()
self:drawOamBox(px, py, w, h)
end
end
if self.phase == "ask" or self.phase == "again" then
-- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
Chrome.textbox(14, 7, 4, 3)
@@ -652,3 +982,4 @@ function CardFlip:drawWidescreen(winW, winH)
end
return CardFlip
+535 -80
View File
@@ -219,10 +219,16 @@ local TWO_LINES = {
function SlotMachine.matchFirstTwo(bet, r1, r2)
local building = SlotMachine.NO_MATCH
local matchingSevens = false
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do
if r1[line[1]] == r2[line[2]] then building = r1[line[1]] end
if r1[line[1]] == r2[line[2]] then
building = r1[line[1]]
if building == SlotMachine.SEVEN then
matchingSevens = true
end
end
end
return building, building == SlotMachine.SEVEN
return building, matchingSevens
end
-- ------------------------------------------------------------------- bias
@@ -403,13 +409,14 @@ end
-- matching SEVENs on a line this bet buys.
function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
local strip = SlotMachine.REELS[2]
local pos = SlotMachine.advance(position)
for _ = 1, SEARCH_LIMIT do
local window = { SlotMachine.window(strip, position) }
local window = { SlotMachine.window(strip, pos) }
local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
if building ~= SlotMachine.NO_MATCH and sevens then return position end
position = SlotMachine.advance(position)
if building ~= SlotMachine.NO_MATCH and sevens then return pos end
pos = SlotMachine.advance(pos)
end
return position
return pos
end
-- ------------------------------------------------------- reel 3's theatre
@@ -580,8 +587,8 @@ end
-- ------------------------------------------------------------------ layout
local COINS_X, COINS_Y = 5, 1
local PAYOUT_X, PAYOUT_Y = 11, 1
-- REEL_X_COORD / TILE_WIDTH.
local REEL_X = { 6, 10, 14 }
-- REEL_X_COORD / TILE_WIDTH (5, 9, 13) for the 3 reel apertures (columns 5-6, 9-10, 13-14)
local REEL_X = { 5, 9, 13 }
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the
-- fourth symbol that only half shows.
@@ -617,7 +624,7 @@ SlotMachine.TEXTS = TEXTS
-- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the
-- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout.
local function linedUpLines(payout)
return { "lined up!", ("Won %d coins!"):format(payout) }
return { " lined up!", ("Won %d coins!"):format(payout) }
end
-- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what
@@ -729,30 +736,41 @@ function SlotMachine:startSpin()
self.reel = 1
self.delay = 32
self.message = TEXTS.start
self.stopped = nil
self.matched = SlotMachine.NO_MATCH
self.matchingSevens = false
self.reel3Action = nil
self.golemAnim = nil
self.chanseyAnim = nil
self.reel2Pause = nil
for i = 1, 3 do
self.rate[i] = 4 -- ReelAction_NormalRate
self.stops[i] = nil
self.distance[i] = 0
end
self:sfx(SFX_START)
end
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot
-- boundary, and the position advances when the distance's low nibble wraps.
-- boundary, and the position advances whenever 16 pixels are traversed.
function SlotMachine:spinReels()
for i = 1, 3 do
local rate = self.rate[i]
if rate > 0 then
self.distance[i] = (self.distance[i] + rate) % 256
if self.distance[i] % 16 == 0 then
self.distance[i] = (self.distance[i] or 0) + rate
while self.distance[i] >= 16 do
self.distance[i] = self.distance[i] - 16
self.positions[i] = SlotMachine.advance(self.positions[i])
-- A reel with a resting slot chosen stops the moment it reaches it.
if self.stops[i] and self.positions[i] == self.stops[i] then
self.rate[i] = 0
self.distance[i] = 0
self.stopped = self.stopped or {}
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
self.positions[i]) }
self:sfx(SFX_STOP)
self:reelStopped(i)
break
end
end
end
@@ -775,11 +793,17 @@ function SlotMachine:pressStop()
self.stops[1] = SlotMachine.stopReel1(here, self.bias)
elseif i == 2 then
local r1 = self.stopped[1]
if SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) then
local hereWindow = { SlotMachine.window(SlotMachine.REELS[2], here) }
local _, hereSevens = SlotMachine.matchFirstTwo(self.bet, r1, hereWindow)
local doSkip = SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random)
if hereSevens and not doSkip then
self.stops[2] = here
elseif doSkip then
-- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
-- fast-spins it at double rate; the pause is the tell.
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1)
self.rate[2] = 8
self.reel2Pause = 32
self.rate[2] = 0 -- paused during the 32-frame tell
else
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
end
@@ -789,32 +813,54 @@ function SlotMachine:pressStop()
self.matchingSevens = sevens
local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
self.reel3Action = action
if action == SlotMachine.REEL3_STOP then
if action == SlotMachine.REEL3_STOP or action == "stop" then
self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2)
elseif action == SlotMachine.REEL3_SLOW then
elseif action == SlotMachine.REEL3_SLOW or action == "slowAdvance" then
self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
self.rate[3] = 1 -- ReelAction_QuarterRate
elseif action == SlotMachine.REEL3_GOLEM then
self.rate[3] = 1 -- ReelAction_QuarterRate slow crawl
elseif action == SlotMachine.REEL3_GOLEM or action == "golem" then
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
self.random)
if count == 0 then count = 3 end
local target = here
for _ = 1, count do target = SlotMachine.advance(target) end
self.stops[3] = target
self.golems = count
self.rate[3] = 8
self.golemAnim = {
count = count,
state = "falling",
var1 = 48,
x = 100,
y = 44 - 112,
animFrame = 0,
animTimer = 0,
}
self.rate[3] = 0 -- reel 3 stepped by each golem impact
else
local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
self.stops[3] = target
self.rate[3] = 16 -- ReelAction_QuadrupleRate, the egg drop
self.chanseyAnim = {
state = "walking",
xcoord = 0,
x = -24,
y = 44,
animTimer = 0,
animPose = 0,
}
self.rate[3] = 0 -- paused until Chansey drops egg
end
end
-- A reel already sitting on its resting slot has nowhere to turn.
if self.stops[i] == self.positions[i] then
self.rate[i] = 0
self.stopped = self.stopped or {}
self.stopped[i] = self:reelWindow(i)
self:sfx(SFX_STOP)
self:reelStopped(i)
-- Only trigger immediate halt if no active pause tell or special animation is running.
if not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
if self.stops[i] == self.positions[i] then
self.rate[i] = 0
self.distance[i] = 0
self.stopped = self.stopped or {}
self.stopped[i] = self:reelWindow(i)
self:sfx(SFX_STOP)
self:reelStopped(i)
end
end
end
@@ -823,6 +869,9 @@ function SlotMachine:reelStopped(i)
self.reel = i + 1
return
end
self.golemAnim = nil
self.chanseyAnim = nil
self.reel2Pause = nil
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
-- before the payout is counted out; a loss skips straight past it.
local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3]
@@ -906,13 +955,134 @@ function SlotMachine:update(_dt)
if phase == "spinning" then
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
-- bet menu cannot stop reel one.
if self.delay > 0 then
if (self.delay or 0) > 0 then
self.delay = self.delay - 1
self:spinReels()
return
end
if self.reel2Pause and self.reel2Pause > 0 then
self.reel2Pause = self.reel2Pause - 1
if self.reel2Pause <= 0 then
self.reel2Pause = nil
self.rate[2] = 8 -- resume fast-spin after the 32-frame tell
end
end
if self.golemAnim then
local g = self.golemAnim
-- Cycle animation frames every 8 ticks (~7.5 fps) matching the original pacing
g.animTimer = (g.animTimer or 0) + 1
if g.animTimer >= 8 then
g.animTimer = 0
g.animFrame = ((g.animFrame or 0) + 1) % 4
end
if g.state == "falling" then
if g.var1 > 32 then
g.var1 = g.var1 - 1
local angle = (g.var1 * math.pi) / 32
local yOffset = math.floor(112 * math.sin(angle) + 0.5)
g.y = 44 + yOffset
g.x = 100
else
-- Landed on Reel 3!
g.y = 44
g.x = 100
g.state = "rolling"
g.xoffset = 0
g.animTimer = 0
g.animFrame = 0
self:sfx("Sfx_PlacePuzzlePieceDown")
-- Advance reel 3 by 1 slot per Golem impact
self.positions[3] = SlotMachine.advance(self.positions[3])
self.distance[3] = 0
end
elseif g.state == "rolling" then
g.xoffset = (g.xoffset or 0) + 1
g.x = 100 - g.xoffset
if g.xoffset >= 88 then
-- Rolled past reel 1 (100 - 88 = 12px) off the screen -> restart or end
g.count = g.count - 1
if g.count > 0 then
g.state = "falling"
g.var1 = 48
g.x = 100
g.y = 44 - 112
else
-- All Golems finished; halt reel 3 at target
self.golemAnim = nil
self.rate[3] = 0
self.distance[3] = 0
self.stopped = self.stopped or {}
self.stopped[3] = self:reelWindow(3)
self:sfx(SFX_STOP)
self:reelStopped(3)
end
end
end
end
if self.chanseyAnim then
local c = self.chanseyAnim
if c.state == "walking" then
c.xcoord = (c.xcoord or 0) + 1
c.x = c.xcoord - 24
c.y = 44
-- Cycle walking poses 0->1->2->3 (maps to Chansey 1->2->3->4) every 6 frames
c.animTimer = (c.animTimer or 0) + 1
if c.animTimer >= 6 then
c.animTimer = 0
c.animPose = ((c.animPose or 0) + 1) % 4
end
if c.xcoord % 16 == 0 then self:sfx("Sfx_JumpOverLedge") end
if c.x >= 88 then
-- Reached reel 3! Switch to tell pause (pose 4 = arm raised)
c.x = 88
c.state = "egg_pause"
c.delay = 14
c.animPose = 3
end
elseif c.state == "egg_pause" then
c.delay = (c.delay or 14) - 1
if c.delay <= 0 then
-- Switch to Chansey 5 (egg drop pose) and spawn egg
c.animPose = 4
c.state = "egg_drop"
c.eggTimer = 0
c.eggStartX = c.x + 14
c.eggStartY = c.y + 8
c.eggX = c.eggStartX
c.eggY = c.eggStartY
c.eggVisible = true
self:sfx("Sfx_Present")
end
elseif c.state == "egg_drop" then
c.eggTimer = (c.eggTimer or 0) + 1
local t = c.eggTimer / 16
if t > 1 then t = 1 end
c.eggX = c.eggStartX + t * (108 - c.eggStartX)
c.eggY = c.eggStartY + t * (56 - c.eggStartY) - math.sin(t * math.pi) * 8
if c.eggTimer >= 16 then
-- Egg landed on Reel 3!
c.eggVisible = false
c.state = "spinning"
self:sfx("Sfx_PlacePuzzlePieceDown")
self.rate[3] = 16 -- fast drop reel 3 to jackpot
end
end
end
self.message = nil
if input:wasPressed("a") then self:pressStop() end
if input:wasPressed("a") then
if not self.stops[self.reel] and not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
self:pressStop()
end
end
self:spinReels()
return
end
@@ -959,57 +1129,302 @@ end
-- ------------------------------------------------------------------- draw
--
-- The cart's reel art is unextracted (see the header), so a symbol draws as its
-- two-letter label inside a 2x2 cell. A `slots` entry in menu_gfx.lua switches
-- this to the real tiles without any other change.
function SlotMachine:sheet()
if self.sheetCache == nil then
local data = self.game and self.game.data
local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots
if gfx and gfx.image then
local TileSheet = require("src.ui.gen2.TileSheet")
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16,
firstTile = gfx.firstTile or 0 })
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
local TileSheet = require("src.ui.gen2.TileSheet")
local GbcPalette = require("src.render.GbcPalette")
local GBC_PALS = {
bg = {
[0] = { { 255, 255, 255 }, { 198, 206, 231 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 0: Base Frame
[1] = { { 255, 255, 255 }, { 247, 82, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 1: Vileplume / Active Lights
[2] = { { 255, 255, 255 }, { 123, 255, 0 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 2: Bet 3 Indicators
[3] = { { 255, 255, 255 }, { 255, 123, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 3: Bet 2 Indicators
[4] = { { 255, 255, 255 }, { 123, 173, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 4: Bet 1 Indicators
[5] = { { 255, 255, 90 }, { 255, 255, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 5: Yellow Highlights
[6] = { { 255, 255, 255 }, { 132, 156, 239 }, { 206, 181, 0 }, { 0, 0, 0 } }, -- 6: Textbox frame
[7] = { { 255, 255, 255 }, { 173, 173, 173 }, { 107, 107, 107 }, { 0, 0, 0 } }, -- 7: Inactive / Gray
},
obj = {
[0] = { { 255, 255, 255 }, { 247, 82, 49 }, { 255, 0, 0 }, { 0, 0, 0 } }, -- 0: Seven (Red)
[1] = { { 255, 255, 255 }, { 99, 206, 8 }, { 41, 115, 0 }, { 0, 0, 0 } }, -- 1: Pokeball (Green/Red)
[2] = { { 255, 255, 255 }, { 99, 206, 8 }, { 247, 82, 49 }, { 0, 0, 0 } }, -- 2: Cherry
[3] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 3: Pikachu (Yellow)
[4] = { { 255, 255, 255 }, { 255, 255, 49 }, { 123, 173, 255 }, { 0, 0, 0 } }, -- 4: Squirtle (Blue/Yellow)
[5] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 5: Staryu / Golem (Rock)
[6] = { { 255, 255, 255 }, { 255, 198, 173 }, { 255, 107, 255 }, { 0, 0, 0 } }, -- 6: Chansey (Pink)
[7] = { { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, { 0, 0, 0 } }, -- 7: Flashing
}
}
local TILEMAP = nil
local function getTilemap()
if TILEMAP == nil then
local path = "assets/generated/slots/gold_slots.tilemap"
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
TILEMAP = {}
for i = 1, #data do
TILEMAP[i] = string.byte(data, i)
end
else
self.sheetCache = false
TILEMAP = false
end
end
return self.sheetCache or nil
return TILEMAP or nil
end
local function cell(tx, ty, label)
local G = love.graphics
G.setColor(0, 0, 0, 1)
G.rectangle("line", tx * 8, ty * 8, 16, 16)
Chrome.print(label, tx, ty + 1)
function SlotMachine:sheets()
if self.sheet1 == nil then
self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 })
self.sheet2 = TileSheet.new({ path = "assets/generated/slots/gold_slots_2.png", wide = 2, firstTile = 0 })
self.sheet3 = TileSheet.new({ path = "assets/generated/slots/gold_slots_3.png", wide = 3, firstTile = 0 })
end
return self.sheet1, self.sheet2, self.sheet3
end
function SlotMachine:drawBackground()
local s1, s2 = self:sheets()
local tm = getTilemap()
if not tm or not s1:available() then
-- Fallback simple background if assets unavailable
Chrome.clear()
return
end
local bet = self.bet or 0
for ty = 0, 11 do
for tx = 0, 19 do
local idx = ty * 20 + tx + 1
local tileId = tm[idx]
-- Palette attribution matching _CGB_SlotMachine
local pal = 0
if (tx <= 2 or tx >= 17) and ty >= 2 and ty <= 11 then
if ty >= 6 and ty <= 7 then pal = 4
elseif ty >= 4 and ty <= 9 then pal = 3
else pal = 2 end
elseif tx >= 4 and tx <= 15 and ty >= 2 and ty <= 3 then
pal = 1 -- Vileplume
elseif (tx == 3 or tx == 16) and ty >= 2 and ty <= 11 then
local isLit = false
if ty == 6 or ty == 7 then isLit = (bet >= 1)
elseif ty == 4 or ty == 5 or ty == 8 or ty == 9 then isLit = (bet >= 2)
elseif ty == 2 or ty == 3 or ty == 10 or ty == 11 then isLit = (bet >= 3)
end
if isLit then
pal = 1
-- Use lit lights tile
if tileId == 0x23 then tileId = 0x14
elseif tileId == 0x24 then tileId = 0x15 end
else
pal = 0
end
end
local colors = GBC_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
if tileId < 0x25 then
s1:draw(tileId, tx, ty)
else
s2:draw(tileId - 0x25, tx, ty)
end
end
end
end
function SlotMachine:drawReels()
local _, s2 = self:sheets()
local G = love.graphics
for i = 1, 3 do
local strip = SlotMachine.REELS[i]
local position = self.positions[i]
-- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays
-- them bottom upward, which is what the three repeated entries at the end
-- of each strip are for: position 14 reads indices 14, 15, 16 and 17
-- without wrapping. Only the lower three are on a pay line; the fourth
-- half-shows at the top of the window.
for row = 1, 4 do
local symbol = strip[position + row]
cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?")
local pos = self.positions[i]
local a = pos
if a == 0 then a = 0x0f end
a = (a - 1) % 16
local rx = REEL_X[i] * 8
local dy = math.floor(self.distance[i] or 0)
-- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
for row = 0, 3 do
local sym = strip[a + row + 1]
local py = 64 - (row * 16) + dy
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
s2.palette = pal
-- 2x2 tiles in 2-wide sheet:
-- sym + 0 = top-left (col 0, row 0)
-- sym + 1 = top-right (col 1, row 0)
-- sym + 2 = bottom-left (col 0, row 1)
-- sym + 3 = bottom-right (col 1, row 1)
local t0 = s2:quad(sym + 0)
local t1 = s2:quad(sym + 1)
local t2 = s2:quad(sym + 2)
local t3 = s2:quad(sym + 3)
local img = s2:image()
if img and t0 and t1 and t2 and t3 then
local function drawSym()
G.draw(img, t0, rx, py)
G.draw(img, t1, rx + 8, py)
G.draw(img, t2, rx, py + 8)
G.draw(img, t3, rx + 8, py + 8)
end
if GbcPalette.available() then
GbcPalette.with(pal, drawSym)
else
drawSym()
end
else
-- Fallback label
cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?")
end
end
end
end
function SlotMachine:drawLights()
local lit = {}
-- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller
-- one: `dec a / jr z` falls through from three to two to one.
for bet = 1, (self.bet or 0) do
for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end
function SlotMachine:actorsImage()
if self.actorsLoaded == nil then
local Assets = require("src.render.Assets")
local ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_actors.png")
if not (ok and img) then
ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_3.png")
end
self.actorsLoaded = (ok and img) or false
if self.actorsLoaded then
local G = love.graphics
-- 24x240 sheet:
-- Y=0: Golem 1 (Standing, 24x32)
-- Y=32: Golem 2 (Ball, 24x32)
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
-- Y=96: Chansey 2 (Step 2, 24x32)
-- Y=128: Chansey 3 (Step 3, 24x32)
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
-- Y=224: Egg (8x16 at X=0)
self.quadGolemStand = G.newQuad(0, 0, 24, 32, 24, 240)
self.quadGolemBall = G.newQuad(0, 32, 24, 32, 24, 240)
self.quadChansey1 = G.newQuad(0, 64, 24, 32, 24, 240)
self.quadChansey2 = G.newQuad(0, 96, 24, 32, 24, 240)
self.quadChansey3 = G.newQuad(0, 128, 24, 32, 24, 240)
self.quadChansey4 = G.newQuad(0, 160, 24, 32, 24, 240)
self.quadChanseyDrop = G.newQuad(0, 192, 24, 32, 24, 240)
self.quadEgg = G.newQuad(0, 224, 8, 16, 24, 240)
end
end
for _, row in ipairs({ 2, 4, 6, 8, 10 }) do
for _, col in ipairs(LIGHT_COLS) do
Chrome.print(lit[row] and "*" or "-", col, row)
return self.actorsLoaded or nil
end
-- Redraw the top Vileplume row (rows 2..3) and bottom frame brackets (rows 10..11)
-- over the reels with solid backdrop to naturally mask any sprite overhang like the Game Boy hardware does.
function SlotMachine:drawOverlays()
local s1, s2 = self:sheets()
local tm = getTilemap()
if not tm or not s1:available() then return end
local G = love.graphics
-- Solid backdrop over header (rows 0..3) and footer (rows 10..11) between columns 4..15
G.setColor(198 / 255, 198 / 255, 74 / 255, 1)
G.rectangle("fill", 4 * 8, 2 * 8, 12 * 8, 2 * 8)
G.rectangle("fill", 4 * 8, 10 * 8, 12 * 8, 2 * 8)
G.setColor(1, 1, 1, 1)
for _, ty in ipairs({ 2, 3, 10, 11 }) do
for tx = 4, 15 do
local idx = ty * 20 + tx + 1
local tileId = tm[idx]
local pal = (ty <= 3) and 1 or 0
local colors = GBC_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
if tileId < 0x25 then
s1:draw(tileId, tx, ty)
else
s2:draw(tileId - 0x25, tx, ty)
end
end
end
-- Draw Golem sprite animation
if self.golemAnim then
local actors = self:actorsImage()
local g = self.golemAnim
if actors then
local quad = self.quadGolemBall
local scaleX = 1
local scaleY = 1
if g.state == "falling" then
quad = self.quadGolemBall
elseif g.state == "rolling" then
-- Frameset_SlotsGolem: 0=Standing, 1=Ball, 2=StandingYFlip, 3=BallXFlip
local rotFrame = (g.animFrame or 0) % 4
if rotFrame == 0 then
quad = self.quadGolemStand
scaleX = 1
scaleY = 1
elseif rotFrame == 1 then
quad = self.quadGolemBall
scaleX = 1
scaleY = 1
elseif rotFrame == 2 then
quad = self.quadGolemStand
scaleX = 1
scaleY = -1
elseif rotFrame == 3 then
quad = self.quadGolemBall
scaleX = -1
scaleY = 1
end
end
G.setColor(1, 1, 1, 1)
local function drawGolem()
-- Draw rotated around center (ox=12, oy=16)
G.draw(actors, quad,
math.floor(g.x + 12),
math.floor(g.y + 16),
0, scaleX, scaleY, 12, 16)
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[5], drawGolem)
else
drawGolem()
end
end
end
-- Draw Chansey & Egg sprite animation
if self.chanseyAnim then
local actors = self:actorsImage()
local c = self.chanseyAnim
if actors then
local quad = self.quadChansey1
if c.state == "walking" then
local walkCycle = { self.quadChansey1, self.quadChansey2, self.quadChansey3, self.quadChansey4 }
quad = walkCycle[((c.animPose or 0) % 4) + 1] or self.quadChansey1
elseif c.state == "egg_pause" then
quad = self.quadChansey4
elseif c.state == "egg_drop" or c.state == "spinning" then
quad = self.quadChanseyDrop
end
G.setColor(1, 1, 1, 1)
local function drawChansey()
G.draw(actors, quad, math.floor(c.x), math.floor(c.y or 44))
if c.eggVisible and c.eggX and c.eggY then
G.draw(actors, self.quadEgg, math.floor(c.eggX), math.floor(c.eggY))
end
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[6], drawChansey)
else
drawChansey()
end
end
end
end
@@ -1022,39 +1437,79 @@ function SlotMachine:drawMessage()
end
if self.matched and self.matched ~= SlotMachine.NO_MATCH
and self.phase == "payoutText" then
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y,
SlotMachine.LABELS[self.matched] or "?")
local _, s2 = self:sheets()
local sym = self.matched
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
s2.palette = pal
local t0 = s2:quad(sym + 0)
local t1 = s2:quad(sym + 1)
local t2 = s2:quad(sym + 2)
local t3 = s2:quad(sym + 3)
local img = s2:image()
local G = love.graphics
local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8
if img and t0 and t1 and t2 and t3 then
local function drawWin()
G.setColor(1, 1, 1, 1)
G.draw(img, t0, px, py)
G.draw(img, t1, px + 8, py)
G.draw(img, t2, px, py + 8)
G.draw(img, t3, px + 8, py + 8)
end
G.setColor(1, 1, 1, 1)
if GbcPalette.available() then
GbcPalette.with(pal, drawWin)
else
drawWin()
end
else
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?")
end
end
end
function SlotMachine:drawPanel()
Chrome.clear()
self:drawLights()
self:drawBackground()
self:drawReels()
self:drawOverlays()
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters.
Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y)
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
self:drawReels()
if self.phase == "bet" then
Chrome.textbox(BET_BOX_X, BET_BOX_Y, BET_BOX_W - 2, BET_BOX_H - 2)
-- Left speech textbox for "Bet how many coins?"
Chrome.textbox(0, 12, 12, 4)
Chrome.print(TEXTS.betHowMany[1], 1, 14)
Chrome.print(TEXTS.betHowMany[2], 1, 16)
-- Right menu for bet choices (14, 10 to 19, 17)
Chrome.textbox(14, 10, 4, 6)
for i, label in ipairs(BET_ROWS) do
local ty = BET_LABEL_Y + (i - 1) * BET_SPACING
if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end
Chrome.print(label, BET_LABEL_X, ty)
local ty = 12 + (i - 1) * 2
if i == self.betIndex then Chrome.cursor(15, ty) end
Chrome.print(label, 16, ty)
end
if not self.message then
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
for i, line in ipairs(TEXTS.betHowMany) do
if self.message then
-- If "Not enough coins." message is shown, overlay full speech box
Chrome.textbox(0, 12, 18, 4)
for i, line in ipairs(self.message) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end
end
end
self:drawMessage()
if self.phase == "again" then
-- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13).
elseif self.phase == "again" then
-- Speech box: "Play again?"
Chrome.textbox(0, 12, 18, 4)
Chrome.print(TEXTS.playAgain[1], TEXT_X, TEXT_Y)
-- PlaceYesNoBox at (14, 12): 6x5 box with YES at (16,13), NO at (16,15)
Chrome.textbox(14, 12, 4, 3)
Chrome.print("YES", 16, 13)
Chrome.print("NO", 16, 15)
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
else
self:drawMessage()
end
end
+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),
"0050")
-- ============================================================ multi-game stress tests
--
-- Verify that 500 consecutive Slot Machine games and 500 consecutive Card Flip hands
-- run to completion with zero softlocks, zero infinite spin loops (issue #1520),
-- and correct coin accounting across all random biases and near-miss theatres.
local mockSave = {
player = { name = "GOLD", coins = 5000 },
}
local mockInput = {
pressed = {},
wasPressed = function(self, key)
local v = self.pressed[key]
self.pressed[key] = false
return v or false
end,
press = function(self, key)
self.pressed[key] = true
end,
}
local mockSlotGame = {
save = mockSave,
input = mockInput,
data = {},
}
-- Test Slot Machine 500-spin continuous loop
local sm = SlotMachine.new(mockSlotGame, { lucky = true })
local completedSpins = 0
for spin = 1, 500 do
mockSave.player.coins = 5000 -- ensure test player always has coins
if sm.phase == "quit" or sm.phase == "ranOut" then
sm = SlotMachine.new(mockSlotGame, { lucky = true })
end
-- Enter bet phase
check("slot machine in bet phase at spin start", sm.phase, "bet")
mockInput:press("a") -- bet 3 coins
sm:update(1/60)
check("slot machine entered spinning phase", sm.phase, "spinning")
local frameCount = 0
local maxFrames = 5000 -- safety bound per spin
while sm.phase == "spinning" and frameCount < maxFrames do
frameCount = frameCount + 1
if frameCount % 30 == 0 then
mockInput:press("a") -- press stop button
end
sm:update(1/60)
end
check(("spin %d must not hang in spinning"):format(spin), frameCount < maxFrames, true)
-- Resolve flash and payout phases
while (sm.phase == "flash" or sm.phase == "payoutText") and frameCount < maxFrames do
frameCount = frameCount + 1
if sm.phase == "payoutText" and sm.matched == SlotMachine.NO_MATCH then
mockInput:press("a")
end
sm:update(1/60)
end
check(("spin %d reached again phase"):format(spin), sm.phase == "again" or sm.phase == "bet", true)
if sm.phase == "again" then
mockInput:press("a") -- choose YES to play again
sm:update(1/60)
completedSpins = completedSpins + 1
elseif sm.phase == "bet" then
completedSpins = completedSpins + 1
end
end
check("all 500 slot machine spins completed without softlock", completedSpins >= 490, true)
-- Test Card Flip 500-hand continuous loop
local cf = CardFlip.new(mockSlotGame)
local completedHands = 0
for hand = 1, 500 do
mockSave.player.coins = 5000
if cf.phase == "quit" then
cf = CardFlip.new(mockSlotGame)
end
local frameCount = 0
local maxFrames = 500
while cf.phase ~= "again" and cf.phase ~= "quit" and frameCount < maxFrames do
frameCount = frameCount + 1
if cf.phase == "ask" or cf.phase == "message" or cf.phase == "result"
or cf.phase == "choose" or cf.phase == "bet" then
mockInput:press("a")
end
cf:update(1/60)
end
check(("hand %d completed in bounds"):format(hand), frameCount < maxFrames, true)
if cf.phase == "again" then
mockInput:press("a") -- play again
cf:update(1/60)
completedHands = completedHands + 1
end
end
check("all 500 card flip hands completed cleanly", completedHands >= 490, true)
print(("gen2 game corner: %d checks, %d failures"):format(checks, failures))
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
-- here would take the whole tier down and silently skip every suite after it.
+131 -4
View File
@@ -40,14 +40,16 @@ local mockGame = {
print("Running SurfingMinigame unit tests...")
-- Test 1: Initialization
-- Test 1: Initialization & Title Screen transition
local mg = SurfingMinigame.new(mockGame)
assert_eq(mg.routine, -1, "Initial routine must be ROUTINE_TITLE (-1)")
mg:startFromTitle()
assert_eq(mg.routine, 0, "Routine must advance to ROUTINE_START_GAME (0) after startFromTitle()")
assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)")
assert_eq(mg.speed, 0.25, "Initial speed must be 0.25")
assert_eq(mg.distance, 0, "Initial distance must be 0")
assert_eq(mg.routine, 0, "Initial routine must be ROUTINE_START_GAME (0)")
assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)")
print("✓ Initial state test passed")
print("✓ Initial state & Title transition test passed")
-- Test 2: Start banner transition to RunGame
for _ = 1, 40 do
@@ -65,6 +67,8 @@ assert_eq(mg.hp, initialHp - 1, "HP should decrease by 1 each frame")
print("✓ Auto acceleration and HP countdown test passed")
-- Test 4: Landing Evaluation Matrix
local old_getWaveTile = mg.getWaveTileUnderPika
mg.getWaveTileUnderPika = function() return 0x01 end -- force open water
mg.frameSet = 5
assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing")
mg.frameSet = 6
@@ -77,6 +81,7 @@ for f = 8, 14 do
mg.frameSet = f
assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout")
end
mg.getWaveTileUnderPika = old_getWaveTile
print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)")
-- Test 5: Stunt Scoring
@@ -145,6 +150,128 @@ end
assert_eq(mg.radness, 0, "Radness should be tallied down to 0")
assert_eq(mg.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)")
assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)")
print("✓ Results tally countdown test passed")
-- Test 8: Crossing finish line while jumping upside-down crashes into water and rights Pikachu before results
local mg8 = SurfingMinigame.new(mockGame, nil, true)
mg8.routine = 1 -- ROUTINE_RUN_GAME
mg8.distanceFixed = (24 * 128 - 2) * 256
mg8.speedFixed = 512
mg8.pikaState = 1 -- PIKA_STATE_JUMPING
mg8.frameSet = 11 -- Upside down
mg8.pikaY = 60
mg8.jumpDescending = true
mg8.jumpArcMagnitude = 4
mg8.radness = 150
local preScore = mg8.radness
-- Update to cross the finish line
mg8:update()
assert_eq(mg8.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2) upon crossing finish")
assert_eq(mg8.pikaState, 1, "Pikachu should remain mid-air immediately after crossing line")
-- Update until Pikachu lands in water
while mg8.pikaState == 1 do
mg8:update()
end
assert_eq(mg8.pikaState, 3, "Upside-down landing post-finish line must trigger PIKA_STATE_CRASHED (3)")
assert_eq(mg8.radness, preScore, "Radness score must NOT change after crossing finish line")
assert_eq(mg8.crashTimer, 96, "Crash timer must be initialized to 96 frames")
-- Update while crashed to verify recovery
while mg8.pikaState == 3 do
mg8:update()
end
assert_eq(mg8.pikaState, 0, "Pikachu must recover back to PIKA_STATE_RIDING (0) and right itself on the board")
assert_eq(mg8.frameSet, 4, "Pikachu frameSet must be reset to upright (4)")
-- Let coasting finish and verify transition to results
while mg8.routine == 2 do
mg8:update()
end
assert_eq(mg8.routine, 3, "Routine should advance to ROUTINE_SCROLL_RESULTS (3) only after Pikachu is upright")
print("✓ Mid-air upside-down finish line crossing crash & recovery test passed")
-- Test 9: Crossing finish line while upright jumping lands cleanly and proceeds
local mg9 = SurfingMinigame.new(mockGame, nil, true)
mg9.routine = 1
mg9.distanceFixed = (24 * 128 - 2) * 256
mg9.speedFixed = 512
mg9.pikaState = 1
mg9.frameSet = 4 -- Clean flat
mg9.pikaY = 60
mg9.jumpDescending = true
mg9.jumpArcMagnitude = 4
mg9.radness = 200
preScore = mg9.radness
mg9:update()
assert_eq(mg9.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)")
while mg9.pikaState == 1 do
mg9:update()
end
assert_eq(mg9.pikaState, 2, "Upright landing post-finish line must trigger PIKA_STATE_LANDING (2)")
assert_eq(mg9.radness, preScore, "Radness score must NOT change post-finish")
while mg9.pikaState == 2 do
mg9:update()
end
assert_eq(mg9.pikaState, 0, "Pikachu must return to PIKA_STATE_RIDING (0)")
print("✓ Mid-air upright finish line crossing test passed")
-- Test 10: Crossing finish line while already crashed recovers before results
local mg10 = SurfingMinigame.new(mockGame, nil, true)
mg10.routine = 1
mg10.distanceFixed = (24 * 128 - 2) * 256
mg10.speedFixed = 512
mg10.pikaState = 3 -- PIKA_STATE_CRASHED
mg10.crashTimer = 50
mg10:update()
assert_eq(mg10.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)")
assert_eq(mg10.pikaState, 3, "Pikachu should still be crashed")
while mg10.pikaState == 3 do
mg10:update()
end
assert_eq(mg10.pikaState, 0, "Pikachu must recover upright before proceeding to results")
print("✓ Pre-crashed finish line crossing recovery test passed")
-- Test 11: Decoupled timestep accumulator (60Hz and 144Hz framerate consistency)
local mg11_60 = SurfingMinigame.new(mockGame, nil, true)
mg11_60.routine = 1 -- ROUTINE_RUN_GAME
for _ = 1, 60 do
mg11_60:update(1 / 60)
end
assert(mg11_60.t == 59 or mg11_60.t == 60, "60Hz update over 1s must produce approx 60 ticks (got " .. mg11_60.t .. ")")
local mg11_144 = SurfingMinigame.new(mockGame, nil, true)
mg11_144.routine = 1 -- ROUTINE_RUN_GAME
for _ = 1, 144 do
mg11_144:update(1 / 144)
end
assert(mg11_144.t == 59 or mg11_144.t == 60, "144Hz update over 1s must produce approx 60 ticks (got " .. mg11_144.t .. ")")
print("✓ Decoupled 59.7275Hz timestep accumulator test passed")
-- Test 12: Landing continuity on slopes (no position jumps while landing)
local mg12 = SurfingMinigame.new(mockGame, nil, true)
mg12.routine = 1
mg12.pikaState = 2 -- PIKA_STATE_LANDING
mg12.landingTimer = 20
mg12.speedFixed = 256
-- Place on a rising wave pattern
mg12.cols[5] = { pat = SurfingMinigame.WAVE_PATTERNS[0x06], hl = 110, hr = 100 }
mg12.distanceFixed = (5 * 16 - 80) * 256
local startY = mg12.pikaY
mg12:update()
assert(mg12.pikaY ~= startY, "pikaY must continuously follow wave surface height while in PIKA_STATE_LANDING")
print("✓ Landing slope height tracking continuity test passed")
-- Test 13: Fixed speed enforcement (minigames must always run at 1X speed)
assert(mg12.isFixedSpeed == true, "SurfingMinigame must have isFixedSpeed flag enabled")
assert(mg12.isMinigame == true, "SurfingMinigame must have isMinigame flag enabled")
local mockStack = { states = { mg12 } }
local Game = require("src.core.Game")
assert(Game.isFixedSpeedInStack(mockStack) == true, "Game.isFixedSpeedInStack must return true for SurfingMinigame")
print("✓ Minigame fixed speed enforcement test passed")
print("All SurfingMinigame unit tests passed successfully!")
+42 -2
View File
@@ -2037,8 +2037,48 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
_decode_2bpp(bytes(reordered), 40, 16),
os.path.join(assets_dir, "credits/the_end.png"))
raw_2bpp(
"WorldMapTileGraphics", 32, 32, "townmap/tiles.png")
if _has_symbol(symbols, "SurfingPikachu1Graphics1"):
raw_2bpp("SurfingPikachu1Graphics1", 40, 104, "minigame/surf_1a.png", transparent=False)
raw_2bpp("SurfingPikachu1Graphics2", 128, 128, "minigame/surf_1b.png", transparent=True)
raw_2bpp("SurfingPikachu1Graphics3", 96, 96, "minigame/surf_1c.png", transparent=True)
beach_intro = rom.bytes(62, 0x50bc, 240)
use_ctrl_pad = rom.bytes(62, 0x51ac, 15)
to_surf_rad = rom.bytes(62, 0x51bb, 13)
title_map = rom.bytes(62, 0x51c8, 72)
screen = [0xff] * (20 * 18)
for i in range(240):
screen[6 * 20 + i] = beach_intro[i]
for r in range(6):
for c in range(12):
screen[r * 20 + (4 + c)] = title_map[r * 12 + c]
for r in range(3):
for c in range(15):
screen[(7 + r) * 20 + (3 + c)] = 0xff
for i in range(15):
screen[7 * 20 + 3 + i] = use_ctrl_pad[i]
for i in range(13):
screen[9 * 20 + 4 + i] = to_surf_rad[i]
sym3 = _symbol(symbols, "SurfingPikachu1Graphics3")
raw_gfx3 = rom.bytes(sym3.bank, sym3.address, 144 * 16)
tiles = [_decode_2bpp(raw_gfx3[i*16:(i+1)*16], 8, 8) for i in range(144)]
blank = Image.new("RGBA", (8, 8), (255, 255, 255, 255))
title_bg = Image.new("RGBA", (160, 144), (255, 255, 255, 255))
for r in range(18):
for c in range(20):
t_id = screen[r * 20 + c]
if t_id == 0xff:
tile_img = blank
elif t_id >= 0x80:
idx = t_id - 0x80
tile_img = tiles[idx] if idx < 144 else blank
else:
idx = 128 + t_id
tile_img = tiles[idx] if idx < 144 else blank
title_bg.paste(tile_img, (c * 8, r * 8))
_save_png(title_bg, os.path.join(assets_dir, "minigame/title_bg.png"))
raw_2bpp("WorldMapTileGraphics", 32, 32, "townmap/tiles.png")
raw_1bpp(
"TownMapCursor", 16, 16, "townmap/cursor.png",
transparent=True)