mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 21:46:51 +02:00
skins fixerino
This commit is contained in:
+51
-25
@@ -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 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/<name>/skin.lua` and copies every image the
|
||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+548
-34
@@ -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,
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user