Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8936e79d6 |
@@ -0,0 +1,9 @@
|
||||
# On-screen touch control art
|
||||
|
||||
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
|
||||
Those Awesome Guys), public domain under CC0:
|
||||
https://thoseawesomeguys.com/prompts/
|
||||
|
||||
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
|
||||
d-pad (neutral + per-direction highlight), A, B, plus (START) and
|
||||
minus (SELECT).
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -47,8 +47,9 @@ function love.conf(t)
|
||||
-- freely to portrait or landscape), otherwise locked to the window's w/h
|
||||
-- aspect. So a non-resizable tall window forced portrait; resizable lets
|
||||
-- the game follow the device. The renderer letterboxes the 160x144
|
||||
-- viewport into whatever size results, and touch input is gesture-based,
|
||||
-- so both orientations just work. iOS follows the Info.plist orientations
|
||||
-- viewport into whatever size results, and the on-screen touch controls
|
||||
-- re-lay themselves out from the new window size, so both orientations
|
||||
-- just work. iOS follows the Info.plist orientations
|
||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||
t.window.resizable = true
|
||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||
|
||||
@@ -179,4 +179,18 @@ Into-battle wipes still run the original eight styles inside the classic
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||
center) over the frame, using Xelu's CC0 controller prompts
|
||||
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||
finger does, sliding on the d-pad changes direction without lifting, and
|
||||
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||
appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
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).
|
||||
@@ -18,6 +18,11 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- bot or screenshot run is not at the mercy of the player's last choice.
|
||||
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
||||
|
||||
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
|
||||
-- mouse stand in for a finger, so the overlay can be exercised on desktop
|
||||
-- (see src/core/TouchControls.lua).
|
||||
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
|
||||
|
||||
-- How many times to run a scripted act+step loop per rendered frame. Only
|
||||
-- scripted runs use this; interactive play fast-forwards through
|
||||
-- Game.speedOverride / the GAME SPEED option instead.
|
||||
@@ -274,6 +279,9 @@ function love.mousepressed(x, y, button)
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchpressed("mouse", x, y)
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousereleased(x, y, button)
|
||||
@@ -281,6 +289,16 @@ function love.mousereleased(x, y, button)
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchreleased("mouse", x, y)
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y)
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
end
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
|
||||
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TouchInput = require("src.core.TouchInput")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local ModLoader = require("src.mods.Loader")
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -44,8 +44,8 @@ function Game:load()
|
||||
self.input = Input
|
||||
Input:init()
|
||||
|
||||
self.touchInput = TouchInput
|
||||
TouchInput:init()
|
||||
self.touchControls = TouchControls
|
||||
TouchControls:init()
|
||||
|
||||
self.renderer = Renderer
|
||||
Renderer:init()
|
||||
@@ -180,9 +180,6 @@ function Game:logicSpeed()
|
||||
end
|
||||
|
||||
function Game:update(dt)
|
||||
-- Touch timers / prior-frame auto-releases before the fixed step so
|
||||
-- deferred A and edge pulses land in Input's press queue for this step.
|
||||
TouchInput:update(dt)
|
||||
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||
-- Give the accumulator room for one full frame at the current speed,
|
||||
-- or the anti-spiral clamp quietly caps every level above ~15X.
|
||||
@@ -246,6 +243,8 @@ function Game:draw()
|
||||
worldZones = self.overworld:sgbWorldZones()
|
||||
end
|
||||
Renderer:endFrame(zones, worldZones)
|
||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
|
||||
@@ -375,6 +374,9 @@ function Game:keyreleased(key)
|
||||
end
|
||||
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
-- a controller is being used: the touch overlay steps aside until the
|
||||
-- next screen touch (mobile only; a no-op elsewhere)
|
||||
TouchControls:noteGamepad()
|
||||
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadPressed then
|
||||
@@ -389,6 +391,8 @@ function Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
function Game:gamepadaxis(joystick, axis, value)
|
||||
-- past-deadzone only, so resting-stick drift can't hide the overlay
|
||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -398,12 +402,12 @@ end
|
||||
-- state is worse than asking the player to re-press.
|
||||
function Game:focus(f)
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
TouchControls:reset()
|
||||
end
|
||||
|
||||
function Game:visible(v)
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
TouchControls:reset()
|
||||
end
|
||||
|
||||
-- A disconnected/dropped controller can't send the button-up for whatever
|
||||
@@ -411,19 +415,19 @@ end
|
||||
-- flags it owned.
|
||||
function Game:joystickremoved(joystick)
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
function Game:touchpressed(id, x, y)
|
||||
TouchInput:touchpressed(id, x, y)
|
||||
TouchControls:touchpressed(id, x, y)
|
||||
end
|
||||
|
||||
function Game:touchmoved(id, x, y)
|
||||
TouchInput:touchmoved(id, x, y)
|
||||
TouchControls:touchmoved(id, x, y)
|
||||
end
|
||||
|
||||
function Game:touchreleased(id, x, y)
|
||||
TouchInput:touchreleased(id, x, y)
|
||||
TouchControls:touchreleased(id, x, y)
|
||||
end
|
||||
|
||||
-- Point the loader's mod.save backing at this save's modData so per-mod
|
||||
|
||||
@@ -154,6 +154,17 @@ function Input:step()
|
||||
self.pressQueue = {}
|
||||
end
|
||||
|
||||
-- The on-screen touch overlay (src/core/TouchControls.lua) presses GB
|
||||
-- buttons directly by name -- not through a keyboard alias -- so a player
|
||||
-- rebind can never detach or shadow the overlay.
|
||||
function Input:overlayPressed(btn)
|
||||
press(self, btn, "touch:" .. btn)
|
||||
end
|
||||
|
||||
function Input:overlayReleased(btn)
|
||||
release(self, btn, "touch:" .. btn)
|
||||
end
|
||||
|
||||
function Input:gamepadpressed(joystick, button)
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
-- On-screen touch controls: a visible d-pad, A, B, START and SELECT drawn
|
||||
-- over the finished frame (art: Xelu's CC0 controller prompts, see
|
||||
-- assets/touch/README.md). Replaces the old touch gesture recognizer:
|
||||
-- every control is a real button under the thumb, so there is no
|
||||
-- tap-vs-swipe classification, no deferred-A double-tap window, and no
|
||||
-- added latency.
|
||||
--
|
||||
-- Mobile only, and only while no controller is being used: the overlay
|
||||
-- shows on Android/iOS, disappears the moment a gamepad button or stick
|
||||
-- is used, and comes back on the next screen touch (Game routes both
|
||||
-- events here). POKEPORT_TOUCH=1 forces it on for desktop testing
|
||||
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
||||
-- off everywhere.
|
||||
--
|
||||
-- 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,
|
||||
-- and a player rebind can never detach the overlay.
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local TouchControls = {}
|
||||
|
||||
-- idle vs pressed overlay opacity
|
||||
local ALPHA = 0.65
|
||||
local ALPHA_PRESSED = 0.95
|
||||
-- translucent backing disc behind each control: the prompt art is dark
|
||||
-- gray, so without it the controls melt into dark map areas
|
||||
local BACK = 0.24
|
||||
local BACK_PRESSED = 0.38
|
||||
|
||||
-- neutral zone at the d-pad center, as a fraction of the d-pad width;
|
||||
-- inside it no direction is held (keeps a resting thumb from jittering)
|
||||
local DPAD_DEAD = 0.16
|
||||
|
||||
-- hit slop: how far past the visible edge a press still counts, as a
|
||||
-- multiplier on the control's half-width. START/SELECT get more because
|
||||
-- the glyphs are small.
|
||||
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
||||
|
||||
local BUTTONS = { "a", "b", "start", "select" }
|
||||
|
||||
local IMAGES = {
|
||||
dpad = "assets/touch/dpad.png",
|
||||
dpad_up = "assets/touch/dpad_up.png",
|
||||
dpad_down = "assets/touch/dpad_down.png",
|
||||
dpad_left = "assets/touch/dpad_left.png",
|
||||
dpad_right = "assets/touch/dpad_right.png",
|
||||
a = "assets/touch/a.png",
|
||||
b = "assets/touch/b.png",
|
||||
start = "assets/touch/start.png",
|
||||
select = "assets/touch/select.png",
|
||||
}
|
||||
|
||||
local function wantsOverlay()
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
if env == "1" then return true end
|
||||
if env == "0" then return false end
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
function TouchControls:init()
|
||||
self.active = wantsOverlay()
|
||||
self.controllerHidden = false
|
||||
self.touches = {}
|
||||
-- per-GB-button owner count: two fingers on A must not double-press it,
|
||||
-- and lifting one of them must not release the other's hold
|
||||
self.held = {}
|
||||
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
|
||||
end
|
||||
self.img = img
|
||||
end
|
||||
|
||||
function TouchControls:visible()
|
||||
return self.active and self.img ~= nil and not self.controllerHidden
|
||||
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.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh 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 },
|
||||
}
|
||||
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
||||
if not self.labelFont or self.fontSize ~= fontSize then
|
||||
self.fontSize = fontSize
|
||||
self.labelFont = love.graphics.newFont(fontSize)
|
||||
end
|
||||
return self.L
|
||||
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
|
||||
|
||||
local function dpadDir(zone, x, y)
|
||||
local dx, dy = x - zone.cx, y - zone.cy
|
||||
local dead = zone.w * DPAD_DEAD
|
||||
if math.abs(dx) < dead and math.abs(dy) < dead then return nil end
|
||||
if math.abs(dx) >= math.abs(dy) then
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
local function pressBtn(self, btn)
|
||||
local n = (self.held[btn] or 0) + 1
|
||||
self.held[btn] = n
|
||||
if n == 1 then Input:overlayPressed(btn) end
|
||||
end
|
||||
|
||||
local function releaseBtn(self, btn)
|
||||
local n = self.held[btn]
|
||||
if not n then return end
|
||||
if n > 1 then
|
||||
self.held[btn] = n - 1
|
||||
else
|
||||
self.held[btn] = nil
|
||||
Input:overlayReleased(btn)
|
||||
end
|
||||
end
|
||||
|
||||
-- the d-pad touch's held direction changed (or ended): swap the GB hold
|
||||
local function setDpad(self, touch, dir)
|
||||
if touch.dir == dir then return end
|
||||
if touch.dir then releaseBtn(self, touch.dir) end
|
||||
touch.dir = dir
|
||||
if dir then pressBtn(self, dir) end
|
||||
end
|
||||
|
||||
function TouchControls:touchpressed(id, x, y)
|
||||
if not (self.active 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
|
||||
return
|
||||
end
|
||||
local L = self:layout()
|
||||
for _, btn in ipairs(BUTTONS) do
|
||||
if inCircle(L[btn], x, y, SLOP[btn]) then
|
||||
self.touches[id] = { control = btn }
|
||||
pressBtn(self, btn)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- square hit zone a bit past the cross art; one owning finger at a time
|
||||
local dz = L.dpad
|
||||
local half = dz.w * 0.65
|
||||
if not self.dpadTouch
|
||||
and math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
|
||||
self.dpadTouch = id
|
||||
local touch = { control = "dpad", dir = nil }
|
||||
self.touches[id] = touch
|
||||
setDpad(self, touch, dpadDir(dz, x, y))
|
||||
end
|
||||
end
|
||||
|
||||
function TouchControls:touchmoved(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
-- 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
|
||||
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
|
||||
end
|
||||
|
||||
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
|
||||
setDpad(self, touch, nil)
|
||||
self.dpadTouch = nil
|
||||
else
|
||||
releaseBtn(self, touch.control)
|
||||
end
|
||||
end
|
||||
|
||||
-- LÖVE has no touchcancelled: a touch interrupted by the OS (app
|
||||
-- backgrounded, a system gesture stealing the finger) never fires
|
||||
-- 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
|
||||
Input:overlayReleased(btn)
|
||||
end
|
||||
self.held = {}
|
||||
self.touches = {}
|
||||
self.dpadTouch = nil
|
||||
end
|
||||
|
||||
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
||||
-- until the next screen touch asks for it back
|
||||
function TouchControls:noteGamepad()
|
||||
if not self.active or self.controllerHidden then return end
|
||||
self.controllerHidden = true
|
||||
self:reset()
|
||||
end
|
||||
|
||||
-- last controller unplugged: show the overlay again immediately instead
|
||||
-- of requiring a blind first tap
|
||||
function TouchControls:joystickremoved()
|
||||
self:reset()
|
||||
if love.joystick and love.joystick.getJoystickCount
|
||||
and love.joystick.getJoystickCount() == 0 then
|
||||
self.controllerHidden = false
|
||||
end
|
||||
end
|
||||
|
||||
local function drawIcon(img, zone, pressed)
|
||||
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
|
||||
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.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).
|
||||
function TouchControls:draw()
|
||||
if not self:visible() then return end
|
||||
local L = self:layout()
|
||||
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)
|
||||
for _, btn in ipairs(BUTTONS) do
|
||||
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
|
||||
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
|
||||
love.graphics.setFont(self.labelFont)
|
||||
local ly = L.start.cy + L.start.w * 0.66
|
||||
local function label(text, zone)
|
||||
local w = self.labelFont:getWidth(text)
|
||||
love.graphics.setColor(0, 0, 0, 0.6)
|
||||
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
||||
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
|
||||
love.graphics.print(text, zone.cx - w / 2, ly)
|
||||
end
|
||||
label("START", L.start)
|
||||
label("SELECT", L.select)
|
||||
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
return TouchControls
|
||||
@@ -1,297 +0,0 @@
|
||||
-- Touch gesture recognizer → virtual keyboard keys for Input.lua.
|
||||
--
|
||||
-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second
|
||||
-- tap. That adds ~280ms latency to every A press so a double-tap can be
|
||||
-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect
|
||||
-- input needs, so the latency is acceptable.
|
||||
--
|
||||
-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct
|
||||
-- touch ID lands while another short, low-movement touch is active, fire
|
||||
-- SELECT (press + one-frame auto-release).
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local TouchInput = {}
|
||||
|
||||
local function dpiScale()
|
||||
if love and love.window then
|
||||
if love.window.getDPIScale then
|
||||
return love.window.getDPIScale()
|
||||
end
|
||||
if love.window.toPixels then
|
||||
return love.window.toPixels(1)
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing.
|
||||
local SWIPE_THRESHOLD_PX = 14
|
||||
local EDGE_PX = 24
|
||||
local EDGE_SWIPE_PX = 24
|
||||
local DOUBLE_TAP_MS = 280
|
||||
local TAP_MAX_MS = 320
|
||||
local TAP_MAX_MOVE_PX = 12
|
||||
|
||||
local function scaled(px)
|
||||
return px * dpiScale()
|
||||
end
|
||||
|
||||
local DIRS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names).
|
||||
local KEY = {
|
||||
up = "up",
|
||||
down = "down",
|
||||
left = "left",
|
||||
right = "right",
|
||||
a = "z",
|
||||
b = "x",
|
||||
start = "escape",
|
||||
select = "tab",
|
||||
}
|
||||
|
||||
local function nowMs()
|
||||
return love.timer.getTime() * 1000
|
||||
end
|
||||
|
||||
local function dominantDir(dx, dy)
|
||||
if math.abs(dx) >= math.abs(dy) then
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
function TouchInput:init()
|
||||
self.touches = {}
|
||||
self.pendingA = nil -- { deadlineMs = number }
|
||||
-- Edge pulses (B / START / SELECT / deferred-A): press now, release on a
|
||||
-- later update so FixedStep can consume wasPressed first.
|
||||
-- `armed` = pressed during events since last update; promoted to
|
||||
-- `autoRelease` at the start of update (released on the *following* update).
|
||||
-- Deferred-A fired inside update goes straight into `autoRelease`.
|
||||
self.armed = {}
|
||||
self.autoRelease = {}
|
||||
self.selectFired = false -- one SELECT per two-finger gesture cluster
|
||||
end
|
||||
|
||||
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
|
||||
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
|
||||
-- touchreleased and would otherwise strand its direction held forever.
|
||||
-- Called from Game alongside Input:reset() on focus/visibility loss.
|
||||
function TouchInput:reset()
|
||||
for _, touch in pairs(self.touches) do
|
||||
releaseDir(self, touch)
|
||||
end
|
||||
self.touches = {}
|
||||
self.pendingA = nil
|
||||
self.armed = {}
|
||||
self.autoRelease = {}
|
||||
self.selectFired = false
|
||||
end
|
||||
|
||||
local function pulse(self, key)
|
||||
Input:keypressed(key)
|
||||
self.armed[#self.armed + 1] = key
|
||||
end
|
||||
|
||||
local function pulseInUpdate(self, key)
|
||||
Input:keypressed(key)
|
||||
self.autoRelease[#self.autoRelease + 1] = key
|
||||
end
|
||||
|
||||
local function releaseDir(self, touch)
|
||||
if touch.dir and DIRS[touch.dir] then
|
||||
Input:keyreleased(KEY[touch.dir])
|
||||
touch.dir = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function pressDir(self, touch, dir)
|
||||
if touch.dir == dir then return end
|
||||
releaseDir(self, touch)
|
||||
touch.dir = dir
|
||||
Input:keypressed(KEY[dir])
|
||||
end
|
||||
|
||||
local function totalMove(touch, x, y)
|
||||
local dx = x - touch.x0
|
||||
local dy = y - touch.y0
|
||||
return math.abs(dx), math.abs(dy), dx, dy
|
||||
end
|
||||
|
||||
local function isTapLike(touch, x, y, tMs)
|
||||
local ax, ay = totalMove(touch, x, y)
|
||||
local elapsed = tMs - touch.t0
|
||||
return elapsed <= TAP_MAX_MS
|
||||
and ax <= scaled(TAP_MAX_MOVE_PX)
|
||||
and ay <= scaled(TAP_MAX_MOVE_PX)
|
||||
and not touch.classified
|
||||
end
|
||||
|
||||
local function countActive(self)
|
||||
local n = 0
|
||||
for _ in pairs(self.touches) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
local function tryTwoFingerSelect(self, tMs)
|
||||
if self.selectFired then return false end
|
||||
local ids = {}
|
||||
for id, touch in pairs(self.touches) do
|
||||
if isTapLike(touch, touch.x, touch.y, tMs) then
|
||||
ids[#ids + 1] = id
|
||||
end
|
||||
end
|
||||
if #ids < 2 then return false end
|
||||
|
||||
self.selectFired = true
|
||||
self.pendingA = nil
|
||||
for _, id in ipairs(ids) do
|
||||
local touch = self.touches[id]
|
||||
touch.classified = true
|
||||
touch.consumed = true
|
||||
releaseDir(self, touch)
|
||||
end
|
||||
pulse(self, KEY.select)
|
||||
return true
|
||||
end
|
||||
|
||||
function TouchInput:update(dt)
|
||||
-- Releases armed on a prior update (FixedStep already saw wasPressed).
|
||||
for i = 1, #self.autoRelease do
|
||||
Input:keyreleased(self.autoRelease[i])
|
||||
end
|
||||
-- Promote event-phase pulses from since the last update; they release next time.
|
||||
self.autoRelease = self.armed
|
||||
self.armed = {}
|
||||
|
||||
local tMs = nowMs()
|
||||
|
||||
-- Deferred A: fire once the double-tap window closes with no second tap.
|
||||
-- Queued into autoRelease so the next update clears hold after this FixedStep.
|
||||
if self.pendingA and tMs >= self.pendingA.deadlineMs then
|
||||
self.pendingA = nil
|
||||
pulseInUpdate(self, KEY.a)
|
||||
end
|
||||
|
||||
-- Keep two-finger SELECT detection live while both fingers stay down.
|
||||
if countActive(self) >= 2 then
|
||||
tryTwoFingerSelect(self, tMs)
|
||||
elseif countActive(self) == 0 then
|
||||
self.selectFired = false
|
||||
end
|
||||
end
|
||||
|
||||
function TouchInput:touchpressed(id, x, y)
|
||||
local tMs = nowMs()
|
||||
|
||||
-- Second tap inside the deferred-A window → START instead of A.
|
||||
if self.pendingA and tMs < self.pendingA.deadlineMs then
|
||||
self.pendingA = nil
|
||||
pulse(self, KEY.start)
|
||||
-- Still record this touch so a lingering finger doesn't become a stray swipe.
|
||||
self.touches[id] = {
|
||||
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
|
||||
edge = x < scaled(EDGE_PX),
|
||||
classified = true,
|
||||
consumed = true,
|
||||
dir = nil,
|
||||
}
|
||||
return
|
||||
end
|
||||
|
||||
self.touches[id] = {
|
||||
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
|
||||
edge = x < scaled(EDGE_PX),
|
||||
classified = false,
|
||||
consumed = false,
|
||||
dir = nil,
|
||||
}
|
||||
|
||||
if countActive(self) >= 2 then
|
||||
tryTwoFingerSelect(self, tMs)
|
||||
end
|
||||
end
|
||||
|
||||
function TouchInput:touchmoved(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
if not touch or touch.consumed then return end
|
||||
|
||||
touch.x, touch.y = x, y
|
||||
local ax, ay, dx, dy = totalMove(touch, x, y)
|
||||
local swipeTh = scaled(SWIPE_THRESHOLD_PX)
|
||||
|
||||
-- Edge-origin swipes become B on release; never promote to d-pad.
|
||||
if touch.edge then
|
||||
if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then
|
||||
touch.classified = true
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not touch.classified then
|
||||
if ax < swipeTh and ay < swipeTh then return end
|
||||
touch.classified = true
|
||||
pressDir(self, touch, dominantDir(dx, dy))
|
||||
return
|
||||
end
|
||||
|
||||
-- Mid-hold direction change: release old, press new (dominant axis).
|
||||
if touch.dir then
|
||||
local fromLastX = x - touch.x0
|
||||
local fromLastY = y - touch.y0
|
||||
-- Re-evaluate from origin so small jitter doesn't flip; require threshold
|
||||
-- distance from origin along the new dominant axis.
|
||||
if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then
|
||||
local newDir = dominantDir(fromLastX, fromLastY)
|
||||
if newDir ~= touch.dir then
|
||||
pressDir(self, touch, newDir)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function TouchInput:touchreleased(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
if not touch then return end
|
||||
|
||||
local tMs = nowMs()
|
||||
touch.x, touch.y = x, y
|
||||
local ax, ay = totalMove(touch, x, y)
|
||||
|
||||
if touch.dir then
|
||||
releaseDir(self, touch)
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
return
|
||||
end
|
||||
|
||||
if touch.consumed then
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
return
|
||||
end
|
||||
|
||||
-- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX
|
||||
-- (or already marked classified while moving). Prefer over d-pad / tap.
|
||||
if touch.edge then
|
||||
local edgeTh = scaled(EDGE_SWIPE_PX)
|
||||
if touch.classified or ax >= edgeTh or ay >= edgeTh then
|
||||
pulse(self, KEY.b)
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Plain tap → defer A (or it was already classified as swipe without dir, ignore).
|
||||
if isTapLike(touch, x, y, tMs) then
|
||||
self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS }
|
||||
end
|
||||
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
end
|
||||
|
||||
return TouchInput
|
||||