skins fixerino

This commit is contained in:
bryanthaboi
2026-08-21 14:03:25 -04:00
parent 5c1837b1eb
commit b0ff1552f2
23 changed files with 1065 additions and 127 deletions
+5 -1
View File
@@ -1955,7 +1955,11 @@ function Game2:applyOptions()
local options = self.options or {}
Music.applyOptions(options)
require("src.core.Sound").applyOptions(options)
require("src.render.Zoom").applyOptions(options)
local Zoom = require("src.render.Zoom")
Zoom.applyOptions(options)
local caps = require("src.core.Performance").applyOptions(options)
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
require("src.render.Tilt").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
+5 -2
View File
@@ -86,8 +86,11 @@ function Performance.detect()
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
-- already ships a build for): the weakest target here. Desktop ARM
-- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those
-- used to resolve AUTO → LOW, which stripped survey zoom-out from
-- OPTIONS so the ZOOM row only offered IN.
if isArm and os == "Linux" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
+5
View File
@@ -12,10 +12,12 @@ function ScreenPosition.normalize(v)
end
function ScreenPosition.label(v)
if ScreenPosition.skinActive() then return "SKIN" end
return LABELS[ScreenPosition.normalize(v)]
end
function ScreenPosition.cycle(v, dir)
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
v = ScreenPosition.normalize(v)
local modes = ScreenPosition.MODES
local cur = 1
@@ -44,6 +46,9 @@ end
function ScreenPosition.skinActive(w, h)
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
w, h = love.graphics.getDimensions()
end
local okv, x = pcall(TouchSkin.viewport, w, h)
return okv and x ~= nil
end
+18 -6
View File
@@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts)
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active)
self:selectSkin(cfg.skin)
-- Off means off everywhere: do not leave a hidden selected skin behind to
-- influence renderer placement on desktop or with a controller attached.
self:selectSkin(cfg.enabled and cfg.skin or nil)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -335,7 +337,10 @@ function TouchControls:visible()
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
-- A selected skin is also a desktop/TV bezel. Input remains gated in
-- touchpressed, but the artwork must not disappear when a controller is
-- connected or the platform is not touch-first.
if TouchSkin.active then return true end
return self.active and not self.controllerHidden
end
@@ -778,12 +783,19 @@ 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)
local function drawCovered(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
-- Cover the assigned box with one uniform scale and crop the excess. The
-- old independent X/Y scale made portrait art visibly squash on wide
-- displays (and vice versa).
local s = math.max(w / iw, h / ih)
local dw, dh = iw * s, ih * s
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
love.graphics.setScissor(x, y, w, h)
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
love.graphics.setScissor()
end
function TouchControls:drawSkin(alphaMul)
@@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul)
love.graphics.push("all")
love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity)
drawCovered(page.image, bx, by, bw, bh, opacity)
local pressed = {}
for _, touch in pairs(self.touches or {}) do
@@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul)
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)
drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
end
end
+56 -3
View File
@@ -636,6 +636,19 @@ local function applyPixelScale(page)
return true
end
-- An overlay image is its own design canvas. Older RetroArch cfg files often
-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
-- all of its normalized controls on the same uniform scale.
function TouchSkin.applyImageAspect(page)
if not page or page.aspectFromCfg or not page.image
or not page.image.getDimensions then return false end
local iw, ih = page.image:getDimensions()
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
page.aspect = iw / ih
page.aspectFromImage = true
return true
end
function TouchSkin.load(root, id)
local cfgPath, format, prefix = findConfig(root)
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
@@ -665,6 +678,7 @@ function TouchSkin.load(root, id)
elseif page.pdfPath then
rasterizePdfPage(page, root)
end
TouchSkin.applyImageAspect(page)
if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath)
.. ", which " .. page.name .. " measures its coordinates against"
@@ -771,6 +785,28 @@ function TouchSkin.find(id)
return nil
end
-- Remove only a user-installed skin. Bundled skins are shipped with the
-- game and intentionally have no delete affordance.
function TouchSkin.remove(id)
local entry = TouchSkin.find(id)
if not entry then return nil, "no skin " .. tostring(id) end
if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
if not (love and love.filesystem and love.filesystem.remove) then
return nil, "no writable filesystem"
end
local function removeTree(path)
if isDir(path) and love.filesystem.getDirectoryItems then
for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
local ok, err = removeTree(path .. "/" .. name)
if not ok then return nil, err end
end
end
local ok, err = love.filesystem.remove(path)
return ok and true or nil, err
end
return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
end
function TouchSkin.assetPaths(skin)
local out, seen = {}, {}
local function add(rel)
@@ -1284,7 +1320,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
-- full_screen means "relative to the window, not the game viewport".
-- When the cfg also names an aspect_ratio, that window is then fitted
-- to the overlay's design aspect so buttons do not stretch. #1503
local fit = ((not page.fullScreen) or page.aspectFromCfg)
local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
and page.aspect and page.aspect > 0 and h > 0
if fit then
local displayAspect = w / h
@@ -1306,6 +1342,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
by = oy + extra
elseif anchor == "top" then
by = oy
elseif page.aspect < 1 then
-- A portrait bezel with controls is a controller deck. On an
-- unusually tall display, pin the deck to the lower edge and leave
-- the additional room for the game above it.
by = oy + extra
else
by = oy + extra * 0.5
end
@@ -1359,8 +1400,10 @@ function TouchSkin.decorativeOnly()
end
function TouchSkin.drawable()
if not TouchSkin.active then return false end
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
-- A selected skin is a presentation choice, not a mobile-only input mode.
-- Its artwork and screen placement therefore belong on every platform;
-- `overlayLive` still controls whether touch input is available.
return TouchSkin.active ~= nil
end
function TouchSkin.hasViewport()
@@ -1410,6 +1453,16 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
return nil
end
-- Centre of the page's screen cutout. The renderer fits the 160x144
-- picture into that rect; this helper is for the studio preview.
function TouchSkin.screenCenter(w, h, ox, oy, page)
page = page or TouchSkin.page()
if not page then return nil end
local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
if not x then return nil end
return x + vw * 0.5, y + vh * 0.5
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not TouchSkin.drawable() then return nil end
+22 -2
View File
@@ -6,8 +6,8 @@
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
-- next boot's applyOptions pick the values up. Every ladder mirrors
-- OptionsMenu's semantics and stored values; when editing one, keep the two
-- in sync. ZOOM is deliberately absent: its range depends on the live
-- renderer's fit scale (Renderer:fitScale), which does not exist here.
-- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as
-- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game.
--
-- Rows are the same descriptor idiom OptionRows draws in game:
-- { label, value = fn() -> string, step = fn(dir) -> changed,
@@ -278,6 +278,16 @@ local function coreRows(opts, hooks)
end)
end
local okZ, Zoom = pcall(require, "src.render.Zoom")
if okZ then
add(Strings("ZOOM"),
function() return Zoom.offsetLabel(opts.zoom or 0) end,
function(dir)
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
return true
end)
end
local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
if okTile and TileRenderer.VOID_FILLS then
add(Strings("VOID FILL"),
@@ -643,6 +653,16 @@ local function gen2Rows(opts, hooks)
end)
end
local okZ, Zoom = pcall(require, "src.render.Zoom")
if okZ then
add(Strings("ZOOM"),
function() return Zoom.offsetLabel(opts.zoom or 0) end,
function(dir)
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
return true
end)
end
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
if okFill and BorderFill.VOID_FILLS then
add(Strings("VOID FILL"),
+137 -11
View File
@@ -753,12 +753,15 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
-- and one below the top-right corner notch, like the DMG cart.
local grooveW = w * 0.115
local grooveH = math.max(1, h * 0.009)
local grooveScale = { 1.22, 1.10, 1.00, 1.00, 1.10, 1.22 }
local grooveInset = w * 0.02
for i = 0, 5 do
local ry = mainTop + h * 0.014 + i * h * 0.021
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry,
grooveW, grooveH, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry,
grooveW, grooveH, faceZ), side, 0.7)
local gw = grooveW * grooveScale[i + 1]
cartPolygon(cartQuad(project, -halfW + grooveInset, ry,
gw, grooveH, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry,
gw, grooveH, faceZ), side, 0.7)
end
-- The thin diagonal mold ridge cut into each long side a little below
-- the grip grooves, mirrored left/right.
@@ -774,12 +777,14 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
}, side, 0.7)
end
local dgY = mainTop + h * 0.20
local dgY = mainTop + h * 0.25
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
-- The Nintendo GAME BOY recess: one stadium pill sunk into the shell.
local pillX, pillW = -halfW + w * 0.17, w * 0.62
local pillY, pillH = mainTop + h * 0.024, h * 0.115
-- The pill recess: one stadium pill sunk into the shell.
local pillX, pillW = -halfW + w * 0.19, w * 0.62
local pillY, pillH = mainTop + h * 0.015, h * 0.115
-- log out pillH
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
local inX, inY = w * 0.008, h * 0.008
cartPill(project, pillX + inX, pillY + inY,
@@ -2272,8 +2277,8 @@ 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)
-- SKINS tab: pick the on-screen skin, import one, or open Skin Studio.
local function buildSkinsPanelLegacy(imp, x, y, w, availH, m)
local skins = imp:_ensureSkins()
local active = imp:_activeSkin()
local gap = m.gap
@@ -2327,7 +2332,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
end
cy = cy + urlH + math.floor(8 * m.s)
-- Studio button. Desktop only: the host supplies the hook nowhere else.
-- The Studio reflows to a touch-first canvas plus inspector on phones.
if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
@@ -2347,6 +2352,64 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
Kit.caption(x, cy, Strings("INSTALLED"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
-- Keep the actionable list below for detailed metadata and exports, but
-- lead with a visual picker: skins are much easier to recognize by their
-- bezel than by a folder name. Cards use the same loaded art that the
-- runtime draws, so they cannot drift from the selected skin.
local previewGap = math.floor(8 * m.s)
local previewCols = w >= math.floor(420 * m.s) and 2 or 1
local previewW = (w - previewGap * (previewCols - 1)) / previewCols
local previewH = math.max(108 * m.s, Kit.tapMin() * 2)
local previewCount = #skins
for i, entry in ipairs(skins) do
local n = i - 1
local px = x + (n % previewCols) * (previewW + previewGap)
local py = cy + math.floor(n / previewCols) * (previewH + previewGap)
local key = "skin-preview-" .. entry.id
local selected = active == entry.id
local focused = Kit.focusable(key, px, py, previewW, previewH)
Kit.card(px, py, previewW, previewH, selected and "selected"
or (focused or Kit.hover(px, py, previewW, previewH)))
local pad = math.floor(8 * m.s)
local artH = math.floor(previewH * 0.60)
Theme.fillRounded(px + pad, py + pad, previewW - pad * 2, artH,
PAL.bg, 1, Theme.cardRadius() * 0.6)
local art = entry.preview
if art and art.getDimensions then
local iw, ih = art:getDimensions()
if iw > 0 and ih > 0 then
local scale = math.min((previewW - pad * 4) / iw, (artH - pad * 2) / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(art, px + (previewW - iw * scale) * 0.5,
py + pad + (artH - ih * scale) * 0.5, 0, scale, scale)
end
else
Theme.strokeRounded(px + previewW * 0.23, py + pad + artH * 0.15,
previewW * 0.54, artH * 0.42, PAL.line, Theme.A.hairline, 1, 2)
Theme.fillRounded(px + previewW * 0.20, py + pad + artH * 0.64,
previewW * 0.22, artH * 0.17, PAL.steel, 0.75, 3)
Theme.fillRounded(px + previewW * 0.60, py + pad + artH * 0.61,
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
Theme.fillRounded(px + previewW * 0.75, py + pad + artH * 0.56,
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
end
Kit.text("mono", Kit.ellipsize("mono", entry.id, previewW - pad * 2),
px + pad, py + pad + artH + math.floor(5 * m.s),
selected and PAL.green or PAL.heading)
if selected then
Kit.text("micro", Strings("IN USE"), px + pad,
py + previewH - pad - Kit.textHeight("micro"), PAL.green)
end
if Kit.press(px, py, previewW, previewH) or Kit._activateId == key then
queueAction(imp, key, function() imp:_useSkin(entry.id) end)
end
end
if previewCount > 0 then
cy = cy + math.ceil(previewCount / previewCols) * previewH
+ math.max(0, math.ceil(previewCount / previewCols) - 1) * previewGap
+ gap
end
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
imp._skinGear = imp._skinGear
or love.graphics.newImage("assets/launcher/gear.png")
@@ -2460,6 +2523,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
return cy + hintH - y
end
-- The launcher is the short path: bring a skin in, see what is enabled, or
-- turn skin use off. Browsing, pagination and per-skin editing/export live
-- together in My Skins, where they are useful instead of competing here.
local function buildSkinsPanel(imp, x, y, w, availH, m)
local cy, gap, bh = y, m.gap, m.btnH
local active = imp:_activeSkin()
Kit.text("button", Strings("Skins"), x, cy, PAL.heading)
local importW = math.min(w * 0.46,
Kit.textWidth("small", imp:_skinsImportButtonLabel()) + math.floor(24 * m.s))
btn(imp, x + w - importW, cy, importW, bh, "skins-import",
imp:_skinsImportButtonLabel(), { kind = "accent", font = "small",
action = function() imp:chooseSkin() end })
cy = cy + bh + gap
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) + gap
end
local addW = Kit.textWidth("small", Strings("Add")) + math.floor(24 * m.s)
if imp._skinFetch then
Loader.inline(x, cy, w, bh, Strings("Downloading %s...",
tostring(imp._skinFetch.name or "")))
else
btn(imp, x + w - addW, cy, addW, bh, "skins-url-add", Strings("Add"), {
kind = "accent", font = "small", action = function() imp:_addSkinFromUrl() end })
textField(imp, x, cy, w - addW - gap, bh, "skins-url", imp.skinUrl or "",
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
imp._skinUrlFocus == true, function() imp:_toggleSkinUrlFocus() end)
end
cy = cy + bh + gap
local currentH = active and (bh * 2 + gap * 2) or (bh + gap * 2)
Kit.card(x, cy, w, currentH)
Kit.caption(x + gap, cy + gap, "CURRENT SKIN")
local current = active and tostring(active) or Strings("No skin enabled")
Kit.text("mono", Kit.ellipsize("mono", current, w - gap * 2), x + gap,
cy + gap + Kit.textHeight("small") + math.floor(4 * m.s),
active and PAL.green or PAL.muted)
local buttonY = cy + bh + gap
local half = (w - gap * 3) * 0.5
if active then
btn(imp, x + gap, buttonY, half, bh, "skins-export-current",
Strings("Export current"), { font = "small",
action = function() imp:_exportSkin(active, "native") end })
btn(imp, x + gap * 2 + half, buttonY, half, bh, "skins-off",
Strings("Turn skins off"), { kind = "danger", font = "small",
action = function() imp:_disableSkins() end })
end
cy = cy + currentH + gap
if imp.onOpenSkinStudio then
btn(imp, x, cy, w, bh, "skins-my-skins", Strings("My Skins"), {
kind = "accent", font = "small",
action = function() imp.onOpenSkinStudio(imp.modScope or "red", active) end })
cy = cy + bh + gap
end
Kit.textWrapped("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."),
x, cy, w, PAL.muted, 3)
return cy + Kit.wrapHeight("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), w, 3) - y
end
local function buildBugPanel(imp, x, y, w, availH, m)
local SaveData = require("src.core.SaveData")
local gap = m.gap
+15 -1
View File
@@ -3197,6 +3197,10 @@ function RomImporter:_ensureSkins(force)
format = skin and skin.format or nil,
pages = skin and #skin.pages or 0,
controls = controls,
-- The launcher owns the visual skin picker, so retain the already
-- loaded first-page bezel for its preview card instead of decoding it
-- again every frame.
preview = page and page.image or nil,
screen = page ~= nil
and (page.viewport ~= nil or page.screenFit == "remainder"),
ok = skin ~= nil,
@@ -3209,7 +3213,7 @@ end
function RomImporter:_activeSkin()
local opts = require("src.core.SaveData").loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
return tc.skin
return tc.enabled == false and nil or tc.skin
end
function RomImporter:_useSkin(id)
@@ -3226,6 +3230,16 @@ function RomImporter:_useSkin(id)
}
end
function RomImporter:_disableSkins()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled, tc.skin = false, nil
opts.touchControls = tc
SaveData.saveOptions(opts)
self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." }
end
function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end
self.tab = "skins"
+33 -1
View File
@@ -19,6 +19,8 @@ Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
-- levels; keep three survey steps so OPTIONS always has zoom-out.
function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1))
local lo, hi = 1 - S, S
@@ -30,7 +32,11 @@ function Zoom.offsetRange(S)
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
if Zoom.allowSurvey == false then
if lo < 0 then lo = 0 end
elseif lo > -3 then
lo = -3
end
return lo, hi
end
@@ -44,6 +50,8 @@ function Zoom.scale(S)
local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end
if s > maxScale then s = maxScale end
-- Integer offset below 1px/world (OPTIONS OUT on a 1× window): 1/2, 1/4, …
if s < 1 then s = 0.5 ^ (1 - s) end
if s < 0.25 then s = 0.25 end
return s
end
@@ -79,6 +87,30 @@ function Zoom.applyOptions(opts)
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
end
-- Integer fit used when OPTIONS has no live renderer (launcher, title).
function Zoom.windowFitScale()
if love and love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
ww, wh = tonumber(ww) or 0, tonumber(wh) or 0
if ww >= 160 and wh >= 144 then
return math.max(1, math.floor(math.min(ww / 160, wh / 144)))
end
end
return 1
end
-- One step of the OPTIONS ZOOM row (dir +1 in, -1 out). Shared by Red
-- and Gold so both ladders offer OUT / FIT / IN.
function Zoom.nudgeOptions(options, dir, S)
S = math.max(1, math.floor(tonumber(S) or Zoom.windowFitScale()))
local lo, hi = Zoom.offsetRange(S)
local off = math.floor(tonumber(options and options.zoom) or 0) + (dir or 1)
if off > hi then off = lo elseif off < lo then off = hi end
if options then options.zoom = off end
Zoom.offset = off
return off
end
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
function Zoom.offsetLabel(offset)
offset = math.floor(tonumber(offset) or 0)
+1 -8
View File
@@ -381,14 +381,7 @@ local function buildRows(game)
return Zoom.offsetLabel(g.save.options.zoom or 0)
end,
step = function(g, dir)
local o = g.save.options
local S = Renderer:fitScale()
local lo, hi = Zoom.offsetRange(S)
local off = (o.zoom or 0) + dir
if off > hi then off = lo
elseif off < lo then off = hi end
o.zoom = off
Zoom.offset = off
Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale())
return true
end },
{ id = "voidFill", label = Strings("VOID FILL"),
+553 -39
View File
@@ -70,6 +70,12 @@ Studio.BIND_GROUPS = {
"pause_toggle", "screenshot", "exit_emulator" } },
{ title = "KEYBOARD",
specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } },
-- These are deliberately keyboard binds rather than new skin-only actions.
-- They therefore take the exact same path as their desktop counterparts in
-- Game:keypressed, including any future changes to those shortcuts.
{ title = "DESKTOP HOTKEYS",
specs = { "key:-", "key:=", "key:1", "key:2", "key:3", "key:4",
"key:5", "key:f1", "key:f2", "key:f10" } },
{ title = "NO INPUT", specs = { "nul" } },
}
@@ -362,7 +368,9 @@ function Studio.load(opts)
Studio.drag = nil
Studio.pendingPlay = false
Studio.canvasIndex = 1
Studio.aspectLock = true
-- A free-form screen is the default. 10:9 is an optional convenience,
-- never a restriction on imported or custom skins.
Studio.aspectLock = false
Studio.skinIdField = ""
Studio.available = TouchSkin.list()
Studio.availableMeta = {}
@@ -374,6 +382,13 @@ function Studio.load(opts)
Studio.showLabels = true
Studio.statusErr = false
Studio.thumbs = {}
Studio.pointerX, Studio.pointerY = nil, nil
Studio.pointerDown, Studio.touchId = false, nil
-- Studio always opens on the library. Creating and choosing a skin are
-- first-class tasks, not controls buried inside the editor workspace.
Studio.mode = "library"
Studio.libraryThumbs = {}
Studio.libraryPage = 1
TouchControls:init()
TouchControls.active = true
@@ -413,6 +428,7 @@ function Studio.open(id)
Studio.undoStack, Studio.redoStack = {}, {}
Studio.undoTag = nil
Studio.thumbs = {}
Studio.libraryThumbs = {}
syncActive()
Studio.applyImportedOrient()
return true
@@ -425,6 +441,7 @@ function Studio.newSkin()
Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {}
Studio.thumbs = {}
Studio.libraryThumbs = {}
syncActive()
markDirty()
end
@@ -468,9 +485,122 @@ end
function Studio.refreshAvailable()
Studio.available = TouchSkin.list()
Studio.availableMeta = {}
Studio.libraryThumbs = {}
return Studio.available
end
function Studio.enterEditor()
Studio.mode = "editor"
Studio.selected = nil
Studio.modal, Studio.confirm = nil, nil
Studio.drag, Studio.guides = nil, nil
end
function Studio.backToLibrary()
Studio.guard("Return to My Skins and lose the unsaved changes?", function()
Studio.refreshAvailable()
Studio.mode = "library"
Studio.selected = nil
Studio.modal, Studio.confirm = nil, nil
Studio.drag, Studio.guides = nil, nil
end)
end
function Studio.libraryThumb(entry)
if not entry then return nil end
Studio.libraryThumbs = Studio.libraryThumbs or {}
local cached = Studio.libraryThumbs[entry.id]
if cached ~= nil then return cached or nil end
local skin = TouchSkin.load(entry.root, entry.id)
local image = skin and skin.pages and skin.pages[1] and skin.pages[1].image
Studio.libraryThumbs[entry.id] = image or false
return image
end
function Studio.exportEntry(id)
local entry = TouchSkin.find(id)
local skin = entry and TouchSkin.load(entry.root, entry.id)
if not skin then
setStatus("Could not read " .. tostring(id), true)
return nil
end
local path, missing = TouchSkin.export(skin)
if not path then
setStatus("Export failed: " .. tostring(missing), true)
return nil
end
Studio.lastExport = path
setStatus("Exported " .. path)
return path
end
function Studio.selectEntry(id)
local entry = TouchSkin.find(id)
if not entry then
setStatus("Could not find " .. tostring(id), true)
return false
end
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled, tc.skin = true, id
opts.touchControls = tc
SaveData.saveOptions(opts)
TouchControls:applyOptions(opts)
setStatus("Using " .. id)
return true
end
function Studio.deleteEntry(id)
local entry = TouchSkin.find(id)
if not entry then return false end
if entry.source ~= "user" then
setStatus("Bundled skins cannot be deleted.", true)
return false
end
Studio.ask("Delete " .. tostring(id) .. "? This removes its artwork and cannot be undone.",
function()
local ok, err = TouchSkin.remove(id)
if not ok then
setStatus("Delete failed: " .. tostring(err), true)
return
end
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
if tc.skin == id then
tc.enabled, tc.skin = false, nil
SaveData.saveOptions(opts)
end
Studio.refreshAvailable()
setStatus("Deleted " .. id)
end, "Delete")
return true
end
function Studio.importSkinFile()
if not FilePicker.available() then
setStatus("On mobile, use Import in the Skins tab to add a downloaded skin.", true)
return false
end
local kind = { label = "Skin", exts = { "zip", "deltaskin", "cfg" } }
local path = FilePicker.open("Choose a skin", kind)
if not path then return false end
local name, data = FilePicker.basename(path), FilePicker.read(path)
if not data then
setStatus("Could not read " .. name, true)
return false
end
local id, note = TouchSkin.installArchive(name, data)
if not id then
setStatus("Import failed: " .. tostring(note), true)
return false
end
Studio.refreshAvailable()
Studio.open(id)
Studio.enterEditor()
setStatus("Imported " .. id)
return true
end
function Studio.openLoadPicker()
return Studio.guard("Open another skin and lose the unsaved changes?",
function()
@@ -509,6 +639,29 @@ function Studio.unload()
Studio.modal, Studio.confirm = nil, nil
Studio.guides = nil
Studio.undoStack, Studio.redoStack = {}, {}
Studio.pointerX, Studio.pointerY = nil, nil
Studio.pointerDown, Studio.touchId = false, nil
end
-- The Studio is a real touch editor on Android and iOS. Keeping this here,
-- instead of asking the host to synthesize a mouse, makes the drag state and
-- the immediate-mode hit tests agree on the same finger position.
function Studio.isMobile()
local osName = love.system and love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
function Studio.disableTouchControls()
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled, tc.skin = false, nil
opts.touchControls = tc
SaveData.saveOptions(opts)
TouchControls:applyOptions(opts)
TouchSkin.setActive(nil)
Studio.closeModal()
setStatus("On-screen controls are off.")
return true
end
-- --------------------------------------------------------------- editing
@@ -678,7 +831,12 @@ function Studio.cycleImage(dir)
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
if field == "pressed" then
owner.pressedImage = img
else
owner.image = img
if field == "bezel" then TouchSkin.applyImageAspect(owner) end
end
markDirty()
end
@@ -748,6 +906,7 @@ function Studio.assignImage(rel)
ctl.imagePath, ctl.image = rel, img
elseif page then
page.imagePath, page.image = rel, img
TouchSkin.applyImageAspect(page)
end
Studio.images = TouchSkin.listImages(Studio.skin.root)
Studio.dirty = true
@@ -1228,8 +1387,11 @@ function Studio.updateDrag(mx, my, r)
ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
else
-- Permit an anchor to live beyond every canvas edge. That is useful for
-- intentional off-centre compositions and mirrors the normal Screen Pos
-- behaviour at runtime; only a non-zero size is required.
page.viewport = {
x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph),
x = (bx - px) / pw, y = (by - py) / ph,
w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
}
end
@@ -1238,6 +1400,35 @@ end
-- ----------------------------------------------------------------- draw
local function drawGameTest(r, page)
-- Test letterboxes a 160x144 picture inside the screen cutout, the same
-- way gameplay fits the Game Boy surface into the hole.
local vx, vy, vw, vh = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
if not vx then vx, vy, vw, vh = r.x, r.y, r.w, r.h end
local s = math.min(vw / 160, vh / 144)
local gw, gh = 160 * s, 144 * s
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
Theme.fill(r.x, r.y, r.w, r.h, { 8, 12, 8 }, 1)
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
local tile = math.max(2, math.floor(16 * s))
for row = 0, 8 do
for col = 0, 9 do
if (row + col) % 2 == 0 then
Theme.fill(gx + col * tile, gy + row * tile, tile, tile,
{ 139, 172, 15 }, 0.45)
end
end
end
Theme.fill(gx + gw * 0.10, gy + gh * 0.15, gw * 0.22, gh * 0.18,
{ 48, 98, 48 }, 0.9)
Theme.fill(gx + gw * 0.58, gy + gh * 0.49, gw * 0.12, gh * 0.18,
{ 48, 98, 48 }, 0.9)
Theme.fill(gx + gw * 0.14, gy + gh * 0.70, gw * 0.72, gh * 0.16,
{ 224, 248, 208 }, 1)
Kit.text("small", "TEST BATTLE", gx + gw * 0.18, gy + gh * 0.74,
{ 24, 56, 24 })
end
local function drawCanvas(x, y, w, h)
local page = Studio.page()
local r = { }
@@ -1251,7 +1442,9 @@ local function drawCanvas(x, y, w, h)
syncActive()
local vx, vy, vw, vh = viewportRect(page, r)
if vx then
if Studio.testing then
drawGameTest(r, page)
elseif 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
@@ -1714,13 +1907,24 @@ local function drawOpenModal(W, H)
local rowH = math.max(Kit.tapMin(), 46 * Kit.scale)
local gap = 4 * Kit.scale
local list = Studio.available or {}
local contentH = #list * (rowH + gap)
-- A visible Off choice is more intentional than making people infer that
-- an empty selection disables the overlay. It also works from the skin
-- grid on phones, where the normal pad editor is not the destination.
local offH = rowH + gap
local contentH = offH + #list * (rowH + gap)
local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH)
local maxScroll = Kit.scrollExtent(contentH, viewH)
local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
local offClicked = Kit.row(mx + pad, baseY, mw - pad * 2, rowH, false,
"open-off")
Kit.text("mono", "Off", mx + pad * 2, baseY + 6 * Kit.scale, PAL.red)
Kit.text("small", "Hide all on-screen controls.", mx + pad * 2,
baseY + 6 * Kit.scale + Kit.textHeight("mono"), PAL.muted)
if offClicked then Studio.disableTouchControls() end
for i, entry in ipairs(list) do
local py = baseY + (i - 1) * (rowH + gap)
local py = baseY + offH + (i - 1) * (rowH + gap)
local selected = Studio.skin and Studio.skin.id == entry.id
local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected,
"open-" .. entry.id)
@@ -1744,14 +1948,19 @@ local function drawPageModal(W, H)
local top = my + pad + Kit.textHeight("title") + pad * 0.5
local by, bh = modalFooter(mx, my, mw, mh, pad)
local gap = 6 * Kit.scale
local fieldW = math.min(220 * Kit.scale, (mw - pad * 2) * 0.5)
Studio.pageNameField = Kit.textfield("pagename", mx + pad, by, fieldW, bh,
-- Keep the page name easy to edit: one full-width field, then its actions
-- below it, with Close retaining its own footer row.
local fieldY = by - bh * 2 - gap * 2
local actionY = by - bh - gap
local innerW = mw - pad * 2
local actionW = (innerW - gap) * 0.5
Studio.pageNameField = Kit.textfield("pagename", mx + pad, fieldY, innerW, bh,
Studio.pageNameField or "", "page name")
if Kit.button(mx + pad + fieldW + gap, by, 100 * Kit.scale, bh, "Rename",
if Kit.button(mx + pad, actionY, actionW, bh, "Rename",
{ id = "page-rename" }) then
Studio.renamePage(Studio.pageNameField)
end
if Kit.button(mx + pad + fieldW + gap + 106 * Kit.scale, by, 100 * Kit.scale,
if Kit.button(mx + pad + actionW + gap, actionY, actionW,
bh, "Delete", { id = "page-del", kind = "danger",
enabled = skin and #skin.pages > 1 }) then
local index = Studio.pageIndex
@@ -1760,7 +1969,7 @@ local function drawPageModal(W, H)
function() Studio.deletePage(index) end, "Delete")
end
local viewH = by - top - pad
local viewH = fieldY - top - pad
local rowH = math.max(Kit.tapMin(), 44 * Kit.scale)
local pages = (skin and skin.pages) or {}
local contentH = #pages * (rowH + gap)
@@ -1852,10 +2061,11 @@ function Studio.drawOverlay(W, H)
return true
end
function Studio.draw()
local function drawLegacyStudio()
local W, H = love.graphics.getDimensions()
Kit.layout(W, H)
local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
@@ -1864,50 +2074,64 @@ function Studio.draw()
Kit.blockClicks = Studio.modalUp()
local pad = 14 * Kit.scale
local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad
local mobile = Studio.isMobile()
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
-- A phone gets an intentional two-row toolbar. The desktop's single row
-- is excellent at a wide monitor, but it would run its canvas/test/history
-- controls beneath Close on a portrait display.
local barH = mobile and (btnH * 2 + pad * 1.5) or (btnH + 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 toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5)
local bx = mobile and pad or titleW
local canvas = Studio.canvas()
if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH,
local canvasW = mobile and math.min(200 * Kit.scale, W * 0.4) or 200 * Kit.scale
if Kit.button(bx, toolY, canvasW, 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,
bx = bx + canvasW + 8 * Kit.scale
local testW = mobile and math.min(110 * Kit.scale, W * 0.21) or 110 * Kit.scale
if Kit.button(bx, toolY, testW, 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
bx = bx + testW + 8 * Kit.scale
local smallW = 74 * Kit.scale
if Kit.button(bx, pad * 0.5, smallW, btnH, "Undo",
if Kit.button(bx, toolY, smallW, btnH, "Undo",
{ id = "undo", font = "small", enabled = Studio.canUndo() }) then
Studio.undo()
end
bx = bx + smallW + 6 * Kit.scale
if Kit.button(bx, pad * 0.5, smallW, btnH, "Redo",
if Kit.button(bx, toolY, smallW, btnH, "Redo",
{ id = "redo", font = "small", enabled = Studio.canRedo() }) then
Studio.redo()
end
bx = bx + smallW + 6 * Kit.scale
if Kit.button(bx, pad * 0.5, smallW + 20 * Kit.scale, btnH,
Studio.showLabels and "Labels: ON" or "Labels: OFF",
{ id = "labels", font = "small", active = Studio.showLabels }) then
Studio.showLabels = not Studio.showLabels
if not mobile then
if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH,
Studio.showLabels and "Labels: ON" or "Labels: OFF",
{ id = "labels", font = "small", active = Studio.showLabels }) then
Studio.showLabels = not Studio.showLabels
end
bx = bx + smallW + 26 * Kit.scale
end
bx = bx + smallW + 26 * Kit.scale
if Studio.dirty then
Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow)
Kit.text("small", "unsaved", bx, toolY + btnH * 0.3, PAL.yellow)
end
local closeW = 100 * Kit.scale
local offW = 74 * Kit.scale
if Kit.button(W - pad - closeW - offW - 6 * Kit.scale, pad * 0.5, offW, btnH,
"Off", { id = "off", kind = "danger" }) then
Studio.disableTouchControls()
end
local closed = false
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "close" }) then
@@ -1922,17 +2146,28 @@ function Studio.draw()
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 r, cx, cw, footY
if Studio.isMobile() then
-- On a phone the canvas stays wide and the inspector becomes the lower
-- sheet. Both remain on screen, so a tapped control can be adjusted
-- without swapping modes or hiding the preview.
cx, cw = pad, W - pad * 2
local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46)
r = drawCanvas(cx, bodyY, cw, canvasH)
local inspectorY = bodyY + canvasH + 8 * Kit.scale
local inspectorH = math.max(0, H - inspectorY - pad - 30 * Kit.scale)
drawInspector(pad, inspectorY, W - pad * 2, inspectorH)
footY = H - pad - 24 * Kit.scale
else
local panelW = math.min(360 * Kit.scale, W * 0.34)
drawInspector(pad, bodyY, panelW, bodyH)
cx = pad * 2 + panelW
cw = W - cx - pad
r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale)
footY = bodyY + bodyH - 30 * Kit.scale
end
local msg = Studio.status
if not msg and Studio.testing then
local held = {}
@@ -1954,6 +2189,259 @@ function Studio.draw()
Studio.canvasArea = r
end
-- ------------------------------------------------------ mobile-first studio
-- Kept as an opt-in diagnostic renderer while downstream tools migrate; the
-- active Studio path below is the library/editor flow.
Studio.drawLegacy = drawLegacyStudio
local function studioCard(x, y, w, h, id, active)
local focused = Kit.focusable(id, x, y, w, h)
Kit.card(x, y, w, h, active and "selected"
or (focused or Kit.hover(x, y, w, h)))
return Kit.press(x, y, w, h) or Kit._activateId == id
end
local function drawSkinArtwork(image, x, y, w, h)
Theme.fillRounded(x, y, w, h, PAL.bg, 1, Theme.cardRadius() * 0.65)
if image and image.getDimensions then
local iw, ih = image:getDimensions()
if iw > 0 and ih > 0 then
local s = math.min((w - 14 * Kit.scale) / iw, (h - 14 * Kit.scale) / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, x + (w - iw * s) * 0.5,
y + (h - ih * s) * 0.5, 0, s, s)
return
end
end
-- A neutral mock reads as a touch skin even before it has artwork.
Theme.strokeRounded(x + w * 0.19, y + h * 0.10, w * 0.62, h * 0.44,
PAL.line, Theme.A.hairline, 1, 3)
Theme.fillRounded(x + w * 0.13, y + h * 0.64, w * 0.26, h * 0.18,
PAL.steel, 0.8, 3)
Theme.fillRounded(x + w * 0.60, y + h * 0.59, w * 0.13, h * 0.24,
PAL.steel, 0.8, h * 0.12)
Theme.fillRounded(x + w * 0.77, y + h * 0.54, w * 0.13, h * 0.24,
PAL.steel, 0.8, h * 0.12)
end
local function drawLibrary(W, H, pad)
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local titleY = pad * 0.55
Kit.textBold("title", "My Skins", pad, titleY, PAL.heading)
local closeW = math.min(112 * Kit.scale, W * 0.24)
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "library-close" }) then
if Studio.onClose then Studio.onClose() end
return
end
local y = pad * 0.5 + btnH + pad
local gap = 8 * Kit.scale
local half = (W - pad * 2 - gap) * 0.5
if Kit.button(pad, y, half, btnH * 1.12, "+ New skin",
{ id = "library-new", kind = "accent" }) then
Studio.newSkin()
Studio.enterEditor()
return
end
if Kit.button(pad + half + gap, y, half, btnH * 1.12, "Import skin",
{ id = "library-import" }) then
Studio.importSkinFile()
return
end
y = y + btnH * 1.12 + pad
Kit.caption(pad, y, "YOUR SKINS")
y = y + Kit.textHeight("small") + gap
local entries = Studio.available or {}
local cols = W >= 520 * Kit.scale and 3 or 2
local cardGap = 10 * Kit.scale
local cardW = (W - pad * 2 - cardGap * (cols - 1)) / cols
local cardH = math.max(182 * Kit.scale, cardW * 1.42)
local saved = SaveData.loadOptions()
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
local activeId = tc.enabled == false and nil or tc.skin
local total = #entries
local maxRows = math.max(1, math.floor((H - y - pad - btnH - gap)
/ (cardH + cardGap)))
local perPage = maxRows * cols
local pages = math.max(1, math.ceil(total / perPage))
Studio.libraryPage = math.max(1, math.min(Studio.libraryPage or 1, pages))
local first = (Studio.libraryPage - 1) * perPage + 1
local last = math.min(total, first + perPage - 1)
for slot = first, last do
local index = slot - first
local cx = pad + (index % cols) * (cardW + cardGap)
local cy = y + math.floor(index / cols) * (cardH + cardGap)
do
local entry = entries[slot]
local selected = entry.id == activeId
Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil)
local artH = cardH * 0.45
drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale,
cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH)
Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale),
cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale,
selected and PAL.green or PAL.heading)
local meta = Studio.skinSummary(entry)
Kit.text("small", Kit.ellipsize("small", meta, cardW - 20 * Kit.scale),
cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale + Kit.textHeight("mono"),
PAL.muted)
local actionGap = 4 * Kit.scale
local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5
local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale
if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH,
selected and "Selected" or "Select",
{ id = "skin-select-" .. entry.id,
kind = selected and "good" or "accent" }) then
Studio.selectEntry(entry.id)
end
if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH,
"Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then
Studio.loadEntry(entry.id)
Studio.enterEditor()
return
end
actionY = actionY + btnH + actionGap
if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH,
"Export", { id = "skin-export-" .. entry.id }) then
Studio.exportEntry(entry.id)
end
if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH,
"Delete", { id = "skin-delete-" .. entry.id,
kind = "danger", enabled = entry.source == "user" }) then
Studio.deleteEntry(entry.id)
end
end
end
if pages > 1 then
local pagerY = H - pad - btnH
local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5
if Kit.button(pad, pagerY, prevW, btnH, "Previous",
{ id = "library-prev", enabled = Studio.libraryPage > 1 }) then
Studio.libraryPage = Studio.libraryPage - 1
end
if Kit.button(pad + prevW + gap, pagerY, nextW, btnH, "Next",
{ id = "library-next", enabled = Studio.libraryPage < pages }) then
Studio.libraryPage = Studio.libraryPage + 1
end
end
if #entries == 0 then
Kit.text("small", "Create a blank skin or import one to get started.", pad,
y + cardH + gap, PAL.muted)
end
end
local function drawEditor(W, H, pad)
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local gap = 7 * Kit.scale
local backW = math.min(120 * Kit.scale, W * 0.23)
if Kit.button(pad, pad * 0.5, backW, btnH, "My Skins",
{ id = "editor-back" }) then
Studio.backToLibrary()
end
local closeW = math.min(90 * Kit.scale, W * 0.17)
local saveW = math.min(90 * Kit.scale, W * 0.17)
local testW = math.min(100 * Kit.scale, W * 0.18)
local right = W - pad
if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "editor-close" }) then
Studio.guard("Close the studio and lose the unsaved changes?", function()
if Studio.onClose then Studio.onClose() end
end)
end
right = right - closeW - gap
if Kit.button(right - saveW, pad * 0.5, saveW, btnH, "Save",
{ id = "editor-save", kind = "accent" }) then Studio.save() end
right = right - saveW - gap
if Kit.button(right - testW, pad * 0.5, testW, btnH,
Studio.testing and "Test: ON" or "Test",
{ id = "editor-test", active = Studio.testing }) then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
end
local titleX = pad + backW + gap
local titleW = math.max(0, right - testW - gap - titleX)
Kit.textBold("button", Kit.ellipsize("button",
(Studio.skin and Studio.skin.name) or "Untitled skin", titleW),
titleX, pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading)
local trayH = btnH * 2 + gap * 3 + 30 * Kit.scale
local bodyY = pad * 0.5 + btnH + pad
local trayY = H - pad - trayH
local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad)
drawCanvas(pad, bodyY, W - pad * 2, canvasH)
Kit.card(pad, trayY, W - pad * 2, trayH)
local innerX, innerW = pad + gap, W - pad * 2 - gap * 2
local actionW = (innerW - gap * 3) / 4
local ctl = Studio.selectedControl()
if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control",
{ id = "tray-add", kind = "accent" }) then
Studio.addControl()
Studio.openBindPicker()
end
if Kit.button(innerX + (actionW + gap), trayY + gap, actionW, btnH,
ctl and ("Bind: " .. ctl.spec) or "Bind",
{ id = "tray-bind", enabled = ctl ~= nil }) then
Studio.openBindPicker()
end
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap, actionW, btnH,
"Button art", { id = "tray-art", enabled = ctl ~= nil }) then
Studio.openImagePicker("idle")
end
if Kit.button(innerX + (actionW + gap) * 3, trayY + gap, actionW, btnH,
"Bezel art", { id = "tray-bezel" }) then
Studio.openImagePicker("bezel")
end
if Kit.button(innerX, trayY + gap * 2 + btnH, actionW, btnH, "Pages",
{ id = "tray-pages" }) then Studio.openPageMenu() end
if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH,
"Screen", { id = "tray-screen" }) then Studio.toggleViewport() end
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH,
Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
{ id = "tray-shape", active = Studio.aspectLock }) then
Studio.aspectLock = not Studio.aspectLock
end
if Kit.button(innerX + (actionW + gap) * 3, trayY + gap * 2 + btnH, actionW, btnH,
"Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then
Studio.deleteControl()
end
local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec)
.. " — drag to move; use blue handles to resize.")
or "Tap a control to select it. Drag to move; use blue handles to resize."
Kit.text("small", Kit.ellipsize("small", Studio.status or hint, innerW),
innerX, trayY + trayH - Kit.textHeight("small") - gap,
Studio.statusErr and PAL.red or PAL.muted)
end
function Studio.draw()
local W, H = love.graphics.getDimensions()
Kit.layout(W, H)
local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, W, H, PAL.bg, 1)
Studio.expireStatus()
Kit.blockClicks = Studio.modalUp()
local pad = 14 * Kit.scale
if Studio.mode == "library" then drawLibrary(W, H, pad)
else drawEditor(W, H, pad) end
Kit.blockClicks = false
Studio.drawOverlay(W, H)
Kit.endFrame()
end
-- ---------------------------------------------------------------- input
function Studio.update()
@@ -1965,8 +2453,11 @@ end
function Studio.mousepressed(x, y, button)
if button ~= 1 then return end
Studio.pointerX, Studio.pointerY = x, y
Studio.pointerDown = true
Studio.clicked = true
if Studio.modalUp() then return end
if Studio.mode == "library" then return end
local r = Studio.lastCanvas
if not r then return end
if Studio.testing then
@@ -1984,18 +2475,22 @@ function Studio.mousepressed(x, y, button)
end
function Studio.mousemoved(x, y)
Studio.pointerX, Studio.pointerY = x, y
if Studio.modalUp() then return end
if Studio.mode == "library" then return end
if Studio.testing then
TouchControls:touchmoved("studio", x, y)
return
end
if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then
if Studio.drag and (Studio.pointerDown or 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
Studio.pointerX, Studio.pointerY = x, y
Studio.pointerDown = false
if Studio.testing then
TouchControls:touchreleased("studio", x, y)
return
@@ -2004,6 +2499,23 @@ function Studio.mousereleased(x, y, button)
Studio.guides = nil
end
function Studio.touchpressed(id, x, y)
if Studio.touchId and Studio.touchId ~= id then return end
Studio.touchId = id
return Studio.mousepressed(x, y, 1)
end
function Studio.touchmoved(id, x, y)
if Studio.touchId ~= id then return end
return Studio.mousemoved(x, y)
end
function Studio.touchreleased(id, x, y)
if Studio.touchId ~= id then return end
Studio.touchId = nil
return Studio.mousereleased(x, y, 1)
end
function Studio.wheelmoved(_, dy)
Studio.wheel = dy
end
@@ -2011,6 +2523,7 @@ end
function Studio.focus()
Studio.drag = nil
Studio.clicked = false
Studio.pointerDown, Studio.touchId = false, nil
if Studio.testing then TouchControls:reset() end
end
@@ -2094,8 +2607,9 @@ function Studio.keypressed(key)
end
function Studio.available_desktop()
local osName = love.system and love.system.getOS and love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
-- Kept as the compatibility name for launcher integrations. The Studio
-- now has a touch layout and is available on every supported platform.
return true
end
return Studio
+5 -7
View File
@@ -123,13 +123,11 @@ local ROWS = {
{ label = "ZOOM", key = "zoom", port = true,
cycle = function(options, delta, game)
local Zoom = require("src.render.Zoom")
local scale = (game and game.world and game.world.fitScale
and game.world:fitScale()) or 1
local lo, hi = Zoom.offsetRange(scale)
local offset = (options.zoom or 0) + delta
if offset > hi then offset = lo elseif offset < lo then offset = hi end
options.zoom = offset
Zoom.offset = offset
local scale = Zoom.windowFitScale()
if game and game.world and game.world.fitScale then
scale = game.world:fitScale()
end
Zoom.nudgeOptions(options, delta, scale)
end,
text = function(options)
return require("src.render.Zoom").offsetLabel(options.zoom or 0)