mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
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:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+388
-57
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user