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