mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user