mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Add nickname editing to the save editor's mon inspector
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.
This commit is contained in:
@@ -842,6 +842,24 @@ function App.keypressed(key)
|
||||
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
|
||||
|
||||
@@ -426,7 +426,12 @@ end
|
||||
-- state because Kit had no input widget; this replaces that hack, and App
|
||||
-- routes love.textinput / love.keypressed in through Kit.textinput /
|
||||
-- Kit.keypressed. Returns the (possibly edited) value; the caller stores it.
|
||||
function Kit.textfield(id, x, y, w, h, value, placeholder)
|
||||
--
|
||||
-- `opts.sanitize(value)` (optional) is a post-filter run on the merged text
|
||||
-- right after this frame's edits and BEFORE it draws, so a keystroke or paste
|
||||
-- the filter rejects never even flashes on screen. It gets the whole value
|
||||
-- because a paste arrives as one textinput chunk alongside existing text.
|
||||
function Kit.textfield(id, x, y, w, h, value, placeholder, opts)
|
||||
audit("control", x, y, w, h, id)
|
||||
value = tostring(value or "")
|
||||
if Kit.press(x, y, w, h) then Kit.focus = id end
|
||||
@@ -444,6 +449,9 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
|
||||
value = value .. e
|
||||
end
|
||||
end
|
||||
if opts and opts.sanitize then
|
||||
value = opts.sanitize(value)
|
||||
end
|
||||
end
|
||||
if G then
|
||||
local r = 8 * Kit.scale
|
||||
|
||||
@@ -16,12 +16,17 @@ local PartyMod = require("src.pokemon.Party")
|
||||
local BoxesMod = require("src.pokemon.Boxes")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local MonOps = require("MonOps")
|
||||
local Charmap = require("src.save_convert.data.charmap")
|
||||
|
||||
local Ops = {}
|
||||
|
||||
Ops.MONEY_MAX = 999999
|
||||
Ops.STACK_MAX = 99
|
||||
Ops.ARM_SECONDS = 2.5
|
||||
-- The in-game naming screen caps a nickname at 10 glyphs
|
||||
-- (BattleState:askNicknameUI / src/ui/NamingScreen.lua maxLen = 10); the
|
||||
-- editor mirrors that cap instead of inventing its own.
|
||||
Ops.NICKNAME_MAX = 10
|
||||
|
||||
local function clamp(n, lo, hi)
|
||||
if n < lo then return lo end
|
||||
@@ -400,6 +405,154 @@ function Ops.healMon(S, mon)
|
||||
return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp))
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- nicknames
|
||||
-- Gen1 has no "is nicknamed" bit: an un-nicknamed mon is mon.nickname == nil,
|
||||
-- and every display site reads `mon.nickname or def.name`
|
||||
-- (src/save_convert/GenSave.lua). The editor edits that field directly.
|
||||
|
||||
-- The byte length of the UTF-8 glyph starting at lead byte `b`. Self-contained
|
||||
-- so this (and eachGlyph) also runs headless under luajit, which has no `utf8`
|
||||
-- standard library.
|
||||
local function glyphByteLen(b)
|
||||
if b < 0x80 then return 1 end
|
||||
if b < 0xE0 then return 2 end
|
||||
if b < 0xF0 then return 3 end
|
||||
return 4
|
||||
end
|
||||
|
||||
-- Walk `name` one UTF-8 glyph at a time; fn(glyph) returning false stops the
|
||||
-- walk early and eachGlyph returns false. Returns true when every glyph was
|
||||
-- visited. The single place that walks a name, so the count / validate /
|
||||
-- sanitize paths cannot drift apart (a glyph is "é" or "♂", not one of its
|
||||
-- bytes, exactly as the naming screen counts its grid cells).
|
||||
local function eachGlyph(name, fn)
|
||||
local i, n = 1, #name
|
||||
while i <= n do
|
||||
local b = name:byte(i)
|
||||
local ch = name:sub(i, i + glyphByteLen(b) - 1)
|
||||
if fn(ch) == false then return false end
|
||||
i = i + #ch
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Glyph count, not byte count: "é" or "♂" is ONE game character, exactly as
|
||||
-- the naming screen counts its grid cells and GenSave.encodeName counts a
|
||||
-- charmap sequence.
|
||||
function Ops.nicknameLength(name)
|
||||
local n = 0
|
||||
eachGlyph(tostring(name or ""), function() n = n + 1 end)
|
||||
return n
|
||||
end
|
||||
|
||||
-- The set of glyphs a nickname may hold: present in BOTH the Gen1 text codec
|
||||
-- charmap (so the name round-trips through a .sav) and the game's font
|
||||
-- charmap (so it actually draws). The codec alone is not enough: "@" is the
|
||||
-- string-terminator byte, and "#" plus the dakuten kana have codec entries
|
||||
-- but no font tile, so Font.encode (src/render/Font.lua) draws them as a
|
||||
-- space -- an invisible nickname. Only single-codepoint entries qualify:
|
||||
-- multi-character macros ("<PK>", the 'd ligature) cannot be typed one
|
||||
-- character at a time, so they have no place in the input gate.
|
||||
-- Built once per loaded font table (a mod replacing the font rebuilds it);
|
||||
-- falls back to the codec-only set when no font data is loaded (headless
|
||||
-- suites that never call Data:load).
|
||||
local glyphCache, glyphCacheFont
|
||||
local function nameGlyphSet(S)
|
||||
local font = S and S.data and S.data.font
|
||||
if not (font and font.charmap) then return Charmap.byToken end
|
||||
if glyphCache and glyphCacheFont == font then return glyphCache end
|
||||
local set = {}
|
||||
for _, e in ipairs(font.charmap) do
|
||||
local s = e.seq
|
||||
if type(s) == "string" and s ~= "" and Charmap.byToken[s]
|
||||
and #s == glyphByteLen(s:byte(1)) then
|
||||
set[s] = true
|
||||
end
|
||||
end
|
||||
glyphCache, glyphCacheFont = set, font
|
||||
return set
|
||||
end
|
||||
|
||||
-- True when every glyph is a legal nickname glyph (see nameGlyphSet): the
|
||||
-- name can be stored in a .sav AND draws in the game. Anything else either
|
||||
-- encodes as "?" (GenSave.encodeName) or renders as a space (Font.encode),
|
||||
-- which the user did not ask for, so it is refused rather than mangled.
|
||||
function Ops.nicknameUsable(S, name)
|
||||
local set = nameGlyphSet(S)
|
||||
return eachGlyph(tostring(name or ""), function(ch)
|
||||
return set[ch] ~= nil
|
||||
end)
|
||||
end
|
||||
|
||||
-- The species' display name, what an un-nicknamed mon reads as.
|
||||
local function speciesName(S, species)
|
||||
local def = species and S.data.pokemon[species]
|
||||
return (def and def.name) or tostring(species or "")
|
||||
end
|
||||
|
||||
-- The input gate for the inspector's nickname field. Given the whole draft
|
||||
-- (existing text plus this frame's keystrokes and any paste), return the
|
||||
-- version the game can actually hold: every glyph kept draws in the game
|
||||
-- (see nameGlyphSet) and the result never exceeds the naming screen's
|
||||
-- 10-glyph cap. Unrenderable glyphs are skipped, not used to abort the rest
|
||||
-- of the string, so a paste of "PIKA€CHU" lands as "PIKACHU". The field runs
|
||||
-- this through Kit.textfield's opts.sanitize, so a blocked character never
|
||||
-- appears at all.
|
||||
function Ops.nicknameSanitize(S, name)
|
||||
local set = nameGlyphSet(S)
|
||||
local out, count = {}, 0
|
||||
eachGlyph(tostring(name or ""), function(ch)
|
||||
if count < Ops.NICKNAME_MAX and set[ch] then
|
||||
out[#out + 1] = ch
|
||||
count = count + 1
|
||||
end
|
||||
end)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- One verb for both writing and clearing. An empty field means "no nickname",
|
||||
-- exactly like an empty confirm on the in-game naming screen (which falls
|
||||
-- through to the species' standard name). A name that equals the species'
|
||||
-- standard name is the un-nicknamed state in this save format
|
||||
-- (importedNickname in GenSave.lua maps exactly that to nil), so it is
|
||||
-- normalized to nil rather than stored as a literal copy of the default.
|
||||
function Ops.setNickname(S, mon, name)
|
||||
if not mon then return Ops.say(S, "Pick a slot first") end
|
||||
name = tostring(name or "")
|
||||
if name == "" then
|
||||
return Ops.clearNickname(S, mon)
|
||||
end
|
||||
if name == mon.nickname then
|
||||
return Ops.say(S, ("Already nicknamed %s"):format(name))
|
||||
end
|
||||
if name == speciesName(S, mon.species) then
|
||||
if mon.nickname == nil then
|
||||
return Ops.say(S, ("%s is already un-nicknamed"):format(mon.species))
|
||||
end
|
||||
mon.nickname = nil
|
||||
return Ops.mark(S, ("%s matches its standard name; nickname cleared")
|
||||
:format(name))
|
||||
end
|
||||
if Ops.nicknameLength(name) > Ops.NICKNAME_MAX then
|
||||
return Ops.say(S, ("Nicknames are capped at %d characters"):format(Ops.NICKNAME_MAX))
|
||||
end
|
||||
if not Ops.nicknameUsable(S, name) then
|
||||
return Ops.say(S,
|
||||
"That name has characters the game cannot render or export cleanly")
|
||||
end
|
||||
mon.nickname = name
|
||||
return Ops.mark(S, ("Nicknamed %s \"%s\""):format(mon.species, name))
|
||||
end
|
||||
|
||||
function Ops.clearNickname(S, mon)
|
||||
if not mon then return Ops.say(S, "Pick a slot first") end
|
||||
if mon.nickname == nil then
|
||||
return Ops.say(S, ("%s has no nickname to clear"):format(mon.species))
|
||||
end
|
||||
mon.nickname = nil
|
||||
return Ops.mark(S, ("Cleared %s's nickname"):format(mon.species))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ boxes
|
||||
function Ops.boxes(S)
|
||||
return BoxesMod.ensure(S.save)
|
||||
|
||||
@@ -43,6 +43,8 @@ function State.new()
|
||||
partyOffset = 0, -- roster scroll position (#715)
|
||||
inspectorScroll = 0, -- MonEditor body pixel scroll (#715)
|
||||
editingMon = nil, -- reference into party or a box
|
||||
nicknameDraft = nil, -- text being typed in the inspector's nickname field
|
||||
nicknameMon = nil, -- the mon the draft belongs to (nil for none)
|
||||
-- species picker overlay: nil when closed, otherwise { query, offset }
|
||||
-- plus mode = "box-add" when it is adding to a box instead of changing a
|
||||
-- species (Ops.openBoxAddPicker). Modal in the literal sense -- App
|
||||
|
||||
@@ -218,7 +218,11 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
else
|
||||
colsH = capH + 10 * s + colRowsH + 12 * s + actH
|
||||
end
|
||||
-- the nickname section: a caption line (with the Clear button on it) plus
|
||||
-- the field + Set row
|
||||
local nickFieldH = 30 * s
|
||||
local contentH = pad + headerH + 18 * s
|
||||
+ capH + 10 * s + nickFieldH + 18 * s
|
||||
+ capH + 10 * s + cellH + 18 * s
|
||||
+ colsH + pad
|
||||
|
||||
@@ -266,8 +270,45 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
drawLevelRow(S, Kit, mon, cx, cy + math.max(sprite, titleH) + 12 * s)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- nickname
|
||||
-- Editing the field is a draft (S.nicknameDraft) held on the mon it belongs
|
||||
-- to; Set / Enter commit it through Ops.setNickname, which clears on an
|
||||
-- empty value, and Clear goes through Ops.clearNickname. The draft resets
|
||||
-- when the selection moves so one mon's typing can never leak onto another.
|
||||
local nickY = cy + headerH + 18 * s
|
||||
Kit.caption(cx, nickY, "NICKNAME")
|
||||
local clearW = 74 * s
|
||||
local clearH = 24 * s
|
||||
if Kit.button(cx + inner - clearW, nickY + (capH - clearH) / 2, clearW, clearH,
|
||||
"Clear", { kind = "danger", font = "micro", radius = 6 * s }) then
|
||||
Ops.clearNickname(S, mon)
|
||||
S.nicknameDraft = ""
|
||||
end
|
||||
local fieldY = nickY + capH + 10 * s
|
||||
local setW = 64 * s
|
||||
local fieldW = inner - setW - 10 * s
|
||||
if S.nicknameMon ~= mon then
|
||||
local switching = S.nicknameMon ~= nil
|
||||
S.nicknameMon = mon
|
||||
S.nicknameDraft = mon.nickname or ""
|
||||
-- a still-focused field would keep appending keystrokes to the newly
|
||||
-- selected mon; the selection move counts as leaving the field. The
|
||||
-- first sync (nicknameMon starts nil) never blurs: the species picker
|
||||
-- owns focus when it opens, and blurring there drops the player's typing.
|
||||
if switching and Kit.focus == "mon-nickname" then Kit.blur() end
|
||||
end
|
||||
S.nicknameDraft = Kit.textfield("mon-nickname", cx, fieldY, fieldW, nickFieldH,
|
||||
S.nicknameDraft or "", "no nickname",
|
||||
{ sanitize = function(value) return Ops.nicknameSanitize(S, value) end })
|
||||
if Kit.button(cx + fieldW + 10 * s, fieldY, setW, nickFieldH, "Set",
|
||||
{ kind = "accent", font = "small", radius = 8 * s }) then
|
||||
if Ops.setNickname(S, mon, S.nicknameDraft) then
|
||||
S.nicknameDraft = mon.nickname or ""
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- derived stats
|
||||
local statsY = cy + headerH + 18 * s
|
||||
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
|
||||
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
|
||||
statsY = statsY + capH + 10 * s
|
||||
local gap = 12 * s
|
||||
|
||||
Reference in New Issue
Block a user