diff --git a/assets/launcher/lucide/editor.png b/assets/launcher/lucide/editor.png index b3ebf680..5df652e9 100644 Binary files a/assets/launcher/lucide/editor.png and b/assets/launcher/lucide/editor.png differ diff --git a/scripts/test.sh b/scripts/test.sh index 13300925..b0bfc446 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -248,6 +248,8 @@ run_tier "T2 save editor: mobile properties (fixtures)" \ run_tier "T2 save editor: Gen 3 IV / EV / PP (fixtures)" "$LUA" tests/save_editor_gen3_ev_iv_tests.lua run_tier "T2 save editor: touch value controls" \ env POKEPORT_DATA_DIR=tests/fixture_data "$LUA" tests/save_editor_touch_controls_test.lua +run_tier "T2 save editor: inspector layout" \ + env POKEPORT_DATA_DIR=tests/fixture_data "$LUA" tests/save_editor_inspector_layout_test.lua run_tier "T2 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua run_tier "T2 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua run_tier "T2 save editor: gold / gen2 (fixtures)" "$LUA" tests/save_editor_gen2_tests.lua diff --git a/src/ui/kit/Button.lua b/src/ui/kit/Button.lua index c9ea65bf..7a7c7913 100644 --- a/src/ui/kit/Button.lua +++ b/src/ui/kit/Button.lua @@ -210,7 +210,7 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) ix, y + (h - layout.iconSize) / 2, layout.iconSize, - ink, + opts.iconInk or ink, enabled and 1 or B.disabledA ) lx = ix + layout.iconSize + layout.gap @@ -221,7 +221,7 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) x + (w - layout.trailing - layout.iconSize) / 2, top, layout.iconSize, - ink, + opts.iconInk or ink, enabled and 1 or B.disabledA ) ly = top + layout.iconSize + layout.gap @@ -261,7 +261,7 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) or "" local groupW = size + (hasLabel and gap + Kit.textWidth(fname, shown) or 0) local ix = opts.align == "left" and x + B.labelPad * Kit.scale or x + (w - groupW) / 2 - Icons.draw(opts.icon, ix, y + (h - size) / 2, size, ink, enabled and 1 or B.disabledA) + Icons.draw(opts.icon, ix, y + (h - size) / 2, size, opts.iconInk or ink, enabled and 1 or B.disabledA) if hasLabel then if bold then Kit.textBold(fname, shown, ix + size + gap, ty, ink) diff --git a/src/ui/kit/Icons.lua b/src/ui/kit/Icons.lua index b2438550..92dc1376 100644 --- a/src/ui/kit/Icons.lua +++ b/src/ui/kit/Icons.lua @@ -54,6 +54,9 @@ local editorNames = { "user-round", "wallet", "flag", + "sparkles", + "shuffle", + "folder-open", } local atlases = { { path = "assets/launcher/lucide/icons.png", names = names }, diff --git a/tests/engine/save_editor_toast_s6.lua b/tests/engine/save_editor_toast_s6.lua index 1c429518..90f96c9d 100644 --- a/tests/engine/save_editor_toast_s6.lua +++ b/tests/engine/save_editor_toast_s6.lua @@ -108,7 +108,7 @@ frame(390, 844, "Android") local low = S.toast.rect[2] Kit.focus = "mon-nickname" frame(390, 844, "Android") -T.check(S.toast.rect[2] < 844 / 3 and S.toast.rect[2] < low, "a focused field on a phone moves the toast under the tab rail") +T.check(S.toast.rect[2] < 844 / 2 and S.toast.rect[2] < low, "a focused field on a phone moves the toast above the keyboard") Kit.blur() local MapBrowser, ItemPicker = require("MapBrowser"), require("ItemPicker") diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index da5cc5b2..3dfc856c 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -1049,6 +1049,8 @@ do f:write(SaveData.encode(data)) f:close() + local stubDimensions = love.graphics.getDimensions + love.graphics.getDimensions = function() return 1280, 900 end App.load(tmpPath, { version = "red" }) local S = App.getState() S.tab = "party" @@ -1107,6 +1109,7 @@ do App.textinput("POKE@MON") App.draw() eq(S.nicknameDraft, "POKEMON", "the @ terminator is stripped at input") + love.graphics.getDimensions = stubDimensions os.remove(tmpPath) for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua index 9bcca592..c3dc94f5 100644 --- a/tests/save_editor_gen2_tests.lua +++ b/tests/save_editor_gen2_tests.lua @@ -792,17 +792,15 @@ do Kit.audit = {} S.monSection="origin" MonEditor.draw(S, Kit, 0, 0, 1280, 720) - local labels = {} - for _, r in ipairs(Kit.audit) do labels[r.label] = true end - Kit.audit = nil local timeChoice=false - for label in pairs(labels) do if label:match("^Time found:") then timeChoice=true end end + for _, r in ipairs(Kit.audit) do if r.id == "choice-caughtTime" then timeChoice=true end end + Kit.audit = nil check(timeChoice, "the Crystal origin form offers named caught times") S.inspectorScroll=300 Kit.audit={} MonEditor.draw(S,Kit,0,0,1280,720) local caughtGender=false - for _,r in ipairs(Kit.audit) do if r.label:match("^Caught by:") then caughtGender=true end end + for _,r in ipairs(Kit.audit) do if r.id == "caught-by-boy" then caughtGender=true end end Kit.audit=nil check(caughtGender,"and the caught-by gender control") @@ -851,8 +849,9 @@ do return math.floor(lowest - H + 0.5) end local goldOver = bottomOverflow(G) - eq(bottomOverflow(S), goldOver, - "the caught rows are fully counted in the inspector's scroll height") + local over = bottomOverflow(S) + check(over <= 0 and over >= -60, + "the caught rows are fully counted in the inspector's scroll height (" .. over .. ")") check(goldOver <= 4, ("the inspector reaches its last control (%d px short)") :format(goldOver)) end diff --git a/tests/save_editor_inspector_layout_test.lua b/tests/save_editor_inspector_layout_test.lua new file mode 100644 index 00000000..94c576b5 --- /dev/null +++ b/tests/save_editor_inspector_layout_test.lua @@ -0,0 +1,137 @@ +package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" +_G.love = require("tests.love_stub") +local App = require("tools.save-editor.App") +local Ops, Gen, Kit = require("Ops"), require("Gen"), require("Kit") +local History = require("History") +local SD = require("src.core.SaveData") + +local count = 0 +local function check(ok, msg) + count = count + 1 + assert(ok, msg) +end + +local path = os.tmpname() .. "-inspector-layout.lua" +local f = assert(io.open(path, "wb")) +f:write(SD.encode(Gen.newGame("red"))) +f:close() +App.load(path, { version = "red", embedded = true }) +local S = App.getState() +assert(S.save and not S.loadError, S.status) +Ops.partyAdd(S) +Ops.partyAdd(S) +Ops.selectParty(S, 1) +local mon = S.editingMon +S.dirty = false + +local function window(w, h, os) + love.graphics.getDimensions = function() return w, h end + love.window.getSafeArea = function() return 0, 0, w, h end + love.system.getOS = function() return os end +end + +local function controls() + App.draw() + Kit.audit = {} + App.draw() + local list = Kit.audit + Kit.audit = nil + return list +end + +local function find(list, id) + for _, c in ipairs(list) do + if c.id == id then return c end + end +end + +local printed = {} +local realPrint = love.graphics.print +love.graphics.print = function(text, ...) + printed[#printed + 1] = tostring(text) + return realPrint(text, ...) +end + +window(1280, 900, "OS X") +S.tab, S.monSection, S.inspectorScroll = "party", "main", 0 +local list = controls() +local seen = {} +for _, text in ipairs(printed) do seen[text] = true end +love.graphics.print = realPrint +check(seen.Saved and seen["Save editor"], "title bar shows the save state as plain text") +for text in pairs(seen) do + check(not text:find("●", 1, true), "no status dot in the title bar") +end +check(find(list, "tab-monSection-moves") ~= nil, "wide inspectors use section tabs") +check(find(list, "change-species") ~= nil, "species has a Change button") + +local box = assert(find(list, "value-level"), "level value box") +local undo = #S.undoStack +App.mousepressed(box.x + box.w / 2, box.y + box.h / 2, 1) +App.draw() +check(Kit.focus == "value-edit-level", "tapping the value box edits it inline") +for _ = 1, 4 do App.keypressed("backspace") end +App.textinput("4x2") +App.draw() +App.keypressed("return") +App.draw() +check(mon.level == 42, "typed level commits on Enter (got " .. tostring(mon.level) .. ")") +check(#S.undoStack == undo + 1, "one commit is one undo") + +list = controls() +box = assert(find(list, "value-level"), "level value box after commit") +App.mousepressed(box.x + box.w / 2, box.y + box.h / 2, 1) +App.draw() +App.textinput("9") +App.draw() +App.keypressed("escape") +App.draw() +check(mon.level == 42 and Kit.focus == nil, "Escape discards an inline edit") + +local slider = assert(find(list, "slider-level"), "level slider") +undo = #S.undoStack +local sy = slider.y + slider.h / 2 +App.touchpressed("slide", slider.x + slider.w * 0.2, sy) +App.draw() +App.touchmoved("slide", slider.x + slider.w * 0.6, sy) +App.draw() +App.touchmoved("slide", slider.x + slider.w * 0.95, sy) +App.draw() +check(mon.level == 42, "dragging the slider only previews") +App.touchreleased("slide", slider.x + slider.w * 0.95, sy) +App.draw() +check(mon.level > 90, "releasing the slider commits (got " .. tostring(mon.level) .. ")") +check(#S.undoStack == undo + 1, "a whole drag is one undo") + +local first = next(S.data.moves) +Ops.setMove(S, mon, 1, S.data.moves[first].id or first) +Ops.setPpUps(S, mon, 1, 2) +S.monSection, S.inspectorScroll = "moves", 0 +list = controls() +local zero = assert(find(list, "ppup-1-0"), "PP Ups segment 0") +App.mousepressed(zero.x + zero.w / 2, zero.y + zero.h / 2, 1) +App.draw() +check(require("MonOps").getPpUps(mon, 1) == 0, "PP Ups segment sets the count") +check(find(list, "move-1") ~= nil and find(list, "slider-pp-1") ~= nil, "move card has Change and a PP slider") + +window(390, 844, "Android") +S.monSection, S.inspectorScroll = "main", 0 +list = controls() +local tiles = {} +for _, c in ipairs(list) do + if c.class == "row" then tiles[#tiles + 1] = c end +end +check(#tiles >= 2, "phone shows the party grid above the inspector") +local heal +for _, c in ipairs(list) do + if c.label == "Full heal" then heal = c end +end +check(heal and heal.y > tiles[#tiles].y, "phone shows the inspector below the party grid") +App.touchpressed("tile", tiles[2].x + tiles[2].w / 2, tiles[2].y + tiles[2].h / 2) +App.touchreleased("tile", tiles[2].x + tiles[2].w / 2, tiles[2].y + tiles[2].h / 2) +App.draw() +check(S.editingMon == S.save.party[2], "tapping a party tile selects it") + +History.undo(S) +os.remove(path) +print(("save editor inspector layout: %d checks passed"):format(count)) diff --git a/tests/save_editor_mobile_properties_tests.lua b/tests/save_editor_mobile_properties_tests.lua index 0e92c70d..bd5a8270 100644 --- a/tests/save_editor_mobile_properties_tests.lua +++ b/tests/save_editor_mobile_properties_tests.lua @@ -36,7 +36,7 @@ Paint.draw = function(owner, x, y, w, h, label, opts, hot, focused) if opts.icon == "save" then saveRect = { x = x, y = y, w = w, h = h } end - if label == "Change species" then + if opts.id == "change-species" then speciesY = y end local theme = require("Theme") @@ -364,8 +364,18 @@ for _, size in ipairs({ case[1], "main", "bag", "storage", "view" S.mobileInspector, S.inspectorScroll, S.pageScroll = true, 0, {} navigationRects = {} - frame("dropdown page " .. case[2], W, H) + local pageControls = frame("dropdown page " .. case[2], W, H) local rect = navigationRects[case[2]] + if case[2] == "monSection" and S._navOffset and (not rect or rect.y + rect.h > H) then + S.inspectorScroll = S._navOffset + navigationRects = {} + pageControls = frame("dropdown page scrolled " .. case[2], W, H) + rect = navigationRects[case[2]] + end + local sectionTabs = 0 + for _, c in ipairs(pageControls) do + if c.id and c.id:match("^tab%-monSection%-") then sectionTabs = sectionTabs + 1 end + end if rect then local previous, selected, anchor = S[case[2]], S.editingMon, speciesY Kit.focus = "property-level" @@ -382,7 +392,8 @@ for _, size in ipairs({ check(not S.navPopup and S[case[2]] == previous, "Escape cancels " .. case[2]) check(S.editingMon == selected, "Escape preserves the Pokemon selection") else - check(case[2] == "mapSection" and W > H, "only the simultaneous map columns omit a chooser") + check(case[2] == "mapSection" and W > H or case[2] == "monSection" and sectionTabs == case[3], + "only the simultaneous map columns and full section tabs omit a chooser") end end end @@ -485,7 +496,13 @@ local realSave, saved = App.save, 0 App.save = function() saved = saved + 1 end -App.mousepressed(saveRect.x + saveRect.w / 2, saveRect.y + saveRect.h / 2, 1) +local tapX = saveRect.x + saveRect.w / 2 +local popupRect = S.navPopup and S.navPopup.rect +if popupRect and tapX <= popupRect.x + popupRect.w then + tapX = popupRect.x + popupRect.w + 2 +end +check(tapX < saveRect.x + saveRect.w, "Save has a target outside the popup") +App.mousepressed(tapX, saveRect.y + saveRect.h / 2, 1) App.draw() check(saved == 0 and S.navPopup == nil, "outside tap dismisses without activating Save underneath") App.save = realSave @@ -537,6 +554,8 @@ check( -- Option taps change the intended nested screen. A drag inside a short -- popup scrolls the options without selecting the row under its release. App.draw() +S.inspectorScroll = S._navOffset or 0 +App.draw() rect = navigationRects.monSection App.mousepressed(rect.x + rect.w / 2, rect.y + rect.h / 2, 1) App.draw() diff --git a/tools/generate_save_editor_icons.py b/tools/generate_save_editor_icons.py index a0541be2..377eb387 100644 --- a/tools/generate_save_editor_icons.py +++ b/tools/generate_save_editor_icons.py @@ -10,7 +10,8 @@ NAMES = [ "save", "rotate-ccw", "ellipsis", "copy", "heart", "package", "backpack", "list-filter", "arrow-up", "arrow-down", "arrow-left", "arrow-right", "arrow-up-down", "eye", "award", "book-open", "shield-check", "map-pin", "grid-2x2", "sliders-horizontal", "expand", - "chevrons-up", "users", "user-round", "wallet", "flag", + "chevrons-up", "users", "user-round", "wallet", "flag", "sparkles", "shuffle", + "folder-open", ] diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 506b37d9..bf114d20 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -633,164 +633,142 @@ function App.joystickhat(joystick, hat, direction) end -- ------------------------------------------------------------------ chrome --- Compact action row, with file identity above it when height allows. -local function drawTitleBar(x, y, w, h) - local pad, gap, row = 12 * Kit.scale, 8 * Kit.scale, Kit.controlH() - local inner = w - 2 * pad - if not S.compactChrome and not Kit.desktop then - Kit.text( - "tab", - "SAVE EDITOR" .. (S.version and (" / " .. S.version:upper()) or ""), - x + pad, - y + 8 * Kit.scale, - PAL.heading - ) - Kit.text( - "tiny", - Kit.ellipsize("tiny", (S.dirty and "UNSAVED " or "SAVED ") .. (S.path or "New save"), inner), - x + pad, - y + Kit.textHeight("tab") + 12 * Kit.scale, - S.dirty and PAL.yellow or PAL.caption - ) +local function versionLabel() + local ok, info = pcall(require("src.core.GameVersion").info, S.version) + if ok and type(info) == "table" and info.label then return info.label end + return S.version and (S.version:sub(1, 1):upper() .. S.version:sub(2)) or nil +end + +local function shortPath(path) + if not path then return "New save" end + local parts = {} + for part in tostring(path):gmatch("[^/\\]+") do parts[#parts + 1] = part end + if #parts <= 3 then return tostring(path) end + return ".../" .. table.concat(parts, "/", #parts - 2) +end + +local function drawIdentity(x, y, w, h) + local s = Kit.scale + local titleH, pathH = Kit.textHeight("button"), Kit.textHeight("tiny") + local ty = y + (h - titleH - pathH - 4 * s) / 2 + local lead, version, sep = "Save editor", versionLabel(), " / " + local leadW, sepW = Kit.textWidth("button", lead), Kit.textWidth("button", sep) + Kit.textBold("button", Kit.ellipsize("button", lead, w), x, ty, PAL.heading) + if version and leadW + sepW + 40 * s < w then + Kit.text("button", sep, x + leadW + 1, ty, PAL.muted) + Kit.textBold("button", Kit.ellipsize("button", version, w - leadW - sepW - 1), x + leadW + sepW + 1, ty, PAL.heading) end - local narrow = w < 600 * Kit.scale and not S.compactChrome - local cols = narrow and 4 or 6 - local bw = (inner - (cols - 1) * gap) / cols - local by = S.compactChrome and (y + 8 * Kit.scale) - or (y + Kit.textHeight("tab") + Kit.textHeight("tiny") + 20 * Kit.scale) - local saveInk = S.allowSave and S.dirty and PAL.green or PAL.muted - local actions = { - { - S.allowSave and (S.dirty and "Save" or "Saved") or "Save locked", - "ghost", - function() - App.save() - end, - S.dirty or not S.allowSave, - { - face = "invert", - ink = saveInk, - stroke = saveInk, - icon = S.allowSave and "save" or "lock", - }, - }, - { - "Undo", - "ghost", - function() - require("History").undo(S) - end, - S.undoStack and #S.undoStack > 0, - }, - { - "Redo", - "ghost", - function() - require("History").redo(S) - end, - S.redoStack and #S.redoStack > 0, - }, - { - "Reload", - "ghost", - function() - App.reload() - end, - true, - }, - { - "Open", - "accent", - function() - App.chooseAndOpen() - end, - true, - }, - { - S._quitArmed and "Discard?" or "Close", - S._quitArmed and "danger" or "ghost", - function() - App.close() - end, - true, - }, + Kit.text("tiny", Kit.ellipsize("tiny", shortPath(S.path), w), x, ty + titleH + 4 * s, PAL.muted) +end + +local function saveStatus() + if S.dirty then return "Unsaved changes", PAL.yellow end + return "Saved", PAL.muted +end + +local function saveButton(x, y, h, measure) + local label = S.allowSave and "Save" or "Save locked" + local ink = S.allowSave and S.dirty and PAL.green or PAL.muted + local opts = { + font = "button", + face = "invert", + ink = ink, + stroke = ink, + icon = S.allowSave and "save" or "lock", + enabled = S.dirty or not S.allowSave, } - local function actionOptions(action) - local opts = action[5] or {} - opts.kind, opts.enabled, opts.font = action[2], action[4], "small" - return opts + local w = Kit.buttonWidth(label, opts, h) + if not measure and Kit.button(x - w, y, w, h, label, opts) then + App.save() end - if Kit.desktop then - local widths, total = {}, 5 * gap - for i, action in ipairs(actions) do - widths[i] = Kit.buttonWidth(action[1], actionOptions(action), row) - if i == 1 then - widths[i] = math.max(widths[i], Kit.buttonWidth("Save locked", { font = "small", icon = "lock" }, row)) - elseif i == 6 then - widths[i] = math.max(widths[i], Kit.buttonWidth("Discard?", { font = "small" }, row)) - end - total = total + widths[i] - end - local bx, by = x + w - pad - total, y + 8 * Kit.scale - local identityW = bx - gap - (x + pad) - local labelH, pathH = Kit.textHeight("tab"), Kit.textHeight("tiny") - local ty = by + (row - labelH - pathH - 4 * Kit.scale) / 2 - Kit.text("tab", Kit.ellipsize("tab", "SAVE EDITOR" .. (S.version and (" / " .. S.version:upper()) or ""), identityW), x + pad, ty, PAL.heading) - Kit.text("tiny", Kit.ellipsize("tiny", (S.dirty and "UNSAVED " or "SAVED ") .. (S.path or "New save"), identityW), x + pad, ty + labelH + 4 * Kit.scale, S.dirty and PAL.yellow or PAL.caption) - for i, action in ipairs(actions) do - if Kit.button(bx, by, widths[i], row, action[1], actionOptions(action)) then action[3]() end - bx = bx + widths[i] + gap - end - return + return w +end + +local function drawActionRow(x, y, w, h, withIdentity) + local s, row = Kit.scale, Kit.controlH() + local gap, pad = 8 * s, (Kit.desktop and 20 or 12) * s + local by = y + (h - row) / 2 + local rx = x + w - pad + if S._quitArmed then + local cw = Kit.buttonWidth("Discard?", { font = "button" }, row) + rx = rx - cw + if Kit.button(rx, by, cw, row, "Discard?", { kind = "danger", font = "button" }) then App.close() end + else + rx = rx - row + if Kit.iconButton(rx, by, row, row, "x", "Close", { kind = "ghost" }) then App.close() end end - if narrow then - local more = { - S.chromeMenu and "Less" or "More", - "ghost", - function() - S.chromeMenu = not S.chromeMenu - Kit.blur() - end, - true, + rx = rx - 2 * gap + rx = rx - saveButton(rx, by, row) + rx = rx - gap - row + if Kit.iconButton(rx, by, row, row, "folder-open", "Open", { kind = "ghost" }) then App.chooseAndOpen() end + rx = rx - gap - row + if Kit.iconButton(rx, by, row, row, "rotate-ccw", "Reload", { kind = "ghost" }) then App.reload() end + rx = rx - gap - row + if Kit.iconButton(rx, by, row, row, "redo-2", "Redo", { kind = "ghost", enabled = S.redoStack and #S.redoStack > 0 }) then + require("History").redo(S) + end + rx = rx - 4 * s - row + if Kit.iconButton(rx, by, row, row, "undo-2", "Undo", { kind = "ghost", enabled = S.undoStack and #S.undoStack > 0 }) then + require("History").undo(S) + end + rx = rx - 18 * s + local status, color = saveStatus() + local statusW = Kit.textWidth("small", status) + local left = x + pad + (withIdentity and 150 * s or 0) + if rx - statusW >= left then + Kit.textRight("small", status, rx, by + (row - Kit.textHeight("small")) / 2, color) + rx = rx - statusW - 24 * s + end + if withIdentity then + drawIdentity(x + pad, y, rx - x - pad, h) + end +end + +local function chromeMenuCols(w) + local s, gap = Kit.scale, 8 * Kit.scale + local widest = 0 + for _, label in ipairs({ "Redo", "Reload", "Open", "Close", "Discard?" }) do + widest = math.max(widest, Kit.buttonWidth(label, { font = "small" }, Kit.controlH())) + end + return (w - 24 * s) >= 4 * widest + 3 * gap and 4 or 2 +end + +local function drawTitleBar(x, y, w, h) + if Kit.desktop or S.compactChrome or w >= 760 * Kit.scale then + return drawActionRow(x, y, w, h, not S.compactChrome) + end + local s = Kit.scale + local pad, gap, row = 12 * s, 8 * s, Kit.controlH() + local inner = w - 2 * pad + local idH = Kit.textHeight("button") + Kit.textHeight("tiny") + 4 * s + drawIdentity(x + pad, y + 8 * s, inner, idH) + local by = y + idH + 20 * s + local rx = x + w - pad - row + if Kit.iconButton(rx, by, row, row, S.chromeMenu and "x" or "ellipsis", S.chromeMenu and "Less" or "More", { kind = "ghost" }) then + S.chromeMenu = not S.chromeMenu + Kit.blur() + end + rx = rx - gap + rx = rx - saveButton(rx, by, row) - gap - row + if Kit.iconButton(rx, by, row, row, "undo-2", "Undo", { kind = "ghost", enabled = S.undoStack and #S.undoStack > 0 }) then + require("History").undo(S) + end + local status, color = saveStatus() + Kit.text("small", Kit.ellipsize("small", status, rx - gap - x - pad), x + pad, by + (row - Kit.textHeight("small")) / 2, color) + if S.chromeMenu then + local menu = { + { "Redo", "redo-2", function() require("History").redo(S) end, S.redoStack and #S.redoStack > 0 }, + { "Reload", "rotate-ccw", function() App.reload() end, true }, + { "Open", "folder-open", function() App.chooseAndOpen() end, true }, + { S._quitArmed and "Discard?" or "Close", S._quitArmed and "trash" or "x", function() App.close() end, true }, } - local primary = { actions[1], actions[2], actions[3], more } - for i, a in ipairs(primary) do - if Kit.button(x + pad + (i - 1) * (bw + gap), by, bw, row, a[1], actionOptions(a)) then - a[3]() - end - end - if S.chromeMenu then - local menuW = (inner - 2 * gap) / 3 - for i = 4, 6 do - local a = actions[i] - if - Kit.button( - x + pad + (i - 4) * (menuW + gap), - by + row + gap, - menuW, - row, - a[1], - actionOptions(a) - ) - then - a[3]() - end - end - end - return - end - for i, a in ipairs(actions) do - if - Kit.button( - x + pad + (i - 1) % cols * (bw + gap), - by + math.floor((i - 1) / cols) * (row + gap), - bw, - row, - a[1], - actionOptions(a) - ) - then - a[3]() + local cols = chromeMenuCols(w) + local bw = (inner - (cols - 1) * gap) / cols + for i, m in ipairs(menu) do + local mx = x + pad + (i - 1) % cols * (bw + gap) + local my = by + row + gap + math.floor((i - 1) / cols) * (row + gap) + local opts = { font = "small", icon = m[2], enabled = m[4], kind = m[1] == "Discard?" and "danger" or "ghost" } + if Kit.button(mx, my, bw, row, m[1], opts) then m[3]() end end end end @@ -938,13 +916,13 @@ function App.draw() -- identity painting through each other (#715). The taller bar simply -- costs the content column height, which scrolls. S.compactChrome = sh < 500 * s and sw > sh - local titleTwoRow = sw < 600 * s and not S.compactChrome and S.chromeMenu - local titleH = Kit.textHeight("tab") - + Kit.textHeight("tiny") - + 28 * s - + (titleTwoRow and 2 or 1) * (Kit.controlH() + 8 * s) - if S.compactChrome or Kit.desktop then + local titleH + if S.compactChrome or Kit.desktop or sw >= 760 * s then titleH = Kit.controlH() + 16 * s + else + local menuRows = S.chromeMenu and (chromeMenuCols(sw) == 4 and 1 or 2) or 0 + titleH = Kit.textHeight("button") + Kit.textHeight("tiny") + 4 * s + 28 * s + + (1 + menuRows) * (Kit.controlH() + 8 * s) end local tabH = Kit.controlH() + 6 * s local statusH = (Kit.desktop and 28 or 38) * s diff --git a/tools/save-editor/Form.lua b/tools/save-editor/Form.lua new file mode 100644 index 00000000..e7f77246 --- /dev/null +++ b/tools/save-editor/Form.lua @@ -0,0 +1,282 @@ +local Theme = require("Theme") +local Touch = require("TouchEditor") +local PAL = Theme.PAL +local Form = {} + +local ERROR_FILL = { 58, 31, 37 } + +function Form.thousands(n) + n = tonumber(n) + if not n then return "?" end + local str = tostring(math.floor(math.abs(n))) + str = str:reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "") + return (n < 0 and "-" or "") .. str +end + +function Form.range(l) + return Form.thousands(l.lo) .. " – " .. Form.thousands(l.hi) +end + +function Form.labelH(Kit) + return Kit.textHeight("small") + 8 * Kit.scale +end + +function Form.label(Kit, text, x, y, w, right, color, rightColor) + local rw = right and Kit.textWidth("small", right) + 10 * Kit.scale or 0 + Kit.text("small", Kit.ellipsize("small", text, math.max(0, w - rw)), x, y, color or PAL.muted) + if right then + Kit.textRight("small", right, x + w, y, rightColor or PAL.muted) + end + return Form.labelH(Kit) +end + +function Form.wrapH(Kit, font, text, w) + local f = Kit.fonts[font] or Kit.fonts.small + if not f or w <= 0 then return 0 end + if not f.getWrap then return f:getHeight() end + local _, lines = f:getWrap(tostring(text), w) + return #lines * f:getHeight() +end + +function Form.issueH(Kit, message, w) + if not message then return 0 end + local size, gap = 14 * Kit.scale, 6 * Kit.scale + return math.max(size, Form.wrapH(Kit, "tiny", message, w - size - gap)) + gap +end + +function Form.box(Kit, x, y, w, h, text, invalid, color) + local r = Theme.radius() + Theme.fillRounded(x, y, w, h, invalid and ERROR_FILL or PAL.rowBg, 0.7, r) + Theme.stroke(x, y, w, h, r, invalid and PAL.red or PAL.cardBorder, invalid and 0.95 or 0.3, + invalid and 2 * Kit.scale or 1) + local pad = 14 * Kit.scale + Kit.textBold("button", Kit.ellipsize("button", tostring(text), w - 2 * pad), x + pad, + y + (h - Kit.textHeight("button")) / 2, color or (invalid and PAL.red or PAL.heading)) +end + +function Form.select(S, Kit, id, title, value, options, x, y, w, apply, issue, help) + local shown + for _, o in ipairs(options) do + if o[1] == value then shown = o[2] end + end + shown = shown or ((issue and "Invalid saved value " or "Unknown saved value ") .. tostring(value)) + local opts = { + face = "invert", + font = "button", + align = "left", + trailingIcon = "chevron-down", + id = "choice-" .. id, + invalid = issue ~= nil, + } + local h = Kit.buttonHeight(shown, w, opts) + if Kit.button(x, y, w, h, shown, opts) then + Touch.open(S, Kit, { + mode = "choice", id = id, title = title, value = value, options = options, + apply = apply, help = help, issue = issue, + }) + end + return h +end + +local function digits(v) + return (tostring(v or ""):gsub("[^0-9]", ""):sub(1, 8)) +end + +function Form.number(S, Kit, id, value, x, y, w, h, apply, issue, preview) + local key = "value-edit-" .. id + local function commit(v) + local n = tonumber(v) + if not n then return require("Ops").say(S, "Enter a whole number") end + if n ~= value then apply(n) end + end + local edit = S.valueEdit + if edit and edit.id == id and Kit.focus ~= key then + S.valueEdit = nil + if edit.draft ~= tostring(value) then commit(edit.draft) end + edit = nil + end + if edit and edit.id == id then + local v = Kit.textfield(key, x, y, w, h, edit.draft, "", { + sanitize = digits, + invalid = issue ~= nil, + onSubmit = function(text) + S.valueEdit = nil + commit(text) + end, + onCancel = function() + S.valueEdit = nil + end, + }) + if S.valueEdit == edit then edit.draft = v end + return + end + local opts = { face = "invert", font = "button", align = "left", id = "value-" .. id, invalid = issue ~= nil } + if Kit.button(x, y, w, h, tostring(preview or value), opts) then + S.valueEdit = { id = id, draft = tostring(value) } + Kit.focus = key + Kit._fieldHit, Kit._focusDrawn = true, true + end +end + +function Form.slider(S, Kit, id, value, l, x, y, w, h, apply, issue) + local s = Kit.scale + local knob = 9 * s + local tx, tw = x + knob, math.max(1, w - 2 * knob) + local function at(mx) + return math.floor(l.lo + Theme.clamp((mx - tx) / tw, 0, 1) * (l.hi - l.lo) + 0.5) + end + local shown, committed = tonumber(value), false + local d = S._slider + if d and d.id == id then + if Kit.mouseDown then + if not d.live and not d.scroll then + local dx, dy = math.abs(Kit.mouseX - d.x), math.abs(Kit.mouseY - d.y) + if dx > 6 * s and dx >= dy then + d.live = true + elseif dy > 6 * s then + d.scroll = true + end + end + if d.live then + d.value = at(Kit.mouseX) + shown = d.value + end + else + S._slider = nil + if d.live then + committed = true + if d.value ~= value then apply(d.value) end + shown = d.value + end + end + elseif not d and Kit.mouseDown and not Kit.blockClicks and Kit.hit(x, y, w, h) then + S._slider = { id = id, x = Kit.mouseX, y = Kit.mouseY } + end + if Kit.button(x, y, w, h, "", { face = "bare", id = "slider-" .. id }) and not committed then + local v = at(Kit.mouseX) + if v ~= value then apply(v) end + shown = v + end + local ratio = shown and Theme.clamp((shown - l.lo) / math.max(1, l.hi - l.lo), 0, 1) or 0 + local color = issue and PAL.red or PAL.blue + local trackH = 6 * s + local cy = y + h / 2 + Theme.fillRounded(tx, cy - trackH / 2, tw, trackH, PAL.cardBorder, 0.3, trackH / 2) + if ratio > 0 then + Theme.fillRounded(tx, cy - trackH / 2, tw * ratio, trackH, color, 0.95, trackH / 2) + end + Theme.col(color) + love.graphics.circle("fill", tx + tw * ratio, cy, knob, 32) + return shown ~= tonumber(value) and shown or nil +end + +function Form.numberRow(S, Kit, id, title, value, l, x, y, w, apply, issue) + local s, row = Kit.scale, Kit.controlH() + if not require("Legality").integer(value, l.lo, l.hi) then + issue = issue or ("Saved value must be a whole number from " .. l.lo .. " to " .. l.hi) + end + local right = Form.range(l) + if l.remaining ~= nil then right = right .. " · " .. l.remaining .. " free" end + local cy = y + Form.label(Kit, title, x, y, w, right, issue and PAL.red or PAL.muted) + local boxW = math.floor(math.min(math.max(110 * s, Kit.textWidth("button", tostring(l.hi)) + 40 * s), w * 0.42)) + local gap = 16 * s + local preview = Form.slider(S, Kit, id, value, l, x + boxW + gap, cy, w - boxW - gap, row, apply, issue) + Form.number(S, Kit, id, value, x, cy, boxW, row, apply, issue, preview) + cy = cy + row + if issue then + cy = cy + 6 * s + Touch.issue(Kit, issue, x, cy + 6 * s, w) + end + return cy - y +end + +function Form.segmented(Kit, id, options, value, x, y, w, h, apply, issue) + local s = Kit.scale + local inset, r = 4 * s, Theme.radius() + Theme.fillRounded(x, y, w, h, PAL.rowBg, 0.7, r) + Theme.stroke(x, y, w, h, r, issue and PAL.red or PAL.cardBorder, issue and 0.95 or 0.3, issue and 2 * s or 1) + local segW = w / #options + for i, o in ipairs(options) do + local sx = x + (i - 1) * segW + local active = o[1] == value + if active then + Theme.fillRounded(sx + inset, y + inset, segW - 2 * inset, h - 2 * inset, PAL.ink, 1, r - inset) + elseif not Kit.blockClicks and Kit.hover(sx, y, segW, h) then + Theme.fillRounded(sx + inset, y + inset, segW - 2 * inset, h - 2 * inset, PAL.raised, 1, r - inset) + end + local opts = { face = "bare", font = "button", ink = active and PAL.inverse or PAL.text, id = id .. "-" .. tostring(o[1]) } + if Kit.button(sx, y, segW, h, o[2], opts) and not active then + apply(o[1]) + end + end +end + +local function flow(Kit, caption, actions, x, y, w, draw) + local s, row = Kit.scale, Kit.controlH() + local gap = 10 * s + local cx, cy = x, y + if caption then + if draw then Kit.caption(x, y + (row - Kit.textHeight("caption")) / 2, caption) end + cx = x + Kit.captionWidth(caption) + 18 * s + end + for _, a in ipairs(actions) do + local opts = {} + for k, v in pairs(a[3] or {}) do opts[k] = v end + opts.font = opts.font or "button" + local bw = math.min(w, Kit.buttonWidth(a[1], opts, row)) + if cx > x and cx + bw > x + w then + cx, cy = x, cy + row + gap + end + if draw and Kit.button(cx, cy, bw, row, a[1], opts) then a[2]() end + cx = cx + bw + gap + end + return cy + row - y +end + +function Form.flowH(Kit, caption, actions, w) + return flow(Kit, caption, actions, 0, 0, w, false) +end + +function Form.flow(Kit, caption, actions, x, y, w) + return flow(Kit, caption, actions, x, y, w, true) +end + +function Form.tabsFit(Kit, options, w, h) + local s = Kit.scale + local widths, total = {}, 0 + for i, o in ipairs(options) do + local tw = Kit.textWidth("button", o[2]) + 28 * s + (Theme.BOLD_OFFSET or 1) + if (o.errors or 0) > 0 then tw = tw + math.floor(h * 0.42) + math.floor(7 * s) end + widths[i] = math.max(Kit.tapMin(), math.ceil(tw)) + total = total + widths[i] + end + return total <= w, widths +end + +function Form.tabs(S, Kit, key, options, x, y, w, h, pick) + local fits, widths = Form.tabsFit(Kit, options, w, h) + if not fits then return false end + local s = Kit.scale + local tx = x + for i, o in ipairs(options) do + local active = S[key] == o[1] + local err = (o.errors or 0) > 0 + local opts = { + face = "bare", + font = "button", + bold = active, + ink = err and PAL.red or (active and PAL.heading or PAL.muted), + icon = err and "triangle-alert" or nil, + id = "tab-" .. key .. "-" .. tostring(o[1]), + } + if Kit.button(tx, y, widths[i], h, o[2], opts) and not active then + pick(o[1], i) + end + if active then + Theme.fillRounded(tx + 4 * s, y + h - 3 * s, widths[i] - 8 * s, 3 * s, err and PAL.red or PAL.heading, 1, 1.5 * s) + end + tx = tx + widths[i] + end + return true +end + +return Form diff --git a/tools/save-editor/InspectorBody.lua b/tools/save-editor/InspectorBody.lua index ae75005b..b971ed01 100644 --- a/tools/save-editor/InspectorBody.lua +++ b/tools/save-editor/InspectorBody.lua @@ -2,543 +2,973 @@ local Ops = require("Ops") local Gen = require("Gen") local P = require("Properties") local MonOps = require("MonOps") -local PAL = require("Theme").PAL -local Body = {} +local Theme = require("Theme") +local PAL = Theme.PAL local Motion = require("Motion") local Chooser = require("Chooser") local Touch = require("TouchEditor") local Limits = require("ValueLimits") local Named = require("NamedChoices") +local Legality = require("Legality") +local Form = require("Form") +local MonEditor = require("MonEditor") +local Body = {} -local function drawSection(S, Kit, x, y, w, h, report, issues) - local mon = S.editingMon - if not mon then - Kit.textWrapped( - "button", - "Select a Pokemon to edit its properties, stats, moves and origin.", - x + 16, - y + 24, - w - 32, - PAL.muted - ) - return - end - local s, pad, gap = Kit.scale, 14 * Kit.scale, 10 * Kit.scale - local row = Kit.controlH() - local cx, inner = x + pad, w - 2 * pad - local g = Gen.ofState(S) - local sections = { - { "main", "Main" }, - { "stats", "Stats" }, - { "moves", "Moves" }, - { "origin", "Origin" }, - { "extras", "Extras" }, - { "checks", "Checks" }, - } - for _, entry in ipairs(sections) do - entry.errors = issues.sections[entry[1]] - entry.icon = entry.errors > 0 and "triangle-alert" or Kit.navigationIcon(entry[2]) - end - S.monSection = S.monSection or "main" - local navY = y + pad - local navX = cx - if S.inspectorBack then - if Kit.iconButton(cx, navY, row, row, "chevron-left", "Back to party") then - Motion.change(S, "mobileInspector", false, -1) +local SECTIONS = { + { "main", "Main" }, + { "stats", "Stats" }, + { "moves", "Moves" }, + { "origin", "Origin" }, + { "extras", "Extras" }, + { "checks", "Checks" }, +} +local NATURE_STATS = { "Atk", "Def", "Spe", "SpA", "SpD" } +local GENDER_NAMES = { M = "Male", F = "Female", male = "Male", female = "Female" } +local STAT_NAMES = { + hp = "HP", atk = "Attack", def = "Defense", spa = "Sp. Atk", spd = "Sp. Def", spe = "Speed", + attack = "Attack", defense = "Defense", speed = "Speed", special = "Special", +} +local STATUSES = { + { "healthy", "Healthy" }, + { "SLP", "Asleep" }, + { "PSN", "Poisoned" }, + { "BRN", "Burned" }, + { "FRZ", "Frozen" }, + { "PAR", "Paralyzed" }, +} + +local function titleCase(str) + str = tostring(str or "") + if str:find("%l") then return str end + return (str:lower():gsub("(%a)([%w']*)", function(a, b) return a:upper() .. b end)) +end + +local function typeName(S, t) + if t == nil then return nil end + if Gen.ofState(S) == 3 then + if type(t) == "number" then + local name + for k, v in pairs(require("src.core.game3.battle.types").ID) do + if v == t then name = k end + end + t = name or tostring(t) end - navX = cx + row + gap + t = tostring(t):gsub("^TYPE_", "") + if t == "MYSTERY" then return "???" end + else + local ok, name = pcall(require("src.battle.TypeChart").displayName, t, S.data) + if ok and name then t = name end end - Chooser.navigation( - S, - Kit, - "monSection", - "Pokemon section", - sections, - navX, - navY, - math.min(cx + inner - navX, 360 * s), - row - ) - local bodyY = navY + row + gap - local bodyH = math.max(0, y + h - pad - bodyY) - if S.formMon ~= mon then - S.formMon, S.monDrafts, S.inspectorScroll = mon, {}, 0 - S._formHeight = 0 - S.propertyChoice = nil - if Kit.focus and Kit.focus:match("^property%-") then + if t == "???" then return t end + return titleCase(t) +end + +local function hpColor(frac) + if frac <= 0.2 then return PAL.red end + if frac <= 0.5 then return PAL.yellow end + return PAL.green +end + +local function hint(C, id, x, y, w) + local message = C.issues.fields[id] + if not message then return 0 end + return 6 * C.s + Touch.issue(C.Kit, message, x, y + 6 * C.s, w) +end + +local function speciesTypes(C) + local S, mon = C.S, C.mon + local out = {} + if C.g == 3 then + local t = require("src.core.game3.pokemon").types(mon.species) + out[1] = typeName(S, t[1]) + if t[2] ~= t[1] then out[2] = typeName(S, t[2]) end + return out + end + local t = (C.def and C.def.types) or mon.types or {} + for i, v in ipairs(t) do + if i == 1 or v ~= t[1] then out[#out + 1] = typeName(S, v) end + end + return out +end + +local function natureOf(mon) + return mon.nature or (tonumber(mon.personality) or 0) % 25 +end + +local function natureName(id) + local ok, name = pcall(function() + local RomText = require("src.core.game3.rom_text") + local key = RomText.key("gNatureNamePointers", id) + if not RomText.has(key) then key = RomText.key("gNatureNames", id) end + return RomText.plain(key) + end) + return ok and name and titleCase(name) or tostring(id) +end + +-- src/pokemon.c:1365 +local function natureEffect(id) + id = tonumber(id) or 0 + local up, down = math.floor(id / 5) + 1, id % 5 + 1 + if up == down or not NATURE_STATS[up] then return "No stat change" end + return "+" .. NATURE_STATS[up] .. " -" .. NATURE_STATS[down] +end + +local function genderOf(C) + local mon = C.mon + if C.g == 3 then + return mon.gender or require("src.core.game3.pokemon").gender(mon.species, tonumber(mon.personality) or 0) + elseif C.g == 2 then + local ok, gender = pcall(require("src.core.gen2.Breeding").gender, C.def, mon.dvs) + return ok and gender or nil + end +end + +local function speciesName(C) + local def = C.def + return (def and def.name) or tostring(C.mon.species or C.mon.speciesId) +end + +local function savedStatus(mon) + local status = mon.status or "healthy" + if type(status) == "number" then + status = ({ [0] = "healthy", [8] = "PSN", [16] = "BRN", [32] = "FRZ", [64] = "PAR", [128] = "TOX" })[status] + or (status >= 1 and status <= 7 and "SLP") or status + end + return status +end + +local function heroActions(C) + local S, mon = C.S, C.mon + local list = {} + if C.g == 3 then + local shiny = MonEditor.isShiny(S, mon) + list[#list + 1] = { "Shiny", function() + Ops.setShiny(S, mon, not shiny) + end, { + kind = shiny and "warn" or "ghost", + icon = "sparkles", + iconInk = not shiny and PAL.yellow or nil, + invalid = C.issues.fields.shiny ~= nil, + id = "shiny", + } } + end + list[#list + 1] = { "Full heal", function() + Ops.healMon(S, mon) + end, { kind = "ghost", icon = "heart", iconInk = PAL.green } } + return list +end + +local function metaLine(C) + local mon = C.mon + local parts = { "Lv " .. tostring(mon.level or "?") } + if C.g == 3 then parts[#parts + 1] = natureName(natureOf(mon)) end + local gender = GENDER_NAMES[genderOf(C) or ""] + if gender then parts[#parts + 1] = gender end + return table.concat(parts, " · ") +end + +local function drawHero(C, x, y, w, stacked) + local S, Kit, mon, s = C.S, C.Kit, C.mon, C.s + local box = math.floor((stacked and 96 or 92) * s) + local actions = heroActions(C) + local actW, gap = 0, 10 * s + for i, a in ipairs(actions) do + actW = actW + Kit.buttonWidth(a[1], { font = "button", icon = a[3].icon }, C.row) + (i > 1 and gap or 0) + end + local inline = not stacked and w - box - actW - 36 * s >= 240 * s + local tx, ty = x + box + 18 * s, y + local textW = inline and (w - box - actW - 36 * s) or (w - box - 18 * s) + if stacked then + tx, ty, textW = x, y + box + 16 * s, w + end + Theme.fillRounded(x, y, box, box, PAL.rowBg, 0.7, Theme.radius()) + Theme.stroke(x, y, box, box, Theme.radius(), PAL.cardBorder, 0.3, 1) + MonEditor.drawSprite(S, Kit, mon.species, x + 6 * s, y + 6 * s, box - 12 * s, mon) + + local def = C.def or {} + local dex = def.dex or def.national + local dexStr = dex and ("#" .. tostring(dex)) or nil + local dexW = dexStr and Kit.textWidth("button", dexStr) + 12 * s or 0 + local name = Kit.ellipsize("title", MonEditor.displayName(S, mon), math.max(40 * s, textW - dexW)) + Kit.textBold("title", name, tx, ty, PAL.heading) + if dexStr then + Kit.text("button", dexStr, tx + Kit.textWidth("title", name) + 12 * s, + ty + Kit.textHeight("title") - Kit.textHeight("button") - 2 * s, PAL.muted) + end + ty = ty + Kit.textHeight("title") + 8 * s + + local chipH = math.floor(Kit.textHeight("small") + 10 * s) + local cx = tx + for _, t in ipairs(speciesTypes(C)) do + local cw = Kit.textWidth("small", t) + 18 * s + if cx > tx and cx + cw > tx + textW then + cx, ty = tx, ty + chipH + 6 * s + end + Theme.fillRounded(cx, ty, cw, chipH, PAL.rowBg, 0.8, 7 * s) + Theme.strokeRounded(cx, ty, cw, chipH, PAL.line, 0.45, 1, 7 * s) + Kit.textCenterBold("small", t, cx, ty + (chipH - Kit.textHeight("small")) / 2, cw, PAL.heading) + cx = cx + cw + 8 * s + end + local meta = metaLine(C) + if cx > tx and cx + Kit.textWidth("small", meta) > tx + textW then + cx, ty = tx, ty + chipH + 6 * s + end + Kit.text("small", Kit.ellipsize("small", meta, tx + textW - cx), cx, ty + (chipH - Kit.textHeight("small")) / 2, PAL.muted) + ty = ty + chipH + 12 * s + + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0 + local hp = tonumber(mon.hp) or 0 + local hpText = ("HP %d/%d"):format(hp, maxHp) + local barW = math.max(60 * s, math.min(400 * s, textW - Kit.textWidth("small", hpText) - 14 * s)) + local frac = Theme.clamp(hp / math.max(1, maxHp), 0, 1) + Kit.meter(tx, ty + (Kit.textHeight("small") - 6 * s) / 2, barW, 6 * s, frac * 100, hpColor(frac)) + Kit.text("small", hpText, tx + barW + 14 * s, ty, PAL.muted) + ty = ty + Kit.textHeight("small") + local bottom = math.max(y + box, ty) + + if inline then + local ax, ay = x + w - actW, y + math.max(0, (box - C.row) / 2 - 8 * s) + for _, a in ipairs(actions) do + local opts = {} + for k, v in pairs(a[3]) do opts[k] = v end + opts.font = "button" + local bw = Kit.buttonWidth(a[1], opts, C.row) + if Kit.button(ax, ay, bw, C.row, a[1], opts) then a[2]() end + ax = ax + bw + gap + end + else + local fy = bottom + 16 * s + bottom = fy + Form.flow(Kit, nil, actions, x, fy, w) + end + local shiny = C.issues.fields.shiny + if shiny then bottom = bottom + hint(C, "shiny", x, bottom, w) end + return bottom - y +end + +local function sectionOptions(C) + local list = {} + for _, e in ipairs(SECTIONS) do + local errors = C.issues.sections[e[1]] or 0 + list[#list + 1] = { + e[1], e[2], + errors = errors, + icon = errors > 0 and "triangle-alert" or C.Kit.navigationIcon(e[2]), + } + end + return list +end + +local function drawNav(C, x, y, w, stacked) + local S, Kit = C.S, C.Kit + local options = sectionOptions(C) + local function pick(value, index) + if stacked then + local keep = S._navKeep or 0 + S.monSection = value Kit.blur() - end - end - S.monDrafts = S.monDrafts or {} - local layoutKey = tostring(Kit._fontKey) .. ":" .. w .. ":" .. h - local resizedScroll - if S._formLayoutKey ~= layoutKey then - resizedScroll = S.inspectorScroll or 0 - local oldMax = math.max(0, (S._formHeight or 0) - (S._formViewHeight or bodyH)) - if resizedScroll > 0 and resizedScroll >= oldMax - 1 then resizedScroll = math.huge end - S._formLayoutKey = layoutKey - end - S.inspectorScroll = - Kit.scrollPixels(cx, bodyY, inner, bodyH, S.inspectorScroll or 0, S._formHeight or 0) - Kit.pushClip(cx, bodyY, inner, bodyH) - local cy = bodyY - S.inspectorScroll - local start = cy - local function text(str, color) - cy = cy + Kit.textWrapped("small", str, cx, cy, inner, color or PAL.caption) + gap - end - local function hint(id) - cy = cy + Touch.issue(Kit, issues.fields[id], cx, cy, inner) - end - if report.errors > 0 then - text(report.errors .. " saved value errors. Check the red fields.", PAL.red) - end - local function button(label, fn, kind, help, id, enabled) - local issue = id and issues.fields[id] - local opts = { kind = kind, font = "small", invalid = issue ~= nil, enabled = enabled } - local buttonH = Kit.buttonHeight(label, inner, opts) - if help then - buttonH = Touch.action(S, Kit, label, help, fn, cx, cy, inner, kind, issue) - elseif Kit.button(cx, cy, inner, buttonH, label, opts) then - fn() - end - cy = cy + buttonH + gap - if issue and not help then hint(id) end - end - local numberX, numberW - local function field(id, label, value, fn, sanitize) - if type(value) == "number" then - label = label:gsub(" %(.-%)", "") - cy = cy - + Touch.value(S, Kit, id, label, value, function() - return Limits.mon(S, mon, id) - end, numberX or cx, cy, numberW or inner, fn, issues.fields[id]) - + 2 * gap + Ops.disarm(S) + S.propertyChoice = nil + S.inspectorScroll = keep return end - text(label, issues.fields[id] and PAL.red or PAL.text) - local key = "property-" .. id - local draft = S.monDrafts[id] - if Kit.focus ~= key and draft == nil then - draft = tostring(value or "") + local old = 1 + for i, o in ipairs(options) do + if o[1] == S.monSection then old = i end end - local function apply(v) - if type(value) == "number" then - local n = tonumber(v) - if not n or n ~= math.floor(n) then - return Ops.say(S, "Enter a whole number") - end - end - if fn(v) then - S.monDrafts[id] = nil - end - end - local applyW = math.max(row, Kit.textWidth("small", "Set") + 24 * s) - S.monDrafts[id] = Kit.textfield( - key, - cx, - cy, - inner - applyW - gap, - row, - draft or tostring(value or ""), - "value", - { sanitize = sanitize, onSubmit = apply, invalid = issues.fields[id] ~= nil } - ) - if - Kit.button(cx + inner - applyW, cy, applyW, row, "Set", { kind = "accent", font = "small" }) - then - apply(S.monDrafts[id]) - Kit.blur() - end - cy = cy + row + 2 * gap - hint(id) + Motion.change(S, "monSection", value, index >= old and 1 or -1) end - local function choice(id, label, value, options, apply, help) - cy = cy + Touch.choice(S, Kit, id, label, value, options, cx, cy, inner, apply, help, issues.fields[id]) + 2 * gap + if Form.tabs(S, Kit, "monSection", options, x, y, w, C.row, pick) then return end + Chooser.navigation(S, Kit, "monSection", "Pokemon section", options, x, y, + math.min(w, 360 * C.s), C.row, stacked and function() + S.inspectorScroll = S._navKeep or 0 + end or nil) +end + +local function identityColumn(C, x, y, w) + local S, Kit, mon, g, s, row, issues = C.S, C.Kit, C.mon, C.g, C.s, C.row, C.issues + local gap, block = 10 * s, 22 * s + local cy = y + Kit.caption(x, y, "IDENTITY") + 16 * s + + cy = cy + Form.label(Kit, "Species", x, cy, w, nil, issues.fields.species and PAL.red) + local chOpts = { font = "button", icon = "pencil", id = "change-species" } + local chW = Kit.buttonWidth("Change", chOpts, row) + Form.box(Kit, x, cy, w - chW - gap, row, speciesName(C), issues.fields.species ~= nil) + if Kit.button(x + w - chW, cy, chW, row, "Change", chOpts) then + Ops.openSpeciesPicker(S, Kit) end + cy = cy + row + cy = cy + hint(C, "species", x, cy, w) + block + + if S.nicknameMon ~= mon then + S.nicknameMon, S.nicknameDraft, S._nickFocus = mon, mon.nickname or "", nil + end + local focused = Kit.focus == "mon-nickname" + if S._nickFocus == mon and not focused then + S._nickFocus = nil + if (S.nicknameDraft or "") ~= (mon.nickname or "") then + Ops.setNickname(S, mon, S.nicknameDraft) + S.nicknameDraft = mon.nickname or "" + end + end + if focused then S._nickFocus = mon end + local count = Ops.nicknameLength(S.nicknameDraft or "") .. "/" .. Ops.NICKNAME_MAX + cy = cy + Form.label(Kit, "Nickname", x, cy, w, count, issues.fields.nickname and PAL.red) + if C.contentTop then + S._fieldOffset = { id = "mon-nickname", off = cy - C.contentTop, h = row } + end + S.nicknameDraft = Kit.textfield("mon-nickname", x, cy, w, row, S.nicknameDraft or "", "None (uses species name)", { + invalid = issues.fields.nickname ~= nil, + sanitize = function(v) + return Ops.nicknameSanitize(S, v) + end, + }) + cy = cy + row + cy = cy + hint(C, "nickname", x, cy, w) + block + local function unownForm(help) local letter = Ops.unownForm(S, mon) - if letter == nil then return false end - choice("form", "Unown form", letter, Ops.unownFormOptions(S), function(v) + if letter == nil then return end + cy = cy + Form.label(Kit, "Unown form", x, cy, w, nil, issues.fields.form and PAL.red) + cy = cy + Form.select(S, Kit, "form", "Unown form", letter, Ops.unownFormOptions(S), x, cy, w, function(v) return Ops.setUnownForm(S, mon, v) - end, help) - return true + end, issues.fields.form, help) + cy = cy + hint(C, "form", x, cy, w) + block + end + + if g == 3 then + local Pokemon = require("src.core.game3.pokemon") + local nature = natureOf(mon) + local natures = {} + for id = 0, 24 do + natures[#natures + 1] = { id, natureName(id) } + end + local slot = mon.abilityNum or (tonumber(mon.personality) or 0) % 2 + local abilities = {} + for i, id in ipairs(Pokemon.abilities(mon.species)) do + if id and id ~= 0 then + abilities[#abilities + 1] = { i - 1, titleCase(Pokemon.abilityName(id)) } + end + end + local half = w >= 340 * s + local hw = half and (w - 16 * s) / 2 or w + local top = cy + cy = cy + Form.label(Kit, "Nature", x, cy, hw, nil, issues.fields.nature and PAL.red) + cy = cy + Form.select(S, Kit, "nature", "Nature", nature, natures, x, cy, hw, function(v) + return Ops.setNature(S, mon, v) + end, issues.fields.nature) + cy = cy + 8 * s + Kit.text("small", natureEffect(nature), x, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + cy = cy + hint(C, "nature", x, cy, hw) + local ax, ay = x, cy + block + if half then ax, ay = x + hw + 16 * s, top end + ay = ay + Form.label(Kit, "Ability", ax, ay, hw, nil, issues.fields.ability and PAL.red) + ay = ay + Form.select(S, Kit, "ability", "Ability", slot, abilities, ax, ay, hw, function(v) + return Ops.setAbility(S, mon, v) + end, issues.fields.ability) + ay = ay + hint(C, "ability", ax, ay, hw) + cy = math.max(cy, ay) + block + + local gender = genderOf(C) + local ratio = (Pokemon.speciesMeta(mon.species) or {}).genderRatio or 255 + local genders = ratio == 255 and { { "U", "Genderless" } } + or ratio == 0 and { { "M", "Male" } } + or ratio == 254 and { { "F", "Female" } } + or { { "M", "Male" }, { "F", "Female" } } + cy = cy + Form.label(Kit, "Gender", x, cy, w, nil, issues.fields.gender and PAL.red) + if #genders == 1 then + Form.box(Kit, x, cy, w, row, genders[1][2], issues.fields.gender ~= nil) + else + Form.segmented(Kit, "gender", genders, gender, x, cy, w, row, function(v) + return Ops.setMonGender(S, mon, v) + end, issues.fields.gender) + end + cy = cy + row + cy = cy + hint(C, "gender", x, cy, w) + block + end + + if g >= 2 then + local heldId = mon.heldItem or mon.item + local held = S.data.items and S.data.items[heldId] + local hasItem = heldId ~= nil and heldId ~= 0 + local heldName = held and held.name or (hasItem and tostring(heldId)) or "None" + cy = cy + Form.label(Kit, "Held item", x, cy, w, nil, issues.fields.heldItem and PAL.red) + local itemOpts = { font = "button", icon = "pencil", id = "change-item" } + local itemW = Kit.buttonWidth("Change", itemOpts, row) + Form.box(Kit, x, cy, w - itemW - row - 2 * gap, row, heldName, issues.fields.heldItem ~= nil, + not hasItem and PAL.muted or nil) + if Kit.button(x + w - row - gap - itemW, cy, itemW, row, "Change", itemOpts) then + Ops.openItemPicker(S, Kit, "held") + end + if Kit.iconButton(x + w - row, cy, row, row, "x", "Clear held item", { kind = "danger", enabled = hasItem }) then + Ops.setHeldItem(S, mon, nil) + end + cy = cy + row + cy = cy + hint(C, "heldItem", x, cy, w) + block + end + + if g == 3 then + unownForm("Keeps nature and shininess. Changes the personality value.") + elseif g == 2 then + local d = P.find(S, "pokerus") + cy = cy + Form.label(Kit, "Pokérus", x, cy, w, nil, issues.fields.pokerus and PAL.red) + cy = cy + Form.select(S, Kit, "pokerus", "Pokérus", mon.pokerus or 0, Named.property(S, d), x, cy, w, function(v) + return Ops.setPokerus(S, mon, v) + end, issues.fields.pokerus) + cy = cy + hint(C, "pokerus", x, cy, w) + block + local isUnown = Ops.unownForm(S, mon) ~= nil + unownForm("Rewrites the middle bits of the DVs. Only I and V can be shiny.") + cy = cy + Kit.textWrapped("small", isUnown and "Gender and shininess follow DVs. Picking a form rewrites the middle DV bits." + or "Gender and shininess follow DVs.", x, cy, w, PAL.muted) + cy = cy + hint(C, "gender", x, cy, w) + cy = cy + hint(C, "shiny", x, cy, w) + if not isUnown then cy = cy + hint(C, "form", x, cy, w) end + cy = cy + block + end + return cy - block +end + +local function conditionColumn(C, x, y, w) + local S, Kit, mon, g, s, issues = C.S, C.Kit, C.mon, C.g, C.s, C.issues + local block = 22 * s + local cy = y + Kit.caption(x, y, "LEVEL & CONDITION") + 16 * s + local function num(id, title, value, apply) + cy = cy + Form.numberRow(S, Kit, id, title, value, Limits.mon(S, mon, id), x, cy, w, apply, issues.fields[id]) + block + end + num("level", "Level", mon.level, function(n) + if not Legality.integer(n, 1, 100) then + return Ops.say(S, "Level must be a whole number from 1 to 100") + end + return Ops.setLevel(S, mon, n) + end) + num("experience", "Experience", Gen.exp(mon), function(n) + return Ops.setExperience(S, mon, n) + end) + num("current-hp", "Current HP", mon.hp or 0, function(n) + return Ops.setCurrentHp(S, mon, n) + end) + if g >= 2 then + num("friendship", "Friendship", mon.friendship or mon.happiness or 0, function(n) + if not Legality.integer(n, 0, 255) then + return Ops.say(S, "Friendship must be 0-255") + end + return Ops.setHappiness(S, mon, n) + end) + end + local statuses = {} + for i, st in ipairs(STATUSES) do statuses[i] = st end + if g >= 2 then statuses[#statuses + 1] = { "TOX", "Badly poisoned" } end + cy = cy + Form.label(Kit, "Status", x, cy, w, nil, issues.fields.status and PAL.red) + cy = cy + Form.select(S, Kit, "status", "Status", savedStatus(mon), statuses, x, cy, w, function(v) + return Ops.setMonStatus(S, mon, v ~= "healthy" and v or nil) + end, issues.fields.status) + cy = cy + hint(C, "status", x, cy, w) + return cy +end + +local function mainBody(C, x, y, w) + local s = C.s + local two = w >= 600 * s + local colGap = 32 * s + local cw = two and (w - colGap) / 2 or w + local left = identityColumn(C, x, y, cw) + local right = conditionColumn(C, two and x + cw + colGap or x, two and y or left + 30 * s, cw) + return math.max(left, right) - y +end + +local function statsBody(C, x, y, w) + local S, Kit, mon, g, s, issues = C.S, C.Kit, C.mon, C.g, C.s, C.issues + local block = 22 * s + local cy = y + local keys = g == 3 and { "hp", "atk", "def", "spa", "spd", "spe" } + or { "hp", "attack", "defense", "speed", "special" } + local stats = mon.stats or {} + local calcColor = issues.fields.calculated and PAL.red or PAL.heading + cy = cy + Kit.caption(x, cy, "CALCULATED") + 12 * s + local line = ("HP %s · Atk %s · Def %s · Spe %s"):format( + tostring(stats.hp or mon.maxHp or "?"), + tostring(stats.attack or mon.attack or "?"), + tostring(stats.defense or mon.defense or "?"), + tostring(stats.speed or mon.speed or "?")) + line = line .. " · " .. (g == 1 and ("Special " .. tostring(stats.special or "?")) + or ("SpA " .. tostring(stats.specialAttack or stats.spAtk or mon.spAtk or "?") + .. " · SpD " .. tostring(stats.specialDefense or stats.spDef or mon.spDef or "?"))) + cy = cy + Kit.textWrapped("button", line, x, cy, w, calcColor) + cy = cy + hint(C, "calculated", x, cy, w) + 8 * s + cy = cy + Kit.textWrapped("small", g == 3 and "IVs 0-31. EVs 0-255 with a total limit of 510." + or "DVs 0-15. HP DV follows the other DVs. Stat experience 0-65535.", x, cy, w, PAL.muted) + if g == 3 then + local total = Limits.evTotal(mon) + cy = cy + 6 * s + Kit.textWrapped("small", "EVs: " .. total .. " / 510 · " .. math.max(0, 510 - total) .. " free", + x, cy + 6 * s, w, total > 510 and PAL.red or PAL.green) + else + cy = cy + 6 * s + Kit.textWrapped("small", "HP DV: " .. tostring(mon.dvs and mon.dvs.hp or 0) .. " / 15 · follows the other DVs", + x, cy + 6 * s, w, issues.fields["dv-hp"] and PAL.red or PAL.muted) + cy = cy + hint(C, "dv-hp", x, cy, w) + end + cy = cy + block + local two = w >= 560 * s + local colGap = 32 * s + local cw = two and (w - colGap) / 2 or w + local function num(id, title, value, apply, nx, ny) + return Form.numberRow(S, Kit, id, title, value, Limits.mon(S, mon, id), nx, ny, cw, apply, issues.fields[id]) + end + for _, k in ipairs(keys) do + local left, right + if g == 3 then + left = { "iv-" .. k, STAT_NAMES[k] .. " IV", mon.ivs and mon.ivs[k] or 0, function(n) + return Ops.setIv(S, mon, k, n) + end } + right = { "ev-" .. k, STAT_NAMES[k] .. " EV", mon.evs and mon.evs[k] or 0, function(n) + return Ops.setEv(S, mon, k, n) + end } + else + if k ~= "hp" then + left = { "dv-" .. k, STAT_NAMES[k] .. " DV", mon.dvs and mon.dvs[k] or 0, function(n) + return Ops.setDv(S, mon, k, n) + end } + end + right = { "se-" .. k, STAT_NAMES[k] .. " stat experience", mon.statExp and mon.statExp[k] or 0, function(n) + return Ops.setStatExp(S, mon, k, n) + end } + end + if left and two then + local lh = num(left[1], left[2], left[3], left[4], x, cy) + local rh = num(right[1], right[2], right[3], right[4], x + cw + colGap, cy) + cy = cy + math.max(lh, rh) + block + else + if left then cy = cy + num(left[1], left[2], left[3], left[4], x, cy) + block end + cy = cy + num(right[1], right[2], right[3], right[4], x, cy) + block + end + end + return cy - block - y +end + +local function moveInfo(C, slot) + local S, mon = C.S, C.mon + local mv = mon.moves and mon.moves[slot] + local id = type(mv) == "table" and (mv.moveId or mv.id) or mv + local md = id and S.data.moves and S.data.moves[id] + local info = { slot = slot, id = id, md = md, empty = id == nil or id == 0 } + if not info.empty then + info.pp = tonumber(type(mv) == "table" and mv.pp or mon.pp and mon.pp[slot]) or 0 + info.ups = MonOps.getPpUps(mon, slot) + info.base = MonOps.getBasePp(S.data, mon, slot) + info.max = MonOps.calcMaxPp(info.base, info.ups, C.g) + local t = md and typeName(S, md.type) + info.sub = (t and (t .. " · ") or "") .. "base PP " .. tostring(info.base or "?") + else + info.sub = "No move in this slot" + end + info.name = md and md.name or (info.empty and "Empty" or tostring(id)) + return info +end + +local function moveCardLayout(C, info, w) + local Kit, s, row = C.Kit, C.s, C.row + local pad, gap = 16 * s, 10 * s + local iw = w - 2 * pad + local badge = math.floor(30 * s) + local label = info.empty and "Choose" or "Change" + local btnOpts = { font = "button", icon = info.empty and "plus" or "pencil", id = "move-" .. info.slot } + local btnW = Kit.buttonWidth(label, btnOpts, row) + local xW = info.empty and 0 or row + gap + local nameW = iw - badge - 12 * s - btnW - xW - gap + if nameW < 80 * s then + btnOpts.iconOnly, btnW = true, row + nameW = iw - badge - 12 * s - btnW - xW - gap + end + local subH = Form.wrapH(Kit, "small", info.sub, nameW) + local headH = math.max(row, Kit.textHeight("button") + 2 * s + subH) + local L = { pad = pad, gap = gap, iw = iw, badge = badge, label = label, btnOpts = btnOpts, btnW = btnW, + xW = xW, nameW = nameW, headH = headH } + local h = pad + headH + local issues = C.issues.fields + h = h + Form.issueH(Kit, issues["move" .. info.slot], iw) + (issues["move" .. info.slot] and 6 * s or 0) + if not info.empty then + local upsLabelW = Kit.textWidth("small", "PP Ups") + 16 * s + local maxUps = Limits.mon(C.S, C.mon, "ppup-" .. info.slot).hi + L.maxUps = maxUps + L.segW = math.max(Kit.tapMin() * (maxUps + 1), math.min(iw - upsLabelW, 56 * s * (maxUps + 1))) + L.upsBelow = iw - upsLabelW < L.segW + h = h + 16 * s + Form.labelH(C.Kit) + row + h = h + Form.issueH(Kit, issues["pp-" .. info.slot], iw) + (issues["pp-" .. info.slot] and 6 * s or 0) + h = h + 12 * s + (L.upsBelow and Form.labelH(C.Kit) + row or row) + h = h + Form.issueH(Kit, issues["ppup-" .. info.slot], iw) + (issues["ppup-" .. info.slot] and 6 * s or 0) + end + L.h = h + pad + return L +end + +local function drawMoveCard(C, info, L, x, y, w, h) + local S, Kit, mon, s, row, issues = C.S, C.Kit, C.mon, C.s, C.row, C.issues.fields + local slot = info.slot + local invalid = issues["move" .. slot] or issues["pp-" .. slot] or issues["ppup-" .. slot] + Theme.fillRounded(x, y, w, h, PAL.rowBg, 0.55, Theme.radius()) + Theme.stroke(x, y, w, h, Theme.radius(), invalid and PAL.red or PAL.cardBorder, invalid and 0.9 or 0.3, + invalid and 2 * s or 1) + local ix, cy = x + L.pad, y + L.pad + local by = cy + (math.min(L.headH, row) - L.badge) / 2 + Theme.strokeRounded(ix, by, L.badge, L.badge, PAL.line, 0.5, 1, 7 * s) + Kit.textCenter("small", tostring(slot), ix, by + (L.badge - Kit.textHeight("small")) / 2, L.badge, PAL.text) + local nx = ix + L.badge + 12 * s + local nameColor = info.empty and PAL.muted or (issues["move" .. slot] and PAL.red or PAL.heading) + local nameY = cy + math.max(0, (L.headH - Kit.textHeight("button") - 2 * s - Form.wrapH(Kit, "small", info.sub, L.nameW)) / 2) + Kit.textBold("button", Kit.ellipsize("button", info.name, L.nameW), nx, nameY, nameColor) + Kit.textWrapped("small", info.sub, nx, nameY + Kit.textHeight("button") + 2 * s, L.nameW, PAL.muted) + local bx = ix + L.iw - L.xW - L.btnW + local btnY = cy + (math.min(L.headH, row) - row) / 2 + if Kit.button(bx, btnY, L.btnW, row, L.label, L.btnOpts) then + Ops.openMovePicker(S, Kit, slot) + end + if not info.empty and Kit.iconButton(ix + L.iw - row, btnY, row, row, "x", "Clear slot " .. slot, { kind = "danger" }) then + Ops.clearMove(S, mon, slot) + end + cy = cy + L.headH + cy = cy + hint(C, "move" .. slot, ix, cy, L.iw) + if info.empty then return end + cy = cy + 16 * s + local ppIssue = issues["pp-" .. slot] + Form.label(Kit, "PP", ix, cy, L.iw, info.pp .. " / " .. info.max, ppIssue and PAL.red, ppIssue and PAL.red or PAL.text) + cy = cy + Form.labelH(Kit) + Form.slider(S, Kit, "pp-" .. slot, info.pp, Limits.mon(S, mon, "pp-" .. slot), ix, cy, L.iw, row, function(n) + return Ops.setPp(S, mon, slot, n) + end, ppIssue) + cy = cy + row + cy = cy + hint(C, "pp-" .. slot, ix, cy, L.iw) + 12 * s + local upsIssue = issues["ppup-" .. slot] + local options = {} + for i = 0, L.maxUps do options[#options + 1] = { i, tostring(i) } end + local function apply(v) + return Ops.setPpUps(S, mon, slot, v) + end + if L.upsBelow then + cy = cy + Form.label(Kit, "PP Ups", ix, cy, L.iw, nil, upsIssue and PAL.red) + Form.segmented(Kit, "ppup-" .. slot, options, info.ups, ix, cy, L.iw, row, apply, upsIssue) + else + Kit.text("small", "PP Ups", ix, cy + (row - Kit.textHeight("small")) / 2, upsIssue and PAL.red or PAL.muted) + Form.segmented(Kit, "ppup-" .. slot, options, info.ups, ix + L.iw - L.segW, cy, L.segW, row, apply, upsIssue) + end + cy = cy + row + hint(C, "ppup-" .. slot, ix, cy, L.iw) +end + +local function movesBody(C, x, y, w) + local s = C.s + local gap = 16 * s + local cols = w >= 3 * 300 * s + 2 * gap and 3 or w >= 2 * 280 * s + gap and 2 or 1 + local cw = (w - (cols - 1) * gap) / cols + local cy = y + for first = 1, 4, cols do + local infos, layouts, rowH = {}, {}, 0 + for slot = first, math.min(4, first + cols - 1) do + infos[#infos + 1] = moveInfo(C, slot) + layouts[#layouts + 1] = moveCardLayout(C, infos[#infos], cw) + rowH = math.max(rowH, layouts[#layouts].h) + end + for i, info in ipairs(infos) do + drawMoveCard(C, info, layouts[i], x + (i - 1) * (cw + gap), cy, cw, info.empty and layouts[i].h or rowH) + end + cy = cy + rowH + gap + end + return cy - gap - y +end + +local function listBody(C, x, y, w, section) + local S, Kit, mon, g, s, issues, report = C.S, C.Kit, C.mon, C.g, C.s, C.issues, C.report + local gap, block = 10 * s, 22 * s + local cy, tail = y, 0 + local function space(n) + cy, tail = cy + n, n + end + local function text(str, color) + cy = cy + Kit.textWrapped("small", str, x, cy, w, color or PAL.muted) + space(gap) + end + local function item(d, ix, iy, iw) + local issue = issues.fields[d.key] + if d.toggle then + local on = P.get(mon, d) + on = on == true or on == 1 + local shown = issue and tostring(P.get(mon, d)) or (on and "ON" or "OFF") + local label = d.label .. ": " .. shown + local opts = { face = "selection", active = on, font = "small", invalid = issue ~= nil } + local height = Kit.buttonHeight(label, iw, opts) + if Kit.button(ix, iy, iw, height, label, opts) then + Ops.setMonProperty(S, mon, d.key, not on) + end + return height + hint(C, d.key, ix, iy + height, iw), gap + elseif Named.property(S, d) then + local h = Form.label(Kit, d.label, ix, iy, iw, nil, issue and PAL.red) + h = h + Form.select(S, Kit, d.key, d.label, P.get(mon, d), Named.property(S, d), ix, iy + h, iw, function(v) + return Ops.setMonProperty(S, mon, d.key, v) + end, issue) + return h + hint(C, d.key, ix, iy + h, iw), block + end + local value = P.get(mon, d) + if type(value) == "number" then + local label = d.label:gsub(" %(.-%)", "") + return Form.numberRow(S, Kit, d.key, label, value, Limits.mon(S, mon, d.key), ix, iy, iw, function(n) + return Ops.setMonProperty(S, mon, d.key, n) + end, issue), block + end + local h = Form.label(Kit, d.label, ix, iy, iw, nil, issue and PAL.red) + Form.box(Kit, ix, iy + h, iw, C.row, tostring(value or ""), issue ~= nil) + return h + C.row + hint(C, d.key, ix, iy + h + C.row, iw), block end local function props(list) + local cols = w >= 600 * s and 2 or 1 + local colGap = 32 * s + local cw = (w - (cols - 1) * colGap) / cols + local col, rowH, rowSpace = 0, 0, 0 for _, d in ipairs(list) do - if d.toggle then - local on = P.get(mon, d) - on = on == true or on == 1 - local issue = issues.fields[d.key] - local shown = issue and tostring(P.get(mon, d)) or (on and "ON" or "OFF") - local label = d.label .. ": " .. shown - local opts = { face = "selection", active = on, font = "small", invalid = issue ~= nil } - local height = Kit.buttonHeight(label, inner, opts) - if Kit.button(cx, cy, inner, height, label, opts) then - Ops.setMonProperty(S, mon, d.key, not on) - end - cy = cy + height + gap - hint(d.key) - elseif Named.property(S, d) then - choice(d.key, d.label, P.get(mon, d), Named.property(S, d), function(v) - return Ops.setMonProperty(S, mon, d.key, v) - end) - else - field(d.key, d.label, P.get(mon, d), function(v) - return Ops.setMonProperty(S, mon, d.key, v) - end) + local h, after = item(d, x + col * (cw + colGap), cy, cw) + rowH, rowSpace = math.max(rowH, h), math.max(rowSpace, after) + col = col + 1 + if col == cols then + cy = cy + rowH + space(rowSpace) + col, rowH, rowSpace = 0, 0, 0 end end + if col > 0 then + cy = cy + rowH + space(rowSpace) + end end - local def = (S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId]) or {} - if S.monSection == "main" then - text(def.name or tostring(mon.species or mon.speciesId), PAL.heading) - button("Change species", function() - Ops.openSpeciesPicker(S, Kit) - end, "accent", nil, "species") - if S.nicknameMon ~= mon then - S.nicknameMon, S.nicknameDraft = mon, mon.nickname or "" - end - text("Nickname (up to 10 game characters)", PAL.text) - local setW = math.max(row, 64 * s) - S.nicknameDraft = Kit.textfield( - "mon-nickname", - cx, - cy, - inner - setW - gap, - row, - S.nicknameDraft or "", - "no nickname", - { - invalid = issues.fields.nickname ~= nil, - sanitize = function(v) - return Ops.nicknameSanitize(S, v) - end, - } - ) - if Kit.button(cx + inner - setW, cy, setW, row, "Set", { kind = "accent", font = "small" }) then - Ops.setNickname(S, mon, S.nicknameDraft) - Kit.blur() - end - cy = cy + row + gap - hint("nickname") - button("Clear nickname", function() - Ops.clearNickname(S, mon) - S.nicknameDraft = "" - end, "danger") - field("level", "Level (1-100)", mon.level, function(v) - local n = tonumber(v) - if not require("Legality").integer(n, 1, 100) then - return Ops.say(S, "Level must be a whole number from 1 to 100") - end - return Ops.setLevel(S, mon, n) - end) - field("experience", "Experience", Gen.exp(mon), function(v) - return Ops.setExperience(S, mon, v) - end) - field( - "current-hp", - "Current HP (max " .. tostring(mon.maxHp or (mon.stats and mon.stats.hp) or 0) .. ")", - mon.hp or 0, - function(v) - return Ops.setCurrentHp(S, mon, v) - end - ) - local statuses = { - { "healthy", "Healthy" }, - { "SLP", "Asleep" }, - { "PSN", "Poisoned" }, - { "BRN", "Burned" }, - { "FRZ", "Frozen" }, - { "PAR", "Paralyzed" }, - } - if g >= 2 then - statuses[#statuses + 1] = { "TOX", "Badly poisoned" } - end - local savedStatus = mon.status or "healthy" - if type(savedStatus) == "number" then - savedStatus = ({ [0]="healthy",[8]="PSN",[16]="BRN",[32]="FRZ",[64]="PAR",[128]="TOX" })[savedStatus] - or (savedStatus >= 1 and savedStatus <= 7 and "SLP") or savedStatus - end - choice("status", "Status", savedStatus, statuses, function(v) - return Ops.setMonStatus(S, mon, v ~= "healthy" and v or nil) - end) - if g >= 2 then - local held = S.data.items and S.data.items[mon.heldItem or mon.item] - button("Held item: " .. tostring(held and held.name or "None"), function() - Ops.openItemPicker(S, Kit, "held") - end, "accent", nil, "heldItem") - button("Clear held item", function() - Ops.setHeldItem(S, mon, nil) - end, "danger") - field("friendship", "Friendship (0-255)", mon.friendship or mon.happiness or 0, function(v) - local n = tonumber(v) - if not require("Legality").integer(n, 0, 255) then - return Ops.say(S, "Friendship must be 0-255") - end - return Ops.setHappiness(S, mon, n) - end) - end - if g == 3 then - local Pokemon = require("src.core.game3.pokemon") - local Summary = require("src.core.game3.summary_data") - local nature = mon.nature or (tonumber(mon.personality) or 0) % 25 - local natures = {} - for id = 0, 24 do - local ok, name = pcall(function() - return Summary.NATURES[id] - end) - natures[#natures + 1] = { id, ok and name or tostring(id) } - end - choice("nature", "Nature", nature, natures, function(v) - return Ops.setNature(S, mon, v) - end) - local slot = mon.abilityNum or (tonumber(mon.personality) or 0) % 2 - local abilities = {} - for i, id in ipairs(Pokemon.abilities(mon.species)) do - if id and id ~= 0 then - abilities[#abilities + 1] = { i - 1, Pokemon.abilityName(id) } - end - end - choice("ability", "Ability", slot, abilities, function(v) - return Ops.setAbility(S, mon, v) - end) - local gender = mon.gender or Pokemon.gender(mon.species, tonumber(mon.personality) or 0) - local ratio = (Pokemon.speciesMeta(mon.species) or {}).genderRatio or 255 - local genders = ratio == 255 and { { "U", "Genderless" } } - or ratio == 0 and { { "M", "Male" } } - or ratio == 254 and { { "F", "Female" } } - or { { "M", "Male" }, { "F", "Female" } } - choice("gender", "Gender", gender, genders, function(v) - return Ops.setMonGender(S, mon, v) - end) - local shiny = mon.isShiny - if shiny == nil then shiny = Pokemon.isShiny(mon) end - button("Shiny: " .. (shiny and "ON" or "OFF"), function() - Ops.setShiny(S, mon, not shiny) - end, "warn", "Changes shininess and personality. Review Checks after editing.", "shiny") - unownForm("Keeps nature and shininess. Changes the personality value.") - elseif g == 2 then - local d = P.find(S, "pokerus") - choice("pokerus", "Pokérus", mon.pokerus or 0, Named.property(S, d), function(v) - return Ops.setPokerus(S, mon, v) - end) - local isUnown = unownForm("Rewrites the middle bits of the DVs. Only I and V can be shiny.") - text(isUnown and "Gender and shininess follow DVs. Picking a form rewrites the middle DV bits." - or "Gender and shininess follow DVs.") - hint("gender") - hint("shiny") - if not isUnown then hint("form") end - end - button("Fix all errors", function() - Ops.fixMonErrors(S, mon) - end, "good", "Fixes invalid values, stats and PP. Origin warnings still need checking.") - button( - "Randomize Pokémon", - function() - Ops.randomizeMon(S, mon) - end, - "accent", - "Replaces this Pokémon with a wild one from this game. Real level range, normal moves. Undo brings yours back." - ) - button( - "Max out Pokémon", - function() - Ops.maxMon(S, mon) - end, - "good", - "Level 100, max IVs or DVs, friendship and PP. Spare EVs go to the strongest stats. Fully heals. In Gen 2 a shiny stays shiny, an Unown keeps its letter, and gender can change." - ) - button("Full heal", function() - Ops.healMon(S, mon) - end, "good") - button("Clone to a box", function() - Ops.cloneMonToBox(S, mon) - end, "accent") - elseif S.monSection == "stats" then - local keys = g == 3 and { "hp", "atk", "def", "spa", "spd", "spe" } - or { "hp", "attack", "defense", "speed", "special" } - local stats = mon.stats or {} - text( - ("Calculated: HP %s / Atk %s / Def %s / Spe %s"):format( - tostring(stats.hp or mon.maxHp or "?"), - tostring(stats.attack or mon.attack or "?"), - tostring(stats.defense or mon.defense or "?"), - tostring(stats.speed or mon.speed or "?") - ), - issues.fields.calculated and PAL.red or PAL.heading - ) - text( - g == 1 and ("Special: " .. tostring(stats.special or "?")) - or ( - "Sp. Atk: " - .. tostring(stats.specialAttack or stats.spAtk or mon.spAtk or "?") - .. " / Sp. Def: " - .. tostring(stats.specialDefense or stats.spDef or mon.spDef or "?") - ), - issues.fields.calculated and PAL.red or PAL.heading - ) - hint("calculated") - text( - g == 3 and "IVs 0-31. EVs 0-255 with a total limit of 510." - or "DVs 0-15. HP DV follows the other DVs. Stat experience 0-65535." - ) - if g == 3 then - button("Max all IVs", function() - Ops.maxIvs(S, mon) - end, "good", "Sets all six IVs to 31. Origin checks may still need review.") - button("Clear EVs", function() - Ops.clearEvs(S, mon) - end, "danger") - local total = 0 - for _, k in ipairs(keys) do - total = total + (mon.evs and mon.evs[k] or 0) - end - text( - "EVs: " .. total .. " / 510 · " .. math.max(0, 510 - total) .. " free", - total > 510 and PAL.red or PAL.green - ) - else - button("Max all DVs", function() - Ops.maxDvs(S, mon) - end, "good", "Sets DVs to 15. In Gen 2 a shiny keeps its shiny DVs (Atk 15, others 10), an Unown keeps its letter, and gender can change.") - button("Max stat training", function() - Ops.maxStatExp(S, mon) - end, "good", "Fills stat experience for every stat.") - text("HP DV: " .. tostring(mon.dvs and mon.dvs.hp or 0) .. " / 15 · follows the other DVs", issues.fields["dv-hp"] and PAL.red) - hint("dv-hp") - end - for _, k in ipairs(keys) do - local paired = Kit.desktop and inner >= 640 * s and (g == 3 or k ~= "hp") - local startY, leftEnd = cy, cy - if paired then numberX, numberW = cx, (inner - gap) / 2 end - local function nextColumn() - if paired then - leftEnd, cy = cy, startY - numberX = cx + numberW + gap - end - end - if g == 3 then - field( - "iv-" .. k, - ( - { hp = "HP", atk = "Attack", def = "Defense", spa = "Sp. Atk", spd = "Sp. Def", spe = "Speed" } - )[k] .. " IV", - mon.ivs and mon.ivs[k] or 0, - function(v) - return Ops.setIv(S, mon, k, tonumber(v) or 0) - end - ) - nextColumn() - field( - "ev-" .. k, - ( - { hp = "HP", atk = "Attack", def = "Defense", spa = "Sp. Atk", spd = "Sp. Def", spe = "Speed" } - )[k] .. " EV", - mon.evs and mon.evs[k] or 0, - function(v) - return Ops.setEv(S, mon, k, tonumber(v) or 0) - end - ) - else - if k ~= "hp" then - field("dv-" .. k, k:upper() .. " DV", mon.dvs and mon.dvs[k] or 0, function(v) - return Ops.setDv(S, mon, k, tonumber(v) or 0) - end) - nextColumn() - end - field( - "se-" .. k, - k:upper() .. " stat experience", - mon.statExp and mon.statExp[k] or 0, - function(v) - return Ops.setStatExp(S, mon, k, v) - end - ) - end - if paired then cy = math.max(cy, leftEnd) end - numberX, numberW = nil, nil - end - elseif S.monSection == "moves" then - for slot = 1, 4 do - local mv = mon.moves and mon.moves[slot] - local id = type(mv) == "table" and (mv.moveId or mv.id) or mv - local md = id and S.data.moves and S.data.moves[id] - button("Slot " .. slot .. ": " .. tostring(md and md.name or id or "empty"), function() - Ops.openMovePicker(S, Kit, slot) - end, "accent", nil, "move" .. slot) - if id and id ~= 0 then - local pp = type(mv) == "table" and mv.pp or mon.pp and mon.pp[slot] or 0 - local ups = MonOps.getPpUps(mon, slot) - local max = MonOps.calcMaxPp(MonOps.getBasePp(S.data, mon, slot), ups, g) - field("pp-" .. slot, "Current PP (max " .. max .. ")", pp, function(v) - return Ops.setPp(S, mon, slot, tonumber(v) or 0) - end) - field("ppup-" .. slot, "PP Ups (0-3)", ups, function(v) - return Ops.setPpUps(S, mon, slot, tonumber(v) or 0) - end) - button("Clear slot " .. slot, function() - Ops.clearMove(S, mon, slot) - end, "danger") - end - end - button("Reset to learnset", function() - Ops.resetMoves(S, mon) - end) - local recommend = Ops.recommendState(S, mon) - button(({ pending = "Fetching recommended moveset...", none = "No recommended moveset" })[recommend] - or "Use recommended moveset", function() - Ops.recommendMoves(S, mon) - end, "accent", nil, nil, recommend == "ready") - button("Max all PP", function() - Ops.maxAllPpUps(S, mon) - end, "good") - elseif S.monSection == "origin" then + if section == "origin" then props(P.identity(S)) if g == 2 and Gen.hasCaughtData(S.save, S.version) then - button("Caught by: " .. tostring(mon.caughtByGender or "none"), function() - Ops.setCaughtByGender(S, mon, mon.caughtByGender == "boy" and "girl" or "boy") - end) + cy = cy + Form.label(Kit, "Caught by", x, cy, w) + Form.segmented(Kit, "caught-by", { { "boy", "Boy" }, { "girl", "Girl" } }, mon.caughtByGender, x, cy, w, C.row, + function(v) + return Ops.setCaughtByGender(S, mon, v) + end) + cy = cy + C.row + space(block) end - elseif S.monSection == "extras" then + elseif section == "extras" then if g == 3 then - text("Contest conditions", issues.fields.contest and PAL.red) - hint("contest") + text("Contest conditions", issues.fields.contest and PAL.red or PAL.heading) + cy = cy + hint(C, "contest", x, cy - gap, w) props(P.contest) - text("Ribbons: setting a ribbon does not establish award or event eligibility.", issues.fields.ribbons and PAL.red) - hint("ribbons") + text("Ribbons: setting a ribbon does not establish award or event eligibility.", issues.fields.ribbons and PAL.red or PAL.heading) + cy = cy + hint(C, "ribbons", x, cy - gap, w) props(P.ribbons) else text("This generation has no contest conditions or ribbons.") end else - button("Fix all errors", function() - Ops.fixMonErrors(S, mon) - end, "good", "Fixes invalid values, stats and PP. Does not invent encounter or event history.") - text( - report.errors > 0 and (report.errors .. " property errors") - or "Values pass. Origin still needs checking.", - report.errors > 0 and PAL.red or PAL.yellow - ) + text(report.errors > 0 and (report.errors .. " property errors") or "Values pass. Origin still needs checking.", + report.errors > 0 and PAL.red or PAL.yellow) for _, check in ipairs(report.checks) do - text( - check.kind:upper() .. ": " .. check.message, - check.kind == "error" and PAL.red or check.kind == "pass" and PAL.green or PAL.yellow - ) + text(check.kind:upper() .. ": " .. check.message, + check.kind == "error" and PAL.red or check.kind == "pass" and PAL.green or PAL.yellow) end end - S._formHeight = cy - start - S._formViewHeight = bodyH - if resizedScroll then - S.inspectorScroll = Ops.clamp(resizedScroll, 0, math.max(0, S._formHeight - bodyH)) + return cy - tail - y +end + +local function sectionBody(C, x, y, w) + local section = C.S.monSection + local cy = y + if C.report.errors > 0 and section ~= "checks" then + cy = cy + C.Kit.textWrapped("small", C.report.errors .. " saved value errors. Check the red fields.", x, cy, w, PAL.red) + + 16 * C.s end - Kit.popClip() - Kit.scrollbar(cx, bodyY, inner, bodyH, S.inspectorScroll, S._formHeight, bodyH) + if section == "main" then + return cy - y + mainBody(C, x, cy, w) + elseif section == "stats" then + return cy - y + statsBody(C, x, cy, w) + elseif section == "moves" then + return cy - y + movesBody(C, x, cy, w) + end + return cy - y + listBody(C, x, cy, w, section) end -function Body.draw(S, Kit, x, y, w, h) - local report = require("Legality").mon(S, S.editingMon) - local issues = require("Legality").highlights(report, S.editingMon) - Motion.pages(S, Kit, "monSection", x, y, w, h, function(state, kit, px, py, pw, ph) - drawSection(state, kit, px, py, pw, ph, report, issues) + +local function sectionTools(C) + local S, mon, g = C.S, C.mon, C.g + local section = S.monSection + if section == "main" then + return "TOOLS", { + { "Max out", function() Ops.maxMon(S, mon) end, { icon = "chevrons-up", iconInk = PAL.green } }, + { "Fix errors", function() Ops.fixMonErrors(S, mon) end, { icon = "shield-check", iconInk = PAL.green } }, + { "Randomize", function() Ops.randomizeMon(S, mon) end, { icon = "shuffle", iconInk = PAL.blue } }, + { "Clone to a box", function() Ops.cloneMonToBox(S, mon) end, { icon = "copy" } }, + } + elseif section == "stats" then + if g == 3 then + return "STAT TOOLS", { + { "Max all IVs", function() Ops.maxIvs(S, mon) end, { icon = "chevrons-up", iconInk = PAL.green } }, + { "Clear EVs", function() Ops.clearEvs(S, mon) end, { icon = "x", iconInk = PAL.red } }, + } + end + return "STAT TOOLS", { + { "Max all DVs", function() Ops.maxDvs(S, mon) end, { icon = "chevrons-up", iconInk = PAL.green } }, + { "Max stat training", function() Ops.maxStatExp(S, mon) end, { icon = "chevrons-up", iconInk = PAL.green } }, + } + elseif section == "moves" then + local recommend = Ops.recommendState(S, mon) + return "MOVE TOOLS", { + { "Max all PP", function() Ops.maxAllPpUps(S, mon) end, { icon = "chevrons-up", iconInk = PAL.green } }, + { ({ pending = "Fetching recommended moveset...", none = "No recommended moveset" })[recommend] + or "Recommended moveset", function() Ops.recommendMoves(S, mon) end, + { icon = "sparkles", iconInk = PAL.blue, enabled = recommend == "ready" } }, + { "Reset to learnset", function() Ops.resetMoves(S, mon) end, { icon = "rotate-ccw" } }, + } + elseif section == "checks" then + return "TOOLS", { + { "Fix errors", function() Ops.fixMonErrors(S, mon) end, { icon = "shield-check", iconInk = PAL.green } }, + } + end +end + +local function resetForm(S, Kit, mon) + if S.formMon ~= mon then + S.formMon, S.monDrafts, S.inspectorScroll = mon, {}, 0 + S.propertyChoice, S.valueEdit, S._slider = nil, nil, nil + if Kit.focus and (Kit.focus:match("^property%-") or Kit.focus:match("^value%-edit")) then + Kit.blur() + end + end +end + +local function scrollRegion(S, Kit, x, y, w, h) + if S._slider and S._slider.live then Kit._dragDelta = 0 end + local key = tostring(Kit._fontKey) .. ":" .. w .. ":" .. h + if S._formLayoutKey ~= key then + local scroll = S.inspectorScroll or 0 + local oldMax = math.max(0, (S._formHeight or 0) - (S._formViewHeight or h)) + S._stickBottom = scroll > 0 and scroll >= oldMax - 1 + S._formLayoutKey = key + end + if S._stickBottom then return end + S.inspectorScroll = Kit.scrollPixels(x, y, w, h, S.inspectorScroll or 0, S._formHeight or 0) +end + +local function revealFocus(S, Kit, viewH) + local fo = S._fieldOffset + if not Kit.focus then + S._revealedFocus = nil + return + end + if S._revealedFocus == Kit.focus or not fo or fo.id ~= Kit.focus then return end + S._revealedFocus = Kit.focus + local scroll = S.inspectorScroll or 0 + if fo.off < scroll or fo.off + fo.h > scroll + viewH then + S.inspectorScroll = Ops.clamp(fo.off - 12 * Kit.scale, 0, math.max(0, (S._formHeight or 0) - viewH)) + end +end + +local function settleScroll(S, contentH, viewH) + S._formHeight, S._formViewHeight = contentH, viewH + local max = math.max(0, contentH - viewH) + S.inspectorScroll = S._stickBottom and max or Ops.clamp(S.inspectorScroll or 0, 0, max) + S._stickBottom = nil +end + +local function drawFixed(C, x, y, w, h) + local S, Kit, s, row = C.S, C.Kit, C.s, C.row + local pad = 20 * s + local cx, inner = x + pad, w - 2 * pad + local heroH = drawHero(C, cx, y + pad, inner, false) + local navY = y + pad + heroH + 10 * s + drawNav(C, cx - 4 * s, navY, inner + 4 * s, false) + Theme.col(PAL.cardBorder, 0.25) + love.graphics.rectangle("fill", x, navY + row, w, 1) + local caption, tools = sectionTools(C) + local footerH = tools and Form.flowH(Kit, caption, tools, inner) + 2 * 14 * s or 0 + local bodyY = navY + row + 1 + local bodyH = math.max(0, y + h - footerH - bodyY) + Motion.pages(S, Kit, "monSection", x, bodyY, w, bodyH, function(st, kit, px, py, pw, ph) + local top = py + 20 * s + local viewH = math.max(0, ph - 20 * s) + scrollRegion(st, kit, px + pad, top, pw - 2 * pad, viewH) + revealFocus(st, kit, viewH) + kit.pushClip(px + pad, top, pw - 2 * pad, viewH) + C.contentTop = top - (st.inspectorScroll or 0) + local contentH = sectionBody(C, px + pad, C.contentTop, pw - 2 * pad) + 20 * s + kit.popClip() + settleScroll(st, contentH, viewH) + kit.scrollbar(px + pad, top, pw - 2 * pad, viewH, st.inspectorScroll, contentH, viewH) end) + if tools then + local fy = y + h - footerH + Theme.col(PAL.cardBorder, 0.25) + love.graphics.rectangle("fill", x, fy, w, 1) + Form.flow(Kit, caption, tools, cx, fy + 14 * s, inner) + end end + +local function drawStacked(C, x, y, w, h, prelude) + local S, Kit, s, row = C.S, C.Kit, C.s, C.row + local pad = (prelude and 18 or 16) * s + local edge = prelude and 0 or 12 * s + if not prelude then Kit.card(x, y, w, h) end + local vx, vy, vw, vh = x + edge, y + edge, w - 2 * edge, h - 2 * edge + scrollRegion(S, Kit, vx, vy, vw, vh) + revealFocus(S, Kit, vh) + Kit.pushClip(vx, vy, vw, vh) + local top = vy - (S.inspectorScroll or 0) + C.contentTop = top + local cy = top + local cw = prelude and vw - 12 * s or vw + if prelude then + cy = cy + prelude(vx, cy, cw) + 16 * s + end + local cardTop = cy + if prelude then Kit.card(vx, cardTop, cw, S._stackCardH or vh) end + local cx, inner = vx + pad, cw - 2 * pad + if C.mon then + cy = cy + pad + cy = cy + drawHero(C, cx, cy, inner, inner < 560 * s) + 14 * s + S._navOffset = cy - top + S._navKeep = math.min(S.inspectorScroll or 0, S._navOffset) + drawNav(C, cx - 4 * s, cy, inner + 4 * s, true) + cy = cy + row + Theme.col(PAL.cardBorder, 0.25) + love.graphics.rectangle("fill", vx, cy, cw, 1) + cy = cy + 20 * s + cy = cy + sectionBody(C, cx, cy, inner) + 20 * s + local caption, tools = sectionTools(C) + if tools then + Theme.col(PAL.cardBorder, 0.25) + love.graphics.rectangle("fill", vx, cy, cw, 1) + cy = cy + 14 * s + cy = cy + Form.flow(Kit, caption, tools, cx, cy, inner) + 14 * s + end + else + cy = cy + pad + cy = cy + Kit.textWrapped("button", "Select a Pokemon to edit its properties, stats, moves and origin.", + cx, cy, inner, PAL.muted) + pad + end + S._stackCardH = cy - cardTop + Kit.popClip() + local contentH = cy - top + settleScroll(S, contentH, vh) + Kit.scrollbar(vx + vw - 7 * s, vy, 0, vh, S.inspectorScroll, contentH, vh) +end + +function Body.stacked(Kit, w, h) + return w < 600 * Kit.scale or h < 520 * Kit.scale +end + +function Body.draw(S, Kit, x, y, w, h, prelude) + local mon = S.editingMon + local s = Kit.scale + local C = { S = S, Kit = Kit, mon = mon, g = Gen.ofState(S), s = s, row = Kit.controlH() } + if mon then + C.report = Legality.mon(S, mon) + C.issues = Legality.highlights(C.report, mon) + C.def = (S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId]) or nil + end + resetForm(S, Kit, mon) + S.monSection = S.monSection or "main" + if prelude or Body.stacked(Kit, w, h) or not mon then + return drawStacked(C, x, y, w, h, prelude) + end + Kit.card(x, y, w, h) + drawFixed(C, x, y, w, h) +end + return Body diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 55cfc590..043d893f 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -272,7 +272,7 @@ function Kit.trackedControls() return tracked, trackedN end -local function audit(class, x, y, w, h, label) +local function audit(class, x, y, w, h, label, id) if Kit.blockClicks then return end @@ -284,6 +284,9 @@ local function audit(class, x, y, w, h, label) return end local c = Kit._clipRect + if c and (x >= c.x + c.w or y >= c.y + c.h or x + w <= c.x or y + h <= c.y) then + return + end a[#a + 1] = { class = class, x = x, @@ -291,6 +294,7 @@ local function audit(class, x, y, w, h, label) w = w, h = h, label = tostring(label or ""), + id = id, clip = c and { x = c.x, y = c.y, w = c.w, h = c.h } or nil, } end @@ -562,7 +566,7 @@ function Kit.button(x, y, w, h, label, opts) opts.face = "selection" end opts.icon = opts.icon or ACTION_ICONS[label] - audit("control", x, y, w, h, label) + audit("control", x, y, w, h, label, opts.id) local hot = opts.enabled ~= false and Kit.hover(x, y, w, h) local shown = label if opts.iconOnly then @@ -738,6 +742,9 @@ function Kit.textfield(id, x, y, w, h, value, placeholder, opts) Kit.blur() -- commit/cancel also lowers the soft keyboard (#529) focused = false elseif e == "\27" then + if opts and opts.onCancel then + opts.onCancel() + end Kit.blur() focused = false else diff --git a/tools/save-editor/README.md b/tools/save-editor/README.md index dd0a77ae..6f1407d7 100644 --- a/tools/save-editor/README.md +++ b/tools/save-editor/README.md @@ -35,8 +35,11 @@ If the file isn't there (or you want another copy), use **Open...**, drop a Party, Boxes, Items, Events, Map, Pokédex, Trainer and Checks are separate pages. The Pokemon inspector has Main, Stats, Moves, Origin, Extras and Checks sections. -Phones open the roster and inspector as separate sliding pages; wide windows -show them side by side. Buttons share the launcher's painter and font scale, +Wide windows show the roster beside the inspector; phones stack a party grid +above it on one scrolling page. The inspector opens with the Pokemon's sprite, +types, level and HP, then section tabs (a dropdown when the tabs do not fit), +the section form and a row of tools for that section. Numbers have a typed +value box and a slider that commits when released. Buttons share the launcher's painter and font scale, with a minimum 44px target. Pages and screen sections use dropdown popup choosers on both phones and desktop. The current page stays in place behind the scrim; choosing a destination closes the popup and slides to that section. diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 8271553d..ea5dad6b 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -1,5 +1,3 @@ --- Species art and the inspector shell. InspectorBody owns the six forms; --- phones open them as pages and wide windows dock them next to the roster. local Theme = require("Theme") local PAL = Theme.PAL @@ -91,10 +89,30 @@ function MonEditor.drawSprite(S, Kit, species, x, y, size, mon) y + size / 2 - Kit.textHeight("micro") / 2, size, PAL.muted) end --- The inspector uses sections so a phone gives each editing task its full height. -function MonEditor.draw(S, Kit, x, y, w, h) - Kit.card(x, y, w, h) - require("InspectorBody").draw(S, Kit, x, y, w, h) +function MonEditor.isShiny(S, mon) + if not mon then return false end + local g = Gen.ofState(S) + if g == 3 then + if mon.isShiny ~= nil then return mon.isShiny == true end + local ok, shiny = pcall(require("src.core.game3.pokemon").isShiny, mon) + return ok and shiny == true + elseif g == 2 then + return require("src.pokemon.Stats").isShiny(mon.dvs) + end + return false +end + +function MonEditor.displayName(S, mon) + if mon.nickname and mon.nickname ~= "" then return mon.nickname end + local def = S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId] + if def and def.name then return def.name end + if type(mon.species) == "string" then return mon.species end + if type(mon.name) == "string" and mon.name ~= "" then return mon.name end + return tostring(mon.species or "POKEMON") +end + +function MonEditor.draw(S, Kit, x, y, w, h, prelude) + require("InspectorBody").draw(S, Kit, x, y, w, h, prelude) end return MonEditor diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index 1c4faff0..4a0fb0ca 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -1,6 +1,3 @@ --- Party roster and inspector: side by side when wide, sliding pages on phones. --- Each viewport scrolls with fixed readable controls. - local PartyMod = require("src.pokemon.Party") local Theme = require("Theme") local Ops = require("Ops") @@ -8,15 +5,9 @@ local MonEditor = require("MonEditor") local PAL = Theme.PAL local Party = {} -local Motion = require("Motion") --- 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(math.min(300 * s, w * 0.42), math.min(460 * s, w * 0.36)) + return Theme.clamp(w * 0.27, 250 * s, 340 * s) end -- HP colour follows the game's own health bar thresholds. @@ -30,44 +21,63 @@ local function hpColor(frac) return PAL.green end +local function hpFrac(mon) + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1 + return Ops.clamp((tonumber(mon.hp) or 0) / math.max(maxHp, 1), 0, 1), maxHp +end + +local function toolbar(S, Kit, x, y, w, vertical) + local s, row, gap = Kit.scale, Kit.controlH(), 8 * Kit.scale + local selected = S.save.party[S.selectedParty or 0] ~= nil and S.editingMon == S.save.party[S.selectedParty] + local n = #S.save.party + if Kit.iconButton(x, y, row, row, vertical and "arrow-up" or "arrow-left", "Move up", + { enabled = selected and S.selectedParty > 1 }) then + Ops.partyMove(S, -1) + end + if Kit.iconButton(x + row + gap, y, row, row, vertical and "arrow-down" or "arrow-right", "Move down", + { enabled = selected and S.selectedParty < n }) then + Ops.partyMove(S, 1) + end + local armed = Ops.armLabel(S, "party-remove", "Remove") ~= "Remove" + local removeW = armed and Kit.buttonWidth("Confirm?", { font = "button", icon = "check" }, row) or row + local rx = x + w - removeW + if armed then + if Kit.button(rx, y, removeW, row, "Confirm?", { kind = "danger", font = "button", icon = "check" }) then + Ops.partyRemove(S) + end + elseif Kit.iconButton(rx, y, row, row, "trash", "Remove", { kind = "danger", enabled = selected }) then + Ops.partyRemove(S) + end + local addOpts = { font = "button", icon = "plus", enabled = n < PartyMod.MAX } + local addW = Kit.buttonWidth("Add", addOpts, row) + if Kit.button(rx - gap - addW, y, addW, row, "Add", addOpts) then + Ops.partyAdd(S) + end + return row +end + local function drawRoster(S, Kit, x, y, listW, h) local s = Kit.scale Kit.card(x, y, listW, h) - local pad = 18 * s + local pad = 16 * s local cx = x + pad local innerW = listW - 2 * pad Kit.caption(cx, y + pad, "PARTY") - Kit.textRight( - "mono", - ("%d/%d"):format(#S.save.party, PartyMod.MAX), - cx + innerW, - y + pad, - PAL.caption - ) + Kit.textRight("mono", ("%d/%d"):format(#S.save.party, PartyMod.MAX), cx + innerW, y + pad, PAL.caption) - -- "+ Add mon" / "Remove" pinned to the card bottom; the roster fills above. local actH = Kit.controlH() local actY = y + h - pad - actH S.toastBottom = math.min(S.toastBottom or actY, actY) - local listTop = y + pad + Kit.textHeight("caption") + 12 * s - local listH = actY - 12 * s - listTop + local listTop = y + pad + Kit.textHeight("caption") + 14 * s + local listH = actY - 14 * s - listTop if #S.save.party == 0 then - Kit.emptyBox( - cx, - listTop, - innerW, - listH, - "Party is empty - Add creates a Lv5 mon owned by the save's player." - ) + Kit.emptyBox(cx, listTop, innerW, listH, "Party is empty. Add creates a Lv5 Pokemon owned by the player.") else - local rowH = math.max(88 * s, Kit.tapMin() + 38 * s) + local rowH = math.max(76 * s, Kit.tapMin() + 28 * s) local rowGap = 8 * s S.selectedParty = Ops.clamp(S.selectedParty or 1, 1, #S.save.party) - -- The list used to `break` past the fold, silently hiding party slots on - -- a short window; it scrolls instead now (#715), same offset contract as - -- every other list in the editor. local drawn, shift = Kit.list(S, "partyOffset", cx, listTop, innerW, listH, #S.save.party, rowH + rowGap) Kit.pushClip(cx, listTop, innerW, listH) for i = 1, drawn do @@ -78,120 +88,89 @@ local function drawRoster(S, Kit, x, y, listW, h) end local ry = listTop + (i - 1) * (rowH + rowGap) - shift local selected = (S.editingMon == mon) - if Kit.row(cx, ry, innerW, rowH, selected, PAL.green) then + if Kit.row(cx, ry, innerW, rowH, selected, PAL.text) then Ops.selectParty(S, slot) - Motion.change(S, "mobileInspector", true, 1) end - - local rpad = 12 * s - local icon = 44 * s + local rpad = 10 * s + local icon = math.min(56 * s, rowH - 12 * s) MonEditor.drawSprite(S, Kit, mon.species, cx + rpad, ry + (rowH - icon) / 2, icon, mon) - -- right cluster first, so the name knows how much room it has left - local rightW = 52 * s - local chipH = 20 * s - local lvText = ("Lv%d"):format(mon.level) - local chipW = Kit.textWidth("tiny", lvText) + 14 * s - local chipX = cx + innerW - rpad - chipW - Theme.stroke(chipX, ry + 10 * s, chipW, chipH, 6 * s, PAL.cardBorder, 0.3, 1) - Kit.textCenter( - "tiny", - lvText, - chipX, - ry + 10 * s + (chipH - Kit.textHeight("tiny")) / 2, - chipW, - PAL.blueInk - ) - if selected then - local ab = Kit.tapMin() - local aw = Kit.tapMin() - local ax = cx + innerW - rpad - 2 * aw - 4 * s - local ay = ry + rowH - 10 * s - ab - if Kit.stepper(ax, ay, aw, ab, "up", { font = "tiny" }) then - Ops.partyMove(S, -1) - end - if Kit.stepper(ax + aw + 4 * s, ay, aw, ab, "down", { font = "tiny" }) then - Ops.partyMove(S, 1) - end - rightW = 2 * aw + 4 * s + rpad + local tx = cx + rpad + icon + 10 * s + local tw = math.max(40 * s, cx + innerW - rpad - tx) + local lvText = ("Lv %d"):format(mon.level or 0) + local lvW = Kit.textWidth("small", lvText) + local nameY = ry + 12 * s + Kit.textRight("small", lvText, tx + tw, nameY + (Kit.textHeight("monoRow") - Kit.textHeight("small")) / 2, PAL.muted) + local shiny = MonEditor.isShiny(S, mon) + local spark = shiny and Kit.textHeight("small") or 0 + local name = Kit.ellipsize("monoRow", MonEditor.displayName(S, mon), tw - lvW - spark - 16 * s) + Kit.textBold("monoRow", name, tx, nameY, PAL.heading) + if shiny then + Kit.icon("sparkles", tx + Kit.textWidth("monoRow", name) + 6 * s, + nameY + (Kit.textHeight("monoRow") - spark) / 2, spark, PAL.yellow) end - - local tx = cx + rpad + icon + 12 * s - local tw = math.max(40 * s, (cx + innerW - rightW - 10 * s) - tx) - local monName = (mon.nickname and mon.nickname ~= "" and mon.nickname) - or (type(mon.species) == "string" and mon.species) - or (type(mon.name) == "string" and mon.name ~= "" and mon.name) - or (S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId] and S.data.pokemon[mon.species or mon.speciesId].name) - or tostring(mon.species or "POKEMON") - local name = Kit.ellipsize("monoRow", monName, tw - 34 * s) - Kit.text("monoRow", name, tx, ry + 10 * s, PAL.heading) - Kit.text( - "tiny", - ("#%d"):format(slot), - tx + Kit.textWidth("monoRow", name) + 8 * s, - ry + 12 * s, - PAL.caption - ) - - local maxHp = (mon.stats and mon.stats.hp) or 1 - local frac = Ops.clamp((mon.hp or 0) / math.max(maxHp, 1), 0, 1) - Kit.meter(tx, ry + rowH / 2 + 2 * s, tw, 6 * s, frac * 100, hpColor(frac)) - Kit.text( - "tiny", - ("HP %d/%d"):format(mon.hp or 0, maxHp), - tx, - ry + rowH - 10 * s - Kit.textHeight("tiny"), - PAL.muted - ) + local frac, maxHp = hpFrac(mon) + Kit.meter(tx, ry + rowH / 2 + 1 * s, tw, 5 * s, frac * 100, hpColor(frac)) + Kit.text("tiny", ("HP %d/%d"):format(mon.hp or 0, maxHp), tx, ry + rowH - 10 * s - Kit.textHeight("tiny"), PAL.muted) end Kit.popClip() Kit.listScrollbar(S, "partyOffset", cx, listTop, innerW, listH) end - local halfW = (innerW - 10 * s) / 2 - if - Kit.button( - cx, - actY, - halfW, - actH, - "Add mon", - { font = "small", radius = 9 * s, enabled = #S.save.party < PartyMod.MAX } - ) - then - Ops.partyAdd(S) - end - if - Kit.button( - cx + halfW + 10 * s, - actY, - halfW, - actH, - Ops.armLabel(S, "party-remove", "Remove"), - { kind = "danger", font = "small", radius = 9 * s } - ) - then - Ops.partyRemove(S) + Theme.col(PAL.cardBorder, 0.22) + love.graphics.rectangle("fill", x, actY - 8 * s, listW, 1) + toolbar(S, Kit, cx, actY, innerW, true) +end + +local function drawStrip(S, Kit, x, y, w) + local s = Kit.scale + local gap = 10 * s + local cy = y + Kit.caption(x, cy, "PARTY") + Kit.textRight("mono", ("%d/%d"):format(#S.save.party, PartyMod.MAX), x + w, cy, PAL.caption) + cy = cy + Kit.textHeight("caption") + 12 * s + local n = #S.save.party + if n == 0 then + local boxH = 80 * s + Kit.emptyBox(x, cy, w, boxH, "Party is empty") + cy = cy + boxH + gap + else + local cols = Theme.clamp(math.floor((w + gap) / (92 * s + gap)), 3, 6) + local tileW = (w - (cols - 1) * gap) / cols + local sprite = math.floor(math.min(tileW - 20 * s, 64 * s)) + local tileH = math.ceil(10 * s + sprite + 4 * s + Kit.textHeight("tiny") + 8 * s + 4 * s + 10 * s) + for i, mon in ipairs(S.save.party) do + local col, line = (i - 1) % cols, math.floor((i - 1) / cols) + local tx, ty = x + col * (tileW + gap), cy + line * (tileH + gap) + if Kit.row(tx, ty, tileW, tileH, S.editingMon == mon, PAL.text) then + Ops.selectParty(S, i) + end + MonEditor.drawSprite(S, Kit, mon.species, tx + (tileW - sprite) / 2, ty + 10 * s, sprite, mon) + local ly = ty + 10 * s + sprite + 4 * s + Kit.textCenter("tiny", ("Lv %d"):format(mon.level or 0), tx, ly, tileW, PAL.muted) + if MonEditor.isShiny(S, mon) then + local spark = 14 * s + Kit.icon("sparkles", tx + tileW - spark - 8 * s, ty + 8 * s, spark, PAL.yellow) + end + local frac = hpFrac(mon) + local barW = tileW - 28 * s + Kit.meter(tx + 14 * s, ly + Kit.textHeight("tiny") + 8 * s, barW, 4 * s, frac * 100, hpColor(frac)) + end + cy = cy + math.ceil(n / cols) * (tileH + gap) end + cy = cy + toolbar(S, Kit, x, cy, w, false) + return cy - y end function Party.draw(S, Kit, x, y, w, h) local s = Kit.scale - local gap = 20 * s + S.inspectorBack = false if w < 800 * s then - if S.mobileInspector == nil then - S.mobileInspector = S.editingMon ~= nil - end - Motion.pages(S, Kit, "mobileInspector", x, y, w, h, function(state, kit, dx, dy, dw, dh) - if state.editingMon and state.mobileInspector then - state.inspectorBack = true - MonEditor.draw(state, kit, dx, dy, dw, dh) - else - drawRoster(state, kit, dx, dy, dw, dh) - end + MonEditor.draw(S, Kit, x, y, w, h, function(px, py, pw) + return drawStrip(S, Kit, px, py, pw) end) else - S.inspectorBack = false + local gap = 14 * s local listW = rosterWidth(w, s) drawRoster(S, Kit, x, y, listW, h) MonEditor.draw(S, Kit, x + listW + gap, y, w - listW - gap, h)