Compare commits

...

3 Commits

Author SHA1 Message Date
bryanthaboi 3954f93653 Merge branch 'main' into bugsquashingjuly26 2026-07-26 14:09:53 -04:00
bryanthaboi b02f8c7daf ok 2026-07-26 14:03:58 -04:00
bryanthaboi 3461937dbc big bug squash 2026-07-26 13:36:57 -04:00
9 changed files with 296 additions and 113 deletions
+7
View File
@@ -2781,6 +2781,13 @@ function BattleState:enemyMonFainted()
local levels, gained = Experience.apply(self.data, mon, self.enemy.def, local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer", self.enemy.mon.level, self.kind == "trainer",
split, mon.traded) split, mon.traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213).
if #levels > 0 then
self.leveledUp = self.leveledUp or {}
self.leveledUp[mon] = true
end
Runtime.emit("battle.exp_gained", { Runtime.emit("battle.exp_gained", {
battle = self, mon = mon, gained = gained, levels = levels, battle = self, mon = mon, gained = gained, levels = levels,
}) })
+15 -6
View File
@@ -184,13 +184,22 @@ function Evolution.request(game, mon, trigger, onDone)
return species return species
end end
-- After-battle hook: evolve everyone who qualifies (queued one at a time). -- After-battle hook: evolve mons that leveled this battle and still
function Evolution.checkParty(game, onDone) -- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight — a B-cancel
-- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
function Evolution.checkParty(game, onDone, leveledUp)
local pending = {} local pending = {}
for _, mon in ipairs(game.save.party) do if leveledUp then
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" }) for _, mon in ipairs(game.save.party) do
if target then if leveledUp[mon] then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method }) local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
end
end
end end
end end
local i = 0 local i = 0
+105 -86
View File
@@ -39,39 +39,42 @@ end
-- endFrame composites the padded canvas back with a matching offset. -- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160 Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio. -- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often -- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB -- Integer scaling in units then maps each GB pixel to a fractional number of
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven / -- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
-- non-square "pixels", and movement judder (issue #87). Always derive the -- judder (issue #87). Always derive the crisp integer scale from the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi) -- *drawable* pixel size (the window framebuffer we present into -- not a
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged. -- combined multi-display metric), then draw with (pixels / axisDpi) so the
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
-- --
-- `dpi` here must be the factor LOVE actually applies to every draw call -- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
-- (getDPIScale), NOT the drawable/unit size ratio pw/ww. On a normal device -- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
-- those are equal (getPixelDimensions == getDimensions * getDPIScale), but on -- highdpi surface, but diverge when unit sizes are truncated independently
-- the AYN Thor dual-screen surface in forced landscape they diverge: LOVE -- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
-- reports pw/ww ≈ 1 while its real transform is 1.5, so scaling by pw/ww lands -- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
-- each GB pixel on Sp*(getDPIScale/(pw/ww)) physical pixels -- a fractional, -- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
-- stretched/non-square count (issue #208). Prefer getDPIScale so draws land -- for both axes makes the other axis land on a fractional, stretched count.
-- on whole physical pixels through LOVE's actual transform; fall back to pw/ww -- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- (then 1) only when getDPIScale is unavailable. Since getDPIScale == pw/ww -- on BOTH axes (square).
-- on every normal device, #87's behavior is byte-identical there.
local function displayMetrics() local function displayMetrics()
local ww, wh = love.graphics.getDimensions() local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh local pw, ph = ww, wh
if love.graphics.getPixelDimensions then if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions() pw, ph = love.graphics.getPixelDimensions()
end end
local dpi local dpiX, dpiY = 1, 1
if love.graphics.getDPIScale then if ww > 0 and pw > 0 then dpiX = pw / ww end
dpi = love.graphics.getDPIScale() if wh > 0 and ph > 0 then dpiY = ph / wh end
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
and love.graphics.getDPIScale then
local d = love.graphics.getDPIScale()
if d and d > 1e-6 then dpiX, dpiY = d, d end
end end
if (not dpi or dpi < 1e-6) and ww > 0 and pw > 0 then if dpiX < 1e-6 then dpiX = 1 end
dpi = pw / ww if dpiY < 1e-6 then dpiY = 1 end
end return ww, wh, pw, ph, dpiX, dpiY
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
end end
function Renderer:init() function Renderer:init()
@@ -105,33 +108,43 @@ end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom / -- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE -- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing. -- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale() function Renderer:fitScale()
local _, _, pw, ph = displayMetrics() local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT))) return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end end
-- The LOVE-unit draw scale endFrame uses for the UI blit: the integer -- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- framebuffer scale (fitScale) divided by the live coordinate->pixel factor, -- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- so a GB pixel lands on fitScale() whole PHYSICAL pixels once LOVE applies -- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
-- its own transform (fitScale() == drawScale() * dpi). Exposed so #208's -- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
-- regression can assert GB pixels stay square on divergent-DPI surfaces -- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
-- without reaching into endFrame's locals; endFrame recomputes the same value function Renderer:drawScaleX()
-- inline (`S = Sp / dpi`). local _, _, _, _, dpiX = displayMetrics()
function Renderer:drawScale() return self:fitScale() / dpiX
local _, _, _, _, dpi = displayMetrics()
return self:fitScale() / dpi
end end
-- world-pass canvas size in world pixels: enough to fill the window at s'. function Renderer:drawScaleY()
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so local _, _, _, _, _, dpiY = displayMetrics()
-- the projected ground plane still covers the whole window with no return self:fitScale() / dpiY
-- background peeking at the receded top/bottom corners; flat mode returns end
-- exactly today's size (growth factor is 1 when tilt is inactive).
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
function Renderer:drawScale()
return self:drawScaleX()
end
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
-- the whole window with no background peeking at the receded top/bottom
-- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive).
function Renderer:worldViewSize() function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics() local _, _, pw, ph = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi local sp = Zoom.scale(self:fitScale())
local vw, vh = Zoom.fillViewSize(s, ww, wh) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral), -- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer. -- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end if vw % 2 ~= 0 then vw = vw + 1 end
@@ -169,21 +182,24 @@ end
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox -- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
-- into the surrounding window, matching BattleTransition wipe progress. -- into the surrounding window, matching BattleTransition wipe progress.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe. -- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S) -- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
if not prog or prog <= 0 then return end if not prog or prog <= 0 then return end
local TILE = 8 * S Sy = Sy or Sx
if TILE < 1 then TILE = 1 end local TILE_W, TILE_H = 8 * Sx, 8 * Sy
local cols = math.ceil(ww / TILE) if TILE_W < 1 then TILE_W = 1 end
local rows = math.ceil(wh / TILE) if TILE_H < 1 then TILE_H = 1 end
local cols = math.ceil(ww / TILE_W)
local rows = math.ceil(wh / TILE_H)
local cx, cy = ox + vpw / 2, oy + vph / 2 local cx, cy = ox + vpw / 2, oy + vph / 2
local order = {} local order = {}
for row = 0, rows - 1 do for row = 0, rows - 1 do
for col = 0, cols - 1 do for col = 0, cols - 1 do
local x, y = col * TILE, row * TILE local x, y = col * TILE_W, row * TILE_H
-- any tile with area outside the letterbox participates -- any tile with area outside the letterbox participates
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
local dist = math.max(math.abs(x + TILE / 2 - cx), local dist = math.max(math.abs(x + TILE_W / 2 - cx),
math.abs(y + TILE / 2 - cy)) math.abs(y + TILE_H / 2 - cy))
order[#order + 1] = { x, y, dist } order[#order + 1] = { x, y, dist }
end end
end end
@@ -200,7 +216,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
love.graphics.setScissor(0, 0, ww, wh) love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do for i = 1, n do
local t = order[i] local t = order[i]
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE) love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
end end
love.graphics.setScissor() love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -323,10 +339,11 @@ end
-- into (nil = default framebuffer; presentCanvas when CRT is on). -- into (nil = default framebuffer; presentCanvas when CRT is on).
-- Returns true on success; false (no shader/mesh) tells endFrame to fall -- Returns true on success; false (no shader/mesh) tells endFrame to fall
-- back to the flat blit unchanged. -- back to the flat blit unchanged.
function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target) function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
local shader = self:tiltShader() local shader = self:tiltShader()
local mesh = self:tiltMesh() local mesh = self:tiltMesh()
if not (shader and mesh) then return false end if not (shader and mesh) then return false end
sy = sy or sx
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() local wvh = self.worldCanvas:getHeight()
@@ -375,7 +392,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
mesh:setVertices(Tilt.meshCorners(wvw, wvh)) mesh:setVertices(Tilt.meshCorners(wvw, wvh))
love.graphics.push() love.graphics.push()
love.graphics.translate(wox, woy) love.graphics.translate(wox, woy)
love.graphics.scale(s, s) love.graphics.scale(sx, sy)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader) love.graphics.setShader(shader)
love.graphics.draw(mesh) love.graphics.draw(mesh)
@@ -417,14 +434,15 @@ end
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas() love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale. -- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale() local Sp = self:fitScale()
local S = Sp / dpi local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * S, self.HEIGHT * S local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units. -- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -469,14 +487,15 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to -- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone -- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- coords (canvas-space) into screen units. -- convert zone coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH) local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH) love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, scale, scale) love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor() love.graphics.setScissor()
return return
end end
@@ -492,10 +511,10 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader(not plain and shader or nil) love.graphics.setShader(not plain and shader or nil)
end end
if not plain then PaletteFX.sendColors(shader, z.colors) end if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale, if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneScale, z.h * zoneScale, z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH) then boxX, boxY, boxW, boxH) then
love.graphics.draw(canvas, bx, by, 0, scale, scale) love.graphics.draw(canvas, bx, by, 0, sx, sy)
end end
end end
love.graphics.setScissor() love.graphics.setScissor()
@@ -510,7 +529,7 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual. -- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh) love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi) love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
love.graphics.setScissor() love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite -- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha local fade = self.worldFadeAlpha
@@ -520,15 +539,15 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
if self.battleCascadeProg then if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S) self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end end
elseif self.worldActive then elseif self.worldActive then
local sp = Zoom.scale(Sp) local sp = Zoom.scale(Sp)
local s = sp / dpi local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpi local woy = math.floor((ph - wvh * sp) / 2) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh -- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or -- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -536,12 +555,12 @@ function Renderer:endFrame(zones, worldZones)
-- falls through to the flat blit, keeping the flat frame byte-for-byte -- falls through to the flat blit, keeping the flat frame byte-for-byte
-- identical to today. -- identical to today.
local projected = local projected =
Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present) Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
if not projected then if not projected then
if worldZones then if worldZones then
blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh) blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
else else
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh) blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
end end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC -- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass -- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -562,11 +581,11 @@ function Renderer:endFrame(zones, worldZones)
end end
if wanted then PaletteFX.sendColors(wanted, r.colors) end if wanted then PaletteFX.sendColors(wanted, r.colors) end
if r.quad then if r.quad then
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s, love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
0, s * r.sx, s) 0, sx * r.sx, sy)
else else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s, love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
0, s * r.sx, s) 0, sx * r.sx, sy)
end end
end end
if activeShader then love.graphics.setShader() end if activeShader then love.graphics.setShader() end
@@ -583,7 +602,7 @@ function Renderer:endFrame(zones, worldZones)
local M = self.UPRIGHT_MARGIN local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh) love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s) love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
love.graphics.setScissor() love.graphics.setScissor()
end end
-- Screen-space warp fade (Transition) over the full world composite so -- Screen-space warp fade (Transition) over the full world composite so
@@ -599,11 +618,11 @@ function Renderer:endFrame(zones, worldZones)
-- Battle transition: cascade black blocks into the area outside the -- Battle transition: cascade black blocks into the area outside the
-- classic 160x144 wipe square (world still shows through until filled). -- classic 160x144 wipe square (world still shows through until filled).
if self.battleCascadeProg then if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S) self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end end
end end
-- UI stays in the classic centered GB letterbox -- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
if present then if present then
love.graphics.setCanvas() love.graphics.setCanvas()
@@ -613,7 +632,7 @@ function Renderer:endFrame(zones, worldZones)
-- grid itself. Each pass hands back a canvas; with none registered -- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical. -- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present, local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
if GBCFX.active() then if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels -- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp) GBCFX.present(composed, Sp)
+10 -2
View File
@@ -465,6 +465,14 @@ function PartyMenu:bottomMessage()
end end
end end
-- Name-row pixel Y for party slot i (1-based).
-- pokered party_menu.asm RedrawPartyMenu_: hlcoord 3, 0, then each entry
-- advances 2*SCREEN_WIDTH (16 px). The bottom message box sits at tile
-- row 12 (y=96); slot 6's HP row is therefore at y=88. #262
function PartyMenu.entryY(i)
return (i - 1) * 16
end
function PartyMenu:draw() function PartyMenu:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -476,9 +484,9 @@ function PartyMenu:draw()
local HudTiles = require("src.render.HudTiles") local HudTiles = require("src.render.HudTiles")
for i, mon in ipairs(party) do for i, mon in ipairs(party) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
local y = (i - 1) * 16 + 12 local y = PartyMenu.entryY(i)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
drawIcon(self.game, mon, 8, y - 2, i == self.index, self.blink or 0) drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 24, y) Font.draw(mon.nickname or def.name, 24, y)
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the -- level at column 13 (<LV> tile + digits, PrintLevel) AND the
+3 -1
View File
@@ -3257,7 +3257,9 @@ function OverworldState:afterBattle(result, battle)
lead and lead.stats.hp or 0) lead and lead.stats.hp or 0)
local Evolution = require("src.pokemon.Evolution") local Evolution = require("src.pokemon.Evolution")
local function evolutions() local function evolutions()
Evolution.checkParty(Game) -- Only mons that gained a level this battle (EXP.ALL included).
-- Scanning the whole party re-offered B-cancelled evolutions forever (#213).
Evolution.checkParty(Game, nil, battle and battle.leveledUp)
end end
if result == "lose" then if result == "lose" then
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1" local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
@@ -8,6 +8,8 @@
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a -- Case 1 (level path, cancelable): open EvolutionState directly, wait a
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and -- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
-- assert the mon stays CATERPIE with "stopped evolving" text on screen. -- assert the mon stays CATERPIE with "stopped evolving" text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
-- Case 2 (control): let the flash run to completion with no input and -- Case 2 (control): let the flash run to completion with no input and
-- assert the mon becomes METAPOD with the "Congratulations!" text. -- assert the mon becomes METAPOD with the "Congratulations!" text.
-- --
@@ -103,6 +105,27 @@ return function(game)
assert(done1, "cancel onDone never fired") assert(done1, "cancel onDone never fired")
assert(mon.species == "CATERPIE", "species changed after cancel tail") assert(mon.species == "CATERPIE", "species changed after cancel tail")
-- === Case 1b: after B-cancel, checkParty without a level-up must not
-- re-offer (regression: cancelled mons stuck at threshold tried again
-- after every later battle, even non-participants). ===
local nQuiet = Evolution.checkParty(game, nil, {})
assert(nQuiet == 0, "checkParty with no level-ups re-offered after cancel")
assert(not evoTop(), "EvolutionState opened after quiet afterBattle")
assert(mon.species == "CATERPIE", "species changed on quiet checkParty")
local nLevel = Evolution.checkParty(game, nil, { [mon] = true })
assert(nLevel == 1, "checkParty after a real level-up should offer once")
if not waitFor(evoTop, 300) then
error("EvolutionState never opened after level-up re-offer")
end
U.wait(20)
U.hold(game, "b", 20) -- cancel so case 2 stays independent
if not waitFor(function() return not evoTop() end, 240) then
error("level-up re-offer did not abort on B")
end
mashUntil(function() return not findText("stopped evolving") end, 80)
assert(mon.species == "CATERPIE", "species changed after re-offer cancel")
-- === Case 2 (control): no input -> evolution completes === -- === Case 2 (control): no input -> evolution completes ===
local mon2 = Pokemon.new(game.data, "CATERPIE", 7) local mon2 = Pokemon.new(game.data, "CATERPIE", 7)
table.insert(game.save.party, 1, mon2) table.insert(game.save.party, 1, mon2)
@@ -0,0 +1,46 @@
-- Driver: party list must not sit under the bottom message box (#262).
-- Gen1 (pokered engine/menus/party_menu.asm RedrawPartyMenu_) places the
-- first name at hlcoord 3, 0 and advances each entry by 2*SCREEN_WIDTH.
-- The recomp used y = (i-1)*16 + 12, which shoved a full party down so
-- slot 6 was clipped by the "Choose a POKéMON." text box (tile row 12).
-- This driver opens a 6-mon party menu, screenshots it, and asserts
-- PartyMenu.entryY keeps slot 6's HP row above y=96.
-- POKEPORT_DRIVER=tests/drivers/party_bug262_offset_test.lua \
-- POKEPORT_IDENTITY=bug262 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local PartyMenu = require("src.ui.PartyMenu")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1; U.log("PASS", label)
else fail = fail + 1; U.log("FAIL", label) end
end
game.save.party = {
Pokemon.new(game.data, "CHARMANDER", 12),
Pokemon.new(game.data, "PARAS", 10),
Pokemon.new(game.data, "DIGLETT", 15),
Pokemon.new(game.data, "FARFETCHD", 5),
Pokemon.new(game.data, "GASTLY", 20),
Pokemon.new(game.data, "SANDSHREW", 22),
}
U.teleport(game, "ROUTE_1", 5, 5, "down")
Screens.push(game, "PartyMenu")
U.wait(8)
U.shot(game, DIR .. "/party_bug262_offset.png")
local pm = game.stack:top()
check("top is PartyMenu", getmetatable(pm) == PartyMenu)
check("entryY(1) == 0", PartyMenu.entryY(1) == 0)
check("entryY(6) == 80", PartyMenu.entryY(6) == 80)
check("slot 6 HP row < message box y=96", PartyMenu.entryY(6) + 8 < 96)
check("field message == 'Choose a POKéMON.'",
pm and pm.bottomMessage and pm:bottomMessage() == "Choose a POKéMON.")
U.log(("RESULT pass=%d fail=%d"):format(pass, fail))
end
+40
View File
@@ -512,6 +512,46 @@ do
"the registered method's own gate holds") "the registered method's own gate holds")
end end
-- ------- checkParty only offers evolutions for mons that leveled (#213)
do
local caterpie = Pokemon.new(Data, "CATERPIE", 7) -- at LEVEL evo threshold
local bench = Pokemon.new(Data, "PIDGEY", 5)
local save = SaveData.newGame()
save.party = { caterpie, bench }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
local game = { data = Data, save = save, stack = stack }
local offered = {}
local origEvolve = Evolution.evolve
Evolution.evolve = function(_, mon, to, onDone)
offered[#offered + 1] = { mon = mon, to = to }
if onDone then onDone() end
end
check(Evolution.checkParty(game) == 0,
"checkParty with no leveledUp set offers nothing")
check(#offered == 0, "no evolve calls without leveledUp")
check(Evolution.checkParty(game, nil, {}) == 0,
"checkParty with empty leveledUp offers nothing")
check(#offered == 0, "cancel-at-threshold does not re-offer next battle")
check(Evolution.checkParty(game, nil, { [bench] = true }) == 0,
"a leveled mon with no pending evo is skipped")
check(#offered == 0, "bench level-up alone does not evolve CATERPIE")
check(Evolution.checkParty(game, nil, { [caterpie] = true }) == 1,
"checkParty offers evo for the mon that leveled")
check(#offered == 1 and offered[1].mon == caterpie and offered[1].to == "METAPOD",
"the leveled CATERPIE is queued for METAPOD")
Evolution.evolve = origEvolve
end
-- ------- the rare-candy flow runs the hook-wrapped dispatch -- ------- the rare-candy flow runs the hook-wrapped dispatch
do do
+47 -18
View File
@@ -1845,9 +1845,12 @@ do
eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y") eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y")
end end
-- ---------------------------------------------------------------- dpi fit scale (#87) -- ---------------------------------------------------------------- dpi fit scale (#87 / #208)
-- Android density is often non-integer; fitScale must use framebuffer -- Android density is often non-integer; fitScale must use framebuffer
-- pixels so each GB pixel maps to a whole number of physical pixels. -- pixels so each GB pixel maps to a whole number of physical pixels.
-- When axis unit→pixel ratios diverge (truncated highdpi sizes, dual-screen
-- / forced-rotation mismatch), draw scales must be anisotropic so X and Y
-- both land on the same integer physical count (square pixels).
do do
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
@@ -1873,27 +1876,46 @@ do
local vw, vh = Renderer:worldViewSize() local vw, vh = Renderer:worldViewSize()
check(vw % 2 == 0 and vh % 2 == 0, "world view sizes are even (integer camera)") check(vw % 2 == 0 and vh % 2 == 0, "world view sizes are even (integer camera)")
-- ceil(pw/Sp)=ceil(1920/7)=275 → even 276; ceil(1080/7)=155 → even 156 -- ceil(pw/Sp)=ceil(1920/7)=275 → even 276; ceil(1080/7)=155 → even 156
eq(vw, 276, "world fill width covers the unit window at pixel scale 7") eq(vw, 276, "world fill width covers the drawable at pixel scale 7")
eq(vh, 156, "world fill height covers the unit window at pixel scale 7") eq(vh, 156, "world fill height covers the drawable at pixel scale 7")
-- #208: on a dual-screen surface (AYN Thor, forced landscape) LOVE can -- #208: axis DPI mismatch. LOVE's projection uses pw/ww on X and ph/wh
-- report drawable == unit size (pw/ww == 1) while its real coordinate->pixel -- on Y independently; getDPIScale() is only ph/wh. A single-dpi draw
-- transform is 1.5. fitScale stays keyed off the drawable pixel size, but -- scale makes one axis fractional → stretched / non-square GB pixels
-- the unit<->pixel conversion must use the REAL getDPIScale, not pw/ww, so -- (fonts especially). Truncated density-2.75 unit sizes on 1080p:
-- each GB pixel still lands on a WHOLE number of physical pixels (square) -- local dpiX = 1920 / 698
-- not the stretched 6-vs-9-device-px fractional pixels the reporter saw. local dpiY = 1080 / 392
-- Before the fix drawScale()==Sp/(pw/ww)==7 and 7*1.5==10.5 px/GB px.
Zoom.reset() Zoom.reset()
g.getDimensions = function() return 1920, 1080 end g.getDimensions = function() return 698, 392 end
g.getPixelDimensions = function() return 1920, 1080 end g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1.5 end g.getDPIScale = function() return dpiY end -- real love.graphics.getDPIScale
eq(Renderer:fitScale(), 7, eq(Renderer:fitScale(), 7,
"#208 divergent DPI: fitScale still 7 from drawable pixels") "#208 anisotropic DPI: fitScale still 7 from drawable pixels")
local physical = Renderer:drawScale() * g.getDPIScale() local physX = Renderer:drawScaleX() * dpiX
local rounded = math.floor(physical + 0.5) local physY = Renderer:drawScaleY() * dpiY
check(math.abs(physical - rounded) < 1e-9, check(math.abs(physX - 7) < 1e-9,
"#208 GB pixel lands on a whole number of physical pixels (square)") "#208 GB pixel covers exactly fitScale (7) physical px on X")
eq(rounded, 7, "#208 each GB pixel covers exactly fitScale (7) physical px") check(math.abs(physY - 7) < 1e-9,
"#208 GB pixel covers exactly fitScale (7) physical px on Y")
check(math.abs(physX - physY) < 1e-9,
"#208 physical X/Y match (square pixels)")
-- single-dpi (getDPIScale-only) path would stretch X:
-- 7 * dpiX / dpiY ≈ 6.989 ≠ 7
check(math.abs(7 * dpiX / dpiY - 7) > 1e-6,
"#208 fixture really has dpiX ≠ dpiY (guards the regression)")
-- #208 severe case: unit aspect swapped vs drawable (forced-rotation /
-- dual-screen mis-report). Uniform dpi would heavily stretch one axis;
-- anisotropic scales keep 7x7 physical GB pixels.
Zoom.reset()
g.getDimensions = function() return 1080, 1920 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1080 / 1920 end
eq(Renderer:fitScale(), 7, "#208 swapped-aspect: fitScale from drawable")
physX = Renderer:drawScaleX() * (1920 / 1080)
physY = Renderer:drawScaleY() * (1080 / 1920)
check(math.abs(physX - 7) < 1e-9 and math.abs(physY - 7) < 1e-9,
"#208 swapped-aspect still yields square 7x7 physical GB pixels")
-- missing pixel API falls back to getDimensions (headless / old stub) -- missing pixel API falls back to getDimensions (headless / old stub)
g.getPixelDimensions = nil g.getPixelDimensions = nil
@@ -3003,6 +3025,13 @@ eq(frameFor("WATER", true), 3, "WATER animates to the walk frame")
-- icons outside the table keep the old uniform fallback -- icons outside the table keep the old uniform fallback
eq(frameFor("BALL", true, 96), 3, "fallback: 16x96 sheet animates to 3") eq(frameFor("BALL", true, 96), 3, "fallback: 16x96 sheet animates to 3")
eq(frameFor("HELIX", true, 32), 1, "fallback: 16x32 sheet animates to 1") eq(frameFor("HELIX", true, 32), 1, "fallback: 16x32 sheet animates to 1")
-- party list Y (pokered party_menu.asm hlcoord 3, 0 + 2-row stride). #262
-- A +12 offset pushed slot 6 into the bottom message box at tile row 12.
local entryY = require("src.ui.PartyMenu").entryY
eq(entryY(1), 0, "party slot 1 name row is y=0")
eq(entryY(6), 80, "party slot 6 name row is y=80")
check(entryY(6) + 8 < 96, "slot 6 HP row stays above the message box (y=96)")
end end
-- ---------------------------------------------- suite discovery -- ---------------------------------------------- suite discovery