mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3954f93653 | |||
| b02f8c7daf | |||
| 3461937dbc |
@@ -2781,6 +2781,13 @@ function BattleState:enemyMonFainted()
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
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", {
|
||||
battle = self, mon = mon, gained = gained, levels = levels,
|
||||
})
|
||||
|
||||
@@ -184,13 +184,22 @@ function Evolution.request(game, mon, trigger, onDone)
|
||||
return species
|
||||
end
|
||||
|
||||
-- After-battle hook: evolve everyone who qualifies (queued one at a time).
|
||||
function Evolution.checkParty(game, onDone)
|
||||
-- After-battle hook: evolve mons that leveled this battle and still
|
||||
-- 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 = {}
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
|
||||
if target then
|
||||
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
|
||||
if leveledUp then
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
if leveledUp[mon] then
|
||||
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
|
||||
local i = 0
|
||||
|
||||
+105
-86
@@ -39,39 +39,42 @@ end
|
||||
-- endFrame composites the padded canvas back with a matching offset.
|
||||
Renderer.UPRIGHT_MARGIN = 160
|
||||
|
||||
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
|
||||
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
|
||||
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
|
||||
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
|
||||
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
||||
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
||||
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
|
||||
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
|
||||
-- Integer scaling in units then maps each GB pixel to a fractional number of
|
||||
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
|
||||
-- judder (issue #87). Always derive the crisp integer scale from the
|
||||
-- *drawable* pixel size (the window framebuffer we present into -- not a
|
||||
-- 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
|
||||
-- (getDPIScale), NOT the drawable/unit size ratio pw/ww. On a normal device
|
||||
-- those are equal (getPixelDimensions == getDimensions * getDPIScale), but on
|
||||
-- the AYN Thor dual-screen surface in forced landscape they diverge: LOVE
|
||||
-- reports pw/ww ≈ 1 while its real transform is 1.5, so scaling by pw/ww lands
|
||||
-- each GB pixel on Sp*(getDPIScale/(pw/ww)) physical pixels -- a fractional,
|
||||
-- stretched/non-square count (issue #208). Prefer getDPIScale so draws land
|
||||
-- on whole physical pixels through LOVE's actual transform; fall back to pw/ww
|
||||
-- (then 1) only when getDPIScale is unavailable. Since getDPIScale == pw/ww
|
||||
-- on every normal device, #87's behavior is byte-identical there.
|
||||
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
|
||||
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
|
||||
-- highdpi surface, but diverge when unit sizes are truncated independently
|
||||
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
|
||||
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
|
||||
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
|
||||
-- for both axes makes the other axis land on a fractional, stretched count.
|
||||
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
||||
-- on BOTH axes (square).
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
local dpi
|
||||
if love.graphics.getDPIScale then
|
||||
dpi = love.graphics.getDPIScale()
|
||||
local dpiX, dpiY = 1, 1
|
||||
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
||||
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
|
||||
if (not dpi or dpi < 1e-6) and ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
end
|
||||
if not dpi or dpi < 1e-6 then dpi = 1 end
|
||||
return ww, wh, pw, ph, dpi
|
||||
if dpiX < 1e-6 then dpiX = 1 end
|
||||
if dpiY < 1e-6 then dpiY = 1 end
|
||||
return ww, wh, pw, ph, dpiX, dpiY
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
@@ -105,33 +108,43 @@ end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
-- 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()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
end
|
||||
|
||||
-- The LOVE-unit draw scale endFrame uses for the UI blit: the integer
|
||||
-- framebuffer scale (fitScale) divided by the live coordinate->pixel factor,
|
||||
-- so a GB pixel lands on fitScale() whole PHYSICAL pixels once LOVE applies
|
||||
-- its own transform (fitScale() == drawScale() * dpi). Exposed so #208's
|
||||
-- regression can assert GB pixels stay square on divergent-DPI surfaces
|
||||
-- without reaching into endFrame's locals; endFrame recomputes the same value
|
||||
-- inline (`S = Sp / dpi`).
|
||||
function Renderer:drawScale()
|
||||
local _, _, _, _, dpi = displayMetrics()
|
||||
return self:fitScale() / dpi
|
||||
-- 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
|
||||
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
|
||||
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
|
||||
function Renderer:drawScaleX()
|
||||
local _, _, _, _, dpiX = displayMetrics()
|
||||
return self:fitScale() / dpiX
|
||||
end
|
||||
|
||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
||||
-- 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:drawScaleY()
|
||||
local _, _, _, _, _, dpiY = displayMetrics()
|
||||
return self:fitScale() / dpiY
|
||||
end
|
||||
|
||||
-- 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()
|
||||
local ww, wh, _, _, dpi = displayMetrics()
|
||||
local s = Zoom.scale(self:fitScale()) / dpi
|
||||
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
local sp = Zoom.scale(self:fitScale())
|
||||
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
||||
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||
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
|
||||
-- into the surrounding window, matching BattleTransition wipe progress.
|
||||
-- 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
|
||||
local TILE = 8 * S
|
||||
if TILE < 1 then TILE = 1 end
|
||||
local cols = math.ceil(ww / TILE)
|
||||
local rows = math.ceil(wh / TILE)
|
||||
Sy = Sy or Sx
|
||||
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
|
||||
if TILE_W < 1 then TILE_W = 1 end
|
||||
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 order = {}
|
||||
for row = 0, rows - 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
|
||||
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
|
||||
local dist = math.max(math.abs(x + TILE / 2 - cx),
|
||||
math.abs(y + TILE / 2 - cy))
|
||||
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_W / 2 - cx),
|
||||
math.abs(y + TILE_H / 2 - cy))
|
||||
order[#order + 1] = { x, y, dist }
|
||||
end
|
||||
end
|
||||
@@ -200,7 +216,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
for i = 1, n do
|
||||
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
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -323,10 +339,11 @@ end
|
||||
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
||||
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
||||
-- 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 mesh = self:tiltMesh()
|
||||
if not (shader and mesh) then return false end
|
||||
sy = sy or sx
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
|
||||
@@ -375,7 +392,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
||||
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
||||
love.graphics.push()
|
||||
love.graphics.translate(wox, woy)
|
||||
love.graphics.scale(s, s)
|
||||
love.graphics.scale(sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
love.graphics.draw(mesh)
|
||||
@@ -417,14 +434,15 @@ end
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas()
|
||||
local ww, wh, pw, ph, dpi = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
|
||||
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel;
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
local Sp = self:fitScale()
|
||||
local S = Sp / dpi
|
||||
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
||||
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - self.HEIGHT * 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.
|
||||
@@ -469,14 +487,15 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
|
||||
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
||||
-- coords (canvas-space) into screen units.
|
||||
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||
-- convert zone coords (canvas-space) into screen units.
|
||||
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
|
||||
if not shader then
|
||||
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()
|
||||
return
|
||||
end
|
||||
@@ -492,10 +511,10 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setShader(not plain and shader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
|
||||
z.w * zoneScale, z.h * zoneScale,
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
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
|
||||
love.graphics.setScissor()
|
||||
@@ -510,7 +529,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
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()
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
@@ -520,15 +539,15 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
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
|
||||
elseif self.worldActive then
|
||||
local sp = Zoom.scale(Sp)
|
||||
local s = sp / dpi
|
||||
local sx, sy = sp / dpiX, sp / dpiY
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = math.floor((pw - wvw * sp) / 2) / dpi
|
||||
local woy = math.floor((ph - wvh * sp) / 2) / dpi
|
||||
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
|
||||
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- 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
|
||||
-- identical to today.
|
||||
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 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
|
||||
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
|
||||
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
||||
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
||||
@@ -562,11 +581,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
||||
if r.quad then
|
||||
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
|
||||
0, s * r.sx, s)
|
||||
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
|
||||
0, sx * r.sx, sy)
|
||||
else
|
||||
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
|
||||
0, s * r.sx, s)
|
||||
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
|
||||
0, sx * r.sx, sy)
|
||||
end
|
||||
end
|
||||
if activeShader then love.graphics.setShader() end
|
||||
@@ -583,7 +602,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local M = self.UPRIGHT_MARGIN
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
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()
|
||||
end
|
||||
-- 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
|
||||
-- classic 160x144 wipe square (world still shows through until filled).
|
||||
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
|
||||
-- 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
|
||||
love.graphics.setCanvas()
|
||||
@@ -613,7 +632,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- grid itself. Each pass hands back a canvas; with none registered
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
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
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
|
||||
+10
-2
@@ -465,6 +465,14 @@ function PartyMenu:bottomMessage()
|
||||
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()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -476,9 +484,9 @@ function PartyMenu:draw()
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
for i, mon in ipairs(party) do
|
||||
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)
|
||||
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)
|
||||
Font.draw(mon.nickname or def.name, 24, y)
|
||||
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
|
||||
|
||||
@@ -3257,7 +3257,9 @@ function OverworldState:afterBattle(result, battle)
|
||||
lead and lead.stats.hp or 0)
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
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
|
||||
if result == "lose" then
|
||||
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
|
||||
-- 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.
|
||||
-- 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
|
||||
-- assert the mon becomes METAPOD with the "Congratulations!" text.
|
||||
--
|
||||
@@ -103,6 +105,27 @@ return function(game)
|
||||
assert(done1, "cancel onDone never fired")
|
||||
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 ===
|
||||
local mon2 = Pokemon.new(game.data, "CATERPIE", 7)
|
||||
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
|
||||
@@ -512,6 +512,46 @@ do
|
||||
"the registered method's own gate holds")
|
||||
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
|
||||
|
||||
do
|
||||
|
||||
+47
-18
@@ -1845,9 +1845,12 @@ do
|
||||
eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- dpi fit scale (#87)
|
||||
-- ---------------------------------------------------------------- dpi fit scale (#87 / #208)
|
||||
-- Android density is often non-integer; fitScale must use framebuffer
|
||||
-- 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
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
@@ -1873,27 +1876,46 @@ do
|
||||
local vw, vh = Renderer:worldViewSize()
|
||||
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
|
||||
eq(vw, 276, "world fill width covers the unit window at pixel scale 7")
|
||||
eq(vh, 156, "world fill height 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 drawable at pixel scale 7")
|
||||
|
||||
-- #208: on a dual-screen surface (AYN Thor, forced landscape) LOVE can
|
||||
-- report drawable == unit size (pw/ww == 1) while its real coordinate->pixel
|
||||
-- transform is 1.5. fitScale stays keyed off the drawable pixel size, but
|
||||
-- the unit<->pixel conversion must use the REAL getDPIScale, not pw/ww, so
|
||||
-- each GB pixel still lands on a WHOLE number of physical pixels (square) --
|
||||
-- not the stretched 6-vs-9-device-px fractional pixels the reporter saw.
|
||||
-- Before the fix drawScale()==Sp/(pw/ww)==7 and 7*1.5==10.5 px/GB px.
|
||||
-- #208: axis DPI mismatch. LOVE's projection uses pw/ww on X and ph/wh
|
||||
-- on Y independently; getDPIScale() is only ph/wh. A single-dpi draw
|
||||
-- scale makes one axis fractional → stretched / non-square GB pixels
|
||||
-- (fonts especially). Truncated density-2.75 unit sizes on 1080p:
|
||||
local dpiX = 1920 / 698
|
||||
local dpiY = 1080 / 392
|
||||
Zoom.reset()
|
||||
g.getDimensions = function() return 1920, 1080 end
|
||||
g.getDimensions = function() return 698, 392 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,
|
||||
"#208 divergent DPI: fitScale still 7 from drawable pixels")
|
||||
local physical = Renderer:drawScale() * g.getDPIScale()
|
||||
local rounded = math.floor(physical + 0.5)
|
||||
check(math.abs(physical - rounded) < 1e-9,
|
||||
"#208 GB pixel lands on a whole number of physical pixels (square)")
|
||||
eq(rounded, 7, "#208 each GB pixel covers exactly fitScale (7) physical px")
|
||||
"#208 anisotropic DPI: fitScale still 7 from drawable pixels")
|
||||
local physX = Renderer:drawScaleX() * dpiX
|
||||
local physY = Renderer:drawScaleY() * dpiY
|
||||
check(math.abs(physX - 7) < 1e-9,
|
||||
"#208 GB pixel covers exactly fitScale (7) physical px on X")
|
||||
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)
|
||||
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
|
||||
eq(frameFor("BALL", true, 96), 3, "fallback: 16x96 sheet animates to 3")
|
||||
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
|
||||
|
||||
-- ---------------------------------------------- suite discovery
|
||||
|
||||
Reference in New Issue
Block a user