mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 21:16:28 +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
|
||||
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)
|
||||
|
||||
|
||||
@@ -5231,6 +5231,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
|
||||
|
||||
+490
-144
@@ -66,6 +66,7 @@ local TOTAL_SECTIONS = 24 -- 24 sections ($18) of 128px = 3072px course
|
||||
local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144)
|
||||
|
||||
-- Routine numbers (wSurfingMinigameRoutineNumber)
|
||||
local ROUTINE_TITLE = -1
|
||||
local ROUTINE_START_GAME = 0
|
||||
local ROUTINE_RUN_GAME = 1
|
||||
local ROUTINE_WAIT_RESULTS = 2
|
||||
@@ -216,7 +217,9 @@ local WAVE_STEPS = {
|
||||
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
|
||||
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET },
|
||||
}
|
||||
local SEQ_STARTS = { 0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c }
|
||||
local SEQ_STARTS = {
|
||||
0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c
|
||||
}
|
||||
|
||||
SurfingMinigame.BG_METATILES = BG_METATILES
|
||||
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
|
||||
@@ -233,6 +236,40 @@ local ANGLE_BASES = {
|
||||
[7] = { 0x33, 0x69 }, -- Angle 06 (nose down steep / frontflip apex)
|
||||
}
|
||||
|
||||
-- OAM Definitions from surfing_pikachu_oam.asm
|
||||
-- .WaterSpray (3 tiles, relative to PIKA_X = 68, y = self.pikaY)
|
||||
local OAM_WATER_SPRAY = {
|
||||
{ dy = -4, dx = 11, tile = 0xa7, xflip = false },
|
||||
{ dy = 4, dx = 3, tile = 0xb6, xflip = false },
|
||||
{ dy = 4, dx = 11, tile = 0xb7, xflip = false },
|
||||
}
|
||||
|
||||
-- .SmallSplash (6 tiles, relative to cx = 80, cy = self.pikaY + 4)
|
||||
local OAM_SMALL_SPLASH = {
|
||||
{ dy = -4, dx = -16, tile = 0xa7, xflip = true },
|
||||
{ dy = -4, dx = 8, tile = 0xa7, xflip = false },
|
||||
{ dy = 4, dx = -16, tile = 0xb7, xflip = true },
|
||||
{ dy = 4, dx = -8, tile = 0xb6, xflip = true },
|
||||
{ dy = 4, dx = 0, tile = 0xb6, xflip = false },
|
||||
{ dy = 4, dx = 8, tile = 0xb7, xflip = false },
|
||||
}
|
||||
|
||||
-- .LargeSplash (12 tiles, relative to cx = 80, cy = self.pikaY + 4)
|
||||
local OAM_LARGE_SPLASH = {
|
||||
{ dy = -12, dx = -16, tile = 0xa8, xflip = false },
|
||||
{ dy = -12, dx = -8, tile = 0xa9, xflip = false },
|
||||
{ dy = -12, dx = 0, tile = 0xa9, xflip = true },
|
||||
{ dy = -12, dx = 8, tile = 0xa8, xflip = true },
|
||||
{ dy = -4, dx = -16, tile = 0xb8, xflip = false },
|
||||
{ dy = -4, dx = -8, tile = 0xb9, xflip = false },
|
||||
{ dy = -4, dx = 0, tile = 0xb9, xflip = true },
|
||||
{ dy = -4, dx = 8, tile = 0xb8, xflip = true },
|
||||
{ dy = 4, dx = -16, tile = 0xc8, xflip = false },
|
||||
{ dy = 4, dx = -8, tile = 0xc9, xflip = false },
|
||||
{ dy = 4, dx = 0, tile = 0xc9, xflip = true },
|
||||
{ dy = 4, dx = 8, tile = 0xc8, xflip = true },
|
||||
}
|
||||
|
||||
-- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10)
|
||||
local BEACH_OUTRO = {
|
||||
{ 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
|
||||
@@ -258,9 +295,9 @@ local PIKACHUS_BEACH_PAL = {
|
||||
-- 5 Tempo tiers (117, 109, 101, 93, 85 from surfing_pikachu.asm)
|
||||
local TEMPO_TIERS = { 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 }
|
||||
|
||||
function SurfingMinigame.new(game, onDone)
|
||||
function SurfingMinigame.new(game, onDone, skipTitle)
|
||||
local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame)
|
||||
self.routine = ROUTINE_START_GAME
|
||||
self.routine = skipTitle and ROUTINE_START_GAME or ROUTINE_TITLE
|
||||
self.pikaState = PIKA_STATE_RIDING
|
||||
self.t = 0
|
||||
self.routineTimer = 0
|
||||
@@ -269,9 +306,11 @@ function SurfingMinigame.new(game, onDone)
|
||||
self.hp = 6000 -- starts at 6000 (60.00 seconds)
|
||||
self.radness = 0 -- accumulated trick stunt points
|
||||
self.totalScore = 0 -- tallied total score
|
||||
self.hiScore = game.save.surfingHighScore or 0
|
||||
self.hiScore = (game and game.save and game.save.surfingHighScore) or 0
|
||||
self.newRecord = false
|
||||
self.currentPitch = 1.0
|
||||
self.isMinigame = true
|
||||
self.isFixedSpeed = true
|
||||
|
||||
-- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV)
|
||||
self.rDiv = 0
|
||||
@@ -297,6 +336,7 @@ function SurfingMinigame.new(game, onDone)
|
||||
self.boardAngleDecreasing = false
|
||||
self.boardAngleTimer = 0
|
||||
self.crashTimer = 0
|
||||
self.landingTimer = 0
|
||||
|
||||
-- 3-frame buffered D-Pad rotation & input accumulator
|
||||
self.joyCounter = 0
|
||||
@@ -318,14 +358,28 @@ function SurfingMinigame.new(game, onDone)
|
||||
self.tallyStep = 0
|
||||
self.tallyTimer = 0
|
||||
|
||||
-- Load sheets safely
|
||||
-- Load sheets safely across all GameVersion prefix paths
|
||||
local function sheet(path)
|
||||
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local prefix = (GameVersion and GameVersion.cachePrefix and GameVersion.cachePrefix(game and game.version)) or "yellow/"
|
||||
local filename = path:match("([^/]+)$") or path
|
||||
local paths = {
|
||||
prefix .. path,
|
||||
path,
|
||||
"yellow/assets/generated/minigame/" .. filename,
|
||||
"assets/generated/minigame/" .. filename,
|
||||
}
|
||||
for _, p in ipairs(paths) do
|
||||
local ok, img = pcall(love.graphics.newImage, p)
|
||||
if ok and img then return img end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
self.bg = sheet("assets/generated/minigame/surf_1a.png")
|
||||
self.ob = sheet("assets/generated/minigame/surf_1b.png")
|
||||
self.intro = sheet("assets/generated/minigame/surf_1c.png")
|
||||
self.titleBg = sheet("assets/generated/minigame/title_bg.png")
|
||||
|
||||
if self.bg then
|
||||
self.tq = {}
|
||||
@@ -343,32 +397,97 @@ function SurfingMinigame.new(game, onDone)
|
||||
end
|
||||
end
|
||||
|
||||
Music.play(game.data, "Music_SurfingPikachu")
|
||||
if self.intro then
|
||||
self.iq = {}
|
||||
local iW, iH = self.intro:getDimensions() -- 96x96 tile sheet (12x12 tiles)
|
||||
for n = 0, 143 do
|
||||
self.iq[n] = love.graphics.newQuad((n % 12) * 8, math.floor(n / 12) * 8, 8, 8, iW, iH)
|
||||
end
|
||||
-- Pika Intro Poses (24x32 px each in top 32px: X=0, 24, 48)
|
||||
self.introPikaQuad1 = love.graphics.newQuad(0, 0, 24, 32, iW, iH)
|
||||
self.introPikaQuad2 = love.graphics.newQuad(24, 0, 24, 32, iW, iH)
|
||||
self.introPikaQuad3 = love.graphics.newQuad(48, 0, 24, 32, iW, iH)
|
||||
-- Title Banner Logo ("PIKACHU'S BEACH") (96x32 px at Y=32..64)
|
||||
self.introLogoQuad = love.graphics.newQuad(0, 32, 96, 32, iW, iH)
|
||||
-- Instruction Text ("Use Control Pad to Surf") (96x32 px at Y=64..96)
|
||||
self.introTextQuad = love.graphics.newQuad(0, 64, 96, 32, iW, iH)
|
||||
end
|
||||
|
||||
-- GPU Shader & Canvas for authentic HBlank wave distortion
|
||||
if love and love.graphics and love.graphics.newShader then
|
||||
local shaderCode = [[
|
||||
extern float u_time;
|
||||
extern float u_water_line;
|
||||
|
||||
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
|
||||
vec2 uv = texture_coords;
|
||||
if (uv.y >= u_water_line) {
|
||||
float pixel_y = uv.y * 144.0;
|
||||
float wave_px = sin((pixel_y * 0.39269908) + (u_time * 6.0)) * (1.5 / 160.0);
|
||||
uv.x = fract(uv.x + wave_px);
|
||||
}
|
||||
return Texel(texture, uv) * color;
|
||||
}
|
||||
]]
|
||||
local ok, shader = pcall(love.graphics.newShader, shaderCode)
|
||||
if ok then self.waveShader = shader end
|
||||
end
|
||||
|
||||
if love and love.graphics and love.graphics.newCanvas then
|
||||
local ok, canvas = pcall(love.graphics.newCanvas, 160, 144)
|
||||
if ok then self.bgCanvas = canvas end
|
||||
end
|
||||
|
||||
Music.play(game and game.data, "Music_SurfingPikachu")
|
||||
return self
|
||||
end
|
||||
|
||||
-- Authentic Game Boy VBlank LFSR Random Number Generator
|
||||
-- Transition cleanly from Title Screen to active run, flushing input bleed-through
|
||||
function SurfingMinigame:startFromTitle()
|
||||
self.routine = ROUTINE_START_GAME
|
||||
self.inputAccum = 0
|
||||
self.joyCounter = 0
|
||||
self.rotCountLeft = 0
|
||||
self.rotCountRight = 0
|
||||
self:resetTempo()
|
||||
if self.game and self.game.input and self.game.input.clearPressed then
|
||||
self.game.input:clearPressed()
|
||||
end
|
||||
Sound.play(self.game and self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
-- Authentic Game Boy LFSR Random Number Generator
|
||||
function SurfingMinigame:getGBRandom()
|
||||
self.rDiv = (self.rDiv + 7 + (self.inputAccum or 0)) % 256
|
||||
self.rAdd = (self.rAdd + self.rDiv) % 256
|
||||
self.rSub = (self.rSub - self.rDiv + 256) % 256
|
||||
return (self.rAdd + self.rSub) % 256
|
||||
self.rDiv = (self.rDiv + 13) % 256
|
||||
local tempAdd = self.rAdd + self.rDiv
|
||||
self.rAdd = tempAdd % 256
|
||||
local tempSub = self.rSub - self.rDiv
|
||||
self.rSub = (tempSub + 256) % 256
|
||||
return self.rAdd
|
||||
end
|
||||
|
||||
function SurfingMinigame:chooseSequence()
|
||||
local distPx = math.floor(self.distanceFixed / 256)
|
||||
if math.floor(distPx / 128) >= 0x16 then
|
||||
local section = math.floor(distPx / 128)
|
||||
if section == 0x16 then
|
||||
self.waveFn = 0x6a
|
||||
else
|
||||
elseif section < 0x16 then
|
||||
local r = self:getGBRandom()
|
||||
if r ~= 0 then self.waveFn = SEQ_STARTS[((r - 1) % 8) + 1] end
|
||||
if r ~= 0 then
|
||||
self.waveFn = SEQ_STARTS[bit.band(r - 1, 0x07) + 1]
|
||||
end
|
||||
end
|
||||
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
|
||||
end
|
||||
|
||||
function SurfingMinigame:pushColumn()
|
||||
local pat, hl, hr
|
||||
if self.waveFn == 0 then
|
||||
local distPx = math.floor(self.distanceFixed / 256)
|
||||
if distPx >= (TOTAL_SECTIONS * 128) or self.waveFn >= 0x74 then
|
||||
-- Lock finish line to solid beach sand without cycling back to ocean waves
|
||||
pat, hl, hr = WAVE_PATTERNS["beach"], FLAT_WATER_Y, FLAT_WATER_Y
|
||||
self.waveFn = 0x74
|
||||
elseif self.waveFn == 0 then
|
||||
pat, hl, hr = self:chooseSequence()
|
||||
else
|
||||
local step = WAVE_STEPS[self.waveFn]
|
||||
@@ -396,30 +515,40 @@ function SurfingMinigame:seaY(x)
|
||||
local distPx = math.floor(self.distanceFixed / 256)
|
||||
local tile = math.floor((distPx + x) / 8)
|
||||
local col = self.cols[math.floor(tile / 2)]
|
||||
if not col then return FLAT_WATER_Y - 16 end
|
||||
return (tile % 2 == 0 and col.hl or col.hr) - 16
|
||||
if not col then return FLAT_WATER_Y end
|
||||
return tile % 2 == 0 and col.hl or col.hr
|
||||
end
|
||||
|
||||
-- Get the tile ID of the wave under Pikachu (sample 9-10 tiles into viewport)
|
||||
-- Get the tile ID of the wave under Pikachu (sample 9 tiles / 72 pixels into viewport)
|
||||
function SurfingMinigame:getWaveTileUnderPika()
|
||||
local distPx = math.floor(self.distanceFixed / 256)
|
||||
local tile = math.floor((distPx + 80) / 8)
|
||||
local col = self.cols[math.floor(tile / 2)]
|
||||
local tile_col = math.floor((distPx + 72) / 8)
|
||||
local waterY = math.floor(self:seaY(80))
|
||||
local tile_row = math.floor(waterY / 8)
|
||||
|
||||
local c = math.floor(tile_col / 2)
|
||||
local col = self.cols[c]
|
||||
if not col or not col.pat then return 0x01 end
|
||||
local pat = col.pat
|
||||
for i = 4, 8 do
|
||||
local mt = pat[i]
|
||||
if mt then
|
||||
if mt == 0x02 or mt == 0x06 or mt == 0x0a or mt == 0x11 then
|
||||
|
||||
local i = math.floor(tile_row / 2) + 1
|
||||
if i < 1 or i > 8 then return 0x01 end
|
||||
local mt = BG_METATILES[col.pat[i]]
|
||||
if not mt then return 0x01 end
|
||||
|
||||
local sub_x = (tile_col % 2 == 0) and 0 or 1
|
||||
local sub_y = (tile_row % 2 == 0) and 0 or 1
|
||||
|
||||
local sub_idx = 1 + sub_x + sub_y * 2
|
||||
local mt_tile = mt[sub_idx] or 0x01
|
||||
|
||||
if mt_tile == 0x02 or mt_tile == 0x04 or mt_tile == 0x06 or mt_tile == 0x0a or mt_tile == 0x11 then
|
||||
return 0x06 -- rising slope
|
||||
elseif mt == 0x03 or mt == 0x07 or mt == 0x0b or mt == 0x0d then
|
||||
elseif mt_tile == 0x03 or mt_tile == 0x05 or mt_tile == 0x07 or mt_tile == 0x0d or mt_tile == 0x13 then
|
||||
return 0x07 -- falling slope
|
||||
elseif mt == 0x08 or mt == 0x09 or mt == 0x0f or mt == 0x10 or mt == 0x14 or mt == 0x15 then
|
||||
elseif mt_tile == 0x08 or mt_tile == 0x09 or mt_tile == 0x0f or mt_tile == 0x10 or mt_tile == 0x12 or mt_tile == 0x14 or mt_tile == 0x15 then
|
||||
return 0x14 -- wave crest / face
|
||||
end
|
||||
end
|
||||
end
|
||||
return 0x01 -- flat open water
|
||||
return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.)
|
||||
end
|
||||
|
||||
function SurfingMinigame:spawnTrickPopup(text)
|
||||
@@ -497,21 +626,51 @@ function SurfingMinigame:evaluateLanding()
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:updateTempo()
|
||||
local tier = 1
|
||||
if self.speedFixed >= 416 then
|
||||
tier = 5
|
||||
elseif self.speedFixed >= 320 then
|
||||
tier = 4
|
||||
elseif self.speedFixed >= 224 then
|
||||
tier = 3
|
||||
elseif self.speedFixed >= 128 then
|
||||
tier = 2
|
||||
else
|
||||
tier = 1
|
||||
end
|
||||
local targetPitch = TEMPO_TIERS[tier] or 1.0
|
||||
if self.currentPitch ~= targetPitch then
|
||||
self.currentPitch = targetPitch
|
||||
if Music and Music.setPitch then
|
||||
Music.setPitch(self.currentPitch)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:resetTempo()
|
||||
self.currentPitch = 1.0
|
||||
if Music and Music.setPitch then
|
||||
Music.setPitch(1.0)
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:updateRiding()
|
||||
-- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0)
|
||||
if self.speedFixed < SPEED_MAX then
|
||||
self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL)
|
||||
end
|
||||
self:updateTempo()
|
||||
|
||||
-- Follow wave surface height
|
||||
local targetY = self:seaY(80)
|
||||
self.pikaY = math.floor(targetY)
|
||||
self.pikaY = math.floor(targetY) - 16
|
||||
self.pikaSubY = 0
|
||||
|
||||
-- Water spray every 4 frames
|
||||
self.sprayTimer = self.sprayTimer + 1
|
||||
if self.sprayTimer % 4 == 0 then
|
||||
table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 })
|
||||
table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 })
|
||||
end
|
||||
|
||||
-- Board angle wobbling every 8 frames
|
||||
@@ -532,14 +691,14 @@ function SurfingMinigame:updateRiding()
|
||||
end
|
||||
end
|
||||
|
||||
-- Select frame based on slope
|
||||
-- Select frame based on slope (lock flat open water to frame 4 without wobble)
|
||||
local tile = self:getWaveTileUnderPika()
|
||||
if tile == 0x06 or tile == 0x14 then
|
||||
self.frameSet = 6 + (self.boardAngleOffset - 1)
|
||||
elseif tile == 0x07 then
|
||||
self.frameSet = 2 + (self.boardAngleOffset - 1)
|
||||
else
|
||||
self.frameSet = 4 + (self.boardAngleOffset - 1)
|
||||
self.frameSet = 4 -- flat open water: steady horizontal ride
|
||||
end
|
||||
self.frameSet = math.max(1, math.min(14, self.frameSet))
|
||||
|
||||
@@ -560,43 +719,71 @@ function SurfingMinigame:updateRiding()
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:handleLanding()
|
||||
local result = self:evaluateLanding()
|
||||
if result == "wipeout" then
|
||||
self.pikaState = PIKA_STATE_CRASHED
|
||||
self.crashTimer = 96
|
||||
self.speedFixed = SPEED_INITIAL
|
||||
self.frameSet = 4
|
||||
Sound.play(self.game.data, "Faint_Fall")
|
||||
else
|
||||
if result == "rough" then
|
||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY)
|
||||
elseif result == "hard" then
|
||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY)
|
||||
end
|
||||
if self.routine == ROUTINE_RUN_GAME then
|
||||
self:calculateStuntPoints()
|
||||
end
|
||||
self.pikaState = PIKA_STATE_LANDING
|
||||
self.landingTimer = 32
|
||||
self.frameSet = 4
|
||||
Sound.play(self.game.data, "Cut")
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:updateJumping()
|
||||
-- Process accumulated input on the 3-frame buffer boundary
|
||||
-- Process accumulated input on the 3-frame buffer boundary (only during active run)
|
||||
if self.routine == ROUTINE_RUN_GAME then
|
||||
self.joyCounter = (self.joyCounter + 1) % 3
|
||||
if self.joyCounter == 0 then
|
||||
local rightHeld = bit.band(self.inputAccum, 1) ~= 0
|
||||
local leftHeld = bit.band(self.inputAccum, 2) ~= 0
|
||||
self.inputAccum = 0
|
||||
|
||||
if rightHeld then
|
||||
self.rotCountLeft = 0
|
||||
self.rotCountRight = self.rotCountRight + 1
|
||||
if self.rotCountRight >= 11 then
|
||||
-- Game Boy priority: Left D-pad checked first, then Right D-pad
|
||||
if leftHeld then
|
||||
self.rotCountRight = 0
|
||||
self.rotCountLeft = self.rotCountLeft + 1
|
||||
if self.rotCountLeft >= 11 then
|
||||
self.rotCountLeft = 0
|
||||
self.radnessMeter = math.min(3, self.radnessMeter + 1)
|
||||
self.trickFlags = bit.bor(self.trickFlags, 1)
|
||||
Sound.play(self.game.data, "Tink")
|
||||
end
|
||||
-- Increment frame set (frontflip forward)
|
||||
self.frameSet = (self.frameSet % 14) + 1
|
||||
elseif leftHeld then
|
||||
self.rotCountRight = 0
|
||||
self.rotCountLeft = self.rotCountLeft + 1
|
||||
if self.rotCountLeft >= 13 then
|
||||
elseif rightHeld then
|
||||
self.rotCountLeft = 0
|
||||
self.rotCountRight = self.rotCountRight + 1
|
||||
if self.rotCountRight >= 13 then
|
||||
self.rotCountRight = 0
|
||||
self.radnessMeter = math.min(3, self.radnessMeter + 1)
|
||||
self.trickFlags = bit.bor(self.trickFlags, 2)
|
||||
Sound.play(self.game.data, "Tink")
|
||||
end
|
||||
-- Decrement frame set (backflip backward)
|
||||
self.frameSet = self.frameSet - 1
|
||||
if self.frameSet < 1 then self.frameSet = 14 end
|
||||
end
|
||||
end
|
||||
else
|
||||
self.inputAccum = 0
|
||||
end
|
||||
|
||||
-- Authentic Game Boy collision boundary & integer fixed-point jump physics
|
||||
if not self.jumpDescending then
|
||||
self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4
|
||||
local a = math.floor(self.jumpArcMagnitude)
|
||||
self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4
|
||||
local intDelta = math.floor(self.pikaSubY / 256)
|
||||
self.pikaSubY = self.pikaSubY % 256
|
||||
self.pikaY = self.pikaY - intDelta
|
||||
@@ -608,34 +795,16 @@ function SurfingMinigame:updateJumping()
|
||||
end
|
||||
else
|
||||
-- Hardware execution order: evaluate boundary before adding velocity
|
||||
local waveY = math.floor(self:seaY(80))
|
||||
local waveY = math.floor(self:seaY(80)) - 16
|
||||
if self.pikaY >= waveY then
|
||||
self.pikaY = waveY
|
||||
self.pikaSubY = 0
|
||||
-- Evaluate landing angle vs wave slope
|
||||
local result = self:evaluateLanding()
|
||||
if result == "wipeout" then
|
||||
self.pikaState = PIKA_STATE_CRASHED
|
||||
self.crashTimer = 96
|
||||
self.speedFixed = SPEED_INITIAL
|
||||
self.frameSet = 4
|
||||
Sound.play(self.game.data, "Faint_Fall")
|
||||
else
|
||||
if result == "rough" then
|
||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY)
|
||||
elseif result == "hard" then
|
||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY)
|
||||
end
|
||||
self:calculateStuntPoints()
|
||||
self.pikaState = PIKA_STATE_LANDING
|
||||
self.routineTimer = 32
|
||||
self.frameSet = 4
|
||||
Sound.play(self.game.data, "Cut")
|
||||
end
|
||||
self:handleLanding()
|
||||
return
|
||||
end
|
||||
|
||||
self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4
|
||||
local a = math.floor(self.jumpArcMagnitude)
|
||||
self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4
|
||||
local intDelta = math.floor(self.pikaSubY / 256)
|
||||
self.pikaSubY = self.pikaSubY % 256
|
||||
self.pikaY = self.pikaY + intDelta
|
||||
@@ -644,57 +813,66 @@ function SurfingMinigame:updateJumping()
|
||||
if self.pikaY >= waveY then
|
||||
self.pikaY = waveY
|
||||
self.pikaSubY = 0
|
||||
local result = self:evaluateLanding()
|
||||
if result == "wipeout" then
|
||||
self.pikaState = PIKA_STATE_CRASHED
|
||||
self.crashTimer = 96
|
||||
self.speedFixed = SPEED_INITIAL
|
||||
self.frameSet = 4
|
||||
Sound.play(self.game.data, "Faint_Fall")
|
||||
else
|
||||
if result == "rough" then
|
||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY)
|
||||
elseif result == "hard" then
|
||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY)
|
||||
end
|
||||
self:calculateStuntPoints()
|
||||
self.pikaState = PIKA_STATE_LANDING
|
||||
self.routineTimer = 32
|
||||
self.frameSet = 4
|
||||
Sound.play(self.game.data, "Cut")
|
||||
end
|
||||
self:handleLanding()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:updateLanding()
|
||||
self.routineTimer = self.routineTimer - 1
|
||||
self.landingTimer = (self.landingTimer or 0) - 1
|
||||
|
||||
-- Follow wave surface height continuously while landing so slopes don't cause position jumps!
|
||||
local targetY = self:seaY(80)
|
||||
self.pikaY = math.floor(targetY) - 16
|
||||
self.pikaSubY = 0
|
||||
|
||||
-- Sine wave splash offset
|
||||
self.pikaYOffset = math.floor(math.sin((32 - self.routineTimer) / 32 * math.pi * 2) * 4)
|
||||
if self.routineTimer % 4 == 0 then
|
||||
table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 })
|
||||
self.pikaYOffset = math.floor(math.sin((32 - math.max(0, self.landingTimer)) / 32 * math.pi * 2) * 4)
|
||||
if self.landingTimer % 4 == 0 then
|
||||
table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 })
|
||||
end
|
||||
if self.routineTimer <= 0 then
|
||||
|
||||
if self.landingTimer <= 0 then
|
||||
self.pikaYOffset = 0
|
||||
self.pikaState = PIKA_STATE_RIDING
|
||||
self.frameSet = 4
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:updateCrashed()
|
||||
self.crashTimer = self.crashTimer - 1
|
||||
self:resetTempo()
|
||||
-- Follow water surface while wiped out
|
||||
local targetY = self:seaY(80)
|
||||
self.pikaY = math.floor(targetY) - 16
|
||||
self.pikaSubY = 0
|
||||
if self.crashTimer <= 0 then
|
||||
self.pikaState = PIKA_STATE_RIDING
|
||||
self.frameSet = 4
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:update()
|
||||
local input = self.game.input
|
||||
-- Single Game Boy hardware VBlank cycle tick (59.7275 Hz)
|
||||
function SurfingMinigame:tick()
|
||||
local input = self.game and self.game.input
|
||||
self.t = self.t + 1
|
||||
self.rDiv = (self.rDiv + 1) % 256
|
||||
|
||||
-- Accumulate physical button presses on every frame (eliminates polling blind spot)
|
||||
if input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end
|
||||
if input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end
|
||||
-- Title Screen State
|
||||
if self.routine == ROUTINE_TITLE then
|
||||
if input and (input:wasPressed("start") or input:wasPressed("a")) then
|
||||
self:startFromTitle()
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Accumulate physical button presses on every frame (only during active run)
|
||||
if self.routine == ROUTINE_RUN_GAME then
|
||||
if input and input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end
|
||||
if input and input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end
|
||||
else
|
||||
self.inputAccum = 0
|
||||
end
|
||||
|
||||
-- Update trick popups
|
||||
for i = #self.trickPopups, 1, -1 do
|
||||
@@ -728,7 +906,7 @@ function SurfingMinigame:update()
|
||||
self.routineTimer = 128
|
||||
self.speedFixed = 0
|
||||
self.ohNoBanner = true
|
||||
Sound.play(self.game.data, "Faint_Fall")
|
||||
Sound.play(self.game and self.game.data, "Faint_Fall")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -758,20 +936,44 @@ function SurfingMinigame:update()
|
||||
end
|
||||
|
||||
elseif self.routine == ROUTINE_WAIT_RESULTS then
|
||||
-- 192 frames coasting past the goal line
|
||||
-- Coasting past the goal line
|
||||
self.distanceFixed = self.distanceFixed + (2 * 256)
|
||||
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25)
|
||||
self:generateAhead()
|
||||
self.pikaY = math.floor(self:seaY(80))
|
||||
|
||||
-- Run Pikachu state machine so mid-air jumps and wipeout crashes complete
|
||||
if self.pikaState == PIKA_STATE_JUMPING then
|
||||
self:updateJumping()
|
||||
elseif self.pikaState == PIKA_STATE_LANDING then
|
||||
self:updateLanding()
|
||||
elseif self.pikaState == PIKA_STATE_CRASHED then
|
||||
self:updateCrashed()
|
||||
else
|
||||
-- Riding: follow water surface
|
||||
local targetY = self:seaY(80)
|
||||
self.pikaY = math.floor(targetY) - 16
|
||||
self.pikaSubY = 0
|
||||
self.frameSet = 4
|
||||
end
|
||||
|
||||
if self.routineTimer > 0 then
|
||||
self.routineTimer = self.routineTimer - 1
|
||||
if self.routineTimer <= 0 then
|
||||
end
|
||||
|
||||
-- Only advance to scroll results when delay has finished AND Pikachu is upright
|
||||
if self.routineTimer <= 0 and self.pikaState == PIKA_STATE_RIDING then
|
||||
self.routine = ROUTINE_SCROLL_RESULTS
|
||||
self.routineTimer = 36
|
||||
self.pikaState = PIKA_STATE_GAME_END
|
||||
end
|
||||
|
||||
elseif self.routine == ROUTINE_SCROLL_RESULTS then
|
||||
self.distanceFixed = self.distanceFixed + (1 * 256)
|
||||
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((1 * 256) * 0.25)
|
||||
self:generateAhead()
|
||||
self.pikaY = math.floor(self:seaY(80))
|
||||
self.pikaY = math.floor(self:seaY(80)) - 16
|
||||
self.pikaSubY = 0
|
||||
self.frameSet = 4
|
||||
self.routineTimer = self.routineTimer - 1
|
||||
if self.routineTimer <= 0 then
|
||||
self.routine = ROUTINE_DRAW_RESULTS
|
||||
@@ -813,7 +1015,7 @@ function SurfingMinigame:update()
|
||||
local step = math.min(self.hp, 99)
|
||||
self.hp = self.hp - step
|
||||
self.totalScore = self.totalScore + step
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
Sound.play(self.game and self.game.data, "Press_AB")
|
||||
else
|
||||
self.routine = ROUTINE_ADD_RAD_TOTAL
|
||||
end
|
||||
@@ -824,18 +1026,18 @@ function SurfingMinigame:update()
|
||||
local step = math.min(self.radness, 99)
|
||||
self.radness = self.radness - step
|
||||
self.totalScore = self.totalScore + step
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
Sound.play(self.game and self.game.data, "Press_AB")
|
||||
else
|
||||
self.routine = ROUTINE_WAIT_LAST
|
||||
self.routineTimer = 64
|
||||
-- High score check
|
||||
self.newRecord = self.totalScore > (self.game.save.surfingHighScore or 0)
|
||||
self.newRecord = self.totalScore > ((self.game and self.game.save and self.game.save.surfingHighScore) or 0)
|
||||
if self.newRecord then
|
||||
self.game.save.surfingHighScore = self.totalScore
|
||||
Sound.play(self.game.data, "Get_Item1")
|
||||
Sound.playPikaCry(self.game.data, 34)
|
||||
if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end
|
||||
Sound.play(self.game and self.game.data, "Get_Item1")
|
||||
Sound.playPikaCry(self.game and self.game.data, 34)
|
||||
else
|
||||
Sound.playPikaCry(self.game.data, 28)
|
||||
Sound.playPikaCry(self.game and self.game.data, 28)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -846,24 +1048,48 @@ function SurfingMinigame:update()
|
||||
end
|
||||
|
||||
elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if input and input:wasPressed("a") then
|
||||
if self.game and self.game.stack then self.game.stack:pop() end
|
||||
if self.onDone then self.onDone(self.totalScore) end
|
||||
end
|
||||
|
||||
elseif self.routine == ROUTINE_GAME_OVER then
|
||||
self.routineTimer = self.routineTimer - 1
|
||||
if self.routineTimer <= 0 and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.game.stack:pop()
|
||||
if self.routineTimer <= 0 and input and input:wasPressed("a") then
|
||||
if self.game and self.game.stack then self.game.stack:pop() end
|
||||
if self.onDone then self.onDone(0) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw scrolling wave background
|
||||
-- Decoupled timestep accumulator for modern multi-refresh-rate displays
|
||||
function SurfingMinigame:update(dt)
|
||||
if not dt then
|
||||
self:tick()
|
||||
return
|
||||
end
|
||||
|
||||
-- Accumulate real-world time
|
||||
self.tickAccumulator = (self.tickAccumulator or 0) + dt
|
||||
|
||||
-- Exact Game Boy Color framerate: 59.7275 Hz (approx 0.0167427 seconds per frame)
|
||||
local gbTickRate = 1 / 59.7275
|
||||
|
||||
-- Process as many physical hardware frames as necessary
|
||||
while self.tickAccumulator >= gbTickRate do
|
||||
self:tick()
|
||||
self.tickAccumulator = self.tickAccumulator - gbTickRate
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw scrolling wave background with authentic GPU shader HBlank wave distortion
|
||||
function SurfingMinigame:drawBackground()
|
||||
local scx = math.floor(self.distanceFixed / 256)
|
||||
local first = math.floor(scx / 16)
|
||||
|
||||
local function renderTiles()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if not self.bg then return end
|
||||
for c = first, first + 10 do
|
||||
local col = self.cols[c]
|
||||
if col then
|
||||
@@ -882,28 +1108,57 @@ function SurfingMinigame:drawBackground()
|
||||
end
|
||||
end
|
||||
|
||||
if self.bgCanvas and self.waveShader and love.graphics.setCanvas and love.graphics.getCanvas then
|
||||
-- 1. Capture the framework's active canvas
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
|
||||
love.graphics.setCanvas(self.bgCanvas)
|
||||
-- 2. Clear with transparency (0 alpha) so the sky remains empty
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
renderTiles()
|
||||
|
||||
-- 3. Restore the framework's canvas before drawing!
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
|
||||
-- 4. Safely capture and restore the shader
|
||||
local prevShader = love.graphics.getShader and love.graphics.getShader()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(self.waveShader)
|
||||
|
||||
if self.waveShader.hasUniform and self.waveShader:hasUniform("u_time") then
|
||||
self.waveShader:send("u_time", self.t / 60.0)
|
||||
end
|
||||
if self.waveShader.hasUniform and self.waveShader:hasUniform("u_water_line") then
|
||||
self.waveShader:send("u_water_line", 48.0 / 144.0)
|
||||
end
|
||||
|
||||
love.graphics.draw(self.bgCanvas, 0, 0)
|
||||
love.graphics.setShader(prevShader)
|
||||
else
|
||||
renderTiles()
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy)
|
||||
function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY)
|
||||
local sx = flipX and -1 or 1
|
||||
local sy = flipY and -1 or 1
|
||||
for r = 0, 2 do
|
||||
for c = 0, 2 do
|
||||
local tileId = baseTile + r * 16 + c
|
||||
local q = self.oq[tileId]
|
||||
if q then
|
||||
if not flipX and not flipY then
|
||||
local dx = (cx - 12) + c * 8
|
||||
local dy = (cy - 12) + r * 8
|
||||
love.graphics.draw(self.ob, q, dx, dy)
|
||||
else
|
||||
local dx = (cx - 12) + (2 - c) * 8 + 8
|
||||
local dy = (cy - 12) + (2 - r) * 8 + 8
|
||||
love.graphics.draw(self.ob, q, dx, dy, 0, -1, -1)
|
||||
end
|
||||
local colIdx = flipX and (2 - c) or c
|
||||
local rowIdx = flipY and (2 - r) or r
|
||||
local dx = (cx - 12) + colIdx * 8 + (flipX and 8 or 0)
|
||||
local dy = (cy - 12) + rowIdx * 8 + (flipY and 8 or 0)
|
||||
love.graphics.draw(self.ob, q, dx, dy, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw HUD status bar
|
||||
-- Draw HUD status bar with inverted progress marker (moving right-to-left towards beach)
|
||||
function SurfingMinigame:drawHUD()
|
||||
-- White background in bottom 16 rows
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -931,9 +1186,14 @@ function SurfingMinigame:drawHUD()
|
||||
end
|
||||
|
||||
-- Mini-Pikachu progress marker (tile $fe)
|
||||
-- Game Boy: initial OAM X = $50 (80) = screen X 72, decrements by 2 per section.
|
||||
-- 24 sections × 2 = 48 px total travel, ending at screen X 24.
|
||||
-- We interpolate continuously across those same 48 pixels.
|
||||
local distPx = math.floor(self.distanceFixed / 256)
|
||||
local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128)))
|
||||
local markerX = 16 + math.floor(progressRatio * 80)
|
||||
local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset = screen X 72
|
||||
local markerTrack = 48 -- 24 sections × 2 px per section
|
||||
local markerX = markerStartX - math.floor(progressRatio * markerTrack)
|
||||
if self.ob and self.oq and self.oq[0xfe] then
|
||||
love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6)
|
||||
end
|
||||
@@ -956,46 +1216,100 @@ function SurfingMinigame:drawResultsOutro()
|
||||
for r = 1, 10 do
|
||||
for c = 1, 20 do
|
||||
local tId = BEACH_OUTRO[r][c]
|
||||
if tId and self.tq[tId] then
|
||||
if tId and self.tq and self.tq[tId] then
|
||||
love.graphics.draw(self.bg, self.tq[tId], (c - 1) * 8, (r + 5) * 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Textbox frame on rows 1..9 (X=8..152, Y=8..72)
|
||||
-- Fill interior with white
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 8, 8, 144, 64)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 8.5, 8.5, 143, 63)
|
||||
love.graphics.rectangle("fill", 16, 16, 128, 56)
|
||||
|
||||
-- Text lines
|
||||
-- Draw the textbox frame using original Game Boy border tiles (rows 1..9, Y=8..72)
|
||||
local function drawBoxRow(rowIdx, leftTile, interiorTile, rightTile)
|
||||
local y = rowIdx * 8
|
||||
if self.tq[leftTile] then
|
||||
love.graphics.draw(self.bg, self.tq[leftTile], 8, y)
|
||||
end
|
||||
if interiorTile and self.tq[interiorTile] then
|
||||
for colIdx = 2, 17 do
|
||||
love.graphics.draw(self.bg, self.tq[interiorTile], colIdx * 8, y)
|
||||
end
|
||||
end
|
||||
if self.tq[rightTile] then
|
||||
love.graphics.draw(self.bg, self.tq[rightTile], 144, y)
|
||||
end
|
||||
end
|
||||
|
||||
drawBoxRow(1, 0x3b, 0x40, 0x3c)
|
||||
for r = 2, 8 do
|
||||
drawBoxRow(r, 0x3f, nil, 0x3f)
|
||||
end
|
||||
drawBoxRow(9, 0x3d, 0x40, 0x3e)
|
||||
|
||||
-- Text lines matching Game Boy screen memory coordinates (cols 2, 10, 15; rows 2, 4, 6, 8)
|
||||
Font.draw(Strings("HP Left"), 16, 16)
|
||||
if self.routine >= ROUTINE_WRITE_HP_LEFT then
|
||||
Font.draw(string.format("%4d Pts", self.hp), 88, 16)
|
||||
Font.draw(string.format("%04d", self.hp), 80, 16)
|
||||
Font.draw(Strings("Pts"), 120, 16)
|
||||
end
|
||||
|
||||
if self.routine >= ROUTINE_WRITE_RADNESS then
|
||||
Font.draw(Strings("Radness"), 16, 32)
|
||||
Font.draw(string.format("%4d Pts", self.radness), 88, 32)
|
||||
Font.draw(string.format("%04d", self.radness), 80, 32)
|
||||
Font.draw(Strings("Pts"), 120, 32)
|
||||
end
|
||||
|
||||
if self.routine >= ROUTINE_WRITE_TOTAL then
|
||||
Font.draw(Strings("Total"), 16, 48)
|
||||
Font.draw(string.format("%4d Pts", self.totalScore), 88, 48)
|
||||
Font.draw(string.format("%04d", self.totalScore), 80, 48)
|
||||
Font.draw(Strings("Pts"), 120, 48)
|
||||
end
|
||||
|
||||
if self.routine >= ROUTINE_WAIT_LAST then
|
||||
if self.newRecord then
|
||||
Font.draw(Strings("Hi-Score!!"), 48, 60)
|
||||
Font.draw(Strings("Hi-Score!!"), 48, 64)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Draw minigame Title Screen ("Pikachu's Beach")
|
||||
function SurfingMinigame:drawTitleScreen()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
-- 1. Draw authentic 160x144 composite title background from ROM
|
||||
if self.titleBg then
|
||||
love.graphics.draw(self.titleBg, 0, 0)
|
||||
else
|
||||
Font.draw("PIKACHU'S BEACH", 20, 32)
|
||||
end
|
||||
|
||||
-- 2. Draw 3x3 Pikachu intro sprite on the water using authentic OAM tile indices
|
||||
if self.ob and self.oq then
|
||||
local animBase = (math.floor(self.t / 32) % 2 == 0) and 0x0c or 0x09
|
||||
self:draw3x3(animBase, 80, 100, false, false)
|
||||
end
|
||||
|
||||
-- 3. High score display at bottom
|
||||
Font.draw(string.format("Hi-Score %4d Pt", self.hiScore), 16, 120)
|
||||
|
||||
if math.floor(self.t / 30) % 2 == 0 then
|
||||
Font.draw("PRESS START", 36, 132)
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
if self.routine == ROUTINE_TITLE then
|
||||
self:drawTitleScreen()
|
||||
return
|
||||
end
|
||||
|
||||
if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then
|
||||
self:drawResultsOutro()
|
||||
return
|
||||
@@ -1004,10 +1318,11 @@ function SurfingMinigame:draw()
|
||||
-- Draw scrolling BG waves
|
||||
self:drawBackground()
|
||||
|
||||
-- Parallax clouds in sky
|
||||
-- Parallax clouds in sky (scrolling left at uniform 0.25x camera speed)
|
||||
local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256)
|
||||
local c1x = (32 - cloudOffsetPx * 2) % 200 - 40
|
||||
local c2x = (128 - cloudOffsetPx * 3) % 200 - 40
|
||||
local c1x = (160 - (cloudOffsetPx % 200)) - 40
|
||||
local c2x = (240 - (cloudOffsetPx % 200)) - 40
|
||||
if self.ob and self.oq then
|
||||
-- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef)
|
||||
for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do
|
||||
love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12)
|
||||
@@ -1016,10 +1331,29 @@ function SurfingMinigame:draw()
|
||||
for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do
|
||||
love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20)
|
||||
end
|
||||
end
|
||||
-- Helper function to draw OAM multi-sprite composite objects with palette and X-flipping
|
||||
function self:drawOAMSprites(sprites, ox, oy)
|
||||
if not (self.ob and self.oq) then return end
|
||||
love.graphics.setColor(0.5, 0.5, 0.5, 1) -- OAM_PAL1 maps shade 1 to shade 2 (Sea Blue)
|
||||
for _, sp in ipairs(sprites) do
|
||||
local q = self.oq[sp.tile]
|
||||
if q then
|
||||
local x = ox + sp.dx
|
||||
local y = oy + sp.dy
|
||||
if sp.xflip then
|
||||
love.graphics.draw(self.ob, q, x + 8, y, 0, -1, 1)
|
||||
else
|
||||
love.graphics.draw(self.ob, q, x, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Draw water spray sprites
|
||||
-- Draw water spray sprites on trailing edge of surfboard
|
||||
for _, s in ipairs(self.waterSprays) do
|
||||
love.graphics.draw(self.ob, self.oq[0xa7], s.x, s.y)
|
||||
self:drawOAMSprites(OAM_WATER_SPRAY, s.x, s.y)
|
||||
end
|
||||
|
||||
-- Draw Pikachu
|
||||
@@ -1028,11 +1362,19 @@ function SurfingMinigame:draw()
|
||||
self.pikaScreenY = cy
|
||||
|
||||
if self.pikaState == PIKA_STATE_CRASHED then
|
||||
-- Empty surfboard + splash animation
|
||||
-- Empty surfboard (3 tiles at bottom)
|
||||
if self.ob and self.oq then
|
||||
love.graphics.draw(self.ob, self.oq[0x98], cx - 12, cy + 4)
|
||||
love.graphics.draw(self.ob, self.oq[0x99], cx - 4, cy + 4)
|
||||
love.graphics.draw(self.ob, self.oq[0x9a], cx + 4, cy + 4)
|
||||
love.graphics.draw(self.ob, self.oq[0xa8], cx - 12, cy - 8)
|
||||
end
|
||||
-- Multi-tile splash animation (.SmallSplash then .LargeSplash)
|
||||
local elapsed = 60 - (self.crashTimer or 0)
|
||||
if elapsed < 16 then
|
||||
self:drawOAMSprites(OAM_SMALL_SPLASH, cx, cy + 4)
|
||||
else
|
||||
self:drawOAMSprites(OAM_LARGE_SPLASH, cx, cy + 4)
|
||||
end
|
||||
else
|
||||
local angleIdx = ((self.frameSet - 1) % 7) + 1
|
||||
local isFlipped = self.frameSet > 7
|
||||
@@ -1048,6 +1390,7 @@ function SurfingMinigame:draw()
|
||||
|
||||
-- "START" banner
|
||||
if self.routine == ROUTINE_START_GAME then
|
||||
if self.ob and self.oq then
|
||||
for r = 0, 1 do
|
||||
for c = 0, 5 do
|
||||
local tid = 0xe0 + r * 16 + c
|
||||
@@ -1055,9 +1398,11 @@ function SurfingMinigame:draw()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- "Oh no.." banner on Game Over
|
||||
if self.ohNoBanner then
|
||||
if self.ob and self.oq then
|
||||
for r = 0, 1 do
|
||||
for c = 0, 5 do
|
||||
local tid = 0xca + r * 16 + c
|
||||
@@ -1065,6 +1410,7 @@ function SurfingMinigame:draw()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw HUD (Progress track, HP digits)
|
||||
self:drawHUD()
|
||||
|
||||
+376
-45
@@ -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
|
||||
local won = CardFlip.payout(self.cursorX, self.cursorY, card)
|
||||
self.payoutLeft = won
|
||||
self.payoutTick = 0
|
||||
self.phase = "flipping"
|
||||
self.flipTimer = 0
|
||||
self.targetCard = card
|
||||
end
|
||||
|
||||
function CardFlip:updateFlipping()
|
||||
self.flipTimer = (self.flipTimer or 0) + 1
|
||||
if self.flipTimer == 4 then
|
||||
self:sfx(SFX_CHOOSE)
|
||||
self:tabulate()
|
||||
elseif self.flipTimer >= 12 then
|
||||
if (self.payoutLeft or 0) > 0 then
|
||||
self.phase = "payout"
|
||||
self.lines = CardFlip.TEXTS.yeah
|
||||
self:sfx(SFX_WIN)
|
||||
else
|
||||
self.phase = "result"
|
||||
self.lines = CardFlip.TEXTS.darn
|
||||
self:sfx(SFX_WRONG)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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.
|
||||
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
|
||||
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]
|
||||
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
|
||||
-- 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(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
|
||||
|
||||
function CardFlip:cursorCell()
|
||||
-- Draw discarded card blanking covers (each 2-tile wide stacked card cell is 16x12 px)
|
||||
for y = 2, 7 do
|
||||
local level = y - 2
|
||||
local pair = math.floor(level / 2)
|
||||
local isBottom = (level % 2 == 1)
|
||||
local py = 24 + pair * 24 + (isBottom and 12 or 0)
|
||||
for x = 2, 5 do
|
||||
local mon = x - 2
|
||||
local card = CardFlip.card(level, mon)
|
||||
if self.discarded[card] then
|
||||
-- Discarded cover over full 16x12 stacked card cell
|
||||
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
|
||||
G.rectangle("fill", MON_COL[x] * 8, py, 16, 12)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function CardFlip:cursorBounds()
|
||||
local x, y = self.cursorX, self.cursorY
|
||||
local 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
|
||||
|
||||
|
||||
+527
-72
@@ -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
|
||||
return building, building == SlotMachine.SEVEN
|
||||
end
|
||||
end
|
||||
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.
|
||||
@@ -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,40 +813,65 @@ 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.
|
||||
-- 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
|
||||
|
||||
function SlotMachine:reelStopped(i)
|
||||
if i < 3 then
|
||||
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
|
||||
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
|
||||
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16,
|
||||
firstTile = gfx.firstTile or 0 })
|
||||
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
|
||||
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
|
||||
for _, row in ipairs({ 2, 4, 6, 8, 10 }) do
|
||||
for _, col in ipairs(LIGHT_COLS) do
|
||||
Chrome.print(lit[row] and "*" or "-", col, row)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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