skins and skin studio

This commit is contained in:
bryanthaboi
2026-08-17 06:47:33 -04:00
parent a5b674f9da
commit 3cca70608f
45 changed files with 4194 additions and 64 deletions
+86
View File
@@ -0,0 +1,86 @@
-- Launcher SKINS tab (#1386): the tab next to Find that lists skins, imports
-- a dropped .zip and opens the desktop Skin Studio. The rendered panel is
-- exercised by tests/drivers/launcher_skins_tab_shot.lua; this pins the
-- archive installer and the launcher wiring around it.
-- luajit tests/engine/launcher_skins_tab.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
-- ------------------------------------------------------ archive installer
eq(select(1, TouchSkin.installArchive("skin.zip", nil)), nil,
"an empty archive is refused")
eq(select(1, TouchSkin.installArchive("skin.zip", "")), nil,
"a zero-byte archive is refused")
eq(select(1, TouchSkin.installArchive("notes.txt", "data")), nil,
"a non-zip is refused")
eq(select(1, TouchSkin.installArchive("", "data")), nil,
"a nameless drop is refused")
-- A zip carrying no skin file must not leave a stray archive behind: the
-- list would keep trying to mount it on every visit to the tab.
local junk = "PK\3\4 not really a skin"
local id, err = TouchSkin.installArchive("bogus.zip", junk)
eq(id, nil, "a zip with no skin.lua or .cfg is refused")
check(tostring(err):find("bogus.zip", 1, true) ~= nil,
"and the error names the file")
eq(love.filesystem.read(TouchSkin.USER_ROOT .. "/bogus.zip"), nil,
"the rejected archive is cleaned up, not left to fail on every listing")
-- the id a good archive would land under is the file name without .zip
check(TouchSkin.find("bogus") == nil, "a refused archive lists nothing")
-- ------------------------------------------------------- launcher wiring
local view = read("src/import/LauncherView.lua")
local imp = read("src/import/RomImporter.lua")
check(view:find('id = "skins"', 1, true) ~= nil,
"LauncherView registers a skins tab")
check(view:find("drawSkinGlyph", 1, true) ~= nil,
"the skins tab draws its own glyph rather than shipping an asset")
-- the tab has to be next to Find, which is what the request was
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
check(order ~= nil, "HEADER_TABS found")
if order then
local findAt = order:find('id = "find"', 1, true)
local skinsAt = order:find('id = "skins"', 1, true)
check(findAt and skinsAt and skinsAt > findAt,
"the skins tab sits immediately after Find")
end
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
"the panel dispatch routes the skins tab")
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
-- the panel must not offer the studio when the host did not supply it
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
"the Studio button is hidden without a host hook (mobile)")
-- the studio boots a game on Play, so it needs a cartridge, not the tab id
check(view:find("imp.modScope or \"red\"", 1, true) ~= nil,
"the studio is handed a real game version, never the skins tab id")
check(imp:find('if self.tab == "skins" then', 1, true) ~= nil,
"a dropped zip on the skins tab installs a skin")
check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists")
check(imp:find("_installMod", 1, true) ~= nil,
"and a zip elsewhere still installs a mod")
local cycle = imp:match("local order = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab")
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"switching to the tab re-reads the skin list")
T.finish("launcher_skins_tab")
+261
View File
@@ -0,0 +1,261 @@
-- Skin Studio model layer (#1386): canvas presets, control editing, the
-- drag/resize math and the pixel coordinate fields. Drawing is exercised by
-- tests/drivers/skin_studio_shot.lua; this pins the state machine underneath.
-- luajit tests/engine/skin_studio_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local Studio = require("src.ui.SkinStudio")
local function near(a, b, tol, msg)
check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) ..
", want " .. tostring(b) .. ")")
end
-- a studio session without touching options or the filesystem
local function session()
Studio.skin = TouchSkin.newSkin("t")
Studio.pageIndex = 1
Studio.selected = nil
Studio.canvasIndex = 1
Studio.aspectLock = true
Studio.drag = nil
Studio.dirty = false
Studio.images = {}
return Studio.skin
end
-- ------------------------------------------------------------- new skin
session()
eq(#Studio.skin.pages, 1, "a new skin starts with one page")
eq(#Studio.page().controls, 0, "and no controls")
check(Studio.page().viewport ~= nil, "and a screen cutout to place")
check(Studio.selectedControl() == nil, "nothing selected yet")
-- ------------------------------------------------------------- controls
Studio.addControl()
eq(#Studio.page().controls, 1, "addControl appends")
eq(Studio.selected, 1, "and selects what it added")
check(Studio.dirty, "editing marks the skin dirty")
local ctl = Studio.selectedControl()
eq(ctl.spec, "a", "new controls default to GB a")
eq(ctl.buttons[1], "a", "and carry the parsed bind")
Studio.cycleBind(1)
check(ctl.spec ~= "a", "cycleBind moves off the current bind")
check(#ctl.buttons + #ctl.hotkeys + #ctl.keys > 0 or ctl.decorative,
"cycleBind reparses the bind")
Studio.cycleBind(-1)
eq(ctl.spec, "a", "cycleBind is reversible")
Studio.duplicateControl()
eq(#Studio.page().controls, 2, "duplicate adds a second control")
eq(Studio.selected, 2, "and selects the copy")
check(Studio.page().controls[2] ~= Studio.page().controls[1],
"the copy is a separate table")
near(Studio.page().controls[2].x, Studio.page().controls[1].x + 0.04, 1e-6,
"the copy is offset so it is visible")
Studio.deleteControl()
eq(#Studio.page().controls, 1, "delete removes it")
Studio.deleteControl()
eq(#Studio.page().controls, 0, "delete works down to empty")
eq(Studio.selected, nil, "and clears the selection")
-- ------------------------------------------------------- canvas presets
local ids = {}
for _, c in ipairs(Studio.CANVASES) do
check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
check(not ids[c.id], c.id .. " appears once")
ids[c.id] = true
end
check(ids.phone_portrait, "a phone portrait preset exists")
check(ids.desktop_1080, "a desktop preset exists")
check(ids.ultrawide, "an ultrawide preset exists")
check(ids.sgb_border, "a Super Game Boy border preset exists")
session()
local sgbIndex
for i, c in ipairs(Studio.CANVASES) do
if c.id == "sgb_border" then sgbIndex = i end
end
Studio.setCanvas(sgbIndex)
local canvas = Studio.canvas()
eq(canvas.w, 256, "SGB canvas is 256 wide")
eq(canvas.h, 224, "SGB canvas is 224 tall")
local vp = Studio.page().viewport
near(vp.x * 256, 48, 1e-6, "SGB screen sits 48px from the left")
near(vp.y * 224, 40, 1e-6, "SGB screen sits 40px from the top")
near(vp.w * 256, 160, 1e-6, "SGB screen is 160px wide")
near(vp.h * 224, 144, 1e-6, "SGB screen is 144px tall")
-- the locked preset refuses to let the cutout be moved or removed
Studio.toggleViewport()
check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off")
-- presets wrap rather than running off the end of the list
Studio.setCanvas(#Studio.CANVASES + 1)
eq(Studio.canvasIndex, 1, "canvas selection wraps")
-- ------------------------------------------------------------ canvas fit
Studio.setCanvas(1)
local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600)
near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6,
"the mock device keeps its aspect")
check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace")
near(cx + cw / 2, 400, 1e-6, "centred horizontally")
near(cy + ch / 2, 300, 1e-6, "centred vertically")
-- ---------------------------------------------------------------- drag
session()
Studio.addControl()
ctl = Studio.selectedControl()
local r = { x = 0, y = 0, w = 1000, h = 1000 }
local startX, startY = ctl.x, ctl.y
Studio.drag = { kind = "control-move", mx = 500, my = 500,
bx = 420, by = 455, bw = 160, bh = 90 }
Studio.updateDrag(600, 500, r)
near(ctl.x, startX + 0.1, 1e-6, "dragging right moves the control right")
near(ctl.y, startY, 1e-6, "and leaves y alone")
Studio.drag = { kind = "control-resize", handle = "se", mx = 500, my = 500,
bx = 400, by = 400, bw = 100, bh = 100 }
Studio.updateDrag(600, 600, r)
near(ctl.rangeX * 2 * r.w, 200, 1e-6, "the se handle widens the control")
near(ctl.rangeY * 2 * r.h, 200, 1e-6, "and heightens it")
-- the viewport keeps the Game Boy's 10:9 while the lock is on
Studio.aspectLock = true
Studio.selected = nil
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
bx = 0, by = 0, bw = 400, bh = 400 }
Studio.updateDrag(100, 0, r)
local v = Studio.page().viewport
near(v.w / v.h, 160 / 144, 1e-6, "10:9 lock holds the screen aspect")
Studio.aspectLock = false
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
bx = 0, by = 0, bw = 400, bh = 400 }
Studio.updateDrag(100, 200, r)
v = Studio.page().viewport
near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer")
near(v.h * r.h, 600, 1e-6, "and height is free")
-- controls never escape the canvas
Studio.selected = 1
Studio.drag = { kind = "control-move", mx = 0, my = 0,
bx = 0, by = 0, bw = 100, bh = 100 }
Studio.updateDrag(-100000, -100000, r)
check(ctl.x >= 0 and ctl.y >= 0, "a control cannot be dragged off the top-left")
Studio.updateDrag(100000, 100000, r)
check(ctl.x <= 1 and ctl.y <= 1, "or off the bottom-right")
-- ------------------------------------------------------- pixel fields
session()
Studio.addControl()
ctl = Studio.selectedControl()
canvas = Studio.canvas()
eq(canvas.w, 1080, "phone portrait is 1080 wide")
Studio.commitField("numW", "216")
near(ctl.rangeX * 2 * canvas.w, 216, 1e-6, "W is typed in canvas pixels")
Studio.commitField("numX", "100")
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X is the left edge in pixels")
Studio.commitField("numH", "180")
near(ctl.rangeY * 2 * canvas.h, 180, 1e-6, "H is typed in canvas pixels")
Studio.commitField("numY", "640")
near((ctl.y - ctl.rangeY) * canvas.h, 640, 1e-4, "Y is the top edge in pixels")
-- X must not have drifted when the later fields were set
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X survives edits to the others")
local keptX = ctl.x
Studio.commitField("numX", "not a number")
near(ctl.x, keptX, 1e-9, "garbage in a field is ignored")
-- ---------------------------------------------------------------- pages
session()
Studio.addPage()
eq(#Studio.skin.pages, 2, "addPage appends a page")
eq(Studio.pageIndex, 2, "and switches to it")
eq(Studio.page().name, "page2", "the new page is named in sequence")
Studio.addControl()
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
-- --------------------------------------------------------------- clone
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
check(source ~= nil, "bundled skin loads for cloning")
if source then
local copy = TouchSkin.clone(source)
eq(#copy.pages, #source.pages, "clone keeps every page")
eq(#copy.pages[1].controls, #source.pages[1].controls, "and every control")
check(copy.pages[1] ~= source.pages[1], "pages are fresh tables")
check(copy.pages[1].controls[1] ~= source.pages[1].controls[1],
"controls are fresh tables")
eq(copy.pages[1].image, source.pages[1].image, "loaded images are shared, not reloaded")
copy.pages[1].controls[1].x = 0.123
check(source.pages[1].controls[1].x ~= 0.123,
"editing the clone does not touch the loaded skin")
end
-- ------------------------------------------------------------ play handoff
-- Play used to call the host handler straight out of the Kit button, which
-- unloaded the studio inside its own draw pass; the rest of that frame then
-- indexed a nil skin and took the app down. The handoff is deferred to
-- update, and a torn-down studio has to survive a draw either way.
session()
Studio.available, Studio.images = {}, {}
Studio.skinIdField, Studio.onClose = "t", function() end
local fired, gotVersion = 0, nil
Studio.version = "red"
Studio.onPlay = function(v)
fired = fired + 1
gotVersion = v
Studio.unload()
end
Studio.play()
check(Studio.pendingPlay, "play queues the handoff")
eq(fired, 0, "play does not hand off during the click")
check(Studio.skin ~= nil, "and leaves the skin alive for the rest of the frame")
check(pcall(Studio.draw), "the frame that queued play still draws")
Studio.update()
eq(fired, 1, "update performs the handoff")
eq(gotVersion, "red", "and passes the launcher tab through")
eq(Studio.skin, nil, "the studio is unloaded by then")
check(pcall(Studio.draw), "a torn-down studio still survives a draw")
Studio.update()
eq(fired, 1, "the handoff does not repeat")
-- unload must clear a queued handoff, or closing then reopening would boot
session()
Studio.onPlay = function() fired = fired + 1 end
Studio.play()
check(Studio.pendingPlay, "queued again")
Studio.unload()
check(not Studio.pendingPlay, "unload drops a queued handoff")
Studio.update()
eq(fired, 1, "closing the studio does not start the game")
-- the studio is a desktop workspace; the launcher only offers it there
check(type(Studio.available_desktop) == "function", "desktop gate is exported")
check(Studio.available_desktop(), "headless/desktop reports available")
T.finish("skin_studio")
+325
View File
@@ -0,0 +1,325 @@
-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press /
-- hotkey path through TouchControls, and the screen viewport the renderer
-- fits the Game Boy picture into.
-- luajit tests/engine/touch_skin_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls")
local Input = require("src.core.Input")
local CFG = [[
overlays = 2
overlay0_name = "shell"
overlay0_overlay = img/back.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_alpha_mod = 0.001
overlay0_range_mod = 1.5
overlay0_viewport = "0.0,0.0,1.0,0.5"
overlay0_viewport_fill = true
overlay0_descs = 7
overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05"
overlay0_desc0_overlay = img/a.png
overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05"
overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10"
overlay0_desc2_overlay = img/dpad.png
overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02"
overlay0_desc3_next_target = "second"
overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02"
overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02"
overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02"
overlay1_name = "second"
overlay1_full_screen = true
overlay1_normalized = true
overlay1_descs = 1
overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10"
]]
local skin = assert(TouchSkin.parse(CFG))
eq(#skin.pages, 2, "two overlay pages parsed")
local page = skin.pages[1]
eq(page.name, "shell", "page name")
eq(page.imagePath, "img/back.png", "page background path")
check(page.fullScreen, "full_screen parsed")
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
eq(#page.controls, 7, "seven descs parsed")
check(page.viewport ~= nil, "viewport parsed")
eq(page.viewport.h, 0.5, "viewport height")
check(page.viewportFill, "viewport_fill parsed")
local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls)
eq(a.buttons[1], "a", "desc0 binds GB a")
eq(a.shape, "radial", "desc0 is radial")
eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod")
eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod")
eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control")
check(decor.decorative, "nul desc is decoration only")
eq(decor.imagePath, "img/dpad.png", "decoration still carries art")
eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped")
eq(nextBtn.nextTarget, "second", "next_target parsed")
eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped")
eq(reset.hotkeys[1], "soft_reset", "reset mapped")
eq(keyBtn.keys[1], "f5", "key: binds a keyboard key")
-- geometry: x/y are the centre, range_x/range_y are half extents, both
-- normalized to the window when full_screen is set
local W, H = 400, 800
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H)
eq(cx, 320, "control centre x")
eq(cy, 600, "control centre y")
eq(halfW, 40, "control half width")
eq(halfH, 40, "control half height")
-- range_mod 1.5 widens the hitbox but not the art
check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits")
check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits")
check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses")
-- radial, so the corner of the bounding box is outside
check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses")
-- rect hitbox does take its corner
check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits")
-- ---------------------------------------------------------------- runtime
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchSkin.setOverlayLive(true)
TouchSkin.setActive(skin)
Input:init()
local ww, wh = love.graphics.getDimensions()
local function at(nx, ny) return nx * ww, ny * wh end
check(TouchSkin.hasViewport(), "active skin reports a viewport")
-- the renderer fits the Game Boy picture into this rect instead of the window
local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
check(fill, "viewport fill flag returned")
eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative")
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
check(Input:isDown("a"), "skin A presses GB a")
TouchControls:touchreleased("f1", at(0.80, 0.75))
check(not Input:isDown("a"), "lifting releases GB a")
-- one finger inside a combined desc holds both directions
TouchControls:touchpressed("f2", at(0.20, 0.90))
check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons")
-- sliding onto the decoration drops them without capturing anything new
TouchControls:touchmoved("f2", at(0.20, 0.75))
check(not Input:isDown("left") and not Input:isDown("down"),
"sliding off a control releases it")
TouchControls:touchreleased("f2", at(0.20, 0.75))
check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil,
"a press on decoration alone is not captured")
-- hotkeys reach the host once per press edge and once per release
local fired = {}
TouchControls:setHotkeyHandler(function(action, pressed)
fired[#fired + 1] = action .. ":" .. tostring(pressed)
end)
TouchControls:touchpressed("f4", at(0.05, 0.55))
eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press")
TouchControls:touchreleased("f4", at(0.05, 0.55))
eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release")
-- a stranded finger unwinds through reset, not just through touchreleased
fired = {}
TouchControls:touchpressed("f5", at(0.05, 0.55))
TouchControls:touchpressed("f6", at(0.80, 0.75))
check(Input:isDown("a"), "second finger holds A")
TouchControls:reset()
eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys")
check(not Input:isDown("a"), "reset releases held buttons")
-- overlay_next swaps the page, and the new page's controls are what hit
eq(TouchSkin.page().name, "shell", "starts on page 1")
TouchControls:touchpressed("f7", at(0.95, 0.55))
eq(TouchSkin.page().name, "second", "overlay_next honours next_target")
TouchControls:touchreleased("f7", at(0.95, 0.55))
TouchControls:touchpressed("f8", at(0.50, 0.50))
check(Input:isDown("start"), "page 2 control presses GB start")
TouchControls:touchreleased("f8", at(0.50, 0.50))
TouchSkin.setPage("shell")
-- ------------------------------------------------------------ persistence
local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" })
eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id")
eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops")
eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops")
TouchControls.skinId = "gb_anim"
eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id")
-- ----------------------------------------------- screen-hole detection
-- Border art usually ships with a transparent screen hole and no viewport
-- key. Detection is verified against real art by
-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely.
eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil,
"no bezel path detects nothing")
eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil,
"empty bezel path detects nothing")
eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil,
"a missing bezel detects nothing")
-- --------------------------------------------------------- bundled skin
local bundled = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
check(bundled ~= nil, "bundled gb_anim skin loads")
if bundled then
eq(#bundled.pages, 2, "gb_anim has a DMG and a Color page")
eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1")
check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport")
eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art")
local named = {}
for _, ctl in ipairs(bundled.pages[1].controls) do
for _, btn in ipairs(ctl.buttons) do named[btn] = true end
end
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
check(named[btn], "gb_anim binds GB " .. btn)
end
end
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
check(tv ~= nil, "bundled tv_crt desktop bezel loads")
if tv then
eq(#tv.pages, 1, "tv_crt is a single page")
eq(#tv.pages[1].controls, 0, "tv_crt binds nothing: it is a frame")
check(tv.pages[1].viewport ~= nil, "tv_crt names the tube as its viewport")
eq(tv.pages[1].imagePath, "img/tv-integer.png", "tv_crt bezel art")
TouchSkin.setActive(tv)
TouchSkin.setOverlayLive(false)
check(TouchSkin.decorativeOnly(), "tv_crt counts as decoration")
check(TouchSkin.drawable(), "so it draws on a desktop window")
end
-- ------------------------------------------- decorative desktop bezels
-- A skin whose page binds nothing is a frame, not a pad: it draws where the
-- touch overlay does not (desktop), and a gamepad must not hide it.
local BEZEL = [[
overlays = 1
overlay0_name = "tv"
overlay0_overlay = img/tv.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_descs = 1
overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5"
overlay0_viewport = "0.2,0.1,0.6,0.8"
]]
local bezel = assert(TouchSkin.parse(BEZEL))
TouchSkin.setActive(bezel)
TouchSkin.setOverlayLive(false)
check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration")
check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)")
check(TouchSkin.hasViewport(), "and its viewport still places the picture")
TouchControls.active = false
TouchControls.enabled = true
TouchControls.controllerHidden = true
check(TouchControls:visible(), "a gamepad does not hide a decorative bezel")
-- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate
TouchSkin.setActive(skin)
TouchSkin.setOverlayLive(false)
check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration")
check(not TouchSkin.drawable(), "and it does not draw where the overlay is off")
check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either")
check(not TouchControls:visible(), "nor draw over a desktop window")
TouchSkin.setOverlayLive(true)
check(TouchSkin.drawable(), "with the overlay live it draws again")
TouchControls.active = true
TouchControls.controllerHidden = false
-- ------------------------------------------------------- native format
local native = TouchSkin.serialize(skin)
check(native:find("^return {"), "serialize emits a Lua data chunk")
local back, nerr = TouchSkin.parseNative(native)
check(back ~= nil, "native round-trip parses: " .. tostring(nerr))
if back then
eq(#back.pages, #skin.pages, "round-trip keeps the page count")
local bp, sp = back.pages[1], skin.pages[1]
eq(#bp.controls, #sp.controls, "round-trip keeps the control count")
eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds")
eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents")
eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape")
eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport")
check(bp.viewportFill, "round-trip keeps viewport_fill")
eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod")
eq(bp.controls[3].decorative, true, "round-trip keeps decoration")
eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target")
end
-- the native format carries the separate pressed image a .cfg cannot
local twoState = assert(TouchSkin.parseNative([[
return { pages = { { name = "p", controls = {
{ bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial",
image = "up.png", imagePressed = "down.png" },
} } } }
]]))
eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image")
eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image")
eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range")
check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected")
check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected")
check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected")
-- skins are third-party data: the chunk must not see the host globals
check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }")
~= nil, "skin.lua loads in an empty environment")
-- --------------------------------------------------------------- export
local SkinZip = require("src.core.SkinZip")
local blob = SkinZip.encode({
{ name = "skin.lua", data = "return {}\n" },
{ name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" },
})
eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header")
check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record")
check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name")
check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim")
local bundledSkin = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
if bundledSkin then
eq(#TouchSkin.assetPaths(bundledSkin), 17, "gb_anim names 17 distinct images")
local tmp = os.tmpname() .. ".zip"
local path, missing = TouchSkin.export(bundledSkin, tmp)
eq(path, tmp, "export writes to the requested path")
eq(#missing, 0, "every gb_anim image was found")
local f = io.open(tmp, "rb")
check(f ~= nil, "exported zip reached disk")
if f then
local bytes = f:read("*a")
f:close()
eq(bytes:sub(1, 4), "PK\3\4", "exported file is a zip")
check(bytes:find("skin.lua", 1, true) ~= nil, "export includes the native skin.lua")
check(bytes:find("img/gb_back.png", 1, true) ~= nil, "export includes the bezel")
check(bytes:find("overlay.cfg", 1, true) ~= nil,
"a .cfg-sourced skin also exports its original config")
os.remove(tmp)
end
end
TouchSkin.setActive(nil)
TouchControls:setHotkeyHandler(nil)
T.finish("touch_skin")