Merge pull request #2582 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-09-30 14:18:33 -04:00
committed by GitHub
130 changed files with 8279 additions and 1279 deletions
+1
View File
@@ -36,6 +36,7 @@ body:
- Silver
- Crystal
- FireRed
- Emerald
- N/A
validations:
required: true
+5 -4
View File
@@ -1,6 +1,6 @@
# G1R Deluxe aka Gen1Recomp
A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, and LeafGreen.
A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, LeafGreen, and Emerald.
The engine and map behavior are hand-written Lua; game data and graphics are
decoded from a ROM supplied by the player.
@@ -61,7 +61,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
## What this is
G1R Deluxe aka Gen1Recomp is a native LÖVE2D recreation of Pokemon Red, Blue, Yellow, Gold,
Silver, Crystal, FireRed, and LeafGreen. The engine and map behavior are
Silver, Crystal, FireRed, LeafGreen, and Emerald. The engine and map behavior are
hand-written Lua, ported from the [pret](https://github.com/pret)
disassemblies & C. Game data, graphics, and audio programs are decoded on first
launch from a ROM you supply.
@@ -70,7 +70,7 @@ The project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. Your ROM is verified, used during import, and
released from memory. It is never copied into the cache, and later launches
load the private generated cache without asking for it again. Music, sound
effects, and cries are synthesized while the game runs. All eight games can be
effects, and cries are synthesized while the game runs. All nine games can be
imported side by side. Gen 2 support is still under construction.
## Quick Start
@@ -84,7 +84,7 @@ imported side by side. Gen 2 support is still under construction.
4. Repeat for any other game you own. Each one gets its own tab in the launcher.
Only the canonical US English ROMs below are accepted. The importer checks the
SHA-1 before creating any game data. FireRed and LeafGreen support is in beta.
SHA-1 before creating any game data. FireRed, LeafGreen, and Emerald support is in beta.
| Game | Revision | ROM size | SHA-1 |
| --- | --- | --- | --- |
@@ -99,6 +99,7 @@ SHA-1 before creating any game data. FireRed and LeafGreen support is in beta.
| FireRed | 1.1 | 16 MiB | `dd5945db9b930750cb39d00c84da8571feebf417` |
| LeafGreen | 1.0 | 16 MiB | `574fa542ffebb14be69902d1d36f1ec0a4afd71e` |
| LeafGreen | 1.1 | 16 MiB | `7862c67bdecbe21d1d69ce082ce34327e1c6ed5e` |
| Emerald | 1.0 | 16 MiB | `f3ae088181bf583e55daf962a92bb46f4f1d07b7` |
**Platform notes:** [Linux](docs/platforms/linux.md),
[iOS](docs/platforms/ios.md), [Xbox Dev Mode](docs/platforms/xbox.md),
+19 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -4
View File
@@ -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
+32 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+16 -2
View File
@@ -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
@@ -812,7 +823,10 @@ function Game:_cycleSpeed(dir)
if busy then return end
local GameSpeed = require("src.core.GameSpeed")
local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack))
self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir)
local nextSpeed = GameSpeed.cycle(self.save.options[key], dir)
for _, c in ipairs(GameSpeed.CATEGORIES) do
self.save.options[GameSpeed.optionKey(c)] = nextSpeed
end
self:writeOptions()
end
+56 -10
View File
@@ -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
+8 -3
View File
@@ -677,7 +677,10 @@ function Game3:_cycleSpeed(dir)
if self:speedLocked() then return end
local GameSpeed = require("src.core.GameSpeed")
local key = GameSpeed.optionKey(self:speedCategory())
self.options[key] = GameSpeed.cycle(self.options[key], dir)
local nextSpeed = GameSpeed.cycle(self.options[key], dir)
for _, c in ipairs(GameSpeed.CATEGORIES) do
self.options[GameSpeed.optionKey(c)] = nextSpeed
end
self:writeOptions()
end
@@ -1246,7 +1249,8 @@ local function clearFieldScreens()
FieldView.setCameraPanning(0, 0)
end
function Game3:returnToTitle()
function Game3:returnToTitle(opts)
opts = opts or {}
self.questPlayback=nil
Help.reset()
Audio.stopAll()
@@ -1285,7 +1289,8 @@ function Game3:returnToTitle()
end
Boot.setSaveStatus(self.boot, saveStatus)
Boot.setTextSpeed(self.boot, Options.block(self.options).textSpeed)
if not self.boot.custom then
if not self.boot.custom or opts.skipIntro then
if self.boot.custom then self.boot.custom.coldBoot = false end
self.boot.phase = Boot.PHASE.TITLE
self.boot.timer = 0
Boot.enterTitle(self.boot)
+40 -3
View File
@@ -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
+4 -1
View File
@@ -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)
+39
View File
@@ -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
+43 -1
View File
@@ -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 })
+83 -16
View File
@@ -421,6 +421,8 @@ function Audio.playSong(id, opts)
opts = opts or {}
id = Audio.resolveSong(id)
if id == nil or id == 0 or id == 0xFFFF then
Audio._fadeOut, Audio._fadeIn = nil, nil
Audio._fanfareRestore, Audio._fanfareDeferred = nil, nil
stop_bgm_source()
Audio._currentSong = nil
return true
@@ -428,7 +430,8 @@ function Audio.playSong(id, opts)
if opts.fanfare or (Audio.songInfo(id) and Audio.songInfo(id).kind == "fanfare") or (fanfare_entry(id) ~= nil) then
return Audio.playFanfare(id)
end
if not opts.restart and Audio._currentSong and Audio._currentSong.id == id then
if not opts.restart and not Audio._fadeOut and Audio._currentSong and Audio._currentSong.id == id then
if Audio._fanfareActive then Audio._fanfareDeferred = nil end
return true
end
local info = Audio.songInfo(id) or {}
@@ -488,8 +491,11 @@ function Audio.playSong(id, opts)
end
function Audio.currentMapMusic()
local pending = Audio._fadeOut and Audio._fadeOut.nextSong
return pending or (Audio._currentSong and Audio._currentSong.id) or 0
if Audio._fanfareActive and Audio._fanfareDeferred ~= nil then
return Audio._fanfareDeferred
end
if Audio._fadeOut then return Audio._fadeOut.nextSong or 0 end
return (Audio._currentSong and Audio._currentSong.id) or 0
end
local function play_policy_song(id, fadeOut, fadeIn)
@@ -630,9 +636,7 @@ function Audio.changeMusicTo(id)
if t then return Audio.fadeOutAndPlay(t.song, t.fadeOut) end
return true
end
local cur = Audio._currentSong and Audio._currentSong.id
local pending = Audio._fadeOut and Audio._fadeOut.nextSong
if (pending or cur) == id then return true end
if Audio.currentMapMusic() == id then return true end
return Audio.fadeOutAndPlay(id, 8)
end
@@ -671,9 +675,8 @@ function Audio.bikeMusic(on, forced)
end
Audio.setSavedSong(nil)
local id = Audio.specialMapSong()
local pending = Audio._fadeOut and Audio._fadeOut.nextSong
if id and id ~= (pending or (Audio._currentSong and Audio._currentSong.id)) then
Audio.playSong(id)
if id and id ~= Audio.currentMapMusic() then
play_policy_song(id)
end
end
@@ -693,7 +696,8 @@ end
function Audio.fadeDefaultBgm(speed)
if rse_policy() then return Audio.changeMusicToDefault() end
local id = Audio._mapSong
if id and Audio._currentSong and Audio._currentSong.id == id then return true end
if id and Audio.currentMapMusic() == id then return true end
if Audio._fanfareActive then return play_policy_song(id) end
Audio.fadeOutBgm(speed)
if id then return Audio.playSong(id) end
return true
@@ -803,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)
@@ -835,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)
@@ -898,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
View File
@@ -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
+12
View File
@@ -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
+9 -1
View File
@@ -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
+8 -1
View File
@@ -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]
+10 -1
View File
@@ -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),
+122 -43
View File
@@ -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
+10 -1
View File
@@ -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)
+29 -17
View File
@@ -93,6 +93,17 @@ local function stop_low_hp_song()
end
end
local function apply_low_hp(on)
if on and not Battle._lowHpSong then
Battle._lowHpSong = true
if Audio and Audio.playSe and SE then
Audio.playSe(SE.SE_LOW_HEALTH, { loop = true })
end
elseif not on then
stop_low_hp_song()
end
end
--- pret HandleLowHpMusicChange / HandleBattleLowHpMusicChange
local function update_low_hp_music()
local st = Battle._st
@@ -106,16 +117,17 @@ local function update_low_hp_music()
local hp = math.floor(tonumber(pres and pres.displayHp) or tonumber(mon.hp) or 0)
local maxHp = math.floor(tonumber(pres and pres.displayMaxHp) or tonumber(mon.maxHp) or 0)
local red = hp_bar_red(hp, maxHp)
if red and not Battle._lowHpSong then
Battle._lowHpSong = true
if Audio and Audio.playSe and SE then
Audio.playSe(SE.SE_LOW_HEALTH, { loop = true })
end
elseif not red then
stop_low_hp_song()
if ModRuntime.wantsHook("battle.low_health_alarm") then
ModRuntime.call("battle.low_health_alarm", function(ctx)
apply_low_hp(ctx.on)
end, { on = red, battle = st })
else
apply_low_hp(red)
end
end
Battle._updateLowHpMusic = update_low_hp_music
local function party_menu_input(PartyMenu, input)
local SummaryMenu = package.loaded["src.ui.game3.summary_menu"]
if SummaryMenu and SummaryMenu.isOpen and SummaryMenu.isOpen() then
@@ -2958,16 +2970,16 @@ function D.commandUpdate(input)
return
end
if Battle._refuseLinkItem(input) then return end
local BagMenu = require("src.ui.game3.bag_menu")
if BagMenu.isOpen and BagMenu.isOpen() then
if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end
return
end
local PartyMenu = require("src.ui.game3.party_menu")
if PartyMenu.isOpen and PartyMenu.isOpen() then
if input then party_menu_input(PartyMenu, input) end
return
end
local BagMenu = require("src.ui.game3.bag_menu")
if BagMenu.isOpen and BagMenu.isOpen() then
if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end
return
end
if Ui._mode == "bag" or Ui._mode == "party" then
Ui._mode = "menu"
end
@@ -3782,16 +3794,16 @@ update_body = function(dt, game)
if Battle._phase == "command" and not Battle._auto then
if refuse_link_item(input) then return end
local BagMenu = require("src.ui.game3.bag_menu")
if BagMenu.isOpen and BagMenu.isOpen() then
if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end
return
end
local PartyMenu = require("src.ui.game3.party_menu")
if PartyMenu.isOpen and PartyMenu.isOpen() then
if input then party_menu_input(PartyMenu, input) end
return
end
local BagMenu = require("src.ui.game3.bag_menu")
if BagMenu.isOpen and BagMenu.isOpen() then
if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end
return
end
if Ui._mode == "bag" or Ui._mode == "party" then
Ui._mode = "menu"
end
+19 -4
View File
@@ -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()
+10 -4
View File
@@ -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)
+44 -2
View File
@@ -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
@@ -220,6 +250,18 @@ function Collision.behavior(cx,cy)
return Collision.behaviorOn(Collision._mapDef, cx, cy)
end
local worldMap
-- pokefirered/src/fieldmap.c:129
function Collision.worldBehavior(cx, cy)
local def = Collision._mapDef
if not (def and def.midLayout) then return nil end
worldMap = worldMap or require("src.core.game3.map")
local mid, pair = worldMap.worldMidAt(cx, cy, def)
local behaviors = InteractionScripts.behaviors[pair]
return behaviors and behaviors[mid]
end
-- pokefirered/include/constants/metatile_behaviors.h:39
local MB_IMPASSABLE_EAST = 0x30
local MB_IMPASSABLE_WEST = 0x31
+6 -1
View File
@@ -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
+5
View File
@@ -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
+58 -2
View File
@@ -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
@@ -1702,6 +1705,50 @@ function Field.flyDestination(section)
return baked[id]
end
-- pokefirered/src/overworld.c:289
local function resetCyclingRoadAfterTravel()
local session = Field._session
if not session then return end
local Profile = require("src.core.game3.profile")
local Flags = require("src.core.game3.scripting.flags")
local Runtime = package.loaded["src.core.game3.runtime"]
local Space = package.loaded["src.core.game3.scripting.space"]
local profile = Profile.forSession(session)
local defs = Flags.forVersion(profile.id)
local roadName = profile.family == "rse"
and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD"
local road = assert(defs.IDS[roadName])
local scene = profile.family ~= "rse" and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16)
local live = Runtime and Runtime.getSession and Runtime.getSession()
local seen = {}
for _, store in ipairs({ session, session.store or false, Space and Space.store or false,
live or false, live and live.store or false }) do
if store and not seen[store] then
seen[store] = true
Flags.setFlag(store, nil, road, false)
if store.flags then store.flags[roadName] = nil end
if scene then
Flags.setVar(store, nil, scene, 0)
if store.vars then
store.vars[tostring(scene)] = nil
store.vars[string.format("0x%X", scene)] = nil
store.vars.VAR_MAP_SCENE_ROUTE16 = nil
end
end
end
end
Player.biking, Player.bikeType = false, nil
Player.surfing, Player.surfHopping = false, false
for _, s in ipairs({ session, live or false }) do
if s then s.biking, s.bikeType = false, nil end
end
local save = Field._game and Field._game.save
if save then
save.biking, save.bikeType = false, nil
if save.position then save.position.biking = false end
end
end
-- pokefirered/src/field_effect.c:1065 ReturnToFieldFromFlyMapSelect
function Field.flyTo(section, mon, info)
local dest = (info and info.dest)
@@ -1716,6 +1763,7 @@ function Field.flyTo(section, mon, info)
local FieldEffects = require("src.core.game3.field_effects")
local function land()
local Map = require("src.core.game3.map")
resetCyclingRoadAfterTravel()
Map.load(Field._mod, Field._game, dest.map, {
x = dest.x, y = dest.y, facing = "down", depth1Connections = true,
})
@@ -1895,6 +1943,7 @@ end
function Field.respawnAtHeal(opts)
local session = Field._session
if not session then return end
resetCyclingRoadAfterTravel()
local HealLocations = require("src.core.game3.heal_locations")
HealLocations.normalizeSession(session)
local whiteOut = not (opts and opts.fieldMove)
@@ -2056,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
+49 -8
View File
@@ -46,6 +46,7 @@ local FLY_BIRD_W, FLY_BIRD_H, FLY_BIRD_FRAMES = 64, 64, 5
local frlgReflective = nil
local frlgReflectionQuad = nil
local frlgModules = nil
local FRLG_REFL_CULL = 64
local objectPoseOpts = {}
local playerPoseOpts = {}
local function isFrlgReflective(behavior)
@@ -99,6 +100,7 @@ local function drawFrlgReflections(camX, camY)
if not modules then return end
local Collision, Ow = modules.Collision, modules.Ow
local Objects, P, Runtime = modules.Objects, modules.Player, modules.Runtime
local behaviorAt = Collision.worldBehavior or Collision.behavior
local drawn = 0
if Objects.forDraw then
for _, obj in ipairs(Objects.forDraw()) do
@@ -109,7 +111,45 @@ local function drawFrlgReflections(camX, camY)
local frame, flip = Ow.pose(spr, obj.facing, Objects.walkPhase(obj), obj.stepFlip,
objectPoseOpts)
if drawFrlgReflection(obj, obj.graphicsId, frame, flip, camX, camY,
Collision.behavior, Ow) then drawn = drawn + 1 end
behaviorAt, Ow) then drawn = drawn + 1 end
end
end
end
end
local Map = package.loaded["src.core.game3.map"]
local Ghosts = package.loaded["src.core.game3.ghosts"]
local world = Map and Map.world
if Ghosts and Ghosts.forDraw and type(world) == "table" then
local host = Collision._mapDef
local FV = package.loaded["src.core.game3.field_view"]
local Display = require("src.core.game3.display")
local vw = FV and FV._viewW or Display.W
local vh = FV and FV._viewH or Display.H
local x0, y0 = camX - FRLG_REFL_CULL, camY - FRLG_REFL_CULL
local x1, y1 = camX + vw + FRLG_REFL_CULL, camY + vh + FRLG_REFL_CULL
for i = 1, #world do
local entry = world[i]
local ox, oy = entry.ox or 0, entry.oy or 0
local L = entry.def and entry.def.midLayout
local ex, ey = ox * CELL, oy * CELL
if entry.def ~= host and L
and ex + (L.width or 0) * CELL > x0 and ex < x1
and ey + (L.height or 0) * CELL > y0 and ey < y1 then
local live = Ghosts.forDraw(entry.id)
if live then
for j = 1, #live do
local obj = live[j]
if not obj.hideReflection and obj.graphicsId and not obj.virtualId then
local spr = Ow.getDraw(obj.graphicsId)
if spr then
objectPoseOpts.frame = obj.customFrame
local frame, flip = Ow.pose(spr, obj.facing, Objects.walkPhase(obj), obj.stepFlip,
objectPoseOpts)
if drawFrlgReflection(obj, obj.graphicsId, frame, flip, camX, camY,
behaviorAt, Ow, ox, oy) then drawn = drawn + 1 end
end
end
end
end
end
end
@@ -122,22 +162,23 @@ local function drawFrlgReflections(camX, camY)
local frame, flip = Ow.pose(spr, P.facing, P.walkPhase and P.walkPhase() or 0,
P.drawFlip and P.drawFlip() or false, playerPoseOpts)
if drawFrlgReflection(P, gid, frame, flip, camX, camY,
Collision.behavior, Ow) then drawn = drawn + 1 end
behaviorAt, Ow) then drawn = drawn + 1 end
end
end
FieldEffects.lastFrlgReflections = drawn
end
drawFrlgReflection = function(obj, graphicsId, frame, hflip, camX, camY, behaviorAt, Ow)
drawFrlgReflection = function(obj, graphicsId, frame, hflip, camX, camY, behaviorAt, Ow, ox, oy)
local spr = Ow.getReflectionDraw and Ow.getReflectionDraw(graphicsId)
if not (spr and spr.quads and spr.quads[frame]) then return false end
local cx = obj.moving and obj.targetX or obj.cellX
local cy = obj.moving and obj.targetY or obj.cellY
local pcx, pcy = obj.cellX, obj.cellY
ox, oy = ox or 0, oy or 0
local cx = (obj.moving and obj.targetX or obj.cellX) + ox
local cy = (obj.moving and obj.targetY or obj.cellY) + oy
local pcx, pcy = obj.cellX + ox, obj.cellY + oy
if not frlgReflectionType(cx, cy, pcx, pcy, spr.width, spr.height, behaviorAt) then return false end
local w, h = spr.width, spr.height
local left = (obj.px or cx * CELL) + (16 - w) / 2
local top = (obj.py or cy * CELL) + 14
local left = (obj.px or (cx - ox) * CELL) + ox * CELL + (16 - w) / 2
local top = (obj.py or (cy - oy) * CELL) + oy * CELL + 14
local x0, x1 = math.floor(left / CELL), math.floor((left + w - 1) / CELL)
local y0, y1 = math.floor(top / CELL), math.floor((top + h - 1) / CELL)
local q = frlgReflectionQuad or love.graphics.newQuad(0, 0, 1, 1, spr.width, spr.height * spr.frameCount)
+92 -17
View File
@@ -144,6 +144,12 @@ local function behaviorAt(x, y)
return C.behavior(x, y)
end
local function worldBehaviorAt(x, y)
local C = Collision()
if not (C and C.worldBehavior) or x == nil or y == nil then return nil end
return C.worldBehavior(x, y)
end
function FxRse.animCmds(name, idx)
local o = FE().manifestObject(name)
@@ -574,7 +580,7 @@ end
-- pokeemerald/src/event_object_movement.c:7625
local function reflectionAt(x, y)
local b = behaviorAt(x, y)
local b = worldBehaviorAt(x, y)
if b == nil then return 0 end
if B.ice(b) then return 1 end
if B.reflective(b) then return 2 end
@@ -645,19 +651,23 @@ end
local reflQuad
local coverCells, coverN = {}, 0
local coverBuckets, coverPairs = {}, {}
local REFL_CULL = 64
local ghostPose = {}
local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY)
local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY, ox, oy)
local Ow = package.loaded["src.core.game3.ow_sprites"]
local spr = Ow and Ow.getDraw and Ow.getDraw(gid)
if not (spr and spr.quads and spr.quads[frame]) then return false end
local cx = obj.moving and obj.targetX or obj.cellX
local cy = obj.moving and obj.targetY or obj.cellY
local pcx, pcy = obj.cellX, obj.cellY
if obj == Player() then pcx, pcy = obj.prevCellX or cx, obj.prevCellY or cy end
ox, oy = ox or 0, oy or 0
local cx = (obj.moving and obj.targetX or obj.cellX) + ox
local cy = (obj.moving and obj.targetY or obj.cellY) + oy
local pcx, pcy = obj.cellX + ox, obj.cellY + oy
if obj == Player() then pcx, pcy = (obj.prevCellX or cx - ox) + ox, (obj.prevCellY or cy - oy) + oy end
local rtype = FxRse.reflectionType(cx, cy, pcx, pcy, spr.width, spr.height)
if rtype == 0 then return false end
local bridge = B.bridgeType(behaviorAt(pcx, pcy))
if bridge == 0 then bridge = B.bridgeType(behaviorAt(cx, cy)) end
local bridge = B.bridgeType(worldBehaviorAt(pcx, pcy))
if bridge == 0 then bridge = B.bridgeType(worldBehaviorAt(cx, cy)) end
local info = fcGfx(gid)
local tag = FxRse.reflectionPaletteTag(gid, bridge)
local src = (tag and recolored(gid, spr, tag)) or spr
@@ -667,8 +677,8 @@ local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY)
local offs = { [1] = 12, [2] = 28, [3] = 44 }
local extra = (not (info and info.noReflectionLoad)) and offs[bridge] or 0
local w, h = spr.width, spr.height
local left = (obj.px or cx * CELL) + (16 - w) / 2 + (x2 or 0)
local top = (obj.py or cy * CELL) + 16 - h + (h - 2) + extra - (y2 or 0)
local left = (obj.px or (cx - ox) * CELL) + ox * CELL + (16 - w) / 2 + (x2 or 0)
local top = (obj.py or (cy - oy) * CELL) + oy * CELL + 16 - h + (h - 2) + extra - (y2 or 0)
local iw, ih = src.image:getDimensions()
reflQuad = reflQuad or love.graphics.newQuad(0, 0, 1, 1, iw, ih)
local fy = frame * h
@@ -677,7 +687,7 @@ local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY)
love.graphics.setColor(1, 1, 1, 1)
for ty = c0y, c1y do
for tx = c0x, c1x do
local b = behaviorAt(tx, ty)
local b = worldBehaviorAt(tx, ty)
if b and B.reflective(b) then
local ix0, ix1 = math.max(left, tx * CELL), math.min(left + w, tx * CELL + CELL)
local iy0, iy1 = math.max(top, ty * CELL), math.min(top + h, ty * CELL + CELL)
@@ -723,17 +733,45 @@ local function coverReflections(camX, camY)
local mapDef = C and C._mapDef
local layout = mapDef and mapDef.midLayout
local NT = package.loaded["src.core.game3.tileset_native"]
if not (layout and NT) then return end
local ts = NT.get(mapDef.pair or layout.pair)
local shader = ts and ts.midImage and getCoverShader()
local Map = package.loaded["src.core.game3.map"]
if not (layout and NT and Map) then return end
local shader = getCoverShader()
if not shader then return end
shader:send("mask", ts.midImage)
love.graphics.setShader(shader)
love.graphics.setColor(1, 1, 1, 1)
local loaded = NT._pairs or {}
local np = 0
for i = 1, n, 2 do
local tx, ty = coverCells[i], coverCells[i + 1]
local q = NT.quad(ts, NT.slotFor(ts, layout:midAt(tx, ty)))
if q then love.graphics.draw(ts.image, q, tx * CELL - camX, ty * CELL - camY) end
local mid, pair = Map.worldMidAt(tx, ty, mapDef)
local ts = pair and loaded[pair]
if ts and ts.midImage then
local bucket = coverBuckets[pair]
if not bucket then
bucket = { n = 0 }
coverBuckets[pair] = bucket
end
if bucket.n == 0 then
np = np + 1
coverPairs[np] = pair
end
local b = bucket.n
bucket[b + 1], bucket[b + 2], bucket[b + 3] = tx, ty, mid
bucket.n = b + 3
end
end
for p = 1, np do
local pair = coverPairs[p]
local bucket = coverBuckets[pair]
local ts = loaded[pair]
shader:send("mask", ts.midImage)
for i = 1, bucket.n, 3 do
local tx, ty = bucket[i], bucket[i + 1]
local q = NT.quad(ts, NT.slotFor(ts, bucket[i + 2]))
if q then love.graphics.draw(ts.image, q, tx * CELL - camX, ty * CELL - camY) end
end
bucket.n = 0
coverPairs[p] = nil
end
love.graphics.setShader()
end
@@ -755,6 +793,43 @@ local function drawReflections(camX, camY)
end
end
end
local Map = package.loaded["src.core.game3.map"]
local Ghosts = package.loaded["src.core.game3.ghosts"]
local world = Map and Map.world
if O and Ghosts and Ghosts.forDraw and type(world) == "table" then
local C = Collision()
local host = C and C._mapDef
local FV = package.loaded["src.core.game3.field_view"]
local Display = require("src.core.game3.display")
local vw = FV and FV._viewW or Display.W
local vh = FV and FV._viewH or Display.H
local x0, y0 = camX - REFL_CULL, camY - REFL_CULL
local x1, y1 = camX + vw + REFL_CULL, camY + vh + REFL_CULL
for i = 1, #world do
local entry = world[i]
local ox, oy = entry.ox or 0, entry.oy or 0
local L = entry.def and entry.def.midLayout
local ex, ey = ox * CELL, oy * CELL
if entry.def ~= host and L
and ex + (L.width or 0) * CELL > x0 and ex < x1
and ey + (L.height or 0) * CELL > y0 and ey < y1 then
local live = Ghosts.forDraw(entry.id)
if live then
for j = 1, #live do
local eo = live[j]
if not eo.hideReflection and eo.graphicsId and not eo.virtualId then
local spr = Ow.getDraw(eo.graphicsId)
if spr then
ghostPose.frame = eo.customFrame
local frame, flip = Ow.pose(spr, eo.facing, O.walkPhase(eo), eo.stepFlip, ghostPose)
drawReflection(eo, eo.graphicsId, frame, flip, eo.raiseX, eo.raiseY, camX, camY, ox, oy)
end
end
end
end
end
end
end
local P = Player()
if P and P.isVisible and P.isVisible() and not P.hideReflection then
local rt = package.loaded["src.core.game3.runtime"]
+201 -29
View File
@@ -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
+74 -17
View File
@@ -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) }
@@ -146,7 +203,7 @@ function Ghosts.capture(mapId)
local pool = { byId = {}, order = {}, bounds = snap.bounds }
for _, lid in ipairs(snap.order or {}) do
local eo = snap.byId[lid]
if eo then
if eo and eo.foreignMap == nil then
pool.byId[lid] = eo
pool.order[#pool.order + 1] = lid
eo.scriptBusy = false
+24 -2
View File
@@ -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
+82 -2
View File
@@ -149,12 +149,27 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
Map._loadedLayouts[entry.id] = true
end
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
if Map._warmPairs and NativeTileset and NativeTileset.get then
if NativeTileset and NativeTileset.get then
local sync = Map._warmPairs
Map._warmPairs = nil
local x0, y0, x1, y1 = Map.warmRect()
local queue, seen, entries = {}, {}, {}
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
if sync and pair and Map.warmNear(entry, x0, y0, x1, y1) then
if NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end
elseif type(pair) == "string" and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then
if not seen[pair] then
seen[pair] = true
queue[#queue + 1] = pair
entries[pair] = {}
end
local list = entries[pair]
list[#list + 1] = entry
end
end
Map._warmQueue = queue[1] and queue or nil
Map._warmEntries = queue[1] and entries or nil
end
Map._worldRoot = rootId
Map._worldReachW = reachW
@@ -164,6 +179,71 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
return Map.world
end
Map.WARM_MARGIN_SCREENS = 1
Map.WARM_BUDGET_SEC = 0.030
Map.WARM_MAX_DEFER = 3
Map._warmDefer = 0
function Map.warmRect()
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 * Map.WARM_MARGIN_SCREENS
local my = math.ceil(rows / 2) + 1 + rows * Map.WARM_MARGIN_SCREENS
return px - mx, py - my, px + mx, py + my
end
function Map.warmNear(entry, x0, y0, x1, y1)
if not x0 then return true end
local layout = entry.def and entry.def.midLayout
local w, h = layout and layout.width, 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 Map.stepWarm()
local queue = Map._warmQueue
if not queue then return false end
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
local timer = love and love.timer
if timer and timer.getDelta and timer.getDelta() > Map.WARM_BUDGET_SEC
and Map._warmDefer < Map.WARM_MAX_DEFER then
Map._warmDefer = Map._warmDefer + 1
return false
end
Map._warmDefer = 0
local entries = Map._warmEntries or {}
local x0, y0, x1, y1 = Map.warmRect()
local i = 1
while queue[i] do
local pair = queue[i]
if NativeTileset and (NativeTileset._pairs and NativeTileset._pairs[pair])
or not (NativeTileset and NativeTileset.ready and NativeTileset.ready(pair)) then
table.remove(queue, i)
else
local near = not entries[pair]
for _, entry in ipairs(entries[pair] or {}) do
if Map.warmNear(entry, x0, y0, x1, y1) then near = true break end
end
if near then
table.remove(queue, i)
pcall(NativeTileset.get, pair)
if not queue[1] then Map._warmQueue = nil end
return true
end
i = i + 1
end
end
if not queue[1] then Map._warmQueue = nil end
return false
end
function Map.overscanSlices()
local slices = {}
for _, n in ipairs(Map.neighborList or {}) do
+46 -18
View File
@@ -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
+23 -4
View File
@@ -804,7 +804,7 @@ function Objects.forDraw()
local eo = Objects._byId[lid]
-- src/event_object_movement.c:8014
if eo and eo.visible and not eo.hidden and not eo.invisible
and not offMap(Objects._bounds, eo) then
and (eo.foreignMap ~= nil or not offMap(Objects._bounds, eo)) then
n = n + 1
list[n] = eo
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
@@ -1622,10 +1629,22 @@ local function idleTick(eo, game, ctx)
end
end
local trackIds, trackRefs, trackActors = {}, {}, {}
function Objects.update(game)
-- Advance script tracks then motion + idle.
local count = 0
for lid, tr in pairs(Objects._tracks) do
advanceTrack(lid, tr, game)
count = count + 1
trackIds[count], trackRefs[count], trackActors[count] = lid, tr, Objects.find(lid)
end
-- pokeemerald/src/event_object_movement.c:2167
for i = 1, count do
local tr, eo = trackRefs[i], trackActors[i]
local lid = (eo and eo.localId) or trackIds[i]
trackIds[i], trackRefs[i], trackActors[i] = nil, nil, nil
if Objects._tracks[lid] == tr and (not eo or Objects.find(lid) == eo) then
advanceTrack(lid, tr, game)
end
end
for _, lid in ipairs(Objects._order) do
local eo = Objects._byId[lid]
+59 -5
View File
@@ -21,6 +21,62 @@ function Palette.load(blob)
return NativePack.palsToRgb8(bgr), bgr
end
local bit = require("bit")
local band, bor, bxor, bnot = bit.band, bit.bor, bit.bxor, bit.bnot
local lshift, rshift, rol, tobit = bit.lshift, bit.rshift, bit.rol, bit.tobit
local MD5_K, MD5_S = {}, {
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
}
for i = 0, 63 do
MD5_K[i + 1] = math.floor(math.abs(math.sin(i + 1)) * 4294967296) % 4294967296
end
local function md5hex(msg)
local len = #msg
local padLen = (55 - len) % 64
msg = msg .. "\128" .. string.rep("\0", padLen)
.. string.char(
(len * 8) % 256, math.floor(len * 8 / 256) % 256,
math.floor(len * 8 / 65536) % 256, math.floor(len * 8 / 16777216) % 256,
0, 0, 0, 0)
local a0, b0, c0, d0 = 0x67452301, tobit(0xefcdab89), tobit(0x98badcfe), 0x10325476
local M = {}
for off = 1, #msg, 64 do
for j = 0, 15 do
local b1, b2, b3, b4 = msg:byte(off + j * 4, off + j * 4 + 3)
M[j] = bor(b1, lshift(b2, 8), lshift(b3, 16), lshift(b4, 24))
end
local A, B, C, D = a0, b0, c0, d0
for i = 0, 63 do
local F, g
if i < 16 then
F = bor(band(B, C), band(bnot(B), D)); g = i
elseif i < 32 then
F = bor(band(D, B), band(bnot(D), C)); g = (5 * i + 1) % 16
elseif i < 48 then
F = bxor(B, C, D); g = (3 * i + 5) % 16
else
F = bxor(C, bor(B, bnot(D))); g = (7 * i) % 16
end
F = tobit(F + A + MD5_K[i + 1] + M[g])
A = D; D = C; C = B
B = tobit(B + rol(F, MD5_S[i + 1]))
end
a0, b0, c0, d0 = tobit(a0 + A), tobit(b0 + B), tobit(c0 + C), tobit(d0 + D)
end
local out = {}
for _, v in ipairs({ a0, b0, c0, d0 }) do
for k = 0, 3 do out[#out + 1] = string.format("%02x", band(rshift(v, k * 8), 255)) end
end
return table.concat(out)
end
Palette._md5hex = md5hex
--- Stable hash of BGR555 pals (+ optional extraBlob) for RGBA disk cache keys.
function Palette.hash(bgrPals, extraBlob)
local parts = {}
@@ -33,13 +89,11 @@ function Palette.hash(bgrPals, extraBlob)
if extraBlob and type(extraBlob) == "string" then
parts[#parts + 1] = extraBlob
end
-- FNV-1a 32-bit over hex stream (stable, no bit lib).
local h = 2166136261
local s = table.concat(parts)
for i = 1, #s do
h = (h * 16777619 + s:byte(i)) % 4294967296
if love and love.data and love.data.hash then
return (love.data.encode("string", "hex", love.data.hash("md5", s)):sub(1, 16))
end
return string.format("%08x", h)
return md5hex(s):sub(1, 16)
end
--- Identity tint hook (weather / time later).
+11 -2
View File
@@ -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
+30
View File
@@ -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
+54 -19
View File
@@ -28,6 +28,7 @@ function TilesetAnim.install(cache)
end
function TilesetAnim.invalidate()
TilesetAnim._manifests = nil
TilesetAnim._pairs = {}
TilesetAnim._visible = {}
TilesetAnim.counter = 0
@@ -42,9 +43,18 @@ local function native_root()
end
local function read_manifest(cache, pair)
local memo = TilesetAnim._manifests
if not memo then
memo = {}
TilesetAnim._manifests = memo
end
local hit = memo[pair]
if hit ~= nil then return hit or nil end
local src = cache:read(native_root() .. pair .. "/anim_manifest.lua")
local chunk = src and load(src, "@anim_manifest.lua", "t", {})
return chunk and chunk() or nil
local man = chunk and chunk() or nil
memo[pair] = man or false
return man
end
local function rse_luts(cache, pair)
@@ -295,10 +305,7 @@ function TilesetAnim.bindPair(pair, atlas)
local entry = TilesetAnim._pairs[pair]
if entry == false then return false end
if not entry then
local src = cache:read(
(Extract.CACHE_ROOT or "data/generated/gba") .. "/native/" .. pair .. "/anim_manifest.lua")
local chunk = src and load(src, "@anim_manifest.lua", "t", {})
local man = chunk and chunk()
local man = read_manifest(cache, pair)
if not man then
TilesetAnim._pairs[pair] = false
return false
@@ -320,27 +327,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 +382,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 +416,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
+14 -2
View File
@@ -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
+8 -1
View File
@@ -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)
+1
View File
@@ -1547,6 +1547,7 @@ end
-- one visited, else Red. Kept as a function so the mods/find/skins panels
-- still answer "for which game" without a game tab being open.
local function gameTabs(imp)
SecretGames.update()
local launcher = type(imp) ~= "table" or imp.launcher and true or false
local c = imp and imp._gameTabs
if c and c.rev == SecretGames.rev and c.launcher == launcher then
+11
View File
@@ -1961,6 +1961,11 @@ function RomImporter:_versionForSha1(hash)
end
function RomImporter:_logoTap()
if SecretGames.update() then
self._secretPopup = nil
self._logoTaps = nil
return
end
if self._secretPopup or not SecretGames.anyLocked() then return end
self._logoTaps = (self._logoTaps or 0) + 1
if self._logoTaps >= SecretGames.TAPS then
@@ -3452,6 +3457,12 @@ function RomImporter:_pollPickedFiles(dt)
end
function RomImporter:update(dt)
local released, changed = SecretGames.update()
if released then
self._secretPopup = nil
self._logoTaps = nil
if changed then self._headerChrome = nil end
end
self.pulse = self.pulse + dt
if self._importerJob then self:_stepImporter() end
if not Transition.armed then
+10 -1
View File
@@ -4,6 +4,7 @@ local SecretGames = {}
SecretGames.GAMES = { emerald = "secretEmerald" }
SecretGames.TAPS = 20
SecretGames.EMERALD_RELEASE_AT = 1790856000
SecretGames.rev = 0
local unlocked
@@ -25,7 +26,7 @@ end
function SecretGames.visible(id)
if not SecretGames.GAMES[id] then return true end
if not unlocked then load() end
SecretGames.update()
return unlocked[id] == true
end
@@ -60,4 +61,12 @@ function SecretGames.unlock()
return saved
end
function SecretGames.update(now)
if not unlocked then load() end
local due = (now or os.time()) >= SecretGames.EMERALD_RELEASE_AT
local changed = due and unlocked.emerald ~= true
if changed then SecretGames.unlock() end
return due, changed
end
return SecretGames
+13 -7
View File
@@ -134,20 +134,26 @@ function NativePack.decodeIdx(blob)
midIds[i] = read_u16(blob, off)
off = off + 2
end
local pixels = {}
local dataOff = off
local n = midCount * 256
for i = 1, n do
pixels[i] = blob:byte(off + i - 1) or 0
end
return {
return setmetatable({
formatVersion = formatVersion,
flags = flags,
midCount = midCount,
atlasCols = atlasCols,
atlasRows = atlasRows,
midIds = midIds,
pixels = pixels,
}
}, {
__index = function(self, k)
if k ~= "pixels" then return nil end
local pixels = {}
for i = 1, n do
pixels[i] = blob:byte(dataOff + i - 1) or 0
end
rawset(self, "pixels", pixels)
return pixels
end,
})
end
function NativePack.encodePalettes(mapPals)
+23 -8
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
+35 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+32 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+12 -4
View File
@@ -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
View File
@@ -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
+2 -5
View File
@@ -167,6 +167,7 @@ function Message.show(text, opts)
end
-- Prefer session options text speed when not overridden.
local instant = tonumber(opts.speed) == 0
local speed = opts.speed
if speed == nil then
local ok, Options = pcall(require, "src.core.game3.options")
@@ -182,10 +183,6 @@ function Message.show(text, opts)
Message._speedIdx = tonumber(speed) or 1
if Message._speedIdx < 0 then Message._speedIdx = 0 end
if Message._speedIdx > 2 then Message._speedIdx = 2 end
if tonumber(speed) == 0 then
-- Instant print (Oak repeat question)
Message._speedIdx = 0
end
local _, _, dlgW = Chrome.dialogueWindow()
local maxW = (opts.frame == "battle" or opts.battle) and 212 or dlgW * Display.TILE
@@ -204,7 +201,7 @@ function Message.show(text, opts)
Message._pages = split_pages(plain)
Message._page = 1
beginPage()
if tonumber(speed) == 0 then
if instant then
Message.skipReveal()
end
return Message
+1 -1
View File
@@ -991,7 +991,7 @@ function Birch:draw()
if self.showDialogue then
local w = WIN_TEXT
Kit.birchDialogueFrame(w.left, w.top, w.width, w.height)
if self.printer then self.printer:draw(w.left * 8, w.top * 8 + 1, { colors = Kit.messageColors() }) end
if self.printer then self.printer:draw(w.left * 8, w.top * 8 + 1, { colors = Kit.messageColors(), clip = { w.left * 8, w.top * 8, w.width * 8, w.height * 8 } }) end
end
if self.genderMenu then
local w = WIN_GENDER
+19 -2
View File
@@ -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
+9
View File
@@ -461,6 +461,12 @@ function Printer:draw(x, y, opts)
opts = opts or {}
local colors = opts.colors or Kit.messageColors()
local cx, cy = x, y
local clip = opts.clip
local sx, sy, sw, sh
if clip then
sx, sy, sw, sh = love.graphics.getScissor()
love.graphics.intersectScissor(clip[1], clip[2], clip[3], clip[4])
end
for i, line in ipairs(self.lines) do
local ly = y + (i - 1) * self.pitch - self.scrollY
if line ~= "" then
@@ -471,6 +477,9 @@ function Printer:draw(x, y, opts)
end
cy = ly
end
if clip then
if sx then love.graphics.setScissor(sx, sy, sw, sh) else love.graphics.setScissor() end
end
if self.arrowFrame then
local img = Kit.rgbaImage("data/generated/gba/chrome/fonts/down_arrow.rgba", 8, 48)
if img then
+1 -1
View File
@@ -253,7 +253,7 @@ function StartMenu.confirm()
or (Runtime and Runtime._game)
or StartMenu._game
if game and game.returnToTitle then
game:returnToTitle()
game:returnToTitle({ skipIntro = true })
end
else -- NO
StartMenu._confirmExit = false
+19 -3
View File
@@ -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
+74 -31
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+12 -4
View File
@@ -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
View File
@@ -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
+17 -2
View File
@@ -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
+21 -2
View File
@@ -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)
+38 -5
View File
@@ -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)
+7 -1
View File
@@ -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
View File
@@ -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
+21 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16 -3
View File
@@ -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)
+29 -10
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+273
View File
@@ -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()
+129 -23
View File
@@ -5,37 +5,143 @@ local d = X.new("em_briney_boat", "/tmp/em_briney_boat")
return function(game)
if not X.newGame(d, game, 0) then return d.finish() end
local Objects = require("src.core.game3.objects")
local Player = require("src.core.game3.player")
local Ghosts = require("src.core.game3.ghosts")
local Space = require("src.core.game3.scripting.space")
local Message = require("src.ui.game3.message")
local Fade = require("src.ui.game3.fade")
local state
local function monitor()
if not state then return end
local s = X.session()
if s and not state.maps[s.map] then
state.maps[s.map] = true
d.note(state.name .. " map " .. s.map .. " VM " .. X.vmWhere())
end
local boat, lid
for id, eo in pairs(Objects._byId) do
if eo.originMapId == state.origin and eo.originLocalId == state.localId then
boat, lid = eo, id
break
end
end
if not boat then return end
for _, pool in pairs(Ghosts._pools) do
for _, ghost in pairs(pool.byId) do
if ghost == boat then state.aliases = state.aliases + 1 end
end
end
local bt = Objects._tracks[lid]
local pt = Objects._tracks[Objects.PLAYER_LOCAL_ID]
if Player.visible == false and bt and pt and not bt.done and not pt.done then
local dx, dy = boat.px - Player.px, boat.py - Player.py
if state.dx == nil then state.dx, state.dy = dx, dy end
state.samples = state.samples + 1
if dx ~= state.dx or dy ~= state.dy then
state.drift = state.drift + 1
if state.drift == 1 then
d.note(string.format("%s drift at %s: expected %s,%s actual %s,%s",
state.name, s.map, state.dx, state.dy, dx, dy))
end
end
end
end
local function waitFor(pred, frames)
for i = 1, frames do
if pred() then return true end
if i % 6 == 0 then table.insert(game.input.pressQueue, "a") end
U.wait(1)
game.input.state.a = false
monitor()
end
return pred()
end
local function begin(name, origin, localId)
state = { name = name, origin = origin, localId = localId, maps = {},
samples = 0, drift = 0, aliases = 0 }
end
local function moment(label, file, pred, frames)
if not d.check(waitFor(pred, frames or 18000), label .. " (VM " .. X.vmWhere() .. ")") then
return false
end
d.check(d.still(game, file), label .. " screenshot")
return true
end
local function finishTrip(mapId, file)
moment(state.name .. " landing", file, function()
return not X.scriptRunning() and X.session().map == mapId and Player.visible ~= false
and not Fade.isActive() and not Fade.lockInput and (Fade.t or 0) <= 0
end)
d.check(state.samples > 0, state.name .. " samples sailing every simulation frame (" .. state.samples .. ")")
d.check(state.drift == 0, state.name .. " boat/player relative pixels remain constant (" .. state.drift .. ")")
d.check(state.aliases == 0, state.name .. " carried boat absent from every ghost pool (" .. state.aliases .. ")")
d.check(Player.visible ~= false, state.name .. " player visible after landing")
state = nil
end
local function start(label, localId, facing)
local key = Space.bundle.labels[label]
return d.check(key and Space.startScript(key, localId, facing), "cached " .. label .. " starts")
end
X.setFlag("FLAG_MR_BRINEY_SAILING_INTRO", true)
X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", true)
X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", false)
X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", false)
X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_PEEKO", false)
X.setVar("VAR_BRINEY_HOUSE_STATE", 0)
d.check(X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", 5, 6, "up"), "Mr. Briney's house loads")
local briney = require("src.core.game3.objects").find(1)
local briney = Objects.find(1)
local bx, by = briney and briney.cellX or 5, briney and briney.cellY or 3
X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", bx, by + 1, "up")
local seen = {}
local finished = X.mash(game, function()
local s = X.session()
if s and s.map and not seen[s.map] then
seen[s.map] = true
d.note("map " .. s.map .. " VM " .. X.vmWhere())
end
return X.flag("FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT") and X.var("VAR_BOARD_BRINEY_BOAT_STATE") == 0
and not X.scriptRunning()
end, 6000, "a", 4)
d.check(seen.EM_ROUTE104, "SailToDewford warps to Route 104 and ON_FRAME boards the boat")
d.check(finished, "the sailing script runs every boat/player movement to the landing (VM " .. X.vmWhere() .. ")")
begin("Route104 Dad call", "EM_ROUTE104", 7)
table.insert(game.input.pressQueue, "a")
moment("Route104 boarded", "90002_route104_boarded.png", function()
return X.session().map == "EM_ROUTE104" and Player.visible == false
end)
moment("Route105 first seam", "90002_route105_first_seam.png", function()
return X.session().map == "EM_ROUTE105"
end)
moment("Dad call fully revealed", "90002_dad_call.png", function()
return Player.visible == false and Message.isOpen() and not Message.isTyping()
end)
moment("Route106 second seam", "90002_route106_second_seam.png", function()
return X.session().map == "EM_ROUTE106"
end)
finishTrip("EM_DEWFORD_TOWN", "90002_dewford_landed.png")
d.check(X.flag("FLAG_ENABLE_NORMAN_MATCH_CALL"), "first sailing trip enables Dad match call")
d.check(X.flag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN") == false, "Dewford Briney shown after landing")
local s = X.session()
local Player = require("src.core.game3.player")
if s and s.map == "EM_DEWFORD_TOWN" then
d.check(true, "player ends in Dewford Town")
else
d.note(string.format("NOTE scripted movement crossed the Route 104 -> Dewford connection but the map did not switch " ..
"(still %s at %s,%s); connection transition during applymovement is a field/connections owner item",
tostring(s and s.map), tostring(Player.cellX), tostring(Player.cellY)))
for _, trip in ipairs({
{ name = "Dewford Petalburg", label = "DewfordTown_EventScript_SailToPetalburg",
seam = "EM_ROUTE106", landing = "EM_ROUTE104_MR_BRINEYS_HOUSE", prefix = "90002_petalburg" },
{ name = "Dewford Slateport", label = "DewfordTown_EventScript_SailToSlateport",
seam = "EM_ROUTE107", landing = "EM_ROUTE109", prefix = "90002_slateport" },
}) do
X.setFlag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", false)
X.setFlag("FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", false)
d.check(X.goTo(d, game, "EM_DEWFORD_TOWN", 11, 9, "right"), trip.name .. " departure loads")
begin(trip.name, "EM_DEWFORD_TOWN", 4)
if start(trip.label, 2, 4) then
moment(trip.name .. " first seam", trip.prefix .. "_first_seam.png", function()
return X.session().map == trip.seam and Player.visible == false
end)
finishTrip(trip.landing, trip.prefix .. "_landed.png")
end
end
for _, approach in ipairs({
{ name = "south", x = 21, y = 23, facing = "down", dir = 1 },
{ name = "east", x = 20, y = 24, facing = "right", dir = 4 },
{ name = "west", x = 22, y = 24, facing = "left", dir = 3 },
}) do
X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY", false)
X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", false)
d.check(X.goTo(d, game, "EM_ROUTE109", approach.x, approach.y, approach.facing),
"Route109 " .. approach.name .. " departure loads")
begin("Route109 " .. approach.name, "EM_ROUTE109", 1)
if start("Route109_EventScript_StartDepartForDewford", 2, approach.dir) then
moment(state.name .. " first seam", "90002_return_" .. approach.name .. "_first_seam.png", function()
return X.session().map == "EM_ROUTE108" and Player.visible == false
end)
finishTrip("EM_DEWFORD_TOWN", "90002_return_" .. approach.name .. "_landed.png")
end
end
d.shot(game, "01_landed_in_dewford.png")
d.finish()
end
+42
View File
@@ -0,0 +1,42 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "shots"
local function fail(msg)
print("FAIL " .. msg)
love.event.quit(1)
end
return function(game)
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "RED" })
U.wait(240)
local Map = require("src.core.game3.map")
local FxRse = require("src.core.game3.field_effects_rse")
local Ghosts = require("src.core.game3.ghosts")
local Objects = require("src.core.game3.objects")
local r104 = game.data.maps["EM_ROUTE104"]
if not r104 then return fail("EM_ROUTE104 missing") end
Map.load(nil, game, "EM_ROUTE104", { x = 28, y = 1, facing = "down" })
U.wait(60)
U.still(game, DIR .. "/em_control_player_on_own_map_reflects.png")
if (FxRse.lastReflections or 0) < 1 then return fail("control player has no reflection") end
Map.load(nil, game, "EM_RUSTBORO_CITY", { x = 28, y = 57, facing = "down" })
U.wait(60)
U.still(game, DIR .. "/em_rustboro_view_route104_no_npc.png")
local defs = { { localId = 1, index = 1, x = 28, y = 1, graphicsId = 5, movementType = 8, elevation = 3, flag = 0 } }
Ghosts._pools["EM_ROUTE104"] = Objects.spawnFromDefs(defs, r104, "EM_ROUTE104")
U.wait(30)
U.still(game, DIR .. "/em_rustboro_view_route104_ghost_npc_on_shore.png")
local n = FxRse.lastReflections or 0
print("GHOST_VIEW_REFL", n)
if n < 1 then return fail("neighbor NPC has no reflection") end
print("PASS")
love.event.quit(0)
end
+113
View File
@@ -0,0 +1,113 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_start_menu_exit_90001"
return function(game)
local failures = 0
local overallDeadline = love.timer.getTime() + 25
local SaveData, originalSave
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function waitUntil(pred, seconds)
local deadline = math.min(overallDeadline, love.timer.getTime() + seconds)
while love.timer.getTime() < deadline do
if pred() then return true end
U.wait(1)
end
return pred()
end
local ok, err = xpcall(function()
local identity = os.getenv("POKEPORT_IDENTITY") or ""
if not check(identity:match("^bsa0930%-") ~= nil, "isolated campaign identity") then return end
if not check(waitUntil(function() return game.boot ~= nil end, 5), "Emerald boot ready") then return end
local Boot = require("src.ui.game3.boot")
local StartMenu = require("src.ui.game3.start_menu")
local Runtime = require("src.core.game3.runtime")
SaveData = require("src.core.SaveData")
if not check(not SaveData.isPortable(), "save fixtures use isolated identity storage") then return end
originalSave = SaveData.save
local saves = 0
SaveData.save = function(...) saves = saves + 1; return originalSave(...) end
local Message = require("src.ui.game3.message")
local Field = require("src.core.game3.field")
local Fade = require("src.ui.game3.fade")
local Hud = require("src.ui.game3.hud")
local Space = require("src.core.game3.scripting.space")
local Warp = require("src.core.game3.warp")
local Forced = require("src.core.game3.forced_movement")
local Player = require("src.core.game3.player")
local function idle()
if Message.isOpen() then U.tap(game, "b") end
local running = Space.vm and Space.vm.isRunning and Space.vm:isRunning()
return game.phase == "field" and not Hud.busy() and not Fade.isActive() and not Fade.lockInput
and not Field.locked and not running and not Warp.isBusy() and not Forced.isForced()
and not Player.moving and Player.boulderPush == nil
end
for caseIndex, saved in ipairs({ false, true }) do
local prefix = saved and "saved" or "unsaved"
local slot = SaveData.createSlot("emerald")
if not check(slot and SaveData.setActiveSlot("emerald", slot) == slot, prefix .. " fresh save slot selected") then return end
game:_handleBootAction({ action = "new_game", name = "BRENDAN",
start = { map = "EM_LITTLEROOT_TOWN", x = 5, y = 9, facing = "down" } })
print("INFO " .. prefix .. " initial fade=" .. tostring(Fade.isActive()) .. " hudBusy=" .. tostring(Hud.busy()))
if not check(waitUntil(idle, 4), prefix .. " field ready after fade and script completion") then return end
if saved then
if not check(game:saveGame(), "saved fixture writes a real Emerald save") then return end
end
if not check(game:_hasContinueSave() == saved, prefix .. " expected continue availability before EXIT") then return end
local session, saveCount = game.session, saves
local rawBefore = SaveData.readSlotSource("emerald", slot)
U.tap(game, "start")
if not check(StartMenu.isOpen(), prefix .. " START opens Emerald menu") then return end
local exitIndex
for i, entry in ipairs(StartMenu.ENTRIES) do if entry.id == "exit" then exitIndex = i end end
if not check(exitIndex ~= nil, prefix .. " Emerald EXIT present") then return end
for _ = 1, #StartMenu.ENTRIES do
if StartMenu.cursor == exitIndex then break end
U.tap(game, "down")
end
U.tap(game, "a")
check(StartMenu._confirmExit and StartMenu._confirmCursor == 2, prefix .. " EXIT confirmation defaults NO")
check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 1) .. "_" .. prefix .. "_exit_default_no.png"), prefix .. " default NO screenshot")
U.tap(game, "b")
check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT B preserves field session")
U.tap(game, "a")
U.tap(game, "a")
check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT NO preserves field session")
U.tap(game, "a")
U.tap(game, "up")
U.tap(game, "a")
local direct = game.boot and game.boot.phase == Boot.PHASE.TITLE and game.boot.custom.title
and not game.boot.custom.intro
if not check(direct, prefix .. " EXIT YES enters title directly") then return end
check(game.session == nil and not Runtime.isActive(), prefix .. " EXIT YES stops field runtime")
check(saves == saveCount and SaveData.readSlotSource("emerald", slot) == rawBefore, prefix .. " EXIT does not implicitly save")
check(game.boot.hasContinue == saved, prefix .. " EXIT preserves existing continue availability")
check(game.boot.custom.coldBoot == false, prefix .. " EXIT consumes cold boot")
if not check(waitUntil(function()
local title = game.boot.custom.title
if not title or title.phase ~= "phase3" then return false end
for _, sprite in pairs(title.m.ppu.sprites.sprites) do
if sprite.inUse and sprite.y == 108 and sprite.data[0] == 1 and not sprite.invisible then return true end
end
return false
end, 5), prefix .. " Emerald title settled") then return end
check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 2) .. "_" .. prefix .. "_exit_title.png"), prefix .. " settled title screenshot")
U.tap(game, "start")
if not check(waitUntil(function()
local menu = game.boot.custom.menu
return game.boot.phase == Boot.PHASE.MENU and menu and menu.state == "input"
end, 4), prefix .. " title START reaches settled main menu") then return end
local menu = game.boot.custom.menu
check(menu.hasContinue == saved and menu.items[1] == (saved and "CONTINUE" or "NEW_GAME"), prefix .. " main menu offers correct first action")
if saved then check(menu.info and menu.info.name == "BRENDAN", "saved main menu retains trainer metadata") end
check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 3) .. "_" .. prefix .. "_main_menu.png"), prefix .. " main menu screenshot")
end
end, debug.traceback)
if SaveData and originalSave then SaveData.save = originalSave end
if not ok then check(false, tostring(err)) end
print((failures == 0 and "PASS" or "FAIL") .. " em_start_menu_exit_90001 failures=" .. failures)
love.event.quit(failures == 0 and 0 or 1)
end
+2
View File
@@ -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
+2 -2
View File
@@ -90,7 +90,7 @@ return function(game)
"Naomi wanders up and down from the sealed band at (14,5)")
U.wait(20)
U.shot(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png")
U.still(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png")
local visited = {}
local shotSouth = false
@@ -99,7 +99,7 @@ return function(game)
visited[naomi.cellX .. "," .. naomi.cellY] = true
visited[naomi.targetX .. "," .. naomi.targetY] = true
if not shotSouth and naomi.cellY == 6 and not naomi.moving then
U.shot(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png")
U.still(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png")
shotSouth = true
end
end
+122
View File
@@ -0,0 +1,122 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_cycling_fly_bug2564"
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
print((failures == 0 and "PASS " or "FAIL ") .. "cycling_fly_bug2564")
love.event.quit(failures == 0 and 0 or 1)
end
return function(game)
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
if not check(game.boot ~= nil, "cycling Fly boot reached") then return finish() end
game:_handleBootAction({ action = "new_game", name = "RED", gender = 0 })
U.wait(240)
local Runtime = require("src.core.game3.runtime")
local session = Runtime.getSession()
if not check(session ~= nil, "cycling Fly field reached") then return finish() end
local Map = require("src.core.game3.map")
local Player = require("src.core.game3.player")
local Field = require("src.core.game3.field")
local Flags = require("src.core.game3.scripting.flags")
local Space = require("src.core.game3.scripting.space")
local Message = require("src.ui.game3.message")
local Fade = require("src.ui.game3.fade")
local ItemUse = require("src.core.game3.item_use")
local Bag = require("src.core.game3.bag")
local Party = require("src.core.game3.party")
local C = require("src.core.game3.constants").of(session.version)
local rse = session.version == "emerald"
local defs = Flags.forVersion(session.version)
local road = assert(defs.IDS[rse and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD"])
local scene = not rse and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16)
local bike = C:require("items", rse and "ITEM_MACH_BIKE" or "ITEM_BICYCLE")
Bag.add(session.bag, bike, 1)
if not rse then Flags.setFlag(Space.store, nil, assert(defs.IDS.FLAG_GOT_BICYCLE), true) end
local function waitFor(pred, limit)
for _ = 1, limit do
if pred() then return true end
U.wait(1)
end
return pred()
end
local function settle()
return waitFor(function()
if Message.isOpen() then
if Message.isTyping() then Message.skipReveal() end
table.insert(game.input.pressQueue, "a")
end
return not Field.locked and not Fade.active and not Player.moving
and not Message.isOpen() and not (Space.vm and Space.vm:isRunning())
end, 600)
end
local function holdUntil(dir, pred, limit)
local reached = false
for _ = 1, limit do
if pred() then reached = true break end
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
U.wait(1)
end
game.input.state[dir] = false
return reached or pred()
end
local gate = rse and "EM_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE"
or "FR_ROUTE_16_NORTH_ENTRANCE_1F"
Map.load(nil, game, gate, { x = rse and 6 or 3, y = rse and 4 or 12,
facing = rse and "right" or "left" })
if not check(settle(), "cycling gate scripts settle") then return finish() end
check(game.data.maps[gate].bikingAllowed == 1, "cycling gate permits riding indoors")
check(ItemUse.useBike(session, bike) and Player.biking, "cycling gate bike mounts")
U.still(game, DIR .. "/cycling_gate_bike.png")
if rse then
if not check(holdUntil("right", function()
return Player.targetX == 12 and Player.targetY == 4
end, 240), "Emerald gate crosses BikeCheck and reaches east exit column") then return finish() end
end
if not check(holdUntil(rse and "down" or "left", function() return session.map ~= gate end, 600),
"cycling gate enters road through coordinate script and warp") then return finish() end
if not check(settle(), "cycling road arrival settles") then return finish() end
check(Flags.getFlag(Space.store, nil, road), "cycling road flag armed by real gate scripts")
if scene then check(Flags.getVar(Space.store, nil, scene) == 1, "Route 16 scene armed") end
check(Player.biking, "cycling road requires bike before Fly")
for _, eo in ipairs(require("src.core.game3.objects").forDraw()) do eo.sight = 0 end
session.party = {}
Party.giveMon(session, C:require("species", rse and "SPECIES_SWELLOW" or "SPECIES_CHARIZARD"), 40)
local section = rse and "MAPSEC_MAUVILLE_CITY" or "MAPSEC_CELADON_CITY"
local dest = assert(Field.flyDestination(section))
check(Field.flyTo(section, session.party[1]), "cycling road Fly starts")
if not check(waitFor(function()
return session.map == dest.map and not Field._flyLanding and not Field.locked and not Fade.active
end, 2400), "cycling Fly landing finishes") then return finish() end
check(not Flags.getFlag(Space.store, nil, road), "Fly clears cycling flag")
if scene then check(Flags.getVar(Space.store, nil, scene) == 0, "Fly clears Route 16 scene") end
check(not Player.biking and not session.biking and not game.save.biking, "Fly lands on foot in every state mirror")
U.still(game, DIR .. "/fly_city_on_foot.png")
local center = rse and "EM_MAUVILLE_CITY_POKEMON_CENTER_1F" or "FR_CELADON_CITY_POKEMON_CENTER_1F"
if not check(holdUntil("up", function() return session.map == center end, 300),
"walking north enters real Center door") then return finish() end
if not check(settle(), "Center warp finishes") then return finish() end
check(game.data.maps[center].bikingAllowed == 0 and not Player.biking and not session.biking,
"Center remains on foot after cycling road Fly")
U.still(game, DIR .. "/center_on_foot.png")
if not check(holdUntil("down", function() return session.map == dest.map end, 300),
"Center exit returns to city") then return finish() end
if not check(settle(), "Center exit settles") then return finish() end
check(ItemUse.useBike(session, bike) and Player.biking, "city bike mounts after cycling road Fly")
U.still(game, DIR .. "/city_bike_toggle.png")
check(ItemUse.useBike(session, bike) and not Player.biking, "city bike dismounts after cycling road Fly")
if rse then
local acro = C:require("items", "ITEM_ACRO_BIKE")
check(ItemUse.useBike(session, acro) and Player.biking and Player.bikeType == "acro", "city Acro Bike mounts")
check(ItemUse.useBike(session, acro) and not Player.biking, "city Acro Bike dismounts")
end
finish()
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)

Some files were not shown because too many files have changed in this diff Show More