From b0ff1552f29727c93ada9e73d298c755912269fc Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 21 Aug 2026 14:03:25 -0400 Subject: [PATCH] skins fixerino --- docs/skin-studio.md | 76 ++- main.lua | 25 +- src/core/Game2.lua | 6 +- src/core/Performance.lua | 7 +- src/core/ScreenPosition.lua | 5 + src/core/TouchControls.lua | 24 +- src/core/TouchSkin.lua | 59 ++- src/import/LauncherSettings.lua | 24 +- src/import/LauncherView.lua | 148 +++++- src/import/RomImporter.lua | 16 +- src/render/Zoom.lua | 34 +- src/ui/OptionsMenu.lua | 9 +- src/ui/SkinStudio.lua | 592 ++++++++++++++++++++-- src/ui/gen2/OptionsMenu.lua | 12 +- tests/drivers/skin_studio_shot.lua | 12 +- tests/drivers/touch_skin_shot.lua | 34 +- tests/engine/launcher_gold_touch_rows.lua | 9 + tests/engine/launcher_scroll_test.lua | 25 +- tests/engine/performance_tiers.lua | 6 + tests/engine/skin_studio_test.lua | 19 + tests/engine/skin_studio_ux.lua | 4 + tests/engine/touch_skin_test.lua | 37 +- tests/mod_ui_tests.lua | 9 + 23 files changed, 1065 insertions(+), 127 deletions(-) diff --git a/docs/skin-studio.md b/docs/skin-studio.md index ff487233..ecae75a7 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -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:` 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 launcher’s **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//skin.lua` and copies every image the skin names, so the folder stands alone. **Export** offers three formats, and diff --git a/main.lua b/main.lua index 1ffe5a89..c73b10ec 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/src/core/Game2.lua b/src/core/Game2.lua index cd3bf3da..c1236a37 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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. diff --git a/src/core/Performance.lua b/src/core/Performance.lua index a3d2945a..b15b520e 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -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); diff --git a/src/core/ScreenPosition.lua b/src/core/ScreenPosition.lua index d4834db4..506fccf6 100644 --- a/src/core/ScreenPosition.lua +++ b/src/core/ScreenPosition.lua @@ -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 diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 32190f90..50c28048 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -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 diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 1b79d620..23463f50 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -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 diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index ab820348..7d828cab 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -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"), diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index a3cfac09..860b5e8b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 88ce014e..63221a14 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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" diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index 13b27cad..00484251 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -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) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 67ca0414..503b5dfa 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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"), diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 99f2b3b5..bdf790aa 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -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 diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 89decadb..3ff6f334 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -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) diff --git a/tests/drivers/skin_studio_shot.lua b/tests/drivers/skin_studio_shot.lua index 75d8bffb..d0820a7e 100644 --- a/tests/drivers/skin_studio_shot.lua +++ b/tests/drivers/skin_studio_shot.lua @@ -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) diff --git a/tests/drivers/touch_skin_shot.lua b/tests/drivers/touch_skin_shot.lua index c45ad950..12a08c35 100644 --- a/tests/drivers/touch_skin_shot.lua +++ b/tests/drivers/touch_skin_shot.lua @@ -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") diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index d54713b4..a09f6b27 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -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") diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index a9440ced..1a5b1a0c 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -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 diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index befdeb2f..153d414b 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -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") diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua index ce97a6f1..b80cbb65 100644 --- a/tests/engine/skin_studio_test.lua +++ b/tests/engine/skin_studio_test.lua @@ -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, diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua index ec794e78..201ede07 100644 --- a/tests/engine/skin_studio_ux.lua +++ b/tests/engine/skin_studio_ux.lua @@ -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() diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua index 7d43e4c0..54c698c8 100644 --- a/tests/engine/touch_skin_test.lua +++ b/tests/engine/touch_skin_test.lua @@ -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 diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0071ce65..dc82fa8b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -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",