mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
skins and skin studio
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -122,6 +122,17 @@ local function addTouchRows(rows, add, opts, hooks)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
if hooks and hooks.openSkinStudio then
|
||||
rows[#rows + 1] = {
|
||||
label = Strings("SKIN STUDIO"),
|
||||
actionLabel = Strings("Open"),
|
||||
action = function()
|
||||
hooks.openSkinStudio()
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local function coreRows(opts, hooks)
|
||||
@@ -638,6 +649,7 @@ end
|
||||
-- writes options while a modal covers it, so the cached table stays true.
|
||||
-- `hooks` carries the host actions a row cannot perform itself:
|
||||
-- editTouchControls() -- hand the screen to the touch-overlay editor
|
||||
-- openSkinStudio() -- hand the screen to the desktop skin studio
|
||||
--
|
||||
-- `version` is the game the gear was opened on. It picks the row set, and
|
||||
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
|
||||
|
||||
+246
-5
@@ -776,6 +776,26 @@ end
|
||||
|
||||
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
|
||||
-- and the launcher ships no icon asset for them.
|
||||
-- Skins tab glyph: a bezel with a screen cutout and two face buttons, drawn
|
||||
-- rather than shipped as art so the tab needs no new asset.
|
||||
local function drawSkinGlyph(x, y, w, h, hot)
|
||||
local box = math.min(w, h)
|
||||
local bx = x + (w - box) / 2
|
||||
local by = y + (h - box) / 2
|
||||
local pad = math.floor(box * 0.22)
|
||||
local ow, oh = box - 2 * pad, box - 2 * pad
|
||||
local ink = hot and PAL.inverse or PAL.ink
|
||||
local a = 1
|
||||
Theme.strokeRounded(bx + pad, by + pad, ow, oh, ink, a,
|
||||
math.max(1, math.floor(Kit.scale)), math.floor(oh * 0.22))
|
||||
local sw, sh = ow * 0.58, oh * 0.40
|
||||
Theme.fillRounded(bx + pad + ow * 0.10, by + pad + oh * 0.14, sw, sh, ink, a,
|
||||
math.max(1, math.floor(sh * 0.2)))
|
||||
local r = math.max(1, oh * 0.09)
|
||||
Theme.fillRounded(bx + pad + ow * 0.60, by + pad + oh * 0.64, r * 2, r * 2, ink, a, r)
|
||||
Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
|
||||
end
|
||||
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
@@ -807,18 +827,46 @@ end
|
||||
-- frame. Their tab rows, opts tables and action closures are built once
|
||||
-- instead of 60 times a second -- only `active`, `image` and the queued
|
||||
-- action are written per frame.
|
||||
-- The four cartridges used to be four tabs of their own. They are one
|
||||
-- dropdown now: the tab row was seven controls wide and wrapped to two rows on
|
||||
-- anything narrow, and only ever one game is being looked at.
|
||||
local GAME_TABS = {
|
||||
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed,
|
||||
label = "Red" },
|
||||
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue,
|
||||
label = "Blue" },
|
||||
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold,
|
||||
label = "Yellow" },
|
||||
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
|
||||
label = "Gold" },
|
||||
}
|
||||
|
||||
local HEADER_TABS = {
|
||||
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
|
||||
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
|
||||
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
|
||||
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
|
||||
{ id = "mods", key = "tab-mods" },
|
||||
{ id = "find", key = "tab-find" },
|
||||
{ id = "skins", key = "tab-skins", glyph = true },
|
||||
}
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||
if t.glyph then t.opts.drawFn = drawSkinGlyph end
|
||||
end
|
||||
|
||||
-- Which cartridge the dropdown is showing: the open game tab, else the last
|
||||
-- one visited, else Red. Kept as a function so the mods/find/skins panels
|
||||
-- still answer "for which game" without a game tab being open.
|
||||
local function currentGame(imp)
|
||||
for _, g in ipairs(GAME_TABS) do
|
||||
if imp.tab == g.id then return g end
|
||||
end
|
||||
for _, g in ipairs(GAME_TABS) do
|
||||
if imp.modScope == g.id then return g end
|
||||
end
|
||||
return GAME_TABS[1]
|
||||
end
|
||||
|
||||
LauncherView.GAME_TABS = GAME_TABS
|
||||
LauncherView.currentGame = currentGame
|
||||
|
||||
local QUIT_INK_HOT = { 0, 0, 0, 1 }
|
||||
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
|
||||
|
||||
@@ -837,11 +885,27 @@ local function headerChrome(imp)
|
||||
hot and QUIT_INK_HOT or QUIT_INK_REST)
|
||||
end },
|
||||
tab = {},
|
||||
game = { face = "tab", font = "tab",
|
||||
action = function()
|
||||
local g = currentGame(imp)
|
||||
if imp.tab == g.id then
|
||||
imp._gamePopup = true
|
||||
else
|
||||
imp:_switchTab(g.id)
|
||||
end
|
||||
end },
|
||||
}
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
local id = t.id
|
||||
c.tab[id] = function() imp:_switchTab(id) end
|
||||
end
|
||||
for _, g in ipairs(GAME_TABS) do
|
||||
local id = g.id
|
||||
c.tab[id] = function()
|
||||
imp._gamePopup = nil
|
||||
imp:_switchTab(id)
|
||||
end
|
||||
end
|
||||
imp._headerChrome = c
|
||||
return c
|
||||
end
|
||||
@@ -927,7 +991,10 @@ local function buildHeader(imp, m)
|
||||
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
|
||||
-- collide.
|
||||
local tabs = HEADER_TABS
|
||||
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
|
||||
for _, t in ipairs(tabs) do
|
||||
if t.id == "mods" then t.icon = imp._modsIcon end
|
||||
if t.id == "find" then t.icon = imp._findIcon end
|
||||
end
|
||||
local tabH = m.chip
|
||||
local tx = m.x + m.pad
|
||||
local ty = y + math.floor(6 * m.s)
|
||||
@@ -935,6 +1002,40 @@ local function buildHeader(imp, m)
|
||||
local tabRight = m.x + m.w - m.pad
|
||||
local tabGap = math.floor(6 * m.s)
|
||||
local tabRowGap = math.floor(4 * m.s)
|
||||
|
||||
-- the cartridge dropdown, sized to its longest label so switching games
|
||||
-- never reflows the row
|
||||
local chrome0 = headerChrome(imp)
|
||||
local game = currentGame(imp)
|
||||
local labelW = 0
|
||||
for _, g in ipairs(GAME_TABS) do
|
||||
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
|
||||
end
|
||||
local dropW = math.min(tabRight - tabLeft,
|
||||
tabH + labelW + math.floor(34 * m.s))
|
||||
chrome0.game.color = game.color
|
||||
chrome0.game.letter = game.letter
|
||||
chrome0.game.active = imp.tab == game.id
|
||||
local gameHot = Kit.hover(tx, ty, dropW, tabH)
|
||||
local gameDown = gameHot and Kit.mouseDown
|
||||
-- face "tab" inverts on hover as well as when active, so the caret has to
|
||||
-- flip with it or it vanishes into the cartridge colour
|
||||
local gameInvert = chrome0.game.active or gameHot
|
||||
chrome0.game.ring = gameHot and not chrome0.game.active or nil
|
||||
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
|
||||
do
|
||||
local cw = math.floor(7 * m.s)
|
||||
local ccx = tx + dropW - math.floor(14 * m.s)
|
||||
local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0)
|
||||
if love.graphics.polygon then
|
||||
Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
|
||||
love.graphics.polygon("fill",
|
||||
ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
tx = tx + dropW + tabGap
|
||||
|
||||
for _, t in ipairs(tabs) do
|
||||
local w = tabH
|
||||
if tx > tabLeft and tx + w > tabRight then
|
||||
@@ -1909,6 +2010,117 @@ end
|
||||
|
||||
-- ---------------------------------------------------------- find mods panel
|
||||
|
||||
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio.
|
||||
local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
local skins = imp:_ensureSkins()
|
||||
local active = imp:_activeSkin()
|
||||
local gap = m.gap
|
||||
local cy = y
|
||||
|
||||
if imp._skinNotice then
|
||||
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
|
||||
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
-- Studio button. Desktop only: the host supplies the hook nowhere else.
|
||||
if imp.onOpenSkinStudio then
|
||||
local label = Strings("Open Skin Studio")
|
||||
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
|
||||
btn(imp, x, cy, bw, m.btnH, "skins-studio", label, {
|
||||
kind = "accent", font = "small",
|
||||
action = function()
|
||||
-- the studio boots the game on Play, so hand it a real cartridge
|
||||
imp.onOpenSkinStudio(imp.modScope or "red")
|
||||
end })
|
||||
local hint = Strings("Design bezels and button layouts, then test them.")
|
||||
Kit.text("small", Kit.ellipsize("small", hint,
|
||||
w - bw - math.floor(12 * m.s)), x + bw + math.floor(12 * m.s),
|
||||
cy + math.floor((m.btnH - Kit.textHeight("small")) / 2), PAL.muted)
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
|
||||
Kit.caption(x, cy, Strings("INSTALLED"))
|
||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
|
||||
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
|
||||
imp._skinGear = imp._skinGear
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
|
||||
-- The row itself is "use this skin"; the gear beside it configures that
|
||||
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
|
||||
-- opens the studio, so neither lands on a screen that cannot edit it.
|
||||
local function skinRow(key, id, title, detail, selected, configure)
|
||||
local gearW = configure and rowH or 0
|
||||
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
|
||||
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
|
||||
function() imp:_useSkin(id) end)
|
||||
local tagW = selected
|
||||
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
|
||||
or math.floor(12 * m.s)
|
||||
local textW = rowW - math.floor(24 * m.s) - tagW
|
||||
local tx = x + math.floor(12 * m.s)
|
||||
local ty = cy + math.floor(7 * m.s)
|
||||
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
|
||||
ink or PAL.heading)
|
||||
Kit.text("small", Kit.ellipsize("small", detail, textW),
|
||||
tx, ty + Kit.textHeight("mono"), ink or PAL.muted)
|
||||
if selected then
|
||||
Kit.textRight("small", Strings("IN USE"), x + rowW - math.floor(12 * m.s),
|
||||
cy + math.floor((rowH - Kit.textHeight("small")) / 2), ink or PAL.green)
|
||||
end
|
||||
if configure then
|
||||
btn(imp, x + w - gearW, cy, gearW, gearW, key .. "-cfg", "", {
|
||||
face = "invert", image = imp._skinGear, action = configure })
|
||||
end
|
||||
cy = cy + rowH + math.floor(4 * m.s)
|
||||
end
|
||||
|
||||
skinRow("skin-none", nil, Strings("Built-in pad"),
|
||||
Strings("The default on-screen buttons."), active == nil,
|
||||
imp.onEditTouchControls and function()
|
||||
imp.onEditTouchControls(imp.modScope or "red")
|
||||
end or nil)
|
||||
|
||||
for _, entry in ipairs(skins) do
|
||||
local bits = {}
|
||||
bits[#bits + 1] = entry.source == "user" and Strings("installed")
|
||||
or Strings("bundled")
|
||||
if entry.controls > 0 then
|
||||
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
|
||||
else
|
||||
bits[#bits + 1] = Strings("bezel only")
|
||||
end
|
||||
if entry.pages > 1 then
|
||||
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
|
||||
end
|
||||
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
|
||||
local configure = imp.onOpenSkinStudio and function()
|
||||
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
|
||||
end or nil
|
||||
skinRow("skin-" .. entry.id, entry.id, entry.id,
|
||||
table.concat(bits, " \194\183 "), active == entry.id, configure)
|
||||
end
|
||||
|
||||
if #skins == 0 then
|
||||
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
|
||||
Strings("No skins installed yet."))
|
||||
cy = cy + math.floor(72 * m.s) + gap
|
||||
end
|
||||
|
||||
cy = cy + math.floor(6 * m.s)
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
Kit.caption(x, cy, Strings("IMPORT"))
|
||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
local boxH = math.floor(76 * m.s)
|
||||
Kit.card(x, cy, w, boxH, "muted")
|
||||
Kit.textWrapped("small", Strings(
|
||||
"Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."),
|
||||
x + math.floor(14 * m.s), cy + math.floor(12 * m.s),
|
||||
w - math.floor(28 * m.s), PAL.muted, 3)
|
||||
Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s),
|
||||
cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint)
|
||||
end
|
||||
|
||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
imp:_ensureFind()
|
||||
imp:_ensureMods()
|
||||
@@ -2780,6 +2992,32 @@ local function buildModScopeModal(imp, m)
|
||||
action = function() imp._modScopePopup = nil end })
|
||||
end
|
||||
|
||||
-- The cartridge dropdown's list. Replaces the four R/B/Y/G tabs, so it is
|
||||
-- also what a controller reaches after the tab row.
|
||||
local function buildGameModal(imp, m)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local w = math.floor(360 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #GAME_TABS * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
local chrome = headerChrome(imp)
|
||||
for _, g in ipairs(GAME_TABS) do
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id,
|
||||
Strings(g.label), {
|
||||
face = "tab", font = "small", letter = g.letter, color = g.color,
|
||||
active = imp.tab == g.id,
|
||||
action = chrome.tab[g.id] })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._gamePopup = nil end })
|
||||
end
|
||||
|
||||
-- Category filter for FIND MODS. Two columns, because an index can list
|
||||
-- enough categories to overflow a single stacked column on a short window.
|
||||
local function buildFilterModal(imp, m)
|
||||
@@ -3753,6 +3991,7 @@ local function buildModals(imp, m)
|
||||
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
||||
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
||||
if imp._sortPopup then buildSortModal(imp, m) return true end
|
||||
if imp._gamePopup then buildGameModal(imp, m) return true end
|
||||
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
|
||||
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
||||
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
||||
@@ -3906,6 +4145,8 @@ function LauncherView.draw(imp)
|
||||
buildModsPanel(imp, x, contentY, w, availH, m)
|
||||
elseif imp.tab == "find" then
|
||||
buildFindPanel(imp, x, contentY, w, availH, m)
|
||||
elseif imp.tab == "skins" then
|
||||
buildSkinsPanel(imp, x, contentY, w, availH, m)
|
||||
else
|
||||
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
|
||||
end
|
||||
|
||||
@@ -1224,6 +1224,7 @@ function RomImporter.new(onComplete, opts)
|
||||
forceImport = opts.forceImport or false,
|
||||
onEditSave = opts.onEditSave,
|
||||
onEditTouchControls = opts.onEditTouchControls,
|
||||
onOpenSkinStudio = opts.onOpenSkinStudio,
|
||||
isNX = isNX,
|
||||
romImportMode = romImportMode,
|
||||
mobileFileBridge = mobileFileBridge,
|
||||
@@ -1784,7 +1785,12 @@ function RomImporter:filedropped(file)
|
||||
-- readDroppedFile does here.
|
||||
local name = file:getFilename() or ""
|
||||
if name:lower():match("%.zip$") then
|
||||
self:_installMod(file)
|
||||
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
|
||||
if self.tab == "skins" then
|
||||
self:_installSkinZip(file)
|
||||
else
|
||||
self:_installMod(file)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- A dropped .sav is a battery save: import it to a new slot for the active
|
||||
@@ -2621,7 +2627,7 @@ function RomImporter:resumeAfterOverlay()
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "gold", "mods", "find" }
|
||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
|
||||
local idx = 1
|
||||
for i, id in ipairs(order) do
|
||||
if id == self.tab then idx = i; break end
|
||||
@@ -2987,11 +2993,78 @@ function RomImporter:_switchTab(id)
|
||||
self.tab = id
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
-- the skins list is cheap and can change behind the launcher's back
|
||||
-- (an export, a hand-dropped folder), so re-read it on every visit
|
||||
if id == "skins" then self:_ensureSkins(true) end
|
||||
if GameVersion.VERSIONS[id] then
|
||||
self:_setModScope(id)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- skins tab (touch skins + the desktop Skin Studio)
|
||||
|
||||
function RomImporter:_ensureSkins(force)
|
||||
if self._skins and not force then return self._skins end
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local out = {}
|
||||
for _, entry in ipairs(TouchSkin.list()) do
|
||||
local skin = TouchSkin.load(entry.root, entry.id)
|
||||
local page = skin and skin.pages[1]
|
||||
local controls = 0
|
||||
for _, ctl in ipairs(page and page.controls or {}) do
|
||||
if not ctl.decorative then controls = controls + 1 end
|
||||
end
|
||||
out[#out + 1] = {
|
||||
id = entry.id,
|
||||
source = entry.source,
|
||||
pages = skin and #skin.pages or 0,
|
||||
controls = controls,
|
||||
screen = page ~= nil and page.viewport ~= nil,
|
||||
ok = skin ~= nil,
|
||||
}
|
||||
end
|
||||
self._skins = out
|
||||
return out
|
||||
end
|
||||
|
||||
function RomImporter:_activeSkin()
|
||||
local opts = require("src.core.SaveData").loadOptions()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
return tc.skin
|
||||
end
|
||||
|
||||
function RomImporter:_useSkin(id)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = SaveData.loadOptions()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled = true
|
||||
tc.skin = id
|
||||
opts.touchControls = tc
|
||||
SaveData.saveOptions(opts)
|
||||
self._skinNotice = {
|
||||
ok = true,
|
||||
text = id and ("Now using " .. id) or "Now using the built-in pad",
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_installSkinZip(file)
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local name = file:getFilename() or ""
|
||||
local data, readError = readDroppedFile(file)
|
||||
if not data then
|
||||
self._skinNotice = { ok = false,
|
||||
text = "Could not read the dropped file: " .. tostring(readError) }
|
||||
return
|
||||
end
|
||||
local id, err = TouchSkin.installArchive(name, data)
|
||||
self:_ensureSkins(true)
|
||||
if not id then
|
||||
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
|
||||
return
|
||||
end
|
||||
self._skinNotice = { ok = true, text = "Imported " .. id }
|
||||
end
|
||||
|
||||
function RomImporter:_toggleFindSearchFocus()
|
||||
self._findSearchFocus = not self._findSearchFocus
|
||||
if self._findSearchFocus then
|
||||
@@ -3018,6 +3091,13 @@ function RomImporter:_openSettings()
|
||||
self.onEditTouchControls(version)
|
||||
end
|
||||
end
|
||||
if self.onOpenSkinStudio then
|
||||
local version = self.tab
|
||||
hooks.openSkinStudio = function(skinId)
|
||||
self:_closeSettings()
|
||||
self.onOpenSkinStudio(version, skinId)
|
||||
end
|
||||
end
|
||||
-- The tab the gear was opened on decides the row set: Gold reads a
|
||||
-- different option block entirely, and offering it Gen 1's rows meant a
|
||||
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
|
||||
|
||||
+29
-16
@@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -84,7 +85,13 @@ local function displayMetrics()
|
||||
end
|
||||
if dpiX < 1e-6 then dpiX = 1 end
|
||||
if dpiY < 1e-6 then dpiY = 1 end
|
||||
return ww, wh, pw, ph, dpiX, dpiY
|
||||
local vx, vy = 0, 0
|
||||
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
|
||||
if sw and sw >= 1 and sh >= 1 then
|
||||
vx, vy = math.floor(sx), math.floor(sy)
|
||||
pw, ph = math.floor(sw), math.floor(sh)
|
||||
end
|
||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
@@ -274,6 +281,10 @@ function Renderer:worldViewSize()
|
||||
-- this is the same sum with the viewport standing in for the window, so
|
||||
-- both platforms show the same map area at the same zoom.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
if not cap and TouchSkin.hasViewport() then
|
||||
local page = TouchSkin.page()
|
||||
if not page.viewportExpand then cap = self:fitScale() end
|
||||
end
|
||||
if cap then
|
||||
local uiw, uih = self:uiSize()
|
||||
pw, ph = uiw * cap, uih * cap
|
||||
@@ -745,7 +756,9 @@ end
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
GameViewport.setTarget()
|
||||
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
||||
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
|
||||
local vux, vuy = vx / dpiX, vy / dpiY
|
||||
local vuw, vuh = pw / dpiX, ph / dpiY
|
||||
-- Sp = integer framebuffer pixels per GB pixel;
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
local Sp = self:fitScale()
|
||||
@@ -753,8 +766,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local uiw, uih = self:uiSize()
|
||||
local vpw, vph = uiw * Sx, uih * Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
||||
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
||||
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
||||
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
||||
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
||||
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
||||
@@ -770,8 +783,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
local Ux, Uy = Up / dpiX, Up / dpiY
|
||||
local uvpw, uvph = uiw * Ux, uih * Uy
|
||||
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
|
||||
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
|
||||
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
||||
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -921,7 +934,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- skipped entirely (nothing drew into it). The UI blit below still
|
||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||
local loveMajor = love.getVersion()
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
||||
@@ -933,7 +946,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
elseif self.worldActive then
|
||||
@@ -941,8 +954,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local sx, sy = sp / dpiX, sp / dpiY
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
|
||||
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
|
||||
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
|
||||
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
@@ -953,9 +966,9 @@ function Renderer:endFrame(zones, worldZones)
|
||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
||||
if not projected then
|
||||
if worldZones then
|
||||
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
|
||||
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
|
||||
else
|
||||
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
|
||||
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, vux, vuy, vuw, vuh)
|
||||
end
|
||||
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
||||
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
||||
@@ -964,7 +977,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local redraws = PaletteFX.spriteRedraws()
|
||||
if redraws[1] then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||
local activeShader = nil
|
||||
for _, r in ipairs(redraws) do
|
||||
local wanted = r.colors
|
||||
@@ -996,7 +1009,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if self.uprightActive then
|
||||
local M = self.UPRIGHT_MARGIN
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
@@ -1007,7 +1020,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
@@ -1029,7 +1042,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- (and its duplicate #772).
|
||||
if self.battleDim and self.battleDim > 0 then
|
||||
love.graphics.setColor(0, 0, 0, self.battleDim)
|
||||
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
|
||||
for _, r in ipairs(subtractRect({ { vux, vuy, vuw, vuh } }, uox, uoy, uvpw, uvph)) do
|
||||
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -0,0 +1,926 @@
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local Theme = require("src.ui.kit.Theme")
|
||||
local PAL = Theme.PAL
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local Studio = {}
|
||||
|
||||
Studio.CANVASES = {
|
||||
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
|
||||
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 },
|
||||
{ id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
|
||||
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
|
||||
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 },
|
||||
{ id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 },
|
||||
{ id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 },
|
||||
{ id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224,
|
||||
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
|
||||
}
|
||||
|
||||
local HANDLE = 7
|
||||
local HANDLES = {
|
||||
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
|
||||
{ "w", 0, 0.5 }, { "e", 1, 0.5 },
|
||||
{ "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 },
|
||||
}
|
||||
|
||||
local GB_ASPECT = 160 / 144
|
||||
|
||||
local function clamp01(v)
|
||||
if v ~= v then return 0 end
|
||||
if v < 0 then return 0 end
|
||||
if v > 1 then return 1 end
|
||||
return v
|
||||
end
|
||||
|
||||
local function round(v) return math.floor(v + 0.5) end
|
||||
|
||||
function Studio.canvas()
|
||||
return Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1]
|
||||
end
|
||||
|
||||
function Studio.page()
|
||||
local skin = Studio.skin
|
||||
if not skin then return nil end
|
||||
return skin.pages[Studio.pageIndex] or skin.pages[1]
|
||||
end
|
||||
|
||||
function Studio.selectedControl()
|
||||
local page = Studio.page()
|
||||
if not page then return nil end
|
||||
return page.controls[Studio.selected]
|
||||
end
|
||||
|
||||
local function markDirty()
|
||||
Studio.dirty = true
|
||||
Studio.status = nil
|
||||
end
|
||||
|
||||
local function syncActive()
|
||||
TouchSkin.setActive(Studio.skin)
|
||||
TouchSkin.pageIndex = Studio.pageIndex
|
||||
end
|
||||
|
||||
function Studio.setCanvas(index)
|
||||
local n = #Studio.CANVASES
|
||||
Studio.canvasIndex = ((index - 1) % n) + 1
|
||||
local canvas = Studio.canvas()
|
||||
local page = Studio.page()
|
||||
if page and canvas.lockViewport then
|
||||
page.viewport = {
|
||||
x = canvas.lockViewport.x, y = canvas.lockViewport.y,
|
||||
w = canvas.lockViewport.w, h = canvas.lockViewport.h,
|
||||
}
|
||||
page.viewportFill = false
|
||||
markDirty()
|
||||
end
|
||||
if page then page.aspect = canvas.w / canvas.h end
|
||||
end
|
||||
|
||||
function Studio.load(opts)
|
||||
opts = opts or {}
|
||||
Studio.onClose = opts.onClose
|
||||
Studio.onPlay = opts.onPlay
|
||||
Studio.version = opts.version
|
||||
Studio.pageIndex = 1
|
||||
Studio.selected = nil
|
||||
Studio.testing = false
|
||||
Studio.dirty = false
|
||||
Studio.status = nil
|
||||
Studio.drag = nil
|
||||
Studio.pendingPlay = false
|
||||
Studio.canvasIndex = 1
|
||||
Studio.aspectLock = true
|
||||
Studio.skinIdField = ""
|
||||
Studio.available = TouchSkin.list()
|
||||
Studio.imageTarget = "idle"
|
||||
|
||||
TouchControls:init()
|
||||
TouchControls.active = true
|
||||
TouchControls.enabled = true
|
||||
TouchControls:setPreview(true)
|
||||
|
||||
local start = opts.skinId
|
||||
if not start then
|
||||
local saved = SaveData.loadOptions()
|
||||
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
|
||||
start = tc.skin
|
||||
end
|
||||
if start and TouchSkin.find(start) then
|
||||
Studio.open(start)
|
||||
else
|
||||
Studio.skin = TouchSkin.newSkin("new_skin")
|
||||
Studio.skinIdField = Studio.skin.id
|
||||
syncActive()
|
||||
end
|
||||
end
|
||||
|
||||
function Studio.open(id)
|
||||
local entry = TouchSkin.find(id)
|
||||
if not entry then return false end
|
||||
local loaded = TouchSkin.load(entry.root, entry.id)
|
||||
if not loaded then return false end
|
||||
Studio.skin = TouchSkin.clone(loaded)
|
||||
Studio.skinIdField = Studio.skin.id
|
||||
Studio.pageIndex = 1
|
||||
Studio.selected = nil
|
||||
Studio.dirty = false
|
||||
Studio.images = TouchSkin.listImages(Studio.skin.root)
|
||||
syncActive()
|
||||
return true
|
||||
end
|
||||
|
||||
function Studio.unload()
|
||||
Studio.pendingPlay = false
|
||||
TouchSkin.setSurface(nil)
|
||||
TouchSkin.setActive(nil)
|
||||
TouchControls:setPreview(false)
|
||||
TouchControls:reset()
|
||||
Studio.skin = nil
|
||||
Studio.onClose = nil
|
||||
Studio.onPlay = nil
|
||||
Studio.drag = nil
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- editing
|
||||
|
||||
function Studio.addControl()
|
||||
local page = Studio.page()
|
||||
if not page then return end
|
||||
page.controls[#page.controls + 1] =
|
||||
TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial")
|
||||
Studio.selected = #page.controls
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.deleteControl()
|
||||
local page = Studio.page()
|
||||
if not page or not Studio.selected then return end
|
||||
table.remove(page.controls, Studio.selected)
|
||||
Studio.selected = page.controls[Studio.selected] and Studio.selected
|
||||
or (#page.controls > 0 and #page.controls or nil)
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.duplicateControl()
|
||||
local page, ctl = Studio.page(), Studio.selectedControl()
|
||||
if not page or not ctl then return end
|
||||
local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04),
|
||||
clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
|
||||
copy.imagePath, copy.image = ctl.imagePath, ctl.image
|
||||
copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage
|
||||
copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod
|
||||
table.insert(page.controls, Studio.selected + 1, copy)
|
||||
Studio.selected = Studio.selected + 1
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.cycleBind(dir)
|
||||
local ctl = Studio.selectedControl()
|
||||
if not ctl then return end
|
||||
local list, at = TouchSkin.BINDS, 1
|
||||
for i, spec in ipairs(list) do
|
||||
if spec == ctl.spec then at = i break end
|
||||
end
|
||||
TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1])
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.cycleImage(dir)
|
||||
local ctl = Studio.selectedControl()
|
||||
local page = Studio.page()
|
||||
if not page then return end
|
||||
Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root)
|
||||
local list = Studio.images
|
||||
local field = Studio.imageTarget
|
||||
local owner = (field == "bezel") and page or ctl
|
||||
if not owner then return end
|
||||
local key = (field == "pressed") and "pressedImagePath" or "imagePath"
|
||||
if field == "bezel" then key = "imagePath" end
|
||||
|
||||
local at = 0
|
||||
for i, rel in ipairs(list) do
|
||||
if rel == owner[key] then at = i break end
|
||||
end
|
||||
local next_ = at + dir
|
||||
if next_ < 0 then next_ = #list end
|
||||
if next_ > #list then next_ = 0 end
|
||||
local rel = (next_ >= 1) and list[next_] or nil
|
||||
owner[key] = rel
|
||||
local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil
|
||||
if field == "pressed" then owner.pressedImage = img else owner.image = img end
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.assignImage(rel)
|
||||
local page, ctl = Studio.page(), Studio.selectedControl()
|
||||
local img = TouchSkin.resolveImage(Studio.skin.root, rel)
|
||||
if ctl and Studio.imageTarget == "pressed" then
|
||||
ctl.pressedImagePath, ctl.pressedImage = rel, img
|
||||
elseif ctl and Studio.imageTarget == "idle" then
|
||||
ctl.imagePath, ctl.image = rel, img
|
||||
elseif page then
|
||||
page.imagePath, page.image = rel, img
|
||||
end
|
||||
Studio.images = TouchSkin.listImages(Studio.skin.root)
|
||||
Studio.dirty = true
|
||||
end
|
||||
|
||||
function Studio.filedropped(file)
|
||||
if not Studio.skin then return end
|
||||
local path = (file.getFilename and file:getFilename()) or ""
|
||||
local base = path:match("([^/\\]+)$") or path
|
||||
if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then
|
||||
Studio.status = "Drop a PNG or JPG to use it as art."
|
||||
return
|
||||
end
|
||||
local ok, data = pcall(function()
|
||||
file:open("r")
|
||||
local bytes = file:read()
|
||||
file:close()
|
||||
return bytes
|
||||
end)
|
||||
if not ok or not data then
|
||||
Studio.status = "Could not read " .. base
|
||||
return
|
||||
end
|
||||
local rel, err = TouchSkin.importImage(Studio.skin, base, data)
|
||||
if not rel then
|
||||
Studio.status = "Import failed: " .. tostring(err)
|
||||
return
|
||||
end
|
||||
Studio.assignImage(rel)
|
||||
local where = Studio.selectedControl()
|
||||
and (Studio.imageTarget == "pressed" and "pressed art" or "idle art")
|
||||
or "bezel"
|
||||
Studio.status = "Imported " .. rel .. " as " .. where
|
||||
Studio.skinIdField = Studio.skin.id
|
||||
end
|
||||
|
||||
function Studio.detectViewport()
|
||||
local page = Studio.page()
|
||||
if not page or not Studio.skin then return end
|
||||
if Studio.canvas().lockViewport then
|
||||
Studio.status = "This preset locks the screen position."
|
||||
return
|
||||
end
|
||||
if not page.imagePath then
|
||||
Studio.status = "Pick a bezel image first."
|
||||
return
|
||||
end
|
||||
local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath)
|
||||
if not rect then
|
||||
Studio.status = "No transparent screen hole found in " .. page.imagePath
|
||||
return
|
||||
end
|
||||
page.viewport = rect
|
||||
Studio.status = ("Screen detected: %dx%d px in the bezel art"):format(pw, ph)
|
||||
Studio.dirty = true
|
||||
end
|
||||
|
||||
function Studio.toggleViewport()
|
||||
local page = Studio.page()
|
||||
if not page then return end
|
||||
if Studio.canvas().lockViewport then return end
|
||||
if page.viewport then
|
||||
page.viewport = nil
|
||||
else
|
||||
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
|
||||
end
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.addPage()
|
||||
local skin = Studio.skin
|
||||
if not skin then return end
|
||||
local page = TouchSkin.newSkin(skin.id).pages[1]
|
||||
page.name = "page" .. (#skin.pages + 1)
|
||||
page.index = #skin.pages + 1
|
||||
skin.pages[#skin.pages + 1] = page
|
||||
Studio.pageIndex = #skin.pages
|
||||
Studio.selected = nil
|
||||
syncActive()
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.save()
|
||||
local skin = Studio.skin
|
||||
if not skin then return end
|
||||
local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "")
|
||||
if id == "" then
|
||||
Studio.status = "Give the skin a name first."
|
||||
return
|
||||
end
|
||||
local dest, failed = TouchSkin.saveTo(skin, id)
|
||||
if not dest then
|
||||
Studio.status = "Save failed: " .. tostring(failed)
|
||||
return
|
||||
end
|
||||
Studio.dirty = false
|
||||
Studio.available = TouchSkin.list()
|
||||
Studio.status = "Saved to " .. dest
|
||||
if type(failed) == "table" and failed[1] then
|
||||
Studio.status = Studio.status .. " (" .. #failed .. " image(s) not found)"
|
||||
end
|
||||
end
|
||||
|
||||
function Studio.export()
|
||||
local skin = Studio.skin
|
||||
if not skin then return end
|
||||
if Studio.dirty then Studio.save() end
|
||||
local path, missing = TouchSkin.export(skin)
|
||||
if not path then
|
||||
Studio.status = "Export failed: " .. tostring(missing)
|
||||
return
|
||||
end
|
||||
Studio.status = "Exported " .. path
|
||||
Studio.available = TouchSkin.list()
|
||||
end
|
||||
|
||||
function Studio.play()
|
||||
local skin = Studio.skin
|
||||
if not skin then return end
|
||||
Studio.save()
|
||||
if Studio.dirty then return end
|
||||
local opts = SaveData.loadOptions()
|
||||
local block = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
block.enabled = true
|
||||
block.skin = skin.id
|
||||
opts.touchControls = block
|
||||
SaveData.saveOptions(opts)
|
||||
-- Handing off here would unload the studio inside its own draw pass and
|
||||
-- leave the rest of this frame drawing against a dead skin; Studio.update
|
||||
-- runs it on the next tick instead.
|
||||
Studio.pendingPlay = true
|
||||
Studio.status = "Starting the game with " .. skin.id .. "..."
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- canvas
|
||||
|
||||
function Studio.canvasRect(x, y, w, h)
|
||||
local canvas = Studio.canvas()
|
||||
local aspect = canvas.w / canvas.h
|
||||
local cw, ch = w, w / aspect
|
||||
if ch > h then ch, cw = h, h * aspect end
|
||||
return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
|
||||
end
|
||||
|
||||
local function controlRect(page, ctl, r)
|
||||
local cx, cy, halfW, halfH =
|
||||
TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y)
|
||||
return cx - halfW, cy - halfH, halfW * 2, halfH * 2
|
||||
end
|
||||
|
||||
local function viewportRect(page, r)
|
||||
local v = page.viewport
|
||||
if not v then return nil end
|
||||
return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h
|
||||
end
|
||||
|
||||
local function handleRects(bx, by, bw, bh)
|
||||
local out = {}
|
||||
for _, h in ipairs(HANDLES) do
|
||||
out[#out + 1] = {
|
||||
id = h[1],
|
||||
x = bx + bw * h[2] - HANDLE * Kit.scale,
|
||||
y = by + bh * h[3] - HANDLE * Kit.scale,
|
||||
w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function applyResize(id, bx, by, bw, bh, dx, dy)
|
||||
if id:find("w") then bx = bx + dx bw = bw - dx end
|
||||
if id:find("e") then bw = bw + dx end
|
||||
if id:find("n") then by = by + dy bh = bh - dy end
|
||||
if id:find("s") then bh = bh + dy end
|
||||
return bx, by, math.max(4, bw), math.max(4, bh)
|
||||
end
|
||||
|
||||
function Studio.beginCanvasDrag(mx, my, r)
|
||||
local page = Studio.page()
|
||||
if not page then return end
|
||||
|
||||
local ctl = Studio.selectedControl()
|
||||
if ctl then
|
||||
local bx, by, bw, bh = controlRect(page, ctl, r)
|
||||
for _, h in ipairs(handleRects(bx, by, bw, bh)) do
|
||||
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
|
||||
Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my,
|
||||
bx = bx, by = by, bw = bw, bh = bh }
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local vx, vy, vw, vh = viewportRect(page, r)
|
||||
if vx and not Studio.canvas().lockViewport then
|
||||
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
|
||||
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
|
||||
Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my,
|
||||
bx = vx, by = vy, bw = vw, bh = vh }
|
||||
Studio.selected = nil
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for i = #page.controls, 1, -1 do
|
||||
local c = page.controls[i]
|
||||
local bx, by, bw, bh = controlRect(page, c, r)
|
||||
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
|
||||
Studio.selected = i
|
||||
Studio.drag = { kind = "control-move", mx = mx, my = my,
|
||||
bx = bx, by = by, bw = bw, bh = bh }
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if vx and not Studio.canvas().lockViewport
|
||||
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
|
||||
Studio.selected = nil
|
||||
Studio.drag = { kind = "viewport-move", mx = mx, my = my,
|
||||
bx = vx, by = vy, bw = vw, bh = vh }
|
||||
return
|
||||
end
|
||||
|
||||
Studio.selected = nil
|
||||
end
|
||||
|
||||
function Studio.updateDrag(mx, my, r)
|
||||
local d = Studio.drag
|
||||
local page = Studio.page()
|
||||
if not d or not page then return end
|
||||
local dx, dy = mx - d.mx, my - d.my
|
||||
local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh
|
||||
|
||||
if d.kind == "control-move" or d.kind == "viewport-move" then
|
||||
bx, by = bx + dx, by + dy
|
||||
else
|
||||
bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy)
|
||||
end
|
||||
|
||||
if d.kind:find("viewport") and Studio.aspectLock and d.handle then
|
||||
-- Derive the axis the handle does not drive, or an edge handle fights the
|
||||
-- lock and appears dead. A corner drives whichever way the pointer moved
|
||||
-- furthest, so dragging it up/down resizes as readily as left/right.
|
||||
local byHeight = (d.handle == "n" or d.handle == "s")
|
||||
if not byHeight and #d.handle > 1 then
|
||||
byHeight = math.abs(dy) > math.abs(dx)
|
||||
end
|
||||
if byHeight then
|
||||
local newW = bh * GB_ASPECT
|
||||
if d.handle:find("w") then bx = bx + (bw - newW)
|
||||
elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end
|
||||
bw = newW
|
||||
else
|
||||
local newH = bw / GB_ASPECT
|
||||
if d.handle:find("n") then by = by + (bh - newH)
|
||||
elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end
|
||||
bh = newH
|
||||
end
|
||||
end
|
||||
|
||||
if d.kind:find("control") then
|
||||
local ctl = Studio.selectedControl()
|
||||
if not ctl then return end
|
||||
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w)
|
||||
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h)
|
||||
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w)
|
||||
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h)
|
||||
else
|
||||
page.viewport = {
|
||||
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h),
|
||||
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h),
|
||||
}
|
||||
end
|
||||
markDirty()
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- draw
|
||||
|
||||
local function drawCanvas(x, y, w, h)
|
||||
local page = Studio.page()
|
||||
local r = { }
|
||||
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
|
||||
Studio.lastCanvas = r
|
||||
if not page then return r end
|
||||
|
||||
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
|
||||
|
||||
TouchSkin.setSurface(r.x, r.y, r.w, r.h)
|
||||
syncActive()
|
||||
|
||||
local vx, vy, vw, vh = viewportRect(page, r)
|
||||
if vx then
|
||||
local scale = math.min(vw / 160, vh / 144)
|
||||
local gw, gh = 160 * scale, 144 * scale
|
||||
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
|
||||
Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1)
|
||||
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
|
||||
Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5,
|
||||
gw, { 20, 40, 20 })
|
||||
end
|
||||
|
||||
TouchControls:draw()
|
||||
TouchSkin.setSurface(nil)
|
||||
|
||||
Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2)
|
||||
|
||||
if Studio.testing then return r end
|
||||
|
||||
if vx then
|
||||
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
|
||||
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
|
||||
if not Studio.canvas().lockViewport then
|
||||
local a = Studio.selectedControl() and 0.45 or 1
|
||||
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
|
||||
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for i, ctl in ipairs(page.controls) do
|
||||
local bx, by, bw, bh = controlRect(page, ctl, r)
|
||||
local selected = (i == Studio.selected)
|
||||
local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line)
|
||||
Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45,
|
||||
selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2)
|
||||
if selected then
|
||||
for _, hd in ipairs(handleRects(bx, by, bw, bh)) do
|
||||
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1)
|
||||
end
|
||||
Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale,
|
||||
PAL.green)
|
||||
end
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
local function inspectorBody(x, y, w)
|
||||
if not Studio.skin then return y end
|
||||
local page = Studio.page()
|
||||
local ctl = Studio.selectedControl()
|
||||
local rowH = math.max(Kit.tapMin(), 30 * Kit.scale)
|
||||
local gap = 6 * Kit.scale
|
||||
local cy = y
|
||||
|
||||
Kit.caption(x, cy, "SKIN")
|
||||
cy = cy + Kit.textHeight("small") + gap
|
||||
Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH,
|
||||
Studio.skinIdField, "skin name")
|
||||
cy = cy + rowH + gap
|
||||
|
||||
local third = (w - gap * 2) / 3
|
||||
if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end
|
||||
if Kit.button(x + third + gap, cy, third, rowH, "Export", { id = "export" }) then
|
||||
Studio.export()
|
||||
end
|
||||
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then
|
||||
Studio.play()
|
||||
end
|
||||
cy = cy + rowH + gap * 2
|
||||
|
||||
Kit.caption(x, cy, "OPEN")
|
||||
cy = cy + Kit.textHeight("small") + gap
|
||||
local half = (w - gap) / 2
|
||||
if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then
|
||||
Studio.skin = TouchSkin.newSkin("new_skin")
|
||||
Studio.skinIdField = "new_skin"
|
||||
Studio.pageIndex, Studio.selected = 1, nil
|
||||
Studio.images = {}
|
||||
syncActive()
|
||||
markDirty()
|
||||
end
|
||||
if Kit.button(x + half + gap, cy, half, rowH,
|
||||
"Load " .. (#Studio.available > 0 and "\226\150\184" or "-"),
|
||||
{ id = "load", enabled = #Studio.available > 0 }) then
|
||||
Studio.loadIndex = ((Studio.loadIndex or 0) % #Studio.available) + 1
|
||||
Studio.open(Studio.available[Studio.loadIndex].id)
|
||||
end
|
||||
cy = cy + rowH + gap * 2
|
||||
|
||||
Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {}))
|
||||
cy = cy + Kit.textHeight("small") + gap
|
||||
if Kit.button(x, cy, half, rowH, "Next page", { id = "pagenext" }) then
|
||||
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
|
||||
Studio.selected = nil
|
||||
syncActive()
|
||||
end
|
||||
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
|
||||
Studio.addPage()
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
|
||||
if page then
|
||||
local bezel = page.imagePath or "(none)"
|
||||
if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
|
||||
Studio.imageTarget = "bezel"
|
||||
Studio.cycleImage(1)
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
|
||||
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
|
||||
enabled = not Studio.canvas().lockViewport }) then
|
||||
Studio.toggleViewport()
|
||||
end
|
||||
Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH,
|
||||
Studio.aspectLock, "10:9 lock", "aspect")
|
||||
cy = cy + rowH + gap
|
||||
if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect",
|
||||
enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then
|
||||
Studio.detectViewport()
|
||||
end
|
||||
cy = cy + rowH + gap * 2
|
||||
end
|
||||
|
||||
Kit.caption(x, cy, "CONTROLS")
|
||||
cy = cy + Kit.textHeight("small") + gap
|
||||
if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end
|
||||
if Kit.button(x + third + gap, cy, third, rowH, "Dup",
|
||||
{ id = "dup", enabled = ctl ~= nil }) then
|
||||
Studio.duplicateControl()
|
||||
end
|
||||
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del",
|
||||
{ id = "del", enabled = ctl ~= nil }) then
|
||||
Studio.deleteControl()
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
|
||||
if not ctl then
|
||||
Kit.textWrapped("small", page and #page.controls == 0
|
||||
and "No controls yet. Add one, then drag it on the canvas."
|
||||
or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3)
|
||||
return cy + Kit.textHeight("small") * 3
|
||||
end
|
||||
|
||||
local canvas = Studio.canvas()
|
||||
if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then
|
||||
Studio.cycleBind(1)
|
||||
end
|
||||
cy = cy + rowH + 2 * Kit.scale
|
||||
Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + gap
|
||||
|
||||
if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then
|
||||
ctl.shape = ctl.shape == "radial" and "rect" or "radial"
|
||||
markDirty()
|
||||
end
|
||||
if Kit.button(x + half + gap, cy, half, rowH,
|
||||
string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then
|
||||
ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25)
|
||||
markDirty()
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
|
||||
local quarter = (w - gap * 3) / 4
|
||||
local fields = {
|
||||
{ "X", round((ctl.x - ctl.rangeX) * canvas.w) },
|
||||
{ "Y", round((ctl.y - ctl.rangeY) * canvas.h) },
|
||||
{ "W", round(ctl.rangeX * 2 * canvas.w) },
|
||||
{ "H", round(ctl.rangeY * 2 * canvas.h) },
|
||||
}
|
||||
for i, f in ipairs(fields) do
|
||||
local fx = x + (quarter + gap) * (i - 1)
|
||||
Kit.text("small", f[1], fx, cy, PAL.faint)
|
||||
local id = "num" .. f[1]
|
||||
local shown = Studio.editing == id and Studio.editBuf or tostring(f[2])
|
||||
local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"),
|
||||
quarter, rowH, shown, "0")
|
||||
if Kit.focus == id then
|
||||
Studio.editing, Studio.editBuf = id, typed
|
||||
elseif Studio.editing == id then
|
||||
Studio.commitField(id, Studio.editBuf)
|
||||
Studio.editing, Studio.editBuf = nil, nil
|
||||
end
|
||||
end
|
||||
cy = cy + Kit.textHeight("small") + rowH + gap
|
||||
Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint)
|
||||
cy = cy + Kit.textHeight("small") + gap
|
||||
|
||||
local idle = ctl.imagePath or "(none)"
|
||||
if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then
|
||||
Studio.imageTarget = "idle"
|
||||
Studio.cycleImage(1)
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
local pressed = ctl.pressedImagePath or "(none)"
|
||||
if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then
|
||||
Studio.imageTarget = "pressed"
|
||||
Studio.cycleImage(1)
|
||||
end
|
||||
return cy + rowH
|
||||
end
|
||||
|
||||
local function drawInspector(x, y, w, h)
|
||||
local pad = 10 * Kit.scale
|
||||
Kit.card(x, y, w, h)
|
||||
|
||||
local scroll = Studio.inspectorScroll or 0
|
||||
if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then
|
||||
scroll = scroll - Kit.wheelY * 48 * Kit.scale
|
||||
end
|
||||
local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2))
|
||||
scroll = math.max(0, math.min(scroll, maxScroll))
|
||||
Studio.inspectorScroll = scroll
|
||||
|
||||
Kit.pushClip(x, y, w, h)
|
||||
local top = y + pad - scroll
|
||||
local endY = inspectorBody(x + pad, top, w - pad * 2) or top
|
||||
Studio.inspectorH = endY - top
|
||||
Kit.popClip()
|
||||
|
||||
if maxScroll > 0 then
|
||||
local frac = h / (h + maxScroll)
|
||||
local barH = math.max(24 * Kit.scale, h * frac)
|
||||
local barY = y + (h - barH) * (scroll / maxScroll)
|
||||
Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4)
|
||||
end
|
||||
end
|
||||
|
||||
function Studio.commitField(id, text)
|
||||
local ctl = Studio.selectedControl()
|
||||
if not ctl then return end
|
||||
local n = tonumber(text)
|
||||
if not n then return end
|
||||
local canvas = Studio.canvas()
|
||||
local left = (ctl.x - ctl.rangeX) * canvas.w
|
||||
local top = (ctl.y - ctl.rangeY) * canvas.h
|
||||
local wpx = ctl.rangeX * 2 * canvas.w
|
||||
local hpx = ctl.rangeY * 2 * canvas.h
|
||||
if id == "numX" then left = n
|
||||
elseif id == "numY" then top = n
|
||||
elseif id == "numW" then wpx = math.max(1, n)
|
||||
elseif id == "numH" then hpx = math.max(1, n) end
|
||||
ctl.rangeX = (wpx * 0.5) / canvas.w
|
||||
ctl.rangeY = (hpx * 0.5) / canvas.h
|
||||
ctl.x = clamp01((left + wpx * 0.5) / canvas.w)
|
||||
ctl.y = clamp01((top + hpx * 0.5) / canvas.h)
|
||||
markDirty()
|
||||
end
|
||||
|
||||
function Studio.draw()
|
||||
local W, H = love.graphics.getDimensions()
|
||||
Kit.layout(W, H)
|
||||
local mx, my = love.mouse.getPosition()
|
||||
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
|
||||
Studio.clicked, Studio.wheel = false, 0
|
||||
|
||||
Theme.fill(0, 0, W, H, PAL.bg, 1)
|
||||
|
||||
local pad = 14 * Kit.scale
|
||||
local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad
|
||||
|
||||
Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading)
|
||||
local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2
|
||||
|
||||
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
|
||||
local bx = titleW
|
||||
local canvas = Studio.canvas()
|
||||
if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH,
|
||||
canvas.label, { id = "canvas" }) then
|
||||
Studio.setCanvas(Studio.canvasIndex + 1)
|
||||
end
|
||||
bx = bx + 208 * Kit.scale
|
||||
if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH,
|
||||
Studio.testing and "Test: ON" or "Test: OFF",
|
||||
{ id = "test", active = Studio.testing }) then
|
||||
Studio.testing = not Studio.testing
|
||||
TouchControls:setPreview(not Studio.testing)
|
||||
TouchControls:reset()
|
||||
end
|
||||
bx = bx + 118 * Kit.scale
|
||||
|
||||
if Studio.dirty then
|
||||
Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow)
|
||||
end
|
||||
|
||||
local closeW = 100 * Kit.scale
|
||||
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
|
||||
{ id = "close" }) then
|
||||
if Studio.onClose then Studio.onClose() end
|
||||
Kit.endFrame()
|
||||
return
|
||||
end
|
||||
|
||||
local panelW = math.min(360 * Kit.scale, W * 0.34)
|
||||
local bodyY = barH + pad * 0.5
|
||||
local bodyH = H - bodyY - pad
|
||||
|
||||
drawInspector(pad, bodyY, panelW, bodyH)
|
||||
|
||||
local cx = pad * 2 + panelW
|
||||
local cw = W - cx - pad
|
||||
local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale)
|
||||
|
||||
local footY = bodyY + bodyH - 30 * Kit.scale
|
||||
local msg = Studio.status
|
||||
if not msg and Studio.testing then
|
||||
local held = {}
|
||||
for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end
|
||||
table.sort(held)
|
||||
msg = "TEST: click the pad. Held: "
|
||||
.. (held[1] and table.concat(held, ", ") or "(none)")
|
||||
end
|
||||
if not msg then
|
||||
msg = "Drag to move, corner handles to resize, blue box is the game screen."
|
||||
end
|
||||
Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, PAL.detail)
|
||||
|
||||
Kit.endFrame()
|
||||
Studio.canvasArea = r
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- input
|
||||
|
||||
function Studio.update()
|
||||
if not Studio.pendingPlay then return end
|
||||
Studio.pendingPlay = false
|
||||
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
|
||||
if onPlay then onPlay(version, canvas) end
|
||||
end
|
||||
|
||||
function Studio.mousepressed(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
Studio.clicked = true
|
||||
local r = Studio.lastCanvas
|
||||
if not r then return end
|
||||
if Studio.testing then
|
||||
if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then
|
||||
TouchControls:touchpressed("studio", x, y)
|
||||
end
|
||||
return
|
||||
end
|
||||
local slop = HANDLE * 2 * Kit.scale
|
||||
if x < r.x - slop or x > r.x + r.w + slop
|
||||
or y < r.y - slop or y > r.y + r.h + slop then
|
||||
return
|
||||
end
|
||||
Studio.beginCanvasDrag(x, y, r)
|
||||
end
|
||||
|
||||
function Studio.mousemoved(x, y)
|
||||
if Studio.testing then
|
||||
TouchControls:touchmoved("studio", x, y)
|
||||
return
|
||||
end
|
||||
if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then
|
||||
Studio.updateDrag(x, y, Studio.lastCanvas)
|
||||
end
|
||||
end
|
||||
|
||||
function Studio.mousereleased(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
if Studio.testing then
|
||||
TouchControls:touchreleased("studio", x, y)
|
||||
return
|
||||
end
|
||||
Studio.drag = nil
|
||||
end
|
||||
|
||||
function Studio.wheelmoved(_, dy)
|
||||
Studio.wheel = dy
|
||||
end
|
||||
|
||||
function Studio.textinput(text)
|
||||
Kit.textinput(text)
|
||||
end
|
||||
|
||||
function Studio.keypressed(key)
|
||||
if Kit.focus then
|
||||
Kit.keypressed(key)
|
||||
return
|
||||
end
|
||||
if key == "escape" then
|
||||
if Studio.onClose then Studio.onClose() end
|
||||
elseif key == "delete" or key == "backspace" then
|
||||
Studio.deleteControl()
|
||||
elseif key == "n" then
|
||||
Studio.addControl()
|
||||
elseif key == "d" then
|
||||
Studio.duplicateControl()
|
||||
elseif key == "t" then
|
||||
Studio.testing = not Studio.testing
|
||||
TouchControls:setPreview(not Studio.testing)
|
||||
TouchControls:reset()
|
||||
elseif key == "s" then
|
||||
Studio.save()
|
||||
elseif key == "tab" then
|
||||
local page = Studio.page()
|
||||
if page and #page.controls > 0 then
|
||||
Studio.selected = ((Studio.selected or 0) % #page.controls) + 1
|
||||
end
|
||||
else
|
||||
Kit.keypressed(key)
|
||||
end
|
||||
end
|
||||
|
||||
function Studio.available_desktop()
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
end
|
||||
|
||||
return Studio
|
||||
+123
-11
@@ -18,6 +18,7 @@
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local PadCursor = require("src.ui.PadCursor")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
|
||||
@@ -46,6 +47,15 @@ local function roundRect(mode, x, y, w, h, r)
|
||||
love.graphics.rectangle(mode, x, y, w, h, r, r)
|
||||
end
|
||||
|
||||
local function fitText(font, text, maxW)
|
||||
text = tostring(text)
|
||||
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
|
||||
while #text > 1 and font:getWidth(text .. "...") > maxW do
|
||||
text = text:sub(1, #text - 1)
|
||||
end
|
||||
return text .. "..."
|
||||
end
|
||||
|
||||
function Editor.load(opts)
|
||||
opts = opts or {}
|
||||
Editor.onClose = opts.onClose
|
||||
@@ -74,9 +84,52 @@ function Editor.load(opts)
|
||||
TouchControls:applyOptions(applied)
|
||||
TouchControls:setPreview(true)
|
||||
Editor.enabled = TouchControls.enabled ~= false
|
||||
Editor.skins = TouchSkin.list()
|
||||
Editor.exportMsg = nil
|
||||
PadCursor.reset()
|
||||
end
|
||||
|
||||
function Editor.skinIndex()
|
||||
for i, entry in ipairs(Editor.skins or {}) do
|
||||
if entry.id == TouchControls.skinId then return i + 1 end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function Editor.skinLabel()
|
||||
local entry = (Editor.skins or {})[Editor.skinIndex() - 1]
|
||||
if not entry then return "Built-in pad" end
|
||||
local skin = TouchSkin.active
|
||||
local pages = skin and #skin.pages or 1
|
||||
if pages > 1 then return entry.id .. " (" .. pages .. " pages)" end
|
||||
return entry.id
|
||||
end
|
||||
|
||||
function Editor.cycleSkin(dir)
|
||||
local n = #(Editor.skins or {}) + 1
|
||||
local idx = ((Editor.skinIndex() - 1 + (dir or 1)) % n) + 1
|
||||
local entry = Editor.skins[idx - 1]
|
||||
Editor.exportMsg = nil
|
||||
TouchControls:selectSkin(entry and entry.id or nil)
|
||||
TouchControls:ensureImages()
|
||||
end
|
||||
|
||||
function Editor.exportSkin()
|
||||
local skin = TouchSkin.active
|
||||
if not skin then return end
|
||||
local path, missing = TouchSkin.export(skin)
|
||||
if not path then
|
||||
Editor.exportMsg = "Export failed: " .. tostring(missing)
|
||||
return
|
||||
end
|
||||
Editor.exportMsg = "Exported to " .. path .. " in your save folder."
|
||||
if type(missing) == "table" and missing[1] then
|
||||
Editor.exportMsg = Editor.exportMsg
|
||||
.. " " .. #missing .. " image(s) were missing and left out."
|
||||
end
|
||||
Editor.skins = TouchSkin.list()
|
||||
end
|
||||
|
||||
function Editor.unload()
|
||||
TouchControls:setPreview(false)
|
||||
TouchControls:reset()
|
||||
@@ -93,6 +146,7 @@ local function persist()
|
||||
local cfg = TouchControls:config()
|
||||
local block = {
|
||||
enabled = cfg.enabled,
|
||||
skin = cfg.skin,
|
||||
layouts = cfg.layouts,
|
||||
}
|
||||
if Editor.version == "gold" then
|
||||
@@ -158,6 +212,8 @@ function Editor.draw()
|
||||
col(PAL.bgTop, 0.85)
|
||||
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
|
||||
if TouchSkin.active then TouchControls:draw() end
|
||||
|
||||
local pad = 18 * s
|
||||
local barH = 56 * s
|
||||
local btnH = 40 * s
|
||||
@@ -170,16 +226,18 @@ function Editor.draw()
|
||||
love.graphics.setLineWidth(1)
|
||||
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
|
||||
|
||||
-- Done / Reset
|
||||
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
||||
w = btnW, h = btnH }
|
||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||
Editor.rects.done, Editor.rects.reset = done, reset
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print(
|
||||
fitText(Editor.fonts.title, "Touch Controls", reset.x - 10 * s - (ox + pad)),
|
||||
ox + pad, oy + pad + 4 * s)
|
||||
|
||||
local function chromeBtn(r, label, fill)
|
||||
col(fill, 0.9)
|
||||
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
|
||||
@@ -223,9 +281,48 @@ function Editor.draw()
|
||||
chromeBtn(toggle, on and "Disable" or "Enable",
|
||||
on and PAL.red or PAL.green)
|
||||
|
||||
local skinY = cardY + cardH + 10 * s
|
||||
col(PAL.card, 0.88)
|
||||
roundRect("fill", cardX, skinY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
roundRect("line", cardX, skinY, cardW, cardH, 12 * s)
|
||||
|
||||
local stepW = 52 * s
|
||||
local skinNext = { x = cardX + cardW - 16 * s - stepW,
|
||||
y = skinY + (cardH - btnH) / 2, w = stepW, h = btnH }
|
||||
local skinPrev = { x = skinNext.x - 10 * s - stepW, y = skinNext.y,
|
||||
w = stepW, h = btnH }
|
||||
Editor.rects.skinNext, Editor.rects.skinPrev = skinNext, skinPrev
|
||||
|
||||
local leftmost = skinPrev.x
|
||||
if TouchSkin.active then
|
||||
local expW = 86 * s
|
||||
local export = { x = skinPrev.x - 10 * s - expW, y = skinPrev.y,
|
||||
w = expW, h = btnH }
|
||||
Editor.rects.export = export
|
||||
leftmost = export.x
|
||||
else
|
||||
Editor.rects.export = nil
|
||||
end
|
||||
|
||||
local skinTextW = leftmost - 10 * s - (cardX + 16 * s)
|
||||
love.graphics.setFont(Editor.fonts.body)
|
||||
col(PAL.label)
|
||||
love.graphics.print("Skin", cardX + 16 * s, skinY + 12 * s)
|
||||
love.graphics.setFont(Editor.fonts.btn)
|
||||
col(TouchControls.skinId and PAL.green or PAL.white)
|
||||
love.graphics.print(fitText(Editor.fonts.btn, Editor.skinLabel(), skinTextW),
|
||||
cardX + 16 * s, skinY + 34 * s)
|
||||
|
||||
chromeBtn(skinPrev, "<", { 60, 70, 110 })
|
||||
chromeBtn(skinNext, ">", { 60, 70, 110 })
|
||||
if Editor.rects.export then
|
||||
chromeBtn(Editor.rects.export, "Export", { 60, 70, 110 })
|
||||
end
|
||||
|
||||
-- size card (#633): -/+ resize every control in the orientation on
|
||||
-- screen; the heading names it so it is plain the other one is untouched
|
||||
local sizeY = cardY + cardH + 10 * s
|
||||
local sizeY = skinY + cardH + 10 * s
|
||||
col(PAL.card, 0.88)
|
||||
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
@@ -243,7 +340,6 @@ function Editor.draw()
|
||||
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
|
||||
cardX + 16 * s, sizeY + 34 * s)
|
||||
|
||||
local stepW = 52 * s
|
||||
local plus = { x = cardX + cardW - 16 * s - stepW,
|
||||
y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
|
||||
local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
|
||||
@@ -254,13 +350,19 @@ function Editor.draw()
|
||||
-- hint
|
||||
love.graphics.setFont(Editor.fonts.body)
|
||||
col(PAL.label, 0.9)
|
||||
local hint = on
|
||||
and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
|
||||
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||
local hint
|
||||
if Editor.exportMsg then
|
||||
hint = Editor.exportMsg
|
||||
elseif not on then
|
||||
hint = "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||
elseif TouchSkin.active then
|
||||
hint = "This skin owns its own art, hitboxes and screen position, so size and dragging are off. Drop a RetroArch overlay folder or .zip into the skins folder of your save directory to add more."
|
||||
else
|
||||
hint = "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
|
||||
end
|
||||
love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
|
||||
-- the overlay itself (preview mode; dimmed when disabled)
|
||||
TouchControls:draw()
|
||||
if not TouchSkin.active then TouchControls:draw() end
|
||||
|
||||
-- highlight the control under drag
|
||||
if Editor.drag then
|
||||
@@ -282,6 +384,9 @@ local function beginDrag(id, x, y)
|
||||
if inside(Editor.rects.done, x, y) then close(); return end
|
||||
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
|
||||
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
|
||||
if inside(Editor.rects.skinPrev, x, y) then Editor.cycleSkin(-1); return end
|
||||
if inside(Editor.rects.skinNext, x, y) then Editor.cycleSkin(1); return end
|
||||
if inside(Editor.rects.export, x, y) then Editor.exportSkin(); return end
|
||||
if inside(Editor.rects.sizeDown, x, y) then
|
||||
TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
|
||||
end
|
||||
@@ -289,6 +394,7 @@ local function beginDrag(id, x, y)
|
||||
TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
|
||||
end
|
||||
|
||||
if TouchSkin.active then return end
|
||||
local name = TouchControls:hitTest(x, y)
|
||||
if not name then return end
|
||||
local zone = TouchControls:layout()[name]
|
||||
@@ -439,6 +545,12 @@ function Editor.keypressed(key)
|
||||
close()
|
||||
elseif key == "r" then
|
||||
resetLayout()
|
||||
elseif key == "[" then
|
||||
Editor.cycleSkin(-1)
|
||||
elseif key == "]" then
|
||||
Editor.cycleSkin(1)
|
||||
elseif key == "e" then
|
||||
Editor.exportSkin()
|
||||
-- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
|
||||
elseif key == "-" or key == "kp-" then
|
||||
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
|
||||
|
||||
Reference in New Issue
Block a user