CLOSES #806, CLOSES #809, CLOSES #853, CLOSES #854, CLOSES #860, CLOSES #862, CLOSES #865, CLOSES #866

This commit is contained in:
bryanthaboi
2026-08-05 14:38:10 -04:00
parent 104c95a942
commit 863f371e68
25 changed files with 1746 additions and 74 deletions
+53 -1
View File
@@ -30,6 +30,15 @@ local SaveData = {}
-- deliberately shared across versions (it holds global preferences and the
-- mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- #828: options.lua is rewritten whole on every write (see saveOptions), and
-- unlike the progress files it had no staged copy, so a write interrupted
-- between the truncate and the flush -- the process replaced by
-- HostShell.restart on the way back to the launcher, an Android
-- external-storage volume that never flushed -- left a truncated or empty
-- file that loadOptions could only answer with defaults: every setting
-- "reset" at once. Same .bak/.tmp witness names the save files use.
local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak"
local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
@@ -305,6 +314,13 @@ function SaveData.defaultOptions()
-- layout (#633). Pre-#633 files stored one top-level positions table;
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
-- Haptic feedback level for on-screen pad presses (#806):
-- off | light | medium | heavy, mapped to a love.system.vibrate
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
-- overlay itself defaulting on, so an options.lua predating this key
-- gets the tick without going looking for the row. Inert wherever the
-- overlay never appears (desktop) or LOVE has no vibrator.
haptics = "light",
}
end
@@ -369,7 +385,21 @@ function SaveData.saveOptions(opts, fs)
opts.modOptions = merged
end
local encoded = SaveSerializer.encode(opts)
local ok, err = fs.write(OPTIONS_FILENAME, encoded)
-- Stage the new bytes and roll the last good file aside BEFORE the main
-- write truncates it, the same tmp/bak dance SaveData.save uses for
-- progress: whatever ends the process mid-write, one of the three copies
-- is complete and loadOptions promotes it instead of falling back to
-- defaults (#828).
local ok, err = fs.write(OPTIONS_TMP_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
end
local prev = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
if type(prev) == "string" and prev ~= "" and prev ~= encoded then
fs.write(OPTIONS_BACKUP_FILENAME, prev)
end
ok, err = fs.write(OPTIONS_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
@@ -387,6 +417,8 @@ function SaveData.saveOptions(opts, fs)
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
return nil
end
-- the staged witness has served its purpose; the main file is verified
remove(fs, OPTIONS_TMP_FILENAME)
return opts
end
@@ -397,6 +429,26 @@ function SaveData.loadOptions(fs)
if fs.getInfo(OPTIONS_FILENAME) then
Logger.error("options load failed: %s", tostring(err))
end
-- #828: answering defaults here is what "closing the game reset all my
-- settings" looked like -- one interrupted whole-file rewrite and every
-- preference, the mod enable-state and the slot registry were gone.
-- Promote the staged copy, then the rolled-aside backup, exactly as
-- SaveData.load does for progress, and heal the main file from whichever
-- one parsed.
local recovered = readTable(fs, OPTIONS_TMP_FILENAME)
local from = "tmp"
if not recovered then
recovered = readTable(fs, OPTIONS_BACKUP_FILENAME)
from = "bak"
end
if recovered then
Logger.warn("options.lua %s; recovered from %s copy",
fs.getInfo(OPTIONS_FILENAME) and "corrupt" or "missing", from)
if fs.write then
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
end
return SaveData.mergeOptions(recovered)
end
return SaveData.defaultOptions()
end
return SaveData.mergeOptions(data)
+64 -1
View File
@@ -85,6 +85,55 @@ local function clampScale(v)
return v
end
-- Haptic feedback (#806): a short vibration the instant a control takes a GB
-- button, the way every mobile emulator front-end does it -- the pad has no
-- edges under a thumb, so the buzz is the only confirmation a press landed.
-- Persisted as options.haptics (src/core/SaveData.lua defaultOptions), NOT
-- under options.touchControls: TouchControls:config() is the launcher
-- editor's save snapshot and only emits enabled + layouts, so a nested key
-- would be dropped on every editor save.
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
-- duration preset: Android runs the platform vibrator for exactly that long,
-- while iOS ignores the duration and fires the fixed system vibration, so
-- there the three levels all read as simply on.
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
TouchControls.HAPTIC_DEFAULT = "light"
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
local HAPTIC_LABELS = {
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
}
function TouchControls.normalizeHaptics(level)
if HAPTIC_SECONDS[level] then return level end
return TouchControls.HAPTIC_DEFAULT
end
function TouchControls.hapticLabel(level)
return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)]
end
function TouchControls.cycleHaptics(level, dir)
local cur, idx = TouchControls.normalizeHaptics(level), 1
for i, m in ipairs(TouchControls.HAPTICS) do
if m == cur then idx = i break end
end
local n = #TouchControls.HAPTICS
return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1]
end
-- One pulse at the given level. Feature-guarded rather than platform-gated:
-- love.system.vibrate is a no-op on desktop and absent from the headless love
-- stubs, so the press path below stays identical everywhere and the tests
-- never reach a vibrator.
function TouchControls.buzz(level)
local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)]
if not secs or secs <= 0 then return false end
if not (love and love.system and love.system.vibrate) then return false end
pcall(love.system.vibrate, secs)
return true
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.
@@ -174,6 +223,10 @@ end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
-- vibration level for presses (#806); applyOptions overwrites it from
-- options.haptics, this is the value a harness that never applies options
-- runs with
self.haptics = TouchControls.HAPTIC_DEFAULT
-- 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
@@ -210,6 +263,9 @@ end
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
-- haptics is a plain top-level option, not part of the layout config the
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -401,7 +457,14 @@ 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
-- Buzz only on the 0 -> 1 edge, the same edge that presses the GB button:
-- a second finger landing on a button that is already held, and a d-pad
-- finger resting inside one direction, must not retrigger it. Sliding the
-- d-pad to a new direction does, which is the point (#806).
if n == 1 then
Input:overlayPressed(btn)
TouchControls.buzz(self.haptics)
end
end
local function releaseBtn(self, btn)