From 527957de526e7f386943166716dfd66b640b91ce Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 7 Oct 2026 19:07:05 -0400 Subject: [PATCH] editor clean up --- assets/launcher/lucide/editor.png | Bin 26008 -> 29990 bytes scripts/test.sh | 2 + src/ui/kit/Button.lua | 6 +- src/ui/kit/Icons.lua | 3 + tests/engine/save_editor_toast_s6.lua | 2 +- tests/run_save_editor_tests.lua | 3 + tests/save_editor_gen2_tests.lua | 13 +- tests/save_editor_inspector_layout_test.lua | 137 ++ tests/save_editor_mobile_properties_tests.lua | 27 +- tools/generate_save_editor_icons.py | 3 +- tools/save-editor/App.lua | 296 ++-- tools/save-editor/Form.lua | 282 ++++ tools/save-editor/InspectorBody.lua | 1428 +++++++++++------ tools/save-editor/Kit.lua | 11 +- tools/save-editor/README.md | 7 +- tools/save-editor/panels/MonEditor.lua | 30 +- tools/save-editor/panels/Party.lua | 235 ++- 17 files changed, 1673 insertions(+), 812 deletions(-) create mode 100644 tests/save_editor_inspector_layout_test.lua create mode 100644 tools/save-editor/Form.lua diff --git a/assets/launcher/lucide/editor.png b/assets/launcher/lucide/editor.png index b3ebf680595e4389406f10eddab9218b3e3f93a3..5df652e986866f5f1e6470020fd2702c58ad01f3 100644 GIT binary patch literal 29990 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 xC=p9NA|ROi^jK7#&>= -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)