launcher editor and widescreen battle

This commit is contained in:
bryanthaboi
2026-07-28 12:00:15 -04:00
parent f9f38d161f
commit 8539a6b268
45 changed files with 5236 additions and 1797 deletions
+67 -11
View File
@@ -27,6 +27,7 @@ local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local WideBattle = require("src.battle.WideBattle")
local BattleState = {}
BattleState.__index = BattleState
@@ -35,9 +36,33 @@ BattleState.isOpaque = true
-- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
-- differs; every battler, queue and animation below is shared. The wide
-- layout is live only while this battle is the state being drawn on top --
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
-- goes back with it and the battle underneath is not drawn at all.
function BattleState:wideLayout()
local options = self.game and self.game.save and self.game.save.options
if not options or options.battleLayout ~= "wide" then return false end
local stack = self.game.stack
return (stack and stack.top and stack:top()) == self
end
-- Renderer:setUISize asks the top state for its surface before anything draws
function BattleState:uiSize()
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
return 160, 144
end
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
-- SGB whole-screen remap must not run over it.
function BattleState.sgbPalettes() return nil end
-- SGB whole-screen remap must not run over it. The wide layout still
-- needs a zone list of its own: the invented 160x144 one would leave its
-- extra columns unremapped in the forced-mono modes (WideBattle.zones).
function BattleState:sgbPalettes()
if self:wideLayout() then return WideBattle.zones() end
return nil
end
local Rulesets = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
@@ -1423,7 +1448,14 @@ function BattleState:update(dt)
if self.phase == "moveSelect" then
local moves = self.player.curMoves
if input:wasPressed("up") then
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
-- four directions navigate it; nil means no direction was pressed and
-- A / B / SELECT below behave the same in either layout.
local grid = self:wideLayout()
and WideBattle.navigate(self.moveIndex, #moves, input)
if grid then
self.moveIndex = grid
elseif input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
@@ -1465,7 +1497,13 @@ function BattleState:update(dt)
-- (core.asm:2553-2557), so there is no backing out with B.
if self.phase == "mimicSelect" then
local moves = self.mimicMoves
if input:wasPressed("up") then
-- the copy menu shares the widescreen move grid, so it navigates the
-- same way there (the classic layout keeps the vertical list)
local grid = self:wideLayout()
and WideBattle.navigate(self.mimicIndex, #moves, input)
if grid then
self.mimicIndex = grid
elseif input:wasPressed("up") then
self.mimicIndex = self.mimicIndex > 1 and self.mimicIndex - 1 or #moves
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
@@ -4038,7 +4076,12 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
-- while an SE effect displaces the pic, confine it to its side's
-- tile window like the GB tilemap does (the pic can never overwrite
-- the HUD columns or the text box rows)
-- ...except under the widescreen layout, where the side's own region
-- scissor is already that window on a battlefield the classic tile
-- columns do not describe (an 88..160 clip would fall entirely outside
-- the enemy's region and erase the pic).
local clip = love.graphics.setScissor and love.graphics.intersectScissor
and not self.wideRegion
local scx, scy, scw, sch
if clip then
scx, scy, scw, sch = love.graphics.getScissor()
@@ -4396,15 +4439,21 @@ end
-- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy)
-- onlySide ("player" / "enemy") draws one side's pic alone, and
-- skipMenuClip drops the move-menu row clip below: the widescreen layout
-- composites each side into its own region of a taller battlefield, where
-- neither the other side's pixels nor the classic menu rows apply.
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
-- The move-select boxes are BG tiles on the GB, so they REPLACE the
-- player pic's rows: the TYPE/PP box at (0,8) (PrintMenuItem) wipes
-- pic rows 8+, and Mimic's copy menu at (0,7) (MoveSelectionMenu
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
-- layer in the colorized pipeline, so clip them to the visible rows.
local g = love.graphics
local clipY = self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64 or nil
local clipY = not skipMenuClip
and (self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64)
or nil
local clipped, cs1, cs2, cs3, cs4
if clipY and g.getScissor and g.intersectScissor then
cs1, cs2, cs3, cs4 = g.getScissor()
@@ -4412,14 +4461,15 @@ function BattleState:drawPicsLayer(slide, sx, sy)
clipped = true
end
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
if self.showEnemyTrainer and self.trainerPic then
if onlySide ~= "player" and self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
-- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317)
love.graphics.draw(img, ex + self:picOffset("foe"), ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
elseif onlySide ~= "player"
and self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
@@ -4448,7 +4498,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then
if onlySide ~= "enemy" and self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic)
@@ -4464,7 +4514,8 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- picOffset: SlideTrainerPicOffScreen walking the back pic off the left
love.graphics.draw(img, dx + slide + sx + self:picOffset("back"),
dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer
elseif onlySide ~= "enemy"
and self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
love.graphics.setColor(1, 1, 1, 1)
@@ -4747,6 +4798,11 @@ function BattleState:drawTextArea()
end
function BattleState:draw()
if self:wideLayout() then return WideBattle.draw(self) end
return self:drawClassic()
end
function BattleState:drawClassic()
-- AskName: ClearSprites + wild ClearScreenArea -- white field under the
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
if self.blankForAskName then
+379
View File
@@ -0,0 +1,379 @@
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
--
-- The battle simulation, timing, animations and rules stay BattleState's;
-- this module only replaces the composition and asks the renderer for a
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
-- border glyphs, species palettes and HP tiles all resolve through the
-- engine, so a COLORS mode or an asset mod still owns the look.
--
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
-- upper left with its picture upper right, the player's picture lower left
-- with their status lower right, a full-width message window, a split
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
local Font = require("src.render.Font")
local HudTiles = require("src.render.HudTiles")
local PaletteFX = require("src.render.PaletteFX")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local TypeChart = require("src.battle.TypeChart")
local WideBattle = {
WIDTH = 304,
HEIGHT = 144,
-- everything above this line is battlefield; the 40 rows below it are
-- the message / command / move windows
FIELD_BOTTOM = 104,
}
-- The forced-mono display modes re-threshold the whole finished frame
-- through the shade shader, and picImage hands them raw DMG grays for that
-- (#207). The wide layout has to know: it exposes a matching whole-surface
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
local function monoMode()
local m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic"
end
local function shownHP(battler)
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
end
-- a name truncated to `pixels` with a trailing '.', measured through the
-- font's own advances so a variable-width page still fits
local function fitName(text, pixels)
local spans = Font.split(text or "")
local n = Font.spansFitting(spans, pixels)
if n >= #spans then return text or "" end
local out = {}
for i = 1, math.max(0, n - 1) do
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
end
return table.concat(out) .. "."
end
local function saveScissor()
if not love.graphics.getScissor then return nil end
local x, y, w, h = love.graphics.getScissor()
if x == nil then return false end
return { x, y, w, h }
end
local function restoreScissor(saved)
if not love.graphics.setScissor then return end
if saved and saved ~= false then
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
else
love.graphics.setScissor()
end
end
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
-- The scissor is in canvas space, so it bounds the region itself while the
-- translate moves the classic 160x144 coordinates into it.
local function inRegion(x, y, w, h, dx, dy, fn)
local g = love.graphics
local saved = saveScissor()
g.setScissor(x, y, w, h)
g.push()
g.translate(dx, dy)
fn()
g.pop()
restoreScissor(saved)
end
local function levelAt(battle, battler, x, y)
if battler.shownStatus then
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
else
HudTiles.tile(0x6E, x, y) -- '<LV>'
Font.draw(tostring(battler.mon.level), x + 8, y)
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).
local function drawStatusPanel(battle, battler, x, y, player)
local tx, ty = math.floor(x / 8), math.floor(y / 8)
local tw, th = player and 15 or 16, player and 5 or 4
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
local nameWidth = player and 64 or 80
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler),
stats = battler.mon.stats,
}, nil, monoMode(), tw - 5)
if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
-- out to the wide screen's own corners
local function drawIntroBalls(battle)
if not battle.introBalls then return end
if battle.enemyParty and
(battle.kind == "trainer" or battle.kind == "link") then
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
end
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
end
local function drawHUDs(battle, slide)
if battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
drawStatusPanel(battle, battle.enemy, 0, 0, false)
end
if battle.safari then
Font.drawBox(23, 7, 15, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
elseif battle.player and not battle.demo and not battle.showPlayerBack
and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
end
local function drawMessageBox(battle)
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
if battle.scrollPx and battle.scrollPx > 0 then
battle.scrollPx = battle.scrollPx - 2
if battle.scrollPx <= 0 then battle.scrollPx = nil end
end
local off = battle.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(battle.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
Font.drawCode(0xEE, 288, 132)
end
end
local function drawCommandMenu(battle)
local col = (battle.menuIndex - 1) % 2
local row = math.floor((battle.menuIndex - 1) / 2)
if battle.safari then
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BALLx"), 16, 112)
Font.draw(Strings("BAIT"), 168, 112)
Font.draw(Strings("THROW ROCK"), 16, 128)
Font.draw(Strings("RUN"), 168, 128)
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
return
end
-- the prompt on the left, the 2x2 commands on the right
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112) -- 'PK'
Font.drawCode(0xE2, 248, 112) -- 'MN'
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
end
local function drawMoveDetails(battle, move)
Font.drawBox(28, 13, 10, 5)
if not move then return end
local def = battle.data.moves[move.id]
if not def then return end
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
end
local function drawMoveGrid(battle, moves, selected)
-- The 8px font needs 28 tiles for two complete twelve-character move
-- names plus their cursors; the details panel gets the other ten.
Font.drawBox(0, 13, 28, 5)
love.graphics.setColor(0, 0, 0, 1)
for i, move in ipairs(moves or {}) do
local col = (i - 1) % 2
local row = math.floor((i - 1) / 2)
local x, y = col == 0 and 16 or 120, 112 + row * 16
local def = battle.data.moves[move.id]
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
end
local col = (selected - 1) % 2
local row = math.floor((selected - 1) / 2)
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
drawMoveDetails(battle, moves and moves[selected])
end
local function drawMoveMenu(battle)
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
if battle.moveSwapIndex then
local col = (battle.moveSwapIndex - 1) % 2
local row = math.floor((battle.moveSwapIndex - 1) / 2)
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
end
end
local function drawTextArea(battle)
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
drawMessageBox(battle)
elseif battle.phase == "menu" then
drawCommandMenu(battle)
elseif battle.phase == "moveSelect" then
drawMoveMenu(battle)
elseif battle.phase == "mimicSelect" then
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
else
Font.drawBox(0, 13, 38, 5)
end
end
-- Battle animations are authored in the original 160px coordinate space.
-- Shift each complete OAM frame as one rigid group between the new player
-- and enemy anchors: drawing the whole animation through both side regions
-- would duplicate any tiles overlapping the other side's source range (most
-- visibly the send-out POOF reappearing on the far right).
function WideBattle.animationOffset(sprites)
if not sprites or #sprites == 0 then return 0, 0 end
local minX, maxX = math.huge, -math.huge
for _, sprite in ipairs(sprites) do
minX = math.min(minX, sprite.x - 8)
maxX = math.max(maxX, sprite.x)
end
local center = (minX + maxX) / 2
local t = math.max(0, math.min(1, (center - 40) / 80))
return math.floor(20 + 116 * t + 0.5),
math.floor(8 * (1 - t) + 0.5)
end
local function currentAnimationSprites(battle)
if battle.animPlaying and battle.animPlayer then
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
return step and step.sprites
end
if battle.lockedBall and battle.animPlayer then
return battle.lockedBall
end
end
local function drawAnimationLayer(battle)
local sprites = currentAnimationSprites(battle)
if not sprites or #sprites == 0 then return end
local dx, dy = WideBattle.animationOffset(sprites)
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
function() battle:drawAnimLayer(false) end)
end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
-- 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))
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
local fx = battle.fx
local sx = (fx and fx.shakeX) or 0
local sy = (fx and fx.shakeY) or 0
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2
end
local slide = (battle.introSlide or 0) * 4
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
drawAnimationLayer(battle)
drawTextArea(battle)
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
g.setColor(1, 1, 1, 1)
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, battle)
end
end
-- The palette zones for the wide surface. The composition already resolves
-- species colors, paper shade and HP-bar colors itself, so the colorized
-- modes take the trueColor opt-out (`colors = false`) over the whole
-- surface; the forced-mono modes still want their whole-screen remap, and
-- get one sized to the wide surface instead of the 160x144 rectangle
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
function WideBattle.zones()
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
if monoMode() then
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
-- the inverted permutation), exactly as it does for ensureZones' zone
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
end
return { { colors = false, x = 0, y = 0, w = w, h = h } }
end
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
-- and a direction pointing at an empty slot holds the current one.
function WideBattle.moveGridIndex(index, count, direction)
if count < 1 then return nil end
local row = math.floor((index - 1) / 2)
local col = (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local otherRow = 1 - row
local other = otherRow * 2 + col + 1
return other <= count and other or index
end
local DIRECTIONS = { "left", "right", "up", "down" }
-- the slot a directional press selects, or nil when none was pressed (the
-- caller then runs its normal list navigation / A / B / SELECT handling)
function WideBattle.navigate(index, count, input)
for _, key in ipairs(DIRECTIONS) do
if input:wasPressed(key) then
return WideBattle.moveGridIndex(index, count, key)
end
end
return nil
end
return WideBattle
+16 -4
View File
@@ -206,10 +206,15 @@ function Data:load()
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
-- Drop every namespace the mod merge created and evict the generated modules
-- from package.loaded, so the next load() re-reads them off disk instead of
-- handing back the cached tables. Two callers:
-- * reloadGenerated below (dev hot reload)
-- * main.lua, when the launcher closes the save editor -- the editor may
-- have loaded the OTHER game's cache, and require would otherwise serve
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
-- clears the matching read-path overlay).
function Data:unloadGenerated()
local pristine = self._pristineKeys
if pristine then
for key in pairs(self) do
@@ -222,6 +227,13 @@ function Data:reloadGenerated()
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
end
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
self:unloadGenerated()
self:load()
end
+10
View File
@@ -238,6 +238,16 @@ function Game:draw()
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
-- The UI surface is resolved once, before any state draws: the top state
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
-- asks for 304x144). Anything else keeps the classic surface, so a menu
-- pushed over a wide battle brings the screen straight back to 160x144.
local top = self.stack:top()
if top and top.uiSize then
Renderer:setUISize(top:uiSize())
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
Renderer:beginFrame(worldBelow)
self.stack:draw()
-- SGB colorization: the topmost state that knows its palette owns the
+22
View File
@@ -194,6 +194,9 @@ function SaveData.defaultOptions()
textSpeed = 3,
animations = true,
battleStyle = "shift",
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -443,6 +446,25 @@ function SaveData.slotSummary(save)
}
end
-- The absolute on-disk path of a slot's save file, for the one caller that
-- cannot go through love.filesystem: the save editor reads and writes with
-- raw io.* so it can also open a file the player dragged in from anywhere.
-- Resolves against the same root persistFs would write to -- the portable
-- game folder when portable mode is on, otherwise LOVE's save directory --
-- so Edit on a launcher save row lands on the file the game actually plays.
-- nil when neither root is available (headless tests with an injected fs).
function SaveData.slotDiskPath(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) or not slotId then return nil end
local base = SaveData.portableBaseDir()
or (love and love.filesystem and love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory())
if not base then return nil end
local sep = package.config:sub(1, 1)
local rel = select(1, slotNames(version, slotId))
return base .. sep .. rel:gsub("/", sep)
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
+62
View File
@@ -148,6 +148,37 @@ local function mountReadable(dir, append)
return fn(dir, append)
end
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
-- CacheFs.unmountVersion needs it: the launcher can open the save editor on
-- one game's cache and then Play the other, and an overlay left mounted
-- would win the read path for the rest of the process.
local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_unmount end)
if oks and fn then
physfsUnmountFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= 0
end
break
end
end
end
return physfsUnmountFn
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
@@ -323,4 +354,35 @@ function CacheFs.mountVersion(version)
return false
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue save, close
-- it, and press Play on Red: with blue/ still prepended, Red's
-- require("data.generated.*") and its generated art would silently resolve to
-- Blue's files. Callers must also drop the generated modules from
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
-- fixes the read path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end
local sub = prefix:gsub("/+$", "")
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
local done = false
local fn = resolveUnmount()
if fn and base then
done = fn(base .. SEP .. sub) or done
end
-- also drop the love.filesystem.mount fallback, which registers the folder
-- under its bare name rather than its absolute path
if love.filesystem.unmount then
done = love.filesystem.unmount(sub) or done
end
return done
end
return CacheFs
+78 -9
View File
@@ -474,7 +474,10 @@ end
-- own cache (Red at the root, Blue under blue/), so both can be imported and
-- played side by side. onComplete(version) hands the chosen game off to boot.
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- forceImport (treat every version as not-yet-imported, so re-import is forced).
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
-- row -- main.lua opens the bundled save editor on that slot; when it is not
-- supplied the Edit label is not drawn at all).
function RomImporter.new(onComplete, opts)
opts = opts or {}
local android = love.system.getOS() == "Android"
@@ -483,6 +486,7 @@ function RomImporter.new(onComplete, opts)
onComplete = onComplete,
launcher = opts.launcher or false,
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
android = android,
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
logo = love.graphics.newImage("assets/logo/logo.png"),
@@ -970,9 +974,30 @@ function RomImporter:choose(version)
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then
self:startPath(path)
elseif love.system.getOS() ~= "OS X"
and love.system.getOS() ~= "Windows"
and love.system.getOS() ~= "Linux" then
return
end
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
-- kdialog. Fall back to the same "drop a .gb next to the game" scan used
-- on Android, which works when the game is launched as an unpacked
-- directory (see build-rg34xxsp.sh).
local name, data = findPendingRom(self.ready)
if name then
self:startData(data, name)
return
end
if love.system.getOS() == "Linux" then
local where = love.filesystem.getSourceBaseDirectory
and love.filesystem.getSourceBaseDirectory()
or love.filesystem.getSource and love.filesystem.getSource()
or "the game folder"
self.notice = {
version = self.chooseVersion,
status = "No file picker. Copy your .gb into:",
detail = where,
}
return
end
if love.system.getOS() ~= "OS X" and love.system.getOS() ~= "Windows" then
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
end
end
@@ -1202,6 +1227,7 @@ function RomImporter:draw()
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
-- version with no panel drawn this frame) cannot inherit last frame's rows.
self.slotRects = nil
self.slotEditRects = nil
self.newSlotRect = nil
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
-- last frame's mod toggles / import button.
@@ -1619,17 +1645,24 @@ function RomImporter:mousepressed(x, y, button)
end
return
end
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- label never also selects the row. On desktop a press only ARMS a click:
-- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
-- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap
-- on either never also selects the row. On desktop a press only ARMS a row
-- click: _updateSlotDrag commits it on release when the pointer did not move
-- (a moved pointer scrolls instead). Android has no reliable pointer
-- polling, so it selects on press. Edit and Delete fire immediately (small
-- fixed targets, no scroll conflict).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotEditRects or {}) do
if inside(r, x, y) then
if self.onEditSave then self.onEditSave(self.panelVersion, r.id) end
return
end
end
for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then
if self.android then
@@ -2091,6 +2124,13 @@ function RomImporter:_ensureSlots(version)
if not self.slots[version] then self:_refreshSlots(version) end
end
-- The host calls this when the save editor closes: the edited slot's player
-- name, badge count and dex total all feed the cached row summary, so it has
-- to be re-read rather than trusted across the round trip.
function RomImporter:savesChanged(version)
self:_refreshSlots(version)
end
-- Point the active slot at id (persisted immediately, per the contract) and
-- reflect it in the LOADED pill without a full relist.
function RomImporter:_selectSlot(version, id)
@@ -2208,6 +2248,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
self.slotEditRects = {}
elseif listH > 0 then
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
@@ -2227,6 +2268,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects = {}
self.slotDeleteRects = {}
self.slotEditRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do
@@ -2253,6 +2295,24 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- Edit label, immediately left of Delete: opens the bundled save
-- editor (tools/save-editor) on this slot's file. Only drawn when the
-- host supplied onEditSave and the slot actually holds a save -- there
-- is nothing to edit in an empty slot, and offering it would open the
-- editor on a new-game stub the player never asked for.
local erect = nil
if self.onEditSave and slot.exists then
local edText = "Edit"
local edW = self.hintFont:getWidth(edText)
local edX = delX - 14 * s - edW
erect = { x = edX - 6 * s, y = delY - 4 * s,
width = edW + 12 * s, height = delH + 8 * s, id = slot.id }
local ehot = self:_hover(erect)
col(ehot and PAL.blue or PAL.warning)
love.graphics.print(edText, edX, delY)
rightReserve = rightReserve + edW + 20 * s
end
-- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0
if selected then
@@ -2301,6 +2361,15 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
if erect then
local evy = math.max(erect.y, listTop)
local evy2 = math.min(erect.y + erect.height, listBottom)
if evy2 > evy then
self.slotEditRects[#self.slotEditRects + 1] =
{ x = erect.x, y = evy, width = erect.width, height = evy2 - evy,
id = slot.id }
end
end
end
end
love.graphics.setScissor()
+14 -5
View File
@@ -127,6 +127,11 @@ end
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red).
--
-- segments: how many 8px cells the bar spans (6, the hardware width,
-- unless a caller asks for more -- the widescreen battle layout has room
-- for a longer bar in the same tiles). The color thresholds scale with
-- it so a wider bar turns yellow and red at the same fractions of full.
--
-- grayFill (#229): when the caller will colorize this bar with an SGB
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
@@ -136,18 +141,22 @@ end
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end
local tint
if not grayFill then
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local green = math.ceil(27 * segments / 6)
local yellow = math.ceil(10 * segments / 6)
local name = px >= green and "GREENBAR"
or px >= yellow and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
@@ -157,11 +166,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
math.min(1, c[3] / 170), 1 }
end
end
for i = 0, 5 do
for i = 0, segments - 1 do
local seg = math.min(8, math.max(0, px - i * 8))
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
end
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
end
return HudTiles
+38 -5
View File
@@ -15,8 +15,15 @@ local Runtime = require("src.mods.Runtime")
local Renderer = {}
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
-- screen is laid out in; uiWidth/uiHeight are the surface actually
-- allocated this frame, which a state may widen through setUISize (the
-- widescreen battle layout asks for 304x144). Anything drawing a normal
-- 160x144 screen can keep reading WIDTH/HEIGHT.
Renderer.WIDTH = 160
Renderer.HEIGHT = 144
Renderer.MAX_UI_WIDTH = 640
Renderer.MAX_UI_HEIGHT = 576
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
@@ -83,7 +90,8 @@ function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -115,7 +123,31 @@ end
-- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
local w, h = self:uiSize()
return math.max(1, math.floor(math.min(pw / w, ph / h)))
end
-- the native-pixel UI surface in use right now
function Renderer:uiSize()
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
end
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
-- only when the size actually changes, so the classic path never rebuilds
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
-- both ends -- never smaller than the Game Boy screen every layout assumes,
-- never large enough for a bad request to allocate an unbounded canvas.
function Renderer:setUISize(w, h)
if type(w) ~= "number" or type(h) ~= "number"
or w < self.WIDTH or h < self.HEIGHT
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
w, h = self.WIDTH, self.HEIGHT
end
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
self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest")
end
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
@@ -443,10 +475,11 @@ function Renderer:endFrame(zones, worldZones)
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
+1
View File
@@ -107,6 +107,7 @@ local function defaultsSave()
modData = {},
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
battleLayout = "og",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
+11
View File
@@ -142,6 +142,17 @@ local function buildRows(game)
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
return true
end },
-- OG is the classic 160x144 battle screen; WIDE is the 304x144
-- widescreen composition (src/battle/WideBattle.lua)
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
value = function(g)
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
end,
step = function(g)
local o = g.save.options
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
return true
end },
{ id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end,
step = function(g, dir)