mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 05:00:43 +02:00
feat: surfing minigame overhaul + authentic Game Corner rendering
Surfing minigame: - Add title screen (ROUTINE_TITLE) with Pikachu intro, logo banner, instructions - Add GLSL HBlank wave distortion shader, OAM water spray/splash sprites - Add multi-path asset loader, isMinigame/isFixedSpeed flags, crash recovery fixes - Extract SurfingPikachu graphics + composite title_bg.png from ROM Game Corner (built visual layer from ROM assets; logic existed, rendering was placeholder): - SlotMachine: GBC tilemap background, authentic reel symbol sprites, Golem/Chansey near-miss animations, lit/unlit lights, payout panel - CardFlip: GBC tilemap board, hardware-accurate card flip sequence, OAM cursor frame - RomExtractorGen2: extract slots + card_flip sprite sheets and tilemaps Tests: SurfingMinigame units 8-13; 500-spin SlotMachine and 500-hand CardFlip stress tests
This commit is contained in:
@@ -326,6 +326,9 @@ function Game:logicSpeed()
|
|||||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||||
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
||||||
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
||||||
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
|
|||||||
return "menu"
|
return "menu"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game.isFixedSpeedInStack(stack)
|
||||||
|
local states = stack and stack.states
|
||||||
|
for i = #(states or {}), 1, -1 do
|
||||||
|
local state = states[i]
|
||||||
|
if state and (state.isFixedSpeed or state.isMinigame) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- Whether a state on the stack composes its own screen and so wants the
|
-- Whether a state on the stack composes its own screen and so wants the
|
||||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||||
-- everything else here: the text box and YES/NO a battle puts up are states
|
-- everything else here: the text box and YES/NO a battle puts up are states
|
||||||
|
|||||||
+1
-1
@@ -230,7 +230,7 @@ end
|
|||||||
|
|
||||||
function Music.play(data, song, loop, ctx)
|
function Music.play(data, song, loop, ctx)
|
||||||
if not song then return end
|
if not song then return end
|
||||||
if not love.audio then return end -- headless test stub
|
if not (love and love.audio) then return end -- headless test stub
|
||||||
ctx = ctx or {}
|
ctx = ctx or {}
|
||||||
song = selectSong(song, ctx)
|
song = selectSong(song, ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -5136,6 +5136,82 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
end
|
end
|
||||||
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
||||||
|
|
||||||
|
-- Goldenrod Game Corner: Slot Machine graphics assets
|
||||||
|
if self.symbols["Slots1LZ"] then
|
||||||
|
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||||||
|
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
|
||||||
|
end
|
||||||
|
if self.symbols["Slots2LZ"] then
|
||||||
|
local raw2 = self:decompressLz3Symbol("Slots2LZ")
|
||||||
|
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
|
||||||
|
for i = 1, math.min(64, #raw2) do
|
||||||
|
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
|
||||||
|
end
|
||||||
|
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
|
||||||
|
end
|
||||||
|
if self.symbols["Slots3LZ"] then
|
||||||
|
local raw3 = self:decompressLz3Symbol("Slots3LZ")
|
||||||
|
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
|
||||||
|
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
|
||||||
|
-- Y=0: Golem 1 (Standing, 24x32)
|
||||||
|
-- Y=32: Golem 2 (Ball, 24x32)
|
||||||
|
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
|
||||||
|
-- Y=96: Chansey 2 (Step 2, 24x32)
|
||||||
|
-- Y=128: Chansey 3 (Step 3, 24x32)
|
||||||
|
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
|
||||||
|
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
|
||||||
|
-- Y=224: Egg (8x16 at X=0)
|
||||||
|
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
|
||||||
|
end
|
||||||
|
if self.symbols["SlotsTilemap"] then
|
||||||
|
local symbol = self:symbol("SlotsTilemap")
|
||||||
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
|
||||||
|
self:save(tm, "slots/gold_slots.tilemap")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Goldenrod Game Corner: Card Flip graphics assets
|
||||||
|
if self.symbols["CardFlipLZ01"] then
|
||||||
|
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
|
||||||
|
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipLZ02"] then
|
||||||
|
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
|
||||||
|
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipLZ03"] then
|
||||||
|
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
|
||||||
|
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipOnButtonGFX"] then
|
||||||
|
local symbol = self:symbol("CardFlipOnButtonGFX")
|
||||||
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipOffButtonGFX"] then
|
||||||
|
local symbol = self:symbol("CardFlipOffButtonGFX")
|
||||||
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipTilemap"] then
|
||||||
|
local symbol = self:symbol("CardFlipTilemap")
|
||||||
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
|
||||||
|
self:save(tm, "card_flip/card_flip.tilemap")
|
||||||
|
end
|
||||||
|
|
||||||
|
out.slots = {
|
||||||
|
sheet1 = "assets/generated/slots/gold_slots_1.png",
|
||||||
|
sheet2 = "assets/generated/slots/gold_slots_2.png",
|
||||||
|
sheet3 = "assets/generated/slots/gold_slots_3.png",
|
||||||
|
tilemap = "assets/generated/slots/gold_slots.tilemap",
|
||||||
|
}
|
||||||
|
|
||||||
|
out.cardFlip = {
|
||||||
|
sheet1 = "assets/generated/card_flip/card_flip_1.png",
|
||||||
|
sheet2 = "assets/generated/card_flip/card_flip_2.png",
|
||||||
|
sheet3 = "assets/generated/card_flip/card_flip_3.png",
|
||||||
|
on = "assets/generated/card_flip/on.png",
|
||||||
|
off = "assets/generated/card_flip/off.png",
|
||||||
|
tilemap = "assets/generated/card_flip/card_flip.tilemap",
|
||||||
|
}
|
||||||
|
|
||||||
self:write("menu_gfx", out)
|
self:write("menu_gfx", out)
|
||||||
self:tick("Menu graphics", 1, 1)
|
self:tick("Menu graphics", 1, 1)
|
||||||
return out
|
return out
|
||||||
|
|||||||
+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)
|
local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144)
|
||||||
|
|
||||||
-- Routine numbers (wSurfingMinigameRoutineNumber)
|
-- Routine numbers (wSurfingMinigameRoutineNumber)
|
||||||
|
local ROUTINE_TITLE = -1
|
||||||
local ROUTINE_START_GAME = 0
|
local ROUTINE_START_GAME = 0
|
||||||
local ROUTINE_RUN_GAME = 1
|
local ROUTINE_RUN_GAME = 1
|
||||||
local ROUTINE_WAIT_RESULTS = 2
|
local ROUTINE_WAIT_RESULTS = 2
|
||||||
@@ -216,7 +217,9 @@ local WAVE_STEPS = {
|
|||||||
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
|
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
|
||||||
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET },
|
[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.BG_METATILES = BG_METATILES
|
||||||
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
|
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
|
||||||
@@ -233,6 +236,40 @@ local ANGLE_BASES = {
|
|||||||
[7] = { 0x33, 0x69 }, -- Angle 06 (nose down steep / frontflip apex)
|
[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)
|
-- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10)
|
||||||
local BEACH_OUTRO = {
|
local BEACH_OUTRO = {
|
||||||
{ 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
|
{ 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)
|
-- 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 }
|
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)
|
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.pikaState = PIKA_STATE_RIDING
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.routineTimer = 0
|
self.routineTimer = 0
|
||||||
@@ -269,9 +306,11 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
self.hp = 6000 -- starts at 6000 (60.00 seconds)
|
self.hp = 6000 -- starts at 6000 (60.00 seconds)
|
||||||
self.radness = 0 -- accumulated trick stunt points
|
self.radness = 0 -- accumulated trick stunt points
|
||||||
self.totalScore = 0 -- tallied total score
|
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.newRecord = false
|
||||||
self.currentPitch = 1.0
|
self.currentPitch = 1.0
|
||||||
|
self.isMinigame = true
|
||||||
|
self.isFixedSpeed = true
|
||||||
|
|
||||||
-- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV)
|
-- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV)
|
||||||
self.rDiv = 0
|
self.rDiv = 0
|
||||||
@@ -297,6 +336,7 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
self.boardAngleDecreasing = false
|
self.boardAngleDecreasing = false
|
||||||
self.boardAngleTimer = 0
|
self.boardAngleTimer = 0
|
||||||
self.crashTimer = 0
|
self.crashTimer = 0
|
||||||
|
self.landingTimer = 0
|
||||||
|
|
||||||
-- 3-frame buffered D-Pad rotation & input accumulator
|
-- 3-frame buffered D-Pad rotation & input accumulator
|
||||||
self.joyCounter = 0
|
self.joyCounter = 0
|
||||||
@@ -318,14 +358,28 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
self.tallyStep = 0
|
self.tallyStep = 0
|
||||||
self.tallyTimer = 0
|
self.tallyTimer = 0
|
||||||
|
|
||||||
-- Load sheets safely
|
-- Load sheets safely across all GameVersion prefix paths
|
||||||
local function sheet(path)
|
local function sheet(path)
|
||||||
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
local GameVersion = require("src.core.GameVersion")
|
||||||
return ok and img or nil
|
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
|
end
|
||||||
self.bg = sheet("assets/generated/minigame/surf_1a.png")
|
self.bg = sheet("assets/generated/minigame/surf_1a.png")
|
||||||
self.ob = sheet("assets/generated/minigame/surf_1b.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
|
if self.bg then
|
||||||
self.tq = {}
|
self.tq = {}
|
||||||
@@ -343,32 +397,97 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
end
|
end
|
||||||
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
|
return self
|
||||||
end
|
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()
|
function SurfingMinigame:getGBRandom()
|
||||||
self.rDiv = (self.rDiv + 7 + (self.inputAccum or 0)) % 256
|
self.rDiv = (self.rDiv + 13) % 256
|
||||||
self.rAdd = (self.rAdd + self.rDiv) % 256
|
local tempAdd = self.rAdd + self.rDiv
|
||||||
self.rSub = (self.rSub - self.rDiv + 256) % 256
|
self.rAdd = tempAdd % 256
|
||||||
return (self.rAdd + self.rSub) % 256
|
local tempSub = self.rSub - self.rDiv
|
||||||
|
self.rSub = (tempSub + 256) % 256
|
||||||
|
return self.rAdd
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:chooseSequence()
|
function SurfingMinigame:chooseSequence()
|
||||||
local distPx = math.floor(self.distanceFixed / 256)
|
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
|
self.waveFn = 0x6a
|
||||||
else
|
elseif section < 0x16 then
|
||||||
local r = self:getGBRandom()
|
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
|
end
|
||||||
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
|
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:pushColumn()
|
function SurfingMinigame:pushColumn()
|
||||||
local pat, hl, hr
|
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()
|
pat, hl, hr = self:chooseSequence()
|
||||||
else
|
else
|
||||||
local step = WAVE_STEPS[self.waveFn]
|
local step = WAVE_STEPS[self.waveFn]
|
||||||
@@ -396,30 +515,40 @@ function SurfingMinigame:seaY(x)
|
|||||||
local distPx = math.floor(self.distanceFixed / 256)
|
local distPx = math.floor(self.distanceFixed / 256)
|
||||||
local tile = math.floor((distPx + x) / 8)
|
local tile = math.floor((distPx + x) / 8)
|
||||||
local col = self.cols[math.floor(tile / 2)]
|
local col = self.cols[math.floor(tile / 2)]
|
||||||
if not col then return FLAT_WATER_Y - 16 end
|
if not col then return FLAT_WATER_Y end
|
||||||
return (tile % 2 == 0 and col.hl or col.hr) - 16
|
return tile % 2 == 0 and col.hl or col.hr
|
||||||
end
|
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()
|
function SurfingMinigame:getWaveTileUnderPika()
|
||||||
local distPx = math.floor(self.distanceFixed / 256)
|
local distPx = math.floor(self.distanceFixed / 256)
|
||||||
local tile = math.floor((distPx + 80) / 8)
|
local tile_col = math.floor((distPx + 72) / 8)
|
||||||
local col = self.cols[math.floor(tile / 2)]
|
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
|
if not col or not col.pat then return 0x01 end
|
||||||
local pat = col.pat
|
|
||||||
for i = 4, 8 do
|
local i = math.floor(tile_row / 2) + 1
|
||||||
local mt = pat[i]
|
if i < 1 or i > 8 then return 0x01 end
|
||||||
if mt then
|
local mt = BG_METATILES[col.pat[i]]
|
||||||
if mt == 0x02 or mt == 0x06 or mt == 0x0a or mt == 0x11 then
|
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
|
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
|
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
|
return 0x14 -- wave crest / face
|
||||||
end
|
end
|
||||||
end
|
return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.)
|
||||||
end
|
|
||||||
return 0x01 -- flat open water
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:spawnTrickPopup(text)
|
function SurfingMinigame:spawnTrickPopup(text)
|
||||||
@@ -497,21 +626,51 @@ function SurfingMinigame:evaluateLanding()
|
|||||||
end
|
end
|
||||||
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()
|
function SurfingMinigame:updateRiding()
|
||||||
-- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0)
|
-- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0)
|
||||||
if self.speedFixed < SPEED_MAX then
|
if self.speedFixed < SPEED_MAX then
|
||||||
self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL)
|
self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL)
|
||||||
end
|
end
|
||||||
|
self:updateTempo()
|
||||||
|
|
||||||
-- Follow wave surface height
|
-- Follow wave surface height
|
||||||
local targetY = self:seaY(80)
|
local targetY = self:seaY(80)
|
||||||
self.pikaY = math.floor(targetY)
|
self.pikaY = math.floor(targetY) - 16
|
||||||
self.pikaSubY = 0
|
self.pikaSubY = 0
|
||||||
|
|
||||||
-- Water spray every 4 frames
|
-- Water spray every 4 frames
|
||||||
self.sprayTimer = self.sprayTimer + 1
|
self.sprayTimer = self.sprayTimer + 1
|
||||||
if self.sprayTimer % 4 == 0 then
|
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
|
end
|
||||||
|
|
||||||
-- Board angle wobbling every 8 frames
|
-- Board angle wobbling every 8 frames
|
||||||
@@ -532,14 +691,14 @@ function SurfingMinigame:updateRiding()
|
|||||||
end
|
end
|
||||||
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()
|
local tile = self:getWaveTileUnderPika()
|
||||||
if tile == 0x06 or tile == 0x14 then
|
if tile == 0x06 or tile == 0x14 then
|
||||||
self.frameSet = 6 + (self.boardAngleOffset - 1)
|
self.frameSet = 6 + (self.boardAngleOffset - 1)
|
||||||
elseif tile == 0x07 then
|
elseif tile == 0x07 then
|
||||||
self.frameSet = 2 + (self.boardAngleOffset - 1)
|
self.frameSet = 2 + (self.boardAngleOffset - 1)
|
||||||
else
|
else
|
||||||
self.frameSet = 4 + (self.boardAngleOffset - 1)
|
self.frameSet = 4 -- flat open water: steady horizontal ride
|
||||||
end
|
end
|
||||||
self.frameSet = math.max(1, math.min(14, self.frameSet))
|
self.frameSet = math.max(1, math.min(14, self.frameSet))
|
||||||
|
|
||||||
@@ -560,43 +719,71 @@ function SurfingMinigame:updateRiding()
|
|||||||
end
|
end
|
||||||
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()
|
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
|
self.joyCounter = (self.joyCounter + 1) % 3
|
||||||
if self.joyCounter == 0 then
|
if self.joyCounter == 0 then
|
||||||
local rightHeld = bit.band(self.inputAccum, 1) ~= 0
|
local rightHeld = bit.band(self.inputAccum, 1) ~= 0
|
||||||
local leftHeld = bit.band(self.inputAccum, 2) ~= 0
|
local leftHeld = bit.band(self.inputAccum, 2) ~= 0
|
||||||
self.inputAccum = 0
|
self.inputAccum = 0
|
||||||
|
|
||||||
if rightHeld then
|
-- Game Boy priority: Left D-pad checked first, then Right D-pad
|
||||||
self.rotCountLeft = 0
|
if leftHeld then
|
||||||
self.rotCountRight = self.rotCountRight + 1
|
|
||||||
if self.rotCountRight >= 11 then
|
|
||||||
self.rotCountRight = 0
|
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.radnessMeter = math.min(3, self.radnessMeter + 1)
|
||||||
self.trickFlags = bit.bor(self.trickFlags, 1)
|
self.trickFlags = bit.bor(self.trickFlags, 1)
|
||||||
Sound.play(self.game.data, "Tink")
|
Sound.play(self.game.data, "Tink")
|
||||||
end
|
end
|
||||||
-- Increment frame set (frontflip forward)
|
|
||||||
self.frameSet = (self.frameSet % 14) + 1
|
self.frameSet = (self.frameSet % 14) + 1
|
||||||
elseif leftHeld then
|
elseif rightHeld then
|
||||||
self.rotCountRight = 0
|
|
||||||
self.rotCountLeft = self.rotCountLeft + 1
|
|
||||||
if self.rotCountLeft >= 13 then
|
|
||||||
self.rotCountLeft = 0
|
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.radnessMeter = math.min(3, self.radnessMeter + 1)
|
||||||
self.trickFlags = bit.bor(self.trickFlags, 2)
|
self.trickFlags = bit.bor(self.trickFlags, 2)
|
||||||
Sound.play(self.game.data, "Tink")
|
Sound.play(self.game.data, "Tink")
|
||||||
end
|
end
|
||||||
-- Decrement frame set (backflip backward)
|
|
||||||
self.frameSet = self.frameSet - 1
|
self.frameSet = self.frameSet - 1
|
||||||
if self.frameSet < 1 then self.frameSet = 14 end
|
if self.frameSet < 1 then self.frameSet = 14 end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
self.inputAccum = 0
|
||||||
|
end
|
||||||
|
|
||||||
-- Authentic Game Boy collision boundary & integer fixed-point jump physics
|
-- Authentic Game Boy collision boundary & integer fixed-point jump physics
|
||||||
if not self.jumpDescending then
|
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)
|
local intDelta = math.floor(self.pikaSubY / 256)
|
||||||
self.pikaSubY = self.pikaSubY % 256
|
self.pikaSubY = self.pikaSubY % 256
|
||||||
self.pikaY = self.pikaY - intDelta
|
self.pikaY = self.pikaY - intDelta
|
||||||
@@ -608,34 +795,16 @@ function SurfingMinigame:updateJumping()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- Hardware execution order: evaluate boundary before adding velocity
|
-- 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
|
if self.pikaY >= waveY then
|
||||||
self.pikaY = waveY
|
self.pikaY = waveY
|
||||||
self.pikaSubY = 0
|
self.pikaSubY = 0
|
||||||
-- Evaluate landing angle vs wave slope
|
self: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
|
|
||||||
self:calculateStuntPoints()
|
|
||||||
self.pikaState = PIKA_STATE_LANDING
|
|
||||||
self.routineTimer = 32
|
|
||||||
self.frameSet = 4
|
|
||||||
Sound.play(self.game.data, "Cut")
|
|
||||||
end
|
|
||||||
return
|
return
|
||||||
end
|
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)
|
local intDelta = math.floor(self.pikaSubY / 256)
|
||||||
self.pikaSubY = self.pikaSubY % 256
|
self.pikaSubY = self.pikaSubY % 256
|
||||||
self.pikaY = self.pikaY + intDelta
|
self.pikaY = self.pikaY + intDelta
|
||||||
@@ -644,57 +813,66 @@ function SurfingMinigame:updateJumping()
|
|||||||
if self.pikaY >= waveY then
|
if self.pikaY >= waveY then
|
||||||
self.pikaY = waveY
|
self.pikaY = waveY
|
||||||
self.pikaSubY = 0
|
self.pikaSubY = 0
|
||||||
local result = self:evaluateLanding()
|
self:handleLanding()
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:updateLanding()
|
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
|
-- Sine wave splash offset
|
||||||
self.pikaYOffset = math.floor(math.sin((32 - self.routineTimer) / 32 * math.pi * 2) * 4)
|
self.pikaYOffset = math.floor(math.sin((32 - math.max(0, self.landingTimer)) / 32 * math.pi * 2) * 4)
|
||||||
if self.routineTimer % 4 == 0 then
|
if self.landingTimer % 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
|
end
|
||||||
if self.routineTimer <= 0 then
|
|
||||||
|
if self.landingTimer <= 0 then
|
||||||
self.pikaYOffset = 0
|
self.pikaYOffset = 0
|
||||||
self.pikaState = PIKA_STATE_RIDING
|
self.pikaState = PIKA_STATE_RIDING
|
||||||
|
self.frameSet = 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:updateCrashed()
|
function SurfingMinigame:updateCrashed()
|
||||||
self.crashTimer = self.crashTimer - 1
|
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
|
if self.crashTimer <= 0 then
|
||||||
self.pikaState = PIKA_STATE_RIDING
|
self.pikaState = PIKA_STATE_RIDING
|
||||||
self.frameSet = 4
|
self.frameSet = 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:update()
|
-- Single Game Boy hardware VBlank cycle tick (59.7275 Hz)
|
||||||
local input = self.game.input
|
function SurfingMinigame:tick()
|
||||||
|
local input = self.game and self.game.input
|
||||||
self.t = self.t + 1
|
self.t = self.t + 1
|
||||||
|
self.rDiv = (self.rDiv + 1) % 256
|
||||||
|
|
||||||
-- Accumulate physical button presses on every frame (eliminates polling blind spot)
|
-- Title Screen State
|
||||||
if input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end
|
if self.routine == ROUTINE_TITLE then
|
||||||
if input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end
|
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
|
-- Update trick popups
|
||||||
for i = #self.trickPopups, 1, -1 do
|
for i = #self.trickPopups, 1, -1 do
|
||||||
@@ -728,7 +906,7 @@ function SurfingMinigame:update()
|
|||||||
self.routineTimer = 128
|
self.routineTimer = 128
|
||||||
self.speedFixed = 0
|
self.speedFixed = 0
|
||||||
self.ohNoBanner = true
|
self.ohNoBanner = true
|
||||||
Sound.play(self.game.data, "Faint_Fall")
|
Sound.play(self.game and self.game.data, "Faint_Fall")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -758,20 +936,44 @@ function SurfingMinigame:update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_WAIT_RESULTS then
|
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.distanceFixed = self.distanceFixed + (2 * 256)
|
||||||
|
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25)
|
||||||
self:generateAhead()
|
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
|
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.routine = ROUTINE_SCROLL_RESULTS
|
||||||
self.routineTimer = 36
|
self.routineTimer = 36
|
||||||
|
self.pikaState = PIKA_STATE_GAME_END
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_SCROLL_RESULTS then
|
elseif self.routine == ROUTINE_SCROLL_RESULTS then
|
||||||
self.distanceFixed = self.distanceFixed + (1 * 256)
|
self.distanceFixed = self.distanceFixed + (1 * 256)
|
||||||
|
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((1 * 256) * 0.25)
|
||||||
self:generateAhead()
|
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
|
self.routineTimer = self.routineTimer - 1
|
||||||
if self.routineTimer <= 0 then
|
if self.routineTimer <= 0 then
|
||||||
self.routine = ROUTINE_DRAW_RESULTS
|
self.routine = ROUTINE_DRAW_RESULTS
|
||||||
@@ -813,7 +1015,7 @@ function SurfingMinigame:update()
|
|||||||
local step = math.min(self.hp, 99)
|
local step = math.min(self.hp, 99)
|
||||||
self.hp = self.hp - step
|
self.hp = self.hp - step
|
||||||
self.totalScore = self.totalScore + step
|
self.totalScore = self.totalScore + step
|
||||||
Sound.play(self.game.data, "Press_AB")
|
Sound.play(self.game and self.game.data, "Press_AB")
|
||||||
else
|
else
|
||||||
self.routine = ROUTINE_ADD_RAD_TOTAL
|
self.routine = ROUTINE_ADD_RAD_TOTAL
|
||||||
end
|
end
|
||||||
@@ -824,18 +1026,18 @@ function SurfingMinigame:update()
|
|||||||
local step = math.min(self.radness, 99)
|
local step = math.min(self.radness, 99)
|
||||||
self.radness = self.radness - step
|
self.radness = self.radness - step
|
||||||
self.totalScore = self.totalScore + step
|
self.totalScore = self.totalScore + step
|
||||||
Sound.play(self.game.data, "Press_AB")
|
Sound.play(self.game and self.game.data, "Press_AB")
|
||||||
else
|
else
|
||||||
self.routine = ROUTINE_WAIT_LAST
|
self.routine = ROUTINE_WAIT_LAST
|
||||||
self.routineTimer = 64
|
self.routineTimer = 64
|
||||||
-- High score check
|
-- 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
|
if self.newRecord then
|
||||||
self.game.save.surfingHighScore = self.totalScore
|
if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end
|
||||||
Sound.play(self.game.data, "Get_Item1")
|
Sound.play(self.game and self.game.data, "Get_Item1")
|
||||||
Sound.playPikaCry(self.game.data, 34)
|
Sound.playPikaCry(self.game and self.game.data, 34)
|
||||||
else
|
else
|
||||||
Sound.playPikaCry(self.game.data, 28)
|
Sound.playPikaCry(self.game and self.game.data, 28)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -846,24 +1048,48 @@ function SurfingMinigame:update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then
|
elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then
|
||||||
if input:wasPressed("a") or input:wasPressed("b") then
|
if input and input:wasPressed("a") then
|
||||||
self.game.stack:pop()
|
if self.game and self.game.stack then self.game.stack:pop() end
|
||||||
if self.onDone then self.onDone(self.totalScore) end
|
if self.onDone then self.onDone(self.totalScore) end
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_GAME_OVER then
|
elseif self.routine == ROUTINE_GAME_OVER then
|
||||||
self.routineTimer = self.routineTimer - 1
|
self.routineTimer = self.routineTimer - 1
|
||||||
if self.routineTimer <= 0 and (input:wasPressed("a") or input:wasPressed("b")) then
|
if self.routineTimer <= 0 and input and input:wasPressed("a") then
|
||||||
self.game.stack:pop()
|
if self.game and self.game.stack then self.game.stack:pop() end
|
||||||
if self.onDone then self.onDone(0) end
|
if self.onDone then self.onDone(0) end
|
||||||
end
|
end
|
||||||
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()
|
function SurfingMinigame:drawBackground()
|
||||||
local scx = math.floor(self.distanceFixed / 256)
|
local scx = math.floor(self.distanceFixed / 256)
|
||||||
local first = math.floor(scx / 16)
|
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
|
for c = first, first + 10 do
|
||||||
local col = self.cols[c]
|
local col = self.cols[c]
|
||||||
if col then
|
if col then
|
||||||
@@ -882,28 +1108,57 @@ function SurfingMinigame:drawBackground()
|
|||||||
end
|
end
|
||||||
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)
|
-- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy)
|
||||||
function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY)
|
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 r = 0, 2 do
|
||||||
for c = 0, 2 do
|
for c = 0, 2 do
|
||||||
local tileId = baseTile + r * 16 + c
|
local tileId = baseTile + r * 16 + c
|
||||||
local q = self.oq[tileId]
|
local q = self.oq[tileId]
|
||||||
if q then
|
if q then
|
||||||
if not flipX and not flipY then
|
local colIdx = flipX and (2 - c) or c
|
||||||
local dx = (cx - 12) + c * 8
|
local rowIdx = flipY and (2 - r) or r
|
||||||
local dy = (cy - 12) + r * 8
|
local dx = (cx - 12) + colIdx * 8 + (flipX and 8 or 0)
|
||||||
love.graphics.draw(self.ob, q, dx, dy)
|
local dy = (cy - 12) + rowIdx * 8 + (flipY and 8 or 0)
|
||||||
else
|
love.graphics.draw(self.ob, q, dx, dy, 0, sx, sy)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
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()
|
function SurfingMinigame:drawHUD()
|
||||||
-- White background in bottom 16 rows
|
-- White background in bottom 16 rows
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
@@ -931,9 +1186,14 @@ function SurfingMinigame:drawHUD()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Mini-Pikachu progress marker (tile $fe)
|
-- 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 distPx = math.floor(self.distanceFixed / 256)
|
||||||
local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128)))
|
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
|
if self.ob and self.oq and self.oq[0xfe] then
|
||||||
love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6)
|
love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6)
|
||||||
end
|
end
|
||||||
@@ -956,46 +1216,100 @@ function SurfingMinigame:drawResultsOutro()
|
|||||||
for r = 1, 10 do
|
for r = 1, 10 do
|
||||||
for c = 1, 20 do
|
for c = 1, 20 do
|
||||||
local tId = BEACH_OUTRO[r][c]
|
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)
|
love.graphics.draw(self.bg, self.tq[tId], (c - 1) * 8, (r + 5) * 8)
|
||||||
end
|
end
|
||||||
end
|
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.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 8, 8, 144, 64)
|
love.graphics.rectangle("fill", 16, 16, 128, 56)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
love.graphics.rectangle("line", 8.5, 8.5, 143, 63)
|
|
||||||
|
|
||||||
-- 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)
|
Font.draw(Strings("HP Left"), 16, 16)
|
||||||
if self.routine >= ROUTINE_WRITE_HP_LEFT then
|
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
|
end
|
||||||
|
|
||||||
if self.routine >= ROUTINE_WRITE_RADNESS then
|
if self.routine >= ROUTINE_WRITE_RADNESS then
|
||||||
Font.draw(Strings("Radness"), 16, 32)
|
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
|
end
|
||||||
|
|
||||||
if self.routine >= ROUTINE_WRITE_TOTAL then
|
if self.routine >= ROUTINE_WRITE_TOTAL then
|
||||||
Font.draw(Strings("Total"), 16, 48)
|
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
|
end
|
||||||
|
|
||||||
if self.routine >= ROUTINE_WAIT_LAST then
|
if self.routine >= ROUTINE_WAIT_LAST then
|
||||||
if self.newRecord then
|
if self.newRecord then
|
||||||
Font.draw(Strings("Hi-Score!!"), 48, 60)
|
Font.draw(Strings("Hi-Score!!"), 48, 64)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
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()
|
function SurfingMinigame:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
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
|
if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then
|
||||||
self:drawResultsOutro()
|
self:drawResultsOutro()
|
||||||
return
|
return
|
||||||
@@ -1004,10 +1318,11 @@ function SurfingMinigame:draw()
|
|||||||
-- Draw scrolling BG waves
|
-- Draw scrolling BG waves
|
||||||
self:drawBackground()
|
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 cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256)
|
||||||
local c1x = (32 - cloudOffsetPx * 2) % 200 - 40
|
local c1x = (160 - (cloudOffsetPx % 200)) - 40
|
||||||
local c2x = (128 - cloudOffsetPx * 3) % 200 - 40
|
local c2x = (240 - (cloudOffsetPx % 200)) - 40
|
||||||
|
if self.ob and self.oq then
|
||||||
-- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef)
|
-- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef)
|
||||||
for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do
|
for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do
|
||||||
love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12)
|
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
|
for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do
|
||||||
love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20)
|
love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20)
|
||||||
end
|
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
|
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
|
end
|
||||||
|
|
||||||
-- Draw Pikachu
|
-- Draw Pikachu
|
||||||
@@ -1028,11 +1362,19 @@ function SurfingMinigame:draw()
|
|||||||
self.pikaScreenY = cy
|
self.pikaScreenY = cy
|
||||||
|
|
||||||
if self.pikaState == PIKA_STATE_CRASHED then
|
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[0x98], cx - 12, cy + 4)
|
||||||
love.graphics.draw(self.ob, self.oq[0x99], cx - 4, 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[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
|
else
|
||||||
local angleIdx = ((self.frameSet - 1) % 7) + 1
|
local angleIdx = ((self.frameSet - 1) % 7) + 1
|
||||||
local isFlipped = self.frameSet > 7
|
local isFlipped = self.frameSet > 7
|
||||||
@@ -1048,6 +1390,7 @@ function SurfingMinigame:draw()
|
|||||||
|
|
||||||
-- "START" banner
|
-- "START" banner
|
||||||
if self.routine == ROUTINE_START_GAME then
|
if self.routine == ROUTINE_START_GAME then
|
||||||
|
if self.ob and self.oq then
|
||||||
for r = 0, 1 do
|
for r = 0, 1 do
|
||||||
for c = 0, 5 do
|
for c = 0, 5 do
|
||||||
local tid = 0xe0 + r * 16 + c
|
local tid = 0xe0 + r * 16 + c
|
||||||
@@ -1055,9 +1398,11 @@ function SurfingMinigame:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- "Oh no.." banner on Game Over
|
-- "Oh no.." banner on Game Over
|
||||||
if self.ohNoBanner then
|
if self.ohNoBanner then
|
||||||
|
if self.ob and self.oq then
|
||||||
for r = 0, 1 do
|
for r = 0, 1 do
|
||||||
for c = 0, 5 do
|
for c = 0, 5 do
|
||||||
local tid = 0xca + r * 16 + c
|
local tid = 0xca + r * 16 + c
|
||||||
@@ -1065,6 +1410,7 @@ function SurfingMinigame:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Draw HUD (Progress track, HP digits)
|
-- Draw HUD (Progress track, HP digits)
|
||||||
self:drawHUD()
|
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
|
-- data/text/common_3.asm; none of these are in the cache's text.lua, because no
|
||||||
-- script bytecode the extractor walks points at them.
|
-- script bytecode the extractor walks points at them.
|
||||||
CardFlip.TEXTS = {
|
CardFlip.TEXTS = {
|
||||||
playWithThree = { "Play with three", "coins?" },
|
playWithThree = { "Play with", "3 coins?" },
|
||||||
notEnough = { "Not enough coins…" },
|
notEnough = { "Not enough", "coins." },
|
||||||
chooseACard = { "Choose a card." },
|
chooseACard = { "Choose a", "card." },
|
||||||
placeYourBet = { "Place your bet." },
|
placeYourBet = { "Place", "your bet" },
|
||||||
playAgain = { "Want to play", "again?" },
|
playAgain = { "Play", "again?" },
|
||||||
shuffled = { "The cards have", "been shuffled." },
|
shuffled = { "The cards", "shuffled." },
|
||||||
yeah = { "Yeah!" },
|
yeah = { "Yeah!" },
|
||||||
darn = { "Darn…" },
|
darn = { "Darn…" },
|
||||||
}
|
}
|
||||||
@@ -401,14 +401,34 @@ function CardFlip:enterBet()
|
|||||||
self.lines = CardFlip.TEXTS.placeYourBet
|
self.lines = CardFlip.TEXTS.placeYourBet
|
||||||
end
|
end
|
||||||
|
|
||||||
-- .CheckTheCard: the dealt card is turned face up and marked on the discard
|
-- .CheckTheCard: trigger hardware-accurate discrete tile flip sequence
|
||||||
-- pile, which is what blanks its cell on the odds board.
|
|
||||||
function CardFlip:flip()
|
function CardFlip:flip()
|
||||||
local card = CardFlip.dealt(self.deck, self.played, self.which)
|
local card = CardFlip.dealt(self.deck, self.played, self.which)
|
||||||
self.faceUp = card
|
self.faceUp = card
|
||||||
self.discarded[card] = true
|
self.discarded[card] = true
|
||||||
|
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: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
|
end
|
||||||
|
|
||||||
function CardFlip:tabulate()
|
function CardFlip:tabulate()
|
||||||
@@ -472,7 +492,21 @@ function CardFlip:quit()
|
|||||||
if self.onClose then self.onClose() end
|
if self.onClose then self.onClose() end
|
||||||
end
|
end
|
||||||
|
|
||||||
function CardFlip:update(_dt)
|
function CardFlip:update(dt)
|
||||||
|
-- Support both fixed-tick 60Hz loop and variable dt accumulator
|
||||||
|
if dt and dt > 0 then
|
||||||
|
self.dtAccum = (self.dtAccum or 0) + dt
|
||||||
|
local TICK = 1 / 60
|
||||||
|
while self.dtAccum >= TICK do
|
||||||
|
self.dtAccum = self.dtAccum - TICK
|
||||||
|
self:tick()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self:tick()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function CardFlip:tick()
|
||||||
local input = self.game and self.game.input
|
local input = self.game and self.game.input
|
||||||
if not input then return end
|
if not input then return end
|
||||||
local phase = self.phase
|
local phase = self.phase
|
||||||
@@ -539,6 +573,11 @@ function CardFlip:update(_dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if phase == "flipping" then
|
||||||
|
self:updateFlipping()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if phase == "payout" then
|
if phase == "payout" then
|
||||||
self:updatePayout()
|
self:updatePayout()
|
||||||
return
|
return
|
||||||
@@ -552,79 +591,370 @@ function CardFlip:update(_dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------- draw
|
-- ------------------------------------------------------------------- draw
|
||||||
|
--
|
||||||
|
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
|
||||||
|
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||||
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
|
|
||||||
|
local CARDFLIP_PALS = {
|
||||||
|
bg = {
|
||||||
|
[0] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 0: Base / Table green
|
||||||
|
[1] = { { 255, 255, 255 }, { 239, 206, 0 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 1: Pikachu (Yellow)
|
||||||
|
[2] = { { 255, 255, 255 }, { 255, 107, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 2: Jigglypuff (Pink)
|
||||||
|
[3] = { { 255, 255, 255 }, { 66, 140, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 3: Poliwag (Blue)
|
||||||
|
[4] = { { 255, 255, 255 }, { 66, 255, 66 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 4: Oddish (Green)
|
||||||
|
[5] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 5: Level header
|
||||||
|
[6] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 6: Border
|
||||||
|
[7] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 7: Textbox
|
||||||
|
},
|
||||||
|
obj = {
|
||||||
|
[0] = { { 255, 255, 255 }, { 248, 56, 40 }, { 248, 56, 40 }, { 248, 56, 40 } }, -- Authentic GBC Red OAM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local TILEMAP = nil
|
||||||
|
local function getCardFlipTilemap()
|
||||||
|
if TILEMAP == nil then
|
||||||
|
local path = "assets/generated/card_flip/card_flip.tilemap"
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if f then
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
TILEMAP = {}
|
||||||
|
for i = 1, #data do
|
||||||
|
TILEMAP[i] = string.byte(data, i)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
TILEMAP = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return TILEMAP or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function CardFlip:sheets()
|
||||||
|
if self.sheet1 == nil then
|
||||||
|
self.sheet1 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_1.png", wide = 16, firstTile = 0 })
|
||||||
|
self.sheet2 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_2.png", wide = 3, firstTile = 0 })
|
||||||
|
self.sheet3 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_3.png", wide = 1, firstTile = 0 })
|
||||||
|
self.sheetOn = TileSheet.new({ path = "assets/generated/card_flip/on.png", wide = 1, firstTile = 0 })
|
||||||
|
self.sheetOff = TileSheet.new({ path = "assets/generated/card_flip/off.png", wide = 1, firstTile = 0 })
|
||||||
|
end
|
||||||
|
return self.sheet1, self.sheet2, self.sheet3, self.sheetOn, self.sheetOff
|
||||||
|
end
|
||||||
|
|
||||||
|
function CardFlip:cursorQuads()
|
||||||
|
if self.s3Image == nil then
|
||||||
|
local _, _, s3 = self:sheets()
|
||||||
|
self.s3Image = s3:image()
|
||||||
|
if self.s3Image and love and love.graphics then
|
||||||
|
local G = love.graphics
|
||||||
|
self.quadCorner = G.newQuad(0, 0, 8, 8, 8, 56) -- Tile 0: 1px corner
|
||||||
|
self.quadVEdge = G.newQuad(0, 8, 8, 8, 8, 56) -- Tile 1: 1px vertical edge
|
||||||
|
self.quadHEdge = G.newQuad(0, 16, 8, 8, 8, 56) -- Tile 2: 1px horizontal edge
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return self.s3Image, self.quadCorner, self.quadVEdge, self.quadHEdge
|
||||||
|
end
|
||||||
|
|
||||||
|
local HEADER_TILE_MAP = {
|
||||||
|
[0x3E] = 0, [0x3F] = 1,
|
||||||
|
[0x40] = 3, [0x41] = 4,
|
||||||
|
[0x42] = 6, [0x43] = 7,
|
||||||
|
[0x44] = 9, [0x45] = 10,
|
||||||
|
[0x46] = 12, [0x47] = 13,
|
||||||
|
[0x48] = 15, [0x49] = 16,
|
||||||
|
[0x4A] = 18, [0x4B] = 19,
|
||||||
|
[0x4C] = 21, [0x4D] = 22,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Draw an authentic GBC red cursor bounding frame using OAM sprite tiles.
|
||||||
|
-- On real GBC hardware OAM color 0 is always transparent, so only the 1px
|
||||||
|
-- dark edges of tiles 0-2 are visible; the board shows through the middle.
|
||||||
|
-- We replicate this by drawing in "multiply" blend mode: white (255,255,255)
|
||||||
|
-- pixels multiply to the board colour unchanged, black (0,0,0) pixels tinted
|
||||||
|
-- to GBC red draw the border, and nothing fills the interior.
|
||||||
|
function CardFlip:drawOamBox(px, py, w, h)
|
||||||
|
local img, qCorner, qVEdge, qHEdge = self:cursorQuads()
|
||||||
|
local G = love.graphics
|
||||||
|
|
||||||
|
if not (img and qCorner and qVEdge and qHEdge) then
|
||||||
|
-- Fallback: plain 1px red outline
|
||||||
|
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
|
||||||
|
G.rectangle("fill", px, py, w, 1)
|
||||||
|
G.rectangle("fill", px, py + h - 1, w, 1)
|
||||||
|
G.rectangle("fill", px, py, 1, h)
|
||||||
|
G.rectangle("fill", px + w - 1, py, 1, h)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local prevBlend, prevAlpha = G.getBlendMode()
|
||||||
|
G.setBlendMode("multiply", "premultiplied")
|
||||||
|
-- Tint: black (0) → GBC red, white (255) → white (passthrough = transparent)
|
||||||
|
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
|
||||||
|
|
||||||
|
-- 4 Corners
|
||||||
|
G.draw(img, qCorner, px, py, 0, 1, 1)
|
||||||
|
G.draw(img, qCorner, px + w, py, 0, -1, 1)
|
||||||
|
G.draw(img, qCorner, px, py + h, 0, 1, -1)
|
||||||
|
G.draw(img, qCorner, px + w, py + h, 0, -1, -1)
|
||||||
|
|
||||||
|
-- Top & Bottom horizontal edges
|
||||||
|
if w > 16 then
|
||||||
|
for x = px + 8, px + w - 16, 8 do
|
||||||
|
G.draw(img, qHEdge, x, py, 0, 1, 1)
|
||||||
|
G.draw(img, qHEdge, x, py + h, 0, 1, -1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Left & Right vertical edges
|
||||||
|
if h > 16 then
|
||||||
|
for y = py + 8, py + h - 16, 8 do
|
||||||
|
G.draw(img, qVEdge, px, y, 0, 1, 1)
|
||||||
|
G.draw(img, qVEdge, px + w, y, 0, -1, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
G.setBlendMode(prevBlend, prevAlpha)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function CardFlip:drawBoard()
|
function CardFlip:drawBoard()
|
||||||
-- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand
|
local s1, s2, s3, sOn, sOff = self:sheets()
|
||||||
-- being played and every one before it stays off.
|
local tm = getCardFlipTilemap()
|
||||||
|
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
|
for row = 0, CardFlip.HANDS_PER_DECK - 1 do
|
||||||
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
|
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
|
||||||
end
|
end
|
||||||
for x = 2, 5 do
|
for x = 2, 5 do
|
||||||
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
|
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
|
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
|
||||||
end
|
end
|
||||||
for y = 2, 7 do
|
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)
|
Chrome.print(tostring(y - 1), LEVEL_COL, row)
|
||||||
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
|
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
|
||||||
for x = 2, 5 do
|
for x = 2, 5 do
|
||||||
-- A still-in-the-deck cell stands in for the card back until the art
|
|
||||||
-- 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)
|
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
|
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 x, y = self.cursorX, self.cursorY
|
||||||
local col
|
if y == 0 then
|
||||||
if x == 0 then col = LEVEL_PAIR_COL
|
-- Pokemon Pair: spans 4 columns (32px), 1 row (8px)
|
||||||
elseif x == 1 then col = LEVEL_COL
|
local px = (MON_COL[x] or 12) * 8
|
||||||
else col = MON_COL[x] end
|
local py = MON_PAIR_ROW * 8
|
||||||
local row
|
return px, py, 32, 8
|
||||||
if y == 0 then row = MON_PAIR_ROW
|
elseif y == 1 then
|
||||||
elseif y == 1 then row = MON_ROW
|
-- Single Pokemon: 2x2 tiles (16x16 px)
|
||||||
else row = LEVEL_ROW[y] end
|
local px = (MON_COL[x] or 12) * 8
|
||||||
return col, row
|
local py = MON_ROW * 8
|
||||||
|
return px, py, 16, 16
|
||||||
|
elseif x == 0 then
|
||||||
|
-- Level Pair: 1 column (8px), spans 24px across the full pair
|
||||||
|
local pair = math.floor((y - 2) / 2)
|
||||||
|
local px = LEVEL_PAIR_COL * 8
|
||||||
|
local py = 24 + pair * 24
|
||||||
|
return px, py, 8, 24
|
||||||
|
elseif x == 1 then
|
||||||
|
-- Single Level: 1 column (8px), 12px tall for each stacked card
|
||||||
|
local pair = math.floor((y - 2) / 2)
|
||||||
|
local isBottom = ((y - 2) % 2 == 1)
|
||||||
|
local px = LEVEL_COL * 8
|
||||||
|
local py = 24 + pair * 24 + (isBottom and 12 or 0)
|
||||||
|
return px, py, 8, 12
|
||||||
|
else
|
||||||
|
-- Exact Card: 2 columns (16px), 12px tall for each stacked card
|
||||||
|
local pair = math.floor((y - 2) / 2)
|
||||||
|
local isBottom = ((y - 2) % 2 == 1)
|
||||||
|
local px = (MON_COL[x] or 12) * 8
|
||||||
|
local py = 24 + pair * 24 + (isBottom and 12 or 0)
|
||||||
|
return px, py, 16, 12
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the
|
local FACE_DOWN_TILES = {
|
||||||
-- 3x3 Pokepic one row further down.
|
{ 0x08, 0x09, 0x09, 0x09, 0x0a },
|
||||||
|
{ 0x0b, 0x28, 0x2b, 0x28, 0x0c },
|
||||||
|
{ 0x0b, 0x2c, 0x2d, 0x2e, 0x0c },
|
||||||
|
{ 0x0b, 0x2f, 0x30, 0x31, 0x0c },
|
||||||
|
{ 0x0b, 0x32, 0x33, 0x34, 0x0c },
|
||||||
|
{ 0x0d, 0x0e, 0x0e, 0x0e, 0x0f },
|
||||||
|
}
|
||||||
|
|
||||||
|
local FACE_UP_TILES = {
|
||||||
|
{ 0x18, 0x19, 0x19, 0x19, 0x1a },
|
||||||
|
{ 0x1b, 0x35, 0x28, 0x28, 0x1c },
|
||||||
|
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
|
||||||
|
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
|
||||||
|
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
|
||||||
|
{ 0x1d, 0x1e, 0x1e, 0x1e, 0x1f },
|
||||||
|
}
|
||||||
|
|
||||||
|
local MON_ANCHORS = {
|
||||||
|
[0] = 24, -- Pikachu (tiles 24..32 in card_flip_2)
|
||||||
|
[1] = 33, -- Jigglypuff (tiles 33..41 in card_flip_2)
|
||||||
|
[2] = 42, -- Poliwag (tiles 42..50 in card_flip_2)
|
||||||
|
[3] = 51, -- Oddish (tiles 51..59 in card_flip_2)
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Draw face-down, flipping, or face-up card at (2, 0) or (2, 6)
|
||||||
function CardFlip:drawCards()
|
function CardFlip:drawCards()
|
||||||
|
local s1, s2 = self:sheets()
|
||||||
|
|
||||||
for slot = 1, 2 do
|
for slot = 1, 2 do
|
||||||
local box = CARD_BOX[slot]
|
local box = CARD_BOX[slot]
|
||||||
Chrome.box(box.x, box.y, CARD_BOX_W, CARD_BOX_H)
|
local bx, by = box.x * 8, box.y * 8
|
||||||
local chosen = (slot - 1) == self.which
|
local chosen = (slot - 1) == self.which
|
||||||
if self.faceUp and chosen then
|
local isFlipping = (self.phase == "flipping") and chosen
|
||||||
Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3,
|
local isFaceUp = (self.faceUp and chosen)
|
||||||
box.y + 1)
|
|
||||||
Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)],
|
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 4) then
|
||||||
box.x + 1, box.y + 3)
|
local activeCard = self.faceUp or self.targetCard or 0
|
||||||
elseif self.phase == "choose" and chosen then
|
local lvl = CardFlip.level(activeCard) + 1
|
||||||
Chrome.cursor(box.x, box.y + 3)
|
local mon = CardFlip.mon(activeCard)
|
||||||
|
local monPal = CARDFLIP_PALS.bg[mon + 1] or CARDFLIP_PALS.bg[1]
|
||||||
|
|
||||||
|
s1.palette = CARDFLIP_PALS.bg[0]
|
||||||
|
for cy = 1, 6 do
|
||||||
|
for cx = 1, 5 do
|
||||||
|
local tid = FACE_UP_TILES[cy][cx]
|
||||||
|
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Level digit at (box.x + 3, box.y + 1)
|
||||||
|
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 8) then
|
||||||
|
Chrome.print(tostring(lvl), box.x + 3, box.y + 1)
|
||||||
|
|
||||||
|
-- Draw 3x3 Pokemon pic from card_flip_2 (s2) at (box.x + 1, box.y + 2)
|
||||||
|
s2.palette = monPal
|
||||||
|
local anchor = MON_ANCHORS[mon] or 24
|
||||||
|
for py = 0, 2 do
|
||||||
|
for px = 0, 2 do
|
||||||
|
local tid = anchor + py * 3 + px
|
||||||
|
s2:draw(tid, box.x + 1 + px, box.y + 2 + py)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
s1.palette = CARDFLIP_PALS.bg[0]
|
||||||
|
for cy = 1, 6 do
|
||||||
|
for cx = 1, 5 do
|
||||||
|
local tid = FACE_DOWN_TILES[cy][cx]
|
||||||
|
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.phase == "choose" and chosen then
|
||||||
|
-- Authentic OAM red selection box around 5x6 card (40x48 px)
|
||||||
|
self:drawOamBox(bx, by, 40, 48)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function CardFlip:drawPanel()
|
function CardFlip:drawPanel()
|
||||||
Chrome.clear()
|
|
||||||
self:drawBoard()
|
self:drawBoard()
|
||||||
self:drawCards()
|
self:drawCards()
|
||||||
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2)
|
|
||||||
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
|
-- Dialogue / Message box at (0, 12), 10 wide, 6 tall (interior 8x4)
|
||||||
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
|
|
||||||
if self.lines then
|
if self.lines then
|
||||||
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
|
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, 8, 4)
|
||||||
for i, line in ipairs(self.lines) do
|
for i, line in ipairs(self.lines) do
|
||||||
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Coin box at (9, 15), 11 wide, 3 tall (interior 9x1)
|
||||||
|
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, 9, 1)
|
||||||
|
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
|
||||||
|
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
|
||||||
|
|
||||||
if self.phase == "bet" then
|
if self.phase == "bet" then
|
||||||
local col, row = self:cursorCell()
|
self.betBlink = (self.betBlink or 0) + 1
|
||||||
Chrome.cursor(col - 1, row)
|
if (self.betBlink % 32) < 24 then
|
||||||
|
local px, py, w, h = self:cursorBounds()
|
||||||
|
self:drawOamBox(px, py, w, h)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if self.phase == "ask" or self.phase == "again" then
|
if self.phase == "ask" or self.phase == "again" then
|
||||||
-- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
|
-- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
|
||||||
Chrome.textbox(14, 7, 4, 3)
|
Chrome.textbox(14, 7, 4, 3)
|
||||||
@@ -652,3 +982,4 @@ function CardFlip:drawWidescreen(winW, winH)
|
|||||||
end
|
end
|
||||||
|
|
||||||
return CardFlip
|
return CardFlip
|
||||||
|
|
||||||
|
|||||||
+527
-72
@@ -219,10 +219,16 @@ local TWO_LINES = {
|
|||||||
|
|
||||||
function SlotMachine.matchFirstTwo(bet, r1, r2)
|
function SlotMachine.matchFirstTwo(bet, r1, r2)
|
||||||
local building = SlotMachine.NO_MATCH
|
local building = SlotMachine.NO_MATCH
|
||||||
|
local matchingSevens = false
|
||||||
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do
|
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do
|
||||||
if r1[line[1]] == r2[line[2]] then building = r1[line[1]] end
|
if r1[line[1]] == r2[line[2]] then
|
||||||
|
building = r1[line[1]]
|
||||||
|
if building == SlotMachine.SEVEN then
|
||||||
|
matchingSevens = true
|
||||||
end
|
end
|
||||||
return building, building == SlotMachine.SEVEN
|
end
|
||||||
|
end
|
||||||
|
return building, matchingSevens
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------- bias
|
-- ------------------------------------------------------------------- bias
|
||||||
@@ -403,13 +409,14 @@ end
|
|||||||
-- matching SEVENs on a line this bet buys.
|
-- matching SEVENs on a line this bet buys.
|
||||||
function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
|
function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
|
||||||
local strip = SlotMachine.REELS[2]
|
local strip = SlotMachine.REELS[2]
|
||||||
|
local pos = SlotMachine.advance(position)
|
||||||
for _ = 1, SEARCH_LIMIT do
|
for _ = 1, SEARCH_LIMIT do
|
||||||
local window = { SlotMachine.window(strip, position) }
|
local window = { SlotMachine.window(strip, pos) }
|
||||||
local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
|
local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
|
||||||
if building ~= SlotMachine.NO_MATCH and sevens then return position end
|
if building ~= SlotMachine.NO_MATCH and sevens then return pos end
|
||||||
position = SlotMachine.advance(position)
|
pos = SlotMachine.advance(pos)
|
||||||
end
|
end
|
||||||
return position
|
return pos
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------- reel 3's theatre
|
-- ------------------------------------------------------- reel 3's theatre
|
||||||
@@ -580,8 +587,8 @@ end
|
|||||||
-- ------------------------------------------------------------------ layout
|
-- ------------------------------------------------------------------ layout
|
||||||
local COINS_X, COINS_Y = 5, 1
|
local COINS_X, COINS_Y = 5, 1
|
||||||
local PAYOUT_X, PAYOUT_Y = 11, 1
|
local PAYOUT_X, PAYOUT_Y = 11, 1
|
||||||
-- REEL_X_COORD / TILE_WIDTH.
|
-- REEL_X_COORD / TILE_WIDTH (5, 9, 13) for the 3 reel apertures (columns 5-6, 9-10, 13-14)
|
||||||
local REEL_X = { 6, 10, 14 }
|
local REEL_X = { 5, 9, 13 }
|
||||||
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
|
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
|
||||||
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the
|
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the
|
||||||
-- fourth symbol that only half shows.
|
-- fourth symbol that only half shows.
|
||||||
@@ -729,30 +736,41 @@ function SlotMachine:startSpin()
|
|||||||
self.reel = 1
|
self.reel = 1
|
||||||
self.delay = 32
|
self.delay = 32
|
||||||
self.message = TEXTS.start
|
self.message = TEXTS.start
|
||||||
|
self.stopped = nil
|
||||||
|
self.matched = SlotMachine.NO_MATCH
|
||||||
|
self.matchingSevens = false
|
||||||
|
self.reel3Action = nil
|
||||||
|
self.golemAnim = nil
|
||||||
|
self.chanseyAnim = nil
|
||||||
|
self.reel2Pause = nil
|
||||||
for i = 1, 3 do
|
for i = 1, 3 do
|
||||||
self.rate[i] = 4 -- ReelAction_NormalRate
|
self.rate[i] = 4 -- ReelAction_NormalRate
|
||||||
self.stops[i] = nil
|
self.stops[i] = nil
|
||||||
|
self.distance[i] = 0
|
||||||
end
|
end
|
||||||
self:sfx(SFX_START)
|
self:sfx(SFX_START)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot
|
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot
|
||||||
-- boundary, and the position advances when the distance's low nibble wraps.
|
-- boundary, and the position advances whenever 16 pixels are traversed.
|
||||||
function SlotMachine:spinReels()
|
function SlotMachine:spinReels()
|
||||||
for i = 1, 3 do
|
for i = 1, 3 do
|
||||||
local rate = self.rate[i]
|
local rate = self.rate[i]
|
||||||
if rate > 0 then
|
if rate > 0 then
|
||||||
self.distance[i] = (self.distance[i] + rate) % 256
|
self.distance[i] = (self.distance[i] or 0) + rate
|
||||||
if self.distance[i] % 16 == 0 then
|
while self.distance[i] >= 16 do
|
||||||
|
self.distance[i] = self.distance[i] - 16
|
||||||
self.positions[i] = SlotMachine.advance(self.positions[i])
|
self.positions[i] = SlotMachine.advance(self.positions[i])
|
||||||
-- A reel with a resting slot chosen stops the moment it reaches it.
|
-- A reel with a resting slot chosen stops the moment it reaches it.
|
||||||
if self.stops[i] and self.positions[i] == self.stops[i] then
|
if self.stops[i] and self.positions[i] == self.stops[i] then
|
||||||
self.rate[i] = 0
|
self.rate[i] = 0
|
||||||
|
self.distance[i] = 0
|
||||||
self.stopped = self.stopped or {}
|
self.stopped = self.stopped or {}
|
||||||
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
|
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
|
||||||
self.positions[i]) }
|
self.positions[i]) }
|
||||||
self:sfx(SFX_STOP)
|
self:sfx(SFX_STOP)
|
||||||
self:reelStopped(i)
|
self:reelStopped(i)
|
||||||
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -775,11 +793,17 @@ function SlotMachine:pressStop()
|
|||||||
self.stops[1] = SlotMachine.stopReel1(here, self.bias)
|
self.stops[1] = SlotMachine.stopReel1(here, self.bias)
|
||||||
elseif i == 2 then
|
elseif i == 2 then
|
||||||
local r1 = self.stopped[1]
|
local r1 = self.stopped[1]
|
||||||
if SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) then
|
local hereWindow = { SlotMachine.window(SlotMachine.REELS[2], here) }
|
||||||
|
local _, hereSevens = SlotMachine.matchFirstTwo(self.bet, r1, hereWindow)
|
||||||
|
local doSkip = SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random)
|
||||||
|
if hereSevens and not doSkip then
|
||||||
|
self.stops[2] = here
|
||||||
|
elseif doSkip then
|
||||||
-- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
|
-- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
|
||||||
-- fast-spins it at double rate; the pause is the tell.
|
-- fast-spins it at double rate; the pause is the tell.
|
||||||
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1)
|
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1)
|
||||||
self.rate[2] = 8
|
self.reel2Pause = 32
|
||||||
|
self.rate[2] = 0 -- paused during the 32-frame tell
|
||||||
else
|
else
|
||||||
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
|
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
|
||||||
end
|
end
|
||||||
@@ -789,40 +813,65 @@ function SlotMachine:pressStop()
|
|||||||
self.matchingSevens = sevens
|
self.matchingSevens = sevens
|
||||||
local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
|
local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
|
||||||
self.reel3Action = action
|
self.reel3Action = action
|
||||||
if action == SlotMachine.REEL3_STOP then
|
if action == SlotMachine.REEL3_STOP or action == "stop" then
|
||||||
self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2)
|
self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2)
|
||||||
elseif action == SlotMachine.REEL3_SLOW then
|
elseif action == SlotMachine.REEL3_SLOW or action == "slowAdvance" then
|
||||||
self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
|
self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
|
||||||
self.rate[3] = 1 -- ReelAction_QuarterRate
|
self.rate[3] = 1 -- ReelAction_QuarterRate slow crawl
|
||||||
elseif action == SlotMachine.REEL3_GOLEM then
|
elseif action == SlotMachine.REEL3_GOLEM or action == "golem" then
|
||||||
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
|
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
|
||||||
self.random)
|
self.random)
|
||||||
|
if count == 0 then count = 3 end
|
||||||
local target = here
|
local target = here
|
||||||
for _ = 1, count do target = SlotMachine.advance(target) end
|
for _ = 1, count do target = SlotMachine.advance(target) end
|
||||||
self.stops[3] = target
|
self.stops[3] = target
|
||||||
self.golems = count
|
self.golems = count
|
||||||
self.rate[3] = 8
|
self.golemAnim = {
|
||||||
|
count = count,
|
||||||
|
state = "falling",
|
||||||
|
var1 = 48,
|
||||||
|
x = 100,
|
||||||
|
y = 44 - 112,
|
||||||
|
animFrame = 0,
|
||||||
|
animTimer = 0,
|
||||||
|
}
|
||||||
|
self.rate[3] = 0 -- reel 3 stepped by each golem impact
|
||||||
else
|
else
|
||||||
local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
|
local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
|
||||||
self.stops[3] = target
|
self.stops[3] = target
|
||||||
self.rate[3] = 16 -- ReelAction_QuadrupleRate, the egg drop
|
self.chanseyAnim = {
|
||||||
|
state = "walking",
|
||||||
|
xcoord = 0,
|
||||||
|
x = -24,
|
||||||
|
y = 44,
|
||||||
|
animTimer = 0,
|
||||||
|
animPose = 0,
|
||||||
|
}
|
||||||
|
self.rate[3] = 0 -- paused until Chansey drops egg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- A reel already sitting on its resting slot has nowhere to turn.
|
-- A reel already sitting on its resting slot has nowhere to turn.
|
||||||
|
-- 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
|
if self.stops[i] == self.positions[i] then
|
||||||
self.rate[i] = 0
|
self.rate[i] = 0
|
||||||
|
self.distance[i] = 0
|
||||||
self.stopped = self.stopped or {}
|
self.stopped = self.stopped or {}
|
||||||
self.stopped[i] = self:reelWindow(i)
|
self.stopped[i] = self:reelWindow(i)
|
||||||
self:sfx(SFX_STOP)
|
self:sfx(SFX_STOP)
|
||||||
self:reelStopped(i)
|
self:reelStopped(i)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function SlotMachine:reelStopped(i)
|
function SlotMachine:reelStopped(i)
|
||||||
if i < 3 then
|
if i < 3 then
|
||||||
self.reel = i + 1
|
self.reel = i + 1
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
self.golemAnim = nil
|
||||||
|
self.chanseyAnim = nil
|
||||||
|
self.reel2Pause = nil
|
||||||
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
|
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
|
||||||
-- before the payout is counted out; a loss skips straight past it.
|
-- before the payout is counted out; a loss skips straight past it.
|
||||||
local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3]
|
local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3]
|
||||||
@@ -906,13 +955,134 @@ function SlotMachine:update(_dt)
|
|||||||
if phase == "spinning" then
|
if phase == "spinning" then
|
||||||
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
|
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
|
||||||
-- bet menu cannot stop reel one.
|
-- bet menu cannot stop reel one.
|
||||||
if self.delay > 0 then
|
if (self.delay or 0) > 0 then
|
||||||
self.delay = self.delay - 1
|
self.delay = self.delay - 1
|
||||||
self:spinReels()
|
self:spinReels()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if self.reel2Pause and self.reel2Pause > 0 then
|
||||||
|
self.reel2Pause = self.reel2Pause - 1
|
||||||
|
if self.reel2Pause <= 0 then
|
||||||
|
self.reel2Pause = nil
|
||||||
|
self.rate[2] = 8 -- resume fast-spin after the 32-frame tell
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.golemAnim then
|
||||||
|
local g = self.golemAnim
|
||||||
|
-- Cycle animation frames every 8 ticks (~7.5 fps) matching the original pacing
|
||||||
|
g.animTimer = (g.animTimer or 0) + 1
|
||||||
|
if g.animTimer >= 8 then
|
||||||
|
g.animTimer = 0
|
||||||
|
g.animFrame = ((g.animFrame or 0) + 1) % 4
|
||||||
|
end
|
||||||
|
|
||||||
|
if g.state == "falling" then
|
||||||
|
if g.var1 > 32 then
|
||||||
|
g.var1 = g.var1 - 1
|
||||||
|
local angle = (g.var1 * math.pi) / 32
|
||||||
|
local yOffset = math.floor(112 * math.sin(angle) + 0.5)
|
||||||
|
g.y = 44 + yOffset
|
||||||
|
g.x = 100
|
||||||
|
else
|
||||||
|
-- Landed on Reel 3!
|
||||||
|
g.y = 44
|
||||||
|
g.x = 100
|
||||||
|
g.state = "rolling"
|
||||||
|
g.xoffset = 0
|
||||||
|
g.animTimer = 0
|
||||||
|
g.animFrame = 0
|
||||||
|
self:sfx("Sfx_PlacePuzzlePieceDown")
|
||||||
|
-- Advance reel 3 by 1 slot per Golem impact
|
||||||
|
self.positions[3] = SlotMachine.advance(self.positions[3])
|
||||||
|
self.distance[3] = 0
|
||||||
|
end
|
||||||
|
elseif g.state == "rolling" then
|
||||||
|
g.xoffset = (g.xoffset or 0) + 1
|
||||||
|
g.x = 100 - g.xoffset
|
||||||
|
|
||||||
|
if g.xoffset >= 88 then
|
||||||
|
-- Rolled past reel 1 (100 - 88 = 12px) off the screen -> restart or end
|
||||||
|
g.count = g.count - 1
|
||||||
|
if g.count > 0 then
|
||||||
|
g.state = "falling"
|
||||||
|
g.var1 = 48
|
||||||
|
g.x = 100
|
||||||
|
g.y = 44 - 112
|
||||||
|
else
|
||||||
|
-- All Golems finished; halt reel 3 at target
|
||||||
|
self.golemAnim = nil
|
||||||
|
self.rate[3] = 0
|
||||||
|
self.distance[3] = 0
|
||||||
|
self.stopped = self.stopped or {}
|
||||||
|
self.stopped[3] = self:reelWindow(3)
|
||||||
|
self:sfx(SFX_STOP)
|
||||||
|
self:reelStopped(3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.chanseyAnim then
|
||||||
|
local c = self.chanseyAnim
|
||||||
|
if c.state == "walking" then
|
||||||
|
c.xcoord = (c.xcoord or 0) + 1
|
||||||
|
c.x = c.xcoord - 24
|
||||||
|
c.y = 44
|
||||||
|
|
||||||
|
-- Cycle walking poses 0->1->2->3 (maps to Chansey 1->2->3->4) every 6 frames
|
||||||
|
c.animTimer = (c.animTimer or 0) + 1
|
||||||
|
if c.animTimer >= 6 then
|
||||||
|
c.animTimer = 0
|
||||||
|
c.animPose = ((c.animPose or 0) + 1) % 4
|
||||||
|
end
|
||||||
|
|
||||||
|
if c.xcoord % 16 == 0 then self:sfx("Sfx_JumpOverLedge") end
|
||||||
|
|
||||||
|
if c.x >= 88 then
|
||||||
|
-- Reached reel 3! Switch to tell pause (pose 4 = arm raised)
|
||||||
|
c.x = 88
|
||||||
|
c.state = "egg_pause"
|
||||||
|
c.delay = 14
|
||||||
|
c.animPose = 3
|
||||||
|
end
|
||||||
|
elseif c.state == "egg_pause" then
|
||||||
|
c.delay = (c.delay or 14) - 1
|
||||||
|
if c.delay <= 0 then
|
||||||
|
-- Switch to Chansey 5 (egg drop pose) and spawn egg
|
||||||
|
c.animPose = 4
|
||||||
|
c.state = "egg_drop"
|
||||||
|
c.eggTimer = 0
|
||||||
|
c.eggStartX = c.x + 14
|
||||||
|
c.eggStartY = c.y + 8
|
||||||
|
c.eggX = c.eggStartX
|
||||||
|
c.eggY = c.eggStartY
|
||||||
|
c.eggVisible = true
|
||||||
|
self:sfx("Sfx_Present")
|
||||||
|
end
|
||||||
|
elseif c.state == "egg_drop" then
|
||||||
|
c.eggTimer = (c.eggTimer or 0) + 1
|
||||||
|
local t = c.eggTimer / 16
|
||||||
|
if t > 1 then t = 1 end
|
||||||
|
c.eggX = c.eggStartX + t * (108 - c.eggStartX)
|
||||||
|
c.eggY = c.eggStartY + t * (56 - c.eggStartY) - math.sin(t * math.pi) * 8
|
||||||
|
|
||||||
|
if c.eggTimer >= 16 then
|
||||||
|
-- Egg landed on Reel 3!
|
||||||
|
c.eggVisible = false
|
||||||
|
c.state = "spinning"
|
||||||
|
self:sfx("Sfx_PlacePuzzlePieceDown")
|
||||||
|
self.rate[3] = 16 -- fast drop reel 3 to jackpot
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
self.message = nil
|
self.message = nil
|
||||||
if input:wasPressed("a") then self:pressStop() end
|
if input:wasPressed("a") then
|
||||||
|
if not self.stops[self.reel] and not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
|
||||||
|
self:pressStop()
|
||||||
|
end
|
||||||
|
end
|
||||||
self:spinReels()
|
self:spinReels()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -959,57 +1129,302 @@ end
|
|||||||
|
|
||||||
-- ------------------------------------------------------------------- draw
|
-- ------------------------------------------------------------------- draw
|
||||||
--
|
--
|
||||||
-- The cart's reel art is unextracted (see the header), so a symbol draws as its
|
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
|
||||||
-- two-letter label inside a 2x2 cell. A `slots` entry in menu_gfx.lua switches
|
|
||||||
-- this to the real tiles without any other change.
|
|
||||||
function SlotMachine:sheet()
|
|
||||||
if self.sheetCache == nil then
|
|
||||||
local data = self.game and self.game.data
|
|
||||||
local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots
|
|
||||||
if gfx and gfx.image then
|
|
||||||
local TileSheet = require("src.ui.gen2.TileSheet")
|
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||||
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16,
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
firstTile = gfx.firstTile or 0 })
|
|
||||||
|
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
|
else
|
||||||
self.sheetCache = false
|
TILEMAP = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return self.sheetCache or nil
|
return TILEMAP or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function cell(tx, ty, label)
|
function SlotMachine:sheets()
|
||||||
local G = love.graphics
|
if self.sheet1 == nil then
|
||||||
G.setColor(0, 0, 0, 1)
|
self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 })
|
||||||
G.rectangle("line", tx * 8, ty * 8, 16, 16)
|
self.sheet2 = TileSheet.new({ path = "assets/generated/slots/gold_slots_2.png", wide = 2, firstTile = 0 })
|
||||||
Chrome.print(label, tx, ty + 1)
|
self.sheet3 = TileSheet.new({ path = "assets/generated/slots/gold_slots_3.png", wide = 3, firstTile = 0 })
|
||||||
|
end
|
||||||
|
return self.sheet1, self.sheet2, self.sheet3
|
||||||
|
end
|
||||||
|
|
||||||
|
function SlotMachine:drawBackground()
|
||||||
|
local s1, s2 = self:sheets()
|
||||||
|
local tm = getTilemap()
|
||||||
|
if not tm or not s1:available() then
|
||||||
|
-- Fallback simple background if assets unavailable
|
||||||
|
Chrome.clear()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local bet = self.bet or 0
|
||||||
|
|
||||||
|
for ty = 0, 11 do
|
||||||
|
for tx = 0, 19 do
|
||||||
|
local idx = ty * 20 + tx + 1
|
||||||
|
local tileId = tm[idx]
|
||||||
|
|
||||||
|
-- Palette attribution matching _CGB_SlotMachine
|
||||||
|
local pal = 0
|
||||||
|
if (tx <= 2 or tx >= 17) and ty >= 2 and ty <= 11 then
|
||||||
|
if ty >= 6 and ty <= 7 then pal = 4
|
||||||
|
elseif ty >= 4 and ty <= 9 then pal = 3
|
||||||
|
else pal = 2 end
|
||||||
|
elseif tx >= 4 and tx <= 15 and ty >= 2 and ty <= 3 then
|
||||||
|
pal = 1 -- Vileplume
|
||||||
|
elseif (tx == 3 or tx == 16) and ty >= 2 and ty <= 11 then
|
||||||
|
local isLit = false
|
||||||
|
if ty == 6 or ty == 7 then isLit = (bet >= 1)
|
||||||
|
elseif ty == 4 or ty == 5 or ty == 8 or ty == 9 then isLit = (bet >= 2)
|
||||||
|
elseif ty == 2 or ty == 3 or ty == 10 or ty == 11 then isLit = (bet >= 3)
|
||||||
|
end
|
||||||
|
if isLit then
|
||||||
|
pal = 1
|
||||||
|
-- Use lit lights tile
|
||||||
|
if tileId == 0x23 then tileId = 0x14
|
||||||
|
elseif tileId == 0x24 then tileId = 0x15 end
|
||||||
|
else
|
||||||
|
pal = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local colors = GBC_PALS.bg[pal]
|
||||||
|
s1.palette = colors
|
||||||
|
s2.palette = colors
|
||||||
|
|
||||||
|
if tileId < 0x25 then
|
||||||
|
s1:draw(tileId, tx, ty)
|
||||||
|
else
|
||||||
|
s2:draw(tileId - 0x25, tx, ty)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SlotMachine:drawReels()
|
function SlotMachine:drawReels()
|
||||||
|
local _, s2 = self:sheets()
|
||||||
|
local G = love.graphics
|
||||||
|
|
||||||
for i = 1, 3 do
|
for i = 1, 3 do
|
||||||
local strip = SlotMachine.REELS[i]
|
local strip = SlotMachine.REELS[i]
|
||||||
local position = self.positions[i]
|
local pos = self.positions[i]
|
||||||
-- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays
|
local a = pos
|
||||||
-- them bottom upward, which is what the three repeated entries at the end
|
if a == 0 then a = 0x0f end
|
||||||
-- of each strip are for: position 14 reads indices 14, 15, 16 and 17
|
a = (a - 1) % 16
|
||||||
-- without wrapping. Only the lower three are on a pay line; the fourth
|
local rx = REEL_X[i] * 8
|
||||||
-- half-shows at the top of the window.
|
local dy = math.floor(self.distance[i] or 0)
|
||||||
for row = 1, 4 do
|
|
||||||
local symbol = strip[position + row]
|
-- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
|
||||||
cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?")
|
for row = 0, 3 do
|
||||||
|
local sym = strip[a + row + 1]
|
||||||
|
local py = 64 - (row * 16) + dy
|
||||||
|
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
|
||||||
|
s2.palette = pal
|
||||||
|
|
||||||
|
-- 2x2 tiles in 2-wide sheet:
|
||||||
|
-- sym + 0 = top-left (col 0, row 0)
|
||||||
|
-- sym + 1 = top-right (col 1, row 0)
|
||||||
|
-- sym + 2 = bottom-left (col 0, row 1)
|
||||||
|
-- sym + 3 = bottom-right (col 1, row 1)
|
||||||
|
local t0 = s2:quad(sym + 0)
|
||||||
|
local t1 = s2:quad(sym + 1)
|
||||||
|
local t2 = s2:quad(sym + 2)
|
||||||
|
local t3 = s2:quad(sym + 3)
|
||||||
|
local img = s2:image()
|
||||||
|
|
||||||
|
if img and t0 and t1 and t2 and t3 then
|
||||||
|
local function drawSym()
|
||||||
|
G.draw(img, t0, rx, py)
|
||||||
|
G.draw(img, t1, rx + 8, py)
|
||||||
|
G.draw(img, t2, rx, py + 8)
|
||||||
|
G.draw(img, t3, rx + 8, py + 8)
|
||||||
|
end
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(pal, drawSym)
|
||||||
|
else
|
||||||
|
drawSym()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Fallback label
|
||||||
|
cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SlotMachine:drawLights()
|
function SlotMachine:actorsImage()
|
||||||
local lit = {}
|
if self.actorsLoaded == nil then
|
||||||
-- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller
|
local Assets = require("src.render.Assets")
|
||||||
-- one: `dec a / jr z` falls through from three to two to one.
|
local ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_actors.png")
|
||||||
for bet = 1, (self.bet or 0) do
|
if not (ok and img) then
|
||||||
for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end
|
ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_3.png")
|
||||||
|
end
|
||||||
|
self.actorsLoaded = (ok and img) or false
|
||||||
|
if self.actorsLoaded then
|
||||||
|
local G = love.graphics
|
||||||
|
-- 24x240 sheet:
|
||||||
|
-- Y=0: Golem 1 (Standing, 24x32)
|
||||||
|
-- Y=32: Golem 2 (Ball, 24x32)
|
||||||
|
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
|
||||||
|
-- Y=96: Chansey 2 (Step 2, 24x32)
|
||||||
|
-- Y=128: Chansey 3 (Step 3, 24x32)
|
||||||
|
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
|
||||||
|
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
|
||||||
|
-- Y=224: Egg (8x16 at X=0)
|
||||||
|
self.quadGolemStand = G.newQuad(0, 0, 24, 32, 24, 240)
|
||||||
|
self.quadGolemBall = G.newQuad(0, 32, 24, 32, 24, 240)
|
||||||
|
self.quadChansey1 = G.newQuad(0, 64, 24, 32, 24, 240)
|
||||||
|
self.quadChansey2 = G.newQuad(0, 96, 24, 32, 24, 240)
|
||||||
|
self.quadChansey3 = G.newQuad(0, 128, 24, 32, 24, 240)
|
||||||
|
self.quadChansey4 = G.newQuad(0, 160, 24, 32, 24, 240)
|
||||||
|
self.quadChanseyDrop = G.newQuad(0, 192, 24, 32, 24, 240)
|
||||||
|
self.quadEgg = G.newQuad(0, 224, 8, 16, 24, 240)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return self.actorsLoaded or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Redraw the top Vileplume row (rows 2..3) and bottom frame brackets (rows 10..11)
|
||||||
|
-- over the reels with solid backdrop to naturally mask any sprite overhang like the Game Boy hardware does.
|
||||||
|
function SlotMachine:drawOverlays()
|
||||||
|
local s1, s2 = self:sheets()
|
||||||
|
local tm = getTilemap()
|
||||||
|
if not tm or not s1:available() then return end
|
||||||
|
|
||||||
|
local G = love.graphics
|
||||||
|
-- Solid backdrop over header (rows 0..3) and footer (rows 10..11) between columns 4..15
|
||||||
|
G.setColor(198 / 255, 198 / 255, 74 / 255, 1)
|
||||||
|
G.rectangle("fill", 4 * 8, 2 * 8, 12 * 8, 2 * 8)
|
||||||
|
G.rectangle("fill", 4 * 8, 10 * 8, 12 * 8, 2 * 8)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
|
||||||
|
for _, ty in ipairs({ 2, 3, 10, 11 }) do
|
||||||
|
for tx = 4, 15 do
|
||||||
|
local idx = ty * 20 + tx + 1
|
||||||
|
local tileId = tm[idx]
|
||||||
|
local pal = (ty <= 3) and 1 or 0
|
||||||
|
local colors = GBC_PALS.bg[pal]
|
||||||
|
s1.palette = colors
|
||||||
|
s2.palette = colors
|
||||||
|
|
||||||
|
if tileId < 0x25 then
|
||||||
|
s1:draw(tileId, tx, ty)
|
||||||
|
else
|
||||||
|
s2:draw(tileId - 0x25, tx, ty)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw Golem sprite animation
|
||||||
|
if self.golemAnim then
|
||||||
|
local actors = self:actorsImage()
|
||||||
|
local g = self.golemAnim
|
||||||
|
if actors then
|
||||||
|
local quad = self.quadGolemBall
|
||||||
|
local scaleX = 1
|
||||||
|
local scaleY = 1
|
||||||
|
if g.state == "falling" then
|
||||||
|
quad = self.quadGolemBall
|
||||||
|
elseif g.state == "rolling" then
|
||||||
|
-- Frameset_SlotsGolem: 0=Standing, 1=Ball, 2=StandingYFlip, 3=BallXFlip
|
||||||
|
local rotFrame = (g.animFrame or 0) % 4
|
||||||
|
if rotFrame == 0 then
|
||||||
|
quad = self.quadGolemStand
|
||||||
|
scaleX = 1
|
||||||
|
scaleY = 1
|
||||||
|
elseif rotFrame == 1 then
|
||||||
|
quad = self.quadGolemBall
|
||||||
|
scaleX = 1
|
||||||
|
scaleY = 1
|
||||||
|
elseif rotFrame == 2 then
|
||||||
|
quad = self.quadGolemStand
|
||||||
|
scaleX = 1
|
||||||
|
scaleY = -1
|
||||||
|
elseif rotFrame == 3 then
|
||||||
|
quad = self.quadGolemBall
|
||||||
|
scaleX = -1
|
||||||
|
scaleY = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
local function drawGolem()
|
||||||
|
-- Draw rotated around center (ox=12, oy=16)
|
||||||
|
G.draw(actors, quad,
|
||||||
|
math.floor(g.x + 12),
|
||||||
|
math.floor(g.y + 16),
|
||||||
|
0, scaleX, scaleY, 12, 16)
|
||||||
|
end
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(GBC_PALS.obj[5], drawGolem)
|
||||||
|
else
|
||||||
|
drawGolem()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw Chansey & Egg sprite animation
|
||||||
|
if self.chanseyAnim then
|
||||||
|
local actors = self:actorsImage()
|
||||||
|
local c = self.chanseyAnim
|
||||||
|
if actors then
|
||||||
|
local quad = self.quadChansey1
|
||||||
|
if c.state == "walking" then
|
||||||
|
local walkCycle = { self.quadChansey1, self.quadChansey2, self.quadChansey3, self.quadChansey4 }
|
||||||
|
quad = walkCycle[((c.animPose or 0) % 4) + 1] or self.quadChansey1
|
||||||
|
elseif c.state == "egg_pause" then
|
||||||
|
quad = self.quadChansey4
|
||||||
|
elseif c.state == "egg_drop" or c.state == "spinning" then
|
||||||
|
quad = self.quadChanseyDrop
|
||||||
|
end
|
||||||
|
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
local function drawChansey()
|
||||||
|
G.draw(actors, quad, math.floor(c.x), math.floor(c.y or 44))
|
||||||
|
if c.eggVisible and c.eggX and c.eggY then
|
||||||
|
G.draw(actors, self.quadEgg, math.floor(c.eggX), math.floor(c.eggY))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(GBC_PALS.obj[6], drawChansey)
|
||||||
|
else
|
||||||
|
drawChansey()
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
@@ -1022,39 +1437,79 @@ function SlotMachine:drawMessage()
|
|||||||
end
|
end
|
||||||
if self.matched and self.matched ~= SlotMachine.NO_MATCH
|
if self.matched and self.matched ~= SlotMachine.NO_MATCH
|
||||||
and self.phase == "payoutText" then
|
and self.phase == "payoutText" then
|
||||||
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y,
|
local _, s2 = self:sheets()
|
||||||
SlotMachine.LABELS[self.matched] or "?")
|
local sym = self.matched
|
||||||
|
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
|
||||||
|
s2.palette = pal
|
||||||
|
local t0 = s2:quad(sym + 0)
|
||||||
|
local t1 = s2:quad(sym + 1)
|
||||||
|
local t2 = s2:quad(sym + 2)
|
||||||
|
local t3 = s2:quad(sym + 3)
|
||||||
|
local img = s2:image()
|
||||||
|
local G = love.graphics
|
||||||
|
local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8
|
||||||
|
if img and t0 and t1 and t2 and t3 then
|
||||||
|
local function drawWin()
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
G.draw(img, t0, px, py)
|
||||||
|
G.draw(img, t1, px + 8, py)
|
||||||
|
G.draw(img, t2, px, py + 8)
|
||||||
|
G.draw(img, t3, px + 8, py + 8)
|
||||||
|
end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(pal, drawWin)
|
||||||
|
else
|
||||||
|
drawWin()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SlotMachine:drawPanel()
|
function SlotMachine:drawPanel()
|
||||||
Chrome.clear()
|
self:drawBackground()
|
||||||
self:drawLights()
|
self:drawReels()
|
||||||
|
self:drawOverlays()
|
||||||
|
|
||||||
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters.
|
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters.
|
||||||
Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y)
|
Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y)
|
||||||
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
|
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
|
||||||
self:drawReels()
|
|
||||||
if self.phase == "bet" then
|
if self.phase == "bet" then
|
||||||
Chrome.textbox(BET_BOX_X, BET_BOX_Y, BET_BOX_W - 2, BET_BOX_H - 2)
|
-- Left speech textbox for "Bet how many coins?"
|
||||||
|
Chrome.textbox(0, 12, 12, 4)
|
||||||
|
Chrome.print(TEXTS.betHowMany[1], 1, 14)
|
||||||
|
Chrome.print(TEXTS.betHowMany[2], 1, 16)
|
||||||
|
|
||||||
|
-- Right menu for bet choices (14, 10 to 19, 17)
|
||||||
|
Chrome.textbox(14, 10, 4, 6)
|
||||||
for i, label in ipairs(BET_ROWS) do
|
for i, label in ipairs(BET_ROWS) do
|
||||||
local ty = BET_LABEL_Y + (i - 1) * BET_SPACING
|
local ty = 12 + (i - 1) * 2
|
||||||
if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end
|
if i == self.betIndex then Chrome.cursor(15, ty) end
|
||||||
Chrome.print(label, BET_LABEL_X, ty)
|
Chrome.print(label, 16, ty)
|
||||||
end
|
end
|
||||||
if not self.message then
|
|
||||||
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
|
if self.message then
|
||||||
for i, line in ipairs(TEXTS.betHowMany) do
|
-- If "Not enough coins." message is shown, overlay full speech box
|
||||||
|
Chrome.textbox(0, 12, 18, 4)
|
||||||
|
for i, line in ipairs(self.message) do
|
||||||
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
elseif self.phase == "again" then
|
||||||
self:drawMessage()
|
-- Speech box: "Play again?"
|
||||||
if self.phase == "again" then
|
Chrome.textbox(0, 12, 18, 4)
|
||||||
-- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13).
|
Chrome.print(TEXTS.playAgain[1], TEXT_X, TEXT_Y)
|
||||||
|
|
||||||
|
-- PlaceYesNoBox at (14, 12): 6x5 box with YES at (16,13), NO at (16,15)
|
||||||
Chrome.textbox(14, 12, 4, 3)
|
Chrome.textbox(14, 12, 4, 3)
|
||||||
Chrome.print("YES", 16, 13)
|
Chrome.print("YES", 16, 13)
|
||||||
Chrome.print("NO", 16, 15)
|
Chrome.print("NO", 16, 15)
|
||||||
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
|
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
|
||||||
|
else
|
||||||
|
self:drawMessage()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1233,6 +1233,118 @@ check("a full wallet fills it", Chrome.money(999999), "\xc2\xa5999999")
|
|||||||
check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true),
|
check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true),
|
||||||
"0050")
|
"0050")
|
||||||
|
|
||||||
|
-- ============================================================ multi-game stress tests
|
||||||
|
--
|
||||||
|
-- Verify that 500 consecutive Slot Machine games and 500 consecutive Card Flip hands
|
||||||
|
-- run to completion with zero softlocks, zero infinite spin loops (issue #1520),
|
||||||
|
-- and correct coin accounting across all random biases and near-miss theatres.
|
||||||
|
|
||||||
|
local mockSave = {
|
||||||
|
player = { name = "GOLD", coins = 5000 },
|
||||||
|
}
|
||||||
|
|
||||||
|
local mockInput = {
|
||||||
|
pressed = {},
|
||||||
|
wasPressed = function(self, key)
|
||||||
|
local v = self.pressed[key]
|
||||||
|
self.pressed[key] = false
|
||||||
|
return v or false
|
||||||
|
end,
|
||||||
|
press = function(self, key)
|
||||||
|
self.pressed[key] = true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local mockSlotGame = {
|
||||||
|
save = mockSave,
|
||||||
|
input = mockInput,
|
||||||
|
data = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Test Slot Machine 500-spin continuous loop
|
||||||
|
local sm = SlotMachine.new(mockSlotGame, { lucky = true })
|
||||||
|
local completedSpins = 0
|
||||||
|
|
||||||
|
for spin = 1, 500 do
|
||||||
|
mockSave.player.coins = 5000 -- ensure test player always has coins
|
||||||
|
if sm.phase == "quit" or sm.phase == "ranOut" then
|
||||||
|
sm = SlotMachine.new(mockSlotGame, { lucky = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Enter bet phase
|
||||||
|
check("slot machine in bet phase at spin start", sm.phase, "bet")
|
||||||
|
mockInput:press("a") -- bet 3 coins
|
||||||
|
sm:update(1/60)
|
||||||
|
|
||||||
|
check("slot machine entered spinning phase", sm.phase, "spinning")
|
||||||
|
|
||||||
|
local frameCount = 0
|
||||||
|
local maxFrames = 5000 -- safety bound per spin
|
||||||
|
|
||||||
|
while sm.phase == "spinning" and frameCount < maxFrames do
|
||||||
|
frameCount = frameCount + 1
|
||||||
|
if frameCount % 30 == 0 then
|
||||||
|
mockInput:press("a") -- press stop button
|
||||||
|
end
|
||||||
|
sm:update(1/60)
|
||||||
|
end
|
||||||
|
|
||||||
|
check(("spin %d must not hang in spinning"):format(spin), frameCount < maxFrames, true)
|
||||||
|
|
||||||
|
-- Resolve flash and payout phases
|
||||||
|
while (sm.phase == "flash" or sm.phase == "payoutText") and frameCount < maxFrames do
|
||||||
|
frameCount = frameCount + 1
|
||||||
|
if sm.phase == "payoutText" and sm.matched == SlotMachine.NO_MATCH then
|
||||||
|
mockInput:press("a")
|
||||||
|
end
|
||||||
|
sm:update(1/60)
|
||||||
|
end
|
||||||
|
|
||||||
|
check(("spin %d reached again phase"):format(spin), sm.phase == "again" or sm.phase == "bet", true)
|
||||||
|
if sm.phase == "again" then
|
||||||
|
mockInput:press("a") -- choose YES to play again
|
||||||
|
sm:update(1/60)
|
||||||
|
completedSpins = completedSpins + 1
|
||||||
|
elseif sm.phase == "bet" then
|
||||||
|
completedSpins = completedSpins + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
check("all 500 slot machine spins completed without softlock", completedSpins >= 490, true)
|
||||||
|
|
||||||
|
-- Test Card Flip 500-hand continuous loop
|
||||||
|
local cf = CardFlip.new(mockSlotGame)
|
||||||
|
local completedHands = 0
|
||||||
|
|
||||||
|
for hand = 1, 500 do
|
||||||
|
mockSave.player.coins = 5000
|
||||||
|
if cf.phase == "quit" then
|
||||||
|
cf = CardFlip.new(mockSlotGame)
|
||||||
|
end
|
||||||
|
|
||||||
|
local frameCount = 0
|
||||||
|
local maxFrames = 500
|
||||||
|
|
||||||
|
while cf.phase ~= "again" and cf.phase ~= "quit" and frameCount < maxFrames do
|
||||||
|
frameCount = frameCount + 1
|
||||||
|
if cf.phase == "ask" or cf.phase == "message" or cf.phase == "result"
|
||||||
|
or cf.phase == "choose" or cf.phase == "bet" then
|
||||||
|
mockInput:press("a")
|
||||||
|
end
|
||||||
|
cf:update(1/60)
|
||||||
|
end
|
||||||
|
|
||||||
|
check(("hand %d completed in bounds"):format(hand), frameCount < maxFrames, true)
|
||||||
|
|
||||||
|
if cf.phase == "again" then
|
||||||
|
mockInput:press("a") -- play again
|
||||||
|
cf:update(1/60)
|
||||||
|
completedHands = completedHands + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
check("all 500 card flip hands completed cleanly", completedHands >= 490, true)
|
||||||
|
|
||||||
print(("gen2 game corner: %d checks, %d failures"):format(checks, failures))
|
print(("gen2 game corner: %d checks, %d failures"):format(checks, failures))
|
||||||
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
|
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
|
||||||
-- here would take the whole tier down and silently skip every suite after it.
|
-- here would take the whole tier down and silently skip every suite after it.
|
||||||
|
|||||||
@@ -40,14 +40,16 @@ local mockGame = {
|
|||||||
|
|
||||||
print("Running SurfingMinigame unit tests...")
|
print("Running SurfingMinigame unit tests...")
|
||||||
|
|
||||||
-- Test 1: Initialization
|
-- Test 1: Initialization & Title Screen transition
|
||||||
local mg = SurfingMinigame.new(mockGame)
|
local mg = SurfingMinigame.new(mockGame)
|
||||||
|
assert_eq(mg.routine, -1, "Initial routine must be ROUTINE_TITLE (-1)")
|
||||||
|
mg:startFromTitle()
|
||||||
|
assert_eq(mg.routine, 0, "Routine must advance to ROUTINE_START_GAME (0) after startFromTitle()")
|
||||||
assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)")
|
assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)")
|
||||||
assert_eq(mg.speed, 0.25, "Initial speed must be 0.25")
|
assert_eq(mg.speed, 0.25, "Initial speed must be 0.25")
|
||||||
assert_eq(mg.distance, 0, "Initial distance must be 0")
|
assert_eq(mg.distance, 0, "Initial distance must be 0")
|
||||||
assert_eq(mg.routine, 0, "Initial routine must be ROUTINE_START_GAME (0)")
|
|
||||||
assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)")
|
assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)")
|
||||||
print("✓ Initial state test passed")
|
print("✓ Initial state & Title transition test passed")
|
||||||
|
|
||||||
-- Test 2: Start banner transition to RunGame
|
-- Test 2: Start banner transition to RunGame
|
||||||
for _ = 1, 40 do
|
for _ = 1, 40 do
|
||||||
@@ -65,6 +67,8 @@ assert_eq(mg.hp, initialHp - 1, "HP should decrease by 1 each frame")
|
|||||||
print("✓ Auto acceleration and HP countdown test passed")
|
print("✓ Auto acceleration and HP countdown test passed")
|
||||||
|
|
||||||
-- Test 4: Landing Evaluation Matrix
|
-- Test 4: Landing Evaluation Matrix
|
||||||
|
local old_getWaveTile = mg.getWaveTileUnderPika
|
||||||
|
mg.getWaveTileUnderPika = function() return 0x01 end -- force open water
|
||||||
mg.frameSet = 5
|
mg.frameSet = 5
|
||||||
assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing")
|
assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing")
|
||||||
mg.frameSet = 6
|
mg.frameSet = 6
|
||||||
@@ -77,6 +81,7 @@ for f = 8, 14 do
|
|||||||
mg.frameSet = f
|
mg.frameSet = f
|
||||||
assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout")
|
assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout")
|
||||||
end
|
end
|
||||||
|
mg.getWaveTileUnderPika = old_getWaveTile
|
||||||
print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)")
|
print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)")
|
||||||
|
|
||||||
-- Test 5: Stunt Scoring
|
-- Test 5: Stunt Scoring
|
||||||
@@ -145,6 +150,128 @@ end
|
|||||||
assert_eq(mg.radness, 0, "Radness should be tallied down to 0")
|
assert_eq(mg.radness, 0, "Radness should be tallied down to 0")
|
||||||
assert_eq(mg.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)")
|
assert_eq(mg.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)")
|
||||||
assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)")
|
assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)")
|
||||||
print("✓ Results tally countdown test passed")
|
-- Test 8: Crossing finish line while jumping upside-down crashes into water and rights Pikachu before results
|
||||||
|
local mg8 = SurfingMinigame.new(mockGame, nil, true)
|
||||||
|
mg8.routine = 1 -- ROUTINE_RUN_GAME
|
||||||
|
mg8.distanceFixed = (24 * 128 - 2) * 256
|
||||||
|
mg8.speedFixed = 512
|
||||||
|
mg8.pikaState = 1 -- PIKA_STATE_JUMPING
|
||||||
|
mg8.frameSet = 11 -- Upside down
|
||||||
|
mg8.pikaY = 60
|
||||||
|
mg8.jumpDescending = true
|
||||||
|
mg8.jumpArcMagnitude = 4
|
||||||
|
mg8.radness = 150
|
||||||
|
local preScore = mg8.radness
|
||||||
|
|
||||||
|
-- Update to cross the finish line
|
||||||
|
mg8:update()
|
||||||
|
assert_eq(mg8.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2) upon crossing finish")
|
||||||
|
assert_eq(mg8.pikaState, 1, "Pikachu should remain mid-air immediately after crossing line")
|
||||||
|
|
||||||
|
-- Update until Pikachu lands in water
|
||||||
|
while mg8.pikaState == 1 do
|
||||||
|
mg8:update()
|
||||||
|
end
|
||||||
|
assert_eq(mg8.pikaState, 3, "Upside-down landing post-finish line must trigger PIKA_STATE_CRASHED (3)")
|
||||||
|
assert_eq(mg8.radness, preScore, "Radness score must NOT change after crossing finish line")
|
||||||
|
assert_eq(mg8.crashTimer, 96, "Crash timer must be initialized to 96 frames")
|
||||||
|
|
||||||
|
-- Update while crashed to verify recovery
|
||||||
|
while mg8.pikaState == 3 do
|
||||||
|
mg8:update()
|
||||||
|
end
|
||||||
|
assert_eq(mg8.pikaState, 0, "Pikachu must recover back to PIKA_STATE_RIDING (0) and right itself on the board")
|
||||||
|
assert_eq(mg8.frameSet, 4, "Pikachu frameSet must be reset to upright (4)")
|
||||||
|
|
||||||
|
-- Let coasting finish and verify transition to results
|
||||||
|
while mg8.routine == 2 do
|
||||||
|
mg8:update()
|
||||||
|
end
|
||||||
|
assert_eq(mg8.routine, 3, "Routine should advance to ROUTINE_SCROLL_RESULTS (3) only after Pikachu is upright")
|
||||||
|
print("✓ Mid-air upside-down finish line crossing crash & recovery test passed")
|
||||||
|
|
||||||
|
-- Test 9: Crossing finish line while upright jumping lands cleanly and proceeds
|
||||||
|
local mg9 = SurfingMinigame.new(mockGame, nil, true)
|
||||||
|
mg9.routine = 1
|
||||||
|
mg9.distanceFixed = (24 * 128 - 2) * 256
|
||||||
|
mg9.speedFixed = 512
|
||||||
|
mg9.pikaState = 1
|
||||||
|
mg9.frameSet = 4 -- Clean flat
|
||||||
|
mg9.pikaY = 60
|
||||||
|
mg9.jumpDescending = true
|
||||||
|
mg9.jumpArcMagnitude = 4
|
||||||
|
mg9.radness = 200
|
||||||
|
preScore = mg9.radness
|
||||||
|
|
||||||
|
mg9:update()
|
||||||
|
assert_eq(mg9.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)")
|
||||||
|
|
||||||
|
while mg9.pikaState == 1 do
|
||||||
|
mg9:update()
|
||||||
|
end
|
||||||
|
assert_eq(mg9.pikaState, 2, "Upright landing post-finish line must trigger PIKA_STATE_LANDING (2)")
|
||||||
|
assert_eq(mg9.radness, preScore, "Radness score must NOT change post-finish")
|
||||||
|
|
||||||
|
while mg9.pikaState == 2 do
|
||||||
|
mg9:update()
|
||||||
|
end
|
||||||
|
assert_eq(mg9.pikaState, 0, "Pikachu must return to PIKA_STATE_RIDING (0)")
|
||||||
|
print("✓ Mid-air upright finish line crossing test passed")
|
||||||
|
|
||||||
|
-- Test 10: Crossing finish line while already crashed recovers before results
|
||||||
|
local mg10 = SurfingMinigame.new(mockGame, nil, true)
|
||||||
|
mg10.routine = 1
|
||||||
|
mg10.distanceFixed = (24 * 128 - 2) * 256
|
||||||
|
mg10.speedFixed = 512
|
||||||
|
mg10.pikaState = 3 -- PIKA_STATE_CRASHED
|
||||||
|
mg10.crashTimer = 50
|
||||||
|
|
||||||
|
mg10:update()
|
||||||
|
assert_eq(mg10.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)")
|
||||||
|
assert_eq(mg10.pikaState, 3, "Pikachu should still be crashed")
|
||||||
|
|
||||||
|
while mg10.pikaState == 3 do
|
||||||
|
mg10:update()
|
||||||
|
end
|
||||||
|
assert_eq(mg10.pikaState, 0, "Pikachu must recover upright before proceeding to results")
|
||||||
|
print("✓ Pre-crashed finish line crossing recovery test passed")
|
||||||
|
|
||||||
|
-- Test 11: Decoupled timestep accumulator (60Hz and 144Hz framerate consistency)
|
||||||
|
local mg11_60 = SurfingMinigame.new(mockGame, nil, true)
|
||||||
|
mg11_60.routine = 1 -- ROUTINE_RUN_GAME
|
||||||
|
for _ = 1, 60 do
|
||||||
|
mg11_60:update(1 / 60)
|
||||||
|
end
|
||||||
|
assert(mg11_60.t == 59 or mg11_60.t == 60, "60Hz update over 1s must produce approx 60 ticks (got " .. mg11_60.t .. ")")
|
||||||
|
|
||||||
|
local mg11_144 = SurfingMinigame.new(mockGame, nil, true)
|
||||||
|
mg11_144.routine = 1 -- ROUTINE_RUN_GAME
|
||||||
|
for _ = 1, 144 do
|
||||||
|
mg11_144:update(1 / 144)
|
||||||
|
end
|
||||||
|
assert(mg11_144.t == 59 or mg11_144.t == 60, "144Hz update over 1s must produce approx 60 ticks (got " .. mg11_144.t .. ")")
|
||||||
|
print("✓ Decoupled 59.7275Hz timestep accumulator test passed")
|
||||||
|
|
||||||
|
-- Test 12: Landing continuity on slopes (no position jumps while landing)
|
||||||
|
local mg12 = SurfingMinigame.new(mockGame, nil, true)
|
||||||
|
mg12.routine = 1
|
||||||
|
mg12.pikaState = 2 -- PIKA_STATE_LANDING
|
||||||
|
mg12.landingTimer = 20
|
||||||
|
mg12.speedFixed = 256
|
||||||
|
-- Place on a rising wave pattern
|
||||||
|
mg12.cols[5] = { pat = SurfingMinigame.WAVE_PATTERNS[0x06], hl = 110, hr = 100 }
|
||||||
|
mg12.distanceFixed = (5 * 16 - 80) * 256
|
||||||
|
local startY = mg12.pikaY
|
||||||
|
mg12:update()
|
||||||
|
assert(mg12.pikaY ~= startY, "pikaY must continuously follow wave surface height while in PIKA_STATE_LANDING")
|
||||||
|
print("✓ Landing slope height tracking continuity test passed")
|
||||||
|
|
||||||
|
-- Test 13: Fixed speed enforcement (minigames must always run at 1X speed)
|
||||||
|
assert(mg12.isFixedSpeed == true, "SurfingMinigame must have isFixedSpeed flag enabled")
|
||||||
|
assert(mg12.isMinigame == true, "SurfingMinigame must have isMinigame flag enabled")
|
||||||
|
local mockStack = { states = { mg12 } }
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
assert(Game.isFixedSpeedInStack(mockStack) == true, "Game.isFixedSpeedInStack must return true for SurfingMinigame")
|
||||||
|
print("✓ Minigame fixed speed enforcement test passed")
|
||||||
|
|
||||||
print("All SurfingMinigame unit tests passed successfully!")
|
print("All SurfingMinigame unit tests passed successfully!")
|
||||||
|
|||||||
+42
-2
@@ -2037,8 +2037,48 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
|
|||||||
_decode_2bpp(bytes(reordered), 40, 16),
|
_decode_2bpp(bytes(reordered), 40, 16),
|
||||||
os.path.join(assets_dir, "credits/the_end.png"))
|
os.path.join(assets_dir, "credits/the_end.png"))
|
||||||
|
|
||||||
raw_2bpp(
|
if _has_symbol(symbols, "SurfingPikachu1Graphics1"):
|
||||||
"WorldMapTileGraphics", 32, 32, "townmap/tiles.png")
|
raw_2bpp("SurfingPikachu1Graphics1", 40, 104, "minigame/surf_1a.png", transparent=False)
|
||||||
|
raw_2bpp("SurfingPikachu1Graphics2", 128, 128, "minigame/surf_1b.png", transparent=True)
|
||||||
|
raw_2bpp("SurfingPikachu1Graphics3", 96, 96, "minigame/surf_1c.png", transparent=True)
|
||||||
|
|
||||||
|
beach_intro = rom.bytes(62, 0x50bc, 240)
|
||||||
|
use_ctrl_pad = rom.bytes(62, 0x51ac, 15)
|
||||||
|
to_surf_rad = rom.bytes(62, 0x51bb, 13)
|
||||||
|
title_map = rom.bytes(62, 0x51c8, 72)
|
||||||
|
screen = [0xff] * (20 * 18)
|
||||||
|
for i in range(240):
|
||||||
|
screen[6 * 20 + i] = beach_intro[i]
|
||||||
|
for r in range(6):
|
||||||
|
for c in range(12):
|
||||||
|
screen[r * 20 + (4 + c)] = title_map[r * 12 + c]
|
||||||
|
for r in range(3):
|
||||||
|
for c in range(15):
|
||||||
|
screen[(7 + r) * 20 + (3 + c)] = 0xff
|
||||||
|
for i in range(15):
|
||||||
|
screen[7 * 20 + 3 + i] = use_ctrl_pad[i]
|
||||||
|
for i in range(13):
|
||||||
|
screen[9 * 20 + 4 + i] = to_surf_rad[i]
|
||||||
|
|
||||||
|
sym3 = _symbol(symbols, "SurfingPikachu1Graphics3")
|
||||||
|
raw_gfx3 = rom.bytes(sym3.bank, sym3.address, 144 * 16)
|
||||||
|
tiles = [_decode_2bpp(raw_gfx3[i*16:(i+1)*16], 8, 8) for i in range(144)]
|
||||||
|
blank = Image.new("RGBA", (8, 8), (255, 255, 255, 255))
|
||||||
|
title_bg = Image.new("RGBA", (160, 144), (255, 255, 255, 255))
|
||||||
|
for r in range(18):
|
||||||
|
for c in range(20):
|
||||||
|
t_id = screen[r * 20 + c]
|
||||||
|
if t_id == 0xff:
|
||||||
|
tile_img = blank
|
||||||
|
elif t_id >= 0x80:
|
||||||
|
idx = t_id - 0x80
|
||||||
|
tile_img = tiles[idx] if idx < 144 else blank
|
||||||
|
else:
|
||||||
|
idx = 128 + t_id
|
||||||
|
tile_img = tiles[idx] if idx < 144 else blank
|
||||||
|
title_bg.paste(tile_img, (c * 8, r * 8))
|
||||||
|
_save_png(title_bg, os.path.join(assets_dir, "minigame/title_bg.png"))
|
||||||
|
raw_2bpp("WorldMapTileGraphics", 32, 32, "townmap/tiles.png")
|
||||||
raw_1bpp(
|
raw_1bpp(
|
||||||
"TownMapCursor", 16, 16, "townmap/cursor.png",
|
"TownMapCursor", 16, 16, "townmap/cursor.png",
|
||||||
transparent=True)
|
transparent=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user