mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +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
|
||||
|
||||
Reference in New Issue
Block a user