mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
ca4d3d283c
Crystal boots from a user-supplied ROM, imports a full cache and is playable: copyright, the Crystal intro movie, the animated title, gender select, Oak, and out into Johto. 122 of the cart's 169 script specials are implemented. Import and data - tools/make_crystal_manifest.py derives the manifest by importing make_gold_manifest as a library, with three additive keyword seams. Gold and Silver still regenerate byte-identical, which is the standing requirement for touching that generator. - crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta: Crystal renames the credits mons, splits the trainer card, Pokegear and pack-pal blocks by gender, and replaces the intro and title outright. - Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the badge block sits one higher) and unownCharmap (the main charmap parser stops at the first newcharmap so the two cannot contaminate each other). Extractor - RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK 0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole, seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim steps with per-row degrade, and the column-major trainer card portraits. - New: animated front sprites (frames, bitmasks, play and idle scripts), the Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal intro and title via src/import/CrystalMovie.lua. Engine - GameVersion gains engine(id) and fixes(id). Gold and Silver keep their original bugs where the bug is not hardware dependent; Crystal gets the fixes Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the Reflect and Light Screen defence overflow. - Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower including the wInBattleTowerBattle badge-boost guard. - Kris and the gender flag, animated fronts in battle and the summary screen, and mon caught data. Verification - Every extracted asset is pixel-compared against pret's own source PNGs. - Gold caches are byte-identical before and after, file for file. - New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
923 lines
33 KiB
Lua
923 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 Gen = require("Gen")
|
|
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.loadError = false
|
|
S.allowSave = true
|
|
elseif existed then
|
|
S.save = Gen.newGame(S.version)
|
|
S.status = "Corrupt save at " .. path .. " (" .. tostring(err) ..
|
|
"), Save disabled, use Reload after fixing the file"
|
|
S.loadError = true
|
|
S.allowSave = false
|
|
else
|
|
S.save = Gen.newGame(S.version)
|
|
S.status = "No save at " .. path .. " (" .. tostring(err) ..
|
|
"), editing new game stub"
|
|
S.loadError = false
|
|
S.allowSave = true
|
|
end
|
|
if Gen.of(S.save, S.version) == 2 then
|
|
S.events = Catalog.gen2EventList(Gen.engineOf(S.save, S.version), S.modRoots)
|
|
end
|
|
local mapId = Gen.playerMap(S.save)
|
|
S.mapId = mapId
|
|
S.dirty = false
|
|
S._quitArmed = false
|
|
S._openArmed = false
|
|
S.editingMon = nil
|
|
Ops.disarm(S)
|
|
Gen.ensureBoxes(S.save)
|
|
Gen.hydrateSave(Data, S.save)
|
|
local probe = require("src.mods.Merge").deepCopy(S.save)
|
|
S.validation = Gen.validate(probe, Data)
|
|
if not Gen.emptyReport(S.save, S.validation) then
|
|
if Gen.of(S.save, S.version) == 2 then
|
|
S.status = S.status .. string.format(
|
|
", game would quarantine: %d script bytes, %d mail, %d events",
|
|
#(S.validation.lostScriptMem or {}),
|
|
#(S.validation.lostMail or {}),
|
|
#(S.validation.lostEvents or {}))
|
|
else
|
|
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
|
|
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
|
|
if opts.version then
|
|
require("src.core.GameVersion").set(opts.version)
|
|
end
|
|
-- 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 or App.dataVersion ~= opts.version 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()
|
|
if Gen.of(nil, opts.version) == 2
|
|
or require("src.core.GameVersion").generation() == 2 then
|
|
Gen.bindGoldData(Data)
|
|
end
|
|
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.modRoots = modRoots
|
|
if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
|
|
S.events = Catalog.gen2EventList(Gen.engineOf(nil, opts.version), modRoots)
|
|
else
|
|
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
|
|
nil, modRoots)
|
|
end
|
|
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, S.data), Bag.capacity(S.data))
|
|
elseif id == "events" then
|
|
return tostring(Gen.flagCount(S.save))
|
|
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 report = S.validation
|
|
if not report or Gen.emptyReport(S.save, report) then
|
|
return "Save validates clean", PAL.green, nil, true
|
|
end
|
|
local parts = {}
|
|
local target
|
|
local function add(n, singular, plural, tab)
|
|
n = n or 0
|
|
if n <= 0 then return end
|
|
parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
|
|
target = target or tab
|
|
end
|
|
if Gen.of(S.save, S.version) == 2 then
|
|
add(#(report.lostScriptMem or {}), "script byte", "script bytes", "events")
|
|
add(#(report.lostMail or {}), "mail", "mail", "party")
|
|
add(#(report.lostEvents or {}), "event", "events", "events")
|
|
add(#(report.lostMapScenes or {}), "map scene", "map scenes", "map")
|
|
else
|
|
add(#(report.lostMons or {}), "mon", "mons", "party")
|
|
add(#(report.lostItems or {}), "item", "items", "items")
|
|
add(#(report.remappedMaps or {}), "map", "maps", "map")
|
|
end
|
|
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
|