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
+51 -25
View File
@@ -1,11 +1,11 @@
# Touch skins and the Skin Studio
A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
control layout, and a screen-placement anchor. Engine:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport),
(draw and input), `src/render/Renderer.lua` (screen placement),
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/skin_studio_ux.lua`,
`tests/engine/skin_studio_image_import.lua`,
@@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
`tests/engine/launcher_skins_tab.lua`,
`tests/engine/launcher_skins_ux.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name.
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
and is the one place that turns skin use off. **My Skins** holds the visual
grid, pagination, edit, delete and per-skin export actions.
`options.touchControls.skin` holds the folder name.
## Formats
@@ -30,7 +32,7 @@ as-is. Supported keys:
| `overlays` | page count |
| `overlayN_name` | page name, the target of `next_target` |
| `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window |
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
| `overlayN_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
@@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
window, and puts the Game Boy picture in the leftover space above -- the
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
stretch to the window the way Delta does. Host functions map to
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
game into it. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
@@ -135,7 +137,7 @@ to decoration and never captures a touch.
As an extension to the format, `key:<name>` presses any keyboard key, which is
how a skin button reaches a mod hotkey.
## The screen viewport
## Screen placement
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
@@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
A viewport also implies the faithful-ratio lock. Without it the world pass
expands to fill the cutout and you get more map instead of a Game Boy screen.
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
the world, and the start menu stays at the hole's fit scale instead of
shrinking with the map.
An image-backed portrait overlay that has no explicit vertical anchor is treated
as a controller deck: it is contained without deformation and pinned to the
bottom on taller screens. The spare space belongs to the game above it.
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
skin rather than the normal Center / Upper / Top setting.
Border art often ships with a transparent hole and no `viewport` key. **Detect
screen from bezel** in the studio measures the hole out of the art's alpha
channel and writes the rect.
@@ -153,10 +166,8 @@ channel and writes the rect.
## Bezels versus pads
A skin whose active page binds nothing is a frame rather than a pad: a TV
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
as well, where the touch overlay itself does not, and a gamepad does not hide
them. Anything that binds a button still follows the usual mobile /
`POKEPORT_TOUCH` rule.
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
**desktop** as well as mobile; a gamepad does not hide them.
## Installing
@@ -190,9 +201,20 @@ shipping branding.
## The studio
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
that skin. Desktop only: the launcher does not offer it on Android or iOS,
because it wants a mouse, typed coordinates and room for an inspector.
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
mobile. **My Skins** is the only visual grid: real bezel previews plus create
and import actions, with each card owning Edit, Export and (for installed
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
New/Load workspace controls are deliberately not duplicated inside that editor.
The editor keeps its canvas unobstructed and puts the contextual actions in a
compact lower tray: add/control binding, button and bezel artwork, pages,
screen placement, freeform/10:9 screen shape and deletion. Touches select, drag and resize the
same controls that a mouse edits on desktop.
The launchers **Turn skins off** button clears the selected skin and disables
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
is not itself a skin card.
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor.
@@ -216,13 +238,16 @@ and of the page itself when it comes within a few pixels, and the guide it
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
typed to the coordinate its art was drawn at. **Back** and **Front** move the
selection through the draw order. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock.
pressed images are per control; the bezel, the pages and the screen anchor are
per page. The SCREEN anchor is itself draggable and resizable; its default
shape is freeform, with an optional 10:9 lock.
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
decoration. The COMBINE chips at the top toggle one part at a time, which is
how a pipe bind like `left|down` is built without typing it.
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
as `key:` controls, so a mobile button invokes the exact same game path as
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
which is how a pipe bind like `left|down` is built without typing it.
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
@@ -247,12 +272,13 @@ the **Import** button there and beside each row opens the host file picker (`src
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
window does the same for whichever slot was last touched. A new bezel does not
move the screen cutout: press **Detect screen from bezel** to measure it out of
move the screen anchor: press **Detect screen from bezel** to measure it out of
the art's alpha.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
buttons and the footer reports what is held. **Play** saves the skin, selects
it, and boots the game with it.
**Testing.** **Test** renders a game-composition preview behind the live overlay:
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
Clicking presses real Game Boy buttons and the footer reports what is held.
**Play** saves the skin, selects it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** offers three formats, and
+19 -6
View File
@@ -302,6 +302,8 @@ local function makeLauncher()
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
-- Keep the compatibility predicate so external hosts using it still work.
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
@@ -931,7 +933,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y)
end
if Studio then return end
if Studio then return Studio.touchpressed(id, x, y) end
if Importer then
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops
@@ -948,7 +950,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y)
end
if Studio then return end
if Studio then return Studio.touchmoved(id, x, y) end
if Importer then
return Importer:touchmoved(id, x, y, dx, dy, pressure)
end
@@ -962,7 +964,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y)
end
if Studio then return end
if Studio then return Studio.touchreleased(id, x, y) end
if Importer then
return Importer:touchreleased(id, x, y, dx, dy, pressure)
end
@@ -1015,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Studio then return Studio.mousepressed(x, y, button) end
if Studio then
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
-- Studio consumes the real finger stream above, so discard the twin.
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousepressed(x, y, button)
end
if Importer then
-- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
@@ -1050,7 +1057,10 @@ function love.mousereleased(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Studio then return Studio.mousereleased(x, y, button) end
if Studio then
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousereleased(x, y, button)
end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -1068,7 +1078,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if Studio then return Studio.mousemoved(x, y) end
if Studio then
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousemoved(x, y)
end
if editorMode or Importer then return end
if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
+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)
+11 -1
View File
@@ -9,7 +9,9 @@ return function(game)
local dir = os.getenv("SHOT_DIR") or "/tmp/studio"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1440, 900, { resizable = true, highdpi = true })
local shotW = tonumber(os.getenv("STUDIO_SHOT_W")) or 1440
local shotH = tonumber(os.getenv("STUDIO_SHOT_H")) or 900
love.window.setMode(shotW, shotH, { resizable = true, highdpi = true })
U.wait(2)
local pending = nil
@@ -41,6 +43,14 @@ return function(game)
U.log("skin:", Studio.skin and Studio.skin.id, "pages:", #(Studio.skin.pages or {}))
U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {}))
shot("studio_open.png")
Studio.enterEditor()
U.wait(2)
shot("studio_editor.png")
Studio.openPageMenu()
U.wait(2)
shot("studio_pages.png")
Studio.closeModal()
Studio.selected = 9
U.wait(3)
+26 -8
View File
@@ -10,7 +10,9 @@ return function(game)
local dir = os.getenv("SHOT_DIR") or "/tmp/skin"
local skinId = os.getenv("SKIN") or "gb_anim"
love.window.setMode(432, 768, { resizable = true, highdpi = true })
local shotW = tonumber(os.getenv("SHOT_W")) or 432
local shotH = tonumber(os.getenv("SHOT_H")) or 768
love.window.setMode(shotW, shotH, { resizable = true, highdpi = true })
U.wait(2)
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
@@ -43,10 +45,26 @@ return function(game)
U.shot(game, dir .. "/skin_idle.png")
local ww, wh = love.graphics.getDimensions()
local function at(nx, ny) return nx * ww, ny * wh end
local function centerFor(button)
local current = TouchSkin.page()
for _, ctl in ipairs(current and current.controls or {}) do
for _, bind in ipairs(ctl.buttons or {}) do
if bind == button then
local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh)
return x, y
end
end
for _, hotkey in ipairs(ctl.hotkeys or {}) do
if hotkey == button then
local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh)
return x, y
end
end
end
end
TouchControls:touchpressed("t1", at(0.87407, 0.72417))
TouchControls:touchpressed("t2", at(0.24074, 0.79771))
TouchControls:touchpressed("t1", centerFor("a"))
TouchControls:touchpressed("t2", centerFor("down"))
U.wait(4)
U.log("held:", (function()
local out = {}
@@ -55,12 +73,12 @@ return function(game)
return table.concat(out, ",")
end)())
U.shot(game, dir .. "/skin_pressed.png")
TouchControls:touchreleased("t1", at(0.87407, 0.72417))
TouchControls:touchreleased("t2", at(0.24074, 0.79771))
TouchControls:touchreleased("t1", centerFor("a"))
TouchControls:touchreleased("t2", centerFor("down"))
U.wait(4)
TouchControls:touchpressed("t3", at(0.95, 0.528))
TouchControls:touchreleased("t3", at(0.95, 0.528))
TouchControls:touchpressed("t3", centerFor("overlay_next"))
TouchControls:touchreleased("t3", centerFor("overlay_next"))
U.wait(6)
U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name)
U.shot(game, dir .. "/skin_page2.png")
@@ -45,6 +45,15 @@ for _, version in ipairs({ "gold", "silver" }) do
check(has(model, "VIBRATION"), version .. " and VIBRATION")
check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor")
check(has(model, "VOID FILL"), version .. " and VOID FILL")
check(has(model, "ZOOM"), version .. " and ZOOM")
local zoom = findRow(model, "ZOOM")
eq(zoom.value(), "FIT", version .. " ZOOM defaults to FIT")
zoom.step(-1)
eq(model.opts.gold.zoom, -1, version .. " left stores OUT1 in the gold block")
eq(zoom.value(), "OUT1", version .. " and the row reads OUT1")
zoom.step(1)
eq(model.opts.gold.zoom, 0, version .. " right restores FIT")
local voidFill = findRow(model, "VOID FILL")
eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
+23 -2
View File
@@ -86,6 +86,16 @@ local function skinLauncher(count)
end
window(360, 780)
local compact = skinLauncher(12)
LauncherView.draw(compact)
LauncherView.draw(compact)
eq(compact._tabScrollMax.skins or 0, 0,
"the compact Skins landing panel never scrolls through installed skins")
-- Historical assertions for the old installed-skins list. Skin browsing now
-- lives exclusively in My Skins, so this launcher panel intentionally has no
-- list/pager region to exercise.
if false then
local imp = skinLauncher(12)
LauncherView.draw(imp)
LauncherView.draw(imp)
@@ -139,6 +149,7 @@ LauncherView.draw(imp)
LauncherView.draw(imp)
eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel")
eq(imp._tabScroll.skins, 0, "and its offset comes back with it")
end
local mods = {}
for i = 1, 60 do
@@ -207,6 +218,7 @@ gameImp._wheelY = -1
LauncherView.draw(gameImp)
check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it")
if false then
window(360, 780)
local touchImp = skinLauncher(12)
LauncherView.draw(touchImp)
@@ -223,6 +235,7 @@ LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2)
eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom")
check((touchImp._pageScroll or 0) > 0, "and spills into the page from there")
LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400)
end
window(360, 780)
local dragMods = RomImporter.new(function() end, { launcher = true })
@@ -244,6 +257,7 @@ eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region")
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
if false then
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
for i = 2, 12 do
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
@@ -266,7 +280,9 @@ eq(dragMods._pages.mods or 1, heldModsPage,
eq(dragMods._tabScroll.mods, heldModsAt,
"with its region offset held for the return trip")
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
end
if false then
love.graphics.polygon = love.graphics.polygon or function() end
window(360, 780)
local padImp = skinLauncher(12)
@@ -305,6 +321,7 @@ check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch")
LauncherView.draw(edgeImp)
check((edgeImp._tabScroll.skins or 0) > 0,
"which reaches the tab region even though the cursor is below it")
end
Kit.blockClicks = false
Kit._clipRect = nil
@@ -330,6 +347,7 @@ eq(Kit.dragAccum, 200, "and its travel stays queued")
Kit.dragEnd()
eq(Kit.dragAccum, 0, "releasing the button retires the gesture")
if false then
window(360, 780)
local mouseImp = skinLauncher(12)
LauncherView.draw(mouseImp)
@@ -371,20 +389,23 @@ LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt ~= nil,
"press and release without travel is still a tap, on release")
mouseImp._clickPt = nil
end
LauncherView.draw(gameImp)
LauncherView.draw(gameImp)
check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect")
local cart = gameImp._noDragRects[1]
local gameMouseDown = false
love.mouse.isDown = function() return gameMouseDown end
pointer(cart.x + cart.w / 2, cart.y + cart.h / 2)
mdown = true
gameMouseDown = true
LauncherView.update(gameImp, 0.016)
check(gameImp._clickPt ~= nil,
"a press on the cartridge clicks at once so its own spin-drag still owns "
.. "the gesture")
check(gameImp._mouseAt == nil, "and never arms the scroll drag")
gameImp._clickPt = nil
mdown = false
gameMouseDown = false
LauncherView.update(gameImp, 0.016)
love.mouse.isDown = nil
+6
View File
@@ -42,6 +42,12 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced")
device("OS X", "x64", 8)
T.eq(Performance.detect(), "high", "8-core desktop -> high")
device("OS X", "arm64", 10)
T.eq(Performance.detect(), "high", "Apple Silicon Mac is not a handheld LOW")
device("Windows", "arm64", 8)
T.eq(Performance.detect(), "high", "Windows-on-ARM desktop -> high")
device("Windows", "x64", 2)
T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced")
+19
View File
@@ -115,6 +115,18 @@ 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")
-- ----------------------------------------------------------- touch bridge
session()
Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 }
Studio.touchpressed("finger", 50, 50)
eq(Studio.touchId, "finger", "the first finger owns the Studio gesture")
check(Studio.pointerDown, "a touch is tracked as a held editor pointer")
Studio.touchmoved("finger", 60, 50)
Studio.touchreleased("finger", 60, 50)
eq(Studio.touchId, nil, "lifting clears the captured finger")
check(not Studio.pointerDown, "and releases the editor pointer")
-- ---------------------------------------------------------------- drag
session()
@@ -152,6 +164,13 @@ 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")
Studio.drag = { kind = "viewport-move", mx = 0, my = 0,
bx = 0, by = 0, bw = 100, bh = 100 }
Studio.updateDrag(-250, 1100, r)
v = Studio.page().viewport
check(v.x < 0, "a screen anchor can move past the left canvas edge")
check(v.y > 1, "a screen anchor can move past the bottom canvas edge")
-- controls never escape the canvas
Studio.selected = 1
Studio.drag = { kind = "control-move", mx = 0, my = 0,
+4
View File
@@ -162,6 +162,10 @@ check(specs["overlay_previous"], "overlay_previous is reachable at last")
check(specs["pause_toggle"] and specs["exit_emulator"],
"so are the hotkeys the old cycle could not reach")
check(specs["key:escape"], "and a keyboard bind can be picked")
check(specs["key:-"] and specs["key:="] and specs["key:1"]
and specs["key:5"] and specs["key:f1"] and specs["key:f2"]
and specs["key:f10"],
"desktop hotkeys can be placed on a mobile skin button")
check(specs["nul"], "decoration is still an option")
session()
+33 -4
View File
@@ -189,6 +189,34 @@ if bundled then
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")
-- Legacy RetroArch skins commonly omit aspect_ratio. Their bezel image is
-- still the design canvas: fitting to that image is what keeps the button
-- coordinates and artwork proportional on unusually shaped phones.
check(bundled.pages[1].aspectFromImage,
"gb_anim derives a design aspect from its bezel image")
local tallW, tallH = 720, 2400
local tallX, tallY, tallBoxW, tallBoxH =
TouchSkin.pageBox(bundled.pages[1], tallW, tallH)
eq(tallX, 0, "a tall portrait skin remains horizontally aligned")
eq(tallY, 1120, "a tall portrait skin pins its controller deck to the bottom")
eq(tallBoxW, tallW, "a tall portrait skin uses the display width")
eq(tallBoxH, 1280, "a tall portrait skin keeps the bezel's 9:16 height")
local _, _, tallHalfW, tallHalfH =
TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], tallW, tallH)
check(math.abs(tallHalfW - tallHalfH) < 0.01,
"a tall-phone face button remains round")
local wideW, wideH = 2400, 720
local wideX, wideY, wideBoxW, wideBoxH =
TouchSkin.pageBox(bundled.pages[1], wideW, wideH)
eq(wideX, 997.5, "a wide display centres the contained portrait skin")
eq(wideY, 0, "a wide display keeps the contained skin vertically aligned")
eq(wideBoxW, 405, "a wide display uses the bezel's 9:16 width")
eq(wideBoxH, wideH, "a wide display uses the full display height")
local _, _, wideHalfW, wideHalfH =
TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], wideW, wideH)
check(math.abs(wideHalfW - wideHalfH) < 0.01,
"a wide-phone face button remains round")
local named = {}
for _, ctl in ipairs(bundled.pages[1].controls) do
for _, btn in ipairs(ctl.buttons) do named[btn] = true end
@@ -239,13 +267,14 @@ 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
-- A selected skin is also a presentation overlay on desktop. The touch
-- input gate remains separate from whether its artwork is drawn.
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")
check(TouchSkin.drawable(), "and it still draws where touch input is off")
check(TouchSkin.hasViewport(), "so its screen placement stays available")
check(TouchControls:visible(), "and it draws over a desktop window")
TouchSkin.setOverlayLive(true)
check(TouchSkin.drawable(), "with the overlay live it draws again")
TouchControls.active = true
+9
View File
@@ -341,6 +341,15 @@ check(orow(om, "zoom").value(om.game) == "FIT",
orow(om, "zoom").step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
orow(om, "zoom").step(om.game, -1)
check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT")
orow(om, "zoom").step(om.game, -1)
check(om.game.save.options.zoom == -1 and Zoom.offset == -1,
"ZOOM row steps to OUT1")
check(orow(om, "zoom").value(om.game) == "OUT1",
"ZOOM row shows OUT1")
orow(om, "zoom").step(om.game, 1)
check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT from OUT")
orow(om, "voidFill").step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",