skins and skin studio

This commit is contained in:
bryanthaboi
2026-08-17 06:47:33 -04:00
parent a5b674f9da
commit 3cca70608f
45 changed files with 4194 additions and 64 deletions
+37
View File
@@ -59,6 +59,9 @@ function Game:load()
self.touchControls = TouchControls
TouchControls:init()
TouchControls:setHotkeyHandler(function(action, pressed)
self:touchSkinHotkey(action, pressed)
end)
self.renderer = Renderer
Renderer:init()
@@ -185,6 +188,40 @@ function Game:returnToTitle()
self.stack:push(self:makeTitleState())
end
Game.SKIN_FAST_FORWARD = 4
function Game:touchSkinHotkey(action, pressed)
if action == "fast_forward_hold" then
if pressed then
self.skinSpeedSaved = self.speedOverride
self.speedOverride = Game.SKIN_FAST_FORWARD
else
self.speedOverride = self.skinSpeedSaved
self.skinSpeedSaved = nil
end
elseif action == "fast_forward_toggle" then
if pressed then self:_cycleSpeed(1) end
elseif action == "soft_reset" then
if pressed then
Input:reset()
TouchControls:reset()
self:returnToTitle()
end
elseif action == "menu" then
if pressed then
local Screens = require("src.ui.Screens")
local top = self.stack and self.stack:top()
if top and not top.isTouchSkinMenu then
local state = Screens.build(self, "OptionsMenu")
if state then
state.isTouchSkinMenu = true
self.stack:push(state)
end
end
end
end
end
function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
+92
View File
@@ -0,0 +1,92 @@
local SkinZip = {}
local bxor
do
local ok, bit = pcall(require, "bit")
if ok and bit and bit.bxor then
bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end
else
local byteXor = {}
local function xor8(a, b)
local key = a * 256 + b
local memo = byteXor[key]
if memo then return memo end
local r, bitv = 0, 1
local x, y = a, b
for _ = 1, 8 do
if (x % 2) ~= (y % 2) then r = r + bitv end
x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2
end
byteXor[key] = r
return r
end
bxor = function(a, b)
local r, mul = 0, 1
for _ = 1, 4 do
r = r + xor8(a % 256, b % 256) * mul
a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256
end
return r
end
end
end
local crcTable
local function crc32(s)
if not crcTable then
crcTable = {}
for i = 0, 255 do
local c = i
for _ = 1, 8 do
if c % 2 == 1 then
c = bxor(math.floor(c / 2), 0xEDB88320)
else
c = math.floor(c / 2)
end
end
crcTable[i] = c
end
end
local crc = 0xFFFFFFFF
for i = 1, #s do
crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256))
end
return bxor(crc, 0xFFFFFFFF) % 0x100000000
end
local function le16(n)
n = math.floor(n) % 0x10000
return string.char(n % 256, math.floor(n / 256) % 256)
end
local function le32(n)
n = math.floor(n) % 0x100000000
return string.char(n % 256, math.floor(n / 256) % 256,
math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256)
end
function SkinZip.encode(entries)
local out, central, offset = {}, {}, 0
for _, entry in ipairs(entries) do
local name, data = entry.name, entry.data or ""
local crc, size = crc32(data), #data
local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. name
out[#out + 1] = local_
out[#out + 1] = data
central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0)
.. le32(0) .. le32(offset) .. name
offset = offset + #local_ + #data
end
local dir = table.concat(central)
return table.concat(out) .. dir
.. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries)
.. le32(#dir) .. le32(offset) .. le16(0)
end
return SkinZip
+219 -7
View File
@@ -39,6 +39,7 @@
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = {}
@@ -189,6 +190,7 @@ function TouchControls.normalizeConfig(tc)
-- no caller ever has to nil-check a bucket's scale
if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end
if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end
local saved = type(tc.layouts) == "table" and tc.layouts or nil
for _, o in ipairs(ORIENTATIONS) do
local b = saved and saved[o]
@@ -241,6 +243,7 @@ end
function TouchControls:init()
self.active = wantsOverlay()
TouchSkin.setOverlayLive(self.active)
self.enabled = true
-- vibration level for presses (#806); applyOptions overwrites it from
-- options.haptics, this is the value a harness that never applies options
@@ -261,6 +264,10 @@ function TouchControls:init()
self.held = {}
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.skinId = nil
self.skinError = nil
self.hotkeysHeld = {}
TouchSkin.setActive(nil)
self.img = nil
-- Images load whenever the platform wants the overlay OR the launcher
-- editor forces a preview (desktop testing of the editor).
@@ -285,6 +292,8 @@ function TouchControls:applyOptions(opts)
-- haptics is a plain top-level option, not part of the layout config the
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active)
self:selectSkin(cfg.skin)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -300,7 +309,7 @@ end
-- Snapshot for the editor's save path: enabled plus both orientation
-- buckets, matching what options.lua stores (#633).
function TouchControls:config()
local out = { enabled = self.enabled ~= false, layouts = {} }
local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} }
for _, o in ipairs(ORIENTATIONS) do
local b = self.layouts and self.layouts[o] or nil
out.layouts[o] = {
@@ -323,11 +332,56 @@ function TouchControls:setPreview(on)
end
function TouchControls:visible()
if self.preview then return self.img ~= nil end
return self.active and self.enabled ~= false and self.img ~= nil
and not self.controllerHidden
local art = TouchSkin.active ~= nil or self.img ~= nil
if self.preview then return art end
if self.enabled == false or not art then return false end
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
return self.active and not self.controllerHidden
end
local function surfaceRect()
local r = TouchSkin.surfaceRect
if r then return r.w, r.h, r.x, r.y end
if love and love.graphics and love.graphics.getDimensions then
local w, h = love.graphics.getDimensions()
return w, h, 0, 0
end
return 0, 0, 0, 0
end
TouchControls.surfaceRect = surfaceRect
function TouchControls:selectSkin(id)
if id == self.skinId and TouchSkin.active then return TouchSkin.active end
self:reset()
self.skinError = nil
if not id or id == "" then
self.skinId = nil
TouchSkin.setActive(nil)
return nil
end
local skin, err = TouchSkin.select(id)
if not skin then
self.skinId = nil
self.skinError = err
TouchSkin.setActive(nil)
return nil, err
end
self.skinId = id
return skin
end
function TouchControls:skin()
if not self:visible() then return nil end
return TouchSkin.active
end
function TouchControls:setHotkeyHandler(fn)
self.hotkeyHandler = type(fn) == "function" and fn or nil
end
local skinHitSet, applySkinSet
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
@@ -451,6 +505,15 @@ end
-- Which control (if any) contains (x, y). Prefer face buttons over the
-- d-pad when they overlap, matching touchpressed's order.
function TouchControls:hitTest(x, y)
local page = TouchSkin.page()
if page then
local ww, wh, ox, oy = surfaceRect()
for i = #page.controls, 1, -1 do
local ctl = page.controls[i]
if TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then return ctl.spec, ctl end
end
return nil
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
@@ -505,6 +568,86 @@ local function setDpad(self, touch, dir)
if dir then pressBtn(self, dir) end
end
local function pressKey(key, down)
local fn = down and love.keypressed or love.keyreleased
if type(fn) == "function" then pcall(fn, key, key, false) end
end
local function fireHotkey(self, action, pressed, ctl)
if action == "overlay_next" then
if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end
return pressed
end
if action == "overlay_prev" then
if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end
return pressed
end
self.hotkeysHeld = self.hotkeysHeld or {}
local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1)
if n < 0 then n = 0 end
self.hotkeysHeld[action] = n > 0 and n or nil
local edge = (pressed and n == 1) or (not pressed and n == 0)
if edge and self.hotkeyHandler then
pcall(self.hotkeyHandler, action, pressed)
end
return false
end
local function enterControl(self, ctl)
local buzzed = false
for _, btn in ipairs(ctl.buttons) do
if not self.held[btn] then buzzed = true end
pressBtn(self, btn)
end
for _, key in ipairs(ctl.keys) do pressKey(key, true) end
local switched = false
for _, action in ipairs(ctl.hotkeys) do
if fireHotkey(self, action, true, ctl) then switched = true end
end
if not buzzed and (ctl.keys[1] or ctl.hotkeys[1]) then
TouchControls.buzz(self.haptics)
end
return switched
end
local function exitControl(self, ctl)
for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end
for _, key in ipairs(ctl.keys) do pressKey(key, false) end
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
end
function skinHitSet(self, x, y)
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
set = set or {}
set[ctl] = true
end
end
return set
end
function applySkinSet(self, touch, set)
local prev = touch.set or {}
local switched = false
for ctl in pairs(prev) do
if not (set and set[ctl]) then exitControl(self, ctl) end
end
for ctl in pairs(set or {}) do
if not prev[ctl] then
if enterControl(self, ctl) then switched = true end
end
end
touch.set = set
if switched then
for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end
touch.set = nil
end
end
-- Returns true when this touch was captured by a virtual control -- the
-- pad's first refusal on the gameplay pointer seam (#807). Capture is
-- decided here, at press, and rides self.touches[id] for the touch's
@@ -513,13 +656,22 @@ end
function TouchControls:touchpressed(id, x, y)
-- preview mode is layout-edit only: never press GB buttons
if self.preview then return end
if not (self.active and self.enabled ~= false and self.img) then return end
if not (self.active and self.enabled ~= false
and (TouchSkin.active or self.img)) then return end
-- a controller hid the overlay; the first touch only brings it back
-- (uncaptured: it began on no control, so mods may still see it)
if self.controllerHidden then
self.controllerHidden = false
return
end
if TouchSkin.active then
local set = skinHitSet(self, x, y)
if not set then return end
local touch = { control = "skin" }
self.touches[id] = touch
applySkinSet(self, touch, set)
return true
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then
@@ -544,9 +696,14 @@ end
function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
if touch.control == "skin" then
applySkinSet(self, touch, skinHitSet(self, x, y))
return
end
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
if not touch or touch.control ~= "dpad" then return end
if touch.control ~= "dpad" then return end
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
end
@@ -555,7 +712,9 @@ function TouchControls:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
if touch.control == "dpad" then
if touch.control == "skin" then
applySkinSet(self, touch, nil)
elseif touch.control == "dpad" then
setDpad(self, touch, nil)
self.dpadTouch = nil
else
@@ -568,6 +727,15 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for _, touch in pairs(self.touches or {}) do
if touch.control == "skin" and touch.set then
for ctl in pairs(touch.set) do exitControl(self, ctl) end
end
end
for action in pairs(self.hotkeysHeld or {}) do
if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end
end
self.hotkeysHeld = {}
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
@@ -608,12 +776,56 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
local function drawStretched(img, x, y, w, h, alpha)
if not img or alpha <= 0 then return end
local iw, ih = img:getWidth(), img:getHeight()
if iw <= 0 or ih <= 0 then return end
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
end
function TouchControls:drawSkin(alphaMul)
local page = TouchSkin.page()
if not page then return false end
local ww, wh, sox, soy = surfaceRect()
local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy)
local opacity = (self.skinOpacity or 1) * alphaMul
love.graphics.push("all")
love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity)
local pressed = {}
for _, touch in pairs(self.touches or {}) do
for ctl in pairs(touch.set or {}) do pressed[ctl] = true end
end
for _, ctl in ipairs(page.controls) do
local down = pressed[ctl] == true
local img = (down and ctl.pressedImage) or ctl.image
if img then
local cx, cy, halfW, halfH =
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
local alpha = opacity
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
end
end
love.graphics.pop()
return true
end
-- OS-window space, called after GameViewport.finish so the overlay rides on
-- top of the game, companion composition and post-processing without being
-- captured or scaled with any game viewport. Also used by the launcher layout
-- editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
if TouchSkin.active then
local mul = (self.preview and self.enabled == false) and 0.45 or 1
if self:drawSkin(mul) then return end
end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
+842
View File
@@ -0,0 +1,842 @@
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