Files
gen1recomp/src/ui/gen2/MartMenu.lua
T
ShaneMcGovernIE 1586aec9f6 fix(gen2): sell TMs at Poké Marts instead of opening the teach party
Selecting a TM in the mart's SELL pack (and the item PC's DEPOSIT pack) opened the teach-party screen: PackMenu:useSelected taught any item carrying `teaches`, even when the pack was built as a chooser (DepositSellPack, world = {}). On the cart that chooser's jumptable is four ScrollingMenus and never reaches tmhm.asm, so the row must hand back to the caller instead.

Gate the teach and field-NOUSE branches behind world.useFieldItem so only the real field PACK teaches; choosers now hand TMs to onChoose. Selling then prices a TM at half its ItemAttributes price -- exactly half of what the Goldenrod/Celadon TM shelves charge, and half of the hidden price for the rest, the way SelectQuantityToSell -> GetItemPrice -> Sell_HalvePrice does on the cart.

Closes #1243
2026-08-13 23:55:31 +01:00

1007 lines
38 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Gen 2 POKeMART: the whole clerk conversation (engine/items/mart.asm).
--
-- `pokemart dialog_id, mart_id` (macros/scripts/events.asm) is a BLOCKING
-- script command. Script_pokemart farcalls OpenMartDialog, which runs the top
-- menu, the buy list and the sell list to completion before the script's next
-- byte is read, so this screen owns the stack while it is up and the VM is
-- parked on its resume exactly the way it is for a battle.
--
-- Four dialog kinds share one buy list (MartTypeDialogs):
-- MARTTYPE_STANDARD MartDialog BUY / SELL / QUIT, StandardMart's loop
-- MARTTYPE_BITTER HerbShop intro -> BuyMenu -> come again
-- MARTTYPE_BARGAIN BargainShop one of each item, at its own prices
-- MARTTYPE_PHARMACY Pharmacist intro -> BuyMenu -> come again
-- Only STANDARD sells: SellMenu is reached from its top menu and nowhere else.
--
-- The screen is transcribed from the ASM's own coordinates rather than laid
-- out by eye, because that is what makes it land on the 8px grid:
--
-- MenuHeader_BuySell menu_coords 0, 0, 11, 8 -- a 12x9 box. Its flags
-- are STATICMENU_CURSOR with no
-- STATICMENU_NO_TOP_SPACING, so GetMenuTextStartCoord
-- derives BUY at (2,2) with the cursor column at 1,
-- and the three labels are two rows apart.
-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and
-- PlaceMoneyTextbox writes the amount at
-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1)
-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 box holding 4
-- entries of two rows. ScrollingMenu_UpdateDisplay
-- starts at (2,4); PlaceMenuItemName prints the name
-- there and .PrintBCDPrices is handed that origin
-- plus the menu's own width (8) plus SCREEN_WIDTH, so
-- the price lands at (10,5). The ▲ sits on the box's
-- top right corner (19,3) and the ▼ on its bottom
-- right (19,11).
-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with
-- the description at (1,14)
-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and
-- SellItem_MenuHeader the same box. BuySellToss_UpdateQuantityDisplay
-- writes '×' at (8,16) and two leading-zero digits
-- after it; BuySell_DisplaySubtotal's `inc hl` then
-- puts the running total at (12,16).
-- YesNoBox `lb bc, SCREEN_WIDTH - 6, 7` -- a 6x5 box at
-- (14,7). YesNoMenuHeader sets STATICMENU_CURSOR and
-- STATICMENU_NO_TOP_SPACING, so YES is at (16,8) and
-- NO at (16,10), cursor column 15.
-- MoneyBottomLeftMenuHeader
-- menu_coords 0, 11, 8, 13 -- a 9x3 box with the
-- amount at (1,12). The sell flow's two money boxes
-- are both this rect: PlaceMoneyAtTopLeftOfTextbox
-- offsets the top-right header by lb de, 0, 11, which
-- lands on exactly these coordinates.
--
-- Every string below is transcribed literally from data/text/common_2.asm and
-- paired with its pokegold label in LABELS. Nothing in the ROM's script
-- bytecode points at any of them -- the clerk's lines are printed by the
-- engine, not by a `writetext` -- so the extractor seeds its text walker at
-- the block by name (RomExtractorGen2's NAMED_TEXT) and this screen prefers
-- the cache's own characters, falling back to the transcription for a cache
-- built before that seed.
local Bag = require("src.inventory.Bag")
local Chrome = require("src.ui.gen2.Chrome")
local CommonText = require("src.core.gen2.CommonText")
local Save = require("src.core.gen2.Save")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
-- PlayTransactionSound (engine/items/mart.asm): `call WaitSFX` then
-- SFX_TRANSACTION. Both tills ring it -- the buy flow at BuyMenuLoop's
-- .proceed, just before TakeMoney and MARTTEXT_HERE_YOU_GO, and the sell flow
-- right after MartBoughtText -- so it is the money changing hands rather than
-- either message. Named the pokegold way; Sound.GEN2_ALIASES is what maps the
-- shared UI's own "Purchase" onto this same label.
local SFX_TRANSACTION = "Sfx_Transaction"
local MartMenu = {}
MartMenu.__index = MartMenu
-- engine/items/mart.asm:54
MartMenu.isOpaque = false
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
-- ahead of the word mart id, and MartTypeDialogs is indexed by it.
local MART_TYPES = { [0] = "STANDARD", [1] = "BITTER", [2] = "BARGAIN",
[3] = "PHARMACY" }
local NUM_MARTS = 34 -- constants/mart_constants.asm, MART_UNDERGROUND is 33
-- GetMart: an id at or past NUM_MARTS is not a mart at all and the clerk sells
-- DefaultMart instead (data/items/marts.asm).
local DEFAULT_MART = { "POKE_BALL", "POTION" }
-- MAX_ITEM_STACK, the ceiling StandardMartAskPurchaseQuantity loads into
-- wItemQuantity before the selector runs.
local MAX_ITEM_STACK = 99
-- ---------------------------------------------------------------- layout
local TOP_BOX_X, TOP_BOX_Y, TOP_BOX_W, TOP_BOX_H = 0, 0, 12, 9
local TOP_LABEL_X, TOP_LABEL_Y, TOP_SPACING = 2, 2, 2
local MONEY_BOX_X, MONEY_BOX_Y, MONEY_BOX_W, MONEY_BOX_H = 11, 0, 9, 3
local MONEY_X, MONEY_Y = 12, 1
local MONEY_LOW_BOX_X, MONEY_LOW_BOX_Y = 0, 11
local MONEY_LOW_X, MONEY_LOW_Y = 1, 12
local LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H = 1, 3, 19, 9
local LIST_X, LIST_Y, LIST_SPACING = 2, 4, 2
local PRICE_X = LIST_X + 8 -- wMenuData_ScrollingMenuWidth
local VISIBLE_ROWS = 4 -- MenuHeader_Buy's `db 4, 8 ; rows, columns`
local ARROW_X = LIST_BOX_X + LIST_BOX_W - 1
local ARROW_UP_Y, ARROW_DOWN_Y = LIST_BOX_Y, LIST_BOX_Y + LIST_BOX_H - 1
local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6
-- TEXTBOX_INNERY is TEXTBOX_Y + 2 and LineChar (`line`, $4f) targets
-- TEXTBOX_INNERY + 2, so a text box's two lines are TWO rows apart -- which is
-- also where <NEXT> ($4e, SCREEN_WIDTH * 2) puts an item description's second
-- line. One rule covers both.
local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2
local QTY_BOX_X, QTY_BOX_Y, QTY_BOX_W, QTY_BOX_H = 7, 15, 13, 3
local QTY_X, QTY_Y = 8, 16
local QTY_PRICE_X = 12
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
-- charmap.asm: × is the quantity glyph, ¥ the currency one, and ▲ / ▼ are the
-- scrolling menu's own arrow tiles (font codes $61 and $ee).
local TIMES = "\xc3\x97"
local YEN = "\xc2\xa5"
local UP_ARROW = "\xe2\x96\xb2"
local DOWN_ARROW = "\xe2\x96\xbc"
-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS
-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST
-- significant digit rather than at a fixed column, and the field is six digits
-- wide -- so the string is always seven tiles and the yen sign floats.
-- PrintBCDNumber, which the buy list's prices go through, prints the same
-- shape: a leading zero becomes a space and still advances the pointer.
local function moneyText(amount)
local digits = ("%06d"):format(math.max(0, math.floor(amount or 0)))
local first = digits:find("[1-9]") or #digits
return (" "):rep(first - 1) .. YEN .. digits:sub(first)
end
MartMenu.moneyText = moneyText
-- ----------------------------------------------------------------- text
--
-- A "page" is one screenful of the speech text box: up to two lines, `line`
-- rows apart. A `para` in the ASM starts a new page; a `cont` scrolls one
-- line, which shows as a page whose first line is the previous page's second.
local function pages(...) return { ... } end
local function page(...) return { ... } end
local TEXTS = {
-- MartDialog / StandardMart (engine/items/mart.asm).
STANDARD = {
welcome = page("Welcome! How may I", "help you?"), -- MartWelcomeText
askMore = page("Can I do anything", "else for you?"), -- MartAskMoreText
comeAgain = pages(page("Please come again!")),
howMany = page("How many?"),
thanks = pages(page("Here you are.", "Thank you!")),
noMoney = pages(page("You don't have", "enough money.")),
packFull = pages(page("You can't carry", "any more items.")),
-- MartFinalPriceText: the quantity and the total are text_decimal, which
-- sets PRINTNUM_LEFTALIGN, so neither is padded; the ¥ is a literal in the
-- string rather than PrintNum's floating one.
finalPrice = function(qty, name, total)
return pages(page(("%d %s(S)"):format(qty, name),
("will be %s%d."):format(YEN, total)))
end,
},
-- HerbShop (MARTTYPE_BITTER): the Goldenrod Underground herb lady.
BITTER = {
intro = pages(
page("Hello, dear."),
page("I sell inexpensive", "herbal medicine."),
page("They're good, but", "a trifle bitter."),
-- `#` is the four-tile POKé compression byte, which the extractor
-- expands everywhere else in the cache; spelled out it is the same
-- sixteen columns.
page("Your POKéMON may", "not like them."),
page("Hehehehe…")),
comeAgain = pages(page("Come again, dear.", "Hehehehe…")),
howMany = page("How many?"),
thanks = pages(page("Thank you, dear.", "Hehehehe…")),
noMoney = pages(page("Hehehe… You don't", "have the money.")),
packFull = pages(page("Oh? Your PACK is", "full, dear.")),
finalPrice = function(qty, name, total)
return pages(page(("%d %s(S)"):format(qty, name),
("will be %s%d."):format(YEN, total)))
end,
},
-- BargainShop (MARTTYPE_BARGAIN): one of each item, at prices carried by
-- BargainShopData rather than by ItemAttributes.
BARGAIN = {
intro = pages(
page("Hiya! Care to see", "some bargains?"),
page("I sell rare items", "that nobody else"),
page("carries--but only", "one of each item.")),
comeAgain = pages(page("Come by again", "sometime.")),
thanks = pages(page("Thanks.")),
noMoney = pages(page("Uh-oh, you're", "short on funds.")),
packFull = pages(page("Uh-oh, your PACK", "is chock-full.")),
-- `cont` scrolls the box one line instead of clearing it, so the second
-- page opens on the first page's second line.
soldOut = pages(
page("You bought that", "already. I'm all"),
page("already. I'm all", "sold out of it.")),
finalPrice = function(_qty, name, total)
return pages(page(("%s costs"):format(name),
("%s%d. Want it?"):format(YEN, total)))
end,
},
-- Pharmacist (MARTTYPE_PHARMACY): Cianwood.
PHARMACY = {
intro = pages(page("What's up? Need", "some medicine?")),
comeAgain = pages(page("All right.", "See you around.")),
howMany = page("How many?"),
thanks = pages(page("Thanks much!")),
noMoney = pages(page("Huh? That's not", "enough money.")),
packFull = pages(page("You don't have any", "more space.")),
finalPrice = function(qty, name, total)
return pages(page(("%d %s(S)"):format(qty, name),
("will cost %s%d."):format(YEN, total)))
end,
},
}
-- SellMenu's own strings. Only MARTTYPE_STANDARD ever reaches them.
local SELL_TEXTS = {
cantBuy = pages(page("Sorry, I can't buy", "that from you.")),
howMany = page("How many?"),
-- MartSellPriceText's `para` is a real page break, and the YES/NO box comes
-- up on the second page without a further press.
price = function(total)
return pages(page("I can pay you", ("%s%d."):format(YEN, total)),
page("Is that OK?"))
end,
bought = function(name, total)
return pages(page(("Got %s%d for"):format(YEN, total),
("%s(S)."):format(name)))
end,
}
MartMenu.MART_TYPES = MART_TYPES
MartMenu.TEXTS = TEXTS
-- ...and the data/text/common_2.asm label behind each of those entries.
-- Nothing in the ROM's bytecode points at any of them -- the clerk's lines are
-- printed by engine/items/mart.asm itself -- so the extractor seeds its text
-- walker at the whole block by name (RomExtractorGen2's NAMED_TEXT) and this
-- screen prefers the cache's own characters. The transcriptions above are
-- what a cache built before that seed falls back to.
local LABELS = {
STANDARD = {
welcome = "_MartWelcomeText", askMore = "_MartAskMoreText",
comeAgain = "_MartComeAgainText", howMany = "_MartHowManyText",
thanks = "_MartThanksText", noMoney = "_MartNoMoneyText",
packFull = "_MartPackFullText", finalPrice = "_MartFinalPriceText",
},
BITTER = {
intro = "_HerbShopLadyIntroText", comeAgain = "_HerbalLadyComeAgainText",
howMany = "_HerbalLadyHowManyText", thanks = "_HerbalLadyThanksText",
noMoney = "_HerbalLadyNoMoneyText", packFull = "_HerbalLadyPackFullText",
finalPrice = "_HerbalLadyFinalPriceText",
},
BARGAIN = {
intro = "_BargainShopIntroText", comeAgain = "_BargainShopComeAgainText",
thanks = "_BargainShopThanksText", noMoney = "_BargainShopNoFundsText",
packFull = "_BargainShopPackFullText", soldOut = "_BargainShopSoldOutText",
finalPrice = "_BargainShopFinalPriceText",
},
PHARMACY = {
intro = "_PharmacyIntroText", comeAgain = "_PharmacyComeAgainText",
howMany = "_PharmacyHowManyText", thanks = "_PharmacyThanksText",
noMoney = "_PharmacyNoMoneyText", packFull = "_PharmacyPackFullText",
finalPrice = "_PharmacyFinalPriceText",
},
SELL = {
cantBuy = "_MartCantBuyText", howMany = "_MartSellHowManyText",
price = "_MartSellPriceText", bought = "_MartBoughtText",
},
}
MartMenu.LABELS = LABELS
-- The three entries that are ONE screenful of lines rather than a list of
-- pages, because they sit under a menu instead of paging: the welcome line
-- the BUY/SELL/QUIT box opens over, the one it reopens over, and the "How
-- many?" prompt the quantity box sits under.
local SINGLE_PAGE = { welcome = true, askMore = true, howMany = true }
-- The formatted entries, keyed by LABEL rather than by entry name, because
-- the markers come in the string's order and the bargain shop names its item
-- FIRST ("X costs ¥N. Want it?") where the other three clerks lead with the
-- quantity.
local FILL = {
_MartFinalPriceText = function(qty, name, total)
return { qty, name, total }
end,
_HerbalLadyFinalPriceText = function(qty, name, total)
return { qty, name, total }
end,
_PharmacyFinalPriceText = function(qty, name, total)
return { qty, name, total }
end,
_BargainShopFinalPriceText = function(_qty, name, total)
return { name, total }
end,
_MartSellPriceText = function(total) return { total } end,
_MartBoughtText = function(name, total) return { total, name } end,
}
-- One dialog's table with every entry the cache carries replaced by the
-- extracted string. Anything missing falls through the metatable to the
-- transcription, so a partial cache is a mix rather than a hole.
local function extractedText(text, base, labels)
local out = setmetatable({}, { __index = base })
for key, label in pairs(labels or {}) do
local list = CommonText.of(text, label)
if list then
local fill = FILL[label]
if fill then
out[key] = function(...) return CommonText.fill(list, fill(...)) end
elseif SINGLE_PAGE[key] then
out[key] = list[1]
else
out[key] = list
end
end
end
return out
end
-- data/generated/marts.lua is what the ROM extractor will write out of `Marts`
-- (data/items/marts.asm): `lists` is a 1-based array in MART_* order, each
-- entry an array of item ids, and `bargain` is BargainShopData's own
-- item/price rows. A flat top-level array is accepted too, because that is
-- the shape items.lua uses and an extractor may well write it the same way.
--
-- Nothing is guessed when the table is absent: an id inside the table's range
-- with no row falls back to DefaultMart the way GetMart does, and a missing
-- table leaves the shelf empty rather than inventing stock.
function MartMenu.inventory(marts, martId)
martId = martId or 0
if martId >= NUM_MARTS then return DEFAULT_MART end
if type(marts) ~= "table" then return {} end
local lists = marts.lists or marts
local list = lists[martId + 1]
if type(list) ~= "table" then return DEFAULT_MART end
return list
end
-- BargainShopData is loaded by BargainShop itself, not by the mart id, so the
-- opcode's mart id is ignored for MARTTYPE_BARGAIN.
function MartMenu.bargainRows(marts)
local rows = type(marts) == "table" and marts.bargain
if type(rows) ~= "table" then return {} end
return rows
end
-- opts: save, items (items.lua), marts (marts.lua), martType (MARTTYPE_*
-- number or name), martId (number), text (text.lua, for the extracted
-- strings), onClose()
function MartMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, MartMenu)
self.game = game
self.save = opts.save or (game and game.save)
self.items = opts.items or (game and game.data and game.data.items)
self.marts = opts.marts or (game and game.data and game.data.gen2Marts)
-- The dialog byte, or the MARTTYPE_* name spelled out (which is what a test
-- reads more clearly). Anything unrecognised is MartDialog, the way
-- MartTypeDialogs' jumptable would land on entry 0.
local kind = opts.martType or 0
if type(kind) == "string" then
self.martType = TEXTS[kind] and kind or "STANDARD"
else
self.martType = MART_TYPES[kind] or "STANDARD"
end
self.martId = opts.martId or 0
self.onClose = opts.onClose
-- text.lua rides on the world, the way the Pokegear's phone strings do: the
-- mart is opened by a script running on a map and never without one.
self.textData = opts.text or (game and game.world and game.world.text)
self.text = extractedText(self.textData,
TEXTS[self.martType], LABELS[self.martType])
self.sellText = extractedText(self.textData, SELL_TEXTS, LABELS.SELL)
self.index = 1
self.scroll = 0
self:buildEntries()
if self.martType == "STANDARD" then
-- .HowMayIHelpYou prints into the speech box and returns TOPMENU without
-- waiting: the welcome line is still on screen under the BUY/SELL/QUIT
-- menu, which is what the mart looks like on the cart.
self.phase = "top"
self.topLines = self.text.welcome
self.topIndex = 1
else
-- HerbShop / BargainShop / Pharmacist: intro, then straight into BuyMenu.
self.phase = "intro"
self:say(self.text.intro, function() self:enterBuy() end)
end
return self
end
-- The shelf. A standard mart prices each row out of ItemAttributes
-- (GetMartItemPrice -> GetItemPrice); the bargain shop carries its own price
-- per row and sells one of each, tracked by wBargainShopFlags.
function MartMenu:buildEntries()
local entries = {}
if self.martType == "BARGAIN" then
local sold = (self.save and self.save.bargainShop) or {}
for _, row in ipairs(MartMenu.bargainRows(self.marts)) do
local id = row.item or row.id or row[1]
local def = id and self.items and self.items[id]
entries[#entries + 1] = {
id = id,
name = (def and def.name) or id or "?",
price = row.price or row[2] or 0,
soldOut = sold[id] == true,
}
end
else
for _, id in ipairs(MartMenu.inventory(self.marts, self.martId)) do
local def = self.items and self.items[id]
entries[#entries + 1] = {
id = id,
name = (def and def.name) or id,
price = (def and def.price) or 0,
}
end
end
self.entries = entries
end
-- ----------------------------------------------------------------- money
function MartMenu:money()
local player = self.save and self.save.player
return (player and player.money) or 0
end
-- GiveMoney clamps at MaxMoney and TakeMoney clamps at zero
-- (engine/events/money.asm); one clamp covers both directions.
function MartMenu:setMoney(amount)
local player = self.save and self.save.player
if not player then return end
player.money = math.max(0, math.min(math.floor(amount or 0), Save.MAX_MONEY))
end
-- BuySell_MultiplyPrice: the running total is unit * quantity.
function MartMenu.buyPrice(unit, qty)
return (unit or 0) * (qty or 1)
end
-- Sell_HalvePrice shifts the 24-bit PRODUCT right once, so the halving happens
-- after the multiply -- selling two of an odd-priced item is not the same as
-- twice half its price, and this is the half a player notices.
function MartMenu.sellPrice(unit, qty)
return math.floor((unit or 0) * (qty or 1) / 2)
end
-- ---------------------------------------------------------------- overlays
--
-- `say` is PrintText plus the JoyWaitAorB that follows it everywhere in
-- mart.asm: the box holds until a button, and multi-page strings advance a
-- page per press.
function MartMenu:say(list, onDone)
self.message = { pages = list or {}, page = 1, onDone = onDone }
end
function MartMenu:updateMessage(input)
if not (input:wasPressed("a") or input:wasPressed("b")) then return end
local message = self.message
if message.page < #message.pages then
message.page = message.page + 1
return
end
self.message = nil
if message.onDone then message.onDone() end
end
-- MartConfirmPurchase / the sell flow's YesNoBox. The prompt's last page is
-- the one the box sits on; earlier pages advance on a press first.
function MartMenu:ask(list, onYes, onNo)
self.confirm = { pages = list or {}, page = 1, choice = 1,
onYes = onYes, onNo = onNo }
end
function MartMenu:updateConfirm(input)
local confirm = self.confirm
if confirm.page < #confirm.pages then
if input:wasPressed("a") or input:wasPressed("b") then
confirm.page = confirm.page + 1
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
confirm.choice = confirm.choice == 1 and 2 or 1
return
end
if input:wasPressed("b") then
self.confirm = nil
if confirm.onNo then confirm.onNo() end
return
end
if input:wasPressed("a") then
local yes = confirm.choice == 1
self.confirm = nil
if yes then
if confirm.onYes then confirm.onYes() end
elseif confirm.onNo then
confirm.onNo()
end
end
end
-- ---------------------------------------------------------------- top menu
local TOP_ITEMS = { "BUY", "SELL", "QUIT" }
-- .TopMenu copies MenuHeader_BuySell fresh every pass, and its `db 1 ; default
-- option` means the cursor is back on BUY each time the loop returns here.
function MartMenu:enterTop(lines)
self.phase = "top"
self.topLines = lines or self.text.askMore
self.topIndex = 1
end
function MartMenu:updateTop(input)
if input:wasPressed("up") then
self.topIndex = self.topIndex > 1 and self.topIndex - 1 or #TOP_ITEMS
return
elseif input:wasPressed("down") then
self.topIndex = self.topIndex < #TOP_ITEMS and self.topIndex + 1 or 1
return
elseif input:wasPressed("b") then
self:quit()
return
elseif input:wasPressed("a") then
if self.topIndex == 1 then
self:enterBuy()
elseif self.topIndex == 2 then
self:enterSell()
else
self:quit()
end
end
end
-- .Quit: the come-again line, then STANDARDMART_EXIT.
function MartMenu:quit()
self.phase = "outro"
self:say(self.text.comeAgain, function()
if self.onClose then self.onClose() end
end)
end
-- ---------------------------------------------------------------- buy list
--
-- BuyMenu resets wMenuCursorPositionBackup / wMenuScrollPositionBackup on
-- entry and restores them around every purchase, so the cursor survives a sale
-- but not a trip back through the top menu.
function MartMenu:enterBuy()
self.phase = "buy"
self.index = 1
self.scroll = 0
end
function MartMenu:total()
return #self.entries + 1 -- the -1 terminator draws as CANCEL
end
function MartMenu:isCancel()
return self.index > #self.entries
end
function MartMenu:selected()
return self.entries[self.index]
end
function MartMenu:ensureVisible()
if self.index <= self.scroll then
self.scroll = self.index - 1
elseif self.index > self.scroll + VISIBLE_ROWS then
self.scroll = self.index - VISIBLE_ROWS
end
self.scroll = math.max(0, math.min(self.scroll,
math.max(0, self:total() - VISIBLE_ROWS)))
end
function MartMenu:updateBuy(input)
-- _2DMENU_EXIT_UP / _2DMENU_EXIT_DOWN with .d_up refusing to move at scroll
-- zero: the buy list does NOT wrap the way the PACK's does.
if input:wasPressed("up") then
if self.index > 1 then
self.index = self.index - 1
self:ensureVisible()
end
return
elseif input:wasPressed("down") then
if self.index < self:total() then
self.index = self.index + 1
self:ensureVisible()
end
return
elseif input:wasPressed("b") then
self:leaveBuy()
return
elseif input:wasPressed("a") then
-- .a_button treats a -1 selection as B, so A on CANCEL leaves the list.
if self:isCancel() then
self:leaveBuy()
else
self:offerToBuy()
end
end
end
-- BuyMenu returns into StandardMart .Buy, which falls through to
-- .AnythingElse; the other three dialog kinds end on their come-again line.
function MartMenu:leaveBuy()
if self.martType == "STANDARD" then
self:enterTop(self.text.askMore)
else
self:quit()
end
end
function MartMenu:offerToBuy()
local entry = self:selected()
if not entry then return end
if self.martType == "BARGAIN" then
-- BargainShopAskPurchaseQuantity: one of each, no quantity selector, and a
-- CHECK_FLAG on wBargainShopFlags ahead of everything else.
if entry.soldOut then
self:say(self.text.soldOut)
return
end
self.qtyItem = entry
self.qty = 1
self.qtyMax = 1
self:confirmPurchase()
return
end
self.qtyItem = entry
self.qty = 1
self.qtyMax = MAX_ITEM_STACK
self.phase = "buyQuantity"
end
function MartMenu:confirmPurchase()
local entry = self.qtyItem
local total = MartMenu.buyPrice(entry.price, self.qty)
self:ask(self.text.finalPrice(self.qty, entry.name, total),
function() self:completePurchase(total) end,
function() self:cancelPurchase() end)
end
-- .cancel simply redraws the speech box and drops back into the buy loop with
-- the cursor where it was, so this keeps index/scroll.
function MartMenu:cancelPurchase()
self.phase = "buy"
end
-- PlayTransactionSound. A driver can run this screen with no audio table at
-- all, so a missing one is silence rather than an error.
function MartMenu:playTransaction()
local data = self.game and self.game.data
if data then Sound.play(data, SFX_TRANSACTION) end
end
-- BuyMenuLoop's order matters: money is compared BEFORE the bag is asked for
-- room, so a broke player is told about the money and never about the PACK.
function MartMenu:completePurchase(total)
local entry = self.qtyItem
if self:money() < total then
self.phase = "buy"
self:say(self.text.noMoney)
return
end
local ok = Bag.add(self.save, entry.id, self.qty,
self.game and self.game.data)
if not ok then
self.phase = "buy"
self:say(self.text.packFull)
return
end
if self.martType == "BARGAIN" then
-- FlagAction SET_FLAG on wBargainShopFlags. The cart mirrors this into
-- DAILYFLAGS1_GOLDENROD_UNDERGROUND_BARGAIN on the way out so the stock
-- comes back with the day; the daily rollover is not modelled here, so the
-- flag simply lives on the save.
self.save.bargainShop = self.save.bargainShop or {}
self.save.bargainShop[entry.id] = true
entry.soldOut = true
end
-- .proceed's order: the bargain flag, PlayTransactionSound, TakeMoney, and
-- only then MARTTEXT_HERE_YOU_GO. The till rings on the money moving, not
-- on the clerk's line.
self:playTransaction()
self:setMoney(self:money() - total)
self.phase = "buy"
self:say(self.text.thanks)
end
-- --------------------------------------------------------- quantity picker
--
-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm). Up and down
-- WRAP through the ends; left and right step by ten and CLAMP instead -- left
-- past 1 lands on 1, right past the ceiling lands on the ceiling.
function MartMenu:quantityStep(delta)
local n = self.qty + delta
if delta == 1 then
if n > self.qtyMax then n = 1 end
elseif delta == -1 then
if n < 1 then n = self.qtyMax end
elseif delta > 0 then
if n > self.qtyMax then n = self.qtyMax end
else
if n <= 0 then n = 1 end
end
self.qty = n
end
function MartMenu:updateQuantity(input, onAccept, onCancel)
if input:wasPressed("up") then
self:quantityStep(1)
elseif input:wasPressed("down") then
self:quantityStep(-1)
elseif input:wasPressed("right") then
self:quantityStep(10)
elseif input:wasPressed("left") then
self:quantityStep(-10)
elseif input:wasPressed("b") then
onCancel()
elseif input:wasPressed("a") then
onAccept()
end
end
-- --------------------------------------------------------------- sell flow
--
-- SellMenu opens DepositSellPack -- the PACK itself, in a mode where A means
-- "sell this" rather than "use this". `world = {}` is a world with no
-- useFieldItem, so PackMenu:useSelected falls straight through to onChoose and
-- no field effect can fire from inside a shop.
--
-- The PACK is held rather than stacked, so this is Screens.build, not
-- Screens.push: same id, same registry lookup and same mod-screen degrade, but
-- the mart keeps drawing and updating it itself.
function MartMenu:enterSell()
self.phase = "sell"
self.pack = Screens.build(self.game, "Gen2PackMenu", {
save = self.save,
items = self.items,
world = {},
onChoose = function(itemId, count) self:offerToSell(itemId, count) end,
onClose = function() self:leaveSell() end,
})
end
function MartMenu:leaveSell()
self.pack = nil
self:enterTop(self.text.askMore)
end
-- .TryToSellItem: CheckItemMenu's field-menu value routes everything the PACK
-- can hold to .try_sell, and _CheckTossableItem is the real gate -- a CANT_TOSS
-- item (every KEY ITEM, and the HMs) gets MartCantBuyText and nothing else.
function MartMenu:offerToSell(itemId, count)
local def = self.items and self.items[itemId]
if not def or def.canToss == false then
self:say(self.sellText.cantBuy)
return
end
-- Nothing in stock is not a sale: the PACK only lists rows it holds, and a
-- zero count would otherwise walk the selector's ceiling down to nothing.
if (count or 0) < 1 then
self:say(self.sellText.cantBuy)
return
end
-- ScrollingMenu's .a_button copies the row's own quantity into
-- wItemQuantity, so the selector's ceiling is how many you hold.
-- The unit price is the item's own ItemAttributes price: GetItemPrice is
-- what SelectQuantityToSell halves AND what the buy list's
-- GetMartItemPrice charges, so a TM sells for exactly half of what the
-- Goldenrod/Celadon TM shelves ask for it, and half of its hidden price
-- (usually ¥1,500) anywhere else (issue #1243).
self.qtyItem = { id = itemId, name = def.name or itemId,
price = def.price or 0 }
self.qty = 1
self.qtyMax = count
self.phase = "sellQuantity"
end
function MartMenu:confirmSale()
local total = MartMenu.sellPrice(self.qtyItem.price, self.qty)
self:ask(self.sellText.price(total),
function() self:completeSale(total) end,
function() self.phase = "sell" end)
end
function MartMenu:completeSale(total)
local entry = self.qtyItem
self:setMoney(self:money() + total)
Bag.remove(self.save, entry.id, self.qty)
if self.pack then self.pack:rebuild() end
self.phase = "sell"
-- The sell side calls PlayTransactionSound after MartBoughtText rather than
-- before it, but both land on the same frame here: the say() only queues the
-- page the update loop draws.
self:playTransaction()
self:say(self.sellText.bought(entry.name, total))
end
-- ----------------------------------------------------------------- update
function MartMenu:update(dt)
local input = self.game and self.game.input
if not input then return end
if self.message then
self:updateMessage(input)
return
end
if self.confirm then
self:updateConfirm(input)
return
end
local phase = self.phase
if phase == "top" then
self:updateTop(input)
elseif phase == "buy" then
self:updateBuy(input)
elseif phase == "buyQuantity" then
self:updateQuantity(input,
function() self:confirmPurchase() end,
function() self.phase = "buy" end)
elseif phase == "sell" then
if self.pack then self.pack:update(dt) end
elseif phase == "sellQuantity" then
self:updateQuantity(input,
function() self:confirmSale() end,
function() self.phase = "sell" end)
end
end
-- ------------------------------------------------------------------- draw
function MartMenu:drawTextBox(lines)
Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H)
for i, line in ipairs(lines or {}) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end
end
function MartMenu:drawMoneyBox()
Chrome.box(MONEY_BOX_X, MONEY_BOX_Y, MONEY_BOX_W, MONEY_BOX_H)
Chrome.print(moneyText(self:money()), MONEY_X, MONEY_Y)
end
-- PlaceMoneyBottomLeft / PlaceMoneyAtTopLeftOfTextbox land on the same rect.
function MartMenu:drawMoneyBoxLow()
Chrome.box(MONEY_LOW_BOX_X, MONEY_LOW_BOX_Y, MONEY_BOX_W, MONEY_BOX_H)
Chrome.print(moneyText(self:money()), MONEY_LOW_X, MONEY_LOW_Y)
end
function MartMenu:drawTopMenu()
Chrome.box(TOP_BOX_X, TOP_BOX_Y, TOP_BOX_W, TOP_BOX_H)
for i, label in ipairs(TOP_ITEMS) do
local ty = TOP_LABEL_Y + (i - 1) * TOP_SPACING
if i == self.topIndex then Chrome.cursor(TOP_LABEL_X - 1, ty) end
Chrome.print(label, TOP_LABEL_X, ty)
end
end
-- The description under the buy list. A TM prints the MOVE's description on
-- the cart (PrintItemDescription branches at TM01 into PrintMoveDescription),
-- which is the same rule the PACK follows.
function MartMenu:description()
local entry = self:selected()
if not entry then return nil end
local def = self.items and self.items[entry.id]
if def and def.teaches then
local moves = self.game and self.game.data and self.game.data.moves
local moveDef = moves and moves[def.teaches]
if moveDef and moveDef.description then return moveDef.description end
end
return def and def.description or nil
end
-- The bottom visible entry's price row IS the box's bottom border row
-- (entry row 10 + SCREEN_WIDTH = 11, MenuHeader_Buy's own last line), and a
-- GB glyph REPLACES the tile it prints over, border and all. White under
-- the seven money tiles first is that replacement; on the three rows clear
-- of the border it repaints white over white.
local function printPriceOpaque(amount, ty)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", PRICE_X * 8, ty * 8, 7 * 8, 8)
G.setColor(0, 0, 0, 1)
Chrome.print(moneyText(amount), PRICE_X, ty)
end
function MartMenu:drawBuyList()
-- engine/items/mart.asm:542
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
for row = 1, VISIBLE_ROWS do
local i = row + self.scroll
local ty = LIST_Y + (row - 1) * LIST_SPACING
if i <= #self.entries then
local entry = self.entries[i]
if i == self.index then Chrome.cursor(LIST_X - 1, ty) end
Chrome.print(entry.name, LIST_X, ty)
printPriceOpaque(entry.price, ty + 1)
elseif i == self:total() then
if i == self.index then Chrome.cursor(LIST_X - 1, ty) end
Chrome.print("CANCEL", LIST_X, ty)
end
end
-- SCROLLINGMENU_DISPLAY_ARROWS: the ▲ only appears once the list has been
-- scrolled, but the ▼ is written every pass whether or not there is more
-- below it.
if self.scroll > 0 then
Chrome.print(UP_ARROW, ARROW_X, ARROW_UP_Y)
end
Chrome.print(DOWN_ARROW, ARROW_X, ARROW_DOWN_Y)
end
function MartMenu:drawDescription()
Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H)
if self:isCancel() then return end
local description = self:description()
if not description then return end
-- The extractor leaves the description's own '<NEXT>' in place, and $4e
-- steps SCREEN_WIDTH * 2 from the line's start (home/text.asm
-- NextLineChar) -- which is the TEXT_LINE both rows already use. '\n'
-- covers hand-written data.
local first, second = description:match("^(.-)<NEXT>(.*)$")
if not first then first, second = description:match("^(.-)\n(.*)$") end
Chrome.print(first or description, TEXT_X, TEXT_Y)
if second then Chrome.print(second, TEXT_X, TEXT_Y + TEXT_LINE) end
end
function MartMenu:drawQuantityBox(total)
Chrome.box(QTY_BOX_X, QTY_BOX_Y, QTY_BOX_W, QTY_BOX_H)
Chrome.print(TIMES, QTY_X, QTY_Y)
-- `lb bc, PRINTNUM_LEADINGZEROS | 1, 2`: two digits, zero padded.
Chrome.print(Chrome.number(self.qty, 2, true), QTY_X + 1, QTY_Y)
Chrome.print(moneyText(total), QTY_PRICE_X, QTY_Y)
end
function MartMenu:drawYesNo(choice)
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3))
end
-- Whatever the overlays sit on top of.
function MartMenu:drawUnder()
local phase = self.phase
if phase == "top" then
self:drawTopMenu()
self:drawTextBox(self.topLines)
elseif phase == "buy" or phase == "buyQuantity" then
self:drawMoneyBox()
self:drawBuyList()
self:drawDescription()
elseif phase == "sell" or phase == "sellQuantity" then
if self.pack then self.pack:drawPanel() end
end
end
function MartMenu:drawPanel()
self:drawUnder()
if self.phase == "buyQuantity" and not (self.message or self.confirm) then
-- StandardMartAskPurchaseQuantity prints MARTTEXT_HOW_MANY first, then the
-- selector is drawn over the bottom of that box.
self:drawTextBox(self.text.howMany)
self:drawQuantityBox(MartMenu.buyPrice(self.qtyItem.price, self.qty))
elseif self.phase == "sellQuantity" and not (self.message or self.confirm) then
self:drawTextBox(self.sellText.howMany)
-- .okay_to_sell prints the question, THEN drops the money box on its top
-- left corner, so the box wins where they overlap.
self:drawMoneyBoxLow()
self:drawQuantityBox(MartMenu.sellPrice(self.qtyItem.price, self.qty))
end
if self.message then
self:drawTextBox(self.message.pages[self.message.page])
-- LoadBlinkingCursor puts the ▼ at hlcoord 18, 17 while a `para` waits.
if self.message.page < #self.message.pages then
Chrome.print(DOWN_ARROW, 18, 17)
end
elseif self.confirm then
self:drawTextBox(self.confirm.pages[self.confirm.page])
if self.confirm.page >= #self.confirm.pages then
self:drawYesNo(self.confirm.choice)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
function MartMenu:draw()
self:drawPanel()
end
return MartMenu