mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
big ass modding update
This commit is contained in:
+17
-15
@@ -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
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user