diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 36e9bf5e..bda28b39 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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. diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index abe3ef7a..75df4b6f 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -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) diff --git a/src/core/Game.lua b/src/core/Game.lua index 35989cfd..7c3f9aaa 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 54627da2..6114385f 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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 diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 78eead2d..4004a86e 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -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")) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 149e4899..4935daf9 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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 diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index c83c5450..945aaca1 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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 diff --git a/tests/drivers/battle_hud_layout_lock_test.lua b/tests/drivers/battle_hud_layout_lock_test.lua new file mode 100644 index 00000000..24b44732 --- /dev/null +++ b/tests/drivers/battle_hud_layout_lock_test.lua @@ -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 diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua new file mode 100644 index 00000000..94311bab --- /dev/null +++ b/tests/drivers/fill_extended_auto_menu_test.lua @@ -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 diff --git a/tests/drivers/fill_extended_white_hud_test.lua b/tests/drivers/fill_extended_white_hud_test.lua new file mode 100644 index 00000000..fc1a3f2a --- /dev/null +++ b/tests/drivers/fill_extended_white_hud_test.lua @@ -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 diff --git a/tests/drivers/fixed_extended_black_hud_test.lua b/tests/drivers/fixed_extended_black_hud_test.lua new file mode 100644 index 00000000..5676c314 --- /dev/null +++ b/tests/drivers/fixed_extended_black_hud_test.lua @@ -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 diff --git a/tests/drivers/fixed_extended_white_hud_test.lua b/tests/drivers/fixed_extended_white_hud_test.lua new file mode 100644 index 00000000..7f528de5 --- /dev/null +++ b/tests/drivers/fixed_extended_white_hud_test.lua @@ -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 diff --git a/tests/drivers/fixed_extended_world_bag_overlay_test.lua b/tests/drivers/fixed_extended_world_bag_overlay_test.lua new file mode 100644 index 00000000..95855569 --- /dev/null +++ b/tests/drivers/fixed_extended_world_bag_overlay_test.lua @@ -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 diff --git a/tests/drivers/fixed_extended_world_hud_test.lua b/tests/drivers/fixed_extended_world_hud_test.lua new file mode 100644 index 00000000..8ed309a2 --- /dev/null +++ b/tests/drivers/fixed_extended_world_hud_test.lua @@ -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 diff --git a/tests/engine/battle_fixed_menu_scale.lua b/tests/engine/battle_fixed_menu_scale.lua index fd5e27d2..33e79812 100644 --- a/tests/engine/battle_fixed_menu_scale.lua +++ b/tests/engine/battle_fixed_menu_scale.lua @@ -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") diff --git a/tests/engine/wide_battle_shake_bug562.lua b/tests/engine/wide_battle_shake_bug562.lua index b445748c..ac5c3468 100644 --- a/tests/engine/wide_battle_shake_bug562.lua +++ b/tests/engine/wide_battle_shake_bug562.lua @@ -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