Files
gen1recomp/src/ui/TouchControlsEditor.lua
T

600 lines
18 KiB
Lua

-- Launcher touch-controls editor (#327): drag each on-screen button to a
-- new spot, resize them, toggle the overlay off entirely, Reset to
-- defaults, Done to persist into options.lua. Opened from the game
-- panel's "Touch Controls" button; main.lua suspends the launcher the same
-- way it does for the save editor.
--
-- Everything edited here belongs to the orientation currently on screen
-- (#633): portrait and landscape keep separate positions and sizes, so a
-- player lays out each rotation once. Rotate the device (or resize the
-- desktop window past square) and the editor switches buckets live.
--
-- Draws in full window LOVE units -- the same space TouchControls uses
-- after Renderer:endFrame -- so what you drag here is what you get in play.
--
-- Switch / gamepad: PadCursor draws the virtual pointer the launcher just
-- dropped (same soft-lock class as the save editor). A clicks / drags, B
-- closes (Done), shoulders nudge button size.
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")
local Editor = {}
local PAL = {
bgTop = { 8, 14, 36 },
bgBot = { 4, 8, 22 },
white = { 245, 248, 255 },
label = { 160, 175, 210 },
green = { 80, 220, 140 },
red = { 240, 90, 110 },
card = { 18, 28, 58 },
stroke = { 120, 150, 220 },
}
local function col(c, a)
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1)
end
local function inside(r, x, y)
return r and x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h
end
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
Editor.version = opts.version
Editor.hostPoll = opts.hostPoll == true
Editor.drag = nil
Editor.rects = {}
Editor._closed = false
Editor._hostMouse = false
Editor._hostTouches = nil
Editor.fonts = {
title = love.graphics.newFont(28),
body = love.graphics.newFont(16),
btn = love.graphics.newFont(18),
}
local optsTbl = SaveData.loadOptions()
local applied = optsTbl
if opts.version == "gold" then
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
applied = {
touchControls = gold.touchControls,
haptics = gold.haptics or optsTbl.haptics,
}
end
TouchControls:init()
TouchControls:ensureImages()
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()
PadCursor.reset()
Editor.drag = nil
Editor.onClose = nil
Editor.version = nil
Editor.rects = {}
Editor._hostMouse = false
Editor._hostTouches = nil
end
local function persist()
local opts = SaveData.loadOptions()
local cfg = TouchControls:config()
local block = {
enabled = cfg.enabled,
skin = cfg.skin,
layouts = cfg.layouts,
}
if Editor.version == "gold" then
opts.gold = type(opts.gold) == "table" and opts.gold or {}
opts.gold.touchControls = block
else
opts.touchControls = block
end
SaveData.saveOptions(opts)
end
local function close()
if Editor._closed then return end
Editor._closed = true
persist()
local cb = Editor.onClose
Editor.unload()
if cb then cb() end
end
local function resetLayout()
TouchControls:clearPositions()
end
local function toggleEnabled()
Editor.enabled = not Editor.enabled
TouchControls.enabled = Editor.enabled
if not Editor.enabled then TouchControls:reset() end
end
function Editor.update(dt)
PadCursor.update(dt or 0)
if Editor.hostPoll then Editor.pollHostPointers() end
if not Editor.drag then return end
local x, y
if Editor.drag.touchId == "pad" then
x, y = PadCursor.pointer()
elseif love.touch and love.touch.getPosition and Editor.drag.touchId then
local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId)
if ok and tx then x, y = tx, ty end
end
if not x and love.mouse then
x, y = love.mouse.getPosition()
end
if x then
TouchControls:setControlCenter(
Editor.drag.name,
x - Editor.drag.offX,
y - Editor.drag.offY)
end
end
function Editor.draw()
local SafeArea = require("src.core.SafeArea")
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768))
-- resolve the orientation bucket before any chrome reads scale (#633)
local bucket = TouchControls:currentBucket()
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
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
local btnW = 100 * s
-- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1)
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
-- 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)
col(PAL.white, 0.2)
roundRect("line", r.x, r.y, r.w, r.h, 8 * s)
love.graphics.setFont(Editor.fonts.btn)
col(PAL.white)
local tw = Editor.fonts.btn:getWidth(label)
love.graphics.print(label, r.x + (r.w - tw) / 2,
r.y + (r.h - Editor.fonts.btn:getHeight()) / 2)
end
chromeBtn(reset, "Reset", { 60, 70, 110 })
chromeBtn(done, "Done", PAL.green)
-- enable toggle card
local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s
local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, cardY, cardW, cardH, 12 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
love.graphics.print("On-screen controls", cardX + 16 * s,
cardY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
local on = Editor.enabled
col(on and PAL.green or PAL.red)
love.graphics.print(on and "ON" or "OFF", cardX + 16 * s,
cardY + 34 * s)
local toggleW = 110 * s
local toggle = {
x = cardX + cardW - 16 * s - toggleW,
y = cardY + (cardH - btnH) / 2,
w = toggleW, h = btnH,
}
Editor.rects.toggle = toggle
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 = skinY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, sizeY, cardW, cardH, 12 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
local orient = TouchControls.orientation == "landscape"
and "Landscape" or "Portrait"
love.graphics.print("Button size (" .. orient .. ")",
cardX + 16 * s, sizeY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
col(PAL.white)
love.graphics.print(
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
cardX + 16 * s, sizeY + 34 * 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 }
Editor.rects.sizeUp, Editor.rects.sizeDown = plus, minus
chromeBtn(minus, "-", { 60, 70, 110 })
chromeBtn(plus, "+", { 60, 70, 110 })
-- hint
love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9)
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")
if not TouchSkin.active then TouchControls:draw() end
-- highlight the control under drag
if Editor.drag then
local L = TouchControls:layout()
local zone = L[Editor.drag.name]
if zone then
love.graphics.setLineWidth(3 * s)
col(PAL.green, 0.85)
love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62)
end
end
-- pad / Joy-Con virtual cursor (after chrome so it sits on top)
PadCursor.draw()
end
local function beginDrag(id, x, y)
-- chrome takes priority over controls
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
if inside(Editor.rects.sizeUp, x, y) then
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]
Editor.drag = {
name = name,
touchId = id,
offX = x - zone.cx,
offY = y - zone.cy,
}
end
local function moveDrag(id, x, y)
local d = Editor.drag
if not d then return end
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
TouchControls:setControlCenter(d.name, x - d.offX, y - d.offY)
end
local function endDrag(id)
local d = Editor.drag
if not d then return end
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
Editor.drag = nil
end
function Editor.pollHostPointers()
local down = love.mouse and love.mouse.isDown and love.mouse.isDown(1)
if down then
local x, y = love.mouse.getPosition()
if not Editor._hostMouse then
Editor._hostMouse = true
beginDrag("mouse", x, y)
if Editor._closed then return end
else
moveDrag("mouse", x, y)
end
elseif Editor._hostMouse then
Editor._hostMouse = false
endDrag("mouse")
end
if not (love.touch and love.touch.getTouches and love.touch.getPosition) then
return
end
Editor._hostTouches = Editor._hostTouches or {}
local seen = {}
for _, id in ipairs(love.touch.getTouches()) do
seen[id] = true
local ok, tx, ty = pcall(love.touch.getPosition, id)
if ok and tx then
if not Editor._hostTouches[id] then
Editor._hostTouches[id] = true
beginDrag(id, tx, ty)
if Editor._closed then return end
else
moveDrag(id, tx, ty)
end
end
end
for id in pairs(Editor._hostTouches) do
if not seen[id] then
Editor._hostTouches[id] = nil
endDrag(id)
end
end
end
function Editor.mousepressed(x, y, button)
if button ~= 1 then return end
-- Finger / mouse tap yields the Joy-Con pointer so the click lands where
-- the event said (same NX soft-miss fix as the save editor).
PadCursor.yieldToPointer()
beginDrag("mouse", x, y)
end
function Editor.mousemoved(x, y)
if love.mouse.isDown(1) then moveDrag("mouse", x, y) end
end
function Editor.mousereleased(x, y, button)
if button ~= 1 then return end
endDrag("mouse")
end
function Editor.touchpressed(id, x, y)
PadCursor.yieldToPointer()
beginDrag(id, x, y)
end
function Editor.touchmoved(id, x, y)
moveDrag(id, x, y)
end
function Editor.touchreleased(id, x, y)
endDrag(id)
end
local function handlePadAction(action)
if not action then return end
if action == "a" then
local mx, my = PadCursor.pointer()
beginDrag("pad", mx, my)
elseif action == "b" then
close()
elseif action == "tab_prev" then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
elseif action == "tab_next" then
TouchControls:nudgeScale(TouchControls.SCALE_STEP)
end
end
function Editor.gamepadpressed(joystick, button)
handlePadAction(PadCursor.gamepadpressed(joystick, button))
end
function Editor.gamepadreleased(joystick, button)
PadCursor.gamepadreleased(joystick, button)
-- A release ends a pad drag (hold A + stick to reposition a control).
local action = GamepadMap.mapGamepadButton(button)
if action == "a" then endDrag("pad") end
end
function Editor.gamepadaxis(joystick, axis, value)
PadCursor.gamepadaxis(joystick, axis, value)
end
function Editor.joystickpressed(joystick, button)
handlePadAction(PadCursor.joystickpressed(joystick, button))
end
function Editor.joystickreleased(joystick, button)
PadCursor.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then
local action = GamepadMap.mapGamepadButton(padButton)
if action == "a" then endDrag("pad") end
end
end
function Editor.joystickaxis(joystick, axis, value)
PadCursor.joystickaxis(joystick, axis, value)
end
function Editor.joystickhat(joystick, hat, direction)
PadCursor.joystickhat(joystick, hat, direction)
end
function Editor.keypressed(key)
if key == "escape" or key == "return" or key == "space" then
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)
elseif key == "=" or key == "+" or key == "kp+" then
TouchControls:nudgeScale(TouchControls.SCALE_STEP)
end
end
function Editor.new(game)
local state = { game = game, isOpaque = true }
Editor.hostPoll = true
Editor.load({
version = "gold",
hostPoll = true,
onClose = function()
Editor.hostPoll = false
if game.options then
game.options.touchControls = TouchControls:config()
end
if game.stack and game.stack:top() == state then
game.stack:pop()
end
if game.applyOptions then game:applyOptions() end
end,
})
function state:wantsFillScale() return true end
function state:drawsWidescreen() return true end
function state:update(dt)
Editor.update(dt)
local input = self.game and self.game.input
if input and input:wasPressed("b") then close() end
end
function state:draw() end
function state:drawWidescreen(_w, _h)
Editor.draw()
end
return state
end
return Editor