mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 11:44:42 +02:00
843 lines
27 KiB
Lua
843 lines
27 KiB
Lua
local TouchSkin = {}
|
|
|
|
TouchSkin.BUNDLED_ROOT = "assets/skins"
|
|
TouchSkin.USER_ROOT = "skins"
|
|
|
|
TouchSkin.GB_BUTTONS = {
|
|
a = "a", b = "b", start = "start", select = "select",
|
|
up = "up", down = "down", left = "left", right = "right",
|
|
}
|
|
|
|
TouchSkin.HOTKEYS = {
|
|
overlay_next = "overlay_next",
|
|
overlay_previous = "overlay_prev",
|
|
menu_toggle = "menu",
|
|
reset = "soft_reset",
|
|
hold_fast_forward = "fast_forward_hold",
|
|
fast_forward = "fast_forward_hold",
|
|
toggle_fast_forward = "fast_forward_toggle",
|
|
screenshot = "screenshot",
|
|
pause_toggle = "pause",
|
|
exit_emulator = "quit",
|
|
}
|
|
|
|
local DEFAULT_ASPECT = 16 / 9
|
|
local PORTRAIT_ASPECT = 9 / 16
|
|
|
|
local function trim(s)
|
|
return (tostring(s or ""):match("^%s*(.-)%s*$"))
|
|
end
|
|
|
|
local function unquote(s)
|
|
s = trim(s)
|
|
local inner = s:match('^"(.*)"$')
|
|
return inner or s
|
|
end
|
|
|
|
local function toBool(v)
|
|
v = trim(v):lower()
|
|
return v == "true" or v == "1"
|
|
end
|
|
|
|
local function tokens(s)
|
|
local out = {}
|
|
for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end
|
|
return out
|
|
end
|
|
|
|
local function num(v, fallback)
|
|
local n = tonumber(trim(v))
|
|
if not n or n ~= n then return fallback end
|
|
return n
|
|
end
|
|
|
|
function TouchSkin.parseConfig(text)
|
|
local kv = {}
|
|
for line in tostring(text or ""):gmatch("[^\r\n]+") do
|
|
if not line:match("^%s*#") then
|
|
local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
|
|
if key then kv[key] = unquote(value) end
|
|
end
|
|
end
|
|
return kv
|
|
end
|
|
|
|
local function parseBinds(spec)
|
|
local buttons, hotkeys, keys, decorative = {}, {}, {}, true
|
|
for raw in tostring(spec or ""):gmatch("[^|]+") do
|
|
local name = trim(raw):lower()
|
|
local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$")
|
|
if name == "nul" or name == "" then
|
|
-- decoration
|
|
elseif key then
|
|
keys[#keys + 1] = key
|
|
decorative = false
|
|
elseif TouchSkin.GB_BUTTONS[name] then
|
|
buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name]
|
|
decorative = false
|
|
elseif TouchSkin.HOTKEYS[name] then
|
|
hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name]
|
|
decorative = false
|
|
end
|
|
end
|
|
return buttons, hotkeys, keys, decorative
|
|
end
|
|
|
|
local function parseDesc(kv, prefix, page)
|
|
local spec = kv[prefix]
|
|
if not spec then return nil end
|
|
local t = tokens(spec)
|
|
if #t < 6 then return nil end
|
|
|
|
local shape = trim(t[4]):lower()
|
|
if shape ~= "radial" and shape ~= "rect" then shape = "rect" end
|
|
|
|
local buttons, hotkeys, keys, decorative = parseBinds(t[1])
|
|
local reachX = num(kv[prefix .. "_reach_x"], 1)
|
|
local reachY = num(kv[prefix .. "_reach_y"], 1)
|
|
|
|
local ctl = {
|
|
spec = t[1],
|
|
buttons = buttons,
|
|
hotkeys = hotkeys,
|
|
keys = keys,
|
|
decorative = decorative,
|
|
x = num(t[2], 0.5),
|
|
y = num(t[3], 0.5),
|
|
shape = shape,
|
|
rangeX = math.abs(num(t[5], 0.05)),
|
|
rangeY = math.abs(num(t[6], 0.05)),
|
|
rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod),
|
|
alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod),
|
|
reachUp = num(kv[prefix .. "_reach_up"], reachY),
|
|
reachDown = num(kv[prefix .. "_reach_down"], reachY),
|
|
reachLeft = num(kv[prefix .. "_reach_left"], reachX),
|
|
reachRight = num(kv[prefix .. "_reach_right"], reachX),
|
|
imagePath = kv[prefix .. "_overlay"],
|
|
pressedImagePath = kv[prefix .. "_overlay_pressed"],
|
|
nextTarget = kv[prefix .. "_next_target"],
|
|
}
|
|
if ctl.imagePath == "" then ctl.imagePath = nil end
|
|
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
|
|
return ctl
|
|
end
|
|
|
|
function TouchSkin.parse(text)
|
|
local kv = TouchSkin.parseConfig(text)
|
|
local count = math.floor(num(kv.overlays, 0))
|
|
if count <= 0 then return nil, "no overlays" end
|
|
|
|
local pages = {}
|
|
for i = 0, count - 1 do
|
|
local p = "overlay" .. i
|
|
local page = {
|
|
index = i + 1,
|
|
name = kv[p .. "_name"] or ("overlay" .. i),
|
|
imagePath = kv[p .. "_overlay"],
|
|
fullScreen = toBool(kv[p .. "_full_screen"]),
|
|
normalized = toBool(kv[p .. "_normalized"]),
|
|
rangeMod = num(kv[p .. "_range_mod"], 1),
|
|
alphaMod = num(kv[p .. "_alpha_mod"], 1),
|
|
aspect = num(kv[p .. "_aspect_ratio"], nil),
|
|
controls = {},
|
|
}
|
|
if page.imagePath == "" then page.imagePath = nil end
|
|
if not page.aspect or page.aspect <= 0 then
|
|
page.aspect = page.name:lower():find("portrait", 1, true)
|
|
and PORTRAIT_ASPECT or DEFAULT_ASPECT
|
|
end
|
|
|
|
local rect = tokens(kv[p .. "_rect"])
|
|
page.rect = { x = 0, y = 0, w = 1, h = 1 }
|
|
if #rect >= 4 then
|
|
page.rect = { x = num(rect[1], 0), y = num(rect[2], 0),
|
|
w = num(rect[3], 1), h = num(rect[4], 1) }
|
|
end
|
|
|
|
local vp = tokens(kv[p .. "_viewport"])
|
|
if #vp >= 4 then
|
|
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
|
w = num(vp[3], 1), h = num(vp[4], 1) }
|
|
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
|
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
|
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
|
|
end
|
|
pages[#pages + 1] = page
|
|
end
|
|
|
|
return { pages = pages }
|
|
end
|
|
|
|
local function readFile(path)
|
|
if love and love.filesystem and love.filesystem.read then
|
|
local ok, data = pcall(love.filesystem.read, path)
|
|
if ok and data then return data end
|
|
end
|
|
local handle = io.open(path, "rb")
|
|
if not handle then return nil end
|
|
local data = handle:read("*a")
|
|
handle:close()
|
|
return data
|
|
end
|
|
|
|
local function listDir(path)
|
|
if love and love.filesystem and love.filesystem.getDirectoryItems then
|
|
local ok, items = pcall(love.filesystem.getDirectoryItems, path)
|
|
if ok and items then return items end
|
|
end
|
|
return {}
|
|
end
|
|
|
|
local function isDir(path)
|
|
if love and love.filesystem and love.filesystem.getInfo then
|
|
local ok, info = pcall(love.filesystem.getInfo, path)
|
|
if ok and info then return info.type == "directory" end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function joinPath(root, rel)
|
|
rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/")
|
|
if rel:sub(1, 1) == "/" then return rel:sub(2) end
|
|
return root .. "/" .. rel
|
|
end
|
|
|
|
TouchSkin.NATIVE_NAME = "skin.lua"
|
|
|
|
local function findConfig(root)
|
|
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
|
|
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
|
|
end
|
|
local items = listDir(root)
|
|
table.sort(items)
|
|
for _, name in ipairs(items) do
|
|
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function loadDataChunk(text, name)
|
|
if loadstring then
|
|
local chunk, err = loadstring(text, name)
|
|
if not chunk then return nil, err end
|
|
return setfenv(chunk, {})
|
|
end
|
|
return load(text, name, "t", {})
|
|
end
|
|
|
|
function TouchSkin.parseNative(text)
|
|
local chunk, err = loadDataChunk(tostring(text or ""), "skin")
|
|
if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end
|
|
local ok, data = pcall(chunk)
|
|
if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end
|
|
if type(data.pages) ~= "table" or not data.pages[1] then
|
|
return nil, "skin.lua has no pages"
|
|
end
|
|
|
|
local pages = {}
|
|
for i, raw in ipairs(data.pages) do
|
|
if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end
|
|
local page = {
|
|
index = i,
|
|
name = tostring(raw.name or ("page" .. i)),
|
|
imagePath = raw.image,
|
|
fullScreen = raw.fullScreen ~= false,
|
|
normalized = true,
|
|
rangeMod = num(raw.rangeMod, 1),
|
|
alphaMod = num(raw.alphaMod, 1),
|
|
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
|
controls = {},
|
|
}
|
|
if type(raw.rect) == "table" then
|
|
page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0),
|
|
w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) }
|
|
end
|
|
if type(raw.viewport) == "table" then
|
|
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
|
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
|
page.viewportFill = raw.viewport.fill == true
|
|
page.viewportExpand = raw.viewport.expand == true
|
|
end
|
|
for _, c in ipairs(raw.controls or {}) do
|
|
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
|
page.controls[#page.controls + 1] = {
|
|
spec = tostring(c.bind or "nul"),
|
|
buttons = buttons, hotkeys = hotkeys, keys = keys,
|
|
decorative = decorative,
|
|
x = num(c.x, 0.5), y = num(c.y, 0.5),
|
|
shape = c.shape == "radial" and "radial" or "rect",
|
|
rangeX = math.abs(num(c.w, 0.1)) * 0.5,
|
|
rangeY = math.abs(num(c.h, 0.1)) * 0.5,
|
|
rangeMod = num(c.rangeMod, page.rangeMod),
|
|
alphaMod = num(c.alphaMod, page.alphaMod),
|
|
reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1),
|
|
reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1),
|
|
imagePath = c.image,
|
|
pressedImagePath = c.imagePressed,
|
|
nextTarget = c.nextTarget,
|
|
}
|
|
end
|
|
pages[#pages + 1] = page
|
|
end
|
|
return { pages = pages, name = data.name, author = data.author,
|
|
notes = data.notes, format = "native" }
|
|
end
|
|
|
|
function TouchSkin.toNative(skin)
|
|
local out = {
|
|
name = skin.name or skin.id,
|
|
author = skin.author,
|
|
notes = skin.notes,
|
|
format = 1,
|
|
pages = {},
|
|
}
|
|
for _, page in ipairs(skin.pages or {}) do
|
|
local p = {
|
|
name = page.name,
|
|
image = page.imagePath,
|
|
fullScreen = page.fullScreen ~= false,
|
|
rangeMod = page.rangeMod,
|
|
alphaMod = page.alphaMod,
|
|
aspect = page.aspect,
|
|
controls = {},
|
|
}
|
|
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
|
|
or page.rect.w ~= 1 or page.rect.h ~= 1) then
|
|
p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h }
|
|
end
|
|
if page.viewport then
|
|
p.viewport = {
|
|
x = page.viewport.x, y = page.viewport.y,
|
|
w = page.viewport.w, h = page.viewport.h,
|
|
fill = page.viewportFill or nil,
|
|
expand = page.viewportExpand or nil,
|
|
}
|
|
end
|
|
for _, ctl in ipairs(page.controls or {}) do
|
|
p.controls[#p.controls + 1] = {
|
|
bind = ctl.spec,
|
|
x = ctl.x, y = ctl.y,
|
|
w = ctl.rangeX * 2, h = ctl.rangeY * 2,
|
|
shape = ctl.shape,
|
|
rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil,
|
|
alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil,
|
|
reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil,
|
|
reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil,
|
|
reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil,
|
|
reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil,
|
|
image = ctl.imagePath,
|
|
imagePressed = ctl.pressedImagePath,
|
|
nextTarget = ctl.nextTarget,
|
|
}
|
|
end
|
|
out.pages[#out.pages + 1] = p
|
|
end
|
|
return out
|
|
end
|
|
|
|
function TouchSkin.serialize(skin)
|
|
return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin))
|
|
end
|
|
|
|
local imageCache = setmetatable({}, { __mode = "v" })
|
|
|
|
local function loadImage(path)
|
|
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
|
local cached = imageCache[path]
|
|
if cached then return cached end
|
|
local ok, img = pcall(love.graphics.newImage, path)
|
|
if not ok or not img then return nil end
|
|
if img.setFilter then img:setFilter("linear", "linear") end
|
|
imageCache[path] = img
|
|
return img
|
|
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 text = readFile(cfgPath)
|
|
if not text then return nil, "unreadable " .. cfgPath end
|
|
local skin, err
|
|
if format == "native" then
|
|
skin, err = TouchSkin.parseNative(text)
|
|
else
|
|
skin, err = TouchSkin.parse(text)
|
|
end
|
|
if not skin then return nil, err end
|
|
|
|
skin.id = id or root:match("([^/]+)$") or root
|
|
skin.root = root
|
|
skin.configPath = cfgPath
|
|
skin.format = format
|
|
skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id
|
|
|
|
for _, page in ipairs(skin.pages) do
|
|
if page.imagePath then
|
|
page.image = loadImage(joinPath(root, page.imagePath))
|
|
end
|
|
for _, ctl in ipairs(page.controls) do
|
|
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
|
|
if ctl.pressedImagePath then
|
|
ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath))
|
|
end
|
|
end
|
|
end
|
|
return skin
|
|
end
|
|
|
|
local function mountZip(archive, point)
|
|
if not (love and love.filesystem and love.filesystem.mount) then return false end
|
|
local ok, mounted = pcall(love.filesystem.mount, archive, point)
|
|
return ok and mounted == true
|
|
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 path = root .. "/" .. name
|
|
if name:match("%.zip$") then
|
|
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
|
|
if mountZip(path, point) and findConfig(point) then
|
|
seen[id] = true
|
|
out[#out + 1] = { id = id, root = point, source = source, archive = path }
|
|
end
|
|
elseif isDir(path) and findConfig(path) then
|
|
seen[id] = true
|
|
out[#out + 1] = { id = id, root = path, source = source }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if love and love.filesystem and love.filesystem.createDirectory then
|
|
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
|
end
|
|
scan(TouchSkin.USER_ROOT, "user")
|
|
scan(TouchSkin.BUNDLED_ROOT, "bundled")
|
|
table.sort(out, function(a, b) return a.id < b.id end)
|
|
return out
|
|
end
|
|
|
|
-- Drop a .zip into <save>/skins and report the id it will list 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
|
|
|
|
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
|
local dest = TouchSkin.USER_ROOT .. "/" .. name
|
|
local ok, err = love.filesystem.write(dest, data)
|
|
if not ok then return nil, tostring(err) end
|
|
|
|
local entry = TouchSkin.find(id)
|
|
if not entry then
|
|
love.filesystem.remove(dest)
|
|
return nil, "no skin.lua or .cfg inside " .. name
|
|
end
|
|
return id
|
|
end
|
|
|
|
function TouchSkin.find(id)
|
|
if not id or id == "" then return nil end
|
|
for _, entry in ipairs(TouchSkin.list()) do
|
|
if entry.id == id then return entry end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function TouchSkin.assetPaths(skin)
|
|
local out, seen = {}, {}
|
|
local function add(rel)
|
|
if rel and rel ~= "" and not seen[rel] then
|
|
seen[rel] = true
|
|
out[#out + 1] = rel
|
|
end
|
|
end
|
|
for _, page in ipairs(skin.pages or {}) do
|
|
add(page.imagePath)
|
|
for _, ctl in ipairs(page.controls or {}) do
|
|
add(ctl.imagePath)
|
|
add(ctl.pressedImagePath)
|
|
end
|
|
end
|
|
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 absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
|
|
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
|
|
else
|
|
local handle = io.open(destPath, "wb")
|
|
if not handle then return nil, "cannot write " .. destPath end
|
|
handle:write(blob)
|
|
handle:close()
|
|
end
|
|
return destPath, missing
|
|
end
|
|
|
|
TouchSkin.BINDS = {
|
|
"nul",
|
|
"up", "down", "left", "right",
|
|
"a", "b", "start", "select",
|
|
"left|up", "right|up", "left|down", "right|down",
|
|
"hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle",
|
|
"overlay_next",
|
|
}
|
|
|
|
function TouchSkin.describeBind(spec)
|
|
local buttons, hotkeys, keys, decorative = parseBinds(spec)
|
|
if decorative then return "decoration" end
|
|
local parts = {}
|
|
for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end
|
|
for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end
|
|
for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end
|
|
return table.concat(parts, " + ")
|
|
end
|
|
|
|
function TouchSkin.newControl(spec, x, y, w, h, shape)
|
|
local buttons, hotkeys, keys, decorative = parseBinds(spec)
|
|
return {
|
|
spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys,
|
|
decorative = decorative,
|
|
x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5,
|
|
shape = shape == "radial" and "radial" or "rect",
|
|
rangeMod = 1, alphaMod = 1,
|
|
reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1,
|
|
}
|
|
end
|
|
|
|
function TouchSkin.setBind(ctl, spec)
|
|
ctl.spec = spec
|
|
ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec)
|
|
return ctl
|
|
end
|
|
|
|
function TouchSkin.newSkin(id)
|
|
local page = {
|
|
index = 1, name = "main", fullScreen = true, normalized = true,
|
|
rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT,
|
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
|
viewport = { x = 0, y = 0, w = 1, h = 0.5 },
|
|
viewportFill = false,
|
|
controls = {},
|
|
}
|
|
return { id = id or "new_skin", name = id or "new_skin",
|
|
root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"),
|
|
format = "native", pages = { page } }
|
|
end
|
|
|
|
local function copyTable(src)
|
|
if type(src) ~= "table" then return src end
|
|
local out = {}
|
|
for k, v in pairs(src) do out[k] = copyTable(v) end
|
|
return out
|
|
end
|
|
|
|
function TouchSkin.clone(skin)
|
|
if not skin then return nil end
|
|
local out = {
|
|
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 = {},
|
|
}
|
|
for i, page in ipairs(skin.pages or {}) do
|
|
local p = copyTable(page)
|
|
p.image = page.image
|
|
p.controls = {}
|
|
for j, ctl in ipairs(page.controls or {}) do
|
|
local c = copyTable(ctl)
|
|
c.image = ctl.image
|
|
c.pressedImage = ctl.pressedImage
|
|
p.controls[j] = c
|
|
end
|
|
out.pages[i] = p
|
|
end
|
|
return out
|
|
end
|
|
|
|
function TouchSkin.resolveImage(root, rel)
|
|
if not rel or rel == "" then return nil end
|
|
return loadImage(joinPath(root, rel))
|
|
end
|
|
|
|
function TouchSkin.detectViewport(root, imagePath)
|
|
if not (love and love.image and love.image.newImageData) then return nil end
|
|
if not imagePath or imagePath == "" then return nil end
|
|
local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath))
|
|
if not ok or not data then return nil end
|
|
local w, h = data:getWidth(), data:getHeight()
|
|
if w < 2 or h < 2 then return nil end
|
|
|
|
local function clear(x, y)
|
|
if x < 0 or y < 0 or x >= w or y >= h then return false end
|
|
local okp, _, _, _, a = pcall(data.getPixel, data, x, y)
|
|
return okp and a ~= nil and a < 0.05
|
|
end
|
|
|
|
local cx, cy = math.floor(w / 2), math.floor(h / 2)
|
|
if not clear(cx, cy) then return nil end
|
|
|
|
local left, right = cx, cx
|
|
while left > 0 and clear(left - 1, cy) do left = left - 1 end
|
|
while right < w - 1 and clear(right + 1, cy) do right = right + 1 end
|
|
local top, bottom = cy, cy
|
|
while top > 0 and clear(cx, top - 1) do top = top - 1 end
|
|
while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end
|
|
|
|
local rw, rh = right - left + 1, bottom - top + 1
|
|
if rw < 8 or rh < 8 then return nil end
|
|
return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh
|
|
end
|
|
|
|
-- Bring outside art into the skin's own folder. A skin still living in
|
|
-- assets/ or a mounted zip is saved out to <save>/skins/<id> first, because
|
|
-- that is the only root the engine can write to.
|
|
function TouchSkin.importImage(skin, name, data)
|
|
if not skin then return nil, "no skin" end
|
|
if not data or data == "" then return nil, "no image data" end
|
|
if not (love and love.filesystem and love.filesystem.write) then
|
|
return nil, "no writable filesystem"
|
|
end
|
|
name = tostring(name):gsub("[^%w%._%-]", "_")
|
|
if name == "" then return nil, "bad file name" end
|
|
|
|
local dest = TouchSkin.USER_ROOT .. "/" .. skin.id
|
|
if skin.root ~= dest then
|
|
local saved, err = TouchSkin.saveTo(skin, skin.id)
|
|
if not saved then return nil, tostring(err) end
|
|
end
|
|
pcall(love.filesystem.createDirectory, dest .. "/img")
|
|
local rel = "img/" .. name
|
|
local ok, err = love.filesystem.write(dest .. "/" .. rel, data)
|
|
if not ok then return nil, tostring(err) end
|
|
return rel
|
|
end
|
|
|
|
function TouchSkin.listImages(root)
|
|
local out = {}
|
|
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
|
|
out[#out + 1] = prefix .. name
|
|
elseif isDir(path) and prefix == "" then
|
|
scan(path, name .. "/")
|
|
end
|
|
end
|
|
end
|
|
scan(root, "")
|
|
table.sort(out)
|
|
return out
|
|
end
|
|
|
|
function TouchSkin.saveTo(skin, id)
|
|
id = id or skin.id
|
|
if not id or id == "" then return nil, "no skin id" end
|
|
if not (love and love.filesystem and love.filesystem.write) then
|
|
return nil, "no writable filesystem"
|
|
end
|
|
local dest = TouchSkin.USER_ROOT .. "/" .. id
|
|
pcall(love.filesystem.createDirectory, dest)
|
|
|
|
local copied, failed = 0, {}
|
|
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
|
|
local target = dest .. "/" .. rel
|
|
local dir = target:match("^(.*)/[^/]+$")
|
|
if dir then pcall(love.filesystem.createDirectory, dir) end
|
|
if skin.root ~= dest then
|
|
local data = readFile(joinPath(skin.root, rel))
|
|
if data then
|
|
if love.filesystem.write(target, data) then copied = copied + 1 end
|
|
else
|
|
failed[#failed + 1] = rel
|
|
end
|
|
end
|
|
end
|
|
|
|
local ok, err = love.filesystem.write(dest .. "/" .. TouchSkin.NATIVE_NAME,
|
|
TouchSkin.serialize(skin))
|
|
if not ok then return nil, tostring(err) end
|
|
skin.id, skin.root, skin.format = id, dest, "native"
|
|
return dest, failed, copied
|
|
end
|
|
|
|
TouchSkin.active = nil
|
|
TouchSkin.pageIndex = 1
|
|
|
|
TouchSkin.surfaceRect = nil
|
|
|
|
function TouchSkin.setSurface(x, y, w, h)
|
|
if not w or w <= 0 or not h or h <= 0 then
|
|
TouchSkin.surfaceRect = nil
|
|
else
|
|
TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h }
|
|
end
|
|
return TouchSkin.surfaceRect
|
|
end
|
|
|
|
function TouchSkin.setActive(skin)
|
|
TouchSkin.active = skin or nil
|
|
TouchSkin.pageIndex = 1
|
|
return TouchSkin.active
|
|
end
|
|
|
|
function TouchSkin.select(id)
|
|
if not id or id == "" then return TouchSkin.setActive(nil) end
|
|
local entry = TouchSkin.find(id)
|
|
if not entry then return nil, "no skin " .. tostring(id) end
|
|
local skin, err = TouchSkin.load(entry.root, entry.id)
|
|
if not skin then return nil, err end
|
|
skin.source = entry.source
|
|
return TouchSkin.setActive(skin)
|
|
end
|
|
|
|
function TouchSkin.page()
|
|
local skin = TouchSkin.active
|
|
if not skin then return nil end
|
|
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
|
end
|
|
|
|
function TouchSkin.setPage(target)
|
|
local skin = TouchSkin.active
|
|
if not skin then return nil end
|
|
if type(target) == "number" then
|
|
local n = #skin.pages
|
|
TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1
|
|
return TouchSkin.page()
|
|
end
|
|
for i, page in ipairs(skin.pages) do
|
|
if page.name == target then
|
|
TouchSkin.pageIndex = i
|
|
return page
|
|
end
|
|
end
|
|
return TouchSkin.page()
|
|
end
|
|
|
|
function TouchSkin.nextPage(target)
|
|
local skin = TouchSkin.active
|
|
if not skin then return nil end
|
|
if target and target ~= "" then return TouchSkin.setPage(target) end
|
|
return TouchSkin.setPage(TouchSkin.pageIndex + 1)
|
|
end
|
|
|
|
function TouchSkin.pageBox(page, w, h, ox, oy)
|
|
ox, oy = ox or 0, oy or 0
|
|
if not page then return ox, oy, w, h end
|
|
local bx, by, bw, bh = ox, oy, w, h
|
|
if not page.fullScreen and h > 0 then
|
|
local displayAspect = w / h
|
|
if displayAspect > page.aspect then
|
|
bw = h * page.aspect
|
|
bx = ox + (w - bw) * 0.5
|
|
else
|
|
bh = w / page.aspect
|
|
by = oy + (h - bh) * 0.5
|
|
end
|
|
end
|
|
local r = page.rect
|
|
return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh
|
|
end
|
|
|
|
function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
|
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
|
local cx, cy = bx + ctl.x * bw, by + ctl.y * bh
|
|
local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh
|
|
return cx, cy, halfW, halfH
|
|
end
|
|
|
|
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
|
|
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 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.shape == "radial" then
|
|
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
|
|
end
|
|
return math.abs(dx) <= rx and math.abs(dy) <= ry
|
|
end
|
|
|
|
TouchSkin.overlayLive = false
|
|
|
|
function TouchSkin.setOverlayLive(on)
|
|
TouchSkin.overlayLive = on and true or false
|
|
end
|
|
|
|
function TouchSkin.decorativeOnly()
|
|
local page = TouchSkin.page()
|
|
if not page then return false end
|
|
for _, ctl in ipairs(page.controls) do
|
|
if not ctl.decorative then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
function TouchSkin.drawable()
|
|
if not TouchSkin.active then return false end
|
|
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
|
|
end
|
|
|
|
function TouchSkin.hasViewport()
|
|
local page = TouchSkin.page()
|
|
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
|
|
end
|
|
|
|
function TouchSkin.viewport(w, h, ox, oy)
|
|
local page = TouchSkin.page()
|
|
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
|
local v = page.viewport
|
|
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
|
|
local vw, vh = v.w * w, v.h * h
|
|
if vw <= 0 or vh <= 0 then return nil end
|
|
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
|
end
|
|
|
|
return TouchSkin
|