initial commit

This commit is contained in:
bryanthaboi
2026-07-17 20:30:02 -04:00
commit a5d2e77e7d
298 changed files with 100561 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
-- The bag: lists inventory, uses items via ItemEffects.
-- opts.battle = BattleState when opened mid-battle (balls throwable,
-- using an item consumes the turn).
local ItemEffects = require("src.inventory.ItemEffects")
local ListMenu = require("src.ui.ListMenu")
local TextBox = require("src.render.TextBox")
local BagMenu = {}
local Bag = require("src.inventory.Bag")
-- acquisition order like wBagItems (Bag.order), not alphabetical
local function buildItems(game)
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
table.insert(items, {
value = id,
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
})
end
return items
end
local function consume(game, id)
Bag.remove(game.save, id, 1)
end
local function save_name(game)
return game.save.player.name
end
local function showMessages(game, msgs, onDone)
if not msgs or #msgs == 0 then
if onDone then onDone() end
return
end
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
end
-- run the use-flow for an item on a chosen target
local function useOn(game, battle, id, target, list, moveIndex)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld)
-- field POKé FLUTE: play the tune, then the no-effect text
if result == "flute_field" then
require("src.core.Sound").play(game.data, "Pokeflute")
showMessages(game, payload)
return
end
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
if result == "flute_wake" then
list:close()
require("src.core.Sound").play(game.data, "Pokeflute")
showMessages(game, payload, function()
local ow = game.overworld
local mod = ow and require("data.scripts.init").get(extra.mapId)
if ow and mod and mod.snorlaxWake then
ow.runner:run(mod.snorlaxWake.script, { npc = extra.npc })
end
end)
return
end
if result == "consumed_escape" then -- Poké Doll
consume(game, id)
list:close()
showMessages(game, payload, function()
battle.result = "run"
battle.afterQueue = "finish"
battle.phase = "messages"
end)
return
end
if result == "bicycle" then
list:close()
local ow = game.overworld
local Music = require("src.core.Music")
-- IsBikeRidingAllowed (home/overworld.asm): the tilesets of
-- bike_riding_tilesets.asm, plus Route 23 / Indigo Plateau by
-- map id. Reads the extracted allowlist when present.
local function bikeAllowed()
if not ow then return false end
local br = game.data.field.bikeRiding
or { tilesets = { "OVERWORLD", "FOREST", "UNDERGROUND",
"SHIP_PORT", "CAVERN" },
maps = { "ROUTE_23", "INDIGO_PLATEAU" } }
for _, m in ipairs(br.maps or {}) do
if ow.map.id == m then return true end
end
for _, t in ipairs(br.tilesets or {}) do
if ow.map.def.tileset == t then return true end
end
return false
end
if game.save.onBike then
game.save.onBike = false
Music.playMap(game.data, ow and ow.map.id, false)
showMessages(game, { save_name(game) .. " got off\nthe BICYCLE." })
elseif bikeAllowed() then
game.save.onBike = true
Music.playMap(game.data, ow.map.id, true)
showMessages(game, { save_name(game) .. " got on\nthe BICYCLE!" })
else
showMessages(game, { "No cycling\nallowed here." })
end
return
end
if result == "fish" then
list:close()
local ow = game.overworld
local p = ow and ow.player
if ow and p then
local fx, fy = p:facingCell()
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
ow:goFishing(id)
return
end
end
showMessages(game, { "No good! It's not\neven near water." })
return
end
if result == "ball" then
if not battle then
showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" })
return
end
consume(game, id)
list:close()
battle:throwBall(id)
return
end
if result == "learn" or result == "learnkept" then
local moveId = payload
local mdef = game.data.moves[moveId]
local function teach()
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
showMessages(game, { ("%s learned\n%s!"):format(target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end
else
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
game.stack:push(MoveLearnMenu.new(game, target, moveId, function(learned)
if learned and result == "learn" then consume(game, id) end
end))
end
end
list:close()
teach()
return
end
-- the TOWN MAP screen (engine/menus/town_map.asm)
if result == "townmap" then
local ok, TownMap = pcall(require, "src.ui.TownMap")
if ok then
game.stack:push(TownMap.new(game))
else
showMessages(game, { "The TOWN MAP is\nunreadable here." })
end
return
end
-- ITEMFINDER (engine/items/itemfinder.asm): responds if the current
-- map still has an unfound hidden item
if result == "itemfinder" then
local ow = game.overworld
local t = game.data.text
if ow and ow:hasHiddenItemLeft() then
showMessages(game, { t._ItemfinderFoundItemText
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." })
else
showMessages(game, { t._ItemfinderFoundNothingText
or "Nope! ITEMFINDER\nisn't responding." })
end
return
end
-- POKé FLUTE in battle: not consumed, but uses the turn
if result == "flute" then
list:close()
require("src.core.Sound").play(game.data, "Pokeflute")
showMessages(game, payload, function() battle:itemUsed({}) end)
return
end
if result == "escape_rope" then
-- ItemUseEscapeRope: only inside the dungeon tilesets
-- (escape_rope_tilesets.asm), never in Agatha's room, and it sets
-- BIT_ESCAPE_WARP so special_warps.asm warps to wLastBlackoutMap
-- -- the last Pokémon Center town, same as Dig/Teleport (NOT the
-- spot you entered the dungeon from)
local ESCAPE_ROPE_TILESETS = { FOREST = true, CEMETERY = true,
CAVERN = true, FACILITY = true,
INTERIOR = true }
local ow = game.overworld
if ow and ESCAPE_ROPE_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
list:close()
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
ow:warpToHealPoint()
else
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" })
end
return
end
if result == "consumed" then
consume(game, id)
if extra and extra.evolveTo then
list:close()
local Evolution = require("src.pokemon.Evolution")
Evolution.evolve(game, target, extra.evolveTo)
return
end
-- RARE CANDY: after the level text, the stat window, any level-up
-- moves and a level evolution follow (item_effects.asm .useRareCandy
-- runs PrintStatsBox, LearnMoveFromLevelUp and TryEvolvingMon)
if extra and extra.leveledTo and target then
list:close()
showMessages(game, payload, function()
local StatBox = require("src.battle.BattleState").StatBox
game.stack:push(StatBox.new(game, target, function()
local Experience = require("src.battle.Experience")
local def = game.data.pokemon[target.species]
local moves = Experience.movesLearnedAt(def, extra.leveledTo)
local i = 0
local function nextStep()
i = i + 1
local moveId = moves[i]
if not moveId then
local Evolution = require("src.pokemon.Evolution")
local evoTo = Evolution.pendingLevelEvo(game.data, target)
if evoTo then Evolution.evolve(game, target, evoTo) end
return
end
for _, mv in ipairs(target.moves) do
if mv.id == moveId then return nextStep() end
end
local mdef = game.data.moves[moveId]
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
local name = target.nickname or def.name
showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) },
nextStep)
else
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
game.stack:push(MoveLearnMenu.new(game, target, moveId, nextStep))
end
end
nextStep()
end))
end)
return
end
-- refresh counts in the list
for i, it in ipairs(list.items) do
if it.value == id then
local left = game.save.inventory[id]
if left then it.right = "x" .. left else table.remove(list.items, i) end
break
end
end
list.index = math.min(list.index, math.max(1, #list.items))
if battle then
list:close()
showMessages(game, payload, function() battle:itemUsed({}) end)
else
showMessages(game, payload)
end
return
end
showMessages(game, payload) -- failed
end
local function useItem(game, battle, id, list)
local def = game.data.items[id]
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- pick a target from the party
local PartyMenu = require("src.ui.PartyMenu")
-- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
useOn(game, battle, id, mon, list)
return
end
local rows = {}
for mi, mv in ipairs(mon.moves) do
local mdef = game.data.moves[mv.id]
table.insert(rows, {
value = mi,
label = mdef and mdef.name or mv.id,
right = ("%d"):format(mv.pp),
})
end
game.stack:push(ListMenu.new(game, "Which move?", rows, {
onChoose = function(row, l)
l:close()
useOn(game, battle, id, mon, list, row.value)
end,
}))
end,
}))
else
useOn(game, battle, id, nil, list)
end
end
function BagMenu.new(game, opts)
opts = opts or {}
local battle = opts.battle
local list
list = ListMenu.new(game, "ITEMS", buildItems(game), {
footer = ("¥%d"):format(game.save.money),
-- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l)
if not item then return end
if not l.swapIndex then
l.swapIndex = l.index
return
end
local order = Bag.order(game.save)
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
l.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
l.items = buildItems(game)
end,
onChoose = function(item)
local id = item.value
local def = game.data.items[id]
if list.swapIndex then -- A also completes a pending swap
local order = Bag.order(game.save)
order[list.swapIndex], order[list.index] = order[list.index], order[list.swapIndex]
list.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
list.items = buildItems(game)
return
end
if battle then -- no tossing mid-battle
useItem(game, battle, id, list)
return
end
-- USE / TOSS submenu (the original's item options)
local Menu = require("src.ui.Menu")
game.stack:push(Menu.new(game, {
{ label = "USE", onSelect = function()
useItem(game, battle, id, list)
end },
{ label = "TOSS", onSelect = function()
-- KeyItemFlags + HMs decide tossability (not price:
-- MOON STONE is price 0 but tossable)
if not def or def.keyItem or id:find("^HM_") then
showMessages(game, { "That's too impor-\ntant to toss!" })
return
end
local QuantityBox = require("src.ui.QuantityBox")
game.stack:push(QuantityBox.new(game, {
max = game.save.inventory[id] or 1,
onDone = function(qty)
if not qty then return end
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
Bag.remove(game.save, id, qty)
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
showMessages(game, { ("Threw away\n%s."):format(def and def.name or id) })
end))
end,
}))
end },
}, { tx = 12, ty = 10, tw = 8, th = 6 }))
end,
})
return list
end
return BagMenu
+160
View File
@@ -0,0 +1,160 @@
-- PC storage: 12 boxes of 20 (engine/pokemon/bills_pc.asm semantics via
-- src/pokemon/Boxes.lua): withdraw from / deposit to the current box,
-- plus CHANGE BOX.
local Boxes = require("src.pokemon.Boxes")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local BoxMenu = {}
local function monLabel(game, mon)
local def = game.data.pokemon[mon.species]
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
end
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
-- action + STATS + CANCEL. STATS shows the status screen and returns
-- here; CANCEL/B goes back to the list.
local function monSubmenu(game, action, mon, onAction)
game.stack:push(Menu.new(game, {
{ label = action, onSelect = onAction },
{
label = "STATS",
keepOpen = true,
onSelect = function()
local SummaryMenu = require("src.ui.SummaryMenu")
game.stack:push(SummaryMenu.new(game, mon))
end,
},
{ label = "CANCEL" },
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end
local function withdraw(game)
local box = Boxes.active(game.save)
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (WITHDRAW)"):format(game.save.currentBox), items, {
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
monSubmenu(game, "WITHDRAW", mon, function()
if #game.save.party >= Party.MAX then
list.footer = "The party is full!"
return
end
table.remove(box, item.value)
table.insert(game.save.party, mon)
list:close()
end)
end,
}))
end
local function deposit(game)
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then
list.footer = "You need at least\none POKéMON!"
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
return
end
table.remove(game.save.party, item.value)
table.insert(box, mon)
list:close()
end)
end,
}))
end
-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
local function release(game)
local box = Boxes.active(game.save)
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
local def = game.data.pokemon[mon.species]
local name = mon.nickname or def.name
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
table.remove(box, item.value)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
list:removeCurrent()
end, { defaultNo = true, noSound = true }))
end))
end,
}))
end
local function changeBox(game)
local boxes = Boxes.ensure(game.save)
local items = {}
for i = 1, Boxes.COUNT do
local mark = i == game.save.currentBox and "*" or " "
table.insert(items, {
label = ("%sBOX %2d"):format(mark, i),
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
value = i,
})
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
game.save.currentBox = item.value
if game.writeSave then game:writeSave() end
list:close()
end, { noSound = true }))
end))
end,
}))
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
{ label = "RELEASE", onSelect = function() release(game) end },
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end },
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
end
return BoxMenu
+50
View File
@@ -0,0 +1,50 @@
-- YES/NO choice box (top-left of the text box area, like the original).
local Font = require("src.render.Font")
local ChoiceBox = {}
ChoiceBox.__index = ChoiceBox
local CURSOR = 0xED
function ChoiceBox.new(game, onChoose, opts)
local self = setmetatable({}, ChoiceBox)
self.game = game
self.onChoose = onChoose
-- some of the original's prompts start on NO (e.g. release)
self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false
return self
end
function ChoiceBox:update(dt)
local input = self.game.input
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("a") then
-- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike
if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop()
self.onChoose(self.index == 1)
elseif input:wasPressed("b") then
if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop()
self.onChoose(false)
end
end
function ChoiceBox:draw()
Font.drawBox(0, 7, 6, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", 16, 8 * 8)
Font.draw("NO", 16, 10 * 8)
Font.drawCode(CURSOR, 8, (self.index == 1 and 8 or 10) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return ChoiceBox
+327
View File
@@ -0,0 +1,327 @@
-- Screen-by-screen end credits (engine/movie/credits.asm HallOfFamePC +
-- Credits). After the Hall of Fame induction fades out, the screen sits
-- blank for 100 frames, then the black letterbox bars appear
-- (FillFourRowsWithBlack: rows 0-3 and 14-17), Music_Credits starts and
-- the first screen follows 128 frames later. Each CreditsOrder screen
-- places its lines at hlcoord 9,6 plus the per-line signed column offset
-- (rows 6, 8, 10, ...) and runs its terminator:
-- CRED_TEXT_FADE_MON fade in (4 BGP steps x 5 frames), hold 90, mon wipe
-- CRED_TEXT_MON text appears at once, hold 110, mon wipe
-- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text
-- CRED_TEXT text appears at once, hold 140
-- The mon wipe is DisplayCreditsMon: the middle band scrolls left 8px per
-- frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
-- CreditsMons entry crosses right-to-left as a black silhouette
-- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which
-- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT
-- composes the Nintendo / Creatures inc. / GAME FREAK inc. block on its
-- screen (LoadCopyrightTiles: rows 7/9/11 from column 2). CRED_THE_END
-- waits 16 frames on the blank band, shows the interleaved THE END
-- letters at tile (4,8), and runs one more FadeInCredits (a no-op: the
-- letters are color 3, so they are black from the start).
--
-- Then the caller's onTheEnd fires -- the point where
-- HallOfFameResetEventsAndSaveScript (scripts/HallOfFame.asm) sets
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData -- the screen
-- holds 600 more frames (the script's 5 x 120 DelayFrames) and finally
-- waits for A/B (WaitForTextScrollButtonPress: no visible arrow here and
-- no press SFX) before popping and calling onDone (the script's
-- `jp Init`). If field.credits hasn't been extracted the roll degrades
-- to just THE END.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Credits = {}
Credits.__index = Credits
Credits.isOpaque = true
-- FadeInCredits: HoFGBPalettes steps the text color index through GB
-- shades 0 (white) -> 1 -> 2 -> 3 (black), 5 frames per step. The font
-- glyphs are black-on-transparent, so drawing them at these alphas over
-- the white band reproduces the 255 -> 170 -> 85 -> 0 gray ramp.
local FADE_STEPS = { 0, 1 / 3, 2 / 3, 1 }
local FADE_STEP_FRAMES = 5
local FADE_FRAMES = FADE_STEP_FRAMES * #FADE_STEPS -- 20
-- DelayFrames after each screen's text is up (Credits .next1/.next2)
local HOLD_FADE_MON = 90
local HOLD_MON = 110
local HOLD_FADE = 120
local HOLD_TEXT = 140
local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
-- sequences into the extracted title/copyright.png strip (tiles $60-$72:
-- (c)'95.'96.'98 + Nintendo + Creatures inc.); the GAME FREAK inc. row is
-- the title/gamefreak_inc.png strip (GameFreakLogoGraphics, tiles
-- $73-$7B), with the intro's composed gamefreak_text.png as a fallback
-- for pre-regeneration data.
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } -- Nintendo
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } -- Creatures inc.
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
-- DisplayCreditsMon shows the mon as a black silhouette: BGP %11111100
-- maps colors 1-3 to black and keeps color 0 white. The extracted
-- front-sprite PNGs keep GB color 0 as white/transparent pixels, so
-- paint every opaque non-white pixel black. Without love.image
-- (headless stub) fall back to a black tint (second return value), which
-- also blackens interior color-0 pixels.
local function silhouette(path)
if not path then return nil end
if love.image and love.image.newImageData then
local ok, imgData = pcall(love.image.newImageData, path)
if ok and imgData then
imgData:mapPixel(function(_, _, r, g, b, a)
if a > 0 and r + g + b < 2.9 then return 0, 0, 0, 1 end
return r, g, b, a
end)
local ok2, img = pcall(love.graphics.newImage, imgData)
if ok2 and img then return img, false end
end
end
local ok, img = pcall(love.graphics.newImage, path)
if ok and img then return img, true end
return nil
end
function Credits.new(game, onDone, onTheEnd)
local self = setmetatable({}, Credits)
self.game = game
self.onDone = onDone
self.onTheEnd = onTheEnd
local credits = game.data.field and game.data.field.credits or {}
self.screens = credits.screens or {}
self.theEnd = credits.theEnd
self.index = 0
self.screen = nil
self.phase = "white"
self.timer = 100 -- HallOfFamePC: ClearScreen + 100 DelayFrames
self.shade = 0
-- assets (all optional; missing ones fall back to Font glyphs)
self.endImg = tryImage(self.theEnd and self.theEnd.path)
self.endQuads = {}
if self.endImg then
local iw, ih = self.endImg:getDimensions()
for l = 0, 4 do -- 8x16 letter columns T,H,E,N,D
self.endQuads[l] = love.graphics.newQuad(l * 8, 0, 8, 16, iw, ih)
end
end
local title = game.data.field and game.data.field.title
self.copyImg = tryImage(title and title.copyright and title.copyright.path)
self.copyQuads = {}
if self.copyImg then
local iw, ih = self.copyImg:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
local intro = game.data.field and game.data.field.intro
self.gfImg = tryImage(title and title.gamefreakInc
and title.gamefreakInc.path)
or tryImage(intro and intro.gamefreakText
and intro.gamefreakText.path)
return self
end
function Credits:enter()
-- AnimateHallOfFame ended on HoFFadeOutScreenAndMusic: silence over the
-- blank lead-in; MUSIC_CREDITS starts when the bars appear
pcall(Music.stop)
end
function Credits:monSprite(species)
local def = self.game.data.pokemon and self.game.data.pokemon[species]
return silhouette(def and def.spriteFront)
end
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
-- past the last one, CRED_THE_END takes over
function Credits:nextScreen()
self.index = self.index + 1
local screen = self.screens[self.index]
self.screen = screen
if not screen then
self.phase = "end_blank" -- .showTheEnd: ld c, 16 on the blank band
self.timer = 16
return
end
if screen.fade then
self.phase = "fade"
self.timer = FADE_FRAMES
self.shade = 0
else
-- no fade: BGP was left black by the previous screen's fade
self.phase = "hold"
self.shade = 1
self.timer = screen.mon and HOLD_MON or HOLD_TEXT
end
end
function Credits:update(dt)
if self.phase == "end_wait" then
-- WaitForTextScrollButtonPress: A or B ends the credits; the HoF
-- script then soft-resets (`jp Init`). No SFX on this press.
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
return
end
self.timer = self.timer - 1
if self.timer > 0 then
if self.phase == "fade" then
local step = math.floor((FADE_FRAMES - self.timer) / FADE_STEP_FRAMES)
self.shade = FADE_STEPS[math.min(#FADE_STEPS, step + 1)]
end
return
end
if self.phase == "white" then
-- bars on, stop-music SFX + PlayMusic MUSIC_CREDITS, then 128 frames
self.phase = "intro"
self.timer = 128
local data = self.game.data
if data.audio and data.audio.songs and data.audio.songs.Music_Credits then
pcall(Music.play, data, "Music_Credits")
end
elseif self.phase == "intro" then
self:nextScreen()
elseif self.phase == "fade" then
self.shade = 1
self.phase = "hold"
self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE
elseif self.phase == "hold" then
if self.screen.mon then
self.phase = "wipe"
self.timer = WIPE_FRAMES
self.monImg, self.monTint = self:monSprite(self.screen.mon)
else
self:nextScreen()
end
elseif self.phase == "wipe" then
self.monImg = nil
self:nextScreen()
elseif self.phase == "end_blank" then
-- THE END letters are color 3: visible from the first fade palette
self.phase = "end_fade"
self.timer = FADE_FRAMES
elseif self.phase == "end_fade" then
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
-- save happens now, then 5 x 120 DelayFrames before the button wait
if self.onTheEnd then self.onTheEnd() end
self.phase = "end_hold"
self.timer = 600
elseif self.phase == "end_hold" then
self.phase = "end_wait"
end
end
-- one credits screen: lines at rows 6/8/10... with the extractor's
-- absolute column (9 + signed offset), plus the copyright block
function Credits:drawPage(screen, xoff, shade)
if not screen then return end
love.graphics.setColor(0, 0, 0, shade)
for i, line in ipairs(screen.lines or {}) do
Font.draw(line.text, xoff + (line.column or 0) * 8, 48 + (i - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
if screen.copyright then self:drawCopyright(xoff) end
end
function Credits:drawCopyright(xoff)
local img = self.copyImg
if img then
-- the copyright tiles are loaded fresh (not color-shifted like the
-- font), so they are color 3: always solid, no fade
local function row(seq, x, y)
for _, t in ipairs(seq) do
love.graphics.draw(img, self.copyQuads[t], x, y)
x = x + 8
end
return x
end
row(COPY_PREFIX, xoff + 16, 56)
row(COPY_NINTENDO, xoff + 80, 56)
row(COPY_PREFIX, xoff + 16, 72)
row(COPY_CREATURES, xoff + 80, 72)
row(COPY_PREFIX, xoff + 16, 88)
if self.gfImg then
love.graphics.draw(self.gfImg, xoff + 80, 88)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("GAME FREAK", xoff + 80, 88)
love.graphics.setColor(1, 1, 1, 1)
end
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("Nintendo", xoff + 80, 56)
Font.draw("Creatures inc.", xoff + 80, 72)
Font.draw("GAME FREAK inc.", xoff + 16, 88)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- the mon silhouette crossing during the wipe; x is the left edge of its
-- 7x7 box (bottom-centered inside it, like the GB pic buffer padding)
function Credits:drawMon(x)
local img = self.monImg
if not img then return end
local w, h = img:getDimensions()
if self.monTint then love.graphics.setColor(0, 0, 0, 1) end
love.graphics.draw(img, x + math.floor((56 - w) / 2), 48 + (56 - h))
love.graphics.setColor(1, 1, 1, 1)
end
-- TheEndTextString: 12 tile columns from (4,8), each an 8x16 letter
-- column of the interleaved the_end gfx (pattern indexes T,H,E,N,D)
function Credits:drawTheEnd()
local te = self.theEnd
if self.endImg and te and te.pattern then
love.graphics.setColor(1, 1, 1, 1)
for i, letter in ipairs(te.pattern) do
if letter >= 0 then
love.graphics.draw(self.endImg, self.endQuads[letter],
32 + (i - 1) * 8, 64)
end
end
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw((te and te.display) or "T H E E N D", 32, 64)
love.graphics.setColor(1, 1, 1, 1)
end
end
function Credits:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == "white" then return end
-- FillFourRowsWithBlack: rows 0-3 and 14-17 stay solid black
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, 160, 32)
love.graphics.rectangle("fill", 0, 112, 160, 32)
love.graphics.setColor(1, 1, 1, 1)
if self.phase == "fade" or self.phase == "hold" then
self:drawPage(self.screen, 0, self.shade)
elseif self.phase == "wipe" then
-- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while
-- the silhouette enters from the right edge one screen behind it
local s = (WIPE_FRAMES - self.timer) * 8
self:drawPage(self.screen, -s, 1)
self:drawMon(160 - s)
elseif self.phase == "end_fade" or self.phase == "end_hold"
or self.phase == "end_wait" then
self:drawTheEnd()
end
love.graphics.setColor(1, 1, 1, 1)
end
return Credits
+72
View File
@@ -0,0 +1,72 @@
-- Pokédex entry page: front sprite, kind, height/weight and the real
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
local Font = require("src.render.Font")
local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu
DexEntryMenu.isOpaque = true
-- SGB: PalPacket_Pokedex (BROWNMON) + the mon pic zone in its palette
function DexEntryMenu:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local base = P.pal(game.data, "BROWNMON")
if not base then return nil end
return { P.whole(base),
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
end
function DexEntryMenu.new(game, species)
local self = setmetatable({ game = game }, DexEntryMenu)
self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
self.sprite = ok and img or nil
require("src.core.Sound").playCry(game.data, species)
return self
end
function DexEntryMenu:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
end
end
function DexEntryMenu:draw()
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()))
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
local e = def.dexEntry or {}
Font.draw((e.kind or "?") .. " POKéMON", 72, 20)
Font.draw(("No.%03d"):format(def.dex or 0), 72, 32)
local owned = self.game.save.pokedex and self.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")
if owned and e.heightFt then
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
Font.draw(("HT %d%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54)
end
local text = owned and e.text and self.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
if y > 132 then break end
Font.draw(line, 8, y)
y = y + 10
end
else
Font.draw("Data unknown.", 8, y)
end
love.graphics.setColor(1, 1, 1, 1)
end
return DexEntryMenu
+93
View File
@@ -0,0 +1,93 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text.
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
-- has no cancel; the flash always completes).
local Font = require("src.render.Font")
local EvolutionState = {}
EvolutionState.__index = EvolutionState
EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local species = self.done and self.newSpecies or self.mon.species
local c = P.monPal(game.data, species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
end
local FLASH_FRAMES = 220
local function frontSprite(game, species)
local def = game.data.pokemon[species]
if not (def and def.spriteFront) then return nil end
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
return ok and img or nil
end
function EvolutionState.new(game, mon, newSpecies, onDone)
local self = setmetatable({}, EvolutionState)
self.game = game
self.mon = mon
self.newSpecies = newSpecies
self.onDone = onDone
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species)
self.newSprite = frontSprite(game, newSpecies)
self.t = 0
self.done = false
return self
end
function EvolutionState:update(dt)
self.t = self.t + 1
if self.done then return end
if self.t >= FLASH_FRAMES then
self.done = true
local game = self.game
local Evolution = require("src.pokemon.Evolution")
Evolution.apply(game, self.mon, self.newSpecies)
require("src.core.Sound").playCry(game.data, self.newSpecies)
local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name
game.stack:push(TextBox.new(game,
("Congratulations!\nYour %s\nevolved into\n%s!")
:format(self.oldName, newName),
function()
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
end))
end
end
function EvolutionState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- accelerating flash between the two forms
local sprite
if self.done then
sprite = self.newSprite
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
sprite = showNew and self.newSprite or self.oldSprite
end
if sprite then
love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2),
math.max(8, 64 - sprite:getHeight()))
end
love.graphics.setColor(0, 0, 0, 1)
if not self.done then
Font.draw("What?", 8, 104)
Font.draw(self.oldName .. " is", 8, 114)
Font.draw("evolving!", 8, 124)
end
love.graphics.setColor(1, 1, 1, 1)
end
return EvolutionState
+29
View File
@@ -0,0 +1,29 @@
-- Fly destination picker: visited towns, landing at the real fly-warp
-- spots from data/maps/special_warps.asm.
local ListMenu = require("src.ui.ListMenu")
local FlyMenu = {}
function FlyMenu.new(game)
local items = {}
local visited = game.save.visited or {}
for _, mapId in ipairs(game.data.field.flyOrder) do
-- towns only (dungeon escape spots share the table)
if visited[mapId] and game.data.maps[mapId]
and game.data.maps[mapId].tileset == "OVERWORLD" then
table.insert(items, {
value = mapId,
label = mapId:gsub("_", " "),
})
end
end
return ListMenu.new(game, "FLY TO?", items, {
onChoose = function(item, list)
list:close()
game.overworld:flyTo(item.value)
end,
})
end
return FlyMenu
+194
View File
@@ -0,0 +1,194 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
-- .ScrollPic), then its name/level shows and its cry plays
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
-- popping itself.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local HallOfFame = {}
HallOfFame.__index = HallOfFame
HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local mon = game.save.party[self.index or 0]
if mon then
local c = P.monPal(game.data, mon.species)
if c then return { P.whole(c) } end
return nil
end
return P.wholeNamed(game.data, "MEWMON")
end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
-- the real front pic that follows enters from the *left* edge and
-- slides *right* into its resting tile, at that same 4px/frame rate --
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
-- register trick with no equivalent in this sprite-based renderer).
local SCROLL_SPEED = 4 -- px/frame @ 60fps
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
-- POKéDEX rating tiers (engine/events/pokedex_rating.asm DexRatingsTable)
local function dexRatingKey(owned)
if owned >= 150 then return "_DexRatingText_Own150To151" end
local lo = math.floor(owned / 10) * 10
return ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
end
-- \n/\v/\f-marked extracted text, one Font.draw line at a time (same
-- technique as DexEntryMenu.lua's dex-description block)
local function drawTextBlock(text, x, y, maxY)
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end
Font.draw(line, x, y)
y = y + 10
end
return y
end
function HallOfFame.new(game, onDone)
local self = setmetatable({}, HallOfFame)
self.game = game
self.onDone = onDone
self.index = 0
self.timer = 0
self.phase = "mons"
self.sprites = {} -- species -> image or false
return self
end
function HallOfFame:enter()
local data = self.game.data
if data.audio and data.audio.songs and data.audio.songs.Music_HallOfFame then
pcall(Music.play, data, "Music_HallOfFame")
end
self:nextMon()
end
function HallOfFame:nextMon()
self.index = self.index + 1
local mon = self.game.save.party[self.index]
if mon then
self.timer = MON_FRAMES
Sound.playCry(self.game.data, mon.species)
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 0
self.scrollRestX = math.floor((160 - w) / 2)
self.scrollX = -w
else
self.phase = "congrats"
end
end
function HallOfFame:spriteFor(species)
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
cached = tryImage(def and def.spriteFront) or false
self.sprites[species] = cached
end
return cached or nil
end
-- HoFDisplayPlayerStats' DisplayDexRating tally (also
-- OverworldController:dexRating / PokedexMenu.new's seen+owned counts)
function HallOfFame:dexSeenOwned()
local dex = self.game.save.pokedex or { seen = {}, owned = {} }
local seen, owned = 0, 0
for _ in pairs(dex.seen or {}) do seen = seen + 1 end
for _ in pairs(dex.owned or {}) do owned = owned + 1 end
return seen, owned
end
function HallOfFame:update(dt)
local input = self.game.input
if self.phase == "mons" then
if self.scrollX and self.scrollX < self.scrollRestX then
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
end
self.timer = self.timer - 1
if input:wasPressed("a") or self.timer <= 0 then
self:nextMon()
end
elseif input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "mons" then
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
local mon = self.game.save.party[self.index]
if mon then
local def = self.game.data.pokemon[mon.species]
love.graphics.setColor(1, 1, 1, 1)
local sprite = self:spriteFor(mon.species)
if sprite then
local w, h = sprite:getDimensions()
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
end
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 32, 108)
Font.draw((":L%d"):format(mon.level), 112, 108)
end
else
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
-- screen is a fresh ClearScreen): trainer name, play time, money,
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
-- using the same real save-data fields as TrainerCard.lua
-- (save.player.name/playTime/money) and PokedexMenu.lua/
-- OverworldController:dexRating (save.pokedex.seen/owned).
local save = self.game.save
local text = self.game.data.text or {}
local y = 8
Font.draw(save.player.name or "RED", 8, y)
y = y + 16
local t = math.floor(save.playTime or 0)
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 8, y)
y = y + 12
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
y = y + 16
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
y = drawTextBlock(seenOwned, 8, y) + 6
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 8, y)
y = y + 12
local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 8, y, 136)
end
love.graphics.setColor(1, 1, 1, 1)
end
return HallOfFame
+369
View File
@@ -0,0 +1,369 @@
-- Boot splash + attract movie, a faithful port of PlayIntro
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
-- using the real extracted art (data/generated/field.lua `intro` manifest).
--
-- Three frame-counted phases:
-- 1. copyright card, 180 frames (intro.asm:311-312).
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
-- the big star streaks down-left for 40 frames while the GAME FREAK
-- logo + letters sit at (72,56)/(40,80) (splash.asm:27-60, 211-228),
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
-- (intro.asm:329-331).
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
-- (GBFadeOutToWhite, home/fade.asm:26-40).
--
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
-- Pops itself and calls onDone() when finished or skipped. All art loads
-- through pcall and every missing graphic degrades to a text/rect
-- fallback, so the movie stays headless-safe.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local IntroMovie = {}
IntroMovie.__index = IntroMovie
IntroMovie.isOpaque = true
-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars)
function IntroMovie:sgbPalettes(game)
local P = require("src.render.PaletteFX")
if self.phase == 2 then
local logo = P.pal(game.data, "GAMEFREAK")
if not logo then return nil end
return {
P.whole(logo),
P.zone(P.pal(game.data, "REDMON"), 5, 11, 7, 13),
P.zone(P.pal(game.data, "VIRIDIAN"), 8, 11, 9, 13),
P.zone(P.pal(game.data, "BLUEMON"), 12, 11, 14, 13),
}
elseif self.phase == 3 then
local purple = P.pal(game.data, "PURPLEMON")
if not purple then return nil end
return {
P.zone(P.pal(game.data, "BLACK"), 0, 0, 19, 3),
P.zone(purple, 0, 4, 19, 13),
P.zone(P.pal(game.data, "BLACK"), 0, 14, 19, 17),
}
end
return nil -- the copyright card stays plain
end
local COPYRIGHT_FRAMES = 180 -- ld c, 180 (intro.asm:311-312)
-- phase 2 (shooting star) timeline, in frames from phase start
local STAR_START = 64 -- ld c, 64 (intro.asm:323-324)
local STAR_FRAMES = 40 -- OAM Y 0->160 in +4 steps (splash.asm:32-60)
local FLASH_START = STAR_START + STAR_FRAMES
local FLASH_FRAMES = 30 -- 3 loops x 10 frames (splash.asm:72-82)
local WAVES_START = FLASH_START + FLASH_FRAMES
local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
-- cancel)
local LOGO_X, LOGO_Y = 72, 56
local TEXT_X, TEXT_Y = 40, 80
-- the 4 waves of small stars: screen X positions, all spawning at y=88
-- (OAM $68; SmallStarsWave*Coords, splash.asm:160-183)
local STAR_WAVES = {
{ 40, 56, 80, 112 },
{ 48, 64, 88, 104 },
{ 44, 68, 76, 92 },
{ 52, 84, 100, 108 },
}
-- Nidorino movement lists: {dy, dx} applied every 5 frames
-- (AnimateIntroNidorino, intro.asm:143-158)
local ANIM = {
-- IntroNidorinoAnimation1..7 (intro.asm:370-437)
{ {0,0}, {-2,2}, {-1,2}, {1,2}, {2,2} }, -- 1: hop arc, +8 right
{ {0,0}, {-2,-2}, {-1,-2}, {1,-2}, {2,-2} }, -- 2: hop arc, -8 left
{ {0,0}, {-12,6}, {-8,6}, {8,6}, {12,6} }, -- 3: dodge leap, +24 right
{ {0,0}, {-8,-4}, {-4,-4}, {4,-4}, {8,-4} }, -- 4: high hop, -16 left
{ {0,0}, {-8,4}, {-4,4}, {4,4}, {8,4} }, -- 5: high hop, +16 right
{ {0,0}, {2,0}, {2,0}, {0,0} }, -- 6: crouch, +4 down
{ {-8,-16}, {-7,-14}, {-6,-12}, {-4,-10} }, -- 7: lunge, -52/-25 up-left
}
-- PlayIntroScene, in source order (intro.asm:23-141). `move` ops shift
-- 2px per 2 frames (IntroMoveMon, intro.asm:235-269): "scrollIn" moves
-- Nidorino right AND Gengar left together (the fallthrough at :247-259),
-- gengar dx<0 = MOVE_GENGAR_LEFT (SCX+2), dx>0 = MOVE_GENGAR_RIGHT.
local FIGHT_SCRIPT = {
{ move = "scrollIn", px = 80 }, -- intro.asm:40-41
{ sfx = "Intro_Hip" }, { anim = 1 }, -- :44-50
{ sfx = "Intro_Hop" }, { anim = 2 }, { wait = 10 }, -- :51-57
{ sfx = "Intro_Hip" }, { anim = 1 }, -- :60-64
{ sfx = "Intro_Hop" }, { anim = 2 }, { wait = 30 }, -- :65-71
{ pose = 2 }, { sfx = "Intro_Raise" }, -- :74-78
{ move = "gengar", dx = -8 }, { wait = 30 }, -- :79-82
{ pose = 3 }, { sfx = "Intro_Crash" }, -- :85-89
{ move = "gengar", dx = 16 }, -- :90-91
{ sfx = "Intro_Hip" }, { frame = 2 }, { anim = 3 }, -- :92-98
{ wait = 30 }, -- :99-100
{ move = "gengar", dx = -8 }, { pose = 1 }, -- :103-106
{ wait = 60 }, -- :107-108
{ sfx = "Intro_Hip" }, { frame = 1 }, { anim = 4 }, -- :111-117
{ sfx = "Intro_Hop" }, { anim = 5 }, { wait = 20 }, -- :118-124
{ frame = 2 }, { anim = 6 }, { wait = 30 }, -- :127-132
{ sfx = "Intro_Lunge" }, { frame = 3 }, { anim = 7 }, -- :135-141
{ fade = 24 }, -- GBFadeOutToWhite: 3 pals x 8 frames (home/fade.asm:26-40)
}
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
function IntroMovie.new(game, onDone)
local self = setmetatable({}, IntroMovie)
self.game = game
self.onDone = onDone
self.phase = 1
self.timer = 0
self.finished = false
local intro = game.data.field and game.data.field.intro or {}
local function img(e) return tryImage(e and e.path) end
self.copyright = tryImage("assets/generated/title/copyright.png")
self.logo = img(intro.gamefreakLogo)
self.gfText = img(intro.gamefreakText)
self.bigStar = img(intro.bigStar)
self.smallStar = img(intro.fallingStar)
self.smallStarBlink = img(intro.fallingStarBlink)
self.gengarFrames, self.nidoFrames = {}, {}
for i = 1, 3 do
self.gengarFrames[i] = img(intro.gengar and intro.gengar["frame" .. i])
self.nidoFrames[i] = img(intro.nidorino and intro.nidorino["frame" .. i])
end
-- fight state (PlayIntroScene entry, intro.asm:30-39): Gengar BG pose at
-- tile (13,7) = screen (104,56); Nidorino OAM base (0,80) = screen
-- (-8,72) after the OAM +8 offsets
self.gengarX, self.gengarY = 104, 56
self.nidoX, self.nidoY = -8, 72
self.gengarPose, self.nidoFrame = 1, 1
self.opIndex, self.opTimer = 1, 0
self.fade = 0
return self
end
function IntroMovie: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 IntroMovie:startPhase(phase)
self.phase = phase
self.timer = 0
if phase == 3 then
-- intro.asm:333-338
local data = self.game.data
local songs = data.audio and data.audio.songs
if songs and songs.Music_IntroBattle then
pcall(Music.play, data, "Music_IntroBattle", false)
end
end
end
-- one frame of the fight script (see FIGHT_SCRIPT)
function IntroMovie:fightStep()
while true do
local op = FIGHT_SCRIPT[self.opIndex]
if not op then
self:finish()
return
end
if op.sfx then
Sound.play(self.game.data, op.sfx)
elseif op.pose then
self.gengarPose = op.pose
elseif op.frame then
self.nidoFrame = op.frame
elseif op.move then
-- 2px per 2 frames (IntroMoveMon: CheckForUserInterruption c=2)
if self.opTimer % 2 == 0 then
if op.move == "scrollIn" then
self.gengarX = self.gengarX - 2
self.nidoX = self.nidoX + 2
else
self.gengarX = self.gengarX + (op.dx > 0 and 2 or -2)
end
end
self.opTimer = self.opTimer + 1
if self.opTimer < (op.px or math.abs(op.dx)) then return end
elseif op.anim then
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
if self.opTimer % 5 == 0 then
local d = ANIM[op.anim][self.opTimer / 5 + 1]
self.nidoY = self.nidoY + d[1]
self.nidoX = self.nidoX + d[2]
end
self.opTimer = self.opTimer + 1
if self.opTimer < #ANIM[op.anim] * 5 then return end
elseif op.wait then
self.opTimer = self.opTimer + 1
if self.opTimer < op.wait then return end
elseif op.fade then
self.opTimer = self.opTimer + 1
self.fade = self.opTimer / op.fade
if self.opTimer >= op.fade then self:finish() end
return
end
self.opIndex = self.opIndex + 1
self.opTimer = 0
end
end
function IntroMovie:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
self:finish()
return
end
self.timer = self.timer + 1
if self.phase == 1 then
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
elseif self.phase == 2 then
if self.timer == STAR_START then
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else
self:fightStep()
end
end
-- the letterbox bars: 4 black tile rows top and bottom
-- (IntroDrawBlackBars, intro.asm:343-357); drawn AFTER the sprites since
-- both Nidorino and the small stars carry OAM_PRIO (intro.asm:195,
-- splash.asm:149) so the bars cover them.
local function drawBars()
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, 160, 32)
love.graphics.rectangle("fill", 0, 112, 160, 32)
love.graphics.setColor(1, 1, 1, 1)
end
function IntroMovie:drawSplash()
local t = self.timer
if t >= STAR_START then
-- logo + GAME FREAK letters appear with the star OAM
-- (LoadShootingStarGraphics, splash.asm:18-25); the logo palette
-- rotates during the 3-flash loop (splash.asm:72-82)
local flashing = t >= FLASH_START and t < FLASH_START + FLASH_FRAMES
local dim = flashing and math.floor((t - FLASH_START) / 5) % 2 == 0
love.graphics.setColor(1, 1, 1, dim and 0.35 or 1)
if self.logo then
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
end
-- custom studio name (replaces the GAME FREAK splash text)
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1)
Font.draw("bois club games", (160 - 15 * 8) / 2, TEXT_Y)
love.graphics.setColor(1, 1, 1, 1)
end
if t >= STAR_START and t < FLASH_START then
-- big star: from OAM (160,0) moving +4Y/-4X per frame
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
if t >= WAVES_START then
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
-- +1px per 3-frame substep until the wave loop ends; the lower star in
-- the tile blinks every substep (splash.asm:97-146, 186-209)
local substep = math.floor((math.min(t, WAVES_END) - WAVES_START) / 3)
local blink = substep % 2 == 0
for w, xs in ipairs(STAR_WAVES) do
local spawn = (w - 1) * 8 -- in substeps
if substep >= spawn then
local y = 88 + (substep - spawn)
if y < 144 then
local img = blink and self.smallStar
or (self.smallStarBlink or self.smallStar)
for _, x in ipairs(xs) do
if img then
love.graphics.draw(img, x, y)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 3, y + 1, 2, 2)
love.graphics.setColor(1, 1, 1, 1)
end
end
end
end
end
end
drawBars()
end
function IntroMovie:drawFight()
-- Gengar: a 56x56 BG-tile pose recomposed from gengar_N.tilemap, moved
-- by scrolling SCX (intro.asm:32-33, 235-269)
-- Nidorino: 6x6 OAM sprite, one of the three red_nidorino poses
local nido = self.nidoFrames[self.nidoFrame]
if nido then
love.graphics.draw(nido, self.nidoX, self.nidoY)
end
local gengar = self.gengarFrames[self.gengarPose]
if gengar then
love.graphics.draw(gengar, self.gengarX, self.gengarY)
end
if not gengar and not nido then
love.graphics.setColor(0, 0, 0, 1)
Font.draw("GENGAR VS NIDORINO", (160 - 18 * 8) / 2, 64)
love.graphics.setColor(1, 1, 1, 1)
end
drawBars()
if self.fade > 0 then
love.graphics.setColor(1, 1, 1, math.min(1, self.fade))
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
end
function IntroMovie:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == 1 then
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw("bois club", (160 - 9 * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
elseif self.phase == 2 then
self:drawSplash()
else
self:drawFight()
end
love.graphics.setColor(1, 1, 1, 1)
end
return IntroMovie
+161
View File
@@ -0,0 +1,161 @@
-- Generic full-screen scrollable list: items are { label=..., right=...,
-- value=... }; onChoose(item) / onCancel(). Used by the bag, shops, the
-- box and the Pokédex.
local Font = require("src.render.Font")
local ListMenu = {}
ListMenu.__index = ListMenu
ListMenu.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function ListMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local CURSOR = 0xED
local ROWS = 7
function ListMenu.new(game, title, items, opts)
opts = opts or {}
local self = setmetatable({}, ListMenu)
self.game = game
self.title = title
self.items = items
self.index = 1
self.scroll = 0
self.onChoose = opts.onChoose
self.onCancel = opts.onCancel
self.footer = opts.footer
self.pageJump = opts.pageJump -- Left/Right move a page at a time
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
-- scripted mode (the old man tutorial): update() runs the script
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
-- branch (home/list_menu.asm:65-80) never calls HandleMenuInput
self.script = opts.script
-- shop mode: the footer becomes the clerk's line in a framed bottom
-- text box, a money box sits top-right, and the list shortens to
-- clear them (DisplayPokemartDialogue_'s screen)
self.dialogue = opts.dialogue
self.money = opts.money -- () -> current money for the box
self.rows = opts.dialogue and 4 or ROWS
return self
end
function ListMenu:update(dt)
if self.script then
self.script(self)
return
end
local input = self.game.input
if #self.items == 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
return
end
if input:wasPressed("up") then
self.index = math.max(1, self.index - 1)
elseif input:wasPressed("down") then
self.index = math.min(#self.items, self.index + 1)
elseif self.pageJump and input:wasPressed("left") then
self.index = math.max(1, self.index - self.rows)
elseif self.pageJump and input:wasPressed("right") then
self.index = math.min(#self.items, self.index + self.rows)
elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif input:wasPressed("a") then
local item = self.items[self.index]
if self.onChoose then
self.onChoose(item, self)
end
return
end
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- remove current item (e.g. consumed); keeps cursor valid
function ListMenu:removeCurrent()
table.remove(self.items, self.index)
self.index = math.max(1, math.min(self.index, #self.items))
end
function ListMenu:close()
local top = self.game.stack:top()
if top == self then self.game.stack:pop() end
end
function ListMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title, 8, 4)
if #self.items == 0 then
Font.draw("Nothing here.", 16, 64)
end
for row = 1, self.rows do
local i = self.scroll + row
local item = self.items[i]
if not item then break end
local y = 8 + row * 16
Font.draw(item.label, 16, y)
if item.ball then -- the Pokédex owned-ball marker tile
local bx = 16 + (#item.label + 1) * 8 + 3
local by = y + 3
love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
love.graphics.circle("fill", bx, by, 1.2)
love.graphics.setColor(0, 0, 0, 1)
end
if item.right then
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
end
if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
-- auto A-press, home/list_menu.asm:89-91)
Font.drawCode((self.swapIndex == i or self.hollowIndex == i)
and 0xEC or CURSOR, 8, y)
end
if self.swapIndex == i and i ~= self.index then
Font.drawCode(0xEC, 8, y) -- ▷ marks the item being moved
end
end
if self.dialogue then
-- money box (DisplayTextBoxID MONEY_BOX, hlcoord 11,0): the amount
-- right-aligned on its middle row
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - #money * 8, 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
elseif self.footer then
Font.draw(self.footer, 8, 136)
end
love.graphics.setColor(1, 1, 1, 1)
end
return ListMenu
+77
View File
@@ -0,0 +1,77 @@
-- Generic bordered list menu with the blinking ▶ cursor.
-- items: { { label=..., onSelect=function }, ... }
-- Pops itself on B (unless cancelable=false); also on START only when
-- opts.startCloses is set -- pokered's wMenuWatchedKeys mask varies per
-- menu and only the start menu's adds PAD_START.
local Font = require("src.render.Font")
local Menu = {}
Menu.__index = Menu
local CURSOR = 0xED -- "▶" glyph (right arrow) in font.png
function Menu.new(game, items, opts)
local self = setmetatable({}, Menu)
opts = opts or {}
self.game = game
self.items = items
self.index = 1
self.tx = opts.tx or 10
self.ty = opts.ty or 0
self.tw = opts.tw or 10
self.th = opts.th or (#items * 2 + 2)
self.cancelable = opts.cancelable ~= false
-- Whether START closes the menu. In pokered a menu responds only to the
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
-- list menu's PAD_A | PAD_B | PAD_SELECT) masks leave START unwatched, so
-- only menus whose real mask includes PAD_START -- the start menu
-- (engine/menus/draw_start_menu.asm) -- opt in here.
self.startCloses = opts.startCloses or false
self.onCancel = opts.onCancel
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false
return self
end
function Menu:update(dt)
local input = self.game.input
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or #self.items
elseif input:wasPressed("down") then
self.index = self.index < #self.items and self.index + 1 or 1
elseif input:wasPressed("a") then
-- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press
if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
local item = self.items[self.index]
-- keepOpen entries run without closing the menu (e.g. the
-- Pokédex CRY option keeps the side menu up)
if not item.keepOpen then self.game.stack:pop() end
if item.onSelect then item.onSelect() end
elseif self.cancelable and (input:wasPressed("b")
or (self.startCloses and input:wasPressed("start"))) then
-- HandleMenuInput_ returns for any watched key, but only replays
-- SFX_PRESS_AB for the PAD_A | PAD_B branch -- so B beeps and START
-- (when watched, e.g. the start menu) closes silently.
if input:wasPressed("b") and not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
end
function Menu:draw()
Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1)
for i, item in ipairs(self.items) do
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
end
Font.drawCode(CURSOR, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return Menu
+133
View File
@@ -0,0 +1,133 @@
-- "Which move should be forgotten?", replaces a move when a Pokémon with
-- four moves learns a new one (engine/pokemon/learn_move.asm). Opens
-- with the TryingToLearnText "Delete an older move...?" YES/NO; HM moves
-- can't be forgotten; B / CANCEL gives up on the new move.
local Font = require("src.render.Font")
local MoveLearnMenu = {}
MoveLearnMenu.__index = MoveLearnMenu
local CURSOR = 0xED
-- data/moves/hm_moves.asm (IsMoveHM)
local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
}
function MoveLearnMenu.new(game, mon, newMoveId, onDone)
local self = setmetatable({}, MoveLearnMenu)
self.game = game
self.mon = mon
self.newMoveId = newMoveId
self.onDone = onDone
self.index = 1
return self
end
function MoveLearnMenu:monName()
return self.mon.nickname or self.game.data.pokemon[self.mon.species].name
end
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning).
function MoveLearnMenu:enter()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
local name = self:monName()
game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
:format(name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then self:confirmAbandon() end
end))
end))
end
function MoveLearnMenu:update(dt)
local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or n
elseif input:wasPressed("down") then
self.index = self.index < n and self.index + 1 or 1
elseif input:wasPressed("b") then
self:confirmAbandon()
elseif input:wasPressed("a") then
if self.index > #self.mon.moves then
self:confirmAbandon()
else
local old = self.mon.moves[self.index]
if HM_MOVES[old.id] then
-- HMCantDeleteText, then back to the forget list
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
"HM techniques\ncan't be deleted!"))
return
end
local mdef = self.game.data.moves[self.newMoveId]
self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp }
self.forgot = self.game.data.moves[old.id].name
self:finish(true)
end
end
end
-- AbandonLearning (learn_move.asm): "Abandon learning MOVE?" YES/NO
-- before giving up; NO returns to the TryingToLearn prompt
-- (DontAbandonLearning)
function MoveLearnMenu:confirmAbandon()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
game.stack:push(TextBox.new(game,
("Abandon learning\n%s?"):format(mdef.name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes then self:finish(false) else self:enter() end
end))
end))
end
function MoveLearnMenu:finish(learned)
local TextBox = require("src.render.TextBox")
local game = self.game
local name = self:monName()
local mdef = game.data.moves[self.newMoveId]
game.stack:pop()
local msg
if learned then
-- OneTwoAndText/PoofText/ForgotAndText
msg = ("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!")
:format(name, self.forgot, name, mdef.name)
else
-- DidNotLearnText
msg = ("%s\ndid not learn\v%s!"):format(name, mdef.name)
end
game.stack:push(TextBox.new(game, msg, function()
if self.onDone then self.onDone(learned) end
end))
end
function MoveLearnMenu:draw()
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7)
love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.mon.moves) do
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
end
Font.draw("CANCEL", 48, (6 + #self.mon.moves) * 8)
Font.drawCode(CURSOR, 40, (5 + self.index) * 8)
-- WhichMoveToForgetText in the bottom dialogue box
Font.drawBox(0, 12, 20, 6)
Font.draw("Which move should", 8, 14 * 8)
Font.draw("be forgotten?", 8, 16 * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return MoveLearnMenu
+162
View File
@@ -0,0 +1,162 @@
-- Gen 1 letter-grid naming screen (engine/menus/naming_screen.asm).
-- Full gen-1 glyph grid (data/text/alphabets.asm): five 9-cell rows
-- ending in ED, plus a case-switch row. A picks a letter, B deletes,
-- SELECT flips case, START or the ED cell confirms. If opts.presets is
-- given, a "NEW NAME" + presets menu is shown first
-- (engine/menus/main_menu.asm name lists).
-- Pops itself from the stack, then calls opts.onDone(name).
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local NamingScreen = {}
NamingScreen.__index = NamingScreen
NamingScreen.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function NamingScreen:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local CURSOR = 0xED
-- both letter pages (wAlphabetCase, data/text/alphabets.asm): row 6 is
-- the case-switch cell, labelled with the page it flips to
local GRID_UPPER = {
{ "A", "B", "C", "D", "E", "F", "G", "H", "I" },
{ "J", "K", "L", "M", "N", "O", "P", "Q", "R" },
{ "S", "T", "U", "V", "W", "X", "Y", "Z", " " },
{ "×", "(", ")", ":", ";", "[", "]", "<PK>", "<MN>" },
{ "-", "?", "!", "", "", "/", ".", ",", "ED" },
{ "lower case" },
}
local GRID_LOWER = {
{ "a", "b", "c", "d", "e", "f", "g", "h", "i" },
{ "j", "k", "l", "m", "n", "o", "p", "q", "r" },
{ "s", "t", "u", "v", "w", "x", "y", "z", " " },
{ "×", "(", ")", ":", ";", "[", "]", "<PK>", "<MN>" },
{ "-", "?", "!", "", "", "/", ".", ",", "ED" },
{ "UPPER CASE" },
}
local CASE_ROW = 6
local ED_ROW, ED_COL = 5, 9
function NamingScreen.new(game, opts)
opts = opts or {}
local self = setmetatable({}, NamingScreen)
self.game = game
self.title = opts.title or "YOUR NAME?"
self.presets = opts.presets
self.maxLen = opts.maxLen or 7
self.default = opts.default
self.onDone = opts.onDone
self.glyphs = {} -- typed glyphs; multi-byte cells (<PK>, ♂, ×) count as 1
self.row, self.col = 1, 1
self.lower = false
return self
end
function NamingScreen:enter()
if self.presets and #self.presets > 0 then
local Menu = require("src.ui.Menu")
local items = { { label = "NEW NAME" } }
for _, preset in ipairs(self.presets) do
table.insert(items, {
label = preset,
onSelect = function()
-- the menu already popped itself; pop the naming screen too
self.game.stack:pop()
if self.onDone then self.onDone(preset) end
end,
})
end
self.game.stack:push(Menu.new(self.game, items, {
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
}))
end
end
function NamingScreen:confirm()
local name = table.concat(self.glyphs)
if name == "" then
name = (self.presets and self.presets[1]) or self.default or "A"
end
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone(name) end
end
function NamingScreen:grid()
return self.lower and GRID_LOWER or GRID_UPPER
end
-- Gen 1 jumps the cursor to ED once the name is full.
function NamingScreen:jumpToEnd()
self.row, self.col = ED_ROW, ED_COL
end
function NamingScreen:update(dt)
local GRID = self:grid()
local input = self.game.input
if input:wasPressed("start") then
self:confirm()
return
end
if input:wasPressed("select") then -- SELECT also flips the case page
self.lower = not self.lower
return
end
if input:wasPressed("up") then
-- wrapping up from the top row lands on the case-switch cell
self.row = self.row > 1 and self.row - 1 or CASE_ROW
self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("down") then
self.row = self.row < #GRID and self.row + 1 or 1
self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("left") then
-- no horizontal movement on the case-switch row
if self.row ~= CASE_ROW then
self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
end
elseif input:wasPressed("right") then
if self.row ~= CASE_ROW then
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
end
elseif input:wasPressed("b") then
table.remove(self.glyphs)
elseif input:wasPressed("a") then
if self.row == ED_ROW and self.col == ED_COL then
self:confirm()
return
end
if self.row == CASE_ROW then
self.lower = not self.lower
return
end
if #self.glyphs < self.maxLen then
Sound.play(self.game.data, "Press_AB")
table.insert(self.glyphs, GRID[self.row][self.col])
if #self.glyphs >= self.maxLen then self:jumpToEnd() end
end
end
end
function NamingScreen:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title, 8, 8)
-- typed name with dashes for the empty slots
for i = 1, self.maxLen do
Font.draw(self.glyphs[i] or "-", 56 + (i - 1) * 8, 24)
end
for r, row in ipairs(self:grid()) do
for c, cell in ipairs(row) do
Font.draw(cell, c * 16, 32 + r * 16)
end
end
Font.drawCode(CURSOR, self.col * 16 - 8, 32 + self.row * 16)
love.graphics.setColor(1, 1, 1, 1)
end
return NamingScreen
+254
View File
@@ -0,0 +1,254 @@
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
-- welcome, the NIDORINO show-off, player and rival naming, and the
-- closing "legend is about to unfold" text followed by the shrink-away:
-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the
-- overworld walking sprite before the fade to white. Uses the real
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText,
-- _IntroduceRivalText) with literal fallbacks.
-- Calls onDone() after popping itself.
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local TextBox = require("src.render.TextBox")
local Font = require("src.render.Font")
local OakSpeech = {}
OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function OakSpeech:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local FALLBACKS = {
_OakSpeechText1 = "Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!",
_OakSpeechText2A = "This world is\ninhabited by\vcreatures called\vPOKéMON!",
_OakSpeechText2B = "\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession.",
_OakSpeechText3 = "{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!",
_IntroducePlayerText = "First, what is\nyour name?",
_IntroduceRivalText = "This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?",
}
local function textOr(game, key)
local t = game.data.text
return (t and t[key]) or FALLBACKS[key]
end
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
function OakSpeech.new(game, onDone)
local self = setmetatable({}, OakSpeech)
self.game = game
self.onDone = onDone
self.step = 0
self.pic = nil
local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
local nido = game.data.pokemon and game.data.pokemon.NIDORINO
self.nidorinoPic = tryImage(nido and nido.spriteFront)
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
local oakGfx = game.data.field and game.data.field.oakSpeech
self.shrinkPic1 = tryImage(oakGfx and oakGfx.shrink1
or "assets/generated/intro/shrink1.png")
self.shrinkPic2 = tryImage(oakGfx and oakGfx.shrink2
or "assets/generated/intro/shrink2.png")
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
-- standing, facing down)
local red = game.data.sprites and game.data.sprites.SPRITE_RED
self.walkSheet = tryImage(red and red.image)
return self
end
function OakSpeech:enter()
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
Music.play(self.game.data, "Music_Routes2")
self:advance()
end
function OakSpeech:say(key, next)
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
end
local STEPS = {
-- 1. Oak's welcome
function(self)
self.pic = self.oakPic
self:say("_OakSpeechText1", function() self:advance() end)
end,
-- 2. NIDORINO show-off, with its cry
function(self)
self.pic = self.nidorinoPic
Sound.playCry(self.game.data, "NIDORINO")
self:say("_OakSpeechText2A", function() self:advance() end)
end,
-- 3. the rest of the world-of-POKéMON spiel
function(self)
self:say("_OakSpeechText2B", function() self:advance() end)
end,
-- 4. "First, what is your name?" over the player's own pic
-- (RedPicFront, oak_speech.asm:86-91) then the naming screen
function(self)
self.pic = self.playerPic or self.oakPic
self:say("_IntroducePlayerText", function() self:advance() end)
end,
function(self)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
title = "YOUR NAME?",
presets = { "RED", "ASH", "JACK" },
maxLen = 7,
onDone = function(name)
self.game.save.player.name = name
self:advance()
end,
}))
end,
-- 6. the rival introduction and naming
function(self)
self.pic = self.rivalPic
self:say("_IntroduceRivalText", function() self:advance() end)
end,
function(self)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
title = "HIS NAME?",
presets = { "BLUE", "GARY", "JOHN" },
maxLen = 7,
onDone = function(name)
self.game.save.player.rival = name
self:advance()
end,
}))
end,
-- 8. "your very own POKéMON legend is about to unfold!" over the
-- player pic again (oak_speech.asm:105-113)
function(self)
self.pic = self.playerPic or self.oakPic
self:say("_OakSpeechText3", function() self:advance() end)
end,
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames
-- into the walking sprite, then fades to white (oak_speech.asm
-- .next, lines 115-166). Not skippable, like the DelayFrames
-- chain it ports.
function(self)
Sound.play(self.game.data, "Shrink")
-- the OakSpeechText3 box holds its last page on screen through the
-- shrink (pokered text boxes persist until overwritten)
self.shrinkText = self:lastPageLines("_OakSpeechText3")
self.shrink = { frame = 0 }
end,
}
-- the last two visible lines of a text's final page, pre-encoded
function OakSpeech:lastPageLines(key)
local ok, lines = pcall(function()
local text = TextBox.substitute(self.game, textOr(self.game, key))
local pages = TextBox.paginate(text)
local page = pages[#pages]
local out = {}
for i = math.max(1, #page - 1), #page do
out[#out + 1] = Font.encode(page[i])
end
return out
end)
return ok and lines or nil
end
function OakSpeech:advance()
self.step = self.step + 1
local fn = STEPS[self.step]
if fn then
fn(self)
else
self:finish()
end
end
function OakSpeech:finish()
-- the map theme starts with the overworld beneath (the original's
-- special warp into Pallet Town)
local ow = self.game.overworld
local mapId = (ow and ow.map and ow.map.id)
or (self.game.save.player and self.game.save.player.map)
if mapId then Music.playMap(self.game.data, mapId) end
self.game.stack:pop()
if self.onDone then self.onDone() end
end
-- Shrink timeline (oak_speech.asm .next):
-- frames 1-4 RedPicFront still up (ld c, 4 / DelayFrames)
-- frames 5-8 ShrinkPic1 (ld c, 4 / DelayFrames)
-- frames 9-28 ShrinkPic2, music fades (wAudioFadeOutControl; ld c, 20)
-- frames 29-78 pic area cleared, walking sprite at the standard
-- player screen spot (ResetPlayerSpriteData /
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
function OakSpeech:update(dt)
if not self.shrink then return end
local s = self.shrink
s.frame = s.frame + 1
if s.frame == 5 then
self.pic = self.shrinkPic1 or self.pic
elseif s.frame == 9 then
self.pic = self.shrinkPic2 or self.pic
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
-- home/fade_audio.asm)
Music.fadeOut(10)
elseif s.frame == 29 then
self.pic = nil
self.walkVisible = true
elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
elseif s.frame > 102 then
self:finish()
end
end
function OakSpeech:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.pic then
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
-- it like the sprite buffer does ((8 - w) >> 1 tiles across,
-- bottom-aligned)
local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 8
love.graphics.draw(self.pic, x, y)
end
if self.walkVisible and self.walkSheet then
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
self.walkQuad = self.walkQuad
or love.graphics.newQuad(0, 0, 16, 16, self.walkSheet:getDimensions())
love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60)
end
if self.shrinkText then
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.shrinkText) do
local y = (12 + 2 * i) * 8
for j, code in ipairs(line) do
Font.drawCode(code, 8 + (j - 1) * 8, y)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
if self.fadeLevel then
love.graphics.setColor(1, 1, 1, self.fadeLevel / 3)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
end
return OakSpeech
+191
View File
@@ -0,0 +1,191 @@
-- Options: text speed, battle animation on/off, battle style SHIFT/SET
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
-- (gen1_faithful keeps the original quirks; modern_clean removes the
-- 1/256 miss etc), plus the port's audio rows and display rows: music/SFX
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- GBC FX.
-- Option boxes scroll through a four-box viewport; CANCEL stays fixed on
-- the bottom line like pokered's.
local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu
OptionsMenu.isOpaque = true
local CURSOR = 0xED -- "▶" (charmap.asm $ED)
local DOWN_ARROW = 0xEE -- "▼" (charmap.asm $EE): more rows below
-- TextSpeedOptionData frame delays with the original labels
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
local RULES = { "gen1_faithful", "modern_clean" }
local FILTERS = { "OFF", "1X", "2X", "3X" }
-- 3 original options + OG GLITCHES / MUSIC VOL / SFX VOL / MUSIC FILTER
-- + COLORS / TILT / GBC FX + CANCEL
local OPTION_ROWS = 10
local ROWS = 11
local CANCEL_ROW = 11
local VISIBLE = 4 -- option boxes on screen at once (4 tiles each)
function OptionsMenu.new(game)
return setmetatable({ game = game, index = 1, scroll = 0 }, OptionsMenu)
end
local function speedIndex(game)
-- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions
local cur = game.save.options.textSpeed or 3
for i, s in ipairs(SPEEDS) do
if s[1] == cur then return i end
end
return 2 -- MEDIUM
end
-- 0-7 volume level display (0 = OFF)
local function volLabel(v)
v = v or 7
return v == 0 and "OFF" or tostring(v)
end
-- volume rows clamp at the ends, like pokered's text-speed cursor
-- (.pressedLeftInTextSpeed stays at FAST rather than wrapping)
local function stepVolume(v, dir)
return math.max(0, math.min(7, (v or 7) + dir))
end
local function colorIndex(opts)
local cur = opts.colors or "gbc"
for i, m in ipairs(PaletteFX.MODES) do
if m == cur then return i end
end
return 1
end
local function wrapIndex(i, n)
i = i % n
if i < 0 then i = i + n end
return i
end
local function stepColors(opts, dir)
local i = colorIndex(opts)
i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1
opts.colors = PaletteFX.MODES[i]
PaletteFX.setMode(opts.colors)
end
local function stepTilt(opts, dir)
opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4)
Tilt.setLevel(opts.tilt)
end
local function stepGbcfx(opts, dir)
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
GBCFX.setLevel(opts.gbcfx)
end
function OptionsMenu:update(dt)
local input = self.game.input
local opts = self.game.save.options
local changed = false
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or ROWS
elseif input:wasPressed("down") then
self.index = self.index < ROWS and self.index + 1 or 1
elseif input:wasPressed("left") or input:wasPressed("right")
or input:wasPressed("a") then
local dir = input:wasPressed("left") and -1 or 1
if self.index == 1 then
local i = speedIndex(self.game) % #SPEEDS + 1
opts.textSpeed = SPEEDS[i][1]
changed = true
elseif self.index == 2 then
opts.animations = opts.animations == false and true or false
changed = true
elseif self.index == 3 then
opts.battleStyle = opts.battleStyle == "set" and "shift" or "set"
changed = true
elseif self.index == 4 then
opts.ruleset = opts.ruleset == RULES[1] and RULES[2] or RULES[1]
changed = true
elseif self.index == 5 then
opts.musicVol = stepVolume(opts.musicVol, dir)
require("src.core.Music").setVolumeLevel(opts.musicVol)
changed = true
elseif self.index == 6 then
opts.sfxVol = stepVolume(opts.sfxVol, dir)
require("src.core.Sound").setVolumeLevel(opts.sfxVol)
changed = true
elseif self.index == 7 then
opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS
require("src.core.Music").setFilterLevel(opts.musicFilter)
changed = true
elseif self.index == 8 then
stepColors(opts, dir)
changed = true
elseif self.index == 9 then
stepTilt(opts, dir)
changed = true
elseif self.index == 10 then
stepGbcfx(opts, dir)
changed = true
elseif input:wasPressed("a") then -- CANCEL
self.game.stack:pop()
end
elseif input:wasPressed("b") or input:wasPressed("start") then
self.game.stack:pop()
end
if changed and self.game.writeOptions then
self.game:writeOptions()
end
-- keep the cursor's box inside the viewport; CANCEL shows the tail
if self.index >= CANCEL_ROW then
self.scroll = OPTION_ROWS - VISIBLE
elseif self.index <= self.scroll then
self.scroll = self.index - 1
elseif self.index > self.scroll + VISIBLE then
self.scroll = self.index - VISIBLE
end
end
function OptionsMenu:draw()
local opts = self.game.save.options
-- one bordered box per option, label line + value line, with CANCEL
-- below (main_menu.asm DisplayOptionMenu layout, extended with the
-- port's rows; a ▼ marks option boxes scrolled off below)
local rows = {
{ "TEXT SPEED", SPEEDS[speedIndex(self.game)][2] },
{ "BATTLE ANIMATION", opts.animations == false and "OFF" or "ON" },
{ "BATTLE STYLE", opts.battleStyle == "set" and "SET" or "SHIFT" },
{ "OG GLITCHES", opts.ruleset == "modern_clean" and "OFF" or "ON" },
{ "MUSIC VOL", volLabel(opts.musicVol) },
{ "SFX VOL", volLabel(opts.sfxVol) },
{ "MUSIC FILTER", FILTERS[(opts.musicFilter or 0) + 1] },
{ "COLORS", PaletteFX.modeLabel(opts.colors or "gbc") },
{ "TILT", Tilt.levelLabel(opts.tilt or 0) },
{ "GBC FX", GBCFX.levelLabel(opts.gbcfx or 0) },
}
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scroll = self.scroll or 0
for slot = 1, VISIBLE do
local i = scroll + slot
local row = rows[i]
Font.drawBox(0, (slot - 1) * 4, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(row[1], 16, ((slot - 1) * 4 + 1) * 8)
Font.draw(row[2], 24, ((slot - 1) * 4 + 2) * 8)
if i == self.index then
Font.drawCode(CURSOR, 8, ((slot - 1) * 4 + 1) * 8)
end
end
if scroll + VISIBLE < #rows then
Font.drawCode(DOWN_ARROW, 144, 128)
end
Font.draw("CANCEL", 16, 136)
if self.index == CANCEL_ROW then Font.drawCode(CURSOR, 8, 136) end
love.graphics.setColor(1, 1, 1, 1)
end
return OptionsMenu
+428
View File
@@ -0,0 +1,428 @@
-- Party menu: list the party, choose a member.
-- Modes:
-- default: A -> submenu (STATS / SWITCH order / CANCEL)
-- opts.onSwitch: A -> hand the chosen mon to the callback (battle
-- switch, item targeting via opts.pickOnly)
-- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B.
local Font = require("src.render.Font")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
PartyMenu.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function PartyMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local CURSOR = 0xED
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
FACILITY = true, INTERIOR = true }
-- Party mon icons (engine/gfx/mon_icons.asm AnimatePartyMon): only the
-- SELECTED mon's icon animates, at a speed set by its HP bar color --
-- 5 / 16 / 32 frames per phase for green / yellow / red (the famous
-- health-speed detail). BALL and HELIX icons nudge one pixel down
-- instead of switching frames; every other icon swaps to a real second
-- frame (+ICONOFFSET).
-- Rest/alt frame per icon (data/icon_pointers.asm
-- MonPartySpritePointers): the base entries are the RESTING frame,
-- the +ICONOFFSET entries the animated alternate. The 16x32 icon
-- sheets stack Frame1 (index 0) over Frame2 (index 1, INC_FRAME_2):
-- BUG/GRASS rest on BugIconFrame2/PlantIconFrame2 and animate to
-- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw
-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12):
-- MON/FAIRY/BIRD rest on the walk frame and animate to standing
-- (tile 0); WATER (Seel) is the reverse.
PartyMenu.iconFrames = {
BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1
GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1
SNAKE = { rest = 0, alt = 1 }, -- SnakeIconFrame1 <-> SnakeIconFrame2
QUADRUPED = { rest = 0, alt = 1 }, -- QuadrupedIconFrame1 <-> Frame2
MON = { rest = 3, alt = 0 }, -- MonsterSprite tile 12 <-> tile 0
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
}
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
-- height) only matters for the fallback, which keeps the old uniform
-- behavior for icons outside the table (BALL/HELIX y-bob instead).
function PartyMenu.frameFor(name, alt, ih)
local m = PartyMenu.iconFrames[name]
if m then return alt and m.alt or m.rest end
return alt and ((ih or 0) >= 64 and 3 or 1) or 0
end
local iconImages = {}
local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons
if not icons then return end
local def = game.data.pokemon[mon.species]
local name = def and def.dex and icons.byDex[def.dex]
local path = name and icons.icons[name]
if not path then return end
if iconImages[path] == nil then
local ok, img = pcall(love.graphics.newImage, path)
iconImages[path] = ok and img or false
end
local img = iconImages[path]
if not img then return end
local alt = false
if selected then
local px = math.floor(mon.hp * 48 / math.max(1, mon.stats.hp))
local speed = px >= 27 and 5 or px >= 10 and 16 or 32
alt = math.floor(counter / speed) % 2 == 1
end
if alt and (name == "BALL" or name == "HELIX") then
y = y + 1
alt = false
end
local iw, ih = img:getDimensions()
if ih > 16 then
local frame = PartyMenu.frameFor(name, alt, ih)
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
else
love.graphics.draw(img, x, y)
end
end
function PartyMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PartyMenu)
self.game = game
self.index = 1
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
self.swapFrom = nil
self.submenu = nil
self.subIndex = 1
self.blink = 0
return self
end
function PartyMenu:update(dt)
-- icon animation counter; 320 = a whole cycle at every HP speed
self.blink = ((self.blink or 0) + 1) % 320
local input = self.game.input
local party = self.party or self.game.save.party
if self.submenu then
local n = #self.subItems
if input:wasPressed("up") then
self.subIndex = self.subIndex > 1 and self.subIndex - 1 or n
elseif input:wasPressed("down") then
self.subIndex = self.subIndex < n and self.subIndex + 1 or 1
elseif input:wasPressed("b") then
self.submenu = nil
elseif input:wasPressed("a") then
local mon = party[self.index]
local action = self.subItems[self.subIndex].action
if action == "stats" then
local SummaryMenu = require("src.ui.SummaryMenu")
self.game.stack:push(SummaryMenu.new(self.game, mon))
elseif action == "switch" then
self.swapFrom = self.index
elseif action == "fly" then
local FlyMenu = require("src.ui.FlyMenu")
self.game.stack:pop() -- close the party menu
self.game.stack:push(FlyMenu.new(self.game))
return
elseif action == "flash" then -- FLASH lights dark tunnels
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap
local ow = self.game.overworld
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
self.game.stack:pop()
ow.dark = false
self.game.save.flashLit = true
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText
or "A blinding FLASH\nlights the area!", function()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
return
elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- current refusals, both of which loop back to the submenu), then
-- ItemUseSurfboard: while surfing it tries to dismount instead;
-- otherwise it mounts only if the FACING tile is water, else
-- SurfingAttemptFailed (_NoSurfingHereText) loops back to the
-- submenu. useSurfFieldMove reports which; trySurf does the mount.
local ow = self.game.overworld
local reason = ow:useSurfFieldMove()
local Transition = require("src.render.Transition")
if reason == "ok" then
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
local fx, fy = ow.player:facingCell()
ow:trySurf(fx, fy)
return
end
if reason == "dismount" then
-- ItemUseSurfboard .stopSurfing: no text -- the walking state
-- and music return first (PlayDefaultMusic +
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:scriptMove(ow.player, ow.player.facing, 1)
end))
return
end
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local key = ({ no_badge = "_NewBadgeRequiredText",
forced_bike = "_CyclingIsFunText",
current = "_CurrentTooFastText",
no_place = "_SurfingNoPlaceToGetOffText" })[reason]
or "_NoSurfingHereText"
local txt = (self.game.data.text[key] or "No SURFing here!")
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
if reason == "no_place" then
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
-- never zeroes wActionResultOrTookBattleTurn, so unlike the
-- other refusals the menu still closes afterwards
-- (GBPalWhiteOutWithDelay3 + .goBackToMap)
self.game.stack:pop()
self.game.stack:push(TextBox.new(self.game, txt, function()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
return
end
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
elseif action == "cut" then
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
-- submenu when the FACING tile isn't a cuttable tree.
local ow = self.game.overworld
local reason = ow:useCutFieldMove()
if reason == "ok" then
self.game.stack:pop() -- close the party menu (CloseTextDisplay)
local fx, fy = ow.player:facingCell()
ow:tryCut(fx, fy)
return
end
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local key = (reason == "no_badge") and "_NewBadgeRequiredText"
or "_NothingToCutText"
local txt = (self.game.data.text[key] or "Nothing to CUT!")
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
elseif action == "strength" then
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
-- predef PrintStrengthText (field_move_messages.asm) sets
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
-- prompt: after the text, the text_asm tail plays the chosen
-- mon's cry, Delay3, and it auto-advances) and
-- _CanMoveBouldersText (`prompt`: waits for A/B). Back in
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map.
local ow = self.game.overworld
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
local name = mon.nickname or def.name
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
ow.strengthActive = true
local t1 = (self.game.data.text._UsedStrengthText
or "{RAM:wNameBuffer} used\nSTRENGTH."):gsub("{RAM:wNameBuffer}", name)
local t2 = (self.game.data.text._CanMoveBouldersText
or "{RAM:wNameBuffer} can\nmove boulders."):gsub("{RAM:wNameBuffer}", name)
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2, function()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
return
elseif action == "softboiled" then
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
-- 1/5 of the user's max HP to a chosen teammate
self.softboiledFrom = self.index
elseif action == "escape" then
-- DIG / TELEPORT both warp to the last Pokémon Center town
-- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport
-- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap
local ow = self.game.overworld
local heal = self.game.save.lastHeal
local Transition = require("src.render.Transition")
self.game.stack:pop()
if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
ow:warpToHealPoint()
end))
end
return
end
self.submenu = nil
end
return
end
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
elseif input:wasPressed("down") then
self.index = self.index < #party and self.index + 1 or 1
elseif input:wasPressed("b") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
elseif input:wasPressed("a") and #party > 0 then
local mon = party[self.index]
if self.softboiledFrom then
local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5)
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
or user.hp <= heal then
self.softboiledFrom = nil
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, "It won't have\nany effect."))
else
user.hp = user.hp - heal
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
self.softboiledFrom = nil
require("src.core.Sound").play(self.game.data, "Heal_HP")
local def = self.game.data.pokemon[mon.species]
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
("%s's HP\nwas restored!"):format(mon.nickname or def.name)))
end
elseif self.swapFrom then
if self.swapFrom ~= self.index then
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
require("src.core.Sound").play(self.game.data, "Swap")
end
self.swapFrom = nil
elseif self.onSwitch then
self.game.stack:pop()
self.onSwitch(mon)
else
self.submenu = true
self.subIndex = 1
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
self.subItems = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local ow = self.game.overworld
if not self.battle and ow and mon.hp > 0 then
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
and self.game.save.inventory.THUNDERBADGE then
table.insert(self.subItems, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
table.insert(self.subItems, { label = "FLASH", action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(self.subItems, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(self.subItems, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
table.insert(self.subItems, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(self.subItems, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(self.subItems, { label = "TELEPORT", action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(self.subItems, { label = "DIG", action = "escape" })
end
end
end
end
end
end
function PartyMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
local party = self.party or self.game.save.party
if #party == 0 then
Font.draw("No POKéMON!", 16, 64)
end
local HudTiles = require("src.render.HudTiles")
for i, mon in ipairs(party) do
local def = self.game.data.pokemon[mon.species]
local y = (i - 1) * 16 + 12
love.graphics.setColor(1, 1, 1, 1)
drawIcon(self.game, mon, 8, y - 2, i == self.index, self.blink or 0)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 24, y)
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
-- status/FNT text at column 17 (PrintStatusCondition), like the
-- original rows -- statused mons keep their level display
if mon.level < 100 then
HudTiles.tile(0x6E, 104, y) -- <LV>
Font.draw(tostring(mon.level), 112, y)
else
-- PrintLevel overwrites the <LV> tile with the third digit
Font.draw(tostring(mon.level), 104, y)
end
if mon.hp <= 0 then
Font.draw("FNT", 136, y)
elseif mon.status then
Font.draw(mon.status, 136, y)
end
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
if i == self.index then
Font.drawCode(CURSOR, 0, y)
end
if i == self.swapFrom or i == self.softboiledFrom then
Font.drawCode(0xEC, 0, y) -- the unfilled swap arrow
end
end
if self.swapFrom then
Font.draw("Move to where?", 8, 136)
elseif self.softboiledFrom then
Font.draw("Use on which one?", 8, 136)
elseif self.pickOnly then
Font.draw("Use on which one?", 8, 136)
end
if self.submenu then
local n = #self.subItems
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
local y0 = (17 - n * 2) * 8
for si, entry in ipairs(self.subItems) do
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
end
Font.drawCode(CURSOR, 80, y0 + (self.subIndex - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
end
return PartyMenu
+41
View File
@@ -0,0 +1,41 @@
-- A framed picture pop-up (DisplayMonFrontSpriteInBox): shows an image
-- in a bordered box over the screen; any button closes it, then the
-- optional text plays.
local Font = require("src.render.Font")
local PicBox = {}
PicBox.__index = PicBox
PicBox.isOpaque = false
function PicBox.new(game, imagePath, text)
local self = setmetatable({}, PicBox)
self.game = game
local ok, img = pcall(love.graphics.newImage, imagePath)
self.image = ok and img or nil
self.text = text
return self
end
function PicBox:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.text then
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, self.text))
end
end
end
function PicBox:draw()
Font.drawBox(6, 4, 9, 9)
if self.image then
love.graphics.setColor(1, 1, 1, 1)
local w, h = self.image:getDimensions()
love.graphics.draw(self.image, math.floor((6 + 4.5) * 8 - w / 2),
math.floor((4 + 4.5) * 8 - h / 2))
end
end
return PicBox
+162
View File
@@ -0,0 +1,162 @@
-- The player's item-storage PC (engine/menus/players_pc.asm):
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
-- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and
-- deposit ask "How many?" via the quantity selector (key items always
-- move one); toss discards after a YES/NO confirm. Follows the
-- BoxMenu/BagMenu list idioms.
local ChoiceBox = require("src.ui.ChoiceBox")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Sound = require("src.core.Sound")
local PlayerPC = {}
local function itemName(game, id)
local def = game.data.items[id]
return def and def.name or id
end
local function buildItems(game, store)
local items = {}
local ids = {}
for id in pairs(store) do table.insert(ids, id) end
table.sort(ids)
for _, id in ipairs(ids) do
table.insert(items, {
value = id,
label = itemName(game, id),
right = "x" .. store[id],
})
end
return items
end
-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText →
-- DisplayChooseQuantityMenu) capped at the stack count. Key items and
-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm).
-- cb(qty) runs only on confirm.
local function askQuantity(game, list, count, id, cb)
local def = game.data.items[id]
if (def and def.keyItem) or id:find("^HM_") then
cb(1)
return
end
list.footer = "How many?"
local QuantityBox = require("src.ui.QuantityBox")
game.stack:push(QuantityBox.new(game, {
max = count,
onDone = function(qty)
if qty then cb(qty) else list.footer = nil end
end,
}))
end
-- refresh the chosen row's count from `store` (or drop the row)
local function refreshRow(list, store, id)
for i, it in ipairs(list.items) do
if it.value == id then
if store[id] then
it.right = "x" .. store[id]
else
table.remove(list.items, i)
end
break
end
end
list.index = math.max(1, math.min(list.index, #list.items))
end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty) then
list.footer = "You can't carry\nany more items."
return
end
pc[item.value] = pc[item.value] - qty
if pc[item.value] <= 0 then pc[item.value] = nil end
refreshRow(list, pc, item.value)
Sound.play(game.data, "Withdraw_Deposit")
list.footer = ("Withdrew\n%s."):format(itemName(game, item.value))
end)
end,
}))
end
-- wNumBoxItems capacity: 50 stacks (PC_ITEM_CAPACITY)
local function pcFull(game, pc, id)
if pc[id] then return false end -- growing an existing stack is fine
local cap = game.data.field.pcItemCap or 50
local stacks = 0
for _ in pairs(pc) do stacks = stacks + 1 end
return stacks >= cap
end
local function deposit(game)
local pc = game.save.pcItems
local inv = game.save.inventory
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv), {
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
list.footer = "No room left to\nstore items."
return
end
require("src.inventory.Bag").remove(game.save, item.value, qty)
pc[item.value] = (pc[item.value] or 0) + qty
refreshRow(list, inv, item.value)
Sound.play(game.data, "Withdraw_Deposit")
list.footer = ("%s was\nstored via PC."):format(itemName(game, item.value))
end)
end,
}))
end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
onChoose = function(item, list)
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
list.footer = "That's too impor-\ntant to toss!"
return
end
local QuantityBox = require("src.ui.QuantityBox")
game.stack:push(QuantityBox.new(game, {
max = pc[item.value] or 1,
onDone = function(qty)
if not qty then return end
list.footer = ("Toss %s?"):format(itemName(game, item.value))
game.stack:push(ChoiceBox.new(game, function(yes)
if yes then
pc[item.value] = pc[item.value] - qty
if pc[item.value] <= 0 then pc[item.value] = nil end
refreshRow(list, pc, item.value)
list.footer = ("Threw away %s."):format(itemName(game, item.value))
else
list.footer = nil
end
end, { noSound = true }))
end,
}))
end,
}))
end
function PlayerPC.new(game)
game.save.pcItems = game.save.pcItems or {}
return Menu.new(game, {
{ label = "WITHDRAW ITEM", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
{ label = "LOG OFF" },
-- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/players_pc.asm PlayersPCMenu)
}, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true })
end
return PlayerPC
+72
View File
@@ -0,0 +1,72 @@
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
local ListMenu = require("src.ui.ListMenu")
local PokedexMenu = {}
-- SGB: PalPacket_Pokedex, whole screen
function PokedexMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON")
end
function PokedexMenu.new(game)
local dex = game.save.pokedex or { seen = {}, owned = {} }
local byDex = {}
for species, def in pairs(game.data.pokemon) do
if def.dex then byDex[def.dex] = def end
end
local items = {}
local seen, owned = 0, 0
for n = 1, 151 do
local def = byDex[n]
if def then
local label
if dex.owned[def.id] then
label = ("%03d %s"):format(n, def.name)
owned = owned + 1
seen = seen + 1
elseif dex.seen[def.id] then
label = ("%03d %s"):format(n, def.name)
seen = seen + 1
else
label = ("%03d -----"):format(n)
end
table.insert(items, {
label = label,
-- owned entries carry the pokéball marker like the original
-- list; seen-only entries are just the name
ball = dex.owned[def.id] or nil,
value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil,
})
end
end
local list = ListMenu.new(game, "POKéDEX", items, {
footer = ("SEEN %d OWNED %d"):format(seen, owned),
pageJump = true, -- Left/Right page jumps like the original
onChoose = function(item)
if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
-- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original, QUIT returns to the list
local Menu = require("src.ui.Menu")
game.stack:push(Menu.new(game, {
{ label = "DATA", onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
game.stack:push(DexEntryMenu.new(game, item.value))
end },
{ label = "CRY", keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value)
end },
{ label = "AREA", onSelect = function()
local TownMap = require("src.ui.TownMap")
game.stack:push(TownMap.new(game, { nestSpecies = item.value }))
end },
{ label = "QUIT" },
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
end,
})
list.sgbPalettes = PokedexMenu.sgbPalettes
return list
end
return PokedexMenu
+55
View File
@@ -0,0 +1,55 @@
-- The "how many?" selector (DisplayChooseQuantityMenu, home/list_menu.asm):
-- Up/Down step by 1 with 1..max roll-over, A confirms, B cancels.
-- Shows a running price when opts.unitPrice is set.
local Font = require("src.render.Font")
local QuantityBox = {}
QuantityBox.__index = QuantityBox
QuantityBox.isOpaque = false
function QuantityBox.new(game, opts)
local self = setmetatable({}, QuantityBox)
self.game = game
self.max = math.max(1, opts.max or 99)
self.qty = math.min(opts.start or 1, self.max)
self.unitPrice = opts.unitPrice
self.onDone = opts.onDone -- onDone(qty | nil on cancel)
return self
end
local function wrap(v, max)
if v < 1 then return max end
if v > max then return 1 end
return v
end
function QuantityBox:update(dt)
local input = self.game.input
if input:wasPressed("up") then
self.qty = wrap(self.qty + 1, self.max)
elseif input:wasPressed("down") then
self.qty = wrap(self.qty - 1, self.max)
elseif input:wasPressed("a") then
self.game.stack:pop()
if self.onDone then self.onDone(self.qty) end
elseif input:wasPressed("b") then
self.game.stack:pop()
if self.onDone then self.onDone(nil) end
end
end
function QuantityBox:draw()
local w = self.unitPrice and 11 or 7
local tx = 20 - w - 1
Font.drawBox(tx, 13, w, 3)
love.graphics.setColor(0, 0, 0, 1)
local s = ("×%02d"):format(self.qty) -- the multiply glyph tile
if self.unitPrice then
s = s .. (" ¥%d"):format(self.qty * self.unitPrice)
end
Font.draw(s, (tx + 1) * 8, 14 * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return QuantityBox
+156
View File
@@ -0,0 +1,156 @@
-- Mart shop (engine/events/pokemart.asm DisplayPokemartDialogue_):
-- the BUY/SELL/QUIT menu loops until QUIT -- BUY and SELL keep it on
-- the stack underneath their list, and QUIT hands control back to the
-- caller (open_mart resumes its yielded script runner there). Both
-- lists run in dialogue mode: the clerk speaks the real _Pokemart*
-- strings in the bottom text box with the money box top-right, then
-- the 1-99 quantity selector (DisplayChooseQuantityMenu) and a YES/NO
-- price confirm. Key items and HMs can't be sold (.unsellableItem).
local Bag = require("src.inventory.Bag")
local ChoiceBox = require("src.ui.ChoiceBox")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local QuantityBox = require("src.ui.QuantityBox")
local ShopMenu = {}
local function txt(game, key, fallback)
return game.data.text[key] or fallback
end
local function buy(game, stock)
local items = {}
for _, id in ipairs(stock) do
local def = game.data.items[id]
if def then
table.insert(items, {
value = id,
label = def.name,
right = ("¥%d"):format(def.price),
})
end
end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
"You don't have\nenough money.")
local list
list = ListMenu.new(game, "BUY", items, {
dialogue = true,
money = function() return game.save.money end,
footer = greet,
onChoose = function(item)
local def = game.data.items[item.value]
if game.save.money < def.price then
list.footer = notEnough
return
end
local affordable = math.min(99, math.floor(game.save.money / math.max(1, def.price)))
game.stack:push(QuantityBox.new(game, {
max = affordable,
unitPrice = def.price,
onDone = function(qty)
if not qty then
list.footer = greet
return
end
local cost = qty * def.price
-- _PokemartTellBuyPriceText + yes/no confirm
list.footer = ("%s?\nThat will be\n¥%d. OK?"):format(def.name, cost)
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
list.footer = greet
return
end
if game.save.money < cost then
list.footer = notEnough
return
end
if not Bag.add(game.save, item.value, qty) then
list.footer = txt(game, "_PokemartItemBagFullText",
"You can't carry\nany more items.")
return
end
require("src.core.Sound").play(game.data, "Purchase")
game.save.money = game.save.money - cost
list.footer = txt(game, "_PokemartBoughtItemText",
"Here you are!\nThank you!")
end))
end,
}))
end,
})
game.stack:push(list)
end
local function sell(game)
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
table.insert(items, {
value = id,
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
})
end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
local list
list = ListMenu.new(game, "SELL", items, {
dialogue = true,
money = function() return game.save.money end,
footer = greet,
onChoose = function(item)
local def = game.data.items[item.value]
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
-- IsItemHM); zero-price items like ETHER sell for ¥0
if (def and def.keyItem) or item.value:find("^HM_") then
list.footer = txt(game, "_PokemartUnsellableItemText",
"I can't put a\nprice on that.")
return
end
local unit = math.floor(def.price / 2)
game.stack:push(QuantityBox.new(game, {
max = game.save.inventory[item.value] or 1,
unitPrice = unit,
onDone = function(qty)
if not qty then
list.footer = greet
return
end
-- _PokemartTellSellPriceText + yes/no confirm
list.footer = ("I can pay you\n¥%d for that."):format(unit * qty)
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
list.footer = greet
return
end
game.save.money = game.save.money + unit * qty
Bag.remove(game.save, item.value, qty)
local left = game.save.inventory[item.value]
if left then
item.label = def.name .. " x" .. left
else
list:removeCurrent()
end
list.footer = txt(game, "_PokemartThankYouText", "Thank you!")
end))
end,
}))
end,
})
game.stack:push(list)
end
function ShopMenu.new(game, stock, onQuit)
-- keepOpen: the mart menu stays underneath its list so closing the
-- list lands back here; only QUIT (or B) leaves and fires onQuit
local menu = Menu.new(game, {
{ label = "BUY", keepOpen = true, onSelect = function() buy(game, stock) end },
{ label = "SELL", keepOpen = true, onSelect = function() sell(game) end },
{ label = "QUIT", onSelect = onQuit },
}, { tx = 0, ty = 0, tw = 8, th = 8 })
menu.onCancel = onQuit
return menu
end
return ShopMenu
+736
View File
@@ -0,0 +1,736 @@
-- Game Corner slot machine minigame.
--
-- Wheels are the real symbol sequences (data/events/slot_machine_wheels.asm
-- via field.slotWheels: 15 symbols per wheel plus 3 wraparound entries,
-- read exactly like SlotMachine_GetWheelTiles). Wheel positions are kept
-- in pokered's half-symbol offsets (wSlotMachineWheelXOffset, 0..29): a
-- wheel may only stop when its offset is odd (a symbol is centred), and
-- every animation step advances the offset by one (SlotMachine_AnimWheel),
-- so slips scroll on screen tile-by-tile like the original.
--
-- Per-wheel stop rules (engine/slots/slot_machine.asm):
-- * wheel 1 (SlotMachine_StopWheel1Early): at each centred position it
-- spends one of 4 slip charges (wSlotMachineWheel1SlipCounter); it stops
-- unless the centred middle symbol is a cherry, which it slips past. In
-- seven-and-bar mode the early-stop test is pokered's bug (`cp
-- HIGH(SLOTS7)` / `jr c`, never true), so it always slips all 4.
-- * wheel 2 (SlotMachine_StopWheel2Early): stops as soon as wheels 1 and 2
-- line up any potential match (SlotMachine_FindWheel1Wheel2Matches); in
-- seven-and-bar mode it instead stops when the matched (or, with no
-- match, bottom) wheel-2 symbol is a 7 or BAR. Up to 4 slips.
-- * wheel 3 (SlotMachine_StopOrAnimWheel3): stops at the next centred
-- position; SlotMachine_CheckForMatches then rerolls it one symbol at a
-- time -- past any match the luck flags forbid (without consuming the
-- counter), or toward a match while wSlotMachineRerollCounter (4) lasts.
--
-- Payouts/paylines follow SlotMachine_CheckForMatches: bet 1 plays the
-- middle row, bet 2 adds top and bottom, bet 3 adds both diagonals,
-- checked in pokered's order with the FIRST match taken; 7-7-7 pays 300,
-- BAR 100, CHERRY 8, anything else 15.
--
-- Hidden luck (SlotMachine_SetFlags + game_corner_slots.asm): one machine
-- per Game Corner visit is "lucky" (seven-and-bar mode chance 5/256 vs
-- 2/256). Each spin: 1/256 arms a 60-charge allow-matches counter,
-- r > chance arms seven-and-bar mode (sticky until a BAR win clears it, or
-- a 300 win does so half the time), r in 211..chance allows a match, the
-- rest can't win.
--
-- Presentation follows pokered's flow: PromptUserToPlaySlots asks "Want to
-- play?" first; MainSlotMachineLoop shows the static SlotMachineMap frame
-- (gfx/slots/slots.tilemap, via field.slotSymbols.tilemap), a "Bet how many
-- coins?" prompt with the ×3/×2/×1 menu (cursor defaults to ×3), flashes the
-- screen on a win (SlotReward*Func b flips of rBGP, 5 frames each) and drips
-- the payout one coin per 8 frames (4 for a 7/BAR) with a jingle and a
-- symbol-palette flicker, then asks "One more go?".
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local SlotMachine = {}
SlotMachine.__index = SlotMachine
SlotMachine.isOpaque = true
-- rBGP/rOBP0 `xor $40` from the default $e4 shows the darkest shade (3) one
-- step lighter (shade 2): the win-screen and payout flash
-- (SlotMachine_CheckForMatches .flashScreenLoop / SlotMachine_PayCoinsToPlayer).
local FLASH_MAP = { [0] = 0, [1] = 1, [2] = 2, [3] = 2 }
-- SGB: PalPacket_Slots + BlkPacket_Slots row bands. While self.flash is set
-- the bands are permuted like pokered's rBGP flip so the machine flashes.
function SlotMachine:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local s1 = P.pal(game.data, "SLOTS1")
if not s1 then return nil end
-- self.flash "all" flips every band (the win-screen rBGP flash); "reels"
-- flips only the symbol window (the payout-time rOBP0 symbol flicker, which
-- the s1 zone at cols 4-15 / rows 4-9 covers).
local function fx(c, reel)
if c and self.flash and (self.flash == "all" or reel) then
return P.permute(c, FLASH_MAP)
end
return c
end
return {
P.zone(fx(P.pal(game.data, "SLOTS2")), 0, 0, 19, 11),
P.zone(fx(P.pal(game.data, "SLOTS3")), 0, 4, 19, 9),
P.zone(fx(P.pal(game.data, "SLOTS4")), 0, 6, 19, 7),
P.zone(fx(s1, true), 4, 4, 15, 9),
P.zone(fx(s1), 0, 12, 19, 17),
}
end
local PAYOUT = { ["7"] = 300, BAR = 100, CHERRY = 8,
MOUSE = 15, FISH = 15, BIRD = 15 }
local SHORT = { ["7"] = " 7 ", BAR = "BAR", CHERRY = "CHR",
MOUSE = "MSE", FISH = "FSH", BIRD = "BRD" }
-- MainSlotMachineLoop timing: one animation step every other frame
-- (DelayFrame in SlotMachine_HandleInputWhileWheelsSpin plus DelayFrames(1)
-- on SGB, which this port colorizes as). The initial free spin is 20
-- steps at the same cadence (SlotMachine_SpinWheels .loop1).
local STEP_FRAMES = 2
local SPINUP_STEPS = 20
local function at(wheel, pos, off)
return wheel[((pos + off - 1) % #wheel) + 1]
end
-- The three visible symbols at a centred position: wheel[pos] (bottom),
-- wheel[pos+1] (middle), wheel[pos+2] (top) -- SlotMachine_GetWheelTiles.
local function rows(wheel, pos)
return at(wheel, pos, 0), at(wheel, pos, 1), at(wheel, pos, 2)
end
-- Paylines in pokered's check order (SlotMachine_CheckForMatches): a
-- 3-coin bet tries both diagonals first, then falls into the 2-coin
-- checks (top row, bottom row), then the 1-coin middle row. The FIRST
-- matching line wins. Entries are row offsets from the bottom.
local LINES = {
{ 0, 1, 2, bet = 3 }, -- wheel1 bottom / wheel2 middle / wheel3 top
{ 2, 1, 0, bet = 3 }, -- wheel1 top / wheel2 middle / wheel3 bottom
{ 2, 2, 2, bet = 2 }, -- top row
{ 0, 0, 0, bet = 2 }, -- bottom row
{ 1, 1, 1, bet = 1 }, -- middle row
}
-- stops = {pos1, pos2, pos3} (1-based bottom-row positions); returns the
-- first matching line's payout+symbol, like SlotMachine_CheckForMatches.
function SlotMachine.evaluate(wheels, stops, bet)
for _, line in ipairs(LINES) do
if bet >= line.bet then
local a = at(wheels[1], stops[1], line[1])
local b = at(wheels[2], stops[2], line[2])
local c = at(wheels[3], stops[3], line[3])
if a == b and b == c then
return { payout = PAYOUT[a] or 15, symbol = a }
end
end
end
return nil
end
-- SlotMachine_StopWheel1Early: true = stop at this centred position.
-- Normally wheel 1 stops unless the centred middle symbol is a cherry.
-- In seven-and-bar mode pokered compares each visible tile with
-- `cp HIGH(SLOTS7)` / `jr c` -- never true, so it never stops early
-- (the wheel always slips through all four charges).
function SlotMachine.stopWheel1Early(wheels, pos1, sevenBar)
if sevenBar then return false end
local _, middle = rows(wheels[1], pos1)
return middle ~= "CHERRY"
end
-- SlotMachine_FindWheel1Wheel2Matches: can wheels 1 and 2, as placed,
-- still line up a payline given a good wheel 3? Pairs are checked in
-- pokered's order: bottom/bottom, bottom/middle, middle/middle,
-- top/middle, top/top (wheel 1 row first). Returns matched plus the
-- wheel-2 tile DE points at afterwards (the matched tile, or wheel 2's
-- bottom tile when nothing matched).
function SlotMachine.findWheel1Wheel2Matches(wheels, pos1, pos2)
local b1, m1, t1 = rows(wheels[1], pos1)
local b2, m2, t2 = rows(wheels[2], pos2)
if b2 == b1 then return true, b2 end
if m2 == b1 then return true, m2 end
if m2 == m1 then return true, m2 end
if m2 == t1 then return true, m2 end
if t2 == t1 then return true, t2 end
return false, b2
end
-- SlotMachine_StopWheel2Early: true = stop at this centred position.
-- Normally wheel 2 stops as soon as any wheel-1/2 match is lined up; in
-- seven-and-bar mode it stops when the matched (or bottom, when nothing
-- matched) wheel-2 symbol is a 7 or BAR.
function SlotMachine.stopWheel2Early(wheels, pos1, pos2, sevenBar)
local matched, tile = SlotMachine.findWheel1Wheel2Matches(wheels, pos1, pos2)
if sevenBar then
return tile == "7" or tile == "BAR"
end
return matched
end
-- One SlotMachine_CheckForMatches decision at the current stops:
-- "accept" -- pay out `win`
-- "roll" -- a match the flags forbid: roll wheel 3 down one symbol
-- and try again (does NOT consume the reroll counter)
-- "nomatch" -- nothing lined up (the caller consumes
-- wSlotMachineRerollCounter to keep rolling toward a match
-- when the flags allow a win)
function SlotMachine.checkForMatch(wheels, stops, bet, canWin, sevenBar)
local win = SlotMachine.evaluate(wheels, stops, bet)
if not win then return "nomatch" end
if not (canWin or sevenBar) then return "roll", win end
if not sevenBar and (win.symbol == "7" or win.symbol == "BAR") then
return "roll", win
end
return "accept", win
end
function SlotMachine.new(game, lucky)
local self = setmetatable({}, SlotMachine)
self.game = game
self.wheels = game.data.field.slotWheels
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
self.stage = "intro"
self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem
-- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to
-- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem.
self.betIndex = 0
self.bet = 3
self.payoutDisplay = 0 -- wPayoutCoins (shown in the top payout box)
self.flash = false
-- wSlotMachineWheelXOffset: 29 matches pokered after LoadSlotMachineTiles
-- draws offset $1c (wheel[15] centred on the bottom row).
self.offset = { 29, 29, 29 }
self.stopping = 0 -- wStoppingWhichSlotMachineWheel
self.slip = { 4, 4 } -- wSlotMachineWheel{1,2}SlipCounter
self.reroll = 4 -- wSlotMachineRerollCounter
self.frame = 0
self.message = nil
-- the per-visit lucky machine gets better seven-and-bar odds
-- (wSlotMachineSevenAndBarModeChance 250 vs 253)
self.sevenBarChance = lucky and 250 or 253
self.allowMatchesCounter = 0 -- wSlotMachineAllowMatchesCounter
-- wSlotMachineFlags bits (BIT_SLOTS_CAN_WIN / _WITH_7_OR_BAR)
self.canWin, self.sevenBar = false, false
return self
end
local function coins(self) return self.game.save.coins or 0 end
-- SlotMachine_SetFlags, rolled as each spin starts. Seven-and-bar mode,
-- once armed, is sticky (the asm returns early while the bit is set).
function SlotMachine:setFlags()
if self.sevenBar then return end
if self.allowMatchesCounter > 0 then
self.canWin = true
return
end
local r = love.math.random(0, 255)
if r == 0 then
-- 1/256: arm 60 guaranteed-winnable spins. This spin's flags are
-- left untouched (the asm returns before writing them).
self.allowMatchesCounter = 60
return
end
if r > self.sevenBarChance then
self.sevenBar = true
return
end
if r > 210 then
self.canWin = true
return
end
self.canWin = false
end
-- SlotMachine_AnimWheel: one half-symbol step; the offset wraps at 30.
function SlotMachine:animWheel(w)
self.offset[w] = (self.offset[w] + 1) % 30
end
local function posOf(offset) return (offset + 1) / 2 end
function SlotMachine:stops()
return { posOf(self.offset[1]), posOf(self.offset[2]), posOf(self.offset[3]) }
end
-- SlotMachine_StopOrAnimWheel1/2: a stopping wheel may halt only at odd
-- offsets; each centred position spends one slip charge on the wheel's
-- early-stop check, freezing the wheel when the check passes or (at the
-- next centred position) when the charges run out.
function SlotMachine:stopOrAnimWheel(w)
if self.stopping < w then
self:animWheel(w)
return
end
local o = self.offset[w]
if o % 2 == 0 then
self:animWheel(w)
return
end
if self.slip[w] == 0 then return end -- stopped
self.slip[w] = self.slip[w] - 1
local stop
if w == 1 then
stop = SlotMachine.stopWheel1Early(self.wheels, posOf(o), self.sevenBar)
else
stop = SlotMachine.stopWheel2Early(self.wheels, posOf(self.offset[1]),
posOf(o), self.sevenBar)
end
if stop then
self.slip[w] = 0
return
end
self:animWheel(w)
end
-- SlotMachine_StopOrAnimWheel3: no slip charges; stops at the next
-- centred position. Returns true when the spin is over.
function SlotMachine:stopOrAnimWheel3()
if self.stopping < 3 then
self:animWheel(3)
return false
end
if self.offset[3] % 2 == 1 then return true end
self:animWheel(3)
return false
end
-- SlotMachine_CheckForMatches at the current stops; either resolves the
-- spin or starts a one-symbol wheel-3 roll (stage "reroll").
function SlotMachine:checkForMatches()
local action, win = SlotMachine.checkForMatch(self.wheels, self:stops(),
self.bet, self.canWin,
self.sevenBar)
if action == "accept" then
self:resolveWin(win)
return
end
if action == "nomatch" then
if not (self.canWin or self.sevenBar) then
self:resolveLose()
return
end
self.reroll = self.reroll - 1
if self.reroll == 0 then
self:resolveLose()
return
end
end
-- .rollWheel3DownByOneSymbol: two half-steps, one per frame
self.stage = "reroll"
self.rerollSteps = 2
end
function SlotMachine:resolveWin(win)
local sym, pay = win.symbol, win.payout
-- SlotReward{300,100,8,15}Func side effects run first (before the flash),
-- and set b = the number of screen flashes.
local flashes
if sym == "7" then
Sound.play(self.game.data, "Get_Item2")
-- SlotReward300Func: "Yeah!", the jackpot always ends an
-- allow-matches streak, and half the time resets the luck flags
if love.math.random(0, 255) >= 128 then
self.canWin, self.sevenBar = false, false
end
self.allowMatchesCounter = 0
flashes = 20 -- b = $14
elseif sym == "BAR" then
Sound.play(self.game.data, "Get_Key_Item")
-- SlotReward100Func always clears the luck flags
self.canWin, self.sevenBar = false, false
flashes = 8 -- b = $8
else
-- SlotReward8Func/SlotReward15Func burn one allow-matches charge
if self.allowMatchesCounter > 0 then
self.allowMatchesCounter = self.allowMatchesCounter - 1
end
flashes = (pay == 8) and 2 or 4 -- b = $2 (cherry) / $4 (15)
end
self.win = win
self.payoutRemaining = pay
self.payoutDisplay = pay
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
-- shows it in the box while the screen flashes. LinedUpText follows.
self.yeah = (sym == "7")
self.message = ("%s lined up!\nScored %d coins!"):format(sym, pay)
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
-- credited until the player dismisses the "lined up" text (see startPayout).
self.stage = "flash"
self.flashLeft = flashes
self.flashTimer = 0
self.flash = false
end
function SlotMachine:resolveLose()
-- NotThisTimeText, then MainSlotMachineLoop asks "One more go?"
self.message = "Not this time!"
self.stage = "message"
self.afterMessage = "onemore"
end
-- MainSlotMachineLoop restart: reset the ×3/×2/×1 menu (wCurrentMenuItem 0
-- defaults the cursor to ×3) and clear the payout box.
function SlotMachine:enterBet()
self.stage = "bet"
self.betIndex = 0
self.bet = 3
self.message = nil
self.payoutDisplay = 0
end
-- OneMoreGoSlotMachineText + its YES/NO menu.
function SlotMachine:enterOneMore()
self.stage = "onemore"
self.yesno = 1
self.message = nil
self.payoutDisplay = 0
end
-- After a spin resolves: running out of coins ends the session (a 60-frame
-- delay then CloseTextDisplay), otherwise ask "One more go?".
function SlotMachine:afterSpin()
if coins(self) == 0 then
self.message = "Darn!\nRan out of coins!"
self.stage = "message"
self.afterMessage = nil
self.exitTimer = 60
else
self:enterOneMore()
end
end
-- SlotMachine_PayCoinsToPlayer: credit one coin every 8 frames (4 for a
-- 7/BAR), a jingle per coin, and flip the object palette every 5 coins.
function SlotMachine:startPayout()
self.stage = "payout"
local sym = self.win and self.win.symbol
self.dripFrames = (sym == "7" or sym == "BAR") and 4 or 8
self.dripTimer = 0
self.dripFlash = 5 -- wAnimCounter
self.flash = false
end
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
function SlotMachine:updateYesNo(onYes)
local input = self.game.input
if input:wasPressed("up") or input:wasPressed("down") then
self.yesno = self.yesno == 1 and 2 or 1
elseif input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
if self.yesno == 1 then onYes() else self.game.stack:pop() end
elseif input:wasPressed("b") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
end
end
function SlotMachine:update(dt)
local input = self.game.input
local save = self.game.save
if self.stage == "intro" then
-- PromptUserToPlaySlots: "A slot machine! Want to play?"
self:updateYesNo(function() self:enterBet() end)
return
end
if self.stage == "message" then
if self.exitTimer then
-- OutOfCoinsSlotMachineText: DelayFrames 60, then leave
self.exitTimer = self.exitTimer - 1
if self.exitTimer <= 0 then self.game.stack:pop() end
return
end
if input:wasPressed("a") or input:wasPressed("b") then
Sound.play(self.game.data, "Press_AB")
local after = self.afterMessage
self.afterMessage = nil
if after == "payout" then
self:startPayout() -- WaitForTextScrollButtonPress -> pay
elseif after == "onemore" then
self:afterSpin()
else
self:enterBet() -- NotEnoughCoinsSlotMachineText -> menu
end
end
return
end
if self.stage == "onemore" then
self:updateYesNo(function() self:enterBet() end)
return
end
if self.stage == "flash" then
-- .flashScreenLoop: toggle the palette every 5 frames, b times
self.flashTimer = self.flashTimer + 1
if self.flashTimer >= 5 then
self.flashTimer = 0
self.flash = self.flash and false or "all"
self.flashLeft = self.flashLeft - 1
if self.flashLeft <= 0 then
self.flash = false
self.stage = "message"
self.afterMessage = "payout"
end
end
return
end
if self.stage == "payout" then
if (self.payoutRemaining or 0) <= 0 then
self.flash = false
self.payoutDisplay = 0
self:afterSpin()
return
end
self.dripTimer = self.dripTimer + 1
if self.dripTimer >= self.dripFrames then
self.dripTimer = 0
save.coins = math.min(9999, coins(self) + 1)
self.payoutRemaining = self.payoutRemaining - 1
self.payoutDisplay = self.payoutRemaining
Sound.play(self.game.data, "Slots_Reward")
self.dripFlash = self.dripFlash - 1
if self.dripFlash <= 0 then
self.dripFlash = 5
self.flash = self.flash and false or "reels" -- rOBP0 xor $40 flicker
end
end
return
end
if self.stage == "bet" then
if input:wasPressed("b") then
self.game.stack:pop()
return
end
-- vertical ×3/×2/×1 menu: UP toward ×3 (betIndex 0), DOWN toward ×1
if input:wasPressed("up") then self.betIndex = math.max(0, self.betIndex - 1) end
if input:wasPressed("down") then self.betIndex = math.min(2, self.betIndex + 1) end
self.bet = 3 - self.betIndex
if input:wasPressed("a") then
if coins(self) < self.bet then
self.message = "Not enough\ncoins!"
self.afterMessage = "bet"
self.stage = "message"
return
end
save.coins = coins(self) - self.bet
self:setFlags()
self.stopping = 0
self.slip = { 4, 4 }
self.reroll = 4
self.frame = 0
self.spinupSteps = SPINUP_STEPS
self.stage = "spinup"
Sound.play(self.game.data, "Slots_New_Spin")
end
return
end
if self.stage == "spinup" then
-- SlotMachine_SpinWheels .loop1: 20 free steps before input is read
self.frame = self.frame + 1
if self.frame % STEP_FRAMES == 0 then
for w = 1, 3 do self:animWheel(w) end
self.spinupSteps = self.spinupSteps - 1
if self.spinupSteps == 0 then self.stage = "spin" end
end
return
end
if self.stage == "spin" then
-- SlotMachine_HandleInputWhileWheelsSpin: A stops the next wheel,
-- but is ignored while the previous wheel is still slipping
if input:wasPressed("a") then
local held = (self.stopping == 1 and self.slip[1] > 0)
or (self.stopping == 2 and self.slip[2] > 0)
if not held then
self.stopping = self.stopping + 1
Sound.play(self.game.data, "Slots_Stop_Wheel")
end
end
self.frame = self.frame + 1
if self.frame % STEP_FRAMES == 0 then
self:stopOrAnimWheel(1)
self:stopOrAnimWheel(2)
if self:stopOrAnimWheel3() then
self:checkForMatches()
end
end
return
end
if self.stage == "reroll" then
self:animWheel(3)
self.rerollSteps = self.rerollSteps - 1
if self.rerollSteps == 0 then
self:checkForMatches()
end
return
end
end
-- reel symbol screen x (wBaseCoordX $30/$50/$70 minus the OAM 8px offset)
-- and the reel window's vertical clip (rows 4-9 of the machine frame).
local SYM_X = { 40, 72, 104 }
local WIN_TOP, WIN_BOT = 32, 80
-- Lazily load the symbol sheet (symbols.png, OAM wheel tiles) and the static
-- machine frame sheet (red_slots_1.png, a tileCols-wide tile atlas).
function SlotMachine:loadArt()
local art = self.game.data.field.slotSymbols
if not art then return nil end
if not self.symbolImg and not self.symbolImgFailed then
local ok, img = pcall(love.graphics.newImage, art.sheet)
if ok then self.symbolImg = img else self.symbolImgFailed = true end
end
if art.tilemap and not self.bgImg and not self.bgImgFailed then
local ok, img = pcall(love.graphics.newImage, art.tilemap.sheet)
if ok then self.bgImg = img else self.bgImgFailed = true end
end
return art
end
-- The three spinning strips over the reel windows. Each strip scrolls in
-- half-symbol (8px) steps like SlotMachine_AnimWheel; even drawn offsets show
-- three full symbols with wheel[(o+1)/2] on the bottom row.
function SlotMachine:drawReels(art)
for w = 1, 3 do
local x = SYM_X[w]
local wheel = self.wheels[w]
local period = math.max(#wheel - 3, 1) -- 15 real symbols + 3 wrap entries
local d = (self.offset[w] - 1) % 30 -- drawn strip offset
local k = math.floor(d / 2)
for j = k - 1, k + 3 do
local yTop = (WIN_BOT - 16) - 16 * j + 8 * d
local clipTop = math.max(yTop, WIN_TOP)
local clipBot = math.min(yTop + 16, WIN_BOT)
if clipBot > clipTop then
local sym = wheel[(j % period) + 1]
local rect = self.symbolImg and art.symbols[sym]
if rect then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.symbolImg,
love.graphics.newQuad(rect.x, rect.y + (clipTop - yTop),
rect.w, clipBot - clipTop,
self.symbolImg:getDimensions()),
x, clipTop)
elseif yTop >= WIN_TOP and yTop + 16 <= WIN_BOT then
love.graphics.setColor(0, 0, 0, 1)
Font.draw(SHORT[sym] or sym, x, yTop)
end
end
end
end
love.graphics.setColor(1, 1, 1, 1)
end
-- The lower dialogue box and, when a prompt is up, the ×3/×2/×1 or YES/NO
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
function SlotMachine:drawBottom()
local lines
if self.stage == "intro" then
lines = { "A slot machine!", "Want to play?" }
elseif self.stage == "bet" then
lines = { "Bet how many", "coins?" }
elseif self.stage == "onemore" then
lines = { "One more", "go?" }
elseif self.stage == "flash" then
lines = self.yeah and { "Yeah!" } or { "Start!" }
elseif self.stage == "spinup" or self.stage == "spin"
or self.stage == "reroll" then
lines = { "Start!" }
elseif self.message then -- message / payout: the wrapped prompt text
lines = {}
for line in (self.message .. "\n"):gmatch("(.-)\n") do
if line ~= "" then lines[#lines + 1] = line end
end
end
if not lines then return end
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(lines[1] or "", 8, 14 * 8)
Font.draw(lines[2] or "", 8, 16 * 8)
if self.stage == "bet" then
Font.drawBox(14, 11, 6, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("×3", 16 * 8, 12 * 8)
Font.draw("×2", 16 * 8, 13 * 8)
Font.draw("×1", 16 * 8, 14 * 8)
Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8)
elseif self.stage == "intro" or self.stage == "onemore" then
-- "One more go?" sits at the right of the box (hlcoord 14,12); the longer
-- "A slot machine!" prompt would clip against it, so the intro's YES/NO
-- floats above the reels instead.
local by = self.stage == "intro" and 6 or 11
Font.drawBox(13, by, 6, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", 15 * 8, (by + 1) * 8)
Font.draw("NO", 15 * 8, (by + 2) * 8)
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
function SlotMachine:draw()
local art = self:loadArt()
local tm = art and art.tilemap
if not (tm and self.bgImg) then return self:drawPlain(art) end
-- static machine frame (SlotMachineMap): blit each tile id from the
-- red_slots_1.png tile atlas; below it stays white for the dialogue box
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local iw, ih = self.bgImg:getDimensions()
self.bgQuads = self.bgQuads or {}
for row = 1, tm.rows do
local cells = tm.tiles[row]
for col = 1, tm.cols do
local id = cells[col]
local q = self.bgQuads[id]
if not q then
q = love.graphics.newQuad((id % tm.tileCols) * 8,
math.floor(id / tm.tileCols) * 8, 8, 8, iw, ih)
self.bgQuads[id] = q
end
love.graphics.draw(self.bgImg, q, (col - 1) * 8, (row - 1) * 8)
end
end
self:drawReels(art)
-- credit / payout numbers (SlotMachine_PrintCreditCoins @5,1 as BCD, and
-- SlotMachine_PrintPayoutCoins @11,1 with leading zeroes)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 40, 8, 32, 8)
love.graphics.rectangle("fill", 88, 8, 32, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%4d"):format(math.min(9999, coins(self))), 40, 8)
Font.draw(("%04d"):format(self.payoutDisplay or 0), 88, 8)
love.graphics.setColor(1, 1, 1, 1)
self:drawBottom()
end
-- Fallback layout for stale builds without the extracted machine frame.
function SlotMachine:drawPlain(art)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("SLOT MACHINE", 32, 4)
Font.draw(("COINS %4d"):format(coins(self)), 8, 16)
if art then self:drawReels(art) end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(">", 12, 56)
Font.draw("<", 140, 56)
love.graphics.setColor(1, 1, 1, 1)
self:drawBottom()
end
return SlotMachine
+117
View File
@@ -0,0 +1,117 @@
-- The START menu (engine/menus/start_menu.asm): entries appear as they
-- become usable -- POKéDEX once Oak gives it, POKéMON once you have any,
-- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT.
local Menu = require("src.ui.Menu")
local StartMenu = {}
function StartMenu.new(game)
local flags = game.save.flags or {}
local items = {}
-- POKéDEX: only after Oak hands it over
if flags.EVENT_GOT_POKEDEX then
table.insert(items, { label = "POKéDEX", onSelect = function()
local PokedexMenu = require("src.ui.PokedexMenu")
game.stack:push(PokedexMenu.new(game))
end })
end
-- POKéMON is always listed (draw_start_menu.asm prints it even with
-- an empty party; selecting it then just no-ops)
table.insert(items, { label = "POKéMON", onSelect = function()
if #game.save.party == 0 then return end
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game))
end })
table.insert(items, { label = "ITEM", onSelect = function()
local BagMenu = require("src.ui.BagMenu")
game.stack:push(BagMenu.new(game))
end })
-- the player's name opens the trainer card (StartMenu_TrainerInfo)
table.insert(items, { label = game.save.player.name or "RED",
onSelect = function()
local TrainerCard = require("src.ui.TrainerCard")
game.stack:push(TrainerCard.new(game))
end })
-- SAVE shows the player/badges/dex/time panel then asks to confirm
-- (PrintSaveScreenText)
table.insert(items, { label = "SAVE", onSelect = function()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local badges = 0
for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE",
"VOLCANOBADGE", "EARTHBADGE" }) do
if game.save.inventory[b] then badges = badges + 1 end
end
local owned = 0
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
owned = owned + 1
end
local t = math.floor(game.save.playTime or 0)
local panel = ("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d")
:format(game.save.player.name or "RED", badges, owned,
math.floor(t / 3600), math.floor(t / 60) % 60)
game.stack:push(TextBox.new(game,
panel .. "\fWould you like to\nSAVE the game?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
-- "Now saving..." beat before the write (save.asm
-- NowSavingString), then GameSavedText + SFX_SAVE
game.stack:push(TextBox.new(game, "Now saving...", function()
game:writeSave()
require("src.core.Sound").play(game.data, "Save")
game.stack:push(TextBox.new(game,
(game.save.player.name or "RED") .. " saved\nthe game!"))
end))
end))
end))
end })
table.insert(items, { label = "OPTION", onSelect = function()
local OptionsMenu = require("src.ui.OptionsMenu")
game.stack:push(OptionsMenu.new(game))
end })
-- LINK needs a party
if #game.save.party > 0 then
table.insert(items, { label = "LINK", onSelect = function()
local LinkState = require("src.link.LinkState")
game.stack:push(LinkState.new(game))
end })
end
-- the original's EXIT just closed the menu (CloseStartMenu); with a
-- window close button covering that, QUIT instead power-cycles back
-- to the title after a confirm (defaultNo guards accidental quits)
table.insert(items, { label = "QUIT", onSelect = function()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game, "RETURN TO MAIN\nMENU?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes then game:returnToTitle() end
end, { defaultNo = true }))
end))
end })
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START.
local menu = Menu.new(game, items,
{ tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
game.save.startMenuIndex = self.index
end
return menu
end
return StartMenu
+142
View File
@@ -0,0 +1,142 @@
-- Pokémon status screen, laid out like the original's two pages
-- (engine/pokemon/status_screen.asm): page 1 = pic, No., HP bar,
-- STATUS/, the ATTACK/DEFENSE/SPEED/SPECIAL box and TYPE1/TYPE2/
-- IDNo/OT; page 2 = EXP and the moves with PP. A flips pages, B (or
-- A on page 2) closes.
local Font = require("src.render.Font")
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
SummaryMenu.isOpaque = true
-- SGB: SetPal_StatusScreen -- HP-bar palette overall, mon pic zone in
-- the species palette
function SummaryMenu:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local mon = self.mon
if not mon then return P.wholeNamed(game.data, "MEWMON") end
local bar = P.pal(game.data, P.barPalName(mon.hp, mon.stats.hp))
if not bar then return nil end
return { P.whole(bar), P.zone(P.monPal(game.data, mon.species), 1, 0, 7, 6) }
end
function SummaryMenu.new(game, mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local def = game.data.pokemon[mon.species]
if def and def.spriteFront then
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
self.sprite = ok and img or nil
end
require("src.core.Sound").playCry(game.data, mon.species)
return self
end
function SummaryMenu:update(dt)
local input = self.game.input
-- both A and B advance the pages (WaitForTextScrollButtonPress)
if input:wasPressed("a") or input:wasPressed("b") then
if self.page == 1 then
self.page = 2
else
self.game.stack:pop()
end
end
end
-- DrawLineBox (status_screen.asm): a vertical edge down the right,
-- a corner, a horizontal run leftward and the half-arrow ending --
-- drawn from the same HUD tiles the original loads
local function drawLineBox(tx, ty, b, c)
local HudTiles = require("src.render.HudTiles")
for i = 0, b - 1 do HudTiles.tile(0x73, tx * 8, (ty + i) * 8) end
HudTiles.tile(0x77, tx * 8, (ty + b) * 8)
for i = 1, c do HudTiles.tile(0x76, (tx - i) * 8, (ty + b) * 8) end
HudTiles.tile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
end
function SummaryMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local mon = self.mon
local game = self.game
local data = game.data
local def = data.pokemon[mon.species]
-- shared header: pic (1,0), name (9,1), <LV> (14,2), No. (1,7)
if self.sprite then
love.graphics.draw(self.sprite, 8,
math.max(0, 56 - self.sprite:getHeight()))
end
local HudTiles = require("src.render.HudTiles")
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 72, 8)
HudTiles.tile(0x6E, 112, 16) -- <LV>
Font.draw(tostring(mon.level), 120, 16)
Font.draw(("No.%03d"):format(def.dex or 0), 8, 56)
if self.page == 1 then
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
-- bracket around the name/HP block
drawLineBox(19, 1, 6, 10)
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
Font.draw("STATUS/", 72, 48)
Font.draw(mon.status or "OK", 128, 48)
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
Font.drawBox(0, 8, 10, 10)
local stats = {
{ "ATTACK", mon.stats.attack }, { "DEFENSE", mon.stats.defense },
{ "SPEED", mon.stats.speed }, { "SPECIAL", mon.stats.special },
}
for i, s in ipairs(stats) do
local y = 72 + (i - 1) * 16
Font.draw(s[1], 8, y)
Font.draw(("%3d"):format(s[2]), 48, y + 8)
end
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
drawLineBox(19, 9, 8, 6)
Font.draw("TYPE1/", 80, 72)
Font.draw(def.types[1] or "", 88, 80)
if def.types[2] then
Font.draw("TYPE2/", 80, 88)
Font.draw(def.types[2], 88, 96)
end
Font.draw("IDNo/", 80, 104)
-- the trainer ID is rolled at new game (SaveData.newGame) and
-- backfilled on load for old saves
Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112)
Font.draw("OT/", 80, 120)
Font.draw(mon.ot or game.save.player.name or "RED", 96, 128)
else
-- page 2: EXP + the moves with PP (StatusScreen2)
drawLineBox(19, 1, 6, 10)
Font.draw("EXP POINTS", 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32)
Font.draw("LEVEL UP", 72, 44)
local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
math.min(100, mon.level + 1)), 88, 52)
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
local y = 72 + (i - 1) * 16
if mv then
local mdef = data.moves[mv.id]
Font.draw(mdef.name, 16, y)
Font.draw("PP", 88, y + 8)
Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8)
else
Font.draw("-", 16, y)
Font.draw("--", 112, y + 8)
end
end
end
love.graphics.setColor(1, 1, 1, 1)
end
return SummaryMenu
+223
View File
@@ -0,0 +1,223 @@
-- Title screen (engine/movie/title.asm + engine/menus/main_menu.asm):
-- the logo (or a text fallback while the asset is missing), a cycling
-- Pokémon front sprite, the copyright line, and the CONTINUE / NEW GAME
-- / OPTION main menu on START or A.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local TitleState = {}
TitleState.__index = TitleState
TitleState.isOpaque = true
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON.
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(P.pal(game.data, "LOGO1"), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
return z[3] and z or nil
end
-- the Red-version TitleMons list (data/pokemon/title_mons.asm):
-- TitleScreenPickNewMon draws a random, never-repeating pick from it
local CYCLE_SPECIES = {
"CHARMANDER", "SQUIRTLE", "BULBASAUR", "WEEDLE", "NIDORAN_M", "SCYTHER",
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
"PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
function TitleState.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TitleState)
self.game = game
self.onNewGame = opts.onNewGame
self.onContinue = opts.onContinue
self.logo = tryImage("assets/logo/pokemon_logo.png")
self.version = tryImage("assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.sprites = {} -- species -> image or false (load failed)
self.cycleIndex = 1
self.timer = 0
self.blink = 0
return self
end
function TitleState:enter()
local data = self.game.data
if data.audio and data.audio.songs and data.audio.songs.Music_TitleScreen then
pcall(Music.play, data, "Music_TitleScreen")
end
end
function TitleState:currentSprite()
local species = CYCLE_SPECIES[self.cycleIndex]
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
cached = tryImage(def and def.spriteFront) or false
self.sprites[species] = cached
end
return cached or nil
end
local function hasSave()
local ok, info = pcall(function()
return love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo("save.lua") or nil
end)
return ok and info ~= nil
end
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
local ContinueInfo = {}
ContinueInfo.__index = ContinueInfo
function ContinueInfo.new(title, save)
return setmetatable({ title = title, game = title.game, save = save },
ContinueInfo)
end
function ContinueInfo:update(dt)
local input = self.game.input
if input:wasPressed("a") then
self.game.stack:pop()
if self.title.onContinue then self.title.onContinue() end
elseif input:wasPressed("b") then
self.game.stack:pop()
self.title:openMenu()
end
end
function ContinueInfo:draw()
local save = self.save
-- box at (4,7), 8x14 content; labels double-spaced from (5,9)
Font.drawBox(4, 7, 16, 10)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PLAYER", 40, 72)
Font.draw((save.player and save.player.name) or "RED", 96, 72)
local badges = 0
for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE",
"VOLCANOBADGE", "EARTHBADGE" }) do
if save.inventory and save.inventory[b] then badges = badges + 1 end
end
Font.draw("BADGES", 40, 88)
Font.draw(("%2d"):format(badges), 128, 88)
local owned = 0
for _ in pairs(save.pokedex and save.pokedex.owned or {}) do
owned = owned + 1
end
Font.draw("POKéDEX", 40, 104)
Font.draw(("%3d"):format(owned), 120, 104)
local t = math.floor(save.playTime or 0)
Font.draw("TIME", 40, 120)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 104, 120)
love.graphics.setColor(1, 1, 1, 1)
end
function TitleState:openMenu()
local Menu = require("src.ui.Menu")
local game = self.game
local items = {}
if hasSave() then
table.insert(items, { label = "CONTINUE", onSelect = function()
-- peek at the save for the info window; fall through if the
-- file can't be read
local ok, loaded = pcall(require("src.core.SaveData").load)
if ok and loaded then
game.stack:push(ContinueInfo.new(self, loaded))
elseif self.onContinue then
self.onContinue()
end
end })
end
table.insert(items, { label = "NEW GAME", onSelect = function()
if self.onNewGame then self.onNewGame() end
end })
table.insert(items, { label = "OPTION", onSelect = function()
game.stack:push(require("src.ui.OptionsMenu").new(game))
end })
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
end
function TitleState:update(dt)
self.timer = self.timer + 1
self.blink = (self.blink + 1) % 60
if self.timer >= CYCLE_FRAMES then
self.timer = 0
-- random pick that never repeats the current one
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #CYCLE_SPECIES)
end
self.cycleIndex = pick
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
end
if self.slideIn and self.slideIn > 0 then
self.slideIn = self.slideIn - 1
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)
require("src.core.Sound").playCry(self.game.data,
CYCLE_SPECIES[self.cycleIndex])
self:openMenu()
end
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.
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.logo then
love.graphics.draw(self.logo, 16, 8)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("POKéMON RED", (160 - 11 * 8) / 2, 24)
love.graphics.setColor(1, 1, 1, 1)
end
if self.version then
-- the strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version")
local iw, ih = self.version:getDimensions()
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
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);
Font.draw("2026 bois club games", 1, 136)
love.graphics.setColor(1, 1, 1, 1)
end
return TitleState
+330
View File
@@ -0,0 +1,330 @@
-- TOWN MAP viewer (engine/menus/town_map.asm; location data from
-- data/maps/town_map_entries.asm via the extractor's field.townMap).
--
-- Grid mode (when field.townMap provides coordinates): the 20x18-tile
-- Kanto map with a filled square per known location -- routes lighter,
-- towns darker -- a blinking cursor the d-pad snaps between locations,
-- the selected name in a banner up top, and the player's current
-- location blinking. List mode (townMap data missing): up/down through
-- an ordered list of fly towns instead. B closes.
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local TownMap = {}
TownMap.__index = TownMap
TownMap.isOpaque = true
-- SGB: PalPacket_TownMap, whole screen
function TownMap:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "TOWNMAP")
end
-- pull x/y out of a townMap entry regardless of the exact shape the
-- extractor settled on ({x=,y=}, {col=,row=} or {coords={x=,y=}})
local function entryCoords(e)
if type(e) ~= "table" then return nil end
local c = e.coords or e
local x = tonumber(c.x or c.col)
local y = tonumber(c.y or c.row)
return x, y
end
local function entryName(e, mapId)
local name = type(e) == "table" and (e.name or e.label) or nil
return name or mapId:gsub("_", " ")
end
local function isRoute(loc)
return loc.name:find("ROUTE", 1, true) ~= nil
end
-- Build the ordered location list. Grid mode dedupes shared entries
-- (interior maps point at their town's square); list mode falls back to
-- the fly towns so the screen still works without townMap data.
local function buildLocations(game)
local field = game.data.field or {}
local townMap = field.townMap
-- the extractor nests the per-map entries under .locations
if type(townMap) == "table" and type(townMap.locations) == "table" then
townMap = townMap.locations
end
local locs, byMap = {}, {}
if type(townMap) == "table" and next(townMap) then
local seen = {}
for mapId, e in pairs(townMap) do
local x, y = entryCoords(e)
if x and y then
local name = entryName(e, mapId)
local key = ("%s:%d:%d"):format(name, x, y)
local loc = seen[key]
if not loc then
loc = { name = name, x = x, y = y }
seen[key] = loc
table.insert(locs, loc)
end
byMap[mapId] = loc
end
end
if #locs > 0 then
table.sort(locs, function(a, b)
if a.y ~= b.y then return a.y < b.y end
if a.x ~= b.x then return a.x < b.x end
return a.name < b.name
end)
return locs, byMap, "grid"
end
end
-- fallback: towns from the fly order (deduped, outdoor maps only)
local seen = {}
for _, mapId in ipairs(field.flyOrder or {}) do
local def = game.data.maps and game.data.maps[mapId]
if not seen[mapId] and def and def.tileset == "OVERWORLD" then
seen[mapId] = true
local loc = { name = mapId:gsub("_", " ") }
table.insert(locs, loc)
byMap[mapId] = loc
end
end
if #locs == 0 then locs = { { name = "KANTO" } } end
return locs, byMap, "list"
end
-- load the extracted Kanto background (nil on stale asset builds)
local function loadBackground(game)
local tm = (game.data.field or {}).townMap or {}
local bg = tm.background
if not (bg and bg.map and bg.tiles) then return nil end
local ok, img = pcall(love.graphics.newImage, bg.tiles.path)
if not ok then return nil end
local quads = {}
local iw, ih = img:getDimensions()
local per = iw / 8
for i = 0, per * (ih / 8) - 1 do
quads[i] = love.graphics.newQuad((i % per) * 8,
math.floor(i / per) * 8, 8, 8, iw, ih)
end
local cursor
if bg.cursor then
local okc, c = pcall(love.graphics.newImage, bg.cursor.path)
cursor = okc and c or nil
end
return { img = img, quads = quads, map = bg.map, cursor = cursor }
end
-- town-map grid -> screen pixels (TownMapCoordsToOAMCoords: the 16x16
-- nybble grid sits 2 tiles in and 1 tile down on the 20x18 screen)
local function markerXY(loc)
return loc.x * 8 + 16, loc.y * 8 + 8
end
-- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) --
-- blink a nest icon on every map whose wild slots hold the species
function TownMap.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TownMap)
self.game = game
self.bg = loadBackground(game)
self.locs, self.byMap, self.mode = buildLocations(game)
if opts.nestSpecies then
self.nestSpecies = opts.nestSpecies
self.nests = {}
local seen = {}
for mapId, enc in pairs(game.data.encounters or {}) do
local found = false
for _, group in pairs(enc) do
for _, slot in ipairs(group.slots or {}) do
if slot.species == opts.nestSpecies then found = true break end
end
if found then break end
end
local loc = found and self.byMap[mapId]
if loc and not seen[loc] then
seen[loc] = true
table.insert(self.nests, loc)
end
end
local ok, img = pcall(love.graphics.newImage,
"assets/generated/townmap/nest.png")
self.nestIcon = ok and img or nil
end
-- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
self.sel = 1
for i, loc in ipairs(self.locs) do
if loc == self.playerLoc then self.sel = i break end
end
self.blink = 0
return self
end
-- snap the cursor to the nearest location in the pressed direction
function TownMap:moveGrid(dx, dy)
local cur = self.locs[self.sel]
local best, bestScore
for i, loc in ipairs(self.locs) do
if i ~= self.sel then
local ddx, ddy = loc.x - cur.x, loc.y - cur.y
local fwd = ddx * dx + ddy * dy -- progress along the d-pad axis
local side = math.abs(ddx * dy) + math.abs(ddy * dx)
if fwd > 0 then
local score = fwd + side * 3 -- prefer staying on-axis
if not best or score < bestScore then best, bestScore = i, score end
end
end
end
if best then
self.sel = best
Sound.play(self.game.data, "Tink")
end
end
function TownMap:moveList(step)
local n = #self.locs
if n < 2 then return end
self.sel = (self.sel - 1 + step) % n + 1
Sound.play(self.game.data, "Tink")
end
function TownMap:update(dt)
self.blink = (self.blink + 1) % 32
local input = self.game.input
if input:wasPressed("b") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
return
end
if self.nestSpecies then
if input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
end
elseif self.mode == "grid" then
if input:wasPressed("up") then self:moveGrid(0, -1)
elseif input:wasPressed("down") then self:moveGrid(0, 1)
elseif input:wasPressed("left") then self:moveGrid(-1, 0)
elseif input:wasPressed("right") then self:moveGrid(1, 0)
end
else
if input:wasPressed("up") then self:moveList(-1)
elseif input:wasPressed("down") then self:moveList(1)
end
end
end
local function drawSquare(loc)
if isRoute(loc) then
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
else
love.graphics.setColor(0.25, 0.25, 0.25, 1) -- towns darker
end
love.graphics.rectangle("fill", loc.x * 8 + 1, loc.y * 8 + 1, 6, 6)
end
function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local selected = self.locs[self.sel]
if self.mode == "grid" and self.bg then
-- the real Kanto map (LoadTownMap's RLE tilemap)
for i, t in ipairs(self.bg.map) do
local col, row = (i - 1) % 20, math.floor((i - 1) / 20)
love.graphics.draw(self.bg.img, self.bg.quads[t], col * 8, row * 8)
end
if self.nestSpecies then
-- AREA mode: blinking nests, the species name up top
if self.blink % 16 < 10 then
for _, loc in ipairs(self.nests) do
local x, y = markerXY(loc)
if self.nestIcon then
love.graphics.draw(self.nestIcon, x, y)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
end
love.graphics.rectangle("fill", 0, 0, 160, 8)
love.graphics.setColor(0, 0, 0, 1)
local def = self.game.data.pokemon[self.nestSpecies]
local name = def and def.name or self.nestSpecies
Font.draw(#self.nests > 0 and (name .. "'s NEST")
or (name .. " AREA UNKNOWN"), 8, 0)
love.graphics.setColor(1, 1, 1, 1)
return
end
-- the player's current location blinks (slow phase)
if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0.75, 0.1, 0.1, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
-- blinking cursor on the selected location
if selected and self.blink % 16 < 10 then
local x, y = markerXY(selected)
if self.bg.cursor then
love.graphics.draw(self.bg.cursor, x, y)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name)
love.graphics.rectangle("fill", 0, 0, 160, 8)
love.graphics.setColor(0, 0, 0, 1)
if selected then Font.draw(selected.name, 8, 0) end
love.graphics.setColor(1, 1, 1, 1)
return
end
love.graphics.setColor(0, 0, 0, 1)
Font.drawBox(0, 0, 20, 18)
if self.mode == "grid" then
-- stale assets (no background art): the old abstract squares
for _, loc in ipairs(self.locs) do
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
love.graphics.setColor(0.75, 0.1, 0.1, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
if selected and self.blink % 16 < 10 then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", selected.x * 8 + 0.5,
selected.y * 8 + 0.5, 7, 7)
end
else
-- list fallback: show a window of names, cursor on the selection
love.graphics.setColor(0, 0, 0, 1)
local rows = 6
local first = math.max(1, math.min(self.sel - 2, #self.locs - rows + 1))
for i = 0, rows - 1 do
local loc = self.locs[first + i]
if loc then
local y = 40 + i * 16
if first + i == self.sel and self.blink % 16 < 10 then
Font.drawCode(0xED, 8, y) -- the "▶" cursor glyph
end
Font.draw(loc.name, 24, y)
if loc == self.playerLoc and self.blink < 20 then
-- blinking marker on the player's current town
love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4)
end
end
end
end
-- name banner across the top
Font.drawBox(0, 0, 20, 3)
love.graphics.setColor(0, 0, 0, 1)
if selected then Font.draw(selected.name, 8, 8) end
love.graphics.setColor(1, 1, 1, 1)
end
return TownMap
+102
View File
@@ -0,0 +1,102 @@
-- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm):
-- the traded POKéMON rises away with its cry and a goodbye, then the
-- received one descends with its cry and "take good care" text.
-- A skips the slide animations ahead. Calls onDone() after popping.
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
local TradeAnim = {}
TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local SLIDE_FRAMES = 90
local REST_Y = 44 -- resting top of the sprite, roughly screen centre
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
local function nameOf(game, mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or (def and def.name) or mon.species
end
local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront)
end
function TradeAnim.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TradeAnim)
self.game = game
self.sent = opts.sent
self.received = opts.received
self.onDone = opts.onDone
self.sentSprite = spriteOf(game, self.sent)
self.receivedSprite = spriteOf(game, self.received)
self.phase = "out"
self.t = 0
return self
end
function TradeAnim:enter()
Sound.playCry(self.game.data, self.sent.species)
end
function TradeAnim:update(dt)
local input = self.game.input
if self.phase == "out" or self.phase == "in" then
self.t = self.t + 1
if input:wasPressed("a") then self.t = SLIDE_FRAMES end
if self.t < SLIDE_FRAMES then return end
if self.phase == "out" then
self.phase = "goodbye"
self.game.stack:push(TextBox.new(self.game,
("Goodbye %s!"):format(nameOf(self.game, self.sent)),
function()
self.phase = "in"
self.t = 0
Sound.playCry(self.game.data, self.received.species)
end))
else
self.phase = "takecare"
self.game.stack:push(TextBox.new(self.game,
("Take good care\nof %s!"):format(nameOf(self.game, self.received)),
function()
self.game.stack:pop()
if self.onDone then self.onDone() end
end))
end
end
end
function TradeAnim:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local sprite, y
if self.phase == "out" then
-- the sent mon rises up and off the screen
sprite = self.sentSprite
y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60))
elseif self.phase == "in" or self.phase == "takecare" then
-- the received mon descends into place
sprite = self.receivedSprite
local t = self.phase == "in" and self.t or SLIDE_FRAMES
y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60))
end
if sprite and y then
local w = sprite:getWidth()
love.graphics.draw(sprite, math.floor((160 - w) / 2), y)
end
end
return TradeAnim
+151
View File
@@ -0,0 +1,151 @@
-- Trainer card (engine/menus/start_sub_menus.asm DrawTrainerInfo):
-- NAME / MONEY / TIME with the player's front pic upper-right, the
-- circle-dotted BADGES banner, and the numbered badge grid. The boxes
-- are built from the real trainer_info.png frame tiles (the patterned
-- band + line style).
local Font = require("src.render.Font")
local TrainerCard = {}
TrainerCard.__index = TrainerCard
TrainerCard.isOpaque = true
-- SGB: PalPacket_TrainerCard leads with MEWMON
function TrainerCard:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
-- gym order (data/scripts/victories.lua badge order)
local BADGES = {
"BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE",
"SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE",
}
local function tryImage(path)
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
local function quads16(img, count, stride, x0, y0)
local q = {}
local iw, ih = img:getDimensions()
for i = 0, count - 1 do
q[i] = love.graphics.newQuad(x0 or 0, (y0 or 0) + i * stride, 16, 16, iw, ih)
end
return q
end
function TrainerCard.new(game)
local self = setmetatable({ game = game }, TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png")
if img then
-- 8 pairs of [gym leader face, badge]
self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) }
end
local nums = tryImage("assets/generated/trainer_card/badge_numbers.png")
if nums then
self.nums = { img = nums, quads = {} }
local iw, ih = nums:getDimensions()
for i = 0, 7 do
self.nums.quads[i] = love.graphics.newQuad((i % 2) * 8,
math.floor(i / 2) * 8,
8, 8, iw, ih)
end
end
-- frame tiles (3x3 sheet): 0 bottom, 1 right, 2 tl, 3 top, 4 tr,
-- 5 left, 6 bl, 7 br, 8 solid pattern
local frame = tryImage("assets/generated/trainer_card/trainer_info.png")
if frame then
self.frame = { img = frame, quads = {} }
for i = 0, 8 do
self.frame.quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8,
8, 8, frame:getDimensions())
end
end
self.circle = tryImage("assets/generated/trainer_card/circle_tile.png")
self.pic = tryImage("assets/generated/trainer_card/red.png")
return self
end
function TrainerCard:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
end
end
-- a frame box in tile coords from the trainer_info tiles
function TrainerCard:frameBox(tx, ty, tw, th)
if not self.frame then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", tx * 8 + 0.5, ty * 8 + 0.5,
tw * 8 - 1, th * 8 - 1)
love.graphics.setColor(1, 1, 1, 1)
return
end
local img, q = self.frame.img, self.frame.quads
love.graphics.setColor(1, 1, 1, 1)
local x1, y1 = (tx + tw - 1) * 8, (ty + th - 1) * 8
love.graphics.draw(img, q[2], tx * 8, ty * 8)
love.graphics.draw(img, q[4], x1, ty * 8)
love.graphics.draw(img, q[6], tx * 8, y1)
love.graphics.draw(img, q[7], x1, y1)
for i = 1, tw - 2 do
love.graphics.draw(img, q[3], (tx + i) * 8, ty * 8)
love.graphics.draw(img, q[0], (tx + i) * 8, y1)
end
for j = 1, th - 2 do
love.graphics.draw(img, q[5], tx * 8, (ty + j) * 8)
love.graphics.draw(img, q[1], x1, (ty + j) * 8)
end
end
function TrainerCard:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local save = self.game.save
-- top card (rows 0-7): NAME / MONEY / TIME, pic upper-right
self:frameBox(0, 0, 20, 8)
if self.pic then
love.graphics.draw(self.pic, 104, 4)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw("NAME/" .. (save.player.name or "RED"), 16, 16)
Font.draw(("MONEY/¥%d"):format(save.money or 0), 16, 32)
local t = math.floor(save.playTime or 0)
Font.draw(("TIME/%3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 16, 48)
-- the circle-dotted BADGES banner (TrainerInfo_BadgesText)
self:frameBox(0, 8, 20, 3)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("BADGES", 56, 72)
if self.circle then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.circle, 48, 72)
love.graphics.draw(self.circle, 104, 72)
love.graphics.setColor(0, 0, 0, 1)
end
-- numbered badge grid (rows 11-17): earned solid, unearned dimmed
self:frameBox(0, 11, 20, 7)
for i = 1, 8 do
local col, row = (i - 1) % 4, math.floor((i - 1) / 4)
local tx, ty = 16 + col * 32, 94 + row * 24
if self.nums then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty)
end
if self.badges and save.inventory[BADGES[i]] then
-- unearned badge slots stay blank (DrawBadges)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.badges.img, self.badges.quads[i - 1],
tx + 4, ty + 6)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
return TrainerCard