mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Nameable save slots in the launcher (#205)
The reporter labels runs by abusing the in-game player name; give slots a real label instead. SaveData.renameSlot persists a trimmed label in the options registry (options.saveSlots[version].names) -- never in the save file, so renaming needs no save rewrite and an empty slot can be labeled too -- listSlots rows carry it as `label`, and deleteSlot drops it with the slot. In the launcher, right-clicking a slot row opens an inline rename modal (Enter commits, Esc cancels, empty clears; 24 whole-codepoint cap via local UTF-8 helpers, since plain luajit has no utf8 library). The row title shows the label over the player name; badges/time/caught stay on the meta line. Desktop-only: touch has no secondary button. main.lua now forwards love.textinput to the importer while it is up. Backend covered by a new renameSlot block in tests/engine/save_slots.lua (78/78); docs/launcher.md's registry section documents the label.
This commit is contained in:
@@ -69,6 +69,15 @@ keeps working unchanged.
|
||||
- **Registry.** The ordered slot list and which one is active persist in
|
||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||
`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
|
||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||
|
||||
@@ -302,7 +302,7 @@ function love.mousemoved(x, y)
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
if Importer then return end
|
||||
if Importer then return Importer:textinput(text) end
|
||||
if editorMode and EditorApp.textinput then
|
||||
return EditorApp.textinput(text)
|
||||
end
|
||||
|
||||
+35
-1
@@ -458,11 +458,44 @@ function SaveData.listSlots(version)
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, version, id)
|
||||
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
|
||||
return out
|
||||
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
|
||||
-- choice to options.lua; also update the process-global cache so the very
|
||||
-- next save/load lands in the chosen slot.
|
||||
@@ -580,6 +613,7 @@ function SaveData.deleteSlot(version, slotId)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.names then reg.names[slotId] = nil end
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
|
||||
+122
-1
@@ -1005,6 +1005,31 @@ local function printB(text, x, y)
|
||||
love.graphics.print(text, x + 0.6, y)
|
||||
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
|
||||
-- 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.
|
||||
@@ -1522,6 +1547,50 @@ function RomImporter:draw()
|
||||
-- events reach the launcher, so click-vs-drag is resolved here)
|
||||
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)
|
||||
if self._hoverEnabled and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
||||
if self._anyHover then
|
||||
@@ -1538,6 +1607,20 @@ local function inside(r, x, y)
|
||||
end
|
||||
|
||||
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 inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
||||
love.system.openURL(COMMUNITY_URL)
|
||||
@@ -1642,6 +1725,16 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
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 key == "return" or key == "space" or key == "kpenter" then
|
||||
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
||||
@@ -2069,6 +2162,33 @@ function RomImporter:_selectSlot(version, id)
|
||||
self.activeSlot[version] = id
|
||||
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
|
||||
-- scroll to the bottom (clamped next draw) so the new row is on screen.
|
||||
function RomImporter:_newSlot(version)
|
||||
@@ -2242,7 +2362,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
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)),
|
||||
rx + 12 * s, ry + rowPadV)
|
||||
|
||||
|
||||
@@ -219,6 +219,50 @@ do
|
||||
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
|
||||
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
|
||||
|
||||
do
|
||||
|
||||
Reference in New Issue
Block a user