mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 03:02:39 +02:00
Merge origin/dev (v0.1.74) into feat/switch-ota-unified-launcher.
Keep Switch version chip alongside upstream's new quit button in the launcher header.
This commit is contained in:
+48
-2
@@ -202,6 +202,30 @@ local function headerChannels(banks, header)
|
||||
return channels
|
||||
end
|
||||
|
||||
-- Which software channels (CHAN5-8) an sfx occupies: its header carries one
|
||||
-- 3-byte descriptor per channel. Audio2_PlaySound walks exactly this list to
|
||||
-- decide whether a new sfx may start at all (audio/engine_2.asm
|
||||
-- .sfxChannelLoop), so Sound.playMove needs the set to reproduce that gate.
|
||||
-- nil = not knowable here (a file def, or the banks are not readable yet),
|
||||
-- which callers read as "no conflict".
|
||||
function ChipSynth.effectChannels(data, def)
|
||||
if type(def) ~= "table" then return nil end
|
||||
local chip = def.chip
|
||||
local specs = chip and chip.channels
|
||||
if not specs then
|
||||
if not def.address then return nil end
|
||||
local ok, banks = pcall(engineBanks, data, chip)
|
||||
if not ok then return nil end
|
||||
local read
|
||||
ok, read = pcall(headerChannels, banks, def)
|
||||
if not ok then return nil end
|
||||
specs = read
|
||||
end
|
||||
local channels = {}
|
||||
for _, spec in ipairs(specs) do channels[#channels + 1] = spec.number end
|
||||
return channels
|
||||
end
|
||||
|
||||
local function fadeValue(nibble)
|
||||
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
|
||||
return nibble
|
||||
@@ -406,7 +430,15 @@ function Channel:nextEvent()
|
||||
elseif command == 0xEC then
|
||||
self.duty = bit.band(self:byte(), 3)
|
||||
elseif command == 0xED then
|
||||
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
||||
local high = self:byte()
|
||||
local low = self:byte()
|
||||
-- a header carrying its own tempo is one of audio/alternate_tempo.asm's
|
||||
-- Music_*AlternateTempo entry points, which re-point channel 1 at a
|
||||
-- stub that sets the tempo and jumps into the normal body -- the body's
|
||||
-- own tempo command never runs there, so ignore it here (#847)
|
||||
if not self.engine.tempoLocked then
|
||||
self.engine.tempo = high * 0x100 + low
|
||||
end
|
||||
elseif command == 0xEE then
|
||||
self.engine.pan = self:byte()
|
||||
elseif command == 0xEF or command == 0xF0 then
|
||||
@@ -458,7 +490,15 @@ function Channel:nextEvent()
|
||||
local volume = bit.rshift(packed, 4)
|
||||
local fade = fadeValue(bit.band(packed, 0x0F))
|
||||
if self.noise then
|
||||
local parameter = self:byte()
|
||||
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
||||
-- frequency low byte for every channel at or past CHAN5, the noise
|
||||
-- channel included (audio/engine_2.asm Audio2_ApplyFrequencyModifier).
|
||||
-- On CHAN8 that byte is the polynomial counter, so the modifier moves
|
||||
-- the noise pitch; it wraps at 8 bits, the carry landing in the high
|
||||
-- byte that noise does not use for frequency. Dropping it left the
|
||||
-- battle hit sounds at their unmodified pitches, where super effective
|
||||
-- reads as the duller of the two (#826).
|
||||
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
|
||||
return self:noiseEvent(
|
||||
self:durationTicks(length), volume, fade, parameter)
|
||||
end
|
||||
@@ -753,6 +793,12 @@ function Engine.new(data, header, options)
|
||||
noiseInstruments = {},
|
||||
channels = {},
|
||||
}, Engine)
|
||||
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
|
||||
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
|
||||
if header.tempo then
|
||||
engine.tempo = header.tempo
|
||||
engine.tempoLocked = true
|
||||
end
|
||||
for _, spec in ipairs(chip and chip.channels
|
||||
or headerChannels(banks, header)) do
|
||||
local frameTicks = options.frameTicks
|
||||
|
||||
+6
-2
@@ -16,6 +16,10 @@ local Screens = require("src.ui.Screens")
|
||||
|
||||
local Game = {}
|
||||
|
||||
local function renderVisible(stack, state)
|
||||
return state and (not stack.renderVisible or stack:renderVisible(state))
|
||||
end
|
||||
|
||||
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
|
||||
-- module unloaded, so a player boot never touches a byte of dev code
|
||||
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
|
||||
@@ -460,7 +464,7 @@ function Game:draw()
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
if state and state.draw then
|
||||
if renderVisible(self.stack, state) and state.draw then
|
||||
if classicOffset ~= 0 and not wideState then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(classicOffset, 0)
|
||||
@@ -484,7 +488,7 @@ function Game:draw()
|
||||
local zones, worldZones, zoneOwner
|
||||
for i = #self.stack.states, 1, -1 do
|
||||
local s = self.stack.states[i]
|
||||
if s.sgbPalettes then
|
||||
if renderVisible(self.stack, s) and s.sgbPalettes then
|
||||
zones = s:sgbPalettes(self)
|
||||
zoneOwner = s
|
||||
break
|
||||
|
||||
+35
-1
@@ -62,8 +62,35 @@ function HostShell.hideHostConsole()
|
||||
return consoleHidden
|
||||
end
|
||||
|
||||
-- #254 was fixed inside the launcher and nowhere else: a native dialog opened
|
||||
-- while a mouse button is still down blocks the whole loop in io.popen, so SDL
|
||||
-- never processes the button-up and never drops the pointer capture it took
|
||||
-- for the press (on X11 an XGrabPointer with owner_events). The grab outlives
|
||||
-- the click, every pointer event over the child dialog is still routed to our
|
||||
-- window, and the dialog draws and keyboard-navigates but ignores the mouse.
|
||||
-- src/import/RomImporter.lua owns the launcher's copy; hoisting it here means
|
||||
-- every host spawn inherits it, including one a mod reaches through HostShell.
|
||||
-- Pump until nothing is held so SDL sees the release first; bounded, so a
|
||||
-- stuck button costs a moment and never the game. pump() drains OS events
|
||||
-- into LOVE's queue and dispatches nothing, so there is no reentry. Worker
|
||||
-- threads load neither love.mouse nor love.event, so the guard below makes
|
||||
-- this a no-op off the main thread.
|
||||
function HostShell.releasePointerGrab()
|
||||
if not (love and love.mouse and love.mouse.isDown and love.event
|
||||
and love.event.pump and love.timer) then
|
||||
return
|
||||
end
|
||||
local deadline = love.timer.getTime() + 1
|
||||
while love.mouse.isDown(1, 2, 3) do
|
||||
love.event.pump()
|
||||
if love.timer.getTime() > deadline then break end
|
||||
love.timer.sleep(0.005)
|
||||
end
|
||||
end
|
||||
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||
function HostShell.popen(command, mode)
|
||||
HostShell.releasePointerGrab()
|
||||
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||
if not ok or not pipe then return nil end
|
||||
return pipe
|
||||
@@ -154,8 +181,15 @@ local function haveBridge()
|
||||
if not (love and love.system and type(love.system.httpDownload) == "function") then
|
||||
return false
|
||||
end
|
||||
-- The OS allowlist is deliberate: the bridge is a per-port native addition,
|
||||
-- not part of LOVE, so a build that exports the name on a platform we never
|
||||
-- wired one for is a name collision, not a transport. UWP is listed because
|
||||
-- Xbox has no curl and no way to spawn one (Platform.canSpawnProcess is
|
||||
-- false there), so the bridge is its only possible transport (#876). Its
|
||||
-- LOVE backend does not export it today and this still returns false, but
|
||||
-- the gate is no longer the thing in the way.
|
||||
local osName = love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||
end
|
||||
|
||||
-- Is any transport available at all? Callers gate on this, never on curl.
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
-- Launch options: boot straight into a game, skipping the launcher.
|
||||
--
|
||||
-- love . --game red -- boot Red
|
||||
-- love . --game yellow --slot 2 -- boot Yellow on save slot 2
|
||||
-- love . --game red --launcher -- open the launcher anyway (a shortcut
|
||||
-- the player wants to edit)
|
||||
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
|
||||
-- love . --game=red -- boot Red
|
||||
-- love . --game=yellow --slot=2 -- boot Yellow on save slot 2
|
||||
-- love . --game=red --launcher -- open the launcher anyway (a shortcut
|
||||
-- the player wants to edit)
|
||||
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
|
||||
--
|
||||
-- The "--flag value" spelling parses here (argValue reads argv[i + 1]), but it
|
||||
-- does not survive LOVE: boot.lua takes the first bare argument as a path to a
|
||||
-- game to run, so `--game red` dies with "Cannot load game at path .../red"
|
||||
-- before love.load is ever called, fused or not. Only the "=" spelling is
|
||||
-- reachable, so that is the one the docs quote.
|
||||
--
|
||||
-- This exists for the click-once cases: a desktop shortcut per game, a Steam
|
||||
-- entry, an EmulationStation/Playnite entry, a handheld frontend. Those all
|
||||
|
||||
+16
-1
@@ -82,6 +82,7 @@ state = {
|
||||
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
|
||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||
tempo = nil, -- alternate-tempo override in force for `current`
|
||||
failed = {}, -- labels whose def could not be started; logged once
|
||||
}
|
||||
|
||||
@@ -234,11 +235,23 @@ function Music.play(data, song, loop, ctx)
|
||||
if not song then return end
|
||||
if not love.audio then return end -- headless test stub
|
||||
song = selectSong(song, ctx)
|
||||
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
|
||||
-- the same song restarted with channel 1 re-pointed at a stub whose only
|
||||
-- difference is its `tempo`, so the same label at a different tempo is a
|
||||
-- different cue and must not be deduped away (#847)
|
||||
local tempo = ctx and ctx.tempo or nil
|
||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||
-- below has to compare against
|
||||
if not song or song == state.current then return end
|
||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
||||
local def = songDef(data, song)
|
||||
if not def or state.failed[song] then return end
|
||||
if tempo then
|
||||
-- shallow copy: the registry def is shared, only this playback is slowed
|
||||
local slowed = {}
|
||||
for key, value in pairs(def) do slowed[key] = value end
|
||||
slowed.tempo = tempo
|
||||
def = slowed
|
||||
end
|
||||
local wantLoop = loop ~= false
|
||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||
if not src then
|
||||
@@ -274,6 +287,7 @@ function Music.play(data, song, loop, ctx)
|
||||
local previous = state.current
|
||||
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||
state.current = song
|
||||
state.tempo = tempo
|
||||
if Runtime.wants("music.started") then
|
||||
Runtime.emit("music.started", {
|
||||
song = song, previous = previous, chip = isChip,
|
||||
@@ -288,6 +302,7 @@ function Music.stop()
|
||||
stopSource(state.loopSource)
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.tempo = nil
|
||||
state.chip = false
|
||||
state.pendingRestore = nil
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
|
||||
@@ -12,6 +12,8 @@ local function compute()
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
local nativePicker = love and love.system
|
||||
and type(love.system.pickFile) == "function"
|
||||
local nativeHttp = love and love.system
|
||||
and type(love.system.httpDownload) == "function"
|
||||
return {
|
||||
os = osName,
|
||||
nx = nx,
|
||||
@@ -24,6 +26,17 @@ local function compute()
|
||||
or (nativePicker and "native-picker")
|
||||
or "desktop",
|
||||
networkValidated = not nx and not uwp,
|
||||
-- networkValidated is the self-updater's gate and stays a per-platform
|
||||
-- policy call: a console package cannot replace itself on disk, so that
|
||||
-- answer never depends on whether a transport exists. Fetching a mod
|
||||
-- index or a mod zip is the narrower question, and #876 showed the two
|
||||
-- had been conflated, so Xbox lost the mod catalog for the updater's
|
||||
-- reason. Desktop answers it with curl through HostShell; the mobile and
|
||||
-- console ports answer it with the native love.system.httpDownload bridge
|
||||
-- (#597). The UWP LOVE backend does not export that bridge yet, so this
|
||||
-- still resolves false on Xbox and the launcher still says so, but the
|
||||
-- day the backend grows one, nothing here or in RomImporter has to change.
|
||||
canFetchRemote = (not nx and not uwp) or nativeHttp,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -52,6 +65,10 @@ function Platform.networkValidated()
|
||||
return Platform.detect().networkValidated
|
||||
end
|
||||
|
||||
function Platform.canFetchRemote()
|
||||
return Platform.detect().canFetchRemote
|
||||
end
|
||||
|
||||
-- Tests may swap love.system between cases.
|
||||
function Platform._resetForTests()
|
||||
cached = nil
|
||||
|
||||
@@ -28,6 +28,24 @@ function SafeArea.rect()
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
-- A safe rect that cannot fit the window's unit space is a backend
|
||||
-- reporting framebuffer PIXELS -- the iOS build (LOVE 12 + SDL3) did this
|
||||
-- in portrait on iOS 16, and clamping it as-is kept a DPI-inflated top
|
||||
-- inset that pushed the whole launcher a band down the screen (#810).
|
||||
-- Convert back to units with per-axis ratios; the axes can disagree on
|
||||
-- forced-rotation devices (see displayMetrics in src/render/Renderer.lua,
|
||||
-- #208).
|
||||
if (w > ww + 0.5 or h > wh + 0.5)
|
||||
and love.graphics.getPixelDimensions then
|
||||
local pw, ph = love.graphics.getPixelDimensions()
|
||||
local dx = (pw and pw > 0) and (pw / ww) or 1
|
||||
local dy = (ph and ph > 0) and (ph / wh) or 1
|
||||
if dx > 1.01 or dy > 1.01 then
|
||||
x, w = x / dx, w / dx
|
||||
y, h = y / dy, h / dy
|
||||
end
|
||||
end
|
||||
|
||||
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||
-- push layout outside the surface.
|
||||
x = math.max(0, math.min(x, ww))
|
||||
|
||||
+81
-2
@@ -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
|
||||
|
||||
@@ -368,11 +384,54 @@ function SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
opts.modOptions = merged
|
||||
end
|
||||
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
|
||||
local encoded = SaveSerializer.encode(opts)
|
||||
-- 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
|
||||
return ok and opts or nil
|
||||
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
|
||||
end
|
||||
-- #828: settings "reset" on Android and Steam Deck with nothing in the log.
|
||||
-- Every options write is a WHOLE-FILE rewrite, so a write that reports
|
||||
-- success without the bytes landing (an external-storage volume that went
|
||||
-- away mid-session, a read-only or full save dir) is indistinguishable from
|
||||
-- "the launcher never saved". Read the file back and fail loudly instead:
|
||||
-- callers already treat nil as a failed write, and the log line is what the
|
||||
-- next report from those platforms needs to carry.
|
||||
local wrote = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
|
||||
if wrote ~= encoded then
|
||||
Logger.error("options save did not land (%d bytes written, %s on disk)",
|
||||
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
|
||||
return nil
|
||||
end
|
||||
-- #828: roll the backup FORWARD to the bytes just verified. The
|
||||
-- pre-write roll above only preserves the previous file for a death
|
||||
-- during this rewrite; at rest the backup must hold the newest verified
|
||||
-- state, because the hard teardown out of a game session (HostShell's
|
||||
-- restartApp kill on Android, execv on a SteamOS AppImage) can eat the
|
||||
-- main file outright and loadOptions then promotes this copy. The
|
||||
-- encoder is key-sorted, so the follow-up rewrites a play session makes
|
||||
-- (play()'s lastVersion stamp, the in-game save flush) are byte-identical
|
||||
-- and skip the conditional roll -- without this line the backup still
|
||||
-- held the file from BEFORE the launcher's change, and recovery reverted
|
||||
-- the just-changed setting (BATTLE LAYOUT back to OG).
|
||||
fs.write(OPTIONS_BACKUP_FILENAME, encoded)
|
||||
-- the staged witness has served its purpose; the main file is verified
|
||||
remove(fs, OPTIONS_TMP_FILENAME)
|
||||
return opts
|
||||
end
|
||||
|
||||
function SaveData.loadOptions(fs)
|
||||
@@ -382,6 +441,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)
|
||||
|
||||
+84
-15
@@ -208,29 +208,91 @@ end
|
||||
-- sfx table; older audio.lua builds without the variants fall back to
|
||||
-- the unmodified sound.
|
||||
-- anim: a moves.lua anim table { sound, pitch, tempo }.
|
||||
--
|
||||
-- Whether a row sound is heard at all is Audio2_PlaySound's channel gate
|
||||
-- (audio/engine_2.asm .playSfx/.sfxChannelLoop): for every channel the new
|
||||
-- sfx wants, a channel still busy with a LOWER sound id aborts the whole
|
||||
-- request (`cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`), while
|
||||
-- an equal or lower id takes those channels over. A sound id is
|
||||
-- (header address - SFX_Headers_1) / 3 (constants/music_constants.asm
|
||||
-- music_const), so a def's header address orders ids inside one engine
|
||||
-- bank. Blizzard's animation is two rows, BLIZZARD then HYDRO_PUMP
|
||||
-- (data/moves/animations.asm BlizzardAnim), and SFX_BATTLE_29 (CHAN5+8) is
|
||||
-- still sounding when the second row starts, so the original never plays
|
||||
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
||||
-- past the end of the animation (#844).
|
||||
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
|
||||
|
||||
local function channelsOverlap(a, b)
|
||||
if not (a and b) then return false end
|
||||
for _, x in ipairs(a) do
|
||||
for _, y in ipairs(b) do
|
||||
if x == y then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- would PlaySound start this def now? Taking a channel over also stops the
|
||||
-- sound that held it, the way .playChannel resets the channel.
|
||||
local function sfxChannelGate(data, def)
|
||||
local cur = lastMoveSfx
|
||||
if not cur then return true end
|
||||
local ok, playing = pcall(cur.src.isPlaying, cur.src)
|
||||
if not (ok and playing) then
|
||||
lastMoveSfx = nil
|
||||
return true
|
||||
end
|
||||
-- an unrankable def (file asset, or another engine's bank) has no
|
||||
-- comparable sound id: leave it to the mixer, as before
|
||||
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
|
||||
return true
|
||||
end
|
||||
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||
if not channelsOverlap(channels, cur.channels) then return true end
|
||||
if def.address > cur.rank then return false end
|
||||
pcall(cur.src.stop, cur.src)
|
||||
lastMoveSfx = nil
|
||||
return true
|
||||
end
|
||||
|
||||
local function noteMoveSfx(data, def, src)
|
||||
if not src or type(def) ~= "table" or not def.address then
|
||||
lastMoveSfx = nil
|
||||
return
|
||||
end
|
||||
lastMoveSfx = {
|
||||
src = src, rank = def.address, engine = def.engine,
|
||||
channels = require("src.core.ChipSynth").effectChannels(data, def),
|
||||
}
|
||||
end
|
||||
|
||||
function Sound.playMove(data, anim)
|
||||
if not anim or not anim.sound then return end
|
||||
local sfx = data.audio and data.audio.sfx
|
||||
if not sfx then return end
|
||||
local name = anim.sound
|
||||
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
||||
local def = sfx[name]
|
||||
if not sfxChannelGate(data, def) then return end
|
||||
local src
|
||||
-- a chip program synthesizes the modified variant on demand; a file def
|
||||
-- can only reach for a pre-rendered one
|
||||
if isChipDef(sfx[name]) then
|
||||
if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
||||
sfx[name], pitch, tempo) then
|
||||
played("move", name)
|
||||
end
|
||||
return
|
||||
end
|
||||
if pitch ~= 0 or tempo ~= 0x80 then
|
||||
if isChipDef(def) then
|
||||
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
||||
def, pitch, tempo)
|
||||
else
|
||||
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||
if sfx[key] then
|
||||
if playPath(data, key, sfx[key]) then played("move", name) end
|
||||
return
|
||||
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
||||
src = playPath(data, key, sfx[key])
|
||||
else
|
||||
src = playPath(data, name, def)
|
||||
end
|
||||
end
|
||||
if playPath(data, name, sfx[name]) then played("move", name) end
|
||||
if src then
|
||||
played("move", name)
|
||||
noteMoveSfx(data, def, src)
|
||||
end
|
||||
end
|
||||
|
||||
-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another
|
||||
@@ -304,12 +366,18 @@ end
|
||||
|
||||
-- returns the source (nil headless) so callers that block on the cry
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
function Sound.playCry(data, species)
|
||||
function Sound.playCry(data, species, pikaClip)
|
||||
if not love.audio then return nil end
|
||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||
-- never used for the species there); clip 1 is the everyday "Pika!"
|
||||
-- never used for the species there). Which clip is a property of the
|
||||
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
||||
-- its own `ldpikacry e, PikachuCryN` -- so pikaClip carries that choice
|
||||
-- in; it is ignored for every other species. Clip 1 is the LONG
|
||||
-- title-screen "Pikachuuu" (engine/movie/title.asm:146), kept as the
|
||||
-- default only for the sites that have not been given their own clip
|
||||
-- yet; battle entrances pass 11/37 (#837).
|
||||
if species == "PIKACHU" then
|
||||
local src = Sound.playPikaCry(data, 1)
|
||||
local src = Sound.playPikaCry(data, pikaClip or 1)
|
||||
if src then return src end
|
||||
end
|
||||
local cries = data.audio and data.audio.cries
|
||||
@@ -451,6 +519,7 @@ end
|
||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||
-- variants included) or all of them, so the next play re-resolves the def
|
||||
function Sound.invalidate(name)
|
||||
lastMoveSfx = nil -- its source is about to be dropped or stopped
|
||||
local function evict(store, key)
|
||||
local src = store[key]
|
||||
if src then pcall(src.stop, src) end
|
||||
|
||||
+14
-2
@@ -39,17 +39,29 @@ function StateStack:update(dt)
|
||||
if top and top.update then top:update(dt) end
|
||||
end
|
||||
|
||||
local function visibleByDefault() return true end
|
||||
|
||||
-- A mod may mirror a state elsewhere and hide only its main-screen render.
|
||||
-- The state stays on the stack, so update and input ownership do not move.
|
||||
function StateStack:renderVisible(state)
|
||||
if not state then return false end
|
||||
if not Runtime.wantsHook("screen.render_visible") then return true end
|
||||
return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false
|
||||
end
|
||||
|
||||
-- index of the lowest state drawn this frame (highest opaque, else 1)
|
||||
function StateStack:visibleBase()
|
||||
for i = #self.states, 1, -1 do
|
||||
if self.states[i].isOpaque then return i end
|
||||
local state = self.states[i]
|
||||
if self:renderVisible(state) and state.isOpaque then return i end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function StateStack:draw()
|
||||
for i = self:visibleBase(), #self.states do
|
||||
if self.states[i].draw then self.states[i]:draw() end
|
||||
local state = self.states[i]
|
||||
if self:renderVisible(state) and state.draw then state:draw() end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user