mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Merge branch 'dev' into feat/switch-nx
Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods (#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L display chords still work. Co-authored-by: Cursor <cursoragent@cursor.com>
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
|
||||
|
||||
|
||||
@@ -77,6 +77,13 @@ end
|
||||
function Data:applyVersionedFieldData()
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
self.field.trades = copy(YELLOW_TRADES)
|
||||
-- The old man's catch demo is a RATTATA in Yellow
|
||||
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
|
||||
-- .SetupBattle: ld a, RATTATA / ld [wCurOpponent], a) but the Yellow
|
||||
-- manifest inherited Red's WEEDLE field.oldManBattle (#617), so old
|
||||
-- Yellow caches carry the wrong demo species too. The fixed import
|
||||
-- manifest below stamps RATTATA for fresh imports.
|
||||
self.field.oldManBattle = { species = "RATTATA", level = 5 }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
-- Faithful resolution: lock the window to an exact integer multiple of the
|
||||
-- Game Boy's 160x144 screen, 1X through 4X.
|
||||
--
|
||||
-- At any other window size the renderer picks the largest integer scale that
|
||||
-- fits and letterboxes the remainder (Renderer:fitScale), so the game is
|
||||
-- already crisp -- what it is not is *exact*: there are bars, and at a wide
|
||||
-- window a lot of them. Locking the window to 160*N x 144*N removes the
|
||||
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
|
||||
--
|
||||
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
|
||||
-- and on boot via Game:applyOptions. No-ops in headless stubs that lack
|
||||
-- love.window.
|
||||
--
|
||||
-- MOBILE takes the other route to the same place. There is no window to
|
||||
-- resize -- the window IS the screen, and it rotates -- so the lock caps the
|
||||
-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N
|
||||
-- physical pixels per GB pixel and centres it, and the rest of the display
|
||||
-- stays black. Same promise as the desktop lock (a GB pixel is exactly N
|
||||
-- screen pixels, no more) reached by moving the picture rather than the
|
||||
-- window. This used to return false on the first line, so the row sat in
|
||||
-- OPTIONS on Android and iOS doing nothing at all.
|
||||
--
|
||||
-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs
|
||||
-- every frame off the live drawable size, so portrait and landscape both get
|
||||
-- the same locked scale with the bars falling wherever the screen is longer.
|
||||
|
||||
local FaithfulRes = {}
|
||||
|
||||
FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
|
||||
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
|
||||
FaithfulRes.DEFAULT = 0
|
||||
|
||||
-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF.
|
||||
-- Renderer:fitScale reads it through FaithfulRes.scaleCap.
|
||||
FaithfulRes.mobileScale = 0
|
||||
|
||||
-- conf.lua's floor for the resizable desktop window, restored when the lock
|
||||
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
|
||||
-- as well as set the size or LOVE clamps the window back up.
|
||||
FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
|
||||
|
||||
-- whether this module currently owns the window size
|
||||
FaithfulRes.locked = false
|
||||
|
||||
-- The highest level this display can actually show.
|
||||
--
|
||||
-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the
|
||||
-- feature shipped with. On mobile there is no window to size, so a fixed
|
||||
-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the
|
||||
-- levels the panel could really use are not on the list at all. Derive it
|
||||
-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top
|
||||
-- of the ladder is the biggest exact-pixel picture it can draw.
|
||||
--
|
||||
-- OFF (0) is untouched by any of this and keeps doing exactly what it always
|
||||
-- did: the renderer fits and letterboxes as usual.
|
||||
function FaithfulRes.maxLevel()
|
||||
-- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea --
|
||||
-- there it names a window size you can see. On a phone the same number
|
||||
-- means a different fraction of every device, and every level below the top
|
||||
-- is just a smaller picture for no reason. ON means one thing instead:
|
||||
-- lock the viewport to the Game Boy's 10:9 and size it to this screen.
|
||||
if FaithfulRes.isMobile() then return 1 end
|
||||
return 4
|
||||
end
|
||||
|
||||
-- the selectable ladder for this display: OFF, then 1X..maxLevel
|
||||
function FaithfulRes.levels()
|
||||
local out = { 0 }
|
||||
for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end
|
||||
return out
|
||||
end
|
||||
|
||||
function FaithfulRes.normalize(v)
|
||||
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
|
||||
if v < 0 then return 0 end
|
||||
local max = FaithfulRes.maxLevel()
|
||||
if v > max then return max end
|
||||
return v
|
||||
end
|
||||
|
||||
function FaithfulRes.label(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return "OFF" end
|
||||
-- mobile has one ON: the level is chosen from the display, not the player
|
||||
if FaithfulRes.isMobile() then return "ON" end
|
||||
return tostring(v) .. "X"
|
||||
end
|
||||
|
||||
function FaithfulRes.cycle(v, dir)
|
||||
local levels = FaithfulRes.levels()
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == FaithfulRes.normalize(v) then cur = i break end
|
||||
end
|
||||
return levels[(cur - 1 + (dir or 1)) % #levels + 1]
|
||||
end
|
||||
|
||||
function FaithfulRes.isMobile()
|
||||
-- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the
|
||||
-- scale lock can be seen and driven without a device. The window is still
|
||||
-- resizable, which is the point -- drag it to a phone aspect, rotate it by
|
||||
-- dragging the other way, and the lock has to hold through both. Only this
|
||||
-- module reads isMobile, so the override cannot leak into anything else.
|
||||
if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local osName = love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
-- Physical pixels per LOVE unit for the CURRENT window.
|
||||
--
|
||||
-- Deliberately NOT love.window.getDPIScale: that reports the display's
|
||||
-- scaling factor even when the window is not high-DPI aware, and conf.lua
|
||||
-- only sets t.window.highdpi on mobile. On a plain desktop window a unit IS
|
||||
-- a pixel, so dividing by the display scale just shrinks the window -- at
|
||||
-- 125% scaling a 2X request became 256x230 pixels, which Renderer:fitScale
|
||||
-- floors to 1, and 4X became 512x461, which floors to 3. That is exactly
|
||||
-- the "2X renders at 1X, 4X renders at 3X" this shipped with.
|
||||
--
|
||||
-- Measuring the ratio the window actually reports is correct in both worlds:
|
||||
-- 1 on a plain desktop window, the real scale on a high-DPI one.
|
||||
local function pixelsPerUnit()
|
||||
local g = love and love.graphics
|
||||
if not (g and g.getDimensions and g.getPixelDimensions) then return 1 end
|
||||
local uw = tonumber((g.getDimensions()))
|
||||
local pw = tonumber((g.getPixelDimensions()))
|
||||
if not uw or not pw or uw <= 0 or pw <= 0 then return 1 end
|
||||
return pw / uw
|
||||
end
|
||||
|
||||
-- The window size in LOVE UNITS that puts 160*v x 144*v PHYSICAL pixels on
|
||||
-- screen.
|
||||
function FaithfulRes.size(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return nil end
|
||||
local ratio = pixelsPerUnit()
|
||||
return math.floor(FaithfulRes.WIDTH * v / ratio + 0.5),
|
||||
math.floor(FaithfulRes.HEIGHT * v / ratio + 0.5)
|
||||
end
|
||||
|
||||
-- Push the lock into the live window. Returns true when the window is
|
||||
-- locked afterwards.
|
||||
-- The largest WHOLE multiple of the Game Boy screen this display can hold.
|
||||
-- Integer, never fractional: a GB pixel has to be the same number of screen
|
||||
-- pixels in both axes or it is not pixel perfect, it is resampled.
|
||||
--
|
||||
-- The leftover is black bars, and on a tall phone there is a lot of it
|
||||
-- vertically -- that is simply what a 10:9 screen looks like on a 9:20
|
||||
-- display, and it is what an emulator shows too.
|
||||
function FaithfulRes.deviceScale()
|
||||
local g = love and love.graphics
|
||||
if not (g and g.getPixelDimensions) then return 1 end
|
||||
local pw, ph = g.getPixelDimensions()
|
||||
if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end
|
||||
return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH,
|
||||
ph / FaithfulRes.HEIGHT)))
|
||||
end
|
||||
|
||||
-- The scale the renderer must lock to, or nil for "fit the window as usual".
|
||||
-- Only ever set on mobile: on desktop the window itself is the lock, so
|
||||
-- fitScale already lands on N and this would be a second, redundant one.
|
||||
--
|
||||
-- Always the device maximum. Anything less is a smaller picture for no gain,
|
||||
-- which is how the first cut ended up showing a postage stamp on a 1080p
|
||||
-- phone.
|
||||
function FaithfulRes.scaleCap()
|
||||
if not FaithfulRes.locked then return nil end
|
||||
if not FaithfulRes.isMobile() then return nil end
|
||||
return FaithfulRes.deviceScale()
|
||||
end
|
||||
|
||||
function FaithfulRes.apply(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
-- Mobile: lock the render scale instead of the window. The scale itself
|
||||
-- comes from the display (deviceScale), not from v -- v only says whether
|
||||
-- the lock is on. Nothing to restore on release: the renderer simply goes
|
||||
-- back to filling the display.
|
||||
if FaithfulRes.isMobile() then
|
||||
FaithfulRes.locked = v > 0
|
||||
FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0
|
||||
return FaithfulRes.locked
|
||||
end
|
||||
if not love or not love.window or not love.window.setMode
|
||||
or not love.window.getMode then
|
||||
return false
|
||||
end
|
||||
local curW, curH, flags = love.window.getMode()
|
||||
flags = flags or {}
|
||||
|
||||
if v == 0 then
|
||||
-- only touch the window if we were the one holding it: an OFF setting on
|
||||
-- boot must not resize a window the player sized themselves
|
||||
if not FaithfulRes.locked then return false end
|
||||
flags.resizable = true
|
||||
flags.minwidth, flags.minheight = FaithfulRes.MIN_W, FaithfulRes.MIN_H
|
||||
love.window.setMode(curW, curH, flags)
|
||||
FaithfulRes.locked = false
|
||||
return false
|
||||
end
|
||||
|
||||
local w, h = FaithfulRes.size(v)
|
||||
-- An exact size and a desktop-fullscreen mode cannot both hold. The lock
|
||||
-- is the more specific request, so it wins and drops fullscreen; VIDEO MODE
|
||||
-- reads BORDERLESS until the player changes it, which then releases this.
|
||||
flags.fullscreen = false
|
||||
-- resizing by hand would silently break the lock, and nothing re-applies it
|
||||
-- (there is no love.resize handler -- the renderer re-reads the size every
|
||||
-- frame), so the window is fixed while locked rather than left draggable.
|
||||
flags.resizable = false
|
||||
flags.minwidth, flags.minheight = w, h
|
||||
love.window.setMode(w, h, flags)
|
||||
FaithfulRes.locked = true
|
||||
return true
|
||||
end
|
||||
|
||||
function FaithfulRes.applyOptions(opts)
|
||||
return FaithfulRes.apply(opts and opts.faithfulRes)
|
||||
end
|
||||
|
||||
return FaithfulRes
|
||||
+29
-4
@@ -7,9 +7,22 @@ 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
|
||||
self.suppressCatchup = false
|
||||
end
|
||||
|
||||
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
||||
@@ -20,6 +33,16 @@ end
|
||||
FixedStep.maxAccum = MAX_ACCUM
|
||||
|
||||
function FixedStep:update(dt)
|
||||
-- A hitch's oversized dt lands on the frame AFTER discardCatchup was
|
||||
-- called (the hitch itself already ran inside the current step); absorb
|
||||
-- that one frame as a single step instead of the normal accumulator so
|
||||
-- the burst it would otherwise release doesn't play out as a slide.
|
||||
if self.suppressCatchup then
|
||||
self.suppressCatchup = false
|
||||
self.accum = self.STEP * RESEED_PHASE
|
||||
self.callback(self.STEP)
|
||||
return
|
||||
end
|
||||
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
|
||||
while self.accum >= self.STEP do
|
||||
self.accum = self.accum - self.STEP
|
||||
@@ -27,12 +50,14 @@ function FixedStep:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop any pending catch-up steps. A hitch inside one logic step (map
|
||||
-- seam setMap / song start) makes the next real-time dt huge; without this
|
||||
-- the while-loop above would advance many walk frames before the next
|
||||
-- draw, which looks like a slide with no leg animation (issue #93).
|
||||
-- Drop any pending catch-up steps and arm the one-frame clamp above. A
|
||||
-- hitch inside one logic step (map seam setMap / song start) makes the
|
||||
-- next real-time dt huge; without this the while-loop above would advance
|
||||
-- many walk frames before the next draw, which looks like a slide with no
|
||||
-- leg animation (issue #93).
|
||||
function FixedStep:discardCatchup()
|
||||
self.accum = 0
|
||||
self.suppressCatchup = true
|
||||
end
|
||||
|
||||
return FixedStep
|
||||
|
||||
+191
-9
@@ -266,6 +266,38 @@ end
|
||||
-- exactly as the owning state computed it
|
||||
local function sameZones(_, zones) return zones end
|
||||
|
||||
-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
|
||||
-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
|
||||
-- during the battle must not drop the dim for a frame.
|
||||
function Game.worldBgBattleDim(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
return state.BG_WORLD_DIM or 0.55
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Does anything on the stack want the surface scaled to FILL the window
|
||||
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
|
||||
-- integer scale?
|
||||
--
|
||||
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
|
||||
-- because the party menu, bag and text boxes a battle opens must not snap the
|
||||
-- surface back to the fixed scale for a frame; the title screen and intro want
|
||||
-- it unconditionally, since neither has a world behind it and neither has any
|
||||
-- reason to sit in a small box in the middle of a large window.
|
||||
function Game.fillScaleInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.wantsFillScale and state:wantsFillScale() then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||
-- but the canvas must not snap to 160px between those states.
|
||||
@@ -279,6 +311,58 @@ function Game.wideBattleInStack(stack)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether a state on the stack composes its own screen and so wants the
|
||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||
-- everything else here: the text box and YES/NO a battle puts up are states
|
||||
-- of their own sitting above it, and they are exactly the elements that must
|
||||
-- stay inside the battle's composition rather than dock to the window.
|
||||
-- UI LAYOUT: is edge docking switched on? Only the explicit "dynamic" turns
|
||||
-- it on, so a save written before the option existed -- and any caller with no
|
||||
-- save at all, which is most of the headless suites -- gets CENTERED, the
|
||||
-- behaviour the port shipped with.
|
||||
function Game.dynamicUI(save)
|
||||
local options = save and save.options
|
||||
return options ~= nil and options.uiLayout == "dynamic"
|
||||
end
|
||||
|
||||
function Game.uiAnchorsHeldInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.holdsUIAnchors then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
|
||||
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
|
||||
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
|
||||
-- the bag) becomes that base, cutting the overworld -- and with it the world
|
||||
-- pass -- out of the frame entirely. The backdrop the battle established
|
||||
-- then collapses to endFrame's flat black clear for as long as the menu is
|
||||
-- up. So a world-bg battle keeps the frame starting from underneath itself
|
||||
-- until it leaves the stack, the same hold uiFill and the dim already use.
|
||||
--
|
||||
-- Only the START of the draw moves. The clear stays keyed to the real
|
||||
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
|
||||
-- before; what changes is the window AROUND its letterbox, which keeps
|
||||
-- showing the map instead of going black. Both menus fill their own
|
||||
-- 160x144 field first, so nothing beneath them shows through it.
|
||||
function Game.drawBaseInStack(stack, visibleBase)
|
||||
local states = stack and stack.states or {}
|
||||
for i = visibleBase - 1, 1, -1 do
|
||||
local state = states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
-- restart the search from under the battle: the highest opaque state at
|
||||
-- or below it (the overworld), not the menu sitting over it
|
||||
for j = i, 1, -1 do
|
||||
if states[j].isOpaque then return j end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
end
|
||||
return visibleBase
|
||||
end
|
||||
|
||||
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||
-- sprite and status zones move with the classic UI content.
|
||||
@@ -304,6 +388,10 @@ function Game:draw()
|
||||
-- white clear
|
||||
local base = self.stack:visibleBase()
|
||||
local worldBelow = self.stack.states[base] == self.overworld
|
||||
-- a world-bg battle keeps the map drawing under whatever it opened, so the
|
||||
-- world pass can run for a frame whose CLEAR is still an opaque menu's
|
||||
local drawFrom = Game.drawBaseInStack(self.stack, base)
|
||||
local worldDrawn = self.stack.states[drawFrom] == self.overworld
|
||||
-- A wide battle holds its 304px surface through every menu or prompt it
|
||||
-- opens. States that do not draw the wide battle composition are centred
|
||||
-- in that surface below, so their classic coordinates and hit testing stay
|
||||
@@ -320,8 +408,33 @@ function Game:draw()
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
-- BATTLE SIZE: scale the battle surface to the window instead of the
|
||||
-- classic integer letterbox. Read from the whole stack, not just the top,
|
||||
-- so a party menu or text box opened mid-battle keeps the same surface.
|
||||
Renderer.uiFill = Game.fillScaleInStack(self.stack)
|
||||
-- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
|
||||
-- the stack for the same reason as uiFill above -- a prompt opened during
|
||||
-- the battle must not drop the dim for a frame.
|
||||
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
||||
-- ...and for the same reason the UI's own scale has to know the world is
|
||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||
-- gated on this frame's world pass -- which the party menu and the bag end
|
||||
-- by being opaque. Without this hold they lose the step-down and blit at
|
||||
-- full fit scale over a battle drawn at the zoomed-out one.
|
||||
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||
-- instead of letting them dock to the window edge.
|
||||
-- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element
|
||||
-- stays inside the 160x144 canvas and the UI does not follow the survey
|
||||
-- zoom, so the screen furniture never moves or resizes under the player.
|
||||
-- That is the composition the port shipped with. DYNAMIC opts into both
|
||||
-- halves: the dialogue box docks to the window's bottom edge, the START
|
||||
-- menu to its top right, and the whole UI steps down with the zoom.
|
||||
Renderer.uiCentered = not Game.dynamicUI(self.save)
|
||||
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
|
||||
Renderer:beginFrame(worldBelow)
|
||||
for i = self.stack:visibleBase(), #self.stack.states do
|
||||
for i = drawFrom, #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
@@ -358,7 +471,13 @@ function Game:draw()
|
||||
if ModRuntime.wantsHook("render.zones") then
|
||||
zones = ModRuntime.call("render.zones", sameZones, self, zones)
|
||||
end
|
||||
if worldBelow and self.overworld.sgbWorldZones then
|
||||
-- Keyed to whether the map actually DREW, not to whether it is the clear's
|
||||
-- base: an opaque menu over a world-bg battle still renders the world pass
|
||||
-- (drawBaseInStack), and leaving worldZones nil there drops endFrame's
|
||||
-- world blit onto the UI zone list instead -- the party menu's own HP-bar
|
||||
-- palettes, in 160x144 space, smeared across a world-canvas-sized image.
|
||||
-- That is the offset, red-for-green map behind the menu.
|
||||
if worldDrawn and self.overworld.sgbWorldZones then
|
||||
worldZones = self.overworld:sgbWorldZones()
|
||||
end
|
||||
local viewport = Renderer:endFrame(zones, worldZones)
|
||||
@@ -391,6 +510,24 @@ function Game:wheelmoved(_, dy)
|
||||
end
|
||||
end
|
||||
|
||||
function Game:_cycleSpeed(dir)
|
||||
if not (self.save and self.save.options) then return end
|
||||
local busy
|
||||
local ow = self.overworld
|
||||
if ow then
|
||||
local top = self.stack:top()
|
||||
busy = ow.transitioning
|
||||
or (top == ow and (
|
||||
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
|
||||
or (ow.scriptMoves and #ow.scriptMoves > 0)
|
||||
or ow.engaging or ow.emote))
|
||||
end
|
||||
if busy then return end
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
|
||||
self:writeOptions()
|
||||
end
|
||||
|
||||
function Game:keypressed(key)
|
||||
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
|
||||
self.stack:top():onKeyPressed(key)
|
||||
@@ -428,6 +565,11 @@ function Game:keypressed(key)
|
||||
elseif key == "=" then
|
||||
self:zoomStep(1)
|
||||
return
|
||||
elseif key == "1" then
|
||||
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
|
||||
-- R2/L2 on gamepad do the same (see gamepadpressed)
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif key == "2" then
|
||||
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
|
||||
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
|
||||
@@ -508,6 +650,27 @@ 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()
|
||||
-- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
|
||||
-- is a display chord on NX / VoxelMod) and for the chord path below.
|
||||
local selectHeld = Input:isDown("select")
|
||||
if not selectHeld and joystick and joystick.isGamepadDown then
|
||||
local ok, down = pcall(function()
|
||||
return joystick:isGamepadDown("back")
|
||||
end)
|
||||
selectHeld = ok and down == true
|
||||
end
|
||||
-- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster,
|
||||
-- L2/leftshoulder = slower; same as keyboard hotkey 1). Skip while
|
||||
-- Select is held so Select+L can reach displayChordDigit ("7").
|
||||
if not selectHeld then
|
||||
if button == "rightshoulder" then
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif button == "leftshoulder" then
|
||||
self:_cycleSpeed(-1)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- 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
|
||||
@@ -517,13 +680,6 @@ function Game:gamepadpressed(joystick, button)
|
||||
-- Select+face display chords → same digit path as Game:keypressed
|
||||
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
|
||||
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
|
||||
local selectHeld = Input:isDown("select")
|
||||
if not selectHeld and joystick and joystick.isGamepadDown then
|
||||
local ok, down = pcall(function()
|
||||
return joystick:isGamepadDown("back")
|
||||
end)
|
||||
selectHeld = ok and down == true
|
||||
end
|
||||
if selectHeld then
|
||||
local digit = GamepadMap.displayChordDigit(button)
|
||||
if digit then
|
||||
@@ -558,15 +714,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)
|
||||
@@ -712,6 +891,9 @@ function Game:applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
||||
-- it has to be the last word on the window (it drops fullscreen to hold)
|
||||
require("src.core.FaithfulRes").applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
|
||||
@@ -18,7 +18,7 @@ local GameSpeed = {}
|
||||
-- attempt is long enough that the iteration loop, not the engine, is the
|
||||
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
|
||||
-- multiplier is increasingly a ceiling rather than a rate.
|
||||
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
|
||||
GameSpeed.LEVELS = { 1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200 }
|
||||
GameSpeed.DEFAULT = 1
|
||||
|
||||
function GameSpeed.levelLabel(v)
|
||||
|
||||
@@ -20,6 +20,11 @@ GamepadMap.NX_GAMEPAD_BINDINGS = {
|
||||
}
|
||||
|
||||
-- Generic SDL joysticks without a game-controller DB entry (Linux handhelds).
|
||||
-- Desktop XInput order only: raw numbering is per-driver, and SDL's iOS/MFi
|
||||
-- driver packs only the buttons a pad reports, which slides the D-pad onto
|
||||
-- 7..10 (#620). These are defaults; Input:applyBindings layers "joyN" pad
|
||||
-- rebinds over them (#632). Only sticks SDL does NOT recognize as gamepads
|
||||
-- are served from this table (see GamepadMap.ignoreRawForJoystick).
|
||||
GamepadMap.RAW_BUTTON_BINDINGS = {
|
||||
[1] = "a", [2] = "b",
|
||||
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
|
||||
@@ -63,6 +68,12 @@ function GamepadMap.gamepadBindings()
|
||||
return GamepadMap.DEFAULT_GAMEPAD_BINDINGS
|
||||
end
|
||||
|
||||
-- Whole raw-index table for Input:applyBindings joyBindings seeding (#632).
|
||||
function GamepadMap.rawBindings()
|
||||
if nxActive() then return GamepadMap.NX_RAW_BUTTON_BINDINGS end
|
||||
return GamepadMap.RAW_BUTTON_BINDINGS
|
||||
end
|
||||
|
||||
function GamepadMap.mapGamepadButton(button)
|
||||
return GamepadMap.gamepadBindings()[button]
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
+43
-3
@@ -30,6 +30,9 @@ local DEFAULT_BINDINGS = {
|
||||
local STICK_ON = 0.5
|
||||
local STICK_OFF = 0.3
|
||||
|
||||
-- Raw joystick defaults + NX overrides live in src/core/GamepadMap.lua
|
||||
-- (see RAW_BUTTON_BINDINGS / NX_RAW_BUTTON_BINDINGS and #620 / #632).
|
||||
|
||||
local HAT_DIRECTIONS = {
|
||||
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
|
||||
lu = { "left", "up" }, ru = { "right", "up" },
|
||||
@@ -49,11 +52,17 @@ 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(GamepadMap.gamepadBindings()) do
|
||||
pads[button] = action
|
||||
end
|
||||
-- Seed raw defaults from GamepadMap (desktop XInput order or NX OLED
|
||||
-- indices) so joyN rebinds (#632) and dual-path guards (#620) share one
|
||||
-- table with the Switch face-label remap.
|
||||
for index, action in pairs(GamepadMap.rawBindings()) 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
|
||||
@@ -62,8 +71,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
|
||||
@@ -179,15 +200,28 @@ 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.
|
||||
-- Gate: GamepadMap.ignoreRawForJoystick (pcall-safe isGamepad check).
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
local btn = GamepadMap.mapRawButton(button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
local btn = GamepadMap.mapRawButton(button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
@@ -227,6 +261,7 @@ function Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Input:joystickaxis(joystick, axis, value)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
@@ -234,7 +269,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 GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
local source = "hat:" .. hat
|
||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||
release(self, btn, source)
|
||||
|
||||
+35
-2
@@ -219,6 +219,24 @@ function SaveData.defaultOptions()
|
||||
-- battle screen composition: og (the 160x144 original) | wide
|
||||
-- (304x144, src/battle/WideBattle.lua)
|
||||
battleLayout = "og",
|
||||
-- BATTLE SIZE: "fixed" = the classic integer-scaled letterbox; "fill" =
|
||||
-- scale the battle surface to the window so it fills vertically. See
|
||||
-- BattleState:wantsFillScale.
|
||||
battleFit = "fixed",
|
||||
-- BATTLE BG: what fills the screen behind and around the battle.
|
||||
-- "white" = the display mode's paper shade (the classic look),
|
||||
-- "black" = plain black bars, "world" = the frozen overworld showing
|
||||
-- through, dimmed. See BattleState:bgMode.
|
||||
battleBg = "white",
|
||||
-- UI LAYOUT: "centered" = a fixed letterbox. Every element sits where it
|
||||
-- was drawn in the 160x144 canvas and the UI does not follow the survey
|
||||
-- zoom, so nothing moves or resizes under the player. The original
|
||||
-- composition. "dynamic" = the dialogue box docks to the window's bottom
|
||||
-- edge, the START menu to its top right, and the UI steps down with the
|
||||
-- zoom. Centered is the default: dynamic reads better zoomed out, but it
|
||||
-- moves the screen furniture, so it is opt-in.
|
||||
-- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale.
|
||||
uiLayout = "centered",
|
||||
ruleset = "gen1_faithful",
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
@@ -239,6 +257,9 @@ function SaveData.defaultOptions()
|
||||
voidFill = "trees",
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
||||
faithfulRes = 0,
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
@@ -254,6 +275,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 = {},
|
||||
@@ -269,8 +299,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
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
-- Hardware frame budgets, in fixed 60Hz logic steps.
|
||||
--
|
||||
-- The original spends a large share of its running time inside DelayFrames
|
||||
-- calls that produce no visible change -- the pause after a page break, the
|
||||
-- beat before a status move resolves, the one-HP-at-a-time drain of an HP
|
||||
-- bar. Porting the visible half of a sequence and dropping the wait is what
|
||||
-- makes a port read as snappier than hardware, so every one of those waits
|
||||
-- lives here with its asm citation instead of as a file-local constant.
|
||||
--
|
||||
-- See docs/timing-parity.md for the full catalog and the measurement method;
|
||||
-- tools/scan_pokered_delays.ps1 regenerates the hardware side from a
|
||||
-- disassembly checkout.
|
||||
|
||||
local Timing = {}
|
||||
|
||||
-- home/palettes.asm:14 -- three frames to let the bg map fully update
|
||||
Timing.DELAY3 = 3
|
||||
|
||||
-- home/fade.asm: each fade is a loop of `ld c, 8 / call DelayFrames`
|
||||
Timing.FADE_IN_FROM_BLACK = 32 -- fade.asm:21, b = 4
|
||||
Timing.FADE_OUT_TO_BLACK = 32 -- fade.asm:43, b = 4
|
||||
Timing.FADE_OUT_TO_WHITE = 24 -- fade.asm:26, b = 3
|
||||
Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3
|
||||
|
||||
-- Overworld -----------------------------------------------------------------
|
||||
|
||||
-- home/overworld.asm:703 PlayMapChangeSound tail-calls GBFadeOutToBlack on
|
||||
-- every map change. There is no matching fade in: the new map is drawn while
|
||||
-- the palettes are still blacked out and LoadGBPal restores them in one write,
|
||||
-- so the map appears instantly.
|
||||
Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK
|
||||
Timing.WARP_FADE_IN = 0
|
||||
|
||||
-- home/overworld.asm:351-352 -- after a battle, before EnterMap
|
||||
Timing.POST_BATTLE_RETURN = 10
|
||||
|
||||
-- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly /
|
||||
-- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite
|
||||
Timing.SPECIAL_WARP_ENTRY = Timing.DELAY3 + Timing.FADE_IN_FROM_WHITE
|
||||
-- player_animations.asm:43 -- dungeon warp holds before handing back control
|
||||
Timing.DUNGEON_WARP_ARRIVAL = 50
|
||||
|
||||
-- Text ----------------------------------------------------------------------
|
||||
|
||||
-- home/text.asm:283-307 ScrollTextUpOneLine is `ld b, 5` of DelayFrame, and
|
||||
-- its own comment notes it is "always called twice in a row"
|
||||
Timing.TEXT_SCROLL_LINE = 5
|
||||
Timing.TEXT_SCROLL_PAIR = Timing.TEXT_SCROLL_LINE * 2
|
||||
|
||||
-- Both _ContText (home/text.asm:262-277) and Paragraph (:230-243) print the
|
||||
-- â–¼ and call ProtectedDelay3 *before* ManualTextScroll starts watching the
|
||||
-- joypad, so three frames pass with the arrow up and the button ignored.
|
||||
Timing.TEXT_PRE_ADVANCE = Timing.DELAY3
|
||||
|
||||
-- Paragraph / PageChar clear the box and then hold (home/text.asm:239-240,
|
||||
-- :254-255) before the next page starts typing.
|
||||
Timing.TEXT_PAGE_CLEAR = 20
|
||||
|
||||
-- Totals, for the catalog and the parity tests.
|
||||
Timing.TEXT_CONT = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_SCROLL_PAIR
|
||||
Timing.TEXT_PARAGRAPH = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_PAGE_CLEAR
|
||||
Timing.TEXT_PAGE = Timing.TEXT_PARAGRAPH
|
||||
|
||||
Timing.TEXT_PAUSE = 30 -- home/text.asm:500 TextCommand_PAUSE
|
||||
Timing.TEXT_DOT = 10 -- home/text.asm:576 TextCommand_DOTS, per dot
|
||||
|
||||
-- Menus ---------------------------------------------------------------------
|
||||
|
||||
-- engine/menus/text_box.asm:322-323 / :333-334 -- both branches of a
|
||||
-- two-option (yes/no) menu hold before restoring the screen tiles
|
||||
Timing.YES_NO_ANSWER = 15
|
||||
|
||||
Timing.LIST_MENU_OPEN = 10 -- home/list_menu.asm:55-56
|
||||
Timing.LIST_MENU_REDRAW = Timing.DELAY3 -- home/list_menu.asm:64
|
||||
|
||||
-- engine/menus/start_sub_menus.asm:224-225
|
||||
Timing.FIELD_TELEPORT = 60 + Timing.DELAY3
|
||||
|
||||
-- Battle --------------------------------------------------------------------
|
||||
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:9-49):
|
||||
-- the enemy comes in on BG SCX $90 -> $00 and the player's back pic on
|
||||
-- decrementing OAM x, both 2 px per frame -- so 144 px over 72 frames. The
|
||||
-- port ran 160 px at 4 px/frame (40 frames), a little under twice too fast.
|
||||
Timing.BATTLE_SLIDE_IN_FRAMES = 72
|
||||
Timing.BATTLE_SLIDE_PX_PER_FRAME = 2
|
||||
|
||||
-- PrintBeginningBattleText .trainerBattle (engine/battle/common_text.asm):
|
||||
-- SFX_SILPH_SCOPE plays into a clear window (PlaySound then
|
||||
-- WaitForSoundToFinish, which blocks), and only after `ld c, 20 /
|
||||
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
|
||||
Timing.TRAINER_INTRO_SFX_GAP = 20
|
||||
|
||||
Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
|
||||
Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
|
||||
|
||||
-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
|
||||
-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
|
||||
-- beat is paid on a large fraction of all turns.
|
||||
Timing.MOVE_STATUS_OR_MISS = 30
|
||||
|
||||
-- PlayApplyingAttackAnimation's six types (AnimationTypePointerTable,
|
||||
-- engine/battle/animations.asm:490-524). The two shake families are
|
||||
-- `AnimationShakeScreenHorizontallySlow`, whose double push/pop makes each
|
||||
-- outer pass cost 4b frames and run c times -- so c * 4b.
|
||||
Timing.SHAKE_VERTICAL = 48 -- type 1, b=8: 8 x 6
|
||||
Timing.SHAKE_HORIZ_HEAVY = 72 -- type 2, b=8: 8 x 9
|
||||
Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6
|
||||
Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9
|
||||
Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3
|
||||
|
||||
-- Type 4 -- the player's damaging move with no added effect, and so the
|
||||
-- single most common animation in the game -- is AnimationBlinkMon
|
||||
-- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5
|
||||
-- + show + DelayFrames 5. The asm's own comment calls it "a second or
|
||||
-- two"; the port ran it in 20 frames, three times too fast, which is a
|
||||
-- large part of why trading blows felt hurried.
|
||||
Timing.BLINK_MON = 60
|
||||
|
||||
-- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT
|
||||
-- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`.
|
||||
-- This one the port ran SLOWER than hardware, at 30.
|
||||
Timing.FAINT_SLIDE = 14
|
||||
|
||||
Timing.RESIDUAL_TICK = 20 -- core.asm:529-530 poison/burn/leech seed
|
||||
Timing.CRIT_OHKO_TEXT = 20 -- core.asm:3813-3814
|
||||
Timing.SWITCH_PLAYER_MON = 50 -- core.asm:2421-2422
|
||||
Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
|
||||
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
|
||||
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
|
||||
Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
|
||||
Timing.FAINT_SLIDE_STEP = 8 / Timing.FAINT_SLIDE_ROW -- 4px per frame at 1x
|
||||
Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
|
||||
|
||||
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
|
||||
--
|
||||
-- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each
|
||||
-- iteration pays:
|
||||
-- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only
|
||||
-- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and
|
||||
-- the party menu, never the enemy HUD; and
|
||||
-- * 2 frames per pixel the bar actually moved, from
|
||||
-- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148).
|
||||
-- The drain closes with one more pixel step and a Delay3 (:133-135).
|
||||
--
|
||||
-- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames,
|
||||
-- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon
|
||||
-- losing everything takes 150 + 96 + 6 = 252 frames on hardware.
|
||||
|
||||
Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
|
||||
Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement
|
||||
Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only
|
||||
|
||||
-- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the
|
||||
-- 48ths and clamps the result to at least 1 for any nonzero HP
|
||||
-- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0.
|
||||
function Timing.hpBarPixels(hp, maxHP)
|
||||
if not maxHP or maxHP <= 0 then return 0 end
|
||||
if hp <= 0 then return 0 end
|
||||
local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
|
||||
if px < 1 then px = 1 end
|
||||
return px
|
||||
end
|
||||
|
||||
-- Frames one single-HP step of the drain costs: the per-HP number print
|
||||
-- (player side only) plus two frames for every pixel that step moved.
|
||||
function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide)
|
||||
local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP)
|
||||
- Timing.hpBarPixels(fromHP, maxHP))
|
||||
local frames = pixels * Timing.HP_BAR_PIXEL_STEP
|
||||
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- After the loop, .animateHPBarDone prints the number one last time, runs
|
||||
-- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135)
|
||||
-- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's.
|
||||
function Timing.hpDrainClosingFrames(playerSide)
|
||||
local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
|
||||
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- Total cost of draining `fromHP` to `toHP`, for tests and for anything that
|
||||
-- needs to budget the whole animation up front.
|
||||
function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
|
||||
local total = 0
|
||||
local hp = fromHP
|
||||
local dir = (toHP < fromHP) and -1 or 1
|
||||
while hp ~= toHP do
|
||||
local nextHP = hp + dir
|
||||
total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide)
|
||||
hp = nextHP
|
||||
end
|
||||
return total + Timing.hpDrainClosingFrames(playerSide)
|
||||
end
|
||||
|
||||
return Timing
|
||||
+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