Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.

The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
This commit is contained in:
1jamie
2026-08-14 18:43:38 -05:00
parent fb738fa1ce
commit df0be1cba6
36 changed files with 3429 additions and 1632 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local Flags = dofile("tools/goldwalk/flags.lua")
local ROOT = arg[1] or "../pokegold"
local OUT = "tests/drivers/gold/flag_names.lua"
local OUT = "src/core/gen2/FlagNames.lua"
local events, _ = Flags.parse(ROOT .. "/constants/event_flags.asm")
local engine, _ = Flags.parse(ROOT .. "/constants/engine_flags.asm")
+49 -42
View File
@@ -25,6 +25,7 @@ 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
@@ -87,56 +88,43 @@ local function applyLoaded(path, statusVerb)
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.save = Gen.newGame(S.version)
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.save = Gen.newGame(S.version)
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
local mapId = Gen.playerMap(S.save)
S.mapId = mapId
S.dirty = false
S._quitArmed = false
S._openArmed = false
S.editingMon = nil
Ops.disarm(S)
local boxes = require("src.pokemon.Boxes").ensure(S.save)
-- Imported .sav box mons have no stat block (box_struct stops before
-- MON_STATS). The game derives them in SaveData.validate; the editor
-- only validates a copy, so hydrate here for Boxes/Party/MonEditor.
local Stats = require("src.pokemon.Stats")
local function ensureStats(mon)
Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon)
end
for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end
for _, box in ipairs(boxes) do
for _, mon in ipairs(box) do ensureStats(mon) end
end
if S.save.daycare and S.save.daycare.mon then
ensureStats(S.save.daycare.mon)
end
-- 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")
Gen.ensureBoxes(S.save)
Gen.hydrateSave(Data, S.save)
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)
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
@@ -152,6 +140,9 @@ function App.load(pathOverride, opts)
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
@@ -163,6 +154,10 @@ function App.load(pathOverride, opts)
-- 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)
@@ -174,9 +169,16 @@ function App.load(pathOverride, opts)
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)
if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
S.events = Catalog.goldEventList(modRoots)
else
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
nil, modRoots)
end
applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
if Gen.of(S.save, S.version) == 2 then
S.events = Catalog.goldEventList(modRoots)
end
end
-- Switch to another save file (Open button, drag-drop, or --save arg).
@@ -582,11 +584,9 @@ local function tabCount(id)
return tostring(n)
elseif id == "items" then
local Bag = require("src.inventory.Bag")
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
return ("%d/%d"):format(Bag.slots(S.save, S.data), Bag.capacity(S.data))
elseif id == "events" then
local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end
return tostring(n)
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)
@@ -602,21 +602,28 @@ end
-- 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 SaveData = require("src.core.SaveData")
local report = S.validation
if not report or SaveData.emptyReport(report) then
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
add(#report.lostMons, "mon", "mons", "party")
add(#report.lostItems, "item", "items", "items")
add(#report.remappedMaps, "map", "maps", "map")
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
+23 -1
View File
@@ -91,7 +91,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
end
end
local dirs = { scriptDir }
local dirs = {}
if scriptDir then dirs[#dirs + 1] = scriptDir end
for _, dir in ipairs(extraDirs or {}) do
dirs[#dirs + 1] = dir
end
@@ -110,4 +111,25 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
return sortedKeys(found)
end
function Catalog.goldEventList(extraDirs)
local names = {}
local ok, flags = pcall(require, "src.core.gen2.FlagNames")
if ok and flags and flags.events then
for name in pairs(flags.events) do
names[#names + 1] = name
end
end
table.sort(names)
local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs)
local seen = {}
for _, name in ipairs(names) do seen[name] = true end
for _, name in ipairs(modFlags) do
if not seen[name] then
names[#names + 1] = name
seen[name] = true
end
end
return names
end
return Catalog
+350
View File
@@ -0,0 +1,350 @@
-- Generation adapter for the save editor. Panels stay generation-blind;
-- Ops and App read Gold vs RBY through this module so a Gold write never
-- lands in Gen 1 fields (save.money, pokedex.owned, 12 boxes, ...).
local GameVersion = require("src.core.GameVersion")
local Gen = {}
local function versionGeneration(version)
if type(version) ~= "string" then return nil end
local info = GameVersion.info(version)
if info then return info.generation or 1 end
return nil
end
function Gen.of(save, version)
if type(save) == "table" then
if save.generation == 2 then return 2 end
local fromVersion = versionGeneration(save.version)
if fromVersion then return fromVersion end
end
local fromArg = versionGeneration(version)
if fromArg then return fromArg end
return GameVersion.generation()
end
function Gen.ofState(S)
if not S then return GameVersion.generation() end
return Gen.of(S.save, S.version)
end
function Gen.is2(save, version)
return Gen.of(save, version) == 2
end
-- Data:load writes Gold maps/tilesets to the Gen 1 keys; Game2 and the mod
-- merge write gen2Maps / gen2Tilesets. Overlay the gen2 table on the loaded
-- cache so a mod patch that landed on an empty gen2Maps (objects only, no
-- width) does not hide the extractor's record, and a new map like BERRY_FARM
-- still appears.
local function overlayRecords(base, overlay)
if not overlay then return base or {} end
if not base or base == overlay then return overlay end
local out = {}
for id, def in pairs(base) do out[id] = def end
for id, def in pairs(overlay) do
local prior = out[id]
if type(def) == "table" and type(prior) == "table" then
local merged = {}
for k, v in pairs(prior) do merged[k] = v end
for k, v in pairs(def) do merged[k] = v end
out[id] = merged
else
out[id] = def
end
end
return out
end
function Gen.maps(data)
if type(data) ~= "table" then return {} end
return overlayRecords(data.maps, data.gen2Maps)
end
function Gen.tilesets(data)
if type(data) ~= "table" then return {} end
return overlayRecords(data.tilesets, data.gen2Tilesets)
end
-- Point the Gen 2 Data keys at the tables Data:load already filled, before
-- mods:load folds into gen2Maps. Same wiring Game2 does when it boots Gold.
function Gen.bindGoldData(data)
if type(data) ~= "table" then return data end
if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end
if data.tilesets and data.gen2Tilesets == nil then
data.gen2Tilesets = data.tilesets
end
if data.palettes and data.gen2Palettes == nil then
data.gen2Palettes = data.palettes
end
if data.gen2Roofs == nil and data.roofs == nil then
local ok, roofs = pcall(require, "data.generated.roofs")
if ok and type(roofs) == "table" then
data.roofs = roofs
data.gen2Roofs = roofs
end
elseif data.roofs and data.gen2Roofs == nil then
data.gen2Roofs = data.roofs
end
return data
end
function Gen.newGame(version)
if (versionGeneration(version) or GameVersion.generation(version)) == 2 then
return require("src.core.gen2.Save").newGame()
end
return require("src.core.SaveData").newGame()
end
function Gen.validate(save, data)
if Gen.of(save) == 2 then
return require("src.core.gen2.Save").validate(save)
end
return require("src.core.SaveData").validate(save, data)
end
function Gen.emptyReport(save, report)
if Gen.of(save) == 2 then
return require("src.core.gen2.Save").emptyReport(report)
end
return require("src.core.SaveData").emptyReport(report)
end
function Gen.hydrateMon(data, mon)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
local gen2 = (mon.stats and mon.stats.specialAttack)
or (def and def.baseStats and def.baseStats.specialAttack)
or mon.experience ~= nil
if gen2 then
require("src.battle.gen2.Mon").refreshStats(mon, data)
else
require("src.pokemon.Stats").ensure(def, mon)
end
return mon
end
-- Party, boxes, Day-Care. Gold's dayCare.man/lady/egg are not save.daycare.
function Gen.hydrateSave(data, save)
if type(save) ~= "table" then return save end
if Gen.of(save) == 2 then
local Mon = require("src.battle.gen2.Mon")
Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end)
return save
end
for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end
for _, box in ipairs(save.boxes or {}) do
if type(box) == "table" then
for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end
end
end
if save.daycare and save.daycare.mon then
Gen.hydrateMon(data, save.daycare.mon)
end
return save
end
function Gen.ensureBoxes(save)
if Gen.of(save) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
save.boxes = save.boxes or {}
for i = 1, Boxes2.NUM_BOXES do
save.boxes[i] = save.boxes[i] or {}
end
save.currentBox = math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1))
return save.boxes
end
return require("src.pokemon.Boxes").ensure(save)
end
function Gen.boxCount(save)
if Gen.of(save) == 2 then
return require("src.core.gen2.Boxes").NUM_BOXES
end
return require("src.pokemon.Boxes").COUNT
end
function Gen.boxCapacity(save)
if Gen.of(save) == 2 then
return require("src.core.gen2.Boxes").MONS_PER_BOX
end
return require("src.pokemon.Boxes").CAPACITY
end
function Gen.money(save)
if Gen.of(save) == 2 then
return (save.player and save.player.money) or 0
end
return save.money or 0
end
function Gen.setMoney(save, amount)
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.money = amount
else
save.money = amount
end
end
function Gen.coins(save)
if Gen.of(save) == 2 then
return (save.player and save.player.coins) or 0
end
return save.coins or 0
end
function Gen.setCoins(save, amount)
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.coins = amount
else
save.coins = amount
end
end
function Gen.dexOwnedKey(save)
if Gen.of(save) == 2 then return "caught" end
return "owned"
end
function Gen.playerMap(save)
if Gen.of(save) == 2 then
local p = save.position
if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end
return save.spawn, 0, 0
end
local p = save.player or {}
return p.map, p.x or 0, p.y or 0
end
function Gen.setPlayerHere(save, mapId, x, y, facing)
if Gen.of(save) == 2 then
local prev = save.position or {}
save.position = {
map = mapId,
x = x,
y = y,
facing = facing or prev.facing or "down",
}
return
end
save.player = save.player or {}
save.player.map = mapId
save.player.x = x
save.player.y = y
end
local JOHTO = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
local KANTO = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
function Gen.badgeIds(save, cat)
if Gen.of(save) == 2 then
local ids = {}
for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end
for _, name in ipairs(KANTO) do ids[#ids + 1] = name end
return ids
end
local ids = {}
for _, id in ipairs((cat and cat.items) or {}) do
if tostring(id):find("BADGE", 1, true) then ids[#ids + 1] = id end
end
return ids
end
local KANTO_SET = {}
for _, name in ipairs(KANTO) do KANTO_SET[name] = true end
function Gen.hasBadge(save, id)
if Gen.of(save) == 2 then
local p = save.player or {}
local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {})
if store[id] then return true end
local list = KANTO_SET[id] and KANTO or JOHTO
for index, name in ipairs(list) do
if name == id then return store[index] == true end
end
return false
end
return save.inventory and save.inventory[id] and true or false
end
function Gen.toggleBadge(save, id)
if Gen.of(save) == 2 then
save.player = save.player or {}
local storeName = KANTO_SET[id] and "kantoBadges" or "badges"
save.player[storeName] = save.player[storeName] or {}
local store = save.player[storeName]
local on = Gen.hasBadge(save, id)
store[id] = (not on) and true or nil
for index, name in ipairs(KANTO_SET[id] and KANTO or JOHTO) do
if name == id then store[index] = nil end
end
return not on
end
local on = save.inventory[id] and true or false
save.inventory[id] = (not on) and 1 or nil
return not on
end
local function goldFlagId(name)
local flags = require("src.core.gen2.FlagNames")
return flags.events and flags.events[name]
end
function Gen.getFlag(save, name)
if Gen.of(save) == 2 then
local id = goldFlagId(name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
return ev:get(id)
end
return save.flags and save.flags[name] == true
end
return save.flags and save.flags[name] == true
end
function Gen.setFlag(save, name, on)
if Gen.of(save) == 2 then
local id = goldFlagId(name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
ev:set(id, on and true or false)
save.events = ev:serialize()
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
end
function Gen.flagCount(save)
if Gen.of(save) == 2 then
local n = 0
for _ in pairs(save.events or {}) do n = n + 1 end
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
local n = 0
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
function Gen.exp(mon)
return mon.experience or mon.exp or 0
end
return Gen
+45 -8
View File
@@ -4,23 +4,40 @@ local Growth = require("src.pokemon.Growth")
local MonOps = {}
function MonOps.create(data, species, level)
function MonOps.create(data, species, level, gen)
if gen == 2 then
local Mon = require("src.battle.gen2.Mon")
local mon = Mon.new(data, species, level)
assert(mon, "unknown species " .. tostring(species))
return mon
end
return Pokemon.new(data, species, level)
end
function MonOps.recalc(data, mon)
function MonOps.recalc(data, mon, gen)
if gen == 2 or (mon.stats and mon.stats.specialAttack) or mon.experience then
require("src.battle.gen2.Mon").refreshStats(mon, data)
return
end
local def = data.pokemon[mon.species]
assert(def, "unknown species")
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp))
end
function MonOps.setLevel(data, mon, level)
function MonOps.setLevel(data, mon, level, gen)
level = math.max(1, math.min(100, math.floor(level)))
local def = data.pokemon[mon.species]
mon.level = level
if gen == 2 or mon.experience ~= nil then
local Mon = require("src.battle.gen2.Mon")
local growth = Mon.growthFor(data, def.growthRate)
mon.experience = Mon.experienceForLevel(growth, level)
Mon.refreshStats(mon, data)
return
end
mon.exp = Growth.expForLevel(def.growthRate, level)
MonOps.recalc(data, mon)
MonOps.recalc(data, mon, gen)
end
function MonOps.setMove(data, mon, slot, moveId)
@@ -32,6 +49,7 @@ function MonOps.setMove(data, mon, slot, moveId)
id = moveId,
pp = mdef.pp + ((mon.moves[slot] and mon.moves[slot].ppUps) or 0) * math.floor(mdef.pp / 5),
ppUps = mon.moves[slot] and mon.moves[slot].ppUps or nil,
maxPp = mdef.pp,
}
end
@@ -42,19 +60,38 @@ function MonOps.syncHpDv(dvs)
return dvs
end
function MonOps.setDv(data, mon, key, value)
function MonOps.setDv(data, mon, key, value, gen)
mon.dvs[key] = math.max(0, math.min(15, math.floor(value)))
if key ~= "hp" then
MonOps.syncHpDv(mon.dvs)
end
MonOps.recalc(data, mon)
if gen == 2 or (mon.stats and mon.stats.specialAttack) then
local Mon = require("src.battle.gen2.Mon")
mon.dvs.hp = Mon.hpDV(mon.dvs)
local def = data.pokemon[mon.species]
if def then
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
local Unown = require("src.core.gen2.Unown")
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
end
end
end
MonOps.recalc(data, mon, gen)
end
-- Keep level; resync exp to the species growth curve (species changes).
function MonOps.setSpecies(data, mon, species)
-- Gold also copies def.name onto mon.name: the party list and SUMMARY print
-- that field when nickname is nil, so leaving the previous species' name
-- made a swapped mon still read as ABRA (or whoever was added first).
function MonOps.setSpecies(data, mon, species, gen)
assert(data.pokemon[species], "unknown species")
mon.species = species
MonOps.setLevel(data, mon, mon.level)
MonOps.setLevel(data, mon, mon.level, gen)
if gen == 2 or mon.experience ~= nil or (mon.stats and mon.stats.specialAttack) then
require("src.battle.gen2.Mon").syncIdentity(mon, data)
end
end
return MonOps
+246 -70
View File
@@ -17,6 +17,7 @@ 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 Gen = require("Gen")
local Ops = {}
@@ -35,6 +36,62 @@ local function clamp(n, lo, hi)
end
Ops.clamp = clamp
local function stampNewMon(S, mon)
if Gen.ofState(S) == 2 then
require("src.battle.gen2.Mon").stampOT(S.save, mon)
else
mon.ot = S.save.player.name
mon.otId = S.save.player.id
end
return mon
end
local function createMon(S, species, level)
local mon = MonOps.create(S.data, species, level, Gen.ofState(S))
return stampNewMon(S, mon)
end
local function partySlot(S, mon)
for i, member in ipairs(S.save.party or {}) do
if member == mon then return i end
end
end
-- Portrait mail (and CheckPokeMail) store species on the letter, not the mon.
local function partyMailEntry(S, slot)
local Mail = require("src.core.gen2.Mail")
return Mail.state(S.save).party[slot]
end
local function syncPartyMailSpecies(S, mon)
if Gen.ofState(S) ~= 2 then return end
local slot = partySlot(S, mon)
if not slot then return end
local entry = partyMailEntry(S, slot)
if entry then entry.species = mon.species end
end
local function syncPartyMailHeldItem(S, mon, prevItem, newItem)
if Gen.ofState(S) ~= 2 then return end
local slot = partySlot(S, mon)
if not slot then return end
local Mail = require("src.core.gen2.Mail")
if Mail.isMail(newItem) then
local prev = partyMailEntry(S, slot)
local player = S.save.player or {}
Mail.set(S.save, slot, Mail.entry(
newItem,
prev and prev.message or "",
tostring(mon.otName or mon.ot or player.name or ""):sub(1, Mail.AUTHOR_LENGTH),
mon.otId or player.id or 0,
mon.species))
return
end
if Mail.isMail(prevItem) or partyMailEntry(S, slot) then
Mail.clear(S.save, slot)
end
end
local function now()
if love and love.timer and love.timer.getTime then
return love.timer.getTime()
@@ -111,9 +168,7 @@ function Ops.partyAdd(S)
return Ops.say(S, ("Party is full (%d/%d)"):format(#S.save.party, PartyMod.MAX))
end
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
local mon = createMon(S, species, 5)
table.insert(S.save.party, mon)
S.selectedParty = #S.save.party
S.editingMon = mon
@@ -129,6 +184,11 @@ function Ops.partyRemove(S)
return false
end
table.remove(S.save.party, index)
-- sPartyMail is keyed by party slot, not by mon: dropping a member without
-- shifting letters hands the next mon someone else's mail.
if Gen.ofState(S) == 2 then
require("src.core.gen2.Mail").removeSlot(S.save, index)
end
if S.editingMon == mon then S.editingMon = nil end
S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1))
S.editingMon = S.save.party[S.selectedParty]
@@ -144,6 +204,9 @@ function Ops.partyMove(S, delta)
return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon")
end
party[i], party[j] = party[j], party[i]
if Gen.ofState(S) == 2 then
require("src.core.gen2.Mail").swapSlots(S.save, i, j)
end
S.selectedParty = j
return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j))
end
@@ -157,7 +220,7 @@ function Ops.setLevel(S, mon, level)
if want == mon.level then
return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100")
end
MonOps.setLevel(S.data, mon, want)
MonOps.setLevel(S.data, mon, want, Gen.ofState(S))
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
end
@@ -172,15 +235,23 @@ end
-- instead of trusting the list: without this, picking such a species walked
-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window
-- down (#541).
local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
local BASE_STAT_KEYS_G1 = { "hp", "attack", "defense", "speed", "special" }
local BASE_STAT_KEYS_G2 = {
"hp", "attack", "defense", "speed", "specialAttack", "specialDefense",
}
local function baseStatsComplete(bs, keys)
for _, key in ipairs(keys) do
if type(bs[key]) ~= "number" then return false end
end
return true
end
function Ops.speciesUsable(S, id)
local def = id and S.data.pokemon[id]
if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end
for _, key in ipairs(BASE_STAT_KEYS) do
if type(def.baseStats[key]) ~= "number" then return false end
end
return true
return baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G1)
or baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G2)
end
-- The one funnel every species change goes through (the picker, the stepper,
@@ -199,14 +270,19 @@ function Ops.setSpecies(S, mon, id)
end
-- MonOps.recalc replaces mon.stats with a fresh table rather than editing
-- it in place, so holding the old reference is a real rollback.
local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp
local wasStats, wasHp = mon.stats, mon.hp
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id)
local wasSpecies, wasLevel, wasExp, wasExperience = mon.species, mon.level, mon.exp, mon.experience
local wasStats, wasHp, wasName = mon.stats, mon.hp, mon.name
local wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp =
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id, Gen.ofState(S))
if not ok then
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
mon.stats, mon.hp = wasStats, wasHp
mon.species, mon.level, mon.exp, mon.experience = wasSpecies, wasLevel, wasExp, wasExperience
mon.stats, mon.hp, mon.name = wasStats, wasHp, wasName
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp =
wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
end
syncPartyMailSpecies(S, mon)
return Ops.mark(S, ("Species set to %s"):format(id))
end
@@ -313,9 +389,9 @@ end
-- the target is the box, not a mon.
function Ops.openBoxAddPicker(S, Kit)
local box = Ops.boxes(S)[S.selectedBox]
if #box >= BoxesMod.CAPACITY then
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
end
S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" }
if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529)
@@ -327,17 +403,15 @@ end
-- box mon and a party mon born in the editor are indistinguishable.
function Ops.boxAddSpecies(S, id)
local box = Ops.boxes(S)[S.selectedBox]
if #box >= BoxesMod.CAPACITY then
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
end
if not Ops.speciesUsable(S, id) then
return Ops.say(S, ("%s has no usable base stats, cannot add it")
:format(tostring(id)))
end
local mon = MonOps.create(S.data, id, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
local mon = createMon(S, id, 5)
table.insert(box, mon)
S.selectedBoxSlot = #box
S.editingMon = mon
@@ -351,7 +425,7 @@ function Ops.setDv(S, mon, key, value)
if want == mon.dvs[key] then
return Ops.say(S, ("%s DV is already %d"):format(key, want))
end
MonOps.setDv(S.data, mon, key, want)
MonOps.setDv(S.data, mon, key, want, Gen.ofState(S))
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
end
@@ -382,7 +456,17 @@ end
function Ops.resetMoves(S, mon)
if not mon then return false end
local def = S.data.pokemon[mon.species]
local learned = Pokemon.movesAtLevel(def, mon.level)
local gen = Gen.ofState(S)
local learned
if gen == 2 then
local Mon = require("src.battle.gen2.Mon")
learned = {}
for _, mv in ipairs(Mon.movesAtLevel(def, mon.level, S.data.moves)) do
learned[#learned + 1] = mv.id
end
else
learned = Pokemon.movesAtLevel(def, mon.level)
end
mon.moves = {}
for slot, id in ipairs(learned) do
MonOps.setMove(S.data, mon, slot, id)
@@ -397,6 +481,7 @@ function Ops.healMon(S, mon)
return Ops.say(S, ("%s is already at full HP"):format(mon.species))
end
mon.hp = mon.stats.hp
if mon.maxHp then mon.maxHp = mon.stats.hp end
mon.status = nil
for _, mv in ipairs(mon.moves or {}) do
local def = S.data.moves[mv.id]
@@ -554,27 +639,35 @@ function Ops.clearNickname(S, mon)
end
-- ------------------------------------------------------------------ boxes
function Ops.boxCount(S)
return Gen.boxCount(S.save)
end
function Ops.boxCapacity(S)
return Gen.boxCapacity(S.save)
end
function Ops.boxes(S)
return BoxesMod.ensure(S.save)
return Gen.ensureBoxes(S.save)
end
function Ops.selectBox(S, index)
S.selectedBox = clamp(index, 1, BoxesMod.COUNT)
S.selectedBox = clamp(index, 1, Ops.boxCount(S))
S.selectedBoxSlot = 1
S.save.currentBox = S.selectedBox
local box = Ops.boxes(S)[S.selectedBox]
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, BoxesMod.CAPACITY)
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, Ops.boxCapacity(S))
return true
end
function Ops.stepBox(S, delta)
local n = BoxesMod.COUNT
local n = Ops.boxCount(S)
return Ops.selectBox(S, ((S.selectedBox - 1 + delta) % n) + 1)
end
function Ops.selectBoxSlot(S, index)
local box = Ops.boxes(S)[S.selectedBox]
S.selectedBoxSlot = clamp(index, 1, BoxesMod.CAPACITY)
S.selectedBoxSlot = clamp(index, 1, Ops.boxCapacity(S))
local mon = box[S.selectedBoxSlot]
S.editingMon = mon
S.status = mon
@@ -589,14 +682,12 @@ end
-- chooses what lands in the box instead of always getting catalog entry #1.
function Ops.boxAdd(S)
local box = Ops.boxes(S)[S.selectedBox]
if #box >= BoxesMod.CAPACITY then
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
end
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
local mon = createMon(S, species, 5)
table.insert(box, mon)
S.selectedBoxSlot = #box
S.editingMon = mon
@@ -612,9 +703,16 @@ function Ops.withdraw(S)
return Ops.say(S, ("Party is full (%d/%d), deposit one first")
:format(#S.save.party, PartyMod.MAX))
end
table.remove(box, S.selectedBoxSlot)
table.insert(S.save.party, mon)
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
if Gen.ofState(S) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
local ok, reason = Boxes2.canWithdraw(S.save, S.selectedBox, S.selectedBoxSlot)
if not ok then return Ops.say(S, reason) end
Boxes2.withdraw(S.save, S.selectedBox, S.selectedBoxSlot)
else
table.remove(box, S.selectedBoxSlot)
table.insert(S.save.party, mon)
end
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#Ops.boxes(S)[S.selectedBox], 1))
S.selectedParty = #S.save.party
return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party))
end
@@ -639,6 +737,17 @@ function Ops.deposit(S)
local i = S.selectedParty
local mon = S.save.party[i]
if not mon then return Ops.say(S, "No party slot selected") end
if Gen.ofState(S) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
local boxIndex = S.selectedBox or S.save.currentBox or 1
local ok, reason = Boxes2.canDeposit(S.save, i, boxIndex)
if not ok then return Ops.say(S, reason) end
Boxes2.deposit(S.save, i, boxIndex)
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
S.selectedBox = boxIndex
if S.editingMon == mon then S.editingMon = nil end
return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxIndex))
end
local boxNum = BoxesMod.deposit(S.save, mon)
if not boxNum then
return Ops.say(S, "Every box is full, release something first")
@@ -652,12 +761,13 @@ end
-- ------------------------------------------------------------------ items
function Ops.addMoney(S, delta)
local want = clamp((S.save.money or 0) + delta, 0, Ops.MONEY_MAX)
if want == S.save.money then
local have = Gen.money(S.save)
local want = clamp(have + delta, 0, Ops.MONEY_MAX)
if want == have then
return Ops.say(S, delta < 0 and "Money is already $0"
or ("Money is already capped at $%d"):format(Ops.MONEY_MAX))
end
S.save.money = want
Gen.setMoney(S.save, want)
return Ops.mark(S, ("Money set to $%d"):format(want))
end
@@ -665,15 +775,29 @@ function Ops.maxMoney(S)
return Ops.addMoney(S, Ops.MONEY_MAX)
end
Ops.COIN_MAX = 9999
function Ops.addCoins(S, delta)
local have = Gen.coins(S.save)
local want = clamp(have + delta, 0, Ops.COIN_MAX)
if want == have then
return Ops.say(S, delta < 0 and "Coins are already 0"
or ("Coins are already capped at %d"):format(Ops.COIN_MAX))
end
Gen.setCoins(S.save, want)
return Ops.mark(S, ("Coins set to %d"):format(want))
end
function Ops.addToBag(S, id)
if not id then return Ops.say(S, "Pick an item first") end
local capacity = Bag.capacity(S.data)
local pocket = Bag.pocketOf(id, S.data)
local capacity = Bag.capacity(S.data, pocket)
if Bag.add(S.save, id, 1, S.data) then
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
:format(id, Bag.slots(S.save), capacity))
return Ops.mark(S, ("Added %s to the bag (%d/%d %s slots)")
:format(id, Bag.slots(S.save, S.data, pocket), capacity, pocket))
end
return Ops.say(S, ("Bag is full (%d/%d slots)")
:format(Bag.slots(S.save), capacity))
return Ops.say(S, ("Bag is full (%d/%d %s slots)")
:format(Bag.slots(S.save, S.data, pocket), capacity, pocket))
end
function Ops.bagAdjust(S, id, delta)
@@ -715,6 +839,12 @@ end
function Ops.addToPc(S, id)
if not id then return Ops.say(S, "Pick an item first") end
local pc = Ops.pcItems(S)
local n = 0
for _ in pairs(pc) do n = n + 1 end
if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then
return Ops.say(S, "PC item storage is full (50 stacks)")
end
local pc = Ops.pcItems(S)
pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1)
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
end
@@ -749,27 +879,17 @@ function Ops.isBadgeId(id)
end
function Ops.badgeIds(S)
local ids = {}
for _, id in ipairs(S.cat.items) do
if Ops.isBadgeId(id) then ids[#ids + 1] = id end
end
return ids
return Gen.badgeIds(S.save, S.cat)
end
function Ops.toggleBadge(S, id)
-- #515: badges are truthy inventory entries written as 1 by the in-game
-- grant (checkVictoryRewards, src/world/OverworldController.lua) and by
-- GenSave's .sav import; read and write that same shape here, or a badge
-- earned in game reads as unowned and an editor-written boolean blows up
-- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua).
local on = S.save.inventory[id] and true or false
S.save.inventory[id] = (not on) and 1 or nil
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
local nowOn = Gen.toggleBadge(S.save, id)
return Ops.mark(S, ("%s %s"):format(id, nowOn and "earned" or "removed"))
end
-- ----------------------------------------------------------------- events
function Ops.setFlag(S, name, on)
S.save.flags[name] = on and true or nil
Gen.setFlag(S.save, name, on)
return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false)))
end
@@ -801,17 +921,19 @@ end
-- -------------------------------------------------------------------- dex
function Ops.dex(S)
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
local key = Gen.dexOwnedKey(S.save)
S.save.pokedex = S.save.pokedex or { seen = {}, [key] = {} }
S.save.pokedex.seen = S.save.pokedex.seen or {}
S.save.pokedex.owned = S.save.pokedex.owned or {}
S.save.pokedex[key] = S.save.pokedex[key] or {}
return S.save.pokedex
end
function Ops.dexCounts(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
local seen, owned = 0, 0
for _ in pairs(dex.seen) do seen = seen + 1 end
for _ in pairs(dex.owned) do owned = owned + 1 end
for _ in pairs(dex[key] or {}) do owned = owned + 1 end
return seen, owned, #S.cat.species
end
@@ -819,25 +941,28 @@ end
-- the game's own rule, enforced here so a hand-edited dex stays legal.
function Ops.dexSeen(S, species, on)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
dex.seen[species] = on and true or nil
if not on then dex.owned[species] = nil end
if not on then dex[key][species] = nil end
return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared"))
end
function Ops.dexOwned(S, species, on)
local dex = Ops.dex(S)
dex.owned[species] = on and true or nil
local key = Gen.dexOwnedKey(S.save)
dex[key][species] = on and true or nil
if on then dex.seen[species] = true end
return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned"))
end
function Ops.dexStamp(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
local n = 0
local function stamp(mon)
if not dex.owned[mon.species] then n = n + 1 end
if not dex[key][mon.species] then n = n + 1 end
dex.seen[mon.species] = true
dex.owned[mon.species] = true
dex[key][mon.species] = true
end
for _, m in ipairs(S.save.party) do stamp(m) end
for _, box in ipairs(S.save.boxes or {}) do
@@ -855,9 +980,10 @@ end
function Ops.dexOwnAll(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
for _, species in ipairs(S.cat.species) do
dex.seen[species] = true
dex.owned[species] = true
dex[key][species] = true
end
return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species))
end
@@ -866,7 +992,8 @@ function Ops.dexClear(S)
if not Ops.arm(S, "dex-clear", "Wipe the whole Pokedex? Click again to confirm") then
return false
end
S.save.pokedex = { seen = {}, owned = {} }
local key = Gen.dexOwnedKey(S.save)
S.save.pokedex = { seen = {}, [key] = {} }
return Ops.mark(S, "Pokedex wiped")
end
@@ -927,6 +1054,11 @@ end
-- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save
-- has already visited.
function Ops.isOutdoor(S, map)
if not map or not map.def then return false end
local Map2 = require("src.world.gen2.Map")
if Gen.ofState(S) == 2 and Map2.isOutdoor then
return Map2.isOutdoor(map.def) and true or false
end
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
return true
end
@@ -937,15 +1069,17 @@ end
function Ops.setPlayerHere(S)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
S.save.player.map = S.mapId
S.save.player.x = cell.cx
S.save.player.y = cell.cy
Gen.setPlayerHere(S.save, S.mapId, cell.cx, cell.cy)
return Ops.mark(S, ("Player set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
end
function Ops.setLastOutdoor(S, map)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
if Gen.ofState(S) == 2 then
S.save.spawn = S.mapId
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
end
if not Ops.isOutdoor(S, map) then
return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)")
end
@@ -956,8 +1090,50 @@ end
function Ops.setLastHeal(S)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
if Gen.ofState(S) == 2 then
S.save.spawn = S.mapId
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
end
S.save.lastHeal = { map = S.mapId, x = cell.cx, y = cell.cy }
return Ops.mark(S, ("lastHeal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
end
function Ops.setHeldItem(S, mon, id)
if not mon then return false end
if id == "" or id == nil then
if not mon.item then return Ops.say(S, "No held item to clear") end
local was = mon.item
mon.item = nil
syncPartyMailHeldItem(S, mon, was, nil)
return Ops.mark(S, ("Cleared held item (%s)"):format(was))
end
if not S.data.items[id] then
return Ops.say(S, ("%s is not an item"):format(tostring(id)))
end
local was = mon.item
mon.item = id
syncPartyMailHeldItem(S, mon, was, id)
return Ops.mark(S, ("%s now holds %s"):format(mon.species, id))
end
function Ops.setHappiness(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
if want == (mon.happiness or 0) then
return Ops.say(S, ("Happiness is already %d"):format(want))
end
mon.happiness = want
return Ops.mark(S, ("%s happiness %d"):format(mon.species, want))
end
function Ops.setPokerus(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
if want == (mon.pokerus or 0) then
return Ops.say(S, ("Pokerus is already %d"):format(want))
end
mon.pokerus = want
return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want))
end
return Ops
+9 -10
View File
@@ -19,7 +19,6 @@
-- in the selected box as a Lv5 mon built by the same MonOps path partyAdd
-- uses, so its stats, exp and moves are consistent.
local BoxesMod = require("src.pokemon.Boxes")
local PartyMod = require("src.pokemon.Party")
local Theme = require("Theme")
local Ops = require("Ops")
@@ -33,12 +32,12 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
local s = Kit.scale
local pad = 16 * s
Kit.card(x, y, stripW, h)
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT))
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(Ops.boxCount(S)))
local stripTop = y + pad + Kit.textHeight("caption") + 10 * s
local stripInner = stripW - 2 * pad
local bRowH = math.min(30 * s, math.max(22 * s,
(h - (stripTop - y) - pad - (BoxesMod.COUNT - 1) * 6 * s) / BoxesMod.COUNT))
for i = 1, BoxesMod.COUNT do
(h - (stripTop - y) - pad - (Ops.boxCount(S) - 1) * 6 * s) / Ops.boxCount(S)))
for i = 1, Ops.boxCount(S) do
local ry = stripTop + (i - 1) * (bRowH + 6 * s)
if ry + bRowH > y + h - pad then break end
if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then
@@ -52,7 +51,7 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
ry + (bRowH - Kit.textHeight("tiny")) / 2, PAL.caption)
local mx = x + pad + stripInner - 10 * s - countW - 8 * s - 44 * s
Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s,
fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue)
fill / Ops.boxCapacity(S) * 100, fill >= Ops.boxCapacity(S) and PAL.yellow or PAL.blue)
end
end
@@ -66,7 +65,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
Kit.text("tab", ("Box %d"):format(S.selectedBox), gx,
y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading)
local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox))
Kit.text("mono", ("%d/%d"):format(#box, BoxesMod.CAPACITY),
Kit.text("mono", ("%d/%d"):format(#box, Ops.boxCapacity(S)),
gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption)
local navW = 34 * s
if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<",
@@ -102,7 +101,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
end
if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel,
{ font = "small", radius = 9 * s,
enabled = #box < BoxesMod.CAPACITY }) then
enabled = #box < Ops.boxCapacity(S) }) then
Ops.openBoxAddPicker(S, Kit)
end
if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel,
@@ -119,7 +118,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
-- of five slivers.
local cols = math.max(2, math.min(COLS,
math.floor((ginner + cellGap) / (86 * s + cellGap))))
local rows = math.ceil(BoxesMod.CAPACITY / cols)
local rows = math.ceil(Ops.boxCapacity(S) / cols)
local cellW = math.max(0, (ginner - cellGap * (cols - 1)) / cols)
-- floor at Kit's 26px tap target so a short window shrinks the cells but
-- never inverts them (#715); overflow clips inside the grid body rather
@@ -128,7 +127,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
math.min((gridH - cellGap * (rows - 1)) / rows, 110 * s))
Kit.pushClip(gx, gridTop, ginner, gridH)
for i = 1, BoxesMod.CAPACITY do
for i = 1, Ops.boxCapacity(S) do
local cc = (i - 1) % cols
local cr = math.floor((i - 1) / cols)
local bx = gx + cc * (cellW + cellGap)
@@ -220,7 +219,7 @@ function M.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT)
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, Ops.boxCount(S))
S.save.currentBox = S.selectedBox
local boxes = Ops.boxes(S)
local box = boxes[S.selectedBox]
+2 -1
View File
@@ -145,7 +145,8 @@ function M.draw(S, Kit, x, y, w, h)
local rx = cx + ci * (colW + colGap)
local ry = gridTop + ri * (rowH + rowGap)
local isSeen = dex.seen[id] == true
local isOwned = dex.owned[id] == true
local ownedKey = require("Gen").dexOwnedKey(S.save)
local isOwned = dex[ownedKey] and dex[ownedKey][id] == true
Theme.row(rx, ry, colW, rowH, 9 * s, 0.6)
local def = S.data.pokemon[id]
+8 -2
View File
@@ -9,6 +9,7 @@
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local M = {}
@@ -50,7 +51,7 @@ local function buildRows(S)
if contains(name, filter) then
rows[#rows + 1] = {
label = name,
checked = S.save.flags[name] == true,
checked = Gen.getFlag(S.save, name),
set = function(on) Ops.setFlag(S, name, on) end,
}
end
@@ -107,7 +108,12 @@ function M.draw(S, Kit, x, y, w, h)
-- past the card edge.
local pillH = 32 * s
local px, py = cx, y + pad
for _, t in ipairs(SUB_TABS) do
local pills = SUB_TABS
if Gen.of(S.save) == 2 then
pills = { SUB_TABS[1] }
if S.eventsTab ~= "flags" then S.eventsTab = "flags" end
end
for _, t in ipairs(pills) do
local pw = Kit.textWidth("small", t.label) + 32 * s
if px > cx and px + pw > cx + inner then
px = cx
+26 -12
View File
@@ -18,6 +18,7 @@
local Bag = require("src.inventory.Bag")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local M = {}
@@ -46,7 +47,11 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu
local qtyW = Kit.textWidth("monoRow", qtyText)
Kit.textRight("monoRow", qtyText, bx - 10 * s,
y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading)
Kit.text("mono", Kit.ellipsize("mono", id, bx - qtyW - 30 * s - (x + 10 * s)),
local label = id
if Gen.of(S.save) == 2 then
label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. id
end
Kit.text("mono", Kit.ellipsize("mono", label, bx - qtyW - 30 * s - (x + 10 * s)),
x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text)
return clicked
end
@@ -55,9 +60,13 @@ end
-- Each card is a function of its own rect so the wide (three column) and the
-- stacked (#715) layouts are the same drawing code with different geometry.
local function moneyHeight(Kit, s, pad)
return pad * 2 + Kit.textHeight("caption") + 8 * s
local function moneyHeight(Kit, s, pad, S)
local h = pad * 2 + Kit.textHeight("caption") + 8 * s
+ Kit.textHeight("headline") + 10 * s + 30 * s
if S and Gen.of(S.save) == 2 then
h = h + 28 * s
end
return h
end
local function drawMoney(S, Kit, x, y, w, h)
@@ -68,11 +77,19 @@ local function drawMoney(S, Kit, x, y, w, h)
local maxW = 74 * s
if Kit.button(x + w - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out",
{ kind = "accent", font = "tiny", radius = 7 * s,
enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then
enabled = Gen.money(S.save) < Ops.MONEY_MAX }) then
Ops.maxMoney(S)
end
Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad,
Kit.text("headline", ("$%d"):format(Gen.money(S.save)), x + pad,
y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow)
if Gen.of(S.save) == 2 then
Kit.text("mono", ("COINS %d"):format(Gen.coins(S.save)), x + pad + 160 * s,
y + pad + Kit.textHeight("caption") + 8 * s, PAL.muted)
if Kit.button(x + w - pad - 74 * s, y + pad + 26 * s, 74 * s, 22 * s, "+100 coins",
{ kind = "ghost", font = "tiny", radius = 6 * s }) then
Ops.addCoins(S, 100)
end
end
local mbY = y + h - pad - 30 * s
local mbW = (w - 2 * pad - 3 * 8 * s) / 4
for i, delta in ipairs(MONEY_STEPS) do
@@ -99,10 +116,7 @@ local function drawBadges(S, Kit, x, y, w, h)
Kit.card(x, y, w, h)
local earned = 0
for _, id in ipairs(badgeIds) do
-- #515: truthy check, not `== true` -- the in-game grant path stores a
-- number (see OverworldController.lua checkVictoryRewards), matching
-- src/inventory/Badges.lua's own truthy read.
if S.save.inventory[id] then earned = earned + 1 end
if Gen.hasBadge(S.save, id) then earned = earned + 1 end
end
Kit.caption(x + pad, y + pad, "BADGES")
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + w - pad,
@@ -112,7 +126,7 @@ local function drawBadges(S, Kit, x, y, w, h)
for i, id in ipairs(badgeIds) do
local bc = (i - 1) % BADGE_COLS
local br = math.floor((i - 1) / BADGE_COLS)
local on = S.save.inventory[id]
local on = Gen.hasBadge(S.save, id)
local short = id:gsub("BADGE$", "")
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
@@ -253,7 +267,7 @@ function M.draw(S, Kit, x, y, w, h)
-- drag over their own bodies first.
local off = Theme.clamp(S.itemsScroll or 0, 0,
math.max(0, (S._itemsContentH or 0) - h))
local moneyH = moneyHeight(Kit, s, pad)
local moneyH = moneyHeight(Kit, s, pad, S)
local badgeH = badgeHeight(S, Kit, s, pad)
local pickH = 280 * s
local listH = 300 * s
@@ -278,7 +292,7 @@ function M.draw(S, Kit, x, y, w, h)
-- Money and badges are fixed-height so the picker gets every pixel left
-- over: cycling through ~250 item ids in a two-row list was the thing that
-- made the old panel unusable.
local moneyH = moneyHeight(Kit, s, pad)
local moneyH = moneyHeight(Kit, s, pad, S)
local badgeH = badgeHeight(S, Kit, s, pad)
drawMoney(S, Kit, x, y, leftW, moneyH)
drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap)
+119 -39
View File
@@ -14,12 +14,18 @@ local MapLoader = require("src.world.MapLoader")
local Warp = require("src.world.Warp")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local MapBrowser = {}
local CELL = 16 -- the walk grid; a cell is 16px of map art
local function playerPos(S)
local map, x, y = Gen.playerMap(S.save)
return map, x or 0, y or 0
end
local function clampZoom(z)
if z < 1 then return 1 end
if z > 4 then return 4 end
@@ -38,7 +44,7 @@ MapBrowser.centerOn = centerOn
local function sortedMapIds(data)
local ids = {}
for id in pairs(data.maps) do ids[#ids + 1] = id end
for id in pairs(Gen.maps(data)) do ids[#ids + 1] = id end
table.sort(ids)
return ids
end
@@ -52,7 +58,19 @@ local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true }
local function goToWarp(S, warp)
local def = warp.def
local fromMap = S.data.maps[S.mapId]
if Gen.of(S.save) == 2 then
local dest = def.destMap or def.map
if dest then
S.mapId = dest
S.mapClickCell = nil
S._mapCenteredFor = dest
S.status = "Followed warp to " .. tostring(dest)
else
S.status = "Warp has no destination map"
end
return
end
local fromMap = Gen.maps(S.data)[S.mapId]
if fromMap and OUTSIDE_TILESETS[fromMap.tileset]
and def.destMap ~= "LAST_MAP" and def.destMap ~= S.mapId then
S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y }
@@ -125,22 +143,25 @@ local function drawOverlays(S, map)
return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL
end
love.graphics.setColor(0.27, 0.59, 1, 0.55)
for _, wdef in ipairs(map.def.warps) do
for _, wdef in ipairs(map.def.warps or {}) do
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
end
if S.save.player.map == S.mapId then
local playerMap, px, py = playerPos(S)
if playerMap == S.mapId then
love.graphics.setColor(1, 0.36, 0.4, 0.9)
love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y))
love.graphics.rectangle("fill", cellRect(px, py))
end
local heal = S.save.lastHeal
if heal and heal.map == S.mapId then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
end
local out = S.save.lastOutdoor
if out and out.id == S.mapId then
love.graphics.setColor(1, 0.8, 0.02, 0.9)
love.graphics.rectangle("line", cellRect(out.x, out.y))
if Gen.of(S.save) ~= 2 then
local heal = S.save.lastHeal
if heal and heal.map == S.mapId then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
end
local out = S.save.lastOutdoor
if out and out.id == S.mapId then
love.graphics.setColor(1, 0.8, 0.02, 0.9)
love.graphics.rectangle("line", cellRect(out.x, out.y))
end
end
if S.mapClickCell then
love.graphics.setColor(1, 1, 0.35, 0.95)
@@ -239,9 +260,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
#ids, perPage)
if Kit.button(lr.x + pad, gotoY, listInner, gotoH, "Go to save location",
{ font = "small", radius = 9 * s }) then
MapBrowser.select(S, S.save.player.map)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map,
S.save.player.x, S.save.player.y))
local pmap, px, py = playerPos(S)
if pmap then
MapBrowser.select(S, pmap)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py))
else
Ops.say(S, "No player location on this save")
end
end
-- ---------------------------------------------------------- the viewport
@@ -253,7 +278,32 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
Kit.text("monoBig", tostring(S.mapId), vx0,
vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading)
local ok, map = pcall(MapLoader.load, S.data, S.mapId)
local ok, map
if Gen.of(S.save) == 2 then
local def = Gen.maps(S.data)[S.mapId]
if def then
local Map2 = require("src.world.gen2.Map")
if type(def.width) ~= "number" or type(def.height) ~= "number" then
ok, map = false, "incomplete map record (missing width/height)"
else
local tileset = Gen.tilesets(S.data)[def.tileset]
ok, map = pcall(Map2.new, def, tileset or {})
if ok and map and not map.renderer then
local MapPreview = require("src.world.gen2.MapPreview")
S._g2MapBaker = S._g2MapBaker or MapPreview.baker({
tilesets = Gen.tilesets(S.data),
gen2Roofs = S.data.gen2Roofs, roofs = S.data.roofs,
gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes,
})
map.renderer = MapPreview.renderer(S._g2MapBaker, map)
end
end
else
ok, map = false, "unknown map"
end
else
ok, map = pcall(MapLoader.load, S.data, S.mapId)
end
if not ok then
Kit.text("mono", "Failed to load map: " .. tostring(map), vx0,
vr.y + vpad + headH + 20 * s, PAL.red)
@@ -279,12 +329,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
local zBtn = 32 * s
local rightEdge = vx0 + vinner
local zoomW = 2 * zBtn + 56 * s + 12 * s
local pmap, px, py = playerPos(S)
local showCenter = vinner >= zoomW + 10 * s + centerW + 160 * s
if showCenter then
if Kit.button(rightEdge - centerW, vr.y + vpad, centerW, headH, "Center on player",
{ kind = "accent", font = "small", radius = 7 * s }) then
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
if pmap == S.mapId then
centerOn(S, px, py)
Ops.say(S, "Centred on the player")
else
Ops.say(S, "Player isn't on this map")
@@ -312,10 +363,11 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
-- the panel has laid itself out.
if S._mapCenteredFor ~= S.mapId then
S._mapCenteredFor = S.mapId
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
if pmap == S.mapId then
centerOn(S, px, py)
else
centerOn(S, map.widthCells / 2, map.heightCells / 2)
centerOn(S, (map.widthCells or map.width or 10) / 2,
(map.heightCells or map.height or 10) / 2)
end
end
@@ -334,7 +386,24 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
love.graphics.push()
love.graphics.translate(vx0, vy0)
love.graphics.scale(S.mapZoom, S.mapZoom)
map.renderer:draw(S.mapCamX, S.mapCamY)
if map.renderer and map.renderer.draw then
map.renderer:draw(S.mapCamX, S.mapCamY)
else
local wc = map.widthCells or ((map.width or 8) * 2)
local hc = map.heightCells or ((map.height or 8) * 2)
for cy = 0, hc - 1 do
for cx = 0, wc - 1 do
if (cx + cy) % 2 == 0 then
love.graphics.setColor(0.18, 0.22, 0.32, 1)
else
love.graphics.setColor(0.14, 0.17, 0.26, 1)
end
love.graphics.rectangle("fill",
cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
drawOverlays(S, map)
love.graphics.pop()
love.graphics.setScissor()
@@ -402,20 +471,31 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS")
local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s
local sInner = sr.w - 2 * pad
local player = S.save.player
local out = S.save.lastOutdoor
local heal = S.save.lastHeal
local spawns = {
{ key = "PLAYER", color = PAL.red,
value = ("%s (%d,%d)"):format(player.map, player.x, player.y),
set = function() Ops.setPlayerHere(S) end },
{ key = "LAST HEAL", color = PAL.green,
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
set = function() Ops.setLastHeal(S) end },
{ key = "LAST OUTDOOR", color = PAL.yellow,
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
set = function() Ops.setLastOutdoor(S, map) end },
}
local pmap2, px2, py2 = playerPos(S)
local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset"
local spawns
if Gen.of(S.save) == 2 then
spawns = {
{ key = "PLAYER", color = PAL.red, value = playerValue,
set = function() Ops.setPlayerHere(S) end },
{ key = "SPAWN", color = PAL.green,
value = tostring(S.save.spawn or "SPAWN_HOME"),
set = function() Ops.setLastHeal(S) end },
}
else
local out = S.save.lastOutdoor
local heal = S.save.lastHeal
spawns = {
{ key = "PLAYER", color = PAL.red, value = playerValue,
set = function() Ops.setPlayerHere(S) end },
{ key = "LAST HEAL", color = PAL.green,
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
set = function() Ops.setLastHeal(S) end },
{ key = "LAST OUTDOOR", color = PAL.yellow,
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
set = function() Ops.setLastOutdoor(S, map) end },
}
end
local spawnH = 62 * s
for i, sp in ipairs(spawns) do
local ry = sTop + (i - 1) * (spawnH + 8 * s)
@@ -431,7 +511,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
sx0 + pad + 12 * s, ry + spawnH - 10 * s - Kit.textHeight("mono"), PAL.muted)
end
local noteY = sTop + 3 * (spawnH + 8 * s) + 6 * s
local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s
Kit.textCenter("tiny",
"Click a cell first. Warp cells follow the warp instead of selecting. " ..
"Arrow keys / WASD pan, the wheel zooms.",
+52 -5
View File
@@ -19,17 +19,26 @@
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local Gen = require("Gen")
local MonEditor = {}
local DV_KEYS = { "attack", "defense", "speed", "special" }
local STAT_KEYS = {
local STAT_KEYS_G1 = {
{ key = "HP", field = "hp" },
{ key = "ATK", field = "attack" },
{ key = "DEF", field = "defense" },
{ key = "SPD", field = "speed" },
{ key = "SPC", field = "special" },
}
local STAT_KEYS_G2 = {
{ key = "HP", field = "hp" },
{ key = "ATK", field = "attack" },
{ key = "DEF", field = "defense" },
{ key = "SPA", field = "specialAttack" },
{ key = "SPD", field = "specialDefense" },
{ key = "SPE", field = "speed" },
}
-- Front sprites are read straight off the generated cache. One image per
-- species, cached for the process: the old panel called newImage every frame,
@@ -96,7 +105,7 @@ local function drawLevelRow(S, Kit, mon, lx0, ly)
end
lx = lx + bw + 8 * s
end
Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s,
Kit.text("mono", ("EXP %d"):format(Gen.exp(mon)), lx + 6 * s,
ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted)
return lh
end
@@ -106,7 +115,7 @@ end
local function levelRowWidth(Kit, mon)
local s = Kit.scale
return 52 * s + 2 * (40 * s + 8 * s) + 58 * s + 8 * s + 2 * (40 * s + 8 * s)
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(mon.exp or 0))
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(Gen.exp(mon)))
end
local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
@@ -179,7 +188,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
local tw = math.min(w - 40 * s, 340 * s)
Kit.textCenter("button",
"Pick a slot on the left to inspect it. Every change here re-runs the " ..
"Gen1 stat formulas, so HP and stats stay legal.",
"stat formulas, so HP and stats stay legal.",
x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted)
return
end
@@ -220,10 +229,13 @@ function MonEditor.draw(S, Kit, x, y, w, h)
end
-- the nickname section: a caption line (with the Clear button on it) plus
-- the field + Set row
local extraH = 0
if Gen.ofState(S) == 2 then extraH = 88 * s end
local nickFieldH = 30 * s
local contentH = pad + headerH + 18 * s
+ capH + 10 * s + nickFieldH + 18 * s
+ capH + 10 * s + cellH + 18 * s
+ extraH
+ colsH + pad
-- Called before the widgets so this frame already draws at the updated
@@ -311,8 +323,9 @@ function MonEditor.draw(S, Kit, x, y, w, h)
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
statsY = statsY + capH + 10 * s
local STAT_KEYS = Gen.ofState(S) == 2 and STAT_KEYS_G2 or STAT_KEYS_G1
local gap = 12 * s
local cellW = (inner - gap * 4) / 5
local cellW = (inner - gap * (#STAT_KEYS - 1)) / #STAT_KEYS
for i, st in ipairs(STAT_KEYS) do
local bx = cx + (i - 1) * (cellW + gap)
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
@@ -326,6 +339,40 @@ function MonEditor.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------- DVs | moves split
local colY = statsY + cellH + 18 * s
if Gen.ofState(S) == 2 then
local extraY = colY
Kit.caption(cx, extraY, "GOLD")
extraY = extraY + capH + 8 * s
local row = 28 * s
Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text)
if Kit.button(cx + inner - 70 * s, extraY, 70 * s, row, "Clear item",
{ kind = "danger", font = "tiny", radius = 6 * s }) then
Ops.setHeldItem(S, mon, nil)
end
extraY = extraY + row + 6 * s
Kit.text("tiny", ("HAPPINESS %d"):format(mon.happiness or 0), cx, extraY, PAL.text)
if Kit.stepper(cx + 140 * s, extraY, 28 * s, row, "-", { font = "small" }) then
Ops.setHappiness(S, mon, (mon.happiness or 0) - 10)
end
if Kit.stepper(cx + 174 * s, extraY, 28 * s, row, "+", { font = "small" }) then
Ops.setHappiness(S, mon, (mon.happiness or 0) + 10)
end
Kit.text("tiny", ("PKRS %d"):format(mon.pokerus or 0), cx + 220 * s, extraY, PAL.text)
if Kit.stepper(cx + 300 * s, extraY, 28 * s, row, "-", { font = "small" }) then
Ops.setPokerus(S, mon, (mon.pokerus or 0) - 1)
end
if Kit.stepper(cx + 334 * s, extraY, 28 * s, row, "+", { font = "small" }) then
Ops.setPokerus(S, mon, (mon.pokerus or 0) + 1)
end
extraY = extraY + row + 4 * s
local bits = {}
if mon.gender then bits[#bits + 1] = mon.gender end
if mon.shiny then bits[#bits + 1] = "shiny" end
if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end
Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ")
or "gender/shiny follow DVs", cx, extraY, PAL.caption)
colY = extraY + 22 * s
end
if narrow then
-- stacked: DVs first, then moves, then the two actions side by side at
-- full width (#715)