mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
Merge pull request #335 from johnjohto/feat-save-slot-names
Nameable save slots in the launcher (#205)
This commit is contained in:
@@ -69,6 +69,15 @@ keeps working unchanged.
|
|||||||
- **Registry.** The ordered slot list and which one is active persist in
|
- **Registry.** The ordered slot list and which one is active persist in
|
||||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||||
|
Custom slot labels (#205) live alongside them in the same registry:
|
||||||
|
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
|
||||||
|
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
|
||||||
|
each `listSlots` row as `label` (the launcher row shows `label`, falling
|
||||||
|
back to the player name). `deleteSlot` drops the label with the slot.
|
||||||
|
Renaming never touches the save file, so an empty slot can be labeled.
|
||||||
|
On desktop, right-clicking a slot row opens the inline rename modal
|
||||||
|
(Enter commits, Esc cancels); touch has no secondary button, so the
|
||||||
|
affordance is desktop-only.
|
||||||
- **Active slot resolution.** `saveNames(version)`, the function every
|
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ function love.mousemoved(x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
if Importer then return end
|
if Importer then return Importer:textinput(text) end
|
||||||
if editorMode and EditorApp.textinput then
|
if editorMode and EditorApp.textinput then
|
||||||
return EditorApp.textinput(text)
|
return EditorApp.textinput(text)
|
||||||
end
|
end
|
||||||
|
|||||||
+35
-1
@@ -490,11 +490,44 @@ function SaveData.listSlots(version)
|
|||||||
for _, id in ipairs(list) do
|
for _, id in ipairs(list) do
|
||||||
local save = decodeSlot(fs, version, id)
|
local save = decodeSlot(fs, version, id)
|
||||||
local name, meta = SaveData.slotSummary(save)
|
local name, meta = SaveData.slotSummary(save)
|
||||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
|
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||||
|
label = reg.names and reg.names[id] or nil }
|
||||||
end
|
end
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||||
|
-- you can see that in the launcher"). The label lives in the options
|
||||||
|
-- registry next to list/active, never in the save file itself, so renaming
|
||||||
|
-- needs no save rewrite and an empty slot can be labeled too. The label is
|
||||||
|
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
|
||||||
|
-- false + an error string when the id is not registered.
|
||||||
|
function SaveData.renameSlot(version, slotId, name)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return false, "unknown version" end
|
||||||
|
if type(slotId) ~= "string" or slotId == "" then
|
||||||
|
return false, "missing slot id"
|
||||||
|
end
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
|
local reg = opts.saveSlots[version]
|
||||||
|
if not reg or not reg.list then return false, "slot not registered" end
|
||||||
|
local found = false
|
||||||
|
for _, id in ipairs(reg.list) do
|
||||||
|
if id == slotId then found = true break end
|
||||||
|
end
|
||||||
|
if not found then return false, "slot not registered" end
|
||||||
|
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
|
||||||
|
if label == "" then label = nil end
|
||||||
|
reg.names = reg.names or {}
|
||||||
|
reg.names[slotId] = label
|
||||||
|
if next(reg.names) == nil then reg.names = nil end
|
||||||
|
opts.saveSlots[version] = reg
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- Point the active slot at slotId (registering it if new) and persist the
|
-- Point the active slot at slotId (registering it if new) and persist the
|
||||||
-- choice to options.lua; also update the process-global cache so the very
|
-- choice to options.lua; also update the process-global cache so the very
|
||||||
-- next save/load lands in the chosen slot.
|
-- next save/load lands in the chosen slot.
|
||||||
@@ -612,6 +645,7 @@ function SaveData.deleteSlot(version, slotId)
|
|||||||
remove(fs, tmp)
|
remove(fs, tmp)
|
||||||
|
|
||||||
table.remove(reg.list, idx)
|
table.remove(reg.list, idx)
|
||||||
|
if reg.names then reg.names[slotId] = nil end
|
||||||
if reg.active == slotId then
|
if reg.active == slotId then
|
||||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||||
end
|
end
|
||||||
|
|||||||
+122
-1
@@ -1196,6 +1196,31 @@ local function printB(text, x, y)
|
|||||||
love.graphics.print(text, x + 0.6, y)
|
love.graphics.print(text, x + 0.6, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- UTF-8 helpers for the slot-rename field (#205). The `utf8` library only
|
||||||
|
-- exists inside LOVE (plain luajit, which loads this module in tests, has
|
||||||
|
-- none), so codepoint walking is done by hand -- the same lead-byte width
|
||||||
|
-- classes GenSave's encodeName uses. utf8Back drops the last codepoint;
|
||||||
|
-- utf8Cap truncates to maxChars whole codepoints.
|
||||||
|
local function utf8Back(t)
|
||||||
|
local i = #t
|
||||||
|
while i > 0 do
|
||||||
|
local b = t:byte(i)
|
||||||
|
i = i - 1
|
||||||
|
if b < 0x80 or b >= 0xC0 then break end -- lead or ASCII: dropped, done
|
||||||
|
end
|
||||||
|
return t:sub(1, i)
|
||||||
|
end
|
||||||
|
local function utf8Cap(t, maxChars)
|
||||||
|
local count, i = 0, 1
|
||||||
|
while i <= #t do
|
||||||
|
count = count + 1
|
||||||
|
if count > maxChars then return t:sub(1, i - 1) end
|
||||||
|
local b = t:byte(i)
|
||||||
|
i = i + ((b < 0x80) and 1 or (b < 0xE0) and 2 or (b < 0xF0) and 3 or 4)
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
-- One reusable unit quad, recoloured per call, for every vertical gradient
|
-- One reusable unit quad, recoloured per call, for every vertical gradient
|
||||||
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
|
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
|
||||||
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
|
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
|
||||||
@@ -1719,6 +1744,50 @@ function RomImporter:draw()
|
|||||||
-- events reach the launcher, so click-vs-drag is resolved here)
|
-- events reach the launcher, so click-vs-drag is resolved here)
|
||||||
self:_updateSlotDrag()
|
self:_updateSlotDrag()
|
||||||
|
|
||||||
|
-- save-slot rename modal (#205), drawn over everything
|
||||||
|
if self._rename then
|
||||||
|
col(PAL.bgBot, 0.72)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||||
|
local dw = math.min(appW - 32 * s, 420 * s)
|
||||||
|
local dh = 128 * s
|
||||||
|
local dx = appX + (appW - dw) / 2
|
||||||
|
local dy = (height - dh) / 2
|
||||||
|
local rr = 12 * s
|
||||||
|
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
|
||||||
|
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
|
||||||
|
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||||
|
col(PAL.green, 0.5)
|
||||||
|
love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr)
|
||||||
|
|
||||||
|
love.graphics.setFont(self.slotNameFont)
|
||||||
|
col(PAL.white)
|
||||||
|
love.graphics.print(Strings("Name save slot"), dx + 16 * s, dy + 14 * s)
|
||||||
|
|
||||||
|
-- the field: bordered strip, current text, blinking caret on the pulse
|
||||||
|
local fx, fy = dx + 16 * s, dy + 44 * s
|
||||||
|
local fw, fh = dw - 32 * s, 30 * s
|
||||||
|
col(PAL.bgBot, 0.9)
|
||||||
|
love.graphics.rectangle("fill", fx, fy, fw, fh, 8 * s, 8 * s)
|
||||||
|
love.graphics.setLineWidth(math.max(1, s))
|
||||||
|
col(PAL.cardBorder, 0.45)
|
||||||
|
love.graphics.rectangle("line", fx, fy, fw, fh, 8 * s, 8 * s)
|
||||||
|
love.graphics.setFont(self.detailFont)
|
||||||
|
col(PAL.heading)
|
||||||
|
local shown = ellipsize(self.detailFont, self._rename.text, fw - 20 * s)
|
||||||
|
love.graphics.print(shown, fx + 10 * s, fy + (fh - self.detailFont:getHeight()) / 2)
|
||||||
|
if (self.pulse * 2 % 1) < 0.5 then
|
||||||
|
local cx = fx + 10 * s + self.detailFont:getWidth(shown) + 2 * s
|
||||||
|
col(PAL.green)
|
||||||
|
love.graphics.rectangle("fill", cx, fy + 6 * s, math.max(1, 1.5 * s),
|
||||||
|
fh - 12 * s)
|
||||||
|
end
|
||||||
|
|
||||||
|
love.graphics.setFont(self.hintFont)
|
||||||
|
col(PAL.detail)
|
||||||
|
printfB(Strings("Enter to save - Esc to cancel - empty clears"),
|
||||||
|
dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left")
|
||||||
|
end
|
||||||
|
|
||||||
-- pointer cursor over any interactive element (desktop only)
|
-- pointer cursor over any interactive element (desktop only)
|
||||||
if self._hoverEnabled and not self._padCursorActive
|
if self._hoverEnabled and not self._padCursorActive
|
||||||
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
||||||
@@ -1764,6 +1833,20 @@ local function inside(r, x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:mousepressed(x, y, button)
|
function RomImporter:mousepressed(x, y, button)
|
||||||
|
if self._rename then return end -- the rename modal swallows all clicks
|
||||||
|
-- right-click a save-slot row to rename it (#205); desktop only (touch
|
||||||
|
-- has no secondary button)
|
||||||
|
if button == 2 then
|
||||||
|
if not self.android and self.workState ~= "working" then
|
||||||
|
for _, r in ipairs(self.slotRects or {}) do
|
||||||
|
if inside(r, x, y) then
|
||||||
|
self:_beginRename(self.panelVersion, r.id)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
if button ~= 1 then return end
|
if button ~= 1 then return end
|
||||||
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
||||||
love.system.openURL(COMMUNITY_URL)
|
love.system.openURL(COMMUNITY_URL)
|
||||||
@@ -1875,6 +1958,16 @@ function RomImporter:mousepressed(x, y, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:keypressed(key)
|
function RomImporter:keypressed(key)
|
||||||
|
if self._rename then
|
||||||
|
if key == "backspace" then
|
||||||
|
self._rename.text = utf8Back(self._rename.text)
|
||||||
|
elseif key == "return" or key == "kpenter" then
|
||||||
|
self:_commitRename()
|
||||||
|
elseif key == "escape" then
|
||||||
|
self._rename = nil
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
if key == "return" or key == "space" or key == "kpenter" then
|
if key == "return" or key == "space" or key == "kpenter" then
|
||||||
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
||||||
@@ -2309,6 +2402,33 @@ function RomImporter:_selectSlot(version, id)
|
|||||||
self.activeSlot[version] = id
|
self.activeSlot[version] = id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Inline slot rename (#205): right-click arms a modal text field; Enter
|
||||||
|
-- commits through SaveData.renameSlot (empty clears the label), Esc cancels.
|
||||||
|
-- While it is up, keypressed/textinput/mousepressed all route here first.
|
||||||
|
local MAX_SLOT_LABEL = 24
|
||||||
|
|
||||||
|
function RomImporter:_beginRename(version, id)
|
||||||
|
local label
|
||||||
|
for _, slot in ipairs(self.slots[version] or {}) do
|
||||||
|
if slot.id == id then label = slot.label break end
|
||||||
|
end
|
||||||
|
self._rename = { version = version, id = id, text = label or "" }
|
||||||
|
self._slotPress = nil -- cancel any armed click/drag on the list
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_commitRename()
|
||||||
|
local r = self._rename
|
||||||
|
if not r then return end
|
||||||
|
self._rename = nil
|
||||||
|
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
|
||||||
|
self:_refreshSlots(r.version)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:textinput(text)
|
||||||
|
if not self._rename then return end
|
||||||
|
self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL)
|
||||||
|
end
|
||||||
|
|
||||||
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
|
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
|
||||||
-- scroll to the bottom (clamped next draw) so the new row is on screen.
|
-- scroll to the bottom (clamped next draw) so the new row is on screen.
|
||||||
function RomImporter:_newSlot(version)
|
function RomImporter:_newSlot(version)
|
||||||
@@ -2502,7 +2622,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
|
|
||||||
love.graphics.setFont(self.slotNameFont)
|
love.graphics.setFont(self.slotNameFont)
|
||||||
col(PAL.white)
|
col(PAL.white)
|
||||||
local name = slot.name or Strings("NEW GAME")
|
-- a custom label (#205) wins over the player name; both ellipsize
|
||||||
|
local name = slot.label or slot.name or Strings("NEW GAME")
|
||||||
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
|
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
|
||||||
rx + 12 * s, ry + rowPadV)
|
rx + 12 * s, ry + rowPadV)
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,50 @@ do
|
|||||||
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
|
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------- renameSlot (#205)
|
||||||
|
|
||||||
|
do
|
||||||
|
local files = fresh()
|
||||||
|
local a = SaveData.createSlot("red")
|
||||||
|
local b = SaveData.createSlot("red")
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.player.name = "ASH"
|
||||||
|
T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save")
|
||||||
|
|
||||||
|
T.check(SaveData.renameSlot("red", a, "Nuzlocke"),
|
||||||
|
"renameSlot labels a registered slot")
|
||||||
|
local opts = SaveSerializer.decode(files["options.lua"])
|
||||||
|
T.eq(opts.saveSlots.red.names[a], "Nuzlocke",
|
||||||
|
"the label persists in the options registry")
|
||||||
|
|
||||||
|
local slots = SaveData.listSlots("red")
|
||||||
|
T.eq(slots[1].label, "Nuzlocke", "listSlots carries the custom label")
|
||||||
|
T.eq(slots[1].name, "ASH", "the player name still comes through separately")
|
||||||
|
T.eq(slots[2].label, nil, "an unlabeled slot has no label")
|
||||||
|
|
||||||
|
T.check(SaveData.renameSlot("red", b, " "), "whitespace-only clears")
|
||||||
|
T.check(SaveData.renameSlot("red", a, ""),
|
||||||
|
"an empty name clears the label")
|
||||||
|
opts = SaveSerializer.decode(files["options.lua"])
|
||||||
|
T.eq(opts.saveSlots.red.names and opts.saveSlots.red.names[a], nil,
|
||||||
|
"cleared labels leave the registry")
|
||||||
|
T.eq(SaveData.listSlots("red")[1].label, nil, "the row is unlabeled again")
|
||||||
|
|
||||||
|
-- trimming + delete cleanup
|
||||||
|
T.check(SaveData.renameSlot("red", a, " Victory run "),
|
||||||
|
"renameSlot trims the label")
|
||||||
|
T.eq(SaveData.listSlots("red")[1].label, "Victory run",
|
||||||
|
"the stored label is trimmed")
|
||||||
|
T.check(SaveData.deleteSlot("red", a), "delete the labeled slot")
|
||||||
|
opts = SaveSerializer.decode(files["options.lua"])
|
||||||
|
T.eq(opts.saveSlots.red.names[a], nil, "deleteSlot drops the label too")
|
||||||
|
|
||||||
|
local bad, badErr = SaveData.renameSlot("red", "slot99", "x")
|
||||||
|
T.check(not bad, "renaming an unknown slot fails")
|
||||||
|
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
|
||||||
|
"unknown-slot rename error is user-presentable")
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------- a version with no slots
|
-- ---------------------------------------------- a version with no slots
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|||||||
Reference in New Issue
Block a user