mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
skins and skin studio
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user