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