mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 04:31:09 +02:00
skin studio updates, save sync CLOSES #1533
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
local Json = require("src.link.Json")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
|
||||
local DeltaSkin = {}
|
||||
|
||||
DeltaSkin.INFO_NAME = "info.json"
|
||||
DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024
|
||||
|
||||
DeltaSkin.GAME_TYPE_PREFIXES = {
|
||||
"com.rileytestut.delta.game.",
|
||||
"public.aoshuang.game.",
|
||||
}
|
||||
|
||||
DeltaSkin.SYSTEMS = { gb = true, gbc = true }
|
||||
|
||||
DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||
|
||||
DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" }
|
||||
DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" }
|
||||
DeltaSkin.ORIENTATIONS = { "portrait", "landscape" }
|
||||
DeltaSkin.SIDES = { "up", "down", "left", "right" }
|
||||
|
||||
DeltaSkin.ASSET_LADDER = { "small", "medium", "large" }
|
||||
DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 }
|
||||
DeltaSkin.DEFAULT_TARGET_WIDTH = 1080
|
||||
|
||||
DeltaSkin.INPUTS = {
|
||||
a = "a", b = "b", start = "start", select = "select",
|
||||
up = "up", down = "down", left = "left", right = "right",
|
||||
menu = "menu_toggle",
|
||||
fastforward = "hold_fast_forward",
|
||||
togglefastforward = "toggle_fast_forward",
|
||||
}
|
||||
|
||||
DeltaSkin.OUTPUT_HOTKEYS = {
|
||||
menu = "menu",
|
||||
fast_forward_hold = "fastForward",
|
||||
fast_forward_toggle = "toggleFastForward",
|
||||
}
|
||||
|
||||
DeltaSkin.MAPPING = {
|
||||
portrait = { width = 1080, height = 1920 },
|
||||
landscape = { width = 1920, height = 1080 },
|
||||
}
|
||||
|
||||
DeltaSkin.SCREEN_WIDTH = 160
|
||||
DeltaSkin.SCREEN_HEIGHT = 144
|
||||
|
||||
local function pick(t, key)
|
||||
if type(t) ~= "table" then return nil end
|
||||
local direct = t[key]
|
||||
if direct ~= nil then return direct end
|
||||
local want = tostring(key):lower()
|
||||
for k, v in pairs(t) do
|
||||
if tostring(k):lower() == want then return v end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function numOr(v, fallback)
|
||||
local n = tonumber(v)
|
||||
if not n or n ~= n then return fallback end
|
||||
return n
|
||||
end
|
||||
|
||||
local function round(n)
|
||||
return math.floor(numOr(n, 0) + 0.5)
|
||||
end
|
||||
|
||||
local function isArray(t)
|
||||
return type(t) == "table" and t[1] ~= nil
|
||||
end
|
||||
|
||||
local function addWarning(list, text)
|
||||
if type(list) ~= "table" then return end
|
||||
for _, existing in ipairs(list) do
|
||||
if existing == text then return end
|
||||
end
|
||||
list[#list + 1] = text
|
||||
end
|
||||
|
||||
function DeltaSkin.findInfo(root)
|
||||
local direct = root .. "/" .. DeltaSkin.INFO_NAME
|
||||
if TouchSkin.readFile(direct) then return direct, "" end
|
||||
local items = TouchSkin.listDir(root)
|
||||
for _, name in ipairs(items) do
|
||||
if tostring(name):lower() == DeltaSkin.INFO_NAME then
|
||||
return root .. "/" .. name, ""
|
||||
end
|
||||
end
|
||||
table.sort(items)
|
||||
for _, name in ipairs(items) do
|
||||
local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME
|
||||
if TouchSkin.readFile(nested) then return nested, name .. "/" end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function DeltaSkin.resolveName(name, opts)
|
||||
name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "")
|
||||
if name == "" then return nil end
|
||||
local names = opts and opts.names
|
||||
if type(names) == "table" then
|
||||
local want = name:lower()
|
||||
for _, entry in ipairs(names) do
|
||||
if tostring(entry):lower() == want then
|
||||
name = tostring(entry)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return ((opts and opts.prefix) or "") .. name
|
||||
end
|
||||
|
||||
function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
||||
if type(assets) ~= "table" then return nil end
|
||||
pdfFiles = pdfFiles or {}
|
||||
local raster = {}
|
||||
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
|
||||
local name = pick(assets, key)
|
||||
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
|
||||
if type(name) == "string" and name ~= "" then
|
||||
if name:lower():match("%.pdf$") then
|
||||
pdfFiles[#pdfFiles + 1] = name
|
||||
else
|
||||
raster[#raster + 1] = { key = key, name = name }
|
||||
end
|
||||
end
|
||||
end
|
||||
local resizable = pick(assets, "resizable")
|
||||
if type(resizable) == "string" and resizable ~= "" then
|
||||
if resizable:lower():match("%.pdf$") then
|
||||
pdfFiles[#pdfFiles + 1] = resizable
|
||||
else
|
||||
raster[#raster + 1] = { key = "large", name = resizable }
|
||||
end
|
||||
end
|
||||
if #raster == 0 then return nil end
|
||||
|
||||
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
|
||||
local chosen
|
||||
for _, cand in ipairs(raster) do
|
||||
if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then
|
||||
chosen = cand.name
|
||||
end
|
||||
end
|
||||
if not chosen then chosen = raster[#raster].name end
|
||||
return DeltaSkin.resolveName(chosen, opts)
|
||||
end
|
||||
|
||||
function DeltaSkin.mergeEdges(base, item)
|
||||
local out = { top = 0, bottom = 0, left = 0, right = 0 }
|
||||
for _, side in ipairs({ "top", "bottom", "left", "right" }) do
|
||||
local v = pick(item, side)
|
||||
if v == nil then v = pick(base, side) end
|
||||
out[side] = numOr(v, 0)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function DeltaSkin.representation(reps, orient)
|
||||
for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do
|
||||
local dev = pick(reps, device)
|
||||
if type(dev) == "table" then
|
||||
for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do
|
||||
local shown = pick(dev, display)
|
||||
if type(shown) == "table" then
|
||||
local obj = pick(shown, orient)
|
||||
if type(obj) == "table" then return obj, device, display end
|
||||
end
|
||||
end
|
||||
local flat = pick(dev, orient)
|
||||
if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then
|
||||
return flat, device, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function DeltaSkin.directionalInputs(inputs)
|
||||
if type(inputs) ~= "table" or isArray(inputs) then return nil end
|
||||
local out, found = {}, 0
|
||||
for _, side in ipairs(DeltaSkin.SIDES) do
|
||||
local v = pick(inputs, side)
|
||||
if type(v) == "string" then
|
||||
local lower = v:lower()
|
||||
local mapped = DeltaSkin.INPUTS[lower]
|
||||
if not mapped and lower:find(side, 1, true) then mapped = side end
|
||||
if mapped then
|
||||
out[side] = mapped
|
||||
found = found + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
if found >= 2 then return out end
|
||||
return nil
|
||||
end
|
||||
|
||||
function DeltaSkin.specFor(inputs)
|
||||
local parts = {}
|
||||
local function add(v)
|
||||
if type(v) ~= "string" then return end
|
||||
local mapped = DeltaSkin.INPUTS[v:lower()]
|
||||
if mapped then parts[#parts + 1] = mapped end
|
||||
end
|
||||
if type(inputs) == "string" then
|
||||
add(inputs)
|
||||
elseif type(inputs) == "table" then
|
||||
if isArray(inputs) then
|
||||
for _, v in ipairs(inputs) do add(v) end
|
||||
else
|
||||
local keys = {}
|
||||
for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end
|
||||
table.sort(keys)
|
||||
for _, k in ipairs(keys) do add(inputs[k]) end
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return "nul" end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function DeltaSkin.screenRect(obj, mapW, mapH)
|
||||
local frame
|
||||
local screens = pick(obj, "screens")
|
||||
if type(screens) == "table" and type(screens[1]) == "table" then
|
||||
frame = pick(screens[1], "outputFrame")
|
||||
end
|
||||
if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end
|
||||
if type(frame) ~= "table" then return nil end
|
||||
local w = numOr(pick(frame, "width"), 0)
|
||||
local h = numOr(pick(frame, "height"), 0)
|
||||
if w <= 0 or h <= 0 then return nil end
|
||||
return {
|
||||
x = numOr(pick(frame, "x"), 0) / mapW,
|
||||
y = numOr(pick(frame, "y"), 0) / mapH,
|
||||
w = w / mapW, h = h / mapH,
|
||||
}
|
||||
end
|
||||
|
||||
function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
|
||||
if type(item) ~= "table" then return end
|
||||
local frame = pick(item, "frame")
|
||||
if type(frame) ~= "table" then return end
|
||||
local fw = numOr(pick(frame, "width"), 0)
|
||||
local fh = numOr(pick(frame, "height"), 0)
|
||||
if fw <= 0 or fh <= 0 then return end
|
||||
local fx = numOr(pick(frame, "x"), 0)
|
||||
local fy = numOr(pick(frame, "y"), 0)
|
||||
|
||||
local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges"))
|
||||
local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH
|
||||
local w, h = fw / mapW, fh / mapH
|
||||
local reachLeft = 1 + edges.left / (fw * 0.5)
|
||||
local reachRight = 1 + edges.right / (fw * 0.5)
|
||||
local reachUp = 1 + edges.top / (fh * 0.5)
|
||||
local reachDown = 1 + edges.bottom / (fh * 0.5)
|
||||
|
||||
local inputs = pick(item, "inputs")
|
||||
local dirs = DeltaSkin.directionalInputs(inputs)
|
||||
if dirs then
|
||||
local base = {
|
||||
x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5,
|
||||
rangeMod = 1, alphaMod = page.alphaMod, shape = "rect",
|
||||
reachLeft = reachLeft, reachRight = reachRight,
|
||||
reachUp = reachUp, reachDown = reachDown,
|
||||
}
|
||||
for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do
|
||||
page.controls[#page.controls + 1] = ctl
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect"
|
||||
local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape)
|
||||
ctl.alphaMod = page.alphaMod
|
||||
ctl.reachLeft, ctl.reachRight = reachLeft, reachRight
|
||||
ctl.reachUp, ctl.reachDown = reachUp, reachDown
|
||||
page.controls[#page.controls + 1] = ctl
|
||||
end
|
||||
|
||||
function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
||||
local mapping = pick(obj, "mappingSize")
|
||||
local mapW = numOr(pick(mapping, "width"), 0)
|
||||
local mapH = numOr(pick(mapping, "height"), 0)
|
||||
if mapW <= 0 or mapH <= 0 then
|
||||
mapW, mapH = 320, 240
|
||||
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
|
||||
end
|
||||
|
||||
local page = {
|
||||
name = orient,
|
||||
orient = orient,
|
||||
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
|
||||
fullScreen = true,
|
||||
normalized = true,
|
||||
pixelCoords = false,
|
||||
rangeMod = 1,
|
||||
alphaMod = pick(obj, "translucent") == true and 0.7 or 1,
|
||||
aspect = mapW / mapH,
|
||||
aspectFromCfg = false,
|
||||
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||
mappingWidth = mapW,
|
||||
mappingHeight = mapH,
|
||||
controls = {},
|
||||
}
|
||||
|
||||
local screen = DeltaSkin.screenRect(obj, mapW, mapH)
|
||||
if screen then
|
||||
page.viewport = screen
|
||||
page.viewportFill = false
|
||||
end
|
||||
|
||||
local baseEdges = pick(obj, "extendedEdges")
|
||||
local items = pick(obj, "items")
|
||||
if type(items) == "table" then
|
||||
for _, item in ipairs(items) do
|
||||
DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
|
||||
end
|
||||
end
|
||||
return page
|
||||
end
|
||||
|
||||
function DeltaSkin.systemOf(gameType)
|
||||
if type(gameType) ~= "string" or gameType == "" then return nil end
|
||||
for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do
|
||||
if gameType:sub(1, #prefix) == prefix then
|
||||
return gameType:sub(#prefix + 1):lower()
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function DeltaSkin.parse(text, opts)
|
||||
opts = opts or {}
|
||||
local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES)
|
||||
if type(info) ~= "table" then
|
||||
return nil, "info.json does not parse: " .. tostring(err)
|
||||
end
|
||||
|
||||
local gameType = info.gameTypeIdentifier
|
||||
if type(gameType) ~= "string" or gameType == "" then
|
||||
return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier"
|
||||
end
|
||||
if gameType:lower():find("gba4ios", 1, true) then
|
||||
return nil, "old GBA4iOS skin, not supported"
|
||||
end
|
||||
local system = DeltaSkin.systemOf(gameType)
|
||||
if not system then
|
||||
return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType
|
||||
end
|
||||
|
||||
local warnings = {}
|
||||
if not DeltaSkin.SYSTEMS[system] then
|
||||
addWarning(warnings, "this skin is for " .. system .. ", not Game Boy")
|
||||
end
|
||||
|
||||
local reps = info.representations
|
||||
if type(reps) ~= "table" then return nil, "info.json has no representations" end
|
||||
|
||||
local pdfFiles, pages = {}, {}
|
||||
for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do
|
||||
local obj = DeltaSkin.representation(reps, orient)
|
||||
if obj then
|
||||
local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
||||
page.index = #pages + 1
|
||||
pages[#pages + 1] = page
|
||||
end
|
||||
end
|
||||
if #pages == 0 then return nil, "info.json has no usable representation" end
|
||||
if #pdfFiles > 0 then
|
||||
addWarning(warnings, "PDF artwork cannot be imported yet")
|
||||
end
|
||||
|
||||
return {
|
||||
pages = pages,
|
||||
name = info.name,
|
||||
author = info.author,
|
||||
notes = info.notes,
|
||||
format = "delta",
|
||||
system = system,
|
||||
identifier = info.identifier,
|
||||
warnings = warnings,
|
||||
pdfFiles = pdfFiles,
|
||||
}
|
||||
end
|
||||
|
||||
function DeltaSkin.needsConversion(skin)
|
||||
if type(skin) ~= "table" then return nil end
|
||||
local files = skin.pdfFiles
|
||||
if type(files) ~= "table" or #files == 0 then return nil end
|
||||
for _, page in ipairs(skin.pages or {}) do
|
||||
if page.imagePath then return nil end
|
||||
end
|
||||
return { pdfOnly = true, files = files }
|
||||
end
|
||||
|
||||
function DeltaSkin.outputInputs(ctl)
|
||||
local out = {}
|
||||
for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end
|
||||
for _, h in ipairs(ctl.hotkeys or {}) do
|
||||
local mapped = DeltaSkin.OUTPUT_HOTKEYS[h]
|
||||
if mapped then out[#out + 1] = mapped end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function DeltaSkin.buildRepresentation(page, orient, warnings)
|
||||
local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait
|
||||
local mapW, mapH = map.width, map.height
|
||||
local items, files = {}, {}
|
||||
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
local names = DeltaSkin.outputInputs(ctl)
|
||||
if ctl.sector and ctl.sector ~= 1 then
|
||||
names = {}
|
||||
elseif ctl.sector and ctl.areaNames then
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local dirs = {}
|
||||
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||
local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()]
|
||||
if mapped then dirs[side] = mapped end
|
||||
end
|
||||
names = next(dirs) and dirs or {}
|
||||
end
|
||||
if names.up or names.down or names.left or names.right or #names > 0 then
|
||||
local item = {
|
||||
inputs = names,
|
||||
frame = {
|
||||
x = round((ctl.x - ctl.rangeX) * mapW),
|
||||
y = round((ctl.y - ctl.rangeY) * mapH),
|
||||
width = round(ctl.rangeX * 2 * mapW),
|
||||
height = round(ctl.rangeY * 2 * mapH),
|
||||
},
|
||||
}
|
||||
if ctl.shape == "radial" then item.mask = "circle" end
|
||||
local edges, any = {}, false
|
||||
local pairsList = {
|
||||
{ key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW },
|
||||
{ key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW },
|
||||
{ key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH },
|
||||
{ key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH },
|
||||
}
|
||||
for _, side in ipairs(pairsList) do
|
||||
local reach = numOr(side.reach, 1)
|
||||
if reach ~= 1 then
|
||||
edges[side.key] = round((reach - 1) * side.half)
|
||||
any = true
|
||||
end
|
||||
end
|
||||
if any then item.extendedEdges = edges end
|
||||
items[#items + 1] = item
|
||||
elseif ctl.imagePath then
|
||||
addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image")
|
||||
end
|
||||
end
|
||||
|
||||
local obj = {
|
||||
items = items,
|
||||
mappingSize = { width = mapW, height = mapH },
|
||||
extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 },
|
||||
translucent = false,
|
||||
}
|
||||
if page.imagePath then
|
||||
obj.assets = {
|
||||
small = page.imagePath, medium = page.imagePath, large = page.imagePath,
|
||||
}
|
||||
files[#files + 1] = page.imagePath
|
||||
end
|
||||
if page.viewport then
|
||||
obj.screens = { {
|
||||
inputFrame = { x = 0, y = 0,
|
||||
width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT },
|
||||
outputFrame = {
|
||||
x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH),
|
||||
width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH),
|
||||
},
|
||||
} }
|
||||
end
|
||||
return obj, files
|
||||
end
|
||||
|
||||
function DeltaSkin.build(skin, opts)
|
||||
if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then
|
||||
return nil, "skin has no pages"
|
||||
end
|
||||
opts = opts or {}
|
||||
local standard, edgeToEdge = {}, {}
|
||||
local assets, warnings, used = {}, {}, {}
|
||||
|
||||
for _, page in ipairs(skin.pages) do
|
||||
local orient = TouchSkin.pageOrient(page)
|
||||
if orient ~= "portrait" and orient ~= "landscape" then
|
||||
orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape"
|
||||
end
|
||||
if not used[orient] then
|
||||
used[orient] = true
|
||||
local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings)
|
||||
standard[orient] = obj
|
||||
edgeToEdge[orient] = obj
|
||||
for _, rel in ipairs(files) do assets[#assets + 1] = rel end
|
||||
end
|
||||
end
|
||||
|
||||
local system = tostring(opts.system or "gbc")
|
||||
local info = {
|
||||
name = skin.name or skin.id or "skin",
|
||||
identifier = opts.identifier
|
||||
or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")),
|
||||
gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system,
|
||||
debug = false,
|
||||
representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } },
|
||||
}
|
||||
return info, assets, warnings
|
||||
end
|
||||
|
||||
function DeltaSkin.encodeInfo(skin, opts)
|
||||
local info, assets, warnings = DeltaSkin.build(skin, opts)
|
||||
if not info then return nil, assets end
|
||||
return Json.encode(info), assets, warnings
|
||||
end
|
||||
|
||||
return DeltaSkin
|
||||
+36
-2
@@ -34,6 +34,7 @@ end
|
||||
|
||||
function Game:load()
|
||||
self.data = Data
|
||||
self.sessionStartedAt = os.time()
|
||||
Data:load()
|
||||
|
||||
-- Mods are a native engine subsystem. They load after the verified ROM
|
||||
@@ -155,6 +156,7 @@ function Game:makeTitleState()
|
||||
onNewGame = function()
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- New Game keeps the standalone options.lua preferences
|
||||
self.sessionStartedAt = os.time()
|
||||
self.save = SaveData.newGame(self:bootConfig())
|
||||
-- no bucket carry-over: mod state from an abandoned session must
|
||||
-- not leak into a fresh slot; mods seed via save.created instead
|
||||
@@ -356,6 +358,7 @@ function Game:update(dt)
|
||||
-- reason: they are presentational, so fast-forward must not speed them up
|
||||
require("src.render.Pipelines").update(dt)
|
||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||
self:updateSync(dt)
|
||||
-- Steady-state memory backstop: advance the incremental collector one
|
||||
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
|
||||
@@ -1178,11 +1181,41 @@ function Game:writeSave()
|
||||
-- stamp here so the save.writing payload carries the exact meta the
|
||||
-- file gets; mods snapshot runtime state into their namespace now
|
||||
self.save.meta = SaveData.buildMeta(
|
||||
self.modStatus and self.modStatus.loaded, self.save.meta)
|
||||
self.modStatus and self.modStatus.loaded, self.save.meta,
|
||||
self.sessionStartedAt)
|
||||
if ModRuntime.wants("save.writing") then
|
||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||
end
|
||||
return SaveData.save(self.save)
|
||||
local written = SaveData.save(self.save)
|
||||
if written then
|
||||
local eng = self:syncEngine()
|
||||
if eng then pcall(eng.noteSaveWritten, eng) end
|
||||
end
|
||||
return written
|
||||
end
|
||||
|
||||
function Game:syncEngine()
|
||||
if self._syncOff then return nil end
|
||||
if self._syncEngineRef then return self._syncEngineRef end
|
||||
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||
if not ok or type(SyncEngine) ~= "table" then
|
||||
self._syncOff = true
|
||||
return nil
|
||||
end
|
||||
local eng = SyncEngine.shared()
|
||||
if not eng then
|
||||
self._syncOff = true
|
||||
return nil
|
||||
end
|
||||
self._syncEngineRef = eng
|
||||
return eng
|
||||
end
|
||||
|
||||
function Game:updateSync(dt)
|
||||
local eng = self:syncEngine()
|
||||
if not eng then return end
|
||||
if not (eng.state.enabled and eng:linked()) and not eng:busy() then return end
|
||||
pcall(eng.update, eng, dt)
|
||||
end
|
||||
|
||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||
@@ -1241,6 +1274,7 @@ function Game:applyOptions(opts)
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered, opts)
|
||||
self.sessionStartedAt = os.time()
|
||||
if ModRuntime.wants("save.loading") then
|
||||
ModRuntime.emit("save.loading", { raw = loaded })
|
||||
end
|
||||
|
||||
+23
-9
@@ -36,6 +36,7 @@ local World = require("src.world.gen2.World")
|
||||
-- other engine file, so a call site here is the same call site Gen 1 has.
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Playfield = require("src.render.Playfield")
|
||||
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
||||
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
||||
-- IO is src/core/gen2/Save.lua.
|
||||
@@ -195,6 +196,7 @@ end
|
||||
function Game2:persistOptions()
|
||||
pcall(Save.saveOptions, self.options)
|
||||
end
|
||||
Game2.writeOptions = Game2.persistOptions
|
||||
|
||||
-- Point the loader's mod.save backing at this save's modData so per-mod state
|
||||
-- persists with the slot. Same contract and same three call sites as Gen 1
|
||||
@@ -1160,7 +1162,8 @@ end
|
||||
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
||||
-- one screen pixel a cell out at survey range.
|
||||
function Game2:pixelScale(w, h)
|
||||
return math.max(1, math.floor(math.min(w / 160, h / 144)))
|
||||
local _, _, pw, ph = Playfield.rect(w, h)
|
||||
return math.max(1, math.floor(math.min(pw / 160, ph / 144)))
|
||||
end
|
||||
|
||||
-- A window-sized canvas the whole frame is composed into, so the post passes
|
||||
@@ -1277,7 +1280,8 @@ end
|
||||
function Game2:blitZones(canvas, zones, w, h)
|
||||
local G = love.graphics
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local sx, sy = w / 160, h / 144
|
||||
local px, py, pw, ph = Playfield.rect(w, h)
|
||||
local sx, sy = pw / 160, ph / 144
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for _, z in ipairs(zones) do
|
||||
-- a colors == false zone is the true-colour opt-out; anything the shader
|
||||
@@ -1296,11 +1300,11 @@ function Game2:blitZones(canvas, zones, w, h)
|
||||
-- whose contract differs from Gen 1's. Whole-screen and half-screen zones
|
||||
-- come out of this at exactly the pixels the plain floor/ceil pair gave
|
||||
-- them, so the vanilla picture is untouched.
|
||||
local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy
|
||||
local x1 = math.floor(math.max(zx, 0))
|
||||
local y1 = math.floor(math.max(zy, 0))
|
||||
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w))
|
||||
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h))
|
||||
local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy
|
||||
local x1 = math.floor(math.max(zx, px))
|
||||
local y1 = math.floor(math.max(zy, py))
|
||||
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw))
|
||||
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph))
|
||||
if x2 > x1 and y2 > y1 then
|
||||
G.setScissor(x1, y1, x2 - x1, y2 - y1)
|
||||
G.draw(canvas, 0, 0)
|
||||
@@ -1402,7 +1406,7 @@ function Game2:drawViewportFrame()
|
||||
scene = self:presentCanvas(1, w, h)
|
||||
end
|
||||
if not scene then
|
||||
self:drawScene(w, h)
|
||||
self:drawContained(w, h)
|
||||
self:drawHud(w, h)
|
||||
return
|
||||
end
|
||||
@@ -1413,7 +1417,7 @@ function Game2:drawViewportFrame()
|
||||
G.origin()
|
||||
G.setCanvas(scene)
|
||||
G.clear(0, 0, 0, 1)
|
||||
self:drawScene(w, h)
|
||||
self:drawContained(w, h)
|
||||
G.setCanvas(previous)
|
||||
|
||||
if composing and self:compose(scene, zones, w, h) then
|
||||
@@ -1462,6 +1466,8 @@ function Game2:drawViewportFrame()
|
||||
generation = 2,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
local cx, cy, cw, ch = Playfield.cutout(w, h)
|
||||
if cx then G.setScissor(cx, cy, cw, ch) end
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
@@ -1469,6 +1475,7 @@ function Game2:drawViewportFrame()
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
end
|
||||
if cx then G.setScissor() end
|
||||
end
|
||||
end
|
||||
G.pop()
|
||||
@@ -1499,6 +1506,13 @@ function Game2:textboxPaper()
|
||||
return nil
|
||||
end
|
||||
|
||||
function Game2:drawContained(w, h)
|
||||
local pw, ph = Playfield.push(w, h)
|
||||
local ok, err = pcall(self.drawScene, self, pw, ph)
|
||||
Playfield.pop()
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
function Game2:drawScene(w, h)
|
||||
local G = love.graphics
|
||||
-- render.compose reads this after the scene is drawn; the plain overworld
|
||||
|
||||
@@ -350,11 +350,23 @@ local function haveBridge()
|
||||
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||
end
|
||||
|
||||
local function haveRequestBridge()
|
||||
if not (love and love.system and type(love.system.httpRequest) == "function") then
|
||||
return false
|
||||
end
|
||||
local osName = love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||
end
|
||||
|
||||
-- Is any transport available at all? Callers gate on this, never on curl.
|
||||
function HostShell.canFetch()
|
||||
return HostShell.haveCurl() or haveBridge()
|
||||
end
|
||||
|
||||
function HostShell.canHttpRequest()
|
||||
return (HostShell.haveCurl() or haveRequestBridge()) and true or false
|
||||
end
|
||||
|
||||
-- Download url to an absolute host path. Returns true, or nil plus an error.
|
||||
-- The curl branch deliberately ignores curl's exit code, as the download paths
|
||||
-- always did: callers judge the result by the file they got.
|
||||
@@ -557,4 +569,173 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
return nil, "no POST transport on this platform"
|
||||
end
|
||||
|
||||
local function requestHeaderList(headers)
|
||||
local out = {}
|
||||
if type(headers) == "table" then
|
||||
if #headers > 0 then
|
||||
for _, line in ipairs(headers) do
|
||||
if type(line) == "string" then out[#out + 1] = line end
|
||||
end
|
||||
else
|
||||
local names = {}
|
||||
for name in pairs(headers) do names[#names + 1] = tostring(name) end
|
||||
table.sort(names)
|
||||
for _, name in ipairs(names) do
|
||||
out[#out + 1] = name .. ": " .. tostring(headers[name])
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, line in ipairs(out) do
|
||||
if line:find("[\r\n]") or not line:find(":", 1, true) then return nil end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local BRIDGE_METHODS = { GET = true, POST = true, PUT = true, DELETE = true }
|
||||
|
||||
local function requestHeaderPairs(lines)
|
||||
local out = {}
|
||||
for _, line in ipairs(lines) do
|
||||
local name, value = line:match("^%s*([^:]-)%s*:%s*(.-)%s*$")
|
||||
if not name or name == "" then return nil end
|
||||
if name:find("[\r\n]") or value:find("[\r\n]") then return nil end
|
||||
out[#out + 1] = name
|
||||
out[#out + 1] = value
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function bridgeRequest(url, method, headers, body, userAgent)
|
||||
if not BRIDGE_METHODS[method] then
|
||||
return nil, "no request transport for " .. method .. " on this platform"
|
||||
end
|
||||
local fields = requestHeaderPairs(headers)
|
||||
if not fields then return nil, "bad request header" end
|
||||
local ok, envelope = pcall(love.system.httpRequest, url, method, fields,
|
||||
body, userAgent)
|
||||
if not ok or type(envelope) ~= "string" or envelope == "" then
|
||||
return nil, "this app build cannot make signed requests: update the app to use save sync"
|
||||
end
|
||||
local head, rest = envelope:match("^([^\n]*)\n(.*)$")
|
||||
if not head then
|
||||
return nil, fetchError(url, nil, "unreadable reply from the network bridge")
|
||||
end
|
||||
local status = tonumber(head:match("^STATUS (%d+)$"))
|
||||
if status then return rest or "", nil, status end
|
||||
return nil, fetchError(url, nil, head:match("^ERROR (.*)$") or head)
|
||||
end
|
||||
|
||||
local requestSeq = 0
|
||||
|
||||
local function requestStagingPath(kind)
|
||||
local dir
|
||||
if love and love.filesystem and love.filesystem.getSaveDirectory then
|
||||
local ok, saveDir = pcall(love.filesystem.getSaveDirectory)
|
||||
if ok and type(saveDir) == "string" and saveDir ~= "" then dir = saveDir end
|
||||
end
|
||||
if not dir then
|
||||
dir = os.getenv("TEMP") or os.getenv("TMP")
|
||||
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
|
||||
end
|
||||
local sep = dir:find("\\") and "\\" or "/"
|
||||
requestSeq = requestSeq + 1
|
||||
return dir .. sep .. ("gen1recomp-req-%s-%d-%d-%d.tmp"):format(
|
||||
kind, os.time() % 1000000, requestSeq, math.random(0, 999999))
|
||||
end
|
||||
|
||||
local function writeStagingFile(kind, text)
|
||||
local path = requestStagingPath(kind)
|
||||
local file, openErr = io.open(path, "wb")
|
||||
if not file then
|
||||
return nil, "could not create the request " .. kind .. ": " .. tostring(openErr)
|
||||
end
|
||||
local wrote, writeErr = pcall(function()
|
||||
assert(file:write(text))
|
||||
assert(file:close())
|
||||
end)
|
||||
if not wrote then
|
||||
pcall(function() file:close() end)
|
||||
pcall(os.remove, path)
|
||||
return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
function HostShell.httpRequest(url, opts)
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
local method = tostring(opts.method or "GET"):upper()
|
||||
if not method:match("^%u+$") then return nil, "bad request method" end
|
||||
local headers = requestHeaderList(opts.headers)
|
||||
if not headers then return nil, "bad request header" end
|
||||
local body = opts.body
|
||||
if body ~= nil and type(body) ~= "string" then return nil, "bad request body" end
|
||||
local userAgent = opts.userAgent or "gen1recomp"
|
||||
local maxTime = tonumber(opts.maxTime) or 30
|
||||
|
||||
if not HostShell.haveCurl() then
|
||||
if haveRequestBridge() then
|
||||
return bridgeRequest(url, method, headers, body, userAgent)
|
||||
end
|
||||
if method == "GET" and #headers == 0 then
|
||||
local got, err = HostShell.httpGet(url, userAgent, opts.accept, maxTime)
|
||||
if not got then return nil, err end
|
||||
return got, nil, 200
|
||||
end
|
||||
if haveBridge() then
|
||||
return nil, "this app build cannot make signed requests: update the app to use save sync"
|
||||
end
|
||||
return nil, "no request transport on this platform"
|
||||
end
|
||||
|
||||
local bodyPath, stageErr
|
||||
if body then
|
||||
bodyPath, stageErr = writeStagingFile("body", body)
|
||||
if not bodyPath then return nil, stageErr end
|
||||
end
|
||||
|
||||
local lines = { "User-Agent: " .. userAgent }
|
||||
for _, line in ipairs(headers) do lines[#lines + 1] = line end
|
||||
if body then
|
||||
lines[#lines + 1] = "Content-Length: " .. tostring(#body)
|
||||
end
|
||||
local headerPath
|
||||
headerPath, stageErr = writeStagingFile("head",
|
||||
table.concat(lines, "\n") .. "\n")
|
||||
if not headerPath then
|
||||
if bodyPath then pcall(os.remove, bodyPath) end
|
||||
return nil, stageErr
|
||||
end
|
||||
|
||||
local function cleanup()
|
||||
if bodyPath then pcall(os.remove, bodyPath) end
|
||||
pcall(os.remove, headerPath)
|
||||
end
|
||||
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
|
||||
.. "-X " .. HostShell.quote(method) .. " "
|
||||
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
|
||||
if body then
|
||||
cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
||||
end
|
||||
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then
|
||||
cleanup()
|
||||
return nil, "could not run curl"
|
||||
end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
cleanup()
|
||||
if not readOk then
|
||||
return nil, fetchError(url, nil, tostring(out))
|
||||
end
|
||||
local respBody, status, noise = splitCurlOutput(out)
|
||||
if not status then return nil, fetchError(url, nil, noise) end
|
||||
return respBody or "", nil, status
|
||||
end
|
||||
|
||||
return HostShell
|
||||
|
||||
+28
-2
@@ -356,6 +356,8 @@ function SaveData.defaultOptions()
|
||||
-- rewind presentation preferences.
|
||||
dateFormat = "device", -- device | dmy | mdy | ymd
|
||||
timeFormat = "device", -- device | 24h | 12h
|
||||
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
|
||||
pendingConflicts = {} },
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1013,6 +1015,22 @@ function SaveData.listSlots(version)
|
||||
return out
|
||||
end
|
||||
|
||||
function SaveData.readSlotSource(version, slotId, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) or type(slotId) ~= "string" then return nil end
|
||||
local fs = persistFs(injectedFs)
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
for _, name in ipairs({ main, tmp, bak }) do
|
||||
if fs.getInfo(name) then
|
||||
local body = fs.read(name)
|
||||
if type(body) == "string" and body ~= "" then
|
||||
if SaveSerializer.decode(body) then return body end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||
-- you can see that in the launcher"). The label lives in the options
|
||||
-- registry next to list/active, never in the save file itself, so renaming
|
||||
@@ -1327,7 +1345,7 @@ end
|
||||
-- loaded list sorted by id and is the ground truth for the load-time
|
||||
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
|
||||
-- headless writer (the save editor) never wipes it.
|
||||
function SaveData.buildMeta(mods, previous)
|
||||
function SaveData.buildMeta(mods, previous, sessionStart)
|
||||
local list
|
||||
if mods ~= nil then
|
||||
list = {}
|
||||
@@ -1338,10 +1356,18 @@ function SaveData.buildMeta(mods, previous)
|
||||
else
|
||||
list = (type(previous) == "table" and previous.mods) or {}
|
||||
end
|
||||
local started = tonumber(sessionStart)
|
||||
if not started or started ~= started or started <= 0
|
||||
or started == math.huge then
|
||||
started = type(previous) == "table" and tonumber(previous.sessionStart) or nil
|
||||
end
|
||||
local savedAt = os.time()
|
||||
if started and started > savedAt then started = savedAt end
|
||||
return {
|
||||
format = Version.saveFormat,
|
||||
engine = Version.engine,
|
||||
savedAt = os.time(),
|
||||
savedAt = savedAt,
|
||||
sessionStart = started,
|
||||
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||
mods = list,
|
||||
}
|
||||
|
||||
@@ -616,13 +616,15 @@ local function exitControl(self, ctl)
|
||||
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
|
||||
end
|
||||
|
||||
function skinHitSet(self, x, y)
|
||||
function skinHitSet(self, x, y, prev)
|
||||
local page = TouchSkin.page()
|
||||
if not page then return nil end
|
||||
local ww, wh, ox, oy = surfaceRect()
|
||||
local set = nil
|
||||
for _, ctl in ipairs(page.controls) do
|
||||
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
|
||||
local held = (prev and prev[ctl]) == true
|
||||
if not ctl.decorative
|
||||
and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then
|
||||
set = set or {}
|
||||
set[ctl] = true
|
||||
end
|
||||
@@ -665,7 +667,7 @@ function TouchControls:touchpressed(id, x, y)
|
||||
return
|
||||
end
|
||||
if TouchSkin.active then
|
||||
local set = skinHitSet(self, x, y)
|
||||
local set = skinHitSet(self, x, y, nil)
|
||||
if not set then return end
|
||||
local touch = { control = "skin" }
|
||||
self.touches[id] = touch
|
||||
@@ -698,7 +700,7 @@ function TouchControls:touchmoved(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
if not touch then return end
|
||||
if touch.control == "skin" then
|
||||
applySkinSet(self, touch, skinHitSet(self, x, y))
|
||||
applySkinSet(self, touch, skinHitSet(self, x, y, touch.set))
|
||||
return
|
||||
end
|
||||
-- only the d-pad tracks movement (slide between directions without
|
||||
|
||||
+443
-46
@@ -2,6 +2,7 @@ local TouchSkin = {}
|
||||
|
||||
TouchSkin.BUNDLED_ROOT = "assets/skins"
|
||||
TouchSkin.USER_ROOT = "skins"
|
||||
TouchSkin.EXPORT_ROOT = "skins/_export"
|
||||
|
||||
TouchSkin.GB_BUTTONS = {
|
||||
a = "a", b = "b", start = "start", select = "select",
|
||||
@@ -83,6 +84,110 @@ local function parseBinds(spec)
|
||||
return buttons, hotkeys, keys, decorative
|
||||
end
|
||||
|
||||
TouchSkin.AREA_DEFAULTS = {
|
||||
dpad_area = { up = "up", down = "down", left = "left", right = "right" },
|
||||
abxy_area = { up = "x", down = "b", left = "y", right = "a" },
|
||||
analog_left = { up = "up", down = "down", left = "left", right = "right" },
|
||||
analog_right = { up = "up", down = "down", left = "left", right = "right" },
|
||||
}
|
||||
|
||||
local DIRECTIONAL_CELLS = {
|
||||
{ col = 1, row = 1, h = "left", v = "up" },
|
||||
{ col = 2, row = 1, v = "up" },
|
||||
{ col = 3, row = 1, h = "right", v = "up" },
|
||||
{ col = 1, row = 2, h = "left" },
|
||||
{ col = 3, row = 2, h = "right" },
|
||||
{ col = 1, row = 3, h = "left", v = "down" },
|
||||
{ col = 2, row = 3, v = "down" },
|
||||
{ col = 3, row = 3, h = "right", v = "down" },
|
||||
}
|
||||
|
||||
local function outwardReach(reach)
|
||||
return 1 + 3 * ((num(reach, 1)) - 1)
|
||||
end
|
||||
|
||||
function TouchSkin.expandDirectional(base, names)
|
||||
names = names or {}
|
||||
local cellX = math.abs(num(base.rangeX, 0.05)) / 3
|
||||
local cellY = math.abs(num(base.rangeY, 0.05)) / 3
|
||||
local out = {}
|
||||
for _, cell in ipairs(DIRECTIONAL_CELLS) do
|
||||
local parts = {}
|
||||
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
|
||||
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
|
||||
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
|
||||
local ctl = TouchSkin.newControl(spec,
|
||||
num(base.x, 0.5) + (cell.col - 2) * cellX * 2,
|
||||
num(base.y, 0.5) + (cell.row - 2) * cellY * 2,
|
||||
cellX * 2, cellY * 2, "rect")
|
||||
ctl.rangeMod = num(base.rangeMod, 1)
|
||||
ctl.alphaMod = num(base.alphaMod, 1)
|
||||
ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1
|
||||
ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1
|
||||
ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1
|
||||
ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1
|
||||
ctl.pixelCoords = base.pixelCoords
|
||||
ctl.movable = base.movable
|
||||
ctl.exclusive = base.exclusive
|
||||
out[#out + 1] = ctl
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local SECTOR_CELLS = {
|
||||
{ h = "right" },
|
||||
{ h = "right", v = "down" },
|
||||
{ v = "down" },
|
||||
{ h = "left", v = "down" },
|
||||
{ h = "left" },
|
||||
{ h = "left", v = "up" },
|
||||
{ v = "up" },
|
||||
{ h = "right", v = "up" },
|
||||
}
|
||||
|
||||
TouchSkin.SECTOR_SPAN = math.pi / 4
|
||||
|
||||
function TouchSkin.sectorHit(sector, dx, dy)
|
||||
local span = TouchSkin.SECTOR_SPAN
|
||||
local start = (sector - 1) * span - span * 0.5
|
||||
local a = (math.atan2(dy, dx) - start) % (math.pi * 2)
|
||||
return a < span
|
||||
end
|
||||
|
||||
function TouchSkin.expandSectors(base, names)
|
||||
names = names or {}
|
||||
local out = {}
|
||||
for i, cell in ipairs(SECTOR_CELLS) do
|
||||
local parts = {}
|
||||
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
|
||||
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
|
||||
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
|
||||
local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5),
|
||||
math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2,
|
||||
base.shape)
|
||||
ctl.sector = i
|
||||
ctl.areaKind = base.areaKind
|
||||
ctl.areaNames = base.areaNames
|
||||
ctl.rangeMod = num(base.rangeMod, 1)
|
||||
ctl.alphaMod = num(base.alphaMod, 1)
|
||||
ctl.reachLeft = num(base.reachLeft, 1)
|
||||
ctl.reachRight = num(base.reachRight, 1)
|
||||
ctl.reachUp = num(base.reachUp, 1)
|
||||
ctl.reachDown = num(base.reachDown, 1)
|
||||
ctl.pixelCoords = base.pixelCoords
|
||||
ctl.movable = base.movable
|
||||
ctl.exclusive = base.exclusive
|
||||
out[#out + 1] = ctl
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function areaSide(kv, prefix, side, fallback)
|
||||
local v = kv[prefix .. "_" .. side]
|
||||
if v == nil or trim(v) == "" then return fallback end
|
||||
return trim(v)
|
||||
end
|
||||
|
||||
local function parseDesc(kv, prefix, page)
|
||||
local spec = kv[prefix]
|
||||
if not spec then return nil end
|
||||
@@ -116,9 +221,28 @@ local function parseDesc(kv, prefix, page)
|
||||
imagePath = kv[prefix .. "_overlay"],
|
||||
pressedImagePath = kv[prefix .. "_overlay_pressed"],
|
||||
nextTarget = kv[prefix .. "_next_target"],
|
||||
movable = toBool(kv[prefix .. "_movable"]) or nil,
|
||||
exclusive = (toBool(kv[prefix .. "_exclusive"])
|
||||
or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil,
|
||||
saturatePct = num(kv[prefix .. "_saturate_pct"], nil),
|
||||
}
|
||||
if ctl.imagePath == "" then ctl.imagePath = nil end
|
||||
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
|
||||
|
||||
local normalized = kv[prefix .. "_normalized"]
|
||||
if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end
|
||||
|
||||
local areaKind = trim(t[1]):lower()
|
||||
local defaults = TouchSkin.AREA_DEFAULTS[areaKind]
|
||||
if defaults then
|
||||
ctl.areaKind = areaKind
|
||||
ctl.areaNames = {
|
||||
up = areaSide(kv, prefix, "up", defaults.up),
|
||||
down = areaSide(kv, prefix, "down", defaults.down),
|
||||
left = areaSide(kv, prefix, "left", defaults.left),
|
||||
right = areaSide(kv, prefix, "right", defaults.right),
|
||||
}
|
||||
end
|
||||
return ctl
|
||||
end
|
||||
|
||||
@@ -127,7 +251,14 @@ function TouchSkin.parse(text)
|
||||
local count = math.floor(num(kv.overlays, 0))
|
||||
if count <= 0 then return nil, "no overlays" end
|
||||
|
||||
local pages = {}
|
||||
local pages, warnings = {}, {}
|
||||
local function warn(text)
|
||||
for _, existing in ipairs(warnings) do
|
||||
if existing == text then return end
|
||||
end
|
||||
warnings[#warnings + 1] = text
|
||||
end
|
||||
|
||||
for i = 0, count - 1 do
|
||||
local p = "overlay" .. i
|
||||
local page = {
|
||||
@@ -166,10 +297,34 @@ function TouchSkin.parse(text)
|
||||
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
||||
end
|
||||
|
||||
page.pixelCoords = not page.normalized
|
||||
if page.pixelCoords and not page.imagePath then
|
||||
page.pixelCoords = false
|
||||
warn(page.name .. " has no base image: desc coordinates read as normalized")
|
||||
end
|
||||
|
||||
local descs = math.floor(num(kv[p .. "_descs"], 0))
|
||||
for d = 0, descs - 1 do
|
||||
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
|
||||
if ctl then page.controls[#page.controls + 1] = ctl end
|
||||
if not ctl then
|
||||
warn(page.name .. " is missing desc " .. d)
|
||||
elseif ctl.areaKind then
|
||||
if ctl.imagePath or ctl.pressedImagePath then
|
||||
local art = TouchSkin.newControl("nul", ctl.x, ctl.y,
|
||||
ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
|
||||
art.imagePath = ctl.imagePath
|
||||
art.pressedImagePath = ctl.pressedImagePath
|
||||
art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod
|
||||
art.pixelCoords = ctl.pixelCoords
|
||||
art.movable, art.exclusive = ctl.movable, ctl.exclusive
|
||||
page.controls[#page.controls + 1] = art
|
||||
end
|
||||
for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do
|
||||
page.controls[#page.controls + 1] = cell
|
||||
end
|
||||
else
|
||||
page.controls[#page.controls + 1] = ctl
|
||||
end
|
||||
end
|
||||
pages[#pages + 1] = page
|
||||
end
|
||||
@@ -180,7 +335,7 @@ function TouchSkin.parse(text)
|
||||
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||
end
|
||||
|
||||
return { pages = pages }
|
||||
return { pages = pages, warnings = warnings }
|
||||
end
|
||||
|
||||
local function readFile(path)
|
||||
@@ -219,18 +374,26 @@ end
|
||||
|
||||
TouchSkin.NATIVE_NAME = "skin.lua"
|
||||
|
||||
TouchSkin.readFile = readFile
|
||||
TouchSkin.listDir = listDir
|
||||
TouchSkin.isDir = isDir
|
||||
|
||||
local function findConfig(root)
|
||||
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
|
||||
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
|
||||
return root .. "/" .. TouchSkin.NATIVE_NAME, "native", ""
|
||||
end
|
||||
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
|
||||
for _, name in ipairs(named) do
|
||||
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
|
||||
if readFile(root .. "/" .. name) then
|
||||
return root .. "/" .. name, "retroarch", ""
|
||||
end
|
||||
end
|
||||
local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root)
|
||||
if infoPath then return infoPath, "delta", prefix end
|
||||
local items = listDir(root)
|
||||
table.sort(items)
|
||||
for _, name in ipairs(items) do
|
||||
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
|
||||
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -262,6 +425,7 @@ function TouchSkin.parseNative(text)
|
||||
imagePath = raw.image,
|
||||
fullScreen = raw.fullScreen ~= false,
|
||||
normalized = true,
|
||||
pixelCoords = false,
|
||||
rangeMod = num(raw.rangeMod, 1),
|
||||
alphaMod = num(raw.alphaMod, 1),
|
||||
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
||||
@@ -283,7 +447,24 @@ function TouchSkin.parseNative(text)
|
||||
end
|
||||
for _, c in ipairs(raw.controls or {}) do
|
||||
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||
local sector = tonumber(c.sector)
|
||||
if sector then
|
||||
sector = math.floor(sector)
|
||||
if sector < 1 or sector > #SECTOR_CELLS then sector = nil end
|
||||
end
|
||||
local areaNames
|
||||
if type(c.areaNames) == "table" then
|
||||
areaNames = {}
|
||||
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||
if type(c.areaNames[side]) == "string" then
|
||||
areaNames[side] = c.areaNames[side]
|
||||
end
|
||||
end
|
||||
end
|
||||
page.controls[#page.controls + 1] = {
|
||||
sector = sector,
|
||||
areaKind = type(c.areaKind) == "string" and c.areaKind or nil,
|
||||
areaNames = areaNames,
|
||||
spec = tostring(c.bind or "nul"),
|
||||
buttons = buttons, hotkeys = hotkeys, keys = keys,
|
||||
decorative = decorative,
|
||||
@@ -298,6 +479,8 @@ function TouchSkin.parseNative(text)
|
||||
imagePath = c.image,
|
||||
pressedImagePath = c.imagePressed,
|
||||
nextTarget = c.nextTarget,
|
||||
movable = c.movable == true or nil,
|
||||
exclusive = c.exclusive == true or nil,
|
||||
}
|
||||
end
|
||||
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||
@@ -355,6 +538,14 @@ function TouchSkin.toNative(skin)
|
||||
image = ctl.imagePath,
|
||||
imagePressed = ctl.pressedImagePath,
|
||||
nextTarget = ctl.nextTarget,
|
||||
movable = ctl.movable or nil,
|
||||
exclusive = ctl.exclusive or nil,
|
||||
sector = ctl.sector,
|
||||
areaKind = ctl.areaKind,
|
||||
areaNames = ctl.areaNames and {
|
||||
up = ctl.areaNames.up, down = ctl.areaNames.down,
|
||||
left = ctl.areaNames.left, right = ctl.areaNames.right,
|
||||
} or nil,
|
||||
}
|
||||
end
|
||||
out.pages[#out.pages + 1] = p
|
||||
@@ -379,14 +570,44 @@ local function loadImage(path)
|
||||
return img
|
||||
end
|
||||
|
||||
local function pixelScalePending(page)
|
||||
if page.pixelCoords then return true end
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
if ctl.pixelCoords then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function applyPixelScale(page)
|
||||
if not pixelScalePending(page) then return true end
|
||||
if not page.image or not page.image.getDimensions then return false end
|
||||
local iw, ih = page.image:getDimensions()
|
||||
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
local pixel = ctl.pixelCoords
|
||||
if pixel == nil then pixel = page.pixelCoords end
|
||||
if pixel then
|
||||
ctl.x, ctl.y = ctl.x / iw, ctl.y / ih
|
||||
ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih
|
||||
ctl.pixelCoords = false
|
||||
end
|
||||
end
|
||||
page.pixelCoords = false
|
||||
return true
|
||||
end
|
||||
|
||||
function TouchSkin.load(root, id)
|
||||
local cfgPath, format = findConfig(root)
|
||||
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
|
||||
local cfgPath, format, prefix = findConfig(root)
|
||||
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
||||
local text = readFile(cfgPath)
|
||||
if not text then return nil, "unreadable " .. cfgPath end
|
||||
local skin, err
|
||||
if format == "native" then
|
||||
skin, err = TouchSkin.parseNative(text)
|
||||
elseif format == "delta" then
|
||||
local dir = cfgPath:match("^(.*)/[^/]+$") or root
|
||||
skin, err = require("src.core.DeltaSkin").parse(text,
|
||||
{ prefix = prefix or "", names = listDir(dir) })
|
||||
else
|
||||
skin, err = TouchSkin.parse(text)
|
||||
end
|
||||
@@ -402,6 +623,10 @@ function TouchSkin.load(root, id)
|
||||
if page.imagePath then
|
||||
page.image = loadImage(joinPath(root, page.imagePath))
|
||||
end
|
||||
if not applyPixelScale(page) then
|
||||
return nil, "could not read " .. tostring(page.imagePath)
|
||||
.. ", which " .. page.name .. " measures its coordinates against"
|
||||
end
|
||||
for _, ctl in ipairs(page.controls) do
|
||||
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
|
||||
if ctl.pressedImagePath then
|
||||
@@ -418,14 +643,30 @@ local function mountZip(archive, point)
|
||||
return ok and mounted == true
|
||||
end
|
||||
|
||||
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
||||
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||
TouchSkin.PDF_ONLY_MESSAGE =
|
||||
"This skin uses PDF artwork, which cannot be imported yet. "
|
||||
.. "Ask the author for a PNG version."
|
||||
|
||||
function TouchSkin.archiveId(name)
|
||||
name = tostring(name or "")
|
||||
local ext = name:match("%.([%w]+)$")
|
||||
if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end
|
||||
local id = name:sub(1, #name - #ext - 1)
|
||||
if id == "" then return nil end
|
||||
return id, ext:lower()
|
||||
end
|
||||
|
||||
function TouchSkin.list()
|
||||
local out, seen = {}, {}
|
||||
local function scan(root, source)
|
||||
for _, name in ipairs(listDir(root)) do
|
||||
local id = name:gsub("%.zip$", "")
|
||||
if not seen[id] then
|
||||
local archiveId = TouchSkin.archiveId(name)
|
||||
local id = archiveId or name
|
||||
if not seen[id] and name:sub(1, 1) ~= "_" then
|
||||
local path = root .. "/" .. name
|
||||
if name:match("%.zip$") then
|
||||
if archiveId then
|
||||
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
|
||||
if mountZip(path, point) and findConfig(point) then
|
||||
seen[id] = true
|
||||
@@ -447,17 +688,20 @@ function TouchSkin.list()
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop a .zip into <save>/skins and report the id it will list under.
|
||||
-- Drop a .zip or .deltaskin into <save>/skins and report the id it lists under.
|
||||
function TouchSkin.installArchive(name, data)
|
||||
if not data or data == "" then return nil, "empty archive" end
|
||||
if not (love and love.filesystem and love.filesystem.write) then
|
||||
return nil, "no writable filesystem"
|
||||
end
|
||||
name = tostring(name or ""):match("([^/\\]+)$") or ""
|
||||
name = name:gsub("[^%w%._%-]", "_")
|
||||
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
|
||||
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
|
||||
if id == "" then return nil, "bad archive name" end
|
||||
name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "")
|
||||
local legacy = name:match("%.([%w]+)$")
|
||||
if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then
|
||||
return nil, "old GBA4iOS skin, not supported"
|
||||
end
|
||||
local id = TouchSkin.archiveId(name)
|
||||
if not id then return nil, "not a .zip or .deltaskin" end
|
||||
|
||||
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
||||
local dest = TouchSkin.USER_ROOT .. "/" .. name
|
||||
@@ -467,9 +711,14 @@ function TouchSkin.installArchive(name, data)
|
||||
local entry = TouchSkin.find(id)
|
||||
if not entry then
|
||||
love.filesystem.remove(dest)
|
||||
return nil, "no skin.lua or .cfg inside " .. name
|
||||
return nil, "no skin.lua, .cfg or info.json inside " .. name
|
||||
end
|
||||
return id
|
||||
local skin = TouchSkin.load(entry.root, entry.id)
|
||||
if skin and require("src.core.DeltaSkin").needsConversion(skin) then
|
||||
love.filesystem.remove(dest)
|
||||
return nil, TouchSkin.PDF_ONLY_MESSAGE
|
||||
end
|
||||
return id, skin and skin.warnings or nil
|
||||
end
|
||||
|
||||
function TouchSkin.find(id)
|
||||
@@ -498,29 +747,13 @@ function TouchSkin.assetPaths(skin)
|
||||
return out
|
||||
end
|
||||
|
||||
function TouchSkin.export(skin, destPath)
|
||||
if not skin then return nil, "no skin" end
|
||||
local SkinZip = require("src.core.SkinZip")
|
||||
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
|
||||
local missing = {}
|
||||
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
|
||||
local data = readFile(joinPath(skin.root, rel))
|
||||
if data then
|
||||
entries[#entries + 1] = { name = rel, data = data }
|
||||
else
|
||||
missing[#missing + 1] = rel
|
||||
end
|
||||
end
|
||||
if skin.configPath and skin.format == "retroarch" then
|
||||
local cfg = readFile(skin.configPath)
|
||||
if cfg then
|
||||
entries[#entries + 1] =
|
||||
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
|
||||
end
|
||||
end
|
||||
local blob = SkinZip.encode(entries)
|
||||
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
|
||||
local function writeArchive(entries, destPath)
|
||||
local blob = require("src.core.SkinZip").encode(entries)
|
||||
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
|
||||
if not absolute and love and love.filesystem and love.filesystem.createDirectory then
|
||||
local dir = destPath:match("^(.*)/[^/]+$")
|
||||
if dir then pcall(love.filesystem.createDirectory, dir) end
|
||||
end
|
||||
if not absolute and love and love.filesystem and love.filesystem.write then
|
||||
local ok, err = love.filesystem.write(destPath, blob)
|
||||
if not ok then return nil, tostring(err) end
|
||||
@@ -530,9 +763,167 @@ function TouchSkin.export(skin, destPath)
|
||||
handle:write(blob)
|
||||
handle:close()
|
||||
end
|
||||
return destPath
|
||||
end
|
||||
|
||||
local function collectAssets(skin, rels)
|
||||
local entries, missing = {}, {}
|
||||
for _, rel in ipairs(rels) do
|
||||
local data = readFile(joinPath(skin.root, rel))
|
||||
if data then
|
||||
entries[#entries + 1] = { name = rel, data = data }
|
||||
else
|
||||
missing[#missing + 1] = rel
|
||||
end
|
||||
end
|
||||
return entries, missing
|
||||
end
|
||||
|
||||
function TouchSkin.export(skin, destPath)
|
||||
if not skin then return nil, "no skin" end
|
||||
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
|
||||
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
|
||||
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||
if skin.configPath and skin.format == "retroarch" then
|
||||
local cfg = readFile(skin.configPath)
|
||||
if cfg then
|
||||
entries[#entries + 1] =
|
||||
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
|
||||
end
|
||||
end
|
||||
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip")
|
||||
local written, err = writeArchive(entries, destPath)
|
||||
if not written then return nil, err end
|
||||
return destPath, missing
|
||||
end
|
||||
|
||||
local function fmtNum(n)
|
||||
n = tonumber(n) or 0
|
||||
if n == math.floor(n) then return string.format("%d", n) end
|
||||
local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "")
|
||||
return s
|
||||
end
|
||||
|
||||
local function fmtRect(r)
|
||||
return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h))
|
||||
end
|
||||
|
||||
local function cfgSpec(spec)
|
||||
local parts = {}
|
||||
for raw in tostring(spec or ""):gmatch("[^|]+") do
|
||||
local name = trim(raw)
|
||||
local key = name:lower():match("^key:(.+)$")
|
||||
parts[#parts + 1] = key and ("retrok_" .. key) or name
|
||||
end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function TouchSkin.toRetroArchConfig(skin)
|
||||
local pages = (skin and skin.pages) or {}
|
||||
local out = { "overlays = " .. #pages }
|
||||
for i, page in ipairs(pages) do
|
||||
local p = "overlay" .. (i - 1)
|
||||
out[#out + 1] = ""
|
||||
out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"'
|
||||
if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end
|
||||
out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false")
|
||||
out[#out + 1] = p .. "_normalized = true"
|
||||
if num(page.rangeMod, 1) ~= 1 then
|
||||
out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod)
|
||||
end
|
||||
if num(page.alphaMod, 1) ~= 1 then
|
||||
out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod)
|
||||
end
|
||||
if page.aspectFromCfg and page.aspect and page.aspect > 0 then
|
||||
out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect)
|
||||
end
|
||||
local r = page.rect
|
||||
if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then
|
||||
out[#out + 1] = p .. "_rect = " .. fmtRect(r)
|
||||
end
|
||||
if page.viewport then
|
||||
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
||||
end
|
||||
local controls = {}
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end
|
||||
end
|
||||
out[#out + 1] = p .. "_descs = " .. #controls
|
||||
for j, ctl in ipairs(controls) do
|
||||
local d = p .. "_desc" .. (j - 1)
|
||||
local spec = ctl.areaKind and ctl.sector and ctl.areaKind
|
||||
or cfgSpec(ctl.spec)
|
||||
if spec == "" then spec = "nul" end
|
||||
out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec,
|
||||
fmtNum(ctl.x), fmtNum(ctl.y),
|
||||
ctl.shape == "radial" and "radial" or "rect",
|
||||
fmtNum(ctl.rangeX), fmtNum(ctl.rangeY))
|
||||
if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end
|
||||
if ctl.pressedImagePath then
|
||||
out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath
|
||||
end
|
||||
if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then
|
||||
out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod)
|
||||
end
|
||||
if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then
|
||||
out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod)
|
||||
end
|
||||
for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown,
|
||||
left = ctl.reachLeft, right = ctl.reachRight }) do
|
||||
if num(value, 1) ~= 1 then
|
||||
out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value)
|
||||
end
|
||||
end
|
||||
if ctl.movable then out[#out + 1] = d .. "_movable = true" end
|
||||
if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end
|
||||
if ctl.nextTarget then
|
||||
out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"'
|
||||
end
|
||||
if ctl.areaKind and ctl.sector and ctl.areaNames then
|
||||
local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {}
|
||||
for _, side in ipairs({ "up", "down", "left", "right" }) do
|
||||
local name = ctl.areaNames[side]
|
||||
if name and name ~= defaults[side] then
|
||||
out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return table.concat(out, "\n") .. "\n"
|
||||
end
|
||||
|
||||
function TouchSkin.exportRetroArch(skin, destPath)
|
||||
if not skin then return nil, "no skin" end
|
||||
local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } }
|
||||
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
|
||||
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip")
|
||||
local written, err = writeArchive(entries, destPath)
|
||||
if not written then return nil, err end
|
||||
return destPath, missing
|
||||
end
|
||||
|
||||
function TouchSkin.exportDelta(skin, opts)
|
||||
if not skin then return nil, "no skin" end
|
||||
opts = opts or {}
|
||||
local DeltaSkin = require("src.core.DeltaSkin")
|
||||
local info, assetRels, warnings = DeltaSkin.build(skin, opts)
|
||||
if not info then return nil, assetRels end
|
||||
local entries = {
|
||||
{ name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) },
|
||||
}
|
||||
local assets, missing = collectAssets(skin, assetRels)
|
||||
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
|
||||
local destPath = opts.path
|
||||
or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin")
|
||||
local written, err = writeArchive(entries, destPath)
|
||||
if not written then return nil, err end
|
||||
return destPath, missing, warnings
|
||||
end
|
||||
|
||||
TouchSkin.BINDS = {
|
||||
"nul",
|
||||
"up", "down", "left", "right",
|
||||
@@ -597,6 +988,7 @@ function TouchSkin.clone(skin)
|
||||
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
|
||||
author = skin.author, notes = skin.notes, configPath = skin.configPath,
|
||||
source = skin.source, pages = {},
|
||||
warnings = skin.warnings and copyTable(skin.warnings) or nil,
|
||||
}
|
||||
for i, page in ipairs(skin.pages or {}) do
|
||||
local p = copyTable(page)
|
||||
@@ -676,7 +1068,8 @@ function TouchSkin.listImages(root)
|
||||
local function scan(dir, prefix)
|
||||
for _, name in ipairs(listDir(dir)) do
|
||||
local path = dir .. "/" .. name
|
||||
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
|
||||
local lower = name:lower()
|
||||
if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then
|
||||
out[#out + 1] = prefix .. name
|
||||
elseif isDir(path) and prefix == "" then
|
||||
scan(path, name .. "/")
|
||||
@@ -872,17 +1265,21 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
||||
return cx, cy, halfW, halfH
|
||||
end
|
||||
|
||||
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
|
||||
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held)
|
||||
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
||||
local left = halfW * ctl.reachLeft * ctl.rangeMod
|
||||
local right = halfW * ctl.reachRight * ctl.rangeMod
|
||||
local up = halfH * ctl.reachUp * ctl.rangeMod
|
||||
local down = halfH * ctl.reachDown * ctl.rangeMod
|
||||
local mod = held == false and 1 or ctl.rangeMod
|
||||
local left = halfW * ctl.reachLeft * mod
|
||||
local right = halfW * ctl.reachRight * mod
|
||||
local up = halfH * ctl.reachUp * mod
|
||||
local down = halfH * ctl.reachDown * mod
|
||||
local dx = px - cx
|
||||
local dy = py - cy
|
||||
local rx = dx < 0 and left or right
|
||||
local ry = dy < 0 and up or down
|
||||
if rx <= 0 or ry <= 0 then return false end
|
||||
if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then
|
||||
return false
|
||||
end
|
||||
if ctl.shape == "radial" then
|
||||
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
|
||||
end
|
||||
|
||||
+29
-2
@@ -301,6 +301,13 @@ end
|
||||
-- gear edit these before the game starts (src/import/LauncherSettings.lua).
|
||||
Save.OPTIONS_KEY = "gold"
|
||||
|
||||
local SHARED_KEYS = {
|
||||
touchControls = true, haptics = true,
|
||||
mods = true, modsByVersion = true, modsGen2 = true,
|
||||
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||
activeProfile = true,
|
||||
}
|
||||
|
||||
function Save.loadOptions(fs)
|
||||
local options = Save.defaultOptions()
|
||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||
@@ -308,7 +315,21 @@ function Save.loadOptions(fs)
|
||||
local loaded = SaveData.loadOptions(fs)
|
||||
local stored = loaded and loaded[Save.OPTIONS_KEY]
|
||||
if type(stored) == "table" then
|
||||
for key, value in pairs(stored) do options[key] = value end
|
||||
for key, value in pairs(stored) do
|
||||
if not SHARED_KEYS[key] then options[key] = value end
|
||||
end
|
||||
end
|
||||
if type(loaded) == "table" then
|
||||
for key in pairs(SHARED_KEYS) do
|
||||
if loaded[key] ~= nil then options[key] = loaded[key] end
|
||||
end
|
||||
end
|
||||
if type(stored) == "table" then
|
||||
for key in pairs(SHARED_KEYS) do
|
||||
if options[key] == nil and stored[key] ~= nil then
|
||||
options[key] = stored[key]
|
||||
end
|
||||
end
|
||||
end
|
||||
return options
|
||||
end
|
||||
@@ -321,7 +342,13 @@ function Save.saveOptions(options, fs)
|
||||
if not ok then return false end
|
||||
local file = SaveData.loadOptions(fs) or {}
|
||||
local block = {}
|
||||
for key, value in pairs(options) do block[key] = value end
|
||||
for key, value in pairs(options) do
|
||||
if SHARED_KEYS[key] then
|
||||
file[key] = value
|
||||
else
|
||||
block[key] = value
|
||||
end
|
||||
end
|
||||
file[Save.OPTIONS_KEY] = block
|
||||
SaveData.saveOptions(file, fs)
|
||||
return true
|
||||
|
||||
+632
-40
@@ -48,6 +48,14 @@ local TAP_SLOP2 = 16 * 16
|
||||
-- Installed mods should not turn into a one- or two-item pager on a compact
|
||||
-- display. Keep a useful page size, then let the list viewport scroll.
|
||||
local MIN_MODS_PER_PAGE = 10
|
||||
local MIN_SKIN_ROWS = 4
|
||||
local SKIN_FORMAT_LABEL = {
|
||||
native = "GEN1",
|
||||
retroarch = "RETROARCH",
|
||||
delta = "DELTA",
|
||||
}
|
||||
local MIN_FIND_ROWS = 3
|
||||
local PANEL_OVERSCAN = 0.75
|
||||
|
||||
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
|
||||
|
||||
@@ -56,6 +64,32 @@ local function inRect(rect, x, y)
|
||||
and y >= rect.y and y <= rect.y + rect.h
|
||||
end
|
||||
|
||||
local function tabKeyOf(imp) return imp.tab or "red" end
|
||||
|
||||
local function tabScrollMax(imp)
|
||||
local t = imp._tabScrollMax
|
||||
return (t and t[tabKeyOf(imp)]) or 0
|
||||
end
|
||||
|
||||
local function tabScrollAt(imp)
|
||||
local t = imp._tabScroll
|
||||
return clamp((t and t[tabKeyOf(imp)]) or 0, 0, tabScrollMax(imp))
|
||||
end
|
||||
|
||||
local function setTabScroll(imp, value)
|
||||
imp._tabScroll = imp._tabScroll or {}
|
||||
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
|
||||
end
|
||||
|
||||
local function modListWantsWheel(imp, wheel)
|
||||
if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end
|
||||
if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end
|
||||
if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end
|
||||
local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax)
|
||||
if wheel < 0 then return at < imp._modScrollMax end
|
||||
return at > 0
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- lifecycle
|
||||
|
||||
local function ensureState(imp)
|
||||
@@ -65,6 +99,9 @@ local function ensureState(imp)
|
||||
imp._actAt = imp._actAt or {}
|
||||
imp._uiActions = imp._uiActions or {}
|
||||
imp._pages = imp._pages or {}
|
||||
imp._tabScroll = imp._tabScroll or {}
|
||||
imp._tabScrollMax = imp._tabScrollMax or {}
|
||||
imp._tabContentH = imp._tabContentH or {}
|
||||
-- Held backspace/arrows must repeat in the text fields; restored on
|
||||
-- detach because the game's Input does its own per-step edge detection
|
||||
-- and never expects repeated keypressed events.
|
||||
@@ -155,7 +192,10 @@ function LauncherView.touchpressed(imp, id, x, y)
|
||||
imp._touchAt = imp._touchAt or {}
|
||||
imp._touchAt[tostring(id)] = {
|
||||
x = x, y = y,
|
||||
modsList = (imp._modScrollMax or 0) > 0 and inRect(imp._modListRect, x, y),
|
||||
modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0
|
||||
and inRect(imp._modListRect, x, y)
|
||||
and inRect(imp._tabRegionRect, x, y),
|
||||
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -170,13 +210,25 @@ function LauncherView.touchmoved(imp, id, x, y)
|
||||
-- A drag that began in the installed-mod viewport scrolls that page's
|
||||
-- rows. Its pager remains available for moving to the next ten-plus
|
||||
-- entries; a drag elsewhere keeps the normal short-window page scroll.
|
||||
if start.dragged and start.modsList then
|
||||
if start.dragged then
|
||||
local last = start.lastY or start.y
|
||||
imp.modScroll = clamp((imp.modScroll or 0) - (y - last), 0,
|
||||
imp._modScrollMax or 0)
|
||||
elseif start.dragged and (imp._pageScrollMax or 0) > 0 then
|
||||
local last = start.lastY or start.y
|
||||
imp._pageScroll = (imp._pageScroll or 0) - (y - last)
|
||||
local move = -(y - last)
|
||||
if start.modsList then
|
||||
local listMax = imp._modScrollMax or 0
|
||||
local at, leftover = Kit.scrollHandoff(
|
||||
clamp(imp.modScroll or 0, 0, listMax), listMax, move)
|
||||
imp.modScroll = at
|
||||
move = leftover
|
||||
end
|
||||
if move ~= 0 and start.region then
|
||||
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
||||
tabScrollMax(imp), move)
|
||||
setTabScroll(imp, at)
|
||||
move = leftover
|
||||
end
|
||||
if move ~= 0 and (imp._pageScrollMax or 0) > 0 then
|
||||
imp._pageScroll = (imp._pageScroll or 0) + move
|
||||
end
|
||||
end
|
||||
start.lastY = y
|
||||
end
|
||||
@@ -796,6 +848,34 @@ local function drawSkinGlyph(x, y, w, h, hot)
|
||||
Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
|
||||
end
|
||||
|
||||
local function drawSyncGlyph(x, y, w, h, hot)
|
||||
local box = math.min(w, h)
|
||||
local bx = x + (w - box) / 2
|
||||
local by = y + (h - box) / 2
|
||||
local pad = box * 0.24
|
||||
local ink = hot and PAL.inverse or PAL.ink
|
||||
local left, right = bx + pad, bx + box - pad
|
||||
local head = box * 0.15
|
||||
local bar = math.max(1, box * 0.09)
|
||||
local topY, botY = by + box * 0.34, by + box * 0.58
|
||||
Theme.fill(left, topY, math.max(0, right - left - head * 0.5), bar, ink, 1)
|
||||
Theme.fill(left + head * 0.5, botY, math.max(0, right - left - head * 0.5),
|
||||
bar, ink, 1)
|
||||
if love.graphics.line then
|
||||
love.graphics.push("all")
|
||||
Theme.col(ink, 1)
|
||||
if love.graphics.setLineWidth then
|
||||
love.graphics.setLineWidth(math.max(1.5, bar))
|
||||
end
|
||||
local ty, byy = topY + bar / 2, botY + bar / 2
|
||||
love.graphics.line(right - head, ty - head, right, ty, right - head,
|
||||
ty + head)
|
||||
love.graphics.line(left + head, byy - head, left, byy, left + head,
|
||||
byy + head)
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
@@ -885,6 +965,8 @@ local function headerChrome(imp)
|
||||
hot and QUIT_INK_HOT or QUIT_INK_REST)
|
||||
end },
|
||||
tab = {},
|
||||
sync = { face = "tab", drawFn = drawSyncGlyph,
|
||||
action = function() imp:_openSync() end },
|
||||
game = { face = "tab", font = "tab",
|
||||
action = function()
|
||||
local g = currentGame(imp)
|
||||
@@ -1050,6 +1132,27 @@ local function buildHeader(imp, m)
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
|
||||
do
|
||||
local w = tabH
|
||||
if tx > tabLeft and tx + w > tabRight then
|
||||
tx = tabLeft
|
||||
ty = ty + tabH + tabRowGap
|
||||
end
|
||||
local o = chrome.sync
|
||||
o.active = imp._syncModal ~= nil
|
||||
btn(imp, tx, ty, w, tabH, "tab-sync", "", o)
|
||||
local bh = math.floor(11 * m.s)
|
||||
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
|
||||
Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh,
|
||||
"BETA", o.active and PAL.inverse or PAL.yellow)
|
||||
local eng = imp._sync
|
||||
if eng and eng.busy and eng:busy() then
|
||||
Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s),
|
||||
math.max(2, math.floor(4 * m.s)))
|
||||
end
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
|
||||
-- `ty` has walked down with the wraps, so this stays correct at one row too.
|
||||
y = ty + tabH + math.floor(8 * m.s)
|
||||
Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline)
|
||||
@@ -1481,7 +1584,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
|
||||
return h
|
||||
end
|
||||
|
||||
local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
|
||||
imp.panelVersion = version
|
||||
local info = GameVersion.info(version)
|
||||
local locked = info == nil
|
||||
@@ -1527,12 +1630,15 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
local afterTitle = math.floor((ready and 22 or 12) * m.s)
|
||||
local cy = y + titleH + afterTitle
|
||||
local remaining = availH - (titleH + afterTitle)
|
||||
local budgetLeft = math.max(remaining,
|
||||
(budgetH or availH) - (titleH + afterTitle))
|
||||
|
||||
local gap = m.gap
|
||||
local lx, lw, rx2, rw
|
||||
if m.twoCol then
|
||||
lx, lw = x, m.colW
|
||||
rx2, rw = x + m.colW + m.colGap, m.colW
|
||||
local colW = math.floor((w - m.colGap) / 2)
|
||||
lx, lw = x, colW
|
||||
rx2, rw = x + colW + m.colGap, colW
|
||||
else
|
||||
lx, lw, rx2, rw = x, w, x, w
|
||||
end
|
||||
@@ -1579,15 +1685,19 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
-- Save slots. Two columns put them beside the left stack; ONE column
|
||||
-- stacks them underneath. Either way the card is clipped to the room it
|
||||
-- actually has, and sizes its own list to that budget.
|
||||
local bottom = ly
|
||||
if not locked then
|
||||
local slotY = m.twoCol and cy or ly
|
||||
local slotAvail = m.twoCol and remaining or (cy + remaining - ly)
|
||||
local slotAvail = m.twoCol and budgetLeft or (cy + budgetLeft - ly)
|
||||
if slotAvail > 80 * m.s then
|
||||
Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail))
|
||||
buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready)
|
||||
local slotH = buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version,
|
||||
ready)
|
||||
Kit.popClip()
|
||||
bottom = math.max(bottom, slotY + math.min(slotH or 0, slotAvail))
|
||||
end
|
||||
end
|
||||
return bottom - y
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- mods panel
|
||||
@@ -1821,7 +1931,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
if #mods == 0 then
|
||||
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
|
||||
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
|
||||
return
|
||||
return (cy - y) + math.floor(110 * m.s)
|
||||
end
|
||||
|
||||
local sortKey = currentSort(imp, "mods")
|
||||
@@ -1882,7 +1992,8 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
if not lr then lr = {}; imp._modListRect = lr end
|
||||
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
|
||||
imp._modScrollMax = scrollMax
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
|
||||
and Kit.hit(x, listTop, w, listH) then
|
||||
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
|
||||
Kit.wheelY = 0
|
||||
elseif scrollMax == 0 then
|
||||
@@ -2008,9 +2119,10 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
Kit.popClip()
|
||||
|
||||
local pagerY = listTop + listH + gap
|
||||
local newPage = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
|
||||
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
|
||||
if newPage ~= cur then imp.modScroll = 0 end
|
||||
setPage(imp, "mods", newPage)
|
||||
return pagerY + newPagerH - y
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- find mods panel
|
||||
@@ -2044,6 +2156,32 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
local urlH = m.btnH
|
||||
local addLabel = Strings("Add")
|
||||
local addW = Kit.textWidth("small", addLabel) + math.floor(24 * m.s)
|
||||
local pasteLabel = Strings("Paste")
|
||||
local pasteW = Kit.textWidth("small", pasteLabel) + math.floor(20 * m.s)
|
||||
if imp._skinFetch then
|
||||
Loader.inline(x, cy, w, urlH,
|
||||
Strings("Downloading %s...", tostring(imp._skinFetch.name or "")))
|
||||
else
|
||||
local urlPlace = Layout.rightCluster(x, w, math.floor(6 * m.s))
|
||||
btn(imp, urlPlace(addW), cy, addW, urlH, "skins-url-add", addLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp:_addSkinFromUrl() end })
|
||||
if w - addW - pasteW > math.floor(140 * m.s) then
|
||||
btn(imp, urlPlace(pasteW), cy, pasteW, urlH, "skins-url-paste",
|
||||
pasteLabel, {
|
||||
font = "small", action = function() imp:_pasteSkinUrl() end })
|
||||
end
|
||||
local fieldW = math.max(0, urlPlace(0) - x - math.floor(6 * m.s))
|
||||
textField(imp, x, cy, fieldW, urlH, "skins-url", imp.skinUrl or "",
|
||||
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
|
||||
imp._skinUrlFocus == true,
|
||||
function() imp:_toggleSkinUrlFocus() end)
|
||||
end
|
||||
cy = cy + urlH + math.floor(8 * m.s)
|
||||
|
||||
-- Studio button. Desktop only: the host supplies the hook nowhere else.
|
||||
if imp.onOpenSkinStudio then
|
||||
local label = Strings("Open Skin Studio")
|
||||
@@ -2071,7 +2209,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
-- The row itself is "use this skin"; the gear beside it configures that
|
||||
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
|
||||
-- opens the studio, so neither lands on a screen that cannot edit it.
|
||||
local function skinRow(key, id, title, detail, selected, configure)
|
||||
local function skinRow(key, id, title, detail, selected, configure, format)
|
||||
local gearW = configure and rowH or 0
|
||||
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
|
||||
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
|
||||
@@ -2079,7 +2217,17 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
local tagW = selected
|
||||
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
|
||||
or math.floor(12 * m.s)
|
||||
local textW = rowW - math.floor(24 * m.s) - tagW
|
||||
local badge = format and SKIN_FORMAT_LABEL[format] or nil
|
||||
local badgeW = 0
|
||||
if badge then
|
||||
badgeW = Kit.textWidth("micro", badge) + math.floor(16 * m.s)
|
||||
local badgeH = math.floor(16 * m.s)
|
||||
Kit.tag(x + rowW - tagW - badgeW - math.floor(12 * m.s),
|
||||
cy + (rowH - badgeH) / 2, badgeW, badgeH, badge,
|
||||
format == "native" and PAL.green or PAL.blue)
|
||||
badgeW = badgeW + math.floor(10 * m.s)
|
||||
end
|
||||
local textW = math.max(0, rowW - math.floor(24 * m.s) - tagW - badgeW)
|
||||
local tx = x + math.floor(12 * m.s)
|
||||
local ty = cy + math.floor(7 * m.s)
|
||||
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
|
||||
@@ -2102,7 +2250,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local hint = Strings(
|
||||
"You can also drop a skin .zip on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files work as-is.",
|
||||
"You can also drop a skin .zip or .deltaskin on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files and Delta skins work as-is.",
|
||||
TouchSkin.USER_ROOT)
|
||||
local hintH = Kit.wrapHeight("small", hint, w, 3)
|
||||
local importH = math.floor(10 * m.s) + hintH
|
||||
@@ -2111,9 +2259,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
local listTop = cy
|
||||
local listH = availH - (cy - y) - importH
|
||||
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, 1, 20)
|
||||
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, MIN_SKIN_ROWS, 20)
|
||||
if #entries > perPage then
|
||||
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20)
|
||||
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap,
|
||||
MIN_SKIN_ROWS, 20)
|
||||
end
|
||||
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
|
||||
#entries, perPage)
|
||||
@@ -2142,11 +2291,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
|
||||
end
|
||||
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
|
||||
local configure = imp.onOpenSkinStudio and function()
|
||||
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
|
||||
end or nil
|
||||
local configure = function() imp._skinActions = { id = entry.id } end
|
||||
skinRow("skin-" .. entry.id, entry.id, entry.id,
|
||||
table.concat(bits, " \194\183 "), active == entry.id, configure)
|
||||
table.concat(bits, " \194\183 "), active == entry.id, configure,
|
||||
entry.format)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2164,6 +2312,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
|
||||
cy = cy + math.floor(10 * m.s)
|
||||
Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
|
||||
return cy + hintH - y
|
||||
end
|
||||
|
||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
@@ -2201,7 +2350,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
aw, m.btnH, "find-add", Strings("Add an index"), {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp._indexManage = true end })
|
||||
return
|
||||
return (cy - y) + h
|
||||
end
|
||||
|
||||
-- One row: the search field, then Filter / Sort / Indexes popup buttons.
|
||||
@@ -2232,7 +2381,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
Kit.emptyBox(x, cy, w, math.floor(110 * m.s),
|
||||
(total == 0) and Strings("This index lists no mods yet.")
|
||||
or Strings("No mods match that search."))
|
||||
return
|
||||
return (cy - y) + math.floor(110 * m.s)
|
||||
end
|
||||
|
||||
local sortKey = currentSort(imp, "find")
|
||||
@@ -2283,7 +2432,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
+ math.floor(8 * m.s)
|
||||
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
local listH = availH - (cy - y) - pagerH - gap
|
||||
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 20)
|
||||
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_FIND_ROWS, 20)
|
||||
local first, last, cur, pages = Kit.pageBounds(page(imp, "find"), #rows, perPage)
|
||||
setPage(imp, "find", cur)
|
||||
local listTop = cy
|
||||
@@ -2384,7 +2533,10 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
end
|
||||
|
||||
local pagerY = listTop + (last - first + 1) * (rowH + gap)
|
||||
setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find"))
|
||||
local findPage, findPagerH = Kit.pager(x, pagerY, w, cur, #rows, perPage,
|
||||
"find")
|
||||
setPage(imp, "find", findPage)
|
||||
local bottom = pagerY + findPagerH
|
||||
|
||||
-- Aggregate progress. Enrichment happens a page at a time and each row says
|
||||
-- so for itself, but with nothing summarising it the panel looked idle while
|
||||
@@ -2398,7 +2550,9 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
Kit.text("micro", Strings("Checking %d of %d on this page...",
|
||||
waiting, last - first + 1),
|
||||
x + dh + math.floor(6 * m.s), py, PAL.muted)
|
||||
bottom = math.max(bottom, py + dh)
|
||||
end
|
||||
return bottom - y
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ footer
|
||||
@@ -3161,6 +3315,75 @@ end
|
||||
|
||||
-- Per-mod actions for the MODS tab: the row itself only carries the enable
|
||||
-- toggle, everything episodic (update check, versions, delete) lives here.
|
||||
local SKIN_EXPORTS = {
|
||||
{ id = "native", key = "skinact-exp-native", label = "Export as gen1recomp .zip" },
|
||||
{ id = "retroarch", key = "skinact-exp-ra", label = "Export as RetroArch .zip" },
|
||||
{ id = "delta", key = "skinact-exp-delta", label = "Export as Delta .deltaskin" },
|
||||
}
|
||||
|
||||
local function buildSkinActionsModal(imp, m)
|
||||
local id = imp._skinActions and imp._skinActions.id
|
||||
if not id then imp._skinActions = nil return end
|
||||
local entry
|
||||
for _, e in ipairs(imp:_ensureSkins()) do
|
||||
if e.id == id then entry = e break end
|
||||
end
|
||||
if not entry then imp._skinActions = nil return end
|
||||
local pad = math.floor(18 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local rows = #SKIN_EXPORTS + 2 + (imp.onOpenSkinStudio and 1 or 0)
|
||||
+ (imp._skinExport and imp._skinExport.dir and 1 or 0)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ rows * (m.btnH + gap) - gap + pad
|
||||
local px, py, pw = modalPanel(m, math.floor(440 * m.s), h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Kit.ellipsize("button", entry.id, pw - 2 * pad),
|
||||
px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
local fmt = SKIN_FORMAT_LABEL[entry.format or ""] or Strings("unknown format")
|
||||
Kit.text("small", Kit.ellipsize("small",
|
||||
fmt .. " \194\183 " .. entry.pages .. " " .. Strings("pages")
|
||||
.. " \194\183 " .. entry.controls .. " " .. Strings("buttons"),
|
||||
pw - 2 * pad), px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-use",
|
||||
Strings("Use this skin"), { kind = "primary", font = "small",
|
||||
action = function()
|
||||
imp:_useSkin(id)
|
||||
imp._skinActions = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
if imp.onOpenSkinStudio then
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-edit",
|
||||
Strings("Open in Skin Studio"), { kind = "accent", font = "small",
|
||||
action = function()
|
||||
imp._skinActions = nil
|
||||
imp.onOpenSkinStudio(imp.modScope or "red", id)
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
for _, spec in ipairs(SKIN_EXPORTS) do
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, spec.key, Strings(spec.label), {
|
||||
font = "small",
|
||||
action = function()
|
||||
imp:_exportSkin(id, spec.id)
|
||||
imp._skinActions = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
if imp._skinExport and imp._skinExport.dir then
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-reveal",
|
||||
Strings("Show the exported file"), { font = "small",
|
||||
action = function() imp:_revealSkinExport() end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._skinActions = nil end })
|
||||
end
|
||||
|
||||
local function buildModActionsModal(imp, m)
|
||||
local mod
|
||||
for _, mm in ipairs(imp.mods or {}) do
|
||||
@@ -3907,6 +4130,335 @@ local function buildDepResolverModal(imp, m)
|
||||
end
|
||||
end
|
||||
|
||||
local SYNC_HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. It is brand new, so keep your own backups too."
|
||||
|
||||
local function syncTitle(imp, m, px, py, pw, pad)
|
||||
local label = Strings("SAVE SYNC")
|
||||
Kit.text("button", label, px + pad, py, PAL.heading)
|
||||
local bh = math.floor(15 * m.s)
|
||||
local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s)
|
||||
Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
|
||||
py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow)
|
||||
return py + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
end
|
||||
|
||||
local function syncStatus(imp, m, x, y, w, eng)
|
||||
if eng:busy() then
|
||||
Loader.inline(x, y, w, m.btnH, eng.status)
|
||||
return m.btnH + math.floor(8 * m.s)
|
||||
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") + math.floor(10 * m.s)
|
||||
end
|
||||
|
||||
local function syncRow(imp, m, x, y, w, key, label, opts)
|
||||
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)
|
||||
end
|
||||
|
||||
function LauncherView.syncSideText(meta)
|
||||
meta = type(meta) == "table" and meta or {}
|
||||
local summary = type(meta.summary) == "table" and meta.summary or {}
|
||||
local bits = {}
|
||||
if type(summary.name) == "string" and summary.name ~= "" then
|
||||
bits[#bits + 1] = summary.name
|
||||
end
|
||||
if tonumber(summary.badges) then
|
||||
bits[#bits + 1] = tostring(math.floor(summary.badges)) .. " "
|
||||
.. Strings("badges")
|
||||
end
|
||||
if type(summary.timeText) == "string" and summary.timeText ~= "" then
|
||||
bits[#bits + 1] = summary.timeText
|
||||
end
|
||||
if tonumber(summary.dexCount) then
|
||||
bits[#bits + 1] = tostring(math.floor(summary.dexCount)) .. " "
|
||||
.. Strings("seen")
|
||||
end
|
||||
local when = tonumber(meta.savedAt)
|
||||
if when then
|
||||
bits[#bits + 1] = Strings("saved") .. " " .. os.date("%Y-%m-%d %H:%M", when)
|
||||
end
|
||||
if #bits == 0 then return Strings("no details") end
|
||||
return table.concat(bits, " \194\183 ")
|
||||
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 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 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)
|
||||
|
||||
local function side(title, meta)
|
||||
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)
|
||||
end
|
||||
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
|
||||
row.localMeta)
|
||||
side(Strings("Other device"), row.remoteMeta)
|
||||
|
||||
local key = row.key
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "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",
|
||||
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",
|
||||
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 })
|
||||
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 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 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",
|
||||
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",
|
||||
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",
|
||||
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 })
|
||||
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 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 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)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
|
||||
Kit.text("stat", eng.shareCode, px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("stat") + math.floor(4 * m.s)
|
||||
Kit.text("micro", Kit.ellipsize("micro",
|
||||
Strings("Enter this code in Save Sync > Get mod list"), innerW),
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
end
|
||||
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 })
|
||||
|
||||
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 = 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 })
|
||||
|
||||
if plan then
|
||||
local line = Strings("%d mods, %d indexes to add",
|
||||
#(plan.toInstall or {}) + #(plan.toEnable or {}), #(plan.indexes or {}))
|
||||
if #(plan.missing or {}) > 0 then
|
||||
line = line .. " \194\183 " .. Strings("%d not in your indexes",
|
||||
#plan.missing)
|
||||
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,
|
||||
Strings("%d of %d", prog.done or 0, prog.total or 0))
|
||||
cy = cy + m.btnH + math.floor(8 * m.s)
|
||||
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 })
|
||||
end
|
||||
end
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
|
||||
{ action = function() imp:_syncView("home") end })
|
||||
end
|
||||
|
||||
function LauncherView.syncDeviceRows(eng, limit)
|
||||
local out = {}
|
||||
if not eng or type(eng.devices) ~= "table" then return out end
|
||||
for _, row in ipairs(eng.devices) do
|
||||
if #out >= (limit or 6) then break end
|
||||
if type(row) == "table" and type(row.id) == "string" then
|
||||
out[#out + 1] = {
|
||||
id = row.id,
|
||||
current = row.current == true,
|
||||
label = type(row.label) == "string" and row.label ~= "" and row.label
|
||||
or "device",
|
||||
}
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function buildSyncHome(imp, m, eng)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(460 * m.s)
|
||||
local linked = eng:linked()
|
||||
local codes = eng.codes
|
||||
local body = linked
|
||||
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.")
|
||||
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 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)
|
||||
|
||||
if codes then
|
||||
Kit.text("small", Strings("Enter these on your other device:"), px + pad,
|
||||
cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
Kit.text("title", codes.code1, px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("title") + math.floor(4 * m.s)
|
||||
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)
|
||||
|
||||
if #devices > 0 then
|
||||
Kit.text("small", 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 })
|
||||
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 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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 })
|
||||
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 })
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
|
||||
Strings("Unlink this device"), { kind = "danger",
|
||||
action = function() imp:_syncUnlink() end })
|
||||
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 })
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
|
||||
Strings("Link this device"), { kind = "accent",
|
||||
action = function() imp:_syncView("link") end })
|
||||
end
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
|
||||
{ action = function() imp:_closeSync() end })
|
||||
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 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 })
|
||||
end
|
||||
|
||||
local function buildSyncModal(imp, m)
|
||||
if not imp:_syncSupported() then
|
||||
buildSyncUnavailable(imp, m, Strings(
|
||||
"Save sync cannot run on this build: it has no way to send the signed requests it needs. Update to the latest app build, or use a desktop build."))
|
||||
return
|
||||
end
|
||||
local eng = imp._sync
|
||||
if not eng then
|
||||
buildSyncUnavailable(imp, m,
|
||||
Strings("Save sync is not available in this build."))
|
||||
return
|
||||
end
|
||||
if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
|
||||
buildSyncConflict(imp, m, eng)
|
||||
return
|
||||
end
|
||||
local view = imp._syncModal and imp._syncModal.view or "home"
|
||||
if view == "link" then
|
||||
buildSyncLink(imp, m, eng)
|
||||
elseif view == "mods" then
|
||||
buildSyncMods(imp, m, eng)
|
||||
else
|
||||
buildSyncHome(imp, m, eng)
|
||||
end
|
||||
end
|
||||
|
||||
-- Whether ANY modal will draw this frame. draw() consults this BEFORE the
|
||||
-- panels build: immediate mode hit-tests each control as it draws, so the
|
||||
-- panels underneath a modal must run with Kit.blockClicks already raised or
|
||||
@@ -3919,7 +4471,7 @@ local function modalUp(imp)
|
||||
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
||||
or imp._filterPopup or imp._modScopePopup or imp._indexManage
|
||||
or imp._gamePopup
|
||||
or imp._modActions or imp._modImports
|
||||
or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal
|
||||
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
||||
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
||||
end
|
||||
@@ -4064,6 +4616,8 @@ local function buildModals(imp, m)
|
||||
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._syncModal then buildSyncModal(imp, m) return true end
|
||||
if imp._skinActions then buildSkinActionsModal(imp, m) return true end
|
||||
if imp._modActions then buildModActionsModal(imp, m) return true end
|
||||
if imp._findEntry then buildFindEntryModal(imp, m) return true end
|
||||
if imp._gameManage then buildGameManageModal(imp, m) return true end
|
||||
@@ -4173,13 +4727,6 @@ function LauncherView.draw(imp)
|
||||
local footH = footerHeight(imp, m)
|
||||
local naturalAvail = m.h - headerHeight(m) - footH - m.gap
|
||||
local scrollMax = math.max(0, minPanelHeight(m) - naturalAvail)
|
||||
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
|
||||
if scrollMax > 0 and (imp._wheelY or 0) ~= 0 then
|
||||
scroll = math.max(0, math.min(
|
||||
scroll - imp._wheelY * math.floor(48 * m.s), scrollMax))
|
||||
imp._wheelY = 0 -- the page consumed the wheel; lists page by tap here
|
||||
end
|
||||
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
|
||||
|
||||
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
||||
imp._clickPt = nil
|
||||
@@ -4192,6 +4739,35 @@ function LauncherView.draw(imp)
|
||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||
Kit.blockClicks = modalUp(imp)
|
||||
|
||||
local step = Kit.scrollStep(m.s)
|
||||
local nested = modListWantsWheel(imp, Kit.wheelY or 0)
|
||||
if not nested then
|
||||
local rect = imp._tabRegionRect
|
||||
if rect then
|
||||
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
|
||||
rect.x, rect.y, rect.w, rect.h, step)))
|
||||
end
|
||||
end
|
||||
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested
|
||||
and not Kit.blockClicks then
|
||||
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
|
||||
if moved ~= scroll then
|
||||
scroll = moved
|
||||
Kit.wheelY = 0
|
||||
end
|
||||
end
|
||||
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
|
||||
if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks
|
||||
and tabScrollMax(imp) > 0 then
|
||||
local was = tabScrollAt(imp)
|
||||
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
|
||||
if to ~= was then
|
||||
setTabScroll(imp, to)
|
||||
Kit.wheelY = 0
|
||||
end
|
||||
end
|
||||
|
||||
-- The header is the only block that moves with the page scroll, so shift
|
||||
-- m.top across the call and put it back rather than wrapping `m` in a
|
||||
-- proxy: the proxy cost two tables a frame and put a metatable lookup on
|
||||
@@ -4210,15 +4786,31 @@ function LauncherView.draw(imp)
|
||||
end
|
||||
|
||||
local x, w = m.contentX, m.contentW
|
||||
local viewH = math.max(0, availH)
|
||||
local rect = imp._tabRegionRect
|
||||
if not rect then rect = {}; imp._tabRegionRect = rect end
|
||||
rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH
|
||||
|
||||
local at = tabScrollAt(imp)
|
||||
local py = Kit.scrollBegin(x, contentY, w, viewH, at, tabScrollMax(imp))
|
||||
local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN))
|
||||
local panelW = math.max(0, w - Kit.scrollGutter(m.s))
|
||||
local contentH
|
||||
if imp.tab == "mods" then
|
||||
buildModsPanel(imp, x, contentY, w, availH, m)
|
||||
contentH = buildModsPanel(imp, x, py, panelW, budgetH, m)
|
||||
elseif imp.tab == "find" then
|
||||
buildFindPanel(imp, x, contentY, w, availH, m)
|
||||
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
|
||||
elseif imp.tab == "skins" then
|
||||
buildSkinsPanel(imp, x, contentY, w, availH, m)
|
||||
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
|
||||
else
|
||||
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
|
||||
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
|
||||
end
|
||||
contentH = contentH or availH
|
||||
imp._tabContentH[tabKeyOf(imp)] = contentH
|
||||
imp._tabScrollMax[tabKeyOf(imp)] = Kit.scrollExtent(contentH, viewH)
|
||||
at = clamp(at, 0, tabScrollMax(imp))
|
||||
imp._tabScroll[tabKeyOf(imp)] = at
|
||||
Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp))
|
||||
|
||||
buildFooter(imp, m, footY)
|
||||
Kit.blockClicks = false
|
||||
|
||||
+421
-12
@@ -1111,18 +1111,18 @@ local function chooseZip()
|
||||
end
|
||||
|
||||
local function chooseSkinZip()
|
||||
local prompt = shellSafe(Strings("Choose a skin .zip"))
|
||||
local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip", "deltaskin"})' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';",
|
||||
"$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||
"$t=Join-Path $env:TEMP $n;",
|
||||
@@ -1134,11 +1134,11 @@ local function chooseSkinZip()
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]])
|
||||
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip *.deltaskin" 2>/dev/null]])
|
||||
:format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput(
|
||||
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]])
|
||||
[[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -1349,6 +1349,7 @@ function RomImporter.new(onComplete, opts)
|
||||
findLoaded = false, findSources = nil, findIndex = nil,
|
||||
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
|
||||
_findSearchFocus = false, _findThumbs = nil,
|
||||
skinUrl = "", _skinUrlFocus = false,
|
||||
-- Page scroll offset (px) for the column under the tab bar -- panel, updater
|
||||
-- banner and footer -- used only while that column is taller than the window
|
||||
-- (see draw()). Clamped against content in draw, reset on a tab change.
|
||||
@@ -1851,10 +1852,14 @@ end
|
||||
function RomImporter:filedropped(file)
|
||||
if self.workState == "working" then return end
|
||||
-- A dropped .zip is a mod archive: hand it straight to the mods installer
|
||||
-- (which mounts + validates it). Everything else is treated as a ROM. The
|
||||
-- dropped file itself is passed through -- installZip opens it the same way
|
||||
-- readDroppedFile does here.
|
||||
-- (which mounts + validates it). A .deltaskin is only ever a skin, and
|
||||
-- everything else is treated as a ROM. The dropped file itself is passed
|
||||
-- through -- installZip opens it the same way readDroppedFile does here.
|
||||
local name = file:getFilename() or ""
|
||||
if name:lower():match("%.deltaskin$") then
|
||||
self:_installSkinZip(file)
|
||||
return
|
||||
end
|
||||
if name:lower():match("%.zip$") then
|
||||
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
|
||||
if self.tab == "skins" then
|
||||
@@ -2481,6 +2486,8 @@ function RomImporter:update(dt)
|
||||
self:_pumpModInfoFetch()
|
||||
self:_pumpFindStats()
|
||||
self:_pumpFindThumbs()
|
||||
self:_pumpSkinFetch()
|
||||
self:_pumpSync(dt)
|
||||
self:_pumpModCheck()
|
||||
self:_pumpModInstall()
|
||||
self:_pumpExtract()
|
||||
@@ -3082,6 +3089,8 @@ end
|
||||
function RomImporter:_switchTab(id)
|
||||
self.tab = id
|
||||
self._findSearchFocus = false
|
||||
self._skinUrlFocus = false
|
||||
self._modScrollMax, self._modListRect = 0, nil
|
||||
self:_disarmTextInput()
|
||||
-- the skins list is cheap and can change behind the launcher's back
|
||||
-- (an export, a hand-dropped folder), so re-read it on every visit
|
||||
@@ -3107,6 +3116,7 @@ function RomImporter:_ensureSkins(force)
|
||||
out[#out + 1] = {
|
||||
id = entry.id,
|
||||
source = entry.source,
|
||||
format = skin and skin.format or nil,
|
||||
pages = skin and #skin.pages or 0,
|
||||
controls = controls,
|
||||
screen = page ~= nil and page.viewport ~= nil,
|
||||
@@ -3140,7 +3150,6 @@ end
|
||||
function RomImporter:_installSkinZip(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "skins"
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local name, data, readError
|
||||
if type(source) == "string" then
|
||||
name = source
|
||||
@@ -3160,13 +3169,368 @@ function RomImporter:_installSkinZip(source)
|
||||
.. tostring(readError or name) }
|
||||
return
|
||||
end
|
||||
local id, err = TouchSkin.installArchive(name, data)
|
||||
self:_installSkinData(name, data)
|
||||
end
|
||||
|
||||
local MAX_SKIN_URL = 300
|
||||
local SKIN_TEMP_DIR = "skins/_download"
|
||||
|
||||
function RomImporter.skinUrlName(url)
|
||||
local path = tostring(url or ""):gsub("[?#].*$", "")
|
||||
local base = (path:match("([^/\\]+)$") or ""):gsub("[^%w%._%-]", "_")
|
||||
local ext = base:match("%.([%w]+)$")
|
||||
if not ext then
|
||||
return (base ~= "" and base or "skin") .. ".zip"
|
||||
end
|
||||
ext = ext:lower()
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
if TouchSkin.ARCHIVE_EXTS[ext] or ext == "cfg" then return base end
|
||||
return (base:gsub("%.[%w]+$", "")) .. ".zip"
|
||||
end
|
||||
|
||||
function RomImporter.wrapSkinPayload(name, data)
|
||||
name = tostring(name or "")
|
||||
if not name:lower():match("%.cfg$") then return name, data end
|
||||
if not data then return name, data end
|
||||
if data:sub(1, 2) == "PK" then
|
||||
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", data
|
||||
end
|
||||
local blob = require("src.core.SkinZip").encode({
|
||||
{ name = "overlay.cfg", data = data },
|
||||
})
|
||||
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", blob
|
||||
end
|
||||
|
||||
function RomImporter:_installSkinData(name, data)
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
if not data or data == "" then
|
||||
self._skinNotice = { ok = false, text = Strings("The skin file was empty.") }
|
||||
return nil
|
||||
end
|
||||
local wrappedName, payload = RomImporter.wrapSkinPayload(name, data)
|
||||
local id, note = TouchSkin.installArchive(wrappedName, payload)
|
||||
self:_ensureSkins(true)
|
||||
if not id then
|
||||
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
|
||||
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(note) }
|
||||
return nil
|
||||
end
|
||||
local text = "Imported " .. id
|
||||
if type(note) == "table" and note[1] then
|
||||
text = text .. ": " .. tostring(note[1])
|
||||
end
|
||||
self._skinNotice = { ok = true, text = text }
|
||||
return id
|
||||
end
|
||||
|
||||
function RomImporter:_toggleSkinUrlFocus()
|
||||
self._skinUrlFocus = not self._skinUrlFocus
|
||||
if self._skinUrlFocus then
|
||||
self:_armTextInput()
|
||||
else
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_pasteSkinUrl()
|
||||
local ok, text = pcall(love.system.getClipboardText)
|
||||
if ok and type(text) == "string" then
|
||||
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
|
||||
MAX_SKIN_URL)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_addSkinFromUrl(url)
|
||||
if self._skinFetch then return false end
|
||||
url = tostring(url or self.skinUrl or ""):gsub("%s", "")
|
||||
if url == "" then
|
||||
self._skinNotice = { ok = false,
|
||||
text = Strings("Paste a link to a skin archive first.") }
|
||||
return false
|
||||
end
|
||||
if not url:match("^https?://") then
|
||||
self._skinNotice = { ok = false,
|
||||
text = Strings("A skin link has to start with http:// or https://") }
|
||||
return false
|
||||
end
|
||||
if not require("src.core.Platform").canFetchRemote() then
|
||||
self._skinNotice = { ok = false,
|
||||
text = Strings("Downloading needs a network transport this build has not got.") }
|
||||
return false
|
||||
end
|
||||
local name = RomImporter.skinUrlName(url)
|
||||
local Fetch = require("src.net.Fetch")
|
||||
self._skinFetch = {
|
||||
url = url, name = name, dest = SKIN_TEMP_DIR .. "/" .. name,
|
||||
job = Fetch.download(url, SKIN_TEMP_DIR .. "/" .. name,
|
||||
{ userAgent = "gen1recomp-skin", maxSeconds = 90 }),
|
||||
}
|
||||
self._skinNotice = { ok = true, text = Strings("Downloading %s...", name) }
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_pumpSkinFetch()
|
||||
local f = self._skinFetch
|
||||
if not f then return end
|
||||
local Fetch = require("src.net.Fetch")
|
||||
local st = Fetch.poll(f.job)
|
||||
if st.status == "pending" then
|
||||
self._skinFetchProgress = st.progress
|
||||
return
|
||||
end
|
||||
self._skinNotice = { ok = true, text = "Imported " .. id }
|
||||
Fetch.release(f.job)
|
||||
self._skinFetch, self._skinFetchProgress = nil, nil
|
||||
if st.status ~= "ok" or not st.path then
|
||||
self._skinNotice = { ok = false,
|
||||
text = "Download failed: " .. tostring(st.err or "no data") }
|
||||
return
|
||||
end
|
||||
local data = love.filesystem.read(st.path)
|
||||
love.filesystem.remove(st.path)
|
||||
if self:_installSkinData(f.name, data) then
|
||||
self.skinUrl = ""
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_exportSkin(id, kind)
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local entry = id and TouchSkin.find(id)
|
||||
if not entry then
|
||||
self._skinNotice = { ok = false, text = Strings("That skin is gone.") }
|
||||
return nil
|
||||
end
|
||||
local skin = TouchSkin.load(entry.root, entry.id)
|
||||
if not skin then
|
||||
self._skinNotice = { ok = false,
|
||||
text = Strings("Could not read %s", tostring(id)) }
|
||||
return nil
|
||||
end
|
||||
local path, missing, warnings
|
||||
if kind == "retroarch" then
|
||||
path, missing = TouchSkin.exportRetroArch(skin)
|
||||
elseif kind == "delta" then
|
||||
path, missing, warnings = TouchSkin.exportDelta(skin)
|
||||
else
|
||||
path, missing = TouchSkin.export(skin)
|
||||
end
|
||||
if not path then
|
||||
self._skinNotice = { ok = false,
|
||||
text = "Export failed: " .. tostring(missing) }
|
||||
return nil
|
||||
end
|
||||
local dir = love.filesystem.getSaveDirectory
|
||||
and love.filesystem.getSaveDirectory() or nil
|
||||
self._skinExport = { path = path, dir = dir }
|
||||
local text = Strings("Exported to %s", (dir and (dir .. "/") or "") .. path)
|
||||
if type(missing) == "table" and missing[1] then
|
||||
text = text .. " (" .. #missing .. " image(s) missing)"
|
||||
end
|
||||
if type(warnings) == "table" and warnings[1] then
|
||||
text = text .. " " .. tostring(warnings[1])
|
||||
end
|
||||
self._skinNotice = { ok = true, text = text }
|
||||
return path
|
||||
end
|
||||
|
||||
function RomImporter:_revealSkinExport()
|
||||
local e = self._skinExport
|
||||
if not e or not e.dir then return false end
|
||||
if love.system and love.system.openURL then
|
||||
pcall(love.system.openURL, fileUrl(e.dir))
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local MAX_SYNC_CODE = 8
|
||||
local MAX_SHARE_CODE = 6
|
||||
|
||||
function RomImporter.syncDigits(text)
|
||||
local digits = tostring(text or ""):gsub("[^%d]", "")
|
||||
return digits:sub(1, MAX_SYNC_CODE)
|
||||
end
|
||||
|
||||
function RomImporter.syncShareCode(text)
|
||||
local out = tostring(text or ""):upper():gsub("[^A-Z2-9]", "")
|
||||
return out:sub(1, MAX_SHARE_CODE)
|
||||
end
|
||||
|
||||
function RomImporter:_syncDeviceLabel()
|
||||
local name = love.system and love.system.getOS and love.system.getOS()
|
||||
if type(name) ~= "string" or name == "" then return "device" end
|
||||
return name
|
||||
end
|
||||
|
||||
function RomImporter:_syncEngine()
|
||||
if self._sync ~= nil then return self._sync or nil end
|
||||
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||
if not ok or type(SyncEngine) ~= "table" then
|
||||
self._sync = false
|
||||
return nil
|
||||
end
|
||||
local made, eng = pcall(SyncEngine.shared)
|
||||
if not made or type(eng) ~= "table" then
|
||||
self._sync = false
|
||||
return nil
|
||||
end
|
||||
self._sync = eng
|
||||
return eng
|
||||
end
|
||||
|
||||
function RomImporter:_syncSupported()
|
||||
if self._syncTransportOk ~= nil then return self._syncTransportOk end
|
||||
local ok, HostShell = pcall(require, "src.core.HostShell")
|
||||
if not ok or type(HostShell) ~= "table"
|
||||
or type(HostShell.canHttpRequest) ~= "function" then
|
||||
self._syncTransportOk = true
|
||||
return true
|
||||
end
|
||||
local asked, can = pcall(HostShell.canHttpRequest)
|
||||
self._syncTransportOk = (not asked) or (can and true or false)
|
||||
return self._syncTransportOk
|
||||
end
|
||||
|
||||
function RomImporter:_pumpSync(dt)
|
||||
if self._sync == nil then
|
||||
if not self.launcher or self._syncBooted then return end
|
||||
if not self:_syncSupported() then return end
|
||||
self._syncBooted = true
|
||||
local booted = self:_syncEngine()
|
||||
if booted and booted.state.enabled and booted:linked() then
|
||||
pcall(booted.syncNow, booted)
|
||||
end
|
||||
end
|
||||
local eng = self._sync
|
||||
if not eng then return end
|
||||
pcall(eng.update, eng, dt)
|
||||
if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
|
||||
if not self._syncModal and not self._syncConflictShown then
|
||||
self._syncConflictShown = true
|
||||
self:_openSync()
|
||||
end
|
||||
else
|
||||
self._syncConflictShown = nil
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_openSync()
|
||||
self:_syncEngine()
|
||||
self._syncModal = self._syncModal
|
||||
or { view = "home", code1 = "", code2 = "", share = "" }
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_closeSync()
|
||||
self._syncModal = nil
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_syncView(view)
|
||||
if not self._syncModal then return end
|
||||
self._syncModal.view = view
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_syncFocusField(field)
|
||||
if not self._syncModal then return end
|
||||
if self._syncFocus == field then
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
return
|
||||
end
|
||||
self._syncFocus = field
|
||||
self:_armTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_syncTypeInto(field, text)
|
||||
local mo = self._syncModal
|
||||
if not mo or not field then return end
|
||||
if field == "share" then
|
||||
mo.share = RomImporter.syncShareCode((mo.share or "") .. tostring(text or ""))
|
||||
else
|
||||
mo[field] = RomImporter.syncDigits((mo[field] or "") .. tostring(text or ""))
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_syncPaste()
|
||||
local field = self._syncFocus
|
||||
if not field then return end
|
||||
local ok, text = pcall(love.system.getClipboardText)
|
||||
if ok and type(text) == "string" then self:_syncTypeInto(field, text) end
|
||||
end
|
||||
|
||||
function RomImporter:_syncCreate()
|
||||
local eng = self:_syncEngine()
|
||||
if not eng then return false end
|
||||
return eng:createAccount(self:_syncDeviceLabel())
|
||||
end
|
||||
|
||||
function RomImporter:_syncLink()
|
||||
local eng, mo = self:_syncEngine(), self._syncModal
|
||||
if not eng or not mo then return false end
|
||||
local ok = eng:linkDevice(mo.code1, mo.code2, self:_syncDeviceLabel())
|
||||
if ok then
|
||||
mo.code1, mo.code2, mo.view = "", "", "home"
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
function RomImporter:_syncNow()
|
||||
local eng = self:_syncEngine()
|
||||
if not eng then return false end
|
||||
return eng:syncNow()
|
||||
end
|
||||
|
||||
function RomImporter:_syncUnlink()
|
||||
local eng = self:_syncEngine()
|
||||
if not eng then return false end
|
||||
eng:unlink()
|
||||
if self._syncModal then self._syncModal.view = "home" end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_syncUnlinkDevice(deviceId)
|
||||
local eng = self:_syncEngine()
|
||||
if not eng or type(eng.unlinkDevice) ~= "function" then return false end
|
||||
return eng:unlinkDevice(deviceId)
|
||||
end
|
||||
|
||||
function RomImporter:_syncShareMods()
|
||||
local eng = self:_syncEngine()
|
||||
if not eng then return false end
|
||||
return eng:shareMods()
|
||||
end
|
||||
|
||||
function RomImporter:_syncGetShare()
|
||||
local eng, mo = self:_syncEngine(), self._syncModal
|
||||
if not eng or not mo then return false end
|
||||
return eng:fetchShare(mo.share or "")
|
||||
end
|
||||
|
||||
function RomImporter:_syncApplyMods()
|
||||
local eng, mo = self:_syncEngine(), self._syncModal
|
||||
if not eng then return false end
|
||||
local ok, err = eng:applyModPlan(function(done, total, label, finished)
|
||||
if not mo then return end
|
||||
if finished then
|
||||
mo.progress = nil
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
else
|
||||
mo.progress = { done = done, total = total, label = label }
|
||||
end
|
||||
end)
|
||||
if mo then mo.progress = nil end
|
||||
if ok and self._refreshMods then self:_refreshMods() end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
function RomImporter:_syncResolve(key, choice)
|
||||
local eng = self:_syncEngine()
|
||||
if not eng then return false end
|
||||
return eng:resolveConflict(key, choice)
|
||||
end
|
||||
|
||||
function RomImporter:_skinsImportButtonLabel()
|
||||
@@ -3338,6 +3702,27 @@ function RomImporter:keypressed(key)
|
||||
if key == "escape" then self:_closeSettings() end
|
||||
return
|
||||
end
|
||||
if self._syncModal then
|
||||
local field = self._syncFocus
|
||||
if field then
|
||||
local mo = self._syncModal
|
||||
if key == "backspace" then
|
||||
mo[field] = tostring(mo[field] or ""):sub(1, -2)
|
||||
elseif key == "return" or key == "kpenter" or key == "escape" then
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
elseif key == "v"
|
||||
and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
|
||||
self:_syncPaste()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
|
||||
return
|
||||
end
|
||||
if key == "escape" then self:_closeSync() end
|
||||
return
|
||||
end
|
||||
if self._rename then
|
||||
if key == "backspace" then
|
||||
self._rename.text = utf8Back(self._rename.text)
|
||||
@@ -3387,6 +3772,21 @@ function RomImporter:keypressed(key)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._skinUrlFocus then
|
||||
if key == "backspace" then
|
||||
self.skinUrl = utf8Back(self.skinUrl or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
self._skinUrlFocus = false
|
||||
self:_disarmTextInput()
|
||||
self:_addSkinFromUrl()
|
||||
elseif key == "escape" then
|
||||
self._skinUrlFocus = false
|
||||
self:_disarmTextInput()
|
||||
elseif key == "v" and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
|
||||
self:_pasteSkinUrl()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._findSearchFocus then
|
||||
if key == "backspace" then
|
||||
self.findQuery = utf8Back(self.findQuery or "")
|
||||
@@ -3494,6 +3894,10 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._syncModal and self._syncFocus then
|
||||
self:_syncTypeInto(self._syncFocus, text)
|
||||
return
|
||||
end
|
||||
if self._profileSavePrompt then
|
||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
@@ -3514,6 +3918,11 @@ function RomImporter:textinput(text)
|
||||
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
|
||||
return
|
||||
end
|
||||
if self._skinUrlFocus then
|
||||
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
|
||||
MAX_SKIN_URL)
|
||||
return
|
||||
end
|
||||
if self._findSearchFocus then
|
||||
self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY)
|
||||
self.findScroll = 0
|
||||
|
||||
@@ -86,6 +86,7 @@ local function drain()
|
||||
else
|
||||
j.status = msg.ok and "ok" or "error"
|
||||
j.body, j.err, j.path = msg.body, msg.err, msg.path
|
||||
j.code = msg.code
|
||||
j.progress = msg.ok and 1 or j.progress
|
||||
end
|
||||
end
|
||||
@@ -143,6 +144,14 @@ function Fetch.post(url, body, opts)
|
||||
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
function Fetch.request(url, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "request", url = url,
|
||||
method = opts.method, body = opts.body, headers = opts.headers,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
|
||||
@@ -113,6 +113,22 @@ local function doPost(job)
|
||||
post({ id = job.id, ok = true, done = true })
|
||||
end
|
||||
|
||||
local function doRequest(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
return
|
||||
end
|
||||
local body, err, code = HostShell.httpRequest(job.url, {
|
||||
method = job.method, body = job.body, headers = job.headers,
|
||||
userAgent = job.userAgent,
|
||||
maxTime = tonumber(job.maxSeconds) or GET_MAX_SECONDS })
|
||||
if not code then
|
||||
post({ id = job.id, ok = false, err = err or "request failed" })
|
||||
return
|
||||
end
|
||||
post({ id = job.id, ok = true, body = body or "", code = code, done = true })
|
||||
end
|
||||
|
||||
while true do
|
||||
local job = cmdCh:demand()
|
||||
-- The flag is checked before the job's KIND, so a worker woken by a
|
||||
@@ -131,6 +147,9 @@ while true do
|
||||
elseif job.kind == "post" then
|
||||
local ok, err = pcall(doPost, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "request" then
|
||||
local ok, err = pcall(doRequest, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "download" then
|
||||
local ok, err = pcall(doDownload, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
|
||||
local Playfield = {}
|
||||
|
||||
Playfield.WIDTH, Playfield.HEIGHT = 160, 144
|
||||
|
||||
Playfield.entered = false
|
||||
Playfield.box = nil
|
||||
|
||||
local function clampRect(x, y, w, h, sw, sh)
|
||||
if type(w) ~= "number" or type(h) ~= "number" then return nil end
|
||||
if w ~= w or h ~= h then return nil end
|
||||
x = math.floor(tonumber(x) or 0)
|
||||
y = math.floor(tonumber(y) or 0)
|
||||
w, h = math.floor(w), math.floor(h)
|
||||
if x < 0 then w, x = w + x, 0 end
|
||||
if y < 0 then h, y = h + y, 0 end
|
||||
if x + w > sw then w = sw - x end
|
||||
if y + h > sh then h = sh - y end
|
||||
if w < 1 or h < 1 then return nil end
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
function Playfield.cutout(sw, sh)
|
||||
if Playfield.entered then return nil end
|
||||
if type(sw) ~= "number" or type(sh) ~= "number" then return nil end
|
||||
if sw < 1 or sh < 1 then return nil end
|
||||
if type(TouchSkin.viewport) ~= "function" then return nil end
|
||||
local ok, x, y, w, h, fill, expand = pcall(TouchSkin.viewport, sw, sh)
|
||||
if not ok then return nil end
|
||||
local cx, cy, cw, ch = clampRect(x, y, w, h, sw, sh)
|
||||
if not cx then return nil end
|
||||
return cx, cy, cw, ch, fill == true, expand == true
|
||||
end
|
||||
|
||||
function Playfield.rect(sw, sh)
|
||||
local x, y, w, h, _, expand = Playfield.cutout(sw, sh)
|
||||
if not x then return 0, 0, sw or 0, sh or 0, false end
|
||||
if expand then return x, y, w, h, true end
|
||||
local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH,
|
||||
h / Playfield.HEIGHT)))
|
||||
local pw = math.min(w, Playfield.WIDTH * s)
|
||||
local ph = math.min(h, Playfield.HEIGHT * s)
|
||||
return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true
|
||||
end
|
||||
|
||||
function Playfield.enter(x, y, w, h)
|
||||
Playfield.entered = true
|
||||
Playfield.box = { x = x, y = y, w = w, h = h }
|
||||
end
|
||||
|
||||
function Playfield.leave()
|
||||
Playfield.entered = false
|
||||
Playfield.box = nil
|
||||
end
|
||||
|
||||
function Playfield.dimensions()
|
||||
if Playfield.entered and Playfield.box then
|
||||
return Playfield.box.w, Playfield.box.h
|
||||
end
|
||||
return GameViewport.dimensions()
|
||||
end
|
||||
|
||||
function Playfield.push(sw, sh)
|
||||
local x, y, w, h, active = Playfield.rect(sw, sh)
|
||||
local G = love.graphics
|
||||
G.push("all")
|
||||
if active then G.setScissor(x, y, w, h) end
|
||||
G.translate(x, y)
|
||||
Playfield.enter(x, y, w, h)
|
||||
return w, h, x, y, active
|
||||
end
|
||||
|
||||
function Playfield.pop()
|
||||
Playfield.leave()
|
||||
love.graphics.setScissor()
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
return Playfield
|
||||
+113
-61
@@ -15,7 +15,7 @@ local Runtime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local Playfield = require("src.render.Playfield")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -86,12 +86,12 @@ local function displayMetrics()
|
||||
if dpiX < 1e-6 then dpiX = 1 end
|
||||
if dpiY < 1e-6 then dpiY = 1 end
|
||||
local vx, vy = 0, 0
|
||||
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
|
||||
if sw and sw >= 1 and sh >= 1 then
|
||||
vx, vy = math.floor(sx), math.floor(sy)
|
||||
pw, ph = math.floor(sw), math.floor(sh)
|
||||
local cut, grow = false, false
|
||||
local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph)
|
||||
if sx then
|
||||
vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand
|
||||
end
|
||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
|
||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
@@ -269,7 +269,7 @@ end
|
||||
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||
-- tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics()
|
||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||
@@ -281,13 +281,11 @@ function Renderer:worldViewSize()
|
||||
-- this is the same sum with the viewport standing in for the window, so
|
||||
-- both platforms show the same map area at the same zoom.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
if not cap and TouchSkin.hasViewport() then
|
||||
local page = TouchSkin.page()
|
||||
if not page.viewportExpand then cap = self:fitScale() end
|
||||
end
|
||||
if not cap and cut and not grow then cap = self:fitScale() end
|
||||
if cap then
|
||||
local uiw, uih = self:uiSize()
|
||||
pw, ph = uiw * cap, uih * cap
|
||||
pw = cut and math.min(pw, uiw * cap) or uiw * cap
|
||||
ph = cut and math.min(ph, uih * cap) or uih * cap
|
||||
end
|
||||
local sp = Zoom.scale(self:fitScale())
|
||||
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
||||
@@ -346,19 +344,20 @@ end
|
||||
-- window is the classic wipe unchanged.
|
||||
--
|
||||
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
||||
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy, wx, wy)
|
||||
if not wipe or not wipe.prog or wipe.prog <= 0 then return end
|
||||
Sy = Sy or Sx
|
||||
wx, wy = wx or 0, wy or 0
|
||||
local TW, TH = 8 * Sx, 8 * Sy
|
||||
if TW < 1 then TW = 1 end
|
||||
if TH < 1 then TH = 1 end
|
||||
local prog = math.min(1, wipe.prog)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.setScissor(wx, wy, ww, wh)
|
||||
|
||||
if prog >= 1 then
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", wx, wy, ww, wh)
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
@@ -366,10 +365,10 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
|
||||
-- whole-tile padding out to each window edge, keeping the grid in phase
|
||||
-- with the letterbox's tiles
|
||||
local padL = math.max(0, math.ceil(ox / TW))
|
||||
local padT = math.max(0, math.ceil(oy / TH))
|
||||
local padR = math.max(0, math.ceil((ww - ox - vpw) / TW))
|
||||
local padB = math.max(0, math.ceil((wh - oy - vph) / TH))
|
||||
local padL = math.max(0, math.ceil((ox - wx) / TW))
|
||||
local padT = math.max(0, math.ceil((oy - wy) / TH))
|
||||
local padR = math.max(0, math.ceil((wx + ww - ox - vpw) / TW))
|
||||
local padB = math.max(0, math.ceil((wy + wh - oy - vph) / TH))
|
||||
local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
|
||||
local lbRows = math.max(1, math.floor(vph / TH + 0.5))
|
||||
local cols, rows = padL + lbCols + padR, padT + lbRows + padB
|
||||
@@ -396,9 +395,9 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
for row = 0, rows - 1 do
|
||||
local y = y0 + row * TH
|
||||
if row % 2 == 0 then
|
||||
love.graphics.rectangle("fill", 0, y, w, TH)
|
||||
love.graphics.rectangle("fill", wx, y, w, TH)
|
||||
else
|
||||
love.graphics.rectangle("fill", ww - w, y, w, TH)
|
||||
love.graphics.rectangle("fill", wx + ww - w, y, w, TH)
|
||||
end
|
||||
end
|
||||
elseif style == "vstripes" then
|
||||
@@ -406,21 +405,21 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
for col = 0, cols - 1 do
|
||||
local x = x0 + col * TW
|
||||
if col % 2 == 0 then
|
||||
love.graphics.rectangle("fill", x, 0, TW, h)
|
||||
love.graphics.rectangle("fill", x, wy, TW, h)
|
||||
else
|
||||
love.graphics.rectangle("fill", x, wh - h, TW, h)
|
||||
love.graphics.rectangle("fill", x, wy + wh - h, TW, h)
|
||||
end
|
||||
end
|
||||
elseif style == "shrink" then
|
||||
local h, w = wh / 2 * prog, ww / 2 * prog
|
||||
love.graphics.rectangle("fill", 0, 0, ww, h)
|
||||
love.graphics.rectangle("fill", 0, wh - h, ww, h)
|
||||
love.graphics.rectangle("fill", 0, 0, w, wh)
|
||||
love.graphics.rectangle("fill", ww - w, 0, w, wh)
|
||||
love.graphics.rectangle("fill", wx, wy, ww, h)
|
||||
love.graphics.rectangle("fill", wx, wy + wh - h, ww, h)
|
||||
love.graphics.rectangle("fill", wx, wy, w, wh)
|
||||
love.graphics.rectangle("fill", wx + ww - w, wy, w, wh)
|
||||
else -- split: a black cross growing out of the centre in both axes
|
||||
local h, w = wh / 2 * prog, ww / 2 * prog
|
||||
love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2)
|
||||
love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh)
|
||||
love.graphics.rectangle("fill", wx, wy + wh / 2 - h, ww, h * 2)
|
||||
love.graphics.rectangle("fill", wx + ww / 2 - w, wy, w * 2, wh)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
@@ -542,7 +541,8 @@ end
|
||||
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
||||
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
||||
-- back to the flat blit unchanged.
|
||||
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target,
|
||||
boxX, boxY, boxW, boxH)
|
||||
local shader = self:tiltShader()
|
||||
local mesh = self:tiltMesh()
|
||||
if not (shader and mesh) then return false end
|
||||
@@ -593,12 +593,16 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
mesh:setTexture(self.tiltCanvas)
|
||||
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
||||
love.graphics.push()
|
||||
if boxW and boxH and boxW > 0 and boxH > 0 then
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
end
|
||||
love.graphics.translate(wox, woy)
|
||||
love.graphics.scale(sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
love.graphics.draw(mesh)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setScissor()
|
||||
love.graphics.pop()
|
||||
return true
|
||||
end
|
||||
@@ -755,20 +759,23 @@ end
|
||||
-- scissored through the shade-remap shader, later zones on top.
|
||||
-- When GBC FX is active the composite is drawn into presentCanvas and
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
GameViewport.setTarget()
|
||||
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
|
||||
local vux, vuy = vx / dpiX, vy / dpiY
|
||||
local vuw, vuh = pw / dpiX, ph / dpiY
|
||||
function Renderer:frameRects()
|
||||
local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics()
|
||||
local r = {
|
||||
ww = ww, wh = wh, pw = pw, ph = ph, dpiX = dpiX, dpiY = dpiY,
|
||||
vx = vx, vy = vy, cut = cut,
|
||||
vux = vx / dpiX, vuy = vy / dpiY, vuw = pw / dpiX, vuh = ph / dpiY,
|
||||
}
|
||||
-- Sp = integer framebuffer pixels per GB pixel;
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
local Sp = self:fitScale()
|
||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
||||
r.Sp, r.Sx, r.Sy = Sp, Sp / dpiX, Sp / dpiY
|
||||
local uiw, uih = self:uiSize()
|
||||
local vpw, vph = uiw * Sx, uih * Sy
|
||||
r.uiw, r.uih = uiw, uih
|
||||
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
||||
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
||||
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
||||
r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
||||
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
||||
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
||||
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
||||
@@ -782,10 +789,38 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if self.uiFill then
|
||||
Up = math.min(ph / uih, pw / uiw)
|
||||
end
|
||||
local Ux, Uy = Up / dpiX, Up / dpiY
|
||||
local uvpw, uvph = uiw * Ux, uih * Uy
|
||||
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
||||
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
||||
if uiw * Up > pw or uih * Up > ph then
|
||||
Up = math.min(ph / uih, pw / uiw)
|
||||
end
|
||||
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
|
||||
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
|
||||
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
||||
r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
||||
return r
|
||||
end
|
||||
|
||||
function Renderer.clipToView(r, x, y, w, h)
|
||||
local x2, y2 = math.min(x + w, r.vux + r.vuw), math.min(y + h, r.vuy + r.vuh)
|
||||
x, y = math.max(x, r.vux), math.max(y, r.vuy)
|
||||
return x, y, math.max(0, x2 - x), math.max(0, y2 - y)
|
||||
end
|
||||
|
||||
function Renderer:playfieldRect()
|
||||
local r = self:frameRects()
|
||||
return r.vux, r.vuy, r.vuw, r.vuh, r.cut
|
||||
end
|
||||
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
GameViewport.setTarget()
|
||||
local R = self:frameRects()
|
||||
local ww, wh, pw, ph = R.ww, R.wh, R.pw, R.ph
|
||||
local dpiX, dpiY, vx, vy, cut = R.dpiX, R.dpiY, R.vx, R.vy, R.cut
|
||||
local vux, vuy, vuw, vuh = R.vux, R.vuy, R.vuw, R.vuh
|
||||
local Sp, Sx, Sy = R.Sp, R.Sx, R.Sy
|
||||
local uiw, uih = R.uiw, R.uih
|
||||
local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy
|
||||
local Ux, Uy = R.Ux, R.Uy
|
||||
local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -814,6 +849,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
|
||||
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
|
||||
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
|
||||
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
|
||||
secondScreen = require("src.render.SecondScreen"),
|
||||
}
|
||||
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
|
||||
@@ -901,23 +937,29 @@ function Renderer:endFrame(zones, worldZones)
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
end
|
||||
if cut then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
if extendedBlackBand then
|
||||
love.graphics.setColor(bandR, bandG, bandB, 1)
|
||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||
love.graphics.rectangle("fill", uox, vuy, uvpw, vuh)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||
-- canvas so the playfield sits on top of the border.
|
||||
if Runtime.wantsHook("render.letterbox") then
|
||||
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
|
||||
Runtime.call("render.letterbox", function() end, {
|
||||
ww = ww, wh = wh, pw = pw, ph = ph,
|
||||
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
worldActive = self.worldActive and true or false,
|
||||
})
|
||||
if cut then love.graphics.setScissor() end
|
||||
end
|
||||
|
||||
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
|
||||
@@ -928,6 +970,10 @@ function Renderer:endFrame(zones, worldZones)
|
||||
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
||||
end
|
||||
|
||||
local function clipToView(x, y, w, h)
|
||||
return Renderer.clipToView(R, x, y, w, h)
|
||||
end
|
||||
|
||||
if self.worldOverride then
|
||||
-- A render pipeline already produced the whole world -- terrain,
|
||||
-- characters and its own FX overlay -- as one window-resolution image,
|
||||
@@ -938,9 +984,9 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||
local loveMajor = love.getVersion()
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
||||
love.graphics.draw(self.worldOverride, vux, vuy + vuh, 0, 1 / dpiX, -1 / dpiY)
|
||||
else
|
||||
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
||||
love.graphics.draw(self.worldOverride, vux, vuy, 0, 1 / dpiX, 1 / dpiY)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
@@ -964,7 +1010,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
||||
-- identical to today.
|
||||
local projected =
|
||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy,
|
||||
present, vux, vuy, vuw, vuh)
|
||||
if not projected then
|
||||
if worldZones then
|
||||
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
|
||||
@@ -1061,7 +1108,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
and not FaithfulRes.scaleCap() then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
|
||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||
love.graphics.rectangle("fill", uox, vuy, uvpw, vuh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -1070,9 +1117,9 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- always been.
|
||||
local anchors = self.uiAnchors
|
||||
if not anchors or #anchors == 0 then
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph)
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, clipToView(uox, uoy, uvpw, uvph))
|
||||
else
|
||||
local rest = { { uox, uoy, uvpw, uvph } }
|
||||
local rest = { { clipToView(uox, uoy, uvpw, uvph) } }
|
||||
local placed = {}
|
||||
for _, a in ipairs(anchors) do
|
||||
local dw, dh = a.w * Ux, a.h * Uy
|
||||
@@ -1086,19 +1133,19 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local dx, dy
|
||||
if a.anchor == "bottom" then
|
||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||
dy = wh - gapB - dh
|
||||
dy = vuy + vuh - gapB - dh
|
||||
elseif a.anchor == "top" then
|
||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||
dy = a.y * Uy
|
||||
dy = vuy + a.y * Uy
|
||||
elseif a.anchor == "topright" then
|
||||
dx = ww - gapR - dw
|
||||
dy = a.y * Uy
|
||||
dx = vux + vuw - gapR - dw
|
||||
dy = vuy + a.y * Uy
|
||||
else -- unknown anchor: leave it where it is
|
||||
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
||||
end
|
||||
if a.windowClamped then
|
||||
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
|
||||
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
|
||||
dx = math.max(vux, math.min(math.max(vux, vux + vuw - dw), dx))
|
||||
dy = math.max(vuy, math.min(math.max(vuy, vuy + vuh - dh), dy))
|
||||
end
|
||||
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
||||
if a.extract then
|
||||
@@ -1113,13 +1160,14 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- The zone scissors are computed from the same origin, so an SGB
|
||||
-- region travels with the element instead of staying in the letterbox.
|
||||
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
||||
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy,
|
||||
clipToView(p.dx, p.dy, p.dw, p.dh))
|
||||
end
|
||||
end
|
||||
local uiRedraws = PaletteFX.uiSpriteRedraws()
|
||||
if uiRedraws[1] then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(uox, uoy, uvpw, uvph)
|
||||
love.graphics.setScissor(clipToView(uox, uoy, uvpw, uvph))
|
||||
for _, r in ipairs(uiRedraws) do
|
||||
if r.quad then
|
||||
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
|
||||
@@ -1135,7 +1183,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- over the finished composite rather than under the UI blit. On hardware
|
||||
-- it is the tilemap being overwritten -- there is nothing it does not cover.
|
||||
if self.battleWipe then
|
||||
self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
self:drawBattleWipe(self.battleWipe, vuw, vuh, ox, oy, vpw, vph, Sx, Sy,
|
||||
vux, vuy)
|
||||
end
|
||||
|
||||
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
|
||||
@@ -1157,7 +1206,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if FaithfulRes.scaleCap() then
|
||||
love.graphics.rectangle("fill", ox, oy, vpw, vph)
|
||||
else
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -1181,6 +1230,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
generation = 1,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
@@ -1190,6 +1240,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
end
|
||||
if cut then love.graphics.setScissor() end
|
||||
end
|
||||
end
|
||||
self.worldActive = false
|
||||
@@ -1202,6 +1253,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
gameWidth = vpw, gameHeight = vph,
|
||||
scale = Sp,
|
||||
dpiX = dpiX, dpiY = dpiY,
|
||||
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local SyncClient = {}
|
||||
SyncClient.__index = SyncClient
|
||||
|
||||
SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL")
|
||||
or "https://sync.147.182.215.255.sslip.io"
|
||||
SyncClient.MAX_BLOB = 2 * 1024 * 1024
|
||||
SyncClient.MAX_RESPONSE = 4 * 1024 * 1024
|
||||
SyncClient.TIMEOUT = 25
|
||||
|
||||
function SyncClient.normalizeCode(code)
|
||||
if type(code) ~= "string" and type(code) ~= "number" then return nil end
|
||||
local digits = tostring(code):gsub("[^%d]", "")
|
||||
if #digits ~= 8 then return nil end
|
||||
return digits
|
||||
end
|
||||
|
||||
function SyncClient.formatCode(code)
|
||||
local digits = SyncClient.normalizeCode(code)
|
||||
if not digits then return nil end
|
||||
return digits:sub(1, 4) .. "-" .. digits:sub(5, 8)
|
||||
end
|
||||
|
||||
local function escape(s)
|
||||
return (tostring(s):gsub("[^%w%-%._~]", function(c)
|
||||
return ("%%%02X"):format(c:byte())
|
||||
end))
|
||||
end
|
||||
|
||||
local function query(params)
|
||||
local names = {}
|
||||
for name in pairs(params or {}) do names[#names + 1] = tostring(name) end
|
||||
table.sort(names)
|
||||
local out = {}
|
||||
for _, name in ipairs(names) do
|
||||
out[#out + 1] = escape(name) .. "=" .. escape(params[name])
|
||||
end
|
||||
if #out == 0 then return "" end
|
||||
return "?" .. table.concat(out, "&")
|
||||
end
|
||||
|
||||
function SyncClient.new(opts)
|
||||
opts = opts or {}
|
||||
local base = opts.baseUrl or SyncClient.DEFAULT_URL
|
||||
base = tostring(base):gsub("/+$", "")
|
||||
local transport = opts.transport
|
||||
if not transport then
|
||||
transport = require("src.sync.SyncTransport").new()
|
||||
end
|
||||
return setmetatable({
|
||||
baseUrl = base,
|
||||
transport = transport,
|
||||
account = opts.account,
|
||||
token = opts.token,
|
||||
}, SyncClient)
|
||||
end
|
||||
|
||||
function SyncClient:setAuth(account, token)
|
||||
self.account = type(account) == "string" and account ~= "" and account or nil
|
||||
self.token = type(token) == "string" and token ~= "" and token or nil
|
||||
end
|
||||
|
||||
function SyncClient:clearAuth()
|
||||
self.account, self.token = nil, nil
|
||||
end
|
||||
|
||||
function SyncClient:isLinked()
|
||||
return self.account ~= nil and self.token ~= nil
|
||||
end
|
||||
|
||||
function SyncClient:send(method, path, body, opts)
|
||||
opts = opts or {}
|
||||
local headers = { ["Accept"] = "application/json" }
|
||||
local payload
|
||||
if body ~= nil then
|
||||
local ok, encoded = pcall(Json.encode, body)
|
||||
if not ok then return nil, "could not encode the request" end
|
||||
payload = encoded
|
||||
headers["Content-Type"] = "application/json"
|
||||
end
|
||||
if not opts.noAuth then
|
||||
if not self:isLinked() then return nil, "this device is not linked" end
|
||||
headers["x-sync-account"] = self.account
|
||||
headers["x-sync-token"] = self.token
|
||||
end
|
||||
local url = self.baseUrl .. path .. query(opts.params)
|
||||
local handle = self.transport:begin({
|
||||
url = url, method = method, body = payload, headers = headers,
|
||||
maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT,
|
||||
})
|
||||
if handle == nil then return nil, "no network transport" end
|
||||
return handle
|
||||
end
|
||||
|
||||
function SyncClient:poll(handle)
|
||||
if handle == nil then return { status = "error", err = "no request" } end
|
||||
local res = self.transport:poll(handle)
|
||||
if res.status == "pending" then return { status = "pending" } end
|
||||
if res.status ~= "ok" then
|
||||
return { status = "error", err = res.err or "sync request failed" }
|
||||
end
|
||||
local raw = res.body or ""
|
||||
local code = tonumber(res.code) or 0
|
||||
if #raw > SyncClient.MAX_RESPONSE then
|
||||
return { status = "error", code = code, err = "the reply was too large" }
|
||||
end
|
||||
local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE)
|
||||
if type(data) ~= "table" then
|
||||
local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply"
|
||||
if code >= 400 then
|
||||
return { status = "error", code = code,
|
||||
err = ("the server answered %d"):format(code) }
|
||||
end
|
||||
return { status = "error", code = code, err = why }
|
||||
end
|
||||
if code >= 400 or data.error then
|
||||
local err = data.error
|
||||
if type(err) ~= "string" or err == "" then
|
||||
err = ("the server answered %d"):format(code)
|
||||
end
|
||||
return { status = "error", code = code, data = data, err = err }
|
||||
end
|
||||
return { status = "ok", code = code, data = data }
|
||||
end
|
||||
|
||||
function SyncClient:release(handle)
|
||||
if handle ~= nil then self.transport:release(handle) end
|
||||
end
|
||||
|
||||
function SyncClient:create(deviceLabel)
|
||||
return self:send("POST", "/sync/create",
|
||||
{ device = deviceLabel or "device" }, { noAuth = true })
|
||||
end
|
||||
|
||||
function SyncClient:link(code1, code2, deviceLabel)
|
||||
local a = SyncClient.normalizeCode(code1)
|
||||
local b = SyncClient.normalizeCode(code2)
|
||||
if not a or not b then return nil, "both codes are 8 digits" end
|
||||
return self:send("POST", "/sync/link",
|
||||
{ code1 = a, code2 = b, device = deviceLabel or "device" },
|
||||
{ noAuth = true })
|
||||
end
|
||||
|
||||
function SyncClient:fetchState()
|
||||
return self:send("GET", "/sync/state")
|
||||
end
|
||||
|
||||
function SyncClient:putSave(entry)
|
||||
if type(entry) ~= "table" then return nil, "missing save entry" end
|
||||
if type(entry.blob) ~= "string" or entry.blob == "" then
|
||||
return nil, "missing save data"
|
||||
end
|
||||
if #entry.blob > SyncClient.MAX_BLOB then
|
||||
return nil, "this save is too large to sync"
|
||||
end
|
||||
return self:send("PUT", "/sync/save", {
|
||||
version = entry.version,
|
||||
slot = entry.slot,
|
||||
meta = entry.meta,
|
||||
blob = entry.blob,
|
||||
baseRev = entry.baseRev,
|
||||
force = entry.force and true or nil,
|
||||
})
|
||||
end
|
||||
|
||||
function SyncClient:getSave(version, id)
|
||||
return self:send("GET", "/sync/save", nil,
|
||||
{ params = { version = version, id = id } })
|
||||
end
|
||||
|
||||
function SyncClient:putMods(manifest)
|
||||
return self:send("PUT", "/sync/mods", { manifest = manifest })
|
||||
end
|
||||
|
||||
function SyncClient:getMods()
|
||||
return self:send("GET", "/sync/mods")
|
||||
end
|
||||
|
||||
function SyncClient:shareMods(manifest)
|
||||
return self:send("POST", "/sync/modshare", { manifest = manifest })
|
||||
end
|
||||
|
||||
function SyncClient:fetchShare(code)
|
||||
local trimmed = tostring(code or ""):gsub("%s", ""):upper()
|
||||
if not trimmed:match("^[A-Z2-9]+$") or #trimmed ~= 6 then
|
||||
return nil, "share codes are 6 characters"
|
||||
end
|
||||
return self:send("GET", "/sync/modshare", nil,
|
||||
{ noAuth = true, params = { code = trimmed } })
|
||||
end
|
||||
|
||||
function SyncClient:unlink(device)
|
||||
return self:send("POST", "/sync/unlink", { device = device })
|
||||
end
|
||||
|
||||
return SyncClient
|
||||
@@ -0,0 +1,690 @@
|
||||
local SyncClient = require("src.sync.SyncClient")
|
||||
local SyncState = require("src.sync.SyncState")
|
||||
local SyncMods = require("src.sync.SyncMods")
|
||||
|
||||
local SyncEngine = {}
|
||||
SyncEngine.__index = SyncEngine
|
||||
|
||||
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||
SyncEngine.AUTO_INTERVAL = 300
|
||||
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||
|
||||
local IDLE_STATUS = "Ready"
|
||||
local UNLINKED_STATUS = "Not set up"
|
||||
|
||||
local function saveApi()
|
||||
return require("src.core.SaveData")
|
||||
end
|
||||
|
||||
local function gameVersions()
|
||||
return require("src.core.GameVersion").ORDER
|
||||
end
|
||||
|
||||
local function slotForPlaythrough(options, version, playthroughId)
|
||||
local byVersion = options.playthroughIds and options.playthroughIds[version]
|
||||
for slotId, id in pairs(byVersion or {}) do
|
||||
if id == playthroughId then return slotId end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function SyncEngine.defaultSaves()
|
||||
return {
|
||||
list = function()
|
||||
local SaveData = saveApi()
|
||||
local options = SaveData.loadOptions()
|
||||
local out = {}
|
||||
for _, version in ipairs(gameVersions()) do
|
||||
for _, slot in ipairs(SaveData.listSlots(version)) do
|
||||
if slot.exists then
|
||||
local source = SaveData.readSlotSource(version, slot.id)
|
||||
local save = source and SaveData.decode(source)
|
||||
if type(save) == "table" then
|
||||
local meta = type(save.meta) == "table" and save.meta or {}
|
||||
local id = meta.playthroughId
|
||||
if type(id) ~= "string" or id == "" then
|
||||
local byVersion = options.playthroughIds
|
||||
and options.playthroughIds[version]
|
||||
id = byVersion and byVersion[slot.id] or nil
|
||||
end
|
||||
if id then
|
||||
local name, summary = SaveData.slotSummary(save)
|
||||
out[#out + 1] = {
|
||||
version = version,
|
||||
slot = slot.id,
|
||||
playthroughId = id,
|
||||
blob = source,
|
||||
meta = {
|
||||
savedAt = tonumber(meta.savedAt),
|
||||
sessionStart = tonumber(meta.sessionStart),
|
||||
playthroughId = id,
|
||||
format = meta.format,
|
||||
engine = meta.engine,
|
||||
playTime = tonumber(save.playTime),
|
||||
summary = {
|
||||
name = name,
|
||||
badges = summary and summary.badges,
|
||||
timeText = summary and summary.timeText,
|
||||
dexCount = summary and summary.dexCount,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end,
|
||||
|
||||
write = function(version, playthroughId, blob, mode)
|
||||
local SaveData = saveApi()
|
||||
local save = SaveData.decode(blob)
|
||||
if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end
|
||||
save.version = save.version or version
|
||||
local options = SaveData.loadOptions()
|
||||
local slotId
|
||||
if mode == "new" then
|
||||
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||
save.meta.playthroughId = SaveData.newPlaythroughId()
|
||||
else
|
||||
slotId = slotForPlaythrough(options, version, playthroughId)
|
||||
end
|
||||
if not slotId then
|
||||
slotId = SaveData.createSlot(version)
|
||||
if not slotId then return nil, "could not make a save slot" end
|
||||
end
|
||||
local ok, err = SaveData.writeSlot(version, slotId, save)
|
||||
if not ok then return nil, err or "could not write the save" end
|
||||
options = SaveData.loadOptions()
|
||||
options.playthroughIds = options.playthroughIds or {}
|
||||
options.playthroughIds[version] = options.playthroughIds[version] or {}
|
||||
options.playthroughIds[version][slotId] =
|
||||
save.meta and save.meta.playthroughId or playthroughId
|
||||
SaveData.saveOptions(options)
|
||||
return slotId
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
function SyncEngine.overlaps(a, b)
|
||||
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
||||
local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt)
|
||||
local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt)
|
||||
if not (aStart and aEnd and bStart and bEnd) then return false end
|
||||
return aStart <= bEnd and bStart <= aEnd
|
||||
end
|
||||
|
||||
function SyncEngine.new(opts)
|
||||
opts = opts or {}
|
||||
local eng = setmetatable({}, SyncEngine)
|
||||
eng.fs = opts.fs
|
||||
eng.state = opts.state or SyncState.load(eng.fs)
|
||||
eng.client = opts.client or SyncClient.new({
|
||||
baseUrl = opts.baseUrl, transport = opts.transport })
|
||||
eng.saves = opts.saves or SyncEngine.defaultSaves()
|
||||
eng.modDeps = opts.modDeps
|
||||
eng.now = opts.now or os.time
|
||||
eng.persist = opts.persist ~= false
|
||||
eng.phase = "idle"
|
||||
eng.error = nil
|
||||
eng.conflicts = {}
|
||||
eng.codes = nil
|
||||
eng.modPlan = nil
|
||||
eng.shareCode = nil
|
||||
eng.clock = 0
|
||||
eng.queue = {}
|
||||
eng.pending = nil
|
||||
eng.uploadAt = nil
|
||||
eng.client:setAuth(eng.state.account, eng.state.deviceToken)
|
||||
eng.status = eng:defaultStatus()
|
||||
return eng
|
||||
end
|
||||
|
||||
function SyncEngine.shared(opts)
|
||||
if SyncEngine._shared == nil then
|
||||
local ok, eng = pcall(SyncEngine.new, opts or {})
|
||||
SyncEngine._shared = (ok and type(eng) == "table") and eng or false
|
||||
end
|
||||
return SyncEngine._shared or nil
|
||||
end
|
||||
|
||||
function SyncEngine.forgetShared()
|
||||
SyncEngine._shared = nil
|
||||
end
|
||||
|
||||
function SyncEngine:defaultStatus()
|
||||
if not SyncState.linked(self.state) then return UNLINKED_STATUS end
|
||||
return IDLE_STATUS
|
||||
end
|
||||
|
||||
function SyncEngine:linked()
|
||||
return SyncState.linked(self.state)
|
||||
end
|
||||
|
||||
function SyncEngine:busy()
|
||||
return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil
|
||||
end
|
||||
|
||||
function SyncEngine:_persist()
|
||||
if not self.persist then return end
|
||||
SyncState.save(self.state, self.fs)
|
||||
end
|
||||
|
||||
function SyncEngine:_fail(message)
|
||||
self.phase = "error"
|
||||
self.error = tostring(message or "sync failed")
|
||||
self.status = "Sync failed: " .. self.error
|
||||
self.queue = {}
|
||||
self.pending = nil
|
||||
end
|
||||
|
||||
function SyncEngine:_finish()
|
||||
if #self.conflicts > 0 then
|
||||
self.phase = "conflict"
|
||||
local overlap = false
|
||||
for _, row in ipairs(self.conflicts) do
|
||||
if row.overlap then overlap = true end
|
||||
end
|
||||
self.status = overlap
|
||||
and "These saves were played at the same time."
|
||||
or "This save also changed on another device."
|
||||
return
|
||||
end
|
||||
self.phase = "idle"
|
||||
self.error = nil
|
||||
self.state.lastSyncAt = self.now()
|
||||
self.status = self:defaultStatus()
|
||||
self:_persist()
|
||||
end
|
||||
|
||||
function SyncEngine:_request(handle, err, onOk, onErr)
|
||||
if not handle then
|
||||
self:_fail(err or "could not start the request")
|
||||
return false
|
||||
end
|
||||
self.pending = { handle = handle, onOk = onOk, onErr = onErr }
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:_enqueue(fn)
|
||||
self.queue[#self.queue + 1] = fn
|
||||
end
|
||||
|
||||
function SyncEngine:cancel()
|
||||
if self.pending then self.client:release(self.pending.handle) end
|
||||
self.pending = nil
|
||||
self.queue = {}
|
||||
self.modApply = nil
|
||||
self.uploadAt = nil
|
||||
if self.phase ~= "conflict" then
|
||||
self.phase = "idle"
|
||||
self.status = self:defaultStatus()
|
||||
end
|
||||
end
|
||||
|
||||
function SyncEngine:noteSaveWritten()
|
||||
if not (self.state.enabled and self:linked()) then return end
|
||||
self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE
|
||||
end
|
||||
|
||||
function SyncEngine:update(dt)
|
||||
self.clock = self.clock + (tonumber(dt) or 0)
|
||||
if self.pending then
|
||||
local res = self.client:poll(self.pending.handle)
|
||||
if res.status == "pending" then return end
|
||||
local job = self.pending
|
||||
self.pending = nil
|
||||
self.client:release(job.handle)
|
||||
if res.status == "ok" then
|
||||
local ok, err = pcall(job.onOk, self, res)
|
||||
if not ok then self:_fail(err) end
|
||||
else
|
||||
local handled = false
|
||||
if job.onErr then
|
||||
local ok, result = pcall(job.onErr, self, res)
|
||||
if not ok then self:_fail(result) return end
|
||||
handled = result == true
|
||||
end
|
||||
if not handled then self:_fail(res.err) end
|
||||
end
|
||||
end
|
||||
if self.pending then return end
|
||||
if self.modApply then
|
||||
self:_stepModApply()
|
||||
return
|
||||
end
|
||||
if self.uploadAt and self.clock >= self.uploadAt and not self:busy() then
|
||||
self.uploadAt = nil
|
||||
if self.state.enabled and self:linked() then self:syncNow() end
|
||||
end
|
||||
local steps = 0
|
||||
while not self.pending and #self.queue > 0
|
||||
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||
steps = steps + 1
|
||||
local task = table.remove(self.queue, 1)
|
||||
local ok, err = pcall(task, self)
|
||||
if not ok then self:_fail(err) return end
|
||||
if not self.pending and #self.queue == 0 and self.phase ~= "error" then
|
||||
self:_finish()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SyncEngine:createAccount(label)
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "checking"
|
||||
self.status = "Creating a sync account..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:create(label)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
||||
eng:_fail("the server sent an unexpected reply")
|
||||
return
|
||||
end
|
||||
eng.codes = {
|
||||
code1 = SyncClient.formatCode(data.code1) or tostring(data.code1 or ""),
|
||||
code2 = SyncClient.formatCode(data.code2) or tostring(data.code2 or ""),
|
||||
}
|
||||
eng.state.account = data.account
|
||||
eng.state.deviceToken = data.deviceToken
|
||||
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
||||
eng.state.deviceLabel = label
|
||||
eng.state.enabled = true
|
||||
eng.client:setAuth(data.account, data.deviceToken)
|
||||
eng.phase = "idle"
|
||||
eng.status = "Sync account created"
|
||||
eng:_persist()
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:linkDevice(code1, code2, label)
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local a = SyncClient.normalizeCode(code1)
|
||||
local b = SyncClient.normalizeCode(code2)
|
||||
if not a or not b then
|
||||
self:_fail("both codes are 8 digits")
|
||||
return false, "both codes are 8 digits"
|
||||
end
|
||||
self.phase = "checking"
|
||||
self.status = "Linking this device..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:link(a, b, label)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
||||
eng:_fail("the server sent an unexpected reply")
|
||||
return
|
||||
end
|
||||
eng.state.account = data.account
|
||||
eng.state.deviceToken = data.deviceToken
|
||||
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
||||
eng.state.deviceLabel = label
|
||||
eng.state.enabled = true
|
||||
eng.client:setAuth(data.account, data.deviceToken)
|
||||
eng.status = "This device is linked"
|
||||
eng:_persist()
|
||||
eng:syncNow()
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_forgetLocal()
|
||||
self.state = SyncState.defaults()
|
||||
self.client:clearAuth()
|
||||
self.codes = nil
|
||||
self.conflicts = {}
|
||||
self.devices = nil
|
||||
self.phase = "idle"
|
||||
self.status = UNLINKED_STATUS
|
||||
self:_persist()
|
||||
end
|
||||
|
||||
function SyncEngine:unlink()
|
||||
if not self:linked() then
|
||||
self:_forgetLocal()
|
||||
return true
|
||||
end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "checking"
|
||||
self.status = "Unlinking this device..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:unlink(self.state.deviceId)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng:_forgetLocal()
|
||||
end, function(eng, res)
|
||||
if res.code == 401 or res.code == 404 then
|
||||
eng:_forgetLocal()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:unlinkDevice(deviceId)
|
||||
if type(deviceId) ~= "string" or deviceId == "" then
|
||||
return false, "no such device"
|
||||
end
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if deviceId == self.state.deviceId then return self:unlink() end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "checking"
|
||||
self.status = "Unlinking that device..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:unlink(deviceId)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng.status = "That device was unlinked"
|
||||
eng.phase = "idle"
|
||||
eng:syncNow()
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:setEnabled(enabled)
|
||||
self.state.enabled = enabled and true or false
|
||||
self:_persist()
|
||||
return self.state.enabled
|
||||
end
|
||||
|
||||
function SyncEngine:syncNow()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self.pending then return false, "sync is busy" end
|
||||
self.queue = {}
|
||||
self.conflicts = {}
|
||||
self.state.pendingConflicts = {}
|
||||
self.phase = "checking"
|
||||
self.status = "Checking for changes..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:fetchState()
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
eng:_planFrom(res.data or {})
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_planFrom(remoteState)
|
||||
self.devices = nil
|
||||
if type(remoteState.devices) == "table" then
|
||||
local list = {}
|
||||
for _, row in ipairs(remoteState.devices) do
|
||||
if type(row) == "table" and type(row.id) == "string" and row.id ~= "" then
|
||||
list[#list + 1] = {
|
||||
id = row.id,
|
||||
label = type(row.label) == "string" and row.label ~= "" and row.label
|
||||
or "device",
|
||||
createdAt = tonumber(row.createdAt),
|
||||
current = row.current == true or row.id == self.state.deviceId,
|
||||
}
|
||||
end
|
||||
end
|
||||
self.devices = list
|
||||
end
|
||||
local remote = type(remoteState.saves) == "table" and remoteState.saves or {}
|
||||
local locals = self.saves.list() or {}
|
||||
local seen = {}
|
||||
for _, entry in ipairs(locals) do
|
||||
local key = SyncState.key(entry.version, entry.playthroughId)
|
||||
if key then
|
||||
seen[key] = true
|
||||
local row = remote[key]
|
||||
local knownRev = SyncState.rev(self.state, key)
|
||||
local stamp = SyncState.stamp(self.state, key)
|
||||
local localChanged = stamp == nil
|
||||
or tonumber(entry.meta and entry.meta.savedAt) ~= stamp
|
||||
local remoteRev = row and tonumber(row.rev)
|
||||
local remoteChanged = row ~= nil and remoteRev ~= knownRev
|
||||
if not row then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif localChanged and remoteChanged then
|
||||
self:_addConflict(entry, key, row)
|
||||
elseif localChanged then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif remoteChanged then
|
||||
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||
end
|
||||
end
|
||||
end
|
||||
for key, row in pairs(remote) do
|
||||
if not seen[key] then
|
||||
local version, id = SyncState.splitKey(key)
|
||||
if version and id then
|
||||
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||
end
|
||||
end
|
||||
end
|
||||
if #self.queue == 0 then self:_finish() end
|
||||
end
|
||||
|
||||
function SyncEngine:_addConflict(entry, key, row)
|
||||
local remoteMeta = row
|
||||
if type(row.meta) == "table" then
|
||||
remoteMeta = row.meta
|
||||
elseif type(row.remoteMeta) == "table" then
|
||||
remoteMeta = row.remoteMeta
|
||||
end
|
||||
self.conflicts[#self.conflicts + 1] = {
|
||||
key = key,
|
||||
version = entry.version,
|
||||
playthroughId = entry.playthroughId,
|
||||
slot = entry.slot,
|
||||
entry = entry,
|
||||
localMeta = entry.meta,
|
||||
remoteMeta = remoteMeta,
|
||||
remoteRev = tonumber(row.rev),
|
||||
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
||||
}
|
||||
local pending = self.state.pendingConflicts or {}
|
||||
self.state.pendingConflicts = pending
|
||||
for _, row in ipairs(pending) do
|
||||
if row.key == key then return end
|
||||
end
|
||||
pending[#pending + 1] = {
|
||||
key = key,
|
||||
version = entry.version,
|
||||
playthroughId = entry.playthroughId,
|
||||
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
||||
}
|
||||
end
|
||||
|
||||
function SyncEngine:_queueUpload(entry, key, force)
|
||||
self:_enqueue(function(eng)
|
||||
eng.phase = "uploading"
|
||||
eng.status = "Uploading saves..."
|
||||
local handle, err = eng.client:putSave({
|
||||
version = entry.version,
|
||||
slot = entry.slot,
|
||||
meta = entry.meta,
|
||||
blob = entry.blob,
|
||||
baseRev = SyncState.rev(eng.state, key),
|
||||
force = force,
|
||||
})
|
||||
eng:_request(handle, err, function(e, res)
|
||||
local data = res.data or {}
|
||||
SyncState.setRev(e.state, key, tonumber(data.rev),
|
||||
entry.meta and entry.meta.savedAt)
|
||||
e:_persist()
|
||||
if not e:busy() then e:_finish() end
|
||||
end, function(e, res)
|
||||
if res.code == 409 then
|
||||
local row = res.data or {}
|
||||
e:_addConflict(entry, key, row)
|
||||
if not e:busy() then e:_finish() end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev)
|
||||
self:_enqueue(function(eng)
|
||||
eng.phase = "downloading"
|
||||
eng.status = "Downloading saves..."
|
||||
local handle, err = eng.client:getSave(version, playthroughId)
|
||||
eng:_request(handle, err, function(e, res)
|
||||
local data = res.data or {}
|
||||
if type(data.blob) ~= "string" or data.blob == "" then
|
||||
e:_fail("the server sent no save data")
|
||||
return
|
||||
end
|
||||
local slotId, writeErr = e.saves.write(version, playthroughId, data.blob, mode)
|
||||
if not slotId then
|
||||
e:_fail(writeErr or "could not write the downloaded save")
|
||||
return
|
||||
end
|
||||
if mode ~= "new" then
|
||||
local meta = type(data.meta) == "table" and data.meta or {}
|
||||
SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev,
|
||||
tonumber(meta.savedAt))
|
||||
end
|
||||
e:_persist()
|
||||
if not e:busy() then e:_finish() end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:resolveConflict(key, choice)
|
||||
local index
|
||||
for i, row in ipairs(self.conflicts) do
|
||||
if row.key == key then index = i break end
|
||||
end
|
||||
if not index then return false, "no such conflict" end
|
||||
local conflict = table.remove(self.conflicts, index)
|
||||
local kept = {}
|
||||
for _, row in ipairs(self.state.pendingConflicts or {}) do
|
||||
if row.key ~= key then kept[#kept + 1] = row end
|
||||
end
|
||||
self.state.pendingConflicts = kept
|
||||
|
||||
if choice == "local" then
|
||||
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
||||
self:_queueUpload(conflict.entry, key, true)
|
||||
elseif choice == "remote" then
|
||||
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
||||
"replace", conflict.remoteRev)
|
||||
elseif choice == "both" then
|
||||
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
||||
"new", conflict.remoteRev)
|
||||
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
||||
self:_queueUpload(conflict.entry, key, true)
|
||||
else
|
||||
return false, "unknown resolution"
|
||||
end
|
||||
self.phase = "uploading"
|
||||
self.status = "Applying your choice..."
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:uploadMods()
|
||||
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)
|
||||
self.phase = "uploading"
|
||||
self.status = "Uploading the mod list..."
|
||||
local handle, err = self.client:putMods(manifest)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng.phase = "idle"
|
||||
eng.status = "Mod list synced"
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:fetchModPlan()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "downloading"
|
||||
self.status = "Reading the mod list..."
|
||||
local handle, err = self.client:getMods()
|
||||
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"
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:shareMods()
|
||||
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)
|
||||
self.phase = "uploading"
|
||||
self.status = "Sharing the mod list..."
|
||||
local handle, err = self.client:shareMods(manifest)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
eng.shareCode = type(data.code) == "string" and data.code or nil
|
||||
eng.phase = "idle"
|
||||
eng.status = eng.shareCode and ("Share code " .. eng.shareCode)
|
||||
or "The server sent no share code"
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:fetchShare(code)
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "downloading"
|
||||
self.status = "Fetching that mod list..."
|
||||
local handle, err = self.client: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"
|
||||
end)
|
||||
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
|
||||
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
||||
if #steps == 0 then
|
||||
self.modPlan = nil
|
||||
self.status = "Mods already match"
|
||||
if progress then progress(0, 0, nil, true) end
|
||||
return true
|
||||
end
|
||||
self.modApply = { steps = steps, index = 0, failures = {},
|
||||
progress = progress }
|
||||
self.phase = "applying"
|
||||
self.status = ("Applying mods... 0 of %d"):format(#steps)
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:applyingMods()
|
||||
return self.modApply ~= nil
|
||||
end
|
||||
|
||||
function SyncEngine:_stepModApply()
|
||||
local job = self.modApply
|
||||
local step = job.steps[job.index + 1]
|
||||
job.index = job.index + 1
|
||||
local ok, res, why = pcall(step.run)
|
||||
if not ok then
|
||||
job.failures[#job.failures + 1] = tostring(res)
|
||||
elseif not res then
|
||||
job.failures[#job.failures + 1] = tostring(why or step.label)
|
||||
end
|
||||
local total = #job.steps
|
||||
local done = job.index >= total
|
||||
if not done then
|
||||
self.status = ("Applying mods... %d of %d"):format(job.index, total)
|
||||
if job.progress then
|
||||
pcall(job.progress, job.index, total, step.label, false)
|
||||
end
|
||||
return
|
||||
end
|
||||
self.modApply = nil
|
||||
self.modPlan = nil
|
||||
self.phase = "idle"
|
||||
if #job.failures > 0 then
|
||||
self.status = "Some mods could not be applied: "
|
||||
.. table.concat(job.failures, "; ")
|
||||
else
|
||||
self.status = "Mods applied"
|
||||
end
|
||||
if job.progress then
|
||||
pcall(job.progress, job.index, total, step.label, true)
|
||||
end
|
||||
end
|
||||
|
||||
return SyncEngine
|
||||
@@ -0,0 +1,196 @@
|
||||
local SyncMods = {}
|
||||
|
||||
SyncMods.REV = 1
|
||||
|
||||
local function versions()
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
||||
return { "red", "blue", "yellow", "gold" }
|
||||
end
|
||||
|
||||
local function defaultDeps()
|
||||
return {
|
||||
installed = function()
|
||||
return require("src.mods.LauncherMods").list()
|
||||
end,
|
||||
indexes = function()
|
||||
return require("src.mods.ModIndex").sources()
|
||||
end,
|
||||
addIndex = function(url)
|
||||
return require("src.mods.ModIndex").addSource(url)
|
||||
end,
|
||||
findEntry = function(id)
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
for _, source in ipairs(ModIndex.sources()) do
|
||||
local cached = ModIndex.readCache(source.feed)
|
||||
for _, entry in ipairs((cached and cached.mods) or {}) do
|
||||
if entry.id == id then return entry end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
install = function(entry)
|
||||
return require("src.mods.LauncherMods").installFromIndex(entry)
|
||||
end,
|
||||
setEnabled = function(id, enabled, version)
|
||||
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function deps(given)
|
||||
local out = defaultDeps()
|
||||
if type(given) == "table" then
|
||||
for k, v in pairs(given) do out[k] = v end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function sourceOf(row)
|
||||
local github = row.github
|
||||
or (type(row.manifest) == "table" and row.manifest.github)
|
||||
if type(github) == "string" and github ~= "" then
|
||||
return "github:" .. github
|
||||
end
|
||||
return "local"
|
||||
end
|
||||
|
||||
function SyncMods.build(given)
|
||||
local d = deps(given)
|
||||
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
local url = row.url or row.feed
|
||||
if type(url) == "string" and url ~= "" then
|
||||
manifest.indexes[#manifest.indexes + 1] = url
|
||||
end
|
||||
end
|
||||
table.sort(manifest.indexes)
|
||||
for _, row in ipairs(d.installed() or {}) do
|
||||
if type(row) == "table" and type(row.id) == "string" then
|
||||
local enabledFor = {}
|
||||
local answers = row.enabledByVersion or {}
|
||||
for _, version in ipairs(versions()) do
|
||||
if answers[version] then enabledFor[#enabledFor + 1] = version end
|
||||
end
|
||||
manifest.mods[#manifest.mods + 1] = {
|
||||
id = row.id,
|
||||
version = row.version,
|
||||
source = sourceOf(row),
|
||||
enabledFor = enabledFor,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(manifest.mods, function(a, b) return a.id < b.id end)
|
||||
return manifest
|
||||
end
|
||||
|
||||
function SyncMods.plan(manifest, given)
|
||||
local d = deps(given)
|
||||
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
|
||||
if type(manifest) ~= "table" then return plan end
|
||||
|
||||
local haveIndex = {}
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
if type(row.url) == "string" then haveIndex[row.url] = true end
|
||||
if type(row.feed) == "string" then haveIndex[row.feed] = true end
|
||||
end
|
||||
for _, url in ipairs(manifest.indexes or {}) do
|
||||
if type(url) == "string" and url ~= "" and not haveIndex[url] then
|
||||
plan.indexes[#plan.indexes + 1] = url
|
||||
haveIndex[url] = true
|
||||
end
|
||||
end
|
||||
|
||||
local installed = {}
|
||||
for _, row in ipairs(d.installed() or {}) do
|
||||
if type(row) == "table" and type(row.id) == "string" then
|
||||
installed[row.id] = row
|
||||
end
|
||||
end
|
||||
|
||||
for _, mod in ipairs(manifest.mods or {}) do
|
||||
if type(mod) == "table" and type(mod.id) == "string" then
|
||||
local here = installed[mod.id]
|
||||
local available = here ~= nil
|
||||
if not here then
|
||||
local entry = d.findEntry(mod.id)
|
||||
if entry then
|
||||
available = true
|
||||
plan.toInstall[#plan.toInstall + 1] =
|
||||
{ id = mod.id, version = mod.version, entry = entry }
|
||||
else
|
||||
plan.missing[#plan.missing + 1] =
|
||||
{ id = mod.id, version = mod.version, source = mod.source }
|
||||
end
|
||||
end
|
||||
if available then
|
||||
local answers = (here and here.enabledByVersion) or {}
|
||||
for _, version in ipairs(mod.enabledFor or {}) do
|
||||
if answers[version] ~= true then
|
||||
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return plan
|
||||
end
|
||||
|
||||
function SyncMods.planEmpty(plan)
|
||||
if type(plan) ~= "table" then return true end
|
||||
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
|
||||
and #(plan.toEnable or {}) == 0
|
||||
end
|
||||
|
||||
function SyncMods.steps(plan, given)
|
||||
local d = deps(given)
|
||||
local out = {}
|
||||
if type(plan) ~= "table" then return out end
|
||||
local broken = {}
|
||||
|
||||
for _, url in ipairs(plan.indexes or {}) do
|
||||
out[#out + 1] = { label = url, run = function()
|
||||
local ok, err = d.addIndex(url)
|
||||
if not ok then return nil, tostring(err or url) end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
for _, mod in ipairs(plan.toInstall or {}) do
|
||||
out[#out + 1] = { label = mod.id, run = function()
|
||||
local ok, err = d.install(mod.entry)
|
||||
if not ok then
|
||||
broken[mod.id] = true
|
||||
return nil, mod.id .. ": " .. tostring(err or "install failed")
|
||||
end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
for _, want in ipairs(plan.toEnable or {}) do
|
||||
out[#out + 1] = { label = want.id, run = function()
|
||||
if broken[want.id] then return true end
|
||||
local ok, err = d.setEnabled(want.id, true, want.version)
|
||||
if ok == false then
|
||||
return nil, want.id .. ": " .. tostring(err or "could not enable")
|
||||
end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SyncMods.apply(plan, progress, given)
|
||||
if type(plan) ~= "table" then return false, "nothing to apply" end
|
||||
local steps = SyncMods.steps(plan, given)
|
||||
local failures = {}
|
||||
for i, step in ipairs(steps) do
|
||||
local ok, err = step.run()
|
||||
if not ok then failures[#failures + 1] = err end
|
||||
if progress then progress(i, #steps, step.label) end
|
||||
end
|
||||
if #failures > 0 then
|
||||
return false, table.concat(failures, "; ")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return SyncMods
|
||||
@@ -0,0 +1,129 @@
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local SyncState = {}
|
||||
|
||||
SyncState.KEY = "saveSync"
|
||||
|
||||
function SyncState.defaults()
|
||||
return {
|
||||
enabled = false,
|
||||
lastSyncAt = 0,
|
||||
revs = {},
|
||||
stamps = {},
|
||||
pendingConflicts = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function str(v)
|
||||
if type(v) == "string" and v ~= "" then return v end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function num(v)
|
||||
local n = tonumber(v)
|
||||
if type(n) ~= "number" or n ~= n or n == math.huge or n == -math.huge then
|
||||
return nil
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
function SyncState.sanitize(raw)
|
||||
local out = SyncState.defaults()
|
||||
if type(raw) ~= "table" then return out end
|
||||
out.enabled = raw.enabled == true
|
||||
out.account = str(raw.account)
|
||||
out.deviceToken = str(raw.deviceToken)
|
||||
out.deviceId = str(raw.deviceId)
|
||||
out.deviceLabel = str(raw.deviceLabel)
|
||||
out.lastSyncAt = num(raw.lastSyncAt) or 0
|
||||
if type(raw.revs) == "table" then
|
||||
for key, rev in pairs(raw.revs) do
|
||||
local n = num(rev)
|
||||
if type(key) == "string" and n then out.revs[key] = n end
|
||||
end
|
||||
end
|
||||
if type(raw.stamps) == "table" then
|
||||
for key, at in pairs(raw.stamps) do
|
||||
local n = num(at)
|
||||
if type(key) == "string" and n then out.stamps[key] = n end
|
||||
end
|
||||
end
|
||||
if type(raw.pendingConflicts) == "table" then
|
||||
for _, row in ipairs(raw.pendingConflicts) do
|
||||
if type(row) == "table" and str(row.key) then
|
||||
out.pendingConflicts[#out.pendingConflicts + 1] = {
|
||||
key = row.key,
|
||||
version = str(row.version),
|
||||
playthroughId = str(row.playthroughId),
|
||||
overlap = row.overlap == true,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SyncState.load(fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
return SyncState.sanitize(opts and opts[SyncState.KEY])
|
||||
end
|
||||
|
||||
function SyncState.save(state, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts[SyncState.KEY] = SyncState.sanitize(state)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return opts[SyncState.KEY]
|
||||
end
|
||||
|
||||
function SyncState.update(fn, fs)
|
||||
local state = SyncState.load(fs)
|
||||
fn(state)
|
||||
return SyncState.save(state, fs)
|
||||
end
|
||||
|
||||
function SyncState.clear(fs)
|
||||
return SyncState.save(SyncState.defaults(), fs)
|
||||
end
|
||||
|
||||
function SyncState.linked(state)
|
||||
return type(state) == "table" and str(state.account) ~= nil
|
||||
and str(state.deviceToken) ~= nil
|
||||
end
|
||||
|
||||
function SyncState.key(version, playthroughId)
|
||||
if type(version) ~= "string" or version == "" then return nil end
|
||||
if type(playthroughId) ~= "string" or playthroughId == "" then return nil end
|
||||
return version .. "/" .. playthroughId
|
||||
end
|
||||
|
||||
function SyncState.splitKey(key)
|
||||
if type(key) ~= "string" then return nil end
|
||||
local version, id = key:match("^([^/]+)/(.+)$")
|
||||
return version, id
|
||||
end
|
||||
|
||||
function SyncState.rev(state, key)
|
||||
if type(state) ~= "table" or type(state.revs) ~= "table" then return nil end
|
||||
return state.revs[key]
|
||||
end
|
||||
|
||||
function SyncState.stamp(state, key)
|
||||
if type(state) ~= "table" or type(state.stamps) ~= "table" then return nil end
|
||||
return state.stamps[key]
|
||||
end
|
||||
|
||||
function SyncState.setRev(state, key, rev, savedAt)
|
||||
if type(state) ~= "table" or type(key) ~= "string" then return end
|
||||
state.revs = state.revs or {}
|
||||
state.stamps = state.stamps or {}
|
||||
state.revs[key] = num(rev)
|
||||
state.stamps[key] = num(savedAt)
|
||||
end
|
||||
|
||||
function SyncState.forget(state, key)
|
||||
if type(state) ~= "table" or type(key) ~= "string" then return end
|
||||
if type(state.revs) == "table" then state.revs[key] = nil end
|
||||
if type(state.stamps) == "table" then state.stamps[key] = nil end
|
||||
end
|
||||
|
||||
return SyncState
|
||||
@@ -0,0 +1,41 @@
|
||||
local Transport = {}
|
||||
Transport.__index = Transport
|
||||
|
||||
function Transport.new(fetch)
|
||||
return setmetatable({ fetch = fetch or require("src.net.Fetch") }, Transport)
|
||||
end
|
||||
|
||||
function Transport:begin(req)
|
||||
return self.fetch.request(req.url, {
|
||||
method = req.method,
|
||||
body = req.body,
|
||||
headers = req.headers,
|
||||
maxSeconds = req.maxSeconds,
|
||||
})
|
||||
end
|
||||
|
||||
function Transport:poll(handle)
|
||||
local st = self.fetch.poll(handle)
|
||||
if st.status == "pending" then return { status = "pending" } end
|
||||
if st.status == "cancelled" then
|
||||
return { status = "error", err = "sync request cancelled" }
|
||||
end
|
||||
if st.status ~= "ok" then
|
||||
return { status = "error", err = st.err or "sync request failed" }
|
||||
end
|
||||
return { status = "ok", body = st.body or "", code = tonumber(st.code) }
|
||||
end
|
||||
|
||||
function Transport:release(handle)
|
||||
if handle ~= nil and self.fetch.release then self.fetch.release(handle) end
|
||||
end
|
||||
|
||||
function Transport:cancel(handle)
|
||||
if handle ~= nil and self.fetch.cancel then self.fetch.cancel(handle) end
|
||||
end
|
||||
|
||||
function Transport:available()
|
||||
return self.fetch.available and self.fetch.available() or false
|
||||
end
|
||||
|
||||
return Transport
|
||||
+1032
-57
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@
|
||||
-- covered by tests; the state at the bottom is the only part that draws.
|
||||
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Playfield = require("src.render.Playfield")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
||||
@@ -510,7 +510,7 @@ function BattleTransition:blackAt(col, row)
|
||||
end
|
||||
|
||||
function BattleTransition:draw()
|
||||
local w, h = GameViewport.dimensions()
|
||||
local w, h = Playfield.dimensions()
|
||||
self:drawWidescreen(w, h)
|
||||
end
|
||||
|
||||
|
||||
+17
-4
@@ -43,16 +43,29 @@ end
|
||||
-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua
|
||||
-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen
|
||||
-- paints still fills the window, the PANEL is what stays on the grid.
|
||||
local function playfieldRect(winW, winH)
|
||||
local ok, Playfield = pcall(require, "src.render.Playfield")
|
||||
if ok and Playfield.rect then
|
||||
local okv, x, y, w, h = pcall(Playfield.rect, winW, winH)
|
||||
if okv and w and w >= 1 and h and h >= 1 then
|
||||
return x, y, w, h
|
||||
end
|
||||
end
|
||||
return 0, 0, winW or 0, winH or 0
|
||||
end
|
||||
|
||||
function Chrome.fitScale(winW, winH)
|
||||
return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8),
|
||||
(winH or 0) / (Chrome.SCREEN_H * 8))))
|
||||
local _, _, w, h = playfieldRect(winW, winH)
|
||||
return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8),
|
||||
h / (Chrome.SCREEN_H * 8))))
|
||||
end
|
||||
|
||||
-- The centred origin that goes with it, so a caller does not re-derive it.
|
||||
function Chrome.fitOrigin(winW, winH, scale)
|
||||
scale = scale or Chrome.fitScale(winW, winH)
|
||||
return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2),
|
||||
math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2)
|
||||
local x, y, w, h = playfieldRect(winW, winH)
|
||||
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
|
||||
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
|
||||
end
|
||||
|
||||
-- A bordered box, tile coords. Leaves the draw color black for text.
|
||||
|
||||
+60
-2
@@ -856,8 +856,8 @@ end
|
||||
|
||||
-- -------------------------------------------------------------------- pager
|
||||
-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is
|
||||
-- never silently truncated. This is the ONLY way the launcher moves through
|
||||
-- a long list: no scrollbars, no momentum, bounded row count per frame.
|
||||
-- never silently truncated. A long list still PAGES rather than scrolling:
|
||||
-- no momentum, bounded row count per frame.
|
||||
-- Returns the new page (1-based) and the row height consumed.
|
||||
local pagerLabels = {}
|
||||
|
||||
@@ -930,6 +930,64 @@ function Kit.wheelPage(x, y, w, h, page, total, perPage)
|
||||
return math.floor(moved)
|
||||
end
|
||||
|
||||
function Kit.scrollExtent(contentH, viewH)
|
||||
return math.max(0, (contentH or 0) - math.max(0, viewH or 0))
|
||||
end
|
||||
|
||||
function Kit.scrollClamp(offset, maxScroll)
|
||||
return math.max(0, math.min(offset or 0, math.max(0, maxScroll or 0)))
|
||||
end
|
||||
|
||||
function Kit.scrollStep(scale)
|
||||
return math.floor(48 * (scale or Kit.scale))
|
||||
end
|
||||
|
||||
function Kit.scrollBarW(scale)
|
||||
return math.max(2, math.floor(4 * (scale or Kit.scale)))
|
||||
end
|
||||
|
||||
function Kit.scrollGutter(scale)
|
||||
return Kit.scrollBarW(scale) + math.max(2, math.floor(4 * (scale or Kit.scale)))
|
||||
end
|
||||
|
||||
function Kit.scrollHandoff(offset, maxScroll, delta)
|
||||
local want = (offset or 0) + (delta or 0)
|
||||
local at = Kit.scrollClamp(want, maxScroll)
|
||||
return at, want - at
|
||||
end
|
||||
|
||||
function Kit.scrollWheel(offset, maxScroll, x, y, w, h, step)
|
||||
local at = Kit.scrollClamp(offset, maxScroll)
|
||||
local wheel = Kit.wheelY or 0
|
||||
if Kit.blockClicks or wheel == 0 or (maxScroll or 0) <= 0 then
|
||||
return at, false
|
||||
end
|
||||
if not Kit.hit(x, y, w, h) then return at, false end
|
||||
local moved = Kit.scrollClamp(at - wheel * (step or Kit.scrollStep()),
|
||||
maxScroll)
|
||||
if moved == at then return at, false end
|
||||
Kit.wheelY = 0
|
||||
return moved, true
|
||||
end
|
||||
|
||||
function Kit.scrollBegin(x, y, w, h, offset, maxScroll)
|
||||
Kit.pushClip(x, y, math.max(0, w or 0), math.max(0, h or 0))
|
||||
return y - Kit.scrollClamp(offset, maxScroll)
|
||||
end
|
||||
|
||||
function Kit.scrollEnd(x, y, w, h, offset, maxScroll)
|
||||
Kit.popClip()
|
||||
if (maxScroll or 0) <= 0 or (h or 0) <= 0 or (w or 0) <= 0 then return end
|
||||
local barW = Kit.scrollBarW()
|
||||
local barX = x + w - barW
|
||||
local at = Kit.scrollClamp(offset, maxScroll)
|
||||
local thumbH = math.max(math.floor(20 * Kit.scale),
|
||||
math.floor(h * (h / (h + maxScroll))))
|
||||
local thumbY = y + (h - thumbH) * (at / maxScroll)
|
||||
Theme.fill(barX, y, barW, h, PAL.bg, 0.35)
|
||||
Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ spinner
|
||||
-- The one animated element in the UI: a rotating arc of ticks. Drawn as N
|
||||
-- short lines at descending alpha, which needs no shader, no canvas and no
|
||||
|
||||
@@ -9,7 +9,6 @@ local Collision = require("src.world.Collision")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
@@ -5196,7 +5195,7 @@ function OverworldState:drawWorld()
|
||||
-- point projects under the pipeline's own camera. That is the direct
|
||||
-- analogue of what :billboard does for tilt, and it keeps exactly one
|
||||
-- copy of every effect: the closures above are the ones that run.
|
||||
local pw, ph = GameViewport.dimensions()
|
||||
local _, _, pw, ph = Game.renderer:playfieldRect()
|
||||
local pscale = Zoom.scale(Game.renderer:fitScale())
|
||||
local ctx = {
|
||||
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
||||
|
||||
@@ -34,7 +34,7 @@ local Font = require("src.render.Font")
|
||||
-- a mod has taken a facade (src/mods/Gen2Compat.lua).
|
||||
local Gen1Facade = require("src.mods.Gen2Compat")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Playfield = require("src.render.Playfield")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local HallOfFame = require("src.core.gen2.HallOfFame")
|
||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
@@ -7499,7 +7499,7 @@ function World:interactBody()
|
||||
end
|
||||
|
||||
function World:fitScale()
|
||||
local w, h = GameViewport.dimensions()
|
||||
local w, h = Playfield.dimensions()
|
||||
return math.max(1, math.floor(math.min(w / 160, h / 144)))
|
||||
end
|
||||
|
||||
@@ -8336,7 +8336,7 @@ function World:rebuildNeighbors()
|
||||
self.neighbors = {}
|
||||
if not self.map then return end
|
||||
local s = self:zoomScale()
|
||||
local ww, wh = GameViewport.dimensions()
|
||||
local ww, wh = Playfield.dimensions()
|
||||
local vw = math.ceil(ww / s)
|
||||
local vh = math.ceil(wh / s)
|
||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||
@@ -9748,7 +9748,7 @@ function World:drawGround(s)
|
||||
if canvas then
|
||||
bw, bh = canvas:getDimensions()
|
||||
else
|
||||
bw, bh = GameViewport.dimensions()
|
||||
bw, bh = Playfield.dimensions()
|
||||
end
|
||||
if BorderFill.fillBlock(self.map.def) == false then
|
||||
-- BLACK: World:draw clears to a brown letterbox, so the void itself
|
||||
@@ -10017,7 +10017,7 @@ end
|
||||
|
||||
function World:draw()
|
||||
local G = love.graphics
|
||||
local w, h = GameViewport.dimensions()
|
||||
local w, h = Playfield.dimensions()
|
||||
self:refreshColorMode()
|
||||
G.clear(0.07, 0.05, 0.02, 1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user