mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
af47e19e1a
The launcher spent ~9ms per frame building and drawing, and the Find Mods tab could hang the window for minutes. Both had the same root cause: a retained UI tree rebuilt every frame, and blocking curl calls made from the draw path. Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit, Theme, Layout, Loader). The kit caches Text objects and all measurement, allocates nothing in the steady state, and draws flat. Build+draw is now under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF). Move every network call off the render thread onto a love.thread pool (src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab stats, thumbnails and mod installs. Mod indexes prewarm at boot so the Find Mods tab is populated before it is opened. Paginate every list -- mods, find, save slots, settings, release notes, versions -- with the page size derived from the real viewport height, so a 500-mod index costs what a 10-mod one does. Scrolling is gone. Anything that waits now raises a non-dismissable loader; per-row background work shows an inline spinner instead. The in-app updater moves to the top right beside the settings gear and pulses when an update is waiting. Theme is black with white outlines, no gradients or glows, and solid colour-coded embossed buttons with bold labels. The game tabs keep their cartridge colours. Everything is 1.3x larger. The save editor shares the theme, and adding an item there is now a searchable pop-up like adding a Pokemon. Also: - Reset rebinds, in Settings and under Touch Controls. Rebinds are additive (Input:applyBindings layers them over the defaults), so there was no in-game way to undo one. - Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into a game for shortcuts and frontends, falling back to that game's tab when its ROM is not imported. Fixes found while porting: - Ellipsis and letterspacing truncated bytes, not codepoints, so a multi-byte mod name crashed the first frame on a Japanese index. Measurement no longer throws on malformed input either. - The new font set missed UiFont's kana fallback, rendering translated builds as tofu. - Fetch workers idle in Channel:demand() and LOVE waits for live threads at exit, so the process outlived the window; quitting mid-download also waited on curl's 300s ceiling. Shut the pool down in love.quit and bound its transfer timeouts. - In one column the save-slot card drew below the fold, over the footer, with no scrollbar left to reach it. The two FlexLove engine tests guarded a scroll manager and an auto-height propagation bug that no longer exist; replace them with a kit suite covering page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test to assert the dependency is gone rather than that its perf guards are set.
897 lines
33 KiB
Lua
897 lines
33 KiB
Lua
-- Save editor app shell. Boots the game's generated Data plus a save file
|
|
-- and draws the chrome the design spec fixes (SaveEditor.dc.html): a version
|
|
-- rail, a title bar, a tab rail and a status bar, with one panel filling the
|
|
-- space between. Panels own their tab's content; this module owns everything
|
|
-- around it.
|
|
--
|
|
-- The editor is reachable two ways and behaves the same in both:
|
|
-- * `love . --editor` standalone window, Close quits
|
|
-- * Edit on a launcher save row (main.lua, embedded = true), Close returns
|
|
-- to the launcher with the slot list refreshed
|
|
--
|
|
-- Vertical rhythm (scaled by Kit's height/768 factor, everything else flexes):
|
|
-- 0 6px tri-colour version rail, identical to the launcher's
|
|
-- 6 64px title bar identity, file chip, Save / Reload / Open / Close
|
|
-- (104px when the bar reflows to two rows, #715)
|
|
-- 70 66px tab rail 6 tab tiles + right-aligned validation pill
|
|
-- 136 flex content one panel per tab, 20px gutters
|
|
-- -38 38px status bar the last Ops message + the keyboard map
|
|
|
|
local Data = require("src.core.Data")
|
|
local TileRenderer = require("src.render.TileRenderer")
|
|
local SaveIO = require("SaveIO")
|
|
local Catalog = require("Catalog")
|
|
local State = require("State")
|
|
local Kit = require("Kit")
|
|
local Theme = require("Theme")
|
|
local Ops = require("Ops")
|
|
local PadInput = require("PadInput")
|
|
local PAL = Theme.PAL
|
|
|
|
local Party = require("Party")
|
|
local Boxes = require("Boxes")
|
|
local Items = require("Items")
|
|
local Events = require("Events")
|
|
local MapBrowser = require("MapBrowser")
|
|
local Dex = require("Dex")
|
|
-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541)
|
|
local SpeciesPicker = require("SpeciesPicker")
|
|
local ItemPicker = require("ItemPicker")
|
|
|
|
local App = {}
|
|
local S
|
|
-- one loader per process: registries collide if a second load re-registers
|
|
-- vanilla records over an already-merged Data
|
|
local mods
|
|
local mouseClicked = false
|
|
-- Click position from the press event. Kit samples the pointer in draw, so a
|
|
-- touch / mouse / pad-A click must use the event coords -- not love.mouse
|
|
-- (often stale on NX) and not the virtual cursor when a finger taps elsewhere.
|
|
local clickX, clickY
|
|
-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit
|
|
-- there like mouseClicked is: LOVE delivers events before love.draw, so a
|
|
-- notch is always spent by the frame that follows it (#595).
|
|
local wheelY = 0
|
|
|
|
-- Which game's cache Data was loaded from. main.lua checks this before
|
|
-- opening the editor on a save from the other version, because the two
|
|
-- caches cannot both be mounted in one process (see CacheFs.mountVersion).
|
|
App.dataVersion = nil
|
|
|
|
local TABS = {
|
|
{ id = "party", glyph = "PT", label = "PARTY" },
|
|
{ id = "boxes", glyph = "BX", label = "BOXES" },
|
|
{ id = "items", glyph = "IT", label = "ITEMS" },
|
|
{ id = "events", glyph = "EV", label = "EVENTS" },
|
|
{ id = "map", glyph = "MP", label = "MAP" },
|
|
{ id = "dex", glyph = "DX", label = "DEX" },
|
|
}
|
|
|
|
local PANELS = {
|
|
party = Party, boxes = Boxes, items = Items,
|
|
events = Events, map = MapBrowser, dex = Dex,
|
|
}
|
|
|
|
local function fileExists(path)
|
|
local f = io.open(path, "rb")
|
|
if f then f:close(); return true end
|
|
return false
|
|
end
|
|
|
|
-- Apply a load attempt for `path` into the current State (S must exist).
|
|
local function applyLoaded(path, statusVerb)
|
|
statusVerb = statusVerb or "Loaded"
|
|
S.path = path
|
|
local existed = fileExists(path)
|
|
local save, err = SaveIO.load(path)
|
|
if save then
|
|
S.save = save
|
|
S.status = statusVerb .. " " .. path
|
|
S.mapId = save.player.map
|
|
S.loadError = false
|
|
S.allowSave = true
|
|
elseif existed then
|
|
-- File is present but SaveIO.load couldn't decode it: treat it as a
|
|
-- real (corrupt) save, not a missing one. Editing a stub here is fine,
|
|
-- but Save must stay disabled so we never clobber the corrupt file
|
|
-- until the user fixes it and Reload succeeds.
|
|
S.save = require("src.core.SaveData").newGame()
|
|
S.status = "Corrupt save at " .. path .. " (" .. tostring(err) ..
|
|
"), Save disabled, use Reload after fixing the file"
|
|
S.mapId = S.save.player.map
|
|
S.loadError = true
|
|
S.allowSave = false
|
|
else
|
|
S.save = require("src.core.SaveData").newGame()
|
|
S.status = "No save at " .. path .. " (" .. tostring(err) ..
|
|
"), editing new game stub"
|
|
S.mapId = S.save.player.map
|
|
S.loadError = false
|
|
S.allowSave = true
|
|
end
|
|
S.dirty = false
|
|
S._quitArmed = false
|
|
S._openArmed = false
|
|
S.editingMon = nil
|
|
Ops.disarm(S)
|
|
local boxes = require("src.pokemon.Boxes").ensure(S.save)
|
|
-- Imported .sav box mons have no stat block (box_struct stops before
|
|
-- MON_STATS). The game derives them in SaveData.validate; the editor
|
|
-- only validates a copy, so hydrate here for Boxes/Party/MonEditor.
|
|
local Stats = require("src.pokemon.Stats")
|
|
local function ensureStats(mon)
|
|
Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon)
|
|
end
|
|
for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end
|
|
for _, box in ipairs(boxes) do
|
|
for _, mon in ipairs(box) do ensureStats(mon) end
|
|
end
|
|
if S.save.daycare and S.save.daycare.mon then
|
|
ensureStats(S.save.daycare.mon)
|
|
end
|
|
-- what the running game would quarantine, computed on a copy so the
|
|
-- editor never mutates the file behind the user's back
|
|
local SaveData = require("src.core.SaveData")
|
|
local probe = require("src.mods.Merge").deepCopy(S.save)
|
|
S.validation = SaveData.validate(probe, Data)
|
|
if not SaveData.emptyReport(S.validation) then
|
|
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
|
|
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
|
|
end
|
|
end
|
|
|
|
-- pathOverride lets tests point App.load at a scratch file instead of the
|
|
-- real default save path (used to exercise the corrupt-save branch below).
|
|
-- opts carries what only the launcher knows: which game the save belongs to,
|
|
-- its slot id, and where Close should go back to.
|
|
function App.load(pathOverride, opts)
|
|
opts = opts or {}
|
|
S = State.new()
|
|
S.data = Data
|
|
S.version = opts.version
|
|
S.slotId = opts.slotId
|
|
S.embedded = opts.embedded or false
|
|
S.onClose = opts.onClose
|
|
-- the same mod set the game loads, merged into Data before the catalogs
|
|
-- build, so modded species/items/moves are editable and MonOps stops
|
|
-- asserting on them
|
|
if not mods then
|
|
-- One loader per editor session. A previous session leaves Data holding
|
|
-- that session's merged registries (and possibly the other game's cache),
|
|
-- and a second builtin registration over them collides -- "statuses
|
|
-- already registered: FRZ". _pristineKeys only exists once Data has been
|
|
-- loaded at least once, so it doubles as the "needs evicting" marker.
|
|
if Data._pristineKeys then Data:unloadGenerated() end
|
|
Data:load()
|
|
local ModLoader = require("src.mods.Loader")
|
|
mods = ModLoader.new()
|
|
mods:load(Data)
|
|
App.dataVersion = opts.version
|
|
end
|
|
S.mods = mods
|
|
S.cat = Catalog.build(Data)
|
|
local modRoots = {}
|
|
for _, mod in ipairs(S.mods:status().loaded) do
|
|
modRoots[#modRoots + 1] = mod.path
|
|
end
|
|
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
|
|
nil, modRoots)
|
|
applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
|
|
end
|
|
|
|
-- Switch to another save file (Open button, drag-drop, or --save arg).
|
|
-- If there are unsaved edits, the first call arms a confirm; call again
|
|
-- (or pass force=true) to discard and open.
|
|
function App.openPath(path, force)
|
|
if not path or path == "" then return false end
|
|
if not S then return false end
|
|
if S.dirty and not force and not S._openArmed then
|
|
S._openArmed = true
|
|
S.status = "Unsaved changes, open again to discard and load " .. path
|
|
return false
|
|
end
|
|
applyLoaded(path, "Opened")
|
|
return true
|
|
end
|
|
|
|
function App.chooseAndOpen()
|
|
local path = SaveIO.choosePath()
|
|
if path then
|
|
App.openPath(path)
|
|
else
|
|
local osName = love and love.system and love.system.getOS
|
|
and love.system.getOS()
|
|
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then
|
|
S.status = "File picker unavailable, drop a save.lua onto the window"
|
|
end
|
|
end
|
|
end
|
|
|
|
function App.filedropped(file)
|
|
if not (file and S) then return end
|
|
local path = file.getFilename and file:getFilename() or nil
|
|
if not path or path == "" then
|
|
S.status = "Could not read dropped file path"
|
|
return
|
|
end
|
|
App.openPath(path)
|
|
end
|
|
|
|
-- Test hook: App.load keeps its state in a module-local so headless tests
|
|
-- can drive App.load/App.draw against a scratch path and then inspect the
|
|
-- resulting flags/status without loving a real save file.
|
|
function App.getState()
|
|
return S
|
|
end
|
|
|
|
-- Tear the editor down far enough that a later App.load rebuilds from
|
|
-- scratch. main.lua calls this after Close so the next Edit -- possibly on
|
|
-- the other game's save -- re-runs Data:load against whatever cache is
|
|
-- mounted by then, instead of reusing this session's merged registries.
|
|
function App.unload()
|
|
S = nil
|
|
mods = nil
|
|
App.dataVersion = nil
|
|
-- Kit is never evicted from package.loaded, so a Close taken while a text
|
|
-- field still owns focus would leak Kit.focus and a raised soft keyboard
|
|
-- (against a rect that is gone) into the launcher and the next session
|
|
-- (#529). A Close taken on the frame the species picker went up would
|
|
-- likewise leave its modal shield raised, and the next session would open
|
|
-- deaf to every click (#541).
|
|
Kit.blur()
|
|
Kit.blockClicks = false
|
|
PadInput.reset()
|
|
end
|
|
|
|
local function cycleTab(delta)
|
|
if not S then return end
|
|
local idx = 1
|
|
for i, t in ipairs(TABS) do
|
|
if t.id == S.tab then idx = i; break end
|
|
end
|
|
idx = ((idx - 1 + delta) % #TABS) + 1
|
|
S.tab = TABS[idx].id
|
|
Ops.say(S, "Tab: " .. TABS[idx].label)
|
|
end
|
|
|
|
-- Pad / Joy-Con actions from PadInput.gamepadpressed (A/B via GamepadMap so
|
|
-- NX physical A confirms and B closes).
|
|
local function handlePadAction(action)
|
|
if not action or not S then return end
|
|
if action == "a" then
|
|
local mx, my = PadInput.pointer()
|
|
App.mousepressed(mx, my, 1)
|
|
elseif action == "b" then
|
|
App.close()
|
|
elseif action == "tab_prev" then
|
|
cycleTab(-1)
|
|
elseif action == "tab_next" then
|
|
cycleTab(1)
|
|
end
|
|
end
|
|
|
|
function App.save()
|
|
if not S.allowSave then
|
|
return Ops.say(S, "Save disabled, corrupt save loaded; fix the file and Reload first")
|
|
end
|
|
local ok, err = SaveIO.save(S.path, S.save)
|
|
if ok then
|
|
S.dirty = false
|
|
S._quitArmed = false
|
|
Ops.disarm(S)
|
|
S.status = "Saved " .. S.path
|
|
return true
|
|
end
|
|
S.status = "Save failed: " .. tostring(err)
|
|
return false
|
|
end
|
|
|
|
function App.reload()
|
|
local save, err = SaveIO.load(S.path)
|
|
if save then
|
|
S.save = save
|
|
S.dirty = false
|
|
S.loadError = false
|
|
S.allowSave = true
|
|
S._quitArmed = false
|
|
S._openArmed = false
|
|
S.editingMon = nil
|
|
S.status = "Reloaded " .. S.path
|
|
require("src.pokemon.Boxes").ensure(S.save)
|
|
return true
|
|
end
|
|
S.status = "Reload failed: " .. tostring(err)
|
|
return false
|
|
end
|
|
|
|
-- Close: back to the launcher when hosted there, otherwise quit. Unsaved
|
|
-- edits arm a confirm exactly like Open does, so leaving can't lose work.
|
|
--
|
|
-- The teardown itself is DEFERRED to the end of the frame (App.draw calls
|
|
-- finishClose below). Close is dispatched from inside drawTitleBar, and the
|
|
-- host's onClose runs App.unload, which drops S -- doing that inline left the
|
|
-- rest of the frame drawing against a nil state.
|
|
function App.close()
|
|
if not S then return false end
|
|
if S.dirty and not S._quitArmed then
|
|
S._quitArmed = true
|
|
S.status = "Unsaved changes, Save first or click Close again to discard"
|
|
return false
|
|
end
|
|
S._closeRequested = true
|
|
return true
|
|
end
|
|
|
|
local function finishClose()
|
|
local embedded, onClose = S.embedded, S.onClose
|
|
S._closeRequested = false
|
|
if embedded and onClose then
|
|
onClose()
|
|
elseif love and love.event then
|
|
love.event.quit()
|
|
end
|
|
end
|
|
|
|
function App.update(dt)
|
|
-- Immediate-mode UI: nothing to simulate per-frame; input is sampled
|
|
-- directly in App.draw() via Kit.beginFrame. Tile animation (water,
|
|
-- flowers) still needs ticking so the Map tab isn't static.
|
|
TileRenderer.tick()
|
|
PadInput.update(dt)
|
|
local notches = PadInput.takeWheel()
|
|
if notches ~= 0 then
|
|
App.wheelmoved(0, notches)
|
|
end
|
|
|
|
-- Dev harness, the launcher's POKEPORT_LAUNCHER_SHOT for this window:
|
|
-- POKEPORT_EDITOR_SHOT=/path.png with POKEPORT_WIN=WxH resizes, lets the
|
|
-- view settle, captures one frame and quits, so a scripted run can see the
|
|
-- real editor at any window shape. POKEPORT_EDITOR_TAB picks the tab and
|
|
-- POKEPORT_EDITOR_ITEMPICK=1 opens the add-item modal.
|
|
local shot = os.getenv("POKEPORT_EDITOR_SHOT")
|
|
if shot and not App._shotDone then
|
|
if not App._shotSized then
|
|
App._shotSized = true
|
|
local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$")
|
|
if w and love.window and love.window.setMode then
|
|
pcall(love.window.setMode, tonumber(w), tonumber(h), { resizable = true })
|
|
end
|
|
local tab = os.getenv("POKEPORT_EDITOR_TAB")
|
|
if tab and tab ~= "" and S then S.tab = tab end
|
|
if os.getenv("POKEPORT_EDITOR_ITEMPICK") == "1" and S then
|
|
Ops.openItemPicker(S, Kit, "bag")
|
|
end
|
|
end
|
|
App._shotTimer = (App._shotTimer or 0) + dt
|
|
if App._shotTimer > 1.0 then
|
|
App._shotDone = true
|
|
love.graphics.captureScreenshot(function(imagedata)
|
|
local fd = imagedata:encode("png")
|
|
local f = io.open(shot, "wb")
|
|
if f then f:write(fd:getString()) f:close() end
|
|
love.event.quit()
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
|
|
function App.mousepressed(x, y, button)
|
|
if button == 1 then
|
|
mouseClicked = true
|
|
clickX, clickY = x, y
|
|
-- A finger / mouse tap yields the virtual cursor so the click lands where
|
|
-- the event said, not under the Joy-Con pointer (NX touch soft-miss).
|
|
PadInput.yieldToPointer()
|
|
end
|
|
end
|
|
|
|
function App.textinput(text)
|
|
Kit.textinput(text)
|
|
end
|
|
|
|
function App.gamepadpressed(joystick, button)
|
|
handlePadAction(PadInput.gamepadpressed(joystick, button))
|
|
end
|
|
|
|
function App.gamepadreleased(joystick, button)
|
|
PadInput.gamepadreleased(joystick, button)
|
|
end
|
|
|
|
function App.gamepadaxis(joystick, axis, value)
|
|
PadInput.gamepadaxis(joystick, axis, value)
|
|
end
|
|
|
|
function App.joystickpressed(joystick, button)
|
|
handlePadAction(PadInput.joystickpressed(joystick, button))
|
|
end
|
|
|
|
function App.joystickreleased(joystick, button)
|
|
PadInput.joystickreleased(joystick, button)
|
|
end
|
|
|
|
function App.joystickaxis(joystick, axis, value)
|
|
PadInput.joystickaxis(joystick, axis, value)
|
|
end
|
|
|
|
function App.joystickhat(joystick, hat, direction)
|
|
PadInput.joystickhat(joystick, hat, direction)
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ chrome
|
|
-- The file chip: the single source of truth for "which file am I editing".
|
|
-- The path truncates from the LEFT so the filename is always readable, and
|
|
-- an amber dot plus the word UNSAVED calls out dirty state from any tab.
|
|
local function drawFileChip(x, y, w, h)
|
|
local s = Kit.scale
|
|
Theme.row(x, y, w, h, 10 * s, 0.6)
|
|
local pad = 14 * s
|
|
local dot = 8 * s
|
|
local cx = x + pad
|
|
if S.dirty then
|
|
Theme.col(PAL.yellow, 1)
|
|
if love.graphics.circle then
|
|
love.graphics.circle("fill", cx + dot / 2, y + h / 2, dot / 2)
|
|
else
|
|
love.graphics.rectangle("fill", cx, y + h / 2 - dot / 2, dot, dot)
|
|
end
|
|
cx = cx + dot + 8 * s
|
|
end
|
|
local label = S.dirty and "UNSAVED" or "SAVED"
|
|
local labelW = Kit.textWidth("tiny", label)
|
|
Kit.textRight("tiny", label, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2,
|
|
S.dirty and PAL.yellow or PAL.caption)
|
|
local avail = (x + w - pad - labelW - 10 * s) - cx
|
|
local shown = Theme.ellipsizeLeft(Kit.fonts.mono, S.path or "(no file)", avail)
|
|
Kit.text("mono", shown, cx, y + (h - Kit.textHeight("mono")) / 2, PAL.detail)
|
|
end
|
|
|
|
-- Measure the title bar's right-aligned action cluster. Shared by App.draw
|
|
-- (which must size the bar before drawing it) and drawTitleBar, so the
|
|
-- two-row decision and the layout can never disagree (#715).
|
|
local function titleButtons()
|
|
local s = Kit.scale
|
|
local gap = 8 * s
|
|
local b = {
|
|
gap = gap,
|
|
closeW = 22 * s + Kit.textWidth("button", S._quitArmed and "Discard?" or "Close"),
|
|
openW = 22 * s + Kit.textWidth("button", "Open..."),
|
|
reloadW = 22 * s + Kit.textWidth("button", "Reload"),
|
|
}
|
|
b.saveLabel, b.saveKind, b.saveEnabled = "SAVED", "disabled", false
|
|
if not S.allowSave then
|
|
b.saveLabel = "SAVE LOCKED"
|
|
elseif S.dirty then
|
|
b.saveLabel, b.saveKind, b.saveEnabled = "SAVE", "primary", true
|
|
end
|
|
b.saveW = 30 * s + Kit.textWidth("button", b.saveLabel)
|
|
b.total = b.saveW + b.reloadW + b.openW + b.closeW + 3 * gap
|
|
return b
|
|
end
|
|
|
|
-- Whether the identity block plus the action cluster fit on one 64px row.
|
|
-- When they do not, the bar reflows to two rows (identity + file chip above,
|
|
-- buttons below) instead of shrinking or overlapping (#715).
|
|
local function titleNeedsTwoRows(w)
|
|
local s = Kit.scale
|
|
return titleButtons().total > w - 2 * (22 * s) - (34 * s) - 10 * s
|
|
end
|
|
|
|
local function drawTitleBar(x, y, w, h, twoRow)
|
|
local s = Kit.scale
|
|
local pad = 22 * s
|
|
Theme.col(PAL.cardBorder, 0.22)
|
|
love.graphics.rectangle("fill", x, y + h - 1, w, 1)
|
|
|
|
-- the identity row is the whole bar in one-row mode, the top slice in two
|
|
local rowH = twoRow and (h * 0.55) or h
|
|
local cx = x + pad
|
|
-- SE badge, the same rounded-square chip shape the launcher's tabs use
|
|
local badge = 34 * s
|
|
local by = y + (rowH - badge) / 2
|
|
Theme.gradRounded(cx, by, badge, badge, 9 * s, PAL.chipTop, PAL.chipBot, 1, 1)
|
|
Kit.textCenter("tab", "SE", cx, by + (badge - Kit.textHeight("tab")) / 2, badge,
|
|
{ 159, 180, 221 })
|
|
cx = cx + badge + 10 * s
|
|
|
|
-- The right-aligned action cluster is laid out from the right edge inward
|
|
-- BEFORE anything on the left is drawn: the buttons are the one thing in
|
|
-- this bar that must always be reachable, so on a phone the identity block,
|
|
-- the version chip and the file chip are what yield. Measuring them last
|
|
-- is why they used to paint straight through the buttons (#497).
|
|
local b = titleButtons()
|
|
local btnH = 38 * s
|
|
local btnY = twoRow and (y + rowH + (h - rowH - btnH) / 2) or (y + (h - btnH) / 2)
|
|
local rightEdge = x + w - pad
|
|
local gap = b.gap
|
|
local saveLabel, saveKind, saveEnabled = b.saveLabel, b.saveKind, b.saveEnabled
|
|
|
|
-- clamped at the left pad so a window narrower than the cluster overflows
|
|
-- to the right (clipped) instead of stacking buttons on each other
|
|
local saveX = math.max(x + pad, rightEdge - b.total)
|
|
local reloadX = saveX + b.saveW + gap
|
|
local openX = reloadX + b.reloadW + gap
|
|
local closeX = openX + b.openW + gap
|
|
-- the identity row yields to the buttons in one-row mode; in two-row mode
|
|
-- the buttons are on their own row and the identity keeps the full width
|
|
local identityLimit = twoRow and (rightEdge + 14 * s) or saveX
|
|
|
|
local wordH = Kit.textHeight("wordmark")
|
|
local brandH = Kit.textHeight("brand")
|
|
local blockY = y + (rowH - (wordH + 2 * s + brandH)) / 2
|
|
local wordW = math.max(
|
|
Theme.spacedWidth(Kit.fonts.wordmark, "SAVE EDITOR", 2 * s),
|
|
Theme.spacedWidth(Kit.fonts.brand, "GEN1RECOMP", 1 * s))
|
|
if cx + wordW + 12 * s < identityLimit then
|
|
love.graphics.setFont(Kit.fonts.wordmark)
|
|
Theme.col(PAL.heading, 1)
|
|
Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s)
|
|
love.graphics.setFont(Kit.fonts.brand)
|
|
Theme.col(PAL.caption, 1)
|
|
Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx,
|
|
blockY + wordH + 2 * s, 1 * s)
|
|
cx = cx + wordW + 12 * s
|
|
end
|
|
|
|
-- version chip: which game this save belongs to (from the launcher slot,
|
|
-- or the save's own header in a standalone run)
|
|
if S.version then
|
|
local name = S.version:upper()
|
|
local c = (S.version == "blue") and PAL.blue or PAL.red
|
|
local cw = Kit.textWidth("chip", name) + 16 * s
|
|
local ch = 22 * s
|
|
local cy = y + (rowH - ch) / 2
|
|
if cx + cw + 12 * s < identityLimit then
|
|
Theme.col(c, 0.1)
|
|
love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s)
|
|
Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1)
|
|
Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2,
|
|
cw, c)
|
|
cx = cx + cw + 12 * s
|
|
end
|
|
end
|
|
|
|
-- Save is the only green-filled control in the chrome; a corrupt load
|
|
-- renders it steel with the reason parked in the status bar rather than
|
|
-- hiding it (rule 3 of the design spec).
|
|
if Kit.button(saveX, btnY, b.saveW, btnH, saveLabel,
|
|
{ kind = saveKind, enabled = saveEnabled or not S.allowSave,
|
|
glow = S.dirty and S.allowSave and 0.6 or nil }) then
|
|
App.save()
|
|
end
|
|
if Kit.button(reloadX, btnY, b.reloadW, btnH, "Reload") then App.reload() end
|
|
if Kit.button(openX, btnY, b.openW, btnH, "Open...") then App.chooseAndOpen() end
|
|
if Kit.button(closeX, btnY, b.closeW, btnH,
|
|
S._quitArmed and "Discard?" or "Close",
|
|
{ kind = S._quitArmed and "danger" or "ghost" }) then
|
|
App.close()
|
|
end
|
|
|
|
local chipW = (identityLimit - 14 * s) - cx
|
|
if chipW > 80 * s then
|
|
drawFileChip(cx, y + (rowH - 38 * s) / 2, chipW, 38 * s)
|
|
end
|
|
end
|
|
|
|
-- Per-tab counters shown under each tile, so the rail doubles as a summary.
|
|
local function tabCount(id)
|
|
if id == "party" then
|
|
return ("%d/%d"):format(#S.save.party, require("src.pokemon.Party").MAX)
|
|
elseif id == "boxes" then
|
|
local n = 0
|
|
for _, box in ipairs(Ops.boxes(S)) do n = n + #box end
|
|
return tostring(n)
|
|
elseif id == "items" then
|
|
local Bag = require("src.inventory.Bag")
|
|
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
|
|
elseif id == "events" then
|
|
local n = 0
|
|
for _ in pairs(S.save.flags or {}) do n = n + 1 end
|
|
return tostring(n)
|
|
elseif id == "map" then
|
|
-- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label
|
|
return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale)
|
|
elseif id == "dex" then
|
|
local _, owned, total = Ops.dexCounts(S)
|
|
return ("%d/%d"):format(owned, total)
|
|
end
|
|
return ""
|
|
end
|
|
|
|
-- The validation pill mirrors SaveData.validate: green when the report is
|
|
-- empty, yellow with counts when the running game would quarantine
|
|
-- something. Returns what to draw plus the tab that owns the first problem,
|
|
-- so the rail can reserve the pill's width before laying out the tiles.
|
|
local function validationPill()
|
|
local SaveData = require("src.core.SaveData")
|
|
local report = S.validation
|
|
if not report or SaveData.emptyReport(report) then
|
|
return "Save validates clean", PAL.green, nil, true
|
|
end
|
|
local parts = {}
|
|
local target
|
|
local function add(n, singular, plural, tab)
|
|
if n <= 0 then return end
|
|
parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
|
|
target = target or tab
|
|
end
|
|
add(#report.lostMons, "mon", "mons", "party")
|
|
add(#report.lostItems, "item", "items", "items")
|
|
add(#report.remappedMaps, "map", "maps", "map")
|
|
return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false
|
|
end
|
|
|
|
-- Tab tiles degrade rather than collide: at full width each tile carries its
|
|
-- glyph, label and counter; when the validation pill would overlap, the
|
|
-- counters drop first and then the labels, leaving the 2-letter glyphs. A
|
|
-- tile is always at least its own square, so every tab stays clickable.
|
|
local function railDetail(x, pillX)
|
|
local s = Kit.scale
|
|
local tile = 40 * s
|
|
local widths = { full = 0, nocount = 0, glyph = 0 }
|
|
for _, t in ipairs(TABS) do
|
|
local labelW = Theme.spacedWidth(Kit.fonts.tab, t.label, 1.5 * s)
|
|
local countW = Kit.textWidth("tiny", tabCount(t.id))
|
|
widths.full = widths.full + tile + 9 * s + labelW + 8 * s + countW + 20 * s
|
|
widths.nocount = widths.nocount + tile + 9 * s + labelW + 20 * s
|
|
widths.glyph = widths.glyph + tile + 12 * s
|
|
end
|
|
local avail = pillX - 14 * s - (x + 22 * s)
|
|
-- the widths come back too: the caller needs the glyph-mode figure to decide
|
|
-- whether the pill still has room, and measuring the rail twice is waste
|
|
if widths.full <= avail then return "full", widths end
|
|
if widths.nocount <= avail then return "nocount", widths end
|
|
return "glyph", widths
|
|
end
|
|
|
|
local function drawTabRail(x, y, w, h)
|
|
local s = Kit.scale
|
|
local pad = 22 * s
|
|
Theme.col(PAL.cardBorder, 0.22)
|
|
love.graphics.rectangle("fill", x, y + h - 1, w, 1)
|
|
|
|
local label, pillColor, target, clean = validationPill()
|
|
local ph = 26 * s
|
|
local pw = Kit.textWidth("small", label) + 28 * s
|
|
local px = x + w - pad - pw
|
|
local detail, widths = railDetail(x, px)
|
|
-- Last stop before the tiles and the pill collide: at phone widths even the
|
|
-- 2-letter glyph tiles need the room the pill is sitting in, and the pill
|
|
-- only repeats a line the status bar already prints on load, so the pill is
|
|
-- what goes (#497). With it gone the tiles get the full bar back.
|
|
local showPill = widths.glyph <= px - 14 * s - (x + 22 * s)
|
|
if not showPill then
|
|
detail = railDetail(x, x + w - pad)
|
|
end
|
|
|
|
local tile = 40 * s
|
|
local cx = x + pad
|
|
local tileY = y + h - 12 * s - tile
|
|
for _, t in ipairs(TABS) do
|
|
local active = (S.tab == t.id)
|
|
local count = (detail == "full") and tabCount(t.id) or ""
|
|
local labelW = (detail == "glyph") and 0
|
|
or Theme.spacedWidth(Kit.fonts.tab, t.label, 1.5 * s)
|
|
local countW = Kit.textWidth("tiny", count)
|
|
local cellW = (detail == "glyph") and (tile + 12 * s)
|
|
or (tile + 9 * s + labelW + (count ~= "" and 8 * s + countW or 0) + 20 * s)
|
|
|
|
Theme.gradRounded(cx, tileY, tile, tile, 11 * s, PAL.chipTop, PAL.chipBot, 1, 1)
|
|
Kit.textCenter("tile", t.glyph, cx, tileY + (tile - Kit.textHeight("tile")) / 2,
|
|
tile, PAL.chipInk)
|
|
if not active then
|
|
-- one tile style plus a scrim, the same trick the launcher uses
|
|
Theme.col(PAL.bgBot, 0.55)
|
|
love.graphics.rectangle("fill", cx, tileY, tile, tile, 11 * s, 11 * s)
|
|
else
|
|
Theme.stroke(cx, tileY, tile, tile, 11 * s, PAL.blue, 0.75, 1.5 * s)
|
|
end
|
|
|
|
if detail ~= "glyph" then
|
|
local lx = cx + tile + 9 * s
|
|
love.graphics.setFont(Kit.fonts.tab)
|
|
Theme.col(active and PAL.heading or PAL.muted, 1)
|
|
Theme.spaced(Kit.fonts.tab, t.label, lx,
|
|
tileY + (tile - Kit.textHeight("tab")) / 2, 1.5 * s)
|
|
if count ~= "" then
|
|
Kit.text("tiny", count, lx + labelW + 8 * s,
|
|
tileY + (tile - Kit.textHeight("tiny")) / 2, PAL.faint)
|
|
end
|
|
end
|
|
|
|
if active then
|
|
Theme.col(PAL.blue, 1)
|
|
love.graphics.rectangle("fill", cx, y + h - 3 * s,
|
|
math.max(tile, cellW - 20 * s), 3 * s)
|
|
end
|
|
if Kit.press(cx - 8 * s, y, cellW, h) then
|
|
S.tab = t.id
|
|
Kit.blur()
|
|
Ops.disarm(S)
|
|
end
|
|
cx = cx + cellW
|
|
end
|
|
|
|
if showPill then
|
|
local py = y + h - 14 * s - ph
|
|
Theme.col(pillColor, clean and 0.08 or 0.1)
|
|
love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2)
|
|
Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1)
|
|
Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2,
|
|
pw, pillColor)
|
|
-- paint and hit target are suppressed together, so a hidden pill cannot
|
|
-- still eat a tap meant for the DEX tile
|
|
if target and Kit.press(px, py, pw, ph) then
|
|
S.tab = target
|
|
Ops.say(S, "Jumped to the tab holding the first quarantine warning")
|
|
end
|
|
end
|
|
end
|
|
|
|
local function drawStatusBar(x, y, w, h)
|
|
local s = Kit.scale
|
|
local pad = 22 * s
|
|
Theme.col(PAL.bgBot, 0.6)
|
|
love.graphics.rectangle("fill", x, y, w, h)
|
|
Theme.col(PAL.cardBorder, 0.22)
|
|
love.graphics.rectangle("fill", x, y, w, 1)
|
|
|
|
local ctrl = (love.system and love.system.getOS
|
|
and love.system.getOS() == "OS X") and "Cmd" or "Ctrl"
|
|
local hint = S.embedded
|
|
and (ctrl .. "+S save . " .. ctrl ..
|
|
"+R reload . Esc clear selection . Close returns to the launcher")
|
|
or (ctrl .. "+S save . " .. ctrl ..
|
|
"+R reload . Esc clear selection . arrows pan map . wheel scrolls lists")
|
|
-- The status message is the load-bearing half of this bar (every Ops verb
|
|
-- narrates through it); the keyboard map is decoration. On a phone the
|
|
-- two used to overlap because the hint was drawn unconditionally and the
|
|
-- status ellipsized against a negative budget (#715), so now the hint only
|
|
-- draws when the status still keeps a readable share of the bar.
|
|
local hintW = Kit.textWidth("tiny", hint)
|
|
local avail = w - 2 * pad - hintW - 14 * s
|
|
if avail >= 120 * s then
|
|
Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint)
|
|
else
|
|
avail = w - 2 * pad
|
|
end
|
|
Kit.text("mono", Kit.ellipsize("mono", S.status or "", avail), x + pad,
|
|
y + (h - Kit.textHeight("mono")) / 2, PAL.detail)
|
|
end
|
|
|
|
function App.draw()
|
|
-- Closing the editor unloads it, and the host may still deliver one more
|
|
-- frame or a queued event before it re-routes; every entry point below
|
|
-- tolerates that rather than indexing a torn-down state.
|
|
if not S then return end
|
|
local width, height = love.graphics.getDimensions()
|
|
Kit.layout(width, height)
|
|
local s = Kit.scale
|
|
|
|
local mx, my = love.mouse.getPosition()
|
|
local padX, padY, padOn = PadInput.pointer()
|
|
if mouseClicked and clickX ~= nil then
|
|
mx, my = clickX, clickY
|
|
elseif padOn then
|
|
mx, my = padX, padY
|
|
end
|
|
Kit.beginFrame(mx, my, mouseClicked, wheelY)
|
|
mouseClicked = false
|
|
clickX, clickY = nil, nil
|
|
wheelY = 0
|
|
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
|
|
-- last: the chrome and the panel underneath would take the same tap. The
|
|
-- shield goes up before anything dispatches and comes down only for the
|
|
-- picker's own layer at the bottom of this function (#541).
|
|
Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil)
|
|
|
|
Theme.field(width, height)
|
|
|
|
local railH = 6 * s
|
|
-- The title bar reflows to two rows (identity above, buttons below) when
|
|
-- the window is too narrow for both on one, instead of the buttons and the
|
|
-- identity painting through each other (#715). The taller bar simply
|
|
-- costs the content column height, which scrolls.
|
|
local titleTwoRow = titleNeedsTwoRows(width)
|
|
local titleH = (titleTwoRow and 104 or 64) * s
|
|
local tabH = 66 * s
|
|
local statusH = 38 * s
|
|
|
|
Theme.versionRail(0, 0, width, railH)
|
|
drawTitleBar(0, railH, width, titleH, titleTwoRow)
|
|
drawTabRail(0, railH + titleH, width, tabH)
|
|
|
|
local contentY = railH + titleH + tabH
|
|
local contentH = height - contentY - statusH
|
|
local panel = PANELS[S.tab]
|
|
if panel then
|
|
panel.draw(S, Kit, 22 * s, contentY + 20 * s,
|
|
width - 44 * s, contentH - 38 * s)
|
|
end
|
|
|
|
drawStatusBar(0, height - statusH, width, statusH)
|
|
Kit.blockClicks = false
|
|
SpeciesPicker.draw(S, Kit, width, height)
|
|
ItemPicker.draw(S, Kit, width, height)
|
|
Kit.endFrame()
|
|
PadInput.draw()
|
|
|
|
-- Only now, with the whole frame painted, is it safe to drop the editor.
|
|
if S._closeRequested then finishClose() end
|
|
end
|
|
|
|
function App.keypressed(key)
|
|
if not S then return end
|
|
-- The picker takes Enter and Escape before the focused field does: Kit maps
|
|
-- both to the same "\r" edit (a blur), which cannot tell "commit the top
|
|
-- match" apart from "give up" (#541).
|
|
if S.itemPicker then
|
|
if key == "return" or key == "kpenter" then
|
|
ItemPicker.commitFirst(S, Kit)
|
|
return
|
|
elseif key == "escape" then
|
|
Ops.closeItemPicker(S, Kit)
|
|
return
|
|
end
|
|
end
|
|
if S.speciesPicker then
|
|
if key == "return" or key == "kpenter" then
|
|
SpeciesPicker.commitFirst(S, Kit)
|
|
return
|
|
elseif key == "escape" then
|
|
Ops.closeSpeciesPicker(S, Kit)
|
|
return
|
|
end
|
|
end
|
|
-- A focused text field eats the keys it cares about (typing "s" into the
|
|
-- map filter must not trigger Save).
|
|
if Kit.keypressed(key) then return end
|
|
if Kit.focus then
|
|
if key == "escape" then Kit.blur() end
|
|
return
|
|
end
|
|
-- Save and Reload both touch the file on disk (Reload discards unsaved
|
|
-- edits), so they need a modifier. A bare letter is one stray keystroke
|
|
-- away from a write, and the editor has no undo.
|
|
local mod = love.keyboard and love.keyboard.isDown
|
|
and (love.keyboard.isDown("lgui", "rgui")
|
|
or love.keyboard.isDown("lctrl", "rctrl"))
|
|
if key == "escape" then
|
|
S.editingMon = nil
|
|
Ops.disarm(S)
|
|
Ops.say(S, "Selection cleared")
|
|
elseif key == "s" and mod then
|
|
App.save()
|
|
elseif key == "r" and mod then
|
|
App.reload()
|
|
end
|
|
if S.tab == "map" and MapBrowser.keypressed then
|
|
MapBrowser.keypressed(S, key)
|
|
end
|
|
end
|
|
|
|
function App.wheelmoved(x, y)
|
|
if not S then return end
|
|
-- The map tab spends the wheel on zoom; every other tab routes it through
|
|
-- Kit so whichever list the pointer is over takes it next draw (#595).
|
|
if S.tab == "map" and MapBrowser.wheelmoved then
|
|
MapBrowser.wheelmoved(S, y)
|
|
return
|
|
end
|
|
wheelY = wheelY + (y or 0)
|
|
end
|
|
|
|
function App.quit()
|
|
if not S then return false end
|
|
if S.dirty then
|
|
-- simple: block quit once and set status; user saves or force-quits again
|
|
if not S._quitArmed then
|
|
S._quitArmed = true
|
|
S.status = "Unsaved changes, save or press quit again"
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
return App
|