From 3945b9d07809156edb1597992cc98a4aa242d5b7 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 31 Jul 2026 09:23:38 -0400 Subject: [PATCH] on screen controller editing CLOSES #327 --- docs/new-features.md | 8 + main.lua | 84 ++++++++-- src/core/Game.lua | 1 + src/core/SaveData.lua | 5 + src/core/TouchControls.lua | 243 +++++++++++++++++++++++------ src/import/RomImporter.lua | 36 ++++- src/ui/OptionsMenu.lua | 34 ++++ src/ui/TouchControlsEditor.lua | 275 +++++++++++++++++++++++++++++++++ tests/run_tests.lua | 72 +++++++++ 9 files changed, 692 insertions(+), 66 deletions(-) create mode 100644 src/ui/TouchControlsEditor.lua diff --git a/docs/new-features.md b/docs/new-features.md index 3f33a59d..dadd454b 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -271,6 +271,14 @@ the last controller restores it immediately. Layout re-derives from the window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces the overlay on and lets the mouse act as a finger (`=0` forces it off). +The launcher's **Touch Controls** button opens a drag editor: move each +button freely, **Disable** to hide the overlay permanently (for +controllers / emulation handhelds — distinct from the temporary +gamepad auto-hide), **Reset** for defaults, **Done** to save into +`options.lua` as normalized window fractions so rotation keeps the +relative placement. In-game, Options → **TOUCH PAD** toggles the same +on/off flag without leaving a play session. + ## Translation support Every string the player can read is now reachable from a mod, so a diff --git a/main.lua b/main.lua index 83376201..17a1c65a 100644 --- a/main.lua +++ b/main.lua @@ -10,7 +10,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true -local Game, EditorApp, Importer +local Game, EditorApp, Importer, TouchEditor local autopilot -- optional scripted-input dev tool (tests/autopilot.lua) local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a @@ -134,6 +134,26 @@ function closeEditor() end end +-- ------------------------------------------------------------ touch controls editor +-- Suspends the launcher while the player drags on-screen buttons / toggles +-- the overlay off (#327). No ROM cache needed -- options.lua only. +local touchEditorHost +local closeTouchControlsEditor -- forward declaration + +local function openTouchControlsEditor() + touchEditorHost = Importer + Importer = nil + TouchEditor = require("src.ui.TouchControlsEditor") + TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) +end + +function closeTouchControlsEditor() + if TouchEditor and TouchEditor.unload then TouchEditor.unload() end + TouchEditor = nil + Importer = touchEditorHost + touchEditorHost = nil +end + local function bootGame(version) -- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and -- headless runs fall back to POKEPORT_VERSION, then Red. Set the active @@ -239,11 +259,17 @@ function love.load(args) Importer = RomImporter.new(function(version) Importer = nil bootGame(version) - end, { launcher = true, forceImport = forceImport, onEditSave = openEditor }) + end, { + launcher = true, + forceImport = forceImport, + onEditSave = openEditor, + onEditTouchControls = openTouchControlsEditor, + }) end function love.update(dt) if editorMode then return EditorApp.update(dt) end + if TouchEditor then return TouchEditor.update(dt) end if Importer then return Importer:update(dt) end -- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly @@ -283,6 +309,7 @@ end function love.draw() if editorMode then return EditorApp.draw() end + if TouchEditor then return TouchEditor.draw() end if Importer then return Importer:draw() end Game:draw() @@ -303,60 +330,61 @@ end function love.keypressed(key, scancode, isrepeat) if editorMode then return EditorApp.keypressed(key) end + if TouchEditor then return TouchEditor.keypressed(key) end if Importer then return Importer:keypressed(key) end Game:keypressed(key) end function love.keyreleased(key) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return end Game:keyreleased(key) end function love.gamepadpressed(joystick, button) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return Importer:gamepadpressed(joystick, button) end Game:gamepadpressed(joystick, button) end function love.gamepadreleased(joystick, button) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return Importer:gamepadreleased(joystick, button) end Game:gamepadreleased(joystick, button) end function love.gamepadaxis(joystick, axis, value) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return Importer:gamepadaxis(joystick, axis, value) end Game:gamepadaxis(joystick, axis, value) end function love.joystickpressed(joystick, button) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return Importer:joystickpressed(joystick, button) end Game:joystickpressed(joystick, button) end function love.joystickreleased(joystick, button) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return Importer:joystickreleased(joystick, button) end Game:joystickreleased(joystick, button) end function love.joystickaxis(joystick, axis, value) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return Importer:joystickaxis(joystick, axis, value) end Game:joystickaxis(joystick, axis, value) end function love.joystickhat(joystick, hat, direction) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return Importer:joystickhat(joystick, hat, direction) end Game:joystickhat(joystick, hat, direction) end function love.joystickremoved(joystick) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return end Game:joystickremoved(joystick) end @@ -365,7 +393,7 @@ end -- direction's key-up can be delivered to the OS instead of the game while -- unfocused, so reset input on either transition rather than trust it. function love.focus(f) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then if Importer.focus then Importer:focus(f) end return @@ -375,13 +403,19 @@ end -- v is true when the window becomes visible again, false on minimize. function love.visible(v) - if editorMode then return end + if editorMode or TouchEditor then return end if Importer then return end Game:visible(v) end function love.touchpressed(id, x, y, dx, dy, pressure) if editorMode then return end + if TouchEditor then + -- iOS synthesizes mousepressed for the primary touch (same as the + -- launcher); Android drives the editor through love.touch directly. + if love.system.getOS() == "iOS" then return end + return TouchEditor.touchpressed(id, x, y) + end if Importer then -- iOS: LÖVE already synthesizes a mousepressed for the primary touch, -- and love.mousepressed below forwards that to the Importer, so @@ -399,12 +433,20 @@ end function love.touchmoved(id, x, y, dx, dy, pressure) if editorMode then return end + if TouchEditor then + if love.system.getOS() == "iOS" then return end + return TouchEditor.touchmoved(id, x, y) + end if Importer then return end Game:touchmoved(id, x, y) end function love.touchreleased(id, x, y, dx, dy, pressure) if editorMode then return end + if TouchEditor then + if love.system.getOS() == "iOS" then return end + return TouchEditor.touchreleased(id, x, y) + end if Importer then return end Game:touchreleased(id, x, y) end @@ -414,11 +456,18 @@ function love.wheelmoved(x, y) if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end return end + if TouchEditor then return end if Importer then return end Game:wheelmoved(x, y) end function love.mousepressed(x, y, button) + if TouchEditor then + -- Android primary touch already arrived via love.touchpressed; a second + -- mouse path would double-fire Done / begin a second drag. + if love.system.getOS() == "Android" then return end + return TouchEditor.mousepressed(x, y, button) + end if Importer then return Importer:mousepressed(x, y, button) end if editorMode and EditorApp.mousepressed then return EditorApp.mousepressed(x, y, button) @@ -429,6 +478,10 @@ function love.mousepressed(x, y, button) end function love.mousereleased(x, y, button) + if TouchEditor then + if love.system.getOS() == "Android" then return end + return TouchEditor.mousereleased(x, y, button) + end if Importer then return end if editorMode and EditorApp.mousereleased then return EditorApp.mousereleased(x, y, button) @@ -439,6 +492,10 @@ function love.mousereleased(x, y, button) end function love.mousemoved(x, y) + if TouchEditor then + if love.system.getOS() == "Android" then return end + return TouchEditor.mousemoved(x, y) + end if editorMode or Importer then return end if mouseTouch and Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) @@ -446,6 +503,7 @@ function love.mousemoved(x, y) end function love.textinput(text) + if TouchEditor then return end if Importer then return Importer:textinput(text) end if editorMode and EditorApp.textinput then return EditorApp.textinput(text) diff --git a/src/core/Game.lua b/src/core/Game.lua index 694e5c72..34474742 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -619,6 +619,7 @@ function Game:applyOptions(opts) -- fpsCap key pace at the standard rate (issue #88) require("src.core.FrameCap").applyOptions(opts) Input:applyBindings(opts.bindings) + TouchControls:applyOptions(opts) -- heal soft-bricked APK installs that already saved gbcfx > 0 (#136) if gbcCleared then self:writeOptions() end end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index c597c361..7775c686 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -249,6 +249,11 @@ function SaveData.defaultOptions() -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, + -- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua). + -- enabled=false hides it permanently (distinct from auto-hide-on-gamepad). + -- positions are optional normalized centers {x=0..1, y=0..1} per control + -- (dpad/a/b/start/select); nil means the default layout. + touchControls = { enabled = true }, } end diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 68be036a..50178e7c 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -12,6 +12,11 @@ -- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it -- off everywhere. -- +-- Player preferences (options.touchControls) can permanently disable the +-- overlay and/or override per-control positions as normalized window +-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes +-- those; applyOptions reads them at boot and whenever options change. +-- -- Controls press GB buttons through Input:overlayPressed/Released -- their -- own input source, not a keyboard alias -- so a held overlay direction -- merges cleanly with a keyboard key or stick holding the same button, @@ -39,6 +44,7 @@ local DPAD_DEAD = 0.16 local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 } local BUTTONS = { "a", "b", "start", "select" } +local CONTROLS = { "dpad", "a", "b", "start", "select" } local IMAGES = { dpad = "assets/touch/dpad.png", @@ -52,6 +58,12 @@ local IMAGES = { select = "assets/touch/select.png", } +local function clamp01(v) + if v < 0 then return 0 end + if v > 1 then return 1 end + return v +end + local function wantsOverlay() local env = os.getenv("POKEPORT_TOUCH") if env == "1" then return true end @@ -60,8 +72,59 @@ local function wantsOverlay() return osName == "Android" or osName == "iOS" end +-- Normalize a persisted touchControls table into {enabled, positions}. +-- Unknown / garbage keys are dropped so a bad options.lua cannot brick +-- the overlay. +function TouchControls.normalizeConfig(tc) + local out = { enabled = true, positions = nil } + if type(tc) ~= "table" then return out end + if tc.enabled == false then out.enabled = false end + if type(tc.positions) == "table" then + local pos = {} + for _, name in ipairs(CONTROLS) do + local p = tc.positions[name] + if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then + pos[name] = { x = clamp01(p.x), y = clamp01(p.y) } + end + end + if next(pos) then out.positions = pos end + end + return out +end + +-- Pure default layout in LOVE units for a given window size. Shared by +-- layout() and the editor's Reset path so defaults stay in one place. +function TouchControls.defaultLayout(ww, wh) + local short = math.min(ww, wh) + local dpadW = math.min(180, short * 0.34) + local abW = dpadW * 0.46 + local ssW = dpadW * 0.30 + local margin = dpadW * 0.12 + return { + dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW }, + a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW }, + b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW }, + start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, + select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, + } +end + +local function loadImages() + local img = {} + for name, path in pairs(IMAGES) do + local ok, im = pcall(love.graphics.newImage, path) + if not ok then return nil end + im:setFilter("linear", "linear") + img[name] = im + end + return img +end + function TouchControls:init() self.active = wantsOverlay() + self.enabled = true + self.positions = nil + self.preview = false self.controllerHidden = false self.touches = {} -- per-GB-button owner count: two fingers on A must not double-press it, @@ -70,51 +133,86 @@ function TouchControls:init() self.dpadTouch = nil self.layoutW, self.layoutH = nil, nil self.img = nil - if not self.active then return end - -- soft-fail: a missing/corrupt PNG must never block boot; the overlay - -- stays off and keyboard/controller play still works - local img = {} - for name, path in pairs(IMAGES) do - local ok, im = pcall(love.graphics.newImage, path) - if not ok then - img = nil - break - end - -- smooth UI icons; the global default filter is nearest for GB pixels - im:setFilter("linear", "linear") - img[name] = im + -- Images load whenever the platform wants the overlay OR the launcher + -- editor forces a preview (desktop testing of the editor). + if self.active then + self.img = loadImages() + end +end + +-- Ensure art is loaded for the launcher editor even when wantsOverlay() +-- is false (desktop without POKEPORT_TOUCH). +function TouchControls:ensureImages() + if self.img then return true end + self.img = loadImages() + return self.img ~= nil +end + +-- Apply options.touchControls. Called from Game:applyOptions and from +-- the launcher editor after a save. +function TouchControls:applyOptions(opts) + local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) + self.enabled = cfg.enabled + self.positions = cfg.positions + self.layoutW, self.layoutH = nil, nil + if not self.enabled then + self.controllerHidden = false + self:reset() + end +end + +function TouchControls:config() + return { + enabled = self.enabled ~= false, + positions = self.positions, + } +end + +-- Preview mode: force-draw the overlay for the layout editor, ignoring +-- platform / enabled / gamepad gates. Gameplay input still respects +-- enabled via touchpressed. +function TouchControls:setPreview(on) + self.preview = on and true or false + if on then + self:ensureImages() + self.controllerHidden = false end - self.img = img end function TouchControls:visible() - return self.active and self.img ~= nil and not self.controllerHidden + if self.preview then return self.img ~= nil end + return self.active and self.enabled ~= false and self.img ~= nil + and not self.controllerHidden +end + +local function clampZone(zone, ww, wh) + local half = zone.w * 0.5 + zone.cx = math.max(half, math.min(ww - half, zone.cx)) + zone.cy = math.max(half, math.min(wh - half, zone.cy)) end -- Layout in LOVE units (density-independent on mobile), recomputed when --- the window size changes (rotation, resize). D-pad bottom-left, B/A --- bottom-right with A above B (the Game Boy diagonal), START/SELECT --- flanking the bottom center. +-- the window size changes (rotation, resize). Default: d-pad bottom-left, +-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT +-- flanking the bottom center. Custom positions (normalized 0..1) override +-- centers while sizes stay derived from the short edge. function TouchControls:layout() local ww, wh = love.graphics.getDimensions() - if self.layoutW == ww and self.layoutH == wh then return self.L end + if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end self.layoutW, self.layoutH = ww, wh - local short = math.min(ww, wh) - -- ~a third of the short edge, capped so tablets don't get a dinner plate - local dpadW = math.min(180, short * 0.34) - local abW = dpadW * 0.46 - local ssW = dpadW * 0.30 - local margin = dpadW * 0.12 - -- START/SELECT hug the bottom center: on a narrow portrait phone the - -- d-pad and B leave little room, so a tight pair is what keeps them off - -- the neighboring controls - self.L = { - dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW }, - a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW }, - b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW }, - start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, - select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, - } + self.L = TouchControls.defaultLayout(ww, wh) + if self.positions then + for _, name in ipairs(CONTROLS) do + local p = self.positions[name] + local zone = self.L[name] + if p and zone then + zone.cx = p.x * ww + zone.cy = p.y * wh + clampZone(zone, ww, wh) + end + end + end + local ssW = self.L.start.w local fontSize = math.max(8, math.floor(ssW * 0.26)) if not self.labelFont or self.fontSize ~= fontSize then self.fontSize = fontSize @@ -123,12 +221,45 @@ function TouchControls:layout() return self.L end +-- Move one control to a screen-space point and persist its normalized +-- position. Used by the layout editor while dragging. +function TouchControls:setControlCenter(name, cx, cy) + local ww, wh = love.graphics.getDimensions() + local L = self:layout() + local zone = L[name] + if not zone then return end + zone.cx, zone.cy = cx, cy + clampZone(zone, ww, wh) + self.positions = self.positions or {} + self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh } +end + +function TouchControls:clearPositions() + self.positions = nil + self.layoutW, self.layoutH = nil, nil +end + local function inCircle(zone, x, y, slop) local r = zone.w * 0.5 * slop local dx, dy = x - zone.cx, y - zone.cy return dx * dx + dy * dy <= r * r 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 L = self:layout() + for _, btn in ipairs(BUTTONS) do + if inCircle(L[btn], x, y, SLOP[btn]) then return btn end + end + local dz = L.dpad + local half = dz.w * 0.65 + if math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then + return "dpad" + end + return nil +end + local function dpadDir(zone, x, y) local dx, dy = x - zone.cx, y - zone.cy local dead = zone.w * DPAD_DEAD @@ -165,7 +296,9 @@ local function setDpad(self, touch, dir) end function TouchControls:touchpressed(id, x, y) - if not (self.active and self.img) then return end + -- 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 -- a controller hid the overlay; the first touch only brings it back if self.controllerHidden then self.controllerHidden = false @@ -192,6 +325,7 @@ function TouchControls:touchpressed(id, x, y) end function TouchControls:touchmoved(id, x, y) + if self.preview then return end local touch = self.touches[id] -- only the d-pad tracks movement (slide between directions without -- lifting); buttons hold until release wherever the finger wanders @@ -200,6 +334,7 @@ function TouchControls:touchmoved(id, x, y) end function TouchControls:touchreleased(id, x, y) + if self.preview then return end local touch = self.touches[id] if not touch then return end self.touches[id] = nil @@ -216,7 +351,7 @@ end -- touchreleased and would strand its button held forever. Called from -- Game alongside Input:reset() on focus/visibility loss. function TouchControls:reset() - for btn in pairs(self.held) do + for btn in pairs(self.held or {}) do Input:overlayReleased(btn) end self.held = {} @@ -225,9 +360,13 @@ function TouchControls:reset() end -- a gamepad is being used: hide the overlay (dropping anything it held) --- until the next screen touch asks for it back +-- until the next screen touch asks for it back. No-op when the player +-- permanently disabled the overlay -- there is nothing to hide, and a +-- later accidental touch must not resurrect it. function TouchControls:noteGamepad() - if not self.active or self.controllerHidden then return end + if not self.active or self.enabled == false or self.controllerHidden then + return + end self.controllerHidden = true self:reset() end @@ -242,40 +381,46 @@ function TouchControls:joystickremoved() end end -local function drawIcon(img, zone, pressed) - love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK) +local function drawIcon(img, zone, pressed, alphaMul) + alphaMul = alphaMul or 1 + love.graphics.setColor(1, 1, 1, (pressed and BACK_PRESSED or BACK) * alphaMul) love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58) local scale = zone.w / img:getWidth() - love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA) + love.graphics.setColor(1, 1, 1, (pressed and ALPHA_PRESSED or ALPHA) * alphaMul) love.graphics.draw(img, zone.cx - zone.w / 2, zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end -- Screen-space, called by Game:draw after Renderer:endFrame so the -- overlay rides on top of everything (world, UI, CRT/GBC FX included). +-- Also used by the launcher layout editor under preview mode. function TouchControls:draw() if not self:visible() then return end local L = self:layout() + -- when the player disabled the overlay but the editor is previewing, + -- draw dimmed so the layout is still editable + local alphaMul = (self.preview and self.enabled == false) and 0.45 or 1 love.graphics.push("all") love.graphics.origin() local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch] local dir = dpadTouch and dpadTouch.dir drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad, - dir ~= nil) + dir ~= nil, alphaMul) for _, btn in ipairs(BUTTONS) do - drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil) + drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil, alphaMul) end -- the +/- glyphs alone don't say which is which; shadowed so the text - -- reads on both the black letterbox and battle's white one + -- reads on both the black letterbox and battle's white one. Each label + -- tracks its own control's cy/w so dragging START cannot move SELECT. love.graphics.setFont(self.labelFont) - local ly = L.start.cy + L.start.w * 0.66 local function label(text, zone) + local ly = zone.cy + zone.w * 0.66 local w = self.labelFont:getWidth(text) - love.graphics.setColor(0, 0, 0, 0.6) + love.graphics.setColor(0, 0, 0, 0.6 * alphaMul) love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1) - love.graphics.setColor(1, 1, 1, ALPHA + 0.2) + love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul) love.graphics.print(text, zone.cx - w / 2, ly) end label("START", L.start) @@ -284,4 +429,6 @@ function TouchControls:draw() love.graphics.pop() end +TouchControls.CONTROLS = CONTROLS + return TouchControls diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index b5b87b7d..7a36d12d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -533,7 +533,9 @@ end -- forceImport (treat every version as not-yet-imported, so re-import is forced), -- onEditSave(version, slotId) (host handler for the Edit affordance on a save -- row -- main.lua opens the bundled save editor on that slot; when it is not --- supplied the Edit label is not drawn at all). +-- supplied the Edit label is not drawn at all), +-- onEditTouchControls() (host handler for the Touch Controls button -- main.lua +-- opens the layout editor; when it is not supplied the button is not drawn). function RomImporter.new(onComplete, opts) opts = opts or {} -- iOS rides the same mobile import flows as Android: the save-dir @@ -548,6 +550,7 @@ function RomImporter.new(onComplete, opts) launcher = opts.launcher or false, forceImport = opts.forceImport or false, onEditSave = opts.onEditSave, + onEditTouchControls = opts.onEditTouchControls, android = android, ios = mobileOS == "iOS", -- One startup poll pass: files dropped through the Files app are swept @@ -1622,6 +1625,9 @@ function RomImporter:_resetFrameRects() self.saveImportRect = nil self.saveExportRect = nil self.saveFolderRect = nil + -- Rebuilt only by the active game panel; nil elsewhere so the mods tab + -- cannot inherit last frame's Touch Controls button. + self.touchControlsRect = nil end function RomImporter:draw() @@ -2217,6 +2223,10 @@ function RomImporter:mousepressed(x, y, button) end return end + if inside(self.touchControlsRect, x, y) then + if self.onEditTouchControls then self.onEditTouchControls() end + return + end -- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap -- on either never also selects the row. A press only ARMS a row click: -- _updateSlotDrag commits it on release when the pointer did not move (a @@ -2626,16 +2636,26 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) local sfFolderH = (sfNotice and sfNotice.dir) and (self.hintFont:getHeight() + 4 * s) or 0 local saveFilesH = pad + labelH + 10 * s + sfBtnH + 6 * s + sfHintH + sfFolderH + pad local playH = math.max(50 * s, self.playFont:getHeight() + 30 * s) + -- Touch Controls editor entry (layout + permanent disable). Drawn whenever + -- the host supplied onEditTouchControls; height reserved only then so a + -- scripted/headless importer without the callback stays compact. + local touchBtnH = self.onEditTouchControls + and math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) or 0 + local touchGap = self.onEditTouchControls and (12 * s) or 0 -- vertical placement of the left column local romY = bodyTop local saveFilesY = romY + romCardH + 12 * s - local leftNaturalH = romCardH + 12 * s + saveFilesH + 12 * s + playH - local playY + local leftNaturalH = romCardH + 12 * s + saveFilesH + touchGap + touchBtnH + + 12 * s + playH + local playY, touchY if twoCol and not paged then - playY = bodyTop + bodyH - playH -- pinned to the column's bottom + -- Play pinned to the column bottom; Touch Controls sits just above it + playY = bodyTop + bodyH - playH + touchY = playY - touchGap - touchBtnH else - playY = saveFilesY + saveFilesH + 12 * s + touchY = saveFilesY + saveFilesH + touchGap + playY = touchY + touchBtnH + 12 * s end -- ROM card @@ -2704,6 +2724,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) self.saveFolderRect = frect end + -- Touch Controls: open the drag-to-reposition / disable editor (#327). + if self.onEditTouchControls and touchBtnH > 0 then + self.touchControlsRect = self:_glassyButton( + leftX, touchY, colW, touchBtnH, "Touch Controls", self.saveBtnFont, true) + end + -- Play button self:_playButton(leftX, playY, colW, playH, gameName, ready, locked) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 6ca776c3..1d3443ca 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -322,6 +322,25 @@ local function buildRows(game) activate = function(g) require("src.ui.Screens").push(g, "BindingsMenu") end }, + -- permanent on-screen pad toggle (#327); layout editing stays in the + -- launcher. Hidden where the overlay never appears (desktop without + -- POKEPORT_TOUCH), so the row costs a non-mobile install nothing. + { id = "touchControls", label = Strings("TOUCH PAD"), + value = function(g) + local tc = g.save.options.touchControls + local on = not (type(tc) == "table" and tc.enabled == false) + return on and Strings("ON") or Strings("OFF") + end, + step = function(g) + local o = g.save.options + local tc = type(o.touchControls) == "table" and o.touchControls or {} + local on = not (tc.enabled == false) + tc.enabled = not on + -- keep any saved positions when toggling + o.touchControls = tc + require("src.core.TouchControls"):applyOptions(o) + return true + end }, } -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks if not GBCFX.isSupported() then @@ -331,6 +350,21 @@ local function buildRows(game) end rows = filtered end + -- TOUCH PAD only where the overlay can appear (mobile, or desktop with + -- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere. + do + local env = os.getenv("POKEPORT_TOUCH") + local osName = love.system and love.system.getOS and love.system.getOS() + local show = env == "1" + or (env ~= "0" and (osName == "Android" or osName == "iOS")) + if not show then + local filtered = {} + for _, row in ipairs(rows) do + if row.id ~= "touchControls" then filtered[#filtered + 1] = row end + end + rows = filtered + end + end -- PIKACHU VOL only means something where the voice clips exist: Yellow -- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue -- keep the row list they always had. diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua new file mode 100644 index 00000000..2a69ea02 --- /dev/null +++ b/src/ui/TouchControlsEditor.lua @@ -0,0 +1,275 @@ +-- Launcher touch-controls editor (#327): drag each on-screen button to a +-- new spot, 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. +-- +-- 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. + +local SaveData = require("src.core.SaveData") +local TouchControls = require("src.core.TouchControls") + +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 + +function Editor.load(opts) + opts = opts or {} + Editor.onClose = opts.onClose + Editor.drag = nil + Editor.rects = {} + Editor.fonts = { + title = love.graphics.newFont(28), + body = love.graphics.newFont(16), + btn = love.graphics.newFont(18), + } + local optsTbl = SaveData.loadOptions() + TouchControls:init() + TouchControls:ensureImages() + TouchControls:applyOptions(optsTbl) + TouchControls:setPreview(true) + Editor.enabled = TouchControls.enabled ~= false +end + +function Editor.unload() + TouchControls:setPreview(false) + TouchControls:reset() + Editor.drag = nil + Editor.onClose = nil +end + +local function persist() + local opts = SaveData.loadOptions() + local cfg = TouchControls:config() + opts.touchControls = { + enabled = cfg.enabled, + positions = cfg.positions, + } + SaveData.saveOptions(opts) +end + +local function close() + 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) + -- drag follows the live pointer when love.touch / mouse is available; + -- touchmoved / mousemoved also update, so this is a belt-and-suspenders + -- path for Android where move events can be thin + if not Editor.drag then return end + local x, y + if 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 ww, wh = love.graphics.getDimensions() + local s = math.max(0.75, math.min(1.4, wh / 768)) + Editor.rects = {} + + -- radial-ish navy field (two stacked fills; matches launcher atmosphere) + col(PAL.bgBot) + love.graphics.rectangle("fill", 0, 0, ww, wh) + col(PAL.bgTop, 0.85) + love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55) + + local pad = 18 * s + local barH = 56 * s + local btnH = 40 * s + local btnW = 100 * s + + -- top bar + col(PAL.card, 0.92) + love.graphics.rectangle("fill", 0, 0, ww, barH + pad) + col(PAL.stroke, 0.35) + love.graphics.setLineWidth(1) + love.graphics.line(0, barH + pad, ww, barH + pad) + + love.graphics.setFont(Editor.fonts.title) + col(PAL.white) + love.graphics.print("Touch Controls", pad, pad + 4 * s) + + -- Done / Reset + local done = { x = ww - pad - btnW, y = 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 + + 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 = barH + pad + 14 * s + local cardH = 64 * s + local cardX, cardW = 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) + + -- hint + love.graphics.setFont(Editor.fonts.body) + col(PAL.label, 0.9) + local hint = on + and "Drag each button to reposition. Layout is saved when you tap Done." + or "Controls are hidden in-game. Enable them to show and edit the layout." + love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left") + + -- the overlay itself (preview mode; dimmed when disabled) + TouchControls:draw() + + -- 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 +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 + + 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.mousepressed(x, y, button) + if button ~= 1 then return end + 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) + 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 + +function Editor.keypressed(key) + if key == "escape" or key == "return" or key == "space" then + close() + elseif key == "r" then + resetLayout() + end +end + +return Editor diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 052027c5..b2809cfe 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1674,6 +1674,16 @@ do local optBack = SD.loadOptions() eq(optBack.zoom, -2, "options.lua round-trips zoom") eq(optBack.voidFill, "water", "options.lua round-trips voidFill") + -- touchControls (#327): enabled flag + normalized positions + rep.options.touchControls = { + enabled = false, + positions = { dpad = { x = 0.2, y = 0.8 }, a = { x = 0.9, y = 0.7 } }, + } + SD.saveOptions(rep.options) + optBack = SD.loadOptions() + eq(optBack.touchControls.enabled, false, "options.lua round-trips touchControls.enabled") + eq(optBack.touchControls.positions.dpad.x, 0.2, + "options.lua round-trips touchControls.positions") local origOpts, loadedOpts = rep.options, back.options rep.options, back.options = nil, nil local same, where = deepEq(rep, back, "save") @@ -1683,6 +1693,68 @@ do SD.saveOptions(SD.defaultOptions()) end +-- ---------------------------------------------------------------- touch controls layout (#327) +do + local TC = require("src.core.TouchControls") + local cfg = TC.normalizeConfig(nil) + eq(cfg.enabled, true, "touchControls default enabled") + check(cfg.positions == nil, "touchControls default positions nil") + + cfg = TC.normalizeConfig({ + enabled = false, + positions = { + dpad = { x = 1.5, y = -0.2 }, -- clamped + a = { x = 0.5, y = 0.5 }, + junk = { x = 0, y = 0 }, + b = { x = "nope", y = 0.1 }, + }, + }) + eq(cfg.enabled, false, "normalizeConfig keeps enabled=false") + eq(cfg.positions.dpad.x, 1, "normalizeConfig clamps x high") + eq(cfg.positions.dpad.y, 0, "normalizeConfig clamps y low") + eq(cfg.positions.a.x, 0.5, "normalizeConfig keeps a") + check(cfg.positions.junk == nil, "normalizeConfig drops unknown controls") + check(cfg.positions.b == nil, "normalizeConfig drops non-numeric") + + local L = TC.defaultLayout(400, 800) + check(L.dpad.cx < 200, "default d-pad on left half") + check(L.a.cx > 200, "default A on right half") + check(L.dpad.cy > 400, "default d-pad in bottom half") + + -- applyOptions + visible gate (no real images needed for the gate) + TC.enabled = true + TC.active = true + TC.img = {} -- pretend art loaded + TC.controllerHidden = false + TC.preview = false + TC:applyOptions({ touchControls = { enabled = false } }) + eq(TC.enabled, false, "applyOptions disables overlay") + check(not TC:visible(), "disabled overlay is not visible") + TC:applyOptions({ + touchControls = { + enabled = true, + positions = { dpad = { x = 0.25, y = 0.75 } }, + }, + }) + eq(TC.enabled, true, "applyOptions re-enables overlay") + eq(TC.positions.dpad.x, 0.25, "applyOptions stores positions") + check(TC:visible(), "enabled overlay is visible when active+imaged") + + -- custom position applied through layout() + local g = love.graphics + local oldDim, oldFont = g.getDimensions, g.newFont + g.getDimensions = function() return 400, 800 end + g.newFont = function() return { getWidth = function() return 10 end, + getHeight = function() return 10 end } end + TC.layoutW, TC.layoutH, TC.L = nil, nil, nil + local lay = TC:layout() + eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww") + eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh") + TC:clearPositions() + check(TC.positions == nil, "clearPositions wipes overrides") + g.getDimensions, g.newFont = oldDim, oldFont +end + -- ---------------------------------------------------------------- crit thresholds (CriticalHitTest) -- The threshold byte b from engine/battle/core.asm's shift chain: -- srl (speed/2), then sla (cap 255) without Focus Energy or srl with the