yellow alpha

This commit is contained in:
bryanthaboi
2026-07-29 11:46:32 -04:00
parent d6e36d457f
commit dde25ec7d0
54 changed files with 55593 additions and 698 deletions
+7
View File
@@ -158,15 +158,22 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local moveId = payload
local mdef = game.data.moves[moveId]
local function teach()
-- PIKAHAPPY_USEDTMHM on a successful teach (item_effects.asm:2500)
local function taught()
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "USEDTMHM", target)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end
taught()
else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
if learned then taught() end
end)
end
end
+45 -3
View File
@@ -127,6 +127,9 @@ local function deposit(game)
end
table.remove(game.save.party, item.value)
table.insert(active, mon)
-- PIKAHAPPY_DEPOSITED (engine/pokemon/bills_pc.asm:247)
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "DEPOSITED", mon)
local name = monName(game, mon)
game.stringBuffer = name
game.boxNumString = tostring(game.save.currentBox)
@@ -220,13 +223,43 @@ local function drawChrome(game)
love.graphics.setColor(1, 1, 1, 1)
end
-- PrintPCBox (engine/printer/printer.asm): Yellow's box-list print job,
-- box number plus each stored mon's name, level and dex number; the PNG
-- under prints/ stands in for the printer paper.
local function printBox(game)
local box = Boxes.active(game.save)
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local h = 32 + math.max(1, #box) * 10
local saved, err = Printer.save("box_" .. (game.save.currentBox or 1),
160, h, function()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, h)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BOX No.%d", game.save.currentBox or 1), 8, 8)
if #box == 0 then Font.draw(Strings("Empty."), 8, 24) end
for i, mon in ipairs(box) do
local def = game.data.pokemon[mon.species]
Font.draw(mon.nickname or (def and def.name) or tostring(mon.species),
8, 14 + i * 10)
Font.draw(Strings(":L%d No.%03d", mon.level or 0,
def and def.dex or 0), 88, 14 + i * 10)
end
love.graphics.setColor(1, 1, 1, 1)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed BOX %d!\fSaved as\n%s\vin the save\nfolder.",
game.save.currentBox or 1, saved)
or Strings("Printer error!\n%s", tostring(err))))
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
local items = {
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
@@ -235,10 +268,19 @@ function BoxMenu.new(game)
onSelect = function() release(game) end },
{ label = Strings("CHANGE BOX"), keepOpen = true,
onSelect = function() changeBox(game) end },
{ label = Strings("SEE YA!") },
}
-- Yellow's PRINT BOX item (bills_pc.asm _YELLOW -> PrintPCBox): the
-- Game Boy Printer box list becomes a PNG under prints/, like the
-- Pokédex PRNT stand-in
if require("src.core.GameVersion").isYellow() then
items[#items + 1] = { label = Strings("PRINT BOX"), keepOpen = true,
onSelect = function() printBox(game) end }
end
items[#items + 1] = { label = Strings("SEE YA!") }
local menu = Menu.new(game, items,
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
+13 -7
View File
@@ -55,11 +55,17 @@ function DexEntryMenu:update(dt)
end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local def = self.def
if self.sprite then
love.graphics.draw(self.sprite, 8, math.max(0, 60 - self.sprite:getHeight()))
if sprite then
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
@@ -70,10 +76,10 @@ function DexEntryMenu:draw()
Font.draw(e.kind or "?", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.forceOwned
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
@@ -84,7 +90,7 @@ function DexEntryMenu:draw()
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
end
local text = owned and e.text and self.game.data.text[e.text] or nil
local text = owned and e.text and game.data.text[e.text] or nil
local y = 72
if text then
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
+50
View File
@@ -0,0 +1,50 @@
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
-- player's name, shown by the Celadon Mansion 3F game designer once 150
-- species are owned. Diploma.render also backs the Yellow-only printed
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Diploma = {}
Diploma.__index = Diploma
Diploma.isOpaque = true
function Diploma.new(game, onDone)
return setmetatable({ game = game, onDone = onDone }, Diploma)
end
function Diploma:update()
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
function Diploma.render(game)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
local congrats = { -- hlcoord 2,6
"Congrats! This", "diploma certifies", "that you have",
"completed your", "POKéDEX.",
}
for i, line in ipairs(congrats) do
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
end
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
love.graphics.setColor(1, 1, 1, 1)
end
function Diploma:draw()
Diploma.render(self.game)
end
return Diploma
+1
View File
@@ -104,6 +104,7 @@ PartyMenu.iconFrames = {
FAIRY = { rest = 3, alt = 0 }, -- FairySprite tile 12 <-> tile 0
BIRD = { rest = 3, alt = 0 }, -- BirdSprite tile 12 <-> tile 0
WATER = { rest = 0, alt = 3 }, -- SeelSprite tile 0 <-> tile 12
PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 12
}
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
+29 -3
View File
@@ -57,7 +57,7 @@ function PokedexMenu.new(game, opts)
-- original, QUIT returns to the list
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
game.stack:push(Menu.new(game, {
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
@@ -67,8 +67,34 @@ function PokedexMenu.new(game, opts)
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
{ label = Strings("QUIT") },
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
}
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
-- stood in for by a PNG of the entry page saved under prints/.
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT") }
game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 }))
end,
})
list.sgbPalettes = PokedexMenu.sgbPalettes
+368
View File
@@ -0,0 +1,368 @@
-- Surfing Pikachu minigame (engine/minigame/surfing_pikachu.asm): the
-- Summer Beach House wave run. Paddle for speed, launch off the wave,
-- spin in the air and land flat for points; a crooked landing wipes out
-- and ends the run. The scene is built from the real ROM sheets
-- (gfx/surfing_pikachu.asm, ripped at import to
-- assets/generated/minigame/surf_1a/1b.png): the scalloped water tiles,
-- the beach with the palm and the doll hut, the "HP:" score strip with
-- the sheet digits, the cloud, and the OAM Pikachu poses -- the air
-- tricks quantize to the sheet's rotation frames like the original's
-- sprite anims, instead of free-rotating one pose. The original drew
-- the big wave with per-scanline scroll tricks (wLYOverrides); here the
-- crest profile is a curve filled with the sheet's foam/shade tiles.
-- Score model keeps the original's shape (ride ticks + airtime + full
-- rotations); high score persists in save.surfingHighScore for the
-- beach-house printer.
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local SurfingMinigame = {}
SurfingMinigame.__index = SurfingMinigame
SurfingMinigame.isOpaque = true
local PIKA_X = 44 -- fixed screen x while riding
local RUN_DISTANCE = 3200 -- scroll px from paddle-out to the beach
local GRAVITY = 0.14
local HORIZON = 24 -- sea starts under the sky strip
-- surf_1b quads: {x, y, w, h} in sheet pixels (pose pitch is 24x24)
local B = {
digits = { x = 0, y = 104 }, -- "0123456789", 8x8 each
good = { 0, 72, 32, 8 },
yeah = { 32, 72, 32, 8 },
ohno = { 80, 96, 48, 24 },
splash = { 48, 80, 32, 24 },
cloud = { 96, 112, 32, 8 },
paddle = { { 0, 80, 24, 24 }, { 24, 80, 24, 24 } },
}
-- rotation frames, 45-degree buckets clockwise from upright
local POSES = {
[0] = { 48, 0, 24, 24 }, -- upright ride
[45] = { 24, 0, 24, 24 }, -- nose down
[90] = { 0, 48, 24, 24 }, -- board vertical
[135] = { 48, 48, 24, 24 }, -- tumbling
[180] = { 72, 48, 24, 24 }, -- upside down
[225] = { 48, 48, 24, 24 },
[270] = { 0, 48, 24, 24 },
[315] = { 0, 0, 24, 24 }, -- tail down
}
-- surf_1a quads (BG tiles)
local A = {
scallop = { 16, 0, 8, 8 }, -- open-water pattern, row A
scallop2 = { 16, 8, 8, 8 }, -- row B variant
shade = { 8, 16, 8, 8 }, -- gray dither, wave belly
lip = { 24, 0, 8, 8 }, -- foam curl for the crest edge
palm = { 8, 32, 8, 8 }, -- palm fronds
beach = { 24, 32, 16, 8 }, -- black shore silhouette
hut = { 8, 40, 16, 8 }, -- the Pikachu doll hut on the sand
hp = { 20, 40, 20, 8 }, -- "HP:" score label
}
-- SGB-style zones: one sea palette over the frame plus a yellow
-- OBJ-flavored palette tracking Pikachu's tiles (rectangular attribute
-- blocks are all the SGB could do, bleed and all)
local SEA_PAL = { { 255, 255, 255 }, { 112, 184, 248 },
{ 56, 120, 216 }, { 0, 0, 0 } }
local PIKA_PAL = { { 255, 255, 255 }, { 248, 216, 64 },
{ 224, 144, 32 }, { 0, 0, 0 } }
local function newQuad(spec, img)
return love.graphics.newQuad(spec[1], spec[2], spec[3], spec[4],
img:getDimensions())
end
function SurfingMinigame.new(game, onDone)
local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame)
self.phase = "ride" -- ride | air | wipeout | results
self.t = 0
self.distance = 0
self.speed = 2
self.score = 0
self.rideTick = 0
self.y = 0 -- air offset above the wave (positive = up)
self.vy = 0
self.rot = 0 -- degrees, accumulates through the air
self.spins = 0
self.airFrames = 0
self.resultShown = 0
self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no..
local function sheet(path)
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
self.bg = sheet("assets/generated/minigame/surf_1a.png")
self.ob = sheet("assets/generated/minigame/surf_1b.png")
if self.bg then
self.aq = {}
for k, spec in pairs(A) do self.aq[k] = newQuad(spec, self.bg) end
end
if self.ob then
self.bq = {}
for k, spec in pairs(B) do
if spec[3] then self.bq[k] = newQuad(spec, self.ob) end
end
self.bq.paddle = { newQuad(B.paddle[1], self.ob),
newQuad(B.paddle[2], self.ob) }
self.bq.poses = {}
for deg, spec in pairs(POSES) do
self.bq.poses[deg] = newQuad(spec, self.ob)
end
self.bq.digit = {}
for d = 0, 9 do
self.bq.digit[d] = love.graphics.newQuad(B.digits.x + d * 8,
B.digits.y, 8, 8, self.ob:getDimensions())
end
end
Music.play(game.data, "Music_SurfingPikachu")
return self
end
-- crest height at screen x for the current scroll (two sines so the
-- wave rolls instead of looping visibly)
function SurfingMinigame:seaY(x)
local s = self.distance + x
return 92 - 14 * math.sin(s / 26) - 6 * math.sin(s / 9.5)
end
function SurfingMinigame:finishRun()
self.phase = "results"
local save = self.game.save
self.newRecord = self.score > (save.surfingHighScore or 0)
if self.newRecord then save.surfingHighScore = self.score end
Music.stop()
Sound.play(self.game.data, self.newRecord and "Get_Item1" or "Ball_Poof")
end
function SurfingMinigame:update()
local input = self.game.input
self.t = self.t + 1
if self.banner then
self.banner.frames = self.banner.frames - 1
if self.banner.frames <= 0 then self.banner = nil end
end
if self.phase == "results" then
self.resultShown = self.resultShown + 1
if self.resultShown > 30
and (input:wasPressed("a") or input:wasPressed("b")) then
self.game.stack:pop()
if self.onDone then self.onDone(self.score) end
end
return
end
if self.phase == "wipeout" then
self.splash = (self.splash or 0) + 1
if self.splash > 70 then self:finishRun() end
return
end
-- the wave scrolls by the current speed; the beach ends the run
self.distance = self.distance + 0.8 + self.speed * 0.35
if self.distance >= RUN_DISTANCE then
-- rode it all the way in: distance bonus like the original's goal
self.score = self.score + 500
self:finishRun()
return
end
if self.phase == "ride" then
-- paddling: mash A for speed, it bleeds off on its own
if input:wasPressed("a") and self.speed < 8 then
self.speed = self.speed + 1
end
if self.t % 45 == 0 and self.speed > 2 then
self.speed = self.speed - 1
end
self.rideTick = self.rideTick + 1
if self.rideTick % 12 == 0 then self.score = self.score + 1 end
-- launch off the lip
if input:wasPressed("up") then
self.phase = "air"
self.vy = 1.6 + self.speed * 0.45
self.rot, self.spins, self.airFrames = 0, 0, 0
Sound.play(self.game.data, "Ledge_Jump")
end
elseif self.phase == "air" then
self.airFrames = self.airFrames + 1
self.vy = self.vy - GRAVITY
self.y = self.y + self.vy
-- tricks: hold either direction to spin
local spin = (input:isDown("left") and -6 or 0)
+ (input:isDown("right") and 6 or 0)
self.rot = self.rot + spin
if math.abs(self.rot) >= (self.spins + 1) * 360 then
self.spins = self.spins + 1
end
if self.y <= 0 and self.vy < 0 then
self.y = 0
local tilt = math.abs(self.rot) % 360
if tilt <= 60 or tilt >= 300 then
-- clean landing: airtime + full rotations pay out
self.score = self.score + self.spins * 100
+ math.floor(self.airFrames / 4)
self.phase = "ride"
self.banner = { quad = self.spins > 0 and "yeah" or "good",
frames = 50 }
Sound.play(self.game.data, "Cut")
else
self.phase = "wipeout"
self.splash = 0
self.banner = { quad = "ohno", frames = 70 }
Sound.play(self.game.data, "Faint_Fall")
end
end
end
end
-- draw one 8x8 sheet tile quad at x, y
function SurfingMinigame:tile(q, x, y)
love.graphics.draw(self.bg, self.aq[q], x, y)
end
function SurfingMinigame:sgbPalettes()
local P = require("src.render.PaletteFX")
local zones = { P.whole(SEA_PAL) }
if self.phase ~= "wipeout" and self.phase ~= "results" then
local tx = math.floor((PIKA_X - 12) / 8)
local ty = math.floor(math.max(0, self.pikaScreenY or 60) / 8)
zones[#zones + 1] = P.zone(PIKA_PAL, tx, ty, tx + 3, ty + 3)
end
return zones
end
function SurfingMinigame:drawScore(x, y, n)
local s = tostring(n)
for i = 1, #s do
love.graphics.draw(self.ob, self.bq.digit[tonumber(s:sub(i, i))],
x + (i - 1) * 8, y)
end
end
function SurfingMinigame:draw()
local haveSheets = self.bg and self.ob
-- sky
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if not haveSheets then
-- cache predates the surf sheets: plain shapes keep it playable
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("SCORE %d", self.score), 4, 4)
love.graphics.rectangle("fill", PIKA_X - 8,
self:seaY(PIKA_X) - 16 - self.y, 16, 16)
love.graphics.setColor(1, 1, 1, 1)
return
end
-- cloud in the sky strip
love.graphics.draw(self.ob, self.bq.cloud, 112, 8)
-- open water: the scalloped pattern tiles the whole sea, phase-locked
-- to the scroll so the surface slides
local shift = math.floor(self.distance) % 8
for ty = HORIZON, 136, 8 do
local alt = (ty / 8) % 2 == 0
for tx = -8, 160, 8 do
self:tile(alt and "scallop" or "scallop2", tx - shift, ty)
end
end
-- the wave face: a white patch hugging the ride line (the original
-- carved it with per-scanline scroll; the ellipse stands in), with a
-- few scallops floating inside and the foam lip along its upper edge
local faceY = self:seaY(56) + 10
love.graphics.setColor(1, 1, 1, 1)
love.graphics.ellipse("fill", 56, faceY, 46, 30)
love.graphics.ellipse("fill", 100, faceY + 16, 40, 22)
for _, spot in ipairs({ { 30, 8 }, { 70, 16 }, { 48, 22 } }) do
self:tile("scallop", 56 - 46 + spot[1] - shift, faceY - 24 + spot[2])
end
local pikaY = self:seaY(PIKA_X) - 20 - self.y
for a = 205, 335, 18 do
local r = math.rad(a)
local lx = 56 + math.cos(r) * 44 - 4
local ly = faceY + math.sin(r) * 28 - 4
-- foam that would land inside Pikachu's SGB zone comes out orange;
-- leave that patch to the spray ellipse instead
if math.abs(lx - PIKA_X) > 28 or math.abs(ly - (pikaY + 12)) > 26 then
self:tile("lip", lx, ly)
end
end
self:tile("shade", 92 - shift, faceY + 20)
self:tile("shade", 116 - shift, faceY + 24)
-- beach slides through at the start and again before the goal
local beachX
if self.distance < 160 then
beachX = -self.distance
elseif self.distance > RUN_DISTANCE - 200 then
beachX = 160 - (self.distance - (RUN_DISTANCE - 200))
end
if beachX then
for tx = 0, 32, 8 do
self:tile("beach", beachX + tx, 128)
self:tile("beach", beachX + tx, 136)
end
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", beachX + 9, 118, 2, 10)
love.graphics.setColor(1, 1, 1, 1)
self:tile("palm", beachX + 6, 112)
self:tile("hut", beachX + 20, 118)
end
-- Pikachu. The white spray patch under him doubles as the yellow SGB
-- zone's backdrop: shade 0 maps to white in both palettes, so the
-- attribute-block bleed never shows on the water pattern.
love.graphics.setColor(1, 1, 1, 1)
local py = self:seaY(PIKA_X) - 20 - self.y
self.pikaScreenY = py -- the yellow SGB zone tracks this
love.graphics.ellipse("fill", PIKA_X, py + 12, 25, 21)
if self.phase == "wipeout" then
love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 16,
self:seaY(PIKA_X) - 16)
else
local quad
if self.phase == "ride" and self.speed <= 2
and self.distance < 120 then
quad = self.bq.paddle[math.floor(self.t / 8) % 2 + 1]
else
local bucket = math.floor(((self.rot % 360) + 22.5) / 45) % 8 * 45
quad = self.bq.poses[bucket] or self.bq.poses[0]
end
love.graphics.draw(self.ob, quad, PIKA_X - 12, py)
end
-- banner beats: GOOD! / YEAH- / Oh no..
if self.banner and self.bq[self.banner.quad] then
love.graphics.draw(self.ob, self.bq[self.banner.quad], 60, 40)
end
-- score strip, bottom right: HP: + sheet digits
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 100, 134, 60, 10)
love.graphics.draw(self.bg, self.aq.hp, 102, 135)
self:drawScore(126, 135, self.score)
if self.phase == "results" then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 20, 48, 120, 48)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 20.5, 48.5, 119, 47)
Font.draw(Strings("SCORE %d", self.score), 32, 56)
if self.newRecord then
Font.draw(Strings("New record!"), 32, 68)
else
Font.draw(Strings("HI %d", self.game.save.surfingHighScore or 0),
32, 68)
end
if self.resultShown > 30 then
Font.draw(Strings("A: done"), 32, 82)
end
love.graphics.setColor(1, 1, 1, 1)
end
end
return SurfingMinigame
+212 -44
View File
@@ -33,11 +33,25 @@ end
function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
local z
if self.yellowLayout then
-- Yellow's BlkPacket_Titlescreen (pokeyellow data/sgb/sgb_packets.asm):
-- rows 0-7 logo band pal 0 (PAL_LOGO2), rows 8-17 Pikachu + copyright
-- pal 2 (PAL_MEWMON), then the two bubble-tail cells at (9,8)-(10,8)
-- back on pal 0. No Red/Blue LOGO1 ribbon band.
local logoPal = P.pal(game.data, "LOGO2")
z = {
P.zone(logoPal, 0, 0, 19, 7),
P.zone(P.pal(game.data, "MEWMON"), 0, 8, 19, 17),
P.zone(logoPal, 9, 8, 10, 8),
}
else
z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
@@ -61,6 +75,14 @@ local BLUE_CYCLE_SPECIES = {
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
}
-- Yellow has no TitleMons table (engine/movie/title_yellow.asm is a fixed
-- Pikachu title). Until field.title.cycleSpecies is imported, keep a short
-- Pikachu-centric list so the Red/Blue cycling UI still has something to show.
local YELLOW_CYCLE_SPECIES = {
"PIKACHU", "EEVEE", "BULBASAUR", "CHARMANDER", "SQUIRTLE",
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
local function tryImage(path)
@@ -93,9 +115,36 @@ function TitleState.new(game, opts)
or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.blue = GameVersion.isBlue()
-- Blue cycles its own title mons and prints its ribbon contiguously; a
-- field.title.cycleSpecies override (mods / total conversions) still wins.
local defaultCycle = self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES
self.yellow = GameVersion.isYellow()
or title.layout == "yellow_pikachu"
-- Yellow title is a fixed Pikachu composition (title_yellow.asm), not
-- TitleMons cycling. Prefer composed pikachu.png from the Yellow import.
self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu)
or "assets/generated/title/pikachu.png") or nil
self.yellowBubble = self.yellow and tryImage(imagePath(title.pikaBubble)
or "assets/generated/title/pika_bubble.png") or nil
self.yellowLayout = self.yellow and self.yellowPikachu ~= nil
if self.yellowLayout then
-- title.asm boot: hSCY starts at $40 with the logo parked above the
-- viewport; .bouncePokemonLogoLoop drops it in with an overshoot
-- bounce, then the whoosh, the speech bubble, and PikachuCry1 before
-- the title music starts. Blink overlays are the OB tile swaps of
-- DoTitleScreenFunction.
self.eyesHalf = tryImage("assets/generated/title/eyes_half.png")
self.eyesClosed = tryImage("assets/generated/title/eyes_closed.png")
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = false
self.blinkTimer = 0
self.blinkAt = nil
else
self.phase = "loop"
self.showBubble = true
end
local defaultCycle = self.yellowLayout and { "PIKACHU" }
or (self.yellow and YELLOW_CYCLE_SPECIES)
or (self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES)
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or defaultCycle
@@ -107,6 +156,14 @@ function TitleState.new(game, opts)
end
function TitleState:enter()
-- Yellow defers the title theme until after the logo drop and
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
-- WaitForSoundToFinish on PikachuCry1)
if self.yellowLayout then return end
self:startMusic()
end
function TitleState:startMusic()
local data = self.game.data
local song = self.title.music or "Music_TitleScreen"
if data.audio and data.audio.songs and data.audio.songs[song] then
@@ -114,6 +171,83 @@ function TitleState:enter()
end
end
-- .TitleScreenPokemonLogoYScrolls: { dy per frame, frames }; the -3
-- rebound step lands with SFX_INTRO_CRASH
local DROP_STEPS = {
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
}
-- the boot cinematic up to the interactive loop; one call per frame
function TitleState:updateSequence()
local Sound = require("src.core.Sound")
local data = self.game.data
if self.phase == "drop" then
local step = DROP_STEPS[self.dropStep]
if not step then
self.phase = "settle"
self.timer = 0
return
end
if self.dropLeft == nil then
self.dropLeft = step[2]
if step[1] == -3 then Sound.play(data, "Intro_Crash") end
end
self.scy = self.scy + step[1]
self.dropLeft = self.dropLeft - 1
if self.dropLeft <= 0 then
self.dropStep = self.dropStep + 1
self.dropLeft = nil
end
elseif self.phase == "settle" then
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
self.timer = self.timer + 1
if self.timer >= 36 then
Sound.play(data, "Intro_Whoosh")
self.showBubble = true
self.phase = "bubble"
self.timer = 0
end
elseif self.phase == "bubble" then
self.timer = self.timer + 1
if self.timer >= 3 then
self.crySrc = Sound.playPikaCry(data, 1)
self.phase = "cry"
self.timer = 0
end
elseif self.phase == "cry" then
-- WaitForSoundToFinish before the music starts
self.timer = self.timer + 1
local playing = self.crySrc and self.crySrc.isPlaying
and self.crySrc:isPlaying()
if not playing or self.timer > 180 then
self.crySrc = nil
self:startMusic()
self.phase = "loop"
self.blinkTimer = 0
end
end
end
-- DoTitleScreenFunction.CheckTimer: an 8-bit frame counter blinks at 0,
-- $80 and $90; the blink itself runs half/closed/half over 9 frames
function TitleState:updateBlink()
local t = self.blinkTimer
self.blinkTimer = (t + 1) % 256
if t == 0 or t == 0x80 or t == 0x90 then self.blinkAt = 0 end
if self.blinkAt then
self.blinkAt = self.blinkAt + 1
if self.blinkAt > 9 then self.blinkAt = nil end
end
end
-- the blink overlay for this frame (nil = open eyes)
function TitleState:blinkOverlay()
local at = self.blinkAt
if not at then return nil end
if at <= 3 or at > 6 then return self.eyesHalf end
return self.eyesClosed
end
function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species]
@@ -219,9 +353,26 @@ function TitleState:openMenu()
end
function TitleState:update(dt)
if self.yellowLayout then
if self.phase ~= "loop" then
self:updateSequence()
return -- input is ignored until the cinematic lands (title.asm)
end
self:updateBlink()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- .go_to_main_menu voices PikachuCry11 on the way out
local Sound = require("src.core.Sound")
if not Sound.playPikaCry(self.game.data, 11) then
Sound.playCry(self.game.data, "PIKACHU")
end
self:openMenu()
end
return
end
self.timer = self.timer + 1
self.blink = (self.blink + 1) % 60
if self.timer >= CYCLE_FRAMES then
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
self.timer = 0
-- random pick that never repeats the current one
if #self.cycleSpecies > 1 then
@@ -238,9 +389,11 @@ function TitleState:update(dt)
end
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting)
-- the title mon cries when you leave the title (.finishedWaiting);
-- Yellow's fixed Pikachu title always cries Pikachu.
require("src.core.Sound").playCry(self.game.data,
self.cycleSpecies[self.cycleIndex])
self.yellowLayout and "PIKACHU"
or self.cycleSpecies[self.cycleIndex])
self:openMenu()
end
end
@@ -248,50 +401,65 @@ end
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
-- (4,8) 12x9 — no version ribbon, no cycling mon, no Red OAM.
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
if self.logo then
love.graphics.draw(self.logo, 16, 8)
love.graphics.draw(self.logo, 16, 8 + scrollY)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.blue and "POKéMON BLUE" or Strings("POKéMON RED"),
(160 - 12 * 8) / 2, 24)
local brand = self.yellow and "POKéMON YELLOW"
or (self.blue and "POKéMON BLUE" or Strings("POKéMON RED"))
Font.draw(brand, (160 - 12 * 8) / 2, 24 + scrollY)
love.graphics.setColor(1, 1, 1, 1)
end
if self.version then
local iw, ih = self.version:getDimensions()
if self.blue then
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
-- The extracted strip packs those eight glyph tiles into image tiles
-- 0..7 (tiles 8..9 are blank), so draw that 64px run at px (56, 64).
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
else
-- Red's strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version").
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
if self.yellowLayout then
-- everything scrolls together through the logo drop (rSCY): screen
-- y = BG y - SCY, so the composition rides at -scy until it lands
local dy = scrollY
if self.yellowBubble and self.showBubble then
love.graphics.draw(self.yellowBubble, 48, 32 + dy)
end
-- hlcoord 4,8 → px (32, 64); composed 13x9 tile sprite
love.graphics.draw(self.yellowPikachu, 32, 64 + dy)
local overlay = self:blinkOverlay()
if overlay then
-- the eye OAM band sits at (56,80) on the landed screen
love.graphics.draw(overlay, 32 + 24, 64 + 16 + dy)
end
else
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
-- the Yellow fallback layout draws no ribbon at all.
if self.version and not self.yellow then
local iw, ih = self.version:getDimensions()
if self.blue then
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
else
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end
end
local sprite = self:currentSprite()
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
136 - h)
end
if self.player then
love.graphics.draw(self.player, 82, 80)
end
end
local sprite = self:currentSprite()
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
136 - h)
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
love.graphics.draw(self.player, 82, 80)
end
love.graphics.setColor(0, 0, 0, 1)
-- the copyright row (tile 2,17); copyrightText because field.title's
-- copyright key already names the extracted image strip
Font.draw(self.title.copyrightText or Strings("2026 bois club games"), 1, 136)
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
1, 136 + scrollY)
love.graphics.setColor(1, 1, 1, 1)
end
+740
View File
@@ -0,0 +1,740 @@
-- Yellow's boot attract movie, a faithful port of PlayIntroScene
-- (pokeyellow engine/movie/intro_yellow.asm) over the extracted atlases
-- (gfx/intro/yellow_intro_1.2bpp -> intro/yellow_intro_1.png, atlas1,
-- 16x8 tiles; yellow_intro_2.2bpp -> yellow_intro_2.png, atlas2, 16x16
-- tiles; clouds.2bpp -> intro/clouds.png, two 4-tile frames).
--
-- Faithful pieces: the 18-scene jumptable with its 128/88-frame timers,
-- the animated-object system (YellowIntro_AnimatedObjectSpawnStateData /
-- Jumptable / FramesData / OAMData, data/sprite_anims/intro_frames.asm +
-- intro_oam.asm), the scene-7 per-scanline SCY sine wave
-- (YellowIntro_Copy8BitSineWave, +-4px period 32, rotated 1 line/frame),
-- the scene-3 SCX ramp to $68, the scene-11 cloud tile flip every 8
-- frames, and the scene-14/15/16 BGP strobe / fade sequences
-- (YellowIntroPalSequence_f9dd6 / _f9e0a). BGP composes with the SGB
-- colorization through sgbPalettes (PalPacket_Generic = MEWMON,
-- PalPacket_PikachusBeach = PIKACHUS_BEACH), like the title screen.
--
-- Deliberately dropped: the CGB-only OBJ-palette pokes of scenes 7/11
-- (Func_f98a2 / Func_f98cb recolor 5-6 tiles of the surf/fly sprite),
-- OBP-vs-BGP divergence during the strobes (one whole-screen shade map
-- stands in for both), and the never-spawned objects $0/$4 (dead code,
-- intro_yellow.asm:173).
--
-- Any of A/B/START skips the whole movie (PlayIntroScene:16-19). Pops
-- itself and calls onDone() when finished or skipped.
local Music = require("src.core.Music")
local YellowIntro = {}
YellowIntro.__index = YellowIntro
YellowIntro.isOpaque = true
-- ------- data tables (data/sprite_anims/intro_oam.asm) ----------------
-- OAM lists: rows of { dy, dx, tileDelta, flip }
local function grid(rows, cols, dy0, dx0, tileForRC)
local list = {}
for r = 0, rows - 1 do
for c = 0, cols - 1 do
list[#list + 1] = { dy0 + r * 8, dx0 + c * 8, tileForRC(r, c), false }
end
end
return list
end
local OAM = {}
-- Unkn_fa17e: 2x2, tiles +0/+1 over +$10/+$11
OAM.fa17e = grid(2, 2, -8, -8, function(r, c) return r * 0x10 + c end)
-- Unkn_fa18f: 16x32; bottom two rows mirror their left half
OAM.fa18f = {
{ -16, -8, 0x00 }, { -16, 0, 0x01 },
{ -8, -8, 0x10 }, { -8, 0, 0x11 },
{ 0, -8, 0x20 }, { 0, 0, 0x20, true },
{ 8, -8, 0x21 }, { 8, 0, 0x21, true },
}
-- Unkn_fa1b0: 32x40 (16-wide head rows, 32-wide mirrored body rows)
OAM.fa1b0 = {
{ -24, -8, 0x00 }, { -24, 0, 0x01 },
{ -16, -8, 0x02 }, { -16, 0, 0x03 },
{ -8, -16, 0x04 }, { -8, -8, 0x05 }, { -8, 0, 0x06 }, { -8, 8, 0x04, true },
{ 0, -16, 0x07 }, { 0, -8, 0x08 }, { 0, 0, 0x08, true }, { 0, 8, 0x07, true },
{ 8, -16, 0x09 }, { 8, -8, 0x0a }, { 8, 0, 0x0a, true }, { 8, 8, 0x09, true },
{ 16, -16, 0x0b }, { 16, -8, 0x0c }, { 16, 0, 0x0c, true }, { 16, 8, 0x0b, true },
}
-- Unkn_fa201: 6x6 = 48x48, row r uses tiles +$r0..+$r5
OAM.fa201 = grid(6, 6, -24, -24, function(r, c) return r * 0x10 + c end)
-- Unkn_fa292: 5x5 = 40x40, row bases $00,$05,$10,$15,$20
local FA292_ROW = { 0x00, 0x05, 0x10, 0x15, 0x20 }
OAM.fa292 = {}
for r = 0, 4 do
for c = 0, 4 do
OAM.fa292[#OAM.fa292 + 1] =
{ -20 + r * 8, -16 + c * 8, FA292_ROW[r + 1] + c, false }
end
end
-- Unkn_fa2f7: 32x8 mirrored streak
OAM.fa2f7 = {
{ -4, -16, 0x00 }, { -4, -8, 0x01 },
{ -4, 0, 0x01, true }, { -4, 8, 0x00, true },
}
-- Unkn_fa308: two mirrored 16x16 clusters, 32px apart
OAM.fa308 = {
{ -8, -24, 0x00 }, { -8, -16, 0x01 },
{ 0, -24, 0x02 }, { 0, -16, 0x03 },
{ -8, 8, 0x01, true }, { -8, 16, 0x00, true },
{ 0, 8, 0x03, true }, { 0, 16, 0x02, true },
}
-- Unkn_fa329: two mirrored 24x16 clusters
OAM.fa329 = {
{ -8, -40, 0x00 }, { -8, -32, 0x01 }, { -8, -24, 0x02 },
{ 0, -40, 0x10 }, { 0, -32, 0x11 }, { 0, -24, 0x12 },
{ -8, 16, 0x02, true }, { -8, 24, 0x01, true }, { -8, 32, 0x00, true },
{ 0, 16, 0x12, true }, { 0, 24, 0x11, true }, { 0, 32, 0x10, true },
}
-- frameId -> { atlas2 tile offset, OAM list }
local FRAMES = {
[0x01] = { 0x96, OAM.fa17e }, [0x02] = { 0x98, OAM.fa17e },
[0x03] = { 0x9a, OAM.fa17e },
[0x04] = { 0x0c, OAM.fa18f }, [0x05] = { 0x0e, OAM.fa18f },
[0x06] = { 0x3c, OAM.fa18f },
[0x07] = { 0x60, OAM.fa1b0 }, [0x08] = { 0x70, OAM.fa1b0 },
[0x09] = { 0x80, OAM.fa1b0 },
[0x0a] = { 0x90, OAM.fa201 }, [0x0b] = { 0x00, OAM.fa201 },
[0x0c] = { 0x06, OAM.fa201 },
[0x0d] = { 0xc6, OAM.fa292 },
[0x0e] = { 0x6d, OAM.fa2f7 },
[0x0f] = { 0xf0, OAM.fa308 }, [0x10] = { 0xf4, OAM.fa308 },
[0x11] = { 0xf8, OAM.fa308 },
[0x12] = { 0x9c, OAM.fa329 }, [0x13] = { 0xec, OAM.fa329 },
}
-- frame scripts (intro_frames.asm): { {frameId, duration}, ..., loop=bool }
local FRAMESETS = {
[1] = { { 0x01, 4 }, { 0x02, 4 }, { 0x03, 4 }, loop = true },
[2] = { { 0x04, 4 }, { 0x05, 4 }, { 0x06, 4 }, loop = true },
[3] = { { 0x07, 4 }, { 0x08, 4 }, { 0x09, 4 }, loop = true },
[5] = { { 0x0b, 32 } },
[6] = { { 0x0c, 32 } },
[7] = { { 0x0d, 32 } },
[8] = { { 0x0e, 32 } },
[9] = { { 0x0f, 31 }, { 0x11, 2 }, { 0x0f, 2 }, { 0x11, 2 },
{ 0x0f, 31 }, { 0x11, 2 }, { 0x0f, 23 }, { 0x10, 32 } },
[10] = { { 0x12, 4 }, { 0x13, 4 }, loop = true },
}
-- object id -> { frameset, seq } (YellowIntro_AnimatedObjectSpawnStateData;
-- seq indexes the movement jumptable)
local SPAWN = {
[1] = { 1, "static" }, [2] = { 2, "static" }, [3] = { 3, "static" },
[5] = { 5, "surf" }, [6] = { 6, "fly" }, [7] = { 7, "static" },
[8] = { 8, "bar" }, [9] = { 9, "static" }, [10] = { 10, "static" },
}
-- speed-bar spawn rows (YellowIntroFlyingSpeedBarData; first byte is X --
-- the source's "; y, x, speed" comment is wrong)
local SPEED_BARS = {
{ 0xD0, 0x20, 2 }, { 0xF0, 0x30, 4 }, { 0xD0, 0x40, 6 },
{ 0xC0, 0x50, 8 }, { 0xE0, 0x60, 8 }, { 0xC0, 0x70, 6 },
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
}
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
-- scene-10 BG tilemaps (gfx/intro/unknown_f9b6e/f9be6/f9bf2.tilemap)
local SKY_MAP = {
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x60,0x61,0x62 },
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00 },
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00,0x00 },
{ 0x60,0x61,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00,0x00,0x00 },
{ 0x00,0x00,0x63,0x60,0x61,0x60,0x61,0x02,0x02,0x02,0x02,0x60,0x61,0x62,0x00,0x00,0x00,0x00,0x00,0x00 },
{ 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x63,0x62,0x63,0x62,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 },
}
local BADGE_MAP = {
{ 0x30, 0x31, 0x32, 0x33 }, { 0x40, 0x41, 0x42, 0x43 },
{ 0x50, 0x51, 0x52, 0x53 },
}
local MARK_MAP = { { 0x12, 0x13 }, { 0x22, 0x23 } }
-- scene-14 strobe (YellowIntroPalSequence_f9dd6): 13 groups of
-- $e4,$c0,$c0,$e4 with the 52nd byte replaced by the terminator
local STROBE_SEQ = {}
for i = 1, 51 do
local m = (i - 1) % 4
STROBE_SEQ[i] = (m == 1 or m == 2) and 0xC0 or 0xE4
end
-- scene-16 fade to white (YellowIntroPalSequence_f9e0a)
local FADE_SEQ = { 0xE4, 0x90, 0x90, 0x40, 0x40, 0x00, 0x00 }
-- Func_fa079's sine bob (Unkn_fa0aa, sine_table 32). The ROM table's
-- `dw sin(x)` truncates the 1.0 peak to $0000, which pops the sprite 8px
-- for one frame at every crest (a=16 / a=48) on hardware; the true peak
-- is restored here so the balloon glide loops smoothly.
local function bobOffset(phase)
local a = phase % 64
local half = a % 32
local v = math.floor(8 * math.sin(math.pi * half / 32))
return a < 32 and v or -v
end
local function tryImage(path)
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
-- ------- state --------------------------------------------------------
function YellowIntro.new(game, onDone)
local self = setmetatable({}, YellowIntro)
self.game = game
self.onDone = onDone
self.finished = false
self.scene = 0
self.timer = 0
self.seqIndex = 0
self.scx = 0
self.bgp = 0xE4
self.palName = "MEWMON" -- PalPacket_Generic
self.objects = {}
self.cloudFrame = 0
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
self.clouds = tryImage("assets/generated/intro/clouds.png")
self.quads = {}
-- 32x32 BG tile grid (vBGMap0); signed addressing: id < $80 -> atlas1,
-- id >= $80 -> atlas2 (LCDC $e3, bit4 = 0)
self.bg = {}
self:bgLetterbox()
self.bgDirty = true
self.wave = nil
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
self.bgCanvas = ok and canvas or nil
-- Yellow boots exactly like Red up to the attract movie: the copyright
-- card and the GAME FREAK shooting-star splash play first. Reuse
-- IntroMovie's phases 1-2 and take over where its Gengar fight (phase
-- 3) would begin; a skip press during the pre-roll skips everything.
local IntroMovie = require("src.ui.IntroMovie")
local pre = IntroMovie.new(game, nil)
local baseStart = pre.startPhase
pre.finish = function(m)
if m.finished then return end
m.finished = true
self.pre = nil
self:finish()
end
pre.startPhase = function(m, phase)
if phase == 3 then
m.finished = true
self.pre = nil
self:beginScenes()
else
baseStart(m, phase)
end
end
self.pre = pre
return self
end
function YellowIntro:sgbPalettes(game)
if self.pre then return self.pre:sgbPalettes(game) end
local P = require("src.render.PaletteFX")
local pal = P.pal(game.data, self.palName)
if not pal then return nil end
-- rBGP composed with the SGB colors: shade i displays palette color
-- ((bgp >> 2i) & 3), whole screen (one map stands in for BGP and OBP)
local bgp = self.bgp
local map = {}
for i = 0, 3 do
map[i] = math.floor(bgp / 4 ^ i) % 4
end
return { P.whole(P.permute(pal, map)) }
end
-- ------- BG helpers ---------------------------------------------------
function YellowIntro:bgFill(id)
for y = 0, 31 do
local row = self.bg[y] or {}
self.bg[y] = row
for x = 0, 31 do row[x] = id end
end
self.bgDirty = true
end
-- Func_f9e5f: rows 0-3 / 14-17 tile $01, rows 4-13 tile $00
function YellowIntro:bgLetterbox()
self:bgFill(0x01)
for y = 4, 13 do
for x = 0, 31 do self.bg[y][x] = 0x00 end
end
for y = 18, 31 do
for x = 0, 31 do self.bg[y][x] = 0x00 end
end
self.bgDirty = true
end
function YellowIntro:bgBlit(col, row, map)
for r, line in ipairs(map) do
for c, id in ipairs(line) do
self.bg[(row + r - 1) % 32][(col + c - 1) % 32] = id
end
end
self.bgDirty = true
end
function YellowIntro:quadFor(image, tile)
local key = image
local cacheByImage = self.quads[key]
if not cacheByImage then
cacheByImage = {}
self.quads[key] = cacheByImage
end
local quad = cacheByImage[tile]
if not quad then
local iw, ih = image:getDimensions()
quad = love.graphics.newQuad(
(tile % 16) * 8, math.floor(tile / 16) * 8, 8, 8, iw, ih)
cacheByImage[tile] = quad
end
return quad
end
function YellowIntro:rebuildBgCanvas()
if not self.bgCanvas then return end
love.graphics.push("all")
love.graphics.setCanvas(self.bgCanvas)
love.graphics.clear(1, 1, 1, 1)
love.graphics.setColor(1, 1, 1, 1)
for y = 0, 31 do
for x = 0, 31 do
local id = self.bg[y][x]
local image, tile
if id < 0x80 then
image, tile = self.atlas1, id
else
image, tile = self.atlas2, id
end
if image then
-- scene-11 cloud animation retargets BG tiles $60-$63 at the
-- clouds sheet (VBlank copy to $9600); frame = clouds row 0/1
if self.clouds and id >= 0x60 and id <= 0x63 then
local cw, ch = self.clouds:getDimensions()
love.graphics.draw(self.clouds,
love.graphics.newQuad((id - 0x60) * 8, self.cloudFrame * 8,
8, 8, cw, ch), x * 8, y * 8)
else
love.graphics.draw(image, self:quadFor(image, tile), x * 8, y * 8)
end
end
end
end
love.graphics.pop()
self.bgDirty = false
end
-- ------- objects ------------------------------------------------------
function YellowIntro:spawn(id, x, y)
local spec = SPAWN[id]
local obj = {
id = id, frameset = spec[1], seq = spec[2],
x = x, y = y, xoff = 0, yoff = 0,
step = 1, wait = 0, held = false,
fieldB = 0, fieldC = 0,
}
local script = FRAMESETS[obj.frameset]
obj.wait = script[1][2]
self.objects[#self.objects + 1] = obj
return obj
end
function YellowIntro:clearObjects()
self.objects = {}
end
local function updateFrameScript(obj)
if obj.held then return end
obj.wait = obj.wait - 1
if obj.wait > 0 then return end
local script = FRAMESETS[obj.frameset]
if obj.step >= #script then
if script.loop then
obj.step = 1
obj.wait = script[1][2]
else
obj.held = true -- endanim: hold last frame forever
end
return
end
obj.step = obj.step + 1
obj.wait = script[obj.step][2]
end
function YellowIntro:updateObjects()
for _, obj in ipairs(self.objects) do
if obj.seq == "bar" then
-- Func_fa062: constant velocity, 8-bit wrap
obj.x = (obj.x + obj.fieldB) % 256
elseif obj.seq == "surf" then
-- Func_fa014, including the original's Y = X + 1 quirk: the
-- comparison register still holds X when Y is written, so the
-- sprite rides a 45-degree diagonal until X parks at $58
if obj.x ~= 0x58 then
obj.x = (obj.x + 4) % 256
obj.y = (obj.x + 1) % 256
end
elseif obj.seq == "fly" then
-- Func_fa02b: rise 2px/frame to Y=$58, then a +-8px sine bob
-- with a 64-frame period (and the truncated-peak notch)
if obj.fieldB == 0 then
if obj.y ~= 0x58 then
obj.y = (obj.y - 2) % 256
else
obj.fieldB = 1
end
end
if obj.fieldB == 1 then
obj.yoff = bobOffset(obj.fieldC)
obj.fieldC = obj.fieldC + 1
end
end
updateFrameScript(obj)
end
end
function YellowIntro:drawObjects()
if not self.atlas2 then return end
for _, obj in ipairs(self.objects) do
local frameId = FRAMESETS[obj.frameset][obj.step][1]
local frame = FRAMES[frameId]
if frame then
local base, list = frame[1], frame[2]
for _, entry in ipairs(list) do
local dy, dx, delta, flip = entry[1], entry[2], entry[3], entry[4]
-- OAM position: screen = (X + dx - 8, Y + dy - 16)
local px = (obj.x + obj.xoff + dx - 8) % 256
local py = (obj.y + obj.yoff + dy - 16) % 256
if px < 160 and py < 144 then
local quad = self:quadFor(self.atlas2, base + delta)
if flip then
love.graphics.draw(self.atlas2, quad, px + 8, py, 0, -1, 1)
else
love.graphics.draw(self.atlas2, quad, px, py)
end
end
end
end
end
end
-- ------- scenes -------------------------------------------------------
-- setup scenes run once and advance immediately; wait scenes count their
-- timer down (YellowIntro_CheckFrameTimerDecrement: N running frames,
-- expiry actions on frame N+1)
function YellowIntro:startScene(scene)
self.scene = scene
local t = self
if scene == 0 then
-- running pika 1 over the boot letterbox
t.palName = "MEWMON"
t.scx = 0
t:bgLetterbox()
t:spawn(1, 0x58, 0x58)
t.timer = 130
t.scene = 1
elseif scene == 2 then
-- pikachu kick: 6x6 atlas2 block parked at BG col 20 row 6 (scrolled
-- in by scene 3) + 8 speed bars
t:bgFill(0x00)
local block = {}
for r = 0, 5 do
local line = {}
for c = 0, 5 do line[c + 1] = 0x90 + r * 0x10 + c end
block[r + 1] = line
end
t:bgBlit(20, 6, block)
for _, bar in ipairs(SPEED_BARS) do
local obj = t:spawn(8, bar[1], bar[2])
obj.fieldB = bar[3]
end
t.palName = "PIKACHUS_BEACH"
t.timer = 128
t.scene = 3
elseif scene == 4 then
-- running pika 2
t:clearObjects()
t.scx = 0
t:bgLetterbox()
t:spawn(2, 0x58, 0x58)
t.palName = "MEWMON"
t.timer = 128
t.scene = 5
elseif scene == 6 then
-- surfing pika over the wavy sea (per-scanline SCY sine)
t.scx = 0
t.wave = {}
for i = 0, 255 do t.wave[i] = WAVE[i % 32 + 1] end
t:bgFill(0x10)
for y = 0, 2 do
for x = 0, 31 do t.bg[y][x] = 0x00 end
end
for x = 0, 31 do t.bg[3][x] = x % 2 == 0 and 0x20 or 0x21 end
t:spawn(5, 0xF8, 0x40)
t.palName = "PIKACHUS_BEACH"
t.bgDirty = true
t.timer = 88
t.scene = 7
elseif scene == 8 then
-- running pika 3
t:clearObjects()
t.wave = nil
t.scx = 0
t:bgLetterbox()
t:spawn(3, 0x58, 0x58)
t.palName = "MEWMON"
t.timer = 128
t.scene = 9
elseif scene == 10 then
-- flying pika over clouds + badge + mark
t:clearObjects()
t.scx = 0
t:bgFill(0x00)
for y = 0, 7 do
for x = 0, 31 do t.bg[y][x] = 0x02 end
end
t:bgBlit(0, 8, SKY_MAP)
t:bgBlit(12, 4, BADGE_MAP)
t:bgBlit(3, 7, MARK_MAP)
t:spawn(6, 0x58, 0x98)
t.palName = "PIKACHUS_BEACH"
t.timer = 128
t.scene = 11
elseif scene == 12 then
-- pika close-up: 12x8 atlas1 paste at BG (5,6) + fixups
t:clearObjects()
t.scx = 0
t:bgLetterbox()
local paste = {}
for r = 0, 7 do
local line = {}
for c = 0, 11 do line[c + 1] = 0x04 + r * 0x10 + c end
paste[r + 1] = line
end
t:bgBlit(5, 6, paste)
t.bg[6][4] = 0x03
t.bg[7][4] = 0x74
t.bg[13][5] = 0x00
t:spawn(9, 0x58, 0x60)
t.palName = "MEWMON"
t.timer = 128
t.scene = 13
elseif scene == 14 then
-- thunderbolt strobe; timer reused as the sequence index
t.seqIndex = 0
t.scene = 14
elseif scene == 15 then
t.timer = 40
t.scene = 15
elseif scene == 16 then
t.seqIndex = 0
t.scene = 16
elseif scene == 17 then
t.timer = 64
t.scene = 17
end
end
function YellowIntro:enter()
if self.pre then return end -- pre-roll first; beginScenes takes over
self:beginScenes()
end
-- InitYellowIntroGFXAndMusic: the movie's own music starts with scene 0
function YellowIntro:beginScenes()
local data = self.game.data
local songs = data.audio and data.audio.songs
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
or songs.Music_IntroBattle and "Music_IntroBattle")
if song then pcall(Music.play, data, song, false) end
self:startScene(0)
end
function YellowIntro:finish()
if self.finished then return end
self.finished = true
pcall(Music.stop)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function YellowIntro:update(dt)
if self.finished then return end
if self.pre then
self.pre:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
self:finish()
return
end
local scene = self.scene
if scene == 1 or scene == 5 or scene == 9 then
if self.timer > 0 then
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(scene + 1)
end
elseif scene == 3 then
if self.timer > 0 then
self.timer = self.timer - 1
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
else
self:clearObjects()
self:startScene(4)
end
elseif scene == 7 then
if self.timer > 0 then
self.timer = self.timer - 1
self.scx = (self.scx + 2) % 256
-- rotate the sine phase 1 scanline per frame
local first = self.wave[0]
for i = 0, 254 do self.wave[i] = self.wave[i + 1] end
self.wave[255] = first
else
self:clearObjects()
self:startScene(8)
end
elseif scene == 11 then
if self.timer > 0 then
-- cloud tiles swap every 8 frames (YellowIntroScene11)
if self.timer % 8 == 0 then
local frame = math.floor(self.timer / 8) % 2
if frame ~= self.cloudFrame then
self.cloudFrame = frame
self.bgDirty = true
end
end
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(12)
end
elseif scene == 13 then
if self.timer > 0 then
self.timer = self.timer - 1
else
-- spawn the thunderbolt over the close-up (object $A stays with $9)
self:spawn(10, 0x58, 0x68)
self:startScene(14)
end
elseif scene == 14 then
self.seqIndex = self.seqIndex + 1
local v = STROBE_SEQ[self.seqIndex]
if v then
self.bgp = v
else
-- .expired: everything despawns, letterbox returns, logo/face
-- object $7 appears for the strobe scene
self:clearObjects()
self:bgLetterbox()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
end
elseif scene == 15 then
if self.timer > 0 then
if self.timer % 4 == 0 then
-- rBGP ^= $03: flips how the two lightest shades display
self.bgp = self.bgp == 0xE4 and 0xE7 or 0xE4
end
self.timer = self.timer - 1
else
self.bgp = 0xE4
self:startScene(16)
end
elseif scene == 16 then
self.seqIndex = self.seqIndex + 1
local v = FADE_SEQ[self.seqIndex]
if v then
self.bgp = v
else
self:startScene(17)
end
elseif scene == 17 then
if self.timer > 0 then
self.timer = self.timer - 1
else
self:finish()
return
end
end
self:updateObjects()
if self.bgDirty then self:rebuildBgCanvas() end
end
function YellowIntro:draw()
if self.pre then
self.pre:draw()
return
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.bgCanvas then
if self.wave then
-- per-scanline SCY override, LY $10-$7F only (scene 7's VBlank
-- copy covers just that band; the rest render unshifted). Each
-- strip wraps horizontally like the BG map: SCX climbs past 96
-- during the scene and a single quad would clamp at the canvas
-- edge and smear the right of the screen.
local cw, ch = self.bgCanvas:getDimensions()
local sx = self.scx % 256
local w1 = math.min(160, 256 - sx)
for ly = 0, 143 do
local dy = (ly >= 16 and ly < 128) and self.wave[ly] or 0
local sy = (ly + dy) % 256
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(sx, sy, w1, 1, cw, ch), 0, ly)
if w1 < 160 then
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(0, sy, 160 - w1, 1, cw, ch), w1, ly)
end
end
else
local cw, ch = self.bgCanvas:getDimensions()
local sx = self.scx % 256
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(sx, 0, math.min(160, 256 - sx), 144, cw, ch),
0, 0)
if sx > 96 then
-- horizontal wrap (scene 3 scrolls the kick block in from col 20)
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(0, 0, 160 - (256 - sx), 144, cw, ch),
256 - sx, 0)
end
end
end
self:drawObjects()
love.graphics.setColor(1, 1, 1, 1)
end
return YellowIntro