skins and skin studio

This commit is contained in:
bryanthaboi
2026-08-17 06:47:33 -04:00
parent a5b674f9da
commit 3cca70608f
45 changed files with 4194 additions and 64 deletions
+10
View File
@@ -0,0 +1,10 @@
# gb_anim -- bundled touch skin
Bezel art and overlay layout from libretro's `common-overlays`
(`gamepads/gb_anim_portrait`), licensed CC-BY-4.0:
https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream `gb_big.cfg`, unmodified. It ships as the
reference skin for the RetroArch-overlay loader in
`src/core/TouchSkin.lua`: a full-device bezel, per-button press art, a
screen viewport, and page switching between the DMG and Color shells.
Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+89
View File
@@ -0,0 +1,89 @@
overlays = 2
overlay0_name = "GameBoy"
overlay0_overlay = img/gb_back.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_range_mod = 1.0
overlay0_alpha_mod = 0.001
overlay0_viewport = "0.0,0.0,1.0,0.505"
overlay0_viewport_fill = true
overlay1_name = "GameBoyColor"
overlay1_overlay = img/gbc_back.png
overlay1_full_screen = true
overlay1_normalized = true
overlay1_range_mod = 1.0
overlay1_alpha_mod = 0.001
overlay1_viewport = "0.0,0.0,1.0,0.505"
overlay1_viewport_fill = true
# GameBoy
overlay0_descs = 18
overlay0_desc0 = "left,0.12778,0.73417,radial,0.09630,0.04635"
overlay0_desc0_overlay = img/gb_left.png
overlay0_desc1 = "right,0.35370,0.73417,radial,0.09630,0.04635"
overlay0_desc1_overlay = img/gb_right.png
overlay0_desc2 = "up,0.24074,0.67063,radial,0.08241,0.05417"
overlay0_desc2_overlay = img/gb_up.png
overlay0_desc3 = "down,0.24074,0.79771,radial,0.08241,0.05417"
overlay0_desc3_overlay = img/gb_down.png
overlay0_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay0_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay0_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay0_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay0_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay0_desc8_overlay = img/gb_a_b.png
overlay0_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay0_desc9_overlay = img/gb_a_b.png
overlay0_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay0_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay0_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay0_desc12_overlay = img/gb_start_select.png
overlay0_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay0_desc13_overlay = img/gb_start_select.png
overlay0_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay0_desc14_overlay = img/menu.png
overlay0_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay0_desc15_overlay = img/rotate.png
overlay0_desc15_next_target = "GameBoyColor"
overlay0_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay0_desc16_overlay =
overlay0_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay0_desc17_overlay =
# GameBoyColor
overlay1_descs = 18
overlay1_desc0 = "left,0.14078,0.73417,radial,0.08530,0.04635"
overlay1_desc0_overlay = img/gbc_left.png
overlay1_desc1 = "right,0.34270,0.73417,radial,0.08530,0.04635"
overlay1_desc1_overlay = img/gbc_right.png
overlay1_desc2 = "up,0.24074,0.67863,radial,0.08241,0.04617"
overlay1_desc2_overlay = img/gbc_up.png
overlay1_desc3 = "down,0.24074,0.78971,radial,0.08241,0.04617"
overlay1_desc3_overlay = img/gbc_down.png
overlay1_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay1_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay1_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay1_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay1_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay1_desc8_overlay = img/gbc_a.png
overlay1_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay1_desc9_overlay = img/gbc_b.png
overlay1_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay1_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay1_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay1_desc12_overlay = img/gbc_start_select.png
overlay1_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay1_desc13_overlay = img/gbc_start_select.png
overlay1_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay1_desc14_overlay = img/menu.png
overlay1_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay1_desc15_overlay = img/rotate.png
overlay1_desc15_next_target = "GameBoy"
overlay1_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay1_desc16_overlay =
overlay1_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay1_desc17_overlay =
+10
View File
@@ -0,0 +1,10 @@
# tv_crt -- bundled desktop bezel
CRT television border from libretro's `common-overlays`
(`borders/tv-integer.cfg` + `borders/img/tv-integer.png`), licensed
CC-BY-4.0: https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream file, unmodified. It is the reference
DESKTOP skin: 1920x1080, `descs = 0` (pure decoration, no touch buttons),
and a `viewport` naming the transparent screen hole, so the Game Boy
picture is fitted into the TV's tube instead of the whole window.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+6
View File
@@ -0,0 +1,6 @@
overlays = 1
overlay0_overlay = img/tv-integer.png
overlay0_full_screen = true
overlay0_descs = 0
overlay0_viewport = "0.2335,0.0855,0.5335,0.830"
overlay0_viewport_fill = true
+2 -20
View File
@@ -11,28 +11,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Translation and custom font support**
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
* **Tiled map editing tools** for mod authors
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Community mod browser**
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage
* **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
## Pokémon Gold (Gen 2)
## Gen 2 Specifics
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms**
* **Border-block surrounds** for maps smaller than the screen
* **Gold-specific launcher options**
* **Optional widescreen battle layout**
* **Skippable trade animation** with B or START
* **QUIT and EXIT GAME** from the menus
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
* **On-screen touch pad** and controller SELECT for registered items
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
+179
View File
@@ -0,0 +1,179 @@
# 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:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/launcher_skins_tab.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.
## Formats
Two load. `skin.lua` wins when a folder has both.
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
as-is. Supported keys:
| Key | Meaning |
| --- | --- |
| `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_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | fallback aspect when not full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
| `overlayN_descM` | `binds,x,y,shape,range_x,range_y` |
| `overlayN_descM_overlay` | control art |
| `overlayN_descM_next_target` | page to switch to |
| `overlayN_descM_range_mod`, `_alpha_mod` | per-control overrides |
| `overlayN_descM_reach_x/_y/_up/_down/_left/_right` | hitbox reach |
`x,y` is the centre and `range_x,range_y` are half extents, both normalized.
Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
control that holds both. A `nul` desc is decoration: it draws and never
captures a touch.
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
image sits at the overlay opacity, and a pressed control's image swaps to
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
fades it out, and both directions read as a press animation.
**Native `skin.lua`.** This module's own model written back out: one Lua
table, no flat key space, and a separate `imagePressed` per control that a
`.cfg` cannot express. Loaded with an empty environment, so a skin authored by
a stranger cannot reach `love` or `io`. Sizes here are full width and height
rather than RetroArch's half extents, because that is what an editor's numeric
fields mean.
```lua
return {
name = "my_skin",
pages = {
{
name = "main",
image = "img/bezel.png",
fullScreen = true,
viewport = { x = 0.0, y = 0.0, w = 1.0, h = 0.5, fill = false },
controls = {
{ bind = "a", x = 0.87, y = 0.72, w = 0.18, h = 0.10,
shape = "radial", image = "img/a.png", imagePressed = "img/a_down.png" },
},
},
},
}
```
## Bindable actions
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
`left`, `right`.
Engine hotkeys, handled in `Game:touchSkinHotkey`:
| Bind | Effect |
| --- | --- |
| `overlay_next`, `overlay_previous` | switch page, honouring `next_target` |
| `hold_fast_forward`, `fast_forward` | fast forward while held |
| `toggle_fast_forward` | step the speed option |
| `reset` | soft reset to the title |
| `menu_toggle` | open OPTIONS |
`screenshot`, `pause_toggle` and `exit_emulator` are recognised but have no
handler yet: a control bound to them draws and does nothing. Anything else,
`rewind` included, is not in the bind table at all, so the control falls back
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
`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
stretching to it, so a bezel gets an exact 160x144 picture; `viewport_fill` is
parsed but does not stretch. `overlayN_viewport_expand = true` is an extension
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.
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.
## 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.
## Installing
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
the launcher window while the Skins tab is open. A zip is mounted in place, so
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
(`overlay.cfg` is preferred when there are several) and the images it names.
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
| Skin | Source | Shape |
| --- | --- | --- |
| `gb_anim` | `gamepads/gb_anim_portrait` | handheld shell, working buttons, two pages |
| `tv_crt` | `borders/tv-integer` | CRT television frame, no buttons |
Attribution lives in each folder's `README.md`. `tv_crt` is a photograph of a
real television: CC-BY-4.0 upstream, but treat it as a test asset rather than
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.
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor.
| Preset | Size |
| --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 |
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 |
| Ultrawide 21:9 | 2560x1080 |
| Super Game Boy border | 256x224 |
The Super Game Boy preset locks the viewport to the real screen window,
160x144 at (48,40), so an SGB border cannot be drawn out of register.
**Editing.** Click a control to select it, drag to move, eight handles to
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
coordinate its art was drawn at. 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. Drop a PNG
or JPG on the window to import art into the skin.
**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.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** packs it as one zip
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
images, and the original `.cfg` when it came from one. An exported skin drops
straight back into `skins/` and still opens in RetroArch.
## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
Image assignment cycles through art already in the skin folder; there is no
file browser, so new art arrives by drag and drop.
+55 -3
View File
@@ -36,7 +36,7 @@ do
end
end
local Game, EditorApp, Importer, TouchEditor
local Game, EditorApp, Importer, TouchEditor, Studio
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
-- it, so it is declared here rather than next to love.quit).
@@ -255,7 +255,43 @@ function closeTouchControlsEditor()
end
end
local function bootGame(version)
-- ------------------------------------------------------------ skin studio
local studioHost
local closeSkinStudio
local bootGame
local function openSkinStudio(version, skinId)
local SkinStudio = require("src.ui.SkinStudio")
if not SkinStudio.available_desktop() then return end
studioHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
Studio = SkinStudio
Studio.load({
version = version,
skinId = skinId,
onClose = function() closeSkinStudio() end,
onPlay = function(v)
closeSkinStudio()
Importer = nil
bootGame(v or version)
end,
})
end
function closeSkinStudio()
if Studio and Studio.unload then Studio.unload() end
Studio = nil
Importer = studioHost
studioHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
end
function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
-- Set the active version and overlay its extracted cache BEFORE anything
@@ -456,6 +492,8 @@ function love.load(args)
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
end
@@ -466,6 +504,7 @@ function love.update(dt)
NxDisplay.sync()
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Studio then return Studio.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
@@ -522,6 +561,12 @@ function love.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Studio then
HostDisplay.beginFrame("skin_studio", Studio)
local result = Studio.draw()
HostDisplay.endFrame("skin_studio", Studio)
return result
end
if Importer then
GameViewport.reset()
HostDisplay.beginFrame("launcher", Importer)
@@ -555,12 +600,13 @@ end
function love.keypressed(key, scancode, isrepeat)
if editorMode then return EditorApp.keypressed(key) end
if TouchEditor then return TouchEditor.keypressed(key) end
if Studio then return Studio.keypressed(key) end
if Importer then return Importer:keypressed(key) end
Game:keypressed(key)
end
function love.keyreleased(key)
if editorMode or TouchEditor then return end
if editorMode or TouchEditor or Studio then return end
if Importer then return end
Game:keyreleased(key)
end
@@ -789,6 +835,7 @@ function love.wheelmoved(x, y)
return
end
if TouchEditor then return end
if Studio then return Studio.wheelmoved(x, y) end
if Importer then return end
Game:wheelmoved(x, y)
end
@@ -826,6 +873,7 @@ 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 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
@@ -860,6 +908,7 @@ 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 Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -877,6 +926,7 @@ 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 editorMode or Importer then return end
if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
@@ -887,6 +937,7 @@ end
function love.textinput(text)
if TouchEditor then return end
if Studio then return Studio.textinput(text) end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
@@ -964,6 +1015,7 @@ function love.filedropped(file)
if editorMode and EditorApp and EditorApp.filedropped then
return EditorApp.filedropped(file)
end
if Studio then return Studio.filedropped(file) end
if Importer then Importer:filedropped(file) end
end
+37
View File
@@ -59,6 +59,9 @@ function Game:load()
self.touchControls = TouchControls
TouchControls:init()
TouchControls:setHotkeyHandler(function(action, pressed)
self:touchSkinHotkey(action, pressed)
end)
self.renderer = Renderer
Renderer:init()
@@ -185,6 +188,40 @@ function Game:returnToTitle()
self.stack:push(self:makeTitleState())
end
Game.SKIN_FAST_FORWARD = 4
function Game:touchSkinHotkey(action, pressed)
if action == "fast_forward_hold" then
if pressed then
self.skinSpeedSaved = self.speedOverride
self.speedOverride = Game.SKIN_FAST_FORWARD
else
self.speedOverride = self.skinSpeedSaved
self.skinSpeedSaved = nil
end
elseif action == "fast_forward_toggle" then
if pressed then self:_cycleSpeed(1) end
elseif action == "soft_reset" then
if pressed then
Input:reset()
TouchControls:reset()
self:returnToTitle()
end
elseif action == "menu" then
if pressed then
local Screens = require("src.ui.Screens")
local top = self.stack and self.stack:top()
if top and not top.isTouchSkinMenu then
local state = Screens.build(self, "OptionsMenu")
if state then
state.isTouchSkinMenu = true
self.stack:push(state)
end
end
end
end
end
function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
+92
View File
@@ -0,0 +1,92 @@
local SkinZip = {}
local bxor
do
local ok, bit = pcall(require, "bit")
if ok and bit and bit.bxor then
bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end
else
local byteXor = {}
local function xor8(a, b)
local key = a * 256 + b
local memo = byteXor[key]
if memo then return memo end
local r, bitv = 0, 1
local x, y = a, b
for _ = 1, 8 do
if (x % 2) ~= (y % 2) then r = r + bitv end
x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2
end
byteXor[key] = r
return r
end
bxor = function(a, b)
local r, mul = 0, 1
for _ = 1, 4 do
r = r + xor8(a % 256, b % 256) * mul
a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256
end
return r
end
end
end
local crcTable
local function crc32(s)
if not crcTable then
crcTable = {}
for i = 0, 255 do
local c = i
for _ = 1, 8 do
if c % 2 == 1 then
c = bxor(math.floor(c / 2), 0xEDB88320)
else
c = math.floor(c / 2)
end
end
crcTable[i] = c
end
end
local crc = 0xFFFFFFFF
for i = 1, #s do
crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256))
end
return bxor(crc, 0xFFFFFFFF) % 0x100000000
end
local function le16(n)
n = math.floor(n) % 0x10000
return string.char(n % 256, math.floor(n / 256) % 256)
end
local function le32(n)
n = math.floor(n) % 0x100000000
return string.char(n % 256, math.floor(n / 256) % 256,
math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256)
end
function SkinZip.encode(entries)
local out, central, offset = {}, {}, 0
for _, entry in ipairs(entries) do
local name, data = entry.name, entry.data or ""
local crc, size = crc32(data), #data
local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. name
out[#out + 1] = local_
out[#out + 1] = data
central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0)
.. le32(0) .. le32(offset) .. name
offset = offset + #local_ + #data
end
local dir = table.concat(central)
return table.concat(out) .. dir
.. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries)
.. le32(#dir) .. le32(offset) .. le16(0)
end
return SkinZip
+219 -7
View File
@@ -39,6 +39,7 @@
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = {}
@@ -189,6 +190,7 @@ function TouchControls.normalizeConfig(tc)
-- no caller ever has to nil-check a bucket's scale
if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end
if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end
local saved = type(tc.layouts) == "table" and tc.layouts or nil
for _, o in ipairs(ORIENTATIONS) do
local b = saved and saved[o]
@@ -241,6 +243,7 @@ end
function TouchControls:init()
self.active = wantsOverlay()
TouchSkin.setOverlayLive(self.active)
self.enabled = true
-- vibration level for presses (#806); applyOptions overwrites it from
-- options.haptics, this is the value a harness that never applies options
@@ -261,6 +264,10 @@ function TouchControls:init()
self.held = {}
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.skinId = nil
self.skinError = nil
self.hotkeysHeld = {}
TouchSkin.setActive(nil)
self.img = nil
-- Images load whenever the platform wants the overlay OR the launcher
-- editor forces a preview (desktop testing of the editor).
@@ -285,6 +292,8 @@ function TouchControls:applyOptions(opts)
-- haptics is a plain top-level option, not part of the layout config the
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active)
self:selectSkin(cfg.skin)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -300,7 +309,7 @@ end
-- Snapshot for the editor's save path: enabled plus both orientation
-- buckets, matching what options.lua stores (#633).
function TouchControls:config()
local out = { enabled = self.enabled ~= false, layouts = {} }
local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} }
for _, o in ipairs(ORIENTATIONS) do
local b = self.layouts and self.layouts[o] or nil
out.layouts[o] = {
@@ -323,11 +332,56 @@ function TouchControls:setPreview(on)
end
function TouchControls:visible()
if self.preview then return self.img ~= nil end
return self.active and self.enabled ~= false and self.img ~= nil
and not self.controllerHidden
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
return self.active and not self.controllerHidden
end
local function surfaceRect()
local r = TouchSkin.surfaceRect
if r then return r.w, r.h, r.x, r.y end
if love and love.graphics and love.graphics.getDimensions then
local w, h = love.graphics.getDimensions()
return w, h, 0, 0
end
return 0, 0, 0, 0
end
TouchControls.surfaceRect = surfaceRect
function TouchControls:selectSkin(id)
if id == self.skinId and TouchSkin.active then return TouchSkin.active end
self:reset()
self.skinError = nil
if not id or id == "" then
self.skinId = nil
TouchSkin.setActive(nil)
return nil
end
local skin, err = TouchSkin.select(id)
if not skin then
self.skinId = nil
self.skinError = err
TouchSkin.setActive(nil)
return nil, err
end
self.skinId = id
return skin
end
function TouchControls:skin()
if not self:visible() then return nil end
return TouchSkin.active
end
function TouchControls:setHotkeyHandler(fn)
self.hotkeyHandler = type(fn) == "function" and fn or nil
end
local skinHitSet, applySkinSet
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
@@ -451,6 +505,15 @@ end
-- Which control (if any) contains (x, y). Prefer face buttons over the
-- d-pad when they overlap, matching touchpressed's order.
function TouchControls:hitTest(x, y)
local page = TouchSkin.page()
if page then
local ww, wh, ox, oy = surfaceRect()
for i = #page.controls, 1, -1 do
local ctl = page.controls[i]
if TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then return ctl.spec, ctl end
end
return nil
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
@@ -505,6 +568,86 @@ local function setDpad(self, touch, dir)
if dir then pressBtn(self, dir) end
end
local function pressKey(key, down)
local fn = down and love.keypressed or love.keyreleased
if type(fn) == "function" then pcall(fn, key, key, false) end
end
local function fireHotkey(self, action, pressed, ctl)
if action == "overlay_next" then
if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end
return pressed
end
if action == "overlay_prev" then
if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end
return pressed
end
self.hotkeysHeld = self.hotkeysHeld or {}
local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1)
if n < 0 then n = 0 end
self.hotkeysHeld[action] = n > 0 and n or nil
local edge = (pressed and n == 1) or (not pressed and n == 0)
if edge and self.hotkeyHandler then
pcall(self.hotkeyHandler, action, pressed)
end
return false
end
local function enterControl(self, ctl)
local buzzed = false
for _, btn in ipairs(ctl.buttons) do
if not self.held[btn] then buzzed = true end
pressBtn(self, btn)
end
for _, key in ipairs(ctl.keys) do pressKey(key, true) end
local switched = false
for _, action in ipairs(ctl.hotkeys) do
if fireHotkey(self, action, true, ctl) then switched = true end
end
if not buzzed and (ctl.keys[1] or ctl.hotkeys[1]) then
TouchControls.buzz(self.haptics)
end
return switched
end
local function exitControl(self, ctl)
for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end
for _, key in ipairs(ctl.keys) do pressKey(key, false) end
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
end
function skinHitSet(self, x, y)
local page = TouchSkin.page()
if not page then return nil end
local ww, wh, ox, oy = surfaceRect()
local set = nil
for _, ctl in ipairs(page.controls) do
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
set = set or {}
set[ctl] = true
end
end
return set
end
function applySkinSet(self, touch, set)
local prev = touch.set or {}
local switched = false
for ctl in pairs(prev) do
if not (set and set[ctl]) then exitControl(self, ctl) end
end
for ctl in pairs(set or {}) do
if not prev[ctl] then
if enterControl(self, ctl) then switched = true end
end
end
touch.set = set
if switched then
for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end
touch.set = nil
end
end
-- Returns true when this touch was captured by a virtual control -- the
-- pad's first refusal on the gameplay pointer seam (#807). Capture is
-- decided here, at press, and rides self.touches[id] for the touch's
@@ -513,13 +656,22 @@ end
function TouchControls:touchpressed(id, x, y)
-- preview mode is layout-edit only: never press GB buttons
if self.preview then return end
if not (self.active and self.enabled ~= false and self.img) then return end
if not (self.active and self.enabled ~= false
and (TouchSkin.active or self.img)) then return end
-- a controller hid the overlay; the first touch only brings it back
-- (uncaptured: it began on no control, so mods may still see it)
if self.controllerHidden then
self.controllerHidden = false
return
end
if TouchSkin.active then
local set = skinHitSet(self, x, y)
if not set then return end
local touch = { control = "skin" }
self.touches[id] = touch
applySkinSet(self, touch, set)
return true
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then
@@ -544,9 +696,14 @@ end
function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
if touch.control == "skin" then
applySkinSet(self, touch, skinHitSet(self, x, y))
return
end
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
if not touch or touch.control ~= "dpad" then return end
if touch.control ~= "dpad" then return end
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
end
@@ -555,7 +712,9 @@ function TouchControls:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
if touch.control == "dpad" then
if touch.control == "skin" then
applySkinSet(self, touch, nil)
elseif touch.control == "dpad" then
setDpad(self, touch, nil)
self.dpadTouch = nil
else
@@ -568,6 +727,15 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for _, touch in pairs(self.touches or {}) do
if touch.control == "skin" and touch.set then
for ctl in pairs(touch.set) do exitControl(self, ctl) end
end
end
for action in pairs(self.hotkeysHeld or {}) do
if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end
end
self.hotkeysHeld = {}
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
@@ -608,12 +776,56 @@ 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)
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
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
end
function TouchControls:drawSkin(alphaMul)
local page = TouchSkin.page()
if not page then return false end
local ww, wh, sox, soy = surfaceRect()
local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy)
local opacity = (self.skinOpacity or 1) * alphaMul
love.graphics.push("all")
love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity)
local pressed = {}
for _, touch in pairs(self.touches or {}) do
for ctl in pairs(touch.set or {}) do pressed[ctl] = true end
end
for _, ctl in ipairs(page.controls) do
local down = pressed[ctl] == true
local img = (down and ctl.pressedImage) or ctl.image
if img then
local cx, cy, halfW, halfH =
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)
end
end
love.graphics.pop()
return true
end
-- OS-window space, called after GameViewport.finish so the overlay rides on
-- top of the game, companion composition and post-processing without being
-- captured or scaled with any game viewport. Also used by the launcher layout
-- editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
if TouchSkin.active then
local mul = (self.preview and self.enabled == false) and 0.45 or 1
if self:drawSkin(mul) then return end
end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
+842
View File
@@ -0,0 +1,842 @@
local TouchSkin = {}
TouchSkin.BUNDLED_ROOT = "assets/skins"
TouchSkin.USER_ROOT = "skins"
TouchSkin.GB_BUTTONS = {
a = "a", b = "b", start = "start", select = "select",
up = "up", down = "down", left = "left", right = "right",
}
TouchSkin.HOTKEYS = {
overlay_next = "overlay_next",
overlay_previous = "overlay_prev",
menu_toggle = "menu",
reset = "soft_reset",
hold_fast_forward = "fast_forward_hold",
fast_forward = "fast_forward_hold",
toggle_fast_forward = "fast_forward_toggle",
screenshot = "screenshot",
pause_toggle = "pause",
exit_emulator = "quit",
}
local DEFAULT_ASPECT = 16 / 9
local PORTRAIT_ASPECT = 9 / 16
local function trim(s)
return (tostring(s or ""):match("^%s*(.-)%s*$"))
end
local function unquote(s)
s = trim(s)
local inner = s:match('^"(.*)"$')
return inner or s
end
local function toBool(v)
v = trim(v):lower()
return v == "true" or v == "1"
end
local function tokens(s)
local out = {}
for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end
return out
end
local function num(v, fallback)
local n = tonumber(trim(v))
if not n or n ~= n then return fallback end
return n
end
function TouchSkin.parseConfig(text)
local kv = {}
for line in tostring(text or ""):gmatch("[^\r\n]+") do
if not line:match("^%s*#") then
local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
if key then kv[key] = unquote(value) end
end
end
return kv
end
local function parseBinds(spec)
local buttons, hotkeys, keys, decorative = {}, {}, {}, true
for raw in tostring(spec or ""):gmatch("[^|]+") do
local name = trim(raw):lower()
local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$")
if name == "nul" or name == "" then
-- decoration
elseif key then
keys[#keys + 1] = key
decorative = false
elseif TouchSkin.GB_BUTTONS[name] then
buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name]
decorative = false
elseif TouchSkin.HOTKEYS[name] then
hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name]
decorative = false
end
end
return buttons, hotkeys, keys, decorative
end
local function parseDesc(kv, prefix, page)
local spec = kv[prefix]
if not spec then return nil end
local t = tokens(spec)
if #t < 6 then return nil end
local shape = trim(t[4]):lower()
if shape ~= "radial" and shape ~= "rect" then shape = "rect" end
local buttons, hotkeys, keys, decorative = parseBinds(t[1])
local reachX = num(kv[prefix .. "_reach_x"], 1)
local reachY = num(kv[prefix .. "_reach_y"], 1)
local ctl = {
spec = t[1],
buttons = buttons,
hotkeys = hotkeys,
keys = keys,
decorative = decorative,
x = num(t[2], 0.5),
y = num(t[3], 0.5),
shape = shape,
rangeX = math.abs(num(t[5], 0.05)),
rangeY = math.abs(num(t[6], 0.05)),
rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod),
alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod),
reachUp = num(kv[prefix .. "_reach_up"], reachY),
reachDown = num(kv[prefix .. "_reach_down"], reachY),
reachLeft = num(kv[prefix .. "_reach_left"], reachX),
reachRight = num(kv[prefix .. "_reach_right"], reachX),
imagePath = kv[prefix .. "_overlay"],
pressedImagePath = kv[prefix .. "_overlay_pressed"],
nextTarget = kv[prefix .. "_next_target"],
}
if ctl.imagePath == "" then ctl.imagePath = nil end
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
return ctl
end
function TouchSkin.parse(text)
local kv = TouchSkin.parseConfig(text)
local count = math.floor(num(kv.overlays, 0))
if count <= 0 then return nil, "no overlays" end
local pages = {}
for i = 0, count - 1 do
local p = "overlay" .. i
local page = {
index = i + 1,
name = kv[p .. "_name"] or ("overlay" .. i),
imagePath = kv[p .. "_overlay"],
fullScreen = toBool(kv[p .. "_full_screen"]),
normalized = toBool(kv[p .. "_normalized"]),
rangeMod = num(kv[p .. "_range_mod"], 1),
alphaMod = num(kv[p .. "_alpha_mod"], 1),
aspect = num(kv[p .. "_aspect_ratio"], nil),
controls = {},
}
if page.imagePath == "" then page.imagePath = nil end
if not page.aspect or page.aspect <= 0 then
page.aspect = page.name:lower():find("portrait", 1, true)
and PORTRAIT_ASPECT or DEFAULT_ASPECT
end
local rect = tokens(kv[p .. "_rect"])
page.rect = { x = 0, y = 0, w = 1, h = 1 }
if #rect >= 4 then
page.rect = { x = num(rect[1], 0), y = num(rect[2], 0),
w = num(rect[3], 1), h = num(rect[4], 1) }
end
local vp = tokens(kv[p .. "_viewport"])
if #vp >= 4 then
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
w = num(vp[3], 1), h = num(vp[4], 1) }
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end
local descs = math.floor(num(kv[p .. "_descs"], 0))
for d = 0, descs - 1 do
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
if ctl then page.controls[#page.controls + 1] = ctl end
end
pages[#pages + 1] = page
end
return { pages = pages }
end
local function readFile(path)
if love and love.filesystem and love.filesystem.read then
local ok, data = pcall(love.filesystem.read, path)
if ok and data then return data end
end
local handle = io.open(path, "rb")
if not handle then return nil end
local data = handle:read("*a")
handle:close()
return data
end
local function listDir(path)
if love and love.filesystem and love.filesystem.getDirectoryItems then
local ok, items = pcall(love.filesystem.getDirectoryItems, path)
if ok and items then return items end
end
return {}
end
local function isDir(path)
if love and love.filesystem and love.filesystem.getInfo then
local ok, info = pcall(love.filesystem.getInfo, path)
if ok and info then return info.type == "directory" end
end
return false
end
local function joinPath(root, rel)
rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/")
if rel:sub(1, 1) == "/" then return rel:sub(2) end
return root .. "/" .. rel
end
TouchSkin.NATIVE_NAME = "skin.lua"
local function findConfig(root)
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
end
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
for _, name in ipairs(named) do
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
end
local items = listDir(root)
table.sort(items)
for _, name in ipairs(items) do
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
end
return nil
end
local function loadDataChunk(text, name)
if loadstring then
local chunk, err = loadstring(text, name)
if not chunk then return nil, err end
return setfenv(chunk, {})
end
return load(text, name, "t", {})
end
function TouchSkin.parseNative(text)
local chunk, err = loadDataChunk(tostring(text or ""), "skin")
if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end
local ok, data = pcall(chunk)
if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end
if type(data.pages) ~= "table" or not data.pages[1] then
return nil, "skin.lua has no pages"
end
local pages = {}
for i, raw in ipairs(data.pages) do
if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end
local page = {
index = i,
name = tostring(raw.name or ("page" .. i)),
imagePath = raw.image,
fullScreen = raw.fullScreen ~= false,
normalized = true,
rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT),
rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {},
}
if type(raw.rect) == "table" then
page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0),
w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) }
end
if type(raw.viewport) == "table" then
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
page.viewportFill = raw.viewport.fill == true
page.viewportExpand = raw.viewport.expand == true
end
for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
page.controls[#page.controls + 1] = {
spec = tostring(c.bind or "nul"),
buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative,
x = num(c.x, 0.5), y = num(c.y, 0.5),
shape = c.shape == "radial" and "radial" or "rect",
rangeX = math.abs(num(c.w, 0.1)) * 0.5,
rangeY = math.abs(num(c.h, 0.1)) * 0.5,
rangeMod = num(c.rangeMod, page.rangeMod),
alphaMod = num(c.alphaMod, page.alphaMod),
reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1),
reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1),
imagePath = c.image,
pressedImagePath = c.imagePressed,
nextTarget = c.nextTarget,
}
end
pages[#pages + 1] = page
end
return { pages = pages, name = data.name, author = data.author,
notes = data.notes, format = "native" }
end
function TouchSkin.toNative(skin)
local out = {
name = skin.name or skin.id,
author = skin.author,
notes = skin.notes,
format = 1,
pages = {},
}
for _, page in ipairs(skin.pages or {}) do
local p = {
name = page.name,
image = page.imagePath,
fullScreen = page.fullScreen ~= false,
rangeMod = page.rangeMod,
alphaMod = page.alphaMod,
aspect = page.aspect,
controls = {},
}
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
or page.rect.w ~= 1 or page.rect.h ~= 1) then
p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h }
end
if page.viewport then
p.viewport = {
x = page.viewport.x, y = page.viewport.y,
w = page.viewport.w, h = page.viewport.h,
fill = page.viewportFill or nil,
expand = page.viewportExpand or nil,
}
end
for _, ctl in ipairs(page.controls or {}) do
p.controls[#p.controls + 1] = {
bind = ctl.spec,
x = ctl.x, y = ctl.y,
w = ctl.rangeX * 2, h = ctl.rangeY * 2,
shape = ctl.shape,
rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil,
alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil,
reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil,
reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil,
reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil,
reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil,
image = ctl.imagePath,
imagePressed = ctl.pressedImagePath,
nextTarget = ctl.nextTarget,
}
end
out.pages[#out.pages + 1] = p
end
return out
end
function TouchSkin.serialize(skin)
return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin))
end
local imageCache = setmetatable({}, { __mode = "v" })
local function loadImage(path)
if not (love and love.graphics and love.graphics.newImage) then return nil end
local cached = imageCache[path]
if cached then return cached end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then return nil end
if img.setFilter then img:setFilter("linear", "linear") end
imageCache[path] = img
return img
end
function TouchSkin.load(root, id)
local cfgPath, format = findConfig(root)
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
local text = readFile(cfgPath)
if not text then return nil, "unreadable " .. cfgPath end
local skin, err
if format == "native" then
skin, err = TouchSkin.parseNative(text)
else
skin, err = TouchSkin.parse(text)
end
if not skin then return nil, err end
skin.id = id or root:match("([^/]+)$") or root
skin.root = root
skin.configPath = cfgPath
skin.format = format
skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id
for _, page in ipairs(skin.pages) do
if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath))
end
for _, ctl in ipairs(page.controls) do
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
if ctl.pressedImagePath then
ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath))
end
end
end
return skin
end
local function mountZip(archive, point)
if not (love and love.filesystem and love.filesystem.mount) then return false end
local ok, mounted = pcall(love.filesystem.mount, archive, point)
return ok and mounted == true
end
function TouchSkin.list()
local out, seen = {}, {}
local function scan(root, source)
for _, name in ipairs(listDir(root)) do
local id = name:gsub("%.zip$", "")
if not seen[id] then
local path = root .. "/" .. name
if name:match("%.zip$") then
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
if mountZip(path, point) and findConfig(point) then
seen[id] = true
out[#out + 1] = { id = id, root = point, source = source, archive = path }
end
elseif isDir(path) and findConfig(path) then
seen[id] = true
out[#out + 1] = { id = id, root = path, source = source }
end
end
end
end
if love and love.filesystem and love.filesystem.createDirectory then
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
end
scan(TouchSkin.USER_ROOT, "user")
scan(TouchSkin.BUNDLED_ROOT, "bundled")
table.sort(out, function(a, b) return a.id < b.id end)
return out
end
-- Drop a .zip into <save>/skins and report the id it will list under.
function TouchSkin.installArchive(name, data)
if not data or data == "" then return nil, "empty archive" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
name = tostring(name or ""):match("([^/\\]+)$") or ""
name = name:gsub("[^%w%._%-]", "_")
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
if id == "" then return nil, "bad archive name" end
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
local dest = TouchSkin.USER_ROOT .. "/" .. name
local ok, err = love.filesystem.write(dest, data)
if not ok then return nil, tostring(err) end
local entry = TouchSkin.find(id)
if not entry then
love.filesystem.remove(dest)
return nil, "no skin.lua or .cfg inside " .. name
end
return id
end
function TouchSkin.find(id)
if not id or id == "" then return nil end
for _, entry in ipairs(TouchSkin.list()) do
if entry.id == id then return entry end
end
return nil
end
function TouchSkin.assetPaths(skin)
local out, seen = {}, {}
local function add(rel)
if rel and rel ~= "" and not seen[rel] then
seen[rel] = true
out[#out + 1] = rel
end
end
for _, page in ipairs(skin.pages or {}) do
add(page.imagePath)
for _, ctl in ipairs(page.controls or {}) do
add(ctl.imagePath)
add(ctl.pressedImagePath)
end
end
return out
end
function TouchSkin.export(skin, destPath)
if not skin then return nil, "no skin" end
local SkinZip = require("src.core.SkinZip")
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
local missing = {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local data = readFile(joinPath(skin.root, rel))
if data then
entries[#entries + 1] = { name = rel, data = data }
else
missing[#missing + 1] = rel
end
end
if skin.configPath and skin.format == "retroarch" then
local cfg = readFile(skin.configPath)
if cfg then
entries[#entries + 1] =
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
end
end
local blob = SkinZip.encode(entries)
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
if not absolute and love and love.filesystem and love.filesystem.write then
local ok, err = love.filesystem.write(destPath, blob)
if not ok then return nil, tostring(err) end
else
local handle = io.open(destPath, "wb")
if not handle then return nil, "cannot write " .. destPath end
handle:write(blob)
handle:close()
end
return destPath, missing
end
TouchSkin.BINDS = {
"nul",
"up", "down", "left", "right",
"a", "b", "start", "select",
"left|up", "right|up", "left|down", "right|down",
"hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle",
"overlay_next",
}
function TouchSkin.describeBind(spec)
local buttons, hotkeys, keys, decorative = parseBinds(spec)
if decorative then return "decoration" end
local parts = {}
for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end
for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end
for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end
return table.concat(parts, " + ")
end
function TouchSkin.newControl(spec, x, y, w, h, shape)
local buttons, hotkeys, keys, decorative = parseBinds(spec)
return {
spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative,
x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5,
shape = shape == "radial" and "radial" or "rect",
rangeMod = 1, alphaMod = 1,
reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1,
}
end
function TouchSkin.setBind(ctl, spec)
ctl.spec = spec
ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec)
return ctl
end
function TouchSkin.newSkin(id)
local page = {
index = 1, name = "main", fullScreen = true, normalized = true,
rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT,
rect = { x = 0, y = 0, w = 1, h = 1 },
viewport = { x = 0, y = 0, w = 1, h = 0.5 },
viewportFill = false,
controls = {},
}
return { id = id or "new_skin", name = id or "new_skin",
root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"),
format = "native", pages = { page } }
end
local function copyTable(src)
if type(src) ~= "table" then return src end
local out = {}
for k, v in pairs(src) do out[k] = copyTable(v) end
return out
end
function TouchSkin.clone(skin)
if not skin then return nil end
local out = {
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
author = skin.author, notes = skin.notes, configPath = skin.configPath,
source = skin.source, pages = {},
}
for i, page in ipairs(skin.pages or {}) do
local p = copyTable(page)
p.image = page.image
p.controls = {}
for j, ctl in ipairs(page.controls or {}) do
local c = copyTable(ctl)
c.image = ctl.image
c.pressedImage = ctl.pressedImage
p.controls[j] = c
end
out.pages[i] = p
end
return out
end
function TouchSkin.resolveImage(root, rel)
if not rel or rel == "" then return nil end
return loadImage(joinPath(root, rel))
end
function TouchSkin.detectViewport(root, imagePath)
if not (love and love.image and love.image.newImageData) then return nil end
if not imagePath or imagePath == "" then return nil end
local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath))
if not ok or not data then return nil end
local w, h = data:getWidth(), data:getHeight()
if w < 2 or h < 2 then return nil end
local function clear(x, y)
if x < 0 or y < 0 or x >= w or y >= h then return false end
local okp, _, _, _, a = pcall(data.getPixel, data, x, y)
return okp and a ~= nil and a < 0.05
end
local cx, cy = math.floor(w / 2), math.floor(h / 2)
if not clear(cx, cy) then return nil end
local left, right = cx, cx
while left > 0 and clear(left - 1, cy) do left = left - 1 end
while right < w - 1 and clear(right + 1, cy) do right = right + 1 end
local top, bottom = cy, cy
while top > 0 and clear(cx, top - 1) do top = top - 1 end
while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end
local rw, rh = right - left + 1, bottom - top + 1
if rw < 8 or rh < 8 then return nil end
return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh
end
-- Bring outside art into the skin's own folder. A skin still living in
-- assets/ or a mounted zip is saved out to <save>/skins/<id> first, because
-- that is the only root the engine can write to.
function TouchSkin.importImage(skin, name, data)
if not skin then return nil, "no skin" end
if not data or data == "" then return nil, "no image data" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
name = tostring(name):gsub("[^%w%._%-]", "_")
if name == "" then return nil, "bad file name" end
local dest = TouchSkin.USER_ROOT .. "/" .. skin.id
if skin.root ~= dest then
local saved, err = TouchSkin.saveTo(skin, skin.id)
if not saved then return nil, tostring(err) end
end
pcall(love.filesystem.createDirectory, dest .. "/img")
local rel = "img/" .. name
local ok, err = love.filesystem.write(dest .. "/" .. rel, data)
if not ok then return nil, tostring(err) end
return rel
end
function TouchSkin.listImages(root)
local out = {}
local function scan(dir, prefix)
for _, name in ipairs(listDir(dir)) do
local path = dir .. "/" .. name
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
out[#out + 1] = prefix .. name
elseif isDir(path) and prefix == "" then
scan(path, name .. "/")
end
end
end
scan(root, "")
table.sort(out)
return out
end
function TouchSkin.saveTo(skin, id)
id = id or skin.id
if not id or id == "" then return nil, "no skin id" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
local dest = TouchSkin.USER_ROOT .. "/" .. id
pcall(love.filesystem.createDirectory, dest)
local copied, failed = 0, {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local target = dest .. "/" .. rel
local dir = target:match("^(.*)/[^/]+$")
if dir then pcall(love.filesystem.createDirectory, dir) end
if skin.root ~= dest then
local data = readFile(joinPath(skin.root, rel))
if data then
if love.filesystem.write(target, data) then copied = copied + 1 end
else
failed[#failed + 1] = rel
end
end
end
local ok, err = love.filesystem.write(dest .. "/" .. TouchSkin.NATIVE_NAME,
TouchSkin.serialize(skin))
if not ok then return nil, tostring(err) end
skin.id, skin.root, skin.format = id, dest, "native"
return dest, failed, copied
end
TouchSkin.active = nil
TouchSkin.pageIndex = 1
TouchSkin.surfaceRect = nil
function TouchSkin.setSurface(x, y, w, h)
if not w or w <= 0 or not h or h <= 0 then
TouchSkin.surfaceRect = nil
else
TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h }
end
return TouchSkin.surfaceRect
end
function TouchSkin.setActive(skin)
TouchSkin.active = skin or nil
TouchSkin.pageIndex = 1
return TouchSkin.active
end
function TouchSkin.select(id)
if not id or id == "" then return TouchSkin.setActive(nil) end
local entry = TouchSkin.find(id)
if not entry then return nil, "no skin " .. tostring(id) end
local skin, err = TouchSkin.load(entry.root, entry.id)
if not skin then return nil, err end
skin.source = entry.source
return TouchSkin.setActive(skin)
end
function TouchSkin.page()
local skin = TouchSkin.active
if not skin then return nil end
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
end
function TouchSkin.setPage(target)
local skin = TouchSkin.active
if not skin then return nil end
if type(target) == "number" then
local n = #skin.pages
TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1
return TouchSkin.page()
end
for i, page in ipairs(skin.pages) do
if page.name == target then
TouchSkin.pageIndex = i
return page
end
end
return TouchSkin.page()
end
function TouchSkin.nextPage(target)
local skin = TouchSkin.active
if not skin then return nil end
if target and target ~= "" then return TouchSkin.setPage(target) end
return TouchSkin.setPage(TouchSkin.pageIndex + 1)
end
function TouchSkin.pageBox(page, w, h, ox, oy)
ox, oy = ox or 0, oy or 0
if not page then return ox, oy, w, h end
local bx, by, bw, bh = ox, oy, w, h
if not page.fullScreen and h > 0 then
local displayAspect = w / h
if displayAspect > page.aspect then
bw = h * page.aspect
bx = ox + (w - bw) * 0.5
else
bh = w / page.aspect
by = oy + (h - bh) * 0.5
end
end
local r = page.rect
return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh
end
function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local cx, cy = bx + ctl.x * bw, by + ctl.y * bh
local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh
return cx, cy, halfW, halfH
end
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local left = halfW * ctl.reachLeft * ctl.rangeMod
local right = halfW * ctl.reachRight * ctl.rangeMod
local up = halfH * ctl.reachUp * ctl.rangeMod
local down = halfH * ctl.reachDown * ctl.rangeMod
local dx = px - cx
local dy = py - cy
local rx = dx < 0 and left or right
local ry = dy < 0 and up or down
if rx <= 0 or ry <= 0 then return false end
if ctl.shape == "radial" then
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
end
return math.abs(dx) <= rx and math.abs(dy) <= ry
end
TouchSkin.overlayLive = false
function TouchSkin.setOverlayLive(on)
TouchSkin.overlayLive = on and true or false
end
function TouchSkin.decorativeOnly()
local page = TouchSkin.page()
if not page then return false end
for _, ctl in ipairs(page.controls) do
if not ctl.decorative then return false end
end
return true
end
function TouchSkin.drawable()
if not TouchSkin.active then return false end
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
end
function TouchSkin.hasViewport()
local page = TouchSkin.page()
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
local vw, vh = v.w * w, v.h * h
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
return TouchSkin
+12
View File
@@ -122,6 +122,17 @@ local function addTouchRows(rows, add, opts, hooks)
end,
}
end
if hooks and hooks.openSkinStudio then
rows[#rows + 1] = {
label = Strings("SKIN STUDIO"),
actionLabel = Strings("Open"),
action = function()
hooks.openSkinStudio()
return false
end,
}
end
end
local function coreRows(opts, hooks)
@@ -638,6 +649,7 @@ end
-- writes options while a modal covers it, so the cached table stays true.
-- `hooks` carries the host actions a row cannot perform itself:
-- editTouchControls() -- hand the screen to the touch-overlay editor
-- openSkinStudio() -- hand the screen to the desktop skin studio
--
-- `version` is the game the gear was opened on. It picks the row set, and
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
+246 -5
View File
@@ -776,6 +776,26 @@ end
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
-- and the launcher ships no icon asset for them.
-- Skins tab glyph: a bezel with a screen cutout and two face buttons, drawn
-- rather than shipped as art so the tab needs no new asset.
local function drawSkinGlyph(x, y, w, h, hot)
local box = math.min(w, h)
local bx = x + (w - box) / 2
local by = y + (h - box) / 2
local pad = math.floor(box * 0.22)
local ow, oh = box - 2 * pad, box - 2 * pad
local ink = hot and PAL.inverse or PAL.ink
local a = 1
Theme.strokeRounded(bx + pad, by + pad, ow, oh, ink, a,
math.max(1, math.floor(Kit.scale)), math.floor(oh * 0.22))
local sw, sh = ow * 0.58, oh * 0.40
Theme.fillRounded(bx + pad + ow * 0.10, by + pad + oh * 0.14, sw, sh, ink, a,
math.max(1, math.floor(sh * 0.2)))
local r = math.max(1, oh * 0.09)
Theme.fillRounded(bx + pad + ow * 0.60, by + pad + oh * 0.64, r * 2, r * 2, ink, a, r)
Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
end
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
@@ -807,18 +827,46 @@ end
-- frame. Their tab rows, opts tables and action closures are built once
-- instead of 60 times a second -- only `active`, `image` and the queued
-- action are written per frame.
-- The four cartridges used to be four tabs of their own. They are one
-- dropdown now: the tab row was seven controls wide and wrapped to two rows on
-- anything narrow, and only ever one game is being looked at.
local GAME_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed,
label = "Red" },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue,
label = "Blue" },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold,
label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" },
}
local HEADER_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true },
}
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then t.opts.drawFn = drawSkinGlyph end
end
-- Which cartridge the dropdown is showing: the open game tab, else the last
-- one visited, else Red. Kept as a function so the mods/find/skins panels
-- still answer "for which game" without a game tab being open.
local function currentGame(imp)
for _, g in ipairs(GAME_TABS) do
if imp.tab == g.id then return g end
end
for _, g in ipairs(GAME_TABS) do
if imp.modScope == g.id then return g end
end
return GAME_TABS[1]
end
LauncherView.GAME_TABS = GAME_TABS
LauncherView.currentGame = currentGame
local QUIT_INK_HOT = { 0, 0, 0, 1 }
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
@@ -837,11 +885,27 @@ local function headerChrome(imp)
hot and QUIT_INK_HOT or QUIT_INK_REST)
end },
tab = {},
game = { face = "tab", font = "tab",
action = function()
local g = currentGame(imp)
if imp.tab == g.id then
imp._gamePopup = true
else
imp:_switchTab(g.id)
end
end },
}
for _, t in ipairs(HEADER_TABS) do
local id = t.id
c.tab[id] = function() imp:_switchTab(id) end
end
for _, g in ipairs(GAME_TABS) do
local id = g.id
c.tab[id] = function()
imp._gamePopup = nil
imp:_switchTab(id)
end
end
imp._headerChrome = c
return c
end
@@ -927,7 +991,10 @@ local function buildHeader(imp, m)
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
-- collide.
local tabs = HEADER_TABS
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
local ty = y + math.floor(6 * m.s)
@@ -935,6 +1002,40 @@ local function buildHeader(imp, m)
local tabRight = m.x + m.w - m.pad
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown, sized to its longest label so switching games
-- never reflows the row
local chrome0 = headerChrome(imp)
local game = currentGame(imp)
local labelW = 0
for _, g in ipairs(GAME_TABS) do
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
end
local dropW = math.min(tabRight - tabLeft,
tabH + labelW + math.floor(34 * m.s))
chrome0.game.color = game.color
chrome0.game.letter = game.letter
chrome0.game.active = imp.tab == game.id
local gameHot = Kit.hover(tx, ty, dropW, tabH)
local gameDown = gameHot and Kit.mouseDown
-- face "tab" inverts on hover as well as when active, so the caret has to
-- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
do
local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0)
if love.graphics.polygon then
Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
love.graphics.polygon("fill",
ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8)
love.graphics.setColor(1, 1, 1, 1)
end
end
tx = tx + dropW + tabGap
for _, t in ipairs(tabs) do
local w = tabH
if tx > tabLeft and tx + w > tabRight then
@@ -1909,6 +2010,117 @@ 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)
local skins = imp:_ensureSkins()
local active = imp:_activeSkin()
local gap = m.gap
local cy = y
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) + math.floor(8 * m.s)
end
-- Studio button. Desktop only: the host supplies the hook nowhere else.
if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
btn(imp, x, cy, bw, m.btnH, "skins-studio", label, {
kind = "accent", font = "small",
action = function()
-- the studio boots the game on Play, so hand it a real cartridge
imp.onOpenSkinStudio(imp.modScope or "red")
end })
local hint = Strings("Design bezels and button layouts, then test them.")
Kit.text("small", Kit.ellipsize("small", hint,
w - bw - math.floor(12 * m.s)), x + bw + math.floor(12 * m.s),
cy + math.floor((m.btnH - Kit.textHeight("small")) / 2), PAL.muted)
cy = cy + m.btnH + gap
end
Kit.caption(x, cy, Strings("INSTALLED"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
imp._skinGear = imp._skinGear
or love.graphics.newImage("assets/launcher/gear.png")
-- The row itself is "use this skin"; the gear beside it configures that
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
-- opens the studio, so neither lands on a screen that cannot edit it.
local function skinRow(key, id, title, detail, selected, configure)
local gearW = configure and rowH or 0
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
function() imp:_useSkin(id) end)
local tagW = selected
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
or math.floor(12 * m.s)
local textW = rowW - math.floor(24 * m.s) - tagW
local tx = x + math.floor(12 * m.s)
local ty = cy + math.floor(7 * m.s)
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
ink or PAL.heading)
Kit.text("small", Kit.ellipsize("small", detail, textW),
tx, ty + Kit.textHeight("mono"), ink or PAL.muted)
if selected then
Kit.textRight("small", Strings("IN USE"), x + rowW - math.floor(12 * m.s),
cy + math.floor((rowH - Kit.textHeight("small")) / 2), ink or PAL.green)
end
if configure then
btn(imp, x + w - gearW, cy, gearW, gearW, key .. "-cfg", "", {
face = "invert", image = imp._skinGear, action = configure })
end
cy = cy + rowH + math.floor(4 * m.s)
end
skinRow("skin-none", nil, Strings("Built-in pad"),
Strings("The default on-screen buttons."), active == nil,
imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red")
end or nil)
for _, entry in ipairs(skins) do
local bits = {}
bits[#bits + 1] = entry.source == "user" and Strings("installed")
or Strings("bundled")
if entry.controls > 0 then
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
else
bits[#bits + 1] = Strings("bezel only")
end
if entry.pages > 1 then
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
end
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
local configure = imp.onOpenSkinStudio and function()
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
end or nil
skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure)
end
if #skins == 0 then
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
Strings("No skins installed yet."))
cy = cy + math.floor(72 * m.s) + gap
end
cy = cy + math.floor(6 * m.s)
local TouchSkin = require("src.core.TouchSkin")
Kit.caption(x, cy, Strings("IMPORT"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
local boxH = math.floor(76 * m.s)
Kit.card(x, cy, w, boxH, "muted")
Kit.textWrapped("small", Strings(
"Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."),
x + math.floor(14 * m.s), cy + math.floor(12 * m.s),
w - math.floor(28 * m.s), PAL.muted, 3)
Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s),
cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint)
end
local function buildFindPanel(imp, x, y, w, availH, m)
imp:_ensureFind()
imp:_ensureMods()
@@ -2780,6 +2992,32 @@ local function buildModScopeModal(imp, m)
action = function() imp._modScopePopup = nil end })
end
-- The cartridge dropdown's list. Replaces the four R/B/Y/G tabs, so it is
-- also what a controller reaches after the tab row.
local function buildGameModal(imp, m)
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(360 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #GAME_TABS * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
local chrome = headerChrome(imp)
for _, g in ipairs(GAME_TABS) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id,
Strings(g.label), {
face = "tab", font = "small", letter = g.letter, color = g.color,
active = imp.tab == g.id,
action = chrome.tab[g.id] })
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close",
Strings("Close"), { font = "small",
action = function() imp._gamePopup = nil end })
end
-- Category filter for FIND MODS. Two columns, because an index can list
-- enough categories to overflow a single stacked column on a short window.
local function buildFilterModal(imp, m)
@@ -3753,6 +3991,7 @@ local function buildModals(imp, m)
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._gamePopup then buildGameModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
@@ -3906,6 +4145,8 @@ function LauncherView.draw(imp)
buildModsPanel(imp, x, contentY, w, availH, m)
elseif imp.tab == "find" then
buildFindPanel(imp, x, contentY, w, availH, m)
elseif imp.tab == "skins" then
buildSkinsPanel(imp, x, contentY, w, availH, m)
else
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
end
+82 -2
View File
@@ -1224,6 +1224,7 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
onEditTouchControls = opts.onEditTouchControls,
onOpenSkinStudio = opts.onOpenSkinStudio,
isNX = isNX,
romImportMode = romImportMode,
mobileFileBridge = mobileFileBridge,
@@ -1784,7 +1785,12 @@ function RomImporter:filedropped(file)
-- readDroppedFile does here.
local name = file:getFilename() or ""
if name:lower():match("%.zip$") then
self:_installMod(file)
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
if self.tab == "skins" then
self:_installSkinZip(file)
else
self:_installMod(file)
end
return
end
-- A dropped .sav is a battery save: import it to a new slot for the active
@@ -2621,7 +2627,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find" }
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -2987,11 +2993,78 @@ function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit
if id == "skins" then self:_ensureSkins(true) end
if GameVersion.VERSIONS[id] then
self:_setModScope(id)
end
end
-- ------- skins tab (touch skins + the desktop Skin Studio)
function RomImporter:_ensureSkins(force)
if self._skins and not force then return self._skins end
local TouchSkin = require("src.core.TouchSkin")
local out = {}
for _, entry in ipairs(TouchSkin.list()) do
local skin = TouchSkin.load(entry.root, entry.id)
local page = skin and skin.pages[1]
local controls = 0
for _, ctl in ipairs(page and page.controls or {}) do
if not ctl.decorative then controls = controls + 1 end
end
out[#out + 1] = {
id = entry.id,
source = entry.source,
pages = skin and #skin.pages or 0,
controls = controls,
screen = page ~= nil and page.viewport ~= nil,
ok = skin ~= nil,
}
end
self._skins = out
return out
end
function RomImporter:_activeSkin()
local opts = require("src.core.SaveData").loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
return tc.skin
end
function RomImporter:_useSkin(id)
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = true
tc.skin = id
opts.touchControls = tc
SaveData.saveOptions(opts)
self._skinNotice = {
ok = true,
text = id and ("Now using " .. id) or "Now using the built-in pad",
}
end
function RomImporter:_installSkinZip(file)
local TouchSkin = require("src.core.TouchSkin")
local name = file:getFilename() or ""
local data, readError = readDroppedFile(file)
if not data then
self._skinNotice = { ok = false,
text = "Could not read the dropped file: " .. tostring(readError) }
return
end
local id, err = TouchSkin.installArchive(name, data)
self:_ensureSkins(true)
if not id then
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
return
end
self._skinNotice = { ok = true, text = "Imported " .. id }
end
function RomImporter:_toggleFindSearchFocus()
self._findSearchFocus = not self._findSearchFocus
if self._findSearchFocus then
@@ -3018,6 +3091,13 @@ function RomImporter:_openSettings()
self.onEditTouchControls(version)
end
end
if self.onOpenSkinStudio then
local version = self.tab
hooks.openSkinStudio = function(skinId)
self:_closeSettings()
self.onOpenSkinStudio(version, skinId)
end
end
-- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
+29 -16
View File
@@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
local TouchSkin = require("src.core.TouchSkin")
local Renderer = {}
@@ -84,7 +85,13 @@ local function displayMetrics()
end
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return ww, wh, pw, ph, dpiX, dpiY
local vx, vy = 0, 0
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
if sw and sw >= 1 and sh >= 1 then
vx, vy = math.floor(sx), math.floor(sy)
pw, ph = math.floor(sw), math.floor(sh)
end
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
end
function Renderer:init()
@@ -274,6 +281,10 @@ function Renderer:worldViewSize()
-- this is the same sum with the viewport standing in for the window, so
-- both platforms show the same map area at the same zoom.
local cap = FaithfulRes.scaleCap()
if not cap and TouchSkin.hasViewport() then
local page = TouchSkin.page()
if not page.viewportExpand then cap = self:fitScale() end
end
if cap then
local uiw, uih = self:uiSize()
pw, ph = uiw * cap, uih * cap
@@ -745,7 +756,9 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
local vux, vuy = vx / dpiX, vy / dpiY
local vuw, vuh = pw / dpiX, ph / dpiY
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
@@ -753,8 +766,8 @@ function Renderer:endFrame(zones, worldZones)
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
@@ -770,8 +783,8 @@ function Renderer:endFrame(zones, worldZones)
end
local Ux, Uy = Up / dpiX, Up / dpiY
local uvpw, uvph = uiw * Ux, uih * Uy
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -921,7 +934,7 @@ function Renderer:endFrame(zones, worldZones)
-- skipped entirely (nothing drew into it). The UI blit below still
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
@@ -933,7 +946,7 @@ function Renderer:endFrame(zones, worldZones)
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
elseif self.worldActive then
@@ -941,8 +954,8 @@ function Renderer:endFrame(zones, worldZones)
local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -953,9 +966,9 @@ function Renderer:endFrame(zones, worldZones)
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
if not projected then
if worldZones then
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
else
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, vux, vuy, vuw, vuh)
end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -964,7 +977,7 @@ function Renderer:endFrame(zones, worldZones)
local redraws = PaletteFX.spriteRedraws()
if redraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local activeShader = nil
for _, r in ipairs(redraws) do
local wanted = r.colors
@@ -996,7 +1009,7 @@ function Renderer:endFrame(zones, worldZones)
if self.uprightActive then
local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
love.graphics.setScissor()
end
@@ -1007,7 +1020,7 @@ function Renderer:endFrame(zones, worldZones)
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
end
@@ -1029,7 +1042,7 @@ function Renderer:endFrame(zones, worldZones)
-- (and its duplicate #772).
if self.battleDim and self.battleDim > 0 then
love.graphics.setColor(0, 0, 0, self.battleDim)
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
for _, r in ipairs(subtractRect({ { vux, vuy, vuw, vuh } }, uox, uoy, uvpw, uvph)) do
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
end
love.graphics.setColor(1, 1, 1, 1)
+926
View File
@@ -0,0 +1,926 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData")
local Studio = {}
Studio.CANVASES = {
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 },
{ id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 },
{ id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 },
{ id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 },
{ id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224,
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
}
local HANDLE = 7
local HANDLES = {
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
{ "w", 0, 0.5 }, { "e", 1, 0.5 },
{ "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 },
}
local GB_ASPECT = 160 / 144
local function clamp01(v)
if v ~= v then return 0 end
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
local function round(v) return math.floor(v + 0.5) end
function Studio.canvas()
return Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1]
end
function Studio.page()
local skin = Studio.skin
if not skin then return nil end
return skin.pages[Studio.pageIndex] or skin.pages[1]
end
function Studio.selectedControl()
local page = Studio.page()
if not page then return nil end
return page.controls[Studio.selected]
end
local function markDirty()
Studio.dirty = true
Studio.status = nil
end
local function syncActive()
TouchSkin.setActive(Studio.skin)
TouchSkin.pageIndex = Studio.pageIndex
end
function Studio.setCanvas(index)
local n = #Studio.CANVASES
Studio.canvasIndex = ((index - 1) % n) + 1
local canvas = Studio.canvas()
local page = Studio.page()
if page and canvas.lockViewport then
page.viewport = {
x = canvas.lockViewport.x, y = canvas.lockViewport.y,
w = canvas.lockViewport.w, h = canvas.lockViewport.h,
}
page.viewportFill = false
markDirty()
end
if page then page.aspect = canvas.w / canvas.h end
end
function Studio.load(opts)
opts = opts or {}
Studio.onClose = opts.onClose
Studio.onPlay = opts.onPlay
Studio.version = opts.version
Studio.pageIndex = 1
Studio.selected = nil
Studio.testing = false
Studio.dirty = false
Studio.status = nil
Studio.drag = nil
Studio.pendingPlay = false
Studio.canvasIndex = 1
Studio.aspectLock = true
Studio.skinIdField = ""
Studio.available = TouchSkin.list()
Studio.imageTarget = "idle"
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchControls:setPreview(true)
local start = opts.skinId
if not start then
local saved = SaveData.loadOptions()
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
start = tc.skin
end
if start and TouchSkin.find(start) then
Studio.open(start)
else
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = Studio.skin.id
syncActive()
end
end
function Studio.open(id)
local entry = TouchSkin.find(id)
if not entry then return false end
local loaded = TouchSkin.load(entry.root, entry.id)
if not loaded then return false end
Studio.skin = TouchSkin.clone(loaded)
Studio.skinIdField = Studio.skin.id
Studio.pageIndex = 1
Studio.selected = nil
Studio.dirty = false
Studio.images = TouchSkin.listImages(Studio.skin.root)
syncActive()
return true
end
function Studio.unload()
Studio.pendingPlay = false
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
TouchControls:setPreview(false)
TouchControls:reset()
Studio.skin = nil
Studio.onClose = nil
Studio.onPlay = nil
Studio.drag = nil
end
-- --------------------------------------------------------------- editing
function Studio.addControl()
local page = Studio.page()
if not page then return end
page.controls[#page.controls + 1] =
TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial")
Studio.selected = #page.controls
markDirty()
end
function Studio.deleteControl()
local page = Studio.page()
if not page or not Studio.selected then return end
table.remove(page.controls, Studio.selected)
Studio.selected = page.controls[Studio.selected] and Studio.selected
or (#page.controls > 0 and #page.controls or nil)
markDirty()
end
function Studio.duplicateControl()
local page, ctl = Studio.page(), Studio.selectedControl()
if not page or not ctl then return end
local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04),
clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
copy.imagePath, copy.image = ctl.imagePath, ctl.image
copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage
copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod
table.insert(page.controls, Studio.selected + 1, copy)
Studio.selected = Studio.selected + 1
markDirty()
end
function Studio.cycleBind(dir)
local ctl = Studio.selectedControl()
if not ctl then return end
local list, at = TouchSkin.BINDS, 1
for i, spec in ipairs(list) do
if spec == ctl.spec then at = i break end
end
TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1])
markDirty()
end
function Studio.cycleImage(dir)
local ctl = Studio.selectedControl()
local page = Studio.page()
if not page then return end
Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root)
local list = Studio.images
local field = Studio.imageTarget
local owner = (field == "bezel") and page or ctl
if not owner then return end
local key = (field == "pressed") and "pressedImagePath" or "imagePath"
if field == "bezel" then key = "imagePath" end
local at = 0
for i, rel in ipairs(list) do
if rel == owner[key] then at = i break end
end
local next_ = at + dir
if next_ < 0 then next_ = #list end
if next_ > #list then next_ = 0 end
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
markDirty()
end
function Studio.assignImage(rel)
local page, ctl = Studio.page(), Studio.selectedControl()
local img = TouchSkin.resolveImage(Studio.skin.root, rel)
if ctl and Studio.imageTarget == "pressed" then
ctl.pressedImagePath, ctl.pressedImage = rel, img
elseif ctl and Studio.imageTarget == "idle" then
ctl.imagePath, ctl.image = rel, img
elseif page then
page.imagePath, page.image = rel, img
end
Studio.images = TouchSkin.listImages(Studio.skin.root)
Studio.dirty = true
end
function Studio.filedropped(file)
if not Studio.skin then return end
local path = (file.getFilename and file:getFilename()) or ""
local base = path:match("([^/\\]+)$") or path
if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then
Studio.status = "Drop a PNG or JPG to use it as art."
return
end
local ok, data = pcall(function()
file:open("r")
local bytes = file:read()
file:close()
return bytes
end)
if not ok or not data then
Studio.status = "Could not read " .. base
return
end
local rel, err = TouchSkin.importImage(Studio.skin, base, data)
if not rel then
Studio.status = "Import failed: " .. tostring(err)
return
end
Studio.assignImage(rel)
local where = Studio.selectedControl()
and (Studio.imageTarget == "pressed" and "pressed art" or "idle art")
or "bezel"
Studio.status = "Imported " .. rel .. " as " .. where
Studio.skinIdField = Studio.skin.id
end
function Studio.detectViewport()
local page = Studio.page()
if not page or not Studio.skin then return end
if Studio.canvas().lockViewport then
Studio.status = "This preset locks the screen position."
return
end
if not page.imagePath then
Studio.status = "Pick a bezel image first."
return
end
local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath)
if not rect then
Studio.status = "No transparent screen hole found in " .. page.imagePath
return
end
page.viewport = rect
Studio.status = ("Screen detected: %dx%d px in the bezel art"):format(pw, ph)
Studio.dirty = true
end
function Studio.toggleViewport()
local page = Studio.page()
if not page then return end
if Studio.canvas().lockViewport then return end
if page.viewport then
page.viewport = nil
else
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
end
markDirty()
end
function Studio.addPage()
local skin = Studio.skin
if not skin then return end
local page = TouchSkin.newSkin(skin.id).pages[1]
page.name = "page" .. (#skin.pages + 1)
page.index = #skin.pages + 1
skin.pages[#skin.pages + 1] = page
Studio.pageIndex = #skin.pages
Studio.selected = nil
syncActive()
markDirty()
end
function Studio.save()
local skin = Studio.skin
if not skin then return end
local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "")
if id == "" then
Studio.status = "Give the skin a name first."
return
end
local dest, failed = TouchSkin.saveTo(skin, id)
if not dest then
Studio.status = "Save failed: " .. tostring(failed)
return
end
Studio.dirty = false
Studio.available = TouchSkin.list()
Studio.status = "Saved to " .. dest
if type(failed) == "table" and failed[1] then
Studio.status = Studio.status .. " (" .. #failed .. " image(s) not found)"
end
end
function Studio.export()
local skin = Studio.skin
if not skin then return end
if Studio.dirty then Studio.save() end
local path, missing = TouchSkin.export(skin)
if not path then
Studio.status = "Export failed: " .. tostring(missing)
return
end
Studio.status = "Exported " .. path
Studio.available = TouchSkin.list()
end
function Studio.play()
local skin = Studio.skin
if not skin then return end
Studio.save()
if Studio.dirty then return end
local opts = SaveData.loadOptions()
local block = type(opts.touchControls) == "table" and opts.touchControls or {}
block.enabled = true
block.skin = skin.id
opts.touchControls = block
SaveData.saveOptions(opts)
-- Handing off here would unload the studio inside its own draw pass and
-- leave the rest of this frame drawing against a dead skin; Studio.update
-- runs it on the next tick instead.
Studio.pendingPlay = true
Studio.status = "Starting the game with " .. skin.id .. "..."
end
-- ---------------------------------------------------------------- canvas
function Studio.canvasRect(x, y, w, h)
local canvas = Studio.canvas()
local aspect = canvas.w / canvas.h
local cw, ch = w, w / aspect
if ch > h then ch, cw = h, h * aspect end
return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
end
local function controlRect(page, ctl, r)
local cx, cy, halfW, halfH =
TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y)
return cx - halfW, cy - halfH, halfW * 2, halfH * 2
end
local function viewportRect(page, r)
local v = page.viewport
if not v then return nil end
return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h
end
local function handleRects(bx, by, bw, bh)
local out = {}
for _, h in ipairs(HANDLES) do
out[#out + 1] = {
id = h[1],
x = bx + bw * h[2] - HANDLE * Kit.scale,
y = by + bh * h[3] - HANDLE * Kit.scale,
w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale,
}
end
return out
end
local function applyResize(id, bx, by, bw, bh, dx, dy)
if id:find("w") then bx = bx + dx bw = bw - dx end
if id:find("e") then bw = bw + dx end
if id:find("n") then by = by + dy bh = bh - dy end
if id:find("s") then bh = bh + dy end
return bx, by, math.max(4, bw), math.max(4, bh)
end
function Studio.beginCanvasDrag(mx, my, r)
local page = Studio.page()
if not page then return end
local ctl = Studio.selectedControl()
if ctl then
local bx, by, bw, bh = controlRect(page, ctl, r)
for _, h in ipairs(handleRects(bx, by, bw, bh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
end
local vx, vy, vw, vh = viewportRect(page, r)
if vx and not Studio.canvas().lockViewport then
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
Studio.selected = nil
return
end
end
end
for i = #page.controls, 1, -1 do
local c = page.controls[i]
local bx, by, bw, bh = controlRect(page, c, r)
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
Studio.selected = i
Studio.drag = { kind = "control-move", mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
if vx and not Studio.canvas().lockViewport
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
Studio.selected = nil
Studio.drag = { kind = "viewport-move", mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
return
end
Studio.selected = nil
end
function Studio.updateDrag(mx, my, r)
local d = Studio.drag
local page = Studio.page()
if not d or not page then return end
local dx, dy = mx - d.mx, my - d.my
local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh
if d.kind == "control-move" or d.kind == "viewport-move" then
bx, by = bx + dx, by + dy
else
bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy)
end
if d.kind:find("viewport") and Studio.aspectLock and d.handle then
-- Derive the axis the handle does not drive, or an edge handle fights the
-- lock and appears dead. A corner drives whichever way the pointer moved
-- furthest, so dragging it up/down resizes as readily as left/right.
local byHeight = (d.handle == "n" or d.handle == "s")
if not byHeight and #d.handle > 1 then
byHeight = math.abs(dy) > math.abs(dx)
end
if byHeight then
local newW = bh * GB_ASPECT
if d.handle:find("w") then bx = bx + (bw - newW)
elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end
bw = newW
else
local newH = bw / GB_ASPECT
if d.handle:find("n") then by = by + (bh - newH)
elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end
bh = newH
end
end
if d.kind:find("control") then
local ctl = Studio.selectedControl()
if not ctl then return end
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w)
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h)
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w)
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h)
else
page.viewport = {
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h),
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h),
}
end
markDirty()
end
-- ----------------------------------------------------------------- draw
local function drawCanvas(x, y, w, h)
local page = Studio.page()
local r = { }
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
Studio.lastCanvas = r
if not page then return r end
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
TouchSkin.setSurface(r.x, r.y, r.w, r.h)
syncActive()
local vx, vy, vw, vh = viewportRect(page, r)
if 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
Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1)
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5,
gw, { 20, 40, 20 })
end
TouchControls:draw()
TouchSkin.setSurface(nil)
Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2)
if Studio.testing then return r end
if vx then
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
if not Studio.canvas().lockViewport then
local a = Studio.selectedControl() and 0.45 or 1
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
end
end
end
for i, ctl in ipairs(page.controls) do
local bx, by, bw, bh = controlRect(page, ctl, r)
local selected = (i == Studio.selected)
local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line)
Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45,
selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2)
if selected then
for _, hd in ipairs(handleRects(bx, by, bw, bh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1)
end
Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale,
PAL.green)
end
end
return r
end
local function inspectorBody(x, y, w)
if not Studio.skin then return y end
local page = Studio.page()
local ctl = Studio.selectedControl()
local rowH = math.max(Kit.tapMin(), 30 * Kit.scale)
local gap = 6 * Kit.scale
local cy = y
Kit.caption(x, cy, "SKIN")
cy = cy + Kit.textHeight("small") + gap
Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH,
Studio.skinIdField, "skin name")
cy = cy + rowH + gap
local third = (w - gap * 2) / 3
if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end
if Kit.button(x + third + gap, cy, third, rowH, "Export", { id = "export" }) then
Studio.export()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then
Studio.play()
end
cy = cy + rowH + gap * 2
Kit.caption(x, cy, "OPEN")
cy = cy + Kit.textHeight("small") + gap
local half = (w - gap) / 2
if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = "new_skin"
Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {}
syncActive()
markDirty()
end
if Kit.button(x + half + gap, cy, half, rowH,
"Load " .. (#Studio.available > 0 and "\226\150\184" or "-"),
{ id = "load", enabled = #Studio.available > 0 }) then
Studio.loadIndex = ((Studio.loadIndex or 0) % #Studio.available) + 1
Studio.open(Studio.available[Studio.loadIndex].id)
end
cy = cy + rowH + gap * 2
Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {}))
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH, "Next page", { id = "pagenext" }) then
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
Studio.selected = nil
syncActive()
end
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
Studio.addPage()
end
cy = cy + rowH + gap
if page then
local bezel = page.imagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
Studio.imageTarget = "bezel"
Studio.cycleImage(1)
end
cy = cy + rowH + gap
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
enabled = not Studio.canvas().lockViewport }) then
Studio.toggleViewport()
end
Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH,
Studio.aspectLock, "10:9 lock", "aspect")
cy = cy + rowH + gap
if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect",
enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then
Studio.detectViewport()
end
cy = cy + rowH + gap * 2
end
Kit.caption(x, cy, "CONTROLS")
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end
if Kit.button(x + third + gap, cy, third, rowH, "Dup",
{ id = "dup", enabled = ctl ~= nil }) then
Studio.duplicateControl()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del",
{ id = "del", enabled = ctl ~= nil }) then
Studio.deleteControl()
end
cy = cy + rowH + gap
if not ctl then
Kit.textWrapped("small", page and #page.controls == 0
and "No controls yet. Add one, then drag it on the canvas."
or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3)
return cy + Kit.textHeight("small") * 3
end
local canvas = Studio.canvas()
if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then
Studio.cycleBind(1)
end
cy = cy + rowH + 2 * Kit.scale
Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then
ctl.shape = ctl.shape == "radial" and "rect" or "radial"
markDirty()
end
if Kit.button(x + half + gap, cy, half, rowH,
string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then
ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25)
markDirty()
end
cy = cy + rowH + gap
local quarter = (w - gap * 3) / 4
local fields = {
{ "X", round((ctl.x - ctl.rangeX) * canvas.w) },
{ "Y", round((ctl.y - ctl.rangeY) * canvas.h) },
{ "W", round(ctl.rangeX * 2 * canvas.w) },
{ "H", round(ctl.rangeY * 2 * canvas.h) },
}
for i, f in ipairs(fields) do
local fx = x + (quarter + gap) * (i - 1)
Kit.text("small", f[1], fx, cy, PAL.faint)
local id = "num" .. f[1]
local shown = Studio.editing == id and Studio.editBuf or tostring(f[2])
local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"),
quarter, rowH, shown, "0")
if Kit.focus == id then
Studio.editing, Studio.editBuf = id, typed
elseif Studio.editing == id then
Studio.commitField(id, Studio.editBuf)
Studio.editing, Studio.editBuf = nil, nil
end
end
cy = cy + Kit.textHeight("small") + rowH + gap
Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint)
cy = cy + Kit.textHeight("small") + gap
local idle = ctl.imagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then
Studio.imageTarget = "idle"
Studio.cycleImage(1)
end
cy = cy + rowH + gap
local pressed = ctl.pressedImagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then
Studio.imageTarget = "pressed"
Studio.cycleImage(1)
end
return cy + rowH
end
local function drawInspector(x, y, w, h)
local pad = 10 * Kit.scale
Kit.card(x, y, w, h)
local scroll = Studio.inspectorScroll or 0
if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then
scroll = scroll - Kit.wheelY * 48 * Kit.scale
end
local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2))
scroll = math.max(0, math.min(scroll, maxScroll))
Studio.inspectorScroll = scroll
Kit.pushClip(x, y, w, h)
local top = y + pad - scroll
local endY = inspectorBody(x + pad, top, w - pad * 2) or top
Studio.inspectorH = endY - top
Kit.popClip()
if maxScroll > 0 then
local frac = h / (h + maxScroll)
local barH = math.max(24 * Kit.scale, h * frac)
local barY = y + (h - barH) * (scroll / maxScroll)
Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4)
end
end
function Studio.commitField(id, text)
local ctl = Studio.selectedControl()
if not ctl then return end
local n = tonumber(text)
if not n then return end
local canvas = Studio.canvas()
local left = (ctl.x - ctl.rangeX) * canvas.w
local top = (ctl.y - ctl.rangeY) * canvas.h
local wpx = ctl.rangeX * 2 * canvas.w
local hpx = ctl.rangeY * 2 * canvas.h
if id == "numX" then left = n
elseif id == "numY" then top = n
elseif id == "numW" then wpx = math.max(1, n)
elseif id == "numH" then hpx = math.max(1, n) end
ctl.rangeX = (wpx * 0.5) / canvas.w
ctl.rangeY = (hpx * 0.5) / canvas.h
ctl.x = clamp01((left + wpx * 0.5) / canvas.w)
ctl.y = clamp01((top + hpx * 0.5) / canvas.h)
markDirty()
end
function Studio.draw()
local W, H = love.graphics.getDimensions()
Kit.layout(W, H)
local mx, my = love.mouse.getPosition()
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, W, H, PAL.bg, 1)
local pad = 14 * Kit.scale
local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + 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 canvas = Studio.canvas()
if Kit.button(bx, pad * 0.5, 200 * Kit.scale, 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,
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
if Studio.dirty then
Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow)
end
local closeW = 100 * Kit.scale
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "close" }) then
if Studio.onClose then Studio.onClose() end
Kit.endFrame()
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 msg = Studio.status
if not msg and Studio.testing then
local held = {}
for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end
table.sort(held)
msg = "TEST: click the pad. Held: "
.. (held[1] and table.concat(held, ", ") or "(none)")
end
if not msg then
msg = "Drag to move, corner handles to resize, blue box is the game screen."
end
Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, PAL.detail)
Kit.endFrame()
Studio.canvasArea = r
end
-- ---------------------------------------------------------------- input
function Studio.update()
if not Studio.pendingPlay then return end
Studio.pendingPlay = false
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
if onPlay then onPlay(version, canvas) end
end
function Studio.mousepressed(x, y, button)
if button ~= 1 then return end
Studio.clicked = true
local r = Studio.lastCanvas
if not r then return end
if Studio.testing then
if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then
TouchControls:touchpressed("studio", x, y)
end
return
end
local slop = HANDLE * 2 * Kit.scale
if x < r.x - slop or x > r.x + r.w + slop
or y < r.y - slop or y > r.y + r.h + slop then
return
end
Studio.beginCanvasDrag(x, y, r)
end
function Studio.mousemoved(x, y)
if Studio.testing then
TouchControls:touchmoved("studio", x, y)
return
end
if Studio.drag and 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
if Studio.testing then
TouchControls:touchreleased("studio", x, y)
return
end
Studio.drag = nil
end
function Studio.wheelmoved(_, dy)
Studio.wheel = dy
end
function Studio.textinput(text)
Kit.textinput(text)
end
function Studio.keypressed(key)
if Kit.focus then
Kit.keypressed(key)
return
end
if key == "escape" then
if Studio.onClose then Studio.onClose() end
elseif key == "delete" or key == "backspace" then
Studio.deleteControl()
elseif key == "n" then
Studio.addControl()
elseif key == "d" then
Studio.duplicateControl()
elseif key == "t" then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
elseif key == "s" then
Studio.save()
elseif key == "tab" then
local page = Studio.page()
if page and #page.controls > 0 then
Studio.selected = ((Studio.selected or 0) % #page.controls) + 1
end
else
Kit.keypressed(key)
end
end
function Studio.available_desktop()
local osName = love.system and love.system.getOS and love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
return Studio
+123 -11
View File
@@ -18,6 +18,7 @@
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local TouchSkin = require("src.core.TouchSkin")
local PadCursor = require("src.ui.PadCursor")
local GamepadMap = require("src.core.GamepadMap")
@@ -46,6 +47,15 @@ local function roundRect(mode, x, y, w, h, r)
love.graphics.rectangle(mode, x, y, w, h, r, r)
end
local function fitText(font, text, maxW)
text = tostring(text)
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
while #text > 1 and font:getWidth(text .. "...") > maxW do
text = text:sub(1, #text - 1)
end
return text .. "..."
end
function Editor.load(opts)
opts = opts or {}
Editor.onClose = opts.onClose
@@ -74,9 +84,52 @@ function Editor.load(opts)
TouchControls:applyOptions(applied)
TouchControls:setPreview(true)
Editor.enabled = TouchControls.enabled ~= false
Editor.skins = TouchSkin.list()
Editor.exportMsg = nil
PadCursor.reset()
end
function Editor.skinIndex()
for i, entry in ipairs(Editor.skins or {}) do
if entry.id == TouchControls.skinId then return i + 1 end
end
return 1
end
function Editor.skinLabel()
local entry = (Editor.skins or {})[Editor.skinIndex() - 1]
if not entry then return "Built-in pad" end
local skin = TouchSkin.active
local pages = skin and #skin.pages or 1
if pages > 1 then return entry.id .. " (" .. pages .. " pages)" end
return entry.id
end
function Editor.cycleSkin(dir)
local n = #(Editor.skins or {}) + 1
local idx = ((Editor.skinIndex() - 1 + (dir or 1)) % n) + 1
local entry = Editor.skins[idx - 1]
Editor.exportMsg = nil
TouchControls:selectSkin(entry and entry.id or nil)
TouchControls:ensureImages()
end
function Editor.exportSkin()
local skin = TouchSkin.active
if not skin then return end
local path, missing = TouchSkin.export(skin)
if not path then
Editor.exportMsg = "Export failed: " .. tostring(missing)
return
end
Editor.exportMsg = "Exported to " .. path .. " in your save folder."
if type(missing) == "table" and missing[1] then
Editor.exportMsg = Editor.exportMsg
.. " " .. #missing .. " image(s) were missing and left out."
end
Editor.skins = TouchSkin.list()
end
function Editor.unload()
TouchControls:setPreview(false)
TouchControls:reset()
@@ -93,6 +146,7 @@ local function persist()
local cfg = TouchControls:config()
local block = {
enabled = cfg.enabled,
skin = cfg.skin,
layouts = cfg.layouts,
}
if Editor.version == "gold" then
@@ -158,6 +212,8 @@ function Editor.draw()
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
if TouchSkin.active then TouchControls:draw() end
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
@@ -170,16 +226,18 @@ function Editor.draw()
love.graphics.setLineWidth(1)
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print(
fitText(Editor.fonts.title, "Touch Controls", reset.x - 10 * s - (ox + pad)),
ox + pad, oy + pad + 4 * s)
local function chromeBtn(r, label, fill)
col(fill, 0.9)
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
@@ -223,9 +281,48 @@ function Editor.draw()
chromeBtn(toggle, on and "Disable" or "Enable",
on and PAL.red or PAL.green)
local skinY = cardY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, skinY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, skinY, cardW, cardH, 12 * s)
local stepW = 52 * s
local skinNext = { x = cardX + cardW - 16 * s - stepW,
y = skinY + (cardH - btnH) / 2, w = stepW, h = btnH }
local skinPrev = { x = skinNext.x - 10 * s - stepW, y = skinNext.y,
w = stepW, h = btnH }
Editor.rects.skinNext, Editor.rects.skinPrev = skinNext, skinPrev
local leftmost = skinPrev.x
if TouchSkin.active then
local expW = 86 * s
local export = { x = skinPrev.x - 10 * s - expW, y = skinPrev.y,
w = expW, h = btnH }
Editor.rects.export = export
leftmost = export.x
else
Editor.rects.export = nil
end
local skinTextW = leftmost - 10 * s - (cardX + 16 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
love.graphics.print("Skin", cardX + 16 * s, skinY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
col(TouchControls.skinId and PAL.green or PAL.white)
love.graphics.print(fitText(Editor.fonts.btn, Editor.skinLabel(), skinTextW),
cardX + 16 * s, skinY + 34 * s)
chromeBtn(skinPrev, "<", { 60, 70, 110 })
chromeBtn(skinNext, ">", { 60, 70, 110 })
if Editor.rects.export then
chromeBtn(Editor.rects.export, "Export", { 60, 70, 110 })
end
-- size card (#633): -/+ resize every control in the orientation on
-- screen; the heading names it so it is plain the other one is untouched
local sizeY = cardY + cardH + 10 * s
local sizeY = skinY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
@@ -243,7 +340,6 @@ function Editor.draw()
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
cardX + 16 * s, sizeY + 34 * s)
local stepW = 52 * s
local plus = { x = cardX + cardW - 16 * s - stepW,
y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
@@ -254,13 +350,19 @@ function Editor.draw()
-- hint
love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9)
local hint = on
and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
local hint
if Editor.exportMsg then
hint = Editor.exportMsg
elseif not on then
hint = "Controls are hidden in-game. Enable them to show and edit the layout."
elseif TouchSkin.active then
hint = "This skin owns its own art, hitboxes and screen position, so size and dragging are off. Drop a RetroArch overlay folder or .zip into the skins folder of your save directory to add more."
else
hint = "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
end
love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
if not TouchSkin.active then TouchControls:draw() end
-- highlight the control under drag
if Editor.drag then
@@ -282,6 +384,9 @@ local function beginDrag(id, x, y)
if inside(Editor.rects.done, x, y) then close(); return end
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
if inside(Editor.rects.skinPrev, x, y) then Editor.cycleSkin(-1); return end
if inside(Editor.rects.skinNext, x, y) then Editor.cycleSkin(1); return end
if inside(Editor.rects.export, x, y) then Editor.exportSkin(); return end
if inside(Editor.rects.sizeDown, x, y) then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
end
@@ -289,6 +394,7 @@ local function beginDrag(id, x, y)
TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
end
if TouchSkin.active then return end
local name = TouchControls:hitTest(x, y)
if not name then return end
local zone = TouchControls:layout()[name]
@@ -439,6 +545,12 @@ function Editor.keypressed(key)
close()
elseif key == "r" then
resetLayout()
elseif key == "[" then
Editor.cycleSkin(-1)
elseif key == "]" then
Editor.cycleSkin(1)
elseif key == "e" then
Editor.exportSkin()
-- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
elseif key == "-" or key == "kp-" then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
+100
View File
@@ -0,0 +1,100 @@
-- Driver: renders the launcher's SKINS tab, exercises Use / import, and
-- shoots it. Stands in for main.lua's Importer branch so the panel can be
-- eyeballed without clicking through the real launcher.
-- SHOT_DIR=/tmp/skinstab POKEPORT_DRIVER=tests/drivers/launcher_skins_tab_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local RomImporter = require("src.import.RomImporter")
local TouchSkin = require("src.core.TouchSkin")
local dir = os.getenv("SHOT_DIR") or "/tmp/skinstab"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
U.wait(2)
local studioOpenedWith = nil
local imp = RomImporter.new(function() end, {
launcher = true,
onOpenSkinStudio = function(v) studioOpenedWith = v end,
})
local pending = nil
love.draw = function()
imp:draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
imp:update(1 / 60)
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
U.log(f and "shot" or "FAIL shot", name)
if f then f:close() end
end
imp:_switchTab("skins")
U.wait(3)
U.log("tab:", imp.tab)
local skins = imp:_ensureSkins()
U.log("skins listed:", #skins)
for _, e in ipairs(skins) do
U.log((" %s source=%s pages=%d buttons=%d screen=%s ok=%s"):format(
e.id, e.source, e.pages, e.controls, tostring(e.screen), tostring(e.ok)))
end
U.log("active skin:", tostring(imp:_activeSkin()))
shot("skins_tab.png")
imp._gamePopup = true
U.wait(2)
shot("game_dropdown.png")
imp._gamePopup = nil
U.wait(2)
-- pick one, the way clicking its row does
imp:_useSkin("tv_crt")
U.wait(2)
U.log("after use:", tostring(imp:_activeSkin()), "notice:", imp._skinNotice.text)
shot("skins_tab_selected.png")
-- studio hook carries a real cartridge, not the "skins" tab id
imp.modScope = "red"
if imp.onOpenSkinStudio then imp.onOpenSkinStudio(imp.modScope or "red") end
U.log("studio opened with version:", tostring(studioOpenedWith))
-- import: a dropped zip on this tab is a skin, not a mod
local exported = TouchSkin.export(
assert(TouchSkin.load("assets/skins/gb_anim", "gb_anim")),
"skins/dropped_probe.zip")
U.log("staged archive:", tostring(exported))
local raw = love.filesystem.read("skins/dropped_probe.zip")
love.filesystem.remove("skins/dropped_probe.zip")
-- same shape LOVE's DroppedFile presents to readDroppedFile
local dropped = {
getFilename = function() return "/tmp/my_cool_skin.zip" end,
getSize = function() return #raw end,
open = function() return true end,
read = function() return raw end,
close = function() return true end,
}
imp:filedropped(dropped)
U.log("import notice:", imp._skinNotice.text)
U.log("skins after import:", #imp:_ensureSkins())
U.log("my_cool_skin found:", tostring(TouchSkin.find("my_cool_skin") ~= nil))
imp:_useSkin(nil)
shot("skins_tab_imported.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+116
View File
@@ -0,0 +1,116 @@
-- Driver: authors a CRT bezel skin from nothing using only Skin Studio
-- actions -- New, drop the art on the window, Detect screen, Save -- then
-- loads the saved folder back and renders the game through it. Answers
-- "could the studio have made tv_crt?" by doing it.
-- SHOT_DIR=/tmp/author POKEPORT_DRIVER=tests/drivers/skin_studio_author_tv.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
local TouchSkin = require("src.core.TouchSkin")
local dir = os.getenv("SHOT_DIR") or "/tmp/author"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
U.wait(2)
local baseDraw = love.draw
local pending = nil
love.draw = function()
Studio.draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
coroutine.yield()
end
U.wait(3)
end
-- a stand-in for LOVE's DroppedFile, same three calls Studio.filedropped uses
local function droppedFile(realPath)
local handle
return {
getFilename = function() return realPath end,
open = function() handle = assert(io.open(realPath, "rb")) end,
read = function() return handle:read("*a") end,
close = function() if handle then handle:close() handle = nil end end,
}
end
Studio.load({ onClose = function() end })
U.wait(3)
-- 1. New skin, named
Studio.skin = TouchSkin.newSkin("my_tv")
Studio.skinIdField = "my_tv"
Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {}
TouchSkin.setActive(Studio.skin)
U.log("1. new skin:", Studio.skin.id, "controls:", #Studio.page().controls)
-- 2. Desktop canvas preset
for i, c in ipairs(Studio.CANVASES) do
if c.id == "desktop_1080" then Studio.setCanvas(i) end
end
U.log("2. canvas:", Studio.canvas().label, Studio.canvas().w .. "x" .. Studio.canvas().h)
-- 3. Drop the bezel art on the window
Studio.filedropped(droppedFile("assets/skins/tv_crt/img/tv-integer.png"))
U.log("3. drop:", Studio.status)
U.log(" bezel:", tostring(Studio.page().imagePath), "root:", Studio.skin.root)
-- 4. Detect the screen hole from the art's alpha
Studio.detectViewport()
local v = Studio.page().viewport
U.log("4. detect:", Studio.status)
U.log((" viewport %.4f %.4f %.4f %.4f"):format(v.x, v.y, v.w, v.h))
shot("author_studio.png")
-- 5. Save
Studio.save()
U.log("5. save:", Studio.status)
-- what landed on disk
local root = "skins/my_tv"
for _, name in ipairs(love.filesystem.getDirectoryItems(root)) do
local info = love.filesystem.getInfo(root .. "/" .. name)
U.log(" file:", name, info and info.type)
end
local src = love.filesystem.read(root .. "/skin.lua")
U.log(" skin.lua bytes:", src and #src)
U.log(" skin.lua:", (src or ""):gsub("%s+", " "):sub(1, 260))
-- 6. Load the saved folder back as a normal skin and play through it
Studio.unload()
local reloaded, err = TouchSkin.load(root, "my_tv")
U.log("6. reload:", reloaded and "ok" or tostring(err),
"format:", reloaded and reloaded.format,
"controls:", reloaded and #reloaded.pages[1].controls)
game.save.options.touchControls = { enabled = true, skin = "my_tv" }
game.save.options.tilt = 0
game.save.options.zoom = 0
game.save.options.pipelines = {}
game:applyOptions()
local TouchControls = require("src.core.TouchControls")
U.log(" active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError))
U.log(" decorativeOnly:", TouchSkin.decorativeOnly(), "drawable:", TouchSkin.drawable())
love.draw = baseDraw
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(12)
U.shot(game, dir .. "/author_result.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+57
View File
@@ -0,0 +1,57 @@
-- Driver: presses Play in the Skin Studio the way the button does, then keeps
-- drawing and pumping update so the deferred handoff runs. Regression cover
-- for the crash where Play unloaded the studio inside its own draw pass.
-- POKEPORT_DRIVER=tests/drivers/skin_studio_play_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
U.wait(2)
local booted, bootVersion = false, nil
Studio.load({
version = "red",
onClose = function() end,
onPlay = function(v)
-- what main.lua's handler does: unload, then boot
Studio.unload()
booted, bootVersion = true, v
end,
})
U.wait(3)
U.log("loaded skin:", Studio.skin and Studio.skin.id)
Studio.skinIdField = "play_probe"
-- press Play exactly as the inspector button does
Studio.play()
U.log("after play(): pendingPlay =", tostring(Studio.pendingPlay),
"skin alive =", tostring(Studio.skin ~= nil), "booted =", tostring(booted))
U.log("status:", tostring(Studio.status))
-- the frame that queued the handoff must still draw
local okDraw, errDraw = pcall(Studio.draw)
U.log("draw on the click frame:", okDraw, errDraw or "")
-- now the deferred handoff
local okUp, errUp = pcall(Studio.update, 1 / 60)
U.log("update handoff:", okUp, errUp or "")
U.log("booted =", tostring(booted), "version =", tostring(bootVersion),
"skin =", tostring(Studio.skin))
-- and a draw after teardown, which is what actually crashed before
local okAfter, errAfter = pcall(Studio.draw)
U.log("draw after teardown:", okAfter, errAfter or "")
local okUp2 = pcall(Studio.update, 1 / 60)
U.log("second update:", okUp2, "booted still once =", tostring(booted))
if okDraw and okUp and okAfter and booted then
U.log("RESULT pass")
else
U.log("RESULT FAIL")
end
love.event.quit()
while true do coroutine.yield() end
end
+107
View File
@@ -0,0 +1,107 @@
-- Driver: shoots the desktop Skin Studio (#1386) across its canvas presets
-- and in Test mode. The studio is a top-level love.draw branch in main.lua,
-- so this stands in for that branch and captures the frames itself.
-- SHOT_DIR=/tmp/studio POKEPORT_DRIVER=tests/drivers/skin_studio_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
local TouchControls = require("src.core.TouchControls")
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 })
U.wait(2)
local pending = nil
love.draw = function()
Studio.draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
if f then f:close() U.log("shot", name) else U.log("FAIL shot", name) end
end
Studio.load({ skinId = "gb_anim", onClose = function() end })
U.wait(5)
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.selected = 9
U.wait(3)
local ctl = Studio.selectedControl()
U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y)
shot("studio_selected.png")
-- drag the selected control and confirm the model moved
local r = Studio.lastCanvas
if r and ctl then
local before = ctl.x
Studio.beginCanvasDrag(r.x + ctl.x * r.w, r.y + ctl.y * r.h, r)
Studio.updateDrag(r.x + ctl.x * r.w - 60, r.y + ctl.y * r.h, r)
Studio.drag = nil
U.log("drag moved x:", before, "->", ctl.x)
end
U.wait(3)
shot("studio_dragged.png")
-- numeric entry: type an exact pixel X
if ctl then
Studio.commitField("numX", "100")
U.log("after numX=100 px, x fraction:", ctl.x, "rangeX:", ctl.rangeX)
end
Studio.testing = true
TouchControls:setPreview(false)
U.wait(3)
if r then
local page = Studio.page()
local TouchSkin = require("src.core.TouchSkin")
TouchSkin.setSurface(r.x, r.y, r.w, r.h)
local cx, cy = TouchSkin.controlGeometry(page, page.controls[13], r.w, r.h, r.x, r.y)
Studio.mousepressed(cx, cy, 1)
TouchSkin.setSurface(nil)
local held = {}
for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end
U.log("test press held:", table.concat(held, ","))
end
U.wait(3)
shot("studio_test.png")
Studio.mousereleased(0, 0, 1)
Studio.testing = false
TouchControls:setPreview(true)
-- Super Game Boy border preset: viewport locks to the 160x144 window
for i, c in ipairs(Studio.CANVASES) do
if c.id == "sgb_border" then Studio.setCanvas(i) end
end
U.wait(3)
local vp = Studio.page().viewport
U.log("sgb viewport:", vp.x, vp.y, vp.w, vp.h)
shot("studio_sgb.png")
for i, c in ipairs(Studio.CANVASES) do
if c.id == "ultrawide" then Studio.setCanvas(i) end
end
U.wait(3)
shot("studio_ultrawide.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+59
View File
@@ -0,0 +1,59 @@
-- Driver: shoots the launcher's Touch Controls editor with the built-in pad
-- and with the bundled skin picked, so the Skin row (#1386) can be eyeballed.
-- The editor is a top-level love.draw branch in main.lua, not a stack state,
-- so this stands in for that branch and captures the frame itself.
-- SHOT_DIR=/tmp/skin POKEPORT_DRIVER=tests/drivers/touch_skin_editor_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Editor = require("src.ui.TouchControlsEditor")
local TouchControls = require("src.core.TouchControls")
local dir = os.getenv("SHOT_DIR") or "/tmp/skin"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(432, 768, { resizable = true, highdpi = true })
U.wait(2)
local pending = nil
love.draw = function()
Editor.draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 60 do
if not pending then break end
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
if f then f:close() U.log("shot", name) else U.log("FAIL shot", name) end
end
Editor.load({ onClose = function() end })
U.wait(5)
U.log("skins:", #Editor.skins, "label:", Editor.skinLabel())
shot("editor_pad.png")
Editor.cycleSkin(1)
U.wait(5)
U.log("picked:", tostring(TouchControls.skinId), Editor.skinLabel())
shot("editor_skin.png")
Editor.exportSkin()
U.wait(5)
U.log("export:", tostring(Editor.exportMsg))
U.log("skins after export:", #Editor.skins)
shot("editor_export.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+71
View File
@@ -0,0 +1,71 @@
-- Driver: shoots the bundled RetroArch-format touch skin (assets/skins/gb_anim)
-- over a live overworld in a portrait window, idle and with A/DOWN held.
-- SHOT_DIR=/tmp/skin POKEPORT_TOUCH=1 POKEPORT_DRIVER=tests/drivers/touch_skin_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TouchControls = require("src.core.TouchControls")
local TouchSkin = require("src.core.TouchSkin")
local Pokemon = require("src.pokemon.Pokemon")
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 })
U.wait(2)
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "bryan"
if os.getenv("SKIN") == "none" then skinId = nil end
game.save.options.touchControls = { enabled = true, skin = skinId }
game.save.options.tilt = 0
game.save.options.zoom = 0
game.save.options.pipelines = {}
game:applyOptions()
U.log("skins on disk:")
for _, entry in ipairs(TouchSkin.list()) do
U.log(" ", entry.id, entry.source, entry.root)
end
U.log("active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError))
local page = TouchSkin.page()
U.log("page:", page and page.name, "controls:", page and #page.controls)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local R = game.renderer
local pw, ph = love.graphics.getPixelDimensions()
U.log("pixel dims:", pw, ph)
U.log("skin viewport:", TouchSkin.viewport(pw, ph))
U.log("hasViewport:", TouchSkin.hasViewport(), "fitScale:", R:fitScale())
U.log("worldViewSize:", R:worldViewSize())
U.log("worldOverride:", tostring(R.worldOverride), "worldActive:", tostring(R.worldActive))
U.shot(game, dir .. "/skin_idle.png")
local ww, wh = love.graphics.getDimensions()
local function at(nx, ny) return nx * ww, ny * wh end
TouchControls:touchpressed("t1", at(0.87407, 0.72417))
TouchControls:touchpressed("t2", at(0.24074, 0.79771))
U.wait(4)
U.log("held:", (function()
local out = {}
for btn in pairs(TouchControls.held or {}) do out[#out + 1] = btn end
table.sort(out)
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))
U.wait(4)
TouchControls:touchpressed("t3", at(0.95, 0.528))
TouchControls:touchreleased("t3", at(0.95, 0.528))
U.wait(6)
U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name)
U.shot(game, dir .. "/skin_page2.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+53
View File
@@ -0,0 +1,53 @@
-- Driver: shoots the bundled desktop CRT bezel (assets/skins/tv_crt) over a
-- live overworld in a 16:9 window, and checks the alpha-based screen detector
-- against the viewport the .cfg declares.
-- SHOT_DIR=/tmp/tv POKEPORT_DRIVER=tests/drivers/tv_skin_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TouchControls = require("src.core.TouchControls")
local TouchSkin = require("src.core.TouchSkin")
local Pokemon = require("src.pokemon.Pokemon")
local dir = os.getenv("SHOT_DIR") or "/tmp/tv"
local skinId = os.getenv("SKIN") or "tv_crt"
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
U.wait(2)
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "bryan"
game.save.options.touchControls = { enabled = true, skin = skinId }
game.save.options.tilt = 0
game.save.options.zoom = 0
game.save.options.pipelines = {}
game:applyOptions()
U.log("active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError))
local page = TouchSkin.page()
U.log("page:", page and page.name, "controls:", page and #page.controls)
local v = page and page.viewport
U.log("declared viewport:", v and v.x, v and v.y, v and v.w, v and v.h)
local detected = TouchSkin.detectViewport(TouchSkin.active.root, page.imagePath)
if detected then
U.log(("detected hole: %.4f %.4f %.4f %.4f"):format(
detected.x, detected.y, detected.w, detected.h))
U.log(("delta vs declared: %.4f %.4f %.4f %.4f"):format(
detected.x - v.x, detected.y - v.y, detected.w - v.w, detected.h - v.h))
else
U.log("FAIL no hole detected")
end
local pw, ph = love.graphics.getPixelDimensions()
U.log("pixel dims:", pw, ph)
U.log("engine viewport px:", TouchSkin.viewport(pw, ph))
U.log("fitScale:", game.renderer:fitScale())
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(12)
U.shot(game, dir .. "/tv_overworld.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+86
View File
@@ -0,0 +1,86 @@
-- Launcher SKINS tab (#1386): the tab next to Find that lists skins, imports
-- a dropped .zip and opens the desktop Skin Studio. The rendered panel is
-- exercised by tests/drivers/launcher_skins_tab_shot.lua; this pins the
-- archive installer and the launcher wiring around it.
-- luajit tests/engine/launcher_skins_tab.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
-- ------------------------------------------------------ archive installer
eq(select(1, TouchSkin.installArchive("skin.zip", nil)), nil,
"an empty archive is refused")
eq(select(1, TouchSkin.installArchive("skin.zip", "")), nil,
"a zero-byte archive is refused")
eq(select(1, TouchSkin.installArchive("notes.txt", "data")), nil,
"a non-zip is refused")
eq(select(1, TouchSkin.installArchive("", "data")), nil,
"a nameless drop is refused")
-- A zip carrying no skin file must not leave a stray archive behind: the
-- list would keep trying to mount it on every visit to the tab.
local junk = "PK\3\4 not really a skin"
local id, err = TouchSkin.installArchive("bogus.zip", junk)
eq(id, nil, "a zip with no skin.lua or .cfg is refused")
check(tostring(err):find("bogus.zip", 1, true) ~= nil,
"and the error names the file")
eq(love.filesystem.read(TouchSkin.USER_ROOT .. "/bogus.zip"), nil,
"the rejected archive is cleaned up, not left to fail on every listing")
-- the id a good archive would land under is the file name without .zip
check(TouchSkin.find("bogus") == nil, "a refused archive lists nothing")
-- ------------------------------------------------------- launcher wiring
local view = read("src/import/LauncherView.lua")
local imp = read("src/import/RomImporter.lua")
check(view:find('id = "skins"', 1, true) ~= nil,
"LauncherView registers a skins tab")
check(view:find("drawSkinGlyph", 1, true) ~= nil,
"the skins tab draws its own glyph rather than shipping an asset")
-- the tab has to be next to Find, which is what the request was
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
check(order ~= nil, "HEADER_TABS found")
if order then
local findAt = order:find('id = "find"', 1, true)
local skinsAt = order:find('id = "skins"', 1, true)
check(findAt and skinsAt and skinsAt > findAt,
"the skins tab sits immediately after Find")
end
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
"the panel dispatch routes the skins tab")
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
-- the panel must not offer the studio when the host did not supply it
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
"the Studio button is hidden without a host hook (mobile)")
-- the studio boots a game on Play, so it needs a cartridge, not the tab id
check(view:find("imp.modScope or \"red\"", 1, true) ~= nil,
"the studio is handed a real game version, never the skins tab id")
check(imp:find('if self.tab == "skins" then', 1, true) ~= nil,
"a dropped zip on the skins tab installs a skin")
check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists")
check(imp:find("_installMod", 1, true) ~= nil,
"and a zip elsewhere still installs a mod")
local cycle = imp:match("local order = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab")
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"switching to the tab re-reads the skin list")
T.finish("launcher_skins_tab")
+261
View File
@@ -0,0 +1,261 @@
-- Skin Studio model layer (#1386): canvas presets, control editing, the
-- drag/resize math and the pixel coordinate fields. Drawing is exercised by
-- tests/drivers/skin_studio_shot.lua; this pins the state machine underneath.
-- luajit tests/engine/skin_studio_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local Studio = require("src.ui.SkinStudio")
local function near(a, b, tol, msg)
check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) ..
", want " .. tostring(b) .. ")")
end
-- a studio session without touching options or the filesystem
local function session()
Studio.skin = TouchSkin.newSkin("t")
Studio.pageIndex = 1
Studio.selected = nil
Studio.canvasIndex = 1
Studio.aspectLock = true
Studio.drag = nil
Studio.dirty = false
Studio.images = {}
return Studio.skin
end
-- ------------------------------------------------------------- new skin
session()
eq(#Studio.skin.pages, 1, "a new skin starts with one page")
eq(#Studio.page().controls, 0, "and no controls")
check(Studio.page().viewport ~= nil, "and a screen cutout to place")
check(Studio.selectedControl() == nil, "nothing selected yet")
-- ------------------------------------------------------------- controls
Studio.addControl()
eq(#Studio.page().controls, 1, "addControl appends")
eq(Studio.selected, 1, "and selects what it added")
check(Studio.dirty, "editing marks the skin dirty")
local ctl = Studio.selectedControl()
eq(ctl.spec, "a", "new controls default to GB a")
eq(ctl.buttons[1], "a", "and carry the parsed bind")
Studio.cycleBind(1)
check(ctl.spec ~= "a", "cycleBind moves off the current bind")
check(#ctl.buttons + #ctl.hotkeys + #ctl.keys > 0 or ctl.decorative,
"cycleBind reparses the bind")
Studio.cycleBind(-1)
eq(ctl.spec, "a", "cycleBind is reversible")
Studio.duplicateControl()
eq(#Studio.page().controls, 2, "duplicate adds a second control")
eq(Studio.selected, 2, "and selects the copy")
check(Studio.page().controls[2] ~= Studio.page().controls[1],
"the copy is a separate table")
near(Studio.page().controls[2].x, Studio.page().controls[1].x + 0.04, 1e-6,
"the copy is offset so it is visible")
Studio.deleteControl()
eq(#Studio.page().controls, 1, "delete removes it")
Studio.deleteControl()
eq(#Studio.page().controls, 0, "delete works down to empty")
eq(Studio.selected, nil, "and clears the selection")
-- ------------------------------------------------------- canvas presets
local ids = {}
for _, c in ipairs(Studio.CANVASES) do
check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
check(not ids[c.id], c.id .. " appears once")
ids[c.id] = true
end
check(ids.phone_portrait, "a phone portrait preset exists")
check(ids.desktop_1080, "a desktop preset exists")
check(ids.ultrawide, "an ultrawide preset exists")
check(ids.sgb_border, "a Super Game Boy border preset exists")
session()
local sgbIndex
for i, c in ipairs(Studio.CANVASES) do
if c.id == "sgb_border" then sgbIndex = i end
end
Studio.setCanvas(sgbIndex)
local canvas = Studio.canvas()
eq(canvas.w, 256, "SGB canvas is 256 wide")
eq(canvas.h, 224, "SGB canvas is 224 tall")
local vp = Studio.page().viewport
near(vp.x * 256, 48, 1e-6, "SGB screen sits 48px from the left")
near(vp.y * 224, 40, 1e-6, "SGB screen sits 40px from the top")
near(vp.w * 256, 160, 1e-6, "SGB screen is 160px wide")
near(vp.h * 224, 144, 1e-6, "SGB screen is 144px tall")
-- the locked preset refuses to let the cutout be moved or removed
Studio.toggleViewport()
check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off")
-- presets wrap rather than running off the end of the list
Studio.setCanvas(#Studio.CANVASES + 1)
eq(Studio.canvasIndex, 1, "canvas selection wraps")
-- ------------------------------------------------------------ canvas fit
Studio.setCanvas(1)
local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600)
near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6,
"the mock device keeps its aspect")
check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace")
near(cx + cw / 2, 400, 1e-6, "centred horizontally")
near(cy + ch / 2, 300, 1e-6, "centred vertically")
-- ---------------------------------------------------------------- drag
session()
Studio.addControl()
ctl = Studio.selectedControl()
local r = { x = 0, y = 0, w = 1000, h = 1000 }
local startX, startY = ctl.x, ctl.y
Studio.drag = { kind = "control-move", mx = 500, my = 500,
bx = 420, by = 455, bw = 160, bh = 90 }
Studio.updateDrag(600, 500, r)
near(ctl.x, startX + 0.1, 1e-6, "dragging right moves the control right")
near(ctl.y, startY, 1e-6, "and leaves y alone")
Studio.drag = { kind = "control-resize", handle = "se", mx = 500, my = 500,
bx = 400, by = 400, bw = 100, bh = 100 }
Studio.updateDrag(600, 600, r)
near(ctl.rangeX * 2 * r.w, 200, 1e-6, "the se handle widens the control")
near(ctl.rangeY * 2 * r.h, 200, 1e-6, "and heightens it")
-- the viewport keeps the Game Boy's 10:9 while the lock is on
Studio.aspectLock = true
Studio.selected = nil
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
bx = 0, by = 0, bw = 400, bh = 400 }
Studio.updateDrag(100, 0, r)
local v = Studio.page().viewport
near(v.w / v.h, 160 / 144, 1e-6, "10:9 lock holds the screen aspect")
Studio.aspectLock = false
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
bx = 0, by = 0, bw = 400, bh = 400 }
Studio.updateDrag(100, 200, r)
v = Studio.page().viewport
near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer")
near(v.h * r.h, 600, 1e-6, "and height is free")
-- controls never escape the canvas
Studio.selected = 1
Studio.drag = { kind = "control-move", mx = 0, my = 0,
bx = 0, by = 0, bw = 100, bh = 100 }
Studio.updateDrag(-100000, -100000, r)
check(ctl.x >= 0 and ctl.y >= 0, "a control cannot be dragged off the top-left")
Studio.updateDrag(100000, 100000, r)
check(ctl.x <= 1 and ctl.y <= 1, "or off the bottom-right")
-- ------------------------------------------------------- pixel fields
session()
Studio.addControl()
ctl = Studio.selectedControl()
canvas = Studio.canvas()
eq(canvas.w, 1080, "phone portrait is 1080 wide")
Studio.commitField("numW", "216")
near(ctl.rangeX * 2 * canvas.w, 216, 1e-6, "W is typed in canvas pixels")
Studio.commitField("numX", "100")
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X is the left edge in pixels")
Studio.commitField("numH", "180")
near(ctl.rangeY * 2 * canvas.h, 180, 1e-6, "H is typed in canvas pixels")
Studio.commitField("numY", "640")
near((ctl.y - ctl.rangeY) * canvas.h, 640, 1e-4, "Y is the top edge in pixels")
-- X must not have drifted when the later fields were set
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X survives edits to the others")
local keptX = ctl.x
Studio.commitField("numX", "not a number")
near(ctl.x, keptX, 1e-9, "garbage in a field is ignored")
-- ---------------------------------------------------------------- pages
session()
Studio.addPage()
eq(#Studio.skin.pages, 2, "addPage appends a page")
eq(Studio.pageIndex, 2, "and switches to it")
eq(Studio.page().name, "page2", "the new page is named in sequence")
Studio.addControl()
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
-- --------------------------------------------------------------- clone
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
check(source ~= nil, "bundled skin loads for cloning")
if source then
local copy = TouchSkin.clone(source)
eq(#copy.pages, #source.pages, "clone keeps every page")
eq(#copy.pages[1].controls, #source.pages[1].controls, "and every control")
check(copy.pages[1] ~= source.pages[1], "pages are fresh tables")
check(copy.pages[1].controls[1] ~= source.pages[1].controls[1],
"controls are fresh tables")
eq(copy.pages[1].image, source.pages[1].image, "loaded images are shared, not reloaded")
copy.pages[1].controls[1].x = 0.123
check(source.pages[1].controls[1].x ~= 0.123,
"editing the clone does not touch the loaded skin")
end
-- ------------------------------------------------------------ play handoff
-- Play used to call the host handler straight out of the Kit button, which
-- unloaded the studio inside its own draw pass; the rest of that frame then
-- indexed a nil skin and took the app down. The handoff is deferred to
-- update, and a torn-down studio has to survive a draw either way.
session()
Studio.available, Studio.images = {}, {}
Studio.skinIdField, Studio.onClose = "t", function() end
local fired, gotVersion = 0, nil
Studio.version = "red"
Studio.onPlay = function(v)
fired = fired + 1
gotVersion = v
Studio.unload()
end
Studio.play()
check(Studio.pendingPlay, "play queues the handoff")
eq(fired, 0, "play does not hand off during the click")
check(Studio.skin ~= nil, "and leaves the skin alive for the rest of the frame")
check(pcall(Studio.draw), "the frame that queued play still draws")
Studio.update()
eq(fired, 1, "update performs the handoff")
eq(gotVersion, "red", "and passes the launcher tab through")
eq(Studio.skin, nil, "the studio is unloaded by then")
check(pcall(Studio.draw), "a torn-down studio still survives a draw")
Studio.update()
eq(fired, 1, "the handoff does not repeat")
-- unload must clear a queued handoff, or closing then reopening would boot
session()
Studio.onPlay = function() fired = fired + 1 end
Studio.play()
check(Studio.pendingPlay, "queued again")
Studio.unload()
check(not Studio.pendingPlay, "unload drops a queued handoff")
Studio.update()
eq(fired, 1, "closing the studio does not start the game")
-- the studio is a desktop workspace; the launcher only offers it there
check(type(Studio.available_desktop) == "function", "desktop gate is exported")
check(Studio.available_desktop(), "headless/desktop reports available")
T.finish("skin_studio")
+325
View File
@@ -0,0 +1,325 @@
-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press /
-- hotkey path through TouchControls, and the screen viewport the renderer
-- fits the Game Boy picture into.
-- luajit tests/engine/touch_skin_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls")
local Input = require("src.core.Input")
local CFG = [[
overlays = 2
overlay0_name = "shell"
overlay0_overlay = img/back.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_alpha_mod = 0.001
overlay0_range_mod = 1.5
overlay0_viewport = "0.0,0.0,1.0,0.5"
overlay0_viewport_fill = true
overlay0_descs = 7
overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05"
overlay0_desc0_overlay = img/a.png
overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05"
overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10"
overlay0_desc2_overlay = img/dpad.png
overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02"
overlay0_desc3_next_target = "second"
overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02"
overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02"
overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02"
overlay1_name = "second"
overlay1_full_screen = true
overlay1_normalized = true
overlay1_descs = 1
overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10"
]]
local skin = assert(TouchSkin.parse(CFG))
eq(#skin.pages, 2, "two overlay pages parsed")
local page = skin.pages[1]
eq(page.name, "shell", "page name")
eq(page.imagePath, "img/back.png", "page background path")
check(page.fullScreen, "full_screen parsed")
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
eq(#page.controls, 7, "seven descs parsed")
check(page.viewport ~= nil, "viewport parsed")
eq(page.viewport.h, 0.5, "viewport height")
check(page.viewportFill, "viewport_fill parsed")
local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls)
eq(a.buttons[1], "a", "desc0 binds GB a")
eq(a.shape, "radial", "desc0 is radial")
eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod")
eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod")
eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control")
check(decor.decorative, "nul desc is decoration only")
eq(decor.imagePath, "img/dpad.png", "decoration still carries art")
eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped")
eq(nextBtn.nextTarget, "second", "next_target parsed")
eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped")
eq(reset.hotkeys[1], "soft_reset", "reset mapped")
eq(keyBtn.keys[1], "f5", "key: binds a keyboard key")
-- geometry: x/y are the centre, range_x/range_y are half extents, both
-- normalized to the window when full_screen is set
local W, H = 400, 800
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H)
eq(cx, 320, "control centre x")
eq(cy, 600, "control centre y")
eq(halfW, 40, "control half width")
eq(halfH, 40, "control half height")
-- range_mod 1.5 widens the hitbox but not the art
check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits")
check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits")
check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses")
-- radial, so the corner of the bounding box is outside
check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses")
-- rect hitbox does take its corner
check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits")
-- ---------------------------------------------------------------- runtime
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchSkin.setOverlayLive(true)
TouchSkin.setActive(skin)
Input:init()
local ww, wh = love.graphics.getDimensions()
local function at(nx, ny) return nx * ww, ny * wh end
check(TouchSkin.hasViewport(), "active skin reports a viewport")
-- the renderer fits the Game Boy picture into this rect instead of the window
local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
check(fill, "viewport fill flag returned")
eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative")
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
check(Input:isDown("a"), "skin A presses GB a")
TouchControls:touchreleased("f1", at(0.80, 0.75))
check(not Input:isDown("a"), "lifting releases GB a")
-- one finger inside a combined desc holds both directions
TouchControls:touchpressed("f2", at(0.20, 0.90))
check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons")
-- sliding onto the decoration drops them without capturing anything new
TouchControls:touchmoved("f2", at(0.20, 0.75))
check(not Input:isDown("left") and not Input:isDown("down"),
"sliding off a control releases it")
TouchControls:touchreleased("f2", at(0.20, 0.75))
check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil,
"a press on decoration alone is not captured")
-- hotkeys reach the host once per press edge and once per release
local fired = {}
TouchControls:setHotkeyHandler(function(action, pressed)
fired[#fired + 1] = action .. ":" .. tostring(pressed)
end)
TouchControls:touchpressed("f4", at(0.05, 0.55))
eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press")
TouchControls:touchreleased("f4", at(0.05, 0.55))
eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release")
-- a stranded finger unwinds through reset, not just through touchreleased
fired = {}
TouchControls:touchpressed("f5", at(0.05, 0.55))
TouchControls:touchpressed("f6", at(0.80, 0.75))
check(Input:isDown("a"), "second finger holds A")
TouchControls:reset()
eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys")
check(not Input:isDown("a"), "reset releases held buttons")
-- overlay_next swaps the page, and the new page's controls are what hit
eq(TouchSkin.page().name, "shell", "starts on page 1")
TouchControls:touchpressed("f7", at(0.95, 0.55))
eq(TouchSkin.page().name, "second", "overlay_next honours next_target")
TouchControls:touchreleased("f7", at(0.95, 0.55))
TouchControls:touchpressed("f8", at(0.50, 0.50))
check(Input:isDown("start"), "page 2 control presses GB start")
TouchControls:touchreleased("f8", at(0.50, 0.50))
TouchSkin.setPage("shell")
-- ------------------------------------------------------------ persistence
local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" })
eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id")
eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops")
eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops")
TouchControls.skinId = "gb_anim"
eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id")
-- ----------------------------------------------- screen-hole detection
-- Border art usually ships with a transparent screen hole and no viewport
-- key. Detection is verified against real art by
-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely.
eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil,
"no bezel path detects nothing")
eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil,
"empty bezel path detects nothing")
eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil,
"a missing bezel detects nothing")
-- --------------------------------------------------------- bundled skin
local bundled = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
check(bundled ~= nil, "bundled gb_anim skin loads")
if bundled then
eq(#bundled.pages, 2, "gb_anim has a DMG and a Color page")
eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1")
check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport")
eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art")
local named = {}
for _, ctl in ipairs(bundled.pages[1].controls) do
for _, btn in ipairs(ctl.buttons) do named[btn] = true end
end
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
check(named[btn], "gb_anim binds GB " .. btn)
end
end
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
check(tv ~= nil, "bundled tv_crt desktop bezel loads")
if tv then
eq(#tv.pages, 1, "tv_crt is a single page")
eq(#tv.pages[1].controls, 0, "tv_crt binds nothing: it is a frame")
check(tv.pages[1].viewport ~= nil, "tv_crt names the tube as its viewport")
eq(tv.pages[1].imagePath, "img/tv-integer.png", "tv_crt bezel art")
TouchSkin.setActive(tv)
TouchSkin.setOverlayLive(false)
check(TouchSkin.decorativeOnly(), "tv_crt counts as decoration")
check(TouchSkin.drawable(), "so it draws on a desktop window")
end
-- ------------------------------------------- decorative desktop bezels
-- A skin whose page binds nothing is a frame, not a pad: it draws where the
-- touch overlay does not (desktop), and a gamepad must not hide it.
local BEZEL = [[
overlays = 1
overlay0_name = "tv"
overlay0_overlay = img/tv.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_descs = 1
overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5"
overlay0_viewport = "0.2,0.1,0.6,0.8"
]]
local bezel = assert(TouchSkin.parse(BEZEL))
TouchSkin.setActive(bezel)
TouchSkin.setOverlayLive(false)
check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration")
check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)")
check(TouchSkin.hasViewport(), "and its viewport still places the picture")
TouchControls.active = false
TouchControls.enabled = true
TouchControls.controllerHidden = true
check(TouchControls:visible(), "a gamepad does not hide a decorative bezel")
-- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate
TouchSkin.setActive(skin)
TouchSkin.setOverlayLive(false)
check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration")
check(not TouchSkin.drawable(), "and it does not draw where the overlay is off")
check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either")
check(not TouchControls:visible(), "nor draw over a desktop window")
TouchSkin.setOverlayLive(true)
check(TouchSkin.drawable(), "with the overlay live it draws again")
TouchControls.active = true
TouchControls.controllerHidden = false
-- ------------------------------------------------------- native format
local native = TouchSkin.serialize(skin)
check(native:find("^return {"), "serialize emits a Lua data chunk")
local back, nerr = TouchSkin.parseNative(native)
check(back ~= nil, "native round-trip parses: " .. tostring(nerr))
if back then
eq(#back.pages, #skin.pages, "round-trip keeps the page count")
local bp, sp = back.pages[1], skin.pages[1]
eq(#bp.controls, #sp.controls, "round-trip keeps the control count")
eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds")
eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents")
eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape")
eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport")
check(bp.viewportFill, "round-trip keeps viewport_fill")
eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod")
eq(bp.controls[3].decorative, true, "round-trip keeps decoration")
eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target")
end
-- the native format carries the separate pressed image a .cfg cannot
local twoState = assert(TouchSkin.parseNative([[
return { pages = { { name = "p", controls = {
{ bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial",
image = "up.png", imagePressed = "down.png" },
} } } }
]]))
eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image")
eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image")
eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range")
check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected")
check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected")
check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected")
-- skins are third-party data: the chunk must not see the host globals
check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }")
~= nil, "skin.lua loads in an empty environment")
-- --------------------------------------------------------------- export
local SkinZip = require("src.core.SkinZip")
local blob = SkinZip.encode({
{ name = "skin.lua", data = "return {}\n" },
{ name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" },
})
eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header")
check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record")
check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name")
check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim")
local bundledSkin = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
if bundledSkin then
eq(#TouchSkin.assetPaths(bundledSkin), 17, "gb_anim names 17 distinct images")
local tmp = os.tmpname() .. ".zip"
local path, missing = TouchSkin.export(bundledSkin, tmp)
eq(path, tmp, "export writes to the requested path")
eq(#missing, 0, "every gb_anim image was found")
local f = io.open(tmp, "rb")
check(f ~= nil, "exported zip reached disk")
if f then
local bytes = f:read("*a")
f:close()
eq(bytes:sub(1, 4), "PK\3\4", "exported file is a zip")
check(bytes:find("skin.lua", 1, true) ~= nil, "export includes the native skin.lua")
check(bytes:find("img/gb_back.png", 1, true) ~= nil, "export includes the bezel")
check(bytes:find("overlay.cfg", 1, true) ~= nil,
"a .cfg-sourced skin also exports its original config")
os.remove(tmp)
end
end
TouchSkin.setActive(nil)
TouchControls:setHotkeyHandler(nil)
T.finish("touch_skin")