skins and skin studio
@@ -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.
|
||||
|
After Width: | Height: | Size: 973 B |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 649 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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 =
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Launcher SKINS tab (#1386): the tab next to Find that lists skins, imports
|
||||
-- a dropped .zip and opens the desktop Skin Studio. The rendered panel is
|
||||
-- exercised by tests/drivers/launcher_skins_tab_shot.lua; this pins the
|
||||
-- archive installer and the launcher wiring around it.
|
||||
-- luajit tests/engine/launcher_skins_tab.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
|
||||
local function read(path)
|
||||
local f = assert(io.open(path, "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
return src
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------ archive installer
|
||||
|
||||
eq(select(1, TouchSkin.installArchive("skin.zip", nil)), nil,
|
||||
"an empty archive is refused")
|
||||
eq(select(1, TouchSkin.installArchive("skin.zip", "")), nil,
|
||||
"a zero-byte archive is refused")
|
||||
eq(select(1, TouchSkin.installArchive("notes.txt", "data")), nil,
|
||||
"a non-zip is refused")
|
||||
eq(select(1, TouchSkin.installArchive("", "data")), nil,
|
||||
"a nameless drop is refused")
|
||||
|
||||
-- A zip carrying no skin file must not leave a stray archive behind: the
|
||||
-- list would keep trying to mount it on every visit to the tab.
|
||||
local junk = "PK\3\4 not really a skin"
|
||||
local id, err = TouchSkin.installArchive("bogus.zip", junk)
|
||||
eq(id, nil, "a zip with no skin.lua or .cfg is refused")
|
||||
check(tostring(err):find("bogus.zip", 1, true) ~= nil,
|
||||
"and the error names the file")
|
||||
eq(love.filesystem.read(TouchSkin.USER_ROOT .. "/bogus.zip"), nil,
|
||||
"the rejected archive is cleaned up, not left to fail on every listing")
|
||||
|
||||
-- the id a good archive would land under is the file name without .zip
|
||||
check(TouchSkin.find("bogus") == nil, "a refused archive lists nothing")
|
||||
|
||||
-- ------------------------------------------------------- launcher wiring
|
||||
|
||||
local view = read("src/import/LauncherView.lua")
|
||||
local imp = read("src/import/RomImporter.lua")
|
||||
|
||||
check(view:find('id = "skins"', 1, true) ~= nil,
|
||||
"LauncherView registers a skins tab")
|
||||
check(view:find("drawSkinGlyph", 1, true) ~= nil,
|
||||
"the skins tab draws its own glyph rather than shipping an asset")
|
||||
-- the tab has to be next to Find, which is what the request was
|
||||
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
|
||||
check(order ~= nil, "HEADER_TABS found")
|
||||
if order then
|
||||
local findAt = order:find('id = "find"', 1, true)
|
||||
local skinsAt = order:find('id = "skins"', 1, true)
|
||||
check(findAt and skinsAt and skinsAt > findAt,
|
||||
"the skins tab sits immediately after Find")
|
||||
end
|
||||
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
|
||||
"the panel dispatch routes the skins tab")
|
||||
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
|
||||
-- the panel must not offer the studio when the host did not supply it
|
||||
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
|
||||
"the Studio button is hidden without a host hook (mobile)")
|
||||
-- the studio boots a game on Play, so it needs a cartridge, not the tab id
|
||||
check(view:find("imp.modScope or \"red\"", 1, true) ~= nil,
|
||||
"the studio is handed a real game version, never the skins tab id")
|
||||
|
||||
check(imp:find('if self.tab == "skins" then', 1, true) ~= nil,
|
||||
"a dropped zip on the skins tab installs a skin")
|
||||
check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists")
|
||||
check(imp:find("_installMod", 1, true) ~= nil,
|
||||
"and a zip elsewhere still installs a mod")
|
||||
local cycle = imp:match("local order = %{(.-)%}")
|
||||
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches the skins tab")
|
||||
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
|
||||
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
|
||||
"switching to the tab re-reads the skin list")
|
||||
|
||||
T.finish("launcher_skins_tab")
|
||||
@@ -0,0 +1,261 @@
|
||||
-- Skin Studio model layer (#1386): canvas presets, control editing, the
|
||||
-- drag/resize math and the pixel coordinate fields. Drawing is exercised by
|
||||
-- tests/drivers/skin_studio_shot.lua; this pins the state machine underneath.
|
||||
-- luajit tests/engine/skin_studio_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local Studio = require("src.ui.SkinStudio")
|
||||
|
||||
local function near(a, b, tol, msg)
|
||||
check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) ..
|
||||
", want " .. tostring(b) .. ")")
|
||||
end
|
||||
|
||||
-- a studio session without touching options or the filesystem
|
||||
local function session()
|
||||
Studio.skin = TouchSkin.newSkin("t")
|
||||
Studio.pageIndex = 1
|
||||
Studio.selected = nil
|
||||
Studio.canvasIndex = 1
|
||||
Studio.aspectLock = true
|
||||
Studio.drag = nil
|
||||
Studio.dirty = false
|
||||
Studio.images = {}
|
||||
return Studio.skin
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- new skin
|
||||
|
||||
session()
|
||||
eq(#Studio.skin.pages, 1, "a new skin starts with one page")
|
||||
eq(#Studio.page().controls, 0, "and no controls")
|
||||
check(Studio.page().viewport ~= nil, "and a screen cutout to place")
|
||||
check(Studio.selectedControl() == nil, "nothing selected yet")
|
||||
|
||||
-- ------------------------------------------------------------- controls
|
||||
|
||||
Studio.addControl()
|
||||
eq(#Studio.page().controls, 1, "addControl appends")
|
||||
eq(Studio.selected, 1, "and selects what it added")
|
||||
check(Studio.dirty, "editing marks the skin dirty")
|
||||
local ctl = Studio.selectedControl()
|
||||
eq(ctl.spec, "a", "new controls default to GB a")
|
||||
eq(ctl.buttons[1], "a", "and carry the parsed bind")
|
||||
|
||||
Studio.cycleBind(1)
|
||||
check(ctl.spec ~= "a", "cycleBind moves off the current bind")
|
||||
check(#ctl.buttons + #ctl.hotkeys + #ctl.keys > 0 or ctl.decorative,
|
||||
"cycleBind reparses the bind")
|
||||
Studio.cycleBind(-1)
|
||||
eq(ctl.spec, "a", "cycleBind is reversible")
|
||||
|
||||
Studio.duplicateControl()
|
||||
eq(#Studio.page().controls, 2, "duplicate adds a second control")
|
||||
eq(Studio.selected, 2, "and selects the copy")
|
||||
check(Studio.page().controls[2] ~= Studio.page().controls[1],
|
||||
"the copy is a separate table")
|
||||
near(Studio.page().controls[2].x, Studio.page().controls[1].x + 0.04, 1e-6,
|
||||
"the copy is offset so it is visible")
|
||||
|
||||
Studio.deleteControl()
|
||||
eq(#Studio.page().controls, 1, "delete removes it")
|
||||
Studio.deleteControl()
|
||||
eq(#Studio.page().controls, 0, "delete works down to empty")
|
||||
eq(Studio.selected, nil, "and clears the selection")
|
||||
|
||||
-- ------------------------------------------------------- canvas presets
|
||||
|
||||
local ids = {}
|
||||
for _, c in ipairs(Studio.CANVASES) do
|
||||
check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
|
||||
check(not ids[c.id], c.id .. " appears once")
|
||||
ids[c.id] = true
|
||||
end
|
||||
check(ids.phone_portrait, "a phone portrait preset exists")
|
||||
check(ids.desktop_1080, "a desktop preset exists")
|
||||
check(ids.ultrawide, "an ultrawide preset exists")
|
||||
check(ids.sgb_border, "a Super Game Boy border preset exists")
|
||||
|
||||
session()
|
||||
local sgbIndex
|
||||
for i, c in ipairs(Studio.CANVASES) do
|
||||
if c.id == "sgb_border" then sgbIndex = i end
|
||||
end
|
||||
Studio.setCanvas(sgbIndex)
|
||||
local canvas = Studio.canvas()
|
||||
eq(canvas.w, 256, "SGB canvas is 256 wide")
|
||||
eq(canvas.h, 224, "SGB canvas is 224 tall")
|
||||
local vp = Studio.page().viewport
|
||||
near(vp.x * 256, 48, 1e-6, "SGB screen sits 48px from the left")
|
||||
near(vp.y * 224, 40, 1e-6, "SGB screen sits 40px from the top")
|
||||
near(vp.w * 256, 160, 1e-6, "SGB screen is 160px wide")
|
||||
near(vp.h * 224, 144, 1e-6, "SGB screen is 144px tall")
|
||||
|
||||
-- the locked preset refuses to let the cutout be moved or removed
|
||||
Studio.toggleViewport()
|
||||
check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off")
|
||||
|
||||
-- presets wrap rather than running off the end of the list
|
||||
Studio.setCanvas(#Studio.CANVASES + 1)
|
||||
eq(Studio.canvasIndex, 1, "canvas selection wraps")
|
||||
|
||||
-- ------------------------------------------------------------ canvas fit
|
||||
|
||||
Studio.setCanvas(1)
|
||||
local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600)
|
||||
near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6,
|
||||
"the mock device keeps its aspect")
|
||||
check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace")
|
||||
near(cx + cw / 2, 400, 1e-6, "centred horizontally")
|
||||
near(cy + ch / 2, 300, 1e-6, "centred vertically")
|
||||
|
||||
-- ---------------------------------------------------------------- drag
|
||||
|
||||
session()
|
||||
Studio.addControl()
|
||||
ctl = Studio.selectedControl()
|
||||
local r = { x = 0, y = 0, w = 1000, h = 1000 }
|
||||
local startX, startY = ctl.x, ctl.y
|
||||
|
||||
Studio.drag = { kind = "control-move", mx = 500, my = 500,
|
||||
bx = 420, by = 455, bw = 160, bh = 90 }
|
||||
Studio.updateDrag(600, 500, r)
|
||||
near(ctl.x, startX + 0.1, 1e-6, "dragging right moves the control right")
|
||||
near(ctl.y, startY, 1e-6, "and leaves y alone")
|
||||
|
||||
Studio.drag = { kind = "control-resize", handle = "se", mx = 500, my = 500,
|
||||
bx = 400, by = 400, bw = 100, bh = 100 }
|
||||
Studio.updateDrag(600, 600, r)
|
||||
near(ctl.rangeX * 2 * r.w, 200, 1e-6, "the se handle widens the control")
|
||||
near(ctl.rangeY * 2 * r.h, 200, 1e-6, "and heightens it")
|
||||
|
||||
-- the viewport keeps the Game Boy's 10:9 while the lock is on
|
||||
Studio.aspectLock = true
|
||||
Studio.selected = nil
|
||||
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
|
||||
bx = 0, by = 0, bw = 400, bh = 400 }
|
||||
Studio.updateDrag(100, 0, r)
|
||||
local v = Studio.page().viewport
|
||||
near(v.w / v.h, 160 / 144, 1e-6, "10:9 lock holds the screen aspect")
|
||||
|
||||
Studio.aspectLock = false
|
||||
Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0,
|
||||
bx = 0, by = 0, bw = 400, bh = 400 }
|
||||
Studio.updateDrag(100, 200, r)
|
||||
v = Studio.page().viewport
|
||||
near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer")
|
||||
near(v.h * r.h, 600, 1e-6, "and height is free")
|
||||
|
||||
-- controls never escape the canvas
|
||||
Studio.selected = 1
|
||||
Studio.drag = { kind = "control-move", mx = 0, my = 0,
|
||||
bx = 0, by = 0, bw = 100, bh = 100 }
|
||||
Studio.updateDrag(-100000, -100000, r)
|
||||
check(ctl.x >= 0 and ctl.y >= 0, "a control cannot be dragged off the top-left")
|
||||
Studio.updateDrag(100000, 100000, r)
|
||||
check(ctl.x <= 1 and ctl.y <= 1, "or off the bottom-right")
|
||||
|
||||
-- ------------------------------------------------------- pixel fields
|
||||
|
||||
session()
|
||||
Studio.addControl()
|
||||
ctl = Studio.selectedControl()
|
||||
canvas = Studio.canvas()
|
||||
eq(canvas.w, 1080, "phone portrait is 1080 wide")
|
||||
|
||||
Studio.commitField("numW", "216")
|
||||
near(ctl.rangeX * 2 * canvas.w, 216, 1e-6, "W is typed in canvas pixels")
|
||||
Studio.commitField("numX", "100")
|
||||
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X is the left edge in pixels")
|
||||
Studio.commitField("numH", "180")
|
||||
near(ctl.rangeY * 2 * canvas.h, 180, 1e-6, "H is typed in canvas pixels")
|
||||
Studio.commitField("numY", "640")
|
||||
near((ctl.y - ctl.rangeY) * canvas.h, 640, 1e-4, "Y is the top edge in pixels")
|
||||
-- X must not have drifted when the later fields were set
|
||||
near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X survives edits to the others")
|
||||
|
||||
local keptX = ctl.x
|
||||
Studio.commitField("numX", "not a number")
|
||||
near(ctl.x, keptX, 1e-9, "garbage in a field is ignored")
|
||||
|
||||
-- ---------------------------------------------------------------- pages
|
||||
|
||||
session()
|
||||
Studio.addPage()
|
||||
eq(#Studio.skin.pages, 2, "addPage appends a page")
|
||||
eq(Studio.pageIndex, 2, "and switches to it")
|
||||
eq(Studio.page().name, "page2", "the new page is named in sequence")
|
||||
Studio.addControl()
|
||||
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
|
||||
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
|
||||
|
||||
-- --------------------------------------------------------------- clone
|
||||
|
||||
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
|
||||
check(source ~= nil, "bundled skin loads for cloning")
|
||||
if source then
|
||||
local copy = TouchSkin.clone(source)
|
||||
eq(#copy.pages, #source.pages, "clone keeps every page")
|
||||
eq(#copy.pages[1].controls, #source.pages[1].controls, "and every control")
|
||||
check(copy.pages[1] ~= source.pages[1], "pages are fresh tables")
|
||||
check(copy.pages[1].controls[1] ~= source.pages[1].controls[1],
|
||||
"controls are fresh tables")
|
||||
eq(copy.pages[1].image, source.pages[1].image, "loaded images are shared, not reloaded")
|
||||
copy.pages[1].controls[1].x = 0.123
|
||||
check(source.pages[1].controls[1].x ~= 0.123,
|
||||
"editing the clone does not touch the loaded skin")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ play handoff
|
||||
|
||||
-- Play used to call the host handler straight out of the Kit button, which
|
||||
-- unloaded the studio inside its own draw pass; the rest of that frame then
|
||||
-- indexed a nil skin and took the app down. The handoff is deferred to
|
||||
-- update, and a torn-down studio has to survive a draw either way.
|
||||
session()
|
||||
Studio.available, Studio.images = {}, {}
|
||||
Studio.skinIdField, Studio.onClose = "t", function() end
|
||||
local fired, gotVersion = 0, nil
|
||||
Studio.version = "red"
|
||||
Studio.onPlay = function(v)
|
||||
fired = fired + 1
|
||||
gotVersion = v
|
||||
Studio.unload()
|
||||
end
|
||||
|
||||
Studio.play()
|
||||
check(Studio.pendingPlay, "play queues the handoff")
|
||||
eq(fired, 0, "play does not hand off during the click")
|
||||
check(Studio.skin ~= nil, "and leaves the skin alive for the rest of the frame")
|
||||
check(pcall(Studio.draw), "the frame that queued play still draws")
|
||||
|
||||
Studio.update()
|
||||
eq(fired, 1, "update performs the handoff")
|
||||
eq(gotVersion, "red", "and passes the launcher tab through")
|
||||
eq(Studio.skin, nil, "the studio is unloaded by then")
|
||||
check(pcall(Studio.draw), "a torn-down studio still survives a draw")
|
||||
|
||||
Studio.update()
|
||||
eq(fired, 1, "the handoff does not repeat")
|
||||
|
||||
-- unload must clear a queued handoff, or closing then reopening would boot
|
||||
session()
|
||||
Studio.onPlay = function() fired = fired + 1 end
|
||||
Studio.play()
|
||||
check(Studio.pendingPlay, "queued again")
|
||||
Studio.unload()
|
||||
check(not Studio.pendingPlay, "unload drops a queued handoff")
|
||||
Studio.update()
|
||||
eq(fired, 1, "closing the studio does not start the game")
|
||||
|
||||
-- the studio is a desktop workspace; the launcher only offers it there
|
||||
check(type(Studio.available_desktop) == "function", "desktop gate is exported")
|
||||
check(Studio.available_desktop(), "headless/desktop reports available")
|
||||
|
||||
T.finish("skin_studio")
|
||||
@@ -0,0 +1,325 @@
|
||||
-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press /
|
||||
-- hotkey path through TouchControls, and the screen viewport the renderer
|
||||
-- fits the Game Boy picture into.
|
||||
-- luajit tests/engine/touch_skin_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local CFG = [[
|
||||
overlays = 2
|
||||
|
||||
overlay0_name = "shell"
|
||||
overlay0_overlay = img/back.png
|
||||
overlay0_full_screen = true
|
||||
overlay0_normalized = true
|
||||
overlay0_alpha_mod = 0.001
|
||||
overlay0_range_mod = 1.5
|
||||
overlay0_viewport = "0.0,0.0,1.0,0.5"
|
||||
overlay0_viewport_fill = true
|
||||
|
||||
overlay0_descs = 7
|
||||
overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05"
|
||||
overlay0_desc0_overlay = img/a.png
|
||||
overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05"
|
||||
overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10"
|
||||
overlay0_desc2_overlay = img/dpad.png
|
||||
overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02"
|
||||
overlay0_desc3_next_target = "second"
|
||||
overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02"
|
||||
overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02"
|
||||
overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02"
|
||||
|
||||
overlay1_name = "second"
|
||||
overlay1_full_screen = true
|
||||
overlay1_normalized = true
|
||||
overlay1_descs = 1
|
||||
overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10"
|
||||
]]
|
||||
|
||||
local skin = assert(TouchSkin.parse(CFG))
|
||||
eq(#skin.pages, 2, "two overlay pages parsed")
|
||||
|
||||
local page = skin.pages[1]
|
||||
eq(page.name, "shell", "page name")
|
||||
eq(page.imagePath, "img/back.png", "page background path")
|
||||
check(page.fullScreen, "full_screen parsed")
|
||||
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
|
||||
eq(#page.controls, 7, "seven descs parsed")
|
||||
|
||||
check(page.viewport ~= nil, "viewport parsed")
|
||||
eq(page.viewport.h, 0.5, "viewport height")
|
||||
check(page.viewportFill, "viewport_fill parsed")
|
||||
|
||||
local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls)
|
||||
eq(a.buttons[1], "a", "desc0 binds GB a")
|
||||
eq(a.shape, "radial", "desc0 is radial")
|
||||
eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod")
|
||||
eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod")
|
||||
eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control")
|
||||
check(decor.decorative, "nul desc is decoration only")
|
||||
eq(decor.imagePath, "img/dpad.png", "decoration still carries art")
|
||||
eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped")
|
||||
eq(nextBtn.nextTarget, "second", "next_target parsed")
|
||||
eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped")
|
||||
eq(reset.hotkeys[1], "soft_reset", "reset mapped")
|
||||
eq(keyBtn.keys[1], "f5", "key: binds a keyboard key")
|
||||
|
||||
-- geometry: x/y are the centre, range_x/range_y are half extents, both
|
||||
-- normalized to the window when full_screen is set
|
||||
local W, H = 400, 800
|
||||
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H)
|
||||
eq(cx, 320, "control centre x")
|
||||
eq(cy, 600, "control centre y")
|
||||
eq(halfW, 40, "control half width")
|
||||
eq(halfH, 40, "control half height")
|
||||
|
||||
-- range_mod 1.5 widens the hitbox but not the art
|
||||
check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits")
|
||||
check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits")
|
||||
check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses")
|
||||
-- radial, so the corner of the bounding box is outside
|
||||
check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses")
|
||||
-- rect hitbox does take its corner
|
||||
check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits")
|
||||
|
||||
-- ---------------------------------------------------------------- runtime
|
||||
|
||||
TouchControls:init()
|
||||
TouchControls.active = true
|
||||
TouchControls.enabled = true
|
||||
TouchSkin.setOverlayLive(true)
|
||||
TouchSkin.setActive(skin)
|
||||
Input:init()
|
||||
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local function at(nx, ny) return nx * ww, ny * wh end
|
||||
|
||||
check(TouchSkin.hasViewport(), "active skin reports a viewport")
|
||||
|
||||
-- the renderer fits the Game Boy picture into this rect instead of the window
|
||||
local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
|
||||
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
|
||||
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
|
||||
check(fill, "viewport fill flag returned")
|
||||
eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative")
|
||||
|
||||
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
|
||||
check(Input:isDown("a"), "skin A presses GB a")
|
||||
TouchControls:touchreleased("f1", at(0.80, 0.75))
|
||||
check(not Input:isDown("a"), "lifting releases GB a")
|
||||
|
||||
-- one finger inside a combined desc holds both directions
|
||||
TouchControls:touchpressed("f2", at(0.20, 0.90))
|
||||
check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons")
|
||||
-- sliding onto the decoration drops them without capturing anything new
|
||||
TouchControls:touchmoved("f2", at(0.20, 0.75))
|
||||
check(not Input:isDown("left") and not Input:isDown("down"),
|
||||
"sliding off a control releases it")
|
||||
TouchControls:touchreleased("f2", at(0.20, 0.75))
|
||||
|
||||
check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil,
|
||||
"a press on decoration alone is not captured")
|
||||
|
||||
-- hotkeys reach the host once per press edge and once per release
|
||||
local fired = {}
|
||||
TouchControls:setHotkeyHandler(function(action, pressed)
|
||||
fired[#fired + 1] = action .. ":" .. tostring(pressed)
|
||||
end)
|
||||
TouchControls:touchpressed("f4", at(0.05, 0.55))
|
||||
eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press")
|
||||
TouchControls:touchreleased("f4", at(0.05, 0.55))
|
||||
eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release")
|
||||
|
||||
-- a stranded finger unwinds through reset, not just through touchreleased
|
||||
fired = {}
|
||||
TouchControls:touchpressed("f5", at(0.05, 0.55))
|
||||
TouchControls:touchpressed("f6", at(0.80, 0.75))
|
||||
check(Input:isDown("a"), "second finger holds A")
|
||||
TouchControls:reset()
|
||||
eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys")
|
||||
check(not Input:isDown("a"), "reset releases held buttons")
|
||||
|
||||
-- overlay_next swaps the page, and the new page's controls are what hit
|
||||
eq(TouchSkin.page().name, "shell", "starts on page 1")
|
||||
TouchControls:touchpressed("f7", at(0.95, 0.55))
|
||||
eq(TouchSkin.page().name, "second", "overlay_next honours next_target")
|
||||
TouchControls:touchreleased("f7", at(0.95, 0.55))
|
||||
TouchControls:touchpressed("f8", at(0.50, 0.50))
|
||||
check(Input:isDown("start"), "page 2 control presses GB start")
|
||||
TouchControls:touchreleased("f8", at(0.50, 0.50))
|
||||
TouchSkin.setPage("shell")
|
||||
|
||||
-- ------------------------------------------------------------ persistence
|
||||
|
||||
local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" })
|
||||
eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id")
|
||||
eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops")
|
||||
eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops")
|
||||
|
||||
TouchControls.skinId = "gb_anim"
|
||||
eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id")
|
||||
|
||||
-- ----------------------------------------------- screen-hole detection
|
||||
|
||||
-- Border art usually ships with a transparent screen hole and no viewport
|
||||
-- key. Detection is verified against real art by
|
||||
-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely.
|
||||
eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil,
|
||||
"no bezel path detects nothing")
|
||||
eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil,
|
||||
"empty bezel path detects nothing")
|
||||
eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil,
|
||||
"a missing bezel detects nothing")
|
||||
|
||||
-- --------------------------------------------------------- bundled skin
|
||||
|
||||
local bundled = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
|
||||
check(bundled ~= nil, "bundled gb_anim skin loads")
|
||||
if bundled then
|
||||
eq(#bundled.pages, 2, "gb_anim has a DMG and a Color page")
|
||||
eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1")
|
||||
check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport")
|
||||
eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art")
|
||||
local named = {}
|
||||
for _, ctl in ipairs(bundled.pages[1].controls) do
|
||||
for _, btn in ipairs(ctl.buttons) do named[btn] = true end
|
||||
end
|
||||
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
|
||||
check(named[btn], "gb_anim binds GB " .. btn)
|
||||
end
|
||||
end
|
||||
|
||||
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
|
||||
check(tv ~= nil, "bundled tv_crt desktop bezel loads")
|
||||
if tv then
|
||||
eq(#tv.pages, 1, "tv_crt is a single page")
|
||||
eq(#tv.pages[1].controls, 0, "tv_crt binds nothing: it is a frame")
|
||||
check(tv.pages[1].viewport ~= nil, "tv_crt names the tube as its viewport")
|
||||
eq(tv.pages[1].imagePath, "img/tv-integer.png", "tv_crt bezel art")
|
||||
TouchSkin.setActive(tv)
|
||||
TouchSkin.setOverlayLive(false)
|
||||
check(TouchSkin.decorativeOnly(), "tv_crt counts as decoration")
|
||||
check(TouchSkin.drawable(), "so it draws on a desktop window")
|
||||
end
|
||||
|
||||
-- ------------------------------------------- decorative desktop bezels
|
||||
|
||||
-- A skin whose page binds nothing is a frame, not a pad: it draws where the
|
||||
-- touch overlay does not (desktop), and a gamepad must not hide it.
|
||||
local BEZEL = [[
|
||||
overlays = 1
|
||||
overlay0_name = "tv"
|
||||
overlay0_overlay = img/tv.png
|
||||
overlay0_full_screen = true
|
||||
overlay0_normalized = true
|
||||
overlay0_descs = 1
|
||||
overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5"
|
||||
overlay0_viewport = "0.2,0.1,0.6,0.8"
|
||||
]]
|
||||
local bezel = assert(TouchSkin.parse(BEZEL))
|
||||
TouchSkin.setActive(bezel)
|
||||
|
||||
TouchSkin.setOverlayLive(false)
|
||||
check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration")
|
||||
check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)")
|
||||
check(TouchSkin.hasViewport(), "and its viewport still places the picture")
|
||||
TouchControls.active = false
|
||||
TouchControls.enabled = true
|
||||
TouchControls.controllerHidden = true
|
||||
check(TouchControls:visible(), "a gamepad does not hide a decorative bezel")
|
||||
|
||||
-- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate
|
||||
TouchSkin.setActive(skin)
|
||||
TouchSkin.setOverlayLive(false)
|
||||
check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration")
|
||||
check(not TouchSkin.drawable(), "and it does not draw where the overlay is off")
|
||||
check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either")
|
||||
check(not TouchControls:visible(), "nor draw over a desktop window")
|
||||
TouchSkin.setOverlayLive(true)
|
||||
check(TouchSkin.drawable(), "with the overlay live it draws again")
|
||||
TouchControls.active = true
|
||||
TouchControls.controllerHidden = false
|
||||
|
||||
-- ------------------------------------------------------- native format
|
||||
|
||||
local native = TouchSkin.serialize(skin)
|
||||
check(native:find("^return {"), "serialize emits a Lua data chunk")
|
||||
local back, nerr = TouchSkin.parseNative(native)
|
||||
check(back ~= nil, "native round-trip parses: " .. tostring(nerr))
|
||||
if back then
|
||||
eq(#back.pages, #skin.pages, "round-trip keeps the page count")
|
||||
local bp, sp = back.pages[1], skin.pages[1]
|
||||
eq(#bp.controls, #sp.controls, "round-trip keeps the control count")
|
||||
eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds")
|
||||
eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents")
|
||||
eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape")
|
||||
eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport")
|
||||
check(bp.viewportFill, "round-trip keeps viewport_fill")
|
||||
eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod")
|
||||
eq(bp.controls[3].decorative, true, "round-trip keeps decoration")
|
||||
eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target")
|
||||
end
|
||||
|
||||
-- the native format carries the separate pressed image a .cfg cannot
|
||||
local twoState = assert(TouchSkin.parseNative([[
|
||||
return { pages = { { name = "p", controls = {
|
||||
{ bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial",
|
||||
image = "up.png", imagePressed = "down.png" },
|
||||
} } } }
|
||||
]]))
|
||||
eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image")
|
||||
eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image")
|
||||
eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range")
|
||||
|
||||
check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected")
|
||||
check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected")
|
||||
check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected")
|
||||
-- skins are third-party data: the chunk must not see the host globals
|
||||
check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }")
|
||||
~= nil, "skin.lua loads in an empty environment")
|
||||
|
||||
-- --------------------------------------------------------------- export
|
||||
|
||||
local SkinZip = require("src.core.SkinZip")
|
||||
local blob = SkinZip.encode({
|
||||
{ name = "skin.lua", data = "return {}\n" },
|
||||
{ name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" },
|
||||
})
|
||||
eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header")
|
||||
check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record")
|
||||
check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name")
|
||||
check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim")
|
||||
|
||||
local bundledSkin = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
|
||||
if bundledSkin then
|
||||
eq(#TouchSkin.assetPaths(bundledSkin), 17, "gb_anim names 17 distinct images")
|
||||
local tmp = os.tmpname() .. ".zip"
|
||||
local path, missing = TouchSkin.export(bundledSkin, tmp)
|
||||
eq(path, tmp, "export writes to the requested path")
|
||||
eq(#missing, 0, "every gb_anim image was found")
|
||||
local f = io.open(tmp, "rb")
|
||||
check(f ~= nil, "exported zip reached disk")
|
||||
if f then
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
eq(bytes:sub(1, 4), "PK\3\4", "exported file is a zip")
|
||||
check(bytes:find("skin.lua", 1, true) ~= nil, "export includes the native skin.lua")
|
||||
check(bytes:find("img/gb_back.png", 1, true) ~= nil, "export includes the bezel")
|
||||
check(bytes:find("overlay.cfg", 1, true) ~= nil,
|
||||
"a .cfg-sourced skin also exports its original config")
|
||||
os.remove(tmp)
|
||||
end
|
||||
end
|
||||
|
||||
TouchSkin.setActive(nil)
|
||||
TouchControls:setHotkeyHandler(nil)
|
||||
|
||||
T.finish("touch_skin")
|
||||