Merge branch 'dev' into feat/switch-nx

Bring latest upstream fixes (Metal/iOS, encounter slide, second-screen seam) into the Switch NX feature branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-01 12:12:07 -03:00
84 changed files with 7435 additions and 265 deletions
+17 -2
View File
@@ -4,7 +4,7 @@
Yellow is a separate pret tree (pokeyellow), not a `_YELLOW` flip of pokered.
Most of the ~3268 Red manifest symbols still exist under the same names in
pokeyellow.sym (~3123 with shifted addresses). The remainder need aliases,
synthetic addresses (Mew in BaseStats), or omission (FightIntro* Yellow's
synthetic addresses (Mew in BaseStats), or omission (FightIntro* -- Yellow's
intro movie is different; RomExtractor must skip those).
Map/object/sprite/tileset/text metadata diverge enough that those sections are
@@ -48,7 +48,7 @@ from yellow_symbol_aliases import ( # noqa: E402
try:
from rom_data import CANONICAL_YELLOW_SHA1
except ImportError: # pragma: no cover constant lands with GameVersion work
except ImportError: # pragma: no cover -- constant lands with GameVersion work
CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1"
DEV = "/Users/bryanbassett/Documents/development"
@@ -88,6 +88,21 @@ YELLOW_EXTRA_SYMBOLS = (
"SurfingPikachu1Graphics1",
"SurfingPikachu1Graphics2",
"SurfingPikachu1Graphics3",
# Oak's own battle back pic. LoadPlayerBackPic (engine/battle/core.asm)
# picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
# BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557).
"ProfOakPicBack",
# Base frames for the framed portrait TalkToPikachu draws, one per
# PikaPicAnimScript (data/pikachu/pikachu_pic_animation.asm). These are
# raw address labels because the pikapic blobs carry no named symbols;
# RomExtractor's PIKAPIC_BASE table indexes them positionally, so the
# order here is not load bearing but every entry must resolve (#561).
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
"Pic_f0abf", "Pic_f0cf4",
)
# Yellow-only dialogue whose bank labels carry no leading underscore, so
+112
View File
@@ -21661,6 +21661,22 @@
29,
18588
],
"GFX_e6020": [
57,
24608
],
"GFX_e6e6f": [
57,
28271
],
"GFX_e718f": [
57,
29071
],
"GFX_e74af": [
57,
29871
],
"GamblerPic": [
19,
21537
@@ -23085,6 +23101,98 @@
7,
20102
],
"Pic_e4000": [
57,
16384
],
"Pic_e411c": [
57,
16668
],
"Pic_e4272": [
57,
17010
],
"Pic_e4383": [
57,
17283
],
"Pic_e458b": [
57,
17803
],
"Pic_e467b": [
57,
18043
],
"Pic_e476e": [
57,
18286
],
"Pic_e49d1": [
57,
18897
],
"Pic_e4b39": [
57,
19257
],
"Pic_e4c3e": [
57,
19518
],
"Pic_e5000": [
57,
20480
],
"Pic_e523f": [
57,
21055
],
"Pic_e548e": [
57,
21646
],
"Pic_e56d1": [
57,
22225
],
"Pic_e5924": [
57,
22820
],
"Pic_e5b7d": [
57,
23421
],
"Pic_e5ddd": [
57,
24029
],
"Pic_e6340": [
57,
25408
],
"Pic_e6587": [
57,
25991
],
"Pic_e67d6": [
57,
26582
],
"Pic_e77cf": [
57,
30671
],
"Pic_f0abf": [
60,
19135
],
"Pic_f0cf4": [
60,
19700
],
"PickUpItemText": [
0,
9199
@@ -23505,6 +23613,10 @@
19,
24890
],
"ProfOakPicBack": [
61,
17618
],
"PsychicPic": [
19,
22312
+92 -43
View File
@@ -32,6 +32,8 @@ local Items = require("Items")
local Events = require("Events")
local MapBrowser = require("MapBrowser")
local Dex = require("Dex")
-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541)
local SpeciesPicker = require("SpeciesPicker")
local App = {}
local S
@@ -222,8 +224,11 @@ function App.unload()
-- Kit is never evicted from package.loaded, so a Close taken while a text
-- field still owns focus would leak Kit.focus and a raised soft keyboard
-- (against a rect that is gone) into the launcher and the next session
-- (#529).
-- (#529). A Close taken on the frame the species picker went up would
-- likewise leave its modal shield raised, and the next session would open
-- deaf to every click (#541).
Kit.blur()
Kit.blockClicks = false
end
function App.save()
@@ -345,35 +350,11 @@ local function drawTitleBar(x, y, w, h)
{ 159, 180, 221 })
cx = cx + badge + 10 * s
local wordH = Kit.textHeight("wordmark")
local brandH = Kit.textHeight("brand")
local blockY = y + (h - (wordH + 2 * s + brandH)) / 2
love.graphics.setFont(Kit.fonts.wordmark)
Theme.col(PAL.heading, 1)
local wordW = Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s)
love.graphics.setFont(Kit.fonts.brand)
Theme.col(PAL.caption, 1)
local brandW = Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx,
blockY + wordH + 2 * s, 1 * s)
cx = cx + math.max(wordW, brandW) + 12 * s
-- version chip: which game this save belongs to (from the launcher slot,
-- or the save's own header in a standalone run)
if S.version then
local name = S.version:upper()
local c = (S.version == "blue") and PAL.blue or PAL.red
local cw = Kit.textWidth("chip", name) + 16 * s
local ch = 22 * s
local cy = y + (h - ch) / 2
Theme.col(c, 0.1)
love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s)
Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1)
Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2, cw, c)
cx = cx + cw + 12 * s
end
-- right-aligned action cluster, laid out from the right edge inward so the
-- file chip can absorb whatever is left
-- The right-aligned action cluster is laid out from the right edge inward
-- BEFORE anything on the left is drawn: the buttons are the one thing in
-- this bar that must always be reachable, so on a phone the identity block,
-- the version chip and the file chip are what yield. Measuring them last
-- is why they used to paint straight through the buttons (#497).
local btnH = 38 * s
local btnY = y + (h - btnH) / 2
local rightEdge = x + w - pad
@@ -395,6 +376,41 @@ local function drawTitleBar(x, y, w, h)
local reloadX = openX - gap - reloadW
local saveX = reloadX - gap - saveW
local wordH = Kit.textHeight("wordmark")
local brandH = Kit.textHeight("brand")
local blockY = y + (h - (wordH + 2 * s + brandH)) / 2
local wordW = math.max(
Theme.spacedWidth(Kit.fonts.wordmark, "SAVE EDITOR", 2 * s),
Theme.spacedWidth(Kit.fonts.brand, "GEN1RECOMP", 1 * s))
if cx + wordW + 12 * s < saveX then
love.graphics.setFont(Kit.fonts.wordmark)
Theme.col(PAL.heading, 1)
Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s)
love.graphics.setFont(Kit.fonts.brand)
Theme.col(PAL.caption, 1)
Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx,
blockY + wordH + 2 * s, 1 * s)
cx = cx + wordW + 12 * s
end
-- version chip: which game this save belongs to (from the launcher slot,
-- or the save's own header in a standalone run)
if S.version then
local name = S.version:upper()
local c = (S.version == "blue") and PAL.blue or PAL.red
local cw = Kit.textWidth("chip", name) + 16 * s
local ch = 22 * s
local cy = y + (h - ch) / 2
if cx + cw + 12 * s < saveX then
Theme.col(c, 0.1)
love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s)
Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1)
Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2,
cw, c)
cx = cx + cw + 12 * s
end
end
-- Save is the only green-filled control in the chrome; a corrupt load
-- renders it steel with the reason parked in the status bar rather than
-- hiding it (rule 3 of the design spec).
@@ -481,9 +497,11 @@ local function railDetail(x, pillX)
widths.glyph = widths.glyph + tile + 12 * s
end
local avail = pillX - 14 * s - (x + 22 * s)
if widths.full <= avail then return "full" end
if widths.nocount <= avail then return "nocount" end
return "glyph"
-- the widths come back too: the caller needs the glyph-mode figure to decide
-- whether the pill still has room, and measuring the rail twice is waste
if widths.full <= avail then return "full", widths end
if widths.nocount <= avail then return "nocount", widths end
return "glyph", widths
end
local function drawTabRail(x, y, w, h)
@@ -496,7 +514,15 @@ local function drawTabRail(x, y, w, h)
local ph = 26 * s
local pw = Kit.textWidth("small", label) + 28 * s
local px = x + w - pad - pw
local detail = railDetail(x, px)
local detail, widths = railDetail(x, px)
-- Last stop before the tiles and the pill collide: at phone widths even the
-- 2-letter glyph tiles need the room the pill is sitting in, and the pill
-- only repeats a line the status bar already prints on load, so the pill is
-- what goes (#497). With it gone the tiles get the full bar back.
local showPill = widths.glyph <= px - 14 * s - (x + 22 * s)
if not showPill then
detail = railDetail(x, x + w - pad)
end
local tile = 40 * s
local cx = x + pad
@@ -546,15 +572,19 @@ local function drawTabRail(x, y, w, h)
cx = cx + cellW
end
local py = y + h - 14 * s - ph
Theme.col(pillColor, clean and 0.08 or 0.1)
love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2)
Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1)
Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2, pw,
pillColor)
if target and Kit.press(px, py, pw, ph) then
S.tab = target
Ops.say(S, "Jumped to the tab holding the first quarantine warning")
if showPill then
local py = y + h - 14 * s - ph
Theme.col(pillColor, clean and 0.08 or 0.1)
love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2)
Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1)
Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2,
pw, pillColor)
-- paint and hit target are suppressed together, so a hidden pill cannot
-- still eat a tap meant for the DEX tile
if target and Kit.press(px, py, pw, ph) then
S.tab = target
Ops.say(S, "Jumped to the tab holding the first quarantine warning")
end
end
end
@@ -592,6 +622,11 @@ function App.draw()
local mx, my = love.mouse.getPosition()
Kit.beginFrame(mx, my, mouseClicked)
mouseClicked = false
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
-- last: the chrome and the panel underneath would take the same tap. The
-- shield goes up before anything dispatches and comes down only for the
-- picker's own layer at the bottom of this function (#541).
Kit.blockClicks = (S.speciesPicker ~= nil)
Theme.field(width, height)
@@ -613,6 +648,8 @@ function App.draw()
end
drawStatusBar(0, height - statusH, width, statusH)
Kit.blockClicks = false
SpeciesPicker.draw(S, Kit, width, height)
Kit.endFrame()
-- Only now, with the whole frame painted, is it safe to drop the editor.
@@ -621,6 +658,18 @@ end
function App.keypressed(key)
if not S then return end
-- The picker takes Enter and Escape before the focused field does: Kit maps
-- both to the same "\r" edit (a blur), which cannot tell "commit the top
-- match" apart from "give up" (#541).
if S.speciesPicker then
if key == "return" or key == "kpenter" then
SpeciesPicker.commitFirst(S, Kit)
return
elseif key == "escape" then
Ops.closeSpeciesPicker(S, Kit)
return
end
end
-- A focused text field eats the keys it cares about (typing "s" into the
-- map filter must not trigger Save).
if Kit.keypressed(key) then return end
+18 -4
View File
@@ -71,16 +71,22 @@ function Kit.endFrame()
for i = #edits, 1, -1 do edits[i] = nil end
end
-- Rebuild the font set when the window size changes. `s` matches the
-- launcher's height/768 scale so both windows step together.
-- Rebuild the font set when the window size changes. `s` matched the
-- launcher's height/768 scale alone until #497: a phone in portrait
-- (720x1560) is TALLER than the desktop reference and barely half as wide, so
-- a height-only scale drew a 1.6x desktop layout into a 720px window and every
-- right-aligned cluster in the chrome landed on top of the block to its left.
-- The layout needs roughly 1000 logical px of width, so the window now pays
-- for both axes. Every desktop and landscape size still lands on the height
-- term, which is why they stay pixel-identical to before.
function Kit.layout(width, height)
local s = Theme.clamp(height / 768, 0.7, 1.6)
local s = Theme.clamp(math.min(width / 1000, height / 768), 0.62, 1.6)
local key = ("%dx%d"):format(width, height)
if Kit._fontKey ~= key then
Kit._fontKey = key
Kit.fonts = Theme.fonts(s)
Kit.scale = s
end
Kit.scale = s
return s
end
@@ -124,7 +130,15 @@ function Kit.hover(x, y, w, h)
return Kit.hit(x, y, w, h)
end
-- Kit hit-tests without a z-order, so an overlay cannot just be drawn last:
-- every widget underneath it would still take the same click. A modal raises
-- this shield over the layers it covers (App.draw does it around the chrome
-- and the panel while the species picker is up) and lowers it for its own
-- layer (#541).
Kit.blockClicks = false
function Kit.press(x, y, w, h)
if Kit.blockClicks then return false end
return Kit.mouseClicked and Kit.hit(x, y, w, h)
end
+106 -3
View File
@@ -156,16 +156,119 @@ function Ops.setLevel(S, mon, level)
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
end
-- A catalog id is only usable as a real mon when its record carries what the
-- Gen1 formulas read: Stats.calc indexes baseStats.<stat> unconditionally
-- (src/pokemon/Stats.lua, home/move_mon.asm CalcStat), because the asm's
-- BaseStats is a fixed 151-entry table and every row is complete. The
-- editor's list is NOT that table -- it is every key in Data.pokemon after
-- the mod merge -- and a mod loaded at api 1 can leave a partial record in
-- there, since the schema violation downgrades to a warning rather than a
-- rejection (src/mods/Schemas.lua R.pokemon). So the editor tests the record
-- instead of trusting the list: without this, picking such a species walked
-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window
-- down (#541).
local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
function Ops.speciesUsable(S, id)
local def = id and S.data.pokemon[id]
if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end
for _, key in ipairs(BASE_STAT_KEYS) do
if type(def.baseStats[key]) ~= "number" then return false end
end
return true
end
-- The one funnel every species change goes through (the picker, the stepper,
-- anything later). MonOps asserts and recalculates, so an unusable record is
-- refused before it runs, and the round trip itself is fenced: a record that
-- passes the check above but still trips a formula has to leave the mon
-- exactly as it was and speak in the status bar, not take the editor with it.
function Ops.setSpecies(S, mon, id)
if not mon then return false end
if id == mon.species then
return Ops.say(S, ("Already a %s"):format(tostring(id)))
end
if not Ops.speciesUsable(S, id) then
return Ops.say(S, ("%s has no usable base stats, cannot assign it")
:format(tostring(id)))
end
-- MonOps.recalc replaces mon.stats with a fresh table rather than editing
-- it in place, so holding the old reference is a real rollback.
local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp
local wasStats, wasHp = mon.stats, mon.hp
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id)
if not ok then
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
mon.stats, mon.hp = wasStats, wasHp
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
end
return Ops.mark(S, ("Species set to %s"):format(id))
end
-- Kept for the keyboard and test path; the inspector opens the searchable
-- picker instead of walking the catalog one arrow at a time (#541). Skips
-- ids Ops.setSpecies would refuse, so one bad record cannot park the walk.
function Ops.stepSpecies(S, mon, delta)
if not mon then return false end
local list = S.cat.species
local n = #list
if n == 0 then return Ops.say(S, "No species in the catalog") end
local idx = 1
for i, id in ipairs(list) do
if id == mon.species then idx = i break end
end
local nextId = list[((idx - 1 + delta) % #list) + 1]
MonOps.setSpecies(S.data, mon, nextId)
return Ops.mark(S, ("Species set to %s"):format(nextId))
for step = 1, n do
local nextId = list[((idx - 1 + delta * step) % n) + 1]
if nextId ~= mon.species and Ops.speciesUsable(S, nextId) then
return Ops.setSpecies(S, mon, nextId)
end
end
return Ops.say(S, "No other species in the catalog can be assigned")
end
-- Search predicate behind the picker's field: the id, the display name, and a
-- bare dex number ("25" finds PIKACHU), all case-insensitive and plain (no
-- pattern magic, so a "." typed by accident matches a literal dot).
function Ops.speciesMatches(S, id, query)
if not query or query == "" then return true end
local q = tostring(query):lower()
if id:lower():find(q, 1, true) then return true end
local def = S.data.pokemon[id]
local name = def and def.name
if name and tostring(name):lower():find(q, 1, true) then return true end
local dex = tonumber(def and def.dex)
-- dex matches exactly, in either the bare or the padded form the inspector
-- prints ("25" and "025" both find PIKACHU). A substring match here would
-- pull in ELECTABUZZ (#125) on a search for 25, which reads as a bug.
return dex ~= nil and (q == tostring(dex) or q == ("%03d"):format(dex))
end
function Ops.speciesSearch(S, query)
local out = {}
for _, id in ipairs(S.cat.species) do
if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end
end
return out
end
-- The picker is modal editor chrome, not a save mutation, so its flag lives
-- with the other view state on S (State.new). Ops owns the door only because
-- both the inspector and App need one and neither should require the other.
-- `opened` marks the frame the picker went up: the click that opened it is
-- still live when the overlay draws later in that same frame (#541).
function Ops.openSpeciesPicker(S, Kit)
if not S.editingMon then
return Ops.say(S, "Pick a slot first, then choose its species")
end
S.speciesPicker = { query = "", offset = 0, opened = true }
-- focus the field on open so the mobile soft keyboard rises with it (#529)
if Kit then Kit.focus = "species-picker" end
return true
end
function Ops.closeSpeciesPicker(S, Kit)
S.speciesPicker = nil
if Kit and Kit.blur then Kit.blur() end
end
function Ops.setDv(S, mon, key, value)
+7 -3
View File
@@ -41,9 +41,13 @@ If the file isn't there (or you want another copy), use **Open...**, drop a
| `App.lua` | chrome (version rail, title bar, tab rail, status bar) and the panel router |
| `panels/` | one file per tab; pure layout that dispatches into Ops |
The design reference is `SaveEditor.dc.html` in the Claude Design project
that this port transcribes; its measurements are in the same pixel space
`App.lua` draws in.
`panels/SpeciesPicker.lua` is the one exception to "one file per tab": it is
the modal species search the inspector opens, drawn by `App.draw` after the
panel rather than routed through the tab table. Kit has no z-order, so while
it is up `Kit.blockClicks` shields every widget underneath it.
The design reference is the `SaveEditor.dc.html` mockup that this port
transcribes; its measurements are in the same pixel space `App.lua` draws in.
Two rules the code enforces and the tests assert:
+4
View File
@@ -41,6 +41,10 @@ function State.new()
-- party / inspector
selectedParty = 1,
editingMon = nil, -- reference into party or a box
-- species picker overlay: nil when closed, otherwise { query, offset }.
-- Modal in the literal sense -- App shields every widget under it for the
-- frame -- because Kit hit-tests without a z-order (#541).
speciesPicker = nil,
-- boxes
selectedBox = 1,
+34 -14
View File
@@ -85,6 +85,10 @@ function MonEditor.draw(S, Kit, x, y, w, h)
local def = S.data.pokemon[mon.species]
local cx, cy = x + pad, y + pad
local inner = w - 2 * pad
-- Backstop for a window too short for even the compacted rhythm below:
-- nothing this panel draws may land outside its own card (#497). Party
-- draws the inspector last, so no outer clip is lost by the pop at the end.
Kit.pushClip(x, y, w, h)
-- ---------------------------------------------------------- header row
local sprite = 96 * s
@@ -97,17 +101,22 @@ function MonEditor.draw(S, Kit, x, y, w, h)
Kit.text("tiny", ("#%03d"):format(def and def.dex or 0), hx + nameW + 12 * s,
cy + Kit.textHeight("title") - Kit.textHeight("tiny") - 2 * s, PAL.caption)
local stepW, stepH = 28 * s, 26 * s
local sx = hx + hw - 2 * stepW - 6 * s
local sy = cy + (Kit.textHeight("title") - stepH) / 2
if Kit.stepper(sx, sy, stepW, stepH, "<", { radius = 7 * s }) then
Ops.stepSpecies(S, mon, -1)
-- One control instead of a pair of arrows: cycling walked the catalog an
-- entry at a time (151 taps to cross the dex) and ran a full MonOps
-- recalculation on every step, including on records the Gen1 formulas
-- cannot use, which is what crashed the editor (#541). This opens the
-- searchable picker; the species name itself is a second, larger target.
local pickH = 30 * s
local pickW = math.min(150 * s, math.max(90 * s, hw * 0.6))
local px = hx + hw - pickW
local py = cy + (Kit.textHeight("title") - pickH) / 2
local openPicker = Kit.button(px, py, pickW, pickH, "Change species",
{ kind = "accent", font = "small", radius = 8 * s })
if not openPicker then
openPicker = Kit.press(hx, cy, math.max(0, px - hx - 10 * s),
Kit.textHeight("title"))
end
if Kit.stepper(sx + stepW + 6 * s, sy, stepW, stepH, ">", { radius = 7 * s }) then
Ops.stepSpecies(S, mon, 1)
end
Kit.textRight("tiny", "species", sx - 8 * s,
sy + (stepH - Kit.textHeight("tiny")) / 2, PAL.caption)
if openPicker then Ops.openSpeciesPicker(S, Kit) end
-- level stepper: -5 -1 [Lv] +1 +5, matching MonOps.setLevel's 1..100 clamp
local ly = cy + Kit.textHeight("title") + 14 * s
@@ -139,7 +148,20 @@ function MonEditor.draw(S, Kit, x, y, w, h)
statsY = statsY + Kit.textHeight("caption") + 10 * s
local gap = 12 * s
local cellW = (inner - gap * 4) / 5
local cellH = 68 * s
-- Everything below the header competes for one vertical budget. At the
-- design size it is generous; in a 720px-tall window (a phone held
-- sideways) it is not, and the DV / move rows used to run past the card and
-- paint over the status bar (#497). Shrink the two flexible blocks -- the
-- stat tiles and the DV / move rows -- instead of overflowing, with floors
-- that keep every row the 26px target Kit's rule 6 promises. statsY is
-- already past the STATS caption here, so only the DVs / MOVES caption is
-- subtracted.
local actH = 34 * s
local rowGap = 8 * s
local budget = (y + h - pad) - statsY - (Kit.textHeight("caption") + 10 * s)
- 18 * s - actH - 4 * s
local cellH = Theme.clamp(budget * 0.3, 46 * s, 68 * s)
local rowH = Theme.clamp((budget - cellH) / 4 - rowGap, 26 * s, 34 * s)
for i, st in ipairs(STAT_KEYS) do
local bx = cx + (i - 1) * (cellW + gap)
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
@@ -164,8 +186,6 @@ function MonEditor.draw(S, Kit, x, y, w, h)
Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption)
local rowY = colY + Kit.textHeight("caption") + 10 * s
local rowH = 34 * s
local rowGap = 8 * s
for i, key in ipairs(DV_KEYS) do
local ry = rowY + (i - 1) * (rowH + rowGap)
@@ -221,7 +241,6 @@ function MonEditor.draw(S, Kit, x, y, w, h)
end
local actY = rowY + 4 * (rowH + rowGap) + 4 * s
local actH = 34 * s
local actW = (colW - 10 * s) / 2
if Kit.button(rightX, actY, actW, actH, "Reset to learnset",
{ font = "small", radius = 9 * s }) then
@@ -231,6 +250,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
{ kind = "good", font = "small", radius = 9 * s }) then
Ops.healMon(S, mon)
end
Kit.popClip()
end
return MonEditor
+4 -1
View File
@@ -15,8 +15,11 @@ local Party = {}
-- Roster column width: the design's 460px at the reference size, but it gives
-- ground to the inspector on a narrow window so neither column collapses.
-- The 300px floor used to be absolute, which on a phone handed the roster
-- three quarters of the panel and left the inspector laying itself out at a
-- negative width (#497); the floor now yields to a share of what there is.
local function rosterWidth(w, s)
return math.max(300 * s, math.min(460 * s, w * 0.36))
return math.max(math.min(300 * s, w * 0.42), math.min(460 * s, w * 0.36))
end
-- HP colour follows the game's own health bar thresholds.
+129
View File
@@ -0,0 +1,129 @@
-- Type-to-search species picker (#541). The inspector used to change species
-- with a pair of arrows, which walked the catalog one entry at a time -- 151
-- taps to cross the dex -- and ran a full MonOps recalculation on whatever
-- record happened to be next, including records the Gen1 formulas cannot use.
-- This is the replacement: one modal list, filtered as you type, committing
-- through Ops.setSpecies so an unusable record refuses instead of crashing.
--
-- Modal is literal. Kit hit-tests without a z-order, so App.draw raises
-- Kit.blockClicks over the chrome and the panel while this is open and lowers
-- it only for this overlay; nothing underneath can take the same tap.
local Theme = require("Theme")
local Ops = require("Ops")
local MonEditor = require("MonEditor")
local PAL = Theme.PAL
local Picker = {}
local FIELD_ID = "species-picker"
function Picker.results(S)
local p = S.speciesPicker
return Ops.speciesSearch(S, p and p.query or "")
end
-- Enter commits the top match, which is the whole point of a search field.
function Picker.commitFirst(S, Kit)
local hits = Picker.results(S)
if not hits[1] then return Ops.say(S, "No species matches that") end
local ok = Ops.setSpecies(S, S.editingMon, hits[1])
if ok then Ops.closeSpeciesPicker(S, Kit) end
return ok
end
function Picker.draw(S, Kit, width, height)
local p = S.speciesPicker
if not p then return end
local s = Kit.scale
-- The click that opened the picker is still the frame's click: the
-- inspector dispatches earlier in App.draw than this overlay does, so
-- without swallowing it the scrim below would read it as a tap outside and
-- shut the picker in the same frame it went up. App re-raises the shield
-- at the top of the next frame, so leaving it up here is safe.
if p.opened then
p.opened = nil
Kit.blockClicks = true
end
-- the scrim doubles as the "tap outside to cancel" target
Theme.col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
local w = math.min(width - 32 * s, 520 * s)
local h = math.min(height - 32 * s, 560 * s)
local x = (width - w) / 2
local y = (height - h) / 2
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
Ops.closeSpeciesPicker(S, Kit)
return
end
Kit.card(x, y, w, h)
local pad = 18 * s
local cx, cy = x + pad, y + pad
local inner = w - 2 * pad
Kit.caption(cx, cy, "CHOOSE A SPECIES")
local closeW = 30 * s
if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x",
{ font = "small", radius = 7 * s }) then
Ops.closeSpeciesPicker(S, Kit)
return
end
cy = cy + Kit.textHeight("caption") + 10 * s
local fieldH = 34 * s
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
"type a name, an id, or a dex number")
cy = cy + fieldH + 10 * s
local hits = Picker.results(S)
local rowH = 40 * s
local rowGap = 6 * s
local pagerH = 30 * s
local listH = (y + h - pad - pagerH - 10 * s) - cy
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap)))
p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage))
if #hits == 0 then
Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.")
else
Kit.pushClip(cx, cy, inner, listH)
for i = 1, perPage do
local id = hits[p.offset + i]
if not id then break end
local ry = cy + (i - 1) * (rowH + rowGap)
local def = S.data.pokemon[id]
-- A record the formulas cannot use still lists, greyed: hiding it would
-- make a modded species look like it never registered (#541).
local usable = Ops.speciesUsable(S, id)
local current = (S.editingMon and S.editingMon.species == id)
if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then
if Ops.setSpecies(S, S.editingMon, id) then
Ops.closeSpeciesPicker(S, Kit)
Kit.popClip()
return
end
end
local icon = rowH - 4 * s
MonEditor.drawSprite(S, Kit, id, cx + 6 * s, ry + 2 * s, icon)
local tx = cx + 6 * s + icon + 8 * s
local tail = usable and ("#%03d"):format(tonumber(def and def.dex) or 0)
or "no data"
local tailW = Kit.textWidth("tiny", tail)
Kit.text("monoRow",
Kit.ellipsize("monoRow", id, inner - (tx - cx) - tailW - 20 * s), tx,
ry + (rowH - Kit.textHeight("monoRow")) / 2,
usable and PAL.text or PAL.faint)
Kit.textRight("tiny", tail, cx + inner - 10 * s,
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption)
end
Kit.popClip()
end
p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage)
end
return Picker