diff --git a/assets/skins/gb_anim/README.md b/assets/skins/gb_anim/README.md new file mode 100644 index 00000000..7ce01506 --- /dev/null +++ b/assets/skins/gb_anim/README.md @@ -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. diff --git a/assets/skins/gb_anim/img/gb_a_b.png b/assets/skins/gb_anim/img/gb_a_b.png new file mode 100644 index 00000000..165b6fd9 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_a_b.png differ diff --git a/assets/skins/gb_anim/img/gb_back.png b/assets/skins/gb_anim/img/gb_back.png new file mode 100644 index 00000000..f0f10cf2 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_back.png differ diff --git a/assets/skins/gb_anim/img/gb_down.png b/assets/skins/gb_anim/img/gb_down.png new file mode 100644 index 00000000..6f925d92 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_down.png differ diff --git a/assets/skins/gb_anim/img/gb_left.png b/assets/skins/gb_anim/img/gb_left.png new file mode 100644 index 00000000..a4e340e4 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_left.png differ diff --git a/assets/skins/gb_anim/img/gb_right.png b/assets/skins/gb_anim/img/gb_right.png new file mode 100644 index 00000000..619d8538 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_right.png differ diff --git a/assets/skins/gb_anim/img/gb_start_select.png b/assets/skins/gb_anim/img/gb_start_select.png new file mode 100644 index 00000000..0c1bdaec Binary files /dev/null and b/assets/skins/gb_anim/img/gb_start_select.png differ diff --git a/assets/skins/gb_anim/img/gb_up.png b/assets/skins/gb_anim/img/gb_up.png new file mode 100644 index 00000000..d13739cd Binary files /dev/null and b/assets/skins/gb_anim/img/gb_up.png differ diff --git a/assets/skins/gb_anim/img/gbc_a.png b/assets/skins/gb_anim/img/gbc_a.png new file mode 100644 index 00000000..f83705d3 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_a.png differ diff --git a/assets/skins/gb_anim/img/gbc_b.png b/assets/skins/gb_anim/img/gbc_b.png new file mode 100644 index 00000000..e506fba4 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_b.png differ diff --git a/assets/skins/gb_anim/img/gbc_back.png b/assets/skins/gb_anim/img/gbc_back.png new file mode 100644 index 00000000..20648bd5 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_back.png differ diff --git a/assets/skins/gb_anim/img/gbc_down.png b/assets/skins/gb_anim/img/gbc_down.png new file mode 100644 index 00000000..8d310d48 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_down.png differ diff --git a/assets/skins/gb_anim/img/gbc_left.png b/assets/skins/gb_anim/img/gbc_left.png new file mode 100644 index 00000000..d7ac3998 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_left.png differ diff --git a/assets/skins/gb_anim/img/gbc_right.png b/assets/skins/gb_anim/img/gbc_right.png new file mode 100644 index 00000000..029520c9 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_right.png differ diff --git a/assets/skins/gb_anim/img/gbc_start_select.png b/assets/skins/gb_anim/img/gbc_start_select.png new file mode 100644 index 00000000..688d1903 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_start_select.png differ diff --git a/assets/skins/gb_anim/img/gbc_up.png b/assets/skins/gb_anim/img/gbc_up.png new file mode 100644 index 00000000..cc0fb248 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_up.png differ diff --git a/assets/skins/gb_anim/img/menu.png b/assets/skins/gb_anim/img/menu.png new file mode 100644 index 00000000..e27e7538 Binary files /dev/null and b/assets/skins/gb_anim/img/menu.png differ diff --git a/assets/skins/gb_anim/img/rotate.png b/assets/skins/gb_anim/img/rotate.png new file mode 100644 index 00000000..060c07bb Binary files /dev/null and b/assets/skins/gb_anim/img/rotate.png differ diff --git a/assets/skins/gb_anim/overlay.cfg b/assets/skins/gb_anim/overlay.cfg new file mode 100644 index 00000000..bfc2e254 --- /dev/null +++ b/assets/skins/gb_anim/overlay.cfg @@ -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 = diff --git a/assets/skins/tv_crt/README.md b/assets/skins/tv_crt/README.md new file mode 100644 index 00000000..fabdb561 --- /dev/null +++ b/assets/skins/tv_crt/README.md @@ -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. diff --git a/assets/skins/tv_crt/img/tv-integer.png b/assets/skins/tv_crt/img/tv-integer.png new file mode 100644 index 00000000..988c8766 Binary files /dev/null and b/assets/skins/tv_crt/img/tv-integer.png differ diff --git a/assets/skins/tv_crt/overlay.cfg b/assets/skins/tv_crt/overlay.cfg new file mode 100644 index 00000000..cc299bc8 --- /dev/null +++ b/assets/skins/tv_crt/overlay.cfg @@ -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 diff --git a/docs/new-features.md b/docs/new-features.md index 2fca8c70..64615d10 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -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 diff --git a/docs/skin-studio.md b/docs/skin-studio.md new file mode 100644 index 00000000..d7a834a4 --- /dev/null +++ b/docs/skin-studio.md @@ -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:` 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//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. diff --git a/main.lua b/main.lua index 4fbd44f2..10a07426 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/src/core/Game.lua b/src/core/Game.lua index 7c3f9aaa..5eaded11 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/SkinZip.lua b/src/core/SkinZip.lua new file mode 100644 index 00000000..f363b112 --- /dev/null +++ b/src/core/SkinZip.lua @@ -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 diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 7d879c41..d54a04c0 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -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 diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua new file mode 100644 index 00000000..db1db502 --- /dev/null +++ b/src/core/TouchSkin.lua @@ -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 /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 /skins/ 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 diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 4004a86e..7f043d1d 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -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 diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index fcb6165f..c08d15a5 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0cb92170..2efd6dcd 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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). diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4935daf9..ff744222 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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) diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua new file mode 100644 index 00000000..6c37bef8 --- /dev/null +++ b/src/ui/SkinStudio.lua @@ -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 diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index be9e0553..404a416c 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -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) diff --git a/tests/drivers/launcher_skins_tab_shot.lua b/tests/drivers/launcher_skins_tab_shot.lua new file mode 100644 index 00000000..6f0460b7 --- /dev/null +++ b/tests/drivers/launcher_skins_tab_shot.lua @@ -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 diff --git a/tests/drivers/skin_studio_author_tv.lua b/tests/drivers/skin_studio_author_tv.lua new file mode 100644 index 00000000..b95f3c4b --- /dev/null +++ b/tests/drivers/skin_studio_author_tv.lua @@ -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 diff --git a/tests/drivers/skin_studio_play_test.lua b/tests/drivers/skin_studio_play_test.lua new file mode 100644 index 00000000..7d9c971a --- /dev/null +++ b/tests/drivers/skin_studio_play_test.lua @@ -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 diff --git a/tests/drivers/skin_studio_shot.lua b/tests/drivers/skin_studio_shot.lua new file mode 100644 index 00000000..75d8bffb --- /dev/null +++ b/tests/drivers/skin_studio_shot.lua @@ -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 diff --git a/tests/drivers/touch_skin_editor_shot.lua b/tests/drivers/touch_skin_editor_shot.lua new file mode 100644 index 00000000..18947638 --- /dev/null +++ b/tests/drivers/touch_skin_editor_shot.lua @@ -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 diff --git a/tests/drivers/touch_skin_shot.lua b/tests/drivers/touch_skin_shot.lua new file mode 100644 index 00000000..c45ad950 --- /dev/null +++ b/tests/drivers/touch_skin_shot.lua @@ -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 diff --git a/tests/drivers/tv_skin_shot.lua b/tests/drivers/tv_skin_shot.lua new file mode 100644 index 00000000..3d701049 --- /dev/null +++ b/tests/drivers/tv_skin_shot.lua @@ -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 diff --git a/tests/engine/launcher_skins_tab.lua b/tests/engine/launcher_skins_tab.lua new file mode 100644 index 00000000..3c69039e --- /dev/null +++ b/tests/engine/launcher_skins_tab.lua @@ -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") diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua new file mode 100644 index 00000000..3fd66f18 --- /dev/null +++ b/tests/engine/skin_studio_test.lua @@ -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") diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua new file mode 100644 index 00000000..c0223638 --- /dev/null +++ b/tests/engine/touch_skin_test.lua @@ -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")