mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
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:
@@ -98,10 +98,69 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
}
|
||||
end
|
||||
|
||||
-- The species string is the source of truth. `mon.name` is a copy of that
|
||||
-- species' display name (GetPokemonName), kept so menus can print without a
|
||||
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
|
||||
-- and prints this copy. Changing species without rewriting it leaves the
|
||||
-- previous species' name on the party list and the SUMMARY's top line.
|
||||
function Mon.syncIdentity(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not def then return mon end
|
||||
mon.name = def.name or mon.species
|
||||
if def.types then mon.types = def.types end
|
||||
if mon.dvs 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 })
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
else
|
||||
mon.unownLetter = nil
|
||||
end
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Every screen that prints a mon without a Data lookup should go through here:
|
||||
-- nickname if the player set one, otherwise the species display copy `name`.
|
||||
-- Skipping `name` and jumping to `species` is how a swapped mon can still
|
||||
-- read as ABRA on one menu and RAYQUAZA on another.
|
||||
function Mon.displayName(mon)
|
||||
if type(mon) ~= "table" then return "?" end
|
||||
return mon.nickname or mon.name or mon.species or "?"
|
||||
end
|
||||
|
||||
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
|
||||
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
|
||||
-- `name` is the ABRA bug in a second closet.
|
||||
function Mon.eachSaveMon(save, fn)
|
||||
if type(save) ~= "table" or type(fn) ~= "function" then return end
|
||||
for _, mon in ipairs(save.party or {}) do fn(mon) end
|
||||
for _, box in pairs(save.boxes or {}) do
|
||||
if type(box) == "table" then
|
||||
for _, mon in ipairs(box) do fn(mon) end
|
||||
end
|
||||
end
|
||||
local dc = save.dayCare
|
||||
if type(dc) == "table" then
|
||||
if dc.man and dc.man.mon then fn(dc.man.mon) end
|
||||
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
|
||||
if dc.egg then fn(dc.egg) end
|
||||
end
|
||||
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
|
||||
end
|
||||
|
||||
function Mon.syncSaveIdentity(save, data)
|
||||
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
|
||||
end
|
||||
|
||||
function Mon.refreshStats(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not (def and def.baseStats) then return mon end
|
||||
Mon.syncIdentity(mon, data)
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
|
||||
mon.stats = stats
|
||||
|
||||
+5
-2
@@ -283,6 +283,9 @@ function Game2:continueGame(save)
|
||||
local modsDiff = SaveData.modsDiff(save, activeMods)
|
||||
self.save = save
|
||||
self:adoptSave(save)
|
||||
-- Editor species swaps used to leave mon.name on the previous species.
|
||||
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
|
||||
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
|
||||
-- options.lua wins over anything a save file carries: options are a display
|
||||
-- preference that survives New Game and is edited from the launcher, so a
|
||||
-- save written before they moved out must not drag old values back in.
|
||||
@@ -588,13 +591,13 @@ function Game2:useFieldItem(itemId)
|
||||
end
|
||||
if not allowed then
|
||||
self:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
return
|
||||
end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then
|
||||
self:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+205
-67
@@ -299,6 +299,11 @@ end
|
||||
|
||||
local CART_DRAG_SLOP = 8
|
||||
local TAU = math.pi * 2
|
||||
-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover
|
||||
-- scale cannot climb into the title row (or the gear) on desktop, high-DPI,
|
||||
-- or a portrait phone. Fraction of the shorter side, with a pixel floor.
|
||||
local CART_MESH_PAD = 0.07
|
||||
local CART_MESH_PAD_MIN = 8
|
||||
|
||||
local function cartridgeState(imp, version)
|
||||
imp._cartridge = imp._cartridge or {}
|
||||
@@ -543,8 +548,11 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
Theme.A.focus, 2, Theme.cardRadius() + 2)
|
||||
end
|
||||
|
||||
local halfW, halfH = w / 2, h / 2
|
||||
local depth = math.max(8, w * 0.14)
|
||||
local meshPad = math.max(CART_MESH_PAD_MIN,
|
||||
math.floor(math.min(w, h) * CART_MESH_PAD))
|
||||
local halfW = math.max(1, w / 2 - meshPad)
|
||||
local halfH = math.max(1, h / 2 - meshPad)
|
||||
local depth = math.max(8, (halfW * 2) * 0.14)
|
||||
local project = function(px, py, pz)
|
||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||
@@ -638,14 +646,8 @@ local function modStatusColor(status)
|
||||
end
|
||||
|
||||
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local function modScopeOptions(imp)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = { { id = nil, label = Strings("All games") } }
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if imp.ready and imp.ready[version] then
|
||||
@@ -653,6 +655,33 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
{ id = version, label = GameVersion.info(version).label }
|
||||
end
|
||||
end
|
||||
return options
|
||||
end
|
||||
|
||||
local function modScopeCurrentLabel(imp, options)
|
||||
for _, opt in ipairs(options) do
|
||||
if imp.modScope == opt.id then return opt.label end
|
||||
end
|
||||
return options[1] and options[1].label or Strings("All games")
|
||||
end
|
||||
|
||||
local function modScopeChipsWidth(options, gap, m)
|
||||
local need = 0
|
||||
for i, opt in ipairs(options) do
|
||||
need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if i < #options then need = need + gap end
|
||||
end
|
||||
return need
|
||||
end
|
||||
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = modScopeOptions(imp)
|
||||
|
||||
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
|
||||
local _, activeProf = LauncherMods.getProfiles()
|
||||
@@ -690,9 +719,12 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
})
|
||||
|
||||
if #options >= 2 then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if cx + cw <= profX - gap then
|
||||
local avail = profX - gap - cx
|
||||
-- Chips stay when they all fit; otherwise they used to be skipped and
|
||||
-- vanish off the portrait edge. Collapse to one menu in that case only.
|
||||
if modScopeChipsWidth(options, gap, m) <= avail then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
|
||||
"mod-scope-" .. tostring(opt.id or "all")) then
|
||||
local want = opt.id
|
||||
@@ -701,6 +733,15 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
end
|
||||
cx = cx + cw + gap
|
||||
end
|
||||
elseif avail > 0 then
|
||||
local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options),
|
||||
math.max(0, avail - math.floor(18 * m.s)))
|
||||
local cw = math.min(avail,
|
||||
Kit.textWidth("micro", shown) + math.floor(18 * m.s))
|
||||
if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then
|
||||
queueAction(imp, "mod-scope-menu",
|
||||
function() imp._modScopePopup = true end)
|
||||
end
|
||||
end
|
||||
end
|
||||
return h + math.floor(8 * m.s)
|
||||
@@ -740,8 +781,8 @@ local function setPage(imp, key, v)
|
||||
imp._pages[key] = v
|
||||
end
|
||||
|
||||
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
|
||||
-- no guaranteed glyph, and the launcher ships no icon asset for it.
|
||||
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
|
||||
-- and the launcher ships no icon asset for them.
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
@@ -752,6 +793,18 @@ local function drawCross(x, y, size, color)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- header
|
||||
-- Rail, logo row (settings and quit on the right), tab bar.
|
||||
-- Returns the y at which content may start. Its vertical arithmetic is
|
||||
@@ -1316,20 +1369,32 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
or tostring(version)
|
||||
local ready = (not locked) and imp.ready[version] or false
|
||||
|
||||
-- title + status tag
|
||||
-- title + status tag. Ready is a check chip (the font has no tick glyph);
|
||||
-- missing ROM stays a yellow "ROM REQUIRED" tag so it still reads as an action.
|
||||
local titleH = Kit.textHeight("title")
|
||||
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
|
||||
local tagText, tagCol
|
||||
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
|
||||
elseif imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
|
||||
+ math.floor(12 * m.s)
|
||||
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
|
||||
local tagY = y + (titleH - tagH) / 2
|
||||
local tagW, tagCol
|
||||
if ready then
|
||||
tagCol = PAL.green
|
||||
tagW = tagH
|
||||
if love.graphics then
|
||||
Theme.strokeRounded(tagX, tagY, tagW, tagH, tagCol, 0.7, 1)
|
||||
local ck = math.floor(tagH * 0.55)
|
||||
drawCheck(tagX + (tagW - ck) / 2, tagY + (tagH - ck) / 2, ck, tagCol)
|
||||
end
|
||||
else
|
||||
local tagText
|
||||
if imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
Kit.tag(tagX, tagY, tagW, tagH, tagText, tagCol)
|
||||
end
|
||||
if ready then
|
||||
local hint = Strings("(PRESS THE CART TO PLAY)")
|
||||
local hintX = tagX + tagW + math.floor(10 * m.s)
|
||||
@@ -1337,8 +1402,11 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
|
||||
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
|
||||
end
|
||||
local cy = y + titleH + math.floor(12 * m.s)
|
||||
local remaining = availH - (titleH + math.floor(12 * m.s))
|
||||
-- Extra gap under the title when the cart is showing: 12px left the 3D
|
||||
-- shell sitting on the hairline. Scaled, and still small on a phone.
|
||||
local afterTitle = math.floor((ready and 22 or 12) * m.s)
|
||||
local cy = y + titleH + afterTitle
|
||||
local remaining = availH - (titleH + afterTitle)
|
||||
|
||||
local gap = m.gap
|
||||
local lx, lw, rx2, rw
|
||||
@@ -1446,20 +1514,6 @@ local function currentSort(imp)
|
||||
return sortKey
|
||||
end
|
||||
|
||||
-- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and
|
||||
-- a tofu box on the "you already have this" signal would be worse than none.
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One compact coloured checkbox for each game. The cartridge colour carries
|
||||
-- the game identity even when the row is narrow.
|
||||
local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
@@ -1978,21 +2032,47 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github "
|
||||
.. "it might have been tampered with. go to the discord to verify "
|
||||
.. COMMUNITY_URL .. " (or click the logo above)"
|
||||
|
||||
-- Mark + optional updater + Patch notes. Chips match the mark's 22px
|
||||
-- height so they do not read as bigger than the logo; the row can still
|
||||
-- be tapMin tall for spacing. On a phone the notes chip drops onto a
|
||||
-- second row rather than overflowing the mark.
|
||||
local function footerLayout(imp, m, markW)
|
||||
local markH = math.floor(22 * m.s)
|
||||
local rowH = math.max(markH, Kit.tapMin())
|
||||
local notesLabel = Strings("Patch notes")
|
||||
-- Tight chip, still enough for Kit.button's labelInset so the words survive.
|
||||
local chipPad = math.floor(24 * m.s)
|
||||
local nw = Kit.textWidth("micro", notesLabel) + chipPad
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + chipPad) or 0
|
||||
local gap = math.floor(10 * m.s)
|
||||
local inner = m.w - 2 * m.pad
|
||||
local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw
|
||||
return {
|
||||
rowH = rowH, chipH = markH, gap = gap,
|
||||
notesLabel = notesLabel, nw = nw,
|
||||
upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow,
|
||||
uw = uw, wrap = topW > inner,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pinned to the bottom of the window; returns the y it starts at, so the
|
||||
-- panels above know how much room they have.
|
||||
-- Deliberately compact: at a large UI scale the footer is pure overhead
|
||||
-- competing with the panel for a short window's height, so the mark and the
|
||||
-- link share one line and the trust warning is capped at a single line.
|
||||
local function footerHeight(imp, m)
|
||||
-- Top pad + mark/update row + gap + the FULL wrapped trust message +
|
||||
-- bottom pad. The message wraps to as many lines as it needs: truncating
|
||||
-- a trust warning defeats its purpose, and the bottom pad is not optional
|
||||
-- either (without it the last line sits flush on the window edge and its
|
||||
-- lower half clips off). The row is tapMin tall because the small update
|
||||
-- button rides beside the mark.
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
return math.floor(8 * m.s) + rowH + math.floor(6 * m.s)
|
||||
+ Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
-- Top pad + mark/update row + optional notes wrap row + gap + the FULL
|
||||
-- wrapped trust message + bottom pad. The message wraps to as many lines
|
||||
-- as it needs: truncating a trust warning defeats its purpose, and the
|
||||
-- bottom pad is not optional either (without it the last line sits flush
|
||||
-- on the window edge and its lower half clips off). The row is tapMin
|
||||
-- tall because the small update button rides beside the mark.
|
||||
local f = footerLayout(imp, m, math.floor(130 * m.s))
|
||||
local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s)
|
||||
if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end
|
||||
return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
+ math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
@@ -2009,19 +2089,17 @@ local function buildFooter(imp, m, y)
|
||||
local bw, bh = imp.bcg:getDimensions()
|
||||
local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh)
|
||||
local dw, dh = bw * scale, bh * scale
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
-- The mark and the small self-update control share the row, centred as a
|
||||
-- group. The updater moved down here from the header, where it overlapped
|
||||
-- the wordmark on a phone; small on purpose, its glow still carries the
|
||||
-- "act on me" signal.
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
-- Kit.button insets its label 16*scale per side, so the width must budget
|
||||
-- more than that or the label ellipsizes ("Check for updat...").
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0
|
||||
local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0)
|
||||
local bx = m.x + math.floor((m.w - groupW) / 2)
|
||||
local f = footerLayout(imp, m, dw)
|
||||
local rowH, gap, chipH = f.rowH, f.gap, f.chipH
|
||||
-- The mark, the small self-update control, and Patch notes share the row,
|
||||
-- centred as a group. The updater moved down here from the header, where
|
||||
-- it overlapped the wordmark on a phone; small on purpose, its glow still
|
||||
-- carries the "act on me" signal. Notes wrap under the mark on a phone.
|
||||
local topW = dw + (f.upStatus and (gap + f.uw) or 0)
|
||||
if not f.wrap then topW = topW + gap + f.nw end
|
||||
local bx = m.x + math.floor((m.w - topW) / 2)
|
||||
local my = cy + math.floor((rowH - dh) / 2)
|
||||
local chipY = cy + math.floor((rowH - chipH) / 2)
|
||||
local hot = Kit.hover(bx, my, dw, dh)
|
||||
love.graphics.setShader(imp.invertShader)
|
||||
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
|
||||
@@ -2031,14 +2109,30 @@ local function buildFooter(imp, m, y)
|
||||
if Kit.press(bx, my, dw, dh) then
|
||||
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
|
||||
end
|
||||
if upStatus then
|
||||
btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater",
|
||||
upLabel, {
|
||||
kind = upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = upGlow, action = upAction,
|
||||
local cx = bx + dw
|
||||
if f.upStatus then
|
||||
cx = cx + gap
|
||||
btn(imp, cx, chipY, f.uw, chipH, "updater",
|
||||
f.upLabel, {
|
||||
kind = f.upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = f.upGlow, action = f.upAction,
|
||||
})
|
||||
cx = cx + f.uw
|
||||
end
|
||||
local function notesBtn(x, y)
|
||||
btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, {
|
||||
kind = "ghost", font = "micro",
|
||||
action = function() imp._appPatchNotes = true end,
|
||||
})
|
||||
end
|
||||
if f.wrap then
|
||||
cy = cy + rowH + gap
|
||||
notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy)
|
||||
cy = cy + chipH + math.floor(6 * m.s)
|
||||
else
|
||||
notesBtn(cx + gap, chipY)
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
end
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
-- The trust message wraps in full, each line centred under the mark, and
|
||||
-- the URL inside it IS the link -- no separate link floating elsewhere.
|
||||
-- font:getWrap never splits an unspaced word, so the URL stays whole on
|
||||
@@ -2543,6 +2637,34 @@ local function buildSortModal(imp, m)
|
||||
action = function() imp._sortPopup = nil end })
|
||||
end
|
||||
|
||||
-- Game-scope chooser used when the Show-for chips cannot all fit on the
|
||||
-- mods toolbar (portrait phones). Same options as the chip row.
|
||||
local function buildModScopeModal(imp, m)
|
||||
local options = modScopeOptions(imp)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(360 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #options * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Show for"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
for _, opt in ipairs(options) do
|
||||
local key = tostring(opt.id or "all")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-" .. key, opt.label, {
|
||||
kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small",
|
||||
action = function()
|
||||
imp:_setModScope(opt.id)
|
||||
imp._modScopePopup = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._modScopePopup = nil end })
|
||||
end
|
||||
|
||||
-- Category filter for FIND MODS. Two columns, because an index can list
|
||||
-- enough categories to overflow a single stacked column on a short window.
|
||||
local function buildFilterModal(imp, m)
|
||||
@@ -3253,8 +3375,9 @@ end
|
||||
local function modalUp(imp)
|
||||
return (imp._settingsText or imp._settings or imp._rename
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._appPatchNotes
|
||||
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
||||
or imp._filterPopup or imp._indexManage or imp._modActions
|
||||
or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._modActions
|
||||
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
||||
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
||||
end
|
||||
@@ -3351,6 +3474,20 @@ local function buildModals(imp, m)
|
||||
return true
|
||||
end
|
||||
if imp._modConfirm then buildConfirmModal(imp, m) return true end
|
||||
if imp._appPatchNotes then
|
||||
local PatchNotes = require("src.update.PatchNotes")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local raw, ver = PatchNotes.body(imp.Check)
|
||||
local body = ModUpdate.cleanBody(raw or "", 0)
|
||||
if body == "" then body = Strings("(No patch notes.)") end
|
||||
local title = Strings("Patch notes")
|
||||
if ver and ver ~= "" then
|
||||
title = title .. " v" .. tostring(ver)
|
||||
end
|
||||
buildTextModal(imp, m, "patch-notes-modal", title, body,
|
||||
function() imp._appPatchNotes = nil end)
|
||||
return true
|
||||
end
|
||||
if imp._modReleaseNotes then
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local n = imp._modReleaseNotes
|
||||
@@ -3380,6 +3517,7 @@ local function buildModals(imp, m)
|
||||
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
||||
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
||||
if imp._sortPopup then buildSortModal(imp, m) return true end
|
||||
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
|
||||
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
||||
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
||||
if imp._modActions then buildModActionsModal(imp, m) return true end
|
||||
|
||||
@@ -2820,7 +2820,7 @@ function RomImporter:keypressed(key)
|
||||
return
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
or self._findDetails or self._appPatchNotes then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
@@ -2833,6 +2833,8 @@ function RomImporter:keypressed(key)
|
||||
self._findDetails = nil
|
||||
elseif self._modReleaseNotes then
|
||||
self._modReleaseNotes = nil
|
||||
elseif self._appPatchNotes then
|
||||
self._appPatchNotes = nil
|
||||
else
|
||||
self._modConfirm = nil
|
||||
self._modVersions = nil
|
||||
|
||||
@@ -154,7 +154,7 @@ function HallOfFame.monPlacements(mon, def)
|
||||
put(out, genderGlyph(mon.gender), 18, 13)
|
||||
-- (8,14) is a bare '/', so the nickname starts at (9,14).
|
||||
put(out, "/", 8, 14)
|
||||
put(out, mon.nickname or mon.species, 9, 14)
|
||||
put(out, mon.nickname or mon.name or mon.species, 9, 14)
|
||||
put(out, levelText(mon.level), 1, 16)
|
||||
end
|
||||
-- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16).
|
||||
|
||||
@@ -630,7 +630,7 @@ function PackMenu:openTeachParty(row)
|
||||
if not allowed then
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -638,7 +638,7 @@ function PackMenu:openTeachParty(row)
|
||||
if move.id == moveId then
|
||||
if game.say then
|
||||
game:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
+12
-3
@@ -71,6 +71,9 @@ function Check.parseRelease(jsonText, Json)
|
||||
payloadName = payloadName,
|
||||
payload = Check.pickAsset(doc.assets, payloadName),
|
||||
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
||||
-- GitHub release body: already fetched with the update check, shown by
|
||||
-- the launcher's Patch notes footer button.
|
||||
notes = type(doc.body) == "string" and doc.body or "",
|
||||
}
|
||||
end
|
||||
|
||||
@@ -135,6 +138,10 @@ local function drain()
|
||||
if stateCh then
|
||||
local msg = stateCh:pop()
|
||||
while msg do
|
||||
if type(msg) == "table" and type(msg.notes) ~= "string"
|
||||
and type(cache.notes) == "string" then
|
||||
msg.notes = cache.notes
|
||||
end
|
||||
cache = msg
|
||||
msg = stateCh:pop()
|
||||
end
|
||||
@@ -158,11 +165,11 @@ function Check.start()
|
||||
return
|
||||
end
|
||||
requested = true
|
||||
cache = { status = "checking" }
|
||||
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
|
||||
cmdCh:push({ cmd = "check" })
|
||||
end
|
||||
|
||||
-- Current snapshot: { status, latest, progress, error }. status is one of
|
||||
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
|
||||
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
||||
function Check.state()
|
||||
drain()
|
||||
@@ -171,6 +178,7 @@ function Check.state()
|
||||
latest = cache.latest,
|
||||
progress = cache.progress,
|
||||
error = cache.error,
|
||||
notes = cache.notes,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -180,7 +188,8 @@ function Check.download()
|
||||
drain()
|
||||
if not cmdCh then return end
|
||||
if cache.status ~= "available" then return end
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0 }
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0,
|
||||
notes = cache.notes }
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Resolve the game's patch notes for the launcher footer modal.
|
||||
--
|
||||
-- Sources, first hit wins:
|
||||
-- 1. The GitHub body the self-updater already fetched (Check.parseRelease)
|
||||
-- 2. PATCH_NOTES.md, if a build packed one
|
||||
-- 3. mobile/ios/app-repo.json -- CI copies each release's notes into
|
||||
-- versions[].localizedDescription, so a checkout already has them
|
||||
-- on disk even when the updater has not run
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local PatchNotes = {}
|
||||
|
||||
PatchNotes.FILES = {
|
||||
"PATCH_NOTES.md",
|
||||
"assets/PATCH_NOTES.md",
|
||||
}
|
||||
|
||||
PatchNotes.REPO_FILES = {
|
||||
"mobile/ios/app-repo.json",
|
||||
}
|
||||
|
||||
local function nonempty(s)
|
||||
return type(s) == "string" and s:find("%S")
|
||||
end
|
||||
|
||||
function PatchNotes.fromCheck(Check)
|
||||
if not (Check and Check.state) then return nil, nil end
|
||||
local ok, st = pcall(Check.state)
|
||||
st = (ok and type(st) == "table") and st or nil
|
||||
if not st then return nil, nil end
|
||||
if nonempty(st.notes) then
|
||||
return st.notes, st.latest
|
||||
end
|
||||
return nil, st.latest
|
||||
end
|
||||
|
||||
local function readPath(path)
|
||||
local fs = love and love.filesystem
|
||||
if fs and fs.read then
|
||||
local ok, text = pcall(fs.read, path)
|
||||
if ok and nonempty(text) then return text end
|
||||
end
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local text = f:read("*a")
|
||||
f:close()
|
||||
return nonempty(text) and text or nil
|
||||
end
|
||||
|
||||
function PatchNotes.fromFile()
|
||||
for _, path in ipairs(PatchNotes.FILES) do
|
||||
local text = readPath(path)
|
||||
if text then return text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Newest-first list of { version, notes } from the iOS sidecar.
|
||||
function PatchNotes.parseRepo(jsonText)
|
||||
local doc = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" or type(doc.apps) ~= "table" then return {} end
|
||||
local out = {}
|
||||
for _, app in ipairs(doc.apps) do
|
||||
if type(app) == "table" and type(app.versions) == "table" then
|
||||
for _, row in ipairs(app.versions) do
|
||||
if type(row) == "table" and nonempty(row.localizedDescription) then
|
||||
out[#out + 1] = {
|
||||
version = row.version,
|
||||
notes = row.localizedDescription,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function PatchNotes.fromRepo(engine)
|
||||
for _, path in ipairs(PatchNotes.REPO_FILES) do
|
||||
local text = readPath(path)
|
||||
if text then
|
||||
local list = PatchNotes.parseRepo(text)
|
||||
if #list == 0 then return nil, nil end
|
||||
if engine and engine ~= "0.0.0-dev" then
|
||||
for _, row in ipairs(list) do
|
||||
if row.version == engine then
|
||||
return row.notes, row.version
|
||||
end
|
||||
end
|
||||
end
|
||||
return list[1].notes, list[1].version
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
function PatchNotes.body(Check)
|
||||
local notes, ver = PatchNotes.fromCheck(Check)
|
||||
if notes then return notes, ver end
|
||||
notes = PatchNotes.fromFile()
|
||||
if notes then return notes, ver end
|
||||
local Version = require("src.core.Version")
|
||||
local engine = (Version and Version.engine) or "?"
|
||||
notes, ver = PatchNotes.fromRepo(engine)
|
||||
if notes then return notes, ver end
|
||||
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
|
||||
.. "They appear here after the launcher checks GitHub for the latest "
|
||||
.. "release.", engine
|
||||
end
|
||||
|
||||
return PatchNotes
|
||||
@@ -45,7 +45,12 @@ local Boot = loadModule("src/update/Boot.lua")
|
||||
local cmdCh = love.thread.getChannel("update_check_cmd")
|
||||
local stateCh = love.thread.getChannel("update_check_state")
|
||||
|
||||
local function post(t) stateCh:push(t) end
|
||||
local function post(t)
|
||||
if pending and type(t) == "table" and t.notes == nil then
|
||||
t.notes = pending.notes
|
||||
end
|
||||
stateCh:push(t)
|
||||
end
|
||||
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
local isWindows = osName == "Windows"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
-- Bake a Gen 2 map canvas without a World instance. The save editor's map
|
||||
-- tab uses this so Gold rooms draw the same tiles the overworld would,
|
||||
-- instead of the checkerboard fallback Map2 has no renderer for.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local BorderFill = require("src.world.gen2.BorderFill")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
|
||||
local MapPreview = {}
|
||||
|
||||
local ROOF_TILESETS = {
|
||||
TILESET_JOHTO = true,
|
||||
TILESET_JOHTO_MODERN = true,
|
||||
TILESET_KANTO = true,
|
||||
}
|
||||
|
||||
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
|
||||
local atlasData = love.image.newImageData(Assets.resolve(atlasPath))
|
||||
local roofData = love.image.newImageData(Assets.resolve(roofPath))
|
||||
for t = 0, 8 do
|
||||
local destId = 0x0a + t
|
||||
local dx = (destId % tilesPerRow) * 8
|
||||
local dy = math.floor(destId / tilesPerRow) * 8
|
||||
local sx = t * 8
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = roofData:getPixel(sx + x, y)
|
||||
atlasData:setPixel(dx + x, dy + y, r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
local image = love.graphics.newImage(atlasData)
|
||||
image:setFilter("nearest", "nearest")
|
||||
return image
|
||||
end
|
||||
|
||||
local function optionalTable(data, gen2Key, plainKey, generated)
|
||||
if data[gen2Key] then return data[gen2Key] end
|
||||
if data[plainKey] then return data[plainKey] end
|
||||
local ok, mod = pcall(require, "data.generated." .. generated)
|
||||
if ok and type(mod) == "table" then return mod end
|
||||
return nil
|
||||
end
|
||||
|
||||
function MapPreview.baker(data)
|
||||
data = data or {}
|
||||
return {
|
||||
tilesets = data.gen2Tilesets or data.tilesets or {},
|
||||
-- Data:load does not pull roofs.lua; the Gold cache still has it.
|
||||
roofs = optionalTable(data, "gen2Roofs", "roofs", "roofs"),
|
||||
palettes = optionalTable(data, "gen2Palettes", "palettes", "palettes"),
|
||||
atlasCache = {},
|
||||
mapImages = {},
|
||||
}
|
||||
end
|
||||
|
||||
function MapPreview.atlasFor(baker, mapDef)
|
||||
if not (baker and mapDef) then return nil, nil end
|
||||
local tileset = baker.tilesets and baker.tilesets[mapDef.tileset]
|
||||
if not tileset then return nil, nil end
|
||||
local cacheKey = mapDef.tileset
|
||||
local roofName = nil
|
||||
local roofs = baker.roofs
|
||||
if ROOF_TILESETS[mapDef.tileset] then
|
||||
roofName = roofs and roofs.mapGroupRoofs and roofs.mapGroupRoofs[mapDef.group]
|
||||
end
|
||||
if roofName then cacheKey = cacheKey .. "|" .. roofName end
|
||||
local cached = baker.atlasCache[cacheKey]
|
||||
if cached then return cached, tileset end
|
||||
|
||||
local tilesPerRow = tileset.tilesPerRow or 16
|
||||
local atlas
|
||||
local roofSpec = roofName and roofs and roofs.roofs and roofs.roofs[roofName]
|
||||
if roofSpec and roofSpec.image and love.image and love.image.newImageData then
|
||||
local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow)
|
||||
if ok then atlas = img end
|
||||
end
|
||||
if not atlas then
|
||||
if not tileset.image then return nil, tileset end
|
||||
local ok, img = pcall(Assets.image, tileset.image)
|
||||
if not ok then return nil, tileset end
|
||||
atlas = img
|
||||
if atlas.setFilter then atlas:setFilter("nearest", "nearest") end
|
||||
end
|
||||
baker.atlasCache[cacheKey] = atlas
|
||||
return atlas, tileset
|
||||
end
|
||||
|
||||
-- Same bake as World:bakeMapImage (src/world/gen2/World.lua), minus the
|
||||
-- World fields the overworld keeps for anim overlays and cave flicker.
|
||||
function MapPreview.bake(baker, map, daytime)
|
||||
local atlas, tileset = MapPreview.atlasFor(baker, map.def)
|
||||
if not atlas or not tileset then return nil end
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local blocks = tileset.blocks
|
||||
local tilesPerRow = tileset.tilesPerRow or 16
|
||||
local pw, ph = map.width * 32, map.height * 32
|
||||
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
|
||||
if not okCanvas or not canvas then return nil end
|
||||
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
|
||||
local quads = {}
|
||||
local function quadFor(tile)
|
||||
local q = quads[tile]
|
||||
if q then return q end
|
||||
local sx = (tile % tilesPerRow) * 8
|
||||
local sy = math.floor(tile / tilesPerRow) * 8
|
||||
q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions())
|
||||
quads[tile] = q
|
||||
return q
|
||||
end
|
||||
|
||||
local tilePalettes = tileset.tilePalettes
|
||||
local bgSet = baker.palettes and daytime
|
||||
and Palettes.bgSet(baker.palettes, map.def, daytime) or nil
|
||||
local colored = bgSet and tilePalettes and GbcPalette.available()
|
||||
local clearColor = { 0.15, 0.55, 0.25 }
|
||||
if bgSet and bgSet[1] and bgSet[1][1] then
|
||||
local c = GbcPalette.color(bgSet[1], 1)
|
||||
clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 }
|
||||
end
|
||||
|
||||
local function drawTiles(slot)
|
||||
for by = 0, map.height - 1 do
|
||||
for bx = 0, map.width - 1 do
|
||||
local blockId = BorderFill.blockFor(
|
||||
map.blocks[by * map.width + bx + 1], map.borderBlock)
|
||||
local block = blocks and blocks[(blockId or 0) + 1]
|
||||
if block then
|
||||
for i = 0, 15 do
|
||||
local tile = block[i + 1] or 0
|
||||
local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1
|
||||
if not slot or tileSlot == slot then
|
||||
local tx = bx * 32 + (i % 4) * 8
|
||||
local ty = by * 32 + math.floor(i / 4) * 8
|
||||
love.graphics.draw(atlas, quadFor(tile), tx, ty)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function paint()
|
||||
love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.push()
|
||||
love.graphics.origin()
|
||||
if colored then
|
||||
for slot = 1, 8 do
|
||||
GbcPalette.with(bgSet[slot], function() drawTiles(slot) end)
|
||||
end
|
||||
else
|
||||
drawTiles(nil)
|
||||
end
|
||||
love.graphics.pop()
|
||||
end
|
||||
if canvas.renderTo then
|
||||
canvas:renderTo(paint)
|
||||
else
|
||||
paint()
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
function MapPreview.imageFor(baker, map)
|
||||
if not (baker and map and map.id) then return nil end
|
||||
local cached = baker.mapImages[map.id]
|
||||
if cached then return cached end
|
||||
local img = MapPreview.bake(baker, map, "DAY")
|
||||
baker.mapImages[map.id] = img or false
|
||||
return img
|
||||
end
|
||||
|
||||
function MapPreview.renderer(baker, map)
|
||||
local img = MapPreview.imageFor(baker, map)
|
||||
if not img then return nil end
|
||||
return {
|
||||
draw = function(_, camX, camY)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, -math.floor(camX or 0), -math.floor(camY or 0))
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
return MapPreview
|
||||
Reference in New Issue
Block a user