-- The Game Corner's three prize counters, and the coin case they all read. -- -- Unlike the slot machine and card flip, these are not engine screens on the -- cart: each counter is a MAP SCRIPT that opens a static menu, and the whole -- transaction is script commands the VM already has opcodes for. The three -- kinds are: -- -- coins GameCornerCoinVendorScript (engine/events/std_scripts.asm) -- the -- attendant who sells 50 coins for ¥1000 and 500 for ¥10000 -- item the TM counters (CeladonGameCornerPrizeRoomTMVendor, -- GoldenrodGameCornerTMVendorScript) -- mon the Pokemon counters (CeladonGameCornerPokemonVendor, -- GoldenrodGameCornerPrizeMonVendorScript) -- -- Because of that this module is NOT a stack state the game ever pushes and -- carries no src/ui/Screens.lua id: talking to a vendor runs the extracted -- script through src/script/gen2/Vm.lua, which has every opcode the three -- counters use. What is still read from here is the coin case at the top, -- which the slot machine and card flip share. -- -- The rest is kept as a transcription of the counters' own tables, because the -- ORDER of the checks is what a player notices and what a test can pin down. -- Those orders are NOT the same: -- -- item counter coins are checked, then the player is asked, and only then -- is the PACK asked for room, so a full bag is discovered -- AFTER saying yes -- mon counter coins are checked, then the PARTY is checked, and only then -- is the player asked -- a full party never gets the question -- coin counter the case is checked for room FIRST and the wallet second, so -- a full case is reported even when you could not afford it -- -- Coordinates are transcribed, never eyeballed. Every counter's menu header is -- `menu_coords 0, 2, W, TEXTBOX_Y - 1` (the coin vendor's is `0, 4, 15, 11`) -- with STATICMENU_CURSOR and no STATICMENU_NO_TOP_SPACING, so -- GetMenuTextStartCoord (home/menu.asm) puts the first label at box + 1 for the -- border, + 1 more row for the top spacing and + 1 more column for the cursor: -- (2,4) for the prize counters, (2,6) for the coin vendor, cursor in column 1, -- labels two rows apart (PlaceMenuStrings adds 2 * SCREEN_WIDTH per item). -- -- DisplayCoinCaseBalance (engine/menus/menu_2.asm) is the little box every -- counter's loop re-prints: Textbox at (11,0) with a 7x1 interior, "COIN" at -- (12,0) -- yes, in the border row -- and the four-digit count at (13,1). local Bag = require("src.inventory.Bag") local Chrome = require("src.ui.gen2.Chrome") local CoinCase = require("src.core.gen2.CoinCase") local Save = require("src.core.gen2.Save") local Sound = require("src.core.Sound") local PrizeMenu = {} PrizeMenu.__index = PrizeMenu PrizeMenu.isOpaque = true -- --------------------------------------------------------------- coin case -- -- engine/events/money.asm GiveCoins / TakeCoins / CheckCoins now live in -- src/core/gen2/CoinCase.lua: the slot machine and card flip read the same -- case, and neither of those is a prize counter, so the case is model, not -- menu. These names stay here as aliases because they are this module's -- public API and the coin vendor counter is still the thing that calls them. PrizeMenu.MAX_COINS = CoinCase.MAX_COINS PrizeMenu.coins = CoinCase.coins PrizeMenu.giveCoins = CoinCase.giveCoins PrizeMenu.takeCoins = CoinCase.takeCoins PrizeMenu.HAVE_MORE = CoinCase.HAVE_MORE PrizeMenu.HAVE_AMOUNT = CoinCase.HAVE_AMOUNT PrizeMenu.HAVE_LESS = CoinCase.HAVE_LESS PrizeMenu.checkCoins = CoinCase.checkCoins local function money(save) local player = save and save.player return (player and player.money) or 0 end local function setMoney(save, amount) local player = save and save.player if not player then return end player.money = math.max(0, math.min(math.floor(amount or 0), Save.MAX_MONEY)) end -- ---------------------------------------------------------------- counters -- -- Prices are the map scripts' own EQU block, kept as the constants they are -- named after so a reader can find them in the .asm. -- -- CeladonGameCornerPrizeRoom.asm: -- TM32 1500 TM29 3500 TM15 7500 -- MR.MIME 3333 EEVEE 6666 PORYGON 9999 -- GoldenrodGameCorner.asm: -- TM25 5500 TM14 5500 TM38 5500 -- ABRA 200 SANDSHREW 700 EKANS 700 DRATINI 2100 -- -- The Goldenrod Pokemon counter branches on `checkver`: Gold sells EKANS where -- Silver sells SANDSHREW, which is the only version difference in the room. -- The labels are the menu data's literal strings, spaces and all, because their -- padding is what right-aligns the price column. PrizeMenu.COUNTERS = { CELADON_TM = { kind = "item", menu = { x = 0, y = 2, w = 16, h = 10 }, prizes = { { id = "TM_DOUBLE_TEAM", label = "TM32 1500", cost = 1500 }, { id = "TM_PSYCHIC_M", label = "TM29 3500", cost = 3500 }, { id = "TM_HYPER_BEAM", label = "TM15 7500", cost = 7500 }, }, }, CELADON_MON = { kind = "mon", menu = { x = 0, y = 2, w = 18, h = 10 }, prizes = { { id = "MR__MIME", label = "MR.MIME 3333", cost = 3333, level = 15 }, { id = "EEVEE", label = "EEVEE 6666", cost = 6666, level = 15 }, { id = "PORYGON", label = "PORYGON 9999", cost = 9999, level = 20 }, }, }, GOLDENROD_TM = { kind = "item", menu = { x = 0, y = 2, w = 16, h = 10 }, prizes = { { id = "TM_THUNDER", label = "TM25 5500", cost = 5500 }, { id = "TM_BLIZZARD", label = "TM14 5500", cost = 5500 }, { id = "TM_FIRE_BLAST", label = "TM38 5500", cost = 5500 }, }, }, GOLDENROD_MON = { kind = "mon", menu = { x = 0, y = 2, w = 18, h = 10 }, prizes = { { id = "ABRA", label = "ABRA 200", cost = 200, level = 10 }, { id = "EKANS", label = "EKANS 700", cost = 700, level = 10, silver = { id = "SANDSHREW", label = "SANDSHREW 700" } }, { id = "DRATINI", label = "DRATINI 2100", cost = 2100, level = 10 }, }, }, -- GameCornerCoinVendorScript's own menu: `menu_coords 0, 4, 15, TEXTBOX_Y - 1` -- and only two entries plus CANCEL. `checkcoins MAX_COINS - 50` with -- HAVE_MORE meaning "full" is how the cart asks whether 50 more will fit. COIN_VENDOR = { kind = "coins", menu = { x = 0, y = 4, w = 16, h = 8 }, prizes = { { amount = 50, cost = 1000, label = " 50 : \xc2\xa51000" }, { amount = 500, cost = 10000, label = "500 : \xc2\xa510000" }, }, }, } -- ------------------------------------------------------------------- text -- -- The Celadon room and the Goldenrod room use DIFFERENT wording for the same -- five beats, and the coin vendor a third set again. All three are transcribed -- from the map scripts / data/text/std_text.asm; none are in the cache's -- text.lua, because no script bytecode the extractor walks points at them. local function page(...) return { ... } end local function pages(...) return { ... } end PrizeMenu.TEXTS = { CELADON = { intro = pages(page("Welcome!"), page("We exchange your", "coins for fabulous"), page("prizes!")), which = page("Which prize would", "you like?"), confirm = function(name) return pages(page("OK, so you wanted", ("a %s?"):format(name))) end, hereYouGo = pages(page("Here you go!")), notEnoughCoins = pages(page("You don't have", "enough coins.")), noRoom = pages(page("You have no room", "for it.")), comeAgain = pages(page("Oh. Please come", "back with coins!")), noCoinCase = pages(page("Oh? You don't have", "a COIN CASE.")), }, GOLDENROD = { intro = pages(page("Welcome!"), page("We exchange your", "game coins for"), page("fabulous prizes!")), which = page("Which prize would", "you like?"), confirm = function(name) return pages(page(("%s."):format(name), "Is that right?")) end, hereYouGo = pages(page("Here you go!")), notEnoughCoins = pages(page("Sorry! You need", "more coins.")), noRoom = pages(page("Sorry. You can't", "carry any more.")), comeAgain = pages(page("OK. Please save", "your coins and"), page("come again!")), noCoinCase = pages(page("Oh? You don't have", "a COIN CASE.")), }, COIN_VENDOR = { intro = pages(page("Welcome to the", "GAME CORNER.")), which = page("Do you need some", "game coins?"), bought = { [50] = pages(page("Thank you!", "Here are 50 coins.")), [500] = pages(page("Thank you! Here", "are 500 coins.")), }, notEnoughMoney = pages(page("You don't have", "enough money.")), caseFull = pages(page("Whoops! Your COIN", "CASE is full.")), comeAgain = pages(page("No coins for you?", "Come again!")), noCoinCase = pages(page("Do you need game", "coins?"), page("Oh, you don't have", "a COIN CASE for"), page("your coins.")), }, } -- --------------------------------------------------------- pure purchase -- -- Everything a counter refuses, decided without a screen so a test can drive -- it. Returns "ok" or the reason, and never mutates. -- -- PARTY_LENGTH is 6 (constants/pokemon_data_constants.asm); `readvar -- VAR_PARTYCOUNT / ifequal PARTY_LENGTH` is the mon counter's room check. PrizeMenu.PARTY_LENGTH = Save.PARTY_SIZE PrizeMenu.canAfford = CoinCase.canAfford -- The room check, split out because the two prize kinds ask different -- questions and ask them at different points in the conversation. function PrizeMenu.hasRoom(save, counter, prize, data) if counter.kind == "mon" then return #((save and save.party) or {}) < PrizeMenu.PARTY_LENGTH end if counter.kind == "coins" then -- `checkcoins MAX_COINS - amount / ifequal HAVE_MORE` -- strictly more than -- the headroom is a full case, exactly the headroom still fits. return PrizeMenu.checkCoins(save, PrizeMenu.MAX_COINS - prize.amount) ~= PrizeMenu.HAVE_MORE end -- giveitem's own failure: AddItemToInventory refuses when the pocket is full -- or the stack would pass 99. Bag.add is that routine, so ask it directly -- against a scratch copy rather than reimplementing the rule. local inv = (save and save.inventory) or {} local scratch = { inventory = {} } for id, count in pairs(inv) do scratch.inventory[id] = count end return Bag.add(scratch, prize.id, 1, data or {}) == true end -- The whole refusal ladder for one counter, in the cart's order. -- Returns "ok", "coins", "room", or "money" (the coin vendor only). function PrizeMenu.check(save, counter, prize, data) if counter.kind == "coins" then if not PrizeMenu.hasRoom(save, counter, prize, data) then return "room" end if money(save) < prize.cost then return "money" end return "ok" end if not PrizeMenu.canAfford(save, prize.cost) then return "coins" end if counter.kind == "mon" and not PrizeMenu.hasRoom(save, counter, prize, data) then return "room" end return "ok" end -- The transaction itself, once the player has said yes. Item counters do the -- giveitem BEFORE the takecoins, so a bag that turns out to be full costs -- nothing; that ordering is what makes "room" a possible answer here too. function PrizeMenu.buy(save, counter, prize, data) if counter.kind == "coins" then local reason = PrizeMenu.check(save, counter, prize, data) if reason ~= "ok" then return reason end PrizeMenu.giveCoins(save, prize.amount) setMoney(save, money(save) - prize.cost) return "ok" end if not PrizeMenu.canAfford(save, prize.cost) then return "coins" end if counter.kind == "mon" then if not PrizeMenu.hasRoom(save, counter, prize, data) then return "room" end local Mon = require("src.battle.gen2.Mon") local mon = Mon.new(data, prize.id, prize.level) if not mon then return "room" end save.party = save.party or {} -- The prize is a `givepoke`, so it takes AddPartyMon's stamp (move_mon.asm:143-149). Mon.stampOT(save, mon) save.party[#save.party + 1] = mon -- `special GameCornerPrizeMonCheckDex` right before the givepoke: the prize -- mon is registered as seen and caught even though it never appeared in a -- battle. save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[prize.id] = true save.pokedex.caught[prize.id] = true PrizeMenu.takeCoins(save, prize.cost) return "ok" end if not Bag.add(save, prize.id, 1, data or {}) then return "room" end PrizeMenu.takeCoins(save, prize.cost) return "ok" end -- ------------------------------------------------------------------ layout local COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W, COIN_BOX_H = 11, 0, 9, 3 local COIN_LABEL_X, COIN_LABEL_Y = 12, 0 local COIN_VALUE_X, COIN_VALUE_Y = 13, 1 local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 -- YesNoBox: `lb bc, SCREEN_WIDTH - 6, 7` puts a 6x5 box at (14,7), and -- YesNoMenuHeader carries STATICMENU_CURSOR | STATICMENU_NO_TOP_SPACING, so YES -- lands at (16,8) and NO at (16,10) with the cursor in column 15. local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 local SFX_TRANSACTION = "Sfx_Transaction" -- ------------------------------------------------------------------ screen function PrizeMenu:wantsFillScale() return true end function PrizeMenu:drawsWidescreen() return true end -- opts: save, counter (a COUNTERS key or a table), texts (a TEXTS key), -- version ("gold"/"silver"), data, hasCoinCase (defaults to the bag), -- onClose() function PrizeMenu.new(game, opts) opts = opts or {} local self = setmetatable({}, PrizeMenu) self.game = game self.save = opts.save or (game and game.save) self.data = opts.data or (game and game.data) self.onClose = opts.onClose local counter = opts.counter or "CELADON_TM" self.counter = type(counter) == "table" and counter or PrizeMenu.COUNTERS[counter] or PrizeMenu.COUNTERS.CELADON_TM local textKey = opts.texts or (self.counter.kind == "coins" and "COIN_VENDOR" or "CELADON") self.text = PrizeMenu.TEXTS[textKey] or PrizeMenu.TEXTS.CELADON self.version = opts.version or (self.save and self.save.version) or "gold" self:buildPrizes() self.index = 1 -- `checkitem COIN_CASE / iffalse` is the very first thing after the intro. if opts.hasCoinCase ~= nil then self.hasCoinCase = opts.hasCoinCase else local inv = (self.save and self.save.inventory) or {} self.hasCoinCase = (inv.COIN_CASE or 0) > 0 end self:say(self.text.intro, function() if not self.hasCoinCase then self:say(self.text.noCoinCase, function() self:close() end) else self:enterMenu() end end) return self end -- The Goldenrod mon counter's `checkver` swap, applied once at construction so -- the menu rows and the purchase agree. function PrizeMenu:buildPrizes() local silver = self.version == "silver" local out = {} for i, prize in ipairs(self.counter.prizes) do if silver and prize.silver then local swapped = {} for k, v in pairs(prize) do swapped[k] = v end swapped.id = prize.silver.id swapped.label = prize.silver.label swapped.silver = nil out[i] = swapped else out[i] = prize end end -- Every counter's menu data ends with a literal CANCEL row. out[#out + 1] = { cancel = true, label = "CANCEL" } self.prizes = out end function PrizeMenu:say(list, onDone) self.message = { pages = list or {}, page = 1, onDone = onDone } end function PrizeMenu:ask(list, onYes, onNo) self.confirm = { pages = list or {}, page = 1, choice = 1, onYes = onYes, onNo = onNo } end function PrizeMenu:enterMenu() self.phase = "menu" -- `db 1 ; default option`: the cursor is back on the first prize every pass -- through the loop. self.index = 1 self.message = nil end function PrizeMenu:close() if self.onClose then self.onClose() end end function PrizeMenu:cancel() self:say(self.text.comeAgain, function() self:close() end) end function PrizeMenu:sfx(name) if self.data then Sound.play(self.data, name) end end function PrizeMenu:prizeName(prize) if self.counter.kind == "coins" then return ("%d coins"):format(prize.amount) end local items = self.data and self.data.items local mons = self.data and self.data.pokemon local def = (self.counter.kind == "mon" and mons or items) local entry = def and def[prize.id] return (entry and entry.name) or prize.id end function PrizeMenu:refuse(reason) local text = self.text if reason == "coins" then self:say(text.notEnoughCoins, function() self:close() end) elseif reason == "money" then self:say(text.notEnoughMoney, function() self:close() end) elseif reason == "room" then self:say(text.noRoom or text.caseFull, function() self:close() end) end end function PrizeMenu:choose() local prize = self.prizes[self.index] if not prize or prize.cancel then self:cancel() return end -- The pre-confirm ladder: coins first for a prize counter, room first for the -- coin vendor. A refusal here ends the conversation, it does not loop. local reason = PrizeMenu.check(self.save, self.counter, prize, self.data) if reason ~= "ok" then self:refuse(reason) return end if self.counter.kind == "coins" then -- The coin vendor never asks; it buys straight off the menu. self:complete(prize) return end self:ask(self.text.confirm(self:prizeName(prize)), function() self:complete(prize) end, function() self:cancel() end) end function PrizeMenu:complete(prize) local reason = PrizeMenu.buy(self.save, self.counter, prize, self.data) if reason ~= "ok" then self:refuse(reason) return end self:sfx(SFX_TRANSACTION) local bought = self.text.bought and self.text.bought[prize.amount] self:say(bought or self.text.hereYouGo, function() self:enterMenu() end) end function PrizeMenu: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 function PrizeMenu: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 function PrizeMenu:update(_dt) local input = self.game and self.game.input if not input then return end if self.confirm then self:updateConfirm(input) return end if self.message then self:updateMessage(input) return end if self.phase ~= "menu" then return end if input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or #self.prizes elseif input:wasPressed("down") then self.index = self.index < #self.prizes and self.index + 1 or 1 elseif input:wasPressed("b") then self:cancel() elseif input:wasPressed("a") then self:choose() end end -- ------------------------------------------------------------------- draw function PrizeMenu:drawCoinBox() Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2) Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y) Chrome.print(Chrome.number(PrizeMenu.coins(self.save), 4, true), COIN_VALUE_X, COIN_VALUE_Y) end function PrizeMenu:drawPanel() Chrome.clear() self:drawCoinBox() if self.phase == "menu" or self.confirm then local menu = self.counter.menu Chrome.textbox(menu.x, menu.y, menu.w - 2, menu.h - 2) for i, prize in ipairs(self.prizes) do local ty = menu.y + 2 + (i - 1) * 2 if i == self.index then Chrome.cursor(menu.x + 1, ty) end Chrome.print(prize.label, menu.x + 2, ty) end end local lines = nil if self.confirm then lines = self.confirm.pages[self.confirm.page] elseif self.message then lines = self.message.pages[self.message.page] elseif self.phase == "menu" then lines = self.text.which end if lines then Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) for i, line in ipairs(lines) do Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) end end if self.confirm and self.confirm.page >= #self.confirm.pages then Chrome.textbox(YESNO_X, YESNO_Y, YESNO_W - 2, YESNO_H - 2) 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 + 1 + (self.confirm.choice - 1) * 2) end end function PrizeMenu:draw() self:drawPanel() end function PrizeMenu:drawWidescreen(winW, winH) local G = love.graphics G.setColor(1, 1, 1, 1) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() end return PrizeMenu