big ass modding update

This commit is contained in:
bryanthaboi
2026-07-19 16:18:18 -04:00
parent b5a673b252
commit 47923d95b3
258 changed files with 31048 additions and 2310 deletions
+17 -15
View File
@@ -149,10 +149,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
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))
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
end)
end
end
list:close()
@@ -162,10 +162,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
-- 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
local ok = pcall(function()
require("src.ui.Screens").push(game, "TownMap")
end)
if not ok then
showMessages(game, { "The TOWN MAP is\nunreadable here." })
end
return
@@ -243,8 +243,11 @@ local function useOn(game, battle, id, target, list, moveIndex)
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
local evoTo, evo = Evolution.pendingFor(game, target,
{ kind = "levelup" })
if evoTo then
Evolution.evolve(game, target, evoTo, nil, evo and evo.method)
end
return
end
for _, mv in ipairs(target.moves) do
@@ -257,8 +260,8 @@ local function useOn(game, battle, id, target, list, moveIndex)
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))
require("src.ui.Screens").push(game, "MoveLearnMenu",
target, moveId, nextStep)
end
end
nextStep()
@@ -291,11 +294,10 @@ 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, {
require("src.ui.Screens").push(game, "PartyMenu", {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
@@ -318,7 +320,7 @@ local function useItem(game, battle, id, list)
end,
}))
end,
}))
})
else
useOn(game, battle, id, nil, list)
end
+99
View File
@@ -0,0 +1,99 @@
-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half,
-- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON"
-- capture and the captured key or pad button lands in
-- save.options.bindings -- the overlay src/core/Bindings.lua
-- (04-mod-api-core) reads back over Input's fixed map.
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
-- Input.lua's map, primary key first where several keys share a button
local BUTTONS = {
{ id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" },
{ id = "left", label = "LEFT", key = "left" },
{ id = "right", label = "RIGHT", key = "right" },
{ id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" },
{ id = "start", label = "START", key = "escape" },
{ id = "select", label = "SELECT", key = "rshift" },
}
-- a binding is a plain key string or { key, pad }; absent = the fixed
-- map, so a vanilla save renders today's keys byte-identically
local function boundKey(overlay, def)
local b = overlay and overlay[def.id]
if type(b) == "table" then return b.key or def.key end
if type(b) == "string" then return b end
return def.key
end
function BindingsMenu.new(game)
local overlay = game.save and game.save.options
and game.save.options.bindings
local items = {}
for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label,
right = boundKey(overlay, def):upper(), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
self.onChoose = function(item) self:beginCapture(item) end
return self
end
-- the capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed
function BindingsMenu:beginCapture(item)
self.capture = item
self.onKeyPressed = BindingsMenu.captureKey
self.onGamepadPressed = BindingsMenu.capturePad
end
function BindingsMenu:captureKey(key)
self:storeBinding("key", key)
end
function BindingsMenu:capturePad(button)
self:storeBinding("pad", button)
end
function BindingsMenu:storeBinding(slot, value)
local item = self.capture
self.capture = nil
self.onKeyPressed = nil
self.onGamepadPressed = nil
local game = self.game
if not (item and value and game.save and game.save.options) then return end
local opts = game.save.options
opts.bindings = opts.bindings or {}
local b = opts.bindings[item.button.id]
if type(b) ~= "table" then
-- keep a direct-edited plain key string when only the pad changes
b = { key = type(b) == "string" and b or nil }
end
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundKey(opts.bindings, item.button):upper()
if game.writeOptions then game:writeOptions() end
end
function BindingsMenu:update(dt)
if self.capture then return end -- the raw capture owns the input
ListMenu.update(self, dt)
end
function BindingsMenu:draw()
ListMenu.draw(self)
if self.capture then
Font.drawBox(1, 6, 18, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PRESS A BUTTON", 24, 60)
love.graphics.setColor(1, 1, 1, 1)
end
end
return BindingsMenu
+1 -2
View File
@@ -24,8 +24,7 @@ local function monSubmenu(game, action, mon, onAction)
label = "STATS",
keepOpen = true,
onSelect = function()
local SummaryMenu = require("src.ui.SummaryMenu")
game.stack:push(SummaryMenu.new(game, mon))
require("src.ui.Screens").push(game, "SummaryMenu", mon)
end,
},
{ label = "CANCEL" },
+7 -6
View File
@@ -1,12 +1,11 @@
-- YES/NO choice box (top-left of the text box area, like the original).
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local ChoiceBox = {}
ChoiceBox.__index = ChoiceBox
local CURSOR = 0xED
function ChoiceBox.new(game, onChoose, opts)
local self = setmetatable({}, ChoiceBox)
self.game = game
@@ -39,11 +38,13 @@ function ChoiceBox:update(dt)
end
function ChoiceBox:draw()
Font.drawBox(0, 7, 6, 5)
local box = Theme.choiceBox
Font.drawBox(box.tx, box.ty, box.tw, box.th)
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)
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
(box.ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+3 -2
View File
@@ -100,6 +100,7 @@ function Credits.new(game, onDone, onTheEnd)
local credits = game.data.field and game.data.field.credits or {}
self.screens = credits.screens or {}
self.theEnd = credits.theEnd
self.music = credits.music or "Music_Credits"
self.index = 0
self.screen = nil
self.phase = "white"
@@ -190,8 +191,8 @@ function Credits:update(dt)
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")
if data.audio and data.audio.songs and data.audio.songs[self.music] then
pcall(Music.play, data, self.music)
end
elseif self.phase == "intro" then
self:nextScreen()
+4 -1
View File
@@ -43,7 +43,10 @@ function DexEntryMenu:draw()
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)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):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
+7 -4
View File
@@ -2,16 +2,19 @@
-- spots from data/maps/special_warps.asm.
local ListMenu = require("src.ui.ListMenu")
local Map = require("src.world.Map")
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
local seen = {}
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
-- towns only (dungeon escape spots share the table), each listed once
local def = game.data.maps[mapId]
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
seen[mapId] = true
table.insert(items, {
value = mapId,
label = mapId:gsub("_", " "),
+16 -4
View File
@@ -138,6 +138,11 @@ function IntroMovie.new(game, onDone)
self.finished = false
local intro = game.data.field and game.data.field.intro or {}
self.introCfg = intro
-- brand-level knobs (12 4.7): studio strings and the skip a total
-- conversion or dev profile sets to jump straight to the title
self.studio = intro.studio or {}
self.skipAll = intro.skip and true or false
local function img(e) return tryImage(e and e.path) end
self.copyright = tryImage("assets/generated/title/copyright.png")
self.logo = img(intro.gamefreakLogo)
@@ -177,8 +182,9 @@ function IntroMovie:startPhase(phase)
-- 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)
local song = self.introCfg.music or "Music_IntroBattle"
if songs and songs[song] then
pcall(Music.play, data, song, false)
end
end
end
@@ -233,6 +239,10 @@ function IntroMovie:fightStep()
end
function IntroMovie:update(dt)
if self.skipAll then
self:finish()
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
@@ -277,7 +287,8 @@ function IntroMovie:drawSplash()
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)
local card = self.studio.card or "bois club games"
Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
love.graphics.setColor(1, 1, 1, 1)
end
if t >= STAR_START and t < FLASH_START then
@@ -355,8 +366,9 @@ function IntroMovie:draw()
-- 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)
local credit = self.studio.credit or "bois club"
Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw("bois club", (160 - 9 * 8) / 2, 64)
Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
elseif self.phase == 2 then
self:drawSplash()
+3 -3
View File
@@ -3,6 +3,7 @@
-- box and the Pokédex.
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local ListMenu = {}
ListMenu.__index = ListMenu
@@ -13,7 +14,6 @@ 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)
@@ -124,10 +124,10 @@ function ListMenu:draw()
-- 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)
and Theme.cursorHollow or Theme.cursor, 8, y)
end
if self.swapIndex == i and i ~= self.index then
Font.drawCode(0xEC, 8, y) -- ▷ marks the item being moved
Font.drawCode(Theme.cursorHollow, 8, y) -- ▷ marks the item being moved
end
end
if self.dialogue then
+2 -3
View File
@@ -5,12 +5,11 @@
-- menu and only the start menu's adds PAD_START.
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
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 {}
@@ -70,7 +69,7 @@ function Menu:draw()
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)
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+60
View File
@@ -0,0 +1,60 @@
-- The widget toolkit as the stable mod-facing surface (mod.ui): the six
-- widgets plus TextBox, Font and Theme, the screen push, and the
-- descriptor-list helpers for the menu-injection hooks. Widgets load on
-- first touch so a headless loader never drags the render stack in.
local ModUI = {}
local MODULES = {
Menu = "src.ui.Menu",
ListMenu = "src.ui.ListMenu",
ChoiceBox = "src.ui.ChoiceBox",
QuantityBox = "src.ui.QuantityBox",
NamingScreen = "src.ui.NamingScreen",
PicBox = "src.ui.PicBox",
TextBox = "src.render.TextBox",
Font = "src.render.Font",
Theme = "src.ui.Theme",
}
setmetatable(ModUI, { __index = function(t, key)
local path = MODULES[key]
if not path then return nil end
local module = require(path)
rawset(t, key, module)
return module
end })
function ModUI.push(game, id, ...)
return require("src.ui.Screens").push(game, id, ...)
end
local function indexOf(items, label)
for i, item in ipairs(items) do
if item.label == label then return i end
end
return nil
end
-- anchored on stable labels so mods place entries without counting rows;
-- a missing anchor appends, which keeps the entry reachable either way
function ModUI.insertBefore(items, anchorLabel, item)
local i = indexOf(items, anchorLabel)
table.insert(items, i or (#items + 1), item)
return items
end
function ModUI.insertAfter(items, anchorLabel, item)
local i = indexOf(items, anchorLabel)
table.insert(items, i and (i + 1) or (#items + 1), item)
return items
end
function ModUI.removeLabel(items, label)
for i = #items, 1, -1 do
if items[i].label == label then table.remove(items, i) end
end
return items
end
return ModUI
+2 -3
View File
@@ -8,6 +8,7 @@
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local Theme = require("src.ui.Theme")
local NamingScreen = {}
NamingScreen.__index = NamingScreen
@@ -18,8 +19,6 @@ 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 = {
@@ -155,7 +154,7 @@ function NamingScreen:draw()
Font.draw(cell, c * 16, 32 + r * 16)
end
end
Font.drawCode(CURSOR, self.col * 16 - 8, 32 + self.row * 16)
Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16)
love.graphics.setColor(1, 1, 1, 1)
end
+31 -18
View File
@@ -16,6 +16,15 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true
-- naming presets are boot config (field.boot.namePresets), which a total
-- conversion replaces; the Red/Blue lists remain the fallback
local function namePresets(game, who, fallback)
local boot = game.data.field and game.data.field.boot
local presets = boot and boot.namePresets and boot.namePresets[who]
if type(presets) == "table" and #presets > 0 then return presets end
return fallback
end
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function OakSpeech:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
@@ -50,15 +59,21 @@ function OakSpeech.new(game, onDone)
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)
local oakGfx = (game.data.field and game.data.field.oakSpeech) or {}
self.cfg = oakGfx
-- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies]
self.demoPic = tryImage(demo and demo.spriteFront)
local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7
-- 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
self.shrinkPic1 = tryImage(oakGfx.shrink1
or "assets/generated/intro/shrink1.png")
self.shrinkPic2 = tryImage(oakGfx and oakGfx.shrink2
self.shrinkPic2 = tryImage(oakGfx.shrink2
or "assets/generated/intro/shrink2.png")
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
-- standing, facing down)
@@ -69,7 +84,7 @@ end
function OakSpeech:enter()
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
Music.play(self.game.data, "Music_Routes2")
Music.play(self.game.data, self.cfg.music or "Music_Routes2")
self:advance()
end
@@ -85,8 +100,8 @@ local STEPS = {
end,
-- 2. NIDORINO show-off, with its cry
function(self)
self.pic = self.nidorinoPic
Sound.playCry(self.game.data, "NIDORINO")
self.pic = self.demoPic
Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end)
end,
-- 3. the rest of the world-of-POKéMON spiel
@@ -100,16 +115,15 @@ local STEPS = {
self:say("_IntroducePlayerText", function() self:advance() end)
end,
function(self)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
require("src.ui.Screens").push(self.game, "NamingScreen", {
title = "YOUR NAME?",
presets = { "RED", "ASH", "JACK" },
maxLen = 7,
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }),
maxLen = self.nameLen,
onDone = function(name)
self.game.save.player.name = name
self:advance()
end,
}))
})
end,
-- 6. the rival introduction and naming
function(self)
@@ -117,16 +131,15 @@ local STEPS = {
self:say("_IntroduceRivalText", function() self:advance() end)
end,
function(self)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
require("src.ui.Screens").push(self.game, "NamingScreen", {
title = "HIS NAME?",
presets = { "BLUE", "GARY", "JOHN" },
maxLen = 7,
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }),
maxLen = self.nameLen,
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)
+59
View File
@@ -0,0 +1,59 @@
-- The four-box options viewport, extracted from OptionsMenu so the mod
-- manager's per-mod options auto-UI renders schemas in the same idiom.
-- Rows are descriptors:
-- { id, label, value = fn(game) -> string,
-- step = fn(game, dir) -> changed, activate = fn(game) }
-- step handles Left/Right/A cyclers; activate is the A-press action for
-- rows that open something instead (MODS, CANCEL stays the caller's).
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local OptionRows = {}
OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each)
-- keep the cursor's box inside the viewport; the fixed bottom row shows
-- the tail of the list
function OptionRows.clampScroll(index, scroll, total, bottomRow)
if bottomRow and index >= bottomRow then
return math.max(0, total - OptionRows.VISIBLE)
elseif index <= scroll then
return index - 1
elseif index > scroll + OptionRows.VISIBLE then
return index - OptionRows.VISIBLE
end
return scroll
end
-- one bordered box per row, label line + value line, with the fixed
-- bottom line below (CANCEL in the options menu, the manager's footer)
function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
for slot = 1, OptionRows.VISIBLE do
local i = scroll + slot
local row = rows[i]
if not row then break end
Font.drawBox(0, (slot - 1) * 4, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8)
Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8)
if i == index then
Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8)
end
end
if scroll + OptionRows.VISIBLE < #rows then
Font.drawCode(Theme.moreArrow, 144, 128)
end
if bottomLabel then
love.graphics.setColor(0, 0, 0, 1)
Font.draw(bottomLabel, 16, 136)
if bottomRow and index == bottomRow then
Font.drawCode(Theme.cursor, 8, 136)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
return OptionRows
+183 -113
View File
@@ -1,37 +1,32 @@
-- 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
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- quirks), 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.
-- GBC FX, and the MODS row that opens the mod manager.
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
-- add their own; CANCEL is appended after the hook and 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 Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
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" }
-- no-loader fallback for the ruleset row, same pair BattleState keeps
local Rulesets = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.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
@@ -42,6 +37,37 @@ local function speedIndex(game)
return 2 -- MEDIUM
end
-- the ruleset row cycles the sorted non-hidden ids of the merged
-- registry (07-battle-extensibility.md 4.6), so mod-registered
-- rulesets are selectable; hidden marks a total conversion's exclusions
local function rulesetIds(game)
local rulesets = game.data and game.data.rulesets or Rulesets
local ids = {}
for id, record in pairs(rulesets) do
if not record.hidden then ids[#ids + 1] = id end
end
table.sort(ids)
return ids
end
local function rulesetIndex(game, ids)
local constants = game.data and game.data.constants
local cur = game.save.options.ruleset
or (constants and constants.defaultRuleset) or "gen1_faithful"
for i, id in ipairs(ids) do
if id == cur then return i end
end
return 1
end
local function rulesetName(game)
local rulesets = game.data and game.data.rulesets or Rulesets
local ids = rulesetIds(game)
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
local record = id and rulesets[id]
return record and record.name or id or "----"
end
-- 0-7 volume level display (0 = OFF)
local function volLabel(v)
v = v or 7
@@ -68,68 +94,152 @@ local function wrapIndex(i, n)
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)
local function sameRows(_, rows) return rows end
-- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged
local function buildRows(game)
return {
{ id = "textSpeed", label = "TEXT SPEED",
value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g)
local i = speedIndex(g) % #SPEEDS + 1
g.save.options.textSpeed = SPEEDS[i][1]
return true
end },
{ id = "animations", label = "BATTLE ANIMATION",
value = function(g)
return g.save.options.animations == false and "OFF" or "ON"
end,
step = function(g)
local o = g.save.options
o.animations = o.animations == false and true or false
return true
end },
{ id = "battleStyle", label = "BATTLE STYLE",
value = function(g)
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
end,
step = function(g)
local o = g.save.options
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
return true
end },
{ id = "ruleset", label = "RULESET",
value = function(g) return rulesetName(g) end,
step = function(g, dir)
local ids = rulesetIds(g)
if #ids == 0 then return false end
local i = rulesetIndex(g, ids)
g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1]
return true
end },
{ id = "musicVol", label = "MUSIC VOL",
value = function(g) return volLabel(g.save.options.musicVol) end,
step = function(g, dir)
local o = g.save.options
o.musicVol = stepVolume(o.musicVol, dir)
require("src.core.Music").setVolumeLevel(o.musicVol)
return true
end },
{ id = "sfxVol", label = "SFX VOL",
value = function(g) return volLabel(g.save.options.sfxVol) end,
step = function(g, dir)
local o = g.save.options
o.sfxVol = stepVolume(o.sfxVol, dir)
require("src.core.Sound").setVolumeLevel(o.sfxVol)
return true
end },
{ id = "musicFilter", label = "MUSIC FILTER",
value = function(g)
return FILTERS[(g.save.options.musicFilter or 0) + 1]
end,
step = function(g, dir)
local o = g.save.options
o.musicFilter = ((o.musicFilter or 0) + dir) % #FILTERS
require("src.core.Music").setFilterLevel(o.musicFilter)
return true
end },
{ id = "colors", label = "COLORS",
value = function(g)
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
end,
step = function(g, dir)
local o = g.save.options
local i = colorIndex(o)
i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1
o.colors = PaletteFX.MODES[i]
PaletteFX.setMode(o.colors)
return true
end },
{ id = "tilt", label = "TILT",
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
step = function(g, dir)
local o = g.save.options
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
Tilt.setLevel(o.tilt)
return true
end },
{ id = "gbcfx", label = "GBC FX",
value = function(g)
return GBCFX.levelLabel(g.save.options.gbcfx or 0)
end,
step = function(g, dir)
local o = g.save.options
o.gbcfx = wrapIndex((o.gbcfx or 0) + dir, 5)
GBCFX.setLevel(o.gbcfx)
return true
end },
-- the manager's discoverable home (18-mod-manager-ux); inert until
-- opened, so the row costs a vanilla install nothing
{ id = "mods", label = "MODS",
value = function(g)
local status = g.modStatus or {}
return ("%d INSTALLED"):format(#(status.available or {}))
end,
activate = function(g)
require("src.ui.Screens").push(g, "ManagerState")
end },
-- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs
-- live in options.bindings, so the row costs a vanilla install nothing
{ id = "controls", label = "CONTROLS",
activate = function(g)
require("src.ui.Screens").push(g, "BindingsMenu")
end },
}
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)
function OptionsMenu.new(game)
local rows = buildRows(game)
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
if type(hooked) == "table" then
rows = hooked
else
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
type(hooked))
end
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0 },
OptionsMenu)
end
function OptionsMenu:update(dt)
local input = self.game.input
local opts = self.game.save.options
local rows = self.rows
-- CANCEL sits below the hook-built rows so a mod cannot orphan the exit
local cancelRow = #rows + 1
local changed = false
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or ROWS
self.index = self.index > 1 and self.index - 1 or cancelRow
elseif input:wasPressed("down") then
self.index = self.index < ROWS and self.index + 1 or 1
self.index = self.index < cancelRow 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
local row = rows[self.index]
if row and row.activate then
if input:wasPressed("a") then row.activate(self.game) end
elseif row and row.step then
changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then -- CANCEL
self.game.stack:pop()
end
@@ -139,53 +249,13 @@ function OptionsMenu:update(dt)
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
self.scroll = OptionRows.clampScroll(self.index, self.scroll or 0,
#rows, cancelRow)
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)
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
"CANCEL", #self.rows + 1)
end
return OptionsMenu
+39 -21
View File
@@ -7,6 +7,10 @@
-- Pops itself on B.
local Font = require("src.render.Font")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Theme = require("src.ui.Theme")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
@@ -17,7 +21,7 @@ function PartyMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local CURSOR = 0xED
local function sameItems(_, items) return items end
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope)
@@ -65,7 +69,9 @@ 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]
-- byDex is the vanilla lookup, but the icons registry can bring the table
-- into existence on its own, so it may be the only key present
local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
local path = name and icons.icons[name]
if not path then return end
if iconImages[path] == nil then
@@ -126,16 +132,18 @@ function PartyMenu:update(dt)
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))
local entry = self.subItems[self.subIndex]
local action = entry.action
if not action and entry.onSelect then
-- hook-injected entries carry a callback instead of an action id
entry.onSelect(mon, self.game)
elseif action == "stats" then
Screens.push(self.game, "SummaryMenu", 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))
Screens.push(self.game, "FlyMenu")
return
elseif action == "flash" then -- FLASH lights dark tunnels
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
@@ -320,45 +328,55 @@ function PartyMenu:update(dt)
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 items = { { 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" })
table.insert(items, { 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" })
table.insert(items, { 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" })
table.insert(items, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(self.subItems, { label = "SURF", action = "surf" })
table.insert(items, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
table.insert(self.subItems, { label = "STRENGTH", action = "strength" })
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(self.subItems, { label = "SOFTBOILED", action = "softboiled" })
table.insert(items, { 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" })
table.insert(items, { 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" })
table.insert(items, { label = "DIG", action = "escape" })
end
end
end
local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems,
self.game, items, mon, ctx)
if type(hooked) == "table" then
items = hooked
else
Logger.error("ui.party.submenu returned %s; keeping the vanilla list",
type(hooked))
end
self.subItems = items
end
end
end
@@ -400,10 +418,10 @@ function PartyMenu:draw()
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)
Font.drawCode(Theme.cursor, 0, y)
end
if i == self.swapFrom or i == self.softboiledFrom then
Font.drawCode(0xEC, 0, y) -- the unfilled swap arrow
Font.drawCode(Theme.cursorHollow, 0, y) -- the unfilled swap arrow
end
end
if self.swapFrom then
@@ -420,7 +438,7 @@ function PartyMenu:draw()
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)
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
end
+11 -8
View File
@@ -17,19 +17,23 @@ function PokedexMenu.new(game)
end
local items = {}
local seen, owned = 0, 0
for n = 1, 151 do
-- dex bound and number width come from constants; the fallbacks keep a
-- cache imported before those keys existed on the Kanto numbering
local constants = game.data.constants or {}
local numFmt = ("%%0%dd"):format(constants.dexDigits or 3)
for n = 1, constants.dexSize or 151 do
local def = byDex[n]
if def then
local label
if dex.owned[def.id] then
label = ("%03d %s"):format(n, def.name)
label = (numFmt .. " %s"):format(n, def.name)
owned = owned + 1
seen = seen + 1
elseif dex.seen[def.id] then
label = ("%03d %s"):format(n, def.name)
label = (numFmt .. " %s"):format(n, def.name)
seen = seen + 1
else
label = ("%03d -----"):format(n)
label = (numFmt .. " -----"):format(n)
end
table.insert(items, {
label = label,
@@ -49,17 +53,16 @@ function PokedexMenu.new(game)
-- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original, QUIT returns to the list
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
game.stack:push(Menu.new(game, {
{ label = "DATA", onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
game.stack:push(DexEntryMenu.new(game, item.value))
Screens.push(game, "DexEntryMenu", 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 }))
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
{ label = "QUIT" },
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
+128
View File
@@ -0,0 +1,128 @@
-- Load-report screen (15-save-data D7.4): what the validation pass moved,
-- removed or remapped, shown once before the overworld. Game:restoreSave
-- pushes it (Screens id "QuarantineReport") only when the report is
-- non-empty, so a vanilla load never constructs it. Nothing here mutates
-- the save -- save.orphaned persists and the report stays re-derivable.
local Font = require("src.render.Font")
local SaveData = require("src.core.SaveData")
local QuarantineReport = {}
QuarantineReport.__index = QuarantineReport
QuarantineReport.isOpaque = true
local VISIBLE = 13 -- report rows on screen at once
local WIDTH = 18 -- text columns inside the border
local function clip(text)
text = tostring(text)
if #text > WIDTH then return text:sub(1, WIDTH) end
return text
end
local function section(lines, header, rows)
if #rows == 0 then return end
if #lines > 0 then lines[#lines + 1] = "" end
lines[#lines + 1] = header
for _, row in ipairs(rows) do lines[#lines + 1] = clip(" " .. row) end
end
-- report shape: { lostMons = {{species, from}}, lostItems = {{id, count,
-- from}}, remappedMaps = {{id, to, field}}, restoredMons, restoredItems,
-- recovered, modsDiff }
local function buildLines(report, meta)
local lines = {}
if report.recovered then
lines[#lines + 1] = "Save recovered from"
lines[#lines + 1] = clip(" the ." .. tostring(report.recovered) .. " backup copy")
end
local rows = {}
for _, mon in ipairs(report.lostMons or {}) do
rows[#rows + 1] = ("%s (%s)"):format(mon.species or "?", mon.from or "?")
end
section(lines, "Moved to LOST box:", rows)
rows = {}
for _, item in ipairs(report.lostItems or {}) do
rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1)
end
section(lines, "Items removed:", rows)
rows = {}
for _, map in ipairs(report.remappedMaps or {}) do
if map.to then
rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to)
else
rows[#rows + 1] = ("%s (%s)"):format(map.id or "?", map.field or "?")
end
end
section(lines, "Location reset:", rows)
rows = {}
for _, mon in ipairs(report.restoredMons or {}) do
rows[#rows + 1] = ("%s to box %d"):format(mon.species or "?", mon.box or 0)
end
for _, item in ipairs(report.restoredItems or {}) do
rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1)
end
section(lines, "Restored:", rows)
local notice = SaveData.modsDiffNotice(report.modsDiff, meta)
if notice then
if #lines > 0 then lines[#lines + 1] = "" end
-- wrap the one-line notice to the box width
for word in notice:gmatch("%S+") do
local last = lines[#lines]
if last and last ~= "" and #last + #word + 1 <= WIDTH then
lines[#lines] = last .. " " .. word
else
lines[#lines + 1] = word
end
end
end
return lines
end
function QuarantineReport.new(game, report)
local self = setmetatable({
game = game,
report = report or {},
offset = 0,
}, QuarantineReport)
self.lines = buildLines(self.report,
game and game.save and game.save.meta)
return self
end
function QuarantineReport:maxOffset()
return math.max(0, #self.lines - VISIBLE)
end
function QuarantineReport:update()
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("up") then
self.offset = math.max(0, self.offset - 1)
elseif input:wasPressed("down") then
self.offset = math.min(self:maxOffset(), self.offset + 1)
elseif input:wasPressed("a") or input:wasPressed("start")
or input:wasPressed("b") then
-- CONTINUE: the overworld is already beneath this screen
self.game.stack:pop()
end
end
function QuarantineReport:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
Font.drawBox(0, 0, 20, 18)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("LOAD REPORT", 8, 8)
for row = 1, VISIBLE do
local line = self.lines[self.offset + row]
if line then Font.draw(line, 8, 12 + row * 8) end
end
if self.offset < self:maxOffset() then
Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124)
end
Font.draw("A:CONTINUE", 8, 130)
love.graphics.setColor(1, 1, 1, 1)
end
return QuarantineReport
+71
View File
@@ -0,0 +1,71 @@
-- Screen id -> factory resolution. The screens registry (Data.screens)
-- wins; engine screens are the require fallback, so a mod-free boot
-- resolves every id to the exact module it required before. One cache,
-- dropped with the rest of the asset caches on dev-mode hot reload.
local Assets = require("src.render.Assets")
local Logger = require("src.core.Logger")
local Screens = {}
-- ids whose builtin module is not under src/ui/
local BUILTIN = {
ManagerState = "src.mods.ManagerState",
}
local cache = {}
local function builtinFor(id)
return require(BUILTIN[id] or ("src.ui." .. id))
end
local function resolve(game, id)
local hit = cache[id]
if hit then return hit end
local screens = game and game.data and game.data.screens
local record = screens and screens[id]
local factory
if record then
-- registry record: { new = fn } or a bare function (05-registry-system)
factory = (type(record) == "function") and { new = record } or record
factory.__modOwned = true
else
factory = builtinFor(id)
end
cache[id] = factory
return factory
end
function Screens.get(game, id)
return resolve(game, id)
end
function Screens.push(game, id, ...)
local factory = resolve(game, id)
local inst
if factory.__modOwned then
-- a broken mod screen degrades to the builtin, never a dead end
local ok, result = pcall(factory.new, game, ...)
if ok and result then
inst = result
else
Logger.error("mod screen '%s' failed: %s -- using builtin",
id, tostring(result))
cache[id] = nil
inst = builtinFor(id).new(game, ...)
end
else
inst = factory.new(game, ...)
end
inst.screenId = inst.screenId or id
game.stack:push(inst)
return inst
end
function Screens.invalidate()
cache = {}
end
Assets.register(Screens.invalidate)
return Screens
+32 -17
View File
@@ -1,11 +1,18 @@
-- 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.
-- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT. The built
-- item list runs through the ui.start_menu.items hook before the menu
-- opens, so mods insert or remove rows without patching this file.
local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local StartMenu = {}
local function sameItems(_, items) return items end
function StartMenu.new(game)
local flags = game.save.flags or {}
local items = {}
@@ -13,8 +20,7 @@ function StartMenu.new(game)
-- 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))
Screens.push(game, "PokedexMenu")
end })
end
@@ -22,20 +28,17 @@ function StartMenu.new(game)
-- 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))
Screens.push(game, "PartyMenu")
end })
table.insert(items, { label = "ITEM", onSelect = function()
local BagMenu = require("src.ui.BagMenu")
game.stack:push(BagMenu.new(game))
Screens.push(game, "BagMenu")
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))
Screens.push(game, "TrainerCard")
end })
-- SAVE shows the player/badges/dex/time panel then asks to confirm
@@ -43,12 +46,7 @@ function StartMenu.new(game)
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 badges = require("src.inventory.Badges").count(game.data, game.save)
local owned = 0
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
owned = owned + 1
@@ -74,8 +72,7 @@ function StartMenu.new(game)
end })
table.insert(items, { label = "OPTION", onSelect = function()
local OptionsMenu = require("src.ui.OptionsMenu")
game.stack:push(OptionsMenu.new(game))
Screens.push(game, "OptionsMenu")
end })
-- LINK needs a party
@@ -86,6 +83,15 @@ function StartMenu.new(game)
end })
end
-- the manager's pause-menu entry (18-mod-manager-ux): gated on at least
-- one discovered mod so a vanilla install's menu is unchanged
local status = game.modStatus
if status and #(status.available or {}) > 0 then
table.insert(items, { label = "MODS", onSelect = function()
Screens.push(game, "ManagerState")
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)
@@ -98,6 +104,15 @@ function StartMenu.new(game)
end, { defaultNo = true }))
end))
end })
local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items)
if type(hooked) == "table" then
items = hooked
else
Logger.error("ui.start_menu.items returned %s; keeping the vanilla items",
type(hooked))
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.
+32
View File
@@ -0,0 +1,32 @@
-- The cursor/border/geometry constants every menu used to redeclare
-- locally, centralized so field.theme can restyle all of them at once.
-- Defaults are the current literals; the merge never runs without a mod,
-- so a vanilla boot draws byte-identically.
local Font = require("src.render.Font")
local Merge = require("src.mods.Merge")
local Renderer = require("src.render.Renderer")
local Theme = {
cursor = 0xED, -- the filled arrow (charmap.asm $ED)
cursorHollow = 0xEC, -- the unfilled arrow left on chosen rows
moreArrow = 0xEE, -- more-below marker (charmap.asm $EE)
tile = 8,
cols = Renderer.WIDTH / 8,
rows = Renderer.HEIGHT / 8,
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
-- Font.load rebuilds its border table, so pick it up here rather than at
-- require time
Theme.border = Font.BORDER
local t = data and data.field and data.field.theme
if t then
Merge.deepMerge(Theme, t)
Font.BORDER = Theme.border
end
end
return Theme
+37 -20
View File
@@ -23,7 +23,8 @@ function TitleState:sgbPalettes(game)
end
-- the Red-version TitleMons list (data/pokemon/title_mons.asm):
-- TitleScreenPickNewMon draws a random, never-repeating pick from it
-- TitleScreenPickNewMon draws a random, never-repeating pick from it;
-- field.title.cycleSpecies replaces it wholesale
local CYCLE_SPECIES = {
"CHARMANDER", "SQUIRTLE", "BULBASAUR", "WEEDLE", "NIDORAN_M", "SCYTHER",
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
@@ -37,15 +38,32 @@ local function tryImage(path)
return ok and img or nil
end
-- the importer seeds field.title with {path,width,height} descriptors
-- (the shape IntroMovie unwraps); mod patches may use plain path strings
local function imagePath(entry)
if type(entry) == "table" then return entry.path end
return entry
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")
-- branding comes from field.title with the shipped art as fallback, so
-- a total conversion rebrands the title without replacing the screen
local title = (game.data.field and game.data.field.title) or {}
self.title = title
self.logo = tryImage(imagePath(title.logo)
or "assets/logo/pokemon_logo.png")
-- versionRibbon is the file-12 key; version is the importer's
self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or CYCLE_SPECIES
self.sprites = {} -- species -> image or false (load failed)
self.cycleIndex = 1
self.timer = 0
@@ -55,13 +73,14 @@ 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")
local song = self.title.music or "Music_TitleScreen"
if data.audio and data.audio.songs and data.audio.songs[song] then
pcall(Music.play, data, song)
end
end
function TitleState:currentSprite()
local species = CYCLE_SPECIES[self.cycleIndex]
local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
@@ -108,12 +127,7 @@ function ContinueInfo:draw()
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
local badges = require("src.inventory.Badges").count(self.game.data, save)
Font.draw("BADGES", 40, 88)
Font.draw(("%2d"):format(badges), 128, 88)
local owned = 0
@@ -149,7 +163,7 @@ function TitleState:openMenu()
if self.onNewGame then self.onNewGame() end
end })
table.insert(items, { label = "OPTION", onSelect = function()
game.stack:push(require("src.ui.OptionsMenu").new(game))
require("src.ui.Screens").push(game, "OptionsMenu")
end })
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
@@ -161,11 +175,13 @@ function TitleState:update(dt)
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)
if #self.cycleSpecies > 1 then
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
self.cycleIndex = pick
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
end
if self.slideIn and self.slideIn > 0 then
@@ -175,7 +191,7 @@ function TitleState:update(dt)
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.cycleSpecies[self.cycleIndex])
self:openMenu()
end
end
@@ -215,8 +231,9 @@ function TitleState:draw()
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)
-- the copyright row (tile 2,17); copyrightText because field.title's
-- copyright key already names the extracted image strip
Font.draw(self.title.copyrightText or "2026 bois club games", 1, 136)
love.graphics.setColor(1, 1, 1, 1)
end
+6 -2
View File
@@ -76,10 +76,11 @@ local function buildLocations(game)
end
end
-- fallback: towns from the fly order (deduped, outdoor maps only)
local Map = require("src.world.Map")
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
if not seen[mapId] and def and Map.isOutdoor(def) then
seen[mapId] = true
local loc = { name = mapId:gsub("_", " ") }
table.insert(locs, loc)
@@ -144,8 +145,11 @@ function TownMap.new(game, opts)
table.insert(self.nests, loc)
end
end
-- field.townMap.nest lifts the icon path out of the engine
local nest = ((game.data.field or {}).townMap or {}).nest
local ok, img = pcall(love.graphics.newImage,
"assets/generated/townmap/nest.png")
(nest and nest.path)
or "assets/generated/townmap/nest.png")
self.nestIcon = ok and img or nil
end
-- the player's current location (guard: overworld may not be running)
+8 -9
View File
@@ -4,6 +4,7 @@
-- are built from the real trainer_info.png frame tiles (the patterned
-- band + line style).
local Badges = require("src.inventory.Badges")
local Font = require("src.render.Font")
local TrainerCard = {}
@@ -15,12 +16,6 @@ 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
@@ -131,14 +126,18 @@ function TrainerCard:draw()
-- numbered badge grid (rows 11-17): earned solid, unearned dimmed
self:frameBox(0, 11, 20, 7)
for i = 1, 8 do
local badges = Badges.list(self.game.data)
for i = 1, #badges 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
-- the extracted sheets cover the eight Kanto slots; a longer badge
-- list draws its extra entries unnumbered rather than crashing
if self.nums and self.nums.quads[i - 1] 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
if self.badges and self.badges.quads[i - 1]
and save.inventory[Badges.itemFor(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],