Merge pull request #1378 from syybott/experiment/fixed-extended-world-alignment

This commit is contained in:
bryanthaboi
2026-08-16 22:04:28 -04:00
committed by GitHub
16 changed files with 726 additions and 33 deletions
+27
View File
@@ -88,6 +88,33 @@ function BattleState:wantsFillScale()
return options and options.battleFit == "fill" or false
end
-- EXTENDED HUD configurations are admitted one at a time after their own
-- placement and screenshot review. FIXED supports the three authored battle
-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock
-- battles retain the paper field required by Gen 1 back sprites, while arena
-- providers may replace it with their own scene. Only the HUD moves to window
-- space.
function BattleState:extendedHUD()
local options = self.game and self.game.save and self.game.save.options
local bg = options and options.battleBg
return self:wideLayout()
and options and options.battleHud == "extended"
and ((options.battleFit == "fixed"
and (bg == "world" or bg == "white" or bg == "black"))
or (options.battleFit == "fill" and bg == "white"))
end
function BattleState:extendedWorldHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options
and options.battleFit == "fixed" and options.battleBg == "world"
end
function BattleState:extendedBlackHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options and options.battleBg == "black"
end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
-- that grow as the window gets bigger or the view is zoomed out. The battle
-- screen itself is untouched: it keeps its white paper field in every mode.
+51 -8
View File
@@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y)
end
end
local function battleIsTopState(battle)
local stack = battle.game and battle.game.stack
return not (stack and stack.top) or stack:top() == battle
end
local function anchorHUD(battle, x, y, w, h, anchor)
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
local renderer = battle.game and battle.game.renderer
if not (renderer and renderer.setBattleUIAnchor) then return end
x = x + (battle.extendedHUDOffsetX or 0)
y = y + (battle.extendedHUDOffsetY or 0)
local x2 = math.min(WideBattle.WIDTH, x + w)
local y2 = math.min(WideBattle.HEIGHT, y + h)
x, y = math.max(0, x), math.max(0, y)
w, h = x2 - x, y2 - y
if w > 0 and h > 0 then
renderer:setBattleUIAnchor(x, y, w, h, anchor)
end
end
-- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original).
@@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top")
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
@@ -144,7 +165,6 @@ local function drawHUDs(battle, slide)
and not battle.showPlayerBack and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
end
local function drawMessageBox(battle)
@@ -268,6 +288,8 @@ local function drawTextArea(battle)
else
Font.drawBox(0, 13, 38, 5)
end
anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM,
WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom")
end
-- Battle animations are authored in the original 160px coordinate space.
@@ -309,17 +331,23 @@ end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
local renderer = battle.game and battle.game.renderer
local extendedHUD = battle:extendedHUD() and renderer
and renderer.beginBattleHUDPass
and renderer.endBattleHUDPass
-- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it.
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
if not (extendedHUD and battle:extendedWorldHUD()) then
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
@@ -346,6 +374,7 @@ function WideBattle.draw(battle)
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawIntroBalls(battle)
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
@@ -359,12 +388,26 @@ function WideBattle.draw(battle)
if sx == 0 and sy == 0 then return fn() end
g.push()
g.translate(sx, sy)
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy
fn()
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil
g.pop()
end
shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle)
shaken(function() drawTextArea(battle) end)
if extendedHUD then
local previous = renderer:beginBattleHUDPass()
shaken(function() drawHUDs(battle, slide) end)
shaken(function() drawTextArea(battle) end)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
renderer:endBattleHUDPass(previous)
else
shaken(function() drawHUDs(battle, slide) end)
shaken(function() drawTextArea(battle) end)
end
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
+44 -10
View File
@@ -306,12 +306,32 @@ 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
-- The extended fixed HUD intentionally exposes the live world across
-- the whole physical window. Keep this as a world-backed battle (zero
-- is non-nil, so scaling and overlay holds remain active), but do not
-- paint the standard dim veil around the native battle rectangle.
if state.extendedWorldHUD and state:extendedWorldHUD() then
return 0
end
return state.BG_WORLD_DIM or 0.55
end
end
return nil
end
-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer
-- uses this separately from battleDim: the world remains the surround, while
-- the native-width battle field receives a paper backing from top to bottom.
function Game.extendedWorldHUDInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.extendedWorldHUD and state:extendedWorldHUD() then
return true
end
end
return false
end
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
@@ -404,13 +424,13 @@ function Game.uiAnchorsHeldInStack(stack)
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.
-- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE
-- battle must not prevent that battle from drawing. Native WIDE battles own
-- the 304x144 surround around a centred classic menu, while external arena
-- providers establish their window-sized scene from BattleState:draw. If the
-- menu becomes the draw base, neither owner runs and the menu's white field
-- replaces the whole presentation. BATTLE BG "world" additionally needs the
-- overworld below the battle, as before.
--
-- 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
@@ -421,9 +441,13 @@ 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
local worldBattle = state and state.bgMode and state:bgMode() == "world"
local wideBattle = state and state.isWideBattleLayout
and state:isWideBattleLayout()
if worldBattle or wideBattle then
-- restart the search from under the battle: the highest opaque state at
-- or below it (the overworld), not the menu sitting over it
-- or below it (the battle itself for white/black WIDE, the overworld for
-- a non-opaque world-backed battle), not the menu sitting over it
for j = i, 1, -1 do
if states[j].isOpaque then return j end
end
@@ -433,6 +457,14 @@ function Game.drawBaseInStack(stack, visibleBase)
return visibleBase
end
-- A classic overlay above the approved world-backed extended HUD paints only
-- its centred area. Keep the wider owner surface transparent so its margins
-- continue to reveal the world instead of becoming an opaque white sheet.
function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle)
return worldBelow or (worldDrawn and wideBattle ~= nil
and wideBattle.extendedHUD and wideBattle:extendedHUD())
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.
@@ -487,6 +519,7 @@ function Game:draw()
-- 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)
Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(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,
@@ -504,7 +537,8 @@ function Game:draw()
-- 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)
Renderer:beginFrame(Game.uiCanvasTransparent(
worldBelow, worldDrawn, wideBattle))
for i = drawFrom, #self.stack.states do
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
+5
View File
@@ -239,6 +239,11 @@ function SaveData.defaultOptions()
-- scale the battle surface to the window so it fills vertically. See
-- BattleState:wantsFillScale.
battleFit = "fixed",
-- BATTLE HUD: STANDARD keeps every wide-battle element inside the native
-- 304x144 surface. EXTENDED is opt-in window-space placement for selected
-- wide layouts; unsupported combinations deliberately fall back to the
-- standard composition.
battleHud = "standard",
-- 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
+64 -7
View File
@@ -138,15 +138,72 @@ local function coreRows(opts, hooks)
ladder(opts, "battleStyle",
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
add(Strings("BATTLE LAYOUT"),
ladder(opts, "battleLayout",
{ { "og", "OG" }, { "wide", "WIDE" } }, "og"))
function()
return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
end,
function()
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE SIZE"),
ladder(opts, "battleFit",
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
function()
return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
end,
function()
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
if opts.battleFit == "fill" and opts.battleLayout == "wide"
and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE HUD"),
function()
return opts.battleLayout == "wide" and opts.battleHud == "extended"
and Strings("EXTENDED")
or Strings("STANDARD")
end,
function()
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
return false
end
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
if opts.battleHud == "extended" and opts.battleFit == "fill" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE BG"),
ladder(opts, "battleBg",
{ { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } },
"white"))
function()
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return Strings("AUTO")
end
if opts.battleBg == "black" then return Strings("BLACK") end
if opts.battleBg == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
function(dir)
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return false
end
local order = { "white", "black", "world" }
local cur = 1
for i, mode in ipairs(order) do
if opts.battleBg == mode then cur = i break end
end
opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
return true
end)
add(Strings("UI LAYOUT"),
ladder(opts, "uiLayout",
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
+92 -6
View File
@@ -93,6 +93,7 @@ function Renderer:init()
-- reason -- worldViewSize() already works in drawable pixels.
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.battleHUDCanvas = nil
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -201,10 +202,38 @@ function Renderer:setUISize(w, h)
w, h = math.floor(w), math.floor(h)
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
if self.canvas and self.canvas.release then self.canvas:release() end
if self.battleHUDCanvas and self.battleHUDCanvas.release then
self.battleHUDCanvas:release()
end
self.battleHUDCanvas = nil
self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest")
end
-- Transparent native-pixel surface for an extended WIDE battle HUD. The
-- battle scene remains in `canvas`; endFrame places registered HUD regions
-- afterward in physical-window space.
function Renderer:beginBattleHUDPass()
local w, h = self:uiSize()
if not self.battleHUDCanvas
or self.battleHUDCanvas:getWidth() ~= w
or self.battleHUDCanvas:getHeight() ~= h then
if self.battleHUDCanvas and self.battleHUDCanvas.release then
self.battleHUDCanvas:release()
end
self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest")
end
local previous = love.graphics.getCanvas and love.graphics.getCanvas()
or self.canvas
love.graphics.setCanvas(self.battleHUDCanvas)
love.graphics.clear(0, 0, 0, 0)
return previous
end
function Renderer:endBattleHUDPass(previous)
love.graphics.setCanvas(previous or self.canvas)
end
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
@@ -670,6 +699,17 @@ end
-- centred letterbox. Declared during the element's own draw, in UI-canvas
-- pixels, and consumed by endFrame this frame only.
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped,
canvas, extract)
renderer.uiAnchors = renderer.uiAnchors or {}
renderer.uiAnchors[#renderer.uiAnchors + 1] = {
x = x, y = y, w = w, h = h, anchor = anchor,
windowClamped = windowClamped and true or false,
canvas = canvas,
extract = extract ~= false,
}
end
function Renderer:setUIAnchor(x, y, w, h, anchor)
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
-- save.options.uiLayout): every element stays where it was drawn in the
@@ -683,9 +723,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor)
-- battle -- keeps every element inside it, so the box blits where it was
-- drawn in the canvas instead of being pulled to the window edge.
if self.uiAnchorHold then return end
self.uiAnchors = self.uiAnchors or {}
self.uiAnchors[#self.uiAnchors + 1] =
{ x = x, y = y, w = w, h = h, anchor = anchor }
addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true)
end
-- Battle-owned window-space placement. Unlike ordinary UI anchors this is
-- intentionally allowed while BattleState holds general dialogue/menu
-- anchors inside the battle surface. Callers must gate it to an explicit
-- battle HUD mode.
function Renderer:setBattleUIAnchor(x, y, w, h, anchor)
addUIAnchor(self, x, y, w, h, anchor, true,
self.battleHUDCanvas or self.canvas, false)
end
-- zones: optional list of SGB palette regions (see PaletteFX) in
@@ -798,6 +845,8 @@ function Renderer:endFrame(zones, worldZones)
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
-- a visibly brighter border.
local clearR, clearG, clearB = 0, 0, 0
local extendedBlackBand = false
local bandR, bandG, bandB = 1, 1, 1
if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game")
local stack = ok and Game and Game.stack
@@ -824,7 +873,15 @@ function Renderer:endFrame(zones, worldZones)
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
-- painted the whole phone white on New Game and in battle (#864), so
-- the lock keeps the default black bars.
if state and state.letterboxWhite
if state and state.extendedBlackHUD and state:extendedBlackHUD()
and not FaithfulRes.scaleCap() then
-- Extended/Black keeps the author's black surround, but extends the
-- fixed battle's paper field vertically through the physical window.
-- The band uses the exact centred fixed-width composition bounds, so
-- only vertical black bars remain at the sides.
extendedBlackBand = true
bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data)
elseif state and state.letterboxWhite
and not (state.bgMode and state:bgMode() == "black")
and not FaithfulRes.scaleCap() then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
@@ -832,6 +889,10 @@ function Renderer:endFrame(zones, worldZones)
end
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
if extendedBlackBand then
love.graphics.setColor(bandR, bandG, bandB, 1)
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
end
love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
@@ -974,6 +1035,22 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1)
end
-- Extended/WORLD keeps the frozen world as the physical surround, but stock
-- Gen 1 back sprites rely on the battle's paper shade for visible highlights.
-- Back the exact fixed-width composition from physical top to bottom so only
-- the left and right sides expose the world. The battle canvas and detached
-- HUD remain transparent layers composited afterward.
-- A worldOverride is an arena provider's completed scene (for example,
-- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle
-- field, so never cover it with the native back-sprite fallback.
if self.extendedWorldBand and not self.worldOverride
and not FaithfulRes.scaleCap() then
local ok, Game = pcall(require, "src.core.Game")
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
love.graphics.setColor(1, 1, 1, 1)
end
-- UI: anchored regions against their screen edges, the rest in the classic
-- centred letterbox. With nothing anchored this is the single blit it has
-- always been.
@@ -996,14 +1073,23 @@ function Renderer:endFrame(zones, worldZones)
if a.anchor == "bottom" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = wh - gapB - dh
elseif a.anchor == "top" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = a.y * Uy
elseif a.anchor == "topright" then
dx = ww - gapR - dw
dy = a.y * Uy
else -- unknown anchor: leave it where it is
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
end
if a.windowClamped then
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
end
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
if a.extract then
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
end
end
for _, r in ipairs(rest) do
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
@@ -1012,7 +1098,7 @@ function Renderer:endFrame(zones, worldZones)
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
-- The zone scissors are computed from the same origin, so an SGB
-- region travels with the element instead of staying in the letterbox.
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end
end
+42 -1
View File
@@ -180,6 +180,11 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
elseif o.battleFit == "fill" and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
@@ -195,6 +200,31 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
if o.battleFit == "fill" and o.battleLayout == "wide"
and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
{ id = "battleHud", label = Strings("BATTLE HUD"),
value = function(g)
local o = g.save.options
return o.battleLayout == "wide" and o.battleHud == "extended"
and Strings("EXTENDED")
or Strings("STANDARD")
end,
step = function(g)
local o = g.save.options
-- The extended HUD is a widescreen-only composition. Keep OG locked
-- to the author's standard HUD even if an older save says otherwise.
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
return false
end
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
if o.battleHud == "extended" and o.battleFit == "fill" then
o.battleBg = "white"
end
return true
end },
-- What sits behind and around the battle. WHITE is the classic paper
@@ -203,13 +233,24 @@ local function buildRows(game)
-- shows through everywhere the battle does not paint).
{ id = "battleBg", label = Strings("BATTLE BG"),
value = function(g)
local m = g.save.options.battleBg
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return Strings("AUTO")
end
local m = o.battleBg
if m == "black" then return Strings("BLACK") end
if m == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
step = function(g, dir)
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return false
end
local order = { "white", "black", "world" }
local cur = 1
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
@@ -0,0 +1,36 @@
local U = require("tests.drivers.util")
local OUT = os.getenv("SHOT_DIR") or "battle-hud-layout-lock"
local BEFORE = OUT .. "/battle_hud_wide_extended.png"
local AFTER = OUT .. "/battle_hud_og_locked_standard.png"
return function(game)
os.remove(BEFORE)
os.remove(AFTER)
local options = game.save.options
options.battleLayout = "wide"
options.battleHud = "extended"
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
local layoutRow, hudRow
for _, row in ipairs(menu.rows) do
if row.id == "battleLayout" then layoutRow = row end
if row.id == "battleHud" then hudRow = row end
end
assert(layoutRow and hudRow, "battle layout/HUD rows are present")
menu.index = 6
menu.scroll = 3
assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")
layoutRow.step(game, 1)
assert(options.battleLayout == "og", "layout switched to OG")
assert(options.battleHud == "standard", "OG normalized HUD to STANDARD")
assert(hudRow.value(game) == "STANDARD", "OG displays STANDARD")
assert(U.shot(game, AFTER), "OG/STANDARD screenshot was written")
print("[driver] BATTLE_HUD_LAYOUT_LOCK_PASS")
game.driverDone = true
end
@@ -0,0 +1,53 @@
-- Visual and behavioral acceptance for the adaptive BATTLE BG menu rule.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
love.window.setMode(1920, 1080, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "black"
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
local fitRow, bgRow
local bgIndex
for i, row in ipairs(menu.rows) do
if row.id == "battleFit" then fitRow = row end
if row.id == "battleBg" then bgRow, bgIndex = row, i end
end
assert(fitRow and bgRow and bgIndex, "battle size/background rows are present")
fitRow.step(game, 1)
assert(options.battleFit == "fill", "battle size switched to FILL")
assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE")
assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO")
assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
assert(options.battleBg == "white", "locked AUTO retains the WHITE value")
menu.index = bgIndex
menu.scroll = math.max(0, bgIndex - 5)
U.wait(2)
local autoPath = DIR .. "/fill_extended_auto_menu.png"
os.remove(autoPath)
local ok = U.shot(game, autoPath)
fitRow.step(game, -1)
assert(options.battleFit == "fixed", "battle size switched back to FIXED")
assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice")
assert(bgRow.step(game, 1) == true and options.battleBg == "black",
"FIXED can select BLACK")
assert(bgRow.step(game, 1) == true and options.battleBg == "world",
"FIXED can select WORLD")
U.wait(2)
local fixedPath = DIR .. "/fixed_extended_background_choices.png"
os.remove(fixedPath)
ok = U.shot(game, fixedPath) and ok
U.log(ok and "FILL_EXTENDED_AUTO_MENU_PASS"
or "FILL_EXTENDED_AUTO_MENU_FAIL")
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,64 @@
-- Visual acceptance driver for WIDE + FILL + EXTENDED + WHITE.
-- The full physical-window backing remains white while the four battle HUD
-- panels move to their approved window anchors.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fill"
options.battleHud = "extended"
options.battleBg = "white"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
assert(battle:extendedHUD(), "FILL/WHITE activates the approved extended HUD")
assert(not battle:extendedWorldHUD(), "FILL/WHITE does not use FIXED's paper band")
local path = DIR .. "/fill_extended_white_separate_layer.png"
os.remove(path)
local ok = U.shot(game, path)
love.window.setMode(960, 540, { resizable = true })
U.wait(5)
local smallPath = DIR .. "/fill_extended_white_small_16x9.png"
os.remove(smallPath)
ok = U.shot(game, smallPath) and ok
love.window.setMode(2048, 1152, { resizable = true })
U.wait(5)
local NamingScreen = require("src.ui.NamingScreen")
game.stack:push(NamingScreen.new(game, {
title = "NICKNAME?", maxLen = 10, onDone = function() end,
}))
U.wait(5)
local overlayPath = DIR .. "/fill_extended_white_naming_overlay.png"
os.remove(overlayPath)
ok = U.shot(game, overlayPath) and ok
U.log(ok and "FILL_EXTENDED_WHITE_PASS" or "FILL_EXTENDED_WHITE_FAIL")
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,64 @@
local U = require("tests.drivers.util")
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-black"
local FULL = OUT .. "/fixed_extended_black_full.png"
local SMALL = OUT .. "/fixed_extended_black_small_16x9.png"
local OVERLAY = OUT .. "/fixed_extended_black_overlay.png"
return function(game)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
os.remove(FULL)
os.remove(SMALL)
os.remove(OVERLAY)
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "black"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
assert(battle:extendedHUD(), "BLACK activates the approved extended HUD")
assert(battle:extendedBlackHUD(), "BLACK activates the white vertical battle band")
assert(not battle:extendedWorldHUD(), "BLACK remains separate from WORLD")
assert(U.shot(game, FULL), "full black-background screenshot was written")
love.window.setMode(960, 540, { resizable = true })
U.wait(10)
assert(U.shot(game, SMALL), "small black-background screenshot was written")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(10)
battle.blankForAskName = true
local naming = require("src.ui.NamingScreen").new(game, {
title = "NICKNAME?", maxLen = 10, onDone = function() end,
})
game.stack:push(naming)
U.wait(10)
assert(U.shot(game, OVERLAY), "black-background overlay screenshot was written")
print("[driver] FIXED_EXTENDED_BLACK_PASS")
love.event.quit(0)
end
@@ -0,0 +1,65 @@
local U = require("tests.drivers.util")
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-white"
local FULL = OUT .. "/fixed_extended_white_full.png"
local SMALL = OUT .. "/fixed_extended_white_small_16x9.png"
local OVERLAY = OUT .. "/fixed_extended_white_overlay.png"
return function(game)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
os.remove(FULL)
os.remove(SMALL)
os.remove(OVERLAY)
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "white"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
assert(battle:extendedHUD(), "WHITE activates the approved extended HUD")
assert(not battle:extendedWorldHUD(), "WHITE keeps its opaque paper field")
assert(U.shot(game, FULL), "full WHITE screenshot was written")
love.window.setMode(960, 540, { resizable = true })
U.wait(10)
assert(U.shot(game, SMALL), "small WHITE screenshot was written")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(10)
battle.blankForAskName = true
local naming = require("src.ui.NamingScreen").new(game, {
title = "NICKNAME?",
maxLen = 10,
initial = "",
onDone = function() end,
})
game.stack:push(naming)
U.wait(10)
assert(U.shot(game, OVERLAY), "WHITE overlay screenshot was written")
print("[driver] FIXED_EXTENDED_WHITE_PASS")
love.event.quit(0)
end
@@ -0,0 +1,44 @@
-- Visual regression coverage for Professor Oak's scripted Yellow capture Bag
-- over the WIDE + FIXED + EXTENDED + WORLD composition.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "world"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(30)
local demo = BattleState.newWild(game, "CHARMANDER", 5)
demo:makeOldManDemo("PROF.OAK")
demo.onFinish = function() end
game.overworld:pushBattle(demo)
for _ = 1, 100 do
if demo.phase == "menu" and (demo.demoTimer or 0) > 5 then break end
U.tap(game, "a")
U.wait(4)
end
for _ = 1, 180 do
if game.stack:top() ~= demo then break end
U.wait(1)
end
U.wait(3)
local path = DIR .. "/fixed_extended_world_oak_charmander_bag.png"
os.remove(path)
local ok = game.stack:top() ~= demo and U.shot(game, path)
U.log(ok and "FIXED_EXTENDED_WORLD_BAG_PASS"
or "FIXED_EXTENDED_WORLD_BAG_FAIL")
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,65 @@
-- Visual acceptance driver for the first EXTENDED HUD configuration only:
-- WIDE + FIXED + EXTENDED + WORLD.
-- POKEPORT_DRIVER=tests/drivers/fixed_extended_world_hud_test.lua \
-- POKEPORT_IDENTITY=fixed-extended-world POKEPORT_TOUCH=0 \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
-- Match the 16:9 acceptance screenshot so the fixed 304x144 surface has
-- measurable space above and below it.
love.window.setMode(2048, 1152, { resizable = true })
U.wait(3)
local options = game.save.options
options.battleLayout = "wide"
options.battleFit = "fixed"
options.battleHud = "extended"
options.battleBg = "world"
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3,
{ onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
local path = DIR .. "/fixed_extended_world_separate_layer.png"
os.remove(path)
local ok = U.shot(game, path)
love.window.setMode(960, 540, { resizable = true })
U.wait(5)
local smallPath = DIR .. "/fixed_extended_world_small_16x9.png"
os.remove(smallPath)
ok = U.shot(game, smallPath) and ok
love.window.setMode(2048, 1152, { resizable = true })
U.wait(5)
local NamingScreen = require("src.ui.NamingScreen")
game.stack:push(NamingScreen.new(game, {
title = "NICKNAME?", maxLen = 10, onDone = function() end,
}))
U.wait(5)
local overlayPath = DIR .. "/fixed_extended_world_naming_overlay.png"
os.remove(overlayPath)
ok = U.shot(game, overlayPath) and ok
U.log(ok and "FIXED_EXTENDED_WORLD_PASS" or "FIXED_EXTENDED_WORLD_FAIL")
love.event.quit(ok and 0 or 1)
end
+9 -1
View File
@@ -92,6 +92,11 @@ local menu = { isOpaque = true } -- PartyMenu / ListMenu
local whiteBattle = setmetatable(
{ game = { save = { options = { battleBg = "white" } } } },
{ __index = BattleState })
local wideWhiteBattle = setmetatable(
{ game = { save = { options = {
battleBg = "white", battleLayout = "wide",
} } } },
{ __index = BattleState })
local function stack(...) return { states = { ... },
visibleBase = function(self)
for i = #self.states, 1, -1 do
@@ -111,7 +116,10 @@ T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld")
T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does")
local s3 = stack(overworld, whiteBattle, menu)
T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3,
"a white-bg battle has no map to hold, so nothing moves")
"a classic white-bg battle has no presentation to hold, so nothing moves")
local s3wide = stack(overworld, wideWhiteBattle, menu)
T.eq(Game.drawBaseInStack(s3wide, s3wide:visibleBase()), 2,
"an opaque WIDE battle still draws beneath its classic menu")
local s4 = stack(overworld, menu)
T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2,
"and a menu outside a battle is untouched")
@@ -77,6 +77,7 @@ local function battleWith(fx, sprites)
statusHUDVisible = function() return true end,
bottomUIVisible = function() return true end,
caughtMarkerVisible = function() return false end,
extendedHUD = function() return false end,
}
end