-- Save editor app shell: boots the game's generated Data + a save file, -- and draws a tabbed immediate-mode UI over it via Kit. Panels own their -- own tab content; this module owns the chrome (save/reload/status/tabs) -- and the modal MonEditor overlay. 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 Party = require("Party") local Boxes = require("Boxes") local Items = require("Items") local Events = require("Events") local MapBrowser = require("MapBrowser") local Dex = require("Dex") local MonEditor = require("MonEditor") 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 local TABS = { { id = "party", label = "Party" }, { id = "boxes", label = "Boxes" }, { id = "items", label = "Items" }, { id = "events", label = "Events" }, { id = "map", label = "Map" }, { id = "dex", label = "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 require("src.pokemon.Boxes").ensure(S.save) -- 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). function App.load(pathOverride) S = State.new() S.data = Data -- 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 Data:load() local ModLoader = require("src.mods.Loader") mods = ModLoader.new() mods:load(Data) 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 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 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() end function App.mousepressed(x, y, button) if button == 1 then mouseClicked = true end end function App.draw() local mx, my = love.mouse.getPosition() Kit.beginFrame(mx, my, mouseClicked) mouseClicked = false local wasDirty = S.dirty love.graphics.clear(0.08, 0.08, 0.1) Kit.label(12, 10, "Save Editor") local saveLabel = S.dirty and "Save*" or "Save" if S.loadError then saveLabel = saveLabel .. " (disabled)" end if Kit.button(110, 6, 80, 28, saveLabel) then if not S.allowSave then S.status = "Save disabled, corrupt save loaded; fix the file and Reload first" else local ok, err = SaveIO.save(S.path, S.save) if ok then S.dirty = false S._quitArmed = false S.status = "Saved " .. S.path else S.status = "Save failed: " .. tostring(err) end end end if Kit.button(200, 6, 80, 28, "Reload") then 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.status = "Reloaded " .. S.path require("src.pokemon.Boxes").ensure(S.save) else S.status = "Reload failed: " .. tostring(err) end end if Kit.button(290, 6, 80, 28, "Open...") then App.chooseAndOpen() end Kit.label(380, 12, S.status) local newTab = Kit.tabs(12, 44, TABS, S.tab) if newTab then S.tab = newTab end local panelY = 80 if S.tab == "party" then Party.draw(S, Kit, 12, panelY) elseif S.tab == "boxes" then Boxes.draw(S, Kit, 12, panelY) elseif S.tab == "items" then Items.draw(S, Kit, 12, panelY) elseif S.tab == "events" then Events.draw(S, Kit, 12, panelY) elseif S.tab == "map" then MapBrowser.draw(S, Kit, 12, panelY) elseif S.tab == "dex" then Dex.draw(S, Kit, 12, panelY) end if S.editingMon then MonEditor.draw(S, Kit, 640, panelY) end -- Any panel above may have just set S.dirty = true; re-arm the quit -- confirmation so a fresh round of edits needs its own "quit again". if not wasDirty and S.dirty then S._quitArmed = false end end function App.keypressed(key) if key == "escape" then S.editingMon = nil end if S.tab == "map" and MapBrowser.keypressed then MapBrowser.keypressed(S, key) end end function App.wheelmoved(x, y) if S.tab == "map" and MapBrowser.wheelmoved then MapBrowser.wheelmoved(S, y) end end function App.quit() 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