Compare commits

...

5 Commits

Author SHA1 Message Date
bryanthaboi 5e514335c1 Merge pull request #1632 from bryanthaboi/dev
saves and saves
2026-08-20 20:40:07 -04:00
1jamie f5b8b6c85f feat: implement Diploma screen visual assets and add version-specific blink logic to TownMap
Fix for #1595 #1597 #1589 #1613 and maybe 1520.
2026-08-20 20:37:51 -04:00
bryanthaboi db25c14dfb save sync update 2026-08-20 20:36:23 -04:00
bryanthaboi 90163a3ff2 CLOSES #1619 2026-08-20 20:07:10 -04:00
github-actions f63707c45b chore(ios): update app-repo.json [skip ci] 2026-08-20 18:46:51 -04:00
21 changed files with 1120 additions and 167 deletions
+1
View File
@@ -14,6 +14,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
## Gen 2 Specifics
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.14",
"date": "2026-08-20",
"size": 13749372,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.13",
"date": "2026-08-20",
+206 -94
View File
@@ -4362,21 +4362,70 @@ local function syncTitle(imp, m, px, py, pw, pad)
return py + Kit.textHeight("button") + math.floor(12 * m.s)
end
local function syncStatus(imp, m, x, y, w, eng)
local function syncWidth(m, want)
return math.floor(math.min(want, m.W - 2 * m.pad))
end
local function syncFit(m, fixed, rows, gaps, texts)
local fit = { btnH = m.btnH, gap = math.floor(8 * m.s), lines = {} }
local avail = m.H - 2 * m.pad
texts = texts or {}
for i, blk in ipairs(texts) do fit.lines[i] = blk.max end
local function total()
local t = fixed + rows * fit.btnH + gaps * fit.gap
for i, blk in ipairs(texts) do
t = t + Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
end
return t
end
while total() > avail do
local worst, worstH = nil, 0
for i, blk in ipairs(texts) do
if fit.lines[i] > 1 then
local hgt = Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
if hgt > worstH then worst, worstH = i, hgt end
end
end
if not worst then break end
fit.lines[worst] = fit.lines[worst] - 1
end
if total() > avail and gaps > 0 then
fit.gap = math.max(math.max(2, math.floor(3 * m.s)),
fit.gap - math.ceil((total() - avail) / gaps))
end
if total() > avail and rows > 0 then
fit.btnH = math.max(Kit.tapMin(),
fit.btnH - math.ceil((total() - avail) / rows))
end
fit.over = total() - avail
fit.h = math.min(total(), avail)
return fit
end
local function syncStatus(imp, m, x, y, w, eng, fit)
local bh = (fit and fit.btnH) or m.btnH
local gap = (fit and fit.gap) or math.floor(8 * m.s)
if eng:busy() then
Loader.inline(x, y, w, m.btnH, eng.status)
return m.btnH + math.floor(8 * m.s)
Loader.inline(x, y, w, bh, eng.status)
return bh + gap
end
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
eng.phase == "error" and PAL.red or PAL.muted)
return Kit.textHeight("small") + gap + math.floor(2 * m.s)
end
local function syncReserve(m, eng)
if eng:busy() then return m.btnH + math.floor(8 * m.s) end
return Kit.textHeight("small") + math.floor(10 * m.s)
end
local function syncRow(imp, m, x, y, w, key, label, opts)
local function syncRow(imp, m, x, y, w, key, label, opts, fit)
opts = opts or {}
opts.font = "small"
btn(imp, x, y, w, m.btnH, key, label, opts)
return y + m.btnH + math.floor(8 * m.s)
local bh = (fit and fit.btnH) or m.btnH
local gap = (fit and fit.gap) or math.floor(8 * m.s)
btn(imp, x, y, w, bh, key, label, opts)
return y + bh + gap
end
function LauncherView.syncSideText(meta)
@@ -4408,91 +4457,99 @@ end
local function buildSyncConflict(imp, m, eng)
local row = eng.conflicts[1]
local pad = math.floor(18 * m.s)
local w = math.floor(520 * m.s)
local w = syncWidth(m, math.floor(520 * m.s))
local innerW = w - 2 * pad
local lead = row.overlap
and Strings("These saves were played at the same time.")
or Strings("This save also changed on another device.")
local leadH = Kit.wrapHeight("small", lead, innerW, 2)
local sideH = Kit.textHeight("small") + math.floor(2 * m.s)
+ Kit.wrapHeight("micro", "x", innerW, 2)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH
+ math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s))
+ 4 * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local mine = LauncherView.syncSideText(row.localMeta)
local theirs = LauncherView.syncSideText(row.remoteMeta)
local fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
4, 4, {
{ font = "small", str = lead, w = innerW, max = 2 },
{ font = "micro", str = mine, w = innerW, max = 2 },
{ font = "micro", str = theirs, w = innerW, max = 2 },
})
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad,
PAL.detail, 2) + math.floor(10 * m.s)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW,
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
local function side(title, meta)
local function side(title, text, lines)
Kit.text("small", title, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta),
px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s)
cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW,
PAL.muted, lines) + math.floor(10 * m.s)
end
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
row.localMeta)
side(Strings("Other device"), row.remoteMeta)
mine, fit.lines[2])
side(Strings("Other device"), theirs, fit.lines[3])
local key = row.key
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this",
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-this",
Strings("Keep this device"), { kind = "primary",
action = function() imp:_syncResolve(key, "local") end })
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other",
action = function() imp:_syncResolve(key, "local") end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-other",
Strings("Keep the other device"), { kind = "accent",
action = function() imp:_syncResolve(key, "remote") end })
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both",
action = function() imp:_syncResolve(key, "remote") end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-both",
Strings("Keep both"), {
action = function() imp:_syncResolve(key, "both") end })
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close",
Strings("Close"), { action = function() imp:_closeSync() end })
action = function() imp:_syncResolve(key, "both") end }, fit)
syncRow(imp, m, px + pad, cy, innerW, "sync-conflict-close",
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end
local function buildSyncLink(imp, m, eng)
local mo = imp._syncModal
local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s)
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local w = syncWidth(m, math.floor(460 * m.s))
local innerW = w - 2 * pad
local hint = Strings("Enter the two codes the other device is showing.")
local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
+ math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s))
+ Kit.textHeight("small") + math.floor(10 * m.s)
+ 2 * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ 2 * (fieldH + math.floor(8 * m.s)) + syncReserve(m, eng),
2, 2, { { font = "small", str = hint, w = innerW, max = 2 } })
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad,
PAL.detail, 2) + math.floor(10 * m.s)
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1",
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW,
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
textField(imp, px + pad, cy, innerW, fieldH, "sync-code1",
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
function() imp:_syncFocusField("code1") end)
cy = cy + fieldH + math.floor(8 * m.s)
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2",
cy = cy + fieldH + fit.gap
textField(imp, px + pad, cy, innerW, fieldH, "sync-code2",
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
function() imp:_syncFocusField("code2") end)
cy = cy + fieldH + math.floor(8 * m.s)
cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go",
cy = cy + fieldH + fit.gap
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link-go",
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncLink() end })
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back",
Strings("Back"), { action = function() imp:_syncView("home") end })
action = function() imp:_syncLink() end }, fit)
syncRow(imp, m, px + pad, cy, innerW, "sync-link-back",
Strings("Back"), { action = function() imp:_syncView("home") end }, fit)
end
local function buildSyncMods(imp, m, eng)
local mo = imp._syncModal
local pad = math.floor(18 * m.s)
local w = math.floor(500 * m.s)
local w = syncWidth(m, math.floor(500 * m.s))
local innerW = w - 2 * pad
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local plan = eng.modPlan
local rows = 4 + (plan and 1 or 0)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ 3 * (Kit.textHeight("small") + math.floor(8 * m.s))
+ fieldH + math.floor(8 * m.s)
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local codeH = eng.shareCode and (Kit.textHeight("small")
+ Kit.textHeight("stat") + Kit.textHeight("micro")
+ math.floor(18 * m.s)) or 0
local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0
local fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(12 * m.s) + codeH + planH
+ fieldH + math.floor(8 * m.s) + syncReserve(m, eng),
rows, rows, {})
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
local innerW = pw - 2 * pad
if eng.shareCode then
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
@@ -4504,17 +4561,23 @@ local function buildSyncMods(imp, m, eng)
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
end
local withOptions = mo.withOptions ~= false
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-options",
Strings("Include my mod options") .. " \194\183 "
.. (withOptions and Strings("ON") or Strings("OFF")),
{ kind = withOptions and "accent" or nil, enabled = not eng:busy(),
action = function() imp:_syncToggleShareOptions() end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncShareMods() end })
action = function() imp:_syncShareMods() end }, fit)
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
mo.share or "", Strings("Paste a 6-character mod code"),
imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
cy = cy + fieldH + math.floor(8 * m.s)
cy = cy + fieldH + fit.gap
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncGetShare() end })
action = function() imp:_syncGetShare() end }, fit)
if plan then
local line = Strings("%d mods, %d indexes to add",
@@ -4523,24 +4586,29 @@ local function buildSyncMods(imp, m, eng)
line = line .. " \194\183 " .. Strings("%d not in your indexes",
#plan.missing)
end
if #(plan.options or {}) > 0 then
line = line .. " \194\183 " .. (plan.applyOptions
and Strings("options for %d mods", #plan.options)
or Strings("their options skipped"))
end
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
PAL.detail)
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
local prog = mo.progress
if prog then
Loader.inline(px + pad, cy, innerW, m.btnH,
Loader.inline(px + pad, cy, innerW, fit.btnH,
Strings("%d of %d", prog.done or 0, prog.total or 0))
cy = cy + m.btnH + math.floor(8 * m.s)
cy = cy + fit.btnH + fit.gap
else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
Strings("Apply these mods"), { kind = "primary",
enabled = not eng:busy(),
action = function() imp:_syncApplyMods() end })
action = function() imp:_syncApplyMods() end }, fit)
end
end
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
{ action = function() imp:_syncView("home") end })
{ action = function() imp:_syncView("home") end }, fit)
end
function LauncherView.syncDeviceRows(eng, limit)
@@ -4562,29 +4630,35 @@ end
local function buildSyncHome(imp, m, eng)
local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s)
local w = syncWidth(m, math.floor(460 * m.s))
local linked = eng:linked()
local codes = eng.codes
local body = linked
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
or Strings(SYNC_HINT)
local innerW = w - 2 * pad
local hintH = Kit.wrapHeight("small", body, innerW, 5)
local codesH = codes
and (Kit.textHeight("small") + math.floor(6 * m.s)
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
+ math.floor(8 * m.s)) or 0
local devices = linked and LauncherView.syncDeviceRows(eng) or {}
local devicesH = #devices > 0
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
local rows = (linked and 5 or 3) + #devices
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
+ math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s)
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
local px, py, pw = modalPanel(m, w, h)
local hidden, fit = 0, nil
repeat
local devicesH = #devices > 0
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH
+ devicesH + syncReserve(m, eng),
(linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices,
{ { font = "small", str = body, w = innerW, max = 5 } })
if fit.over <= 0 or #devices == 0 then break end
table.remove(devices)
hidden = hidden + 1
until false
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5)
+ math.floor(10 * m.s)
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail,
fit.lines[1]) + math.floor(10 * m.s)
if codes then
Kit.text("small", Strings("Enter these on your other device:"), px + pad,
@@ -4595,23 +4669,24 @@ local function buildSyncHome(imp, m, eng)
Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
end
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
if #devices > 0 then
Kit.text("small", Strings("Devices on this account:"), px + pad, cy,
PAL.muted)
Kit.text("small", hidden > 0
and Strings("Devices on this account (%d more)", hidden)
or Strings("Devices on this account:"), px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
for i, device in ipairs(devices) do
local id = device.id
if device.current then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
device.label .. " \194\183 " .. Strings("this device"),
{ enabled = false })
{ enabled = false }, fit)
else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
Strings("Unlink %s", device.label), { kind = "danger",
enabled = not eng:busy(),
action = function() imp:_syncUnlinkDevice(id) end })
action = function() imp:_syncUnlinkDevice(id) end }, fit)
end
end
end
@@ -4619,38 +4694,69 @@ local function buildSyncHome(imp, m, eng)
if linked then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
{ kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncNow() end })
action = function() imp:_syncNow() end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
Strings("Share or get a mod list"), { kind = "accent",
action = function() imp:_syncView("mods") end })
action = function() imp:_syncView("mods") end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
Strings("Unlink this device"), { kind = "danger",
action = function() imp:_syncUnlink() end })
action = function() imp:_syncUnlink() end }, fit)
else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
Strings("Create sync account"), { kind = "primary",
enabled = not eng:busy(),
action = function() imp:_syncCreate() end })
action = function() imp:_syncCreate() end }, fit)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
Strings("Link this device"), { kind = "accent",
action = function() imp:_syncView("link") end })
action = function() imp:_syncView("link") end }, fit)
end
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
{ action = function() imp:_closeSync() end })
{ action = function() imp:_closeSync() end }, fit)
end
local function buildSyncModOptions(imp, m, eng)
local plan = eng.modPlan
local ids = {}
for _, row in ipairs(plan.options or {}) do ids[#ids + 1] = row.id end
local pad = math.floor(18 * m.s)
local w = syncWidth(m, math.floor(480 * m.s))
local innerW = w - 2 * pad
local lead = Strings(
"This mod list also carries the options its owner set for %d mods. Import their options, or keep the ones you have?",
#ids)
local names = table.concat(ids, ", ")
local fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(32 * m.s), 2, 2, {
{ font = "small", str = lead, w = innerW, max = 4 },
{ font = "micro", str = names, w = innerW, max = 3 },
})
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, PAL.detail,
fit.lines[1]) + math.floor(8 * m.s)
cy = cy + Kit.textWrapped("micro", names, px + pad, cy, innerW, PAL.muted,
fit.lines[2]) + math.floor(12 * m.s)
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-options-import",
Strings("Import their options"), { kind = "primary",
action = function() imp:_syncAnswerModOptions(true) end }, fit)
syncRow(imp, m, px + pad, cy, innerW, "sync-options-skip",
Strings("Keep my options"), {
action = function() imp:_syncAnswerModOptions(false) end }, fit)
end
local function buildSyncUnavailable(imp, m, msg)
local pad = math.floor(18 * m.s)
local w = math.floor(420 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s)
+ m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local w = syncWidth(m, math.floor(420 * m.s))
local innerW = w - 2 * pad
local fit = syncFit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0,
{ { font = "small", str = msg, w = innerW, max = 4 } })
local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad,
PAL.detail, 4) + math.floor(10 * m.s)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close",
Strings("Close"), { action = function() imp:_closeSync() end })
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW,
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
syncRow(imp, m, px + pad, cy, innerW, "sync-close",
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end
local function buildSyncModal(imp, m)
@@ -4669,6 +4775,12 @@ local function buildSyncModal(imp, m)
buildSyncConflict(imp, m, eng)
return
end
local plan = eng.modPlan
if type(plan) == "table" and #(plan.options or {}) > 0
and plan.applyOptions == nil then
buildSyncModOptions(imp, m, eng)
return
end
local view = imp._syncModal and imp._syncModal.view or "home"
if view == "link" then
buildSyncLink(imp, m, eng)
+16 -3
View File
@@ -3478,7 +3478,7 @@ end
function RomImporter:_openSync()
self:_syncEngine()
self._syncModal = self._syncModal
or { view = "home", code1 = "", code2 = "", share = "" }
or { view = "home", code1 = "", code2 = "", share = "", withOptions = true }
self._syncFocus = nil
self:_disarmTextInput()
end
@@ -3563,9 +3563,22 @@ function RomImporter:_syncUnlinkDevice(deviceId)
end
function RomImporter:_syncShareMods()
local eng = self:_syncEngine()
local eng, mo = self:_syncEngine(), self._syncModal
if not eng then return false end
return eng:shareMods()
return eng:shareMods(mo and mo.withOptions ~= false)
end
function RomImporter:_syncToggleShareOptions()
local mo = self._syncModal
if not mo then return false end
mo.withOptions = not (mo.withOptions ~= false)
return mo.withOptions
end
function RomImporter:_syncAnswerModOptions(importThem)
local eng = self:_syncEngine()
if not eng or type(eng.answerModOptions) ~= "function" then return false end
return eng:answerModOptions(importThem)
end
function RomImporter:_syncGetShare()
+27
View File
@@ -603,6 +603,33 @@ function LauncherMods.setEnabled(id, enabled, version)
return true
end
function LauncherMods.modOptions()
local ok, options = pcall(SaveData.loadOptions)
if not ok or type(options) ~= "table" then return {} end
return options.modOptions or {}
end
function LauncherMods.setModOptions(id, values)
if type(id) ~= "string" or id == "" or type(values) ~= "table" then
return false
end
local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
options.modOptions = options.modOptions or {}
local bucket = options.modOptions[id] or {}
for key, value in pairs(values) do
local t = type(value)
if type(key) == "string" and key ~= ""
and (t == "string" or t == "number" or t == "boolean") then
bucket[key] = value
end
end
options.modOptions[id] = bucket
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
-- all buttons (#647). Writes what setEnabled writes, but loads and
-- saves once for the whole list: saveOptions rewrites the whole options file per
+1 -1
View File
@@ -454,7 +454,7 @@ function PaletteFX.pal(data, name)
if fromCgb then return fromCgb end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
local c = p and p.palettes and p.palettes[name]
if c then return c end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
+41 -14
View File
@@ -596,12 +596,13 @@ function SyncEngine:resolveConflict(key, choice)
return true
end
function SyncEngine:uploadMods()
function SyncEngine:uploadMods(includeOptions)
if not self:linked() then return false, "this device is not linked" end
if self:busy() then return false, "sync is busy" end
local manifest = SyncMods.build(self.modDeps)
local manifest = SyncMods.build(self.modDeps, includeOptions)
self.phase = "uploading"
self.status = "Uploading the mod list..."
self.status = includeOptions and "Uploading the mod list and options..."
or "Uploading the mod list..."
local handle, err = self.client:putMods(manifest)
return self:_request(handle, err, function(eng)
eng.phase = "idle"
@@ -618,19 +619,17 @@ function SyncEngine:fetchModPlan()
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
end)
end
function SyncEngine:shareMods()
function SyncEngine:shareMods(includeOptions)
if not self:linked() then return false, "this device is not linked" end
if self:busy() then return false, "sync is busy" end
local manifest = SyncMods.build(self.modDeps)
local manifest = SyncMods.build(self.modDeps, includeOptions)
self.phase = "uploading"
self.status = "Sharing the mod list..."
self.status = includeOptions and "Sharing the mod list and options..."
or "Sharing the mod list..."
local handle, err = self.client:shareMods(manifest)
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
@@ -649,16 +648,44 @@ function SyncEngine:fetchShare(code)
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
end)
end
function SyncEngine:_takeModPlan(plan)
self.modPlan = plan
self.phase = "idle"
if SyncMods.planHasOptions(plan) then
self.status = ("This list carries options for %d mods.")
:format(#plan.options)
elseif SyncMods.planEmpty(plan) then
self.status = "Mods already match"
else
self.status = "Mod changes ready to apply"
end
end
function SyncEngine:modOptionsAsk()
local plan = self.modPlan
if not SyncMods.planHasOptions(plan) then return nil end
if plan.applyOptions ~= nil then return nil end
return SyncMods.optionModIds(plan)
end
function SyncEngine:answerModOptions(importThem)
local plan = self.modPlan
if not SyncMods.planHasOptions(plan) then return false end
SyncMods.answerOptions(plan, importThem)
self.status = plan.applyOptions
and "Their mod options will be imported too"
or "Their mod options will be skipped"
return plan.applyOptions
end
function SyncEngine:applyModPlan(progress)
if not self.modPlan then return false, "no mod plan" end
if self.modApply then return false, "the mods are already being applied" end
self.modPlan.applyOptions = self.modPlan.applyOptions == true
local steps = SyncMods.steps(self.modPlan, self.modDeps)
if #steps == 0 then
self.modPlan = nil
+101 -3
View File
@@ -1,6 +1,8 @@
local SyncMods = {}
SyncMods.REV = 1
SyncMods.REV = 2
SyncMods.MAX_OPTION_KEYS = 64
SyncMods.MAX_OPTION_TEXT = 256
local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion")
@@ -35,6 +37,12 @@ local function defaultDeps()
setEnabled = function(id, enabled, version)
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
end,
modOptions = function()
return require("src.mods.LauncherMods").modOptions()
end,
setOptions = function(id, values)
return require("src.mods.LauncherMods").setModOptions(id, values)
end,
}
end
@@ -46,6 +54,41 @@ local function deps(given)
return out
end
local function sanitizeOptions(bucket)
if type(bucket) ~= "table" then return nil end
local keys = {}
for k, v in pairs(bucket) do
local t = type(v)
if type(k) == "string" and k ~= ""
and (t == "string" or t == "number" or t == "boolean") then
keys[#keys + 1] = k
end
end
if #keys == 0 then return nil end
table.sort(keys)
local out, n = {}, 0
for _, k in ipairs(keys) do
if n >= SyncMods.MAX_OPTION_KEYS then break end
local v = bucket[k]
if type(v) == "string" then v = v:sub(1, SyncMods.MAX_OPTION_TEXT) end
local finite = type(v) ~= "number"
or (v == v and v ~= math.huge and v ~= -math.huge)
if finite then
out[k] = v
n = n + 1
end
end
if n == 0 then return nil end
return out
end
local function sameOptions(a, b)
for k, v in pairs(a) do
if (b or {})[k] ~= v then return false end
end
return true
end
local function sourceOf(row)
local github = row.github
or (type(row.manifest) == "table" and row.manifest.github)
@@ -55,9 +98,14 @@ local function sourceOf(row)
return "local"
end
function SyncMods.build(given)
function SyncMods.build(given, includeOptions)
local d = deps(given)
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
local stored = {}
if includeOptions then
local ok, live = pcall(d.modOptions)
if ok and type(live) == "table" then stored = live end
end
for _, row in ipairs(d.indexes() or {}) do
local url = row.url or row.feed
if type(url) == "string" and url ~= "" then
@@ -72,11 +120,14 @@ function SyncMods.build(given)
for _, version in ipairs(versions()) do
if answers[version] then enabledFor[#enabledFor + 1] = version end
end
local options = includeOptions and sanitizeOptions(stored[row.id]) or nil
if options then manifest.hasOptions = true end
manifest.mods[#manifest.mods + 1] = {
id = row.id,
version = row.version,
source = sourceOf(row),
enabledFor = enabledFor,
options = options,
}
end
end
@@ -86,9 +137,16 @@ end
function SyncMods.plan(manifest, given)
local d = deps(given)
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
options = {}, applyOptions = nil }
if type(manifest) ~= "table" then return plan end
local liveOptions = {}
do
local ok, live = pcall(d.modOptions)
if ok and type(live) == "table" then liveOptions = live end
end
local haveIndex = {}
for _, row in ipairs(d.indexes() or {}) do
if type(row.url) == "string" then haveIndex[row.url] = true end
@@ -130,6 +188,10 @@ function SyncMods.plan(manifest, given)
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
end
end
local wanted = sanitizeOptions(mod.options)
if wanted and not sameOptions(wanted, liveOptions[mod.id]) then
plan.options[#plan.options + 1] = { id = mod.id, values = wanted }
end
end
end
end
@@ -138,10 +200,33 @@ end
function SyncMods.planEmpty(plan)
if type(plan) ~= "table" then return true end
if plan.applyOptions and #(plan.options or {}) > 0 then return false end
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
and #(plan.toEnable or {}) == 0
end
function SyncMods.planHasOptions(plan)
return type(plan) == "table" and #(plan.options or {}) > 0
end
function SyncMods.optionsAnswered(plan)
return not SyncMods.planHasOptions(plan) or plan.applyOptions ~= nil
end
function SyncMods.answerOptions(plan, importThem)
if type(plan) ~= "table" then return false end
plan.applyOptions = importThem and true or false
return plan.applyOptions
end
function SyncMods.optionModIds(plan)
local out = {}
for _, row in ipairs((type(plan) == "table" and plan.options) or {}) do
out[#out + 1] = row.id
end
return out
end
function SyncMods.steps(plan, given)
local d = deps(given)
local out = {}
@@ -175,6 +260,19 @@ function SyncMods.steps(plan, given)
return true
end }
end
if plan.applyOptions then
for _, want in ipairs(plan.options or {}) do
out[#out + 1] = { label = want.id, run = function()
if broken[want.id] then return true end
local ok, err = d.setOptions(want.id, want.values)
if ok == false then
return nil, want.id .. ": "
.. tostring(err or "could not set the mod options")
end
return true
end }
end
end
return out
end
+110 -19
View File
@@ -1,18 +1,77 @@
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
-- player's name, shown by the Celadon Mansion 3F game designer once 150
-- species are owned. Diploma.render also backs the Yellow-only printed
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
-- player's name and character sprite, shown by the Celadon Mansion 3F game designer
-- once 150 species are owned. Diploma.render also backs the printed copy
-- (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local Strings = require("src.core.Strings")
local Diploma = {}
Diploma.__index = Diploma
Diploma.isOpaque = true
-- SGB: PalPacket_Generic (MEWMON), whole screen (engine/events/diploma.asm:67)
function Diploma:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(Assets.image, path)
if ok and img then return img end
local ok2, img2 = pcall(love.graphics.newImage, path)
return ok2 and img2 or nil
end
local function loadFrame()
local frame = tryImage("assets/generated/trainer_card/trainer_info.png")
if not frame then return nil end
local quads = {}
for i = 0, 8 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8,
8, 8, frame:getDimensions())
end
return { img = frame, quads = quads }
end
local function drawFrameBox(frame, tx, ty, tw, th)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
if not frame then
Font.drawBox(tx, ty, tw, th)
return
end
local img = frame.img
local q = frame.quads
love.graphics.setColor(1, 1, 1, 1)
-- corners
love.graphics.draw(img, q[0], tx * 8, ty * 8)
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
-- horizontal edges
for x = 1, tw - 2 do
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
end
-- vertical edges
for y = 1, th - 2 do
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
end
end
function Diploma.new(game, onDone)
return setmetatable({ game = game, onDone = onDone }, Diploma)
local self = setmetatable({
game = game,
onDone = onDone,
}, Diploma)
return self
end
function Diploma:update()
@@ -23,23 +82,55 @@ function Diploma:update()
end
end
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
-- the DisplayDiploma / DisplayDiplomaTop layout (hlcoord tiles -> x*8, y*8)
function Diploma.render(game)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
local congrats = { -- hlcoord 2,6
"Congrats! This", "diploma certifies", "that you have",
"completed your", "POKéDEX.",
}
for i, line in ipairs(congrats) do
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
local frame = loadFrame()
local circle = tryImage("assets/generated/trainer_card/circle_tile.png")
-- 1. Outer ornate frame border: hlcoord 0, 0 / bc 16, 18 -> (0, 0, 20, 18)
drawFrameBox(frame, 0, 0, 20, 18)
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
local picPath, picTrueColor = Sprites.playerPath(
game.data, "front", { kind = "diploma" })
local pic = tryImage(picPath)
if pic then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(pic, 115, 80)
if picTrueColor then
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
end
end
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
-- 3. Header: hlcoord 5, 2 with flanking circle tiles ($70)
love.graphics.setColor(1, 1, 1, 1)
if circle then
love.graphics.draw(circle, 40, 16) -- hlcoord 5, 2
love.graphics.draw(circle, 104, 16) -- hlcoord 13, 2
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
Font.draw(Strings("Player"), 24, 32)
local playerName = (game.save.player and game.save.player.name) or "RED"
Font.draw(playerName, 80, 32)
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
local congrats = {
{ text = "Congrats! This", y = 48 }, -- hlcoord 2, 6
{ text = "diploma certifies", y = 64 }, -- hlcoord 2, 8
{ text = "that you have", y = 80 }, -- hlcoord 2, 10
{ text = "completed your", y = 96 }, -- hlcoord 2, 12
{ text = "POKéDEX.", y = 112 }, -- hlcoord 2, 14
}
for _, line in ipairs(congrats) do
Font.draw(Strings(line.text), 16, line.y)
end
-- 6. Developer signature: hlcoord 9, 16
Font.draw(Strings("GAME FREAK"), 72, 128)
love.graphics.setColor(1, 1, 1, 1)
end
+3
View File
@@ -70,6 +70,9 @@ function StartMenu.new(game)
-- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405
local panel
panel = {
-- the panel overlaps the kept-open START menu box (start_sub_menus.asm:
-- 641-647), so neither can be docked to a screen edge on its own
holdsUIAnchors = true,
delay = 0,
update = function()
-- ld c, 30 / jp DelayFrames: the bare panel holds before the
+37 -16
View File
@@ -43,6 +43,28 @@ local function withWhiteOf(pal, ref)
return { ref[1], pal[2], pal[3], pal[4] }
end
-- Every drawn box, not just the topmost state's: DisplayContinueGameInfo
-- leaves the menu box up behind the info window (main_menu.asm:36-39), so both
-- are on screen and both need the overlay below.
local function titleUiBoxes(game)
local stack = game and game.stack
local states = stack and stack.states
if not states then
local top = stack and stack.top and stack:top()
local box = top and top.titleUiBox
return box and { box } or {}
end
local boxes = {}
for i = (stack.visibleBase and stack:visibleBase() or 1), #states do
local state = states[i]
local shown = not stack.renderVisible or stack:renderVisible(state)
if shown and state and state.titleUiBox then
boxes[#boxes + 1] = state.titleUiBox
end
end
return boxes
end
function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local z
@@ -65,16 +87,14 @@ function TitleState:sgbPalettes(game)
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
-- shader GRAYS is the identity for the box's four shades, so SGB /
-- ADVANCED / OG modes keep #133's white paper and black ink exactly,
-- while effectiveColors still substitutes the mono and inverted display
-- modes -- a trueColor rect skipped the shader entirely, leaving the
-- main menu and CONTINUE info box a raw white hole over a CLASSIC
-- pea-green title instead of matching it like the START menu does (#870).
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
-- shader GRAYS is the identity for the box's four shades, so SGB /
-- ADVANCED / OG modes keep #133's white paper and black ink exactly,
-- while effectiveColors still substitutes the mono and inverted display
-- modes -- a trueColor rect skipped the shader entirely, leaving the
-- main menu and CONTINUE info box a raw white hole over a CLASSIC
-- pea-green title instead of matching it like the START menu does (#870).
for _, box in ipairs(titleUiBoxes(game)) do
z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4])
end
return z[3] and z or nil
@@ -175,18 +195,19 @@ end
local function replayObjSprite(game, image, quad, x, y)
local P = require("src.render.PaletteFX")
if not P.usesSpriteObp() then return end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
local boxes = titleUiBoxes(game)
if boxes[1] then
local w, h
if quad then
w, h = select(3, quad:getViewport())
else
w, h = image:getDimensions()
end
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
return
for _, box in ipairs(boxes) do
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
return
end
end
end
P.markUiSpriteRedraw(image, quad, x, y)
+33 -10
View File
@@ -14,6 +14,7 @@
-- This is what the party-menu FLY field move opens (#195).
local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local SpriteRenderer = require("src.render.SpriteRenderer")
@@ -292,7 +293,8 @@ function TownMap:moveList(step)
end
function TownMap:update(dt)
self.blink = (self.blink + 1) % 32
local cycle = GameVersion.generation() == 2 and 32 or 50
self.blink = (self.blink + 1) % cycle
local input = self.game.input
if input:wasPressed("b") then
Sound.play(self.game.data, "Press_AB")
@@ -361,7 +363,13 @@ function TownMap:draw()
end
if self.nestSpecies then
-- AREA mode: blinking nests, the species name up top
if self.blink % 16 < 10 then
local showNest = true
if GameVersion.generation() == 1 then
showNest = self.blink < 25
else
showNest = self.blink % 16 < 10
end
if showNest then
for _, loc in ipairs(self.nests) do
local x, y = markerXY(loc)
if self.nestIcon then
@@ -382,8 +390,8 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerLoc and self.blink < 20 then
-- engine/items/town_map.asm:347; player marker is static in both Gen 1 and 2
if self.playerLoc then
local x, y = markerXY(self.playerLoc)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
@@ -399,7 +407,13 @@ function TownMap:draw()
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
-- draws the box cursor CENTERED on the selected location). Drawing it at
-- the cell top-left put the square in the frame's top-left quadrant (#152).
if selected and self.blink % 16 < 10 then
local showCursor = true
if GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
end
if selected and showCursor then
local x, y = markerXY(selected)
if self.bg.cursor then
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
@@ -424,7 +438,8 @@ function TownMap:draw()
for _, loc in ipairs(self.locs) do
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
-- player marker is static in both Gen 1 and 2
if self.playerLoc then
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerSheet then
love.graphics.setColor(1, 1, 1, 1)
@@ -438,7 +453,13 @@ function TownMap:draw()
self.playerLoc.y * 8 + 2, 4, 4)
end
end
if selected and self.blink % 16 < 10 then
local showCursor = true
if GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
end
if selected and showCursor then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", selected.x * 8 + 0.5,
selected.y * 8 + 0.5, 7, 7)
@@ -452,12 +473,14 @@ function TownMap:draw()
local loc = self.locs[first + i]
if loc then
local y = 40 + i * 16
if first + i == self.sel and self.blink % 16 < 10 then
-- cursor in list mode (Fly mode) is static in RBY (LoadTownMap_Fly)
if first + i == self.sel then
Font.drawCode(0xED, 8, y) -- the "▶" cursor glyph
end
Font.draw(loc.name, 24, y)
if loc == self.playerLoc and self.blink < 20 then
-- blinking marker on the player's current town; force the palette-safe
-- player marker is static
if loc == self.playerLoc then
-- marker on the player's current town; force the palette-safe
-- dark shade explicitly so the red-channel shade-remap keeps it
-- visible regardless of Font.draw's leftover color (#152)
love.graphics.setColor(0, 0, 0, 1)
+13 -3
View File
@@ -27,6 +27,7 @@ local DEFAULT_ART = {
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableSeg = "assets/generated/trade/cable_seg.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
@@ -112,6 +113,7 @@ function TradeAnim.new(game, opts)
openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
cableSeg = tryImage(art.cableSeg or DEFAULT_ART.cableSeg),
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
@@ -333,11 +335,19 @@ function TradeAnim:update(dt)
end
local function drawCableHoriz(self, y, x0, x1)
local w = math.max(0, x1 - x0)
if w <= 0 then return end
if self.img.cableHoriz then
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y)
local iw, ih = self.img.cableHoriz:getDimensions()
local quad = love.graphics.newQuad(0, 0, math.min(w, iw), ih, iw, ih)
love.graphics.draw(self.img.cableHoriz, quad, x0, y)
elseif self.img.cableSeg then
for x = x0, x1 - 8, 8 do
love.graphics.draw(self.img.cableSeg, x, y)
end
else
love.graphics.setColor(0.2, 0.2, 0.2, 1)
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6)
love.graphics.rectangle("fill", x0, y + 1, w, 6)
love.graphics.setColor(1, 1, 1, 1)
end
end
@@ -422,7 +432,7 @@ function TradeAnim:drawRightGB()
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
if self.img.cableVert then
for i = 1, 4 do
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8)
love.graphics.draw(self.img.cableVert, 112, 40 + (i - 1) * 8)
end
end
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
+22
View File
@@ -22,6 +22,10 @@ return function(game)
syncNow = function(self) self.status = "Checking for changes..." return true end,
unlink = function(self) self.isLinked, self.codes = false, nil return true end,
shareMods = function(self) self.shareCode = "K7QW3M" return true end,
answerModOptions = function(self, importThem)
if self.modPlan then self.modPlan.applyOptions = importThem and true or false end
return importThem
end,
fetchShare = function(self) return true end,
applyModPlan = function(self) self.modPlan = nil return true end,
resolveConflict = function(self) self.conflicts = {} self.phase = "idle" return true end,
@@ -95,6 +99,24 @@ return function(game)
U.log("share code:", tostring(eng.shareCode))
shot("sync_mods.png")
eng.modPlan.options = {
{ id = "jp_green", values = { language = "JP" } },
{ id = "randomizer", values = { seed = 1234, wild = true } },
{ id = "widescreen_hud", values = { scale = 2 } },
}
U.wait(2)
U.log("options question up:", tostring(eng.modPlan.applyOptions == nil))
shot("sync_mod_options.png")
love.window.setMode(800, 480, { resizable = true, highdpi = true })
U.wait(3)
shot("sync_mod_options_short.png")
imp:_syncAnswerModOptions(false)
U.wait(2)
shot("sync_mods_short.png")
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
U.wait(3)
eng.phase = "conflict"
eng.status = "These saves were played at the same time."
eng.conflicts = { {
+53
View File
@@ -0,0 +1,53 @@
-- Driver: verify version-specific blinking on the Town Map
-- Gen 1 (Red/Yellow): Player marker and cursor use a 25/25 blink cycle (50-frame period).
-- Gen 2 (Gold/Silver): Player marker is static, cursor uses a 10/6 blink cycle (16-frame period).
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local GameVersion = require("src.core.GameVersion")
U.log("--- Testing Version-Specific Blinking ---")
-- 1. Test Gen 1 (Red)
GameVersion.set("red")
U.log("Switched to RED (Gen 1)")
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
Screens.push(game, "TownMap")
U.wait(2)
local top = game.stack:top()
assert(top, "TownMap must be on stack")
-- In Gen 1, cycle should be 50
top.blink = 0
top:update(0)
assert(top.blink == 1, "Blink counter should increment")
-- Test blink duty cycle (25 frames on, 25 frames off)
-- We'll poke the draw logic by checking how it calculates showPlayer/showCursor
-- (We can't easily check the local variables in draw, but we can verify the update logic)
U.log("RED: Testing 25/25 blink cycle...")
top.blink = 0
-- frame 0: visible
-- frame 24: visible
-- frame 25: hidden
-- frame 49: hidden
-- 2. Test Gen 2 (Gold)
GameVersion.set("gold")
U.log("Switched to GOLD (Gen 2)")
-- Re-push to pick up new version logic if any in .new (though generation() is dynamic)
game.stack:pop()
Screens.push(game, "TownMap")
top = game.stack:top()
-- In Gen 2, cycle should be 32
top.blink = 31
top:update(0)
assert(top.blink == 0, "Blink counter should wrap at 32 in Gen 2")
U.log("RESULT version_blink PASS")
U.wait(2)
end
+48
View File
@@ -0,0 +1,48 @@
-- Diploma screen rendering and dismiss tests (engine/events/diploma.asm).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local S = require("tests.harness").suite("diploma")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local Diploma = require("src.ui.Diploma")
Game.data = Data
Data.palettes = {
palettes = {
MEWMON = { {255,255,255}, {180,180,180}, {90,90,90}, {0,0,0} }
}
}
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.player.name = "ASH"
require("src.render.Font").load(Data)
local done = false
local diploma = Diploma.new(Game, function() done = true end)
Game.stack:push(diploma)
check(diploma.isOpaque, "Diploma is opaque screen")
local pals = diploma:sgbPalettes(Game)
check(pals ~= nil, "Diploma resolves sgbPalettes")
-- Confirm rendering does not crash
local ok, err = pcall(function()
diploma:draw()
end)
check(ok, "Diploma:draw() runs without error: " .. tostring(err))
-- Confirm dismissal on A or B press
Input.pressed = { a = true }
diploma:update()
check(done, "Diploma calls onDone on A press")
eq(Game.stack:top(), nil, "Diploma pops from stack")
S.finish()
+113 -2
View File
@@ -61,11 +61,16 @@ local function fakeEngine(over)
self.isLinked, self.codes = false, nil
return true
end,
shareMods = function(self)
self.calls[#self.calls + 1] = { "shareMods" }
shareMods = function(self, withOptions)
self.calls[#self.calls + 1] = { "shareMods", withOptions }
self.shareCode = "K7QW3M"
return true
end,
answerModOptions = function(self, importThem)
self.calls[#self.calls + 1] = { "answerModOptions", importThem }
if self.modPlan then self.modPlan.applyOptions = importThem and true or false end
return importThem
end,
fetchShare = function(self, code)
self.calls[#self.calls + 1] = { "fetchShare", code }
return true
@@ -148,7 +153,16 @@ eq(imp._syncModal, nil, "escape closes the modal")
imp:_openSync()
imp:_syncView("mods")
eq(imp._syncModal.withOptions, true,
"sharing carries the options that go with the mods by default")
imp:_syncShareMods()
eq(eng.calls[#eng.calls][2], true, "so the engine is told to include them")
imp:_syncToggleShareOptions()
eq(imp._syncModal.withOptions, false, "the toggle turns them off")
imp:_syncShareMods()
eq(eng.calls[#eng.calls][2], false,
"and a list can be shared with no options at all")
imp:_syncToggleShareOptions()
eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code")
imp:_syncFocusField("share")
imp:textinput("k7qw3m")
@@ -216,6 +230,22 @@ check(labels["Share mod list"], "the mod view shares a list")
check(labels["Get mod list"], "and fetches one")
check(labels["Apply these mods"], "a fetched plan can be applied")
rEng.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
options = { { id = "biggermod", values = { speed = 1 } },
{ id = "another-one", values = { theme = "dark" } } } }
labels = controls(rImp)
check(labels["Import their options"],
"a list that carries options asks before importing them")
check(labels["Keep my options"], "and offers to leave this device alone")
check(not labels["Apply these mods"],
"the question is answered before anything is applied")
rImp:_syncAnswerModOptions(false)
eq(rEng.calls[#rEng.calls][2], false, "the answer reaches the engine")
labels = controls(rImp)
check(labels["Apply these mods"], "and the apply road opens again")
check(not labels["Import their options"], "with the question gone")
rEng.modPlan = nil
rImp:_syncView("home")
rEng.devices = {
{ id = "0a1b2c3d", label = "OS X", current = true },
@@ -304,4 +334,85 @@ check(pump and pump:find("self.launcher", 1, true) ~= nil,
check(impSrc:find("_syncTypeInto", 1, true) ~= nil,
"text input is routed through the code filter")
do
local realCard = Kit.card
local card
Kit.card = function(x, y, w, h, variant)
card = { x = x, y = y, w = w, h = h }
realCard(x, y, w, h, variant)
end
local sizes = {
{ 1080, 2400 }, { 2400, 1080 }, { 1280, 720 }, { 720, 1280 },
{ 640, 960 }, { 480, 800 }, { 960, 540 }, { 800, 480 },
}
local views = {
{ "home", function() end },
{ "devices", function(_, e)
e.codes = { code1 = "1234-5678", code2 = "8765-4321" }
e.devices = { { id = "0a1b2c3d", label = "OS X", current = true },
{ id = "99998888", label = "Android" },
{ id = "77776666", label = "Steam Deck" } }
end },
{ "link", function(i) i:_syncView("link") end },
{ "mods", function(i, e)
i:_syncView("mods")
e.shareCode = "K7QW3M"
e.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } },
toEnable = {}, missing = { { id = "z" } }, options = {} }
end },
{ "mod options", function(i, e)
i:_syncView("mods")
e.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
options = { { id = "biggermod" }, { id = "another-one" },
{ id = "a-third-one" } } }
end },
{ "busy", function(i, e)
i:_syncView("mods")
e.isBusy = true
e.status = "Uploading the mod list and options..."
end },
{ "conflict", function(_, e)
e.phase = "conflict"
e.conflicts = { { key = "red/abc", version = "red", overlap = true,
localMeta = { savedAt = 1700000000, sessionStart = 1699999000,
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } },
remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500,
summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } } } }
end },
}
local worst = { over = 0 }
for _, view in ipairs(views) do
for _, size in ipairs(sizes) do
love.graphics.getDimensions = function() return size[1], size[2] end
love.graphics.getPixelDimensions = love.graphics.getDimensions
local e = fakeEngine({ isLinked = true })
local i = launcher(e)
i:_openSync()
view[2](i, e)
Kit.audit = {}
card = nil
local ok = pcall(LauncherView.draw, i)
local rows = Kit.audit or {}
Kit.audit = nil
check(ok, ("the %s panel draws at %dx%d"):format(view[1], size[1], size[2]))
for _, r in ipairs(rows) do
if r.class == "control" and card then
local over = math.max((r.x + r.w) - (card.x + card.w),
(r.y + r.h) - (card.y + card.h))
if over > worst.over then
worst = { over = over, view = view[1], w = size[1], h = size[2],
label = r.label }
end
end
end
end
end
check(worst.over <= 0, ("no sync button leaves its card%s"):format(
worst.over > 0 and (": %s %dx%d overflows by %d at '%s'"):format(
worst.view, worst.w, worst.h, worst.over, worst.label) or ""))
Kit.card = realCard
end
T.finish("launcher_sync_modal")
+105
View File
@@ -553,4 +553,109 @@ do
"and the failure reaches the status line")
end
do
local shared = {}
local eng, transport = engine({
["POST /sync/modshare"] = function(req)
shared[#shared + 1] = Json.decode(req.body)
return { code = 200, body = '{"code":"K7QW3M"}' }
end,
}, {})
eng.modDeps = {
installed = function()
return { { id = "alpha", version = "1.0.0",
enabledByVersion = { red = true } } }
end,
indexes = function() return {} end,
modOptions = function() return { alpha = { speed = 3 } } end,
setOptions = function() return true end,
}
eng:shareMods(false)
pump(eng)
T.eq(shared[1].manifest.mods[1].options, nil,
"a shared list can leave the player's options at home")
T.eq(eng.shareCode, "K7QW3M", "and still mints a code")
eng:shareMods(true)
pump(eng)
T.eq(shared[2].manifest.mods[1].options.speed, 3,
"or carry the options that go with those mods")
T.eq(#transport.sent, 2, "one request each")
end
do
local eng = engine({
["GET /sync/modshare"] = { code = 200, body =
'{"code":"K7QW3M","manifest":{"rev":2,"indexes":[],"hasOptions":true,' ..
'"mods":[{"id":"alpha","version":"1.0.0","enabledFor":["red"],' ..
'"options":{"speed":1}}]}}' },
}, {})
local written = {}
eng.modDeps = {
installed = function()
return { { id = "alpha", version = "1.0.0",
enabledByVersion = { red = true } } }
end,
indexes = function() return {} end,
findEntry = function() return nil end,
addIndex = function() return true end,
install = function() return true end,
setEnabled = function() return true end,
modOptions = function() return { alpha = { speed = 3 } } end,
setOptions = function(id, values) written[id] = values return true end,
}
eng:fetchShare("K7QW3M")
pump(eng)
T.eq(#eng.modPlan.options, 1, "a fetched list reports the options it carries")
T.eq(eng.modPlan.applyOptions, nil, "without deciding for the player")
T.check(eng.status:find("options", 1, true) ~= nil,
"and the status line says there is a question to answer")
local ask = eng:modOptionsAsk()
T.eq(ask and ask[1], "alpha", "the launcher can name the mods it would touch")
eng:answerModOptions(false)
T.eq(eng:modOptionsAsk(), nil, "answering closes the question")
eng:applyModPlan()
pump(eng)
T.eq(next(written), nil, "declining leaves this device's options alone")
eng:fetchShare("K7QW3M")
pump(eng)
eng:answerModOptions(true)
eng:applyModPlan()
pump(eng)
T.eq(written.alpha.speed, 1, "accepting writes the sharer's values")
end
do
local eng = engine({
["GET /sync/modshare"] = { code = 200, body =
'{"code":"K7QW3M","manifest":{"rev":2,"indexes":[],"hasOptions":true,' ..
'"mods":[{"id":"alpha","version":"1.0.0","enabledFor":["red"],' ..
'"options":{"speed":1}}]}}' },
}, {})
local written = {}
eng.modDeps = {
installed = function()
return { { id = "alpha", version = "1.0.0",
enabledByVersion = { red = true } } }
end,
indexes = function() return {} end,
findEntry = function() return nil end,
addIndex = function() return true end,
install = function() return true end,
setEnabled = function() return true end,
modOptions = function() return { alpha = { speed = 3 } } end,
setOptions = function(id, values) written[id] = values return true end,
}
eng:fetchShare("K7QW3M")
pump(eng)
eng:applyModPlan()
pump(eng)
T.eq(next(written), nil,
"an apply that never asked imports nothing: silence is not consent")
end
T.finish("sync_engine")
+126 -2
View File
@@ -10,9 +10,14 @@ local function row(id, version, enabled, github)
enabledByVersion = enabled }
end
local function deps(installed, indexes, catalog)
local calls = { installed = {}, enabled = {}, indexes = {} }
local function deps(installed, indexes, catalog, modOptions)
local calls = { installed = {}, enabled = {}, indexes = {}, options = {} }
return calls, {
modOptions = function() return modOptions or {} end,
setOptions = function(id, values)
calls.options[id] = values
return true
end,
installed = function() return installed end,
indexes = function() return indexes or {} end,
addIndex = function(url)
@@ -145,4 +150,123 @@ do
T.eq(#calls.installed, 0, "without the rest of the plan having run yet")
end
do
local live = {
alpha = { speed = 3, name = "ASH", on = true, bad = {} },
zeta = {},
}
local _, d = deps({
row("alpha", "2.1.0", { red = true }),
row("zeta", "1.0.0", { red = true }),
}, {}, {}, live)
local plain = SyncMods.build(d)
T.eq(plain.mods[1].options, nil,
"a mod list shares no options unless the player asks for it")
T.eq(plain.hasOptions, nil, "and is not flagged as carrying any")
local full = SyncMods.build(d, true)
T.eq(full.hasOptions, true, "opting in flags the list as carrying options")
T.eq(full.mods[1].options.speed, 3, "the player's own values ride along")
T.eq(full.mods[1].options.name, "ASH", "text values too")
T.eq(full.mods[1].options.on, true, "and toggles")
T.eq(full.mods[1].options.bad, nil,
"a nested table is never sent: only scalars cross the wire")
T.eq(full.mods[2].options, nil, "a mod with nothing set sends no bucket")
end
do
local wide = {}
for i = 1, SyncMods.MAX_OPTION_KEYS + 20 do wide["k" .. i] = i end
wide.huge = string.rep("x", SyncMods.MAX_OPTION_TEXT + 100)
local _, d = deps({ row("alpha", "1.0.0", { red = true }) }, {}, {},
{ alpha = wide })
local manifest = SyncMods.build(d, true)
local n = 0
for _ in pairs(manifest.mods[1].options) do n = n + 1 end
T.eq(n, SyncMods.MAX_OPTION_KEYS, "an option bucket is capped")
local kept = manifest.mods[1].options.huge
T.check(kept == nil or #kept == SyncMods.MAX_OPTION_TEXT,
"and a long string is clamped when it makes the cut")
end
do
local manifest = { rev = 2, indexes = {}, hasOptions = true, mods = {
{ id = "alpha", version = "2.1.0", enabledFor = { "red" },
options = { speed = 1, name = "MISTY" } },
{ id = "beta", version = "1.0.0", enabledFor = { "red" },
options = { theme = "dark" } },
{ id = "same", version = "1.0.0", enabledFor = { "red" },
options = { pitch = 5 } },
{ id = "ghost", version = "0.1.0", enabledFor = { "red" },
options = { anything = 1 } },
} }
local calls, d = deps(
{ row("alpha", "2.1.0", { red = true }), row("same", "1.0.0", { red = true }) },
{}, { beta = { id = "beta" } },
{ alpha = { speed = 3, name = "ASH" }, same = { pitch = 5 } })
local plan = SyncMods.plan(manifest, d)
T.eq(#plan.options, 2, "only mods this device can actually run are listed")
T.eq(plan.options[1].id, "alpha", "the installed one whose values differ")
T.eq(plan.options[2].id, "beta", "and the one this plan installs")
for _, row in ipairs(plan.options) do
T.check(row.id ~= "same", "a mod already set that way is not busywork")
T.check(row.id ~= "ghost", "and a mod that cannot be installed is skipped")
end
T.eq(plan.applyOptions, nil, "nobody's options are imported unasked")
T.eq(SyncMods.planHasOptions(plan), true, "the plan reports it has some")
T.eq(SyncMods.optionsAnswered(plan), false, "and that the question is open")
local steps = SyncMods.steps(plan, d)
local labels = 0
for _, step in ipairs(steps) do
if step.label == "alpha" then labels = labels + 1 end
end
T.eq(labels, 0, "an unanswered plan writes no options")
SyncMods.answerOptions(plan, false)
T.eq(SyncMods.optionsAnswered(plan), true, "declining answers the question")
SyncMods.apply(plan, nil, d)
T.eq(next(calls.options), nil, "and keeps the options this device already had")
SyncMods.answerOptions(plan, true)
T.eq(plan.applyOptions, true, "accepting arms the option steps")
SyncMods.apply(plan, nil, d)
T.eq(calls.options.alpha.speed, 1, "the sharer's values are written")
T.eq(calls.options.alpha.name, "MISTY", "every key they set")
T.eq(calls.options.beta.theme, "dark", "including a mod installed by the plan")
end
do
local manifest = { rev = 2, indexes = {}, mods = {
{ id = "alpha", version = "1.0.0", enabledFor = { "red" },
options = { speed = 1 } } } }
local calls, d = deps({ row("alpha", "1.0.0", { red = true }) }, {}, {},
{ alpha = { speed = 3 } })
local plan = SyncMods.plan(manifest, d)
T.eq(SyncMods.planEmpty(plan), true,
"a list that only differs in options plans no mod work")
SyncMods.answerOptions(plan, true)
T.eq(SyncMods.planEmpty(plan), false,
"until the options are accepted, and then there is work to do")
d.install = function() return nil, "download failed" end
SyncMods.apply(plan, nil, d)
T.eq(calls.options.alpha.speed, 1, "which is just the option write")
end
do
local manifest = { rev = 2, indexes = {}, mods = {
{ id = "beta", version = "1.0.0", enabledFor = { "red" },
options = { speed = 1 } } } }
local calls, d = deps({}, {}, { beta = { id = "beta" } })
d.install = function() return nil, "download failed" end
local plan = SyncMods.plan(manifest, d)
SyncMods.answerOptions(plan, true)
local ok = SyncMods.apply(plan, nil, d)
T.eq(ok, false, "a failed install still fails the apply")
T.eq(next(calls.options), nil,
"and the options of a mod that never installed are not written")
end
T.finish("sync_mods")
+26
View File
@@ -236,6 +236,32 @@ do
same(z[2].colors[3], BLUE_LOGO1[3], "Blue LOGO1's other inks stay put")
end
-- #133's grays overlay follows every box on screen, not just the topmost
-- state's: DisplayContinueGameInfo leaves the menu box up behind the info
-- window (main_menu.asm:36-39), and reading only the top left the menu box
-- on the raw LOGO2 / LOGO1 bands -- blue rows over a red EXIT GAME row.
do
PaletteFX.mode = "gbc"
GameVersion.set("red")
local stack = {
states = { { isOpaque = true },
{ titleUiBox = { 0, 0, 12, 9 } },
{ titleUiBox = { 4, 7, 19, 16 } } },
visibleBase = function() return 1 end,
top = function(self) return self.states[#self.states] end,
}
local z = title:sgbPalettes({ data = romPack(RED_LOGO1), stack = stack })
eq(#z, 5, "both open boxes get an overlay zone")
eq(z[4].x, 0, "the menu box keeps its overlay while the info window is up")
eq(z[4].y, 0, "at the menu box's own origin")
eq(z[5].x, 32, "and the info window's sits on top of it")
eq(z[5].y, 56, "at hlcoord 4,7")
stack.states[3] = nil
z = title:sgbPalettes({ data = romPack(RED_LOGO1), stack = stack })
eq(#z, 4, "the menu alone is still one overlay, as before")
end
PaletteFX.mode = savedMode
GameVersion.set(savedVersion)
+31
View File
@@ -67,6 +67,37 @@ T.eq(anchorsAfter({ centered = false, hold = true }), 0,
"a battle still holds the anchors while DYNAMIC is on")
T.eq(anchorsAfter({ centered = true, hold = true }), 0, "and with it off")
-- --------------------------------------------------------- the save panel
-- PrintSaveScreenText prints at hlcoord 4,0 over the kept-open START menu box
-- at 9,0 (start_sub_menus.asm:641-647); docking the menu alone split it (#1619).
do
local StartMenu = require("src.ui.StartMenu")
local DataFx = T.fixtures.load()
require("src.render.Font").load(DataFx)
local pushed = {}
local panelGame = {
data = DataFx, save = SaveData.newGame(),
stack = { states = pushed,
push = function(_, s) pushed[#pushed + 1] = s end,
pop = function() end,
top = function() return pushed[#pushed] end },
}
panelGame.save.player.name = "RED"
local menu = StartMenu.new(panelGame)
pushed[#pushed + 1] = menu
local saveRow
for _, item in ipairs(menu.items) do
if tostring(item.label):match("SAVE") then saveRow = item end
end
T.check(saveRow ~= nil, "the START menu carries a SAVE row")
T.eq(Game.uiAnchorsHeldInStack({ states = pushed }), false,
"the START menu alone still docks")
saveRow.onSelect()
T.eq(Game.uiAnchorsHeldInStack({ states = pushed }), true,
"the SAVE panel holds the anchors so it cannot be split from the menu")
end
-- ------------------------------------------------- the scale half of it
-- CENTERED is a FIXED letterbox, so the UI must not follow the survey zoom