mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-02 15:50:39 -04:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -12,9 +12,10 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
|
||||
-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE.
|
||||
local emergencyQuitTimer = 0
|
||||
-- getJoysticks() allocates a fresh table every call; this runs once (twice)
|
||||
-- per frame, so cache the list and refresh it once a second instead. The 5s
|
||||
-- hold requirement makes a 1s hotplug delay irrelevant.
|
||||
-- getJoysticks() allocates a fresh table every call; this runs once per
|
||||
-- frame (from love.run, before love.update), so cache the list and refresh
|
||||
-- it once a second instead. The 5s hold requirement makes a 1s hotplug
|
||||
-- delay irrelevant.
|
||||
local cachedJoysticks = nil
|
||||
local joystickCacheAge = 1
|
||||
|
||||
@@ -929,7 +930,8 @@ function love.load(args)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
checkEmergencyQuit(dt)
|
||||
-- checkEmergencyQuit runs from love.run each frame; calling it here too
|
||||
-- would double-count dt and fire the 5s hold after 2.5s.
|
||||
HostDisplay.update(dt)
|
||||
SwitchDiagnostics.maybeFlush(false)
|
||||
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
||||
@@ -1242,8 +1244,20 @@ function love.joystickhat(joystick, hat, direction)
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- The joystick lists cached for polling (checkEmergencyQuit's, Input's
|
||||
-- pollPads) are only refreshed on a count change or a timer otherwise, which
|
||||
-- a controller swapped for another can slip past.
|
||||
local function noteJoysticksChanged()
|
||||
cachedJoysticks = nil
|
||||
local input = Game and Game.input
|
||||
if type(input) == "table" and input.joysticksChanged then
|
||||
input:joysticksChanged()
|
||||
end
|
||||
end
|
||||
|
||||
function love.joystickadded(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
||||
noteJoysticksChanged()
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
@@ -1252,6 +1266,7 @@ end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
||||
noteJoysticksChanged()
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
|
||||
+62
-36
@@ -722,6 +722,25 @@ local function sameColors(a, b)
|
||||
return true
|
||||
end
|
||||
|
||||
-- one OAM tile at its screen rect (rx, ry), honoring the X/Y flips
|
||||
local function blitTile(g, img, quad, s, rx, ry)
|
||||
g.draw(img, quad,
|
||||
rx + (s.xf and 8 or 0),
|
||||
ry + (s.yf and 8 or 0),
|
||||
0,
|
||||
s.xf and -1 or 1,
|
||||
s.yf and -1 or 1)
|
||||
end
|
||||
|
||||
-- the shade-remap uniforms; c0 is the transparent color-0 slot, so it just
|
||||
-- takes color 1
|
||||
local function sendTriple(shader, colors)
|
||||
shader:send("c0", colors[1])
|
||||
shader:send("c1", colors[1])
|
||||
shader:send("c2", colors[2])
|
||||
shader:send("c3", colors[3])
|
||||
end
|
||||
|
||||
-- Draw one compiled step's OAM sprites. With colorFn, each sprite is
|
||||
-- drawn through the PaletteFX shade-remap shader. The SGB colorized
|
||||
-- the finished DMG picture per 8x8 screen cell (the ATTR_BLK regions
|
||||
@@ -730,14 +749,22 @@ end
|
||||
-- once per attribute cell the 8x8 tile touches (up to 4), and cells
|
||||
-- that resolve to a different palette than the first are repainted
|
||||
-- through a scissor clipped to the cell.
|
||||
--
|
||||
-- Per-frame cost: no closures per tile, and the uniforms are only re-sent
|
||||
-- when the colors differ from the last ones this call sent (colorFn
|
||||
-- memoizes, so equal colors are usually the same table). The first send
|
||||
-- of every call is unconditional -- other passes share the shader.
|
||||
function AnimPlayer:drawSprites(sprites, colorFn)
|
||||
local g = love and love.graphics
|
||||
local shader
|
||||
local PaletteFX, shader
|
||||
if colorFn and g and g.setShader then
|
||||
shader = require("src.render.PaletteFX").shader()
|
||||
PaletteFX = require("src.render.PaletteFX")
|
||||
shader = PaletteFX.shader()
|
||||
end
|
||||
local slices = shader and g.getScissor and g.intersectScissor
|
||||
and g.setScissor
|
||||
local sent -- the triple the shader currently holds (this call)
|
||||
local floor = math.floor
|
||||
for i = 1, #sprites do
|
||||
local s = sprites[i]
|
||||
-- hardware hides sprites at the OAM extremes (y=0/y>=160, x=0/x>=168);
|
||||
@@ -747,51 +774,48 @@ function AnimPlayer:drawSprites(sprites, colorFn)
|
||||
local quad = img and self:tileQuad(s.ts, s.tile)
|
||||
if quad then
|
||||
local rx, ry = s.x - 8, s.y - 16 -- screen-space rect of the tile
|
||||
local function blit()
|
||||
g.draw(img, quad,
|
||||
rx + (s.xf and 8 or 0),
|
||||
ry + (s.yf and 8 or 0),
|
||||
0,
|
||||
s.xf and -1 or 1,
|
||||
s.yf and -1 or 1)
|
||||
end
|
||||
-- the attribute cell holding the tile's top-left pixel
|
||||
local cx = math.floor(rx / 8) * 8
|
||||
local cy = math.floor(ry / 8) * 8
|
||||
local cx = floor(rx / 8) * 8
|
||||
local cy = floor(ry / 8) * 8
|
||||
local colors = shader and colorFn(s, cx, cy)
|
||||
if colors then
|
||||
g.setShader(shader)
|
||||
-- c0 is the transparent color-0 slot; send anything
|
||||
shader:send("c0", colors[1])
|
||||
shader:send("c1", colors[1])
|
||||
shader:send("c2", colors[2])
|
||||
shader:send("c3", colors[3])
|
||||
if not (sent and sameColors(sent, colors)) then
|
||||
sendTriple(shader, colors)
|
||||
sent = colors
|
||||
end
|
||||
end
|
||||
blit()
|
||||
blitTile(g, img, quad, s, rx, ry)
|
||||
if colors and slices and (cx ~= rx or cy ~= ry) then
|
||||
-- unaligned: the tile spills into up to 3 more cells; repaint
|
||||
-- the ones whose zone palette differs (opaque overdraw -- GB
|
||||
-- tiles have binary alpha)
|
||||
local function slice(px, py)
|
||||
if px < 0 or py < 0 or px >= 160 or py >= 144 then
|
||||
return -- fully off-canvas
|
||||
local cx2 = floor((rx + 7) / 8) * 8
|
||||
local cy2 = floor((ry + 7) / 8) * 8
|
||||
for n = 1, 3 do
|
||||
local px, py
|
||||
if n == 1 then
|
||||
if cx2 ~= cx then px, py = cx2, cy end
|
||||
elseif n == 2 then
|
||||
if cy2 ~= cy then px, py = cx, cy2 end
|
||||
elseif cx2 ~= cx and cy2 ~= cy then
|
||||
px, py = cx2, cy2
|
||||
end
|
||||
-- skip cells fully off-canvas
|
||||
if px and px >= 0 and py >= 0 and px < 160 and py < 144 then
|
||||
local cc = colorFn(s, px, py)
|
||||
if cc and not sameColors(cc, colors) then
|
||||
local s1, s2, s3, s4 = g.getScissor()
|
||||
g.intersectScissor(px, py, 8, 8)
|
||||
if not sameColors(sent, cc) then
|
||||
sendTriple(shader, cc)
|
||||
sent = cc
|
||||
end
|
||||
blitTile(g, img, quad, s, rx, ry)
|
||||
if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end
|
||||
end
|
||||
end
|
||||
local cc = colorFn(s, px, py)
|
||||
if not cc or sameColors(cc, colors) then return end
|
||||
local s1, s2, s3, s4 = g.getScissor()
|
||||
g.intersectScissor(px, py, 8, 8)
|
||||
shader:send("c0", cc[1])
|
||||
shader:send("c1", cc[1])
|
||||
shader:send("c2", cc[2])
|
||||
shader:send("c3", cc[3])
|
||||
blit()
|
||||
if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end
|
||||
end
|
||||
local cx2 = math.floor((rx + 7) / 8) * 8
|
||||
local cy2 = math.floor((ry + 7) / 8) * 8
|
||||
if cx2 ~= cx then slice(cx2, cy) end
|
||||
if cy2 ~= cy then slice(cx, cy2) end
|
||||
if cx2 ~= cx and cy2 ~= cy then slice(cx2, cy2) end
|
||||
end
|
||||
if colors then
|
||||
g.setShader()
|
||||
@@ -799,6 +823,8 @@ function AnimPlayer:drawSprites(sprites, colorFn)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- the direct sends above bypass sendColors' last-sent record
|
||||
if sent then PaletteFX.forgetSent(shader) end
|
||||
end
|
||||
|
||||
return AnimPlayer
|
||||
|
||||
+182
-33
@@ -247,6 +247,14 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
|
||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||
-- rebuilt for any battle pic, whatever code loaded it
|
||||
local imageMeta = setmetatable({}, WEAK_KEYS)
|
||||
-- image -> memoized fadeImage / blackImage variants (the per-frame picImage
|
||||
-- lookups). They cache what getImage returned, so invalidate() drops them
|
||||
-- together with imageCache.
|
||||
local function newDerivedMemo()
|
||||
return { fade = setmetatable({}, WEAK_KEYS),
|
||||
black = setmetatable({}, WEAK_KEYS) }
|
||||
end
|
||||
local derivedMemo = newDerivedMemo()
|
||||
local function mattedPic(path)
|
||||
return path:sub(1, 17) == "assets/generated/"
|
||||
or path:sub(1, 17) == "save/mod-derived/"
|
||||
@@ -326,6 +334,7 @@ end
|
||||
-- are collected on their own rather than leaking.
|
||||
function BattleState.invalidate()
|
||||
imageCache = {}
|
||||
derivedMemo = newDerivedMemo()
|
||||
end
|
||||
|
||||
Assets.register(BattleState.invalidate)
|
||||
@@ -434,12 +443,28 @@ local function fadeImage(img, bgp)
|
||||
if not meta then return img end
|
||||
-- a full-color pic has no DMG shades to remap
|
||||
if meta.trueColor then return img end
|
||||
-- picImage asks for this every drawn frame of a fade: memoize per
|
||||
-- (pic, shade map) instead of rebuilding the name string + palette table
|
||||
-- (meta is fixed per image; the map's four 0..3 shades are the rest of
|
||||
-- getImage's key, packed into one number)
|
||||
local mkey = bgp[0] * 64 + bgp[1] * 16 + bgp[2] * 4 + bgp[3]
|
||||
local row = derivedMemo.fade[img]
|
||||
local hit = row and row[mkey]
|
||||
if hit then return hit end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local base = meta.pal and meta.pal.colors or PaletteFX.GRAYS
|
||||
local name = (meta.pal and meta.pal.name or "GB")
|
||||
.. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3]
|
||||
return getImage(meta.path,
|
||||
{ name = name, colors = PaletteFX.permute(base, bgp) })
|
||||
local out = getImage(meta.path,
|
||||
{ name = name, colors = PaletteFX.permute(base, bgp) })
|
||||
if out then
|
||||
if not row then
|
||||
row = {}
|
||||
derivedMemo.fade[img] = row
|
||||
end
|
||||
row[mkey] = out
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- the raw DMG-gray build of a colored pic (SE_WAVY_SCREEN bakes the
|
||||
@@ -463,7 +488,15 @@ local function blackImage(data, img)
|
||||
local colors = PaletteFX.usesYellowCgb() and pals.BLACK
|
||||
or PaletteFX.pal(data, "BLACK") or pals.BLACK
|
||||
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
|
||||
return getImage(meta.path, { name = name, colors = colors }) or img
|
||||
-- drawn every frame of the intro slide / blackout text: reuse the last
|
||||
-- bake while the resolved palette and cache name are unchanged
|
||||
local memo = derivedMemo.black[img]
|
||||
if memo and memo.colors == colors and memo.name == name then
|
||||
return memo.out
|
||||
end
|
||||
local out = getImage(meta.path, { name = name, colors = colors }) or img
|
||||
derivedMemo.black[img] = { colors = colors, name = name, out = out }
|
||||
return out
|
||||
end
|
||||
|
||||
-- the asset path a loaded battle image came from (nil for the headless
|
||||
@@ -767,6 +800,18 @@ function BattleState:playerPartyView()
|
||||
end
|
||||
|
||||
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
||||
-- Queue the battle's first two cries on the chip audio worker while the
|
||||
-- transition runs, so the entrance cries don't render on the frame they
|
||||
-- play. A no-op without a worker; playCry renders as before on a miss.
|
||||
local function prewarmCries(self)
|
||||
local Sound = package.loaded["src.core.Sound"]
|
||||
if not (Sound and Sound.prewarmCry) then return end
|
||||
for _, battler in ipairs({ self.enemy, self.player }) do
|
||||
local mon = battler and battler.mon
|
||||
if mon and mon.species then pcall(Sound.prewarmCry, self.data, mon.species) end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState.newWild(game, species, level, opts)
|
||||
local self = newBattle(game)
|
||||
self.kind = "wild"
|
||||
@@ -784,6 +829,7 @@ function BattleState.newWild(game, species, level, opts)
|
||||
else
|
||||
self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name)
|
||||
end
|
||||
prewarmCries(self)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -917,6 +963,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex, opts)
|
||||
self.trainerPic = BattleState.trainerSprite(
|
||||
game.data, self.trainer, oppClass, partyIndex)
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
prewarmCries(self)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -2101,9 +2148,17 @@ end
|
||||
-- flag survived into the next turn, ate a move the real players saw
|
||||
-- land, and from there the replay was watching a different battle.
|
||||
-- LinkBattle.newSpectator calls this at the head of every turn.
|
||||
local function clearFlinch(b)
|
||||
if not (b.mustRecharge or b.rageMove) then b.flinched = false end
|
||||
end
|
||||
|
||||
function BattleState:clearTurnFlinches()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
|
||||
-- ipairs({ self.player, self.enemy }) without the per-call table: the
|
||||
-- enemy is only visited when the player slot is set, like ipairs did
|
||||
local p = self.player
|
||||
if p then
|
||||
clearFlinch(p)
|
||||
if self.enemy then clearFlinch(self.enemy) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2305,16 +2360,37 @@ function BattleState:tickFx()
|
||||
self:updateFx()
|
||||
end
|
||||
|
||||
-- ScrollTextUpOneLine's pixel scroll (beginMsgLine sets scrollPx = 8):
|
||||
-- 2px per logic step while the message box shows the rolling window. It
|
||||
-- used to count down inside drawTextArea / WideBattle's drawMessageBox, so
|
||||
-- a 144Hz display scrolled 2.4x too fast; update() now runs this once per
|
||||
-- step AFTER the queue typed, which is the order the draw-side decrement
|
||||
-- saw it (8 is set and the box already draws at 6 on that frame).
|
||||
function BattleState:tickTextScroll()
|
||||
local px = self.scrollPx
|
||||
if px and px > 0 and self.phase == "messages"
|
||||
and (self.current or self.animPlaying or self.msgHold) then
|
||||
px = px - 2
|
||||
self.scrollPx = px > 0 and px or nil
|
||||
end
|
||||
end
|
||||
|
||||
local function snapIdleBar(b)
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
|
||||
-- runs every menu-phase step: no per-call { player, enemy } table (the
|
||||
-- enemy is only visited when the player slot is set, like ipairs did)
|
||||
function BattleState:snapIdleBars()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
local p = self.player
|
||||
if p then
|
||||
snapIdleBar(p)
|
||||
if self.enemy then snapIdleBar(self.enemy) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2357,6 +2433,7 @@ function BattleState:update(dt)
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
self:tickTextScroll()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -5820,6 +5897,26 @@ local HudTiles = require("src.render.HudTiles")
|
||||
local hudTile = HudTiles.tile
|
||||
local drawHPBar = HudTiles.drawHPBar
|
||||
|
||||
-- One reusable row-clip quad per battler for the faint / slide pic effects
|
||||
-- (drawn every frame they run): re-aimed with setViewport instead of a new
|
||||
-- Quad per frame. A quad without setViewport (headless stubs) is simply
|
||||
-- rebuilt, which is what every frame used to do.
|
||||
function BattleState:picQuad(battler, x, y, w, h, sw, sh)
|
||||
local quads = self.picQuads
|
||||
if not quads then
|
||||
quads = setmetatable({}, WEAK_KEYS)
|
||||
self.picQuads = quads
|
||||
end
|
||||
local q = quads[battler]
|
||||
if q and q.setViewport then
|
||||
q:setViewport(x, y, w, h, sw, sh)
|
||||
return q
|
||||
end
|
||||
q = love.graphics.newQuad(x, y, w, h, sw, sh)
|
||||
quads[battler] = q
|
||||
return q
|
||||
end
|
||||
|
||||
-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile.
|
||||
-- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one
|
||||
-- charmap sequence per glyph, and byte length would push it a tile left.
|
||||
@@ -6084,8 +6181,8 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
|
||||
end
|
||||
local visible = img:getHeight() - math.floor(off / scale)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
|
||||
img:getWidth(), img:getHeight())
|
||||
local quad = self:picQuad(battler, 0, 0, img:getWidth(), visible,
|
||||
img:getWidth(), img:getHeight())
|
||||
love.graphics.draw(img, quad, x, y + off, 0, scale, scale)
|
||||
end
|
||||
return
|
||||
@@ -6170,7 +6267,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
|
||||
-- sink below the baseline (AnimationSlideMonDown-style row clip)
|
||||
local visible = h - math.floor(oy / scale)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, 0, w, visible, w, h)
|
||||
local quad = self:picQuad(battler, 0, 0, w, visible, w, h)
|
||||
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
|
||||
end
|
||||
elseif k == "slideUp" then
|
||||
@@ -6181,7 +6278,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
|
||||
local step = math.min(7, math.floor((t - 1) / 2) + 1)
|
||||
local visible = math.floor(h * step / 7)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
|
||||
local quad = self:picQuad(battler, 0, h - visible, w, visible, w, h)
|
||||
love.graphics.draw(img, quad, x + ox,
|
||||
y + (h - visible) * scale, 0, scale, scale)
|
||||
end
|
||||
@@ -6291,9 +6388,10 @@ function BattleState:sgbBattlePals()
|
||||
return out
|
||||
end
|
||||
|
||||
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
|
||||
function BattleState:zoneColorsAt(x, y)
|
||||
local pals = self:sgbBattlePals()
|
||||
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions).
|
||||
-- pals: an sgbBattlePals() result the caller already resolved this frame.
|
||||
function BattleState:zoneColorsAt(x, y, pals)
|
||||
pals = pals or self:sgbBattlePals()
|
||||
if not pals then return nil end
|
||||
local tx = math.floor(x / 8)
|
||||
local ty = math.floor(y / 8)
|
||||
@@ -6358,13 +6456,32 @@ function BattleState:drawZonePass(src, sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
local shaking = sx ~= 0 or sy ~= 0
|
||||
-- the six zones share four palettes (one gray ramp in mono): permute each
|
||||
-- once per pass instead of once per zone
|
||||
local permuted = self.zonePermuted
|
||||
if not permuted then
|
||||
permuted = {}
|
||||
self.zonePermuted = permuted
|
||||
end
|
||||
permuted[0], permuted[1], permuted[2], permuted[3], permuted.gray =
|
||||
nil, nil, nil, nil, nil
|
||||
for _, z in ipairs(BATTLE_ZONES) do
|
||||
if mono then
|
||||
-- the BGP fade still runs, just in gray: the frame-level pass colors
|
||||
-- whatever DMG shade this leaves behind
|
||||
PaletteFX.sendShades(shader, PaletteFX.permute(PaletteFX.GRAYS, bgp))
|
||||
local shades = permuted.gray
|
||||
if not shades then
|
||||
shades = PaletteFX.permute(PaletteFX.GRAYS, bgp)
|
||||
permuted.gray = shades
|
||||
end
|
||||
PaletteFX.sendShades(shader, shades)
|
||||
else
|
||||
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
|
||||
local colors = permuted[z.pal]
|
||||
if not colors then
|
||||
colors = PaletteFX.permute(pals[z.pal], bgp)
|
||||
permuted[z.pal] = colors
|
||||
end
|
||||
PaletteFX.sendColors(shader, colors)
|
||||
end
|
||||
local zx, zy = z[1] * 8, z[2] * 8
|
||||
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
|
||||
@@ -6390,7 +6507,18 @@ local OBJ_SHADES = {
|
||||
e4x = { 2, 1, 3 }, -- $e4 xor %00111100 = $d8
|
||||
obp1 = { 3, 2, 1 }, -- $6c
|
||||
}
|
||||
function BattleState:animSpriteColors(s, px, py)
|
||||
local function shadeTriple(P, shade)
|
||||
local col = P[shade + 1]
|
||||
return { col[1] / 255, col[2] / 255, col[3] / 255 }
|
||||
end
|
||||
|
||||
-- memo (optional): a per-frame table from drawAnimLayer. It holds the
|
||||
-- frame's sgbBattlePals() (memo.pals, false when there are none) so the
|
||||
-- palette set is resolved once per frame instead of once per attribute
|
||||
-- cell of every OAM tile, and the finished triples per (palette, obp key)
|
||||
-- so tiles in the same zone share one table (AnimPlayer then sees equal
|
||||
-- colors by identity).
|
||||
function BattleState:animSpriteColors(s, px, py, memo)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local key = s.obp or "f0"
|
||||
local P
|
||||
@@ -6399,16 +6527,32 @@ function BattleState:animSpriteColors(s, px, py)
|
||||
-- engine/battle/init_battle_variables.asm:18
|
||||
P = PaletteFX.ogObjBase()
|
||||
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
|
||||
elseif memo then
|
||||
local pals = memo.pals
|
||||
if pals == nil then
|
||||
pals = self:sgbBattlePals() or false
|
||||
memo.pals = pals
|
||||
end
|
||||
P = pals and self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4),
|
||||
pals) or nil
|
||||
else
|
||||
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
|
||||
end
|
||||
if not P then return nil end
|
||||
local byKey = memo and memo[P]
|
||||
local hit = byKey and byKey[key]
|
||||
if hit then return hit end
|
||||
local m = OBJ_SHADES[key] or OBJ_SHADES.f0
|
||||
local function c(shade)
|
||||
local col = P[shade + 1]
|
||||
return { col[1] / 255, col[2] / 255, col[3] / 255 }
|
||||
local out = { shadeTriple(P, m[1]), shadeTriple(P, m[2]),
|
||||
shadeTriple(P, m[3]) }
|
||||
if memo then
|
||||
if not byKey then
|
||||
byKey = {}
|
||||
memo[P] = byKey
|
||||
end
|
||||
byKey[key] = out
|
||||
end
|
||||
return { c(m[1]), c(m[2]), c(m[3]) }
|
||||
return out
|
||||
end
|
||||
|
||||
-- the OAM anim layer (subanimation sprites / the resting caught ball)
|
||||
@@ -6417,7 +6561,16 @@ function BattleState:drawAnimLayer(colorized)
|
||||
if self.fieldCleared then return end
|
||||
local colorFn
|
||||
if colorized then
|
||||
colorFn = function(s, px, py) return self:animSpriteColors(s, px, py) end
|
||||
-- fresh per-frame memo (palette set + color triples), one long-lived
|
||||
-- closure: colorFn runs up to four times per OAM tile
|
||||
self.animColorMemo = {}
|
||||
colorFn = self.animColorFn
|
||||
if not colorFn then
|
||||
colorFn = function(s, px, py)
|
||||
return self:animSpriteColors(s, px, py, self.animColorMemo)
|
||||
end
|
||||
self.animColorFn = colorFn
|
||||
end
|
||||
end
|
||||
if self.animPlaying and self.animPlayer then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -6807,10 +6960,6 @@ function BattleState:drawTextArea()
|
||||
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
||||
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
||||
-- view instead of drawing off-screen at y=144 (#216).
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
|
||||
@@ -183,10 +183,8 @@ 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
|
||||
-- scrollPx counts down in BattleState:tickTextScroll (logic step), not
|
||||
-- here, so the scroll speed does not follow the display refresh rate
|
||||
local off = battle.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(battle.shown or {}) do
|
||||
|
||||
@@ -42,6 +42,7 @@ local NUM_STRUCTS = 10 -- NUM_BATTLE_ANIM_STRUCTS
|
||||
-- wShadowOAM is 40 objects; BattleAnimOAMUpdate returns carry once it is full
|
||||
-- and BattleAnim_UpdateOAM_All stops walking the structs.
|
||||
local OAM_LIMIT = 40
|
||||
local EMPTY = {}
|
||||
|
||||
local OAM_PRIO, OAM_YFLIP, OAM_XFLIP = 0x80, 0x40, 0x20
|
||||
local OAM_FLAG_MASK = 0xe0
|
||||
@@ -332,22 +333,31 @@ function Pool:updateOam(st)
|
||||
local tileId = u8(buf.tileId + (set.vtile or 0))
|
||||
local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0
|
||||
local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0
|
||||
for _, entry in ipairs(set.sprites or {}) do
|
||||
if #self.oam >= OAM_LIMIT then return true end
|
||||
local oam = self.oam
|
||||
local pool = self.oamPool
|
||||
for _, entry in ipairs(set.sprites or EMPTY) do
|
||||
local n = #oam
|
||||
if n >= OAM_LIMIT then return true end
|
||||
-- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's;
|
||||
-- OAM_PAL1 passes through from the entry, and the palette slot comes from
|
||||
-- the struct.
|
||||
local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK)
|
||||
attr = attr + bit.band(entry.attr or 0, OAM_PAL1)
|
||||
self.oam[#self.oam + 1] = {
|
||||
y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)),
|
||||
x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)),
|
||||
-- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by
|
||||
-- every sheet lookup, so the port keeps tiles in sheet-relative space.
|
||||
tile = u8(tileId + (entry.tile or 0)),
|
||||
attr = attr,
|
||||
palette = buf.palette,
|
||||
}
|
||||
-- Shadow OAM slots are recycled frame to frame (playFrame), the way the
|
||||
-- cart rewrites wShadowOAM in place: the 40 tables are made once.
|
||||
local out = pool and pool[n + 1]
|
||||
if not out then
|
||||
out = {}
|
||||
if pool then pool[n + 1] = out end
|
||||
end
|
||||
out.y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip))
|
||||
out.x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip))
|
||||
-- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by
|
||||
-- every sheet lookup, so the port keeps tiles in sheet-relative space.
|
||||
out.tile = u8(tileId + (entry.tile or 0))
|
||||
out.attr = attr
|
||||
out.palette = buf.palette
|
||||
oam[n + 1] = out
|
||||
end
|
||||
return false
|
||||
end
|
||||
@@ -356,8 +366,18 @@ end
|
||||
-- write its OAM entries. A struct that deinitialises itself inside its
|
||||
-- function still draws this frame, because the ASM calls BattleAnimOAMUpdate
|
||||
-- unconditionally.
|
||||
--
|
||||
-- The OAM list and its entries are reused from frame to frame: what a frame
|
||||
-- returns is valid until the next playFrame, which is all the draw needs.
|
||||
function Pool:playFrame()
|
||||
self.oam = {}
|
||||
local oam = self.oamList
|
||||
if not oam then
|
||||
oam = {}
|
||||
self.oamList = oam
|
||||
self.oamPool = {}
|
||||
end
|
||||
for i = #oam, 1, -1 do oam[i] = nil end
|
||||
self.oam = oam
|
||||
for slot = 1, NUM_STRUCTS do
|
||||
local st = self.structs[slot]
|
||||
if st.index ~= 0 then
|
||||
|
||||
+224
-26
@@ -84,6 +84,16 @@ local lastCosted
|
||||
local worker, cmdCh, outCh
|
||||
local workerReady -- nil = untried, true = running, false = unavailable
|
||||
|
||||
-- SFX/cry prewarm state (see "one-shot effects" below): results come back on
|
||||
-- their own channel so a music stop/clear never drops them
|
||||
local fxCh
|
||||
local fxEpoch = 0 -- bumped by invalidate: drops results from a stale def
|
||||
local fxReady = {} -- key -> SoundData | false (renders to nothing)
|
||||
local fxPending = {} -- key -> true while a request is in flight
|
||||
local fxReadyCount = 0
|
||||
local FX_READY_MAX = 64
|
||||
local drainEffects, dropEffects
|
||||
|
||||
local function ensureWorker()
|
||||
if workerReady ~= nil then return workerReady end
|
||||
if not (love.thread and love.thread.newThread and love.audio) then
|
||||
@@ -147,25 +157,70 @@ end
|
||||
|
||||
-- The queue is deep (MUSIC_BUFFER_COUNT, ~6s) for stall tolerance, but
|
||||
-- synthesizing all of it on the frame a song starts renders ~6s of audio at
|
||||
-- once. Cap how many buffers each fill renders; playback drains ~1 buffer
|
||||
-- every ~11 frames while update() tops up a few per frame, so the deep queue
|
||||
-- still ramps to full within a fraction of a second.
|
||||
-- once. A (re)start renders MUSIC_FILL_INITIAL whole buffers so playback has
|
||||
-- lookahead; after that each update() renders a flat slice of samples into a
|
||||
-- staging buffer and queues it once all MUSIC_BUFFER_SAMPLES are in. Whole
|
||||
-- 8192-sample buffers rendered on the frame a queue slot freed (every ~11
|
||||
-- frames) read as a periodic stutter; ~1024 samples per 60 Hz tick
|
||||
-- (playback drains ~735) keeps the per-frame cost flat while the queue still
|
||||
-- creeps up to full. Below MUSIC_FILL_LOW_WATER queued buffers (a long
|
||||
-- stall, or update() called well under 60 Hz) a tick renders a whole buffer's
|
||||
-- worth so the queue cannot run dry. The sample stream is unchanged: every
|
||||
-- buffer is still MUSIC_BUFFER_SAMPLES consecutive engine samples, and a
|
||||
-- buffer begun before the song ended is completed exactly as before.
|
||||
local MUSIC_FILL_INITIAL = 4
|
||||
local MUSIC_FILL_PER_CALL = 3
|
||||
local MUSIC_FILL_TICK_SAMPLES = 1024 -- at 44100 Hz; scaled with the rate
|
||||
local MUSIC_FILL_LOW_WATER = 2
|
||||
|
||||
local function fillSync(limit)
|
||||
local function tickSamples()
|
||||
return math.max(256,
|
||||
math.floor(MUSIC_FILL_TICK_SAMPLES * sampleRate() / 44100 + 0.5))
|
||||
end
|
||||
|
||||
-- render up to `budget` samples of the fallback song, queueing each staging
|
||||
-- buffer as it fills. A new buffer is only begun while the Source has a free
|
||||
-- slot and the song has not finished (the old whole-buffer loop's gates).
|
||||
local function renderSync(music, budget)
|
||||
while budget > 0 do
|
||||
if not music.staging then
|
||||
if music.engine:finished() then return end
|
||||
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
|
||||
if not ok or type(free) ~= "number" or free <= 0 then return end
|
||||
music.staging = ChipSynth.newBuffer(MUSIC_BUFFER_SAMPLES, 2)
|
||||
music.stagingFill = 0
|
||||
end
|
||||
local count = math.min(budget, MUSIC_BUFFER_SAMPLES - music.stagingFill)
|
||||
ChipSynth.renderInto(music.engine, music.staging, music.stagingFill,
|
||||
count, 2)
|
||||
music.stagingFill = music.stagingFill + count
|
||||
budget = budget - count
|
||||
if music.stagingFill >= MUSIC_BUFFER_SAMPLES then
|
||||
local sd = music.staging
|
||||
music.staging, music.stagingFill = nil, 0
|
||||
if not pcall(music.source.queue, music.source, sd) then return end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- `buffers`: render that many whole buffers now (song start / restart);
|
||||
-- omitted: one per-frame tick
|
||||
local function fillSync(buffers)
|
||||
if suspended then return end
|
||||
local music = currentMusic
|
||||
if not music or not music.engine or music.engine:finished() then return end
|
||||
limit = limit or MUSIC_FILL_PER_CALL
|
||||
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
|
||||
if not ok or type(free) ~= "number" then return end
|
||||
while free > 0 and limit > 0 and not music.engine:finished() do
|
||||
local sd = ChipSynth.soundData(music.engine, MUSIC_BUFFER_SAMPLES, 2)
|
||||
if not pcall(music.source.queue, music.source, sd) then return end
|
||||
free = free - 1
|
||||
limit = limit - 1
|
||||
if not music or not music.engine then return end
|
||||
if not music.staging and music.engine:finished() then return end
|
||||
local budget
|
||||
if buffers then
|
||||
budget = buffers * MUSIC_BUFFER_SAMPLES - (music.stagingFill or 0)
|
||||
else
|
||||
budget = tickSamples()
|
||||
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
|
||||
if ok and type(free) == "number"
|
||||
and MUSIC_BUFFER_COUNT - free < MUSIC_FILL_LOW_WATER then
|
||||
budget = MUSIC_BUFFER_SAMPLES
|
||||
end
|
||||
end
|
||||
renderSync(music, budget)
|
||||
end
|
||||
|
||||
local function playMusicSync(data, header, allowLoops)
|
||||
@@ -360,6 +415,7 @@ end
|
||||
|
||||
function ChipAudio.update()
|
||||
if suspended then return end
|
||||
if fxCh then drainEffects() end
|
||||
local m = currentMusic
|
||||
if not m then return end
|
||||
if m.threaded then
|
||||
@@ -446,6 +502,7 @@ end
|
||||
function ChipAudio.invalidate()
|
||||
ChipAudio.stopMusic()
|
||||
ChipSynth.invalidateBanks()
|
||||
dropEffects()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||
end
|
||||
|
||||
@@ -458,6 +515,8 @@ function ChipAudio.shutdown()
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, outCh = nil, nil, nil
|
||||
workerReady = nil
|
||||
dropEffects()
|
||||
fxCh = nil
|
||||
end
|
||||
|
||||
function ChipAudio.currentSource()
|
||||
@@ -500,6 +559,8 @@ function ChipAudio.setStereo(enabled)
|
||||
local m = currentMusic
|
||||
if m and m.engine then
|
||||
ChipSynth.applyStereo(m.engine)
|
||||
-- a half-rendered fallback buffer holds the previous pan mix (#1471)
|
||||
m.staging, m.stagingFill = nil, 0
|
||||
end
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
@@ -619,31 +680,161 @@ Assets.register(ChipAudio.invalidate)
|
||||
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Prewarm (#first-cry hitch): the first play of an SFX/cry synthesizes its
|
||||
-- whole PCM on the main thread (a cry is ~10-25 ms of Lua synthesis, a long
|
||||
-- jingle 150-350 ms). ChipAudio.prewarmSfx / prewarmCry hand that render to
|
||||
-- the music worker ahead of time -- e.g. while a battle transition runs, for
|
||||
-- the two species about to cry -- and the next newSfx/newCry with the same
|
||||
-- def, modifiers and channel mix takes the finished SoundData instead of
|
||||
-- rendering. Playback timing is unchanged: a play never waits on the worker
|
||||
-- (a request still in flight is rendered synchronously as before and its
|
||||
-- late result dropped), and the worker runs the same ChipSynth code with the
|
||||
-- same rate/mix, so the PCM is identical. No worker (headless, love.thread
|
||||
-- unavailable or dead): prewarm is a no-op and plays render as before.
|
||||
|
||||
local function effectKey(data, header, options)
|
||||
if type(header) ~= "table" then return nil end
|
||||
local volumes = ChipSynth.getChannelVolumes()
|
||||
local pitches = ChipSynth.getChannelPitches()
|
||||
-- table identities: a reloaded def (or dataset) is a new table
|
||||
return table.concat({
|
||||
tostring(data and data.audio), tostring(header.chip or header),
|
||||
tostring(options.frequencyOffset), tostring(options.frameTicks),
|
||||
tostring(options.plainFrames), tostring(options.cryLength),
|
||||
tostring(options.maxSeconds),
|
||||
sampleRate(), ChipSynth.getStereo() and 1 or 0,
|
||||
volumes[1], volumes[2], volumes[3], volumes[4],
|
||||
pitches[1], pitches[2], pitches[3], pitches[4], fxEpoch,
|
||||
}, "|")
|
||||
end
|
||||
|
||||
function drainEffects()
|
||||
if not fxCh then return end
|
||||
while true do
|
||||
local result = fxCh:pop()
|
||||
if not result then return end
|
||||
if result.epoch == fxEpoch and fxPending[result.key] then
|
||||
fxPending[result.key] = nil
|
||||
if result.error then
|
||||
require("src.core.Logger").warn("chip audio prewarm: %s",
|
||||
tostring(result.error))
|
||||
elseif fxReadyCount < FX_READY_MAX then
|
||||
fxReady[result.key] = result.sd or false
|
||||
fxReadyCount = fxReadyCount + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function dropEffects()
|
||||
fxEpoch = fxEpoch + 1
|
||||
fxReady, fxPending, fxReadyCount = {}, {}, 0
|
||||
if fxCh then fxCh:clear() end
|
||||
end
|
||||
|
||||
local function requestEffect(data, header, options)
|
||||
if not ensureWorker() or not workerAlive() then return false end
|
||||
local key = effectKey(data, header, options)
|
||||
if not key then return false end
|
||||
drainEffects()
|
||||
if fxReady[key] ~= nil or fxPending[key] then return true end
|
||||
fxCh = fxCh or love.thread.getChannel("chipaudio_fx")
|
||||
local pushed = pcall(cmdCh.push, cmdCh, {
|
||||
cmd = "effect", key = key, epoch = fxEpoch, header = header,
|
||||
options = {
|
||||
frequencyOffset = options.frequencyOffset,
|
||||
frameTicks = options.frameTicks,
|
||||
plainFrames = options.plainFrames,
|
||||
cryLength = options.cryLength,
|
||||
maxSeconds = options.maxSeconds,
|
||||
},
|
||||
audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches(),
|
||||
stereo = ChipSynth.getStereo(),
|
||||
sampleRate = sampleRate(),
|
||||
})
|
||||
if not pushed then return false end
|
||||
fxPending[key] = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- a finished prewarm for exactly this render, or nil
|
||||
local function takeEffect(data, header, options)
|
||||
if not fxCh then return nil end
|
||||
local key = effectKey(data, header, options)
|
||||
if not key then return nil end
|
||||
drainEffects()
|
||||
local sd = fxReady[key]
|
||||
if sd ~= nil then
|
||||
fxReady[key] = nil
|
||||
fxReadyCount = fxReadyCount - 1
|
||||
return sd
|
||||
end
|
||||
-- about to render it here; the worker's copy would arrive unused
|
||||
fxPending[key] = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
local function renderEffect(data, header, options)
|
||||
local sd = ChipSynth.renderEffectData(data, header, options)
|
||||
local sd = takeEffect(data, header, options)
|
||||
if sd == nil then sd = ChipSynth.renderEffectData(data, header, options) end
|
||||
if not sd then return nil end
|
||||
return love.audio.newSource(sd, "static")
|
||||
end
|
||||
|
||||
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
|
||||
header = header or data.audio.sfx[name]
|
||||
return renderEffect(data, header, {
|
||||
local function sfxOptions(pitch, tempo, plainFrames)
|
||||
return {
|
||||
frequencyOffset = pitch or 0,
|
||||
frameTicks = 0x80 + (tempo or 0x80),
|
||||
plainFrames = plainFrames,
|
||||
})
|
||||
}
|
||||
end
|
||||
|
||||
local function cryDef(data, species, resolved)
|
||||
return resolved or (data.audio.cries and data.audio.cries[species])
|
||||
end
|
||||
|
||||
local function cryOptions(cry)
|
||||
return { frequencyOffset = cry.pitch, cryLength = cry.length }
|
||||
end
|
||||
|
||||
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
|
||||
header = header or data.audio.sfx[name]
|
||||
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
|
||||
end
|
||||
|
||||
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
|
||||
-- out -- a derived cry borrowing another species' header with its own
|
||||
-- modifiers, which no registry lookup under `species` could find
|
||||
function ChipAudio.newCry(data, species, resolved)
|
||||
local cry = resolved or (data.audio.cries and data.audio.cries[species])
|
||||
local cry = cryDef(data, species, resolved)
|
||||
if not cry then return nil end
|
||||
return renderEffect(data, cry.chip and cry or cry.header, {
|
||||
frequencyOffset = cry.pitch,
|
||||
cryLength = cry.length,
|
||||
})
|
||||
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry))
|
||||
end
|
||||
|
||||
-- Same arguments as newSfx / newCry; returns true when a render was queued
|
||||
-- on the worker (or is already queued / done), false when there is no worker
|
||||
-- to do it. Never renders on the calling thread.
|
||||
function ChipAudio.prewarmSfx(data, name, pitch, tempo, header, plainFrames)
|
||||
header = header or (data.audio and data.audio.sfx and data.audio.sfx[name])
|
||||
if not header then return false end
|
||||
return requestEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
|
||||
end
|
||||
|
||||
function ChipAudio.prewarmCry(data, species, resolved)
|
||||
local cry = data.audio and cryDef(data, species, resolved)
|
||||
if not cry then return false end
|
||||
local header = cry.chip and cry or cry.header
|
||||
if not header then return false end
|
||||
return requestEffect(data, header, cryOptions(cry))
|
||||
end
|
||||
|
||||
-- test hook: prewarm bookkeeping
|
||||
function ChipAudio._effectStateForTest()
|
||||
local pending = 0
|
||||
for _ in pairs(fxPending) do pending = pending + 1 end
|
||||
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch }
|
||||
end
|
||||
|
||||
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
|
||||
@@ -659,6 +850,7 @@ function ChipAudio.newLowHealthAlarm()
|
||||
local SAMPLE_RATE = sampleRate()
|
||||
local samples = math.floor(SAMPLE_RATE * 60 / 60)
|
||||
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
|
||||
local pointer = ChipSynth._int16Pointer(data)
|
||||
local phase = 0
|
||||
for index = 0, samples - 1 do
|
||||
local frame = math.floor(index * 60 / SAMPLE_RATE) % 30
|
||||
@@ -666,8 +858,14 @@ function ChipAudio.newLowHealthAlarm()
|
||||
local frequency = 131072 / (2048 - register)
|
||||
phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
local value = (phase < 0.5 and 1 or -1) * 0.25
|
||||
data:setSample(index, 1, value)
|
||||
data:setSample(index, 2, value)
|
||||
if pointer then
|
||||
-- same int16 setSample stores (ChipSynth bulk PCM writes)
|
||||
pointer[index * 2] = value * 32767
|
||||
pointer[index * 2 + 1] = value * 32767
|
||||
else
|
||||
data:setSample(index, 1, value)
|
||||
data:setSample(index, 2, value)
|
||||
end
|
||||
end
|
||||
return love.audio.newSource(data, "static")
|
||||
end
|
||||
|
||||
+118
-8
@@ -1407,12 +1407,68 @@ function Engine:sampleChannel(number)
|
||||
return analogOut(self, selected, "hpfCap", "lpf")
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
-- is available on worker threads, so this is the hand-off unit the worker
|
||||
-- produces and the main thread queues.
|
||||
local function soundData(engine, samples, channels)
|
||||
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
|
||||
for index = 0, samples - 1 do
|
||||
-- Bulk PCM writes. SoundData:setSample is a C call per sample (two per
|
||||
-- stereo frame); at 44100 Hz that was a large slice of every rendered buffer.
|
||||
-- Where LuaJIT's ffi and Data:getFFIPointer exist, samples go straight into
|
||||
-- the SoundData's int16 storage instead. LOVE 11.5's 16-bit setSample stores
|
||||
-- (int16)(value * 32767) in double precision (truncating, no clamp), which is
|
||||
-- exactly what an ffi int16 store of `value * 32767` does, so the PCM is
|
||||
-- bit-identical to the setSample path (tests/chip_synth_bulk_pcm_test.lua,
|
||||
-- plus a real-LOVE comparison over 2M values incl. out-of-range and NaN).
|
||||
-- The setSample path stays as the fallback (headless stub, no ffi).
|
||||
local ffiOk, ffi = pcall(require, "ffi")
|
||||
local bulkWrites = true
|
||||
|
||||
function ChipSynth._setBulkWritesForTest(enabled)
|
||||
bulkWrites = not not enabled
|
||||
end
|
||||
|
||||
local function int16Pointer(sd)
|
||||
if not (bulkWrites and ffiOk and ffi and sd.getFFIPointer) then return nil end
|
||||
if sd.getBitDepth then
|
||||
local okDepth, depth = pcall(sd.getBitDepth, sd)
|
||||
if not okDepth or depth ~= 16 then return nil end
|
||||
end
|
||||
local ok, pointer = pcall(sd.getFFIPointer, sd)
|
||||
if not ok or pointer == nil then return nil end
|
||||
return ffi.cast("int16_t *", pointer)
|
||||
end
|
||||
ChipSynth._int16Pointer = int16Pointer
|
||||
|
||||
-- does this runtime's SoundData expose writable int16 storage? Probed once
|
||||
-- (a 1-frame buffer) so renderEffectData can pick its path before rendering.
|
||||
local pointerSupport
|
||||
|
||||
local function bulkAvailable()
|
||||
if not (bulkWrites and ffiOk and ffi) then return false end
|
||||
if pointerSupport == nil then
|
||||
local ok, probe = pcall(love.sound.newSoundData, 1, SAMPLE_RATE, 16, 2)
|
||||
pointerSupport = (ok and probe and int16Pointer(probe)) and true or false
|
||||
end
|
||||
return pointerSupport
|
||||
end
|
||||
|
||||
-- render `count` frames from `engine` into an existing SoundData starting at
|
||||
-- frame `offset`. The synchronous music fallback fills one buffer a slice at
|
||||
-- a time across frames (ChipAudio fillSync) so its per-frame cost is flat.
|
||||
local function renderInto(engine, result, offset, count, channels)
|
||||
local last = offset + count - 1
|
||||
local pointer = int16Pointer(result)
|
||||
if pointer then
|
||||
if channels == 2 then
|
||||
for index = offset, last do
|
||||
local left, right = engine:sampleStereo()
|
||||
pointer[index * 2] = left * 32767
|
||||
pointer[index * 2 + 1] = right * 32767
|
||||
end
|
||||
else
|
||||
for index = offset, last do
|
||||
pointer[index] = engine:sample() * 32767
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
for index = offset, last do
|
||||
if channels == 2 then
|
||||
local left, right = engine:sampleStereo()
|
||||
result:setSample(index, 1, left)
|
||||
@@ -1424,6 +1480,34 @@ local function soundData(engine, samples, channels)
|
||||
return result
|
||||
end
|
||||
|
||||
local function newBuffer(samples, channels)
|
||||
return love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
-- is available on worker threads, so this is the hand-off unit the worker
|
||||
-- produces and the main thread queues.
|
||||
local function soundData(engine, samples, channels)
|
||||
local result = newBuffer(samples, channels)
|
||||
return renderInto(engine, result, 0, samples, channels)
|
||||
end
|
||||
|
||||
-- reusable int16 scratch for renderEffectData's bulk path: the effect length
|
||||
-- is only known once the engine finishes, so samples are converted as they
|
||||
-- are produced and copied (duplicated to both channels) at the end, instead
|
||||
-- of growing a Lua table of doubles per render
|
||||
local scratch, scratchSize = nil, 0
|
||||
|
||||
local function ensureScratch(size)
|
||||
if scratchSize >= size then return scratch end
|
||||
local grown = math.max(size, scratchSize * 2, 4096)
|
||||
local fresh = ffi.new("int16_t[?]", grown)
|
||||
if scratch and scratchSize > 0 then
|
||||
ffi.copy(fresh, scratch, scratchSize * 2)
|
||||
end
|
||||
scratch, scratchSize = fresh, grown
|
||||
return scratch
|
||||
end
|
||||
|
||||
local function renderEffectData(data, header, options)
|
||||
if not header then return nil end
|
||||
@@ -1433,14 +1517,38 @@ local function renderEffectData(data, header, options)
|
||||
local engine = Engine.new(data, header, options)
|
||||
-- audio/sfx/pokeflute.asm:20
|
||||
local maximum = SAMPLE_RATE * (options.maxSeconds or 12)
|
||||
local minimum = math.floor(SAMPLE_RATE / 100)
|
||||
if bulkAvailable() then
|
||||
local values = ensureScratch(SAMPLE_RATE)
|
||||
local count = 0
|
||||
while count < maximum and not engine:finished() do
|
||||
if count >= scratchSize then values = ensureScratch(count + 1) end
|
||||
values[count] = engine:sample() * 32767
|
||||
count = count + 1
|
||||
end
|
||||
if count < minimum then return nil end
|
||||
local result = newBuffer(count, 2)
|
||||
local pointer = int16Pointer(result)
|
||||
if pointer then
|
||||
for index = 0, count - 1 do
|
||||
local value = values[index]
|
||||
pointer[index * 2] = value
|
||||
pointer[index * 2 + 1] = value
|
||||
end
|
||||
return result
|
||||
end
|
||||
-- the probe said yes but this buffer has no pointer: re-render through
|
||||
-- setSample below (truncated int16s cannot be turned back into floats)
|
||||
engine = Engine.new(data, header, options)
|
||||
end
|
||||
local values = {}
|
||||
local count = 0
|
||||
while count < maximum and not engine:finished() do
|
||||
count = count + 1
|
||||
values[count] = engine:sample()
|
||||
end
|
||||
if count < math.floor(SAMPLE_RATE / 100) then return nil end
|
||||
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2)
|
||||
if count < minimum then return nil end
|
||||
local result = newBuffer(count, 2)
|
||||
for index = 1, count do
|
||||
local value = values[index]
|
||||
result:setSample(index - 1, 1, value)
|
||||
@@ -1451,6 +1559,8 @@ end
|
||||
|
||||
ChipSynth.newEngine = Engine.new
|
||||
ChipSynth.soundData = soundData
|
||||
ChipSynth.renderInto = renderInto
|
||||
ChipSynth.newBuffer = newBuffer
|
||||
ChipSynth.renderEffectData = renderEffectData
|
||||
|
||||
function ChipSynth.applyStereo(engine)
|
||||
|
||||
+12
-1
@@ -309,6 +309,10 @@ function Game:breakLink(err, source)
|
||||
end
|
||||
|
||||
function Game:step(dt)
|
||||
-- A monotonic count of fixed logic steps, for presentation counters that
|
||||
-- are sampled from draw code but must advance at the 60Hz step rate
|
||||
-- rather than the display refresh (Player:pose's surf bob).
|
||||
Game.logicStep = (Game.logicStep or 0) + 1
|
||||
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||
-- the same fixed-step boundary as a physical controller. Run them before
|
||||
-- Input:step promotes queued edges so a button chosen here is visible to
|
||||
@@ -413,6 +417,13 @@ function Game:logicSpeed()
|
||||
resolveLogicSpeedVanilla, self))
|
||||
end
|
||||
|
||||
-- Discord Rich Presence tick, pcall'd by Game:update: a named function so
|
||||
-- the per-frame call builds no closure (the require stays inside the pcall,
|
||||
-- so a missing/broken module still soft-fails exactly as before)
|
||||
local function discordPresenceUpdate(dt)
|
||||
require("src.core.DiscordPresence").update(dt)
|
||||
end
|
||||
|
||||
function Game:update(dt)
|
||||
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||
-- Give the accumulator room for one full frame at the current speed,
|
||||
@@ -437,7 +448,7 @@ function Game:update(dt)
|
||||
-- mod render pipelines tween on the same real-frame clock, for the same
|
||||
-- reason: they are presentational, so fast-forward must not speed them up
|
||||
require("src.render.Pipelines").update(dt)
|
||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||
pcall(discordPresenceUpdate, dt)
|
||||
self:updateSync(dt)
|
||||
-- Steady-state memory backstop: advance the incremental collector one
|
||||
-- small step every few rendered frames. The heavy GPU objects are now
|
||||
|
||||
+56
-10
@@ -55,6 +55,36 @@ local SaveData = require("src.core.SaveData")
|
||||
-- had when they required the module by hand.
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
-- Already loaded by src/world/gen2/World.lua above; bound here so the
|
||||
-- per-frame update/draw paths do not call require() every frame.
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
|
||||
-- Resolved on first use rather than at load, keeping this file's load order.
|
||||
local lazyModules = {}
|
||||
local function lazy(name)
|
||||
local mod = lazyModules[name]
|
||||
if mod == nil then
|
||||
mod = require(name)
|
||||
lazyModules[name] = mod
|
||||
end
|
||||
return mod
|
||||
end
|
||||
|
||||
-- DiscordPresence is optional: a require that fails once is not retried
|
||||
-- every frame, and no closure is minted per frame to guard the call.
|
||||
local discordPresence = nil
|
||||
local function discordUpdate(dt)
|
||||
if discordPresence == nil then
|
||||
local ok, mod = pcall(require, "src.core.DiscordPresence")
|
||||
discordPresence = (ok and type(mod) == "table") and mod or false
|
||||
end
|
||||
if discordPresence and discordPresence.update then
|
||||
pcall(discordPresence.update, dt)
|
||||
end
|
||||
end
|
||||
|
||||
local Game2 = {}
|
||||
Game2.__index = Game2
|
||||
|
||||
@@ -1254,6 +1284,11 @@ function Game2:load(opts)
|
||||
self:softReset()
|
||||
return
|
||||
end
|
||||
-- LoadPoisonBGPals' four-frame hold and the void-fill dissolve are spent
|
||||
-- here, on the logic clock and whatever is on top (the faint text can
|
||||
-- open the same frame), rather than once per render frame in World:draw.
|
||||
local world = self.world
|
||||
if world and world.tickFrameClocks then world:tickFrameClocks() end
|
||||
-- Not the audio tick: _UpdateSound runs once per frame off VBlank
|
||||
-- (audio/engine.asm:84, home/vblank.asm:141-143), never off the logic clock.
|
||||
local top = self.stack:top()
|
||||
@@ -1340,12 +1375,12 @@ function Game2:update(dt)
|
||||
end
|
||||
-- TILT eases toward its new angle in real time, not on the logic clock, so
|
||||
-- fast-forward does not fling the camera over.
|
||||
require("src.render.Tilt").update(dt)
|
||||
Tilt.update(dt)
|
||||
-- Mod render pipelines tween on the same real-frame clock, for the same
|
||||
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
|
||||
-- they are presentational, so fast-forward must not speed them up.
|
||||
require("src.render.Pipelines").update(dt)
|
||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||
Pipelines.update(dt)
|
||||
discordUpdate(dt)
|
||||
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
|
||||
-- audio runs off its own real-time accumulator, so music and sfx keep their
|
||||
-- tempo at every multiplier (#1990/#1991/#1997). speedOverride is the
|
||||
@@ -1488,7 +1523,6 @@ end
|
||||
-- present pass has always been.
|
||||
function Game2:blitZones(canvas, zones, w, h)
|
||||
local G = love.graphics
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local px, py, pw, ph = Playfield.rect(w, h)
|
||||
local sx, sy = pw / 160, ph / 144
|
||||
G.setColor(1, 1, 1, 1)
|
||||
@@ -1599,7 +1633,7 @@ end
|
||||
-- the grid itself.
|
||||
function Game2:drawViewportFrame()
|
||||
local G = love.graphics
|
||||
local serial = require("src.core.FaithfulRes").modeSerial
|
||||
local serial = lazy("src.core.FaithfulRes").modeSerial
|
||||
if serial ~= (self.modeSerial or 0) then
|
||||
if self.world and self.world.map then
|
||||
self.world:dropBakes()
|
||||
@@ -1607,9 +1641,7 @@ function Game2:drawViewportFrame()
|
||||
self.modeSerial = serial
|
||||
end
|
||||
local w, h = GameViewport.dimensions()
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local ShaderFX = lazy("src.render.ShaderFX")
|
||||
-- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1
|
||||
-- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed).
|
||||
local shaderfx = ShaderFX.active()
|
||||
@@ -1626,10 +1658,24 @@ function Game2:drawViewportFrame()
|
||||
-- lighting mod written against Gen 1 tints Gold through the same seam.
|
||||
local zones = nil
|
||||
local classic = GbcPalette.available() and GbcPalette.presentColors() or nil
|
||||
local zoneHook = ModRuntime.wantsHook("render.zones")
|
||||
if classic then
|
||||
zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } }
|
||||
if zoneHook or ModRuntime.wantsHook("render.compose") then
|
||||
-- A mod may keep or edit the list it is handed: give it a fresh one.
|
||||
zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } }
|
||||
else
|
||||
-- Nobody else sees it, so the one list is reused frame to frame.
|
||||
local zone = self._classicZone
|
||||
if not zone then
|
||||
zone = { x = 0, y = 0, w = 160, h = 144 }
|
||||
self._classicZone = zone
|
||||
self._classicZones = { zone }
|
||||
end
|
||||
zone.colors = classic
|
||||
zones = self._classicZones
|
||||
end
|
||||
end
|
||||
if ModRuntime.wantsHook("render.zones") then
|
||||
if zoneHook then
|
||||
zones = ModRuntime.call("render.zones", sameZones, self, zones)
|
||||
end
|
||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||
|
||||
+40
-3
@@ -331,6 +331,11 @@ local function anyPadDown(pads, button)
|
||||
return false
|
||||
end
|
||||
|
||||
-- a joystick was added or removed: rebuild pollPads' list on the next poll
|
||||
function Input:joysticksChanged()
|
||||
self._pollPads = nil
|
||||
end
|
||||
|
||||
-- pokefirered/src/main.c:296
|
||||
function Input:pollPads()
|
||||
local js = love and love.joystick
|
||||
@@ -341,7 +346,24 @@ function Input:pollPads()
|
||||
return
|
||||
end
|
||||
local pads = self._pollPads
|
||||
if not pads or self._pollPadCount ~= count then
|
||||
-- The count alone cannot see one controller swapped for another between
|
||||
-- two polls; a cached pad that has gone away forces the rebuild too (and
|
||||
-- main.lua's joystickadded/removed drop the cache via
|
||||
-- Input:joysticksChanged).
|
||||
local stale = not pads or self._pollPadCount ~= count
|
||||
if not stale then
|
||||
for k = 1, #pads do
|
||||
local j = pads[k]
|
||||
if j.isConnected then
|
||||
local okConn, connected = pcall(j.isConnected, j)
|
||||
if okConn and not connected then
|
||||
stale = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if stale then
|
||||
pads = {}
|
||||
local ok, list = pcall(js.getJoysticks)
|
||||
if ok and type(list) == "table" then
|
||||
@@ -390,9 +412,24 @@ function Input:pollPads()
|
||||
end
|
||||
end
|
||||
|
||||
-- step's two per-step tables are recycled rather than reallocated, but only
|
||||
-- while they are still the ones step itself installed: a test or driver that
|
||||
-- assigns its own `pressed` / `pressQueue` gets a fresh table afterwards,
|
||||
-- exactly as before, and never sees its own table emptied.
|
||||
local function ownedCleared(self, field, ownKey)
|
||||
local own = self[ownKey]
|
||||
if own ~= nil and self[field] == own then
|
||||
for k in pairs(own) do own[k] = nil end
|
||||
else
|
||||
own = {}
|
||||
self[ownKey] = own
|
||||
end
|
||||
self[field] = own
|
||||
end
|
||||
|
||||
function Input:step()
|
||||
self:pollPads()
|
||||
self.pressed = {}
|
||||
ownedCleared(self, "pressed", "_ownPressed")
|
||||
for _, btn in ipairs(self.pressQueue) do
|
||||
self.pressed[btn] = true
|
||||
local sources = self.sources[btn]
|
||||
@@ -409,7 +446,7 @@ function Input:step()
|
||||
self.sources[btn] = nil
|
||||
end
|
||||
end
|
||||
self.pressQueue = {}
|
||||
ownedCleared(self, "pressQueue", "_ownPressQueue")
|
||||
-- pokefirered/src/main.c:325
|
||||
local aliases = self.aliases
|
||||
local held = nil
|
||||
|
||||
@@ -7,8 +7,11 @@ local ModRuntime = require("src.mods.Runtime")
|
||||
|
||||
local PlatformHooks = {}
|
||||
|
||||
-- hoisted: update runs every frame, and the vanilla step closes over nothing
|
||||
local function vanillaUpdate(g, d) g:update(d) end
|
||||
|
||||
function PlatformHooks.update(game, dt)
|
||||
return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt)
|
||||
return ModRuntime.call("core.update", vanillaUpdate, game, dt)
|
||||
end
|
||||
|
||||
function PlatformHooks.quitToLauncher(vanilla)
|
||||
|
||||
@@ -827,6 +827,45 @@ function Sound.playCry(data, species, pikaClip)
|
||||
return src
|
||||
end
|
||||
|
||||
-- Prewarm: the first play of a chip cry/SFX synthesizes it on the calling
|
||||
-- frame (~10-25 ms for a cry, far more for a long jingle), which is the hitch
|
||||
-- at a battle's first cry. These hand that render to the chip audio worker
|
||||
-- ahead of time (ChipAudio.prewarmCry/prewarmSfx); the play that follows
|
||||
-- builds its Source from the finished PCM, identical to a fresh render. Call
|
||||
-- at an idle point before the sound is needed (battle creation / the
|
||||
-- transition, for both species). Returns true when a render was queued or
|
||||
-- the sound is already cached; never renders on the calling thread, and a
|
||||
-- play that comes first simply renders as before.
|
||||
function Sound.prewarmCry(data, species)
|
||||
if not love.audio or deviceSuspended() then return false end
|
||||
if cache["cry:" .. tostring(species)] ~= nil then return true end
|
||||
local audio = data and data.audio
|
||||
local def = audio and audio.cries and audio.cries[species]
|
||||
if not def then return false end
|
||||
-- pokeyellow/home/pokemon.asm:140 -- voiced by a PCM clip instead
|
||||
if species == "PIKACHU" and audio.pikaCries then return false end
|
||||
local resolved = resolveCry(data, def, 0)
|
||||
if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then
|
||||
return false
|
||||
end
|
||||
local ok, queued = pcall(
|
||||
require("src.core.ChipAudio").prewarmCry, data, species, resolved)
|
||||
return ok and queued or false
|
||||
end
|
||||
|
||||
-- the plain Sound.play variant of `name` (no pitch/tempo modifiers)
|
||||
function Sound.prewarmSfx(data, name)
|
||||
if not love.audio or deviceSuspended() then return false end
|
||||
name = Sound.resolve(data, name)
|
||||
if cache[name] ~= nil then return true end
|
||||
local sfx = data and data.audio and data.audio.sfx
|
||||
local def = sfx and sfx[name]
|
||||
if not isChipDef(def) then return false end
|
||||
local ok, queued = pcall(
|
||||
require("src.core.ChipAudio").prewarmSfx, data, name, nil, nil, def, nil)
|
||||
return ok and queued or false
|
||||
end
|
||||
|
||||
-- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks
|
||||
-- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/
|
||||
-- tempo bytes on top of the cry's species modifiers before the tempo
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
-- cmd = "channelMix" { volumes, pitches, stereo?, stereoEpoch? }
|
||||
-- stereoEpoch present: live SOUND toggle; drop lookahead
|
||||
-- cmd = "invalidate" drop the bank cache
|
||||
-- cmd = "effect" { key, epoch, header, options, audio, channelVolumes,
|
||||
-- channelPitches, stereo, sampleRate }
|
||||
-- prewarm one SFX/cry (ChipSynth.renderEffectData); the
|
||||
-- result goes to "chipaudio_fx" as
|
||||
-- { key, epoch, sd = SoundData|nil } or { key, epoch, error }
|
||||
-- cmd = "quit" end the thread
|
||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||
-- discard anything left over from a superseded song:
|
||||
@@ -37,6 +42,7 @@ local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))()
|
||||
|
||||
local cmdCh = love.thread.getChannel("chipaudio_cmd")
|
||||
local outCh = love.thread.getChannel("chipaudio_out")
|
||||
local fxCh = love.thread.getChannel("chipaudio_fx")
|
||||
|
||||
local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||
local BUF_SECONDS = BUF / ChipSynth.SAMPLE_RATE
|
||||
@@ -50,10 +56,37 @@ local engine = nil -- the ChipSynth engine producing the current song
|
||||
local finished = false -- the current song ran out (non-looping)
|
||||
local data = nil -- { audio = <slim audio tables> } for ROM bank/wave reads
|
||||
local stereoEpoch = 0 -- matches ChipAudio; stale pan buffers are dropped
|
||||
local effects = {} -- queued "effect" prewarm requests, oldest first
|
||||
local produced = 0 -- buffers rendered for the current song so far
|
||||
|
||||
-- a song start outranks a prewarm: the first MUSIC_PREROLL buffers gate when
|
||||
-- the song is heard, a prewarmed cry only has to be done before its play
|
||||
local PREROLL = 4
|
||||
|
||||
local function renderEffect(req)
|
||||
if req.sampleRate ~= nil then
|
||||
BUF_SECONDS = BUF / ChipSynth.setSampleRate(req.sampleRate)
|
||||
end
|
||||
if req.channelVolumes ~= nil then
|
||||
ChipSynth.setChannelVolumes(req.channelVolumes)
|
||||
end
|
||||
if req.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(req.channelPitches)
|
||||
end
|
||||
if req.stereo ~= nil then ChipSynth.setStereo(req.stereo) end
|
||||
local ok, sd = pcall(ChipSynth.renderEffectData, { audio = req.audio },
|
||||
req.header, req.options or {})
|
||||
if ok then
|
||||
fxCh:push({ key = req.key, epoch = req.epoch, sd = sd or nil })
|
||||
else
|
||||
fxCh:push({ key = req.key, epoch = req.epoch, error = tostring(sd) })
|
||||
end
|
||||
end
|
||||
|
||||
local function handle(cmd)
|
||||
if cmd.cmd == "play" then
|
||||
gen = cmd.gen
|
||||
produced = 0
|
||||
finished = false
|
||||
engine = nil
|
||||
outCh:clear() -- drop any buffers left from the previous song
|
||||
@@ -95,6 +128,9 @@ local function handle(cmd)
|
||||
end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
effects = {}
|
||||
elseif cmd.cmd == "effect" then
|
||||
effects[#effects + 1] = cmd
|
||||
elseif cmd.cmd == "quit" then
|
||||
return true
|
||||
end
|
||||
@@ -113,7 +149,12 @@ while true do
|
||||
end
|
||||
if quit then break end
|
||||
|
||||
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
|
||||
local musicWants = engine and not finished and gen
|
||||
and outCh:getCount() < LOOKAHEAD
|
||||
if #effects > 0 and not (musicWants and produced < PREROLL) then
|
||||
idleWait = false
|
||||
renderEffect(table.remove(effects, 1))
|
||||
elseif musicWants then
|
||||
idleWait = false
|
||||
local activeGen = gen
|
||||
local began = love.timer.getTime()
|
||||
@@ -123,6 +164,7 @@ while true do
|
||||
stereoEpoch = stereoEpoch, jit = jitEnabled })
|
||||
finished = true
|
||||
else
|
||||
produced = produced + 1
|
||||
outCh:push({ gen = activeGen, sd = sd, stereoEpoch = stereoEpoch,
|
||||
jit = jitEnabled,
|
||||
xrt = (love.timer.getTime() - began) / BUF_SECONDS })
|
||||
|
||||
@@ -807,6 +807,7 @@ function Audio._seRawClear()
|
||||
Audio._seRaw = {}
|
||||
Audio._seRawFrames = 0
|
||||
Audio._seRawTick = 0
|
||||
if Audio._seSourceClear then Audio._seSourceClear() end
|
||||
end
|
||||
|
||||
function Audio._seRawGet(id)
|
||||
@@ -839,6 +840,72 @@ function Audio._seRawPut(id, loop, rawL, rawR, loopStart)
|
||||
end
|
||||
end
|
||||
|
||||
-- Static Sources for one-shot / looping SEs, keyed by everything baked into
|
||||
-- their PCM (id, pan, master gain, mono, loop). A repeat SE (wall bump, ball
|
||||
-- placement, menu cursor) used to rebuild its SoundData sample by sample and
|
||||
-- upload a new Source on every play; now a cached Source is rewound and
|
||||
-- replayed, or cloned (shares the uploaded buffer) while the cached one is
|
||||
-- still live on another player slot. Entries are tied to the memoized raw
|
||||
-- PCM tables, so an evicted/rebaked SE never reuses a stale Source.
|
||||
Audio.SE_SOURCE_CACHE_MAX = 32
|
||||
|
||||
function Audio._seSourceClear()
|
||||
Audio._seSrcCache = {}
|
||||
Audio._seSrcCount = 0
|
||||
Audio._seSrcTick = 0
|
||||
end
|
||||
|
||||
function Audio._seSourceFor(id, rawL, rawR, master, pan, mono, loop)
|
||||
if not (love and love.audio and love.audio.newSource) then return nil end
|
||||
local hit = Audio._seRaw and Audio._seRaw[id]
|
||||
local cacheable = hit and hit.rawL == rawL and hit.rawR == rawR
|
||||
local key = cacheable and table.concat({ tostring(id), tostring(pan),
|
||||
tostring(master), mono and "m" or "s", loop and "l" or "o" }, ":")
|
||||
if not Audio._seSrcCache then Audio._seSourceClear() end
|
||||
local entry = key and Audio._seSrcCache[key]
|
||||
if entry and entry.rawL == rawL then
|
||||
Audio._seSrcTick = Audio._seSrcTick + 1
|
||||
entry.tick = Audio._seSrcTick
|
||||
local src = entry.src
|
||||
if Audio._seMeta[src] then
|
||||
-- still tracked as a live SE: play a copy rather than cut it off
|
||||
local ok, copy = pcall(function() return src:clone() end)
|
||||
src = ok and copy or nil
|
||||
end
|
||||
if src then
|
||||
pcall(function() src:stop() end)
|
||||
if loop then pcall(function() src:setLooping(true) end) end
|
||||
return src
|
||||
end
|
||||
key = nil -- no clone support: build a one-off, keep the cached entry
|
||||
end
|
||||
local sd = Audio._buildSeSoundData(rawL, rawR, master, pan, mono)
|
||||
if not sd then return nil end
|
||||
local src = love.audio.newSource(sd, "static")
|
||||
if loop then
|
||||
pcall(function() src:setLooping(true) end)
|
||||
end
|
||||
if key then
|
||||
if not Audio._seSrcCache[key] then
|
||||
Audio._seSrcCount = Audio._seSrcCount + 1
|
||||
end
|
||||
Audio._seSrcTick = Audio._seSrcTick + 1
|
||||
Audio._seSrcCache[key] = { src = src, rawL = rawL, tick = Audio._seSrcTick }
|
||||
while Audio._seSrcCount > Audio.SE_SOURCE_CACHE_MAX do
|
||||
local oldKey, oldTick
|
||||
for k, e in pairs(Audio._seSrcCache) do
|
||||
if k ~= key and (oldTick == nil or e.tick < oldTick) then
|
||||
oldKey, oldTick = k, e.tick
|
||||
end
|
||||
end
|
||||
if oldKey == nil then break end
|
||||
Audio._seSrcCache[oldKey] = nil
|
||||
Audio._seSrcCount = Audio._seSrcCount - 1
|
||||
end
|
||||
end
|
||||
return src
|
||||
end
|
||||
|
||||
function Audio.playSe(id, opts)
|
||||
opts = opts or {}
|
||||
id = SE.resolve(id)
|
||||
@@ -902,12 +969,8 @@ function Audio.playSe(id, opts)
|
||||
if loop and loopStart and love and love.audio and love.audio.newQueueableSource then
|
||||
src, body = Audio._newIntroLoopSource(rawL, rawR, loopStart, master, pan, Audio._mono)
|
||||
end
|
||||
local sd = not src and Audio._buildSeSoundData(rawL, rawR, master, pan, Audio._mono)
|
||||
if sd and love and love.audio and love.audio.newSource then
|
||||
src = love.audio.newSource(sd, "static")
|
||||
if loop then
|
||||
pcall(function() src:setLooping(true) end)
|
||||
end
|
||||
if not src then
|
||||
src = Audio._seSourceFor(id, rawL, rawR, master, pan, Audio._mono, loop)
|
||||
end
|
||||
if src then
|
||||
src:setVolume(1)
|
||||
|
||||
+37
-1
@@ -517,12 +517,34 @@ function Bag.set(bag, id, qty)
|
||||
return Bag.get(bag, id)
|
||||
end
|
||||
|
||||
-- listPocket rows per bag and pocket, rebuilt only when the pocket's
|
||||
-- (id, qty) slots or the loaded item pack change. Bag menus call listPocket
|
||||
-- every frame; each row costs three ItemsData.info lookups.
|
||||
local rowCache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function rows_match(entry, slots, byId)
|
||||
if entry.byId ~= byId then return false end
|
||||
local ids, qtys, n = entry.ids, entry.qtys, entry.n
|
||||
for i = 1, n do
|
||||
local slot = slots[i]
|
||||
if slot == nil or slot.id ~= ids[i] or slot.qty ~= qtys[i] then return false end
|
||||
end
|
||||
return slots[n + 1] == nil
|
||||
end
|
||||
|
||||
--- Ordered list of { id, qty, name, info } for a pocket (bag UI).
|
||||
-- The returned rows are shared between calls until the pocket changes, so
|
||||
-- callers must not modify them.
|
||||
function Bag.listPocket(bag, pocket)
|
||||
bag = ensure(bag)
|
||||
pocket = pocket or "ITEMS"
|
||||
local slots = bag.pockets[pocket] or {}
|
||||
local byId = ItemsData._byId
|
||||
local perBag = rowCache[bag]
|
||||
local entry = perBag and perBag[pocket]
|
||||
if entry and byId and rows_match(entry, slots, byId) then return entry.rows end
|
||||
local rows = {}
|
||||
for _, slot in ipairs(bag.pockets[pocket] or {}) do
|
||||
for _, slot in ipairs(slots) do
|
||||
local qty = tonumber(slot.qty) or 0
|
||||
if slot.id and qty > 0 then
|
||||
rows[#rows + 1] = {
|
||||
@@ -534,6 +556,20 @@ function Bag.listPocket(bag, pocket)
|
||||
}
|
||||
end
|
||||
end
|
||||
byId = ItemsData._byId
|
||||
if byId then
|
||||
local n = 0
|
||||
local ids, qtys = {}, {}
|
||||
for i, slot in ipairs(slots) do
|
||||
n = i
|
||||
ids[i], qtys[i] = slot.id, slot.qty
|
||||
end
|
||||
if not perBag then
|
||||
perBag = {}
|
||||
rowCache[bag] = perBag
|
||||
end
|
||||
perBag[pocket] = { byId = byId, n = n, ids = ids, qtys = qtys, rows = rows }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
|
||||
@@ -1066,4 +1066,16 @@ function Anim.drawParticles(minZ, maxZ)
|
||||
if Anim._vm then Anim._vm:draw(minZ, maxZ) end
|
||||
end
|
||||
|
||||
--- Bracket one frame's z-band drawParticles calls so the VM sorts its
|
||||
--- sprites once per frame instead of once per band.
|
||||
function Anim.beginParticleFrame()
|
||||
local vm = Anim._vm
|
||||
if vm and vm.beginDrawFrame then vm:beginDrawFrame() end
|
||||
end
|
||||
|
||||
function Anim.endParticleFrame()
|
||||
local vm = Anim._vm
|
||||
if vm and vm.endDrawFrame then vm:endDrawFrame() end
|
||||
end
|
||||
|
||||
return Anim
|
||||
|
||||
@@ -17,9 +17,17 @@ AnimPal.loaded = {}
|
||||
AnimPal.idxOf = setmetatable({}, { __mode = "k" })
|
||||
AnimPal._pack = nil
|
||||
|
||||
-- Normalised tags by raw tag (strings and numbers only); tags come from a
|
||||
-- fixed set, and norm runs for every sprite drawn.
|
||||
local _normCache = {}
|
||||
local function norm(tag)
|
||||
if tag == nil then return nil end
|
||||
return (tostring(tag):upper():gsub("^ANIM_TAG_", ""))
|
||||
local hit = _normCache[tag]
|
||||
if hit then return hit end
|
||||
local out = (tostring(tag):upper():gsub("^ANIM_TAG_", ""))
|
||||
local t = type(tag)
|
||||
if t == "string" or t == "number" then _normCache[tag] = out end
|
||||
return out
|
||||
end
|
||||
AnimPal.norm = norm
|
||||
|
||||
|
||||
@@ -745,7 +745,14 @@ P.tagInfo = tag_info
|
||||
|
||||
local function quad_for(img, sx, sy, w, h)
|
||||
local iw, ih = img:getDimensions()
|
||||
local key = sx .. ":" .. sy .. ":" .. w .. ":" .. h
|
||||
-- Numeric key for whole-pixel rects (13+13+10+10 bits), string otherwise.
|
||||
local key
|
||||
if sx >= 0 and sx < 8192 and sy >= 0 and sy < 8192 and w >= 0 and w < 1024 and h >= 0 and h < 1024
|
||||
and sx % 1 == 0 and sy % 1 == 0 and w % 1 == 0 and h % 1 == 0 then
|
||||
key = ((sx * 8192 + sy) * 1024 + w) * 1024 + h
|
||||
else
|
||||
key = sx .. ":" .. sy .. ":" .. w .. ":" .. h
|
||||
end
|
||||
local c = SHEET_CACHE[img]
|
||||
if not c then c = {}; SHEET_CACHE[img] = c end
|
||||
local q = c[key]
|
||||
|
||||
@@ -1416,13 +1416,22 @@ local function overlayResources()
|
||||
return overlayRes
|
||||
end
|
||||
|
||||
-- One quad re-pointed per draw (draw reads the viewport immediately).
|
||||
local windowSpriteQuad = nil
|
||||
|
||||
local function drawWindowSprite(sp)
|
||||
local img = sp.image
|
||||
if not img or sp.invisible or not sp.active then return end
|
||||
local bw = sp._baseW or sp.w or 32
|
||||
local bh = sp._baseH or sp.h or 32
|
||||
local iw, ih = img:getDimensions()
|
||||
local q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
|
||||
local q = windowSpriteQuad
|
||||
if q then
|
||||
q:setViewport(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
|
||||
else
|
||||
q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
|
||||
windowSpriteQuad = q
|
||||
end
|
||||
local sx = (sp.scaleX or 1) * (sp.hFlip and -1 or 1)
|
||||
local sy = (sp.scaleY or 1) * (sp.vFlip and -1 or 1)
|
||||
love.graphics.draw(img, q, math.floor(sp.x + (sp.ox or 0) + 0.5), math.floor(sp.y + (sp.oy or 0) + 0.5),
|
||||
|
||||
@@ -613,6 +613,26 @@ local function effective_z(vm, s)
|
||||
return AnimCoords.layerZ(sub, vm._monbg, vm._bgPrio)
|
||||
end
|
||||
|
||||
-- Last uniforms sent to the shared blend shader. Every send goes through
|
||||
-- send_blend so the cache always matches what the shader holds (the anim bg
|
||||
-- and sprites share one shader).
|
||||
local _lastBlendShader = nil
|
||||
local _lastBlendCoeff = nil
|
||||
local _lastBlendR = nil
|
||||
local _lastBlendG = nil
|
||||
local _lastBlendB = nil
|
||||
|
||||
local function send_blend(sh, coeff, r, g, b)
|
||||
if sh ~= _lastBlendShader or coeff ~= _lastBlendCoeff or r ~= _lastBlendR
|
||||
or g ~= _lastBlendG or b ~= _lastBlendB then
|
||||
_lastBlendShader = sh
|
||||
_lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b
|
||||
pcall(sh.send, sh, "coeff", coeff)
|
||||
pcall(sh.send, sh, "target", { r, g, b })
|
||||
end
|
||||
end
|
||||
AnimVm._sendBlend = send_blend
|
||||
|
||||
local function draw_anim_bg(vm)
|
||||
local id = vm._animBgId
|
||||
local tintBg = vm._animBgBlend
|
||||
@@ -638,10 +658,7 @@ local function draw_anim_bg(vm)
|
||||
local sh = tint and (tint.coeff or 0) > 0 and blend_shader()
|
||||
if sh then
|
||||
local c = tonumber(tint.color) or 0
|
||||
pcall(function()
|
||||
sh:send("coeff", tint.coeff)
|
||||
sh:send("target", { band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31) })
|
||||
end)
|
||||
send_blend(sh, tint.coeff, band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31))
|
||||
love.graphics.setShader(sh)
|
||||
end
|
||||
love.graphics.draw(img, vm._bgQuad, 0, 0)
|
||||
@@ -657,11 +674,6 @@ local function draw_anim_bg(vm)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local _lastBlendCoeff = nil
|
||||
local _lastBlendR = nil
|
||||
local _lastBlendG = nil
|
||||
local _lastBlendB = nil
|
||||
|
||||
local function particle_sort_cmp(a, b)
|
||||
if a._drawZ ~= b._drawZ then return a._drawZ < b._drawZ end
|
||||
return (a._poolIndex or 0) > (b._poolIndex or 0)
|
||||
@@ -694,12 +706,7 @@ local function draw_sprite(self, s, a)
|
||||
local sh = (not pimg) and tint and blend_shader()
|
||||
if sh then
|
||||
local r, g, b = band(tint.color, 31), band(rshift(tint.color, 5), 31), band(rshift(tint.color, 10), 31)
|
||||
local coeff = tint.coeff
|
||||
if coeff ~= _lastBlendCoeff or r ~= _lastBlendR or g ~= _lastBlendG or b ~= _lastBlendB then
|
||||
_lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b
|
||||
pcall(sh.send, sh, "coeff", coeff)
|
||||
pcall(sh.send, sh, "target", { r, g, b })
|
||||
end
|
||||
send_blend(sh, tint.coeff, r, g, b)
|
||||
love.graphics.setShader(sh)
|
||||
end
|
||||
local q = sprite_source_quad(s, bw, bh)
|
||||
@@ -712,44 +719,115 @@ local function draw_sprite(self, s, a)
|
||||
end
|
||||
end
|
||||
|
||||
-- Blend mode currently set by AnimVm:draw (reset to "alpha" per call).
|
||||
local _activeBlend = "alpha"
|
||||
local function set_blend_mode(mode)
|
||||
if mode ~= _activeBlend then
|
||||
if mode == "add" then
|
||||
love.graphics.setBlendMode("add", "alphamultiply")
|
||||
else
|
||||
love.graphics.setBlendMode("alpha", "alphamultiply")
|
||||
end
|
||||
_activeBlend = mode
|
||||
end
|
||||
end
|
||||
|
||||
-- Every active, visible sprite with its effective z, sorted once. The sort
|
||||
-- is a strict total order (z, then pool index), so slicing this list by a z
|
||||
-- band gives the same order as sorting that band on its own.
|
||||
local function build_sorted_sprites(self, list)
|
||||
for i = #list, 1, -1 do list[i] = nil end
|
||||
AnimSprites.init()
|
||||
local pool = AnimSprites._pool
|
||||
local n = 0
|
||||
for i = 1, AnimSprites.MAX do
|
||||
local s = pool[i]
|
||||
s._poolIndex = i
|
||||
if s.active and s.visible ~= false then
|
||||
s._drawZ = effective_z(self, s)
|
||||
n = n + 1
|
||||
list[n] = s
|
||||
end
|
||||
end
|
||||
if n > 1 then table.sort(list, particle_sort_cmp) end
|
||||
return list
|
||||
end
|
||||
|
||||
local function any_task_draw()
|
||||
if not (AnimTasks and AnimTasks._pool) then return false end
|
||||
local pool = AnimTasks._pool
|
||||
for i = 1, AnimTasks.MAX do
|
||||
local t = pool[i]
|
||||
if t and t.active and t.draw then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Bracket the z-band draws of one frame (battle ui). Between begin and end
|
||||
--- the sorted sprite list and the task scan are built once and reused by
|
||||
--- every AnimVm:draw band call; outside a bracket each call rebuilds them.
|
||||
function AnimVm:beginDrawFrame()
|
||||
self._inDrawFrame = true
|
||||
self._frameSorted = false
|
||||
self._frameTasks = nil
|
||||
end
|
||||
|
||||
function AnimVm:endDrawFrame()
|
||||
self._inDrawFrame = false
|
||||
self._frameSorted = false
|
||||
self._frameTasks = nil
|
||||
end
|
||||
|
||||
function AnimVm:draw(minZ, maxZ)
|
||||
if not (love and love.graphics) then return end
|
||||
|
||||
if (minZ or 0) <= 0 then draw_anim_bg(self) end
|
||||
|
||||
if AnimTasks and AnimTasks.draw then
|
||||
AnimTasks.draw(minZ, maxZ, self)
|
||||
local hasTasks = true
|
||||
if self._inDrawFrame then
|
||||
if self._frameTasks == nil then self._frameTasks = any_task_draw() end
|
||||
hasTasks = self._frameTasks
|
||||
end
|
||||
if hasTasks then AnimTasks.draw(minZ, maxZ, self) end
|
||||
end
|
||||
|
||||
local list = self._drawList
|
||||
for i = #list, 1, -1 do list[i] = nil end
|
||||
AnimSprites.init()
|
||||
for i = 1, AnimSprites.MAX do
|
||||
local s = AnimSprites._pool[i]
|
||||
s._poolIndex = i
|
||||
if s.active and s.visible ~= false then
|
||||
local z = effective_z(self, s)
|
||||
local sorted = self._drawList
|
||||
if not (self._inDrawFrame and self._frameSorted) then
|
||||
build_sorted_sprites(self, sorted)
|
||||
self._frameSorted = self._inDrawFrame and true or false
|
||||
end
|
||||
if #sorted == 0 then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
local list = sorted
|
||||
if minZ or maxZ then
|
||||
list = self._bandList
|
||||
if not list then
|
||||
list = {}
|
||||
self._bandList = list
|
||||
end
|
||||
for i = #list, 1, -1 do list[i] = nil end
|
||||
local n = 0
|
||||
for i = 1, #sorted do
|
||||
local s = sorted[i]
|
||||
local z = s._drawZ
|
||||
if (not minZ or z >= minZ) and (not maxZ or z <= maxZ) then
|
||||
s._drawZ = z
|
||||
list[#list + 1] = s
|
||||
n = n + 1
|
||||
list[n] = s
|
||||
end
|
||||
end
|
||||
if n == 0 then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
end
|
||||
table.sort(list, particle_sort_cmp)
|
||||
|
||||
local activeBlend = "alpha"
|
||||
_activeBlend = "alpha"
|
||||
local bld = self.bldAlpha
|
||||
local function setMode(mode)
|
||||
if mode ~= activeBlend then
|
||||
if mode == "add" then
|
||||
love.graphics.setBlendMode("add", "alphamultiply")
|
||||
else
|
||||
love.graphics.setBlendMode("alpha", "alphamultiply")
|
||||
end
|
||||
activeBlend = mode
|
||||
end
|
||||
end
|
||||
for _, s in ipairs(list) do
|
||||
for i = 1, #list do
|
||||
local s = list[i]
|
||||
local a = s.alpha or 1
|
||||
local eva, evb = nil, nil
|
||||
if s.objBlend and bld then
|
||||
@@ -758,12 +836,12 @@ function AnimVm:draw(minZ, maxZ)
|
||||
end
|
||||
if eva and eva + evb ~= 16 and s.image and AnimPal.indexImage(s.image) then
|
||||
-- pokefirered/src/battle_anim.c:630
|
||||
setMode("alpha")
|
||||
set_blend_mode("alpha")
|
||||
AnimPal.blackPass = true
|
||||
s._drawAlpha = a * (1 - evb / 16)
|
||||
draw_sprite(self, s, s._drawAlpha)
|
||||
AnimPal.blackPass = nil
|
||||
setMode("add")
|
||||
set_blend_mode("add")
|
||||
s._drawAlpha = a * eva / 16
|
||||
draw_sprite(self, s, s._drawAlpha)
|
||||
else
|
||||
@@ -772,14 +850,15 @@ function AnimVm:draw(minZ, maxZ)
|
||||
if evb >= 16 and eva < 16 then desiredBlend = "add" end
|
||||
a = a * eva / 16
|
||||
end
|
||||
setMode(desiredBlend)
|
||||
set_blend_mode(desiredBlend)
|
||||
s._drawAlpha = a
|
||||
draw_sprite(self, s, a)
|
||||
end
|
||||
end
|
||||
|
||||
if activeBlend ~= "alpha" then
|
||||
if _activeBlend ~= "alpha" then
|
||||
love.graphics.setBlendMode("alpha", "alphamultiply")
|
||||
_activeBlend = "alpha"
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -136,8 +136,17 @@ local function display_hp_nums(side, battler)
|
||||
return math.floor(hp), math.floor(maxHp)
|
||||
end
|
||||
|
||||
-- One read-only FrlgFont opts table per colour set (FrlgFont never writes
|
||||
-- to opts), so per-frame healthbox text does not allocate.
|
||||
local _smallOpts = setmetatable({}, { __mode = "k" })
|
||||
local function small_opts(colors)
|
||||
return { small = true, colors = colors or HB_TEXT }
|
||||
colors = colors or HB_TEXT
|
||||
local o = _smallOpts[colors]
|
||||
if not o then
|
||||
o = { small = true, colors = colors }
|
||||
_smallOpts[colors] = o
|
||||
end
|
||||
return o
|
||||
end
|
||||
|
||||
local function erase_placeholder_ink(boxX, boxY, pts)
|
||||
|
||||
@@ -2130,6 +2130,19 @@ local function rse_window_colors(fgIdx, shadowIdx)
|
||||
}
|
||||
end
|
||||
|
||||
-- The default (13, 15) window colours, rebuilt only when the manifest's
|
||||
-- palette table changes. Callers must not modify the returned table
|
||||
-- (rse_pp_colors, which does, builds its own with rse_window_colors).
|
||||
local _defaultWinColors, _defaultWinPal = nil, nil
|
||||
local function rse_default_colors()
|
||||
local pal = BattleChrome.manifest().windowTextPal or nil
|
||||
if not _defaultWinColors or pal ~= _defaultWinPal then
|
||||
_defaultWinColors = rse_window_colors()
|
||||
_defaultWinPal = pal
|
||||
end
|
||||
return _defaultWinColors
|
||||
end
|
||||
|
||||
-- pokeemerald/src/battle_message.c:3033
|
||||
local function rse_pp_colors(pp, maxPp)
|
||||
local pp2 = BattleChrome.manifest().ppTextPal or {}
|
||||
@@ -2147,7 +2160,7 @@ local function rse_text(win, text, dx, opts)
|
||||
local useNarrow = opts.narrow == nil and narrow or opts.narrow
|
||||
F.draw(tostring(text or ""), x + (dx or 0), y, {
|
||||
font = useNarrow and "narrow" or nil,
|
||||
colors = opts.colors or rse_window_colors(),
|
||||
colors = opts.colors or rse_default_colors(),
|
||||
})
|
||||
end
|
||||
|
||||
@@ -2161,10 +2174,10 @@ local function draw_action_menu_rse(st)
|
||||
local mx, my = BattleChrome.textOrigin(W.ACTION_MENU)
|
||||
local c = Ui._menuIndex - 1
|
||||
local col, row = c % 2, math.floor(c / 2)
|
||||
Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_window_colors() })
|
||||
Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_default_colors() })
|
||||
for i = 1, 4 do
|
||||
local cc, rr = (i - 1) % 2, math.floor((i - 1) / 2)
|
||||
battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_window_colors() })
|
||||
battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_default_colors() })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2175,7 +2188,7 @@ local function draw_move_menu_rse(st)
|
||||
local mon = ab and ab.mon
|
||||
local c = Ui._moveIndex - 1
|
||||
local _, cy = BattleChrome.textOrigin(W.MOVE_NAME_1)
|
||||
Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_window_colors() })
|
||||
Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_default_colors() })
|
||||
for i = 1, 4 do
|
||||
local mv = mon and mon.moves and mon.moves[i]
|
||||
local label = "-"
|
||||
@@ -2537,6 +2550,7 @@ function Ui.draw(w, h)
|
||||
-- 4. Player Mon (Z: 200)
|
||||
-- 5. In front of Player & Global Foreground (Z: 201 .. 999)
|
||||
local dbl = st and is_double(st)
|
||||
if Anim.beginParticleFrame then Anim.beginParticleFrame() end
|
||||
if dbl then
|
||||
draw_double_mons(st, stage, Anim, screenFxActive)
|
||||
else
|
||||
@@ -2556,6 +2570,7 @@ function Ui.draw(w, h)
|
||||
if screenFxActive then Anim.beginScreenEffect() end
|
||||
Anim.drawParticles(201, 999)
|
||||
end
|
||||
if Anim.endParticleFrame then Anim.endParticleFrame() end
|
||||
draw_intro_ball(stage)
|
||||
-- pokefirered/src/pokeball.c:770
|
||||
BallOpen.draw()
|
||||
|
||||
@@ -1673,15 +1673,21 @@ local function drawFieldRows(R, G, fx)
|
||||
local bx0, by0 = floor(R.X0 / m), floor(R.Y0 / m)
|
||||
local cw = floor((R.X1 - 1) / m) - bx0 + 1
|
||||
local ch = floor((R.Y1 - 1) / m) - by0 + 1
|
||||
local key = cw .. "x" .. ch
|
||||
-- One canvas per transition, grown only when a level needs more room;
|
||||
-- each level draws just its cw x ch corner through a quad.
|
||||
local small = BattleTransition._mosaicCanvas
|
||||
if not small or BattleTransition._mosaicKey ~= key then
|
||||
local key = BattleTransition._mosaicKey
|
||||
if not small or type(key) ~= "table" or key.w < cw or key.h < ch then
|
||||
if small and small.release then pcall(small.release, small) end
|
||||
small = G.newCanvas(cw, ch, { dpiscale = 1 })
|
||||
local nw = math.max(cw, type(key) == "table" and small and key.w or 0)
|
||||
local nh = math.max(ch, type(key) == "table" and small and key.h or 0)
|
||||
small = G.newCanvas(nw, nh, { dpiscale = 1 })
|
||||
small:setFilter("nearest", "nearest")
|
||||
key = { w = nw, h = nh, quad = G.newQuad(0, 0, cw, ch, nw, nh) }
|
||||
BattleTransition._mosaicCanvas = small
|
||||
BattleTransition._mosaicKey = key
|
||||
end
|
||||
key.quad:setViewport(0, 0, cw, ch, key.w, key.h)
|
||||
G.push("all")
|
||||
G.origin()
|
||||
G.setCanvas(small)
|
||||
@@ -1690,7 +1696,7 @@ local function drawFieldRows(R, G, fx)
|
||||
G.draw(tex, -bx0 + 0.5 - (R.gx + 0.5) / m, -by0 + 0.5 - (R.gy + 0.5) / m, 0, 1 / m, 1 / m)
|
||||
G.pop()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(small, bx0 * m, by0 * m, 0, m, m)
|
||||
G.draw(small, key.quad, bx0 * m, by0 * m, 0, m, m)
|
||||
return
|
||||
end
|
||||
G.draw(tex, R.X0, R.Y0)
|
||||
|
||||
@@ -32,9 +32,14 @@ local function log(msg)
|
||||
print("[game3/collision] " .. tostring(msg))
|
||||
end
|
||||
|
||||
local permsLoaded, permsMod
|
||||
local function permissions()
|
||||
local ok, P = pcall(require, "src.world.gen2.Permissions")
|
||||
return ok and P or nil
|
||||
if not permsLoaded then
|
||||
local ok, P = pcall(require, "src.world.gen2.Permissions")
|
||||
permsMod = ok and P or nil
|
||||
permsLoaded = true
|
||||
end
|
||||
return permsMod
|
||||
end
|
||||
|
||||
local function resolveTileset(game, mapDef)
|
||||
@@ -193,6 +198,31 @@ function Collision.installWarps(mapDef)
|
||||
end
|
||||
end
|
||||
|
||||
--- Patch one cell of the bound native grid after a metatile write (what a
|
||||
-- full bindMap would rebuild for it) without re-deriving the whole map.
|
||||
-- Returns false when the caller must bindMap instead: another map is bound,
|
||||
-- the cell is off the grid, or a warp sits on it (installWarps derives warp
|
||||
-- activity and door repairs from the cell).
|
||||
function Collision.patchCell(mapId, mapDef, x, y)
|
||||
if not (Collision._grid and mapDef and mapDef.midLayout) then return false end
|
||||
if Collision._mapId ~= mapId or Collision._mapDef ~= mapDef then return false end
|
||||
local layout = mapDef.midLayout
|
||||
x, y = tonumber(x), tonumber(y)
|
||||
if not (x and y) or x ~= math.floor(x) or y ~= math.floor(y) then return false end
|
||||
local w, h = Collision._widthCells, Collision._heightCells
|
||||
if w ~= layout.width or h ~= layout.height then return false end
|
||||
if x < 0 or y < 0 or x >= w or y >= h then return false end
|
||||
if x >= (layout.trueWidth or w) or y >= (layout.trueHeight or h) then return false end
|
||||
for _, warp in ipairs(mapDef.warps or {}) do
|
||||
if tonumber(warp.x) == x and tonumber(warp.y) == y then return false end
|
||||
end
|
||||
-- same value LayoutNative:collArray yields for this cell
|
||||
local ov = layout.overrides and layout.overrides[y * 1024 + x]
|
||||
local c = layout.cells and layout.cells[y * w + x + 1]
|
||||
Collision._grid[y * w + x + 1] = (ov and ov.coll) or (c and c.coll) or 0xff
|
||||
return true
|
||||
end
|
||||
|
||||
function Collision.clear()
|
||||
Collision._grid = nil
|
||||
Collision._mapId = nil
|
||||
|
||||
@@ -92,9 +92,14 @@ local function loveCache()
|
||||
}
|
||||
end
|
||||
|
||||
-- The cache object is stateless (every read resolves CacheFs, love.filesystem
|
||||
-- and Dataset.cacheRootOverride at call time), so one instance is shared.
|
||||
local sharedCache = nil
|
||||
|
||||
--- Shared firered CacheFs-backed cache for standalone Game3 (mod.cache is nil).
|
||||
function Dataset.cache()
|
||||
return loveCache()
|
||||
if not sharedCache then sharedCache = loveCache() end
|
||||
return sharedCache
|
||||
end
|
||||
|
||||
local dsLoadWarned = false
|
||||
|
||||
@@ -355,7 +355,12 @@ local function presentPlanes(game)
|
||||
Display.mirrorFlatFrame(Renderer)
|
||||
end
|
||||
|
||||
-- The plane path presents straight from Renderer's canvases; the flat 240x160
|
||||
-- copy is only for test drivers that read Display._canvas back.
|
||||
Display.mirrorForTests = false
|
||||
|
||||
function Display.mirrorFlatFrame(Renderer)
|
||||
if not Display.mirrorForTests then return end
|
||||
local canvas = Display.ensureCanvas("main")
|
||||
if not canvas then return end
|
||||
local world = Renderer.worldCanvas
|
||||
|
||||
@@ -207,6 +207,9 @@ function Field.update(_dt)
|
||||
local Ghosts = require("src.core.game3.ghosts")
|
||||
Ghosts.sync()
|
||||
Ghosts.update(game)
|
||||
-- pre-load tileset pairs queued by a seamless world refresh, one per frame
|
||||
local MapMod = package.loaded["src.core.game3.map"]
|
||||
if MapMod and MapMod._warmQueue and MapMod.stepWarm then MapMod.stepWarm() end
|
||||
|
||||
Field.pollMapChange(game)
|
||||
-- pokefirered/src/safari_zone.c:60 CB2_EndSafariBattle
|
||||
@@ -2102,9 +2105,16 @@ function Field.setMetatile(x, y, metatile, isImpassable)
|
||||
layout:applyOverride(x, y, mid, coll, layout:elevAt(x, y))
|
||||
Field._overrideLayouts[mapId] = layout
|
||||
local Collision = require("src.core.game3.collision")
|
||||
Collision.bindMap(game, mapId, mapDef)
|
||||
if not (Collision.patchCell and Collision.patchCell(mapId, mapDef, x, y)) then
|
||||
Collision.bindMap(game, mapId, mapDef)
|
||||
end
|
||||
-- applyOverride already invalidated the view cell (FieldView.invalidateLayoutCell).
|
||||
local FieldView = package.loaded["src.core.game3.field_view"]
|
||||
if FieldView then FieldView._nativeDirty = true end
|
||||
local LayoutNative = package.loaded["src.core.game3.layout_native"]
|
||||
if FieldView and not (FieldView.invalidateLayoutCell and LayoutNative
|
||||
and layout.applyOverride == LayoutNative.applyOverride) then
|
||||
FieldView._nativeDirty = true
|
||||
end
|
||||
end
|
||||
local world = game and (game.overworld or game.world)
|
||||
if world and world.map and world.map.setBlock then
|
||||
|
||||
+201
-29
@@ -840,14 +840,27 @@ local function washColor(bgSet)
|
||||
return 0.12, 0.28, 0.22
|
||||
end
|
||||
|
||||
local function releaseBatch(batch)
|
||||
if batch and batch.release then pcall(batch.release, batch) end
|
||||
end
|
||||
|
||||
-- Outside a full reset, visible cells hold sprite indices into the batch
|
||||
-- stored under `srcPair`, so a texture swap cannot replace it in place (the
|
||||
-- next releaseNativeSprite would index a smaller batch). Report the swap and
|
||||
-- let drawNativeTiles rebuild everything this frame instead.
|
||||
local function ensure_batch(store, srcPair, texture, capacity)
|
||||
local batch = store[srcPair]
|
||||
if batch and batch.getTexture and batch:getTexture() ~= texture then
|
||||
if not FieldView._nativeResetting then
|
||||
FieldView._nativeSwap = true
|
||||
return nil
|
||||
end
|
||||
releaseBatch(batch)
|
||||
batch = nil
|
||||
end
|
||||
if not batch then
|
||||
batch = love.graphics.newSpriteBatch(texture, capacity)
|
||||
store[srcPair] = batch
|
||||
elseif batch.getTexture and batch:getTexture() ~= texture then
|
||||
batch = love.graphics.newSpriteBatch(texture, capacity)
|
||||
store[srcPair] = batch
|
||||
end
|
||||
return batch
|
||||
end
|
||||
@@ -864,8 +877,12 @@ local function nativeAtlas(NativeTileset, pair)
|
||||
return ts
|
||||
end
|
||||
|
||||
local function acquireNativeSprite(store, free, key, texture, capacity, quad, x, y)
|
||||
-- Physical sprite slots per layer (free-list reuse does not grow them).
|
||||
local nativeSlots = { under = 0, over = 0 }
|
||||
|
||||
local function acquireNativeSprite(store, free, key, texture, capacity, quad, x, y, layer)
|
||||
local batch = ensure_batch(store, key, texture, capacity)
|
||||
if not batch then return nil end
|
||||
local available = free[key]
|
||||
local index
|
||||
if available and #available > 0 then
|
||||
@@ -874,7 +891,9 @@ local function acquireNativeSprite(store, free, key, texture, capacity, quad, x,
|
||||
batch:set(index, quad, x, y)
|
||||
else
|
||||
index = batch:add(quad, x, y)
|
||||
FieldView._nativeSpriteSlots = FieldView._nativeSpriteSlots + 1
|
||||
local n = nativeSlots[layer] + 1
|
||||
nativeSlots[layer] = n
|
||||
if n > FieldView._nativeSpriteSlots then FieldView._nativeSpriteSlots = n end
|
||||
end
|
||||
return { key = key, index = index, quad = quad }
|
||||
end
|
||||
@@ -883,6 +902,7 @@ local function releaseNativeSprite(store, free, sprite)
|
||||
if not sprite then return true end
|
||||
local batch = store[sprite.key]
|
||||
if not (batch and batch.set) then return false end
|
||||
if batch.getCount and sprite.index > batch:getCount() then return false end
|
||||
batch:set(sprite.index, sprite.quad, HIDDEN_SPRITE_X, HIDDEN_SPRITE_Y)
|
||||
local available = free[sprite.key]
|
||||
if not available then
|
||||
@@ -893,6 +913,80 @@ local function releaseNativeSprite(store, free, sprite)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Clear every native batch for a full rebuild, keeping the SpriteBatch
|
||||
-- objects (and their GPU buffers) for reuse. Batches that cannot be cleared
|
||||
-- (test doubles) are dropped.
|
||||
local function clearNativeStore(store)
|
||||
for k, batch in pairs(store) do
|
||||
if batch.clear then
|
||||
batch:clear()
|
||||
else
|
||||
releaseBatch(batch)
|
||||
store[k] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- After a rebuild, release batches no visible cell uses any more.
|
||||
local function pruneNativeStore(store)
|
||||
for k, batch in pairs(store) do
|
||||
if batch.getCount and batch:getCount() == 0 then
|
||||
releaseBatch(batch)
|
||||
store[k] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function releaseVoidFrom(from)
|
||||
if not from then return end
|
||||
for _, t in ipairs({ from.under, from.over }) do
|
||||
for _, batch in pairs(t or {}) do releaseBatch(batch) end
|
||||
end
|
||||
end
|
||||
|
||||
--- Mark one cell of the drawn map's layout for re-sampling on the next draw
|
||||
-- (metatile writes). x, y are layout (current-map) cell coordinates. Cells
|
||||
-- that are not visible need nothing: they are sampled when they scroll in.
|
||||
function FieldView.invalidateCell(x, y, layout)
|
||||
if FieldView._nativeDirty or type(FieldView._nativeVisibleCells) ~= "table" then return end
|
||||
x, y = tonumber(x), tonumber(y)
|
||||
if not (x and y) or (layout ~= nil and layout ~= FieldView._nativeLayout) then
|
||||
FieldView._nativeDirty = true
|
||||
return
|
||||
end
|
||||
local cells = FieldView._nativeDirtyCells
|
||||
if not cells then
|
||||
cells = {}
|
||||
FieldView._nativeDirtyCells = cells
|
||||
end
|
||||
local n = #cells
|
||||
if n >= 1024 then
|
||||
FieldView._nativeDirty = true
|
||||
return
|
||||
end
|
||||
cells[n + 1] = x
|
||||
cells[n + 2] = y
|
||||
end
|
||||
|
||||
--- Metatile write on `layout` at its own (x, y). Only the drawn map's own
|
||||
-- layout maps 1:1 onto view cells; anything else (neighbour maps, a layout
|
||||
-- shared with a neighbour) falls back to a full rebuild.
|
||||
function FieldView.invalidateLayoutCell(layout, x, y)
|
||||
if FieldView._nativeDirty then return end
|
||||
if layout == nil or layout ~= FieldView._nativeLayout then
|
||||
FieldView._nativeDirty = true
|
||||
return
|
||||
end
|
||||
local Map = package.loaded["src.core.game3.map"]
|
||||
for _, entry in ipairs((Map and Map.world) or {}) do
|
||||
if entry.def and entry.def.midLayout == layout then
|
||||
FieldView._nativeDirty = true
|
||||
return
|
||||
end
|
||||
end
|
||||
FieldView.invalidateCell(x, y, layout)
|
||||
end
|
||||
|
||||
FieldView.VOID_FADE_FRAMES = 20
|
||||
local voidKeys = setmetatable({}, { __index = function(t, pair)
|
||||
local k = "void|" .. pair
|
||||
@@ -969,7 +1063,8 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
|
||||
end
|
||||
return false
|
||||
end
|
||||
if not NativeTileset.get(pair) then return false end
|
||||
local mainTs = nativeAtlas(NativeTileset, pair)
|
||||
if not mainTs then return false end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, canvasW, canvasH)
|
||||
@@ -986,22 +1081,39 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
|
||||
local voidMode = VoidFill.normalize(VoidFill.mode)
|
||||
beginVoidFade(Map, mapDef, camX, camY, voidMode)
|
||||
|
||||
local underStore = FieldView._nativeBatches
|
||||
if not underStore then
|
||||
underStore = {}
|
||||
FieldView._nativeBatches = underStore
|
||||
end
|
||||
local overStore = FieldView._nativeOverBatches
|
||||
if not overStore then
|
||||
overStore = {}
|
||||
FieldView._nativeOverBatches = overStore
|
||||
end
|
||||
local mainBatch = underStore[pair]
|
||||
|
||||
local moved = FieldView._nativeBx ~= cx0 or FieldView._nativeBy ~= cy0
|
||||
local reset = FieldView._nativeDirty
|
||||
or FieldView._nativeVoid ~= voidMode
|
||||
or FieldView._nativePair ~= pair
|
||||
or FieldView._nativeLayout ~= layout
|
||||
or FieldView._nativeViewCols ~= cols
|
||||
or FieldView._nativeViewRows ~= rows
|
||||
or type(FieldView._nativeVisibleCells) ~= "table"
|
||||
or type(FieldView._nativeFreeUnder) ~= "table"
|
||||
or type(FieldView._nativeFreeOver) ~= "table"
|
||||
or (FieldView._nativeSpriteSlots or 0) > capacity * 4
|
||||
-- primary atlas reloaded (tileset reinstall): cells index the old texture
|
||||
or (mainBatch ~= nil and mainBatch.getTexture ~= nil and mainBatch:getTexture() ~= mainTs.image)
|
||||
if not reset and moved then
|
||||
local dx = math.abs(cx0 - (FieldView._nativeBx or cx0))
|
||||
local dy = math.abs(cy0 - (FieldView._nativeBy or cy0))
|
||||
if dx >= cols or dy >= rows then
|
||||
reset = true
|
||||
else
|
||||
for _, store in ipairs({ FieldView._nativeBatches, FieldView._nativeOverBatches }) do
|
||||
for _, batch in pairs(store or {}) do
|
||||
for _, store in ipairs({ underStore, overStore }) do
|
||||
for _, batch in pairs(store) do
|
||||
if type(batch.set) ~= "function" then reset = true; break end
|
||||
end
|
||||
if reset then break end
|
||||
@@ -1040,16 +1152,16 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
|
||||
local slot = NativeTileset.slotFor(ts, mid)
|
||||
local q = NativeTileset.quad(ts, slot)
|
||||
if q then
|
||||
info.under = acquireNativeSprite(FieldView._nativeBatches,
|
||||
info.under = acquireNativeSprite(underStore,
|
||||
FieldView._nativeFreeUnder, key, ts.image, capacity, q,
|
||||
(wx - baseBx) * CELL, (wy - baseBy) * CELL)
|
||||
(wx - baseBx) * CELL, (wy - baseBy) * CELL, "under")
|
||||
end
|
||||
if ts.layered and ts.overImage then
|
||||
local oq = NativeTileset.overQuad(ts, slot)
|
||||
if oq then
|
||||
info.over = acquireNativeSprite(FieldView._nativeOverBatches,
|
||||
info.over = acquireNativeSprite(overStore,
|
||||
FieldView._nativeFreeOver, key, ts.overImage, capacity, oq,
|
||||
(wx - baseBx) * CELL, (wy - baseBy) * CELL)
|
||||
(wx - baseBx) * CELL, (wy - baseBy) * CELL, "over")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1057,12 +1169,16 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
|
||||
return info
|
||||
end
|
||||
|
||||
local function releaseCell(info)
|
||||
return releaseNativeSprite(underStore, FieldView._nativeFreeUnder, info.under)
|
||||
and releaseNativeSprite(overStore, FieldView._nativeFreeOver, info.over)
|
||||
end
|
||||
|
||||
if not reset and moved then
|
||||
for wy, row in pairs(visibleCells) do
|
||||
for wx, info in pairs(row) do
|
||||
if wx < cx0 or wx >= cx0 + cols or wy < cy0 or wy >= cy0 + rows then
|
||||
if not releaseNativeSprite(FieldView._nativeBatches, FieldView._nativeFreeUnder, info.under)
|
||||
or not releaseNativeSprite(FieldView._nativeOverBatches, FieldView._nativeFreeOver, info.over) then
|
||||
if not releaseCell(info) then
|
||||
reset = true
|
||||
break
|
||||
end
|
||||
@@ -1074,39 +1190,89 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
|
||||
end
|
||||
end
|
||||
|
||||
if reset then
|
||||
FieldView._nativeBatches, FieldView._nativeOverBatches = {}, {}
|
||||
-- Metatile writes since the last draw: drop just those cells so the fill
|
||||
-- loop below re-samples them (FieldView.invalidateCell).
|
||||
local dirtyCells = FieldView._nativeDirtyCells
|
||||
if dirtyCells then
|
||||
FieldView._nativeDirtyCells = nil
|
||||
if not reset then
|
||||
for i = 1, #dirtyCells - 1, 2 do
|
||||
local wx, wy = dirtyCells[i], dirtyCells[i + 1]
|
||||
local row = visibleCells[wy]
|
||||
local info = row and row[wx]
|
||||
if info then
|
||||
if not releaseCell(info) then
|
||||
reset = true
|
||||
break
|
||||
end
|
||||
row[wx] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function resetNative()
|
||||
clearNativeStore(underStore)
|
||||
clearNativeStore(overStore)
|
||||
FieldView._nativeFreeUnder, FieldView._nativeFreeOver = {}, {}
|
||||
visibleCells = {}
|
||||
FieldView._nativeVisibleCells = visibleCells
|
||||
visiblePairs = {}
|
||||
FieldView._nativeVisiblePairs = visiblePairs
|
||||
nativeSlots.under, nativeSlots.over = 0, 0
|
||||
FieldView._nativeSpriteSlots = 0
|
||||
FieldView._nativeBaseBx, FieldView._nativeBaseBy = cx0, cy0
|
||||
FieldView._nativeViewCols, FieldView._nativeViewRows = cols, rows
|
||||
baseBx, baseBy = cx0, cy0
|
||||
end
|
||||
|
||||
if reset then
|
||||
resetNative()
|
||||
elseif moved then
|
||||
for k in pairs(visiblePairs) do visiblePairs[k] = nil end
|
||||
end
|
||||
|
||||
for wy = cy0, cy0 + rows - 1 do
|
||||
local row = visibleCells[wy]
|
||||
if not row then
|
||||
row = {}
|
||||
visibleCells[wy] = row
|
||||
end
|
||||
for wx = cx0, cx0 + cols - 1 do
|
||||
local info = row[wx]
|
||||
if not info then
|
||||
info = addCell(wx, wy)
|
||||
row[wx] = info
|
||||
local function fill()
|
||||
for wy = cy0, cy0 + rows - 1 do
|
||||
local row = visibleCells[wy]
|
||||
if not row then
|
||||
row = {}
|
||||
visibleCells[wy] = row
|
||||
end
|
||||
for wx = cx0, cx0 + cols - 1 do
|
||||
local info = row[wx]
|
||||
if not info then
|
||||
info = addCell(wx, wy)
|
||||
if FieldView._nativeSwap then return false end
|
||||
row[wx] = info
|
||||
end
|
||||
if info.draw then visiblePairs[info.pair] = true end
|
||||
end
|
||||
if info.draw then visiblePairs[info.pair] = true end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
FieldView._nativeSwap = false
|
||||
FieldView._nativeResetting = reset
|
||||
if not fill() then
|
||||
-- An atlas was reloaded while its cells were live: rebuild everything
|
||||
-- this frame rather than swap the batch under their sprite indices.
|
||||
FieldView._nativeSwap = false
|
||||
reset = true
|
||||
resetNative()
|
||||
FieldView._nativeResetting = true
|
||||
fill()
|
||||
end
|
||||
FieldView._nativeResetting = false
|
||||
FieldView._nativeSwap = false
|
||||
if reset then
|
||||
pruneNativeStore(underStore)
|
||||
pruneNativeStore(overStore)
|
||||
end
|
||||
|
||||
FieldView._nativeBx, FieldView._nativeBy = cx0, cy0
|
||||
FieldView._nativePair = pair
|
||||
FieldView._nativeLayout = layout
|
||||
FieldView._nativeVoid = voidMode
|
||||
FieldView._nativeDirty = false
|
||||
if reset or moved then
|
||||
@@ -1128,7 +1294,10 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
|
||||
drawLayer(FieldView._nativeBatches, pair, ox, oy, from and from.under, camX, camY)
|
||||
if from then
|
||||
from.t = from.t + 1
|
||||
if from.t >= FieldView.VOID_FADE_FRAMES then FieldView._voidFrom = nil end
|
||||
if from.t >= FieldView.VOID_FADE_FRAMES then
|
||||
releaseVoidFrom(from)
|
||||
FieldView._voidFrom = nil
|
||||
end
|
||||
end
|
||||
FieldView._lastCamX, FieldView._lastCamY = camX, camY
|
||||
|
||||
@@ -1634,6 +1803,9 @@ function FieldView.invalidate()
|
||||
FieldView._nativeVisiblePairs = nil
|
||||
FieldView._nativeFreeUnder, FieldView._nativeFreeOver = nil, nil
|
||||
FieldView._nativeSpriteSlots = 0
|
||||
nativeSlots.under, nativeSlots.over = 0, 0
|
||||
FieldView._nativeDirtyCells = nil
|
||||
FieldView._nativeLayout = nil
|
||||
FieldView._voidFrom, FieldView._voidMapDef = nil, nil
|
||||
FieldView._lastCamX, FieldView._lastCamY = nil, nil
|
||||
FieldView._nativeBx = nil
|
||||
|
||||
+73
-16
@@ -69,23 +69,48 @@ Ghosts._contextFor = contextFor
|
||||
|
||||
local ctxCache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function Ghosts.sync()
|
||||
local M = Map()
|
||||
local placed = {}
|
||||
for _, entry in ipairs(M.world or {}) do
|
||||
placed[entry.id] = true
|
||||
if not Ghosts._pools[entry.id] then
|
||||
local defs = defsFor(entry.id, entry.def)
|
||||
if defs then
|
||||
Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def, entry.id)
|
||||
end
|
||||
end
|
||||
local EMPTY = {}
|
||||
local syncPlaced = {}
|
||||
|
||||
-- True when the last rebuild still describes Map.world and the pool table:
|
||||
-- same world list, a pool for every placed map and no extra pools.
|
||||
local function syncCurrent(world)
|
||||
if Ghosts._syncWorld ~= world or Ghosts._syncPools ~= Ghosts._pools then return false end
|
||||
local n = 0
|
||||
for _, entry in ipairs(world) do
|
||||
if not Ghosts._pools[entry.id] then return false end
|
||||
n = n + 1
|
||||
end
|
||||
for id in pairs(Ghosts._pools) do
|
||||
if not placed[id] and id ~= Ghosts._held then
|
||||
Ghosts._pools[id] = nil
|
||||
end
|
||||
if not syncPlaced[id] and id ~= Ghosts._held then return false end
|
||||
n = n - 1
|
||||
end
|
||||
return n == 0 or (Ghosts._held ~= nil and n == -1)
|
||||
end
|
||||
|
||||
function Ghosts.sync()
|
||||
local M = Map()
|
||||
local world = M.world or EMPTY
|
||||
if not syncCurrent(world) then
|
||||
local placed = syncPlaced
|
||||
for id in pairs(placed) do placed[id] = nil end
|
||||
for _, entry in ipairs(world) do
|
||||
placed[entry.id] = true
|
||||
if not Ghosts._pools[entry.id] then
|
||||
local defs = defsFor(entry.id, entry.def)
|
||||
if defs then
|
||||
Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def, entry.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
for id in pairs(Ghosts._pools) do
|
||||
if not placed[id] and id ~= Ghosts._held then
|
||||
Ghosts._pools[id] = nil
|
||||
end
|
||||
end
|
||||
Ghosts._syncWorld, Ghosts._syncPools = world, Ghosts._pools
|
||||
end
|
||||
local placed = syncPlaced
|
||||
if Ghosts._held and not placed[Ghosts._held] then
|
||||
Ghosts._heldGrace = (Ghosts._heldGrace or 0) + 1
|
||||
if Ghosts._heldGrace > 2 then
|
||||
@@ -98,12 +123,44 @@ function Ghosts.sync()
|
||||
end
|
||||
end
|
||||
|
||||
-- Neighbour NPCs only step while their map is near the camera (one screen of
|
||||
-- margin past the view); farther pools hold still (pret only runs object
|
||||
-- events spawned around the camera, TrySpawnObjectEvents). Raise
|
||||
-- TICK_MARGIN_SCREENS (math.huge = tick every pool) to widen it.
|
||||
Ghosts.TICK_MARGIN_SCREENS = 1
|
||||
|
||||
local function tickRect()
|
||||
local P = package.loaded["src.core.game3.player"]
|
||||
local px, py = P and tonumber(P.cellX), P and tonumber(P.cellY)
|
||||
if not (px and py) then return nil end
|
||||
local FieldView = package.loaded["src.core.game3.field_view"]
|
||||
local Display = package.loaded["src.core.game3.display"]
|
||||
local vw = (FieldView and FieldView._viewW) or (Display and Display.W) or 240
|
||||
local vh = (FieldView and FieldView._viewH) or (Display and Display.H) or 160
|
||||
local cols, rows = math.ceil(vw / 16), math.ceil(vh / 16)
|
||||
local mx = math.ceil(cols / 2) + 1 + cols * Ghosts.TICK_MARGIN_SCREENS
|
||||
local my = math.ceil(rows / 2) + 1 + rows * Ghosts.TICK_MARGIN_SCREENS
|
||||
-- cull rect in current-map cells
|
||||
return px - mx, py - my, px + mx, py + my
|
||||
end
|
||||
|
||||
local function nearView(entry, x0, y0, x1, y1)
|
||||
if not x0 then return true end
|
||||
local layout = entry.def and entry.def.midLayout
|
||||
local w = layout and layout.width
|
||||
local h = layout and layout.height
|
||||
if not (w and h) then return true end
|
||||
local ox, oy = entry.ox or 0, entry.oy or 0
|
||||
return ox + w > x0 and ox <= x1 and oy + h > y0 and oy <= y1
|
||||
end
|
||||
|
||||
function Ghosts.update(game)
|
||||
local M = Map()
|
||||
local Obj = Objects()
|
||||
for _, entry in ipairs(M.world or {}) do
|
||||
local x0, y0, x1, y1 = tickRect()
|
||||
for _, entry in ipairs(M.world or EMPTY) do
|
||||
local pool = Ghosts._pools[entry.id]
|
||||
if pool then
|
||||
if pool and nearView(entry, x0, y0, x1, y1) then
|
||||
local c = ctxCache[entry]
|
||||
if not c or c.pool ~= pool or c.def ~= entry.def or c.layout ~= (entry.def and entry.def.midLayout) or c.ox ~= entry.ox or c.oy ~= entry.oy then
|
||||
c = { pool = pool, def = entry.def, layout = entry.def and entry.def.midLayout, ox = entry.ox, oy = entry.oy, ctx = contextFor(entry, pool) }
|
||||
|
||||
@@ -109,7 +109,11 @@ function LayoutNative:applyOverride(x, y, mid, coll, elev)
|
||||
}
|
||||
local FieldView = package.loaded["src.core.game3.field_view"]
|
||||
if FieldView then
|
||||
FieldView._nativeDirty = true
|
||||
if FieldView.invalidateLayoutCell then
|
||||
FieldView.invalidateLayoutCell(self, x, y)
|
||||
else
|
||||
FieldView._nativeDirty = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -120,6 +124,22 @@ local function markDirty()
|
||||
end
|
||||
end
|
||||
|
||||
-- Re-sample only the written cells when there are few of them (per-frame
|
||||
-- metatile animations); bulk writes rebuild the whole view.
|
||||
local MAX_CELL_INVALIDATIONS = 64
|
||||
|
||||
local function markCellsDirty(layout, cells, n)
|
||||
local FieldView = package.loaded["src.core.game3.field_view"]
|
||||
if not FieldView then return end
|
||||
if not FieldView.invalidateLayoutCell or n > MAX_CELL_INVALIDATIONS then
|
||||
FieldView._nativeDirty = true
|
||||
return
|
||||
end
|
||||
for i = 1, n * 2, 2 do
|
||||
FieldView.invalidateLayoutCell(layout, cells[i], cells[i + 1])
|
||||
end
|
||||
end
|
||||
|
||||
-- pokeemerald/src/battle_pyramid.c:1523
|
||||
function LayoutNative:stamp(src, ox, oy)
|
||||
if type(src) ~= "table" or type(src.cells) ~= "table" then return 0 end
|
||||
@@ -145,6 +165,7 @@ end
|
||||
-- pokeemerald/src/fieldmap.c:357
|
||||
function LayoutNative:setMetatiles(rows)
|
||||
local n = 0
|
||||
local cells = {}
|
||||
for _, r in ipairs(rows or {}) do
|
||||
local x, y = tonumber(r.x) or 0, tonumber(r.y) or 0
|
||||
local cur = self:cellAt(x, y)
|
||||
@@ -154,8 +175,9 @@ function LayoutNative:setMetatiles(rows)
|
||||
elev = r.elev ~= nil and r.elev or cur.elev or 0,
|
||||
}
|
||||
n = n + 1
|
||||
cells[n * 2 - 1], cells[n * 2] = x, y
|
||||
end
|
||||
if n > 0 then markDirty() end
|
||||
if n > 0 then markCellsDirty(self, cells, n) end
|
||||
return n
|
||||
end
|
||||
|
||||
|
||||
@@ -151,10 +151,24 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
|
||||
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
|
||||
if Map._warmPairs and NativeTileset and NativeTileset.get then
|
||||
Map._warmPairs = nil
|
||||
Map._warmQueue = nil
|
||||
for _, entry in ipairs(Map.world) do
|
||||
local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair))
|
||||
if pair and NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end
|
||||
end
|
||||
elseif NativeTileset and NativeTileset.get then
|
||||
-- Seamless crossing: queue the new world's unloaded pairs and load one per
|
||||
-- frame (Map.stepWarm) so a pair is ready before it scrolls into view.
|
||||
local queue, seen = {}, {}
|
||||
for _, entry in ipairs(Map.world) do
|
||||
local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair))
|
||||
if type(pair) == "string" and not seen[pair]
|
||||
and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then
|
||||
seen[pair] = true
|
||||
queue[#queue + 1] = pair
|
||||
end
|
||||
end
|
||||
Map._warmQueue = queue[1] and queue or nil
|
||||
end
|
||||
Map._worldRoot = rootId
|
||||
Map._worldReachW = reachW
|
||||
@@ -164,6 +178,24 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
|
||||
return Map.world
|
||||
end
|
||||
|
||||
--- Load at most one queued tileset pair (see refreshWorld).
|
||||
function Map.stepWarm()
|
||||
local queue = Map._warmQueue
|
||||
if not queue then return false end
|
||||
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
|
||||
while queue[1] do
|
||||
local pair = table.remove(queue, 1)
|
||||
if NativeTileset and not (NativeTileset._pairs and NativeTileset._pairs[pair])
|
||||
and NativeTileset.ready and NativeTileset.ready(pair) then
|
||||
pcall(NativeTileset.get, pair)
|
||||
if not queue[1] then Map._warmQueue = nil end
|
||||
return true
|
||||
end
|
||||
end
|
||||
Map._warmQueue = nil
|
||||
return false
|
||||
end
|
||||
|
||||
function Map.overscanSlices()
|
||||
local slices = {}
|
||||
for _, n in ipairs(Map.neighborList or {}) do
|
||||
|
||||
+46
-18
@@ -119,6 +119,7 @@ function Oam.reset()
|
||||
s.anims, s.animQuads, s.affineAnim = nil, nil, nil
|
||||
end
|
||||
Oam._buffer = nil
|
||||
Oam._sorted = nil
|
||||
Oam._clip = nil
|
||||
Oam._fx = nil
|
||||
Oam._blend = nil
|
||||
@@ -295,7 +296,14 @@ end
|
||||
--- Begin frame (pret: clear shadow OAM build).
|
||||
function Oam.resetFrame()
|
||||
ensure_pool()
|
||||
Oam._buffer = {}
|
||||
-- Empty this frame's draw list in place; the sorted order survives in
|
||||
-- Oam._sorted so buildOamBuffer can skip re-sorting an unchanged scene.
|
||||
local buf = Oam._buffer
|
||||
if buf == nil or buf == Oam._sorted then
|
||||
Oam._buffer = {}
|
||||
else
|
||||
for i = #buf, 1, -1 do buf[i] = nil end
|
||||
end
|
||||
end
|
||||
|
||||
Oam._layer = "ui"
|
||||
@@ -504,25 +512,44 @@ function Oam.buildOamBuffer(pretOrder)
|
||||
end
|
||||
end
|
||||
local cmp = pretOrder and sort_sprites_pret or sort_sprites
|
||||
local cached = Oam._buffer
|
||||
if cached and #cached == n then
|
||||
local ok = true
|
||||
-- Cache: last frame's sorted list. Same length, every entry still drawable
|
||||
-- and still in order means it holds exactly this frame's sprites (entries
|
||||
-- are distinct pool slots), so the sort can be skipped.
|
||||
local sorted = Oam._sorted
|
||||
local ok = false
|
||||
if sorted and #sorted == n and Oam._sortedPool == Oam._sprites then
|
||||
ok = true
|
||||
for i = 1, n do
|
||||
local s = cached[i]
|
||||
local s = sorted[i]
|
||||
if not (s.inUse and not s.invisible and s.image) then ok = false break end
|
||||
if i < n and cmp(cached[i + 1], s) then ok = false break end
|
||||
end
|
||||
if ok then return cached end
|
||||
end
|
||||
local buf = {}
|
||||
for i = 0, Oam.MAX_SPRITES - 1 do
|
||||
local s = Oam._sprites[i]
|
||||
if s.inUse and not s.invisible and s.image then
|
||||
buf[#buf + 1] = s
|
||||
local nx = sorted[i + 1]
|
||||
if nx then
|
||||
if cmp(nx, s) then ok = false break end
|
||||
-- equal keys: keep pool order (pret's insertion sort is stable)
|
||||
if not cmp(s, nx) and (s._id or 0) > (nx._id or 0) then ok = false break end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(buf, cmp)
|
||||
Oam._buffer = buf
|
||||
if not ok then
|
||||
sorted = sorted or {}
|
||||
for i = #sorted, 1, -1 do sorted[i] = nil end
|
||||
for i = 0, Oam.MAX_SPRITES - 1 do
|
||||
local s = Oam._sprites[i]
|
||||
if s.inUse and not s.invisible and s.image then
|
||||
sorted[#sorted + 1] = s
|
||||
end
|
||||
end
|
||||
table.sort(sorted, cmp)
|
||||
Oam._sorted = sorted
|
||||
Oam._sortedPool = Oam._sprites
|
||||
end
|
||||
local buf = Oam._buffer
|
||||
if buf == nil or buf == sorted then
|
||||
buf = {}
|
||||
Oam._buffer = buf
|
||||
end
|
||||
for i = 1, n do buf[i] = sorted[i] end
|
||||
for i = #buf, n + 1, -1 do buf[i] = nil end
|
||||
return buf
|
||||
end
|
||||
|
||||
@@ -560,11 +587,12 @@ local function blit_sprite(s)
|
||||
local blend = s.blend or Oam._blend
|
||||
local clip = s.clip or Oam._clip
|
||||
if clip and (clip.w <= 0 or clip.h <= 0) then return end
|
||||
local draw = s.affineScale and function() blit_affine(s) end or function() blit_plain(s) end
|
||||
local affine = s.affineScale
|
||||
if not fx and not blend and not clip then
|
||||
draw()
|
||||
if affine then blit_affine(s) else blit_plain(s) end
|
||||
return
|
||||
end
|
||||
local draw = affine and function() blit_affine(s) end or function() blit_plain(s) end
|
||||
Fx.withClip(clip, function() Fx.draw(draw, fx, blend) end)
|
||||
end
|
||||
|
||||
|
||||
@@ -1514,6 +1514,13 @@ local function pick(t)
|
||||
return t[(idleRng.Random() % #t) + 1]
|
||||
end
|
||||
|
||||
-- tostring(movement):upper(), memoised (idleTick runs per object per frame).
|
||||
local UPPER_MOVEMENT = setmetatable({}, { __index = function(t, k)
|
||||
local v = tostring(k):upper()
|
||||
t[k] = v
|
||||
return v
|
||||
end })
|
||||
|
||||
local function idleTick(eo, game, ctx)
|
||||
if eo.frozen or eo.scriptBusy or eo.moving or eo.hidden or not eo.visible then
|
||||
return
|
||||
@@ -1527,7 +1534,7 @@ local function idleTick(eo, game, ctx)
|
||||
return
|
||||
end
|
||||
|
||||
local mv = tostring(eo.movement or "STAY"):upper()
|
||||
local mv = UPPER_MOVEMENT[eo.movement or "STAY"]
|
||||
if mv == "STAY" then return end
|
||||
if mv == "IN_PLACE" then
|
||||
-- pokeemerald/src/event_object_movement.c:4422
|
||||
|
||||
@@ -1502,8 +1502,17 @@ local function pic(store, kind, species, form, shiny)
|
||||
if shiny and species ~= Pokemon.SPECIES_EGG then kind = kind .. "_shiny" end
|
||||
local key = form > 0 and (species .. "_" .. form) or species
|
||||
if kind:find("_shiny", 1, true) then key = "shiny:" .. key end
|
||||
if store[key] then return store[key] end
|
||||
return pic_entry(store, key, read_pic(pic_rel(kind, species, form)))
|
||||
local hit = store[key]
|
||||
if hit then return hit end
|
||||
-- false marks a pic file known to be missing, so draw loops that probe
|
||||
-- backPic then frontPic every frame do not re-read the filesystem.
|
||||
if hit == false then return nil end
|
||||
local rgba = read_pic(pic_rel(kind, species, form))
|
||||
if not rgba then
|
||||
store[key] = false
|
||||
return nil
|
||||
end
|
||||
return pic_entry(store, key, rgba)
|
||||
end
|
||||
|
||||
Pokemon.SPECIES_SPINDA = 308
|
||||
|
||||
@@ -99,7 +99,37 @@ function RomText.box(key, ctx)
|
||||
return TextIR.toTextBox(RomText.translate(RomText.ir(key), ctx, key), ctx)
|
||||
end
|
||||
|
||||
-- Segment types whose expansion reads nothing but the segment itself. An IR
|
||||
-- made only of these expands to the same plain string every time.
|
||||
local PURE_SEG = { text = true, tag = true, nl = true, para = true, scroll = true, eos = true, ext = true }
|
||||
|
||||
-- plain() results for pure IRs, keyed by the IR table itself (false = not
|
||||
-- pure). Keying on the IR follows overrides, profile aliases and bundle
|
||||
-- reloads for free: each resolves to a different table.
|
||||
local plainCache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function pure_ir(ir)
|
||||
if type(ir) ~= "table" then return false end
|
||||
for i = 1, #ir do
|
||||
local seg = ir[i]
|
||||
if type(seg) ~= "table" or not PURE_SEG[seg.t] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomText.plain(key, ctx)
|
||||
if ctx == nil and not Strings.active() then
|
||||
local ir = RomText.ir(key)
|
||||
local hit = plainCache[ir]
|
||||
if hit then return hit end
|
||||
if hit == nil then
|
||||
local pure = pure_ir(ir)
|
||||
local out = TextIR.toPlain(ir, {})
|
||||
plainCache[ir] = pure and out or false
|
||||
return out
|
||||
end
|
||||
return TextIR.toPlain(ir, {})
|
||||
end
|
||||
ctx = ctx or {}
|
||||
return TextIR.toPlain(RomText.translate(RomText.ir(key), ctx, key), ctx)
|
||||
end
|
||||
|
||||
@@ -320,27 +320,45 @@ function TilesetAnim.bindPair(pair, atlas)
|
||||
end
|
||||
TilesetAnim._visible[pair] = true
|
||||
if entry.rse then return true end
|
||||
for _, kind in ipairs({ "water", "sand", "flower" }) do
|
||||
TilesetAnim._applyKind(entry, kind, TilesetAnim["_" .. kind .. "Frame"])
|
||||
end
|
||||
TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame)
|
||||
TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame)
|
||||
TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame)
|
||||
return true
|
||||
end
|
||||
|
||||
function TilesetAnim.setVisiblePairs(visible)
|
||||
local set = TilesetAnim._visible
|
||||
for pair in pairs(set) do set[pair] = nil end
|
||||
for pair in pairs(visible) do set[pair] = true end
|
||||
for pair in pairs(set) do
|
||||
if not visible[pair] then set[pair] = nil end
|
||||
end
|
||||
for pair in pairs(visible) do
|
||||
if not set[pair] then
|
||||
set[pair] = true
|
||||
-- a General atlas coming back into view catches up before it is drawn
|
||||
local entry = TilesetAnim._pairs[pair]
|
||||
if entry and not entry.rse then
|
||||
TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame)
|
||||
TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame)
|
||||
TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function get_frame_piece(bank, frame, mi, frameRgba)
|
||||
local function get_frame_piece(bank, frame, mi, srcOff)
|
||||
if not (love and love.image and love.image.newImageData) then return nil end
|
||||
bank.pieces = bank.pieces or {}
|
||||
local pieces = bank.pieces
|
||||
if not pieces then
|
||||
pieces = {}
|
||||
bank.pieces = pieces
|
||||
end
|
||||
local pKey = frame * 1000 + mi
|
||||
local piece = bank.pieces[pKey]
|
||||
local piece = pieces[pKey]
|
||||
if piece then return piece end
|
||||
local ok, imgData = pcall(love.image.newImageData, 16, 16, "rgba8", frameRgba)
|
||||
local ok, imgData = pcall(love.image.newImageData, 16, 16, "rgba8",
|
||||
bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA))
|
||||
if ok and imgData then
|
||||
bank.pieces[pKey] = imgData
|
||||
pieces[pKey] = imgData
|
||||
return imgData
|
||||
end
|
||||
return nil
|
||||
@@ -357,20 +375,29 @@ function TilesetAnim._applyKind(entry, kind, frame)
|
||||
|
||||
local nMids = #bank.mids
|
||||
local cols = ts.cols or 16
|
||||
local image = ts.image
|
||||
-- pokeemerald-style partial upload (see rse_paste): push only the 16x16
|
||||
-- metatiles this bank owns instead of re-uploading the whole atlas.
|
||||
local partial = image ~= nil and image.replacePixels ~= nil
|
||||
local full = false
|
||||
for mi, mid in ipairs(bank.mids) do
|
||||
local slot = ts.midToSlot[mid]
|
||||
if slot then
|
||||
local srcOff = (frame * nMids + (mi - 1)) * MID_RGBA
|
||||
local ax = (slot % cols) * 16
|
||||
local ay = math.floor(slot / cols) * 16
|
||||
local frameRgba = bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA)
|
||||
local piece = get_frame_piece(bank, frame, mi, frameRgba)
|
||||
local piece = get_frame_piece(bank, frame, mi, srcOff)
|
||||
local pasted = false
|
||||
if piece and ts.imageData.paste then
|
||||
ts.imageData:paste(piece, ax, ay)
|
||||
pasted = true
|
||||
if partial and not full then
|
||||
local ok = pcall(image.replacePixels, image, piece, 1, 1, ax, ay, false)
|
||||
if not ok then full = true end
|
||||
end
|
||||
end
|
||||
if not pasted then
|
||||
local frameRgba = bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA)
|
||||
local i = 1
|
||||
for y = 0, 15 do
|
||||
for x = 0, 15 do
|
||||
@@ -382,11 +409,12 @@ function TilesetAnim._applyKind(entry, kind, frame)
|
||||
i = i + 4
|
||||
end
|
||||
end
|
||||
full = true
|
||||
end
|
||||
end
|
||||
end
|
||||
if ts.image and ts.image.replacePixels then
|
||||
ts.image:replacePixels(ts.imageData)
|
||||
if partial then
|
||||
if full then image:replacePixels(ts.imageData) end
|
||||
elseif ts.image and love and love.graphics then
|
||||
ts.image = love.graphics.newImage(ts.imageData)
|
||||
if ts.image.setFilter then ts.image:setFilter("nearest", "nearest") end
|
||||
|
||||
@@ -161,9 +161,18 @@ function NativeTileset.ready(pair)
|
||||
and cache:exists(NATIVE .. "/" .. pair .. "/palettes.bin")
|
||||
end
|
||||
|
||||
local animMod
|
||||
local function tilesetAnim()
|
||||
if animMod == nil then
|
||||
local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim")
|
||||
animMod = okA and TilesetAnim or false
|
||||
end
|
||||
return animMod or nil
|
||||
end
|
||||
|
||||
local function bind_anim(pair, atlas)
|
||||
local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim")
|
||||
if okA and TilesetAnim and TilesetAnim.bindPair then
|
||||
local TilesetAnim = tilesetAnim()
|
||||
if TilesetAnim and TilesetAnim.bindPair then
|
||||
TilesetAnim.bindPair(pair, atlas)
|
||||
end
|
||||
end
|
||||
@@ -172,6 +181,9 @@ function NativeTileset.get(pair)
|
||||
if not pair then return nil end
|
||||
local cached = NativeTileset._pairs[pair]
|
||||
if cached then
|
||||
-- Cheap for a bound pair (no manifest read): marks it visible and
|
||||
-- catches its atlas up to the current anim frames before a draw.
|
||||
-- FieldView reads cached atlases directly and does not come here per frame.
|
||||
bind_anim(pair, cached)
|
||||
return cached
|
||||
end
|
||||
|
||||
@@ -78,6 +78,9 @@ function TrainerSight.getTrainerId(eo)
|
||||
local scripts = Sp and Sp.bundle and Sp.bundle.scripts
|
||||
local list = scripts and scripts[scriptKey]
|
||||
if type(list) == "table" then
|
||||
-- negative cache: this exact script list was already scanned without a
|
||||
-- trainerbattle (a script swapped in under the key rescans)
|
||||
if eo._trainerIdMiss == list then return nil end
|
||||
for _, row in ipairs(list) do
|
||||
if row.op == "trainerbattle" or row.op == "dotrainerbattle" then
|
||||
local tid = tonumber(row.trainer or row[1])
|
||||
@@ -88,6 +91,7 @@ function TrainerSight.getTrainerId(eo)
|
||||
end
|
||||
end
|
||||
end
|
||||
eo._trainerIdMiss = list
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -633,6 +637,8 @@ end
|
||||
-- @param game game instance
|
||||
-- @param specificTrainer optional specific EventObject to check (e.g. from spinning NPC turn update)
|
||||
-- @return boolean true if an encounter was triggered
|
||||
local approachFound = {}
|
||||
|
||||
function TrainerSight.check(game, specificTrainer)
|
||||
local F = Field()
|
||||
if F.locked then return false end
|
||||
@@ -681,7 +687,8 @@ function TrainerSight.check(game, specificTrainer)
|
||||
local order = Objs._order or {}
|
||||
if twoTrainerApproach() then
|
||||
-- pokeemerald/src/trainer_see.c:191
|
||||
local found = {}
|
||||
local found = approachFound
|
||||
for i = #found, 1, -1 do found[i] = nil end
|
||||
local doubles = canDouble()
|
||||
for _, lid in ipairs(order) do
|
||||
local eo = Objs.find(lid)
|
||||
|
||||
+23
-8
@@ -28,9 +28,30 @@ function Hooks:wrap(name, callback, priority, owner)
|
||||
for i, candidate in ipairs(chain) do
|
||||
if candidate == entry then table.remove(chain, i) break end
|
||||
end
|
||||
-- an empty chain is dropped, as removeOwner does, so Runtime.wantsHook
|
||||
-- goes back to false; only if it is still the live chain for the name
|
||||
-- (a later wrap after removeOwner may have started a new one)
|
||||
if #chain == 0 and self.chains[name] == chain then
|
||||
self.chains[name] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- vanilla's pcall results, unpacked without a pack table: its values, or
|
||||
-- the PASS-wrapped error
|
||||
local function vanillaResult(ok, ...)
|
||||
if ok then return ... end
|
||||
error({ [PASS] = (...) }, 0)
|
||||
end
|
||||
|
||||
-- the outermost pcall's results: the chain's values, or its error unwrapped
|
||||
local function finish(ok, ...)
|
||||
if ok then return ... end
|
||||
local err = (...)
|
||||
if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end
|
||||
error(err, 0)
|
||||
end
|
||||
|
||||
-- each link runs under pcall: a throwing wrapper is logged and skipped and
|
||||
-- the chain continues with the current arguments, so a broken mod degrades
|
||||
-- to "not installed for this call" instead of breaking the pipeline.
|
||||
@@ -46,9 +67,7 @@ function Hooks:call(name, vanilla, ...)
|
||||
local function run(index)
|
||||
if index > #chain then
|
||||
ranVanilla = true
|
||||
local res = pack(pcall(vanilla, unpack(args, 1, args.n)))
|
||||
if res[1] then return unpack(res, 2, res.n) end
|
||||
error({ [PASS] = res[2] }, 0)
|
||||
return vanillaResult(pcall(vanilla, unpack(args, 1, args.n)))
|
||||
end
|
||||
local entry = chain[index]
|
||||
local downstream
|
||||
@@ -81,11 +100,7 @@ function Hooks:call(name, vanilla, ...)
|
||||
tostring(entry.owner or "?"), name, tostring(err))
|
||||
return run(index + 1)
|
||||
end
|
||||
local res = pack(pcall(run, 1))
|
||||
if res[1] then return unpack(res, 2, res.n) end
|
||||
local err = res[2]
|
||||
if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end
|
||||
error(err, 0)
|
||||
return finish(pcall(run, 1))
|
||||
end
|
||||
|
||||
-- drops every wrap a mod made; used by entry-chunk rollback
|
||||
|
||||
@@ -258,6 +258,13 @@ function Loader:_installDevShim()
|
||||
if devShim.installed then return end
|
||||
devShim.installed = true
|
||||
local delegate = require
|
||||
-- The stock require answers an already-loaded module straight out of
|
||||
-- package.loaded, so once every check below has passed the shim can do the
|
||||
-- same without the pcall. Only when delegating to the stock C function:
|
||||
-- a Lua wrapper in between may want to see every call.
|
||||
local delegateIsStock = delegate == rawRequire
|
||||
and type(delegate) == "function"
|
||||
and (debug.getinfo(delegate, "S") or {}).what == "C"
|
||||
_G.require = function(name, ...)
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
@@ -295,6 +302,12 @@ function Loader:_installDevShim()
|
||||
end
|
||||
end
|
||||
end
|
||||
if delegateIsStock then
|
||||
-- a userdata here may be require's own "loading" sentinel, whose
|
||||
-- loop error the stock path has to raise
|
||||
local loaded = package.loaded[name]
|
||||
if loaded and type(loaded) ~= "userdata" then return loaded end
|
||||
end
|
||||
devShim.depth = devShim.depth + 1
|
||||
local ok, result = pcall(delegate, name, ...)
|
||||
devShim.depth = devShim.depth - 1
|
||||
|
||||
+30
-6
@@ -45,9 +45,14 @@ local function head(name)
|
||||
return (name:match("^([^%.]+)")) or name
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
-- The verdict for a name never changes (the tables above are fixed), and the
|
||||
-- Loader's require shim asks for every require the engine makes while a mod
|
||||
-- is enabled, so it is worked out once per name: false for allowed, the
|
||||
-- denial message, or NETWORK_ONLY with the message kept alongside.
|
||||
local verdicts, verdictCount = {}, 0
|
||||
local VERDICT_MAX = 4096
|
||||
|
||||
local function verdictFor(name)
|
||||
local root = head(name)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
@@ -58,10 +63,29 @@ function Sandbox.moduleDenial(name, permissionSet)
|
||||
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
|
||||
.. "and the engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
if NETWORK[root] then
|
||||
return { network = ("%s needs the \"network\" permission in manifest.json")
|
||||
:format(name) }
|
||||
end
|
||||
return nil
|
||||
return false
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local verdict = verdicts[name]
|
||||
if verdict == nil then
|
||||
verdict = verdictFor(name)
|
||||
if verdictCount >= VERDICT_MAX then verdicts, verdictCount = {}, 0 end
|
||||
verdicts[name] = verdict
|
||||
verdictCount = verdictCount + 1
|
||||
end
|
||||
if not verdict then return nil end
|
||||
if type(verdict) == "table" then
|
||||
if (permissionSet or {}).network then return nil end
|
||||
return verdict.network
|
||||
end
|
||||
return verdict
|
||||
end
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
@@ -125,6 +125,9 @@ function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
|
||||
return vanillaPath, trueColor
|
||||
end
|
||||
-- checked before the ctx table: party menus ask every drawn frame, and
|
||||
-- an unhooked call must not allocate
|
||||
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end
|
||||
local species = mon and mon.species
|
||||
local ctx = {
|
||||
species = species,
|
||||
@@ -134,7 +137,6 @@ function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||
kind = "icon",
|
||||
trueColor = trueColor,
|
||||
}
|
||||
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end
|
||||
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
|
||||
trueColor = ctx.trueColor and true or false
|
||||
if type(hooked) == "string" and hooked ~= "" then return hooked, trueColor end
|
||||
|
||||
+94
-7
@@ -100,8 +100,12 @@ local function tileSet(spec)
|
||||
return set
|
||||
end
|
||||
|
||||
local resetTextCaches, resetWidthCache
|
||||
|
||||
function Font.load(data)
|
||||
loadedFrom = data
|
||||
-- a new charmap / page set / TTF: every cached encode and width is stale
|
||||
if resetTextCaches then resetTextCaches() end
|
||||
local def = data.font
|
||||
state = { def = def, pages = {}, order = {}, byFirstByte = {} }
|
||||
for id, page in pairs(pagesOf(def)) do
|
||||
@@ -267,6 +271,8 @@ function Font.useBattleExtra(on)
|
||||
if not state then return false end
|
||||
local was = state.battleExtra or false
|
||||
state.battleExtra = on and true or false
|
||||
-- the swapped page may carry its own advance
|
||||
if state.battleExtra ~= was and resetWidthCache then resetWidthCache() end
|
||||
return was
|
||||
end
|
||||
|
||||
@@ -278,7 +284,9 @@ end
|
||||
-- Module state, not `state`: applyOptions runs before Font.load on boot
|
||||
-- (src/core/Game2.lua Game2:load).
|
||||
function Font.setFrame(index)
|
||||
currentFrame = math.floor(tonumber(index) or 1)
|
||||
local frame = math.floor(tonumber(index) or 1)
|
||||
if frame ~= currentFrame and resetWidthCache then resetWidthCache() end
|
||||
currentFrame = frame
|
||||
end
|
||||
|
||||
function Font.frameIndex()
|
||||
@@ -428,7 +436,7 @@ end
|
||||
-- Convert a text string into a list of glyph codes. Unknown characters
|
||||
-- render as space (and are reported once).
|
||||
local reported = {}
|
||||
function Font.encode(text)
|
||||
local function encodeUncached(text)
|
||||
local codes = {}
|
||||
for _, span in ipairs(Font.split(text)) do
|
||||
local code = span.code
|
||||
@@ -445,6 +453,65 @@ function Font.encode(text)
|
||||
return codes
|
||||
end
|
||||
|
||||
-- Font.width/Font.draw run for the same few strings every frame, and
|
||||
-- Font.split allocates a table per glyph. The codes only depend on the
|
||||
-- charmap and TTF that Font.load builds, so cache them per string until the
|
||||
-- next load. Widths also depend on which page answers a code (the frame
|
||||
-- row, the battle-extra swap), so that cache is also dropped when either of
|
||||
-- those changes. Both are bounded: a cache past ENCODE_CACHE_MAX entries is
|
||||
-- thrown away whole rather than tracked LRU.
|
||||
local ENCODE_CACHE_MAX = 4096
|
||||
-- Bumped whenever a cached encode or width may have changed (Font.load, a
|
||||
-- frame or battle-sheet swap), for callers that keep their own copies.
|
||||
Font.revision = 0
|
||||
local encodeCache, encodeCount = {}, 0
|
||||
local stockSplit = Font.split
|
||||
local widthCache, widthCount = {}, 0
|
||||
|
||||
function resetWidthCache()
|
||||
widthCache, widthCount = {}, 0
|
||||
-- anything a caller derived from encode/width is stale too
|
||||
Font.revision = Font.revision + 1
|
||||
end
|
||||
|
||||
function resetTextCaches()
|
||||
encodeCache, encodeCount = {}, 0
|
||||
resetWidthCache()
|
||||
end
|
||||
|
||||
-- Returns the SHARED cached array: read it, never modify or keep it.
|
||||
local function cachedCodes(text)
|
||||
-- a replaced split (a test double) is honoured and nothing is cached
|
||||
if Font.split ~= stockSplit then return encodeUncached(text) end
|
||||
local codes = encodeCache[text]
|
||||
if codes then return codes end
|
||||
codes = encodeUncached(text)
|
||||
if type(text) == "string" then
|
||||
if encodeCount >= ENCODE_CACHE_MAX then
|
||||
encodeCache, encodeCount = {}, 0
|
||||
end
|
||||
encodeCache[text] = codes
|
||||
encodeCount = encodeCount + 1
|
||||
end
|
||||
return codes
|
||||
end
|
||||
|
||||
-- Returns a fresh array: callers (TextBox's typewriter lines) append to
|
||||
-- and keep the list, so they must never be handed the cached one.
|
||||
function Font.encode(text)
|
||||
local src = cachedCodes(text)
|
||||
local codes = {}
|
||||
for i = 1, #src do codes[i] = src[i] end
|
||||
return codes
|
||||
end
|
||||
|
||||
local stockEncode = Font.encode
|
||||
|
||||
-- Number of glyphs `text` draws as (#Font.split(text), without the spans).
|
||||
function Font.glyphCount(text)
|
||||
return #cachedCodes(text)
|
||||
end
|
||||
|
||||
function Font.drawCode(code, x, y)
|
||||
local ttf = state and state.ttf
|
||||
if ttf and code >= TTF_BASE then
|
||||
@@ -481,13 +548,31 @@ function Font.advanceOf(code)
|
||||
return page and page.advance or GLYPH
|
||||
end
|
||||
|
||||
local stockAdvanceOf = Font.advanceOf
|
||||
|
||||
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
|
||||
-- Multi-byte charmap entries like "¥" are one glyph; callers that
|
||||
-- right-align with `#text * 8` mis-place them.
|
||||
function Font.width(text)
|
||||
local w = 0
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
w = w + Font.advanceOf(code)
|
||||
if Font.encode ~= stockEncode or Font.advanceOf ~= stockAdvanceOf
|
||||
or Font.split ~= stockSplit then
|
||||
-- a replaced encode/advanceOf/split (a test double, a mod) is still
|
||||
-- what gets measured, uncached
|
||||
local w = 0
|
||||
for _, code in ipairs(Font.encode(text)) do w = w + Font.advanceOf(code) end
|
||||
return w
|
||||
end
|
||||
local w = widthCache[text]
|
||||
if w then return w end
|
||||
w = 0
|
||||
local codes = cachedCodes(text)
|
||||
for i = 1, #codes do
|
||||
w = w + Font.advanceOf(codes[i])
|
||||
end
|
||||
if type(text) == "string" then
|
||||
if widthCount >= ENCODE_CACHE_MAX then resetWidthCache() end
|
||||
widthCache[text] = w
|
||||
widthCount = widthCount + 1
|
||||
end
|
||||
return w
|
||||
end
|
||||
@@ -495,9 +580,11 @@ end
|
||||
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
||||
-- drawn, which is #codes * 8 for every fixed-width page.
|
||||
function Font.draw(text, x, y)
|
||||
local codes = Font.encode(text)
|
||||
local codes = Font.encode == stockEncode and cachedCodes(text)
|
||||
or Font.encode(text)
|
||||
local pen = x
|
||||
for _, code in ipairs(codes) do
|
||||
for i = 1, #codes do
|
||||
local code = codes[i]
|
||||
Font.drawCode(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
|
||||
@@ -377,13 +377,42 @@ end
|
||||
-- The same, ignoring the COLOR mode. The present pass needs it: it IS the
|
||||
-- mode, so running its own palette back through resolve would flatten the
|
||||
-- green ramp to grey and the mode would do nothing.
|
||||
-- The pal0..pal3 uniforms each shader last received, weak-keyed so a
|
||||
-- recompiled shader (see compiler) starts with no record. Nothing outside
|
||||
-- this file sends pal0..pal3 to these shaders, so a uniform whose value is
|
||||
-- unchanged is skipped: a send to the active shader flushes LOVE's batch.
|
||||
-- The {r,g,b} arrays are scratch; shader:send copies the values.
|
||||
local PAL_UNIFORMS = { [0] = "pal0", "pal1", "pal2", "pal3" }
|
||||
local palScratch = { [0] = { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
|
||||
local palSent = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function sendPalette(sh, colors)
|
||||
local rec = palSent[sh]
|
||||
if not rec then
|
||||
rec = { valid = false }
|
||||
palSent[sh] = rec
|
||||
end
|
||||
-- only trusted once all four sends landed (an error propagates as before)
|
||||
local valid = rec.valid
|
||||
rec.valid = false
|
||||
for i = 0, 3 do
|
||||
local r, g, b = channel(colors, i + 1)
|
||||
local k = i * 3
|
||||
if not (valid and rec[k + 1] == r and rec[k + 2] == g
|
||||
and rec[k + 3] == b) then
|
||||
local v = palScratch[i]
|
||||
v[1], v[2], v[3] = r, g, b
|
||||
sh:send(PAL_UNIFORMS[i], v)
|
||||
rec[k + 1], rec[k + 2], rec[k + 3] = r, g, b
|
||||
end
|
||||
end
|
||||
rec.valid = true
|
||||
end
|
||||
|
||||
function GbcPalette.useRaw(colors)
|
||||
local sh = GbcPalette.shader()
|
||||
if not sh then return false end
|
||||
for i = 0, 3 do
|
||||
local r, g, b = channel(colors, i + 1)
|
||||
sh:send("pal" .. i, { r, g, b })
|
||||
end
|
||||
sendPalette(sh, colors)
|
||||
love.graphics.setShader(sh)
|
||||
return true
|
||||
end
|
||||
@@ -391,11 +420,8 @@ end
|
||||
function GbcPalette.useKeyed(colors)
|
||||
local sh = GbcPalette.keyedShader()
|
||||
if not sh then return false end
|
||||
local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp)
|
||||
for i = 0, 3 do
|
||||
local r, g, b = channel(resolved, i + 1)
|
||||
sh:send("pal" .. i, { r, g, b })
|
||||
end
|
||||
sendPalette(sh,
|
||||
GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp))
|
||||
love.graphics.setShader(sh)
|
||||
return true
|
||||
end
|
||||
|
||||
+19
-3
@@ -92,7 +92,11 @@ end
|
||||
local function put(t, x, y, tint)
|
||||
if not t then return end
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.setColor(tint or { 1, 1, 1, 1 })
|
||||
if tint then
|
||||
love.graphics.setColor(tint)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
love.graphics.draw(t.img, t.quad, x, y)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
@@ -159,6 +163,19 @@ end
|
||||
-- caller that is animating the bar between two HP values
|
||||
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
|
||||
-- thresholds do. Without it the length comes from mon.hp as before.
|
||||
-- The fill tint for a bar colour, kept per colour table (the bar palettes
|
||||
-- are a handful of fixed tables) and rebuilt if its values ever differ.
|
||||
-- put() only reads it.
|
||||
local fillTints = setmetatable({}, { __mode = "k" })
|
||||
local function fillTint(c)
|
||||
local t = fillTints[c]
|
||||
if t and t.r == c[1] and t.g == c[2] and t.b == c[3] then return t end
|
||||
t = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1, r = c[1], g = c[2], b = c[3] }
|
||||
fillTints[c] = t
|
||||
return t
|
||||
end
|
||||
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
@@ -182,8 +199,7 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixe
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
tint = fillTint(c)
|
||||
end
|
||||
end
|
||||
for i = 0, segments - 1 do
|
||||
|
||||
+157
-20
@@ -67,9 +67,37 @@ function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
|
||||
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
|
||||
-- active mode resolved, with a distinct cache group so the lit and dark bakes
|
||||
-- of one sheet never collide in SpriteRenderer's obpCache.
|
||||
--
|
||||
-- It runs per sprite per frame in a dark cave, so the permuted palette is
|
||||
-- kept per source palette (and per map, should DARK_BGP ever be swapped) and
|
||||
-- the group name per group; permute's result is only ever read.
|
||||
local darkPermuted = setmetatable({}, { __mode = "k" })
|
||||
local darkGroupNames = {}
|
||||
|
||||
-- permute(colors, map), remembered in memo[colors] while `map` stays the same
|
||||
local function permuteMemo(memo, colors, map)
|
||||
local hit = memo[colors]
|
||||
if hit and hit.map == map then return hit.colors end
|
||||
local out = PaletteFX.permute(colors, map)
|
||||
if type(colors) == "table" then memo[colors] = { map = map, colors = out } end
|
||||
return out
|
||||
end
|
||||
|
||||
-- tostring(group) .. suffix, remembered per group
|
||||
local function suffixedGroup(names, group, suffix)
|
||||
if group == nil or group ~= group then return tostring(group) .. suffix end
|
||||
local name = names[group]
|
||||
if not name then
|
||||
name = tostring(group) .. suffix
|
||||
names[group] = name
|
||||
end
|
||||
return name
|
||||
end
|
||||
|
||||
function PaletteFX.darkObp(colors, group)
|
||||
if not (colors and darkWorld) then return colors, group end
|
||||
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
|
||||
return permuteMemo(darkPermuted, colors, PaletteFX.DARK_BGP),
|
||||
suffixedGroup(darkGroupNames, group, "dark")
|
||||
end
|
||||
|
||||
-- the same shift folded into a baked 8-group world palette array (ADVANCED)
|
||||
@@ -85,6 +113,9 @@ end
|
||||
-- home/fade.asm:52-58
|
||||
local fadeObpMap = nil
|
||||
local fadeObpGroups = setmetatable({}, { __mode = "k" })
|
||||
-- per fade map: the permuted palettes and suffixed group names (see darkObp)
|
||||
local fadePermuted = setmetatable({}, { __mode = "k" })
|
||||
local fadeGroupNames = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function PaletteFX.setFadeObp(map)
|
||||
fadeObpMap = map
|
||||
@@ -98,7 +129,16 @@ function PaletteFX.fadeObp(colors, group)
|
||||
.. fadeObpMap[2] .. fadeObpMap[3]
|
||||
fadeObpGroups[fadeObpMap] = suffix
|
||||
end
|
||||
return PaletteFX.permute(colors, fadeObpMap), tostring(group) .. suffix
|
||||
local memo = fadePermuted[fadeObpMap]
|
||||
local names = fadeGroupNames[fadeObpMap]
|
||||
if not memo then
|
||||
memo = setmetatable({}, { __mode = "k" })
|
||||
names = {}
|
||||
fadePermuted[fadeObpMap] = memo
|
||||
fadeGroupNames[fadeObpMap] = names
|
||||
end
|
||||
return permuteMemo(memo, colors, fadeObpMap),
|
||||
suffixedGroup(names, group, suffix)
|
||||
end
|
||||
|
||||
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
|
||||
@@ -369,6 +409,8 @@ end
|
||||
|
||||
-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas
|
||||
-- via __index so shared Red tilesets stay identical byte-for-byte.
|
||||
local yellowWorld, yellowWorldBase, yellowWorldDelta
|
||||
|
||||
function PaletteFX.worldPack()
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
@@ -377,7 +419,13 @@ function PaletteFX.worldPack()
|
||||
local y = PaletteFX.gbcYellowPack()
|
||||
local yw = y and y.world
|
||||
if not yw then return w end
|
||||
return {
|
||||
-- per sprite per frame on Yellow: build the merged view once per pair of
|
||||
-- packs (a reload loads new tables, so identity is the whole key)
|
||||
if yellowWorld and yellowWorldBase == w and yellowWorldDelta == yw then
|
||||
return yellowWorld
|
||||
end
|
||||
yellowWorldBase, yellowWorldDelta = w, yw
|
||||
yellowWorld = {
|
||||
tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }),
|
||||
groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }),
|
||||
roofGroup = w.roofGroup,
|
||||
@@ -387,6 +435,7 @@ function PaletteFX.worldPack()
|
||||
and setmetatable(yw.spritePalettes, { __index = w.spritePalettes })
|
||||
or w.spritePalettes,
|
||||
}
|
||||
return yellowWorld
|
||||
end
|
||||
|
||||
function PaletteFX.usesGbcPack(mode)
|
||||
@@ -775,27 +824,44 @@ end
|
||||
-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent
|
||||
-- here): a stable hash instead, so the same NPC instance always shows the
|
||||
-- same one of the 4 SPR_PAL_* colors.
|
||||
local sourceKinds = {}
|
||||
local seedGroups = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function PaletteFX.spriteObp(spriteDef, seed)
|
||||
local w = PaletteFX.worldPack()
|
||||
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
||||
if not (w and src) then return nil end
|
||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||
-- the parse depends only on the string (per sprite per frame otherwise)
|
||||
local parsed = sourceKinds[src]
|
||||
if not parsed then
|
||||
local n = tonumber(src:match("%[(%d+)%]"))
|
||||
parsed = n or (src:find("RedBikeSprite", 1, true) and "bike")
|
||||
or (src:find("SurfingPikachuSprite", 1, true) and "surf") or "none"
|
||||
sourceKinds[src] = parsed
|
||||
end
|
||||
local idx = type(parsed) == "number" and parsed or nil
|
||||
-- RedBikeSprite and SurfingPikachuSprite load outside
|
||||
-- SpriteSheetPointerTable, so their source has no bracketed index;
|
||||
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
|
||||
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
|
||||
if not idx and src:find("RedBikeSprite", 1, true) then
|
||||
if parsed == "bike" then
|
||||
idx = 0
|
||||
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
|
||||
elseif parsed == "surf" then
|
||||
idx = GameVersion.isYellow() and 60 or 0
|
||||
end
|
||||
local group = idx and w.spriteAssignment[idx]
|
||||
if group == nil then return nil end
|
||||
if group == "random" then
|
||||
local h = 0
|
||||
seed = tostring(seed or "")
|
||||
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||
group = h % 4
|
||||
local key = seed == nil and "" or seed
|
||||
local h = seedGroups[key]
|
||||
if not h then
|
||||
h = 0
|
||||
local str = tostring(seed or "")
|
||||
for i = 1, #str do h = (h * 31 + str:byte(i)) % 4294967296 end
|
||||
h = h % 4
|
||||
if key == key then seedGroups[key] = h end
|
||||
end
|
||||
group = h
|
||||
end
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
@@ -1009,15 +1075,89 @@ function PaletteFX.effectiveColors(c)
|
||||
return PaletteFX.permute(out, shadeMap)
|
||||
end
|
||||
|
||||
local stockEffectiveColors = PaletteFX.effectiveColors
|
||||
|
||||
-- ------- uniform sends
|
||||
--
|
||||
-- sendColors runs per zone per frame and per colored sprite, and a send to
|
||||
-- the active shader flushes LOVE's draw batch. Remember what each shader
|
||||
-- was last given (weak-keyed, so a released or replaced shader drops its
|
||||
-- record) and only send a uniform whose value changed. The {r,g,b} arrays
|
||||
-- are scratch: shader:send copies the values, so they are reused.
|
||||
local UNIFORMS = { "c0", "c1", "c2", "c3" }
|
||||
local scratch = { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
|
||||
local lastSent = setmetatable({}, { __mode = "k" })
|
||||
|
||||
-- A caller that sends c0..c3 itself (AnimPlayer's per-cell OBJ colors) has
|
||||
-- to drop the record, or the next sendColors of the old colors would be
|
||||
-- skipped. AnimPlayer:drawSprites does this after its direct sends.
|
||||
function PaletteFX.forgetSent(sh)
|
||||
local rec = sh and lastSent[sh]
|
||||
if rec then rec.valid = false end
|
||||
end
|
||||
|
||||
local function sendOne(sh, rec, i, col, valid)
|
||||
local r, g, b = col[1] / 255, col[2] / 255, col[3] / 255
|
||||
local k = i * 3
|
||||
if valid and rec[k - 2] == r and rec[k - 1] == g and rec[k] == b then
|
||||
return
|
||||
end
|
||||
local v = scratch[i]
|
||||
v[1], v[2], v[3] = r, g, b
|
||||
sh:send(UNIFORMS[i], v)
|
||||
rec[k - 2], rec[k - 1], rec[k] = r, g, b
|
||||
end
|
||||
|
||||
local function sendFour(sh, a, b, c, d)
|
||||
local rec = lastSent[sh]
|
||||
if not rec then
|
||||
rec = { valid = false }
|
||||
lastSent[sh] = rec
|
||||
end
|
||||
-- An error from send propagates as the unconditional sends' did; the
|
||||
-- record only counts once all four landed, so a raise mid-way never
|
||||
-- leaves a half-written palette looking current.
|
||||
local valid = rec.valid
|
||||
rec.valid = false
|
||||
sendOne(sh, rec, 1, a, valid)
|
||||
sendOne(sh, rec, 2, b, valid)
|
||||
sendOne(sh, rec, 3, c, valid)
|
||||
sendOne(sh, rec, 4, d, valid)
|
||||
rec.valid = true
|
||||
end
|
||||
|
||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
||||
-- applying the active COLORS display mode
|
||||
-- applying the active COLORS display mode. Resolves the same colors
|
||||
-- effectiveColors returns, indexing through the mode and shade maps rather
|
||||
-- than building the permuted tables.
|
||||
function PaletteFX.sendColors(shader, c)
|
||||
c = PaletteFX.effectiveColors(c)
|
||||
if not c then return end
|
||||
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
||||
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
|
||||
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
|
||||
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
|
||||
if PaletteFX.effectiveColors ~= stockEffectiveColors then
|
||||
-- something replaced effectiveColors: honour it, as the send always did
|
||||
c = PaletteFX.effectiveColors(c)
|
||||
if not c then return end
|
||||
return sendFour(shader, c[1], c[2], c[3], c[4])
|
||||
end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
local base, modeMap = c, nil
|
||||
if PaletteFX.customRamp and not PaletteFX.pickerActive() then
|
||||
base = PaletteFX.customRamp
|
||||
elseif mode == "og" then
|
||||
base = PaletteFX.GRAYS
|
||||
elseif mode == "og_inv" then
|
||||
base, modeMap = PaletteFX.GRAYS, INV_MAP
|
||||
elseif mode == "classic" then
|
||||
base = PaletteFX.CLASSIC
|
||||
elseif mode == "gbc_inv" then
|
||||
modeMap = INV_MAP
|
||||
end
|
||||
local sm = shadeMap
|
||||
local i0, i1, i2, i3 = 0, 1, 2, 3
|
||||
if sm then i0, i1, i2, i3 = sm[0], sm[1], sm[2], sm[3] end
|
||||
if modeMap then
|
||||
i0, i1, i2, i3 = modeMap[i0], modeMap[i1], modeMap[i2], modeMap[i3]
|
||||
end
|
||||
sendFour(shader, base[i0 + 1], base[i1 + 1], base[i2 + 1], base[i3 + 1])
|
||||
end
|
||||
|
||||
-- The same send with NO display-mode substitution and no shade map: the four
|
||||
@@ -1030,10 +1170,7 @@ function PaletteFX.sendShades(shader, c)
|
||||
-- headless (no love.graphics) leaves shader() nil; sendColors reaches the
|
||||
-- same no-op through effectiveColors returning nil for an absent palette
|
||||
if not shader or not c then return end
|
||||
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
||||
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
|
||||
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
|
||||
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
|
||||
sendFour(shader, c[1], c[2], c[3], c[4])
|
||||
end
|
||||
|
||||
return PaletteFX
|
||||
|
||||
+113
-9
@@ -673,6 +673,88 @@ local function sizeTable(state, i, dims, sizeUniforms, viewport, original)
|
||||
return values
|
||||
end
|
||||
|
||||
-- ------- per-pass uniform bookkeeping
|
||||
--
|
||||
-- runPass used to rebuild and pcall-send every uniform every frame. Each
|
||||
-- pass's shader is private to its chain state (built at activate, dropped
|
||||
-- with the state) and nothing else sends to it, so `cached.sent` remembers
|
||||
-- what the shader last received and a uniform is only re-sent when its value
|
||||
-- changed. A failed send is remembered too: it would fail the same way again
|
||||
-- for the same name and value.
|
||||
|
||||
local textureUniformNames = {}
|
||||
local function textureUniform(name)
|
||||
local u = textureUniformNames[name]
|
||||
if not u then
|
||||
u = "LIBRA_TEXTURE_" .. name
|
||||
textureUniformNames[name] = u
|
||||
end
|
||||
return u
|
||||
end
|
||||
|
||||
-- Fixup.packValues over `values` with ALL_DEFAULTS layered on top (a default
|
||||
-- wins, exactly as copying it into `values` did), into tables reused across
|
||||
-- frames: shader:send copies, so the vec4 groups need not be fresh.
|
||||
local PACK_SLOT = { x = 1, y = 2, z = 3, w = 4 }
|
||||
local function packPass(cached, manifest, defaults, values)
|
||||
local packed = cached.packed
|
||||
if not packed then
|
||||
packed = {}
|
||||
cached.packed = packed
|
||||
cached.groups = {}
|
||||
for _, entry in ipairs(manifest) do
|
||||
if entry.component and not cached.groups[entry.uniform] then
|
||||
cached.groups[entry.uniform] = { 0, 0, 0, 0 }
|
||||
end
|
||||
end
|
||||
end
|
||||
for uniform, group in pairs(cached.groups) do
|
||||
group[1], group[2], group[3], group[4] = 0, 0, 0, 0
|
||||
packed[uniform] = group
|
||||
end
|
||||
for _, entry in ipairs(manifest) do
|
||||
local value = defaults[entry.name]
|
||||
if value == nil then value = values[entry.name] end
|
||||
if entry.component then
|
||||
packed[entry.uniform][PACK_SLOT[entry.component]] = value or 0
|
||||
else
|
||||
packed[entry.uniform] = value
|
||||
end
|
||||
end
|
||||
return packed
|
||||
end
|
||||
|
||||
-- Whether `value` differs from what `sent[name]` recorded. Arrays of plain
|
||||
-- numbers/booleans are compared element-wise; anything else always sends.
|
||||
local function changedSince(sent, name, value)
|
||||
local last = sent[name]
|
||||
if last == nil then return true end
|
||||
if type(value) ~= "table" then return last ~= value end
|
||||
if type(last) ~= "table" or last.n ~= #value then return true end
|
||||
for k = 1, last.n do
|
||||
local v = value[k]
|
||||
local tv = type(v)
|
||||
if (tv ~= "number" and tv ~= "boolean") or last[k] ~= v then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function remember(sent, name, value)
|
||||
if type(value) ~= "table" then
|
||||
sent[name] = value
|
||||
return
|
||||
end
|
||||
local snap = sent[name]
|
||||
if type(snap) ~= "table" then
|
||||
snap = {}
|
||||
sent[name] = snap
|
||||
end
|
||||
local n = #value
|
||||
for k = 1, n do snap[k] = value[k] end
|
||||
for k = n + 1, (snap.n or 0) do snap[k] = nil end
|
||||
snap.n = n
|
||||
end
|
||||
|
||||
-- Runs pass `i`, drawing `srcImg` into a freshly sized canvas. `lutByName`
|
||||
-- resolves a User-semantic sampler by the name the translation reported.
|
||||
local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport, original, layer)
|
||||
@@ -684,6 +766,11 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
|
||||
local cached = assert(state.shaderCache[i],
|
||||
("pass%d: shader was not built at activate"):format(i))
|
||||
local shader, fragManifest = cached.shader, cached.fragManifest
|
||||
local sent = cached.sent
|
||||
if not sent then
|
||||
sent = {}
|
||||
cached.sent = sent
|
||||
end
|
||||
|
||||
for _, s in ipairs(pass.samplers) do
|
||||
local img
|
||||
@@ -694,13 +781,21 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
|
||||
elseif s.semantic == "User" then img = lutByName and lutByName[s.user_name] end
|
||||
assert(img, ("pass%d: no binding resolved for sampler %s (semantic=%s)")
|
||||
:format(i, s.name, tostring(s.semantic)))
|
||||
pcall(shader.send, shader, "LIBRA_TEXTURE_" .. s.name, img)
|
||||
local uniform = textureUniform(s.name)
|
||||
if sent[uniform] ~= img then
|
||||
pcall(shader.send, shader, uniform, img)
|
||||
sent[uniform] = img
|
||||
end
|
||||
end
|
||||
|
||||
local values = sizeTable(state, i, dims, pass.size_uniforms, viewport, original)
|
||||
for name, value in pairs(state.ALL_DEFAULTS) do values[name] = value end
|
||||
local packed = Fixup.packValues(fragManifest, values)
|
||||
for name, value in pairs(packed) do pcall(shader.send, shader, name, value) end
|
||||
local packed = packPass(cached, fragManifest, state.ALL_DEFAULTS, values)
|
||||
for name, value in pairs(packed) do
|
||||
if changedSince(sent, name, value) then
|
||||
pcall(shader.send, shader, name, value)
|
||||
remember(sent, name, value)
|
||||
end
|
||||
end
|
||||
|
||||
local srcImg = (i == 0) and frameSource or outputs[i - 1]
|
||||
local srcDims = passInputDims(state, i, original)
|
||||
@@ -1090,6 +1185,7 @@ end
|
||||
-- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas
|
||||
-- is cached module-wide and reallocated only on a real size change.
|
||||
local cropCanvasCache = {}
|
||||
local cropQuadCache = {}
|
||||
|
||||
local function gridGeometry(rect, renderScale, originX, originY)
|
||||
local s = tonumber(rect.scale)
|
||||
@@ -1112,16 +1208,24 @@ local function cropToGbSource(canvas, rect, srcW, srcH, renderScale, layer, geo)
|
||||
-- Cheap insurance against a read-after-write hazard between this draw and
|
||||
-- whatever last rendered into `canvas`.
|
||||
love.graphics.flushBatch()
|
||||
local outW, outH, quad
|
||||
local outW, outH, qx, qy, qw, qh
|
||||
if geo then
|
||||
outW, outH = geo.crop.w, geo.crop.h
|
||||
quad = love.graphics.newQuad(rect.x + geo.x0 + CROP_SAMPLE_BIAS,
|
||||
rect.y + geo.y0 + CROP_SAMPLE_BIAS, geo.cols * geo.scale, geo.rows * geo.scale,
|
||||
canvas:getPixelWidth(), canvas:getPixelHeight())
|
||||
qx, qy = rect.x + geo.x0 + CROP_SAMPLE_BIAS, rect.y + geo.y0 + CROP_SAMPLE_BIAS
|
||||
qw, qh = geo.cols * geo.scale, geo.rows * geo.scale
|
||||
else
|
||||
outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale)
|
||||
quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
||||
qx, qy, qw, qh = rect.x, rect.y, rect.w, rect.h
|
||||
end
|
||||
-- One quad per layer, re-aimed each frame rather than allocated. A quad
|
||||
-- without setViewport (a test double) is simply rebuilt.
|
||||
local quad = cropQuadCache[layer]
|
||||
if quad and quad.setViewport then
|
||||
quad:setViewport(qx, qy, qw, qh, canvas:getPixelWidth(), canvas:getPixelHeight())
|
||||
else
|
||||
quad = love.graphics.newQuad(qx, qy, qw, qh,
|
||||
canvas:getPixelWidth(), canvas:getPixelHeight())
|
||||
cropQuadCache[layer] = quad
|
||||
end
|
||||
local out
|
||||
local cached = cropCanvasCache[layer]
|
||||
|
||||
@@ -40,8 +40,21 @@ end
|
||||
-- hardware, where sprite palette index 0 is unconditionally transparent
|
||||
-- (same rule TileRenderer's getColor0KeyShader documents for tall grass).
|
||||
local obpCache = {}
|
||||
-- obpCache is keyed by the string `path .. "#obp" .. group`; this indexes the
|
||||
-- same entries by path then group, so a draw that hits the cache (every draw
|
||||
-- after the first) builds no key string. A numeric group is looked up by
|
||||
-- its string form, since the concatenation cannot tell 1 from "1" either.
|
||||
local obpByPath = {}
|
||||
local groupStrings = {}
|
||||
|
||||
local function getObpImage(path, colors, group)
|
||||
local byGroup = type(path) == "string" and obpByPath[path]
|
||||
if byGroup then
|
||||
local g = group
|
||||
if type(g) == "number" then g = groupStrings[g] end
|
||||
local hit = g ~= nil and byGroup[g]
|
||||
if hit then return hit end
|
||||
end
|
||||
local key = path .. "#obp" .. group
|
||||
if not obpCache[key] then
|
||||
local img
|
||||
@@ -59,7 +72,24 @@ local function getObpImage(path, colors, group)
|
||||
end
|
||||
obpCache[key] = img
|
||||
end
|
||||
return obpCache[key]
|
||||
local img = obpCache[key]
|
||||
-- only string/number groups get here: concatenating anything else raised
|
||||
if group ~= group or type(path) ~= "string" then return img end -- NaN key
|
||||
if type(group) == "number" then
|
||||
local g = groupStrings[group]
|
||||
if not g then
|
||||
g = "" .. group
|
||||
groupStrings[group] = g
|
||||
end
|
||||
group = g
|
||||
end
|
||||
byGroup = obpByPath[path]
|
||||
if not byGroup then
|
||||
byGroup = {}
|
||||
obpByPath[path] = byGroup
|
||||
end
|
||||
byGroup[group] = img
|
||||
return img
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = getObpImage
|
||||
@@ -69,6 +99,7 @@ SpriteRenderer.obpImage = getObpImage
|
||||
function SpriteRenderer.invalidate()
|
||||
imageCache = {}
|
||||
obpCache = {}
|
||||
obpByPath = {}
|
||||
end
|
||||
|
||||
Assets.register(SpriteRenderer.invalidate)
|
||||
|
||||
+28
-8
@@ -437,7 +437,21 @@ function TextBox:moneyVisible()
|
||||
return not self.moneyWithChoice or not not self.choicePushed
|
||||
end
|
||||
|
||||
local step
|
||||
|
||||
-- One logic frame. The ScrollTextUpOneLine slide advances here, after the
|
||||
-- step (so a line begun this frame is first drawn 2px into its slide, as it
|
||||
-- was when draw advanced it), and not in draw, whose rate follows the
|
||||
-- display's refresh rather than the 60Hz logic clock.
|
||||
function TextBox:update(dt)
|
||||
step(self, dt)
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
end
|
||||
|
||||
function step(self, dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 480
|
||||
-- home/text.asm:506
|
||||
@@ -722,10 +736,6 @@ function TextBox:draw()
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
-- Only the retained line carries the offset: it slides up from where it
|
||||
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
|
||||
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
|
||||
@@ -757,14 +767,24 @@ function TextBox:draw()
|
||||
love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local cap = 13 * 8
|
||||
for _, code in ipairs(Font.encode("MONEY")) do
|
||||
if not self.moneyLabel or self.moneyRev ~= Font.revision then
|
||||
self.moneyLabel, self.moneyAmount = Font.encode("MONEY"), nil
|
||||
self.moneyRev = Font.revision
|
||||
end
|
||||
for _, code in ipairs(self.moneyLabel) do
|
||||
drawGlyph(code, cap, 0)
|
||||
cap = cap + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
local money = ("¥%d"):format(self.money() or 0)
|
||||
local pen = 152 - Font.width(money)
|
||||
for _, code in ipairs(Font.encode(money)) do
|
||||
-- formatted and encoded only when the amount (or the font) changes
|
||||
local amount = self.money() or 0
|
||||
if amount ~= self.moneyAmount or self.moneyRev ~= Font.revision then
|
||||
local money = ("¥%d"):format(amount)
|
||||
self.moneyAmount, self.moneyRev = amount, Font.revision
|
||||
self.moneyCodes, self.moneyWidth = Font.encode(money), Font.width(money)
|
||||
end
|
||||
local pen = 152 - self.moneyWidth
|
||||
for _, code in ipairs(self.moneyCodes) do
|
||||
drawGlyph(code, pen, 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
|
||||
+29
-9
@@ -45,6 +45,18 @@ local function fitLabel(text, pixels)
|
||||
return table.concat(out) .. "."
|
||||
end
|
||||
|
||||
-- item -> { label, budget, out }: fitLabel's result for a row, kept
|
||||
-- until the row's label or pixel budget changes (draw asks every frame).
|
||||
-- Weak-keyed and outside the item so caller-owned rows are never written.
|
||||
local fitCache = setmetatable({}, { __mode = "k" })
|
||||
local function fitRowLabel(item, pixels)
|
||||
local c = fitCache[item]
|
||||
if c and c.label == item.label and c.budget == pixels then return c.out end
|
||||
local out = fitLabel(item.label, pixels)
|
||||
fitCache[item] = { label = item.label, budget = pixels, out = out }
|
||||
return out
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||
@@ -258,16 +270,27 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
-- self.footer paginated into one flat line list, re-run only when the
|
||||
-- footer text changes (both footer draws below ask every frame)
|
||||
local function footerLines(self)
|
||||
local text = self.footer
|
||||
if self.footerFlatFor ~= text or not self.footerFlat then
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(text)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
self.footerFlatFor, self.footerFlat = text, flat
|
||||
end
|
||||
return self.footerFlat
|
||||
end
|
||||
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
local function drawMessageBox(self)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.footer then return end
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local flat = footerLines(self)
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
@@ -362,7 +385,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
local budget = ROW_RIGHT_MARGIN - ROW_LEFT
|
||||
if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end
|
||||
local label = fitLabel(item.label, budget)
|
||||
local label = fitRowLabel(item, budget)
|
||||
Font.draw(label, 16, y)
|
||||
if item.ball then -- the Pokédex owned-ball marker tile
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
@@ -400,10 +423,7 @@ function ListMenu:draw()
|
||||
drawMessageBox(self)
|
||||
elseif self.footer then
|
||||
-- bare footer (bag money line, etc.)
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local flat = footerLines(self)
|
||||
local y = (#flat >= 2) and 120 or 136
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
|
||||
+15
-7
@@ -130,16 +130,24 @@ function Menu:draw()
|
||||
-- PlaceString at hlcoord 3,0 writes over the border row it was just
|
||||
-- drawn on (oak_speech2.asm:162-170)
|
||||
if self.title then
|
||||
local title = self.title:gsub("{DONE}%s*$", "")
|
||||
:gsub("{PROMPT}%s*$", "")
|
||||
local lines = {}
|
||||
for line in (title .. "\n"):gmatch("(.-)\n") do
|
||||
lines[#lines + 1] = line
|
||||
-- the title's lines and glyph counts, rebuilt only when it changes
|
||||
-- (this draws every frame the menu is up)
|
||||
local lines, glyphs = self.titleLines, self.titleGlyphs
|
||||
if self.titleLinesFor ~= self.title or not lines then
|
||||
local title = self.title:gsub("{DONE}%s*$", "")
|
||||
:gsub("{PROMPT}%s*$", "")
|
||||
lines, glyphs = {}, {}
|
||||
for line in (title .. "\n"):gmatch("(.-)\n") do
|
||||
lines[#lines + 1] = line
|
||||
glyphs[#lines] = #Font.split(line)
|
||||
end
|
||||
self.titleLinesFor = self.title
|
||||
self.titleLines, self.titleGlyphs = lines, glyphs
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
for i = 1, #lines do
|
||||
love.graphics.rectangle("fill", (self.tx + 3) * 8,
|
||||
(self.ty + i - 1) * 8, #Font.split(line) * 8, 8)
|
||||
(self.ty + i - 1) * 8, glyphs[i] * 8, 8)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
|
||||
+42
-7
@@ -175,6 +175,29 @@ function PartyMenu.mirrorsIcon(name)
|
||||
end
|
||||
|
||||
local iconImages = {}
|
||||
-- the OBP0-baked copies: path -> ogGroup (or "") -> image, the "#obp" half
|
||||
-- of the cache, nested so a drawn frame builds no key string per icon
|
||||
local obpIconImages = {}
|
||||
-- image -> { half = { [frame] = quad }, full = { [frame] = quad } }: icon
|
||||
-- quads never change once built, so every drawn frame reuses them (they may
|
||||
-- also sit in PaletteFX's UI redraw list, which only reads them)
|
||||
local iconQuads = setmetatable({}, { __mode = "k" })
|
||||
local function iconQuad(img, kind, frame, w, iw, ih)
|
||||
local byImg = iconQuads[img]
|
||||
if not byImg then
|
||||
byImg = { half = {}, full = {} }
|
||||
iconQuads[img] = byImg
|
||||
end
|
||||
local byFrame = byImg[kind]
|
||||
local q = byFrame[frame]
|
||||
if not q then
|
||||
q = love.graphics.newQuad(0, frame * 16, w, 16, iw, ih)
|
||||
byFrame[frame] = q
|
||||
end
|
||||
return q
|
||||
end
|
||||
-- Sprites.iconPath's opts, reused (it only reads name / trueColor)
|
||||
local iconPathOpts = {}
|
||||
-- engine/items/town_map.asm:514
|
||||
local OAM_XFLIP = { sx = -1 }
|
||||
|
||||
@@ -236,8 +259,10 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||
path = name and icons.icons and icons.icons[name]
|
||||
end
|
||||
iconPathOpts.name, iconPathOpts.trueColor = name, trueColor
|
||||
path, trueColor = require("src.pokemon.Sprites")
|
||||
.iconPath(game.data, mon, path, { name = name, trueColor = trueColor })
|
||||
.iconPath(game.data, mon, path, iconPathOpts)
|
||||
iconPathOpts.name, iconPathOpts.trueColor = nil, nil
|
||||
if not path then return end
|
||||
-- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a
|
||||
-- mod's own image (an entry table rather than an icon name) is authored
|
||||
@@ -261,8 +286,18 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
ogColors, ogGroup = PaletteFX.ogObjNormal()
|
||||
end
|
||||
end
|
||||
local key = baked and (path .. "#obp" .. (ogGroup or "")) or path
|
||||
if iconImages[key] == nil then
|
||||
-- one cache, two halves: the plain image under its path, the baked one
|
||||
-- under (path, ogGroup) -- the old path .. "#obp" .. group key, nested
|
||||
local cache, key = iconImages, path
|
||||
if baked then
|
||||
cache = obpIconImages[path]
|
||||
if not cache then
|
||||
cache = {}
|
||||
obpIconImages[path] = cache
|
||||
end
|
||||
key = ogGroup or ""
|
||||
end
|
||||
if cache[key] == nil then
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
||||
-- picked up the same way battle sprites are
|
||||
@@ -275,9 +310,9 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
else
|
||||
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
end
|
||||
iconImages[key] = ok and img or false
|
||||
cache[key] = ok and img or false
|
||||
end
|
||||
local img = iconImages[key]
|
||||
local img = cache[key]
|
||||
if not img then return end
|
||||
local alt = forceAlt or false
|
||||
if selected then
|
||||
@@ -302,7 +337,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
-- reuse overworld sheets whose walk-down frame is NOT symmetric, so
|
||||
-- drawing the raw 16x16 showed a tucked-back foot the hardware never
|
||||
-- displays (#276, absorbing #238).
|
||||
local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih)
|
||||
local half = iconQuad(img, "half", frame, 8, iw, ih)
|
||||
love.graphics.draw(img, half, x, y)
|
||||
-- sx = -1 about the block's right edge, so the flipped copy lands on
|
||||
-- x+8..x+16: the OAM_XFLIP half
|
||||
@@ -312,7 +347,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
PaletteFX.markUiSpriteRedraw(img, half, x + 16, y, OAM_XFLIP)
|
||||
end
|
||||
elseif ih > 16 then
|
||||
local quad = love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih)
|
||||
local quad = iconQuad(img, "full", frame, 16, iw, ih)
|
||||
love.graphics.draw(img, quad, x, y)
|
||||
if ogColors then PaletteFX.markUiSpriteRedraw(img, quad, x, y) end
|
||||
else
|
||||
|
||||
+8
-3
@@ -285,9 +285,14 @@ local function drawClerk(menu)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if menu.footer then
|
||||
local flat = {}
|
||||
for _, page in ipairs(TextBox.paginate(menu.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
-- paginated once per footer text, not once per drawn frame
|
||||
local flat = menu.shopFooterFlat
|
||||
if menu.shopFooterFor ~= menu.footer or not flat then
|
||||
flat = {}
|
||||
for _, page in ipairs(TextBox.paginate(menu.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
menu.shopFooterFor, menu.shopFooterFlat = menu.footer, flat
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
|
||||
+23
-2
@@ -17,6 +17,28 @@ local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
-- front pics by path: opening the summary used to decode the PNG into a new
|
||||
-- Image every time. Loaded exactly as before (the raw path, no Assets
|
||||
-- resolution); a failed load is retried next open, as before. Hot reload
|
||||
-- (Assets.flush) drops the cache so an edited pic is picked up again.
|
||||
local frontPics = {}
|
||||
local function frontPic(path)
|
||||
local img = frontPics[path]
|
||||
if img then return img end
|
||||
local ok, loaded = pcall(love.graphics.newImage, path)
|
||||
if ok and loaded then
|
||||
frontPics[path] = loaded
|
||||
return loaded
|
||||
end
|
||||
return nil
|
||||
end
|
||||
do
|
||||
local Assets = require("src.render.Assets")
|
||||
if Assets.register then -- test harnesses may stub Assets without it
|
||||
Assets.register(function() frontPics = {} end)
|
||||
end
|
||||
end
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
SummaryMenu.isOpaque = true
|
||||
@@ -48,8 +70,7 @@ function SummaryMenu.new(game, mon)
|
||||
local path, trueColor = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
self.sprite = frontPic(path)
|
||||
end
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
-- engine/pokemon/status_screen.asm:82,168-172
|
||||
|
||||
+39
-5
@@ -328,6 +328,31 @@ local function skipAllowed()
|
||||
return envSkipAllowed or _G.POKEPORT_DEV_MODE == true
|
||||
end
|
||||
|
||||
-- sheet -> tile index -> 8x8 quad. The frame blocks redraw every frame
|
||||
-- the trade machine is on screen; a tile's quad never changes, so it is
|
||||
-- built once (it may also sit in PaletteFX's UI redraw list, read-only).
|
||||
local tileQuads = setmetatable({}, { __mode = "k" })
|
||||
local function tileQuad(sheet, tile, iw, ih)
|
||||
local byTile = tileQuads[sheet]
|
||||
if not byTile then
|
||||
byTile = {}
|
||||
tileQuads[sheet] = byTile
|
||||
end
|
||||
local q = byTile[tile]
|
||||
if not q then
|
||||
q = love.graphics.newQuad((tile % 16) * 8, math.floor(tile / 16) * 8,
|
||||
8, 8, iw, ih)
|
||||
byTile[tile] = q
|
||||
end
|
||||
return q
|
||||
end
|
||||
|
||||
-- markUiSpriteRedraw's flip opts (it only reads sx / sy off them)
|
||||
local FLIP_OPTS = {
|
||||
[1] = { [1] = { sx = 1, sy = 1 }, [-1] = { sx = 1, sy = -1 } },
|
||||
[-1] = { [1] = { sx = -1, sy = 1 }, [-1] = { sx = -1, sy = -1 } },
|
||||
}
|
||||
|
||||
-- engine/movie/trade.asm:20
|
||||
function TradeAnim:skipHeld()
|
||||
if not skipAllowed() then return false end
|
||||
@@ -343,9 +368,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y)
|
||||
local P = baked and require("src.render.PaletteFX")
|
||||
local iw, ih = sheet:getDimensions()
|
||||
for _, tile in ipairs(block) do
|
||||
local tx = (tile.tile % 16) * 8
|
||||
local ty = math.floor(tile.tile / 16) * 8
|
||||
local quad = love.graphics.newQuad(tx, ty, 8, 8, iw, ih)
|
||||
local quad = tileQuad(sheet, tile.tile, iw, ih)
|
||||
local sx = tile.xflip and -1 or 1
|
||||
local sy = tile.yflip and -1 or 1
|
||||
local ox = tile.xflip and 8 or 0
|
||||
@@ -353,7 +376,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y)
|
||||
love.graphics.draw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy)
|
||||
if P then
|
||||
P.markUiSpriteRedraw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy,
|
||||
{ sx = sx, sy = sy })
|
||||
FLIP_OPTS[sx][sy])
|
||||
end
|
||||
end
|
||||
else
|
||||
@@ -764,9 +787,20 @@ local function drawCableHoriz(self, y, x0, x1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.img.cableHoriz then
|
||||
local iw, ih = self.img.cableHoriz:getDimensions()
|
||||
-- one quad per run width, kept on the instance (the last run of a
|
||||
-- span is usually the only partial one)
|
||||
local quads = self.cableQuads
|
||||
if not quads or quads.img ~= self.img.cableHoriz then
|
||||
quads = { img = self.img.cableHoriz }
|
||||
self.cableQuads = quads
|
||||
end
|
||||
for x = x0, x1 - 1, iw do
|
||||
local drawW = math.min(iw, x1 - x)
|
||||
local quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
|
||||
local quad = quads[drawW]
|
||||
if not quad then
|
||||
quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
|
||||
quads[drawW] = quad
|
||||
end
|
||||
love.graphics.draw(self.img.cableHoriz, quad, x, y)
|
||||
end
|
||||
elseif self.img.cableSeg then
|
||||
|
||||
@@ -528,13 +528,21 @@ end
|
||||
local function elements_tile_quad(ti, sheet)
|
||||
sheet = sheet or BattleChrome._elements
|
||||
if not sheet or not love or not love.graphics then return nil end
|
||||
local key = (sheet == BattleChrome._elementsExp and "exp_" or "elt_") .. tostring(ti)
|
||||
if not BattleChrome._quads[key] then
|
||||
-- Per-sheet subtables keyed by tile index (no per-tile string keys).
|
||||
local subKey = sheet == BattleChrome._elementsExp and "exp_tiles" or "elt_tiles"
|
||||
local sub = BattleChrome._quads[subKey]
|
||||
if not sub then
|
||||
sub = {}
|
||||
BattleChrome._quads[subKey] = sub
|
||||
end
|
||||
local q = sub[ti]
|
||||
if not q then
|
||||
local tw = 40 -- 320/8
|
||||
local tx, ty = ti % tw, math.floor(ti / tw)
|
||||
BattleChrome._quads[key] = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24)
|
||||
q = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24)
|
||||
sub[ti] = q
|
||||
end
|
||||
return BattleChrome._quads[key]
|
||||
return q
|
||||
end
|
||||
|
||||
-- pokefirered/src/battle_interface.c:2050
|
||||
|
||||
+81
-28
@@ -264,7 +264,7 @@ local function loadImage(candidates)
|
||||
if okId and id then
|
||||
local img = love.graphics.newImage(id)
|
||||
if img and img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
return img, path
|
||||
return img, path, id
|
||||
end
|
||||
elseif love and love.filesystem and love.image and love.graphics then
|
||||
local okFd, fd = pcall(love.filesystem.newFileData, data, path)
|
||||
@@ -273,7 +273,7 @@ local function loadImage(candidates)
|
||||
if okId and id then
|
||||
local img = love.graphics.newImage(id)
|
||||
if img and img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
return img, path
|
||||
return img, path, id
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -289,6 +289,42 @@ local function loadImage(candidates)
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- Pack a sheet's fg and shadow images into one texture (fg on top, shadow
|
||||
-- below a transparent gap) with matching quads. Each glyph still draws its
|
||||
-- shadow then its fg, but from the same texture, so LOVE's autobatcher keeps
|
||||
-- a whole string in one draw call instead of flushing on every texture swap.
|
||||
-- Pixels are copied verbatim, so the output is unchanged. nil (draw from the
|
||||
-- separate sheets) when either ImageData is missing or they do not match.
|
||||
local ATLAS_GAP = 16
|
||||
local function pack_atlas(fgData, shData, quads)
|
||||
if not (fgData and shData and quads and love and love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage and love.graphics.newQuad) then
|
||||
return nil
|
||||
end
|
||||
local ok, atlas = pcall(function()
|
||||
local w, h = fgData:getDimensions()
|
||||
local sw, shh = shData:getDimensions()
|
||||
if sw ~= w or shh ~= h then return nil end
|
||||
if fgData.getFormat and shData.getFormat and fgData:getFormat() ~= shData:getFormat() then return nil end
|
||||
local off = h + ATLAS_GAP
|
||||
local fmt = fgData.getFormat and fgData:getFormat() or "rgba8"
|
||||
local data = love.image.newImageData(w, off + h, fmt)
|
||||
data:paste(fgData, 0, 0, 0, 0, w, h)
|
||||
data:paste(shData, 0, off, 0, 0, w, h)
|
||||
local img = love.graphics.newImage(data)
|
||||
if img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
local aw, ah = img:getDimensions()
|
||||
local fq, sq = {}, {}
|
||||
for id, q in pairs(quads) do
|
||||
local qx, qy, qw, qh = q:getViewport()
|
||||
fq[id] = love.graphics.newQuad(qx, qy, qw, qh, aw, ah)
|
||||
sq[id] = love.graphics.newQuad(qx, qy + off, qw, qh, aw, ah)
|
||||
end
|
||||
return { image = img, fg = fq, sh = sq, src = quads }
|
||||
end)
|
||||
return ok and atlas or nil
|
||||
end
|
||||
|
||||
local function loadTable(path)
|
||||
local okC, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
if not (okC and CacheFs and CacheFs.readActive) then return nil end
|
||||
@@ -387,8 +423,8 @@ local function loadFace(spec, name)
|
||||
error("FrlgFont: the active profile has no font face '" .. tostring(name) .. "'", 0)
|
||||
end
|
||||
local dir = spec.dir or ""
|
||||
local fg, fgp = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } })
|
||||
local sh = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } })
|
||||
local fg, fgp, fgData = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } })
|
||||
local sh, _, shData = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } })
|
||||
local widths = fs.widths and loadTable(dir .. fs.widths)
|
||||
local m = metricsFor(spec, fs.fontId)
|
||||
if not (fg and widths and m) then
|
||||
@@ -408,6 +444,7 @@ local function loadFace(spec, name)
|
||||
name = name, fg = fg, sh = sh, quads = quads, widths = widths,
|
||||
height = m.maxLetterHeight, pitch = m.maxLetterHeight + (m.lineSpacing or 0),
|
||||
letterSpacing = m.letterSpacing or 0,
|
||||
atlas = pack_atlas(fgData, shData, quads),
|
||||
}
|
||||
FrlgFont._faces[name] = face
|
||||
log(fs.sheet .. " ready " .. tostring(fgp))
|
||||
@@ -471,8 +508,8 @@ local function ensure()
|
||||
end
|
||||
FrlgFont._widths = widths or {}
|
||||
|
||||
local fg, fgp = loadImage(FG_PATHS)
|
||||
local sh = loadImage(SH_PATHS)
|
||||
local fg, fgp, fgData = loadImage(FG_PATHS)
|
||||
local sh, _, shData = loadImage(SH_PATHS)
|
||||
if not fg then
|
||||
if not FrlgFont._logged then
|
||||
log("latin_normal font missing")
|
||||
@@ -490,6 +527,7 @@ local function ensure()
|
||||
quads[id] = love.graphics.newQuad(col * 16, row * 16, 16, 16, iw, ih)
|
||||
end
|
||||
FrlgFont._quads = quads
|
||||
FrlgFont._atlas = pack_atlas(fgData, shData, quads)
|
||||
if not FrlgFont._logged then
|
||||
log("latin_normal ready " .. tostring(fgp))
|
||||
FrlgFont._logged = true
|
||||
@@ -529,8 +567,8 @@ local function ensure_small()
|
||||
if chunk then widths = chunk() end
|
||||
end
|
||||
end
|
||||
local fg, fgp = loadImage(SMALL_FG_PATHS)
|
||||
local sh = loadImage(SMALL_SH_PATHS)
|
||||
local fg, fgp, fgData = loadImage(SMALL_FG_PATHS)
|
||||
local sh, _, shData = loadImage(SMALL_SH_PATHS)
|
||||
if not fg then return false end
|
||||
local iw, ih = fg:getDimensions()
|
||||
local quads = {}
|
||||
@@ -545,6 +583,7 @@ local function ensure_small()
|
||||
-- Sheets from extract_latin_small.py (ROM hwlat @ 0x1EAF00), CHARMAP-ordered.
|
||||
FrlgFont._small = {
|
||||
fg = fg, sh = sh, quads = quads, widths = widths or {}, _romBaked = true,
|
||||
atlas = pack_atlas(fgData, shData, quads),
|
||||
}
|
||||
log("latin_small ready " .. tostring(fgp) .. " (ROM FONT_SMALL)")
|
||||
return true
|
||||
@@ -612,18 +651,18 @@ FrlgFont.JAPANESE_GLYPHS = japanese_glyphs()
|
||||
|
||||
local function load_japanese(key, fgPaths, shPaths)
|
||||
if FrlgFont[key] ~= nil then return FrlgFont[key] or nil end
|
||||
local fg = loadImage(fgPaths)
|
||||
local fg, _, fgData = loadImage(fgPaths)
|
||||
if not fg then
|
||||
FrlgFont[key] = false
|
||||
return nil
|
||||
end
|
||||
local sh = loadImage(shPaths)
|
||||
local sh, _, shData = loadImage(shPaths)
|
||||
local iw, ih = fg:getDimensions()
|
||||
local quads = {}
|
||||
for code = 0, 511 do
|
||||
quads[code] = love.graphics.newQuad((code % 16) * 16, math.floor(code / 16) * 16, 16, 16, iw, ih)
|
||||
end
|
||||
FrlgFont[key] = { fg = fg, sh = sh, quads = quads }
|
||||
FrlgFont[key] = { fg = fg, sh = sh, quads = quads, atlas = pack_atlas(fgData, shData, quads) }
|
||||
return FrlgFont[key]
|
||||
end
|
||||
|
||||
@@ -649,7 +688,7 @@ local function japanese_quad(glyphId, small)
|
||||
local sheet = small and load_japanese("_jpSmall", JP_SMALL_FG_PATHS, JP_SMALL_SH_PATHS)
|
||||
or load_japanese("_jpNormal", JP_FG_PATHS, JP_SH_PATHS)
|
||||
if not sheet then return nil end
|
||||
return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE]
|
||||
return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE], sheet.atlas
|
||||
end
|
||||
|
||||
-- The remaining single characters of the Latin block of pret
|
||||
@@ -1162,6 +1201,14 @@ end
|
||||
local ADVANCE_SMALL = { small = true }
|
||||
local ADVANCE_NORMAL = {}
|
||||
|
||||
local function set_col(c)
|
||||
if type(c) == "table" then
|
||||
love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
--- Draw full string at pixel (x,y).
|
||||
-- opts.maxWidth clips (CopyGlyphToWindow). opts.colors = COLOR.NORMAL etc.
|
||||
-- opts.limitChars: only draw first N printable characters (typewriter).
|
||||
@@ -1210,22 +1257,15 @@ function FrlgFont.draw(text, x, y, opts)
|
||||
local drawn = 0
|
||||
local pitch = opts.linePitch or (face and face.pitch)
|
||||
or (useSmall and FrlgFont.SMALL_LINE_PITCH or FrlgFont.LINE_PITCH)
|
||||
local fg, sh, quads
|
||||
local fg, sh, quads, atlas
|
||||
if face then
|
||||
fg, sh, quads = face.fg, face.sh, face.quads
|
||||
fg, sh, quads, atlas = face.fg, face.sh, face.quads, face.atlas
|
||||
elseif useSmall then
|
||||
fg, sh, quads = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads
|
||||
fg, sh, quads, atlas = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads, FrlgFont._small.atlas
|
||||
else
|
||||
fg, sh, quads = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads
|
||||
end
|
||||
|
||||
local function set_col(c)
|
||||
if type(c) == "table" then
|
||||
love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
fg, sh, quads, atlas = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads, FrlgFont._atlas
|
||||
end
|
||||
if atlas and (atlas.src ~= quads or not sh) then atlas = nil end
|
||||
|
||||
for ttype, val, curCol in FrlgFont.scanTokens(text, baseColors) do
|
||||
if limit and drawn >= limit then break end
|
||||
@@ -1262,9 +1302,12 @@ function FrlgFont.draw(text, x, y, opts)
|
||||
end
|
||||
if penX + adv <= maxW or penX == 0 then
|
||||
local dx, dy = x + penX, y + penY
|
||||
local gfg, gsh, q = fg, sh, quads[id]
|
||||
local gfg, gsh, q, gat = fg, sh, quads[id], atlas
|
||||
local aid = id
|
||||
if id >= FrlgFont.JAPANESE_BASE then
|
||||
gfg, gsh, q = japanese_quad(id, useSmall)
|
||||
gfg, gsh, q, gat = japanese_quad(id, useSmall)
|
||||
aid = id - FrlgFont.JAPANESE_BASE
|
||||
if not gsh then gat = nil end
|
||||
end
|
||||
if q then
|
||||
-- Draw background / highlight fill if bg is not transparent
|
||||
@@ -1272,15 +1315,24 @@ function FrlgFont.draw(text, x, y, opts)
|
||||
set_col(curCol.bg)
|
||||
love.graphics.rectangle("fill", dx, dy, adv, pitch)
|
||||
end
|
||||
local aq = gat and gat.fg[aid]
|
||||
-- Draw shadow
|
||||
if gsh and curCol.shadow and (not curCol.shadow[4] or curCol.shadow[4] > 0) then
|
||||
set_col(curCol.shadow)
|
||||
love.graphics.draw(gsh, q, dx, dy)
|
||||
if aq then
|
||||
love.graphics.draw(gat.image, gat.sh[aid], dx, dy)
|
||||
else
|
||||
love.graphics.draw(gsh, q, dx, dy)
|
||||
end
|
||||
end
|
||||
-- Draw foreground
|
||||
if curCol.fg and (not curCol.fg[4] or curCol.fg[4] > 0) then
|
||||
set_col(curCol.fg)
|
||||
love.graphics.draw(gfg, q, dx, dy)
|
||||
if aq then
|
||||
love.graphics.draw(gat.image, aq, dx, dy)
|
||||
else
|
||||
love.graphics.draw(gfg, q, dx, dy)
|
||||
end
|
||||
end
|
||||
end
|
||||
penX = penX + japanese_step(id, adv, minW, jpn, opts, useSmall)
|
||||
@@ -1381,6 +1433,7 @@ function FrlgFont.invalidate()
|
||||
FrlgFont._fg = nil
|
||||
FrlgFont._sh = nil
|
||||
FrlgFont._quads = nil
|
||||
FrlgFont._atlas = nil
|
||||
FrlgFont._small = nil
|
||||
FrlgFont._keypad = nil
|
||||
FrlgFont._jpNormal = nil
|
||||
|
||||
@@ -213,8 +213,25 @@ local function bgPal(s)
|
||||
return Gfx.bgPalette(paletteSet(s))
|
||||
end
|
||||
|
||||
-- Read-only bg palettes for per-frame draws, rebuilt when the manifest
|
||||
-- changes. bgPal returns a fresh copy for callers that edit it.
|
||||
local _readPals, _readPalsMan = {}, nil
|
||||
local function bgPalRead(s)
|
||||
local man = Gfx.manifest()
|
||||
if man ~= _readPalsMan then
|
||||
_readPals, _readPalsMan = {}, man
|
||||
end
|
||||
local set = paletteSet(s)
|
||||
local pal = _readPals[set]
|
||||
if not pal then
|
||||
pal = Gfx.bgPalette(set)
|
||||
_readPals[set] = pal
|
||||
end
|
||||
return pal
|
||||
end
|
||||
|
||||
local function textColors(s, fgIdx, shIdx)
|
||||
local pal = bgPal(s)
|
||||
local pal = bgPalRead(s)
|
||||
return { fg = Gfx.color(pal[fgIdx or 15]), shadow = Gfx.color(pal[shIdx or 3]), bg = { 0, 0, 0, 0 } }
|
||||
end
|
||||
|
||||
@@ -2130,7 +2147,7 @@ end
|
||||
local function listRowsText(s)
|
||||
local colors = textColors(s, 15, 3)
|
||||
local vofs = s.bg2vofs or 0
|
||||
local pal = bgPal(s)
|
||||
local pal = bgPalRead(s)
|
||||
local ball = Gfx.sprite("caught_ball", 0, 8, 16, { pal[0], pal[1], pal[2], pal[3], pal[4], pal[5], pal[6], pal[7], pal[8],
|
||||
pal[9], pal[10], pal[11], pal[12], pal[13], pal[14], pal[15] })
|
||||
-- pokeemerald/src/pokedex.c:847
|
||||
|
||||
@@ -28,6 +28,11 @@ local _cards = nil
|
||||
local _cardEdge = {}
|
||||
local _screens = nil
|
||||
local _assetsTried = false
|
||||
-- RSE front pics keyed by trainer pic id (false = load failed), and the
|
||||
-- badge quads keyed by the badge sheet they were built for.
|
||||
local _rsePics = {}
|
||||
local _rseBadgeQuads = nil
|
||||
local _rseBadgeQuadsImg = nil
|
||||
|
||||
local function read_cache_file(path)
|
||||
local ok, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
@@ -997,7 +1002,11 @@ local function drawRse(c)
|
||||
else
|
||||
-- pokeemerald/src/trainer_card.c:1895
|
||||
local picId = c.female and m.pics.female or m.pics.male
|
||||
local pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64)
|
||||
local pic = _rsePics[picId]
|
||||
if pic == nil then
|
||||
pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64) or false
|
||||
_rsePics[picId] = pic
|
||||
end
|
||||
if pic then
|
||||
love.graphics.draw(pic, (19 + m.picOffset[1]) * 8, (5 + m.picOffset[2]) * 8)
|
||||
end
|
||||
@@ -1009,10 +1018,17 @@ local function drawRse(c)
|
||||
end
|
||||
local badges = Kit.image(m.badges.png)
|
||||
if badges then
|
||||
local bw, bh = badges:getDimensions()
|
||||
if _rseBadgeQuadsImg ~= badges then
|
||||
local bw, bh = badges:getDimensions()
|
||||
_rseBadgeQuads = {}
|
||||
for i = 1, 8 do
|
||||
_rseBadgeQuads[i] = love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh)
|
||||
end
|
||||
_rseBadgeQuadsImg = badges
|
||||
end
|
||||
for i = 1, 8 do
|
||||
if c.badges[i] then
|
||||
love.graphics.draw(badges, love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh), (4 + (i - 1) * 3) * 8, 15 * 8)
|
||||
love.graphics.draw(badges, _rseBadgeQuads[i], (4 + (i - 1) * 3) * 8, 15 * 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,8 @@ local Palettes = require("src.world.gen2.Palettes")
|
||||
local BattleAnimView = {}
|
||||
BattleAnimView.__index = BattleAnimView
|
||||
|
||||
local EMPTY = {}
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
-- An OBJ at OAM (x, y) draws at (x - 8, y - 16).
|
||||
@@ -64,14 +66,19 @@ function BattleAnimView:image(path)
|
||||
return cached or nil
|
||||
end
|
||||
|
||||
-- Nested by sheet then index, so a lookup builds no string.
|
||||
function BattleAnimView:quad(sheetName, index, wide, image)
|
||||
local key = sheetName .. ":" .. index
|
||||
local quad = self.quads[key]
|
||||
local bySheet = self.quads[sheetName]
|
||||
if not bySheet then
|
||||
bySheet = {}
|
||||
self.quads[sheetName] = bySheet
|
||||
end
|
||||
local quad = bySheet[index]
|
||||
if not quad then
|
||||
local w, h = image:getDimensions()
|
||||
quad = love.graphics.newQuad(
|
||||
(index % wide) * 8, math.floor(index / wide) * 8, 8, 8, w, h)
|
||||
self.quads[key] = quad
|
||||
bySheet[index] = quad
|
||||
end
|
||||
return quad
|
||||
end
|
||||
@@ -93,30 +100,55 @@ end
|
||||
-- colours; the other six are the fixed block from
|
||||
-- gfx/battle_anims/battle_anims.pal, which the extractor reads into
|
||||
-- palettes.battleObjects.
|
||||
-- Palettes.monColors, memoized per (palettes, species, shiny): it builds five
|
||||
-- tables a call and this runs per OAM object per frame. The answer is a pure
|
||||
-- function of those three, and the shader only reads it.
|
||||
function BattleAnimView:monColors(species, shiny)
|
||||
local cache = self.monColorCache
|
||||
if not cache or cache.palettes ~= self.palettes then
|
||||
cache = { palettes = self.palettes, [true] = {}, [false] = {} }
|
||||
self.monColorCache = cache
|
||||
end
|
||||
local byShiny = cache[shiny and true or false]
|
||||
local colors = byShiny[species]
|
||||
if colors == nil then
|
||||
if species == nil then return Palettes.monColors(self.palettes, species, shiny) end
|
||||
colors = Palettes.monColors(self.palettes, species, shiny) or false
|
||||
byShiny[species] = colors
|
||||
end
|
||||
return colors or nil
|
||||
end
|
||||
|
||||
function BattleAnimView:objPalette(name, battle)
|
||||
if name == "PAL_BATTLE_OB_ENEMY" then
|
||||
local enemy = battle and battle.enemy
|
||||
return enemy and Palettes.monColors(self.palettes, enemy.species, enemy.shiny)
|
||||
return enemy and self:monColors(enemy.species, enemy.shiny)
|
||||
end
|
||||
if name == "PAL_BATTLE_OB_PLAYER" then
|
||||
local player = battle and battle.player
|
||||
return player and Palettes.monColors(self.palettes, player.species, player.shiny)
|
||||
return player and self:monColors(player.species, player.shiny)
|
||||
end
|
||||
local set = self.palettes and self.palettes.battleObjects
|
||||
return set and set[name] or nil
|
||||
end
|
||||
|
||||
-- One frame's OBJ layer.
|
||||
-- One frame's OBJ layer. The palette shader is set once per run of objects
|
||||
-- sharing a palette and the caller's shader restored once at the end: what
|
||||
-- GbcPalette.with did per object, without the closure and pcall each.
|
||||
function BattleAnimView:drawObjects(runner, battle)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local shaded = GbcPalette.available()
|
||||
local previous = shaded and G.getShader and G.getShader() or nil
|
||||
local active = nil
|
||||
local gfx = self.data.gfx or EMPTY
|
||||
for _, obj in ipairs(runner:oam()) do
|
||||
local entry, index = sheetForTile(runner, obj.tile)
|
||||
-- The two battler-pic pseudo-sheets are the mons' own tiles; nothing in
|
||||
-- the cache holds them as a sheet, so they are simply not drawn rather
|
||||
-- than drawn from the wrong image.
|
||||
if entry and not entry.battler then
|
||||
local sheet = (self.data.gfx or {})[entry.gfx]
|
||||
local sheet = gfx[entry.gfx]
|
||||
local image = sheet and self:image(sheet.image)
|
||||
if image then
|
||||
local wide = sheet.wide or 8
|
||||
@@ -132,19 +164,17 @@ function BattleAnimView:drawObjects(runner, battle)
|
||||
local syScale = bit.band(obj.attr, OAM_YFLIP) ~= 0 and -1 or 1
|
||||
local ox = sxScale < 0 and 8 or 0
|
||||
local oy = syScale < 0 and 8 or 0
|
||||
local colors = self:objPalette(obj.palette, battle)
|
||||
local function body()
|
||||
G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale)
|
||||
end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
local colors = shaded and self:objPalette(obj.palette, battle) or nil
|
||||
if colors ~= active then
|
||||
if colors then GbcPalette.use(colors) else G.setShader(previous) end
|
||||
active = colors
|
||||
end
|
||||
G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if active then G.setShader(previous) end
|
||||
end
|
||||
|
||||
-- True when the BG layer needs the scanline treatment at all; a plain
|
||||
@@ -269,24 +299,34 @@ end
|
||||
BattleAnimView.bgpBands = bgpBands
|
||||
|
||||
-- engine/battle_anims/bg_effects.asm:2638
|
||||
--
|
||||
-- One scanline's source row and x shift, or nil when the row shows nothing.
|
||||
local function scanline(bg, scy, row)
|
||||
local dx, src = 0, row + scy
|
||||
-- home/lcd.asm:12
|
||||
local inWindow = bg.lcdc and bg.lcdc ~= "BGP"
|
||||
and row > bg.lyStart and row <= bg.lyEnd
|
||||
local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0
|
||||
if inWindow then
|
||||
local value = signed(byte)
|
||||
if bg.lcdc == "SCX" then dx = -value else src = src + value end
|
||||
-- home/lcd.asm:3
|
||||
if byte ~= 0x90 and scy == 0 then
|
||||
if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end
|
||||
end
|
||||
end
|
||||
if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then
|
||||
return src, dx
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function BattleAnimView.scanlines(bg)
|
||||
local lines = {}
|
||||
local scy = signed(bg.scy)
|
||||
for row = 0, SCREEN_H - 1 do
|
||||
local dx, src = 0, row + scy
|
||||
-- home/lcd.asm:12
|
||||
local inWindow = bg.lcdc and bg.lcdc ~= "BGP"
|
||||
and row > bg.lyStart and row <= bg.lyEnd
|
||||
local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0
|
||||
if inWindow then
|
||||
local value = signed(byte)
|
||||
if bg.lcdc == "SCX" then dx = -value else src = src + value end
|
||||
-- home/lcd.asm:3
|
||||
if byte ~= 0x90 and scy == 0 then
|
||||
if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end
|
||||
end
|
||||
end
|
||||
if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then
|
||||
local src, dx = scanline(bg, scy, row)
|
||||
if src then
|
||||
lines[#lines + 1] = { src = src, dest = row, dx = dx }
|
||||
end
|
||||
end
|
||||
@@ -339,8 +379,11 @@ function BattleAnimView:present(runner, drawBg, battle)
|
||||
-- hSCX / hSCY move the whole background; the per-scanline overrides only
|
||||
-- apply inside the effect's own window.
|
||||
local baseX = -signed(bg.scx)
|
||||
for _, line in ipairs(BattleAnimView.scanlines(bg)) do
|
||||
self:blitRowAt(line.src, line.dest, baseX + line.dx)
|
||||
-- BattleAnimView.scanlines row by row, without a table per scanline.
|
||||
local scy = signed(bg.scy)
|
||||
for row = 0, SCREEN_H - 1 do
|
||||
local src, dx = scanline(bg, scy, row)
|
||||
if src then self:blitRowAt(src, row, baseX + dx) end
|
||||
end
|
||||
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to
|
||||
-- permute and the entry's BRIGHTNESS is the only thing left to reproduce.
|
||||
|
||||
+25
-14
@@ -82,13 +82,18 @@ function BattleHud:image(key)
|
||||
end
|
||||
|
||||
-- One 8x8 tile out of a horizontal strip, cached per (sheet, index).
|
||||
-- Nested by image then index, so a lookup builds no string.
|
||||
function BattleHud:quad(image, index)
|
||||
local key = tostring(image) .. ":" .. index
|
||||
local quad = self.quads[key]
|
||||
local byImage = self.quads[image]
|
||||
if not byImage then
|
||||
byImage = {}
|
||||
self.quads[image] = byImage
|
||||
end
|
||||
local quad = byImage[index]
|
||||
if not quad then
|
||||
local w, h = image:getDimensions()
|
||||
quad = love.graphics.newQuad(index * 8, 0, 8, 8, w, h)
|
||||
self.quads[key] = quad
|
||||
byImage[index] = quad
|
||||
end
|
||||
return quad
|
||||
end
|
||||
@@ -120,20 +125,26 @@ function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror)
|
||||
if index < 0 then return false end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body()
|
||||
if mirror then
|
||||
-- Flip in place: the origin moves a tile right and x scales by -1.
|
||||
G.draw(image, self:quad(image, index), tx * 8 + 8, ty * 8, 0, -1, 1)
|
||||
else
|
||||
G.draw(image, self:quad(image, index), tx * 8, ty * 8)
|
||||
end
|
||||
end
|
||||
local quad = self:quad(image, index)
|
||||
-- home/fade.asm:35 (RotateThreePalettesRight)
|
||||
if GbcPalette.available() then
|
||||
GbcPalette.with(colors or GbcPalette.DMG_SHADES, body)
|
||||
-- GbcPalette.with without the per-tile closure: set, draw, restore.
|
||||
local shaded = GbcPalette.available()
|
||||
local previous
|
||||
if shaded then
|
||||
previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(colors or GbcPalette.DMG_SHADES)
|
||||
else
|
||||
G.setColor(0, 0, 0, 1)
|
||||
body()
|
||||
end
|
||||
if mirror then
|
||||
-- Flip in place: the origin moves a tile right and x scales by -1.
|
||||
G.draw(image, quad, tx * 8 + 8, ty * 8, 0, -1, 1)
|
||||
else
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
end
|
||||
if shaded then
|
||||
G.setShader(previous)
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
return true
|
||||
|
||||
+65
-11
@@ -622,6 +622,12 @@ function BattleState.new(game, opts)
|
||||
species = enemy and enemy.species,
|
||||
level = enemy and enemy.level,
|
||||
})
|
||||
-- queue both entrance cries on the chip audio worker during the intro so
|
||||
-- playCry doesn't render them on the frame they play (no-op without one)
|
||||
local data = game and game.data
|
||||
for _, mon in ipairs({ enemy or false, player or false }) do
|
||||
if mon and mon.species then pcall(Sound.prewarmCry, data, mon.species) end
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -750,7 +756,10 @@ function BattleState:pic(mon, back)
|
||||
-- picked -- resolving the species row again would throw the form away. The
|
||||
-- two extra keys are what Gen 2 genuinely carries more of: the Unown letter
|
||||
-- and the shiny flag that decides the palette.
|
||||
if path then
|
||||
-- Unhooked, Sprites.pic hands back exactly (path, trueColor); the ctx is
|
||||
-- only built when a subscriber is there to read it (drawPic asks twice a
|
||||
-- frame).
|
||||
if path and Runtime.wantsHook("pokemon.sprite") then
|
||||
path, trueColor = Sprites.pic(path, {
|
||||
species = mon.species,
|
||||
side = back and "back" or "front",
|
||||
@@ -937,6 +946,42 @@ function BattleState:isUnderground(side, mon)
|
||||
and (self.vanishSeen and self.vanishSeen[side]) and true or false
|
||||
end
|
||||
|
||||
-- Palettes.monColors / trainerColors, memoized per palettes table: each
|
||||
-- builds five tables a call and drawPic asks twice a frame. Both are pure
|
||||
-- functions of the palette data, and the shader only reads the answer.
|
||||
local function colorMemo(self)
|
||||
local memo = self.picColorMemo
|
||||
if not memo or memo.palettes ~= self.palettes then
|
||||
memo = { palettes = self.palettes, [true] = {}, [false] = {}, trainers = {} }
|
||||
self.picColorMemo = memo
|
||||
end
|
||||
return memo
|
||||
end
|
||||
|
||||
function BattleState:cachedMonColors(species, shiny)
|
||||
if species == nil then
|
||||
return Palettes.monColors(self.palettes, species, shiny)
|
||||
end
|
||||
local bucket = colorMemo(self)[shiny and true or false]
|
||||
local colors = bucket[species]
|
||||
if colors == nil then
|
||||
colors = Palettes.monColors(self.palettes, species, shiny) or false
|
||||
bucket[species] = colors
|
||||
end
|
||||
return colors or nil
|
||||
end
|
||||
|
||||
function BattleState:cachedTrainerColors(row)
|
||||
local key = row or "PLAYER"
|
||||
local bucket = colorMemo(self).trainers
|
||||
local colors = bucket[key]
|
||||
if colors == nil then
|
||||
colors = Palettes.trainerColors(self.palettes, row) or false
|
||||
bucket[key] = colors
|
||||
end
|
||||
return colors or nil
|
||||
end
|
||||
|
||||
function BattleState:drawPic(mon, back)
|
||||
-- During the intro slide the player-side pic belongs to presentSlide's
|
||||
-- backpic overlay, not to the baked bands (see BattleAnimView).
|
||||
@@ -1041,17 +1086,17 @@ function BattleState:drawPic(mon, back)
|
||||
-- No mon on this side at all in the catching tutorial, where the box holds
|
||||
-- the DUDE's back-pic and nothing else for the whole battle.
|
||||
local colors = self.palettes and mon
|
||||
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
|
||||
and self:cachedMonColors(mon.species, mon.shiny)
|
||||
if trainerBack then
|
||||
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
|
||||
-- TrainerPalettes (Chris shares Cal's).
|
||||
-- engine/gfx/color.asm:683-696
|
||||
local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER"
|
||||
colors = Palettes.trainerColors(self.palettes, row)
|
||||
or Palettes.trainerColors(self.palettes, "PLAYER") or colors
|
||||
colors = self:cachedTrainerColors(row)
|
||||
or self:cachedTrainerColors("PLAYER") or colors
|
||||
elseif enemyTrainer then
|
||||
-- The opponent's class row out of the same TrainerPalettes table.
|
||||
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
|
||||
colors = self:cachedTrainerColors(self.enemyTrainerClass)
|
||||
or colors
|
||||
end
|
||||
-- ../pokecrystal/engine/gfx/cgb_layouts.asm:67
|
||||
@@ -4374,14 +4419,23 @@ end
|
||||
-- The four labels the battle menu draws. Inside the contest the third one
|
||||
-- carries the park ball count, which .PrintParkBallsRemaining writes with
|
||||
-- PRINTNUM_LEADINGZEROS over two digits.
|
||||
--
|
||||
-- The list is refilled in place each call (the menu asks every frame); the
|
||||
-- lookups themselves still run, so a catalog a mod edits live is followed.
|
||||
function BattleState:menuLabels()
|
||||
if not self.contest then
|
||||
return { Strings(MENU[1]), Strings(MENU[2]),
|
||||
Strings(MENU[3]), Strings(MENU[4]) }
|
||||
local labels = self.menuLabelList
|
||||
if not labels then
|
||||
labels = {}
|
||||
self.menuLabelList = labels
|
||||
end
|
||||
return { Strings(MENU[1]), Strings(MENU[2]),
|
||||
Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save)),
|
||||
Strings(MENU[4]) }
|
||||
labels[1], labels[2], labels[4] =
|
||||
Strings(MENU[1]), Strings(MENU[2]), Strings(MENU[4])
|
||||
if not self.contest then
|
||||
labels[3] = Strings(MENU[3])
|
||||
else
|
||||
labels[3] = Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save))
|
||||
end
|
||||
return labels
|
||||
end
|
||||
|
||||
-- The message on the cart's own two rows: 14 and 16, with 15 blank between
|
||||
|
||||
+54
-36
@@ -852,6 +852,39 @@ end
|
||||
-- GetFrontpic's `cp EGG / jr nz, .not_egg` arm hands back EggPic, never the
|
||||
-- hatchling's pic (engine/gfx/load_pics.asm:88-91); it rides menu_gfx.eggHatch,
|
||||
-- with the party list's ICON_EGG standing in for a cache built before that.
|
||||
-- One reusable quad per role, re-aimed per draw rather than a new Quad a
|
||||
-- frame. nil when the backend cannot make one (the old pcall's degrade).
|
||||
local function reusedQuad(self, slot, x, y, w, h, sw, sh)
|
||||
local quads = self.reusedQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
self.reusedQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
return quad
|
||||
end
|
||||
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
|
||||
if not ok then return nil end
|
||||
quads[slot] = made
|
||||
return made
|
||||
end
|
||||
|
||||
-- GbcPalette.with without a closure: set `colors` (when there is a shader),
|
||||
-- returning what finishShade needs to put the caller's shader back.
|
||||
local function startShade(colors)
|
||||
if not (colors and GbcPalette.available()) then return false end
|
||||
local G = love.graphics
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(colors)
|
||||
return true, previous
|
||||
end
|
||||
|
||||
local function finishShade(shaded, previous)
|
||||
if shaded then love.graphics.setShader(previous) end
|
||||
end
|
||||
|
||||
function BoxMenu:drawEggPic(mon)
|
||||
local G = love.graphics
|
||||
local colors = self:panelColors("EGG", mon and mon.shiny)
|
||||
@@ -866,18 +899,15 @@ function BoxMenu:drawEggPic(mon)
|
||||
local w = entry.width or 16
|
||||
local h = math.min(entry.height or 16, image:getHeight())
|
||||
if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end
|
||||
local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h,
|
||||
local quad = reusedQuad(self, "egg", 0, 0, w, h,
|
||||
image:getWidth(), image:getHeight())
|
||||
if not ok then return end
|
||||
if not quad then return end
|
||||
local x = PIC_X * 8 + math.floor((7 * 8 - w * 2) / 2)
|
||||
local y = PIC_Y * 8 + math.floor((7 * 8 - h * 2) / 2)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body() G.draw(image, quad, x, y, 0, 2, 2) end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
local shaded, previous = startShade(colors)
|
||||
G.draw(image, quad, x, y, 0, 2, 2)
|
||||
finishShade(shaded, previous)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -889,18 +919,14 @@ function BoxMenu:drawHeldIcon(mon)
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
local iw, ih = image:getDimensions()
|
||||
local quad = reusedQuad(self, "held", row * 8, 0, 8, 8, iw, ih)
|
||||
if not quad then return end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
local shaded, previous = startShade(gfx and gfx.palette)
|
||||
G.draw(image, quad, ICON_X * 8, ICON_Y * 8)
|
||||
finishShade(shaded, previous)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -911,25 +937,17 @@ function BoxMenu:drawBoxArrows()
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local G = love.graphics
|
||||
local quads = {}
|
||||
for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do
|
||||
local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
quads[#quads + 1] = { quad, arrow[2] }
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
local left = reusedQuad(self, "arrowLeft", ARROW_LEFT[1] * 8, 0, 8, 8, iw, ih)
|
||||
if not left then return end
|
||||
local right = reusedQuad(self, "arrowRight", ARROW_RIGHT[1] * 8, 0, 8, 8,
|
||||
iw, ih)
|
||||
if not right then return end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body()
|
||||
for _, entry in ipairs(quads) do
|
||||
G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8)
|
||||
end
|
||||
end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
local shaded, previous = startShade(gfx and gfx.palette)
|
||||
G.draw(image, left, ARROW_LEFT[2] * 8, ARROW_ROW * 8)
|
||||
G.draw(image, right, ARROW_RIGHT[2] * 8, ARROW_ROW * 8)
|
||||
finishShade(shaded, previous)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+67
-26
@@ -13,6 +13,9 @@
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
|
||||
local WHITE = { 255, 255, 255 }
|
||||
local BLACK = { 0, 0, 0 }
|
||||
|
||||
local Chrome = {}
|
||||
|
||||
-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the
|
||||
@@ -32,7 +35,11 @@ function Chrome.paletteFill(px, py, pw, ph, palette)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
if GbcPalette.available() then
|
||||
GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end)
|
||||
-- GbcPalette.with without the closure: set, draw, restore.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(palette)
|
||||
G.rectangle("fill", px, py, pw, ph)
|
||||
G.setShader(previous)
|
||||
else
|
||||
G.rectangle("fill", px, py, pw, ph)
|
||||
end
|
||||
@@ -160,8 +167,14 @@ Chrome.DEFAULT_BOX_PALETTE = {
|
||||
function Chrome.paletteBox(tx, ty, tw, th, palette)
|
||||
palette = palette or Chrome.DEFAULT_BOX_PALETTE
|
||||
if GbcPalette.available() then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
-- GbcPalette.with, restoring the caller's shader even if the box throws.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(palette)
|
||||
local ok, err = pcall(Font.drawBox, tx, ty, tw, th)
|
||||
G.setShader(previous)
|
||||
if not ok then error(err, 0) end
|
||||
else
|
||||
Font.drawBox(tx, ty, tw, th, palette[1])
|
||||
end
|
||||
@@ -278,19 +291,56 @@ function Chrome.paletteGlyphs(palette, invert, raw)
|
||||
return pal, drawGlyph, finish
|
||||
end
|
||||
|
||||
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
|
||||
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
|
||||
if not pal then return Chrome.print(text, tx, ty) end
|
||||
local width = Font.width(text)
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
|
||||
local pen = tx * 8
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
drawGlyph(code, pen, ty * 8)
|
||||
-- printThrough's resolved palette, or nil when it has to take the plain
|
||||
-- degrade: paletteGlyphs' own test and fold, without its two closures.
|
||||
local function glyphPalette(palette, invert, raw)
|
||||
if not (palette and GbcPalette.available()) then return nil end
|
||||
return raw and Chrome.rawPalette(palette, invert)
|
||||
or Chrome.throughPalette(palette, invert)
|
||||
end
|
||||
|
||||
-- The string's glyph codes and its width, off one encode: the width is the
|
||||
-- sum of the same advances the pen walks, which is all Font.width is.
|
||||
local function measure(text)
|
||||
local codes = Font.encode(text)
|
||||
local width = 0
|
||||
for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end
|
||||
return codes, width
|
||||
end
|
||||
|
||||
-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and
|
||||
-- finish inlined (see there for the TTF split, gen1recomp#1642).
|
||||
local function drawThrough(codes, width, x, y, pal)
|
||||
local G = love.graphics
|
||||
local paper = pal[1] or WHITE
|
||||
local ink = pal[4] or BLACK
|
||||
local previous = G.getShader()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", x, y, width, 8)
|
||||
local shaded = false
|
||||
local pen = x
|
||||
for i = 1, #codes do
|
||||
local code = codes[i]
|
||||
if code >= Font.TTF_BASE then
|
||||
if shaded then G.setShader(previous); shaded = false end
|
||||
G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
|
||||
elseif not shaded then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
GbcPalette.useRaw(pal)
|
||||
shaded = true
|
||||
end
|
||||
Font.drawCode(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
finish()
|
||||
if shaded then G.setShader(previous) end
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
|
||||
local pal = glyphPalette(palette, invert, raw)
|
||||
if not pal then return Chrome.print(text, tx, ty) end
|
||||
local codes, width = measure(text)
|
||||
drawThrough(codes, width, tx * 8, ty * 8, pal)
|
||||
return width
|
||||
end
|
||||
|
||||
@@ -317,19 +367,10 @@ function Chrome.printRight(text, txEnd, ty)
|
||||
end
|
||||
|
||||
function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw)
|
||||
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
|
||||
local pal = glyphPalette(palette, invert, raw)
|
||||
if not pal then return Chrome.printRight(text, txEnd, ty) end
|
||||
local width = Font.width(text)
|
||||
local tx = txEnd * 8 - width
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx, ty * 8, width, 8)
|
||||
local pen = tx
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
drawGlyph(code, pen, ty * 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
finish()
|
||||
local codes, width = measure(text)
|
||||
drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal)
|
||||
return width
|
||||
end
|
||||
|
||||
|
||||
@@ -501,11 +501,19 @@ function NamingScreen:drawPanel()
|
||||
if self.iconImage then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local w, h = self.iconImage:getDimensions()
|
||||
local quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h),
|
||||
w, h)
|
||||
-- Cut once per icon image rather than every frame.
|
||||
local quad = self.iconQuad
|
||||
if not quad or self.iconQuadImage ~= self.iconImage then
|
||||
quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h),
|
||||
w, h)
|
||||
self.iconQuad, self.iconQuadImage = quad, self.iconImage
|
||||
end
|
||||
if self.iconColors and GbcPalette.available() then
|
||||
GbcPalette.with(self.iconColors,
|
||||
function() G.draw(self.iconImage, quad, 16, 16) end)
|
||||
-- GbcPalette.with without the closure: set, draw, restore.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(self.iconColors)
|
||||
G.draw(self.iconImage, quad, 16, 16)
|
||||
G.setShader(previous)
|
||||
else
|
||||
G.draw(self.iconImage, quad, 16, 16)
|
||||
end
|
||||
|
||||
+42
-24
@@ -869,6 +869,24 @@ function PartyMenu:iconBob(index)
|
||||
return (math.floor(self.clock / 16) % 2 == 1) and -2 or 0
|
||||
end
|
||||
|
||||
-- One reusable quad per role, re-aimed per draw: an icon draws every frame
|
||||
-- and a fresh Quad per tile would churn the GC.
|
||||
local function reusedQuad(self, slot, x, y, w, h, sw, sh)
|
||||
local quads = self.iconQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
self.iconQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
else
|
||||
quad = love.graphics.newQuad(x, y, w, h, sw, sh)
|
||||
quads[slot] = quad
|
||||
end
|
||||
return quad
|
||||
end
|
||||
|
||||
function PartyMenu:drawIcon(mon, px, py)
|
||||
local image, frame = self:iconFor(mon)
|
||||
if not image then return end
|
||||
@@ -876,7 +894,19 @@ function PartyMenu:drawIcon(mon, px, py)
|
||||
local iw, ih = image:getDimensions()
|
||||
local markerRow = PartyMenu.heldMarkerRow(mon)
|
||||
local marker = markerRow and self:heldMarkerImage() or nil
|
||||
local paint
|
||||
G.setColor(1, 1, 1, 1)
|
||||
-- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355)
|
||||
-- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list,
|
||||
-- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229).
|
||||
local pals = self.palettes and self.palettes.partyMenu
|
||||
local colors = pals and pals[1] or nil
|
||||
local shaded = colors and GbcPalette.available()
|
||||
local previous
|
||||
if shaded then
|
||||
-- GbcPalette.with without the closure: set, draw, restore.
|
||||
previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(colors)
|
||||
end
|
||||
if marker then
|
||||
-- The _WITH_ITEM / _WITH_MAIL OAM sets (data/sprite_anims/oam.asm) are the
|
||||
-- ordinary four quadrants with the `dbsprite -1, 0` entry -- the bottom
|
||||
@@ -886,31 +916,19 @@ function PartyMenu:drawIcon(mon, px, py)
|
||||
-- Both of the frameset's oamframes name the same marker tile, so it must
|
||||
-- not be indexed by `frame`: the icon bobs, the marker does not.
|
||||
local mw, mh = marker:getDimensions()
|
||||
local topLeft = G.newQuad(0, frame * 16, 8, 8, iw, ih)
|
||||
local topRight = G.newQuad(8, frame * 16, 8, 8, iw, ih)
|
||||
local bottomRight = G.newQuad(8, frame * 16 + 8, 8, 8, iw, ih)
|
||||
local held = G.newQuad(0, markerRow * 8, 8, 8, mw, mh)
|
||||
paint = function()
|
||||
G.draw(image, topLeft, px, py)
|
||||
G.draw(image, topRight, px + 8, py)
|
||||
G.draw(image, bottomRight, px + 8, py + 8)
|
||||
G.draw(marker, held, px, py + 8)
|
||||
end
|
||||
G.draw(image, reusedQuad(self, "topLeft", 0, frame * 16, 8, 8, iw, ih),
|
||||
px, py)
|
||||
G.draw(image, reusedQuad(self, "topRight", 8, frame * 16, 8, 8, iw, ih),
|
||||
px + 8, py)
|
||||
G.draw(image, reusedQuad(self, "bottomRight", 8, frame * 16 + 8, 8, 8,
|
||||
iw, ih), px + 8, py + 8)
|
||||
G.draw(marker, reusedQuad(self, "held", 0, markerRow * 8, 8, 8, mw, mh),
|
||||
px, py + 8)
|
||||
else
|
||||
local quad = G.newQuad(0, frame * 16, 16, 16, iw, ih)
|
||||
paint = function() G.draw(image, quad, px, py) end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
-- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355)
|
||||
-- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list,
|
||||
-- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229).
|
||||
local pals = self.palettes and self.palettes.partyMenu
|
||||
local colors = pals and pals[1] or nil
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, paint)
|
||||
else
|
||||
paint()
|
||||
G.draw(image, reusedQuad(self, "whole", 0, frame * 16, 16, 16, iw, ih),
|
||||
px, py)
|
||||
end
|
||||
if shaded then G.setShader(previous) end
|
||||
end
|
||||
|
||||
-- PlacePartyHPBar calls DrawBattleHPBar (home/pokemon.asm) with `ld d, $6`, and
|
||||
|
||||
@@ -1919,6 +1919,10 @@ end
|
||||
local SPACE_TILE = 0x7f
|
||||
|
||||
function Pokegear:tile(id, tx, ty)
|
||||
-- A flat cell draws under the caller's shader, not a batched tile palette.
|
||||
if (id == SPACE_TILE or id == BLANK_TILE) and self.sheet then
|
||||
self.sheet:suspend()
|
||||
end
|
||||
if id == SPACE_TILE then
|
||||
local paper = self:paperColor()
|
||||
local G = love.graphics
|
||||
@@ -1938,8 +1942,7 @@ function Pokegear:tile(id, tx, ty)
|
||||
if self.sheet then self.sheet:draw(id, tx, ty) end
|
||||
end
|
||||
|
||||
function Pokegear:drawTilemap(cells)
|
||||
if not cells then return end
|
||||
local function drawTilemapCells(self, cells)
|
||||
for index = 1, SCREEN_W * SCREEN_H do
|
||||
local tile = cells[index]
|
||||
if tile then
|
||||
@@ -1949,6 +1952,18 @@ function Pokegear:drawTilemap(cells)
|
||||
end
|
||||
end
|
||||
|
||||
-- 360 cells a frame: batched so the palette shader is set once per run of
|
||||
-- same-palette tiles rather than once per tile (TileSheet:begin).
|
||||
function Pokegear:drawTilemap(cells)
|
||||
if not cells then return end
|
||||
local sheet = self.sheet
|
||||
if not sheet then return drawTilemapCells(self, cells) end
|
||||
sheet:begin()
|
||||
local ok, err = pcall(drawTilemapCells, self, cells)
|
||||
sheet:finish()
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- Pokegear_FinishTilemap.
|
||||
-- PokegearSpritesGFX, shared by the mode indicator arrow ($00) and the town
|
||||
-- map cursor ($04). Loaded once and remembered as `false` when there is no
|
||||
|
||||
@@ -1126,6 +1126,25 @@ function SummaryMenu:drawEggPic()
|
||||
self:drawEggIconFallback(colors)
|
||||
end
|
||||
|
||||
-- One reusable quad per role, re-aimed per draw rather than a new Quad each
|
||||
-- frame (which churns the GC). nil only when the backend cannot make one.
|
||||
local function reusedQuad(owner, slot, x, y, w, h, sw, sh)
|
||||
local quads = owner.reusedQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
owner.reusedQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
return quad
|
||||
end
|
||||
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
|
||||
if not ok then return nil end
|
||||
quads[slot] = made
|
||||
return made
|
||||
end
|
||||
|
||||
-- The ICON_EGG sheet is two 16x16 frames stacked into one 16x32 image
|
||||
-- (src/ui/gen2/PartyMenu.lua reads the same entry); the first frame is the
|
||||
-- egg at rest, which is the one the party list shows while nothing is moving.
|
||||
@@ -1141,9 +1160,9 @@ function SummaryMenu:drawEggIconFallback(colors)
|
||||
local w = entry.width or 16
|
||||
local h = math.min(entry.height or 16, image:getHeight())
|
||||
if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end
|
||||
local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h,
|
||||
local quad = reusedQuad(self, "egg", 0, 0, w, h,
|
||||
image:getWidth(), image:getHeight())
|
||||
if not ok then return end
|
||||
if not quad then return end
|
||||
-- Centred in the block at 2x: a 16x16 icon inside 7x7 tiles.
|
||||
local x = math.floor((7 * 8 - w * 2) / 2)
|
||||
local y = math.floor((7 * 8 - h * 2) / 2)
|
||||
|
||||
@@ -77,19 +77,52 @@ function TileSheet:draw(tile, tx, ty)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local colors = self.palette
|
||||
if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end
|
||||
local function body() G.draw(image, quad, tx * 8, ty * 8) end
|
||||
local batch = self.batch
|
||||
if colors and GbcPalette.available() then
|
||||
if self.raw then
|
||||
GbcPalette.withRaw(colors, body)
|
||||
if batch then
|
||||
-- Inside begin/finish: the shader only changes when the palette does.
|
||||
if batch.active ~= colors then
|
||||
if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end
|
||||
batch.active = colors
|
||||
end
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
else
|
||||
GbcPalette.with(colors, body)
|
||||
-- GbcPalette.with / withRaw without the closure: set, draw, restore.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
G.setShader(previous)
|
||||
end
|
||||
else
|
||||
body()
|
||||
if batch then self:suspend() end
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Batch a pass of draws: between begin and finish the palette shader is set
|
||||
-- once per run of tiles sharing a palette rather than once per tile, and the
|
||||
-- caller's shader comes back at finish. Anything drawn between two tiles
|
||||
-- that is not a sheet tile (a flat rect, text) calls suspend first so it
|
||||
-- draws under the caller's shader, as it would have outside the batch.
|
||||
function TileSheet:begin()
|
||||
local G = love.graphics
|
||||
self.batch = { previous = G.getShader and G.getShader() or nil, active = nil }
|
||||
end
|
||||
|
||||
function TileSheet:suspend()
|
||||
local batch = self.batch
|
||||
if batch and batch.active then
|
||||
love.graphics.setShader(batch.previous)
|
||||
batch.active = nil
|
||||
end
|
||||
end
|
||||
|
||||
function TileSheet:finish()
|
||||
self:suspend()
|
||||
self.batch = nil
|
||||
end
|
||||
|
||||
-- A run of consecutive ids, left to right: the shape most of these routines
|
||||
-- use for a header strip or a caption.
|
||||
function TileSheet:run(first, count, tx, ty)
|
||||
|
||||
@@ -831,7 +831,13 @@ function TradeAnimView:drawIcon(record, x, y)
|
||||
end
|
||||
if not cached then return end
|
||||
local G = love.graphics
|
||||
local quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions())
|
||||
-- One quad per icon sheet, cut once rather than every frame.
|
||||
self.iconQuads = self.iconQuads or {}
|
||||
local quad = self.iconQuads[cached]
|
||||
if not quad then
|
||||
quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions())
|
||||
self.iconQuads[cached] = quad
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local colors = Palettes.monColors(self.palettes, record.species,
|
||||
record.shiny)
|
||||
|
||||
+16
-3
@@ -56,16 +56,22 @@ end
|
||||
|
||||
-- ../pokecrystal/home/text.asm:520 _ContTextNoPause
|
||||
function Typer:start(page)
|
||||
local out, total, kept = {}, 0, 0
|
||||
local out, spans, total, kept = {}, {}, 0, 0
|
||||
local scrolled = type(page) == "table" and page.scrolled
|
||||
for i, line in ipairs(linesOf(page)) do
|
||||
local text = self.expand and self.expand(line) or line
|
||||
out[i] = text
|
||||
local n = #spansOf(text)
|
||||
-- Split once here: lines() runs every frame while the page types out.
|
||||
local lineSpans = spansOf(text)
|
||||
spans[i] = lineSpans
|
||||
local n = #lineSpans
|
||||
total = total + n
|
||||
if scrolled and i == 1 then kept = n end
|
||||
end
|
||||
self.page = out
|
||||
self.spans = spans
|
||||
self.spansPage = out
|
||||
self.linesOut, self.linesShown = nil, nil
|
||||
self.total = total
|
||||
self.shown = self.instant and total or kept
|
||||
self.timer = 0
|
||||
@@ -108,9 +114,16 @@ end
|
||||
|
||||
function Typer:lines()
|
||||
if self:done() then return self.page end
|
||||
-- The same partial page is asked for every frame between two letters.
|
||||
if self.linesOut and self.linesShown == self.shown
|
||||
and self.linesPage == self.page then
|
||||
return self.linesOut
|
||||
end
|
||||
local cached = self.spansPage == self.page and self.spans or nil
|
||||
local out, left = {}, self.shown
|
||||
self.linesOut, self.linesShown, self.linesPage = out, self.shown, self.page
|
||||
for i, line in ipairs(self.page) do
|
||||
local spans = spansOf(line)
|
||||
local spans = cached and cached[i] or spansOf(line)
|
||||
if left >= #spans then
|
||||
out[i] = line
|
||||
elseif left <= 0 then
|
||||
|
||||
@@ -438,6 +438,25 @@ function UnownPuzzle:fillCell(cell, tile)
|
||||
end
|
||||
end
|
||||
|
||||
-- One reusable quad per role, re-aimed per draw rather than a new Quad each
|
||||
-- frame (which churns the GC). nil only when the backend cannot make one.
|
||||
local function reusedQuad(owner, slot, x, y, w, h, sw, sh)
|
||||
local quads = owner.reusedQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
owner.reusedQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
return quad
|
||||
end
|
||||
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
|
||||
if not ok then return nil end
|
||||
quads[slot] = made
|
||||
return made
|
||||
end
|
||||
|
||||
-- .Corners is `piece -> corner tile` on a 12-tile-wide sheet; as a quad that is
|
||||
-- just the panel's row and column in the 4x4 picture.
|
||||
function UnownPuzzle:drawPiece(piece, tx, ty)
|
||||
@@ -447,7 +466,7 @@ function UnownPuzzle:drawPiece(piece, tx, ty)
|
||||
local sx = (index % UnownPuzzle.PIECES_WIDE) * size
|
||||
local sy = math.floor(index / UnownPuzzle.PIECES_WIDE) * size
|
||||
local w, h = self.picture:getDimensions()
|
||||
local quad = love.graphics.newQuad(sx, sy, size, size, w, h)
|
||||
local quad = reusedQuad(self, "piece", sx, sy, size, size, w, h)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- The four pictures are drawn out of colours 1 and 2 alone, so binding BG
|
||||
-- palette 0 here is what turns the panels tan and dark brown.
|
||||
@@ -496,7 +515,7 @@ function UnownPuzzle:drawCursor(tx, ty)
|
||||
for _, spec in ipairs(CURSOR_CELLS) do
|
||||
local col, tile, row, flipX, flipY = spec[1], spec[2], spec[3], spec[4],
|
||||
spec[5]
|
||||
local quad = love.graphics.newQuad(tile * 8, 0, 8, 8, w, h)
|
||||
local quad = reusedQuad(self, "cursor", tile * 8, 0, 8, 8, w, h)
|
||||
local sx, sy = flipX and -1 or 1, flipY and -1 or 1
|
||||
local ox = (tx + col) * 8 + (flipX and 8 or 0)
|
||||
local oy = (ty + row) * 8 + (flipY and 8 or 0)
|
||||
|
||||
+396
-313
@@ -63,6 +63,9 @@ OverworldState.withOverrideMirror = withOverrideMirror
|
||||
|
||||
local Game -- set on enter (avoids circular require at load time)
|
||||
|
||||
-- handleInput's d-pad poll order (a constant: it runs every frame)
|
||||
local INPUT_DIRS = { "up", "down", "left", "right" }
|
||||
|
||||
local mapScripts -- registry of hand-ported map scripts
|
||||
|
||||
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
||||
@@ -535,7 +538,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
elseif fromMapId ~= mapId then
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
local npc = self.npcPool[mapId .. "_obj_" .. obj.index]
|
||||
if npc then npc.pendingSpawnReset = true end
|
||||
if npc then
|
||||
npc.pendingSpawnReset = true
|
||||
-- lets applyPendingSpawnResets skip its whole-pool scan while
|
||||
-- nothing is armed (it runs every frame)
|
||||
self.spawnResetsArmed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
self.npcs = {}
|
||||
@@ -794,6 +802,9 @@ end
|
||||
-- the deferred half of the seam re-seed armed in setMap (#1755): the cart's
|
||||
-- connected map has no sprites to be seen snapping -- home/overworld.asm:2133
|
||||
function OverworldState:applyPendingSpawnResets()
|
||||
-- setMap is the only place that arms a re-seed; nothing armed since the
|
||||
-- last scan came up empty -> no pool walk this frame
|
||||
if not self.spawnResetsArmed then return end
|
||||
local pool = self.npcPool
|
||||
if not (pool and self.camera) then return end
|
||||
local due
|
||||
@@ -803,7 +814,10 @@ function OverworldState:applyPendingSpawnResets()
|
||||
due[npc] = true
|
||||
end
|
||||
end
|
||||
if not due then return end
|
||||
if not due then
|
||||
self.spawnResetsArmed = nil
|
||||
return
|
||||
end
|
||||
local cam = self.camera
|
||||
local vw, vh = Game.renderer:worldViewSize()
|
||||
local function onCamera(px, py)
|
||||
@@ -1545,7 +1559,6 @@ function OverworldState:update(dt)
|
||||
or (self.hopLand or 0) > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive or self.spinArrive
|
||||
or self.holeFall or self.holeArrive
|
||||
or self.holeFall or self.holeArrive
|
||||
or self.cutAnim
|
||||
or (self.dustAnim and self.dustAnim.boulder)
|
||||
@@ -1719,7 +1732,7 @@ function OverworldState:handleInput()
|
||||
return
|
||||
end
|
||||
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
for _, dir in ipairs(INPUT_DIRS) do
|
||||
if input:isDown(dir) then
|
||||
if not self.player.moving and self.player.facing == dir then
|
||||
if self:checkEdgeExit(dir) then return end
|
||||
@@ -1785,13 +1798,25 @@ function OverworldState:handleInput()
|
||||
end
|
||||
|
||||
-- JoypadOverworld both make -- home/overworld.asm:382
|
||||
-- slopeMaps list -> { n = #list, ids = { [mapId] = true } }: update() asks
|
||||
-- every frame, so the list is indexed once instead of scanned per call.
|
||||
-- Keyed weakly on the list itself (a data reload brings a new one) and
|
||||
-- rebuilt if its length changes.
|
||||
local slopeSets = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function OverworldState:onSlopeMap(mapId)
|
||||
local fm = Game.data.field.forcedMovement
|
||||
mapId = mapId or (self.map and self.map.id)
|
||||
for _, m in ipairs((fm and fm.slopeMaps) or {}) do
|
||||
if m == mapId then return true end
|
||||
local list = fm and fm.slopeMaps
|
||||
if not list or mapId == nil then return false end
|
||||
local entry = slopeSets[list]
|
||||
if not entry or entry.n ~= #list then
|
||||
local ids = {}
|
||||
for _, m in ipairs(list) do ids[m] = true end
|
||||
entry = { n = #list, ids = ids }
|
||||
slopeSets[list] = entry
|
||||
end
|
||||
return false
|
||||
return entry.ids[mapId] == true
|
||||
end
|
||||
|
||||
-- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder):
|
||||
@@ -5829,7 +5854,7 @@ end
|
||||
-- sprite through the tile's white gaps) over the plain one (sprites, FX
|
||||
-- overlays). drawFn issues the actual draws in flat world-canvas coordinates;
|
||||
-- the transform just slides them from the flat foot onto the projected anchor.
|
||||
function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn, ...)
|
||||
local sx, sy = Tilt.groundPoint(fx, fy, vw, vh)
|
||||
local shader = colors and (keyed and PaletteFX.keyedShader()
|
||||
or PaletteFX.shader()) or nil
|
||||
@@ -5839,11 +5864,321 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
end
|
||||
love.graphics.push()
|
||||
love.graphics.translate(sx - fx, sy - fy)
|
||||
drawFn()
|
||||
drawFn(...) -- extra billboard args (the FX bodies take self, cam)
|
||||
love.graphics.pop()
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
|
||||
-- === shared FX draw bodies ==========================================
|
||||
-- Each draws at flat world-canvas offsets; the tilt path wraps the
|
||||
-- standing ones in an upright billboard, the flat path calls them inline
|
||||
-- in their historical order. (Bodies are byte-identical to the pre-tilt
|
||||
-- inline code, so the flat draw sequence is unchanged.) Module-level and
|
||||
-- handed (self, cam) so drawWorld does not build six closures per frame;
|
||||
-- the pipeline path wraps them for its no-argument ctx.fx contract.
|
||||
|
||||
-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor
|
||||
-- tile over the machine's screen and one ball per healed mon in two
|
||||
-- mirrored columns, all blinking during the jingle flash. The GB
|
||||
-- draws it at fixed screen coords with the player's cell BG-aligned
|
||||
-- at (64,64); anchoring those coords to where the player stood keeps
|
||||
-- the overlay on the machine at any zoom.
|
||||
local function fxHeal(self, cam)
|
||||
if not self.healAnim then return end
|
||||
local ha = self.healAnim
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
|
||||
local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path)
|
||||
self.healMachineImg = ok and img or false
|
||||
end
|
||||
local img = self.healMachineImg
|
||||
if img then
|
||||
if not self.healMachineQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.healMachineQuads = {
|
||||
love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c)
|
||||
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
|
||||
}
|
||||
end
|
||||
local shader, redrawColors = healMachineShader(ha.visible)
|
||||
local mark = redrawColors and PaletteFX.spriteRedrawPassActive()
|
||||
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
||||
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
|
||||
-- the balls a pixel off the machine tiles
|
||||
local ox = ha.px - 64 - math.floor(cam.x)
|
||||
local oy = ha.py - 64 - math.floor(cam.y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1],
|
||||
ox + 44, oy + 20, 1, redrawColors)
|
||||
end
|
||||
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
||||
local b = HEAL_BALL_XY[i]
|
||||
if b[3] then -- right column: OAM_XFLIP
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], 0, -1, 1)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], -1, redrawColors)
|
||||
end
|
||||
else
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2])
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2], 1, redrawColors)
|
||||
end
|
||||
end
|
||||
end
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
end
|
||||
|
||||
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
|
||||
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
|
||||
local function fxDust(self, cam)
|
||||
if not self.dustAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local smoke = fxDef and fxDef.smoke
|
||||
if smoke then
|
||||
if self.smokeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, smoke.path)
|
||||
self.smokeImg = ok and img or false
|
||||
end
|
||||
if self.smokeImg then
|
||||
local da = self.dustAnim
|
||||
local dx = da.x * 16 + (da.ox or 0) - cam.x
|
||||
local dy = da.y * 16 + (da.oy or 0) - cam.y
|
||||
local flicker = math.floor(da.frames / 4) % 2 == 0
|
||||
if da.boulder then flicker = not da.faded end
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
for i = 0, 1 do
|
||||
for j = 0, 1 do
|
||||
love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the cut tree splitting apart (AnimCut): top half slides right,
|
||||
-- bottom half slides left, 1px per frame, flickering as they go
|
||||
local function fxCutTree(self, cam)
|
||||
if not self.cutAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local tree = fxDef and fxDef.cutTree
|
||||
if not tree then return end
|
||||
if self.cutTreeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, tree.path)
|
||||
self.cutTreeImg = ok and img or false
|
||||
end
|
||||
local img = self.cutTreeImg
|
||||
if not img then return end
|
||||
if not self.cutTreeQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.cutTreeQuads = {
|
||||
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
|
||||
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
|
||||
}
|
||||
end
|
||||
local ca = self.cutAnim
|
||||
local off = (ca.total or 8) - ca.frames
|
||||
local dx = ca.x * 16 - cam.x
|
||||
local dy = ca.y * 16 - cam.y
|
||||
local flicker = ca.frames % 2 == 0
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
|
||||
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- the "!" bubble above a trainer who spotted the player
|
||||
local function fxEmote(self, cam)
|
||||
if not (self.emote and self.emote.npc) then return end
|
||||
-- bubble = false is a silent hold (a Pikachu emotion that plays a
|
||||
-- cry with no bubble still pauses the world for its beat)
|
||||
if self.emote.bubble == false then return end
|
||||
local npc = self.emote.npc
|
||||
-- engine/overworld/emotion_bubbles.asm:41
|
||||
local ex = npc.px - cam.x
|
||||
local ey = npc.py - cam.y - 20
|
||||
local bubble = Game.data.field.emotionBubbles
|
||||
local drawn = false
|
||||
if bubble and bubble.path then
|
||||
local ok, img = pcall(function()
|
||||
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
||||
return self.emoteImg
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
-- picks question/happy crops instead
|
||||
local bi = self.emote.bubble or 1
|
||||
local rect = bubble.bubbles and bubble.bubbles[bi]
|
||||
if ok and img and rect then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- one Quad per bubble crop, cached: this draws every frame the "!"
|
||||
-- (or the emote-command crops) is up, so a fresh Quad here churned
|
||||
-- the GC. The bubble set is small and fixed, so the cache is bounded.
|
||||
self.emoteQuads = self.emoteQuads or {}
|
||||
local q = self.emoteQuads[bi]
|
||||
if not q then
|
||||
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
||||
img:getDimensions())
|
||||
self.emoteQuads[bi] = q
|
||||
end
|
||||
love.graphics.draw(img, q, ex, ey)
|
||||
-- engine/overworld/emotion_bubbles.asm:18
|
||||
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
PaletteFX.markSpriteRedraw(img, q, ex, ey, 1)
|
||||
end
|
||||
drawn = true
|
||||
end
|
||||
end
|
||||
if not drawn then
|
||||
local Font = require("src.render.Font")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", ex, ey, 10, 12)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12)
|
||||
Font.draw("!", ex + 1, ey + 2)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- the FLY bird sweeping off with the player
|
||||
local function fxBird(self, cam)
|
||||
local anim = self.flyAnim or self.flyArrive
|
||||
if not anim then return end
|
||||
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
|
||||
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
self.birdSprite = SR.new(Game.data.sprites[birdId])
|
||||
end
|
||||
if not self.birdSprite then return end
|
||||
-- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
|
||||
-- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
|
||||
-- the flight rides any screen position, and it faces its travel
|
||||
-- direction (rightward travel flips the left-drawn sheet)
|
||||
local phase = anim.phase or "arrive"
|
||||
if phase == "hold" then return end -- parked off screen between paths
|
||||
local path, anchor, facing
|
||||
if phase == "path1" then
|
||||
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
|
||||
elseif phase == "path2" then
|
||||
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
|
||||
elseif phase == "arrive" then
|
||||
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
|
||||
end
|
||||
local step = math.floor(anim.t / 3)
|
||||
local sx, sy
|
||||
if path then
|
||||
local pair = path[math.min(#path, step + 1)]
|
||||
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
|
||||
else
|
||||
sx, sy = 0, 0 -- the in-place flap sits on the player
|
||||
facing = "right"
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
|
||||
cam.x, cam.y, facing, step % 2, false)
|
||||
end
|
||||
|
||||
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
|
||||
local function fxRod(self, cam)
|
||||
if not self.fishing then return end
|
||||
-- engine/overworld/player_animations.asm:424
|
||||
if self.fishing.hideRod then return end
|
||||
local fx = Game.data.field.overworldFx
|
||||
local rod = fx and fx.fishingRod
|
||||
if rod then
|
||||
if self.rodImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, rod.path)
|
||||
self.rodImg = ok and img or false
|
||||
end
|
||||
if self.rodImg then
|
||||
local p = self.player
|
||||
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
|
||||
if not self.rodQuads then
|
||||
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
|
||||
local iw, ih = self.rodImg:getDimensions()
|
||||
self.rodQuads = {}
|
||||
for i = 0, math.floor(ih / 8) - 1 do
|
||||
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
|
||||
end
|
||||
end
|
||||
local quad = self.rodQuads[oam.tile]
|
||||
-- Place the rod against the active sprite's anchored top-left. The
|
||||
-- vanilla result is still (px-cam, py-cam-4), while custom larger
|
||||
-- sheets keep the rod attached to their feet.
|
||||
-- Fishing always uses the on-foot player sheet; read its fields
|
||||
-- directly so this FX pass does not advance pose-side animation.
|
||||
local sprite, px, py = p.sprite, p.px, p.py
|
||||
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
|
||||
local rx = sx + oam.dx
|
||||
-- engine/overworld/player_animations.asm:453
|
||||
local ry = sy + oam.dy + (p.fishShakeDy or 0)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if quad and oam.flip then
|
||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||
elseif quad then
|
||||
love.graphics.draw(self.rodImg, quad, rx, ry)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- drawWorld's y-sort orders, hoisted so a frame builds no comparators
|
||||
local function ghostDrawOrder(a, b)
|
||||
return a.npc.py + a.oy < b.npc.py + b.oy
|
||||
end
|
||||
|
||||
local function entityDrawOrder(a, b)
|
||||
if a.py ~= b.py then return a.py < b.py end
|
||||
-- a fresh warp spawn parks the follower on the player's own cell
|
||||
-- until it trails out; the tie must draw it under him, never on
|
||||
-- top (#863)
|
||||
return a.pikachuFollower == true and b.pikachuFollower ~= true
|
||||
end
|
||||
|
||||
-- Tall-grass feet overdraw queue for the flat path: each distinct grass
|
||||
-- cell an entity stands on or steps into, in first-queued order. A
|
||||
-- scratch pair of coordinate arrays reused every frame (the queue holds a
|
||||
-- few cells, so the duplicate check is a short linear scan) instead of a
|
||||
-- "cx:cy" string key and a { cx, cy } table per cell per frame.
|
||||
local function queueGrass(self, q, cx, cy)
|
||||
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
|
||||
return
|
||||
end
|
||||
local n = q.n
|
||||
local xs, ys = q.x, q.y
|
||||
for i = 1, n do
|
||||
if xs[i] == cx and ys[i] == cy then return end
|
||||
end
|
||||
n = n + 1
|
||||
xs[n], ys[n] = cx, cy
|
||||
q.n = n
|
||||
end
|
||||
|
||||
-- Is a neighbour map's ghost NPC wholly outside the vw x vh view? The
|
||||
-- rect is the sprite's frame at its anchored origin, padded by a tile on
|
||||
-- every side (hop shadows, multi-part cells), so only NPCs that could not
|
||||
-- put a pixel on screen are skipped.
|
||||
local GHOST_CULL_PAD = 16
|
||||
local function ghostOffscreen(g, camX, camY, vw, vh)
|
||||
local npc = g.npc
|
||||
local sprite = npc and npc.sprite
|
||||
if not (sprite and sprite.getScreenOrigin and vw and vh) then
|
||||
return false
|
||||
end
|
||||
local sx, sy = sprite:getScreenOrigin(npc.px, npc.py, camX, camY)
|
||||
local fw = sprite.frameWidth or 16
|
||||
local fh = sprite.frameHeight or 16
|
||||
local pad = GHOST_CULL_PAD
|
||||
return sx + fw + pad < 0 or sy + fh + pad < 0
|
||||
or sx - pad > vw or sy - pad > vh
|
||||
end
|
||||
|
||||
function OverworldState:drawWorld()
|
||||
-- Dark-map BG shade shift, armed for the whole frame before anything draws.
|
||||
-- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the
|
||||
@@ -5908,11 +6243,12 @@ function OverworldState:drawWorld()
|
||||
-- advance the water/flower tile animation (runs under dialogs too).
|
||||
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
|
||||
-- does not speed or slow the cycle (issue #4).
|
||||
require("src.render.TileRenderer").tick()
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick()
|
||||
-- let the renderer know whether a spinner puzzle is currently sliding
|
||||
-- the player, so it can flicker the arrow tiles between the blur and
|
||||
-- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles)
|
||||
require("src.render.TileRenderer").setSpinning(self.spinnerSliding or false)
|
||||
TileRenderer.setSpinning(self.spinnerSliding or false)
|
||||
local cam = self.camera
|
||||
-- ShakeElevator's oscillation (engine/overworld/elevator.asm) writes
|
||||
-- hSCY, which scrolls the BG layer only -- tiles bounce while OAM
|
||||
@@ -5957,272 +6293,8 @@ function OverworldState:drawWorld()
|
||||
local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil
|
||||
|
||||
-- ghost NPCs on neighbor maps, y-sorted among themselves
|
||||
table.sort(self.ghosts,
|
||||
function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end)
|
||||
table.sort(self.entities, function(a, b)
|
||||
if a.py ~= b.py then return a.py < b.py end
|
||||
-- a fresh warp spawn parks the follower on the player's own cell
|
||||
-- until it trails out; the tie must draw it under him, never on
|
||||
-- top (#863)
|
||||
return a.pikachuFollower == true and b.pikachuFollower ~= true
|
||||
end)
|
||||
|
||||
-- === shared FX draw bodies ==========================================
|
||||
-- Each draws at flat world-canvas offsets; the tilt path wraps the
|
||||
-- standing ones in an upright billboard, the flat path calls them inline
|
||||
-- in their historical order. (Bodies are byte-identical to the pre-tilt
|
||||
-- inline code, so the flat draw sequence is unchanged.)
|
||||
|
||||
-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor
|
||||
-- tile over the machine's screen and one ball per healed mon in two
|
||||
-- mirrored columns, all blinking during the jingle flash. The GB
|
||||
-- draws it at fixed screen coords with the player's cell BG-aligned
|
||||
-- at (64,64); anchoring those coords to where the player stood keeps
|
||||
-- the overlay on the machine at any zoom.
|
||||
local function fxHeal()
|
||||
if not self.healAnim then return end
|
||||
local ha = self.healAnim
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
|
||||
local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path)
|
||||
self.healMachineImg = ok and img or false
|
||||
end
|
||||
local img = self.healMachineImg
|
||||
if img then
|
||||
if not self.healMachineQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.healMachineQuads = {
|
||||
love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c)
|
||||
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
|
||||
}
|
||||
end
|
||||
local shader, redrawColors = healMachineShader(ha.visible)
|
||||
local mark = redrawColors and PaletteFX.spriteRedrawPassActive()
|
||||
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
||||
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
|
||||
-- the balls a pixel off the machine tiles
|
||||
local ox = ha.px - 64 - math.floor(cam.x)
|
||||
local oy = ha.py - 64 - math.floor(cam.y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1],
|
||||
ox + 44, oy + 20, 1, redrawColors)
|
||||
end
|
||||
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
||||
local b = HEAL_BALL_XY[i]
|
||||
if b[3] then -- right column: OAM_XFLIP
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], 0, -1, 1)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], -1, redrawColors)
|
||||
end
|
||||
else
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2])
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2], 1, redrawColors)
|
||||
end
|
||||
end
|
||||
end
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
end
|
||||
|
||||
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
|
||||
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
|
||||
local function fxDust()
|
||||
if not self.dustAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local smoke = fxDef and fxDef.smoke
|
||||
if smoke then
|
||||
if self.smokeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, smoke.path)
|
||||
self.smokeImg = ok and img or false
|
||||
end
|
||||
if self.smokeImg then
|
||||
local da = self.dustAnim
|
||||
local dx = da.x * 16 + (da.ox or 0) - cam.x
|
||||
local dy = da.y * 16 + (da.oy or 0) - cam.y
|
||||
local flicker = math.floor(da.frames / 4) % 2 == 0
|
||||
if da.boulder then flicker = not da.faded end
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
for i = 0, 1 do
|
||||
for j = 0, 1 do
|
||||
love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the cut tree splitting apart (AnimCut): top half slides right,
|
||||
-- bottom half slides left, 1px per frame, flickering as they go
|
||||
local function fxCutTree()
|
||||
if not self.cutAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local tree = fxDef and fxDef.cutTree
|
||||
if not tree then return end
|
||||
if self.cutTreeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, tree.path)
|
||||
self.cutTreeImg = ok and img or false
|
||||
end
|
||||
local img = self.cutTreeImg
|
||||
if not img then return end
|
||||
if not self.cutTreeQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.cutTreeQuads = {
|
||||
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
|
||||
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
|
||||
}
|
||||
end
|
||||
local ca = self.cutAnim
|
||||
local off = (ca.total or 8) - ca.frames
|
||||
local dx = ca.x * 16 - cam.x
|
||||
local dy = ca.y * 16 - cam.y
|
||||
local flicker = ca.frames % 2 == 0
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
|
||||
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- the "!" bubble above a trainer who spotted the player
|
||||
local function fxEmote()
|
||||
if not (self.emote and self.emote.npc) then return end
|
||||
-- bubble = false is a silent hold (a Pikachu emotion that plays a
|
||||
-- cry with no bubble still pauses the world for its beat)
|
||||
if self.emote.bubble == false then return end
|
||||
local npc = self.emote.npc
|
||||
-- engine/overworld/emotion_bubbles.asm:41
|
||||
local ex = npc.px - cam.x
|
||||
local ey = npc.py - cam.y - 20
|
||||
local bubble = Game.data.field.emotionBubbles
|
||||
local drawn = false
|
||||
if bubble and bubble.path then
|
||||
local ok, img = pcall(function()
|
||||
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
||||
return self.emoteImg
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
-- picks question/happy crops instead
|
||||
local bi = self.emote.bubble or 1
|
||||
local rect = bubble.bubbles and bubble.bubbles[bi]
|
||||
if ok and img and rect then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- one Quad per bubble crop, cached: this draws every frame the "!"
|
||||
-- (or the emote-command crops) is up, so a fresh Quad here churned
|
||||
-- the GC. The bubble set is small and fixed, so the cache is bounded.
|
||||
self.emoteQuads = self.emoteQuads or {}
|
||||
local q = self.emoteQuads[bi]
|
||||
if not q then
|
||||
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
||||
img:getDimensions())
|
||||
self.emoteQuads[bi] = q
|
||||
end
|
||||
love.graphics.draw(img, q, ex, ey)
|
||||
-- engine/overworld/emotion_bubbles.asm:18
|
||||
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
PaletteFX.markSpriteRedraw(img, q, ex, ey, 1)
|
||||
end
|
||||
drawn = true
|
||||
end
|
||||
end
|
||||
if not drawn then
|
||||
local Font = require("src.render.Font")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", ex, ey, 10, 12)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12)
|
||||
Font.draw("!", ex + 1, ey + 2)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- the FLY bird sweeping off with the player
|
||||
local function fxBird()
|
||||
local anim = self.flyAnim or self.flyArrive
|
||||
if not anim then return end
|
||||
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
|
||||
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
self.birdSprite = SR.new(Game.data.sprites[birdId])
|
||||
end
|
||||
if not self.birdSprite then return end
|
||||
-- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
|
||||
-- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
|
||||
-- the flight rides any screen position, and it faces its travel
|
||||
-- direction (rightward travel flips the left-drawn sheet)
|
||||
local phase = anim.phase or "arrive"
|
||||
if phase == "hold" then return end -- parked off screen between paths
|
||||
local path, anchor, facing
|
||||
if phase == "path1" then
|
||||
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
|
||||
elseif phase == "path2" then
|
||||
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
|
||||
elseif phase == "arrive" then
|
||||
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
|
||||
end
|
||||
local step = math.floor(anim.t / 3)
|
||||
local sx, sy
|
||||
if path then
|
||||
local pair = path[math.min(#path, step + 1)]
|
||||
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
|
||||
else
|
||||
sx, sy = 0, 0 -- the in-place flap sits on the player
|
||||
facing = "right"
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
|
||||
cam.x, cam.y, facing, step % 2, false)
|
||||
end
|
||||
|
||||
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
|
||||
local function fxRod()
|
||||
if not self.fishing then return end
|
||||
-- engine/overworld/player_animations.asm:424
|
||||
if self.fishing.hideRod then return end
|
||||
local fx = Game.data.field.overworldFx
|
||||
local rod = fx and fx.fishingRod
|
||||
if rod then
|
||||
if self.rodImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, rod.path)
|
||||
self.rodImg = ok and img or false
|
||||
end
|
||||
if self.rodImg then
|
||||
local p = self.player
|
||||
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
|
||||
if not self.rodQuads then
|
||||
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
|
||||
local iw, ih = self.rodImg:getDimensions()
|
||||
self.rodQuads = {}
|
||||
for i = 0, math.floor(ih / 8) - 1 do
|
||||
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
|
||||
end
|
||||
end
|
||||
local quad = self.rodQuads[oam.tile]
|
||||
-- Place the rod against the active sprite's anchored top-left. The
|
||||
-- vanilla result is still (px-cam, py-cam-4), while custom larger
|
||||
-- sheets keep the rod attached to their feet.
|
||||
-- Fishing always uses the on-foot player sheet; read its fields
|
||||
-- directly so this FX pass does not advance pose-side animation.
|
||||
local sprite, px, py = p.sprite, p.px, p.py
|
||||
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
|
||||
local rx = sx + oam.dx
|
||||
-- engine/overworld/player_animations.asm:453
|
||||
local ry = sy + oam.dy + (p.fishShakeDy or 0)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if quad and oam.flip then
|
||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||
elseif quad then
|
||||
love.graphics.draw(self.rodImg, quad, rx, ry)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(self.ghosts, ghostDrawOrder)
|
||||
table.sort(self.entities, entityDrawOrder)
|
||||
|
||||
if pipelineId then
|
||||
-- === PIPELINE PATH: a mod owns the world pass. ======================
|
||||
@@ -6234,6 +6306,13 @@ function OverworldState:drawWorld()
|
||||
-- copy of every effect: the closures above are the ones that run.
|
||||
local _, _, pw, ph = Game.renderer:playfieldRect()
|
||||
local pscale = Zoom.scale(Game.renderer:fitScale())
|
||||
-- the mod-facing ctx.fx draw bodies take no arguments
|
||||
local fxHealFn = function() return fxHeal(self, cam) end
|
||||
local fxDustFn = function() return fxDust(self, cam) end
|
||||
local fxCutTreeFn = function() return fxCutTree(self, cam) end
|
||||
local fxEmoteFn = function() return fxEmote(self, cam) end
|
||||
local fxBirdFn = function() return fxBird(self, cam) end
|
||||
local fxRodFn = function() return fxRod(self, cam) end
|
||||
local ctx = {
|
||||
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
||||
width = pw, height = ph, scale = pscale,
|
||||
@@ -6247,8 +6326,8 @@ function OverworldState:drawWorld()
|
||||
if PaletteFX.usesGbcPack() then return nil end
|
||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||
end,
|
||||
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
||||
emote = fxEmote, bird = fxBird, rod = fxRod },
|
||||
fx = { heal = fxHealFn, dust = fxDustFn, cutTree = fxCutTreeFn,
|
||||
emote = fxEmoteFn, bird = fxBirdFn, rod = fxRodFn },
|
||||
}
|
||||
-- Draw every active field FX into the finished scene. `project(wx, wy)`
|
||||
-- maps a world point to canvas pixels (nil when it is behind the
|
||||
@@ -6283,23 +6362,23 @@ function OverworldState:drawWorld()
|
||||
-- ground-hugging effects sit on the cell they belong to
|
||||
if self.dustAnim then
|
||||
local da = self.dustAnim
|
||||
at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
|
||||
at(fxDustFn, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
|
||||
end
|
||||
if self.cutAnim then
|
||||
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
|
||||
at(fxCutTreeFn, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
|
||||
end
|
||||
if self.healAnim then
|
||||
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
|
||||
at(fxHealFn, self.healAnim.px + 8, self.healAnim.py + 16)
|
||||
end
|
||||
-- standing effects anchor at the foot of whoever they belong to
|
||||
if self.emote and self.emote.npc then
|
||||
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
|
||||
at(fxEmoteFn, self.emote.npc.px + 8, self.emote.npc.py + 16)
|
||||
end
|
||||
if self.flyAnim then
|
||||
at(fxBird, self.player.px + 8, self.player.py + 16)
|
||||
at(fxBirdFn, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
if self.fishing then
|
||||
at(fxRod, self.player.px + 8, self.player.py + 16)
|
||||
at(fxRodFn, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -6337,44 +6416,45 @@ function OverworldState:drawWorld()
|
||||
local grassColors = PaletteFX.usesSpriteObp()
|
||||
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
|
||||
local boulderDust = self.dustAnim and self.dustAnim.boulder
|
||||
local grassCells, grassSeen = {}, {}
|
||||
local function queueGrass(cx, cy)
|
||||
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
|
||||
return
|
||||
end
|
||||
local key = tostring(cx) .. ":" .. tostring(cy)
|
||||
if grassSeen[key] then return end
|
||||
grassSeen[key] = true
|
||||
grassCells[#grassCells + 1] = { cx, cy }
|
||||
local grass = self.grassScratch
|
||||
if not grass then
|
||||
grass = { n = 0, x = {}, y = {} }
|
||||
self.grassScratch = grass
|
||||
end
|
||||
if boulderDust then fxDust() end
|
||||
grass.n = 0
|
||||
if boulderDust then fxDust(self, cam) end
|
||||
for _, g in ipairs(self.ghosts) do
|
||||
if self.battleOamKeep == nil then
|
||||
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
|
||||
local gx, gy = cam.x - g.ox, cam.y - g.oy
|
||||
if not ghostOffscreen(g, gx, gy, vw, vh) then
|
||||
g.npc:draw(gx, gy)
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, e in ipairs(self.entities) do
|
||||
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
|
||||
and e == self.player) and not self:oamCulled(e) then
|
||||
e:draw(cam.x, cam.y)
|
||||
queueGrass(e.cellX, e.cellY)
|
||||
queueGrass(e.targetX, e.targetY)
|
||||
queueGrass(self, grass, e.cellX, e.cellY)
|
||||
queueGrass(self, grass, e.targetX, e.targetY)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for _, cell in ipairs(grassCells) do
|
||||
self.map.renderer:drawCellBottom(cell[1], cell[2], cam.x, bgY)
|
||||
local gxs, gys = grass.x, grass.y
|
||||
for i = 1, grass.n do
|
||||
local cx, cy = gxs[i], gys[i]
|
||||
self.map.renderer:drawCellBottom(cx, cy, cam.x, bgY)
|
||||
if grassColors then
|
||||
self.map.renderer:markCellBottomRedraw(cell[1], cell[2],
|
||||
self.map.renderer:markCellBottomRedraw(cx, cy,
|
||||
cam.x, bgY, grassColors)
|
||||
end
|
||||
end
|
||||
fxHeal()
|
||||
if not boulderDust then fxDust() end
|
||||
fxCutTree()
|
||||
fxEmote()
|
||||
fxBird()
|
||||
fxRod()
|
||||
fxHeal(self, cam)
|
||||
if not boulderDust then fxDust(self, cam) end
|
||||
fxCutTree(self, cam)
|
||||
fxEmote(self, cam)
|
||||
fxBird(self, cam)
|
||||
fxRod(self, cam)
|
||||
else
|
||||
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
|
||||
-- standing things billboard upright over it in a separate pass. ======
|
||||
@@ -6384,9 +6464,9 @@ function OverworldState:drawWorld()
|
||||
-- draws them last, over the sprites, in the same canvas; here the two
|
||||
-- layers are separate and composited ground-under-upright, so drawing
|
||||
-- them now into the still-active ground canvas is order-equivalent.
|
||||
fxHeal()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
fxHeal(self, cam)
|
||||
fxDust(self, cam)
|
||||
fxCutTree(self, cam)
|
||||
|
||||
Game.renderer:beginUprightPass()
|
||||
|
||||
@@ -6451,17 +6531,20 @@ function OverworldState:drawWorld()
|
||||
if self.emote and self.emote.npc then
|
||||
local fx = self.emote.npc.px - cam.x + 8
|
||||
local fy = self.emote.npc.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote)
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote,
|
||||
self, cam)
|
||||
end
|
||||
if self.flyAnim or self.flyArrive then
|
||||
local fx = self.player.px - cam.x + 8
|
||||
local fy = self.player.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird)
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird,
|
||||
self, cam)
|
||||
end
|
||||
if self.fishing then
|
||||
local fx = self.player.px - cam.x + 8
|
||||
local fy = self.player.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod,
|
||||
self, cam)
|
||||
end
|
||||
|
||||
Game.renderer:endUprightPass()
|
||||
|
||||
+23
-1
@@ -275,6 +275,28 @@ function Player:walkPhase()
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
-- The surf bob is sampled by pose() (draw code) but must run at the fixed
|
||||
-- logic rate: it used to tick once per pose() call, so a 144Hz display
|
||||
-- bobbed 2.4x too fast and the battle-transition wipe, which draws the
|
||||
-- player twice a frame, doubled it. It now advances by the number of
|
||||
-- Game:step logic steps since the last pose(), i.e. exactly once per step at
|
||||
-- 60Hz and never twice for one step. Without a running Game (headless
|
||||
-- callers) there is no step clock and each call advances once, as before.
|
||||
function Player:advanceBob()
|
||||
local Game = package.loaded["src.core.Game"]
|
||||
local step = type(Game) == "table" and Game.logicStep or nil
|
||||
local ticks = 1
|
||||
if step then
|
||||
local last = self.bobStep
|
||||
ticks = last and step - last or 1
|
||||
self.bobStep = step
|
||||
end
|
||||
if ticks > 0 then
|
||||
self.bobTimer = ((self.bobTimer or 0) + ticks) % 32
|
||||
end
|
||||
return self.bobTimer or 0
|
||||
end
|
||||
|
||||
local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
-- constants/sprite_data_constants.asm:3
|
||||
local IMAGE_FACING = { [0] = "down", [1] = "up", [2] = "left", [3] = "right" }
|
||||
@@ -298,7 +320,7 @@ function Player:pose()
|
||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
hopping = true
|
||||
elseif self.surfing then
|
||||
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
|
||||
self:advanceBob()
|
||||
py = py + (self.bobTimer < 16 and 0 or 1)
|
||||
end
|
||||
-- engine/overworld/player_animations.asm:453
|
||||
|
||||
@@ -259,17 +259,25 @@ BorderFill.CROSSFADE_FRAMES = 20
|
||||
-- The bookkeeping half, love-free so it can be checked without a canvas.
|
||||
-- Returns the image to draw underneath (nil on the first fill and once the
|
||||
-- dissolve is over) and the alpha the incoming image draws at.
|
||||
function BorderFill.crossfade(owner, image, key)
|
||||
--
|
||||
-- `advance` is how many 60Hz frames the dissolve moves on by (default 1, one
|
||||
-- per call). An owner that keeps `borderTicks` (World counts its logic steps
|
||||
-- there) has BorderFill.draw pass those instead, so the fade runs on the
|
||||
-- logic clock rather than once per render frame.
|
||||
function BorderFill.crossfade(owner, image, key, advance)
|
||||
if not owner or key == nil then return nil, 1 end
|
||||
if owner.borderKey ~= key then
|
||||
-- Nothing to dissolve from on the first map of a session.
|
||||
owner.borderFrom = (owner.borderKey ~= nil) and owner.borderLast or nil
|
||||
owner.borderKey = key
|
||||
owner.borderFade = owner.borderFrom and 0 or nil
|
||||
-- Ticks banked before the swap (a stretch with the fill not drawn at all)
|
||||
-- belong to no dissolve: a new one starts at its first frame.
|
||||
if advance and advance > 1 then advance = 1 end
|
||||
end
|
||||
owner.borderLast = image
|
||||
if not owner.borderFade then return nil, 1 end
|
||||
owner.borderFade = owner.borderFade + 1
|
||||
owner.borderFade = owner.borderFade + (advance or 1)
|
||||
if owner.borderFade >= BorderFill.CROSSFADE_FRAMES then
|
||||
owner.borderFade, owner.borderFrom = nil, nil
|
||||
return nil, 1
|
||||
@@ -291,7 +299,12 @@ function BorderFill.draw(owner, image, camX, camY, w, h, s, key)
|
||||
BorderFill.SIZE, BorderFill.SIZE)
|
||||
if owner then owner.borderQuad = q end
|
||||
end
|
||||
local from, alpha = BorderFill.crossfade(owner, image, key)
|
||||
local advance = nil
|
||||
if owner and owner.borderTicks ~= nil then
|
||||
advance = owner.borderTicks
|
||||
owner.borderTicks = 0
|
||||
end
|
||||
local from, alpha = BorderFill.crossfade(owner, image, key, advance)
|
||||
if from then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(from, q, sx, sy, 0, s, s)
|
||||
|
||||
@@ -58,15 +58,30 @@ function MapAttrGrid.cellAt(map, tileset, mx, my)
|
||||
return { tileId = tileId, rawTileId = raw, attr = attr }
|
||||
end
|
||||
|
||||
-- Full map grid keyed by "mx,my" for fast lookup during overdraw.
|
||||
-- Full map grid for fast lookup during overdraw: a flat array indexed by
|
||||
-- 8x8 cell (row-major, `cols` cells to a row), so a lookup is two divides and
|
||||
-- an index rather than a string key built per tile per entity per frame.
|
||||
-- Cells with the same raw tile id share one read-only cell table: the
|
||||
-- normalization depends only on (raw id, tileset).
|
||||
function MapAttrGrid.build(map, tileset)
|
||||
local grid = {}
|
||||
local grid = { cols = 0, rows = 0, cells = {} }
|
||||
if not (map and tileset) then return grid end
|
||||
local pw, ph = map.width * 32, map.height * 32
|
||||
for my = 0, ph - 1, 8 do
|
||||
for mx = 0, pw - 1, 8 do
|
||||
local cell = MapAttrGrid.cellAt(map, tileset, mx, my)
|
||||
if cell then grid[mx .. "," .. my] = cell end
|
||||
local cols, rows = map.width * 4, map.height * 4
|
||||
grid.cols, grid.rows = cols, rows
|
||||
local cells = grid.cells
|
||||
local byRaw = {}
|
||||
for cy = 0, rows - 1 do
|
||||
for cx = 0, cols - 1 do
|
||||
local raw = MapAttrGrid.tileAt(map, tileset, cx * 8, cy * 8)
|
||||
if raw ~= nil then
|
||||
local cell = byRaw[raw]
|
||||
if not cell then
|
||||
local tileId, attr = MapAttrGrid.normalizeTile(raw, tileset)
|
||||
cell = { tileId = tileId, rawTileId = raw, attr = attr }
|
||||
byRaw[raw] = cell
|
||||
end
|
||||
cells[cy * cols + cx + 1] = cell
|
||||
end
|
||||
end
|
||||
end
|
||||
return grid
|
||||
@@ -74,9 +89,13 @@ end
|
||||
|
||||
function MapAttrGrid.lookup(grid, mx, my)
|
||||
if not grid then return nil end
|
||||
local tx = math.floor(mx / 8) * 8
|
||||
local ty = math.floor(my / 8) * 8
|
||||
return grid[tx .. "," .. ty]
|
||||
local cx = math.floor(mx / 8)
|
||||
local cy = math.floor(my / 8)
|
||||
local cols = grid.cols
|
||||
if not cols or cx < 0 or cy < 0 or cx >= cols or cy >= grid.rows then
|
||||
return nil
|
||||
end
|
||||
return grid.cells[cy * cols + cx + 1]
|
||||
end
|
||||
|
||||
return MapAttrGrid
|
||||
|
||||
+523
-217
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
-- ChipSynth bulk PCM writes (ffi int16 stores through Data:getFFIPointer)
|
||||
-- must be bit-identical to the per-sample SoundData:setSample path they
|
||||
-- replace, for streamed music buffers, SFX/cries (renderEffectData) and the
|
||||
-- synchronous music fallback's sliced staging fill (ChipAudio fillSync).
|
||||
-- luajit tests/chip_synth_bulk_pcm_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("chip synth bulk pcm")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
local ffi = require("ffi")
|
||||
|
||||
-- ffi-backed SoundData. setSample reproduces LOVE 11.5's 16-bit store,
|
||||
-- (int16)(value * 32767) in double precision with no clamp -- verified
|
||||
-- against a real LOVE 11.5 SoundData over 2M values including
|
||||
-- out-of-range and NaN. getFFIPointer exposes the same storage.
|
||||
local FfiSoundData = {}
|
||||
FfiSoundData.__index = FfiSoundData
|
||||
function FfiSoundData:setSample(index, a, b)
|
||||
if b == nil then
|
||||
self.buf[index * self.channels] = a * 32767
|
||||
else
|
||||
self.buf[index * self.channels + (a - 1)] = b * 32767
|
||||
end
|
||||
end
|
||||
function FfiSoundData:getSample(index, channel)
|
||||
return self.buf[index * self.channels + ((channel or 1) - 1)] / 32767
|
||||
end
|
||||
function FfiSoundData:getFFIPointer() return self.buf end
|
||||
function FfiSoundData:getSampleCount() return self.samples end
|
||||
function FfiSoundData:getSampleRate() return self.rate end
|
||||
function FfiSoundData:getBitDepth() return 16 end
|
||||
function FfiSoundData:getChannelCount() return self.channels end
|
||||
|
||||
love.sound = love.sound or {}
|
||||
love.sound.newSoundData = function(samples, rate, bits, channels)
|
||||
local sd = setmetatable({
|
||||
samples = samples, rate = rate, channels = channels,
|
||||
buf = ffi.new("int16_t[?]", samples * channels),
|
||||
}, FfiSoundData)
|
||||
return sd
|
||||
end
|
||||
|
||||
-- the conversion itself, including values the synth never produces
|
||||
do
|
||||
local a = love.sound.newSoundData(8, 44100, 16, 1)
|
||||
local values = { 0, 1, -1, 1.5, -1.5, 0.99999, 1.5 / 32767, 0 / 0 }
|
||||
local expect = { 0, 32767, -32767, -16386, 16386, 32766, 1, 0 }
|
||||
for i, v in ipairs(values) do a:setSample(i - 1, v) end
|
||||
for i = 1, #expect do
|
||||
eq(a.buf[i - 1], expect[i], "LOVE 11.5 int16 store of " .. tostring(values[i]))
|
||||
end
|
||||
end
|
||||
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
|
||||
ChipSynth.setChannelPitches({ 1, 1, 1, 1 })
|
||||
|
||||
-- the fake SoundData really takes the bulk path (and the switch disables it)
|
||||
check(ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
|
||||
"bulk path active on an ffi SoundData")
|
||||
ChipSynth._setBulkWritesForTest(false)
|
||||
check(not ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
|
||||
"bulk switch off falls back to setSample")
|
||||
ChipSynth._setBulkWritesForTest(true)
|
||||
|
||||
local function same(a, b)
|
||||
if a.samples ~= b.samples or a.channels ~= b.channels then
|
||||
return false, "shape " .. a.samples .. "x" .. a.channels
|
||||
.. " vs " .. b.samples .. "x" .. b.channels
|
||||
end
|
||||
local n = a.samples * a.channels
|
||||
for i = 0, n - 1 do
|
||||
if a.buf[i] ~= b.buf[i] then
|
||||
return false, ("sample %d: %d vs %d"):format(i, a.buf[i], b.buf[i])
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function nonzero(sd)
|
||||
local count = 0
|
||||
for i = 0, sd.samples * sd.channels - 1 do
|
||||
if sd.buf[i] ~= 0 then count = count + 1 end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local data = { audio = {} }
|
||||
local song = ChipAsm.song{
|
||||
tempo = 0x100,
|
||||
channels = {
|
||||
{ hw = 1, program = {
|
||||
{ duty = 2 },
|
||||
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
||||
{ octave = 4 },
|
||||
{ label = "body" },
|
||||
{ note = "C", len = 8 }, { note = "E", len = 8 }, { note = "G", len = 4 },
|
||||
{ loop = { count = 0, to = "body" } },
|
||||
} },
|
||||
{ hw = 2, program = {
|
||||
{ duty = 1 },
|
||||
{ notetype = { speed = 12, volume = 10, fade = 2 } },
|
||||
{ octave = 3 },
|
||||
{ label = "body" },
|
||||
{ note = "G", len = 4 }, { note = "A", len = 12 },
|
||||
{ loop = { count = 0, to = "body" } },
|
||||
} },
|
||||
{ hw = 4, program = {
|
||||
{ label = "body" },
|
||||
{ drum = 3, len = 4 },
|
||||
{ loop = { count = 0, to = "body" } },
|
||||
} },
|
||||
},
|
||||
drums = { [3] = { { len = 4, volume = 13, fade = 2, parameter = 0x42 } } },
|
||||
}
|
||||
|
||||
local function render(bulk, fn)
|
||||
ChipSynth._setBulkWritesForTest(bulk)
|
||||
local ok, result = pcall(fn)
|
||||
ChipSynth._setBulkWritesForTest(true)
|
||||
assert(ok, result)
|
||||
return result
|
||||
end
|
||||
|
||||
-- streamed music buffers (worker + sync fallback), stereo and mono
|
||||
for _, channels in ipairs({ 2, 1 }) do
|
||||
local function music()
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
local first = ChipSynth.soundData(engine, 8192, channels)
|
||||
local second = ChipSynth.soundData(engine, 8192, channels)
|
||||
return { first, second }
|
||||
end
|
||||
local slow, fast = render(false, music), render(true, music)
|
||||
for i = 1, 2 do
|
||||
local ok, why = same(slow[i], fast[i])
|
||||
check(ok, ("music buffer %d (%dch) bit-identical %s")
|
||||
:format(i, channels, why or ""))
|
||||
end
|
||||
check(nonzero(fast[1]) > 1000, "music buffer carries audio")
|
||||
end
|
||||
|
||||
-- sliced staging fill == one whole-buffer render
|
||||
do
|
||||
local whole = render(false, function()
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
ChipSynth.soundData(engine, 1000, 2)
|
||||
return ChipSynth.soundData(engine, 8192, 2)
|
||||
end)
|
||||
local sliced = render(true, function()
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
ChipSynth.soundData(engine, 1000, 2)
|
||||
local sd = ChipSynth.newBuffer(8192, 2)
|
||||
local fill = 0
|
||||
for _, n in ipairs({ 1024, 1024, 777, 1024, 3000, 1343 }) do
|
||||
ChipSynth.renderInto(engine, sd, fill, n, 2)
|
||||
fill = fill + n
|
||||
end
|
||||
assert(fill == 8192)
|
||||
return sd
|
||||
end)
|
||||
local ok, why = same(whole, sliced)
|
||||
check(ok, "sliced staging fill bit-identical " .. (why or ""))
|
||||
end
|
||||
|
||||
-- SFX / cries
|
||||
local sfx = ChipAsm.sfx{ channels = {
|
||||
{ hw = 1, program = {
|
||||
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
|
||||
{ squareNote = { len = 16, volume = 12, fade = 3, frequency = 0x700 } },
|
||||
} },
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 16, volume = 14, fade = 2, parameter = 0x33 } },
|
||||
} },
|
||||
} }
|
||||
|
||||
for _, opts in ipairs({
|
||||
function() return {} end,
|
||||
function() return { frequencyOffset = 0x40, cryLength = 0x60 } end,
|
||||
function() return { frequencyOffset = -0x20, frameTicks = 0xC0 } end,
|
||||
}) do
|
||||
local slow = render(false, function()
|
||||
return ChipSynth.renderEffectData(data, sfx, opts())
|
||||
end)
|
||||
local fast = render(true, function()
|
||||
return ChipSynth.renderEffectData(data, sfx, opts())
|
||||
end)
|
||||
check(slow and fast, "effect renders both ways")
|
||||
if slow and fast then
|
||||
local ok, why = same(slow, fast)
|
||||
check(ok, "effect bit-identical " .. (why or ""))
|
||||
check(nonzero(fast) > 100, "effect carries audio")
|
||||
end
|
||||
end
|
||||
|
||||
-- a second, longer effect after a short one: the reused scratch grows
|
||||
do
|
||||
local notes = {}
|
||||
for i = 1, 24 do
|
||||
notes[i] = { squareNote = { len = 16, volume = 15, fade = 0,
|
||||
frequency = 0x600 + i * 8 } }
|
||||
end
|
||||
local long = ChipAsm.sfx{ channels = { { hw = 1, program = notes } } }
|
||||
local slow = render(false, function()
|
||||
return ChipSynth.renderEffectData(data, long, {})
|
||||
end)
|
||||
local fast = render(true, function()
|
||||
return ChipSynth.renderEffectData(data, long, {})
|
||||
end)
|
||||
check(slow and fast and slow.samples > 44100,
|
||||
"long effect renders past the initial scratch")
|
||||
if slow and fast then
|
||||
local ok, why = same(slow, fast)
|
||||
check(ok, "long effect bit-identical " .. (why or ""))
|
||||
end
|
||||
end
|
||||
|
||||
-- synchronous music fallback: the sliced per-tick staging fill queues the
|
||||
-- same 8192-sample buffers, in order, as whole-buffer renders did
|
||||
do
|
||||
love.thread = nil
|
||||
local queued = {}
|
||||
local source = { free = 32, playing = false }
|
||||
function source:getFreeBufferCount() return self.free end
|
||||
function source:queue(sd) self.free = self.free - 1; queued[#queued + 1] = sd end
|
||||
function source:play() self.playing = true end
|
||||
function source:stop() self.playing = false end
|
||||
function source:isPlaying() return self.playing end
|
||||
function source:setVolume() end
|
||||
love.audio = love.audio or {}
|
||||
love.audio.newQueueableSource = function() return source end
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
ChipAudio.playMusic(data, song, true)
|
||||
eq(#queued, 4, "song start queues the initial buffers")
|
||||
local maxTick = 0
|
||||
local consumed = 0
|
||||
for _ = 1, 400 do
|
||||
consumed = consumed + ChipSynth.SAMPLE_RATE / 60
|
||||
while consumed >= 8192 and source.free < 32 do
|
||||
consumed = consumed - 8192
|
||||
source.free = source.free + 1
|
||||
end
|
||||
local before = #queued
|
||||
ChipAudio.update()
|
||||
maxTick = math.max(maxTick, #queued - before)
|
||||
end
|
||||
check(#queued > 40, "the queue keeps filling (" .. #queued .. " buffers)")
|
||||
check(maxTick <= 1, "at most one buffer completes per tick")
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
local allSame = true
|
||||
for i = 1, #queued do
|
||||
local ref = ChipSynth.soundData(engine, 8192, 2)
|
||||
local ok = same(ref, queued[i])
|
||||
if not ok then allSame = false; break end
|
||||
end
|
||||
check(allSame, "sliced fallback stream bit-identical to whole buffers")
|
||||
-- a starved queue (below the low-water mark) renders a whole buffer's
|
||||
-- worth in one tick
|
||||
source.free = 31
|
||||
local before = #queued
|
||||
ChipAudio.update()
|
||||
check(#queued - before >= 1, "low queue catches up within one tick")
|
||||
ChipAudio.stopMusic()
|
||||
-- no worker: prewarm declines rather than rendering on this thread
|
||||
local fx = { audio = { sfx = { Beep = sfx }, cries = {} } }
|
||||
check(ChipAudio.prewarmSfx(fx, "Beep") == false,
|
||||
"prewarm is a no-op without a worker")
|
||||
eq(ChipAudio._effectStateForTest().pending, 0, "nothing left pending")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -92,6 +92,8 @@ local function compareBox(ours, ref, mask, diffPath)
|
||||
end
|
||||
|
||||
return function(game)
|
||||
-- nativeFrame() reads the flat 240x160 mirror of the plane path
|
||||
require("src.core.game3.display").mirrorForTests = true
|
||||
os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null')
|
||||
for _ = 1, 900 do
|
||||
if game.phase == "boot" and game.boot then break end
|
||||
|
||||
@@ -19,6 +19,9 @@ local function finish()
|
||||
end
|
||||
|
||||
return function(game)
|
||||
-- the Fuchsia check waits on a Route 17 biker ~150 cells away wandering
|
||||
-- into line; tick every ghost pool, not just those near the view
|
||||
require("src.core.game3.ghosts").TICK_MARGIN_SCREENS = math.huge
|
||||
for _ = 1, 600 do
|
||||
if game.phase == "boot" and game.boot then break end
|
||||
U.wait(1)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
-- Gen 2 overworld draw-path fixes and the caches that replaced per-frame work:
|
||||
-- * Crystal ghost NPCs (neighbour-map objects) skip the grass composite,
|
||||
-- as Gold/Silver already did -- their px/py are the neighbour's.
|
||||
-- * bgOverQuads is one quad cache per atlas, not one shared by every atlas.
|
||||
-- * the poison flash and the void-fill dissolve run on the logic clock.
|
||||
-- * MapAttrGrid's numeric grid, the anim-cell index and the people cull.
|
||||
-- luajit tests/engine/gen2_world_overdraw_perf_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
local BorderFill = require("src.world.gen2.BorderFill")
|
||||
local MapAttrGrid = require("src.world.gen2.MapAttrGrid")
|
||||
|
||||
local function crystalWorld(fields)
|
||||
local world = setmetatable(fields, { __index = World })
|
||||
function world:isCrystal() return true end
|
||||
return world
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ ghost grass (Crystal)
|
||||
do
|
||||
local grass = 0
|
||||
local world = crystalWorld({})
|
||||
function world:drawGrassOver() grass = grass + 1 end
|
||||
function world:drawBgPriorityOver() end
|
||||
function world:drawGrassShake() end
|
||||
local rows = {}
|
||||
local function sprite(row) rows[#rows + 1] = row or "full" end
|
||||
local entity = { px = 0, py = 0, inGrass = true }
|
||||
|
||||
world:drawEntityComposite(entity, 0, 0, 1, sprite, true, false)
|
||||
eq(grass, 1, "an on-map object in grass gets the grass composite")
|
||||
eq(rows[1], "bottom", "bottom OAM first")
|
||||
|
||||
grass, rows = 0, {}
|
||||
world:drawEntityComposite(entity, 0, 0, 1, sprite, false, true)
|
||||
eq(grass, 0, "BUG FIX: a neighbour-map ghost never samples this map's grass")
|
||||
eq(#rows, 1, "the ghost draws once, whole")
|
||||
eq(rows[1], "full", "as a single full sprite")
|
||||
|
||||
-- The old six-argument call (drawPipeline mods) keeps its behaviour.
|
||||
grass = 0
|
||||
world:drawEntityComposite(entity, 0, 0, 1, sprite, false)
|
||||
eq(grass, 1, "no offMap argument still composites")
|
||||
end
|
||||
|
||||
-- drawPeople passes offMap for ghosts.
|
||||
do
|
||||
local composites = {}
|
||||
local world = crystalWorld({
|
||||
player = { px = 0, py = 0, draw = function() end },
|
||||
npcs = {}, camera = { x = 0, y = 0 }, viewW = 160, viewH = 144,
|
||||
})
|
||||
local ghostNpc = { px = 16, py = 16, inGrass = true, draw = function() end }
|
||||
world.ghosts = { { npc = ghostNpc, ox = 320, oy = 0 } }
|
||||
world.camera.x = 300
|
||||
function world:flyHides() return false, false end
|
||||
function world:drawJumpShadow() end
|
||||
function world:drawEntityComposite(entity, _, _, _, _, withExtras, offMap)
|
||||
composites[#composites + 1] = { entity = entity, extras = withExtras,
|
||||
offMap = offMap }
|
||||
end
|
||||
function world:drawEmote() end
|
||||
function world:drawHealAnim() end
|
||||
function world:drawFlyAnim() end
|
||||
world:drawPeople(1)
|
||||
eq(#composites, 2, "player and ghost both drawn")
|
||||
local ghost
|
||||
for _, c in ipairs(composites) do
|
||||
if c.entity == ghostNpc then ghost = c end
|
||||
end
|
||||
check(ghost ~= nil, "the ghost reached the compositor")
|
||||
eq(ghost and ghost.offMap, true, "drawPeople flags the ghost as off-map")
|
||||
eq(ghost and ghost.extras, false, "and without the on-map extras")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ people cull + reuse
|
||||
do
|
||||
local drawn = {}
|
||||
local function person(name, px, py)
|
||||
return { name = name, px = px, py = py,
|
||||
draw = function(self) drawn[#drawn + 1] = self.name end }
|
||||
end
|
||||
local world = setmetatable({
|
||||
player = person("player", 5000, 5000),
|
||||
npcs = { person("near", 64, 32), person("far", 2000, 32),
|
||||
person("row", 80, 32) },
|
||||
ghosts = {}, camera = { x = 0, y = 0 }, viewW = 160, viewH = 144,
|
||||
}, { __index = World })
|
||||
function world:isCrystal() return false end
|
||||
function world:flyHides() return false, false end
|
||||
function world:drawJumpShadow() end
|
||||
function world:drawGrassShake() end
|
||||
function world:drawEmote() end
|
||||
function world:drawHealAnim() end
|
||||
function world:drawFlyAnim() end
|
||||
world:drawPeople(1)
|
||||
eq(#drawn, 3, "the far NPC is culled, the player never is")
|
||||
eq(drawn[1], "near", "equal rows draw in list order")
|
||||
eq(drawn[2], "row", "equal rows draw in list order (second)")
|
||||
eq(drawn[3], "player", "Y order holds")
|
||||
local list = world._peopleList
|
||||
drawn = {}
|
||||
world:drawPeople(1)
|
||||
eq(#drawn, 3, "a second frame draws the same people")
|
||||
check(world._peopleList == list, "and reuses the draw list")
|
||||
|
||||
-- The billboard (TILT) path shows more than the view: no cull there.
|
||||
drawn = {}
|
||||
world:drawPeople(1, function(_, _, body) body() end)
|
||||
eq(#drawn, 4, "the tilt billboard pass keeps everyone")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ bgOverQuads per atlas
|
||||
do
|
||||
local world = crystalWorld({})
|
||||
local a = love.graphics.newImage("a.png")
|
||||
local b = love.graphics.newImage("b.png")
|
||||
local qa = world:bgOverQuadsFor(a)
|
||||
local qb = world:bgOverQuadsFor(b)
|
||||
check(qa ~= qb, "BUG FIX: two atlases get two quad caches")
|
||||
check(world:bgOverQuadsFor(a) == qa, "one atlas keeps its cache")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ poison flash clock
|
||||
do
|
||||
local world = setmetatable({}, { __index = World })
|
||||
world:poisonBGFlash()
|
||||
eq(world.poisonFlash, 4, "the flash arms four frames")
|
||||
for i = 3, 0, -1 do
|
||||
world:tickPoisonFlash()
|
||||
eq(world.poisonFlash, i, "one logic tick spends one frame")
|
||||
end
|
||||
world:tickPoisonFlash()
|
||||
eq(world.poisonFlash, 0, "and it stops at zero")
|
||||
|
||||
-- Game2's fixed step drives both clocks through tickFrameClocks.
|
||||
world:poisonBGFlash()
|
||||
world:tickFrameClocks()
|
||||
eq(world.poisonFlash, 3, "tickFrameClocks spends a poison frame")
|
||||
eq(world.borderTicks, 1, "and banks one dissolve tick")
|
||||
for _ = 1, 100 do world:tickFrameClocks() end
|
||||
eq(world.borderTicks, BorderFill.CROSSFADE_FRAMES,
|
||||
"banked ticks cap at one whole dissolve")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ void dissolve clock
|
||||
do
|
||||
local owner = { borderTicks = 0 }
|
||||
BorderFill.crossfade(owner, "water", "A")
|
||||
local from, alpha = BorderFill.crossfade(owner, "trees", "B", 1)
|
||||
eq(from, "water", "a new fill dissolves from the old one")
|
||||
local a1 = alpha
|
||||
from, alpha = BorderFill.crossfade(owner, "trees", "B", 0)
|
||||
eq(alpha, a1, "BUG FIX: a render frame with no logic tick does not advance")
|
||||
from, alpha = BorderFill.crossfade(owner, "trees", "B", 2)
|
||||
check(alpha > a1, "two banked ticks advance two frames")
|
||||
|
||||
-- Ticks banked before a swap do not skip the new dissolve.
|
||||
local o2 = {}
|
||||
BorderFill.crossfade(o2, "water", "A")
|
||||
local f2, al2 = BorderFill.crossfade(o2, "trees", "B", 15)
|
||||
eq(f2, "water", "a swap after a long gap still dissolves")
|
||||
check(al2 <= 1 / BorderFill.CROSSFADE_FRAMES + 1e-9,
|
||||
"from its first frame")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ MapAttrGrid numeric
|
||||
do
|
||||
local block = {}
|
||||
for i = 1, 16 do block[i] = i - 1 end
|
||||
block[16] = 0x85
|
||||
local tileset = { blocks = { block, block } }
|
||||
local map = { width = 2, height = 1, blocks = { 1, 0 }, borderBlock = 1 }
|
||||
local grid = MapAttrGrid.build(map, tileset)
|
||||
for my = 0, 31, 8 do
|
||||
for mx = 0, 63, 8 do
|
||||
local want = MapAttrGrid.cellAt(map, tileset, mx, my)
|
||||
local got = MapAttrGrid.lookup(grid, mx + 3, my + 5)
|
||||
eq(got and got.tileId, want.tileId, "lookup tile id at " .. mx .. "," .. my)
|
||||
eq(got and got.rawTileId, want.rawTileId, "raw id at " .. mx .. "," .. my)
|
||||
eq(got and got.attr.vramBank, want.attr.vramBank,
|
||||
"bank at " .. mx .. "," .. my)
|
||||
end
|
||||
end
|
||||
eq(MapAttrGrid.lookup(grid, -8, 0), nil, "left of the map is nothing")
|
||||
eq(MapAttrGrid.lookup(grid, 64, 0), nil, "right of the map does not wrap")
|
||||
eq(MapAttrGrid.lookup(grid, 0, 32), nil, "below the map is nothing")
|
||||
eq(MapAttrGrid.lookup(nil, 0, 0), nil, "no grid is nothing")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ anim cell index
|
||||
do
|
||||
local world = setmetatable({}, { __index = World })
|
||||
local water = { layer = {}, tile = 1, cells = { 0, 0, 8, 0, 32, 64 } }
|
||||
local flower = { layer = {}, tile = 2, cells = { 16, 8 } }
|
||||
local cells = { [1] = water, [2] = flower }
|
||||
eq(world:animListAt(cells, 8, 0), water, "indexed water cell")
|
||||
eq(world:animListAt(cells, 32, 64), water, "indexed far water cell")
|
||||
eq(world:animListAt(cells, 16, 8), flower, "indexed flower cell")
|
||||
eq(world:animListAt(cells, 8, 8), nil, "a plain cell is not animated")
|
||||
eq(world:animListAt(nil, 0, 0), nil, "no anim table")
|
||||
eq(world:animListAt(false, 0, 0), nil, "a map with no anims")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ memoized keys
|
||||
do
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local fake = { daytime = "DAY", flickerPhase = 1 }
|
||||
local k1 = World.mapCacheKey(fake, "M")
|
||||
eq(k1, "M|DAY|" .. tostring(GbcPalette.mode) .. "|nil|1", "key format")
|
||||
check(World.mapCacheKey(fake, "M") == k1, "memoized")
|
||||
fake.daytime = "NITE"
|
||||
check(World.mapCacheKey(fake, "M") ~= k1, "a daytime change rebuilds it")
|
||||
fake.daytime = "DARK"
|
||||
local dark1 = World.mapCacheKey(fake, "M")
|
||||
fake.flickerPhase = 2
|
||||
check(World.mapCacheKey(fake, "M") ~= dark1, "a flicker change rebuilds it")
|
||||
|
||||
BorderFill.setVoidFill("fade")
|
||||
local def = { id = "M", tileset = "TILESET_JOHTO", borderBlock = 5 }
|
||||
local w = setmetatable({}, { __index = World })
|
||||
eq(w:borderFillKey(def), BorderFill.fillKey(def), "fill key matches")
|
||||
BorderFill.setVoidFill("water")
|
||||
eq(w:borderFillKey(def), BorderFill.fillKey(def), "fill key follows mode")
|
||||
BorderFill.setVoidFill("fade")
|
||||
end
|
||||
|
||||
T.finish("gen2 world overdraw perf")
|
||||
@@ -51,7 +51,11 @@ for i = 1, 4 do
|
||||
"shade " .. (i - 1) .. " is not a DMG gray")
|
||||
end
|
||||
|
||||
-- the heal overlay body is a module-level function drawWorld calls
|
||||
-- (fxHeal), not an inline closure; look through it as well
|
||||
local healShader = upvalue(OW.drawWorld, "healMachineShader")
|
||||
or upvalue(upvalue(OW.drawWorld, "fxHeal") or function() end,
|
||||
"healMachineShader")
|
||||
check(type(healShader) == "function",
|
||||
"the heal overlay picks its palette through healMachineShader")
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
-- Presentation counters that draw code samples must advance at the fixed
|
||||
-- 60Hz logic rate, not once per rendered frame:
|
||||
-- * Player:pose's surf bob (a 144Hz display bobbed 2.4x too fast, and the
|
||||
-- battle-transition wipe, which draws the player twice a frame, doubled
|
||||
-- it) -- now counted in Game:step logic steps (Game.logicStep);
|
||||
-- * the battle text box's ScrollTextUpOneLine offset (scrollPx), which
|
||||
-- BattleState:drawTextArea / WideBattle counted down per draw -- now
|
||||
-- BattleState:tickTextScroll, run once per update step.
|
||||
-- Also pins the per-frame caching in the battle OAM colorizer: one
|
||||
-- sgbBattlePals() per frame, shared color triples, and AnimPlayer's
|
||||
-- uniform sends always matching the colors of the cell being painted.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
-- ---------------------------------------------------------------- surf bob
|
||||
do
|
||||
local prevGame = package.loaded["src.core.Game"]
|
||||
local fakeGame = { logicStep = 10 }
|
||||
package.loaded["src.core.Game"] = fakeGame
|
||||
local Player = require("src.world.Player")
|
||||
local p = setmetatable({ surfing = true, px = 0, py = 0, facing = "down",
|
||||
progress = 0, animClock = 0 }, Player)
|
||||
-- the wipe's double draw (and any 120/144Hz frame without a new step)
|
||||
p:pose(); p:pose(); p:pose()
|
||||
T.eq(p.bobTimer, 1, "three draws inside one logic step advance the bob once")
|
||||
fakeGame.logicStep = 11
|
||||
p:pose(); p:pose()
|
||||
T.eq(p.bobTimer, 2, "the next step advances it exactly once more")
|
||||
fakeGame.logicStep = 14
|
||||
p:pose()
|
||||
T.eq(p.bobTimer, 5, "steps run between draws (fast-forward) all count")
|
||||
-- the 32-step cycle: low for bob 0..15, one pixel down for 16..31
|
||||
local lows, highs = 0, 0
|
||||
for _ = 1, 32 do
|
||||
fakeGame.logicStep = fakeGame.logicStep + 1
|
||||
local _, _, y = p:pose()
|
||||
local y2 = select(3, p:pose()) -- a second draw in the same step
|
||||
T.eq(y2, y, "a repeat draw in the same step shows the same bob")
|
||||
if y == 0 then lows = lows + 1 else highs = highs + 1 end
|
||||
end
|
||||
T.eq(lows, 16, "16 steps of the cycle sit on the baseline")
|
||||
T.eq(highs, 16, "16 steps of the cycle sit a pixel down")
|
||||
-- no running Game (headless callers): the old per-call tick
|
||||
fakeGame.logicStep = nil
|
||||
local before = p.bobTimer
|
||||
p:pose(); p:pose()
|
||||
T.eq(p.bobTimer, (before + 2) % 32, "without a step clock each pose ticks")
|
||||
package.loaded["src.core.Game"] = prevGame
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------- battle scroll
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
do
|
||||
local b = setmetatable({ phase = "messages", current = {}, scrollPx = 8 },
|
||||
{ __index = BattleState })
|
||||
local seen = {}
|
||||
for i = 1, 5 do
|
||||
b:tickTextScroll()
|
||||
seen[i] = tostring(b.scrollPx)
|
||||
end
|
||||
T.eq(table.concat(seen, ","), "6,4,2,nil,nil",
|
||||
"2px per logic step, cleared at 0 (the draws saw 6,4,2,0 before)")
|
||||
b.scrollPx = 8
|
||||
b.phase = "menu"
|
||||
b:tickTextScroll()
|
||||
T.eq(b.scrollPx, 8, "no count-down while the message box is not shown")
|
||||
b.phase, b.current, b.animPlaying, b.msgHold = "messages", nil, nil, true
|
||||
b:tickTextScroll()
|
||||
T.eq(b.scrollPx, 6, "msgHold keeps the box (and its scroll) live")
|
||||
end
|
||||
|
||||
-- ------------------------------------------- battle OAM colorizer caching
|
||||
do
|
||||
local zoneA = { { 255, 255, 255 }, { 200, 0, 0 }, { 100, 0, 0 }, { 0, 0, 0 } }
|
||||
local zoneB = { { 255, 255, 255 }, { 0, 200, 0 }, { 0, 100, 0 }, { 0, 0, 0 } }
|
||||
local palsCalls = 0
|
||||
local b = setmetatable({}, { __index = BattleState })
|
||||
b.sgbBattlePals = function()
|
||||
palsCalls = palsCalls + 1
|
||||
return { [0] = zoneA, [1] = zoneB, [2] = zoneA, [3] = zoneB }
|
||||
end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local realObp = PaletteFX.usesSpriteObp
|
||||
PaletteFX.usesSpriteObp = function() return false end
|
||||
local memo = {}
|
||||
local c1 = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 0, 0, memo)
|
||||
local c2 = b:animSpriteColors({ obp = "e4", x = 20, y = 28 }, 0, 8, memo)
|
||||
local c3 = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 8, 8, memo)
|
||||
T.eq(palsCalls, 1, "one sgbBattlePals per frame memo")
|
||||
T.check(c1 == c2, "same zone + obp shares one triple table")
|
||||
T.check(c3 ~= c1, "the enemy-HUD zone resolves its own triple")
|
||||
T.eq(math.floor(c1[1][1] * 255 + 0.5), 200, "e4 color 1 is the zone's shade 1")
|
||||
T.eq(math.floor(c3[1][2] * 255 + 0.5), 200, "enemy HUD wears zone 1's shade 1")
|
||||
local fresh = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 0, 0)
|
||||
T.eq(palsCalls, 2, "without a memo it resolves the palettes itself")
|
||||
T.same(fresh, c1, "memoized and unmemoized colors agree")
|
||||
PaletteFX.usesSpriteObp = realObp
|
||||
|
||||
-- AnimPlayer.drawSprites: every blit must see the uniforms of the cell
|
||||
-- it paints, whatever sends were skipped as redundant
|
||||
local AnimPlayer = require("src.battle.AnimPlayer")
|
||||
local g = love.graphics
|
||||
local saved = {}
|
||||
for _, k in ipairs({ "draw", "setShader", "getScissor", "intersectScissor",
|
||||
"setScissor" }) do saved[k] = g[k] end
|
||||
local shader = { u = {} }
|
||||
function shader:send(name, v) self.u[name] = v end
|
||||
local realShaderFn = PaletteFX.shader
|
||||
PaletteFX.shader = function() return shader end
|
||||
local scissor
|
||||
local blits = {}
|
||||
g.draw = function()
|
||||
blits[#blits + 1] = { c1 = shader.u.c1, c2 = shader.u.c2,
|
||||
c3 = shader.u.c3, clip = scissor }
|
||||
end
|
||||
g.setShader = function() end
|
||||
g.getScissor = function()
|
||||
if scissor then return scissor[1], scissor[2], scissor[3], scissor[4] end
|
||||
end
|
||||
g.intersectScissor = function(x, y, w, h) scissor = { x, y, w, h } end
|
||||
g.setScissor = function(x, y, w, h) scissor = x and { x, y, w, h } or nil end
|
||||
local tri = {}
|
||||
local function colorAt(px, py)
|
||||
-- a vertical zone boundary at x = 16
|
||||
local key = px < 16 and "L" or "R"
|
||||
tri[key] = tri[key] or { { px < 16 and 1 or 0, 0, 0 }, { 0, 0, 0 },
|
||||
{ 0, 0, 0 } }
|
||||
return tri[key]
|
||||
end
|
||||
local ap = setmetatable({ sheetImage = function() return {} end,
|
||||
tileQuad = function() return {} end },
|
||||
{ __index = AnimPlayer })
|
||||
local sprites = {
|
||||
{ x = 8 + 4, y = 16, tile = 1 }, -- rx 4: fully in L
|
||||
{ x = 8 + 12, y = 16, tile = 1 }, -- rx 12: straddles L|R
|
||||
{ x = 8 + 20, y = 16 + 3, tile = 1 }, -- rx 20, ry 3: R only (2 rows)
|
||||
{ x = 8 + 5, y = 16, tile = 1 }, -- back to L
|
||||
}
|
||||
ap:drawSprites(sprites, function(_, px, py) return colorAt(px, py) end)
|
||||
-- expected paint order: L, L+R(slice), R, R(slice, same colors -> no
|
||||
-- repaint), L
|
||||
local want = { "L", "L", "R", "R", "L" }
|
||||
T.eq(#blits, #want, "one blit per tile plus one per differing cell")
|
||||
for i, w in ipairs(want) do
|
||||
local bl = blits[i]
|
||||
if bl then
|
||||
T.eq(bl.c1 and bl.c1[1], w == "L" and 1 or 0,
|
||||
("blit %d paints with its cell's colors"):format(i))
|
||||
end
|
||||
end
|
||||
T.check(blits[3] and blits[3].clip ~= nil,
|
||||
"the straddling tile's second cell is scissored")
|
||||
for k, v in pairs(saved) do g[k] = v end
|
||||
PaletteFX.shader = realShaderFn
|
||||
end
|
||||
|
||||
T.finish("refresh-rate-independent counters")
|
||||
@@ -0,0 +1,319 @@
|
||||
-- Hot-path caches and the bugs fixed alongside them: Font's encode/width
|
||||
-- memo, PaletteFX/GbcPalette last-sent uniform detection, SpriteRenderer's
|
||||
-- obp bake lookup, Hooks' unwrap of the last link, Sandbox's per-name
|
||||
-- verdict, Input's recycled step tables and stale-pad rebuild, and
|
||||
-- TextBox's scroll slide running on the logic step instead of draw.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
package.loaded["src.core.Logger"] = {
|
||||
info = function() end, warn = function() end, error = function() end,
|
||||
debug = function() end,
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------- Font
|
||||
do
|
||||
local Font = require("src.render.Font")
|
||||
local a = Font.encode("ABC")
|
||||
a[1], a[4] = -1, -2
|
||||
local b = Font.encode("ABC")
|
||||
eq(#b, 3, "encode hands out a copy: a caller's append does not stick")
|
||||
check(b[1] ~= -1, "encode hands out a copy: a caller's write does not stick")
|
||||
check(a ~= b, "each encode is a fresh table")
|
||||
eq(Font.glyphCount("ABC"), #Font.split("ABC"), "glyphCount matches split")
|
||||
local w = Font.width("ABC")
|
||||
eq(Font.width("ABC"), w, "cached width is stable")
|
||||
local rev = Font.revision
|
||||
Font.setFrame(Font.frameIndex())
|
||||
eq(Font.revision, rev, "re-selecting the same frame keeps the caches")
|
||||
Font.setFrame(Font.frameIndex() + 1)
|
||||
check(Font.revision > rev, "a frame change drops cached widths")
|
||||
Font.setFrame(1)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- PaletteFX
|
||||
local function recordingShader()
|
||||
local sh = { sends = {} }
|
||||
function sh.send(self, name, value)
|
||||
self.sends[#self.sends + 1] = { name = name, value = { value[1], value[2], value[3] } }
|
||||
end
|
||||
return sh
|
||||
end
|
||||
|
||||
do
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
PaletteFX.pickerActive = function() return false end
|
||||
local pal = { { 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 }, { 0, 0, 0 } }
|
||||
local sh = recordingShader()
|
||||
|
||||
PaletteFX.mode = "gbc"
|
||||
PaletteFX.sendColors(sh, pal)
|
||||
eq(#sh.sends, 4, "first send sets all four colors")
|
||||
PaletteFX.sendColors(sh, pal)
|
||||
eq(#sh.sends, 4, "identical colors to the same shader are not re-sent")
|
||||
local changed = { pal[1], pal[2], { 10, 20, 30 }, pal[4] }
|
||||
PaletteFX.sendColors(sh, changed)
|
||||
eq(#sh.sends, 5, "only the changed uniform is re-sent")
|
||||
eq(sh.sends[5].name, "c2", "and it is the one that changed")
|
||||
|
||||
local other = recordingShader()
|
||||
PaletteFX.sendColors(other, changed)
|
||||
eq(#other.sends, 4, "the record is per shader")
|
||||
|
||||
PaletteFX.forgetSent(sh)
|
||||
PaletteFX.sendColors(sh, changed)
|
||||
eq(#sh.sends, 9, "forgetSent makes the next send go out in full")
|
||||
|
||||
PaletteFX.sendShades(sh, pal)
|
||||
PaletteFX.sendColors(sh, changed)
|
||||
eq(sh.sends[#sh.sends].name, "c2", "sendShades shares the record with sendColors")
|
||||
|
||||
-- the index arithmetic must land on exactly what effectiveColors resolves
|
||||
local function lastFour(s)
|
||||
local out = {}
|
||||
for i = #s.sends, 1, -1 do
|
||||
local e = s.sends[i]
|
||||
local k = tonumber(e.name:sub(2)) + 1
|
||||
if not out[k] then out[k] = e.value end
|
||||
end
|
||||
return out
|
||||
end
|
||||
for _, mode in ipairs({ "gbc", "gbc_inv", "og", "og_inv", "classic" }) do
|
||||
for _, map in ipairs({ false, PaletteFX.DARK_BGP, PaletteFX.POISON_BGP }) do
|
||||
PaletteFX.mode = mode
|
||||
PaletteFX.setShadeMap(map or nil)
|
||||
local s = recordingShader()
|
||||
PaletteFX.sendColors(s, pal)
|
||||
local want = PaletteFX.effectiveColors(pal)
|
||||
local got = lastFour(s)
|
||||
local same = true
|
||||
for i = 1, 4 do
|
||||
for c = 1, 3 do
|
||||
if math.abs(got[i][c] - want[i][c] / 255) > 1e-12 then same = false end
|
||||
end
|
||||
end
|
||||
check(same, ("sendColors matches effectiveColors (%s, map %s)")
|
||||
:format(mode, tostring(map and true)))
|
||||
end
|
||||
end
|
||||
PaletteFX.mode = "gbc"
|
||||
PaletteFX.setShadeMap(nil)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- GbcPalette
|
||||
do
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local sh = recordingShader()
|
||||
love.graphics.newShader = function() return sh end
|
||||
love.graphics.setShader = function() end
|
||||
local pal = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 } }
|
||||
check(GbcPalette.useRaw(pal), "useRaw binds")
|
||||
eq(#sh.sends, 4, "useRaw sends pal0..pal3 once")
|
||||
GbcPalette.useRaw(pal)
|
||||
eq(#sh.sends, 4, "an unchanged palette is not re-sent")
|
||||
GbcPalette.useRaw({ pal[1], { 1, 2, 3 }, pal[3], pal[4] })
|
||||
eq(#sh.sends, 5, "one changed entry is one send")
|
||||
eq(sh.sends[5].name, "pal1", "to the changed uniform")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- SpriteRenderer
|
||||
do
|
||||
local Assets = require("src.render.Assets")
|
||||
local loads = 0
|
||||
Assets.image = function() loads = loads + 1; return { tag = loads } end
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
SpriteRenderer.invalidate()
|
||||
local savedImage = love.image
|
||||
love.image = nil -- headless bake path
|
||||
local colors = { {}, {}, {}, {} }
|
||||
local one = SpriteRenderer.obpImage("sheet.png", colors, 1)
|
||||
eq(SpriteRenderer.obpImage("sheet.png", colors, 1), one, "a cache hit returns the bake")
|
||||
eq(SpriteRenderer.obpImage("sheet.png", colors, "1"), one,
|
||||
"number and string groups share a key, as the concatenated key did")
|
||||
check(SpriteRenderer.obpImage("sheet.png", colors, "dark") ~= nil, "a new group bakes")
|
||||
SpriteRenderer.invalidate()
|
||||
check(SpriteRenderer.obpImage("sheet.png", colors, 1) ~= one, "invalidate drops the lookup too")
|
||||
love.image = savedImage
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- Hooks
|
||||
do
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local h = Hooks.new()
|
||||
local unwrap = h:wrap("x", function(next, v) return next(v + 1) end)
|
||||
eq(h:call("x", function(v) return v * 2 end, 1), 4, "a single link runs")
|
||||
unwrap()
|
||||
eq(h.chains.x, nil, "unwrapping the last link drops the chain (wantsHook false)")
|
||||
eq(h:call("x", function(v) return v * 2 end, 1), 2, "and the call is plain vanilla")
|
||||
local u1 = h:wrap("y", function(next, ...) return next(...) end)
|
||||
local u2 = h:wrap("y", function(next, ...) return next(...) end)
|
||||
u1()
|
||||
check(h.chains.y ~= nil, "a chain with links left stays")
|
||||
u2()
|
||||
eq(h.chains.y, nil, "and goes once empty")
|
||||
local stale = h:wrap("z", function(next, ...) return next(...) end)
|
||||
h:removeOwner(nil)
|
||||
h.chains.z = nil
|
||||
local fresh = h:wrap("z", function(next, ...) return next(...) end)
|
||||
stale()
|
||||
check(h.chains.z ~= nil, "a stale unwrap never drops a newer chain")
|
||||
fresh()
|
||||
local ok, err = pcall(function()
|
||||
return h:call("none", function() error("boom", 0) end)
|
||||
end)
|
||||
check(not ok and err == "boom", "vanilla errors propagate without a chain")
|
||||
h:wrap("w", function(next, ...) return next(...) end)
|
||||
ok, err = pcall(function()
|
||||
return h:call("w", function() error("deep", 0) end)
|
||||
end)
|
||||
check(not ok and err == "deep", "vanilla errors propagate through a chain unwrapped")
|
||||
local n, a, b, c = select("#", h:call("w", function() return 1, nil, 3 end)),
|
||||
h:call("w", function() return 1, nil, 3 end)
|
||||
eq(n, 3, "trailing results keep their count")
|
||||
check(a == 1 and b == nil and c == 3, "and their values")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- Sandbox
|
||||
do
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
for _ = 1, 2 do
|
||||
check(Sandbox.moduleDenial("io") ~= nil, "io denied")
|
||||
check(Sandbox.moduleDenial("love.filesystem") ~= nil, "love.* denied")
|
||||
eq(Sandbox.moduleDenial("love"), nil, "bare love allowed")
|
||||
check(Sandbox.moduleDenial("socket.http") ~= nil, "network needs the permission")
|
||||
eq(Sandbox.moduleDenial("socket.http", { network = true }), nil, "and passes with it")
|
||||
eq(Sandbox.moduleDenial("src.core.Game"), nil, "engine modules allowed")
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- Input
|
||||
do
|
||||
local Input = require("src.core.Input")
|
||||
Input:init()
|
||||
Input:keypressed("up")
|
||||
Input:step()
|
||||
local pressed, queue = Input.pressed, Input.pressQueue
|
||||
check(Input:wasPressed("up"), "press edges")
|
||||
Input:keyreleased("up")
|
||||
Input:step()
|
||||
eq(Input.pressed, pressed, "step recycles its own pressed table")
|
||||
eq(Input.pressQueue, queue, "and its own queue")
|
||||
check(not Input:wasPressed("up"), "cleared in place")
|
||||
local mine = { a = true }
|
||||
Input.pressed = mine
|
||||
Input:step()
|
||||
check(Input.pressed ~= mine and mine.a == true,
|
||||
"a table a test installed is replaced, never emptied")
|
||||
|
||||
-- a pad swapped for another between two polls, same count
|
||||
local function pad(name, connected)
|
||||
return { name = name, connected = connected,
|
||||
isGamepadDown = function() return false end,
|
||||
isConnected = function(self) return self.connected end,
|
||||
isGamepad = function() return true end,
|
||||
getName = function(self) return self.name end,
|
||||
getGUID = function() return "" end,
|
||||
getID = function() return 1 end,
|
||||
getButtonCount = function() return 12 end,
|
||||
getAxisCount = function() return 4 end }
|
||||
end
|
||||
local p1, p2 = pad("one", true), pad("two", true)
|
||||
local current = { p1 }
|
||||
love.joystick = {
|
||||
getJoystickCount = function() return #current end,
|
||||
getJoysticks = function() return current end,
|
||||
}
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
GamepadMap.ignoreRawForJoystick = function() return true end
|
||||
GamepadMap.isAccelerometer = function() return false end
|
||||
Input:pollPads()
|
||||
eq(Input._pollPads[1], p1, "first poll caches the pad")
|
||||
p1.connected = false
|
||||
current = { p2 }
|
||||
Input:pollPads()
|
||||
eq(Input._pollPads[1], p2, "a disconnected cached pad forces a rebuild")
|
||||
current = { p1 }
|
||||
p1.connected = true
|
||||
Input:joysticksChanged()
|
||||
Input:pollPads()
|
||||
eq(Input._pollPads[1], p1, "joysticksChanged drops the cache")
|
||||
love.joystick = nil
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- TextBox
|
||||
do
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local input = {
|
||||
wasPressed = function() return false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
local game = { data = { text = {} }, input = input,
|
||||
save = { generation = 2, options = { textSpeed = 1 } } }
|
||||
local box = TextBox.new(game, "a\nb\nc")
|
||||
local steps = 0
|
||||
while not box.scrollPx and steps < 200 do
|
||||
box:update(1 / 60)
|
||||
steps = steps + 1
|
||||
end
|
||||
eq(box.scrollPx, 6, "the frame that scrolls ends 2px into the slide")
|
||||
local drew = pcall(box.draw, box)
|
||||
if drew then
|
||||
pcall(box.draw, box)
|
||||
eq(box.scrollPx, 6, "draw no longer advances the slide")
|
||||
end
|
||||
box:update(1 / 60)
|
||||
eq(box.scrollPx, 4, "each logic step moves it 2px")
|
||||
box:update(1 / 60)
|
||||
box:update(1 / 60)
|
||||
eq(box.scrollPx, nil, "and it ends after four steps")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- ShaderFX
|
||||
do
|
||||
love.filesystem.write = function() return true end
|
||||
love.graphics.getRendererInfo = function() return "OpenGL", "4.1 stub", "stub", "stub" end
|
||||
love.graphics.getSupported = function() return { glsl3 = true } end
|
||||
love.graphics.flushBatch = function() end
|
||||
love.graphics.validateShader = function() return true end
|
||||
love.timer = love.timer or {}
|
||||
love.timer.getDelta = love.timer.getDelta or function() return 1 / 60 end
|
||||
local sends = {}
|
||||
love.graphics.newShader = function(frag, vert)
|
||||
local sh = { frag = frag, vert = vert }
|
||||
function sh.send(self, name, value) sends[#sends + 1] = name end
|
||||
return sh
|
||||
end
|
||||
local quads = 0
|
||||
love.graphics.newQuad = function(x, y, w, h, sw, sh)
|
||||
quads = quads + 1
|
||||
local q = { x = x, y = y, w = w, h = h }
|
||||
function q.setViewport(self, nx, ny, nw, nh) self.x, self.y, self.w, self.h = nx, ny, nw, nh end
|
||||
return q
|
||||
end
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local ok = ShaderFX.activate("main", { name = "lcd3x.slangp",
|
||||
fullPath = "tests/data/shaderfx/gl/lcd3x.slangp", converted = true })
|
||||
eq(ok, true, "lcd3x activates under the stub")
|
||||
local canvas = setmetatable({ w = 800, h = 720 }, { __index = {
|
||||
getWidth = function(self) return self.w end,
|
||||
getHeight = function(self) return self.h end,
|
||||
getPixelWidth = function(self) return self.w end,
|
||||
getPixelHeight = function(self) return self.h end,
|
||||
} })
|
||||
local rect = { x = 0, y = 0, w = 800, h = 720, scale = 5 }
|
||||
ShaderFX.render(canvas, rect, { w = 160, h = 144 }, 1, 1)
|
||||
local first = #sends
|
||||
check(first > 0, "the first frame sends the pass uniforms")
|
||||
ShaderFX.render(canvas, rect, { w = 160, h = 144 }, 1, 1)
|
||||
eq(#sends, first, "an identical frame re-sends nothing")
|
||||
eq(quads, 1, "the crop quad is re-aimed, not rebuilt")
|
||||
ShaderFX.render(canvas, { x = 0, y = 0, w = 640, h = 576, scale = 4 },
|
||||
{ w = 160, h = 144 }, 1, 1)
|
||||
check(#sends > first, "a size change re-sends what changed")
|
||||
ShaderFX.deactivate("main")
|
||||
end
|
||||
|
||||
T.finish("render_hotpath_caches")
|
||||
@@ -0,0 +1,205 @@
|
||||
-- FieldView native tile SpriteBatches: a full reset reuses (clears) the
|
||||
-- existing batches instead of allocating new ones, an atlas texture swap
|
||||
-- mid-scroll rebuilds instead of indexing a replaced batch, and a single
|
||||
-- metatile write re-samples one cell without a full rebuild.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local failures = 0
|
||||
local function check(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
end
|
||||
|
||||
local created, released = 0, 0
|
||||
local function newBatch(texture)
|
||||
created = created + 1
|
||||
local b = { texture = texture, sprites = {}, released = false }
|
||||
local function live(self)
|
||||
if self.released then error("use of released SpriteBatch", 2) end
|
||||
end
|
||||
function b:add(q, x, y)
|
||||
live(self)
|
||||
self.sprites[#self.sprites + 1] = { q = q, x = x, y = y }
|
||||
return #self.sprites
|
||||
end
|
||||
function b:set(i, q, x, y)
|
||||
live(self)
|
||||
if i < 1 or i > #self.sprites then error("Invalid sprite index: " .. tostring(i), 2) end
|
||||
self.sprites[i] = { q = q, x = x, y = y }
|
||||
end
|
||||
function b:clear() live(self); self.sprites = {} end
|
||||
function b:getCount() return #self.sprites end
|
||||
function b:getTexture() return self.texture end
|
||||
function b:release() released = released + 1; self.released = true end
|
||||
return b
|
||||
end
|
||||
|
||||
local noop = function() end
|
||||
love = love or {}
|
||||
love.graphics = love.graphics or {}
|
||||
love.graphics.newSpriteBatch = function(texture) return newBatch(texture) end
|
||||
love.graphics.setColor = noop
|
||||
love.graphics.rectangle = noop
|
||||
love.graphics.draw = noop
|
||||
|
||||
local Versions = require("src.import.gba.versions")
|
||||
Versions.NATIVE_RENDER = true
|
||||
|
||||
-- Fake native atlases: quad = { pair, slot } so tests can read back mids.
|
||||
local Native = { _pairs = {} }
|
||||
local function atlas(pair)
|
||||
local ts = { pair = pair, image = { name = pair .. "#" .. tostring(math.random()) },
|
||||
layered = false, quads = {} }
|
||||
Native._pairs[pair] = ts
|
||||
return ts
|
||||
end
|
||||
function Native.ready() return true end
|
||||
function Native.get(pair) return Native._pairs[pair] end
|
||||
function Native.slotFor(_, mid) return mid end
|
||||
function Native.hasMid() return true end
|
||||
function Native.quad(ts, slot)
|
||||
local q = ts.quads[slot]
|
||||
if not q then q = { pair = ts.pair, slot = slot }; ts.quads[slot] = q end
|
||||
return q
|
||||
end
|
||||
function Native.overQuad() return nil end
|
||||
package.loaded["src.core.game3.tileset_native"] = Native
|
||||
package.loaded["src.core.game3.tileset_anim"] = { setVisiblePairs = noop }
|
||||
|
||||
-- Primary map "A" (pair a) covers x >= 0; everything west of it is pair b.
|
||||
local overrides = {}
|
||||
local layout = { width = 64, height = 64, pair = "a" }
|
||||
function layout:midAt(x, y) return overrides[y * 1024 + x] or ((x + y) % 7) end
|
||||
local mapDef = { midLayout = layout, pair = "a" }
|
||||
local Map = { world = {}, neighborList = {} }
|
||||
function Map.worldMidAt(wx, wy, def)
|
||||
if wx < 0 then return (wx * 3 + wy) % 5, "b" end
|
||||
return def.midLayout:midAt(wx, wy), "a"
|
||||
end
|
||||
package.loaded["src.core.game3.map"] = Map
|
||||
|
||||
local FieldView = require("src.core.game3.field_view")
|
||||
local drawNativeTiles
|
||||
for i = 1, 200 do
|
||||
local name, fn = debug.getupvalue(FieldView.draw, i)
|
||||
if not name then break end
|
||||
if name == "drawNativeTiles" then drawNativeTiles = fn break end
|
||||
end
|
||||
check(type(drawNativeTiles) == "function", "found drawNativeTiles")
|
||||
if not drawNativeTiles then os.exit(1) end
|
||||
|
||||
local W, H = 240, 160
|
||||
local function cellSprite(wx, wy)
|
||||
local info = FieldView._nativeVisibleCells[wy] and FieldView._nativeVisibleCells[wy][wx]
|
||||
local batch = info and info.under and FieldView._nativeBatches[info.under.key]
|
||||
return batch and batch.sprites[info.under.index], info
|
||||
end
|
||||
|
||||
local function allCellsValid()
|
||||
for wy, row in pairs(FieldView._nativeVisibleCells) do
|
||||
for wx, info in pairs(row) do
|
||||
if info.under then
|
||||
local batch = FieldView._nativeBatches[info.under.key]
|
||||
if not batch or batch.released or info.under.index > #batch.sprites then return false end
|
||||
local s = batch.sprites[info.under.index]
|
||||
local mid = Map.worldMidAt(wx, wy, mapDef)
|
||||
if s.q.slot ~= mid or s.x ~= (wx - FieldView._nativeBaseBx) * 16 then return false end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
atlas("a")
|
||||
atlas("b")
|
||||
check(drawNativeTiles(mapDef, 0, 0, W, H) == true, "first native draw")
|
||||
local first = FieldView._nativeBatches
|
||||
local batchA, batchB = first.a, first.b
|
||||
local createdAfterFirst = created
|
||||
check(batchA and batchB, "one batch per visible pair")
|
||||
check(allCellsValid(), "first build samples every visible cell")
|
||||
|
||||
-- 1. full reset keeps the same SpriteBatch objects
|
||||
FieldView._nativeDirty = true
|
||||
drawNativeTiles(mapDef, 0, 0, W, H)
|
||||
check(created == createdAfterFirst, "dirty reset allocates no new SpriteBatch")
|
||||
check(FieldView._nativeBatches == first and first.a == batchA and first.b == batchB,
|
||||
"dirty reset reuses the batch store and batches")
|
||||
check(allCellsValid(), "reset rebuild samples every visible cell")
|
||||
local cols, rows = math.ceil(W / 16) + 3, math.ceil(H / 16) + 3
|
||||
check(batchA:getCount() + batchB:getCount() == cols * rows, "reset clears before refilling")
|
||||
|
||||
-- 2. scrolling reuses freed slots; the slot counter stays bounded
|
||||
for step = 1, 40 do drawNativeTiles(mapDef, step * 4, 0, W, H) end
|
||||
for step = 40, 1, -1 do drawNativeTiles(mapDef, step * 4, step, W, H) end
|
||||
check(allCellsValid(), "scrolling keeps every cell's sprite valid")
|
||||
check(FieldView._nativeSpriteSlots <= cols * rows * 4, "slot counter bounded by the reset heuristic")
|
||||
|
||||
-- 3. a pair scrolled fully out of view is pruned (released) on the next reset
|
||||
local goneB = FieldView._nativeBatches.b
|
||||
FieldView._nativeDirty = true
|
||||
drawNativeTiles(mapDef, 400, 0, W, H)
|
||||
check(goneB and FieldView._nativeBatches.b == nil and goneB.released,
|
||||
"reset releases a batch no visible cell uses")
|
||||
|
||||
-- 4. neighbour atlas reloaded mid-scroll: rebuild, never index a new batch
|
||||
drawNativeTiles(mapDef, -8, 0, W, H)
|
||||
local oldB = FieldView._nativeBatches.b
|
||||
check(oldB ~= nil, "pair b batch back in view")
|
||||
local freshB = atlas("b")
|
||||
local ok, err = pcall(drawNativeTiles, mapDef, -40, 0, W, H)
|
||||
check(ok, "texture swap mid-scroll does not crash (" .. tostring(err) .. ")")
|
||||
check(FieldView._nativeBatches.b ~= oldB and FieldView._nativeBatches.b:getTexture() == freshB.image,
|
||||
"swapped pair gets a batch on the new texture")
|
||||
check(oldB.released, "old batch released after the swap")
|
||||
ok, err = pcall(function()
|
||||
for step = 1, 30 do drawNativeTiles(mapDef, -40 + step * 8, step, W, H) end
|
||||
end)
|
||||
check(ok, "scrolling after the swap releases cells safely (" .. tostring(err) .. ")")
|
||||
check(allCellsValid(), "cells valid after swap + scroll")
|
||||
|
||||
-- 5. primary atlas reloaded with no scroll: rebuild onto the new texture
|
||||
local freshA = atlas("a")
|
||||
drawNativeTiles(mapDef, 200, 40, W, H)
|
||||
check(FieldView._nativeBatches.a:getTexture() == freshA.image, "primary atlas swap rebuilds")
|
||||
check(allCellsValid(), "cells valid after primary swap")
|
||||
|
||||
-- 6. one metatile write re-samples just that cell
|
||||
local clearsBefore = created
|
||||
local base = FieldView._nativeBaseBx
|
||||
local cx, cy = 20, 6
|
||||
overrides[cy * 1024 + cx] = 99
|
||||
FieldView.invalidateLayoutCell(layout, cx, cy)
|
||||
check(FieldView._nativeDirty == false, "cell write does not force a full rebuild")
|
||||
local countBefore = FieldView._nativeBatches.a:getCount()
|
||||
drawNativeTiles(mapDef, 200, 40, W, H)
|
||||
local s = cellSprite(cx, cy)
|
||||
check(s and s.q.slot == 99, "written cell shows the new metatile")
|
||||
check(FieldView._nativeBaseBx == base and FieldView._nativeBatches.a:getCount() == countBefore
|
||||
and created == clearsBefore, "cell write reused a freed slot without a rebuild")
|
||||
check(allCellsValid(), "all cells valid after cell write")
|
||||
|
||||
-- a layout that is not the drawn map's falls back to a full rebuild
|
||||
FieldView.invalidateLayoutCell({}, 1, 1)
|
||||
check(FieldView._nativeDirty == true, "foreign layout write forces a full rebuild")
|
||||
drawNativeTiles(mapDef, 200, 40, W, H)
|
||||
|
||||
-- LayoutNative wiring: applyOverride invalidates a single cell
|
||||
local LayoutNative = require("src.core.game3.layout_native")
|
||||
local real = LayoutNative.fromDecoded({ width = 64, height = 64, cells = {} }, "T", "a")
|
||||
local realDef = { midLayout = real, pair = "a" }
|
||||
drawNativeTiles(realDef, 0, 0, W, H)
|
||||
real:applyOverride(3, 4, 42, 0, 0)
|
||||
check(FieldView._nativeDirty == false and FieldView._nativeDirtyCells
|
||||
and FieldView._nativeDirtyCells[1] == 3 and FieldView._nativeDirtyCells[2] == 4,
|
||||
"applyOverride queues one dirty cell")
|
||||
Map.worldMidAt = function(wx, wy, def) return def.midLayout:midAt(wx, wy), "a" end
|
||||
drawNativeTiles(realDef, 0, 0, W, H)
|
||||
local info = FieldView._nativeVisibleCells[4][3]
|
||||
check(info and FieldView._nativeBatches.a.sprites[info.under.index].q.slot == 42,
|
||||
"applyOverride cell redrawn with the new metatile")
|
||||
real:clearOverrides()
|
||||
check(FieldView._nativeDirty == true, "clearOverrides still rebuilds everything")
|
||||
|
||||
print(failures == 0 and "PASS game3_native_batch_reuse_test" or ("FAIL game3_native_batch_reuse_test failures=" .. failures))
|
||||
os.exit(failures == 0 and 0 or 1)
|
||||
@@ -0,0 +1,180 @@
|
||||
-- Per-frame caches in the game3 overworld: OAM sort reuse, ghost pool sync /
|
||||
-- camera-range ticking, one-cell collision patch, trainer id negative cache
|
||||
-- and seamless tileset pair warming.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local failures = 0
|
||||
local function check(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
end
|
||||
|
||||
-- OAM ---------------------------------------------------------------------
|
||||
do
|
||||
local Oam = require("src.core.game3.oam")
|
||||
Oam.reset()
|
||||
local img = {}
|
||||
local function spawn(y, pri)
|
||||
local id, s = Oam.createSprite({ oam = { shape = 0, size = 1, priority = pri or 0 } }, 10, y, 0)
|
||||
s.image = img
|
||||
return id, s
|
||||
end
|
||||
local _, a = spawn(40)
|
||||
local _, b = spawn(20)
|
||||
local _, c = spawn(30, 1)
|
||||
Oam.resetFrame()
|
||||
check(#Oam._buffer == 0, "resetFrame empties the frame buffer")
|
||||
local buf = Oam.buildOamBuffer()
|
||||
check(#buf == 3 and buf[1] == b and buf[2] == a and buf[3] == c, "sorted by priority then y")
|
||||
local sorted = Oam._sorted
|
||||
Oam.resetFrame()
|
||||
check(Oam._buffer == buf and #buf == 0, "resetFrame clears in place")
|
||||
local buf2 = Oam.buildOamBuffer()
|
||||
check(buf2 == buf and Oam._sorted == sorted and buf2[1] == b and buf2[3] == c,
|
||||
"unchanged scene reuses the sorted cache")
|
||||
a.y = 0
|
||||
Oam.resetFrame()
|
||||
buf2 = Oam.buildOamBuffer()
|
||||
check(buf2[1] == a and buf2[2] == b and buf2[3] == c, "moved sprite re-sorts")
|
||||
a.invisible = true
|
||||
Oam.resetFrame()
|
||||
buf2 = Oam.buildOamBuffer()
|
||||
check(#buf2 == 2 and buf2[1] == b, "hidden sprite leaves the buffer")
|
||||
a.invisible = false
|
||||
c.image = nil
|
||||
local _, d = spawn(20)
|
||||
Oam.resetFrame()
|
||||
buf2 = Oam.buildOamBuffer()
|
||||
local seen = {}
|
||||
for _, s in ipairs(buf2) do seen[s] = true end
|
||||
check(#buf2 == 3 and seen[d] and seen[a] and seen[b] and not seen[c],
|
||||
"swapped sprite with equal count rebuilds the set")
|
||||
Oam.reset()
|
||||
check(Oam._sorted == nil, "reset drops the sort cache")
|
||||
end
|
||||
|
||||
-- Ghosts ------------------------------------------------------------------
|
||||
do
|
||||
local spawned, ticked = 0, {}
|
||||
local Objects = {
|
||||
spawnFromDefs = function(defs, def, id)
|
||||
spawned = spawned + 1
|
||||
return { order = {}, byId = {}, id = id }
|
||||
end,
|
||||
tickPool = function(pool) ticked[pool.id] = (ticked[pool.id] or 0) + 1 end,
|
||||
playerBlocks = function() return false end,
|
||||
}
|
||||
local Map = { world = {} }
|
||||
local Player = { cellX = 5, cellY = 5 }
|
||||
package.loaded["src.core.game3.objects"] = Objects
|
||||
package.loaded["src.core.game3.map"] = Map
|
||||
package.loaded["src.core.game3.player"] = Player
|
||||
package.loaded["src.core.game3.field_view"] = { _viewW = 240, _viewH = 160 }
|
||||
local Ghosts = require("src.core.game3.ghosts")
|
||||
local function entry(id, ox, oy)
|
||||
return { id = id, ox = ox, oy = oy,
|
||||
def = { objects = { {} }, midLayout = { width = 20, height = 20 } } }
|
||||
end
|
||||
Map.world = { entry("NEAR", 20, 0), entry("FAR", 200, 0), entry("NORTH", 0, -20) }
|
||||
Ghosts.sync()
|
||||
check(spawned == 3, "sync spawns a pool per world map")
|
||||
for _ = 1, 5 do Ghosts.sync() end
|
||||
check(spawned == 3, "sync with an unchanged world does not respawn")
|
||||
Ghosts._pools.NORTH = nil
|
||||
Ghosts.sync()
|
||||
check(spawned == 4 and Ghosts._pools.NORTH, "externally dropped pool is respawned")
|
||||
Ghosts._pools.EXTRA = { order = {}, byId = {} }
|
||||
Ghosts.sync()
|
||||
check(Ghosts._pools.EXTRA == nil, "pool outside the world is dropped")
|
||||
Map.world = { Map.world[1] }
|
||||
Ghosts.sync()
|
||||
check(Ghosts._pools.FAR == nil and Ghosts._pools.NEAR, "new world table resyncs")
|
||||
Map.world = { entry("NEAR", 20, 0), entry("FAR", 200, 0) }
|
||||
Ghosts.clear()
|
||||
Ghosts.sync()
|
||||
check(Ghosts._pools.NEAR and Ghosts._pools.FAR, "clear then sync respawns")
|
||||
Ghosts.update({})
|
||||
check(ticked.NEAR == 1 and ticked.FAR == nil, "only pools near the camera tick")
|
||||
Player.cellX = 190
|
||||
Ghosts.update({})
|
||||
check(ticked.FAR == 1, "a pool ticks again once the camera is back in range")
|
||||
Ghosts.clear()
|
||||
package.loaded["src.core.game3.objects"] = nil
|
||||
package.loaded["src.core.game3.map"] = nil
|
||||
package.loaded["src.core.game3.player"] = nil
|
||||
package.loaded["src.core.game3.field_view"] = nil
|
||||
end
|
||||
|
||||
-- Collision.patchCell -----------------------------------------------------
|
||||
do
|
||||
local Collision = require("src.core.game3.collision")
|
||||
local LayoutNative = require("src.core.game3.layout_native")
|
||||
local cells = {}
|
||||
for i = 1, 16 do cells[i] = { mid = 1, coll = 0, elev = 0 } end
|
||||
local layout = LayoutNative.fromDecoded({ width = 4, height = 4, cells = cells }, "T", "p")
|
||||
local def = { midLayout = layout, warps = { { x = 2, y = 2 } } }
|
||||
Collision.bindMap(nil, "T", def)
|
||||
local grid = Collision._grid
|
||||
layout:applyOverride(1, 1, 5, 0x07, 0)
|
||||
check(Collision.patchCell("T", def, 1, 1) and Collision._grid == grid and grid[1 * 4 + 1 + 1] == 0x07,
|
||||
"patchCell updates one grid entry in place")
|
||||
local full = layout:collArray()
|
||||
local same = true
|
||||
for i = 1, 16 do if full[i] ~= grid[i] then same = false end end
|
||||
check(same, "patched grid matches a full rebuild")
|
||||
check(not Collision.patchCell("T", def, 2, 2), "warp cell falls back to bindMap")
|
||||
check(not Collision.patchCell("OTHER", def, 1, 1), "other bound map falls back")
|
||||
check(not Collision.patchCell("T", def, 9, 1), "off-grid cell falls back")
|
||||
end
|
||||
|
||||
-- TrainerSight negative cache ----------------------------------------------
|
||||
do
|
||||
local Space = require("src.core.game3.scripting.space")
|
||||
local prevBundle = Space.bundle
|
||||
local list = { { op = "msgbox" } }
|
||||
Space.bundle = { scripts = { S = list } }
|
||||
local TrainerSight = require("src.core.game3.trainer_sight")
|
||||
local eo = { scriptKey = "S" }
|
||||
check(TrainerSight.getTrainerId(eo) == nil and eo._trainerIdMiss == list, "first scan misses")
|
||||
-- same list table: the cached miss stands without rescanning
|
||||
list[2] = { op = "trainerbattle", trainer = 3 }
|
||||
check(TrainerSight.getTrainerId(eo) == nil, "repeat lookups hit the negative cache")
|
||||
Space.bundle.scripts.S = { { op = "trainerbattle", trainer = 7 } }
|
||||
check(TrainerSight.getTrainerId(eo) == 7, "replaced script is rescanned")
|
||||
Space.bundle = prevBundle
|
||||
end
|
||||
|
||||
-- Map warm queue ----------------------------------------------------------
|
||||
do
|
||||
local loads = {}
|
||||
local Native = { _pairs = { loaded = {} } }
|
||||
function Native.ready() return true end
|
||||
function Native.get(pair) loads[#loads + 1] = pair; Native._pairs[pair] = {}; return {} end
|
||||
package.loaded["src.core.game3.tileset_native"] = Native
|
||||
local Map = require("src.core.game3.map")
|
||||
local function def(pair) return { pair = pair, midLayout = { pair = pair } } end
|
||||
Map.computeWorld = function()
|
||||
return { { id = "A", def = def("loaded") }, { id = "B", def = def("p1") },
|
||||
{ id = "C", def = def("p2") }, { id = "D", def = def("p1") } }
|
||||
end
|
||||
Map.ensureMidLayout = function() end
|
||||
Map._warmPairs = nil
|
||||
Map._worldRoot = nil
|
||||
Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT")
|
||||
check(#loads == 0 and Map._warmQueue and #Map._warmQueue == 2, "seamless refresh queues unloaded pairs")
|
||||
Map.stepWarm()
|
||||
check(#loads == 1 and loads[1] == "p1", "stepWarm loads one pair per call")
|
||||
Map.stepWarm()
|
||||
check(#loads == 2 and loads[2] == "p2" and Map._warmQueue == nil, "queue drains")
|
||||
Map._warmPairs = true
|
||||
Map._worldRoot = nil
|
||||
Native._pairs = {}
|
||||
loads = {}
|
||||
Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT")
|
||||
check(#loads == 4 and Map._warmQueue == nil, "non-seamless load still warms synchronously")
|
||||
package.loaded["src.core.game3.tileset_native"] = nil
|
||||
end
|
||||
|
||||
print(failures == 0 and "PASS game3_overworld_perf_caches_test"
|
||||
or ("FAIL game3_overworld_perf_caches_test failures=" .. failures))
|
||||
os.exit(failures == 0 and 0 or 1)
|
||||
@@ -0,0 +1,296 @@
|
||||
-- tests/game3_render_caches_test.lua
|
||||
-- Per-frame render caches in the Gen 3 battle system and UI: each one must
|
||||
-- stay correct when what it caches changes.
|
||||
-- * anim_vm: the anim bg and sprites share one blend shader; a sprite drawn
|
||||
-- after the bg must re-send its own tint (the uniform cache used to miss
|
||||
-- the bg's send, so the sprite drew with the bg's tint).
|
||||
-- * anim_vm: band draws inside a frame bracket reuse one sorted list.
|
||||
-- * pokemon.lua: a missing pic file is probed once, not every frame.
|
||||
-- * trainer_card RSE: the front pic and badge quads are built once.
|
||||
-- * rom_text: plain() memoises pure IRs and follows overrides.
|
||||
-- * bag: listPocket rows are reused until the pocket changes.
|
||||
|
||||
package.path = package.path .. ";./?.lua"
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function test(name, fn)
|
||||
local ok, err = pcall(fn)
|
||||
if ok then
|
||||
passed = passed + 1
|
||||
print(" PASS: " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print(" FAIL: " .. name .. " -> " .. tostring(err))
|
||||
end
|
||||
end
|
||||
local function eq(a, b, msg)
|
||||
if a ~= b then
|
||||
error(string.format("%s: expected %s, got %s", msg or "eq", tostring(b), tostring(a)), 2)
|
||||
end
|
||||
end
|
||||
|
||||
-- Minimal love.graphics that records shader uniforms and draws.
|
||||
local counts = { newImageData = 0, newImage = 0, newQuad = 0, draws = 0, fsRead = 0 }
|
||||
local Image = {}
|
||||
Image.__index = Image
|
||||
function Image:getDimensions() return self.w, self.h end
|
||||
function Image:setFilter() end
|
||||
function Image:setWrap() end
|
||||
local Quad = {}
|
||||
Quad.__index = Quad
|
||||
function Quad:setViewport(x, y, w, h) self.x, self.y, self.w, self.h = x, y, w, h end
|
||||
function Quad:getViewport() return self.x, self.y, self.w, self.h end
|
||||
local shaders = {}
|
||||
local drawLog = {}
|
||||
local currentShader = nil
|
||||
love = {
|
||||
graphics = {
|
||||
newImage = function(data)
|
||||
counts.newImage = counts.newImage + 1
|
||||
return setmetatable({ w = data and data.w or 64, h = data and data.h or 64 }, Image)
|
||||
end,
|
||||
newQuad = function(x, y, w, h)
|
||||
counts.newQuad = counts.newQuad + 1
|
||||
return setmetatable({ x = x, y = y, w = w, h = h }, Quad)
|
||||
end,
|
||||
newShader = function()
|
||||
local sh = { uniforms = {}, sends = 0 }
|
||||
function sh:send(name, v)
|
||||
self.sends = self.sends + 1
|
||||
if type(v) == "table" then v = { v[1], v[2], v[3] } end
|
||||
self.uniforms[name] = v
|
||||
end
|
||||
shaders[#shaders + 1] = sh
|
||||
return sh
|
||||
end,
|
||||
setShader = function(sh) currentShader = sh end,
|
||||
getShader = function() return currentShader end,
|
||||
draw = function(img)
|
||||
counts.draws = counts.draws + 1
|
||||
local sh = currentShader
|
||||
drawLog[#drawLog + 1] = { img = img, coeff = sh and sh.uniforms.coeff,
|
||||
target = sh and sh.uniforms.target and table.concat(sh.uniforms.target, ",") }
|
||||
end,
|
||||
setColor = function() end,
|
||||
getColor = function() return 1, 1, 1, 1 end,
|
||||
setBlendMode = function() end,
|
||||
rectangle = function() end,
|
||||
push = function() end,
|
||||
pop = function() end,
|
||||
translate = function() end,
|
||||
scale = function() end,
|
||||
},
|
||||
image = {
|
||||
newImageData = function(w, h)
|
||||
counts.newImageData = counts.newImageData + 1
|
||||
return { w = w, h = h }
|
||||
end,
|
||||
},
|
||||
filesystem = {
|
||||
read = function(path)
|
||||
counts.fsRead = counts.fsRead + 1
|
||||
if path:find("trainers/front/", 1, true) then return string.rep("\0", 64 * 64 * 4) end
|
||||
return nil
|
||||
end,
|
||||
},
|
||||
timer = { getTime = function() return 0 end },
|
||||
}
|
||||
|
||||
-- anim_vm -----------------------------------------------------------------
|
||||
local AnimVm = require("src.core.game3.battle.anim_vm")
|
||||
local AnimSprites = require("src.core.game3.battle.anim_sprites")
|
||||
local AnimTasks = require("src.core.game3.battle.anim_tasks")
|
||||
local AnimPal = require("src.core.game3.battle.anim_pal")
|
||||
|
||||
local function reset_anim()
|
||||
AnimSprites.reset()
|
||||
AnimTasks.reset()
|
||||
for i = #drawLog, 1, -1 do drawLog[i] = nil end
|
||||
end
|
||||
|
||||
test("sprite drawn after a tinted anim bg re-sends its own blend", function()
|
||||
reset_anim()
|
||||
local vm = AnimVm.new()
|
||||
local bgImg = setmetatable({ w = 256, h = 256, name = "bg" }, Image)
|
||||
local spriteImg = setmetatable({ w = 32, h = 32, name = "sprite" }, Image)
|
||||
local realBgImage, realBgColors = AnimVm.animBgImage, AnimPal.bgColors
|
||||
AnimVm.animBgImage = function() return bgImg end
|
||||
AnimPal.bgColors = function() return nil end
|
||||
local ok, err = pcall(function()
|
||||
vm._animBgId = 1
|
||||
vm._animBgBlend = { coeff = 12, color = 0x001F } -- red, from the bg
|
||||
local s = AnimSprites._pool[1]
|
||||
s.active, s.visible, s.image = true, true, spriteImg
|
||||
s.x, s.y, s.z = 100, 50, 150
|
||||
s.palBlend = { coeff = 6, color = 0x7C00 } -- blue, the sprite's own
|
||||
for _ = 1, 3 do vm:draw(0, 999) end
|
||||
local sprites = 0
|
||||
for _, d in ipairs(drawLog) do
|
||||
if d.img == spriteImg then
|
||||
sprites = sprites + 1
|
||||
eq(d.coeff, 6, "sprite coeff")
|
||||
eq(d.target, "0,0,31", "sprite target")
|
||||
elseif d.img == bgImg then
|
||||
eq(d.coeff, 12, "bg coeff")
|
||||
eq(d.target, "31,0,0", "bg target")
|
||||
end
|
||||
end
|
||||
eq(sprites, 3, "sprite draws")
|
||||
end)
|
||||
AnimVm.animBgImage, AnimPal.bgColors = realBgImage, realBgColors
|
||||
vm._animBgId = nil
|
||||
if not ok then error(err, 0) end
|
||||
end)
|
||||
|
||||
test("band draws in a frame bracket slice one sorted list", function()
|
||||
reset_anim()
|
||||
local vm = AnimVm.new()
|
||||
local imgs = {}
|
||||
local zs = { 150, 50, 250, 120, 50 }
|
||||
for i, z in ipairs(zs) do
|
||||
local s = AnimSprites._pool[i]
|
||||
imgs[i] = setmetatable({ w = 8, h = 8, id = i }, Image)
|
||||
s.active, s.visible, s.image, s.x, s.y, s.z = true, true, imgs[i], 0, 0, z
|
||||
end
|
||||
local function order(bracket)
|
||||
for i = #drawLog, 1, -1 do drawLog[i] = nil end
|
||||
if bracket then vm:beginDrawFrame() end
|
||||
vm:draw(0, 99)
|
||||
vm:draw(101, 199)
|
||||
vm:draw(201, 999)
|
||||
if bracket then vm:endDrawFrame() end
|
||||
local out = {}
|
||||
for _, d in ipairs(drawLog) do out[#out + 1] = d.img.id end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
local plain = order(false)
|
||||
-- z ascending, ties by higher pool index first (battle_anim.c:630 order)
|
||||
eq(plain, "5,2,4,1,3", "unbracketed order")
|
||||
eq(order(true), plain, "bracketed order")
|
||||
-- a change between frames is picked up by the next bracket
|
||||
AnimSprites._pool[3].z = 10
|
||||
eq(order(true), "3,5,2,4,1", "next frame sees the new z")
|
||||
-- z 100 / 200 stay outside every band, as before
|
||||
AnimSprites._pool[1].z = 100
|
||||
eq(order(true), "3,5,2,4", "z=100 is still not drawn by the bands")
|
||||
end)
|
||||
|
||||
-- pokemon pics --------------------------------------------------------------
|
||||
local Pokemon = require("src.core.game3.pokemon")
|
||||
|
||||
test("missing pic is probed once and re-probed after invalidate", function()
|
||||
local reads = 0
|
||||
local savedCache = Pokemon._cache
|
||||
Pokemon._cache = { read = function() reads = reads + 1 return nil end }
|
||||
Pokemon._back = {}
|
||||
Pokemon._front = {}
|
||||
eq(Pokemon.backPic(25), nil, "missing back pic")
|
||||
eq(Pokemon.backPic(25), nil, "missing back pic again")
|
||||
eq(Pokemon.frontPic(25), nil, "missing front pic")
|
||||
eq(Pokemon.frontPic(25), nil, "missing front pic again")
|
||||
eq(reads, 2, "one read per missing pic")
|
||||
Pokemon._front = {}
|
||||
Pokemon._back = nil
|
||||
eq(Pokemon.backPic(25), nil, "after reset")
|
||||
eq(reads, 3, "a cache reset probes again")
|
||||
Pokemon._cache = savedCache
|
||||
Pokemon._front = {}
|
||||
Pokemon._back = nil
|
||||
end)
|
||||
|
||||
test("present pic is still loaded and cached", function()
|
||||
local reads = 0
|
||||
local savedCache = Pokemon._cache
|
||||
Pokemon._cache = { read = function() reads = reads + 1 return string.rep("\0", 64 * 64 * 4) end }
|
||||
Pokemon._front = {}
|
||||
local a = Pokemon.frontPic(4)
|
||||
local b = Pokemon.frontPic(4)
|
||||
if not a then error("expected a pic entry", 0) end
|
||||
eq(a, b, "same entry")
|
||||
eq(reads, 1, "one read")
|
||||
Pokemon._cache = savedCache
|
||||
Pokemon._front = {}
|
||||
end)
|
||||
|
||||
-- trainer card (RSE) ----------------------------------------------------------
|
||||
test("RSE trainer card builds its pic and badge quads once", function()
|
||||
local realKit = package.loaded["src.ui.game3.rse.scene_kit"]
|
||||
local badgeImg = setmetatable({ w = 128, h = 16 }, Image)
|
||||
package.loaded["src.ui.game3.rse.scene_kit"] = {
|
||||
manifest = function()
|
||||
return { layers = {}, pics = { male = 71, female = 72 }, picOffset = { 0, 0 },
|
||||
star = { png = "star" }, badges = { png = "badges" } }
|
||||
end,
|
||||
image = function(name) return name == "badges" and badgeImg or nil end,
|
||||
}
|
||||
local TrainerCard = require("src.ui.game3.trainer_card")
|
||||
local realFront = TrainerCard.frontTextsRse
|
||||
TrainerCard.frontTextsRse = function() return {} end
|
||||
local ok, err = pcall(function()
|
||||
TrainerCard.open, TrainerCard._rse, TrainerCard.side, TrainerCard._flip = true, true, "front", nil
|
||||
TrainerCard._card = { female = false, stars = 0,
|
||||
badges = { true, false, true, true, false, false, false, true } }
|
||||
local before = { img = counts.newImageData, quad = counts.newQuad }
|
||||
for _ = 1, 5 do TrainerCard.draw() end
|
||||
eq(counts.newImageData - before.img, 1, "pic decoded once")
|
||||
eq(counts.newQuad - before.quad, 8, "badge quads built once")
|
||||
TrainerCard._card.female = true
|
||||
TrainerCard.draw()
|
||||
TrainerCard.draw()
|
||||
eq(counts.newImageData - before.img, 2, "the other pic decoded once")
|
||||
end)
|
||||
TrainerCard.frontTextsRse = realFront
|
||||
TrainerCard.open, TrainerCard._rse, TrainerCard._card = false, nil, nil
|
||||
package.loaded["src.ui.game3.rse.scene_kit"] = realKit
|
||||
if not ok then error(err, 0) end
|
||||
end)
|
||||
|
||||
-- rom_text ------------------------------------------------------------------
|
||||
local RomText = require("src.core.game3.rom_text")
|
||||
local TextIR = require("src.core.game3.scripting.text_ir")
|
||||
|
||||
test("plain() follows overrides and ignores cached text for impure IRs", function()
|
||||
RomText.overrides.TEST_CACHE_KEY = TextIR.fromAscii("HELLO")
|
||||
eq(RomText.plain("TEST_CACHE_KEY"), "HELLO", "first")
|
||||
eq(RomText.plain("TEST_CACHE_KEY"), "HELLO", "memoised")
|
||||
RomText.overrides.TEST_CACHE_KEY = TextIR.fromAscii("WORLD")
|
||||
eq(RomText.plain("TEST_CACHE_KEY"), "WORLD", "override replaced")
|
||||
local ir = { { t = "text", s = "A " }, { t = "strvar", n = 1 }, { t = "eos" } }
|
||||
RomText.overrides.TEST_CACHE_KEY = ir
|
||||
eq(RomText.plain("TEST_CACHE_KEY", { stringVars = { "X" } }), "A X", "with ctx")
|
||||
eq(RomText.plain("TEST_CACHE_KEY", { stringVars = { "Y" } }), "A Y", "ctx still honoured")
|
||||
RomText.overrides.TEST_CACHE_KEY = nil
|
||||
end)
|
||||
|
||||
-- bag rows --------------------------------------------------------------------
|
||||
local okData = pcall(function()
|
||||
require("tests.game3_cache").root("items/pack.lua")
|
||||
end)
|
||||
local ItemsData = require("src.core.game3.items_data")
|
||||
local Bag = require("src.core.game3.bag")
|
||||
local haveItems = okData and pcall(ItemsData.ensureLoaded)
|
||||
if haveItems then
|
||||
test("listPocket reuses rows until the pocket changes", function()
|
||||
local bag = Bag.new and Bag.new() or {}
|
||||
Bag.add(bag, 13, 3) -- POTION
|
||||
local a = Bag.listPocket(bag, "ITEMS")
|
||||
local b = Bag.listPocket(bag, "ITEMS")
|
||||
eq(a, b, "same rows while unchanged")
|
||||
eq(#a, 1, "one row")
|
||||
eq(a[1].qty, 3, "qty")
|
||||
Bag.add(bag, 13, 2)
|
||||
local c = Bag.listPocket(bag, "ITEMS")
|
||||
if c == a then error("rows not rebuilt after add", 0) end
|
||||
eq(c[1].qty, 5, "qty after add")
|
||||
eq(a[1].qty, 3, "old rows untouched")
|
||||
bag.pockets.ITEMS[1].qty = 7 -- direct slot edit (save load, scripts)
|
||||
eq(Bag.listPocket(bag, "ITEMS")[1].qty, 7, "direct edit seen")
|
||||
Bag.remove(bag, 13, 7)
|
||||
eq(#Bag.listPocket(bag, "ITEMS"), 0, "empty after remove")
|
||||
end)
|
||||
else
|
||||
print(" SKIP: listPocket (items pack not in the cache)")
|
||||
end
|
||||
|
||||
print(string.format("game3_render_caches_test: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env luajit
|
||||
-- Gen 3 Audio.playSe caches the static Source per (id, pan, gain, mono,
|
||||
-- loop): a repeat SE neither re-bakes nor rebuilds SoundData / Source, the
|
||||
-- player-slot bookkeeping (_seSources/_seByPlayer/_seMeta) behaves as with a
|
||||
-- fresh Source, and anything baked into the PCM (pan, volume) still misses.
|
||||
-- luajit tests/game3_se_source_cache_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("game3 se source cache")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local built, sources = 0, 0
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true; self.plays = self.plays + 1 end
|
||||
function Source:stop() self.playing = false; self.stops = self.stops + 1 end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:setVolume(v) self.volume = v end
|
||||
function Source:getVolume() return self.volume or 1 end
|
||||
function Source:setLooping(v) self.looping = v end
|
||||
function Source:clone()
|
||||
sources = sources + 1
|
||||
return setmetatable({ sd = self.sd, plays = 0, stops = 0, clone = true },
|
||||
Source)
|
||||
end
|
||||
love.audio = love.audio or {}
|
||||
love.audio.newSource = function(sd)
|
||||
sources = sources + 1
|
||||
return setmetatable({ sd = sd, plays = 0, stops = 0 }, Source)
|
||||
end
|
||||
local realNewSoundData = love.sound.newSoundData
|
||||
love.sound.newSoundData = function(...)
|
||||
built = built + 1
|
||||
return realNewSoundData(...)
|
||||
end
|
||||
|
||||
local Player = require("src.core.game3.m4a_player")
|
||||
local bakes = 0
|
||||
Player.songInfo = function(_, id)
|
||||
return { kind = "se", player = (id == 7) and 2 or 1 }
|
||||
end
|
||||
Player.start = function() return true end
|
||||
Player.bakeSlot = function()
|
||||
bakes = bakes + 1
|
||||
local L, R = {}, {}
|
||||
for i = 1, 64 do L[i] = math.sin(i) * 0.5; R[i] = math.cos(i) * 0.5 end
|
||||
return L, R
|
||||
end
|
||||
|
||||
local Audio = require("src.core.game3.audio")
|
||||
Audio._ready = true
|
||||
Audio._pack = {}
|
||||
Audio._cache = {}
|
||||
Audio._seRawClear()
|
||||
|
||||
local function live(id)
|
||||
local n = 0
|
||||
for _, src in ipairs(Audio._seSources) do
|
||||
local meta = Audio._seMeta[src]
|
||||
if meta and meta.id == id then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
check(Audio.playSe(5), "first play")
|
||||
local first = Audio._seByPlayer[1]
|
||||
eq(bakes, 1, "first play bakes")
|
||||
eq(sources, 1, "first play builds one Source")
|
||||
check(first and first.playing, "first play is sounding")
|
||||
|
||||
check(Audio.playSe(5), "repeat play")
|
||||
eq(bakes, 1, "repeat does not re-bake")
|
||||
eq(sources, 1, "repeat reuses the cached Source")
|
||||
check(Audio._seByPlayer[1] == first, "same Source on its player slot")
|
||||
check(first.playing and first.plays == 2, "cached Source restarted")
|
||||
eq(live(5), 1, "one live entry for the SE (the previous play was replaced)")
|
||||
check(Audio._seMeta[first] and Audio._seMeta[first].id == 5
|
||||
and Audio._seMeta[first].player == 1, "meta rebuilt for the replay")
|
||||
|
||||
-- a different SE on the same player replaces it, then the first comes back
|
||||
local builtBefore = built
|
||||
Audio.playSe(6)
|
||||
check(not first.playing, "player 1 SE replaced stops the cached Source")
|
||||
check(Audio._seMeta[first] == nil, "replaced Source forgotten")
|
||||
Audio.playSe(5)
|
||||
check(Audio._seByPlayer[1] == first and first.playing,
|
||||
"cached Source reused after being replaced")
|
||||
eq(built - builtBefore, 1, "only the new SE built SoundData")
|
||||
|
||||
-- PCM-baked parameters miss the cache
|
||||
local n = sources
|
||||
Audio.playSe(5, { pan = 63 })
|
||||
eq(sources, n + 1, "a different pan builds its own Source")
|
||||
Audio.playSe(5, { pan = 63 })
|
||||
eq(sources, n + 1, "and that one is cached too")
|
||||
Audio.playSe(5, { volume = 0.5 })
|
||||
eq(sources, n + 2, "a different volume builds its own Source")
|
||||
|
||||
-- stopSe still stops and forgets it
|
||||
Audio.playSe(5)
|
||||
local cached = Audio._seByPlayer[1]
|
||||
Audio.stopSe(5)
|
||||
check(not cached.playing and Audio._seMeta[cached] == nil
|
||||
and Audio._seByPlayer[1] == nil, "stopSe stops and forgets a cached Source")
|
||||
|
||||
-- a cached Source still tracked live (not replaced) is cloned, not cut off
|
||||
Audio._seSources[#Audio._seSources + 1] = cached
|
||||
Audio._seMeta[cached] = { id = 5, player = 3 }
|
||||
cached.playing = true
|
||||
Audio.playSe(5)
|
||||
local now = Audio._seByPlayer[1]
|
||||
check(now ~= cached and now.clone and now.playing,
|
||||
"live cached Source is cloned for the new play")
|
||||
check(cached.playing, "the live one keeps playing")
|
||||
Audio.stopSe()
|
||||
|
||||
-- a re-install / session end drops the cache with the raw memo
|
||||
Audio._seRawClear()
|
||||
n = sources
|
||||
Audio.playSe(5)
|
||||
eq(sources, n + 1, "cache cleared with the raw memo")
|
||||
eq(bakes, 3, "and the SE is baked again (5 and 6 before)")
|
||||
|
||||
-- non-memoable plays (explicit loop/maxSec) never touch the cache
|
||||
n = sources
|
||||
Audio.playSe(5, { maxSec = 1 })
|
||||
Audio.playSe(5, { maxSec = 1 })
|
||||
eq(sources, n + 2, "maxSec plays build fresh Sources")
|
||||
|
||||
S.finish()
|
||||
Reference in New Issue
Block a user