mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
CLOSES #455, CLOSES #487, CLOSES #501, CLOSES #540, CLOSES #585, CLOSES #591, CLOSES #593, CLOSES #595, CLOSES #597, CLOSES #599, CLOSES #600, CLOSES #606, CLOSES #607, CLOSES #610, CLOSES #613, CLOSES #616, CLOSES #620, CLOSES #626, CLOSES #632, CLOSES #633, CLOSES #647
This commit is contained in:
@@ -418,16 +418,22 @@ function ChipAudio.newCry(data, species, resolved)
|
||||
})
|
||||
end
|
||||
|
||||
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
|
||||
-- a mono Source is spatialized by OpenAL at the listener position and spreads
|
||||
-- over every output an interface has (#626). The siren itself is unchanged,
|
||||
-- both channels carry the same sample.
|
||||
function ChipAudio.newLowHealthAlarm()
|
||||
local samples = math.floor(SAMPLE_RATE * 62 / 60)
|
||||
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
|
||||
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
|
||||
local phase = 0
|
||||
for index = 0, samples - 1 do
|
||||
local frame = math.floor(index * 60 / SAMPLE_RATE) % 31
|
||||
local register = frame < 11 and 0x750 or 0x6EE
|
||||
local frequency = 131072 / (2048 - register)
|
||||
phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25)
|
||||
local value = (phase < 0.5 and 1 or -1) * 0.25
|
||||
data:setSample(index, 1, value)
|
||||
data:setSample(index, 2, value)
|
||||
end
|
||||
return love.audio.newSource(data, "static")
|
||||
end
|
||||
|
||||
+22
-5
@@ -808,9 +808,22 @@ local function soundData(engine, samples, channels)
|
||||
return result
|
||||
end
|
||||
|
||||
-- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is
|
||||
-- too short to be audible. The caller wraps it in a static love.audio.Source
|
||||
-- (a playback concern, hence not done here).
|
||||
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
|
||||
-- it is too short to be audible. The caller wraps it in a static
|
||||
-- love.audio.Source (a playback concern, hence not done here).
|
||||
--
|
||||
-- The synthesis is mono (one summed value per frame, unlike the music path's
|
||||
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
|
||||
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
|
||||
-- position, exactly where the listener sits, is rendered as an ambient sound
|
||||
-- spread over EVERY output channel the device exposes at gains that differ
|
||||
-- from the front pair. On an interface with more than two outputs that put
|
||||
-- the SFX on outputs 5+6 as well, while the 2-channel music source
|
||||
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
|
||||
-- spatialization entirely and map onto the front pair, so duplicating the
|
||||
-- sample costs one buffer's memory and makes effects route exactly like
|
||||
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
|
||||
-- and would newly hard-pan any effect whose header issues command 0xEE.
|
||||
local function renderEffectData(data, header, options)
|
||||
if not header then return nil end
|
||||
options = options or {}
|
||||
@@ -825,8 +838,12 @@ local function renderEffectData(data, header, options)
|
||||
values[count] = engine:sample()
|
||||
end
|
||||
if count < math.floor(SAMPLE_RATE / 100) then return nil end
|
||||
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1)
|
||||
for index = 1, count do result:setSample(index - 1, values[index]) end
|
||||
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2)
|
||||
for index = 1, count do
|
||||
local value = values[index]
|
||||
result:setSample(index - 1, 1, value)
|
||||
result:setSample(index - 1, 2, value)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
|
||||
+13
-1
@@ -7,6 +7,18 @@ local FixedStep = {}
|
||||
FixedStep.STEP = 1 / 60
|
||||
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
|
||||
|
||||
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
|
||||
-- been paid for. Half a step is the balanced point: a frame has to come in
|
||||
-- ~8ms short before it drops a step and ~8ms long before it doubles one up.
|
||||
-- Zero -- what this used to leave behind -- has no margin at all on the long
|
||||
-- side, so the accumulator settles a hair BELOW one step and parks there, and
|
||||
-- ordinary sub-millisecond vsync wobble then flips it between 0 and 2 steps
|
||||
-- every few frames for the rest of the session. That is why pacing stayed
|
||||
-- visibly broken after a route/city seam and nowhere else: crossConnection in
|
||||
-- src/world/OverworldController.lua is the only caller of discardCatchup, and
|
||||
-- warps go through Transition instead (issue #487).
|
||||
local RESEED_PHASE = 0.5
|
||||
|
||||
function FixedStep:init(callback)
|
||||
self.accum = 0
|
||||
self.callback = callback
|
||||
@@ -27,7 +39,7 @@ function FixedStep:update(dt)
|
||||
-- the burst it would otherwise release doesn't play out as a slide.
|
||||
if self.suppressCatchup then
|
||||
self.suppressCatchup = false
|
||||
self.accum = 0
|
||||
self.accum = self.STEP * RESEED_PHASE
|
||||
self.callback(self.STEP)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -643,15 +643,38 @@ local function isAccelerometer(joystick)
|
||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
||||
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
||||
-- as a gamepad reaches the capture: a recognized pad raises BOTH
|
||||
-- joystickpressed and gamepadpressed for one press, and the joystick half
|
||||
-- would otherwise beat its own gamepadpressed to the armed row and record
|
||||
-- "JOY1" for a button the player can plainly see is A. Same predicate as
|
||||
-- Input's, kept local here so Game never reaches into Input's internals.
|
||||
local function isRawStick(joystick)
|
||||
return not (joystick and joystick.isGamepad and joystick:isGamepad())
|
||||
end
|
||||
|
||||
function Game:joystickpressed(joystick, button)
|
||||
if isAccelerometer(joystick) then return end
|
||||
TouchControls:noteGamepad()
|
||||
local top = self.stack and self.stack:top()
|
||||
if isRawStick(joystick) and top and top.onJoystickPressed then
|
||||
top:onJoystickPressed(button)
|
||||
return
|
||||
end
|
||||
Input:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game:joystickreleased(joystick, button)
|
||||
if isAccelerometer(joystick) then return end
|
||||
-- same observe-after-Input contract as Game:keyreleased (#589): the
|
||||
-- capture watches the release, it never owns it, so a held-state flag
|
||||
-- Input saw go down before the capture armed cannot be stranded
|
||||
Input:joystickreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if isRawStick(joystick) and top and top.onJoystickReleased then
|
||||
top:onJoystickReleased(button)
|
||||
end
|
||||
end
|
||||
|
||||
function Game:joystickaxis(joystick, axis, value)
|
||||
|
||||
@@ -13,6 +13,55 @@ function HostShell.envPrefix()
|
||||
return ""
|
||||
end
|
||||
|
||||
-- Windows: every host tool we shell out to (curl for the update and mod-index
|
||||
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
|
||||
-- spawned through io.popen / os.execute, which run it under cmd.exe. A
|
||||
-- GUI-subsystem process owns no console, so each of those children allocates
|
||||
-- its own -- one console window flashing per call, several stacking up during
|
||||
-- a mod install or an update (#606). #74 fixed the same storm for the
|
||||
-- per-file cache mkdir by dropping the shell entirely (src/import/CacheFs.lua);
|
||||
-- the callers above genuinely need one, so we do the other half: allocate a
|
||||
-- single console for ourselves, once, and hide it. A child inherits the
|
||||
-- parent's console when the parent has one, so every later spawn attaches to
|
||||
-- that invisible console and pops up nothing. GUI dialogs the children raise
|
||||
-- (the PowerShell OpenFileDialog) are desktop windows and still appear.
|
||||
--
|
||||
-- Skipped when a console already exists, which is the developer case
|
||||
-- (lovec.exe, what scripts/run.ps1 prefers, or t.console), so printed output
|
||||
-- keeps landing in the terminal the game was launched from. POKEPORT_CONSOLE=1
|
||||
-- opts out entirely and restores the old behaviour. Memoized; non-Windows and
|
||||
-- FFI-less builds no-op. Called once from love.load before anything shells
|
||||
-- out (main.lua).
|
||||
local consoleHidden = nil
|
||||
|
||||
function HostShell.hideHostConsole()
|
||||
if consoleHidden ~= nil then return consoleHidden end
|
||||
consoleHidden = false
|
||||
if os.getenv("POKEPORT_CONSOLE") == "1" then return consoleHidden end
|
||||
|
||||
local okFfi, ffi = pcall(require, "ffi")
|
||||
if not okFfi or ffi.os ~= "Windows" then return consoleHidden end
|
||||
|
||||
-- kernel32 (AllocConsole/GetConsoleWindow) and user32 (ShowWindow) are
|
||||
-- already loaded in any LOVE process, so ffi.C resolves both -- the same
|
||||
-- assumption CacheFs makes for CreateDirectoryA.
|
||||
pcall(ffi.cdef, [[
|
||||
void *GetConsoleWindow(void);
|
||||
int AllocConsole(void);
|
||||
int ShowWindow(void *hWnd, int nCmdShow);
|
||||
]])
|
||||
local ok, hidden = pcall(function()
|
||||
if ffi.C.GetConsoleWindow() ~= nil then return false end
|
||||
if ffi.C.AllocConsole() == 0 then return false end
|
||||
local hwnd = ffi.C.GetConsoleWindow()
|
||||
if hwnd == nil then return false end
|
||||
ffi.C.ShowWindow(hwnd, 0) -- SW_HIDE
|
||||
return true
|
||||
end)
|
||||
consoleHidden = (ok and hidden) or false
|
||||
return consoleHidden
|
||||
end
|
||||
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||
function HostShell.popen(command, mode)
|
||||
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||
@@ -69,4 +118,121 @@ function HostShell.restart()
|
||||
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
|
||||
end
|
||||
|
||||
-- ------- HTTP transport ----------------------------------------------------
|
||||
--
|
||||
-- Every remote fetch (mod index, mod releases, thumbnails) used to shell out
|
||||
-- to curl, which macOS / Windows 10+ / desktop Linux all ship and Android does
|
||||
-- not: adding a mod index on Android died with "curl is not available on this
|
||||
-- platform" (#597). Android goes through the GameActivity.httpDownload JNI
|
||||
-- bridge instead (HttpsURLConnection, using the INTERNET permission link play
|
||||
-- already needs), surfaced by our vendored liblove as
|
||||
-- love.system.httpDownload(url, absPath, userAgent, accept). Both transports
|
||||
-- block the calling thread and deal in whole files, so callers keep exactly
|
||||
-- the contract they had with curl.
|
||||
|
||||
-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
|
||||
function HostShell.quote(s)
|
||||
s = tostring(s)
|
||||
if love and love.system and love.system.getOS
|
||||
and love.system.getOS() == "Windows" then
|
||||
return '"' .. s:gsub('"', '') .. '"'
|
||||
end
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
function HostShell.haveCurl()
|
||||
local pipe = HostShell.popen("curl --version")
|
||||
if not pipe then return false end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- The bridge only exists in our Android liblove. An older APK reports nil
|
||||
-- here and falls back to the "no transport" error the callers already show;
|
||||
-- the iOS build compiles the same wrapper but always returns false, so gate
|
||||
-- on the OS as well and keep its error message honest.
|
||||
local function haveBridge()
|
||||
if not (love and love.system and type(love.system.httpDownload) == "function") then
|
||||
return false
|
||||
end
|
||||
return love.system.getOS and love.system.getOS() == "Android"
|
||||
end
|
||||
|
||||
-- Is any transport available at all? Callers gate on this, never on curl.
|
||||
function HostShell.canFetch()
|
||||
return HostShell.haveCurl() or haveBridge()
|
||||
end
|
||||
|
||||
-- Download url to an absolute host path. Returns true, or nil plus an error.
|
||||
-- The curl branch deliberately ignores curl's exit code, as the download paths
|
||||
-- always did: callers judge the result by the file they got.
|
||||
function HostShell.httpDownload(url, absPath, userAgent, accept)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 "
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
end
|
||||
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url)
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then return nil, "could not start download" end
|
||||
pcall(function() pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
return true
|
||||
end
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
|
||||
if ok and done then return true end
|
||||
return nil, "download failed"
|
||||
end
|
||||
|
||||
-- GET returning the body. curl streams it through a pipe; the Android bridge
|
||||
-- can only write a file, so there we fetch into the save directory (the only
|
||||
-- writable root on Android) and read it back.
|
||||
function HostShell.httpGet(url, userAgent, accept)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
end
|
||||
cmd = cmd .. HostShell.quote(url)
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then return nil, "could not run curl" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
if not readOk then return nil, "fetch failed: " .. tostring(out) end
|
||||
if not out or out == "" then return nil, "empty response from " .. url end
|
||||
return out
|
||||
end
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
if not (love.filesystem and love.filesystem.getSaveDirectory) then
|
||||
return nil, "fetch needs LOVE"
|
||||
end
|
||||
local dirOk, saveDir = pcall(love.filesystem.getSaveDirectory)
|
||||
if not dirOk or not saveDir or saveDir == "" then
|
||||
return nil, "no save directory"
|
||||
end
|
||||
local name = "http_fetch.tmp"
|
||||
pcall(love.filesystem.remove, name)
|
||||
local ok, err = HostShell.httpDownload(url, saveDir .. "/" .. name, userAgent, accept)
|
||||
if not ok then return nil, err end
|
||||
local readOk, body = pcall(love.filesystem.read, name)
|
||||
pcall(love.filesystem.remove, name)
|
||||
if not readOk or type(body) ~= "string" or body == "" then
|
||||
return nil, "empty response from " .. url
|
||||
end
|
||||
return body
|
||||
end
|
||||
|
||||
return HostShell
|
||||
|
||||
+47
-4
@@ -41,7 +41,14 @@ local STICK_OFF = 0.3
|
||||
|
||||
-- Generic SDL joysticks expose the left stick as the first two numbered
|
||||
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
|
||||
-- controller has no game-controller database entry.
|
||||
-- controller has no game-controller database entry. The indices below are
|
||||
-- the desktop XInput order and are only meaningful for such pads: raw
|
||||
-- numbering is per-driver, and SDL's iOS/MFi driver packs only the buttons
|
||||
-- a pad actually reports, which slides the D-pad down onto 7..10 (#620).
|
||||
-- These are the raw DEFAULTS only: applyBindings layers the player's
|
||||
-- "joyN" pad bindings over them (#632), and only a stick SDL does NOT
|
||||
-- recognize as a gamepad is ever served out of this table -- see
|
||||
-- joystickpressed below.
|
||||
local RAW_BUTTON_BINDINGS = {
|
||||
[1] = "a", [2] = "b",
|
||||
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
|
||||
@@ -66,9 +73,10 @@ end
|
||||
-- BindingsMenu:storeBinding) -- without this the menu records a choice
|
||||
-- that never actually reaches gameplay.
|
||||
function Input:applyBindings(overlay)
|
||||
local keys, pads = {}, {}
|
||||
local keys, pads, joys = {}, {}, {}
|
||||
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
|
||||
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
|
||||
for index, action in pairs(RAW_BUTTON_BINDINGS) do joys[index] = action end
|
||||
for actionId, binding in pairs(overlay or {}) do
|
||||
if type(binding) == "table" then
|
||||
if binding.key then keys[binding.key] = actionId end
|
||||
@@ -77,8 +85,20 @@ function Input:applyBindings(overlay)
|
||||
keys[binding] = actionId
|
||||
end
|
||||
end
|
||||
-- A pad binding named "joyN" is the Nth button of a stick SDL has no
|
||||
-- game-controller-database entry for, captured on the joystick path by
|
||||
-- src/ui/BindingsMenu.lua (#632). It deliberately rides the existing
|
||||
-- pad slot: the CONTROLS row, the swap in BindingsMenu:storeBinding and
|
||||
-- START's reset-all then all stay one code path, and this loop is the
|
||||
-- only place that has to know what the name means. Laid over the raw
|
||||
-- defaults AFTER them, so a rebind wins the button it claims.
|
||||
for padName, action in pairs(pads) do
|
||||
local n = tonumber(padName:match("^joy(%d+)$"))
|
||||
if n then joys[n] = action end
|
||||
end
|
||||
self.keyBindings = keys
|
||||
self.padBindings = pads
|
||||
self.joyBindings = joys
|
||||
end
|
||||
|
||||
-- Purely event-driven state (press sets true, release sets false) has no
|
||||
@@ -194,13 +214,30 @@ function Input:gamepadreleased(joystick, button)
|
||||
end
|
||||
end
|
||||
|
||||
-- LOVE raises love.joystickpressed for EVERY stick, including ones SDL
|
||||
-- recognizes as gamepads, which raise love.gamepadpressed for the same
|
||||
-- physical press as well. Answering both meant the fixed raw table
|
||||
-- re-asserted the factory A/B/START/SELECT map underneath the player's
|
||||
-- rebinds, so swapping A and B in CONTROLS pressed both at once and any
|
||||
-- controller rebind of those four looked ignored; on iOS the MFi driver's
|
||||
-- packing put the D-pad on 7..10, so a D-pad press also fired SELECT or
|
||||
-- START (#620, #632). A recognized pad is served by the gamepad path
|
||||
-- alone; the raw path exists for sticks with no game-controller-database
|
||||
-- entry. A nil joystick is a raw stick: that is how
|
||||
-- tests/input_hold_test.lua and the drivers drive this path.
|
||||
local function isRawStick(joystick)
|
||||
return not (joystick and joystick.isGamepad and joystick:isGamepad())
|
||||
end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if not isRawStick(joystick) then return end
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if not isRawStick(joystick) then return end
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
@@ -240,6 +277,7 @@ function Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Input:joystickaxis(joystick, axis, value)
|
||||
if not isRawStick(joystick) then return end
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
@@ -247,7 +285,12 @@ function Input:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
-- Same duplicate-event rule as joystickpressed (#620, #632): a recognized
|
||||
-- pad's D-pad already arrived as dpup/dpdown/dpleft/dpright through the
|
||||
-- gamepad map, so letting the hat answer too would re-assert the factory
|
||||
-- directions on top of a direction rebind.
|
||||
function Input:joystickhat(joystick, hat, direction)
|
||||
if not isRawStick(joystick) then return end
|
||||
local source = "hat:" .. hat
|
||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||
release(self, btn, source)
|
||||
|
||||
+14
-2
@@ -266,6 +266,15 @@ 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 = {},
|
||||
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
|
||||
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
||||
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
|
||||
-- activeProfile names the row the live set currently matches (nil for
|
||||
-- ad-hoc, so it has no default entry here; mergeOptions preserves it).
|
||||
-- modProfilesSeeded records that the pre-profiles setup was already
|
||||
-- migrated into PROFILE 1, so deleting every profile does not re-seed one.
|
||||
modProfiles = {},
|
||||
modProfilesSeeded = false,
|
||||
-- GitHub release checks for mods with a manifest "github" field
|
||||
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
|
||||
modUpdateCache = {},
|
||||
@@ -281,8 +290,11 @@ function SaveData.defaultOptions()
|
||||
modIndexCache = {},
|
||||
-- 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.
|
||||
-- layouts.portrait / layouts.landscape each hold optional normalized
|
||||
-- centers {x=0..1, y=0..1} per control (dpad/a/b/start/select) plus a
|
||||
-- size scale; nil positions mean that orientation draws the default
|
||||
-- layout (#633). Pre-#633 files stored one top-level positions table;
|
||||
-- TouchControls.normalizeConfig folds it into both orientations on load.
|
||||
touchControls = { enabled = true },
|
||||
}
|
||||
end
|
||||
|
||||
+42
-3
@@ -82,6 +82,40 @@ local function isChipDef(def)
|
||||
return type(def) == "table" and (def.chip ~= nil or def.address ~= nil)
|
||||
end
|
||||
|
||||
-- OpenAL only spatializes 1-channel Sources, and one left at the default
|
||||
-- (0,0,0) position sits on top of the listener, which OpenAL renders as an
|
||||
-- ambient sound spread over every output channel the device has: on an
|
||||
-- interface with more than two outputs the SFX also came out of outputs 5+6
|
||||
-- while the 2-channel music stayed on 1+2 (#626). A Source cannot change its
|
||||
-- channel count after the fact, so a mono file def is re-decoded and its
|
||||
-- sample duplicated into a stereo buffer, which OpenAL never spatializes.
|
||||
-- Chip SFX and cries are already stereo at the source (ChipSynth
|
||||
-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM
|
||||
-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg
|
||||
-- SFX. Every step is guarded: a headless love stub without love.sound, or a
|
||||
-- decoder that will not hand back SoundData, keeps the original Source.
|
||||
local function widenMono(source, file)
|
||||
if not (source and love.sound and love.sound.newSoundData) then
|
||||
return source
|
||||
end
|
||||
local ok, channels = pcall(function() return source:getChannelCount() end)
|
||||
if not ok or channels ~= 1 then return source end
|
||||
local built, widened = pcall(function()
|
||||
local mono = love.sound.newSoundData(file)
|
||||
local frames = mono:getSampleCount()
|
||||
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(),
|
||||
mono:getBitDepth(), 2)
|
||||
for index = 0, frames - 1 do
|
||||
local value = mono:getSample(index)
|
||||
stereo:setSample(index, 1, value)
|
||||
stereo:setSample(index, 2, value)
|
||||
end
|
||||
return love.audio.newSource(stereo, "static")
|
||||
end)
|
||||
if built and widened then return widened end
|
||||
return source
|
||||
end
|
||||
|
||||
-- a file def carries an optional playback rate; a bare string is shorthand
|
||||
-- for { file = <string> }
|
||||
local function newFileSource(def)
|
||||
@@ -89,6 +123,7 @@ local function newFileSource(def)
|
||||
if type(file) ~= "string" then return nil, "no chip program and no file" end
|
||||
local ok, s = pcall(love.audio.newSource, file, "static")
|
||||
if not ok or not s then return nil, ok and "no source" or tostring(s) end
|
||||
s = widenMono(s, file) -- keep mono defs off the surround channels (#626)
|
||||
if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end
|
||||
return s
|
||||
end
|
||||
@@ -233,12 +268,16 @@ function Sound.playPikaCry(data, n)
|
||||
local src = cache[key]
|
||||
if src == false then return nil end
|
||||
if not src then
|
||||
local ok, s = pcall(love.audio.newSource,
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
|
||||
if not ok then
|
||||
local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n)
|
||||
local ok, s = pcall(love.audio.newSource, path, "static")
|
||||
if not ok or not s then
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
-- the importer writes these clips as 8-bit mono (RomExtractor
|
||||
-- extractPikachuCries), so they need the same widening as the chip
|
||||
-- effects to stay off a multi-output device's surround channels (#626)
|
||||
s = widenMono(s, path)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
|
||||
+144
-29
@@ -14,8 +14,12 @@
|
||||
--
|
||||
-- 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.
|
||||
-- fractions. Positions and a size multiplier are stored per orientation
|
||||
-- (#633): options.touchControls.layouts.portrait / .landscape, picked from
|
||||
-- the safe rect's aspect, so laying the pad out in landscape never moves
|
||||
-- the portrait one. 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
|
||||
@@ -47,6 +51,15 @@ 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" }
|
||||
|
||||
-- Per-orientation layout buckets (#633). Orientation comes from the safe
|
||||
-- rect, not the device: sw > sh is landscape, so a resized desktop window
|
||||
-- under POKEPORT_TOUCH exercises the same path a phone rotation does.
|
||||
local ORIENTATIONS = { "portrait", "landscape" }
|
||||
|
||||
-- Control size multiplier bounds for the editor's -/+ (#633). 1.0 is the
|
||||
-- historical size, so an install that never touches it draws as before.
|
||||
local SCALE_MIN, SCALE_MAX, SCALE_STEP = 0.6, 1.6, 0.1
|
||||
|
||||
local IMAGES = {
|
||||
dpad = "assets/touch/dpad.png",
|
||||
dpad_up = "assets/touch/dpad_up.png",
|
||||
@@ -65,6 +78,33 @@ local function clamp01(v)
|
||||
return v
|
||||
end
|
||||
|
||||
local function clampScale(v)
|
||||
if type(v) ~= "number" or v ~= v then return 1 end
|
||||
if v < SCALE_MIN then return SCALE_MIN end
|
||||
if v > SCALE_MAX then return SCALE_MAX end
|
||||
return v
|
||||
end
|
||||
|
||||
-- Copy a persisted positions table, dropping unknown / non-numeric entries.
|
||||
-- Always a fresh table: two orientations seeded from the same pre-#633
|
||||
-- layout must not alias, or dragging one would still move the other.
|
||||
local function normalizePositions(src)
|
||||
if type(src) ~= "table" then return nil end
|
||||
local pos = {}
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = src[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 not next(pos) then return nil end
|
||||
return pos
|
||||
end
|
||||
|
||||
local function orientationFor(sw, sh)
|
||||
return (sw or 0) > (sh or 0) and "landscape" or "portrait"
|
||||
end
|
||||
|
||||
local function wantsOverlay()
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
if env == "1" then return true end
|
||||
@@ -73,22 +113,26 @@ local function wantsOverlay()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
-- Normalize a persisted touchControls table into {enabled, positions}.
|
||||
-- Normalize a persisted touchControls table into
|
||||
-- {enabled, layouts = {portrait = {positions, scale}, landscape = {...}}}.
|
||||
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
|
||||
-- the overlay.
|
||||
-- the overlay. Pre-#633 files stored one top-level positions table and no
|
||||
-- scale; that layout seeds both orientations, so an upgrading player keeps
|
||||
-- what they had until they edit one of them.
|
||||
function TouchControls.normalizeConfig(tc)
|
||||
local out = { enabled = true, positions = nil }
|
||||
if type(tc) ~= "table" then return out end
|
||||
local out = { enabled = true, layouts = { portrait = {}, landscape = {} } }
|
||||
-- a nil / garbage table still yields full buckets (scale defaulted), so
|
||||
-- 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.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
|
||||
local saved = type(tc.layouts) == "table" and tc.layouts or nil
|
||||
for _, o in ipairs(ORIENTATIONS) do
|
||||
local b = saved and saved[o]
|
||||
if type(b) ~= "table" then b = { positions = tc.positions, scale = tc.scale } end
|
||||
out.layouts[o] = {
|
||||
positions = normalizePositions(b.positions),
|
||||
scale = clampScale(b.scale),
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
@@ -96,11 +140,14 @@ end
|
||||
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||
-- defaults stay in one place. ox/oy default to 0 for the headless tests
|
||||
-- and for callers that already pass a full-window size.
|
||||
function TouchControls.defaultLayout(ww, wh, ox, oy)
|
||||
-- and for callers that already pass a full-window size. scale (#633) is
|
||||
-- the orientation's size multiplier: every width and the margin derive
|
||||
-- from dpadW, so scaling it moves the default centers with the art
|
||||
-- instead of letting bigger buttons hang off the edge.
|
||||
function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
||||
ox, oy = ox or 0, oy or 0
|
||||
local short = math.min(ww, wh)
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local dpadW = math.min(180, short * 0.34) * clampScale(scale)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
@@ -127,7 +174,13 @@ end
|
||||
function TouchControls:init()
|
||||
self.active = wantsOverlay()
|
||||
self.enabled = true
|
||||
-- per-orientation buckets (#633); self.positions / self.scale mirror the
|
||||
-- one currently on screen so layout(), the editor and the tests keep a
|
||||
-- single lookup
|
||||
self.layouts = { portrait = {}, landscape = {} }
|
||||
self.orientation = nil
|
||||
self.positions = nil
|
||||
self.scale = 1
|
||||
self.preview = false
|
||||
self.controllerHidden = false
|
||||
self.touches = {}
|
||||
@@ -157,20 +210,30 @@ end
|
||||
function TouchControls:applyOptions(opts)
|
||||
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
|
||||
self.enabled = cfg.enabled
|
||||
self.positions = cfg.positions
|
||||
self.layouts = cfg.layouts
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
-- prime positions/scale for the orientation on screen so callers that
|
||||
-- read them before the next layout() (editor chrome, tests) see the file
|
||||
self:currentBucket()
|
||||
if not self.enabled then
|
||||
self.controllerHidden = false
|
||||
self:reset()
|
||||
end
|
||||
end
|
||||
|
||||
-- Snapshot for the editor's save path: enabled plus both orientation
|
||||
-- buckets, matching what options.lua stores (#633).
|
||||
function TouchControls:config()
|
||||
return {
|
||||
enabled = self.enabled ~= false,
|
||||
positions = self.positions,
|
||||
}
|
||||
local out = { enabled = self.enabled ~= false, layouts = {} }
|
||||
for _, o in ipairs(ORIENTATIONS) do
|
||||
local b = self.layouts and self.layouts[o] or nil
|
||||
out.layouts[o] = {
|
||||
positions = b and b.positions or nil,
|
||||
scale = clampScale(b and b.scale),
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Preview mode: force-draw the overlay for the layout editor, ignoring
|
||||
@@ -197,13 +260,33 @@ local function clampZone(zone, x0, y0, x1, y1)
|
||||
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||
end
|
||||
|
||||
-- The bucket for the orientation currently on screen (#633), created on
|
||||
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
|
||||
-- which layout(), the editor chrome and the tests read.
|
||||
function TouchControls:currentBucket()
|
||||
local _, _, sw, sh = SafeArea.rect()
|
||||
local o = orientationFor(sw, sh)
|
||||
self.layouts = self.layouts or { portrait = {}, landscape = {} }
|
||||
local b = self.layouts[o]
|
||||
if type(b) ~= "table" then
|
||||
b = {}
|
||||
self.layouts[o] = b
|
||||
end
|
||||
b.scale = clampScale(b.scale)
|
||||
self.orientation = o
|
||||
self.positions = b.positions
|
||||
self.scale = b.scale
|
||||
return b
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window or safe area changes (rotation, resize, notch insets).
|
||||
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||
-- device safe area so thumbs clear the home indicator / cutouts.
|
||||
-- Custom positions (normalized 0..1 within the safe rect) override centers
|
||||
-- while sizes stay derived from the short edge.
|
||||
-- while sizes stay derived from the short edge, times the orientation's
|
||||
-- size setting (#633).
|
||||
function TouchControls:layout()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
@@ -212,10 +295,13 @@ function TouchControls:layout()
|
||||
end
|
||||
self.layoutW, self.layoutH = sw, sh
|
||||
self.layoutOx, self.layoutOy = ox, oy
|
||||
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
|
||||
if self.positions then
|
||||
-- orientation picks which saved layout applies; rotating swaps buckets
|
||||
-- because sw/sh swapped, which is already the cache key above (#633)
|
||||
local bucket = self:currentBucket()
|
||||
self.L = TouchControls.defaultLayout(sw, sh, ox, oy, bucket.scale)
|
||||
if bucket.positions then
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local p = bucket.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = ox + p.x * sw
|
||||
@@ -242,19 +328,45 @@ function TouchControls:setControlCenter(name, cx, cy)
|
||||
if not zone then return end
|
||||
zone.cx, zone.cy = cx, cy
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
self.positions = self.positions or {}
|
||||
self.positions[name] = {
|
||||
-- writes land in the orientation on screen only (#633)
|
||||
local bucket = self:currentBucket()
|
||||
bucket.positions = bucket.positions or {}
|
||||
self.positions = bucket.positions
|
||||
bucket.positions[name] = {
|
||||
x = sw > 0 and (zone.cx - ox) / sw or 0,
|
||||
y = sh > 0 and (zone.cy - oy) / sh or 0,
|
||||
}
|
||||
end
|
||||
|
||||
-- Editor Reset: defaults for the orientation on screen only (#633), so
|
||||
-- resetting landscape never throws away the portrait layout.
|
||||
function TouchControls:clearPositions()
|
||||
local bucket = self:currentBucket()
|
||||
bucket.positions = nil
|
||||
bucket.scale = 1
|
||||
self.positions = nil
|
||||
self.scale = 1
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
end
|
||||
|
||||
-- Control size multiplier for the orientation on screen (#633). Widths and
|
||||
-- the default centers both derive from it in defaultLayout; custom centers
|
||||
-- keep their normalized spot and re-clamp inside the safe rect on the next
|
||||
-- layout().
|
||||
function TouchControls:setScale(scale)
|
||||
local bucket = self:currentBucket()
|
||||
bucket.scale = clampScale(scale)
|
||||
self.scale = bucket.scale
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
return self.scale
|
||||
end
|
||||
|
||||
function TouchControls:nudgeScale(delta)
|
||||
return self:setScale((self.scale or 1) + delta)
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
local r = zone.w * 0.5 * slop
|
||||
local dx, dy = x - zone.cx, y - zone.cy
|
||||
@@ -446,5 +558,8 @@ function TouchControls:draw()
|
||||
end
|
||||
|
||||
TouchControls.CONTROLS = CONTROLS
|
||||
TouchControls.ORIENTATIONS = ORIENTATIONS
|
||||
TouchControls.SCALE_MIN, TouchControls.SCALE_MAX = SCALE_MIN, SCALE_MAX
|
||||
TouchControls.SCALE_STEP = SCALE_STEP
|
||||
|
||||
return TouchControls
|
||||
|
||||
Reference in New Issue
Block a user