mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
9cc72f951b
The mon inspector now has a NICKNAME section: a text field with Set / Clear, commit-on-Enter and discard-on-Escape. Input is gated at the field instead of refused at commit. Only glyphs the game font can actually draw AND that round-trip through a .sav are accepted, capped at the naming screen's 10 glyphs, so a bad keystroke or paste never appears and Set always succeeds. Chars like "@" (the Gen1 string terminator), "#" and the dakuten kana have codec entries but no font tile so they are blocked rather than silently mangled.
915 lines
33 KiB
Lua
915 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
|
|
-- The inspector's nickname field is a commit-on-Enter field, unlike the
|
|
-- search fields, which are live view state. Enter commits the draft through
|
|
-- Ops and blurs; Escape discards it and blurs. Both must run before
|
|
-- Kit.keypressed, which maps return/escape to the same "\r" edit and cannot
|
|
-- tell "commit" from "cancel".
|
|
if Kit.focus == "mon-nickname" then
|
|
if key == "return" or key == "kpenter" then
|
|
if S.editingMon and Ops.setNickname(S, S.editingMon, S.nicknameDraft) then
|
|
S.nicknameDraft = S.editingMon.nickname or ""
|
|
end
|
|
Kit.blur()
|
|
return
|
|
elseif key == "escape" then
|
|
Kit.blur()
|
|
if S.editingMon then S.nicknameDraft = S.editingMon.nickname or "" end
|
|
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
|