mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-09 18:07:27 -04:00
Merge pull request #2582 from bryanthaboi/dev
This commit is contained in:
@@ -36,6 +36,7 @@ body:
|
||||
- Silver
|
||||
- Crystal
|
||||
- FireRed
|
||||
- Emerald
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -12,9 +12,10 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
|
||||
-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE.
|
||||
local emergencyQuitTimer = 0
|
||||
-- getJoysticks() allocates a fresh table every call; this runs once (twice)
|
||||
-- per frame, so cache the list and refresh it once a second instead. The 5s
|
||||
-- hold requirement makes a 1s hotplug delay irrelevant.
|
||||
-- getJoysticks() allocates a fresh table every call; this runs once per
|
||||
-- frame (from love.run, before love.update), so cache the list and refresh
|
||||
-- it once a second instead. The 5s hold requirement makes a 1s hotplug
|
||||
-- delay irrelevant.
|
||||
local cachedJoysticks = nil
|
||||
local joystickCacheAge = 1
|
||||
|
||||
@@ -929,7 +930,8 @@ function love.load(args)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
checkEmergencyQuit(dt)
|
||||
-- checkEmergencyQuit runs from love.run each frame; calling it here too
|
||||
-- would double-count dt and fire the 5s hold after 2.5s.
|
||||
HostDisplay.update(dt)
|
||||
SwitchDiagnostics.maybeFlush(false)
|
||||
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
||||
@@ -1242,8 +1244,20 @@ function love.joystickhat(joystick, hat, direction)
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- The joystick lists cached for polling (checkEmergencyQuit's, Input's
|
||||
-- pollPads) are only refreshed on a count change or a timer otherwise, which
|
||||
-- a controller swapped for another can slip past.
|
||||
local function noteJoysticksChanged()
|
||||
cachedJoysticks = nil
|
||||
local input = Game and Game.input
|
||||
if type(input) == "table" and input.joysticksChanged then
|
||||
input:joysticksChanged()
|
||||
end
|
||||
end
|
||||
|
||||
function love.joystickadded(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
||||
noteJoysticksChanged()
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
@@ -1252,6 +1266,7 @@ end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
||||
noteJoysticksChanged()
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
|
||||
+62
-36
@@ -722,6 +722,25 @@ local function sameColors(a, b)
|
||||
return true
|
||||
end
|
||||
|
||||
-- one OAM tile at its screen rect (rx, ry), honoring the X/Y flips
|
||||
local function blitTile(g, img, quad, s, rx, ry)
|
||||
g.draw(img, quad,
|
||||
rx + (s.xf and 8 or 0),
|
||||
ry + (s.yf and 8 or 0),
|
||||
0,
|
||||
s.xf and -1 or 1,
|
||||
s.yf and -1 or 1)
|
||||
end
|
||||
|
||||
-- the shade-remap uniforms; c0 is the transparent color-0 slot, so it just
|
||||
-- takes color 1
|
||||
local function sendTriple(shader, colors)
|
||||
shader:send("c0", colors[1])
|
||||
shader:send("c1", colors[1])
|
||||
shader:send("c2", colors[2])
|
||||
shader:send("c3", colors[3])
|
||||
end
|
||||
|
||||
-- Draw one compiled step's OAM sprites. With colorFn, each sprite is
|
||||
-- drawn through the PaletteFX shade-remap shader. The SGB colorized
|
||||
-- the finished DMG picture per 8x8 screen cell (the ATTR_BLK regions
|
||||
@@ -730,14 +749,22 @@ end
|
||||
-- once per attribute cell the 8x8 tile touches (up to 4), and cells
|
||||
-- that resolve to a different palette than the first are repainted
|
||||
-- through a scissor clipped to the cell.
|
||||
--
|
||||
-- Per-frame cost: no closures per tile, and the uniforms are only re-sent
|
||||
-- when the colors differ from the last ones this call sent (colorFn
|
||||
-- memoizes, so equal colors are usually the same table). The first send
|
||||
-- of every call is unconditional -- other passes share the shader.
|
||||
function AnimPlayer:drawSprites(sprites, colorFn)
|
||||
local g = love and love.graphics
|
||||
local shader
|
||||
local PaletteFX, shader
|
||||
if colorFn and g and g.setShader then
|
||||
shader = require("src.render.PaletteFX").shader()
|
||||
PaletteFX = require("src.render.PaletteFX")
|
||||
shader = PaletteFX.shader()
|
||||
end
|
||||
local slices = shader and g.getScissor and g.intersectScissor
|
||||
and g.setScissor
|
||||
local sent -- the triple the shader currently holds (this call)
|
||||
local floor = math.floor
|
||||
for i = 1, #sprites do
|
||||
local s = sprites[i]
|
||||
-- hardware hides sprites at the OAM extremes (y=0/y>=160, x=0/x>=168);
|
||||
@@ -747,51 +774,48 @@ function AnimPlayer:drawSprites(sprites, colorFn)
|
||||
local quad = img and self:tileQuad(s.ts, s.tile)
|
||||
if quad then
|
||||
local rx, ry = s.x - 8, s.y - 16 -- screen-space rect of the tile
|
||||
local function blit()
|
||||
g.draw(img, quad,
|
||||
rx + (s.xf and 8 or 0),
|
||||
ry + (s.yf and 8 or 0),
|
||||
0,
|
||||
s.xf and -1 or 1,
|
||||
s.yf and -1 or 1)
|
||||
end
|
||||
-- the attribute cell holding the tile's top-left pixel
|
||||
local cx = math.floor(rx / 8) * 8
|
||||
local cy = math.floor(ry / 8) * 8
|
||||
local cx = floor(rx / 8) * 8
|
||||
local cy = floor(ry / 8) * 8
|
||||
local colors = shader and colorFn(s, cx, cy)
|
||||
if colors then
|
||||
g.setShader(shader)
|
||||
-- c0 is the transparent color-0 slot; send anything
|
||||
shader:send("c0", colors[1])
|
||||
shader:send("c1", colors[1])
|
||||
shader:send("c2", colors[2])
|
||||
shader:send("c3", colors[3])
|
||||
if not (sent and sameColors(sent, colors)) then
|
||||
sendTriple(shader, colors)
|
||||
sent = colors
|
||||
end
|
||||
end
|
||||
blit()
|
||||
blitTile(g, img, quad, s, rx, ry)
|
||||
if colors and slices and (cx ~= rx or cy ~= ry) then
|
||||
-- unaligned: the tile spills into up to 3 more cells; repaint
|
||||
-- the ones whose zone palette differs (opaque overdraw -- GB
|
||||
-- tiles have binary alpha)
|
||||
local function slice(px, py)
|
||||
if px < 0 or py < 0 or px >= 160 or py >= 144 then
|
||||
return -- fully off-canvas
|
||||
local cx2 = floor((rx + 7) / 8) * 8
|
||||
local cy2 = floor((ry + 7) / 8) * 8
|
||||
for n = 1, 3 do
|
||||
local px, py
|
||||
if n == 1 then
|
||||
if cx2 ~= cx then px, py = cx2, cy end
|
||||
elseif n == 2 then
|
||||
if cy2 ~= cy then px, py = cx, cy2 end
|
||||
elseif cx2 ~= cx and cy2 ~= cy then
|
||||
px, py = cx2, cy2
|
||||
end
|
||||
-- skip cells fully off-canvas
|
||||
if px and px >= 0 and py >= 0 and px < 160 and py < 144 then
|
||||
local cc = colorFn(s, px, py)
|
||||
if cc and not sameColors(cc, colors) then
|
||||
local s1, s2, s3, s4 = g.getScissor()
|
||||
g.intersectScissor(px, py, 8, 8)
|
||||
if not sameColors(sent, cc) then
|
||||
sendTriple(shader, cc)
|
||||
sent = cc
|
||||
end
|
||||
blitTile(g, img, quad, s, rx, ry)
|
||||
if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end
|
||||
end
|
||||
end
|
||||
local cc = colorFn(s, px, py)
|
||||
if not cc or sameColors(cc, colors) then return end
|
||||
local s1, s2, s3, s4 = g.getScissor()
|
||||
g.intersectScissor(px, py, 8, 8)
|
||||
shader:send("c0", cc[1])
|
||||
shader:send("c1", cc[1])
|
||||
shader:send("c2", cc[2])
|
||||
shader:send("c3", cc[3])
|
||||
blit()
|
||||
if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end
|
||||
end
|
||||
local cx2 = math.floor((rx + 7) / 8) * 8
|
||||
local cy2 = math.floor((ry + 7) / 8) * 8
|
||||
if cx2 ~= cx then slice(cx2, cy) end
|
||||
if cy2 ~= cy then slice(cx, cy2) end
|
||||
if cx2 ~= cx and cy2 ~= cy then slice(cx2, cy2) end
|
||||
end
|
||||
if colors then
|
||||
g.setShader()
|
||||
@@ -799,6 +823,8 @@ function AnimPlayer:drawSprites(sprites, colorFn)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- the direct sends above bypass sendColors' last-sent record
|
||||
if sent then PaletteFX.forgetSent(shader) end
|
||||
end
|
||||
|
||||
return AnimPlayer
|
||||
|
||||
+182
-33
@@ -247,6 +247,14 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
|
||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||
-- rebuilt for any battle pic, whatever code loaded it
|
||||
local imageMeta = setmetatable({}, WEAK_KEYS)
|
||||
-- image -> memoized fadeImage / blackImage variants (the per-frame picImage
|
||||
-- lookups). They cache what getImage returned, so invalidate() drops them
|
||||
-- together with imageCache.
|
||||
local function newDerivedMemo()
|
||||
return { fade = setmetatable({}, WEAK_KEYS),
|
||||
black = setmetatable({}, WEAK_KEYS) }
|
||||
end
|
||||
local derivedMemo = newDerivedMemo()
|
||||
local function mattedPic(path)
|
||||
return path:sub(1, 17) == "assets/generated/"
|
||||
or path:sub(1, 17) == "save/mod-derived/"
|
||||
@@ -326,6 +334,7 @@ end
|
||||
-- are collected on their own rather than leaking.
|
||||
function BattleState.invalidate()
|
||||
imageCache = {}
|
||||
derivedMemo = newDerivedMemo()
|
||||
end
|
||||
|
||||
Assets.register(BattleState.invalidate)
|
||||
@@ -434,12 +443,28 @@ local function fadeImage(img, bgp)
|
||||
if not meta then return img end
|
||||
-- a full-color pic has no DMG shades to remap
|
||||
if meta.trueColor then return img end
|
||||
-- picImage asks for this every drawn frame of a fade: memoize per
|
||||
-- (pic, shade map) instead of rebuilding the name string + palette table
|
||||
-- (meta is fixed per image; the map's four 0..3 shades are the rest of
|
||||
-- getImage's key, packed into one number)
|
||||
local mkey = bgp[0] * 64 + bgp[1] * 16 + bgp[2] * 4 + bgp[3]
|
||||
local row = derivedMemo.fade[img]
|
||||
local hit = row and row[mkey]
|
||||
if hit then return hit end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local base = meta.pal and meta.pal.colors or PaletteFX.GRAYS
|
||||
local name = (meta.pal and meta.pal.name or "GB")
|
||||
.. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3]
|
||||
return getImage(meta.path,
|
||||
{ name = name, colors = PaletteFX.permute(base, bgp) })
|
||||
local out = getImage(meta.path,
|
||||
{ name = name, colors = PaletteFX.permute(base, bgp) })
|
||||
if out then
|
||||
if not row then
|
||||
row = {}
|
||||
derivedMemo.fade[img] = row
|
||||
end
|
||||
row[mkey] = out
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- the raw DMG-gray build of a colored pic (SE_WAVY_SCREEN bakes the
|
||||
@@ -463,7 +488,15 @@ local function blackImage(data, img)
|
||||
local colors = PaletteFX.usesYellowCgb() and pals.BLACK
|
||||
or PaletteFX.pal(data, "BLACK") or pals.BLACK
|
||||
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
|
||||
return getImage(meta.path, { name = name, colors = colors }) or img
|
||||
-- drawn every frame of the intro slide / blackout text: reuse the last
|
||||
-- bake while the resolved palette and cache name are unchanged
|
||||
local memo = derivedMemo.black[img]
|
||||
if memo and memo.colors == colors and memo.name == name then
|
||||
return memo.out
|
||||
end
|
||||
local out = getImage(meta.path, { name = name, colors = colors }) or img
|
||||
derivedMemo.black[img] = { colors = colors, name = name, out = out }
|
||||
return out
|
||||
end
|
||||
|
||||
-- the asset path a loaded battle image came from (nil for the headless
|
||||
@@ -767,6 +800,18 @@ function BattleState:playerPartyView()
|
||||
end
|
||||
|
||||
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
||||
-- Queue the battle's first two cries on the chip audio worker while the
|
||||
-- transition runs, so the entrance cries don't render on the frame they
|
||||
-- play. A no-op without a worker; playCry renders as before on a miss.
|
||||
local function prewarmCries(self)
|
||||
local Sound = package.loaded["src.core.Sound"]
|
||||
if not (Sound and Sound.prewarmCry) then return end
|
||||
for _, battler in ipairs({ self.enemy, self.player }) do
|
||||
local mon = battler and battler.mon
|
||||
if mon and mon.species then pcall(Sound.prewarmCry, self.data, mon.species) end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState.newWild(game, species, level, opts)
|
||||
local self = newBattle(game)
|
||||
self.kind = "wild"
|
||||
@@ -784,6 +829,7 @@ function BattleState.newWild(game, species, level, opts)
|
||||
else
|
||||
self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name)
|
||||
end
|
||||
prewarmCries(self)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -917,6 +963,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex, opts)
|
||||
self.trainerPic = BattleState.trainerSprite(
|
||||
game.data, self.trainer, oppClass, partyIndex)
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
prewarmCries(self)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -2101,9 +2148,17 @@ end
|
||||
-- flag survived into the next turn, ate a move the real players saw
|
||||
-- land, and from there the replay was watching a different battle.
|
||||
-- LinkBattle.newSpectator calls this at the head of every turn.
|
||||
local function clearFlinch(b)
|
||||
if not (b.mustRecharge or b.rageMove) then b.flinched = false end
|
||||
end
|
||||
|
||||
function BattleState:clearTurnFlinches()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
|
||||
-- ipairs({ self.player, self.enemy }) without the per-call table: the
|
||||
-- enemy is only visited when the player slot is set, like ipairs did
|
||||
local p = self.player
|
||||
if p then
|
||||
clearFlinch(p)
|
||||
if self.enemy then clearFlinch(self.enemy) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2305,16 +2360,37 @@ function BattleState:tickFx()
|
||||
self:updateFx()
|
||||
end
|
||||
|
||||
-- ScrollTextUpOneLine's pixel scroll (beginMsgLine sets scrollPx = 8):
|
||||
-- 2px per logic step while the message box shows the rolling window. It
|
||||
-- used to count down inside drawTextArea / WideBattle's drawMessageBox, so
|
||||
-- a 144Hz display scrolled 2.4x too fast; update() now runs this once per
|
||||
-- step AFTER the queue typed, which is the order the draw-side decrement
|
||||
-- saw it (8 is set and the box already draws at 6 on that frame).
|
||||
function BattleState:tickTextScroll()
|
||||
local px = self.scrollPx
|
||||
if px and px > 0 and self.phase == "messages"
|
||||
and (self.current or self.animPlaying or self.msgHold) then
|
||||
px = px - 2
|
||||
self.scrollPx = px > 0 and px or nil
|
||||
end
|
||||
end
|
||||
|
||||
local function snapIdleBar(b)
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
|
||||
-- runs every menu-phase step: no per-call { player, enemy } table (the
|
||||
-- enemy is only visited when the player slot is set, like ipairs did)
|
||||
function BattleState:snapIdleBars()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
local p = self.player
|
||||
if p then
|
||||
snapIdleBar(p)
|
||||
if self.enemy then snapIdleBar(self.enemy) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2357,6 +2433,7 @@ function BattleState:update(dt)
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
self:tickTextScroll()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -5820,6 +5897,26 @@ local HudTiles = require("src.render.HudTiles")
|
||||
local hudTile = HudTiles.tile
|
||||
local drawHPBar = HudTiles.drawHPBar
|
||||
|
||||
-- One reusable row-clip quad per battler for the faint / slide pic effects
|
||||
-- (drawn every frame they run): re-aimed with setViewport instead of a new
|
||||
-- Quad per frame. A quad without setViewport (headless stubs) is simply
|
||||
-- rebuilt, which is what every frame used to do.
|
||||
function BattleState:picQuad(battler, x, y, w, h, sw, sh)
|
||||
local quads = self.picQuads
|
||||
if not quads then
|
||||
quads = setmetatable({}, WEAK_KEYS)
|
||||
self.picQuads = quads
|
||||
end
|
||||
local q = quads[battler]
|
||||
if q and q.setViewport then
|
||||
q:setViewport(x, y, w, h, sw, sh)
|
||||
return q
|
||||
end
|
||||
q = love.graphics.newQuad(x, y, w, h, sw, sh)
|
||||
quads[battler] = q
|
||||
return q
|
||||
end
|
||||
|
||||
-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile.
|
||||
-- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one
|
||||
-- charmap sequence per glyph, and byte length would push it a tile left.
|
||||
@@ -6084,8 +6181,8 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
|
||||
end
|
||||
local visible = img:getHeight() - math.floor(off / scale)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
|
||||
img:getWidth(), img:getHeight())
|
||||
local quad = self:picQuad(battler, 0, 0, img:getWidth(), visible,
|
||||
img:getWidth(), img:getHeight())
|
||||
love.graphics.draw(img, quad, x, y + off, 0, scale, scale)
|
||||
end
|
||||
return
|
||||
@@ -6170,7 +6267,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
|
||||
-- sink below the baseline (AnimationSlideMonDown-style row clip)
|
||||
local visible = h - math.floor(oy / scale)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, 0, w, visible, w, h)
|
||||
local quad = self:picQuad(battler, 0, 0, w, visible, w, h)
|
||||
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
|
||||
end
|
||||
elseif k == "slideUp" then
|
||||
@@ -6181,7 +6278,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
|
||||
local step = math.min(7, math.floor((t - 1) / 2) + 1)
|
||||
local visible = math.floor(h * step / 7)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
|
||||
local quad = self:picQuad(battler, 0, h - visible, w, visible, w, h)
|
||||
love.graphics.draw(img, quad, x + ox,
|
||||
y + (h - visible) * scale, 0, scale, scale)
|
||||
end
|
||||
@@ -6291,9 +6388,10 @@ function BattleState:sgbBattlePals()
|
||||
return out
|
||||
end
|
||||
|
||||
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
|
||||
function BattleState:zoneColorsAt(x, y)
|
||||
local pals = self:sgbBattlePals()
|
||||
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions).
|
||||
-- pals: an sgbBattlePals() result the caller already resolved this frame.
|
||||
function BattleState:zoneColorsAt(x, y, pals)
|
||||
pals = pals or self:sgbBattlePals()
|
||||
if not pals then return nil end
|
||||
local tx = math.floor(x / 8)
|
||||
local ty = math.floor(y / 8)
|
||||
@@ -6358,13 +6456,32 @@ function BattleState:drawZonePass(src, sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
local shaking = sx ~= 0 or sy ~= 0
|
||||
-- the six zones share four palettes (one gray ramp in mono): permute each
|
||||
-- once per pass instead of once per zone
|
||||
local permuted = self.zonePermuted
|
||||
if not permuted then
|
||||
permuted = {}
|
||||
self.zonePermuted = permuted
|
||||
end
|
||||
permuted[0], permuted[1], permuted[2], permuted[3], permuted.gray =
|
||||
nil, nil, nil, nil, nil
|
||||
for _, z in ipairs(BATTLE_ZONES) do
|
||||
if mono then
|
||||
-- the BGP fade still runs, just in gray: the frame-level pass colors
|
||||
-- whatever DMG shade this leaves behind
|
||||
PaletteFX.sendShades(shader, PaletteFX.permute(PaletteFX.GRAYS, bgp))
|
||||
local shades = permuted.gray
|
||||
if not shades then
|
||||
shades = PaletteFX.permute(PaletteFX.GRAYS, bgp)
|
||||
permuted.gray = shades
|
||||
end
|
||||
PaletteFX.sendShades(shader, shades)
|
||||
else
|
||||
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
|
||||
local colors = permuted[z.pal]
|
||||
if not colors then
|
||||
colors = PaletteFX.permute(pals[z.pal], bgp)
|
||||
permuted[z.pal] = colors
|
||||
end
|
||||
PaletteFX.sendColors(shader, colors)
|
||||
end
|
||||
local zx, zy = z[1] * 8, z[2] * 8
|
||||
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
|
||||
@@ -6390,7 +6507,18 @@ local OBJ_SHADES = {
|
||||
e4x = { 2, 1, 3 }, -- $e4 xor %00111100 = $d8
|
||||
obp1 = { 3, 2, 1 }, -- $6c
|
||||
}
|
||||
function BattleState:animSpriteColors(s, px, py)
|
||||
local function shadeTriple(P, shade)
|
||||
local col = P[shade + 1]
|
||||
return { col[1] / 255, col[2] / 255, col[3] / 255 }
|
||||
end
|
||||
|
||||
-- memo (optional): a per-frame table from drawAnimLayer. It holds the
|
||||
-- frame's sgbBattlePals() (memo.pals, false when there are none) so the
|
||||
-- palette set is resolved once per frame instead of once per attribute
|
||||
-- cell of every OAM tile, and the finished triples per (palette, obp key)
|
||||
-- so tiles in the same zone share one table (AnimPlayer then sees equal
|
||||
-- colors by identity).
|
||||
function BattleState:animSpriteColors(s, px, py, memo)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local key = s.obp or "f0"
|
||||
local P
|
||||
@@ -6399,16 +6527,32 @@ function BattleState:animSpriteColors(s, px, py)
|
||||
-- engine/battle/init_battle_variables.asm:18
|
||||
P = PaletteFX.ogObjBase()
|
||||
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
|
||||
elseif memo then
|
||||
local pals = memo.pals
|
||||
if pals == nil then
|
||||
pals = self:sgbBattlePals() or false
|
||||
memo.pals = pals
|
||||
end
|
||||
P = pals and self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4),
|
||||
pals) or nil
|
||||
else
|
||||
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
|
||||
end
|
||||
if not P then return nil end
|
||||
local byKey = memo and memo[P]
|
||||
local hit = byKey and byKey[key]
|
||||
if hit then return hit end
|
||||
local m = OBJ_SHADES[key] or OBJ_SHADES.f0
|
||||
local function c(shade)
|
||||
local col = P[shade + 1]
|
||||
return { col[1] / 255, col[2] / 255, col[3] / 255 }
|
||||
local out = { shadeTriple(P, m[1]), shadeTriple(P, m[2]),
|
||||
shadeTriple(P, m[3]) }
|
||||
if memo then
|
||||
if not byKey then
|
||||
byKey = {}
|
||||
memo[P] = byKey
|
||||
end
|
||||
byKey[key] = out
|
||||
end
|
||||
return { c(m[1]), c(m[2]), c(m[3]) }
|
||||
return out
|
||||
end
|
||||
|
||||
-- the OAM anim layer (subanimation sprites / the resting caught ball)
|
||||
@@ -6417,7 +6561,16 @@ function BattleState:drawAnimLayer(colorized)
|
||||
if self.fieldCleared then return end
|
||||
local colorFn
|
||||
if colorized then
|
||||
colorFn = function(s, px, py) return self:animSpriteColors(s, px, py) end
|
||||
-- fresh per-frame memo (palette set + color triples), one long-lived
|
||||
-- closure: colorFn runs up to four times per OAM tile
|
||||
self.animColorMemo = {}
|
||||
colorFn = self.animColorFn
|
||||
if not colorFn then
|
||||
colorFn = function(s, px, py)
|
||||
return self:animSpriteColors(s, px, py, self.animColorMemo)
|
||||
end
|
||||
self.animColorFn = colorFn
|
||||
end
|
||||
end
|
||||
if self.animPlaying and self.animPlayer then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -6807,10 +6960,6 @@ function BattleState:drawTextArea()
|
||||
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
||||
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
||||
-- view instead of drawing off-screen at y=144 (#216).
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
|
||||
@@ -183,10 +183,8 @@ end
|
||||
local function drawMessageBox(battle)
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if battle.scrollPx and battle.scrollPx > 0 then
|
||||
battle.scrollPx = battle.scrollPx - 2
|
||||
if battle.scrollPx <= 0 then battle.scrollPx = nil end
|
||||
end
|
||||
-- scrollPx counts down in BattleState:tickTextScroll (logic step), not
|
||||
-- here, so the scroll speed does not follow the display refresh rate
|
||||
local off = battle.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(battle.shown or {}) do
|
||||
|
||||
@@ -42,6 +42,7 @@ local NUM_STRUCTS = 10 -- NUM_BATTLE_ANIM_STRUCTS
|
||||
-- wShadowOAM is 40 objects; BattleAnimOAMUpdate returns carry once it is full
|
||||
-- and BattleAnim_UpdateOAM_All stops walking the structs.
|
||||
local OAM_LIMIT = 40
|
||||
local EMPTY = {}
|
||||
|
||||
local OAM_PRIO, OAM_YFLIP, OAM_XFLIP = 0x80, 0x40, 0x20
|
||||
local OAM_FLAG_MASK = 0xe0
|
||||
@@ -332,22 +333,31 @@ function Pool:updateOam(st)
|
||||
local tileId = u8(buf.tileId + (set.vtile or 0))
|
||||
local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0
|
||||
local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0
|
||||
for _, entry in ipairs(set.sprites or {}) do
|
||||
if #self.oam >= OAM_LIMIT then return true end
|
||||
local oam = self.oam
|
||||
local pool = self.oamPool
|
||||
for _, entry in ipairs(set.sprites or EMPTY) do
|
||||
local n = #oam
|
||||
if n >= OAM_LIMIT then return true end
|
||||
-- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's;
|
||||
-- OAM_PAL1 passes through from the entry, and the palette slot comes from
|
||||
-- the struct.
|
||||
local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK)
|
||||
attr = attr + bit.band(entry.attr or 0, OAM_PAL1)
|
||||
self.oam[#self.oam + 1] = {
|
||||
y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)),
|
||||
x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)),
|
||||
-- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by
|
||||
-- every sheet lookup, so the port keeps tiles in sheet-relative space.
|
||||
tile = u8(tileId + (entry.tile or 0)),
|
||||
attr = attr,
|
||||
palette = buf.palette,
|
||||
}
|
||||
-- Shadow OAM slots are recycled frame to frame (playFrame), the way the
|
||||
-- cart rewrites wShadowOAM in place: the 40 tables are made once.
|
||||
local out = pool and pool[n + 1]
|
||||
if not out then
|
||||
out = {}
|
||||
if pool then pool[n + 1] = out end
|
||||
end
|
||||
out.y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip))
|
||||
out.x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip))
|
||||
-- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by
|
||||
-- every sheet lookup, so the port keeps tiles in sheet-relative space.
|
||||
out.tile = u8(tileId + (entry.tile or 0))
|
||||
out.attr = attr
|
||||
out.palette = buf.palette
|
||||
oam[n + 1] = out
|
||||
end
|
||||
return false
|
||||
end
|
||||
@@ -356,8 +366,18 @@ end
|
||||
-- write its OAM entries. A struct that deinitialises itself inside its
|
||||
-- function still draws this frame, because the ASM calls BattleAnimOAMUpdate
|
||||
-- unconditionally.
|
||||
--
|
||||
-- The OAM list and its entries are reused from frame to frame: what a frame
|
||||
-- returns is valid until the next playFrame, which is all the draw needs.
|
||||
function Pool:playFrame()
|
||||
self.oam = {}
|
||||
local oam = self.oamList
|
||||
if not oam then
|
||||
oam = {}
|
||||
self.oamList = oam
|
||||
self.oamPool = {}
|
||||
end
|
||||
for i = #oam, 1, -1 do oam[i] = nil end
|
||||
self.oam = oam
|
||||
for slot = 1, NUM_STRUCTS do
|
||||
local st = self.structs[slot]
|
||||
if st.index ~= 0 then
|
||||
|
||||
+224
-26
@@ -84,6 +84,16 @@ local lastCosted
|
||||
local worker, cmdCh, outCh
|
||||
local workerReady -- nil = untried, true = running, false = unavailable
|
||||
|
||||
-- SFX/cry prewarm state (see "one-shot effects" below): results come back on
|
||||
-- their own channel so a music stop/clear never drops them
|
||||
local fxCh
|
||||
local fxEpoch = 0 -- bumped by invalidate: drops results from a stale def
|
||||
local fxReady = {} -- key -> SoundData | false (renders to nothing)
|
||||
local fxPending = {} -- key -> true while a request is in flight
|
||||
local fxReadyCount = 0
|
||||
local FX_READY_MAX = 64
|
||||
local drainEffects, dropEffects
|
||||
|
||||
local function ensureWorker()
|
||||
if workerReady ~= nil then return workerReady end
|
||||
if not (love.thread and love.thread.newThread and love.audio) then
|
||||
@@ -147,25 +157,70 @@ end
|
||||
|
||||
-- The queue is deep (MUSIC_BUFFER_COUNT, ~6s) for stall tolerance, but
|
||||
-- synthesizing all of it on the frame a song starts renders ~6s of audio at
|
||||
-- once. Cap how many buffers each fill renders; playback drains ~1 buffer
|
||||
-- every ~11 frames while update() tops up a few per frame, so the deep queue
|
||||
-- still ramps to full within a fraction of a second.
|
||||
-- once. A (re)start renders MUSIC_FILL_INITIAL whole buffers so playback has
|
||||
-- lookahead; after that each update() renders a flat slice of samples into a
|
||||
-- staging buffer and queues it once all MUSIC_BUFFER_SAMPLES are in. Whole
|
||||
-- 8192-sample buffers rendered on the frame a queue slot freed (every ~11
|
||||
-- frames) read as a periodic stutter; ~1024 samples per 60 Hz tick
|
||||
-- (playback drains ~735) keeps the per-frame cost flat while the queue still
|
||||
-- creeps up to full. Below MUSIC_FILL_LOW_WATER queued buffers (a long
|
||||
-- stall, or update() called well under 60 Hz) a tick renders a whole buffer's
|
||||
-- worth so the queue cannot run dry. The sample stream is unchanged: every
|
||||
-- buffer is still MUSIC_BUFFER_SAMPLES consecutive engine samples, and a
|
||||
-- buffer begun before the song ended is completed exactly as before.
|
||||
local MUSIC_FILL_INITIAL = 4
|
||||
local MUSIC_FILL_PER_CALL = 3
|
||||
local MUSIC_FILL_TICK_SAMPLES = 1024 -- at 44100 Hz; scaled with the rate
|
||||
local MUSIC_FILL_LOW_WATER = 2
|
||||
|
||||
local function fillSync(limit)
|
||||
local function tickSamples()
|
||||
return math.max(256,
|
||||
math.floor(MUSIC_FILL_TICK_SAMPLES * sampleRate() / 44100 + 0.5))
|
||||
end
|
||||
|
||||
-- render up to `budget` samples of the fallback song, queueing each staging
|
||||
-- buffer as it fills. A new buffer is only begun while the Source has a free
|
||||
-- slot and the song has not finished (the old whole-buffer loop's gates).
|
||||
local function renderSync(music, budget)
|
||||
while budget > 0 do
|
||||
if not music.staging then
|
||||
if music.engine:finished() then return end
|
||||
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
|
||||
if not ok or type(free) ~= "number" or free <= 0 then return end
|
||||
music.staging = ChipSynth.newBuffer(MUSIC_BUFFER_SAMPLES, 2)
|
||||
music.stagingFill = 0
|
||||
end
|
||||
local count = math.min(budget, MUSIC_BUFFER_SAMPLES - music.stagingFill)
|
||||
ChipSynth.renderInto(music.engine, music.staging, music.stagingFill,
|
||||
count, 2)
|
||||
music.stagingFill = music.stagingFill + count
|
||||
budget = budget - count
|
||||
if music.stagingFill >= MUSIC_BUFFER_SAMPLES then
|
||||
local sd = music.staging
|
||||
music.staging, music.stagingFill = nil, 0
|
||||
if not pcall(music.source.queue, music.source, sd) then return end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- `buffers`: render that many whole buffers now (song start / restart);
|
||||
-- omitted: one per-frame tick
|
||||
local function fillSync(buffers)
|
||||
if suspended then return end
|
||||
local music = currentMusic
|
||||
if not music or not music.engine or music.engine:finished() then return end
|
||||
limit = limit or MUSIC_FILL_PER_CALL
|
||||
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
|
||||
if not ok or type(free) ~= "number" then return end
|
||||
while free > 0 and limit > 0 and not music.engine:finished() do
|
||||
local sd = ChipSynth.soundData(music.engine, MUSIC_BUFFER_SAMPLES, 2)
|
||||
if not pcall(music.source.queue, music.source, sd) then return end
|
||||
free = free - 1
|
||||
limit = limit - 1
|
||||
if not music or not music.engine then return end
|
||||
if not music.staging and music.engine:finished() then return end
|
||||
local budget
|
||||
if buffers then
|
||||
budget = buffers * MUSIC_BUFFER_SAMPLES - (music.stagingFill or 0)
|
||||
else
|
||||
budget = tickSamples()
|
||||
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
|
||||
if ok and type(free) == "number"
|
||||
and MUSIC_BUFFER_COUNT - free < MUSIC_FILL_LOW_WATER then
|
||||
budget = MUSIC_BUFFER_SAMPLES
|
||||
end
|
||||
end
|
||||
renderSync(music, budget)
|
||||
end
|
||||
|
||||
local function playMusicSync(data, header, allowLoops)
|
||||
@@ -360,6 +415,7 @@ end
|
||||
|
||||
function ChipAudio.update()
|
||||
if suspended then return end
|
||||
if fxCh then drainEffects() end
|
||||
local m = currentMusic
|
||||
if not m then return end
|
||||
if m.threaded then
|
||||
@@ -446,6 +502,7 @@ end
|
||||
function ChipAudio.invalidate()
|
||||
ChipAudio.stopMusic()
|
||||
ChipSynth.invalidateBanks()
|
||||
dropEffects()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||
end
|
||||
|
||||
@@ -458,6 +515,8 @@ function ChipAudio.shutdown()
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, outCh = nil, nil, nil
|
||||
workerReady = nil
|
||||
dropEffects()
|
||||
fxCh = nil
|
||||
end
|
||||
|
||||
function ChipAudio.currentSource()
|
||||
@@ -500,6 +559,8 @@ function ChipAudio.setStereo(enabled)
|
||||
local m = currentMusic
|
||||
if m and m.engine then
|
||||
ChipSynth.applyStereo(m.engine)
|
||||
-- a half-rendered fallback buffer holds the previous pan mix (#1471)
|
||||
m.staging, m.stagingFill = nil, 0
|
||||
end
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
@@ -619,31 +680,161 @@ Assets.register(ChipAudio.invalidate)
|
||||
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Prewarm (#first-cry hitch): the first play of an SFX/cry synthesizes its
|
||||
-- whole PCM on the main thread (a cry is ~10-25 ms of Lua synthesis, a long
|
||||
-- jingle 150-350 ms). ChipAudio.prewarmSfx / prewarmCry hand that render to
|
||||
-- the music worker ahead of time -- e.g. while a battle transition runs, for
|
||||
-- the two species about to cry -- and the next newSfx/newCry with the same
|
||||
-- def, modifiers and channel mix takes the finished SoundData instead of
|
||||
-- rendering. Playback timing is unchanged: a play never waits on the worker
|
||||
-- (a request still in flight is rendered synchronously as before and its
|
||||
-- late result dropped), and the worker runs the same ChipSynth code with the
|
||||
-- same rate/mix, so the PCM is identical. No worker (headless, love.thread
|
||||
-- unavailable or dead): prewarm is a no-op and plays render as before.
|
||||
|
||||
local function effectKey(data, header, options)
|
||||
if type(header) ~= "table" then return nil end
|
||||
local volumes = ChipSynth.getChannelVolumes()
|
||||
local pitches = ChipSynth.getChannelPitches()
|
||||
-- table identities: a reloaded def (or dataset) is a new table
|
||||
return table.concat({
|
||||
tostring(data and data.audio), tostring(header.chip or header),
|
||||
tostring(options.frequencyOffset), tostring(options.frameTicks),
|
||||
tostring(options.plainFrames), tostring(options.cryLength),
|
||||
tostring(options.maxSeconds),
|
||||
sampleRate(), ChipSynth.getStereo() and 1 or 0,
|
||||
volumes[1], volumes[2], volumes[3], volumes[4],
|
||||
pitches[1], pitches[2], pitches[3], pitches[4], fxEpoch,
|
||||
}, "|")
|
||||
end
|
||||
|
||||
function drainEffects()
|
||||
if not fxCh then return end
|
||||
while true do
|
||||
local result = fxCh:pop()
|
||||
if not result then return end
|
||||
if result.epoch == fxEpoch and fxPending[result.key] then
|
||||
fxPending[result.key] = nil
|
||||
if result.error then
|
||||
require("src.core.Logger").warn("chip audio prewarm: %s",
|
||||
tostring(result.error))
|
||||
elseif fxReadyCount < FX_READY_MAX then
|
||||
fxReady[result.key] = result.sd or false
|
||||
fxReadyCount = fxReadyCount + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function dropEffects()
|
||||
fxEpoch = fxEpoch + 1
|
||||
fxReady, fxPending, fxReadyCount = {}, {}, 0
|
||||
if fxCh then fxCh:clear() end
|
||||
end
|
||||
|
||||
local function requestEffect(data, header, options)
|
||||
if not ensureWorker() or not workerAlive() then return false end
|
||||
local key = effectKey(data, header, options)
|
||||
if not key then return false end
|
||||
drainEffects()
|
||||
if fxReady[key] ~= nil or fxPending[key] then return true end
|
||||
fxCh = fxCh or love.thread.getChannel("chipaudio_fx")
|
||||
local pushed = pcall(cmdCh.push, cmdCh, {
|
||||
cmd = "effect", key = key, epoch = fxEpoch, header = header,
|
||||
options = {
|
||||
frequencyOffset = options.frequencyOffset,
|
||||
frameTicks = options.frameTicks,
|
||||
plainFrames = options.plainFrames,
|
||||
cryLength = options.cryLength,
|
||||
maxSeconds = options.maxSeconds,
|
||||
},
|
||||
audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches(),
|
||||
stereo = ChipSynth.getStereo(),
|
||||
sampleRate = sampleRate(),
|
||||
})
|
||||
if not pushed then return false end
|
||||
fxPending[key] = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- a finished prewarm for exactly this render, or nil
|
||||
local function takeEffect(data, header, options)
|
||||
if not fxCh then return nil end
|
||||
local key = effectKey(data, header, options)
|
||||
if not key then return nil end
|
||||
drainEffects()
|
||||
local sd = fxReady[key]
|
||||
if sd ~= nil then
|
||||
fxReady[key] = nil
|
||||
fxReadyCount = fxReadyCount - 1
|
||||
return sd
|
||||
end
|
||||
-- about to render it here; the worker's copy would arrive unused
|
||||
fxPending[key] = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
local function renderEffect(data, header, options)
|
||||
local sd = ChipSynth.renderEffectData(data, header, options)
|
||||
local sd = takeEffect(data, header, options)
|
||||
if sd == nil then sd = ChipSynth.renderEffectData(data, header, options) end
|
||||
if not sd then return nil end
|
||||
return love.audio.newSource(sd, "static")
|
||||
end
|
||||
|
||||
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
|
||||
header = header or data.audio.sfx[name]
|
||||
return renderEffect(data, header, {
|
||||
local function sfxOptions(pitch, tempo, plainFrames)
|
||||
return {
|
||||
frequencyOffset = pitch or 0,
|
||||
frameTicks = 0x80 + (tempo or 0x80),
|
||||
plainFrames = plainFrames,
|
||||
})
|
||||
}
|
||||
end
|
||||
|
||||
local function cryDef(data, species, resolved)
|
||||
return resolved or (data.audio.cries and data.audio.cries[species])
|
||||
end
|
||||
|
||||
local function cryOptions(cry)
|
||||
return { frequencyOffset = cry.pitch, cryLength = cry.length }
|
||||
end
|
||||
|
||||
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
|
||||
header = header or data.audio.sfx[name]
|
||||
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
|
||||
end
|
||||
|
||||
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
|
||||
-- out -- a derived cry borrowing another species' header with its own
|
||||
-- modifiers, which no registry lookup under `species` could find
|
||||
function ChipAudio.newCry(data, species, resolved)
|
||||
local cry = resolved or (data.audio.cries and data.audio.cries[species])
|
||||
local cry = cryDef(data, species, resolved)
|
||||
if not cry then return nil end
|
||||
return renderEffect(data, cry.chip and cry or cry.header, {
|
||||
frequencyOffset = cry.pitch,
|
||||
cryLength = cry.length,
|
||||
})
|
||||
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry))
|
||||
end
|
||||
|
||||
-- Same arguments as newSfx / newCry; returns true when a render was queued
|
||||
-- on the worker (or is already queued / done), false when there is no worker
|
||||
-- to do it. Never renders on the calling thread.
|
||||
function ChipAudio.prewarmSfx(data, name, pitch, tempo, header, plainFrames)
|
||||
header = header or (data.audio and data.audio.sfx and data.audio.sfx[name])
|
||||
if not header then return false end
|
||||
return requestEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
|
||||
end
|
||||
|
||||
function ChipAudio.prewarmCry(data, species, resolved)
|
||||
local cry = data.audio and cryDef(data, species, resolved)
|
||||
if not cry then return false end
|
||||
local header = cry.chip and cry or cry.header
|
||||
if not header then return false end
|
||||
return requestEffect(data, header, cryOptions(cry))
|
||||
end
|
||||
|
||||
-- test hook: prewarm bookkeeping
|
||||
function ChipAudio._effectStateForTest()
|
||||
local pending = 0
|
||||
for _ in pairs(fxPending) do pending = pending + 1 end
|
||||
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch }
|
||||
end
|
||||
|
||||
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
|
||||
@@ -659,6 +850,7 @@ function ChipAudio.newLowHealthAlarm()
|
||||
local SAMPLE_RATE = sampleRate()
|
||||
local samples = math.floor(SAMPLE_RATE * 60 / 60)
|
||||
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
|
||||
local pointer = ChipSynth._int16Pointer(data)
|
||||
local phase = 0
|
||||
for index = 0, samples - 1 do
|
||||
local frame = math.floor(index * 60 / SAMPLE_RATE) % 30
|
||||
@@ -666,8 +858,14 @@ function ChipAudio.newLowHealthAlarm()
|
||||
local frequency = 131072 / (2048 - register)
|
||||
phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
local value = (phase < 0.5 and 1 or -1) * 0.25
|
||||
data:setSample(index, 1, value)
|
||||
data:setSample(index, 2, value)
|
||||
if pointer then
|
||||
-- same int16 setSample stores (ChipSynth bulk PCM writes)
|
||||
pointer[index * 2] = value * 32767
|
||||
pointer[index * 2 + 1] = value * 32767
|
||||
else
|
||||
data:setSample(index, 1, value)
|
||||
data:setSample(index, 2, value)
|
||||
end
|
||||
end
|
||||
return love.audio.newSource(data, "static")
|
||||
end
|
||||
|
||||
+118
-8
@@ -1407,12 +1407,68 @@ function Engine:sampleChannel(number)
|
||||
return analogOut(self, selected, "hpfCap", "lpf")
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
-- is available on worker threads, so this is the hand-off unit the worker
|
||||
-- produces and the main thread queues.
|
||||
local function soundData(engine, samples, channels)
|
||||
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
|
||||
for index = 0, samples - 1 do
|
||||
-- Bulk PCM writes. SoundData:setSample is a C call per sample (two per
|
||||
-- stereo frame); at 44100 Hz that was a large slice of every rendered buffer.
|
||||
-- Where LuaJIT's ffi and Data:getFFIPointer exist, samples go straight into
|
||||
-- the SoundData's int16 storage instead. LOVE 11.5's 16-bit setSample stores
|
||||
-- (int16)(value * 32767) in double precision (truncating, no clamp), which is
|
||||
-- exactly what an ffi int16 store of `value * 32767` does, so the PCM is
|
||||
-- bit-identical to the setSample path (tests/chip_synth_bulk_pcm_test.lua,
|
||||
-- plus a real-LOVE comparison over 2M values incl. out-of-range and NaN).
|
||||
-- The setSample path stays as the fallback (headless stub, no ffi).
|
||||
local ffiOk, ffi = pcall(require, "ffi")
|
||||
local bulkWrites = true
|
||||
|
||||
function ChipSynth._setBulkWritesForTest(enabled)
|
||||
bulkWrites = not not enabled
|
||||
end
|
||||
|
||||
local function int16Pointer(sd)
|
||||
if not (bulkWrites and ffiOk and ffi and sd.getFFIPointer) then return nil end
|
||||
if sd.getBitDepth then
|
||||
local okDepth, depth = pcall(sd.getBitDepth, sd)
|
||||
if not okDepth or depth ~= 16 then return nil end
|
||||
end
|
||||
local ok, pointer = pcall(sd.getFFIPointer, sd)
|
||||
if not ok or pointer == nil then return nil end
|
||||
return ffi.cast("int16_t *", pointer)
|
||||
end
|
||||
ChipSynth._int16Pointer = int16Pointer
|
||||
|
||||
-- does this runtime's SoundData expose writable int16 storage? Probed once
|
||||
-- (a 1-frame buffer) so renderEffectData can pick its path before rendering.
|
||||
local pointerSupport
|
||||
|
||||
local function bulkAvailable()
|
||||
if not (bulkWrites and ffiOk and ffi) then return false end
|
||||
if pointerSupport == nil then
|
||||
local ok, probe = pcall(love.sound.newSoundData, 1, SAMPLE_RATE, 16, 2)
|
||||
pointerSupport = (ok and probe and int16Pointer(probe)) and true or false
|
||||
end
|
||||
return pointerSupport
|
||||
end
|
||||
|
||||
-- render `count` frames from `engine` into an existing SoundData starting at
|
||||
-- frame `offset`. The synchronous music fallback fills one buffer a slice at
|
||||
-- a time across frames (ChipAudio fillSync) so its per-frame cost is flat.
|
||||
local function renderInto(engine, result, offset, count, channels)
|
||||
local last = offset + count - 1
|
||||
local pointer = int16Pointer(result)
|
||||
if pointer then
|
||||
if channels == 2 then
|
||||
for index = offset, last do
|
||||
local left, right = engine:sampleStereo()
|
||||
pointer[index * 2] = left * 32767
|
||||
pointer[index * 2 + 1] = right * 32767
|
||||
end
|
||||
else
|
||||
for index = offset, last do
|
||||
pointer[index] = engine:sample() * 32767
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
for index = offset, last do
|
||||
if channels == 2 then
|
||||
local left, right = engine:sampleStereo()
|
||||
result:setSample(index, 1, left)
|
||||
@@ -1424,6 +1480,34 @@ local function soundData(engine, samples, channels)
|
||||
return result
|
||||
end
|
||||
|
||||
local function newBuffer(samples, channels)
|
||||
return love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
-- is available on worker threads, so this is the hand-off unit the worker
|
||||
-- produces and the main thread queues.
|
||||
local function soundData(engine, samples, channels)
|
||||
local result = newBuffer(samples, channels)
|
||||
return renderInto(engine, result, 0, samples, channels)
|
||||
end
|
||||
|
||||
-- reusable int16 scratch for renderEffectData's bulk path: the effect length
|
||||
-- is only known once the engine finishes, so samples are converted as they
|
||||
-- are produced and copied (duplicated to both channels) at the end, instead
|
||||
-- of growing a Lua table of doubles per render
|
||||
local scratch, scratchSize = nil, 0
|
||||
|
||||
local function ensureScratch(size)
|
||||
if scratchSize >= size then return scratch end
|
||||
local grown = math.max(size, scratchSize * 2, 4096)
|
||||
local fresh = ffi.new("int16_t[?]", grown)
|
||||
if scratch and scratchSize > 0 then
|
||||
ffi.copy(fresh, scratch, scratchSize * 2)
|
||||
end
|
||||
scratch, scratchSize = fresh, grown
|
||||
return scratch
|
||||
end
|
||||
|
||||
local function renderEffectData(data, header, options)
|
||||
if not header then return nil end
|
||||
@@ -1433,14 +1517,38 @@ local function renderEffectData(data, header, options)
|
||||
local engine = Engine.new(data, header, options)
|
||||
-- audio/sfx/pokeflute.asm:20
|
||||
local maximum = SAMPLE_RATE * (options.maxSeconds or 12)
|
||||
local minimum = math.floor(SAMPLE_RATE / 100)
|
||||
if bulkAvailable() then
|
||||
local values = ensureScratch(SAMPLE_RATE)
|
||||
local count = 0
|
||||
while count < maximum and not engine:finished() do
|
||||
if count >= scratchSize then values = ensureScratch(count + 1) end
|
||||
values[count] = engine:sample() * 32767
|
||||
count = count + 1
|
||||
end
|
||||
if count < minimum then return nil end
|
||||
local result = newBuffer(count, 2)
|
||||
local pointer = int16Pointer(result)
|
||||
if pointer then
|
||||
for index = 0, count - 1 do
|
||||
local value = values[index]
|
||||
pointer[index * 2] = value
|
||||
pointer[index * 2 + 1] = value
|
||||
end
|
||||
return result
|
||||
end
|
||||
-- the probe said yes but this buffer has no pointer: re-render through
|
||||
-- setSample below (truncated int16s cannot be turned back into floats)
|
||||
engine = Engine.new(data, header, options)
|
||||
end
|
||||
local values = {}
|
||||
local count = 0
|
||||
while count < maximum and not engine:finished() do
|
||||
count = count + 1
|
||||
values[count] = engine:sample()
|
||||
end
|
||||
if count < math.floor(SAMPLE_RATE / 100) then return nil end
|
||||
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2)
|
||||
if count < minimum then return nil end
|
||||
local result = newBuffer(count, 2)
|
||||
for index = 1, count do
|
||||
local value = values[index]
|
||||
result:setSample(index - 1, 1, value)
|
||||
@@ -1451,6 +1559,8 @@ end
|
||||
|
||||
ChipSynth.newEngine = Engine.new
|
||||
ChipSynth.soundData = soundData
|
||||
ChipSynth.renderInto = renderInto
|
||||
ChipSynth.newBuffer = newBuffer
|
||||
ChipSynth.renderEffectData = renderEffectData
|
||||
|
||||
function ChipSynth.applyStereo(engine)
|
||||
|
||||
+16
-2
@@ -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
@@ -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
@@ -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
@@ -331,6 +331,11 @@ local function anyPadDown(pads, button)
|
||||
return false
|
||||
end
|
||||
|
||||
-- a joystick was added or removed: rebuild pollPads' list on the next poll
|
||||
function Input:joysticksChanged()
|
||||
self._pollPads = nil
|
||||
end
|
||||
|
||||
-- pokefirered/src/main.c:296
|
||||
function Input:pollPads()
|
||||
local js = love and love.joystick
|
||||
@@ -341,7 +346,24 @@ function Input:pollPads()
|
||||
return
|
||||
end
|
||||
local pads = self._pollPads
|
||||
if not pads or self._pollPadCount ~= count then
|
||||
-- The count alone cannot see one controller swapped for another between
|
||||
-- two polls; a cached pad that has gone away forces the rebuild too (and
|
||||
-- main.lua's joystickadded/removed drop the cache via
|
||||
-- Input:joysticksChanged).
|
||||
local stale = not pads or self._pollPadCount ~= count
|
||||
if not stale then
|
||||
for k = 1, #pads do
|
||||
local j = pads[k]
|
||||
if j.isConnected then
|
||||
local okConn, connected = pcall(j.isConnected, j)
|
||||
if okConn and not connected then
|
||||
stale = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if stale then
|
||||
pads = {}
|
||||
local ok, list = pcall(js.getJoysticks)
|
||||
if ok and type(list) == "table" then
|
||||
@@ -390,9 +412,24 @@ function Input:pollPads()
|
||||
end
|
||||
end
|
||||
|
||||
-- step's two per-step tables are recycled rather than reallocated, but only
|
||||
-- while they are still the ones step itself installed: a test or driver that
|
||||
-- assigns its own `pressed` / `pressQueue` gets a fresh table afterwards,
|
||||
-- exactly as before, and never sees its own table emptied.
|
||||
local function ownedCleared(self, field, ownKey)
|
||||
local own = self[ownKey]
|
||||
if own ~= nil and self[field] == own then
|
||||
for k in pairs(own) do own[k] = nil end
|
||||
else
|
||||
own = {}
|
||||
self[ownKey] = own
|
||||
end
|
||||
self[field] = own
|
||||
end
|
||||
|
||||
function Input:step()
|
||||
self:pollPads()
|
||||
self.pressed = {}
|
||||
ownedCleared(self, "pressed", "_ownPressed")
|
||||
for _, btn in ipairs(self.pressQueue) do
|
||||
self.pressed[btn] = true
|
||||
local sources = self.sources[btn]
|
||||
@@ -409,7 +446,7 @@ function Input:step()
|
||||
self.sources[btn] = nil
|
||||
end
|
||||
end
|
||||
self.pressQueue = {}
|
||||
ownedCleared(self, "pressQueue", "_ownPressQueue")
|
||||
-- pokefirered/src/main.c:325
|
||||
local aliases = self.aliases
|
||||
local held = nil
|
||||
|
||||
@@ -7,8 +7,11 @@ local ModRuntime = require("src.mods.Runtime")
|
||||
|
||||
local PlatformHooks = {}
|
||||
|
||||
-- hoisted: update runs every frame, and the vanilla step closes over nothing
|
||||
local function vanillaUpdate(g, d) g:update(d) end
|
||||
|
||||
function PlatformHooks.update(game, dt)
|
||||
return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt)
|
||||
return ModRuntime.call("core.update", vanillaUpdate, game, dt)
|
||||
end
|
||||
|
||||
function PlatformHooks.quitToLauncher(vanilla)
|
||||
|
||||
@@ -827,6 +827,45 @@ function Sound.playCry(data, species, pikaClip)
|
||||
return src
|
||||
end
|
||||
|
||||
-- Prewarm: the first play of a chip cry/SFX synthesizes it on the calling
|
||||
-- frame (~10-25 ms for a cry, far more for a long jingle), which is the hitch
|
||||
-- at a battle's first cry. These hand that render to the chip audio worker
|
||||
-- ahead of time (ChipAudio.prewarmCry/prewarmSfx); the play that follows
|
||||
-- builds its Source from the finished PCM, identical to a fresh render. Call
|
||||
-- at an idle point before the sound is needed (battle creation / the
|
||||
-- transition, for both species). Returns true when a render was queued or
|
||||
-- the sound is already cached; never renders on the calling thread, and a
|
||||
-- play that comes first simply renders as before.
|
||||
function Sound.prewarmCry(data, species)
|
||||
if not love.audio or deviceSuspended() then return false end
|
||||
if cache["cry:" .. tostring(species)] ~= nil then return true end
|
||||
local audio = data and data.audio
|
||||
local def = audio and audio.cries and audio.cries[species]
|
||||
if not def then return false end
|
||||
-- pokeyellow/home/pokemon.asm:140 -- voiced by a PCM clip instead
|
||||
if species == "PIKACHU" and audio.pikaCries then return false end
|
||||
local resolved = resolveCry(data, def, 0)
|
||||
if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then
|
||||
return false
|
||||
end
|
||||
local ok, queued = pcall(
|
||||
require("src.core.ChipAudio").prewarmCry, data, species, resolved)
|
||||
return ok and queued or false
|
||||
end
|
||||
|
||||
-- the plain Sound.play variant of `name` (no pitch/tempo modifiers)
|
||||
function Sound.prewarmSfx(data, name)
|
||||
if not love.audio or deviceSuspended() then return false end
|
||||
name = Sound.resolve(data, name)
|
||||
if cache[name] ~= nil then return true end
|
||||
local sfx = data and data.audio and data.audio.sfx
|
||||
local def = sfx and sfx[name]
|
||||
if not isChipDef(def) then return false end
|
||||
local ok, queued = pcall(
|
||||
require("src.core.ChipAudio").prewarmSfx, data, name, nil, nil, def, nil)
|
||||
return ok and queued or false
|
||||
end
|
||||
|
||||
-- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks
|
||||
-- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/
|
||||
-- tempo bytes on top of the cry's species modifiers before the tempo
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
-- cmd = "channelMix" { volumes, pitches, stereo?, stereoEpoch? }
|
||||
-- stereoEpoch present: live SOUND toggle; drop lookahead
|
||||
-- cmd = "invalidate" drop the bank cache
|
||||
-- cmd = "effect" { key, epoch, header, options, audio, channelVolumes,
|
||||
-- channelPitches, stereo, sampleRate }
|
||||
-- prewarm one SFX/cry (ChipSynth.renderEffectData); the
|
||||
-- result goes to "chipaudio_fx" as
|
||||
-- { key, epoch, sd = SoundData|nil } or { key, epoch, error }
|
||||
-- cmd = "quit" end the thread
|
||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||
-- discard anything left over from a superseded song:
|
||||
@@ -37,6 +42,7 @@ local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))()
|
||||
|
||||
local cmdCh = love.thread.getChannel("chipaudio_cmd")
|
||||
local outCh = love.thread.getChannel("chipaudio_out")
|
||||
local fxCh = love.thread.getChannel("chipaudio_fx")
|
||||
|
||||
local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||
local BUF_SECONDS = BUF / ChipSynth.SAMPLE_RATE
|
||||
@@ -50,10 +56,37 @@ local engine = nil -- the ChipSynth engine producing the current song
|
||||
local finished = false -- the current song ran out (non-looping)
|
||||
local data = nil -- { audio = <slim audio tables> } for ROM bank/wave reads
|
||||
local stereoEpoch = 0 -- matches ChipAudio; stale pan buffers are dropped
|
||||
local effects = {} -- queued "effect" prewarm requests, oldest first
|
||||
local produced = 0 -- buffers rendered for the current song so far
|
||||
|
||||
-- a song start outranks a prewarm: the first MUSIC_PREROLL buffers gate when
|
||||
-- the song is heard, a prewarmed cry only has to be done before its play
|
||||
local PREROLL = 4
|
||||
|
||||
local function renderEffect(req)
|
||||
if req.sampleRate ~= nil then
|
||||
BUF_SECONDS = BUF / ChipSynth.setSampleRate(req.sampleRate)
|
||||
end
|
||||
if req.channelVolumes ~= nil then
|
||||
ChipSynth.setChannelVolumes(req.channelVolumes)
|
||||
end
|
||||
if req.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(req.channelPitches)
|
||||
end
|
||||
if req.stereo ~= nil then ChipSynth.setStereo(req.stereo) end
|
||||
local ok, sd = pcall(ChipSynth.renderEffectData, { audio = req.audio },
|
||||
req.header, req.options or {})
|
||||
if ok then
|
||||
fxCh:push({ key = req.key, epoch = req.epoch, sd = sd or nil })
|
||||
else
|
||||
fxCh:push({ key = req.key, epoch = req.epoch, error = tostring(sd) })
|
||||
end
|
||||
end
|
||||
|
||||
local function handle(cmd)
|
||||
if cmd.cmd == "play" then
|
||||
gen = cmd.gen
|
||||
produced = 0
|
||||
finished = false
|
||||
engine = nil
|
||||
outCh:clear() -- drop any buffers left from the previous song
|
||||
@@ -95,6 +128,9 @@ local function handle(cmd)
|
||||
end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
effects = {}
|
||||
elseif cmd.cmd == "effect" then
|
||||
effects[#effects + 1] = cmd
|
||||
elseif cmd.cmd == "quit" then
|
||||
return true
|
||||
end
|
||||
@@ -113,7 +149,12 @@ while true do
|
||||
end
|
||||
if quit then break end
|
||||
|
||||
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
|
||||
local musicWants = engine and not finished and gen
|
||||
and outCh:getCount() < LOOKAHEAD
|
||||
if #effects > 0 and not (musicWants and produced < PREROLL) then
|
||||
idleWait = false
|
||||
renderEffect(table.remove(effects, 1))
|
||||
elseif musicWants then
|
||||
idleWait = false
|
||||
local activeGen = gen
|
||||
local began = love.timer.getTime()
|
||||
@@ -123,6 +164,7 @@ while true do
|
||||
stereoEpoch = stereoEpoch, jit = jitEnabled })
|
||||
finished = true
|
||||
else
|
||||
produced = produced + 1
|
||||
outCh:push({ gen = activeGen, sd = sd, stereoEpoch = stereoEpoch,
|
||||
jit = jitEnabled,
|
||||
xrt = (love.timer.getTime() - began) / BUF_SECONDS })
|
||||
|
||||
+83
-16
@@ -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
@@ -517,12 +517,34 @@ function Bag.set(bag, id, qty)
|
||||
return Bag.get(bag, id)
|
||||
end
|
||||
|
||||
-- listPocket rows per bag and pocket, rebuilt only when the pocket's
|
||||
-- (id, qty) slots or the loaded item pack change. Bag menus call listPocket
|
||||
-- every frame; each row costs three ItemsData.info lookups.
|
||||
local rowCache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function rows_match(entry, slots, byId)
|
||||
if entry.byId ~= byId then return false end
|
||||
local ids, qtys, n = entry.ids, entry.qtys, entry.n
|
||||
for i = 1, n do
|
||||
local slot = slots[i]
|
||||
if slot == nil or slot.id ~= ids[i] or slot.qty ~= qtys[i] then return false end
|
||||
end
|
||||
return slots[n + 1] == nil
|
||||
end
|
||||
|
||||
--- Ordered list of { id, qty, name, info } for a pocket (bag UI).
|
||||
-- The returned rows are shared between calls until the pocket changes, so
|
||||
-- callers must not modify them.
|
||||
function Bag.listPocket(bag, pocket)
|
||||
bag = ensure(bag)
|
||||
pocket = pocket or "ITEMS"
|
||||
local slots = bag.pockets[pocket] or {}
|
||||
local byId = ItemsData._byId
|
||||
local perBag = rowCache[bag]
|
||||
local entry = perBag and perBag[pocket]
|
||||
if entry and byId and rows_match(entry, slots, byId) then return entry.rows end
|
||||
local rows = {}
|
||||
for _, slot in ipairs(bag.pockets[pocket] or {}) do
|
||||
for _, slot in ipairs(slots) do
|
||||
local qty = tonumber(slot.qty) or 0
|
||||
if slot.id and qty > 0 then
|
||||
rows[#rows + 1] = {
|
||||
@@ -534,6 +556,20 @@ function Bag.listPocket(bag, pocket)
|
||||
}
|
||||
end
|
||||
end
|
||||
byId = ItemsData._byId
|
||||
if byId then
|
||||
local n = 0
|
||||
local ids, qtys = {}, {}
|
||||
for i, slot in ipairs(slots) do
|
||||
n = i
|
||||
ids[i], qtys[i] = slot.id, slot.qty
|
||||
end
|
||||
if not perBag then
|
||||
perBag = {}
|
||||
rowCache[bag] = perBag
|
||||
end
|
||||
perBag[pocket] = { byId = byId, n = n, ids = ids, qtys = qtys, rows = rows }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
|
||||
@@ -1066,4 +1066,16 @@ function Anim.drawParticles(minZ, maxZ)
|
||||
if Anim._vm then Anim._vm:draw(minZ, maxZ) end
|
||||
end
|
||||
|
||||
--- Bracket one frame's z-band drawParticles calls so the VM sorts its
|
||||
--- sprites once per frame instead of once per band.
|
||||
function Anim.beginParticleFrame()
|
||||
local vm = Anim._vm
|
||||
if vm and vm.beginDrawFrame then vm:beginDrawFrame() end
|
||||
end
|
||||
|
||||
function Anim.endParticleFrame()
|
||||
local vm = Anim._vm
|
||||
if vm and vm.endDrawFrame then vm:endDrawFrame() end
|
||||
end
|
||||
|
||||
return Anim
|
||||
|
||||
@@ -17,9 +17,17 @@ AnimPal.loaded = {}
|
||||
AnimPal.idxOf = setmetatable({}, { __mode = "k" })
|
||||
AnimPal._pack = nil
|
||||
|
||||
-- Normalised tags by raw tag (strings and numbers only); tags come from a
|
||||
-- fixed set, and norm runs for every sprite drawn.
|
||||
local _normCache = {}
|
||||
local function norm(tag)
|
||||
if tag == nil then return nil end
|
||||
return (tostring(tag):upper():gsub("^ANIM_TAG_", ""))
|
||||
local hit = _normCache[tag]
|
||||
if hit then return hit end
|
||||
local out = (tostring(tag):upper():gsub("^ANIM_TAG_", ""))
|
||||
local t = type(tag)
|
||||
if t == "string" or t == "number" then _normCache[tag] = out end
|
||||
return out
|
||||
end
|
||||
AnimPal.norm = norm
|
||||
|
||||
|
||||
@@ -745,7 +745,14 @@ P.tagInfo = tag_info
|
||||
|
||||
local function quad_for(img, sx, sy, w, h)
|
||||
local iw, ih = img:getDimensions()
|
||||
local key = sx .. ":" .. sy .. ":" .. w .. ":" .. h
|
||||
-- Numeric key for whole-pixel rects (13+13+10+10 bits), string otherwise.
|
||||
local key
|
||||
if sx >= 0 and sx < 8192 and sy >= 0 and sy < 8192 and w >= 0 and w < 1024 and h >= 0 and h < 1024
|
||||
and sx % 1 == 0 and sy % 1 == 0 and w % 1 == 0 and h % 1 == 0 then
|
||||
key = ((sx * 8192 + sy) * 1024 + w) * 1024 + h
|
||||
else
|
||||
key = sx .. ":" .. sy .. ":" .. w .. ":" .. h
|
||||
end
|
||||
local c = SHEET_CACHE[img]
|
||||
if not c then c = {}; SHEET_CACHE[img] = c end
|
||||
local q = c[key]
|
||||
|
||||
@@ -1416,13 +1416,22 @@ local function overlayResources()
|
||||
return overlayRes
|
||||
end
|
||||
|
||||
-- One quad re-pointed per draw (draw reads the viewport immediately).
|
||||
local windowSpriteQuad = nil
|
||||
|
||||
local function drawWindowSprite(sp)
|
||||
local img = sp.image
|
||||
if not img or sp.invisible or not sp.active then return end
|
||||
local bw = sp._baseW or sp.w or 32
|
||||
local bh = sp._baseH or sp.h or 32
|
||||
local iw, ih = img:getDimensions()
|
||||
local q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
|
||||
local q = windowSpriteQuad
|
||||
if q then
|
||||
q:setViewport(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
|
||||
else
|
||||
q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
|
||||
windowSpriteQuad = q
|
||||
end
|
||||
local sx = (sp.scaleX or 1) * (sp.hFlip and -1 or 1)
|
||||
local sy = (sp.scaleY or 1) * (sp.vFlip and -1 or 1)
|
||||
love.graphics.draw(img, q, math.floor(sp.x + (sp.ox or 0) + 0.5), math.floor(sp.y + (sp.oy or 0) + 0.5),
|
||||
|
||||
@@ -613,6 +613,26 @@ local function effective_z(vm, s)
|
||||
return AnimCoords.layerZ(sub, vm._monbg, vm._bgPrio)
|
||||
end
|
||||
|
||||
-- Last uniforms sent to the shared blend shader. Every send goes through
|
||||
-- send_blend so the cache always matches what the shader holds (the anim bg
|
||||
-- and sprites share one shader).
|
||||
local _lastBlendShader = nil
|
||||
local _lastBlendCoeff = nil
|
||||
local _lastBlendR = nil
|
||||
local _lastBlendG = nil
|
||||
local _lastBlendB = nil
|
||||
|
||||
local function send_blend(sh, coeff, r, g, b)
|
||||
if sh ~= _lastBlendShader or coeff ~= _lastBlendCoeff or r ~= _lastBlendR
|
||||
or g ~= _lastBlendG or b ~= _lastBlendB then
|
||||
_lastBlendShader = sh
|
||||
_lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b
|
||||
pcall(sh.send, sh, "coeff", coeff)
|
||||
pcall(sh.send, sh, "target", { r, g, b })
|
||||
end
|
||||
end
|
||||
AnimVm._sendBlend = send_blend
|
||||
|
||||
local function draw_anim_bg(vm)
|
||||
local id = vm._animBgId
|
||||
local tintBg = vm._animBgBlend
|
||||
@@ -638,10 +658,7 @@ local function draw_anim_bg(vm)
|
||||
local sh = tint and (tint.coeff or 0) > 0 and blend_shader()
|
||||
if sh then
|
||||
local c = tonumber(tint.color) or 0
|
||||
pcall(function()
|
||||
sh:send("coeff", tint.coeff)
|
||||
sh:send("target", { band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31) })
|
||||
end)
|
||||
send_blend(sh, tint.coeff, band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31))
|
||||
love.graphics.setShader(sh)
|
||||
end
|
||||
love.graphics.draw(img, vm._bgQuad, 0, 0)
|
||||
@@ -657,11 +674,6 @@ local function draw_anim_bg(vm)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local _lastBlendCoeff = nil
|
||||
local _lastBlendR = nil
|
||||
local _lastBlendG = nil
|
||||
local _lastBlendB = nil
|
||||
|
||||
local function particle_sort_cmp(a, b)
|
||||
if a._drawZ ~= b._drawZ then return a._drawZ < b._drawZ end
|
||||
return (a._poolIndex or 0) > (b._poolIndex or 0)
|
||||
@@ -694,12 +706,7 @@ local function draw_sprite(self, s, a)
|
||||
local sh = (not pimg) and tint and blend_shader()
|
||||
if sh then
|
||||
local r, g, b = band(tint.color, 31), band(rshift(tint.color, 5), 31), band(rshift(tint.color, 10), 31)
|
||||
local coeff = tint.coeff
|
||||
if coeff ~= _lastBlendCoeff or r ~= _lastBlendR or g ~= _lastBlendG or b ~= _lastBlendB then
|
||||
_lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b
|
||||
pcall(sh.send, sh, "coeff", coeff)
|
||||
pcall(sh.send, sh, "target", { r, g, b })
|
||||
end
|
||||
send_blend(sh, tint.coeff, r, g, b)
|
||||
love.graphics.setShader(sh)
|
||||
end
|
||||
local q = sprite_source_quad(s, bw, bh)
|
||||
@@ -712,44 +719,115 @@ local function draw_sprite(self, s, a)
|
||||
end
|
||||
end
|
||||
|
||||
-- Blend mode currently set by AnimVm:draw (reset to "alpha" per call).
|
||||
local _activeBlend = "alpha"
|
||||
local function set_blend_mode(mode)
|
||||
if mode ~= _activeBlend then
|
||||
if mode == "add" then
|
||||
love.graphics.setBlendMode("add", "alphamultiply")
|
||||
else
|
||||
love.graphics.setBlendMode("alpha", "alphamultiply")
|
||||
end
|
||||
_activeBlend = mode
|
||||
end
|
||||
end
|
||||
|
||||
-- Every active, visible sprite with its effective z, sorted once. The sort
|
||||
-- is a strict total order (z, then pool index), so slicing this list by a z
|
||||
-- band gives the same order as sorting that band on its own.
|
||||
local function build_sorted_sprites(self, list)
|
||||
for i = #list, 1, -1 do list[i] = nil end
|
||||
AnimSprites.init()
|
||||
local pool = AnimSprites._pool
|
||||
local n = 0
|
||||
for i = 1, AnimSprites.MAX do
|
||||
local s = pool[i]
|
||||
s._poolIndex = i
|
||||
if s.active and s.visible ~= false then
|
||||
s._drawZ = effective_z(self, s)
|
||||
n = n + 1
|
||||
list[n] = s
|
||||
end
|
||||
end
|
||||
if n > 1 then table.sort(list, particle_sort_cmp) end
|
||||
return list
|
||||
end
|
||||
|
||||
local function any_task_draw()
|
||||
if not (AnimTasks and AnimTasks._pool) then return false end
|
||||
local pool = AnimTasks._pool
|
||||
for i = 1, AnimTasks.MAX do
|
||||
local t = pool[i]
|
||||
if t and t.active and t.draw then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Bracket the z-band draws of one frame (battle ui). Between begin and end
|
||||
--- the sorted sprite list and the task scan are built once and reused by
|
||||
--- every AnimVm:draw band call; outside a bracket each call rebuilds them.
|
||||
function AnimVm:beginDrawFrame()
|
||||
self._inDrawFrame = true
|
||||
self._frameSorted = false
|
||||
self._frameTasks = nil
|
||||
end
|
||||
|
||||
function AnimVm:endDrawFrame()
|
||||
self._inDrawFrame = false
|
||||
self._frameSorted = false
|
||||
self._frameTasks = nil
|
||||
end
|
||||
|
||||
function AnimVm:draw(minZ, maxZ)
|
||||
if not (love and love.graphics) then return end
|
||||
|
||||
if (minZ or 0) <= 0 then draw_anim_bg(self) end
|
||||
|
||||
if AnimTasks and AnimTasks.draw then
|
||||
AnimTasks.draw(minZ, maxZ, self)
|
||||
local hasTasks = true
|
||||
if self._inDrawFrame then
|
||||
if self._frameTasks == nil then self._frameTasks = any_task_draw() end
|
||||
hasTasks = self._frameTasks
|
||||
end
|
||||
if hasTasks then AnimTasks.draw(minZ, maxZ, self) end
|
||||
end
|
||||
|
||||
local list = self._drawList
|
||||
for i = #list, 1, -1 do list[i] = nil end
|
||||
AnimSprites.init()
|
||||
for i = 1, AnimSprites.MAX do
|
||||
local s = AnimSprites._pool[i]
|
||||
s._poolIndex = i
|
||||
if s.active and s.visible ~= false then
|
||||
local z = effective_z(self, s)
|
||||
local sorted = self._drawList
|
||||
if not (self._inDrawFrame and self._frameSorted) then
|
||||
build_sorted_sprites(self, sorted)
|
||||
self._frameSorted = self._inDrawFrame and true or false
|
||||
end
|
||||
if #sorted == 0 then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
local list = sorted
|
||||
if minZ or maxZ then
|
||||
list = self._bandList
|
||||
if not list then
|
||||
list = {}
|
||||
self._bandList = list
|
||||
end
|
||||
for i = #list, 1, -1 do list[i] = nil end
|
||||
local n = 0
|
||||
for i = 1, #sorted do
|
||||
local s = sorted[i]
|
||||
local z = s._drawZ
|
||||
if (not minZ or z >= minZ) and (not maxZ or z <= maxZ) then
|
||||
s._drawZ = z
|
||||
list[#list + 1] = s
|
||||
n = n + 1
|
||||
list[n] = s
|
||||
end
|
||||
end
|
||||
if n == 0 then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
end
|
||||
table.sort(list, particle_sort_cmp)
|
||||
|
||||
local activeBlend = "alpha"
|
||||
_activeBlend = "alpha"
|
||||
local bld = self.bldAlpha
|
||||
local function setMode(mode)
|
||||
if mode ~= activeBlend then
|
||||
if mode == "add" then
|
||||
love.graphics.setBlendMode("add", "alphamultiply")
|
||||
else
|
||||
love.graphics.setBlendMode("alpha", "alphamultiply")
|
||||
end
|
||||
activeBlend = mode
|
||||
end
|
||||
end
|
||||
for _, s in ipairs(list) do
|
||||
for i = 1, #list do
|
||||
local s = list[i]
|
||||
local a = s.alpha or 1
|
||||
local eva, evb = nil, nil
|
||||
if s.objBlend and bld then
|
||||
@@ -758,12 +836,12 @@ function AnimVm:draw(minZ, maxZ)
|
||||
end
|
||||
if eva and eva + evb ~= 16 and s.image and AnimPal.indexImage(s.image) then
|
||||
-- pokefirered/src/battle_anim.c:630
|
||||
setMode("alpha")
|
||||
set_blend_mode("alpha")
|
||||
AnimPal.blackPass = true
|
||||
s._drawAlpha = a * (1 - evb / 16)
|
||||
draw_sprite(self, s, s._drawAlpha)
|
||||
AnimPal.blackPass = nil
|
||||
setMode("add")
|
||||
set_blend_mode("add")
|
||||
s._drawAlpha = a * eva / 16
|
||||
draw_sprite(self, s, s._drawAlpha)
|
||||
else
|
||||
@@ -772,14 +850,15 @@ function AnimVm:draw(minZ, maxZ)
|
||||
if evb >= 16 and eva < 16 then desiredBlend = "add" end
|
||||
a = a * eva / 16
|
||||
end
|
||||
setMode(desiredBlend)
|
||||
set_blend_mode(desiredBlend)
|
||||
s._drawAlpha = a
|
||||
draw_sprite(self, s, a)
|
||||
end
|
||||
end
|
||||
|
||||
if activeBlend ~= "alpha" then
|
||||
if _activeBlend ~= "alpha" then
|
||||
love.graphics.setBlendMode("alpha", "alphamultiply")
|
||||
_activeBlend = "alpha"
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -136,8 +136,17 @@ local function display_hp_nums(side, battler)
|
||||
return math.floor(hp), math.floor(maxHp)
|
||||
end
|
||||
|
||||
-- One read-only FrlgFont opts table per colour set (FrlgFont never writes
|
||||
-- to opts), so per-frame healthbox text does not allocate.
|
||||
local _smallOpts = setmetatable({}, { __mode = "k" })
|
||||
local function small_opts(colors)
|
||||
return { small = true, colors = colors or HB_TEXT }
|
||||
colors = colors or HB_TEXT
|
||||
local o = _smallOpts[colors]
|
||||
if not o then
|
||||
o = { small = true, colors = colors }
|
||||
_smallOpts[colors] = o
|
||||
end
|
||||
return o
|
||||
end
|
||||
|
||||
local function erase_placeholder_ink(boxX, boxY, pts)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2130,6 +2130,19 @@ local function rse_window_colors(fgIdx, shadowIdx)
|
||||
}
|
||||
end
|
||||
|
||||
-- The default (13, 15) window colours, rebuilt only when the manifest's
|
||||
-- palette table changes. Callers must not modify the returned table
|
||||
-- (rse_pp_colors, which does, builds its own with rse_window_colors).
|
||||
local _defaultWinColors, _defaultWinPal = nil, nil
|
||||
local function rse_default_colors()
|
||||
local pal = BattleChrome.manifest().windowTextPal or nil
|
||||
if not _defaultWinColors or pal ~= _defaultWinPal then
|
||||
_defaultWinColors = rse_window_colors()
|
||||
_defaultWinPal = pal
|
||||
end
|
||||
return _defaultWinColors
|
||||
end
|
||||
|
||||
-- pokeemerald/src/battle_message.c:3033
|
||||
local function rse_pp_colors(pp, maxPp)
|
||||
local pp2 = BattleChrome.manifest().ppTextPal or {}
|
||||
@@ -2147,7 +2160,7 @@ local function rse_text(win, text, dx, opts)
|
||||
local useNarrow = opts.narrow == nil and narrow or opts.narrow
|
||||
F.draw(tostring(text or ""), x + (dx or 0), y, {
|
||||
font = useNarrow and "narrow" or nil,
|
||||
colors = opts.colors or rse_window_colors(),
|
||||
colors = opts.colors or rse_default_colors(),
|
||||
})
|
||||
end
|
||||
|
||||
@@ -2161,10 +2174,10 @@ local function draw_action_menu_rse(st)
|
||||
local mx, my = BattleChrome.textOrigin(W.ACTION_MENU)
|
||||
local c = Ui._menuIndex - 1
|
||||
local col, row = c % 2, math.floor(c / 2)
|
||||
Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_window_colors() })
|
||||
Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_default_colors() })
|
||||
for i = 1, 4 do
|
||||
local cc, rr = (i - 1) % 2, math.floor((i - 1) / 2)
|
||||
battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_window_colors() })
|
||||
battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_default_colors() })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2175,7 +2188,7 @@ local function draw_move_menu_rse(st)
|
||||
local mon = ab and ab.mon
|
||||
local c = Ui._moveIndex - 1
|
||||
local _, cy = BattleChrome.textOrigin(W.MOVE_NAME_1)
|
||||
Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_window_colors() })
|
||||
Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_default_colors() })
|
||||
for i = 1, 4 do
|
||||
local mv = mon and mon.moves and mon.moves[i]
|
||||
local label = "-"
|
||||
@@ -2537,6 +2550,7 @@ function Ui.draw(w, h)
|
||||
-- 4. Player Mon (Z: 200)
|
||||
-- 5. In front of Player & Global Foreground (Z: 201 .. 999)
|
||||
local dbl = st and is_double(st)
|
||||
if Anim.beginParticleFrame then Anim.beginParticleFrame() end
|
||||
if dbl then
|
||||
draw_double_mons(st, stage, Anim, screenFxActive)
|
||||
else
|
||||
@@ -2556,6 +2570,7 @@ function Ui.draw(w, h)
|
||||
if screenFxActive then Anim.beginScreenEffect() end
|
||||
Anim.drawParticles(201, 999)
|
||||
end
|
||||
if Anim.endParticleFrame then Anim.endParticleFrame() end
|
||||
draw_intro_ball(stage)
|
||||
-- pokefirered/src/pokeball.c:770
|
||||
BallOpen.draw()
|
||||
|
||||
@@ -1673,15 +1673,21 @@ local function drawFieldRows(R, G, fx)
|
||||
local bx0, by0 = floor(R.X0 / m), floor(R.Y0 / m)
|
||||
local cw = floor((R.X1 - 1) / m) - bx0 + 1
|
||||
local ch = floor((R.Y1 - 1) / m) - by0 + 1
|
||||
local key = cw .. "x" .. ch
|
||||
-- One canvas per transition, grown only when a level needs more room;
|
||||
-- each level draws just its cw x ch corner through a quad.
|
||||
local small = BattleTransition._mosaicCanvas
|
||||
if not small or BattleTransition._mosaicKey ~= key then
|
||||
local key = BattleTransition._mosaicKey
|
||||
if not small or type(key) ~= "table" or key.w < cw or key.h < ch then
|
||||
if small and small.release then pcall(small.release, small) end
|
||||
small = G.newCanvas(cw, ch, { dpiscale = 1 })
|
||||
local nw = math.max(cw, type(key) == "table" and small and key.w or 0)
|
||||
local nh = math.max(ch, type(key) == "table" and small and key.h or 0)
|
||||
small = G.newCanvas(nw, nh, { dpiscale = 1 })
|
||||
small:setFilter("nearest", "nearest")
|
||||
key = { w = nw, h = nh, quad = G.newQuad(0, 0, cw, ch, nw, nh) }
|
||||
BattleTransition._mosaicCanvas = small
|
||||
BattleTransition._mosaicKey = key
|
||||
end
|
||||
key.quad:setViewport(0, 0, cw, ch, key.w, key.h)
|
||||
G.push("all")
|
||||
G.origin()
|
||||
G.setCanvas(small)
|
||||
@@ -1690,7 +1696,7 @@ local function drawFieldRows(R, G, fx)
|
||||
G.draw(tex, -bx0 + 0.5 - (R.gx + 0.5) / m, -by0 + 0.5 - (R.gy + 0.5) / m, 0, 1 / m, 1 / m)
|
||||
G.pop()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(small, bx0 * m, by0 * m, 0, m, m)
|
||||
G.draw(small, key.quad, bx0 * m, by0 * m, 0, m, m)
|
||||
return
|
||||
end
|
||||
G.draw(tex, R.X0, R.Y0)
|
||||
|
||||
@@ -32,9 +32,14 @@ local function log(msg)
|
||||
print("[game3/collision] " .. tostring(msg))
|
||||
end
|
||||
|
||||
local permsLoaded, permsMod
|
||||
local function permissions()
|
||||
local ok, P = pcall(require, "src.world.gen2.Permissions")
|
||||
return ok and P or nil
|
||||
if not permsLoaded then
|
||||
local ok, P = pcall(require, "src.world.gen2.Permissions")
|
||||
permsMod = ok and P or nil
|
||||
permsLoaded = true
|
||||
end
|
||||
return permsMod
|
||||
end
|
||||
|
||||
local function resolveTileset(game, mapDef)
|
||||
@@ -193,6 +198,31 @@ function Collision.installWarps(mapDef)
|
||||
end
|
||||
end
|
||||
|
||||
--- Patch one cell of the bound native grid after a metatile write (what a
|
||||
-- full bindMap would rebuild for it) without re-deriving the whole map.
|
||||
-- Returns false when the caller must bindMap instead: another map is bound,
|
||||
-- the cell is off the grid, or a warp sits on it (installWarps derives warp
|
||||
-- activity and door repairs from the cell).
|
||||
function Collision.patchCell(mapId, mapDef, x, y)
|
||||
if not (Collision._grid and mapDef and mapDef.midLayout) then return false end
|
||||
if Collision._mapId ~= mapId or Collision._mapDef ~= mapDef then return false end
|
||||
local layout = mapDef.midLayout
|
||||
x, y = tonumber(x), tonumber(y)
|
||||
if not (x and y) or x ~= math.floor(x) or y ~= math.floor(y) then return false end
|
||||
local w, h = Collision._widthCells, Collision._heightCells
|
||||
if w ~= layout.width or h ~= layout.height then return false end
|
||||
if x < 0 or y < 0 or x >= w or y >= h then return false end
|
||||
if x >= (layout.trueWidth or w) or y >= (layout.trueHeight or h) then return false end
|
||||
for _, warp in ipairs(mapDef.warps or {}) do
|
||||
if tonumber(warp.x) == x and tonumber(warp.y) == y then return false end
|
||||
end
|
||||
-- same value LayoutNative:collArray yields for this cell
|
||||
local ov = layout.overrides and layout.overrides[y * 1024 + x]
|
||||
local c = layout.cells and layout.cells[y * w + x + 1]
|
||||
Collision._grid[y * w + x + 1] = (ov and ov.coll) or (c and c.coll) or 0xff
|
||||
return true
|
||||
end
|
||||
|
||||
function Collision.clear()
|
||||
Collision._grid = nil
|
||||
Collision._mapId = nil
|
||||
@@ -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
|
||||
|
||||
@@ -92,9 +92,14 @@ local function loveCache()
|
||||
}
|
||||
end
|
||||
|
||||
-- The cache object is stateless (every read resolves CacheFs, love.filesystem
|
||||
-- and Dataset.cacheRootOverride at call time), so one instance is shared.
|
||||
local sharedCache = nil
|
||||
|
||||
--- Shared firered CacheFs-backed cache for standalone Game3 (mod.cache is nil).
|
||||
function Dataset.cache()
|
||||
return loveCache()
|
||||
if not sharedCache then sharedCache = loveCache() end
|
||||
return sharedCache
|
||||
end
|
||||
|
||||
local dsLoadWarned = false
|
||||
|
||||
@@ -355,7 +355,12 @@ local function presentPlanes(game)
|
||||
Display.mirrorFlatFrame(Renderer)
|
||||
end
|
||||
|
||||
-- The plane path presents straight from Renderer's canvases; the flat 240x160
|
||||
-- copy is only for test drivers that read Display._canvas back.
|
||||
Display.mirrorForTests = false
|
||||
|
||||
function Display.mirrorFlatFrame(Renderer)
|
||||
if not Display.mirrorForTests then return end
|
||||
local canvas = Display.ensureCanvas("main")
|
||||
if not canvas then return end
|
||||
local world = Renderer.worldCanvas
|
||||
|
||||
@@ -207,6 +207,9 @@ function Field.update(_dt)
|
||||
local Ghosts = require("src.core.game3.ghosts")
|
||||
Ghosts.sync()
|
||||
Ghosts.update(game)
|
||||
-- pre-load tileset pairs queued by a seamless world refresh, one per frame
|
||||
local MapMod = package.loaded["src.core.game3.map"]
|
||||
if MapMod and MapMod._warmQueue and MapMod.stepWarm then MapMod.stepWarm() end
|
||||
|
||||
Field.pollMapChange(game)
|
||||
-- pokefirered/src/safari_zone.c:60 CB2_EndSafariBattle
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1502,8 +1502,17 @@ local function pic(store, kind, species, form, shiny)
|
||||
if shiny and species ~= Pokemon.SPECIES_EGG then kind = kind .. "_shiny" end
|
||||
local key = form > 0 and (species .. "_" .. form) or species
|
||||
if kind:find("_shiny", 1, true) then key = "shiny:" .. key end
|
||||
if store[key] then return store[key] end
|
||||
return pic_entry(store, key, read_pic(pic_rel(kind, species, form)))
|
||||
local hit = store[key]
|
||||
if hit then return hit end
|
||||
-- false marks a pic file known to be missing, so draw loops that probe
|
||||
-- backPic then frontPic every frame do not re-read the filesystem.
|
||||
if hit == false then return nil end
|
||||
local rgba = read_pic(pic_rel(kind, species, form))
|
||||
if not rgba then
|
||||
store[key] = false
|
||||
return nil
|
||||
end
|
||||
return pic_entry(store, key, rgba)
|
||||
end
|
||||
|
||||
Pokemon.SPECIES_SPINDA = 308
|
||||
|
||||
@@ -99,7 +99,37 @@ function RomText.box(key, ctx)
|
||||
return TextIR.toTextBox(RomText.translate(RomText.ir(key), ctx, key), ctx)
|
||||
end
|
||||
|
||||
-- Segment types whose expansion reads nothing but the segment itself. An IR
|
||||
-- made only of these expands to the same plain string every time.
|
||||
local PURE_SEG = { text = true, tag = true, nl = true, para = true, scroll = true, eos = true, ext = true }
|
||||
|
||||
-- plain() results for pure IRs, keyed by the IR table itself (false = not
|
||||
-- pure). Keying on the IR follows overrides, profile aliases and bundle
|
||||
-- reloads for free: each resolves to a different table.
|
||||
local plainCache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function pure_ir(ir)
|
||||
if type(ir) ~= "table" then return false end
|
||||
for i = 1, #ir do
|
||||
local seg = ir[i]
|
||||
if type(seg) ~= "table" or not PURE_SEG[seg.t] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomText.plain(key, ctx)
|
||||
if ctx == nil and not Strings.active() then
|
||||
local ir = RomText.ir(key)
|
||||
local hit = plainCache[ir]
|
||||
if hit then return hit end
|
||||
if hit == nil then
|
||||
local pure = pure_ir(ir)
|
||||
local out = TextIR.toPlain(ir, {})
|
||||
plainCache[ir] = pure and out or false
|
||||
return out
|
||||
end
|
||||
return TextIR.toPlain(ir, {})
|
||||
end
|
||||
ctx = ctx or {}
|
||||
return TextIR.toPlain(RomText.translate(RomText.ir(key), ctx, key), ctx)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -161,9 +161,18 @@ function NativeTileset.ready(pair)
|
||||
and cache:exists(NATIVE .. "/" .. pair .. "/palettes.bin")
|
||||
end
|
||||
|
||||
local animMod
|
||||
local function tilesetAnim()
|
||||
if animMod == nil then
|
||||
local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim")
|
||||
animMod = okA and TilesetAnim or false
|
||||
end
|
||||
return animMod or nil
|
||||
end
|
||||
|
||||
local function bind_anim(pair, atlas)
|
||||
local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim")
|
||||
if okA and TilesetAnim and TilesetAnim.bindPair then
|
||||
local TilesetAnim = tilesetAnim()
|
||||
if TilesetAnim and TilesetAnim.bindPair then
|
||||
TilesetAnim.bindPair(pair, atlas)
|
||||
end
|
||||
end
|
||||
@@ -172,6 +181,9 @@ function NativeTileset.get(pair)
|
||||
if not pair then return nil end
|
||||
local cached = NativeTileset._pairs[pair]
|
||||
if cached then
|
||||
-- Cheap for a bound pair (no manifest read): marks it visible and
|
||||
-- catches its atlas up to the current anim frames before a draw.
|
||||
-- FieldView reads cached atlases directly and does not come here per frame.
|
||||
bind_anim(pair, cached)
|
||||
return cached
|
||||
end
|
||||
|
||||
@@ -78,6 +78,9 @@ function TrainerSight.getTrainerId(eo)
|
||||
local scripts = Sp and Sp.bundle and Sp.bundle.scripts
|
||||
local list = scripts and scripts[scriptKey]
|
||||
if type(list) == "table" then
|
||||
-- negative cache: this exact script list was already scanned without a
|
||||
-- trainerbattle (a script swapped in under the key rescans)
|
||||
if eo._trainerIdMiss == list then return nil end
|
||||
for _, row in ipairs(list) do
|
||||
if row.op == "trainerbattle" or row.op == "dotrainerbattle" then
|
||||
local tid = tonumber(row.trainer or row[1])
|
||||
@@ -88,6 +91,7 @@ function TrainerSight.getTrainerId(eo)
|
||||
end
|
||||
end
|
||||
end
|
||||
eo._trainerIdMiss = list
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -633,6 +637,8 @@ end
|
||||
-- @param game game instance
|
||||
-- @param specificTrainer optional specific EventObject to check (e.g. from spinning NPC turn update)
|
||||
-- @return boolean true if an encounter was triggered
|
||||
local approachFound = {}
|
||||
|
||||
function TrainerSight.check(game, specificTrainer)
|
||||
local F = Field()
|
||||
if F.locked then return false end
|
||||
@@ -681,7 +687,8 @@ function TrainerSight.check(game, specificTrainer)
|
||||
local order = Objs._order or {}
|
||||
if twoTrainerApproach() then
|
||||
-- pokeemerald/src/trainer_see.c:191
|
||||
local found = {}
|
||||
local found = approachFound
|
||||
for i = #found, 1, -1 do found[i] = nil end
|
||||
local doubles = canDouble()
|
||||
for _, lid in ipairs(order) do
|
||||
local eo = Objs.find(lid)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -28,9 +28,30 @@ function Hooks:wrap(name, callback, priority, owner)
|
||||
for i, candidate in ipairs(chain) do
|
||||
if candidate == entry then table.remove(chain, i) break end
|
||||
end
|
||||
-- an empty chain is dropped, as removeOwner does, so Runtime.wantsHook
|
||||
-- goes back to false; only if it is still the live chain for the name
|
||||
-- (a later wrap after removeOwner may have started a new one)
|
||||
if #chain == 0 and self.chains[name] == chain then
|
||||
self.chains[name] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- vanilla's pcall results, unpacked without a pack table: its values, or
|
||||
-- the PASS-wrapped error
|
||||
local function vanillaResult(ok, ...)
|
||||
if ok then return ... end
|
||||
error({ [PASS] = (...) }, 0)
|
||||
end
|
||||
|
||||
-- the outermost pcall's results: the chain's values, or its error unwrapped
|
||||
local function finish(ok, ...)
|
||||
if ok then return ... end
|
||||
local err = (...)
|
||||
if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end
|
||||
error(err, 0)
|
||||
end
|
||||
|
||||
-- each link runs under pcall: a throwing wrapper is logged and skipped and
|
||||
-- the chain continues with the current arguments, so a broken mod degrades
|
||||
-- to "not installed for this call" instead of breaking the pipeline.
|
||||
@@ -46,9 +67,7 @@ function Hooks:call(name, vanilla, ...)
|
||||
local function run(index)
|
||||
if index > #chain then
|
||||
ranVanilla = true
|
||||
local res = pack(pcall(vanilla, unpack(args, 1, args.n)))
|
||||
if res[1] then return unpack(res, 2, res.n) end
|
||||
error({ [PASS] = res[2] }, 0)
|
||||
return vanillaResult(pcall(vanilla, unpack(args, 1, args.n)))
|
||||
end
|
||||
local entry = chain[index]
|
||||
local downstream
|
||||
@@ -81,11 +100,7 @@ function Hooks:call(name, vanilla, ...)
|
||||
tostring(entry.owner or "?"), name, tostring(err))
|
||||
return run(index + 1)
|
||||
end
|
||||
local res = pack(pcall(run, 1))
|
||||
if res[1] then return unpack(res, 2, res.n) end
|
||||
local err = res[2]
|
||||
if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end
|
||||
error(err, 0)
|
||||
return finish(pcall(run, 1))
|
||||
end
|
||||
|
||||
-- drops every wrap a mod made; used by entry-chunk rollback
|
||||
|
||||
@@ -258,6 +258,13 @@ function Loader:_installDevShim()
|
||||
if devShim.installed then return end
|
||||
devShim.installed = true
|
||||
local delegate = require
|
||||
-- The stock require answers an already-loaded module straight out of
|
||||
-- package.loaded, so once every check below has passed the shim can do the
|
||||
-- same without the pcall. Only when delegating to the stock C function:
|
||||
-- a Lua wrapper in between may want to see every call.
|
||||
local delegateIsStock = delegate == rawRequire
|
||||
and type(delegate) == "function"
|
||||
and (debug.getinfo(delegate, "S") or {}).what == "C"
|
||||
_G.require = function(name, ...)
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
@@ -295,6 +302,12 @@ function Loader:_installDevShim()
|
||||
end
|
||||
end
|
||||
end
|
||||
if delegateIsStock then
|
||||
-- a userdata here may be require's own "loading" sentinel, whose
|
||||
-- loop error the stock path has to raise
|
||||
local loaded = package.loaded[name]
|
||||
if loaded and type(loaded) ~= "userdata" then return loaded end
|
||||
end
|
||||
devShim.depth = devShim.depth + 1
|
||||
local ok, result = pcall(delegate, name, ...)
|
||||
devShim.depth = devShim.depth - 1
|
||||
|
||||
+30
-6
@@ -45,9 +45,14 @@ local function head(name)
|
||||
return (name:match("^([^%.]+)")) or name
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
-- The verdict for a name never changes (the tables above are fixed), and the
|
||||
-- Loader's require shim asks for every require the engine makes while a mod
|
||||
-- is enabled, so it is worked out once per name: false for allowed, the
|
||||
-- denial message, or NETWORK_ONLY with the message kept alongside.
|
||||
local verdicts, verdictCount = {}, 0
|
||||
local VERDICT_MAX = 4096
|
||||
|
||||
local function verdictFor(name)
|
||||
local root = head(name)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
@@ -58,10 +63,29 @@ function Sandbox.moduleDenial(name, permissionSet)
|
||||
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
|
||||
.. "and the engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
if NETWORK[root] then
|
||||
return { network = ("%s needs the \"network\" permission in manifest.json")
|
||||
:format(name) }
|
||||
end
|
||||
return nil
|
||||
return false
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local verdict = verdicts[name]
|
||||
if verdict == nil then
|
||||
verdict = verdictFor(name)
|
||||
if verdictCount >= VERDICT_MAX then verdicts, verdictCount = {}, 0 end
|
||||
verdicts[name] = verdict
|
||||
verdictCount = verdictCount + 1
|
||||
end
|
||||
if not verdict then return nil end
|
||||
if type(verdict) == "table" then
|
||||
if (permissionSet or {}).network then return nil end
|
||||
return verdict.network
|
||||
end
|
||||
return verdict
|
||||
end
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
@@ -125,6 +125,9 @@ function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
|
||||
return vanillaPath, trueColor
|
||||
end
|
||||
-- checked before the ctx table: party menus ask every drawn frame, and
|
||||
-- an unhooked call must not allocate
|
||||
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end
|
||||
local species = mon and mon.species
|
||||
local ctx = {
|
||||
species = species,
|
||||
@@ -134,7 +137,6 @@ function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||
kind = "icon",
|
||||
trueColor = trueColor,
|
||||
}
|
||||
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end
|
||||
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
|
||||
trueColor = ctx.trueColor and true or false
|
||||
if type(hooked) == "string" and hooked ~= "" then return hooked, trueColor end
|
||||
|
||||
+94
-7
@@ -100,8 +100,12 @@ local function tileSet(spec)
|
||||
return set
|
||||
end
|
||||
|
||||
local resetTextCaches, resetWidthCache
|
||||
|
||||
function Font.load(data)
|
||||
loadedFrom = data
|
||||
-- a new charmap / page set / TTF: every cached encode and width is stale
|
||||
if resetTextCaches then resetTextCaches() end
|
||||
local def = data.font
|
||||
state = { def = def, pages = {}, order = {}, byFirstByte = {} }
|
||||
for id, page in pairs(pagesOf(def)) do
|
||||
@@ -267,6 +271,8 @@ function Font.useBattleExtra(on)
|
||||
if not state then return false end
|
||||
local was = state.battleExtra or false
|
||||
state.battleExtra = on and true or false
|
||||
-- the swapped page may carry its own advance
|
||||
if state.battleExtra ~= was and resetWidthCache then resetWidthCache() end
|
||||
return was
|
||||
end
|
||||
|
||||
@@ -278,7 +284,9 @@ end
|
||||
-- Module state, not `state`: applyOptions runs before Font.load on boot
|
||||
-- (src/core/Game2.lua Game2:load).
|
||||
function Font.setFrame(index)
|
||||
currentFrame = math.floor(tonumber(index) or 1)
|
||||
local frame = math.floor(tonumber(index) or 1)
|
||||
if frame ~= currentFrame and resetWidthCache then resetWidthCache() end
|
||||
currentFrame = frame
|
||||
end
|
||||
|
||||
function Font.frameIndex()
|
||||
@@ -428,7 +436,7 @@ end
|
||||
-- Convert a text string into a list of glyph codes. Unknown characters
|
||||
-- render as space (and are reported once).
|
||||
local reported = {}
|
||||
function Font.encode(text)
|
||||
local function encodeUncached(text)
|
||||
local codes = {}
|
||||
for _, span in ipairs(Font.split(text)) do
|
||||
local code = span.code
|
||||
@@ -445,6 +453,65 @@ function Font.encode(text)
|
||||
return codes
|
||||
end
|
||||
|
||||
-- Font.width/Font.draw run for the same few strings every frame, and
|
||||
-- Font.split allocates a table per glyph. The codes only depend on the
|
||||
-- charmap and TTF that Font.load builds, so cache them per string until the
|
||||
-- next load. Widths also depend on which page answers a code (the frame
|
||||
-- row, the battle-extra swap), so that cache is also dropped when either of
|
||||
-- those changes. Both are bounded: a cache past ENCODE_CACHE_MAX entries is
|
||||
-- thrown away whole rather than tracked LRU.
|
||||
local ENCODE_CACHE_MAX = 4096
|
||||
-- Bumped whenever a cached encode or width may have changed (Font.load, a
|
||||
-- frame or battle-sheet swap), for callers that keep their own copies.
|
||||
Font.revision = 0
|
||||
local encodeCache, encodeCount = {}, 0
|
||||
local stockSplit = Font.split
|
||||
local widthCache, widthCount = {}, 0
|
||||
|
||||
function resetWidthCache()
|
||||
widthCache, widthCount = {}, 0
|
||||
-- anything a caller derived from encode/width is stale too
|
||||
Font.revision = Font.revision + 1
|
||||
end
|
||||
|
||||
function resetTextCaches()
|
||||
encodeCache, encodeCount = {}, 0
|
||||
resetWidthCache()
|
||||
end
|
||||
|
||||
-- Returns the SHARED cached array: read it, never modify or keep it.
|
||||
local function cachedCodes(text)
|
||||
-- a replaced split (a test double) is honoured and nothing is cached
|
||||
if Font.split ~= stockSplit then return encodeUncached(text) end
|
||||
local codes = encodeCache[text]
|
||||
if codes then return codes end
|
||||
codes = encodeUncached(text)
|
||||
if type(text) == "string" then
|
||||
if encodeCount >= ENCODE_CACHE_MAX then
|
||||
encodeCache, encodeCount = {}, 0
|
||||
end
|
||||
encodeCache[text] = codes
|
||||
encodeCount = encodeCount + 1
|
||||
end
|
||||
return codes
|
||||
end
|
||||
|
||||
-- Returns a fresh array: callers (TextBox's typewriter lines) append to
|
||||
-- and keep the list, so they must never be handed the cached one.
|
||||
function Font.encode(text)
|
||||
local src = cachedCodes(text)
|
||||
local codes = {}
|
||||
for i = 1, #src do codes[i] = src[i] end
|
||||
return codes
|
||||
end
|
||||
|
||||
local stockEncode = Font.encode
|
||||
|
||||
-- Number of glyphs `text` draws as (#Font.split(text), without the spans).
|
||||
function Font.glyphCount(text)
|
||||
return #cachedCodes(text)
|
||||
end
|
||||
|
||||
function Font.drawCode(code, x, y)
|
||||
local ttf = state and state.ttf
|
||||
if ttf and code >= TTF_BASE then
|
||||
@@ -481,13 +548,31 @@ function Font.advanceOf(code)
|
||||
return page and page.advance or GLYPH
|
||||
end
|
||||
|
||||
local stockAdvanceOf = Font.advanceOf
|
||||
|
||||
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
|
||||
-- Multi-byte charmap entries like "¥" are one glyph; callers that
|
||||
-- right-align with `#text * 8` mis-place them.
|
||||
function Font.width(text)
|
||||
local w = 0
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
w = w + Font.advanceOf(code)
|
||||
if Font.encode ~= stockEncode or Font.advanceOf ~= stockAdvanceOf
|
||||
or Font.split ~= stockSplit then
|
||||
-- a replaced encode/advanceOf/split (a test double, a mod) is still
|
||||
-- what gets measured, uncached
|
||||
local w = 0
|
||||
for _, code in ipairs(Font.encode(text)) do w = w + Font.advanceOf(code) end
|
||||
return w
|
||||
end
|
||||
local w = widthCache[text]
|
||||
if w then return w end
|
||||
w = 0
|
||||
local codes = cachedCodes(text)
|
||||
for i = 1, #codes do
|
||||
w = w + Font.advanceOf(codes[i])
|
||||
end
|
||||
if type(text) == "string" then
|
||||
if widthCount >= ENCODE_CACHE_MAX then resetWidthCache() end
|
||||
widthCache[text] = w
|
||||
widthCount = widthCount + 1
|
||||
end
|
||||
return w
|
||||
end
|
||||
@@ -495,9 +580,11 @@ end
|
||||
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
||||
-- drawn, which is #codes * 8 for every fixed-width page.
|
||||
function Font.draw(text, x, y)
|
||||
local codes = Font.encode(text)
|
||||
local codes = Font.encode == stockEncode and cachedCodes(text)
|
||||
or Font.encode(text)
|
||||
local pen = x
|
||||
for _, code in ipairs(codes) do
|
||||
for i = 1, #codes do
|
||||
local code = codes[i]
|
||||
Font.drawCode(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
|
||||
@@ -377,13 +377,42 @@ end
|
||||
-- The same, ignoring the COLOR mode. The present pass needs it: it IS the
|
||||
-- mode, so running its own palette back through resolve would flatten the
|
||||
-- green ramp to grey and the mode would do nothing.
|
||||
-- The pal0..pal3 uniforms each shader last received, weak-keyed so a
|
||||
-- recompiled shader (see compiler) starts with no record. Nothing outside
|
||||
-- this file sends pal0..pal3 to these shaders, so a uniform whose value is
|
||||
-- unchanged is skipped: a send to the active shader flushes LOVE's batch.
|
||||
-- The {r,g,b} arrays are scratch; shader:send copies the values.
|
||||
local PAL_UNIFORMS = { [0] = "pal0", "pal1", "pal2", "pal3" }
|
||||
local palScratch = { [0] = { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
|
||||
local palSent = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function sendPalette(sh, colors)
|
||||
local rec = palSent[sh]
|
||||
if not rec then
|
||||
rec = { valid = false }
|
||||
palSent[sh] = rec
|
||||
end
|
||||
-- only trusted once all four sends landed (an error propagates as before)
|
||||
local valid = rec.valid
|
||||
rec.valid = false
|
||||
for i = 0, 3 do
|
||||
local r, g, b = channel(colors, i + 1)
|
||||
local k = i * 3
|
||||
if not (valid and rec[k + 1] == r and rec[k + 2] == g
|
||||
and rec[k + 3] == b) then
|
||||
local v = palScratch[i]
|
||||
v[1], v[2], v[3] = r, g, b
|
||||
sh:send(PAL_UNIFORMS[i], v)
|
||||
rec[k + 1], rec[k + 2], rec[k + 3] = r, g, b
|
||||
end
|
||||
end
|
||||
rec.valid = true
|
||||
end
|
||||
|
||||
function GbcPalette.useRaw(colors)
|
||||
local sh = GbcPalette.shader()
|
||||
if not sh then return false end
|
||||
for i = 0, 3 do
|
||||
local r, g, b = channel(colors, i + 1)
|
||||
sh:send("pal" .. i, { r, g, b })
|
||||
end
|
||||
sendPalette(sh, colors)
|
||||
love.graphics.setShader(sh)
|
||||
return true
|
||||
end
|
||||
@@ -391,11 +420,8 @@ end
|
||||
function GbcPalette.useKeyed(colors)
|
||||
local sh = GbcPalette.keyedShader()
|
||||
if not sh then return false end
|
||||
local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp)
|
||||
for i = 0, 3 do
|
||||
local r, g, b = channel(resolved, i + 1)
|
||||
sh:send("pal" .. i, { r, g, b })
|
||||
end
|
||||
sendPalette(sh,
|
||||
GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp))
|
||||
love.graphics.setShader(sh)
|
||||
return true
|
||||
end
|
||||
|
||||
+19
-3
@@ -92,7 +92,11 @@ end
|
||||
local function put(t, x, y, tint)
|
||||
if not t then return end
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.setColor(tint or { 1, 1, 1, 1 })
|
||||
if tint then
|
||||
love.graphics.setColor(tint)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
love.graphics.draw(t.img, t.quad, x, y)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
@@ -159,6 +163,19 @@ end
|
||||
-- caller that is animating the bar between two HP values
|
||||
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
|
||||
-- thresholds do. Without it the length comes from mon.hp as before.
|
||||
-- The fill tint for a bar colour, kept per colour table (the bar palettes
|
||||
-- are a handful of fixed tables) and rebuilt if its values ever differ.
|
||||
-- put() only reads it.
|
||||
local fillTints = setmetatable({}, { __mode = "k" })
|
||||
local function fillTint(c)
|
||||
local t = fillTints[c]
|
||||
if t and t.r == c[1] and t.g == c[2] and t.b == c[3] then return t end
|
||||
t = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1, r = c[1], g = c[2], b = c[3] }
|
||||
fillTints[c] = t
|
||||
return t
|
||||
end
|
||||
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
@@ -182,8 +199,7 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixe
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
tint = fillTint(c)
|
||||
end
|
||||
end
|
||||
for i = 0, segments - 1 do
|
||||
|
||||
+157
-20
@@ -67,9 +67,37 @@ function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
|
||||
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
|
||||
-- active mode resolved, with a distinct cache group so the lit and dark bakes
|
||||
-- of one sheet never collide in SpriteRenderer's obpCache.
|
||||
--
|
||||
-- It runs per sprite per frame in a dark cave, so the permuted palette is
|
||||
-- kept per source palette (and per map, should DARK_BGP ever be swapped) and
|
||||
-- the group name per group; permute's result is only ever read.
|
||||
local darkPermuted = setmetatable({}, { __mode = "k" })
|
||||
local darkGroupNames = {}
|
||||
|
||||
-- permute(colors, map), remembered in memo[colors] while `map` stays the same
|
||||
local function permuteMemo(memo, colors, map)
|
||||
local hit = memo[colors]
|
||||
if hit and hit.map == map then return hit.colors end
|
||||
local out = PaletteFX.permute(colors, map)
|
||||
if type(colors) == "table" then memo[colors] = { map = map, colors = out } end
|
||||
return out
|
||||
end
|
||||
|
||||
-- tostring(group) .. suffix, remembered per group
|
||||
local function suffixedGroup(names, group, suffix)
|
||||
if group == nil or group ~= group then return tostring(group) .. suffix end
|
||||
local name = names[group]
|
||||
if not name then
|
||||
name = tostring(group) .. suffix
|
||||
names[group] = name
|
||||
end
|
||||
return name
|
||||
end
|
||||
|
||||
function PaletteFX.darkObp(colors, group)
|
||||
if not (colors and darkWorld) then return colors, group end
|
||||
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
|
||||
return permuteMemo(darkPermuted, colors, PaletteFX.DARK_BGP),
|
||||
suffixedGroup(darkGroupNames, group, "dark")
|
||||
end
|
||||
|
||||
-- the same shift folded into a baked 8-group world palette array (ADVANCED)
|
||||
@@ -85,6 +113,9 @@ end
|
||||
-- home/fade.asm:52-58
|
||||
local fadeObpMap = nil
|
||||
local fadeObpGroups = setmetatable({}, { __mode = "k" })
|
||||
-- per fade map: the permuted palettes and suffixed group names (see darkObp)
|
||||
local fadePermuted = setmetatable({}, { __mode = "k" })
|
||||
local fadeGroupNames = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function PaletteFX.setFadeObp(map)
|
||||
fadeObpMap = map
|
||||
@@ -98,7 +129,16 @@ function PaletteFX.fadeObp(colors, group)
|
||||
.. fadeObpMap[2] .. fadeObpMap[3]
|
||||
fadeObpGroups[fadeObpMap] = suffix
|
||||
end
|
||||
return PaletteFX.permute(colors, fadeObpMap), tostring(group) .. suffix
|
||||
local memo = fadePermuted[fadeObpMap]
|
||||
local names = fadeGroupNames[fadeObpMap]
|
||||
if not memo then
|
||||
memo = setmetatable({}, { __mode = "k" })
|
||||
names = {}
|
||||
fadePermuted[fadeObpMap] = memo
|
||||
fadeGroupNames[fadeObpMap] = names
|
||||
end
|
||||
return permuteMemo(memo, colors, fadeObpMap),
|
||||
suffixedGroup(names, group, suffix)
|
||||
end
|
||||
|
||||
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
|
||||
@@ -369,6 +409,8 @@ end
|
||||
|
||||
-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas
|
||||
-- via __index so shared Red tilesets stay identical byte-for-byte.
|
||||
local yellowWorld, yellowWorldBase, yellowWorldDelta
|
||||
|
||||
function PaletteFX.worldPack()
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
@@ -377,7 +419,13 @@ function PaletteFX.worldPack()
|
||||
local y = PaletteFX.gbcYellowPack()
|
||||
local yw = y and y.world
|
||||
if not yw then return w end
|
||||
return {
|
||||
-- per sprite per frame on Yellow: build the merged view once per pair of
|
||||
-- packs (a reload loads new tables, so identity is the whole key)
|
||||
if yellowWorld and yellowWorldBase == w and yellowWorldDelta == yw then
|
||||
return yellowWorld
|
||||
end
|
||||
yellowWorldBase, yellowWorldDelta = w, yw
|
||||
yellowWorld = {
|
||||
tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }),
|
||||
groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }),
|
||||
roofGroup = w.roofGroup,
|
||||
@@ -387,6 +435,7 @@ function PaletteFX.worldPack()
|
||||
and setmetatable(yw.spritePalettes, { __index = w.spritePalettes })
|
||||
or w.spritePalettes,
|
||||
}
|
||||
return yellowWorld
|
||||
end
|
||||
|
||||
function PaletteFX.usesGbcPack(mode)
|
||||
@@ -775,27 +824,44 @@ end
|
||||
-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent
|
||||
-- here): a stable hash instead, so the same NPC instance always shows the
|
||||
-- same one of the 4 SPR_PAL_* colors.
|
||||
local sourceKinds = {}
|
||||
local seedGroups = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function PaletteFX.spriteObp(spriteDef, seed)
|
||||
local w = PaletteFX.worldPack()
|
||||
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
||||
if not (w and src) then return nil end
|
||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||
-- the parse depends only on the string (per sprite per frame otherwise)
|
||||
local parsed = sourceKinds[src]
|
||||
if not parsed then
|
||||
local n = tonumber(src:match("%[(%d+)%]"))
|
||||
parsed = n or (src:find("RedBikeSprite", 1, true) and "bike")
|
||||
or (src:find("SurfingPikachuSprite", 1, true) and "surf") or "none"
|
||||
sourceKinds[src] = parsed
|
||||
end
|
||||
local idx = type(parsed) == "number" and parsed or nil
|
||||
-- RedBikeSprite and SurfingPikachuSprite load outside
|
||||
-- SpriteSheetPointerTable, so their source has no bracketed index;
|
||||
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
|
||||
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
|
||||
if not idx and src:find("RedBikeSprite", 1, true) then
|
||||
if parsed == "bike" then
|
||||
idx = 0
|
||||
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
|
||||
elseif parsed == "surf" then
|
||||
idx = GameVersion.isYellow() and 60 or 0
|
||||
end
|
||||
local group = idx and w.spriteAssignment[idx]
|
||||
if group == nil then return nil end
|
||||
if group == "random" then
|
||||
local h = 0
|
||||
seed = tostring(seed or "")
|
||||
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||
group = h % 4
|
||||
local key = seed == nil and "" or seed
|
||||
local h = seedGroups[key]
|
||||
if not h then
|
||||
h = 0
|
||||
local str = tostring(seed or "")
|
||||
for i = 1, #str do h = (h * 31 + str:byte(i)) % 4294967296 end
|
||||
h = h % 4
|
||||
if key == key then seedGroups[key] = h end
|
||||
end
|
||||
group = h
|
||||
end
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
@@ -1009,15 +1075,89 @@ function PaletteFX.effectiveColors(c)
|
||||
return PaletteFX.permute(out, shadeMap)
|
||||
end
|
||||
|
||||
local stockEffectiveColors = PaletteFX.effectiveColors
|
||||
|
||||
-- ------- uniform sends
|
||||
--
|
||||
-- sendColors runs per zone per frame and per colored sprite, and a send to
|
||||
-- the active shader flushes LOVE's draw batch. Remember what each shader
|
||||
-- was last given (weak-keyed, so a released or replaced shader drops its
|
||||
-- record) and only send a uniform whose value changed. The {r,g,b} arrays
|
||||
-- are scratch: shader:send copies the values, so they are reused.
|
||||
local UNIFORMS = { "c0", "c1", "c2", "c3" }
|
||||
local scratch = { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
|
||||
local lastSent = setmetatable({}, { __mode = "k" })
|
||||
|
||||
-- A caller that sends c0..c3 itself (AnimPlayer's per-cell OBJ colors) has
|
||||
-- to drop the record, or the next sendColors of the old colors would be
|
||||
-- skipped. AnimPlayer:drawSprites does this after its direct sends.
|
||||
function PaletteFX.forgetSent(sh)
|
||||
local rec = sh and lastSent[sh]
|
||||
if rec then rec.valid = false end
|
||||
end
|
||||
|
||||
local function sendOne(sh, rec, i, col, valid)
|
||||
local r, g, b = col[1] / 255, col[2] / 255, col[3] / 255
|
||||
local k = i * 3
|
||||
if valid and rec[k - 2] == r and rec[k - 1] == g and rec[k] == b then
|
||||
return
|
||||
end
|
||||
local v = scratch[i]
|
||||
v[1], v[2], v[3] = r, g, b
|
||||
sh:send(UNIFORMS[i], v)
|
||||
rec[k - 2], rec[k - 1], rec[k] = r, g, b
|
||||
end
|
||||
|
||||
local function sendFour(sh, a, b, c, d)
|
||||
local rec = lastSent[sh]
|
||||
if not rec then
|
||||
rec = { valid = false }
|
||||
lastSent[sh] = rec
|
||||
end
|
||||
-- An error from send propagates as the unconditional sends' did; the
|
||||
-- record only counts once all four landed, so a raise mid-way never
|
||||
-- leaves a half-written palette looking current.
|
||||
local valid = rec.valid
|
||||
rec.valid = false
|
||||
sendOne(sh, rec, 1, a, valid)
|
||||
sendOne(sh, rec, 2, b, valid)
|
||||
sendOne(sh, rec, 3, c, valid)
|
||||
sendOne(sh, rec, 4, d, valid)
|
||||
rec.valid = true
|
||||
end
|
||||
|
||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
||||
-- applying the active COLORS display mode
|
||||
-- applying the active COLORS display mode. Resolves the same colors
|
||||
-- effectiveColors returns, indexing through the mode and shade maps rather
|
||||
-- than building the permuted tables.
|
||||
function PaletteFX.sendColors(shader, c)
|
||||
c = PaletteFX.effectiveColors(c)
|
||||
if not c then return end
|
||||
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
||||
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
|
||||
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
|
||||
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
|
||||
if PaletteFX.effectiveColors ~= stockEffectiveColors then
|
||||
-- something replaced effectiveColors: honour it, as the send always did
|
||||
c = PaletteFX.effectiveColors(c)
|
||||
if not c then return end
|
||||
return sendFour(shader, c[1], c[2], c[3], c[4])
|
||||
end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
local base, modeMap = c, nil
|
||||
if PaletteFX.customRamp and not PaletteFX.pickerActive() then
|
||||
base = PaletteFX.customRamp
|
||||
elseif mode == "og" then
|
||||
base = PaletteFX.GRAYS
|
||||
elseif mode == "og_inv" then
|
||||
base, modeMap = PaletteFX.GRAYS, INV_MAP
|
||||
elseif mode == "classic" then
|
||||
base = PaletteFX.CLASSIC
|
||||
elseif mode == "gbc_inv" then
|
||||
modeMap = INV_MAP
|
||||
end
|
||||
local sm = shadeMap
|
||||
local i0, i1, i2, i3 = 0, 1, 2, 3
|
||||
if sm then i0, i1, i2, i3 = sm[0], sm[1], sm[2], sm[3] end
|
||||
if modeMap then
|
||||
i0, i1, i2, i3 = modeMap[i0], modeMap[i1], modeMap[i2], modeMap[i3]
|
||||
end
|
||||
sendFour(shader, base[i0 + 1], base[i1 + 1], base[i2 + 1], base[i3 + 1])
|
||||
end
|
||||
|
||||
-- The same send with NO display-mode substitution and no shade map: the four
|
||||
@@ -1030,10 +1170,7 @@ function PaletteFX.sendShades(shader, c)
|
||||
-- headless (no love.graphics) leaves shader() nil; sendColors reaches the
|
||||
-- same no-op through effectiveColors returning nil for an absent palette
|
||||
if not shader or not c then return end
|
||||
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
||||
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
|
||||
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
|
||||
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
|
||||
sendFour(shader, c[1], c[2], c[3], c[4])
|
||||
end
|
||||
|
||||
return PaletteFX
|
||||
|
||||
+113
-9
@@ -673,6 +673,88 @@ local function sizeTable(state, i, dims, sizeUniforms, viewport, original)
|
||||
return values
|
||||
end
|
||||
|
||||
-- ------- per-pass uniform bookkeeping
|
||||
--
|
||||
-- runPass used to rebuild and pcall-send every uniform every frame. Each
|
||||
-- pass's shader is private to its chain state (built at activate, dropped
|
||||
-- with the state) and nothing else sends to it, so `cached.sent` remembers
|
||||
-- what the shader last received and a uniform is only re-sent when its value
|
||||
-- changed. A failed send is remembered too: it would fail the same way again
|
||||
-- for the same name and value.
|
||||
|
||||
local textureUniformNames = {}
|
||||
local function textureUniform(name)
|
||||
local u = textureUniformNames[name]
|
||||
if not u then
|
||||
u = "LIBRA_TEXTURE_" .. name
|
||||
textureUniformNames[name] = u
|
||||
end
|
||||
return u
|
||||
end
|
||||
|
||||
-- Fixup.packValues over `values` with ALL_DEFAULTS layered on top (a default
|
||||
-- wins, exactly as copying it into `values` did), into tables reused across
|
||||
-- frames: shader:send copies, so the vec4 groups need not be fresh.
|
||||
local PACK_SLOT = { x = 1, y = 2, z = 3, w = 4 }
|
||||
local function packPass(cached, manifest, defaults, values)
|
||||
local packed = cached.packed
|
||||
if not packed then
|
||||
packed = {}
|
||||
cached.packed = packed
|
||||
cached.groups = {}
|
||||
for _, entry in ipairs(manifest) do
|
||||
if entry.component and not cached.groups[entry.uniform] then
|
||||
cached.groups[entry.uniform] = { 0, 0, 0, 0 }
|
||||
end
|
||||
end
|
||||
end
|
||||
for uniform, group in pairs(cached.groups) do
|
||||
group[1], group[2], group[3], group[4] = 0, 0, 0, 0
|
||||
packed[uniform] = group
|
||||
end
|
||||
for _, entry in ipairs(manifest) do
|
||||
local value = defaults[entry.name]
|
||||
if value == nil then value = values[entry.name] end
|
||||
if entry.component then
|
||||
packed[entry.uniform][PACK_SLOT[entry.component]] = value or 0
|
||||
else
|
||||
packed[entry.uniform] = value
|
||||
end
|
||||
end
|
||||
return packed
|
||||
end
|
||||
|
||||
-- Whether `value` differs from what `sent[name]` recorded. Arrays of plain
|
||||
-- numbers/booleans are compared element-wise; anything else always sends.
|
||||
local function changedSince(sent, name, value)
|
||||
local last = sent[name]
|
||||
if last == nil then return true end
|
||||
if type(value) ~= "table" then return last ~= value end
|
||||
if type(last) ~= "table" or last.n ~= #value then return true end
|
||||
for k = 1, last.n do
|
||||
local v = value[k]
|
||||
local tv = type(v)
|
||||
if (tv ~= "number" and tv ~= "boolean") or last[k] ~= v then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function remember(sent, name, value)
|
||||
if type(value) ~= "table" then
|
||||
sent[name] = value
|
||||
return
|
||||
end
|
||||
local snap = sent[name]
|
||||
if type(snap) ~= "table" then
|
||||
snap = {}
|
||||
sent[name] = snap
|
||||
end
|
||||
local n = #value
|
||||
for k = 1, n do snap[k] = value[k] end
|
||||
for k = n + 1, (snap.n or 0) do snap[k] = nil end
|
||||
snap.n = n
|
||||
end
|
||||
|
||||
-- Runs pass `i`, drawing `srcImg` into a freshly sized canvas. `lutByName`
|
||||
-- resolves a User-semantic sampler by the name the translation reported.
|
||||
local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport, original, layer)
|
||||
@@ -684,6 +766,11 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
|
||||
local cached = assert(state.shaderCache[i],
|
||||
("pass%d: shader was not built at activate"):format(i))
|
||||
local shader, fragManifest = cached.shader, cached.fragManifest
|
||||
local sent = cached.sent
|
||||
if not sent then
|
||||
sent = {}
|
||||
cached.sent = sent
|
||||
end
|
||||
|
||||
for _, s in ipairs(pass.samplers) do
|
||||
local img
|
||||
@@ -694,13 +781,21 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
|
||||
elseif s.semantic == "User" then img = lutByName and lutByName[s.user_name] end
|
||||
assert(img, ("pass%d: no binding resolved for sampler %s (semantic=%s)")
|
||||
:format(i, s.name, tostring(s.semantic)))
|
||||
pcall(shader.send, shader, "LIBRA_TEXTURE_" .. s.name, img)
|
||||
local uniform = textureUniform(s.name)
|
||||
if sent[uniform] ~= img then
|
||||
pcall(shader.send, shader, uniform, img)
|
||||
sent[uniform] = img
|
||||
end
|
||||
end
|
||||
|
||||
local values = sizeTable(state, i, dims, pass.size_uniforms, viewport, original)
|
||||
for name, value in pairs(state.ALL_DEFAULTS) do values[name] = value end
|
||||
local packed = Fixup.packValues(fragManifest, values)
|
||||
for name, value in pairs(packed) do pcall(shader.send, shader, name, value) end
|
||||
local packed = packPass(cached, fragManifest, state.ALL_DEFAULTS, values)
|
||||
for name, value in pairs(packed) do
|
||||
if changedSince(sent, name, value) then
|
||||
pcall(shader.send, shader, name, value)
|
||||
remember(sent, name, value)
|
||||
end
|
||||
end
|
||||
|
||||
local srcImg = (i == 0) and frameSource or outputs[i - 1]
|
||||
local srcDims = passInputDims(state, i, original)
|
||||
@@ -1090,6 +1185,7 @@ end
|
||||
-- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas
|
||||
-- is cached module-wide and reallocated only on a real size change.
|
||||
local cropCanvasCache = {}
|
||||
local cropQuadCache = {}
|
||||
|
||||
local function gridGeometry(rect, renderScale, originX, originY)
|
||||
local s = tonumber(rect.scale)
|
||||
@@ -1112,16 +1208,24 @@ local function cropToGbSource(canvas, rect, srcW, srcH, renderScale, layer, geo)
|
||||
-- Cheap insurance against a read-after-write hazard between this draw and
|
||||
-- whatever last rendered into `canvas`.
|
||||
love.graphics.flushBatch()
|
||||
local outW, outH, quad
|
||||
local outW, outH, qx, qy, qw, qh
|
||||
if geo then
|
||||
outW, outH = geo.crop.w, geo.crop.h
|
||||
quad = love.graphics.newQuad(rect.x + geo.x0 + CROP_SAMPLE_BIAS,
|
||||
rect.y + geo.y0 + CROP_SAMPLE_BIAS, geo.cols * geo.scale, geo.rows * geo.scale,
|
||||
canvas:getPixelWidth(), canvas:getPixelHeight())
|
||||
qx, qy = rect.x + geo.x0 + CROP_SAMPLE_BIAS, rect.y + geo.y0 + CROP_SAMPLE_BIAS
|
||||
qw, qh = geo.cols * geo.scale, geo.rows * geo.scale
|
||||
else
|
||||
outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale)
|
||||
quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
||||
qx, qy, qw, qh = rect.x, rect.y, rect.w, rect.h
|
||||
end
|
||||
-- One quad per layer, re-aimed each frame rather than allocated. A quad
|
||||
-- without setViewport (a test double) is simply rebuilt.
|
||||
local quad = cropQuadCache[layer]
|
||||
if quad and quad.setViewport then
|
||||
quad:setViewport(qx, qy, qw, qh, canvas:getPixelWidth(), canvas:getPixelHeight())
|
||||
else
|
||||
quad = love.graphics.newQuad(qx, qy, qw, qh,
|
||||
canvas:getPixelWidth(), canvas:getPixelHeight())
|
||||
cropQuadCache[layer] = quad
|
||||
end
|
||||
local out
|
||||
local cached = cropCanvasCache[layer]
|
||||
|
||||
@@ -40,8 +40,21 @@ end
|
||||
-- hardware, where sprite palette index 0 is unconditionally transparent
|
||||
-- (same rule TileRenderer's getColor0KeyShader documents for tall grass).
|
||||
local obpCache = {}
|
||||
-- obpCache is keyed by the string `path .. "#obp" .. group`; this indexes the
|
||||
-- same entries by path then group, so a draw that hits the cache (every draw
|
||||
-- after the first) builds no key string. A numeric group is looked up by
|
||||
-- its string form, since the concatenation cannot tell 1 from "1" either.
|
||||
local obpByPath = {}
|
||||
local groupStrings = {}
|
||||
|
||||
local function getObpImage(path, colors, group)
|
||||
local byGroup = type(path) == "string" and obpByPath[path]
|
||||
if byGroup then
|
||||
local g = group
|
||||
if type(g) == "number" then g = groupStrings[g] end
|
||||
local hit = g ~= nil and byGroup[g]
|
||||
if hit then return hit end
|
||||
end
|
||||
local key = path .. "#obp" .. group
|
||||
if not obpCache[key] then
|
||||
local img
|
||||
@@ -59,7 +72,24 @@ local function getObpImage(path, colors, group)
|
||||
end
|
||||
obpCache[key] = img
|
||||
end
|
||||
return obpCache[key]
|
||||
local img = obpCache[key]
|
||||
-- only string/number groups get here: concatenating anything else raised
|
||||
if group ~= group or type(path) ~= "string" then return img end -- NaN key
|
||||
if type(group) == "number" then
|
||||
local g = groupStrings[group]
|
||||
if not g then
|
||||
g = "" .. group
|
||||
groupStrings[group] = g
|
||||
end
|
||||
group = g
|
||||
end
|
||||
byGroup = obpByPath[path]
|
||||
if not byGroup then
|
||||
byGroup = {}
|
||||
obpByPath[path] = byGroup
|
||||
end
|
||||
byGroup[group] = img
|
||||
return img
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = getObpImage
|
||||
@@ -69,6 +99,7 @@ SpriteRenderer.obpImage = getObpImage
|
||||
function SpriteRenderer.invalidate()
|
||||
imageCache = {}
|
||||
obpCache = {}
|
||||
obpByPath = {}
|
||||
end
|
||||
|
||||
Assets.register(SpriteRenderer.invalidate)
|
||||
|
||||
+28
-8
@@ -437,7 +437,21 @@ function TextBox:moneyVisible()
|
||||
return not self.moneyWithChoice or not not self.choicePushed
|
||||
end
|
||||
|
||||
local step
|
||||
|
||||
-- One logic frame. The ScrollTextUpOneLine slide advances here, after the
|
||||
-- step (so a line begun this frame is first drawn 2px into its slide, as it
|
||||
-- was when draw advanced it), and not in draw, whose rate follows the
|
||||
-- display's refresh rather than the 60Hz logic clock.
|
||||
function TextBox:update(dt)
|
||||
step(self, dt)
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
end
|
||||
|
||||
function step(self, dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 480
|
||||
-- home/text.asm:506
|
||||
@@ -722,10 +736,6 @@ function TextBox:draw()
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
-- Only the retained line carries the offset: it slides up from where it
|
||||
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
|
||||
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
|
||||
@@ -757,14 +767,24 @@ function TextBox:draw()
|
||||
love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local cap = 13 * 8
|
||||
for _, code in ipairs(Font.encode("MONEY")) do
|
||||
if not self.moneyLabel or self.moneyRev ~= Font.revision then
|
||||
self.moneyLabel, self.moneyAmount = Font.encode("MONEY"), nil
|
||||
self.moneyRev = Font.revision
|
||||
end
|
||||
for _, code in ipairs(self.moneyLabel) do
|
||||
drawGlyph(code, cap, 0)
|
||||
cap = cap + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
local money = ("¥%d"):format(self.money() or 0)
|
||||
local pen = 152 - Font.width(money)
|
||||
for _, code in ipairs(Font.encode(money)) do
|
||||
-- formatted and encoded only when the amount (or the font) changes
|
||||
local amount = self.money() or 0
|
||||
if amount ~= self.moneyAmount or self.moneyRev ~= Font.revision then
|
||||
local money = ("¥%d"):format(amount)
|
||||
self.moneyAmount, self.moneyRev = amount, Font.revision
|
||||
self.moneyCodes, self.moneyWidth = Font.encode(money), Font.width(money)
|
||||
end
|
||||
local pen = 152 - self.moneyWidth
|
||||
for _, code in ipairs(self.moneyCodes) do
|
||||
drawGlyph(code, pen, 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
|
||||
+29
-9
@@ -45,6 +45,18 @@ local function fitLabel(text, pixels)
|
||||
return table.concat(out) .. "."
|
||||
end
|
||||
|
||||
-- item -> { label, budget, out }: fitLabel's result for a row, kept
|
||||
-- until the row's label or pixel budget changes (draw asks every frame).
|
||||
-- Weak-keyed and outside the item so caller-owned rows are never written.
|
||||
local fitCache = setmetatable({}, { __mode = "k" })
|
||||
local function fitRowLabel(item, pixels)
|
||||
local c = fitCache[item]
|
||||
if c and c.label == item.label and c.budget == pixels then return c.out end
|
||||
local out = fitLabel(item.label, pixels)
|
||||
fitCache[item] = { label = item.label, budget = pixels, out = out }
|
||||
return out
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||
@@ -258,16 +270,27 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
-- self.footer paginated into one flat line list, re-run only when the
|
||||
-- footer text changes (both footer draws below ask every frame)
|
||||
local function footerLines(self)
|
||||
local text = self.footer
|
||||
if self.footerFlatFor ~= text or not self.footerFlat then
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(text)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
self.footerFlatFor, self.footerFlat = text, flat
|
||||
end
|
||||
return self.footerFlat
|
||||
end
|
||||
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
local function drawMessageBox(self)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.footer then return end
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local flat = footerLines(self)
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
@@ -362,7 +385,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
local budget = ROW_RIGHT_MARGIN - ROW_LEFT
|
||||
if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end
|
||||
local label = fitLabel(item.label, budget)
|
||||
local label = fitRowLabel(item, budget)
|
||||
Font.draw(label, 16, y)
|
||||
if item.ball then -- the Pokédex owned-ball marker tile
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
@@ -400,10 +423,7 @@ function ListMenu:draw()
|
||||
drawMessageBox(self)
|
||||
elseif self.footer then
|
||||
-- bare footer (bag money line, etc.)
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local flat = footerLines(self)
|
||||
local y = (#flat >= 2) and 120 or 136
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
|
||||
+15
-7
@@ -130,16 +130,24 @@ function Menu:draw()
|
||||
-- PlaceString at hlcoord 3,0 writes over the border row it was just
|
||||
-- drawn on (oak_speech2.asm:162-170)
|
||||
if self.title then
|
||||
local title = self.title:gsub("{DONE}%s*$", "")
|
||||
:gsub("{PROMPT}%s*$", "")
|
||||
local lines = {}
|
||||
for line in (title .. "\n"):gmatch("(.-)\n") do
|
||||
lines[#lines + 1] = line
|
||||
-- the title's lines and glyph counts, rebuilt only when it changes
|
||||
-- (this draws every frame the menu is up)
|
||||
local lines, glyphs = self.titleLines, self.titleGlyphs
|
||||
if self.titleLinesFor ~= self.title or not lines then
|
||||
local title = self.title:gsub("{DONE}%s*$", "")
|
||||
:gsub("{PROMPT}%s*$", "")
|
||||
lines, glyphs = {}, {}
|
||||
for line in (title .. "\n"):gmatch("(.-)\n") do
|
||||
lines[#lines + 1] = line
|
||||
glyphs[#lines] = #Font.split(line)
|
||||
end
|
||||
self.titleLinesFor = self.title
|
||||
self.titleLines, self.titleGlyphs = lines, glyphs
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
for i = 1, #lines do
|
||||
love.graphics.rectangle("fill", (self.tx + 3) * 8,
|
||||
(self.ty + i - 1) * 8, #Font.split(line) * 8, 8)
|
||||
(self.ty + i - 1) * 8, glyphs[i] * 8, 8)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
|
||||
+42
-7
@@ -175,6 +175,29 @@ function PartyMenu.mirrorsIcon(name)
|
||||
end
|
||||
|
||||
local iconImages = {}
|
||||
-- the OBP0-baked copies: path -> ogGroup (or "") -> image, the "#obp" half
|
||||
-- of the cache, nested so a drawn frame builds no key string per icon
|
||||
local obpIconImages = {}
|
||||
-- image -> { half = { [frame] = quad }, full = { [frame] = quad } }: icon
|
||||
-- quads never change once built, so every drawn frame reuses them (they may
|
||||
-- also sit in PaletteFX's UI redraw list, which only reads them)
|
||||
local iconQuads = setmetatable({}, { __mode = "k" })
|
||||
local function iconQuad(img, kind, frame, w, iw, ih)
|
||||
local byImg = iconQuads[img]
|
||||
if not byImg then
|
||||
byImg = { half = {}, full = {} }
|
||||
iconQuads[img] = byImg
|
||||
end
|
||||
local byFrame = byImg[kind]
|
||||
local q = byFrame[frame]
|
||||
if not q then
|
||||
q = love.graphics.newQuad(0, frame * 16, w, 16, iw, ih)
|
||||
byFrame[frame] = q
|
||||
end
|
||||
return q
|
||||
end
|
||||
-- Sprites.iconPath's opts, reused (it only reads name / trueColor)
|
||||
local iconPathOpts = {}
|
||||
-- engine/items/town_map.asm:514
|
||||
local OAM_XFLIP = { sx = -1 }
|
||||
|
||||
@@ -236,8 +259,10 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||
path = name and icons.icons and icons.icons[name]
|
||||
end
|
||||
iconPathOpts.name, iconPathOpts.trueColor = name, trueColor
|
||||
path, trueColor = require("src.pokemon.Sprites")
|
||||
.iconPath(game.data, mon, path, { name = name, trueColor = trueColor })
|
||||
.iconPath(game.data, mon, path, iconPathOpts)
|
||||
iconPathOpts.name, iconPathOpts.trueColor = nil, nil
|
||||
if not path then return end
|
||||
-- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a
|
||||
-- mod's own image (an entry table rather than an icon name) is authored
|
||||
@@ -261,8 +286,18 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
ogColors, ogGroup = PaletteFX.ogObjNormal()
|
||||
end
|
||||
end
|
||||
local key = baked and (path .. "#obp" .. (ogGroup or "")) or path
|
||||
if iconImages[key] == nil then
|
||||
-- one cache, two halves: the plain image under its path, the baked one
|
||||
-- under (path, ogGroup) -- the old path .. "#obp" .. group key, nested
|
||||
local cache, key = iconImages, path
|
||||
if baked then
|
||||
cache = obpIconImages[path]
|
||||
if not cache then
|
||||
cache = {}
|
||||
obpIconImages[path] = cache
|
||||
end
|
||||
key = ogGroup or ""
|
||||
end
|
||||
if cache[key] == nil then
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
||||
-- picked up the same way battle sprites are
|
||||
@@ -275,9 +310,9 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
else
|
||||
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
end
|
||||
iconImages[key] = ok and img or false
|
||||
cache[key] = ok and img or false
|
||||
end
|
||||
local img = iconImages[key]
|
||||
local img = cache[key]
|
||||
if not img then return end
|
||||
local alt = forceAlt or false
|
||||
if selected then
|
||||
@@ -302,7 +337,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
-- reuse overworld sheets whose walk-down frame is NOT symmetric, so
|
||||
-- drawing the raw 16x16 showed a tucked-back foot the hardware never
|
||||
-- displays (#276, absorbing #238).
|
||||
local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih)
|
||||
local half = iconQuad(img, "half", frame, 8, iw, ih)
|
||||
love.graphics.draw(img, half, x, y)
|
||||
-- sx = -1 about the block's right edge, so the flipped copy lands on
|
||||
-- x+8..x+16: the OAM_XFLIP half
|
||||
@@ -312,7 +347,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
|
||||
PaletteFX.markUiSpriteRedraw(img, half, x + 16, y, OAM_XFLIP)
|
||||
end
|
||||
elseif ih > 16 then
|
||||
local quad = love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih)
|
||||
local quad = iconQuad(img, "full", frame, 16, iw, ih)
|
||||
love.graphics.draw(img, quad, x, y)
|
||||
if ogColors then PaletteFX.markUiSpriteRedraw(img, quad, x, y) end
|
||||
else
|
||||
|
||||
+8
-3
@@ -285,9 +285,14 @@ local function drawClerk(menu)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if menu.footer then
|
||||
local flat = {}
|
||||
for _, page in ipairs(TextBox.paginate(menu.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
-- paginated once per footer text, not once per drawn frame
|
||||
local flat = menu.shopFooterFlat
|
||||
if menu.shopFooterFor ~= menu.footer or not flat then
|
||||
flat = {}
|
||||
for _, page in ipairs(TextBox.paginate(menu.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
menu.shopFooterFor, menu.shopFooterFlat = menu.footer, flat
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
|
||||
+23
-2
@@ -17,6 +17,28 @@ local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
-- front pics by path: opening the summary used to decode the PNG into a new
|
||||
-- Image every time. Loaded exactly as before (the raw path, no Assets
|
||||
-- resolution); a failed load is retried next open, as before. Hot reload
|
||||
-- (Assets.flush) drops the cache so an edited pic is picked up again.
|
||||
local frontPics = {}
|
||||
local function frontPic(path)
|
||||
local img = frontPics[path]
|
||||
if img then return img end
|
||||
local ok, loaded = pcall(love.graphics.newImage, path)
|
||||
if ok and loaded then
|
||||
frontPics[path] = loaded
|
||||
return loaded
|
||||
end
|
||||
return nil
|
||||
end
|
||||
do
|
||||
local Assets = require("src.render.Assets")
|
||||
if Assets.register then -- test harnesses may stub Assets without it
|
||||
Assets.register(function() frontPics = {} end)
|
||||
end
|
||||
end
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
SummaryMenu.isOpaque = true
|
||||
@@ -48,8 +70,7 @@ function SummaryMenu.new(game, mon)
|
||||
local path, trueColor = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
self.sprite = frontPic(path)
|
||||
end
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
-- engine/pokemon/status_screen.asm:82,168-172
|
||||
|
||||
+39
-5
@@ -328,6 +328,31 @@ local function skipAllowed()
|
||||
return envSkipAllowed or _G.POKEPORT_DEV_MODE == true
|
||||
end
|
||||
|
||||
-- sheet -> tile index -> 8x8 quad. The frame blocks redraw every frame
|
||||
-- the trade machine is on screen; a tile's quad never changes, so it is
|
||||
-- built once (it may also sit in PaletteFX's UI redraw list, read-only).
|
||||
local tileQuads = setmetatable({}, { __mode = "k" })
|
||||
local function tileQuad(sheet, tile, iw, ih)
|
||||
local byTile = tileQuads[sheet]
|
||||
if not byTile then
|
||||
byTile = {}
|
||||
tileQuads[sheet] = byTile
|
||||
end
|
||||
local q = byTile[tile]
|
||||
if not q then
|
||||
q = love.graphics.newQuad((tile % 16) * 8, math.floor(tile / 16) * 8,
|
||||
8, 8, iw, ih)
|
||||
byTile[tile] = q
|
||||
end
|
||||
return q
|
||||
end
|
||||
|
||||
-- markUiSpriteRedraw's flip opts (it only reads sx / sy off them)
|
||||
local FLIP_OPTS = {
|
||||
[1] = { [1] = { sx = 1, sy = 1 }, [-1] = { sx = 1, sy = -1 } },
|
||||
[-1] = { [1] = { sx = -1, sy = 1 }, [-1] = { sx = -1, sy = -1 } },
|
||||
}
|
||||
|
||||
-- engine/movie/trade.asm:20
|
||||
function TradeAnim:skipHeld()
|
||||
if not skipAllowed() then return false end
|
||||
@@ -343,9 +368,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y)
|
||||
local P = baked and require("src.render.PaletteFX")
|
||||
local iw, ih = sheet:getDimensions()
|
||||
for _, tile in ipairs(block) do
|
||||
local tx = (tile.tile % 16) * 8
|
||||
local ty = math.floor(tile.tile / 16) * 8
|
||||
local quad = love.graphics.newQuad(tx, ty, 8, 8, iw, ih)
|
||||
local quad = tileQuad(sheet, tile.tile, iw, ih)
|
||||
local sx = tile.xflip and -1 or 1
|
||||
local sy = tile.yflip and -1 or 1
|
||||
local ox = tile.xflip and 8 or 0
|
||||
@@ -353,7 +376,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y)
|
||||
love.graphics.draw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy)
|
||||
if P then
|
||||
P.markUiSpriteRedraw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy,
|
||||
{ sx = sx, sy = sy })
|
||||
FLIP_OPTS[sx][sy])
|
||||
end
|
||||
end
|
||||
else
|
||||
@@ -764,9 +787,20 @@ local function drawCableHoriz(self, y, x0, x1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.img.cableHoriz then
|
||||
local iw, ih = self.img.cableHoriz:getDimensions()
|
||||
-- one quad per run width, kept on the instance (the last run of a
|
||||
-- span is usually the only partial one)
|
||||
local quads = self.cableQuads
|
||||
if not quads or quads.img ~= self.img.cableHoriz then
|
||||
quads = { img = self.img.cableHoriz }
|
||||
self.cableQuads = quads
|
||||
end
|
||||
for x = x0, x1 - 1, iw do
|
||||
local drawW = math.min(iw, x1 - x)
|
||||
local quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
|
||||
local quad = quads[drawW]
|
||||
if not quad then
|
||||
quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
|
||||
quads[drawW] = quad
|
||||
end
|
||||
love.graphics.draw(self.img.cableHoriz, quad, x, y)
|
||||
end
|
||||
elseif self.img.cableSeg then
|
||||
|
||||
@@ -528,13 +528,21 @@ end
|
||||
local function elements_tile_quad(ti, sheet)
|
||||
sheet = sheet or BattleChrome._elements
|
||||
if not sheet or not love or not love.graphics then return nil end
|
||||
local key = (sheet == BattleChrome._elementsExp and "exp_" or "elt_") .. tostring(ti)
|
||||
if not BattleChrome._quads[key] then
|
||||
-- Per-sheet subtables keyed by tile index (no per-tile string keys).
|
||||
local subKey = sheet == BattleChrome._elementsExp and "exp_tiles" or "elt_tiles"
|
||||
local sub = BattleChrome._quads[subKey]
|
||||
if not sub then
|
||||
sub = {}
|
||||
BattleChrome._quads[subKey] = sub
|
||||
end
|
||||
local q = sub[ti]
|
||||
if not q then
|
||||
local tw = 40 -- 320/8
|
||||
local tx, ty = ti % tw, math.floor(ti / tw)
|
||||
BattleChrome._quads[key] = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24)
|
||||
q = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24)
|
||||
sub[ti] = q
|
||||
end
|
||||
return BattleChrome._quads[key]
|
||||
return q
|
||||
end
|
||||
|
||||
-- pokefirered/src/battle_interface.c:2050
|
||||
|
||||
+81
-28
@@ -264,7 +264,7 @@ local function loadImage(candidates)
|
||||
if okId and id then
|
||||
local img = love.graphics.newImage(id)
|
||||
if img and img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
return img, path
|
||||
return img, path, id
|
||||
end
|
||||
elseif love and love.filesystem and love.image and love.graphics then
|
||||
local okFd, fd = pcall(love.filesystem.newFileData, data, path)
|
||||
@@ -273,7 +273,7 @@ local function loadImage(candidates)
|
||||
if okId and id then
|
||||
local img = love.graphics.newImage(id)
|
||||
if img and img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
return img, path
|
||||
return img, path, id
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -289,6 +289,42 @@ local function loadImage(candidates)
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- Pack a sheet's fg and shadow images into one texture (fg on top, shadow
|
||||
-- below a transparent gap) with matching quads. Each glyph still draws its
|
||||
-- shadow then its fg, but from the same texture, so LOVE's autobatcher keeps
|
||||
-- a whole string in one draw call instead of flushing on every texture swap.
|
||||
-- Pixels are copied verbatim, so the output is unchanged. nil (draw from the
|
||||
-- separate sheets) when either ImageData is missing or they do not match.
|
||||
local ATLAS_GAP = 16
|
||||
local function pack_atlas(fgData, shData, quads)
|
||||
if not (fgData and shData and quads and love and love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage and love.graphics.newQuad) then
|
||||
return nil
|
||||
end
|
||||
local ok, atlas = pcall(function()
|
||||
local w, h = fgData:getDimensions()
|
||||
local sw, shh = shData:getDimensions()
|
||||
if sw ~= w or shh ~= h then return nil end
|
||||
if fgData.getFormat and shData.getFormat and fgData:getFormat() ~= shData:getFormat() then return nil end
|
||||
local off = h + ATLAS_GAP
|
||||
local fmt = fgData.getFormat and fgData:getFormat() or "rgba8"
|
||||
local data = love.image.newImageData(w, off + h, fmt)
|
||||
data:paste(fgData, 0, 0, 0, 0, w, h)
|
||||
data:paste(shData, 0, off, 0, 0, w, h)
|
||||
local img = love.graphics.newImage(data)
|
||||
if img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
local aw, ah = img:getDimensions()
|
||||
local fq, sq = {}, {}
|
||||
for id, q in pairs(quads) do
|
||||
local qx, qy, qw, qh = q:getViewport()
|
||||
fq[id] = love.graphics.newQuad(qx, qy, qw, qh, aw, ah)
|
||||
sq[id] = love.graphics.newQuad(qx, qy + off, qw, qh, aw, ah)
|
||||
end
|
||||
return { image = img, fg = fq, sh = sq, src = quads }
|
||||
end)
|
||||
return ok and atlas or nil
|
||||
end
|
||||
|
||||
local function loadTable(path)
|
||||
local okC, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
if not (okC and CacheFs and CacheFs.readActive) then return nil end
|
||||
@@ -387,8 +423,8 @@ local function loadFace(spec, name)
|
||||
error("FrlgFont: the active profile has no font face '" .. tostring(name) .. "'", 0)
|
||||
end
|
||||
local dir = spec.dir or ""
|
||||
local fg, fgp = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } })
|
||||
local sh = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } })
|
||||
local fg, fgp, fgData = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } })
|
||||
local sh, _, shData = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } })
|
||||
local widths = fs.widths and loadTable(dir .. fs.widths)
|
||||
local m = metricsFor(spec, fs.fontId)
|
||||
if not (fg and widths and m) then
|
||||
@@ -408,6 +444,7 @@ local function loadFace(spec, name)
|
||||
name = name, fg = fg, sh = sh, quads = quads, widths = widths,
|
||||
height = m.maxLetterHeight, pitch = m.maxLetterHeight + (m.lineSpacing or 0),
|
||||
letterSpacing = m.letterSpacing or 0,
|
||||
atlas = pack_atlas(fgData, shData, quads),
|
||||
}
|
||||
FrlgFont._faces[name] = face
|
||||
log(fs.sheet .. " ready " .. tostring(fgp))
|
||||
@@ -471,8 +508,8 @@ local function ensure()
|
||||
end
|
||||
FrlgFont._widths = widths or {}
|
||||
|
||||
local fg, fgp = loadImage(FG_PATHS)
|
||||
local sh = loadImage(SH_PATHS)
|
||||
local fg, fgp, fgData = loadImage(FG_PATHS)
|
||||
local sh, _, shData = loadImage(SH_PATHS)
|
||||
if not fg then
|
||||
if not FrlgFont._logged then
|
||||
log("latin_normal font missing")
|
||||
@@ -490,6 +527,7 @@ local function ensure()
|
||||
quads[id] = love.graphics.newQuad(col * 16, row * 16, 16, 16, iw, ih)
|
||||
end
|
||||
FrlgFont._quads = quads
|
||||
FrlgFont._atlas = pack_atlas(fgData, shData, quads)
|
||||
if not FrlgFont._logged then
|
||||
log("latin_normal ready " .. tostring(fgp))
|
||||
FrlgFont._logged = true
|
||||
@@ -529,8 +567,8 @@ local function ensure_small()
|
||||
if chunk then widths = chunk() end
|
||||
end
|
||||
end
|
||||
local fg, fgp = loadImage(SMALL_FG_PATHS)
|
||||
local sh = loadImage(SMALL_SH_PATHS)
|
||||
local fg, fgp, fgData = loadImage(SMALL_FG_PATHS)
|
||||
local sh, _, shData = loadImage(SMALL_SH_PATHS)
|
||||
if not fg then return false end
|
||||
local iw, ih = fg:getDimensions()
|
||||
local quads = {}
|
||||
@@ -545,6 +583,7 @@ local function ensure_small()
|
||||
-- Sheets from extract_latin_small.py (ROM hwlat @ 0x1EAF00), CHARMAP-ordered.
|
||||
FrlgFont._small = {
|
||||
fg = fg, sh = sh, quads = quads, widths = widths or {}, _romBaked = true,
|
||||
atlas = pack_atlas(fgData, shData, quads),
|
||||
}
|
||||
log("latin_small ready " .. tostring(fgp) .. " (ROM FONT_SMALL)")
|
||||
return true
|
||||
@@ -612,18 +651,18 @@ FrlgFont.JAPANESE_GLYPHS = japanese_glyphs()
|
||||
|
||||
local function load_japanese(key, fgPaths, shPaths)
|
||||
if FrlgFont[key] ~= nil then return FrlgFont[key] or nil end
|
||||
local fg = loadImage(fgPaths)
|
||||
local fg, _, fgData = loadImage(fgPaths)
|
||||
if not fg then
|
||||
FrlgFont[key] = false
|
||||
return nil
|
||||
end
|
||||
local sh = loadImage(shPaths)
|
||||
local sh, _, shData = loadImage(shPaths)
|
||||
local iw, ih = fg:getDimensions()
|
||||
local quads = {}
|
||||
for code = 0, 511 do
|
||||
quads[code] = love.graphics.newQuad((code % 16) * 16, math.floor(code / 16) * 16, 16, 16, iw, ih)
|
||||
end
|
||||
FrlgFont[key] = { fg = fg, sh = sh, quads = quads }
|
||||
FrlgFont[key] = { fg = fg, sh = sh, quads = quads, atlas = pack_atlas(fgData, shData, quads) }
|
||||
return FrlgFont[key]
|
||||
end
|
||||
|
||||
@@ -649,7 +688,7 @@ local function japanese_quad(glyphId, small)
|
||||
local sheet = small and load_japanese("_jpSmall", JP_SMALL_FG_PATHS, JP_SMALL_SH_PATHS)
|
||||
or load_japanese("_jpNormal", JP_FG_PATHS, JP_SH_PATHS)
|
||||
if not sheet then return nil end
|
||||
return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE]
|
||||
return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE], sheet.atlas
|
||||
end
|
||||
|
||||
-- The remaining single characters of the Latin block of pret
|
||||
@@ -1162,6 +1201,14 @@ end
|
||||
local ADVANCE_SMALL = { small = true }
|
||||
local ADVANCE_NORMAL = {}
|
||||
|
||||
local function set_col(c)
|
||||
if type(c) == "table" then
|
||||
love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
--- Draw full string at pixel (x,y).
|
||||
-- opts.maxWidth clips (CopyGlyphToWindow). opts.colors = COLOR.NORMAL etc.
|
||||
-- opts.limitChars: only draw first N printable characters (typewriter).
|
||||
@@ -1210,22 +1257,15 @@ function FrlgFont.draw(text, x, y, opts)
|
||||
local drawn = 0
|
||||
local pitch = opts.linePitch or (face and face.pitch)
|
||||
or (useSmall and FrlgFont.SMALL_LINE_PITCH or FrlgFont.LINE_PITCH)
|
||||
local fg, sh, quads
|
||||
local fg, sh, quads, atlas
|
||||
if face then
|
||||
fg, sh, quads = face.fg, face.sh, face.quads
|
||||
fg, sh, quads, atlas = face.fg, face.sh, face.quads, face.atlas
|
||||
elseif useSmall then
|
||||
fg, sh, quads = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads
|
||||
fg, sh, quads, atlas = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads, FrlgFont._small.atlas
|
||||
else
|
||||
fg, sh, quads = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads
|
||||
end
|
||||
|
||||
local function set_col(c)
|
||||
if type(c) == "table" then
|
||||
love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
fg, sh, quads, atlas = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads, FrlgFont._atlas
|
||||
end
|
||||
if atlas and (atlas.src ~= quads or not sh) then atlas = nil end
|
||||
|
||||
for ttype, val, curCol in FrlgFont.scanTokens(text, baseColors) do
|
||||
if limit and drawn >= limit then break end
|
||||
@@ -1262,9 +1302,12 @@ function FrlgFont.draw(text, x, y, opts)
|
||||
end
|
||||
if penX + adv <= maxW or penX == 0 then
|
||||
local dx, dy = x + penX, y + penY
|
||||
local gfg, gsh, q = fg, sh, quads[id]
|
||||
local gfg, gsh, q, gat = fg, sh, quads[id], atlas
|
||||
local aid = id
|
||||
if id >= FrlgFont.JAPANESE_BASE then
|
||||
gfg, gsh, q = japanese_quad(id, useSmall)
|
||||
gfg, gsh, q, gat = japanese_quad(id, useSmall)
|
||||
aid = id - FrlgFont.JAPANESE_BASE
|
||||
if not gsh then gat = nil end
|
||||
end
|
||||
if q then
|
||||
-- Draw background / highlight fill if bg is not transparent
|
||||
@@ -1272,15 +1315,24 @@ function FrlgFont.draw(text, x, y, opts)
|
||||
set_col(curCol.bg)
|
||||
love.graphics.rectangle("fill", dx, dy, adv, pitch)
|
||||
end
|
||||
local aq = gat and gat.fg[aid]
|
||||
-- Draw shadow
|
||||
if gsh and curCol.shadow and (not curCol.shadow[4] or curCol.shadow[4] > 0) then
|
||||
set_col(curCol.shadow)
|
||||
love.graphics.draw(gsh, q, dx, dy)
|
||||
if aq then
|
||||
love.graphics.draw(gat.image, gat.sh[aid], dx, dy)
|
||||
else
|
||||
love.graphics.draw(gsh, q, dx, dy)
|
||||
end
|
||||
end
|
||||
-- Draw foreground
|
||||
if curCol.fg and (not curCol.fg[4] or curCol.fg[4] > 0) then
|
||||
set_col(curCol.fg)
|
||||
love.graphics.draw(gfg, q, dx, dy)
|
||||
if aq then
|
||||
love.graphics.draw(gat.image, aq, dx, dy)
|
||||
else
|
||||
love.graphics.draw(gfg, q, dx, dy)
|
||||
end
|
||||
end
|
||||
end
|
||||
penX = penX + japanese_step(id, adv, minW, jpn, opts, useSmall)
|
||||
@@ -1381,6 +1433,7 @@ function FrlgFont.invalidate()
|
||||
FrlgFont._fg = nil
|
||||
FrlgFont._sh = nil
|
||||
FrlgFont._quads = nil
|
||||
FrlgFont._atlas = nil
|
||||
FrlgFont._small = nil
|
||||
FrlgFont._keypad = nil
|
||||
FrlgFont._jpNormal = nil
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,11 @@ local _cards = nil
|
||||
local _cardEdge = {}
|
||||
local _screens = nil
|
||||
local _assetsTried = false
|
||||
-- RSE front pics keyed by trainer pic id (false = load failed), and the
|
||||
-- badge quads keyed by the badge sheet they were built for.
|
||||
local _rsePics = {}
|
||||
local _rseBadgeQuads = nil
|
||||
local _rseBadgeQuadsImg = nil
|
||||
|
||||
local function read_cache_file(path)
|
||||
local ok, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
@@ -997,7 +1002,11 @@ local function drawRse(c)
|
||||
else
|
||||
-- pokeemerald/src/trainer_card.c:1895
|
||||
local picId = c.female and m.pics.female or m.pics.male
|
||||
local pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64)
|
||||
local pic = _rsePics[picId]
|
||||
if pic == nil then
|
||||
pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64) or false
|
||||
_rsePics[picId] = pic
|
||||
end
|
||||
if pic then
|
||||
love.graphics.draw(pic, (19 + m.picOffset[1]) * 8, (5 + m.picOffset[2]) * 8)
|
||||
end
|
||||
@@ -1009,10 +1018,17 @@ local function drawRse(c)
|
||||
end
|
||||
local badges = Kit.image(m.badges.png)
|
||||
if badges then
|
||||
local bw, bh = badges:getDimensions()
|
||||
if _rseBadgeQuadsImg ~= badges then
|
||||
local bw, bh = badges:getDimensions()
|
||||
_rseBadgeQuads = {}
|
||||
for i = 1, 8 do
|
||||
_rseBadgeQuads[i] = love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh)
|
||||
end
|
||||
_rseBadgeQuadsImg = badges
|
||||
end
|
||||
for i = 1, 8 do
|
||||
if c.badges[i] then
|
||||
love.graphics.draw(badges, love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh), (4 + (i - 1) * 3) * 8, 15 * 8)
|
||||
love.graphics.draw(badges, _rseBadgeQuads[i], (4 + (i - 1) * 3) * 8, 15 * 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,8 @@ local Palettes = require("src.world.gen2.Palettes")
|
||||
local BattleAnimView = {}
|
||||
BattleAnimView.__index = BattleAnimView
|
||||
|
||||
local EMPTY = {}
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
-- An OBJ at OAM (x, y) draws at (x - 8, y - 16).
|
||||
@@ -64,14 +66,19 @@ function BattleAnimView:image(path)
|
||||
return cached or nil
|
||||
end
|
||||
|
||||
-- Nested by sheet then index, so a lookup builds no string.
|
||||
function BattleAnimView:quad(sheetName, index, wide, image)
|
||||
local key = sheetName .. ":" .. index
|
||||
local quad = self.quads[key]
|
||||
local bySheet = self.quads[sheetName]
|
||||
if not bySheet then
|
||||
bySheet = {}
|
||||
self.quads[sheetName] = bySheet
|
||||
end
|
||||
local quad = bySheet[index]
|
||||
if not quad then
|
||||
local w, h = image:getDimensions()
|
||||
quad = love.graphics.newQuad(
|
||||
(index % wide) * 8, math.floor(index / wide) * 8, 8, 8, w, h)
|
||||
self.quads[key] = quad
|
||||
bySheet[index] = quad
|
||||
end
|
||||
return quad
|
||||
end
|
||||
@@ -93,30 +100,55 @@ end
|
||||
-- colours; the other six are the fixed block from
|
||||
-- gfx/battle_anims/battle_anims.pal, which the extractor reads into
|
||||
-- palettes.battleObjects.
|
||||
-- Palettes.monColors, memoized per (palettes, species, shiny): it builds five
|
||||
-- tables a call and this runs per OAM object per frame. The answer is a pure
|
||||
-- function of those three, and the shader only reads it.
|
||||
function BattleAnimView:monColors(species, shiny)
|
||||
local cache = self.monColorCache
|
||||
if not cache or cache.palettes ~= self.palettes then
|
||||
cache = { palettes = self.palettes, [true] = {}, [false] = {} }
|
||||
self.monColorCache = cache
|
||||
end
|
||||
local byShiny = cache[shiny and true or false]
|
||||
local colors = byShiny[species]
|
||||
if colors == nil then
|
||||
if species == nil then return Palettes.monColors(self.palettes, species, shiny) end
|
||||
colors = Palettes.monColors(self.palettes, species, shiny) or false
|
||||
byShiny[species] = colors
|
||||
end
|
||||
return colors or nil
|
||||
end
|
||||
|
||||
function BattleAnimView:objPalette(name, battle)
|
||||
if name == "PAL_BATTLE_OB_ENEMY" then
|
||||
local enemy = battle and battle.enemy
|
||||
return enemy and Palettes.monColors(self.palettes, enemy.species, enemy.shiny)
|
||||
return enemy and self:monColors(enemy.species, enemy.shiny)
|
||||
end
|
||||
if name == "PAL_BATTLE_OB_PLAYER" then
|
||||
local player = battle and battle.player
|
||||
return player and Palettes.monColors(self.palettes, player.species, player.shiny)
|
||||
return player and self:monColors(player.species, player.shiny)
|
||||
end
|
||||
local set = self.palettes and self.palettes.battleObjects
|
||||
return set and set[name] or nil
|
||||
end
|
||||
|
||||
-- One frame's OBJ layer.
|
||||
-- One frame's OBJ layer. The palette shader is set once per run of objects
|
||||
-- sharing a palette and the caller's shader restored once at the end: what
|
||||
-- GbcPalette.with did per object, without the closure and pcall each.
|
||||
function BattleAnimView:drawObjects(runner, battle)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local shaded = GbcPalette.available()
|
||||
local previous = shaded and G.getShader and G.getShader() or nil
|
||||
local active = nil
|
||||
local gfx = self.data.gfx or EMPTY
|
||||
for _, obj in ipairs(runner:oam()) do
|
||||
local entry, index = sheetForTile(runner, obj.tile)
|
||||
-- The two battler-pic pseudo-sheets are the mons' own tiles; nothing in
|
||||
-- the cache holds them as a sheet, so they are simply not drawn rather
|
||||
-- than drawn from the wrong image.
|
||||
if entry and not entry.battler then
|
||||
local sheet = (self.data.gfx or {})[entry.gfx]
|
||||
local sheet = gfx[entry.gfx]
|
||||
local image = sheet and self:image(sheet.image)
|
||||
if image then
|
||||
local wide = sheet.wide or 8
|
||||
@@ -132,19 +164,17 @@ function BattleAnimView:drawObjects(runner, battle)
|
||||
local syScale = bit.band(obj.attr, OAM_YFLIP) ~= 0 and -1 or 1
|
||||
local ox = sxScale < 0 and 8 or 0
|
||||
local oy = syScale < 0 and 8 or 0
|
||||
local colors = self:objPalette(obj.palette, battle)
|
||||
local function body()
|
||||
G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale)
|
||||
end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
local colors = shaded and self:objPalette(obj.palette, battle) or nil
|
||||
if colors ~= active then
|
||||
if colors then GbcPalette.use(colors) else G.setShader(previous) end
|
||||
active = colors
|
||||
end
|
||||
G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if active then G.setShader(previous) end
|
||||
end
|
||||
|
||||
-- True when the BG layer needs the scanline treatment at all; a plain
|
||||
@@ -269,24 +299,34 @@ end
|
||||
BattleAnimView.bgpBands = bgpBands
|
||||
|
||||
-- engine/battle_anims/bg_effects.asm:2638
|
||||
--
|
||||
-- One scanline's source row and x shift, or nil when the row shows nothing.
|
||||
local function scanline(bg, scy, row)
|
||||
local dx, src = 0, row + scy
|
||||
-- home/lcd.asm:12
|
||||
local inWindow = bg.lcdc and bg.lcdc ~= "BGP"
|
||||
and row > bg.lyStart and row <= bg.lyEnd
|
||||
local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0
|
||||
if inWindow then
|
||||
local value = signed(byte)
|
||||
if bg.lcdc == "SCX" then dx = -value else src = src + value end
|
||||
-- home/lcd.asm:3
|
||||
if byte ~= 0x90 and scy == 0 then
|
||||
if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end
|
||||
end
|
||||
end
|
||||
if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then
|
||||
return src, dx
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function BattleAnimView.scanlines(bg)
|
||||
local lines = {}
|
||||
local scy = signed(bg.scy)
|
||||
for row = 0, SCREEN_H - 1 do
|
||||
local dx, src = 0, row + scy
|
||||
-- home/lcd.asm:12
|
||||
local inWindow = bg.lcdc and bg.lcdc ~= "BGP"
|
||||
and row > bg.lyStart and row <= bg.lyEnd
|
||||
local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0
|
||||
if inWindow then
|
||||
local value = signed(byte)
|
||||
if bg.lcdc == "SCX" then dx = -value else src = src + value end
|
||||
-- home/lcd.asm:3
|
||||
if byte ~= 0x90 and scy == 0 then
|
||||
if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end
|
||||
end
|
||||
end
|
||||
if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then
|
||||
local src, dx = scanline(bg, scy, row)
|
||||
if src then
|
||||
lines[#lines + 1] = { src = src, dest = row, dx = dx }
|
||||
end
|
||||
end
|
||||
@@ -339,8 +379,11 @@ function BattleAnimView:present(runner, drawBg, battle)
|
||||
-- hSCX / hSCY move the whole background; the per-scanline overrides only
|
||||
-- apply inside the effect's own window.
|
||||
local baseX = -signed(bg.scx)
|
||||
for _, line in ipairs(BattleAnimView.scanlines(bg)) do
|
||||
self:blitRowAt(line.src, line.dest, baseX + line.dx)
|
||||
-- BattleAnimView.scanlines row by row, without a table per scanline.
|
||||
local scy = signed(bg.scy)
|
||||
for row = 0, SCREEN_H - 1 do
|
||||
local src, dx = scanline(bg, scy, row)
|
||||
if src then self:blitRowAt(src, row, baseX + dx) end
|
||||
end
|
||||
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to
|
||||
-- permute and the entry's BRIGHTNESS is the only thing left to reproduce.
|
||||
|
||||
+25
-14
@@ -82,13 +82,18 @@ function BattleHud:image(key)
|
||||
end
|
||||
|
||||
-- One 8x8 tile out of a horizontal strip, cached per (sheet, index).
|
||||
-- Nested by image then index, so a lookup builds no string.
|
||||
function BattleHud:quad(image, index)
|
||||
local key = tostring(image) .. ":" .. index
|
||||
local quad = self.quads[key]
|
||||
local byImage = self.quads[image]
|
||||
if not byImage then
|
||||
byImage = {}
|
||||
self.quads[image] = byImage
|
||||
end
|
||||
local quad = byImage[index]
|
||||
if not quad then
|
||||
local w, h = image:getDimensions()
|
||||
quad = love.graphics.newQuad(index * 8, 0, 8, 8, w, h)
|
||||
self.quads[key] = quad
|
||||
byImage[index] = quad
|
||||
end
|
||||
return quad
|
||||
end
|
||||
@@ -120,20 +125,26 @@ function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror)
|
||||
if index < 0 then return false end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body()
|
||||
if mirror then
|
||||
-- Flip in place: the origin moves a tile right and x scales by -1.
|
||||
G.draw(image, self:quad(image, index), tx * 8 + 8, ty * 8, 0, -1, 1)
|
||||
else
|
||||
G.draw(image, self:quad(image, index), tx * 8, ty * 8)
|
||||
end
|
||||
end
|
||||
local quad = self:quad(image, index)
|
||||
-- home/fade.asm:35 (RotateThreePalettesRight)
|
||||
if GbcPalette.available() then
|
||||
GbcPalette.with(colors or GbcPalette.DMG_SHADES, body)
|
||||
-- GbcPalette.with without the per-tile closure: set, draw, restore.
|
||||
local shaded = GbcPalette.available()
|
||||
local previous
|
||||
if shaded then
|
||||
previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(colors or GbcPalette.DMG_SHADES)
|
||||
else
|
||||
G.setColor(0, 0, 0, 1)
|
||||
body()
|
||||
end
|
||||
if mirror then
|
||||
-- Flip in place: the origin moves a tile right and x scales by -1.
|
||||
G.draw(image, quad, tx * 8 + 8, ty * 8, 0, -1, 1)
|
||||
else
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
end
|
||||
if shaded then
|
||||
G.setShader(previous)
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
return true
|
||||
|
||||
+65
-11
@@ -622,6 +622,12 @@ function BattleState.new(game, opts)
|
||||
species = enemy and enemy.species,
|
||||
level = enemy and enemy.level,
|
||||
})
|
||||
-- queue both entrance cries on the chip audio worker during the intro so
|
||||
-- playCry doesn't render them on the frame they play (no-op without one)
|
||||
local data = game and game.data
|
||||
for _, mon in ipairs({ enemy or false, player or false }) do
|
||||
if mon and mon.species then pcall(Sound.prewarmCry, data, mon.species) end
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -750,7 +756,10 @@ function BattleState:pic(mon, back)
|
||||
-- picked -- resolving the species row again would throw the form away. The
|
||||
-- two extra keys are what Gen 2 genuinely carries more of: the Unown letter
|
||||
-- and the shiny flag that decides the palette.
|
||||
if path then
|
||||
-- Unhooked, Sprites.pic hands back exactly (path, trueColor); the ctx is
|
||||
-- only built when a subscriber is there to read it (drawPic asks twice a
|
||||
-- frame).
|
||||
if path and Runtime.wantsHook("pokemon.sprite") then
|
||||
path, trueColor = Sprites.pic(path, {
|
||||
species = mon.species,
|
||||
side = back and "back" or "front",
|
||||
@@ -937,6 +946,42 @@ function BattleState:isUnderground(side, mon)
|
||||
and (self.vanishSeen and self.vanishSeen[side]) and true or false
|
||||
end
|
||||
|
||||
-- Palettes.monColors / trainerColors, memoized per palettes table: each
|
||||
-- builds five tables a call and drawPic asks twice a frame. Both are pure
|
||||
-- functions of the palette data, and the shader only reads the answer.
|
||||
local function colorMemo(self)
|
||||
local memo = self.picColorMemo
|
||||
if not memo or memo.palettes ~= self.palettes then
|
||||
memo = { palettes = self.palettes, [true] = {}, [false] = {}, trainers = {} }
|
||||
self.picColorMemo = memo
|
||||
end
|
||||
return memo
|
||||
end
|
||||
|
||||
function BattleState:cachedMonColors(species, shiny)
|
||||
if species == nil then
|
||||
return Palettes.monColors(self.palettes, species, shiny)
|
||||
end
|
||||
local bucket = colorMemo(self)[shiny and true or false]
|
||||
local colors = bucket[species]
|
||||
if colors == nil then
|
||||
colors = Palettes.monColors(self.palettes, species, shiny) or false
|
||||
bucket[species] = colors
|
||||
end
|
||||
return colors or nil
|
||||
end
|
||||
|
||||
function BattleState:cachedTrainerColors(row)
|
||||
local key = row or "PLAYER"
|
||||
local bucket = colorMemo(self).trainers
|
||||
local colors = bucket[key]
|
||||
if colors == nil then
|
||||
colors = Palettes.trainerColors(self.palettes, row) or false
|
||||
bucket[key] = colors
|
||||
end
|
||||
return colors or nil
|
||||
end
|
||||
|
||||
function BattleState:drawPic(mon, back)
|
||||
-- During the intro slide the player-side pic belongs to presentSlide's
|
||||
-- backpic overlay, not to the baked bands (see BattleAnimView).
|
||||
@@ -1041,17 +1086,17 @@ function BattleState:drawPic(mon, back)
|
||||
-- No mon on this side at all in the catching tutorial, where the box holds
|
||||
-- the DUDE's back-pic and nothing else for the whole battle.
|
||||
local colors = self.palettes and mon
|
||||
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
|
||||
and self:cachedMonColors(mon.species, mon.shiny)
|
||||
if trainerBack then
|
||||
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
|
||||
-- TrainerPalettes (Chris shares Cal's).
|
||||
-- engine/gfx/color.asm:683-696
|
||||
local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER"
|
||||
colors = Palettes.trainerColors(self.palettes, row)
|
||||
or Palettes.trainerColors(self.palettes, "PLAYER") or colors
|
||||
colors = self:cachedTrainerColors(row)
|
||||
or self:cachedTrainerColors("PLAYER") or colors
|
||||
elseif enemyTrainer then
|
||||
-- The opponent's class row out of the same TrainerPalettes table.
|
||||
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
|
||||
colors = self:cachedTrainerColors(self.enemyTrainerClass)
|
||||
or colors
|
||||
end
|
||||
-- ../pokecrystal/engine/gfx/cgb_layouts.asm:67
|
||||
@@ -4374,14 +4419,23 @@ end
|
||||
-- The four labels the battle menu draws. Inside the contest the third one
|
||||
-- carries the park ball count, which .PrintParkBallsRemaining writes with
|
||||
-- PRINTNUM_LEADINGZEROS over two digits.
|
||||
--
|
||||
-- The list is refilled in place each call (the menu asks every frame); the
|
||||
-- lookups themselves still run, so a catalog a mod edits live is followed.
|
||||
function BattleState:menuLabels()
|
||||
if not self.contest then
|
||||
return { Strings(MENU[1]), Strings(MENU[2]),
|
||||
Strings(MENU[3]), Strings(MENU[4]) }
|
||||
local labels = self.menuLabelList
|
||||
if not labels then
|
||||
labels = {}
|
||||
self.menuLabelList = labels
|
||||
end
|
||||
return { Strings(MENU[1]), Strings(MENU[2]),
|
||||
Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save)),
|
||||
Strings(MENU[4]) }
|
||||
labels[1], labels[2], labels[4] =
|
||||
Strings(MENU[1]), Strings(MENU[2]), Strings(MENU[4])
|
||||
if not self.contest then
|
||||
labels[3] = Strings(MENU[3])
|
||||
else
|
||||
labels[3] = Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save))
|
||||
end
|
||||
return labels
|
||||
end
|
||||
|
||||
-- The message on the cart's own two rows: 14 and 16, with 15 blank between
|
||||
|
||||
+54
-36
@@ -852,6 +852,39 @@ end
|
||||
-- GetFrontpic's `cp EGG / jr nz, .not_egg` arm hands back EggPic, never the
|
||||
-- hatchling's pic (engine/gfx/load_pics.asm:88-91); it rides menu_gfx.eggHatch,
|
||||
-- with the party list's ICON_EGG standing in for a cache built before that.
|
||||
-- One reusable quad per role, re-aimed per draw rather than a new Quad a
|
||||
-- frame. nil when the backend cannot make one (the old pcall's degrade).
|
||||
local function reusedQuad(self, slot, x, y, w, h, sw, sh)
|
||||
local quads = self.reusedQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
self.reusedQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
return quad
|
||||
end
|
||||
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
|
||||
if not ok then return nil end
|
||||
quads[slot] = made
|
||||
return made
|
||||
end
|
||||
|
||||
-- GbcPalette.with without a closure: set `colors` (when there is a shader),
|
||||
-- returning what finishShade needs to put the caller's shader back.
|
||||
local function startShade(colors)
|
||||
if not (colors and GbcPalette.available()) then return false end
|
||||
local G = love.graphics
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(colors)
|
||||
return true, previous
|
||||
end
|
||||
|
||||
local function finishShade(shaded, previous)
|
||||
if shaded then love.graphics.setShader(previous) end
|
||||
end
|
||||
|
||||
function BoxMenu:drawEggPic(mon)
|
||||
local G = love.graphics
|
||||
local colors = self:panelColors("EGG", mon and mon.shiny)
|
||||
@@ -866,18 +899,15 @@ function BoxMenu:drawEggPic(mon)
|
||||
local w = entry.width or 16
|
||||
local h = math.min(entry.height or 16, image:getHeight())
|
||||
if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end
|
||||
local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h,
|
||||
local quad = reusedQuad(self, "egg", 0, 0, w, h,
|
||||
image:getWidth(), image:getHeight())
|
||||
if not ok then return end
|
||||
if not quad then return end
|
||||
local x = PIC_X * 8 + math.floor((7 * 8 - w * 2) / 2)
|
||||
local y = PIC_Y * 8 + math.floor((7 * 8 - h * 2) / 2)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body() G.draw(image, quad, x, y, 0, 2, 2) end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
local shaded, previous = startShade(colors)
|
||||
G.draw(image, quad, x, y, 0, 2, 2)
|
||||
finishShade(shaded, previous)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -889,18 +919,14 @@ function BoxMenu:drawHeldIcon(mon)
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
local iw, ih = image:getDimensions()
|
||||
local quad = reusedQuad(self, "held", row * 8, 0, 8, 8, iw, ih)
|
||||
if not quad then return end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
local shaded, previous = startShade(gfx and gfx.palette)
|
||||
G.draw(image, quad, ICON_X * 8, ICON_Y * 8)
|
||||
finishShade(shaded, previous)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -911,25 +937,17 @@ function BoxMenu:drawBoxArrows()
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local G = love.graphics
|
||||
local quads = {}
|
||||
for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do
|
||||
local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
quads[#quads + 1] = { quad, arrow[2] }
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
local left = reusedQuad(self, "arrowLeft", ARROW_LEFT[1] * 8, 0, 8, 8, iw, ih)
|
||||
if not left then return end
|
||||
local right = reusedQuad(self, "arrowRight", ARROW_RIGHT[1] * 8, 0, 8, 8,
|
||||
iw, ih)
|
||||
if not right then return end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body()
|
||||
for _, entry in ipairs(quads) do
|
||||
G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8)
|
||||
end
|
||||
end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
local shaded, previous = startShade(gfx and gfx.palette)
|
||||
G.draw(image, left, ARROW_LEFT[2] * 8, ARROW_ROW * 8)
|
||||
G.draw(image, right, ARROW_RIGHT[2] * 8, ARROW_ROW * 8)
|
||||
finishShade(shaded, previous)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+67
-26
@@ -13,6 +13,9 @@
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
|
||||
local WHITE = { 255, 255, 255 }
|
||||
local BLACK = { 0, 0, 0 }
|
||||
|
||||
local Chrome = {}
|
||||
|
||||
-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the
|
||||
@@ -32,7 +35,11 @@ function Chrome.paletteFill(px, py, pw, ph, palette)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
if GbcPalette.available() then
|
||||
GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end)
|
||||
-- GbcPalette.with without the closure: set, draw, restore.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(palette)
|
||||
G.rectangle("fill", px, py, pw, ph)
|
||||
G.setShader(previous)
|
||||
else
|
||||
G.rectangle("fill", px, py, pw, ph)
|
||||
end
|
||||
@@ -160,8 +167,14 @@ Chrome.DEFAULT_BOX_PALETTE = {
|
||||
function Chrome.paletteBox(tx, ty, tw, th, palette)
|
||||
palette = palette or Chrome.DEFAULT_BOX_PALETTE
|
||||
if GbcPalette.available() then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
-- GbcPalette.with, restoring the caller's shader even if the box throws.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(palette)
|
||||
local ok, err = pcall(Font.drawBox, tx, ty, tw, th)
|
||||
G.setShader(previous)
|
||||
if not ok then error(err, 0) end
|
||||
else
|
||||
Font.drawBox(tx, ty, tw, th, palette[1])
|
||||
end
|
||||
@@ -278,19 +291,56 @@ function Chrome.paletteGlyphs(palette, invert, raw)
|
||||
return pal, drawGlyph, finish
|
||||
end
|
||||
|
||||
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
|
||||
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
|
||||
if not pal then return Chrome.print(text, tx, ty) end
|
||||
local width = Font.width(text)
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
|
||||
local pen = tx * 8
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
drawGlyph(code, pen, ty * 8)
|
||||
-- printThrough's resolved palette, or nil when it has to take the plain
|
||||
-- degrade: paletteGlyphs' own test and fold, without its two closures.
|
||||
local function glyphPalette(palette, invert, raw)
|
||||
if not (palette and GbcPalette.available()) then return nil end
|
||||
return raw and Chrome.rawPalette(palette, invert)
|
||||
or Chrome.throughPalette(palette, invert)
|
||||
end
|
||||
|
||||
-- The string's glyph codes and its width, off one encode: the width is the
|
||||
-- sum of the same advances the pen walks, which is all Font.width is.
|
||||
local function measure(text)
|
||||
local codes = Font.encode(text)
|
||||
local width = 0
|
||||
for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end
|
||||
return codes, width
|
||||
end
|
||||
|
||||
-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and
|
||||
-- finish inlined (see there for the TTF split, gen1recomp#1642).
|
||||
local function drawThrough(codes, width, x, y, pal)
|
||||
local G = love.graphics
|
||||
local paper = pal[1] or WHITE
|
||||
local ink = pal[4] or BLACK
|
||||
local previous = G.getShader()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", x, y, width, 8)
|
||||
local shaded = false
|
||||
local pen = x
|
||||
for i = 1, #codes do
|
||||
local code = codes[i]
|
||||
if code >= Font.TTF_BASE then
|
||||
if shaded then G.setShader(previous); shaded = false end
|
||||
G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
|
||||
elseif not shaded then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
GbcPalette.useRaw(pal)
|
||||
shaded = true
|
||||
end
|
||||
Font.drawCode(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
finish()
|
||||
if shaded then G.setShader(previous) end
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
|
||||
local pal = glyphPalette(palette, invert, raw)
|
||||
if not pal then return Chrome.print(text, tx, ty) end
|
||||
local codes, width = measure(text)
|
||||
drawThrough(codes, width, tx * 8, ty * 8, pal)
|
||||
return width
|
||||
end
|
||||
|
||||
@@ -317,19 +367,10 @@ function Chrome.printRight(text, txEnd, ty)
|
||||
end
|
||||
|
||||
function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw)
|
||||
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
|
||||
local pal = glyphPalette(palette, invert, raw)
|
||||
if not pal then return Chrome.printRight(text, txEnd, ty) end
|
||||
local width = Font.width(text)
|
||||
local tx = txEnd * 8 - width
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx, ty * 8, width, 8)
|
||||
local pen = tx
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
drawGlyph(code, pen, ty * 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
finish()
|
||||
local codes, width = measure(text)
|
||||
drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal)
|
||||
return width
|
||||
end
|
||||
|
||||
|
||||
@@ -501,11 +501,19 @@ function NamingScreen:drawPanel()
|
||||
if self.iconImage then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local w, h = self.iconImage:getDimensions()
|
||||
local quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h),
|
||||
w, h)
|
||||
-- Cut once per icon image rather than every frame.
|
||||
local quad = self.iconQuad
|
||||
if not quad or self.iconQuadImage ~= self.iconImage then
|
||||
quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h),
|
||||
w, h)
|
||||
self.iconQuad, self.iconQuadImage = quad, self.iconImage
|
||||
end
|
||||
if self.iconColors and GbcPalette.available() then
|
||||
GbcPalette.with(self.iconColors,
|
||||
function() G.draw(self.iconImage, quad, 16, 16) end)
|
||||
-- GbcPalette.with without the closure: set, draw, restore.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(self.iconColors)
|
||||
G.draw(self.iconImage, quad, 16, 16)
|
||||
G.setShader(previous)
|
||||
else
|
||||
G.draw(self.iconImage, quad, 16, 16)
|
||||
end
|
||||
|
||||
+42
-24
@@ -869,6 +869,24 @@ function PartyMenu:iconBob(index)
|
||||
return (math.floor(self.clock / 16) % 2 == 1) and -2 or 0
|
||||
end
|
||||
|
||||
-- One reusable quad per role, re-aimed per draw: an icon draws every frame
|
||||
-- and a fresh Quad per tile would churn the GC.
|
||||
local function reusedQuad(self, slot, x, y, w, h, sw, sh)
|
||||
local quads = self.iconQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
self.iconQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
else
|
||||
quad = love.graphics.newQuad(x, y, w, h, sw, sh)
|
||||
quads[slot] = quad
|
||||
end
|
||||
return quad
|
||||
end
|
||||
|
||||
function PartyMenu:drawIcon(mon, px, py)
|
||||
local image, frame = self:iconFor(mon)
|
||||
if not image then return end
|
||||
@@ -876,7 +894,19 @@ function PartyMenu:drawIcon(mon, px, py)
|
||||
local iw, ih = image:getDimensions()
|
||||
local markerRow = PartyMenu.heldMarkerRow(mon)
|
||||
local marker = markerRow and self:heldMarkerImage() or nil
|
||||
local paint
|
||||
G.setColor(1, 1, 1, 1)
|
||||
-- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355)
|
||||
-- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list,
|
||||
-- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229).
|
||||
local pals = self.palettes and self.palettes.partyMenu
|
||||
local colors = pals and pals[1] or nil
|
||||
local shaded = colors and GbcPalette.available()
|
||||
local previous
|
||||
if shaded then
|
||||
-- GbcPalette.with without the closure: set, draw, restore.
|
||||
previous = G.getShader and G.getShader() or nil
|
||||
GbcPalette.use(colors)
|
||||
end
|
||||
if marker then
|
||||
-- The _WITH_ITEM / _WITH_MAIL OAM sets (data/sprite_anims/oam.asm) are the
|
||||
-- ordinary four quadrants with the `dbsprite -1, 0` entry -- the bottom
|
||||
@@ -886,31 +916,19 @@ function PartyMenu:drawIcon(mon, px, py)
|
||||
-- Both of the frameset's oamframes name the same marker tile, so it must
|
||||
-- not be indexed by `frame`: the icon bobs, the marker does not.
|
||||
local mw, mh = marker:getDimensions()
|
||||
local topLeft = G.newQuad(0, frame * 16, 8, 8, iw, ih)
|
||||
local topRight = G.newQuad(8, frame * 16, 8, 8, iw, ih)
|
||||
local bottomRight = G.newQuad(8, frame * 16 + 8, 8, 8, iw, ih)
|
||||
local held = G.newQuad(0, markerRow * 8, 8, 8, mw, mh)
|
||||
paint = function()
|
||||
G.draw(image, topLeft, px, py)
|
||||
G.draw(image, topRight, px + 8, py)
|
||||
G.draw(image, bottomRight, px + 8, py + 8)
|
||||
G.draw(marker, held, px, py + 8)
|
||||
end
|
||||
G.draw(image, reusedQuad(self, "topLeft", 0, frame * 16, 8, 8, iw, ih),
|
||||
px, py)
|
||||
G.draw(image, reusedQuad(self, "topRight", 8, frame * 16, 8, 8, iw, ih),
|
||||
px + 8, py)
|
||||
G.draw(image, reusedQuad(self, "bottomRight", 8, frame * 16 + 8, 8, 8,
|
||||
iw, ih), px + 8, py + 8)
|
||||
G.draw(marker, reusedQuad(self, "held", 0, markerRow * 8, 8, 8, mw, mh),
|
||||
px, py + 8)
|
||||
else
|
||||
local quad = G.newQuad(0, frame * 16, 16, 16, iw, ih)
|
||||
paint = function() G.draw(image, quad, px, py) end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
-- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355)
|
||||
-- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list,
|
||||
-- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229).
|
||||
local pals = self.palettes and self.palettes.partyMenu
|
||||
local colors = pals and pals[1] or nil
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, paint)
|
||||
else
|
||||
paint()
|
||||
G.draw(image, reusedQuad(self, "whole", 0, frame * 16, 16, 16, iw, ih),
|
||||
px, py)
|
||||
end
|
||||
if shaded then G.setShader(previous) end
|
||||
end
|
||||
|
||||
-- PlacePartyHPBar calls DrawBattleHPBar (home/pokemon.asm) with `ld d, $6`, and
|
||||
|
||||
@@ -1919,6 +1919,10 @@ end
|
||||
local SPACE_TILE = 0x7f
|
||||
|
||||
function Pokegear:tile(id, tx, ty)
|
||||
-- A flat cell draws under the caller's shader, not a batched tile palette.
|
||||
if (id == SPACE_TILE or id == BLANK_TILE) and self.sheet then
|
||||
self.sheet:suspend()
|
||||
end
|
||||
if id == SPACE_TILE then
|
||||
local paper = self:paperColor()
|
||||
local G = love.graphics
|
||||
@@ -1938,8 +1942,7 @@ function Pokegear:tile(id, tx, ty)
|
||||
if self.sheet then self.sheet:draw(id, tx, ty) end
|
||||
end
|
||||
|
||||
function Pokegear:drawTilemap(cells)
|
||||
if not cells then return end
|
||||
local function drawTilemapCells(self, cells)
|
||||
for index = 1, SCREEN_W * SCREEN_H do
|
||||
local tile = cells[index]
|
||||
if tile then
|
||||
@@ -1949,6 +1952,18 @@ function Pokegear:drawTilemap(cells)
|
||||
end
|
||||
end
|
||||
|
||||
-- 360 cells a frame: batched so the palette shader is set once per run of
|
||||
-- same-palette tiles rather than once per tile (TileSheet:begin).
|
||||
function Pokegear:drawTilemap(cells)
|
||||
if not cells then return end
|
||||
local sheet = self.sheet
|
||||
if not sheet then return drawTilemapCells(self, cells) end
|
||||
sheet:begin()
|
||||
local ok, err = pcall(drawTilemapCells, self, cells)
|
||||
sheet:finish()
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- Pokegear_FinishTilemap.
|
||||
-- PokegearSpritesGFX, shared by the mode indicator arrow ($00) and the town
|
||||
-- map cursor ($04). Loaded once and remembered as `false` when there is no
|
||||
|
||||
@@ -1126,6 +1126,25 @@ function SummaryMenu:drawEggPic()
|
||||
self:drawEggIconFallback(colors)
|
||||
end
|
||||
|
||||
-- One reusable quad per role, re-aimed per draw rather than a new Quad each
|
||||
-- frame (which churns the GC). nil only when the backend cannot make one.
|
||||
local function reusedQuad(owner, slot, x, y, w, h, sw, sh)
|
||||
local quads = owner.reusedQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
owner.reusedQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
return quad
|
||||
end
|
||||
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
|
||||
if not ok then return nil end
|
||||
quads[slot] = made
|
||||
return made
|
||||
end
|
||||
|
||||
-- The ICON_EGG sheet is two 16x16 frames stacked into one 16x32 image
|
||||
-- (src/ui/gen2/PartyMenu.lua reads the same entry); the first frame is the
|
||||
-- egg at rest, which is the one the party list shows while nothing is moving.
|
||||
@@ -1141,9 +1160,9 @@ function SummaryMenu:drawEggIconFallback(colors)
|
||||
local w = entry.width or 16
|
||||
local h = math.min(entry.height or 16, image:getHeight())
|
||||
if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end
|
||||
local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h,
|
||||
local quad = reusedQuad(self, "egg", 0, 0, w, h,
|
||||
image:getWidth(), image:getHeight())
|
||||
if not ok then return end
|
||||
if not quad then return end
|
||||
-- Centred in the block at 2x: a 16x16 icon inside 7x7 tiles.
|
||||
local x = math.floor((7 * 8 - w * 2) / 2)
|
||||
local y = math.floor((7 * 8 - h * 2) / 2)
|
||||
|
||||
@@ -77,19 +77,52 @@ function TileSheet:draw(tile, tx, ty)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local colors = self.palette
|
||||
if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end
|
||||
local function body() G.draw(image, quad, tx * 8, ty * 8) end
|
||||
local batch = self.batch
|
||||
if colors and GbcPalette.available() then
|
||||
if self.raw then
|
||||
GbcPalette.withRaw(colors, body)
|
||||
if batch then
|
||||
-- Inside begin/finish: the shader only changes when the palette does.
|
||||
if batch.active ~= colors then
|
||||
if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end
|
||||
batch.active = colors
|
||||
end
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
else
|
||||
GbcPalette.with(colors, body)
|
||||
-- GbcPalette.with / withRaw without the closure: set, draw, restore.
|
||||
local previous = G.getShader and G.getShader() or nil
|
||||
if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
G.setShader(previous)
|
||||
end
|
||||
else
|
||||
body()
|
||||
if batch then self:suspend() end
|
||||
G.draw(image, quad, tx * 8, ty * 8)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Batch a pass of draws: between begin and finish the palette shader is set
|
||||
-- once per run of tiles sharing a palette rather than once per tile, and the
|
||||
-- caller's shader comes back at finish. Anything drawn between two tiles
|
||||
-- that is not a sheet tile (a flat rect, text) calls suspend first so it
|
||||
-- draws under the caller's shader, as it would have outside the batch.
|
||||
function TileSheet:begin()
|
||||
local G = love.graphics
|
||||
self.batch = { previous = G.getShader and G.getShader() or nil, active = nil }
|
||||
end
|
||||
|
||||
function TileSheet:suspend()
|
||||
local batch = self.batch
|
||||
if batch and batch.active then
|
||||
love.graphics.setShader(batch.previous)
|
||||
batch.active = nil
|
||||
end
|
||||
end
|
||||
|
||||
function TileSheet:finish()
|
||||
self:suspend()
|
||||
self.batch = nil
|
||||
end
|
||||
|
||||
-- A run of consecutive ids, left to right: the shape most of these routines
|
||||
-- use for a header strip or a caption.
|
||||
function TileSheet:run(first, count, tx, ty)
|
||||
|
||||
@@ -831,7 +831,13 @@ function TradeAnimView:drawIcon(record, x, y)
|
||||
end
|
||||
if not cached then return end
|
||||
local G = love.graphics
|
||||
local quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions())
|
||||
-- One quad per icon sheet, cut once rather than every frame.
|
||||
self.iconQuads = self.iconQuads or {}
|
||||
local quad = self.iconQuads[cached]
|
||||
if not quad then
|
||||
quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions())
|
||||
self.iconQuads[cached] = quad
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local colors = Palettes.monColors(self.palettes, record.species,
|
||||
record.shiny)
|
||||
|
||||
+16
-3
@@ -56,16 +56,22 @@ end
|
||||
|
||||
-- ../pokecrystal/home/text.asm:520 _ContTextNoPause
|
||||
function Typer:start(page)
|
||||
local out, total, kept = {}, 0, 0
|
||||
local out, spans, total, kept = {}, {}, 0, 0
|
||||
local scrolled = type(page) == "table" and page.scrolled
|
||||
for i, line in ipairs(linesOf(page)) do
|
||||
local text = self.expand and self.expand(line) or line
|
||||
out[i] = text
|
||||
local n = #spansOf(text)
|
||||
-- Split once here: lines() runs every frame while the page types out.
|
||||
local lineSpans = spansOf(text)
|
||||
spans[i] = lineSpans
|
||||
local n = #lineSpans
|
||||
total = total + n
|
||||
if scrolled and i == 1 then kept = n end
|
||||
end
|
||||
self.page = out
|
||||
self.spans = spans
|
||||
self.spansPage = out
|
||||
self.linesOut, self.linesShown = nil, nil
|
||||
self.total = total
|
||||
self.shown = self.instant and total or kept
|
||||
self.timer = 0
|
||||
@@ -108,9 +114,16 @@ end
|
||||
|
||||
function Typer:lines()
|
||||
if self:done() then return self.page end
|
||||
-- The same partial page is asked for every frame between two letters.
|
||||
if self.linesOut and self.linesShown == self.shown
|
||||
and self.linesPage == self.page then
|
||||
return self.linesOut
|
||||
end
|
||||
local cached = self.spansPage == self.page and self.spans or nil
|
||||
local out, left = {}, self.shown
|
||||
self.linesOut, self.linesShown, self.linesPage = out, self.shown, self.page
|
||||
for i, line in ipairs(self.page) do
|
||||
local spans = spansOf(line)
|
||||
local spans = cached and cached[i] or spansOf(line)
|
||||
if left >= #spans then
|
||||
out[i] = line
|
||||
elseif left <= 0 then
|
||||
|
||||
@@ -438,6 +438,25 @@ function UnownPuzzle:fillCell(cell, tile)
|
||||
end
|
||||
end
|
||||
|
||||
-- One reusable quad per role, re-aimed per draw rather than a new Quad each
|
||||
-- frame (which churns the GC). nil only when the backend cannot make one.
|
||||
local function reusedQuad(owner, slot, x, y, w, h, sw, sh)
|
||||
local quads = owner.reusedQuads
|
||||
if not quads then
|
||||
quads = {}
|
||||
owner.reusedQuads = quads
|
||||
end
|
||||
local quad = quads[slot]
|
||||
if quad then
|
||||
quad:setViewport(x, y, w, h, sw, sh)
|
||||
return quad
|
||||
end
|
||||
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
|
||||
if not ok then return nil end
|
||||
quads[slot] = made
|
||||
return made
|
||||
end
|
||||
|
||||
-- .Corners is `piece -> corner tile` on a 12-tile-wide sheet; as a quad that is
|
||||
-- just the panel's row and column in the 4x4 picture.
|
||||
function UnownPuzzle:drawPiece(piece, tx, ty)
|
||||
@@ -447,7 +466,7 @@ function UnownPuzzle:drawPiece(piece, tx, ty)
|
||||
local sx = (index % UnownPuzzle.PIECES_WIDE) * size
|
||||
local sy = math.floor(index / UnownPuzzle.PIECES_WIDE) * size
|
||||
local w, h = self.picture:getDimensions()
|
||||
local quad = love.graphics.newQuad(sx, sy, size, size, w, h)
|
||||
local quad = reusedQuad(self, "piece", sx, sy, size, size, w, h)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- The four pictures are drawn out of colours 1 and 2 alone, so binding BG
|
||||
-- palette 0 here is what turns the panels tan and dark brown.
|
||||
@@ -496,7 +515,7 @@ function UnownPuzzle:drawCursor(tx, ty)
|
||||
for _, spec in ipairs(CURSOR_CELLS) do
|
||||
local col, tile, row, flipX, flipY = spec[1], spec[2], spec[3], spec[4],
|
||||
spec[5]
|
||||
local quad = love.graphics.newQuad(tile * 8, 0, 8, 8, w, h)
|
||||
local quad = reusedQuad(self, "cursor", tile * 8, 0, 8, 8, w, h)
|
||||
local sx, sy = flipX and -1 or 1, flipY and -1 or 1
|
||||
local ox = (tx + col) * 8 + (flipX and 8 or 0)
|
||||
local oy = (ty + row) * 8 + (flipY and 8 or 0)
|
||||
|
||||
+396
-313
@@ -63,6 +63,9 @@ OverworldState.withOverrideMirror = withOverrideMirror
|
||||
|
||||
local Game -- set on enter (avoids circular require at load time)
|
||||
|
||||
-- handleInput's d-pad poll order (a constant: it runs every frame)
|
||||
local INPUT_DIRS = { "up", "down", "left", "right" }
|
||||
|
||||
local mapScripts -- registry of hand-ported map scripts
|
||||
|
||||
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
||||
@@ -535,7 +538,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
elseif fromMapId ~= mapId then
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
local npc = self.npcPool[mapId .. "_obj_" .. obj.index]
|
||||
if npc then npc.pendingSpawnReset = true end
|
||||
if npc then
|
||||
npc.pendingSpawnReset = true
|
||||
-- lets applyPendingSpawnResets skip its whole-pool scan while
|
||||
-- nothing is armed (it runs every frame)
|
||||
self.spawnResetsArmed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
self.npcs = {}
|
||||
@@ -794,6 +802,9 @@ end
|
||||
-- the deferred half of the seam re-seed armed in setMap (#1755): the cart's
|
||||
-- connected map has no sprites to be seen snapping -- home/overworld.asm:2133
|
||||
function OverworldState:applyPendingSpawnResets()
|
||||
-- setMap is the only place that arms a re-seed; nothing armed since the
|
||||
-- last scan came up empty -> no pool walk this frame
|
||||
if not self.spawnResetsArmed then return end
|
||||
local pool = self.npcPool
|
||||
if not (pool and self.camera) then return end
|
||||
local due
|
||||
@@ -803,7 +814,10 @@ function OverworldState:applyPendingSpawnResets()
|
||||
due[npc] = true
|
||||
end
|
||||
end
|
||||
if not due then return end
|
||||
if not due then
|
||||
self.spawnResetsArmed = nil
|
||||
return
|
||||
end
|
||||
local cam = self.camera
|
||||
local vw, vh = Game.renderer:worldViewSize()
|
||||
local function onCamera(px, py)
|
||||
@@ -1545,7 +1559,6 @@ function OverworldState:update(dt)
|
||||
or (self.hopLand or 0) > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive or self.spinArrive
|
||||
or self.holeFall or self.holeArrive
|
||||
or self.holeFall or self.holeArrive
|
||||
or self.cutAnim
|
||||
or (self.dustAnim and self.dustAnim.boulder)
|
||||
@@ -1719,7 +1732,7 @@ function OverworldState:handleInput()
|
||||
return
|
||||
end
|
||||
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
for _, dir in ipairs(INPUT_DIRS) do
|
||||
if input:isDown(dir) then
|
||||
if not self.player.moving and self.player.facing == dir then
|
||||
if self:checkEdgeExit(dir) then return end
|
||||
@@ -1785,13 +1798,25 @@ function OverworldState:handleInput()
|
||||
end
|
||||
|
||||
-- JoypadOverworld both make -- home/overworld.asm:382
|
||||
-- slopeMaps list -> { n = #list, ids = { [mapId] = true } }: update() asks
|
||||
-- every frame, so the list is indexed once instead of scanned per call.
|
||||
-- Keyed weakly on the list itself (a data reload brings a new one) and
|
||||
-- rebuilt if its length changes.
|
||||
local slopeSets = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function OverworldState:onSlopeMap(mapId)
|
||||
local fm = Game.data.field.forcedMovement
|
||||
mapId = mapId or (self.map and self.map.id)
|
||||
for _, m in ipairs((fm and fm.slopeMaps) or {}) do
|
||||
if m == mapId then return true end
|
||||
local list = fm and fm.slopeMaps
|
||||
if not list or mapId == nil then return false end
|
||||
local entry = slopeSets[list]
|
||||
if not entry or entry.n ~= #list then
|
||||
local ids = {}
|
||||
for _, m in ipairs(list) do ids[m] = true end
|
||||
entry = { n = #list, ids = ids }
|
||||
slopeSets[list] = entry
|
||||
end
|
||||
return false
|
||||
return entry.ids[mapId] == true
|
||||
end
|
||||
|
||||
-- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder):
|
||||
@@ -5829,7 +5854,7 @@ end
|
||||
-- sprite through the tile's white gaps) over the plain one (sprites, FX
|
||||
-- overlays). drawFn issues the actual draws in flat world-canvas coordinates;
|
||||
-- the transform just slides them from the flat foot onto the projected anchor.
|
||||
function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn, ...)
|
||||
local sx, sy = Tilt.groundPoint(fx, fy, vw, vh)
|
||||
local shader = colors and (keyed and PaletteFX.keyedShader()
|
||||
or PaletteFX.shader()) or nil
|
||||
@@ -5839,11 +5864,321 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
end
|
||||
love.graphics.push()
|
||||
love.graphics.translate(sx - fx, sy - fy)
|
||||
drawFn()
|
||||
drawFn(...) -- extra billboard args (the FX bodies take self, cam)
|
||||
love.graphics.pop()
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
|
||||
-- === shared FX draw bodies ==========================================
|
||||
-- Each draws at flat world-canvas offsets; the tilt path wraps the
|
||||
-- standing ones in an upright billboard, the flat path calls them inline
|
||||
-- in their historical order. (Bodies are byte-identical to the pre-tilt
|
||||
-- inline code, so the flat draw sequence is unchanged.) Module-level and
|
||||
-- handed (self, cam) so drawWorld does not build six closures per frame;
|
||||
-- the pipeline path wraps them for its no-argument ctx.fx contract.
|
||||
|
||||
-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor
|
||||
-- tile over the machine's screen and one ball per healed mon in two
|
||||
-- mirrored columns, all blinking during the jingle flash. The GB
|
||||
-- draws it at fixed screen coords with the player's cell BG-aligned
|
||||
-- at (64,64); anchoring those coords to where the player stood keeps
|
||||
-- the overlay on the machine at any zoom.
|
||||
local function fxHeal(self, cam)
|
||||
if not self.healAnim then return end
|
||||
local ha = self.healAnim
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
|
||||
local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path)
|
||||
self.healMachineImg = ok and img or false
|
||||
end
|
||||
local img = self.healMachineImg
|
||||
if img then
|
||||
if not self.healMachineQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.healMachineQuads = {
|
||||
love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c)
|
||||
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
|
||||
}
|
||||
end
|
||||
local shader, redrawColors = healMachineShader(ha.visible)
|
||||
local mark = redrawColors and PaletteFX.spriteRedrawPassActive()
|
||||
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
||||
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
|
||||
-- the balls a pixel off the machine tiles
|
||||
local ox = ha.px - 64 - math.floor(cam.x)
|
||||
local oy = ha.py - 64 - math.floor(cam.y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1],
|
||||
ox + 44, oy + 20, 1, redrawColors)
|
||||
end
|
||||
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
||||
local b = HEAL_BALL_XY[i]
|
||||
if b[3] then -- right column: OAM_XFLIP
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], 0, -1, 1)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], -1, redrawColors)
|
||||
end
|
||||
else
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2])
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2], 1, redrawColors)
|
||||
end
|
||||
end
|
||||
end
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
end
|
||||
|
||||
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
|
||||
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
|
||||
local function fxDust(self, cam)
|
||||
if not self.dustAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local smoke = fxDef and fxDef.smoke
|
||||
if smoke then
|
||||
if self.smokeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, smoke.path)
|
||||
self.smokeImg = ok and img or false
|
||||
end
|
||||
if self.smokeImg then
|
||||
local da = self.dustAnim
|
||||
local dx = da.x * 16 + (da.ox or 0) - cam.x
|
||||
local dy = da.y * 16 + (da.oy or 0) - cam.y
|
||||
local flicker = math.floor(da.frames / 4) % 2 == 0
|
||||
if da.boulder then flicker = not da.faded end
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
for i = 0, 1 do
|
||||
for j = 0, 1 do
|
||||
love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the cut tree splitting apart (AnimCut): top half slides right,
|
||||
-- bottom half slides left, 1px per frame, flickering as they go
|
||||
local function fxCutTree(self, cam)
|
||||
if not self.cutAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local tree = fxDef and fxDef.cutTree
|
||||
if not tree then return end
|
||||
if self.cutTreeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, tree.path)
|
||||
self.cutTreeImg = ok and img or false
|
||||
end
|
||||
local img = self.cutTreeImg
|
||||
if not img then return end
|
||||
if not self.cutTreeQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.cutTreeQuads = {
|
||||
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
|
||||
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
|
||||
}
|
||||
end
|
||||
local ca = self.cutAnim
|
||||
local off = (ca.total or 8) - ca.frames
|
||||
local dx = ca.x * 16 - cam.x
|
||||
local dy = ca.y * 16 - cam.y
|
||||
local flicker = ca.frames % 2 == 0
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
|
||||
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- the "!" bubble above a trainer who spotted the player
|
||||
local function fxEmote(self, cam)
|
||||
if not (self.emote and self.emote.npc) then return end
|
||||
-- bubble = false is a silent hold (a Pikachu emotion that plays a
|
||||
-- cry with no bubble still pauses the world for its beat)
|
||||
if self.emote.bubble == false then return end
|
||||
local npc = self.emote.npc
|
||||
-- engine/overworld/emotion_bubbles.asm:41
|
||||
local ex = npc.px - cam.x
|
||||
local ey = npc.py - cam.y - 20
|
||||
local bubble = Game.data.field.emotionBubbles
|
||||
local drawn = false
|
||||
if bubble and bubble.path then
|
||||
local ok, img = pcall(function()
|
||||
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
||||
return self.emoteImg
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
-- picks question/happy crops instead
|
||||
local bi = self.emote.bubble or 1
|
||||
local rect = bubble.bubbles and bubble.bubbles[bi]
|
||||
if ok and img and rect then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- one Quad per bubble crop, cached: this draws every frame the "!"
|
||||
-- (or the emote-command crops) is up, so a fresh Quad here churned
|
||||
-- the GC. The bubble set is small and fixed, so the cache is bounded.
|
||||
self.emoteQuads = self.emoteQuads or {}
|
||||
local q = self.emoteQuads[bi]
|
||||
if not q then
|
||||
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
||||
img:getDimensions())
|
||||
self.emoteQuads[bi] = q
|
||||
end
|
||||
love.graphics.draw(img, q, ex, ey)
|
||||
-- engine/overworld/emotion_bubbles.asm:18
|
||||
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
PaletteFX.markSpriteRedraw(img, q, ex, ey, 1)
|
||||
end
|
||||
drawn = true
|
||||
end
|
||||
end
|
||||
if not drawn then
|
||||
local Font = require("src.render.Font")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", ex, ey, 10, 12)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12)
|
||||
Font.draw("!", ex + 1, ey + 2)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- the FLY bird sweeping off with the player
|
||||
local function fxBird(self, cam)
|
||||
local anim = self.flyAnim or self.flyArrive
|
||||
if not anim then return end
|
||||
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
|
||||
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
self.birdSprite = SR.new(Game.data.sprites[birdId])
|
||||
end
|
||||
if not self.birdSprite then return end
|
||||
-- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
|
||||
-- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
|
||||
-- the flight rides any screen position, and it faces its travel
|
||||
-- direction (rightward travel flips the left-drawn sheet)
|
||||
local phase = anim.phase or "arrive"
|
||||
if phase == "hold" then return end -- parked off screen between paths
|
||||
local path, anchor, facing
|
||||
if phase == "path1" then
|
||||
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
|
||||
elseif phase == "path2" then
|
||||
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
|
||||
elseif phase == "arrive" then
|
||||
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
|
||||
end
|
||||
local step = math.floor(anim.t / 3)
|
||||
local sx, sy
|
||||
if path then
|
||||
local pair = path[math.min(#path, step + 1)]
|
||||
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
|
||||
else
|
||||
sx, sy = 0, 0 -- the in-place flap sits on the player
|
||||
facing = "right"
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
|
||||
cam.x, cam.y, facing, step % 2, false)
|
||||
end
|
||||
|
||||
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
|
||||
local function fxRod(self, cam)
|
||||
if not self.fishing then return end
|
||||
-- engine/overworld/player_animations.asm:424
|
||||
if self.fishing.hideRod then return end
|
||||
local fx = Game.data.field.overworldFx
|
||||
local rod = fx and fx.fishingRod
|
||||
if rod then
|
||||
if self.rodImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, rod.path)
|
||||
self.rodImg = ok and img or false
|
||||
end
|
||||
if self.rodImg then
|
||||
local p = self.player
|
||||
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
|
||||
if not self.rodQuads then
|
||||
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
|
||||
local iw, ih = self.rodImg:getDimensions()
|
||||
self.rodQuads = {}
|
||||
for i = 0, math.floor(ih / 8) - 1 do
|
||||
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
|
||||
end
|
||||
end
|
||||
local quad = self.rodQuads[oam.tile]
|
||||
-- Place the rod against the active sprite's anchored top-left. The
|
||||
-- vanilla result is still (px-cam, py-cam-4), while custom larger
|
||||
-- sheets keep the rod attached to their feet.
|
||||
-- Fishing always uses the on-foot player sheet; read its fields
|
||||
-- directly so this FX pass does not advance pose-side animation.
|
||||
local sprite, px, py = p.sprite, p.px, p.py
|
||||
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
|
||||
local rx = sx + oam.dx
|
||||
-- engine/overworld/player_animations.asm:453
|
||||
local ry = sy + oam.dy + (p.fishShakeDy or 0)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if quad and oam.flip then
|
||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||
elseif quad then
|
||||
love.graphics.draw(self.rodImg, quad, rx, ry)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- drawWorld's y-sort orders, hoisted so a frame builds no comparators
|
||||
local function ghostDrawOrder(a, b)
|
||||
return a.npc.py + a.oy < b.npc.py + b.oy
|
||||
end
|
||||
|
||||
local function entityDrawOrder(a, b)
|
||||
if a.py ~= b.py then return a.py < b.py end
|
||||
-- a fresh warp spawn parks the follower on the player's own cell
|
||||
-- until it trails out; the tie must draw it under him, never on
|
||||
-- top (#863)
|
||||
return a.pikachuFollower == true and b.pikachuFollower ~= true
|
||||
end
|
||||
|
||||
-- Tall-grass feet overdraw queue for the flat path: each distinct grass
|
||||
-- cell an entity stands on or steps into, in first-queued order. A
|
||||
-- scratch pair of coordinate arrays reused every frame (the queue holds a
|
||||
-- few cells, so the duplicate check is a short linear scan) instead of a
|
||||
-- "cx:cy" string key and a { cx, cy } table per cell per frame.
|
||||
local function queueGrass(self, q, cx, cy)
|
||||
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
|
||||
return
|
||||
end
|
||||
local n = q.n
|
||||
local xs, ys = q.x, q.y
|
||||
for i = 1, n do
|
||||
if xs[i] == cx and ys[i] == cy then return end
|
||||
end
|
||||
n = n + 1
|
||||
xs[n], ys[n] = cx, cy
|
||||
q.n = n
|
||||
end
|
||||
|
||||
-- Is a neighbour map's ghost NPC wholly outside the vw x vh view? The
|
||||
-- rect is the sprite's frame at its anchored origin, padded by a tile on
|
||||
-- every side (hop shadows, multi-part cells), so only NPCs that could not
|
||||
-- put a pixel on screen are skipped.
|
||||
local GHOST_CULL_PAD = 16
|
||||
local function ghostOffscreen(g, camX, camY, vw, vh)
|
||||
local npc = g.npc
|
||||
local sprite = npc and npc.sprite
|
||||
if not (sprite and sprite.getScreenOrigin and vw and vh) then
|
||||
return false
|
||||
end
|
||||
local sx, sy = sprite:getScreenOrigin(npc.px, npc.py, camX, camY)
|
||||
local fw = sprite.frameWidth or 16
|
||||
local fh = sprite.frameHeight or 16
|
||||
local pad = GHOST_CULL_PAD
|
||||
return sx + fw + pad < 0 or sy + fh + pad < 0
|
||||
or sx - pad > vw or sy - pad > vh
|
||||
end
|
||||
|
||||
function OverworldState:drawWorld()
|
||||
-- Dark-map BG shade shift, armed for the whole frame before anything draws.
|
||||
-- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the
|
||||
@@ -5908,11 +6243,12 @@ function OverworldState:drawWorld()
|
||||
-- advance the water/flower tile animation (runs under dialogs too).
|
||||
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
|
||||
-- does not speed or slow the cycle (issue #4).
|
||||
require("src.render.TileRenderer").tick()
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick()
|
||||
-- let the renderer know whether a spinner puzzle is currently sliding
|
||||
-- the player, so it can flicker the arrow tiles between the blur and
|
||||
-- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles)
|
||||
require("src.render.TileRenderer").setSpinning(self.spinnerSliding or false)
|
||||
TileRenderer.setSpinning(self.spinnerSliding or false)
|
||||
local cam = self.camera
|
||||
-- ShakeElevator's oscillation (engine/overworld/elevator.asm) writes
|
||||
-- hSCY, which scrolls the BG layer only -- tiles bounce while OAM
|
||||
@@ -5957,272 +6293,8 @@ function OverworldState:drawWorld()
|
||||
local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil
|
||||
|
||||
-- ghost NPCs on neighbor maps, y-sorted among themselves
|
||||
table.sort(self.ghosts,
|
||||
function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end)
|
||||
table.sort(self.entities, function(a, b)
|
||||
if a.py ~= b.py then return a.py < b.py end
|
||||
-- a fresh warp spawn parks the follower on the player's own cell
|
||||
-- until it trails out; the tie must draw it under him, never on
|
||||
-- top (#863)
|
||||
return a.pikachuFollower == true and b.pikachuFollower ~= true
|
||||
end)
|
||||
|
||||
-- === shared FX draw bodies ==========================================
|
||||
-- Each draws at flat world-canvas offsets; the tilt path wraps the
|
||||
-- standing ones in an upright billboard, the flat path calls them inline
|
||||
-- in their historical order. (Bodies are byte-identical to the pre-tilt
|
||||
-- inline code, so the flat draw sequence is unchanged.)
|
||||
|
||||
-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor
|
||||
-- tile over the machine's screen and one ball per healed mon in two
|
||||
-- mirrored columns, all blinking during the jingle flash. The GB
|
||||
-- draws it at fixed screen coords with the player's cell BG-aligned
|
||||
-- at (64,64); anchoring those coords to where the player stood keeps
|
||||
-- the overlay on the machine at any zoom.
|
||||
local function fxHeal()
|
||||
if not self.healAnim then return end
|
||||
local ha = self.healAnim
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
|
||||
local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path)
|
||||
self.healMachineImg = ok and img or false
|
||||
end
|
||||
local img = self.healMachineImg
|
||||
if img then
|
||||
if not self.healMachineQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.healMachineQuads = {
|
||||
love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c)
|
||||
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
|
||||
}
|
||||
end
|
||||
local shader, redrawColors = healMachineShader(ha.visible)
|
||||
local mark = redrawColors and PaletteFX.spriteRedrawPassActive()
|
||||
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
||||
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
|
||||
-- the balls a pixel off the machine tiles
|
||||
local ox = ha.px - 64 - math.floor(cam.x)
|
||||
local oy = ha.py - 64 - math.floor(cam.y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1],
|
||||
ox + 44, oy + 20, 1, redrawColors)
|
||||
end
|
||||
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
||||
local b = HEAL_BALL_XY[i]
|
||||
if b[3] then -- right column: OAM_XFLIP
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], 0, -1, 1)
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1] + 8, oy + b[2], -1, redrawColors)
|
||||
end
|
||||
else
|
||||
love.graphics.draw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2])
|
||||
if mark then
|
||||
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
|
||||
ox + b[1], oy + b[2], 1, redrawColors)
|
||||
end
|
||||
end
|
||||
end
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
end
|
||||
|
||||
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
|
||||
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
|
||||
local function fxDust()
|
||||
if not self.dustAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local smoke = fxDef and fxDef.smoke
|
||||
if smoke then
|
||||
if self.smokeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, smoke.path)
|
||||
self.smokeImg = ok and img or false
|
||||
end
|
||||
if self.smokeImg then
|
||||
local da = self.dustAnim
|
||||
local dx = da.x * 16 + (da.ox or 0) - cam.x
|
||||
local dy = da.y * 16 + (da.oy or 0) - cam.y
|
||||
local flicker = math.floor(da.frames / 4) % 2 == 0
|
||||
if da.boulder then flicker = not da.faded end
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
for i = 0, 1 do
|
||||
for j = 0, 1 do
|
||||
love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the cut tree splitting apart (AnimCut): top half slides right,
|
||||
-- bottom half slides left, 1px per frame, flickering as they go
|
||||
local function fxCutTree()
|
||||
if not self.cutAnim then return end
|
||||
local fxDef = Game.data.field.overworldFx
|
||||
local tree = fxDef and fxDef.cutTree
|
||||
if not tree then return end
|
||||
if self.cutTreeImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, tree.path)
|
||||
self.cutTreeImg = ok and img or false
|
||||
end
|
||||
local img = self.cutTreeImg
|
||||
if not img then return end
|
||||
if not self.cutTreeQuads then
|
||||
local w, h = img:getWidth(), img:getHeight()
|
||||
self.cutTreeQuads = {
|
||||
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
|
||||
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
|
||||
}
|
||||
end
|
||||
local ca = self.cutAnim
|
||||
local off = (ca.total or 8) - ca.frames
|
||||
local dx = ca.x * 16 - cam.x
|
||||
local dy = ca.y * 16 - cam.y
|
||||
local flicker = ca.frames % 2 == 0
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
|
||||
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- the "!" bubble above a trainer who spotted the player
|
||||
local function fxEmote()
|
||||
if not (self.emote and self.emote.npc) then return end
|
||||
-- bubble = false is a silent hold (a Pikachu emotion that plays a
|
||||
-- cry with no bubble still pauses the world for its beat)
|
||||
if self.emote.bubble == false then return end
|
||||
local npc = self.emote.npc
|
||||
-- engine/overworld/emotion_bubbles.asm:41
|
||||
local ex = npc.px - cam.x
|
||||
local ey = npc.py - cam.y - 20
|
||||
local bubble = Game.data.field.emotionBubbles
|
||||
local drawn = false
|
||||
if bubble and bubble.path then
|
||||
local ok, img = pcall(function()
|
||||
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
||||
return self.emoteImg
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
-- picks question/happy crops instead
|
||||
local bi = self.emote.bubble or 1
|
||||
local rect = bubble.bubbles and bubble.bubbles[bi]
|
||||
if ok and img and rect then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- one Quad per bubble crop, cached: this draws every frame the "!"
|
||||
-- (or the emote-command crops) is up, so a fresh Quad here churned
|
||||
-- the GC. The bubble set is small and fixed, so the cache is bounded.
|
||||
self.emoteQuads = self.emoteQuads or {}
|
||||
local q = self.emoteQuads[bi]
|
||||
if not q then
|
||||
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
||||
img:getDimensions())
|
||||
self.emoteQuads[bi] = q
|
||||
end
|
||||
love.graphics.draw(img, q, ex, ey)
|
||||
-- engine/overworld/emotion_bubbles.asm:18
|
||||
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
PaletteFX.markSpriteRedraw(img, q, ex, ey, 1)
|
||||
end
|
||||
drawn = true
|
||||
end
|
||||
end
|
||||
if not drawn then
|
||||
local Font = require("src.render.Font")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", ex, ey, 10, 12)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12)
|
||||
Font.draw("!", ex + 1, ey + 2)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- the FLY bird sweeping off with the player
|
||||
local function fxBird()
|
||||
local anim = self.flyAnim or self.flyArrive
|
||||
if not anim then return end
|
||||
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
|
||||
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
self.birdSprite = SR.new(Game.data.sprites[birdId])
|
||||
end
|
||||
if not self.birdSprite then return end
|
||||
-- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
|
||||
-- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
|
||||
-- the flight rides any screen position, and it faces its travel
|
||||
-- direction (rightward travel flips the left-drawn sheet)
|
||||
local phase = anim.phase or "arrive"
|
||||
if phase == "hold" then return end -- parked off screen between paths
|
||||
local path, anchor, facing
|
||||
if phase == "path1" then
|
||||
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
|
||||
elseif phase == "path2" then
|
||||
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
|
||||
elseif phase == "arrive" then
|
||||
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
|
||||
end
|
||||
local step = math.floor(anim.t / 3)
|
||||
local sx, sy
|
||||
if path then
|
||||
local pair = path[math.min(#path, step + 1)]
|
||||
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
|
||||
else
|
||||
sx, sy = 0, 0 -- the in-place flap sits on the player
|
||||
facing = "right"
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
|
||||
cam.x, cam.y, facing, step % 2, false)
|
||||
end
|
||||
|
||||
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
|
||||
local function fxRod()
|
||||
if not self.fishing then return end
|
||||
-- engine/overworld/player_animations.asm:424
|
||||
if self.fishing.hideRod then return end
|
||||
local fx = Game.data.field.overworldFx
|
||||
local rod = fx and fx.fishingRod
|
||||
if rod then
|
||||
if self.rodImg == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, rod.path)
|
||||
self.rodImg = ok and img or false
|
||||
end
|
||||
if self.rodImg then
|
||||
local p = self.player
|
||||
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
|
||||
if not self.rodQuads then
|
||||
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
|
||||
local iw, ih = self.rodImg:getDimensions()
|
||||
self.rodQuads = {}
|
||||
for i = 0, math.floor(ih / 8) - 1 do
|
||||
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
|
||||
end
|
||||
end
|
||||
local quad = self.rodQuads[oam.tile]
|
||||
-- Place the rod against the active sprite's anchored top-left. The
|
||||
-- vanilla result is still (px-cam, py-cam-4), while custom larger
|
||||
-- sheets keep the rod attached to their feet.
|
||||
-- Fishing always uses the on-foot player sheet; read its fields
|
||||
-- directly so this FX pass does not advance pose-side animation.
|
||||
local sprite, px, py = p.sprite, p.px, p.py
|
||||
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
|
||||
local rx = sx + oam.dx
|
||||
-- engine/overworld/player_animations.asm:453
|
||||
local ry = sy + oam.dy + (p.fishShakeDy or 0)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if quad and oam.flip then
|
||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||
elseif quad then
|
||||
love.graphics.draw(self.rodImg, quad, rx, ry)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(self.ghosts, ghostDrawOrder)
|
||||
table.sort(self.entities, entityDrawOrder)
|
||||
|
||||
if pipelineId then
|
||||
-- === PIPELINE PATH: a mod owns the world pass. ======================
|
||||
@@ -6234,6 +6306,13 @@ function OverworldState:drawWorld()
|
||||
-- copy of every effect: the closures above are the ones that run.
|
||||
local _, _, pw, ph = Game.renderer:playfieldRect()
|
||||
local pscale = Zoom.scale(Game.renderer:fitScale())
|
||||
-- the mod-facing ctx.fx draw bodies take no arguments
|
||||
local fxHealFn = function() return fxHeal(self, cam) end
|
||||
local fxDustFn = function() return fxDust(self, cam) end
|
||||
local fxCutTreeFn = function() return fxCutTree(self, cam) end
|
||||
local fxEmoteFn = function() return fxEmote(self, cam) end
|
||||
local fxBirdFn = function() return fxBird(self, cam) end
|
||||
local fxRodFn = function() return fxRod(self, cam) end
|
||||
local ctx = {
|
||||
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
||||
width = pw, height = ph, scale = pscale,
|
||||
@@ -6247,8 +6326,8 @@ function OverworldState:drawWorld()
|
||||
if PaletteFX.usesGbcPack() then return nil end
|
||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||
end,
|
||||
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
||||
emote = fxEmote, bird = fxBird, rod = fxRod },
|
||||
fx = { heal = fxHealFn, dust = fxDustFn, cutTree = fxCutTreeFn,
|
||||
emote = fxEmoteFn, bird = fxBirdFn, rod = fxRodFn },
|
||||
}
|
||||
-- Draw every active field FX into the finished scene. `project(wx, wy)`
|
||||
-- maps a world point to canvas pixels (nil when it is behind the
|
||||
@@ -6283,23 +6362,23 @@ function OverworldState:drawWorld()
|
||||
-- ground-hugging effects sit on the cell they belong to
|
||||
if self.dustAnim then
|
||||
local da = self.dustAnim
|
||||
at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
|
||||
at(fxDustFn, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
|
||||
end
|
||||
if self.cutAnim then
|
||||
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
|
||||
at(fxCutTreeFn, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
|
||||
end
|
||||
if self.healAnim then
|
||||
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
|
||||
at(fxHealFn, self.healAnim.px + 8, self.healAnim.py + 16)
|
||||
end
|
||||
-- standing effects anchor at the foot of whoever they belong to
|
||||
if self.emote and self.emote.npc then
|
||||
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
|
||||
at(fxEmoteFn, self.emote.npc.px + 8, self.emote.npc.py + 16)
|
||||
end
|
||||
if self.flyAnim then
|
||||
at(fxBird, self.player.px + 8, self.player.py + 16)
|
||||
at(fxBirdFn, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
if self.fishing then
|
||||
at(fxRod, self.player.px + 8, self.player.py + 16)
|
||||
at(fxRodFn, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -6337,44 +6416,45 @@ function OverworldState:drawWorld()
|
||||
local grassColors = PaletteFX.usesSpriteObp()
|
||||
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
|
||||
local boulderDust = self.dustAnim and self.dustAnim.boulder
|
||||
local grassCells, grassSeen = {}, {}
|
||||
local function queueGrass(cx, cy)
|
||||
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
|
||||
return
|
||||
end
|
||||
local key = tostring(cx) .. ":" .. tostring(cy)
|
||||
if grassSeen[key] then return end
|
||||
grassSeen[key] = true
|
||||
grassCells[#grassCells + 1] = { cx, cy }
|
||||
local grass = self.grassScratch
|
||||
if not grass then
|
||||
grass = { n = 0, x = {}, y = {} }
|
||||
self.grassScratch = grass
|
||||
end
|
||||
if boulderDust then fxDust() end
|
||||
grass.n = 0
|
||||
if boulderDust then fxDust(self, cam) end
|
||||
for _, g in ipairs(self.ghosts) do
|
||||
if self.battleOamKeep == nil then
|
||||
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
|
||||
local gx, gy = cam.x - g.ox, cam.y - g.oy
|
||||
if not ghostOffscreen(g, gx, gy, vw, vh) then
|
||||
g.npc:draw(gx, gy)
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, e in ipairs(self.entities) do
|
||||
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
|
||||
and e == self.player) and not self:oamCulled(e) then
|
||||
e:draw(cam.x, cam.y)
|
||||
queueGrass(e.cellX, e.cellY)
|
||||
queueGrass(e.targetX, e.targetY)
|
||||
queueGrass(self, grass, e.cellX, e.cellY)
|
||||
queueGrass(self, grass, e.targetX, e.targetY)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for _, cell in ipairs(grassCells) do
|
||||
self.map.renderer:drawCellBottom(cell[1], cell[2], cam.x, bgY)
|
||||
local gxs, gys = grass.x, grass.y
|
||||
for i = 1, grass.n do
|
||||
local cx, cy = gxs[i], gys[i]
|
||||
self.map.renderer:drawCellBottom(cx, cy, cam.x, bgY)
|
||||
if grassColors then
|
||||
self.map.renderer:markCellBottomRedraw(cell[1], cell[2],
|
||||
self.map.renderer:markCellBottomRedraw(cx, cy,
|
||||
cam.x, bgY, grassColors)
|
||||
end
|
||||
end
|
||||
fxHeal()
|
||||
if not boulderDust then fxDust() end
|
||||
fxCutTree()
|
||||
fxEmote()
|
||||
fxBird()
|
||||
fxRod()
|
||||
fxHeal(self, cam)
|
||||
if not boulderDust then fxDust(self, cam) end
|
||||
fxCutTree(self, cam)
|
||||
fxEmote(self, cam)
|
||||
fxBird(self, cam)
|
||||
fxRod(self, cam)
|
||||
else
|
||||
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
|
||||
-- standing things billboard upright over it in a separate pass. ======
|
||||
@@ -6384,9 +6464,9 @@ function OverworldState:drawWorld()
|
||||
-- draws them last, over the sprites, in the same canvas; here the two
|
||||
-- layers are separate and composited ground-under-upright, so drawing
|
||||
-- them now into the still-active ground canvas is order-equivalent.
|
||||
fxHeal()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
fxHeal(self, cam)
|
||||
fxDust(self, cam)
|
||||
fxCutTree(self, cam)
|
||||
|
||||
Game.renderer:beginUprightPass()
|
||||
|
||||
@@ -6451,17 +6531,20 @@ function OverworldState:drawWorld()
|
||||
if self.emote and self.emote.npc then
|
||||
local fx = self.emote.npc.px - cam.x + 8
|
||||
local fy = self.emote.npc.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote)
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote,
|
||||
self, cam)
|
||||
end
|
||||
if self.flyAnim or self.flyArrive then
|
||||
local fx = self.player.px - cam.x + 8
|
||||
local fy = self.player.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird)
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird,
|
||||
self, cam)
|
||||
end
|
||||
if self.fishing then
|
||||
local fx = self.player.px - cam.x + 8
|
||||
local fy = self.player.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod,
|
||||
self, cam)
|
||||
end
|
||||
|
||||
Game.renderer:endUprightPass()
|
||||
|
||||
+23
-1
@@ -275,6 +275,28 @@ function Player:walkPhase()
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
-- The surf bob is sampled by pose() (draw code) but must run at the fixed
|
||||
-- logic rate: it used to tick once per pose() call, so a 144Hz display
|
||||
-- bobbed 2.4x too fast and the battle-transition wipe, which draws the
|
||||
-- player twice a frame, doubled it. It now advances by the number of
|
||||
-- Game:step logic steps since the last pose(), i.e. exactly once per step at
|
||||
-- 60Hz and never twice for one step. Without a running Game (headless
|
||||
-- callers) there is no step clock and each call advances once, as before.
|
||||
function Player:advanceBob()
|
||||
local Game = package.loaded["src.core.Game"]
|
||||
local step = type(Game) == "table" and Game.logicStep or nil
|
||||
local ticks = 1
|
||||
if step then
|
||||
local last = self.bobStep
|
||||
ticks = last and step - last or 1
|
||||
self.bobStep = step
|
||||
end
|
||||
if ticks > 0 then
|
||||
self.bobTimer = ((self.bobTimer or 0) + ticks) % 32
|
||||
end
|
||||
return self.bobTimer or 0
|
||||
end
|
||||
|
||||
local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
-- constants/sprite_data_constants.asm:3
|
||||
local IMAGE_FACING = { [0] = "down", [1] = "up", [2] = "left", [3] = "right" }
|
||||
@@ -298,7 +320,7 @@ function Player:pose()
|
||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
hopping = true
|
||||
elseif self.surfing then
|
||||
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
|
||||
self:advanceBob()
|
||||
py = py + (self.bobTimer < 16 and 0 or 1)
|
||||
end
|
||||
-- engine/overworld/player_animations.asm:453
|
||||
|
||||
@@ -259,17 +259,25 @@ BorderFill.CROSSFADE_FRAMES = 20
|
||||
-- The bookkeeping half, love-free so it can be checked without a canvas.
|
||||
-- Returns the image to draw underneath (nil on the first fill and once the
|
||||
-- dissolve is over) and the alpha the incoming image draws at.
|
||||
function BorderFill.crossfade(owner, image, key)
|
||||
--
|
||||
-- `advance` is how many 60Hz frames the dissolve moves on by (default 1, one
|
||||
-- per call). An owner that keeps `borderTicks` (World counts its logic steps
|
||||
-- there) has BorderFill.draw pass those instead, so the fade runs on the
|
||||
-- logic clock rather than once per render frame.
|
||||
function BorderFill.crossfade(owner, image, key, advance)
|
||||
if not owner or key == nil then return nil, 1 end
|
||||
if owner.borderKey ~= key then
|
||||
-- Nothing to dissolve from on the first map of a session.
|
||||
owner.borderFrom = (owner.borderKey ~= nil) and owner.borderLast or nil
|
||||
owner.borderKey = key
|
||||
owner.borderFade = owner.borderFrom and 0 or nil
|
||||
-- Ticks banked before the swap (a stretch with the fill not drawn at all)
|
||||
-- belong to no dissolve: a new one starts at its first frame.
|
||||
if advance and advance > 1 then advance = 1 end
|
||||
end
|
||||
owner.borderLast = image
|
||||
if not owner.borderFade then return nil, 1 end
|
||||
owner.borderFade = owner.borderFade + 1
|
||||
owner.borderFade = owner.borderFade + (advance or 1)
|
||||
if owner.borderFade >= BorderFill.CROSSFADE_FRAMES then
|
||||
owner.borderFade, owner.borderFrom = nil, nil
|
||||
return nil, 1
|
||||
@@ -291,7 +299,12 @@ function BorderFill.draw(owner, image, camX, camY, w, h, s, key)
|
||||
BorderFill.SIZE, BorderFill.SIZE)
|
||||
if owner then owner.borderQuad = q end
|
||||
end
|
||||
local from, alpha = BorderFill.crossfade(owner, image, key)
|
||||
local advance = nil
|
||||
if owner and owner.borderTicks ~= nil then
|
||||
advance = owner.borderTicks
|
||||
owner.borderTicks = 0
|
||||
end
|
||||
local from, alpha = BorderFill.crossfade(owner, image, key, advance)
|
||||
if from then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(from, q, sx, sy, 0, s, s)
|
||||
|
||||
@@ -58,15 +58,30 @@ function MapAttrGrid.cellAt(map, tileset, mx, my)
|
||||
return { tileId = tileId, rawTileId = raw, attr = attr }
|
||||
end
|
||||
|
||||
-- Full map grid keyed by "mx,my" for fast lookup during overdraw.
|
||||
-- Full map grid for fast lookup during overdraw: a flat array indexed by
|
||||
-- 8x8 cell (row-major, `cols` cells to a row), so a lookup is two divides and
|
||||
-- an index rather than a string key built per tile per entity per frame.
|
||||
-- Cells with the same raw tile id share one read-only cell table: the
|
||||
-- normalization depends only on (raw id, tileset).
|
||||
function MapAttrGrid.build(map, tileset)
|
||||
local grid = {}
|
||||
local grid = { cols = 0, rows = 0, cells = {} }
|
||||
if not (map and tileset) then return grid end
|
||||
local pw, ph = map.width * 32, map.height * 32
|
||||
for my = 0, ph - 1, 8 do
|
||||
for mx = 0, pw - 1, 8 do
|
||||
local cell = MapAttrGrid.cellAt(map, tileset, mx, my)
|
||||
if cell then grid[mx .. "," .. my] = cell end
|
||||
local cols, rows = map.width * 4, map.height * 4
|
||||
grid.cols, grid.rows = cols, rows
|
||||
local cells = grid.cells
|
||||
local byRaw = {}
|
||||
for cy = 0, rows - 1 do
|
||||
for cx = 0, cols - 1 do
|
||||
local raw = MapAttrGrid.tileAt(map, tileset, cx * 8, cy * 8)
|
||||
if raw ~= nil then
|
||||
local cell = byRaw[raw]
|
||||
if not cell then
|
||||
local tileId, attr = MapAttrGrid.normalizeTile(raw, tileset)
|
||||
cell = { tileId = tileId, rawTileId = raw, attr = attr }
|
||||
byRaw[raw] = cell
|
||||
end
|
||||
cells[cy * cols + cx + 1] = cell
|
||||
end
|
||||
end
|
||||
end
|
||||
return grid
|
||||
@@ -74,9 +89,13 @@ end
|
||||
|
||||
function MapAttrGrid.lookup(grid, mx, my)
|
||||
if not grid then return nil end
|
||||
local tx = math.floor(mx / 8) * 8
|
||||
local ty = math.floor(my / 8) * 8
|
||||
return grid[tx .. "," .. ty]
|
||||
local cx = math.floor(mx / 8)
|
||||
local cy = math.floor(my / 8)
|
||||
local cols = grid.cols
|
||||
if not cols or cx < 0 or cy < 0 or cx >= cols or cy >= grid.rows then
|
||||
return nil
|
||||
end
|
||||
return grid.cells[cy * cols + cx + 1]
|
||||
end
|
||||
|
||||
return MapAttrGrid
|
||||
|
||||
+523
-217
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
-- ChipSynth bulk PCM writes (ffi int16 stores through Data:getFFIPointer)
|
||||
-- must be bit-identical to the per-sample SoundData:setSample path they
|
||||
-- replace, for streamed music buffers, SFX/cries (renderEffectData) and the
|
||||
-- synchronous music fallback's sliced staging fill (ChipAudio fillSync).
|
||||
-- luajit tests/chip_synth_bulk_pcm_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("chip synth bulk pcm")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
local ffi = require("ffi")
|
||||
|
||||
-- ffi-backed SoundData. setSample reproduces LOVE 11.5's 16-bit store,
|
||||
-- (int16)(value * 32767) in double precision with no clamp -- verified
|
||||
-- against a real LOVE 11.5 SoundData over 2M values including
|
||||
-- out-of-range and NaN. getFFIPointer exposes the same storage.
|
||||
local FfiSoundData = {}
|
||||
FfiSoundData.__index = FfiSoundData
|
||||
function FfiSoundData:setSample(index, a, b)
|
||||
if b == nil then
|
||||
self.buf[index * self.channels] = a * 32767
|
||||
else
|
||||
self.buf[index * self.channels + (a - 1)] = b * 32767
|
||||
end
|
||||
end
|
||||
function FfiSoundData:getSample(index, channel)
|
||||
return self.buf[index * self.channels + ((channel or 1) - 1)] / 32767
|
||||
end
|
||||
function FfiSoundData:getFFIPointer() return self.buf end
|
||||
function FfiSoundData:getSampleCount() return self.samples end
|
||||
function FfiSoundData:getSampleRate() return self.rate end
|
||||
function FfiSoundData:getBitDepth() return 16 end
|
||||
function FfiSoundData:getChannelCount() return self.channels end
|
||||
|
||||
love.sound = love.sound or {}
|
||||
love.sound.newSoundData = function(samples, rate, bits, channels)
|
||||
local sd = setmetatable({
|
||||
samples = samples, rate = rate, channels = channels,
|
||||
buf = ffi.new("int16_t[?]", samples * channels),
|
||||
}, FfiSoundData)
|
||||
return sd
|
||||
end
|
||||
|
||||
-- the conversion itself, including values the synth never produces
|
||||
do
|
||||
local a = love.sound.newSoundData(8, 44100, 16, 1)
|
||||
local values = { 0, 1, -1, 1.5, -1.5, 0.99999, 1.5 / 32767, 0 / 0 }
|
||||
local expect = { 0, 32767, -32767, -16386, 16386, 32766, 1, 0 }
|
||||
for i, v in ipairs(values) do a:setSample(i - 1, v) end
|
||||
for i = 1, #expect do
|
||||
eq(a.buf[i - 1], expect[i], "LOVE 11.5 int16 store of " .. tostring(values[i]))
|
||||
end
|
||||
end
|
||||
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
|
||||
ChipSynth.setChannelPitches({ 1, 1, 1, 1 })
|
||||
|
||||
-- the fake SoundData really takes the bulk path (and the switch disables it)
|
||||
check(ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
|
||||
"bulk path active on an ffi SoundData")
|
||||
ChipSynth._setBulkWritesForTest(false)
|
||||
check(not ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
|
||||
"bulk switch off falls back to setSample")
|
||||
ChipSynth._setBulkWritesForTest(true)
|
||||
|
||||
local function same(a, b)
|
||||
if a.samples ~= b.samples or a.channels ~= b.channels then
|
||||
return false, "shape " .. a.samples .. "x" .. a.channels
|
||||
.. " vs " .. b.samples .. "x" .. b.channels
|
||||
end
|
||||
local n = a.samples * a.channels
|
||||
for i = 0, n - 1 do
|
||||
if a.buf[i] ~= b.buf[i] then
|
||||
return false, ("sample %d: %d vs %d"):format(i, a.buf[i], b.buf[i])
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function nonzero(sd)
|
||||
local count = 0
|
||||
for i = 0, sd.samples * sd.channels - 1 do
|
||||
if sd.buf[i] ~= 0 then count = count + 1 end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local data = { audio = {} }
|
||||
local song = ChipAsm.song{
|
||||
tempo = 0x100,
|
||||
channels = {
|
||||
{ hw = 1, program = {
|
||||
{ duty = 2 },
|
||||
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
||||
{ octave = 4 },
|
||||
{ label = "body" },
|
||||
{ note = "C", len = 8 }, { note = "E", len = 8 }, { note = "G", len = 4 },
|
||||
{ loop = { count = 0, to = "body" } },
|
||||
} },
|
||||
{ hw = 2, program = {
|
||||
{ duty = 1 },
|
||||
{ notetype = { speed = 12, volume = 10, fade = 2 } },
|
||||
{ octave = 3 },
|
||||
{ label = "body" },
|
||||
{ note = "G", len = 4 }, { note = "A", len = 12 },
|
||||
{ loop = { count = 0, to = "body" } },
|
||||
} },
|
||||
{ hw = 4, program = {
|
||||
{ label = "body" },
|
||||
{ drum = 3, len = 4 },
|
||||
{ loop = { count = 0, to = "body" } },
|
||||
} },
|
||||
},
|
||||
drums = { [3] = { { len = 4, volume = 13, fade = 2, parameter = 0x42 } } },
|
||||
}
|
||||
|
||||
local function render(bulk, fn)
|
||||
ChipSynth._setBulkWritesForTest(bulk)
|
||||
local ok, result = pcall(fn)
|
||||
ChipSynth._setBulkWritesForTest(true)
|
||||
assert(ok, result)
|
||||
return result
|
||||
end
|
||||
|
||||
-- streamed music buffers (worker + sync fallback), stereo and mono
|
||||
for _, channels in ipairs({ 2, 1 }) do
|
||||
local function music()
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
local first = ChipSynth.soundData(engine, 8192, channels)
|
||||
local second = ChipSynth.soundData(engine, 8192, channels)
|
||||
return { first, second }
|
||||
end
|
||||
local slow, fast = render(false, music), render(true, music)
|
||||
for i = 1, 2 do
|
||||
local ok, why = same(slow[i], fast[i])
|
||||
check(ok, ("music buffer %d (%dch) bit-identical %s")
|
||||
:format(i, channels, why or ""))
|
||||
end
|
||||
check(nonzero(fast[1]) > 1000, "music buffer carries audio")
|
||||
end
|
||||
|
||||
-- sliced staging fill == one whole-buffer render
|
||||
do
|
||||
local whole = render(false, function()
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
ChipSynth.soundData(engine, 1000, 2)
|
||||
return ChipSynth.soundData(engine, 8192, 2)
|
||||
end)
|
||||
local sliced = render(true, function()
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
ChipSynth.soundData(engine, 1000, 2)
|
||||
local sd = ChipSynth.newBuffer(8192, 2)
|
||||
local fill = 0
|
||||
for _, n in ipairs({ 1024, 1024, 777, 1024, 3000, 1343 }) do
|
||||
ChipSynth.renderInto(engine, sd, fill, n, 2)
|
||||
fill = fill + n
|
||||
end
|
||||
assert(fill == 8192)
|
||||
return sd
|
||||
end)
|
||||
local ok, why = same(whole, sliced)
|
||||
check(ok, "sliced staging fill bit-identical " .. (why or ""))
|
||||
end
|
||||
|
||||
-- SFX / cries
|
||||
local sfx = ChipAsm.sfx{ channels = {
|
||||
{ hw = 1, program = {
|
||||
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
|
||||
{ squareNote = { len = 16, volume = 12, fade = 3, frequency = 0x700 } },
|
||||
} },
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 16, volume = 14, fade = 2, parameter = 0x33 } },
|
||||
} },
|
||||
} }
|
||||
|
||||
for _, opts in ipairs({
|
||||
function() return {} end,
|
||||
function() return { frequencyOffset = 0x40, cryLength = 0x60 } end,
|
||||
function() return { frequencyOffset = -0x20, frameTicks = 0xC0 } end,
|
||||
}) do
|
||||
local slow = render(false, function()
|
||||
return ChipSynth.renderEffectData(data, sfx, opts())
|
||||
end)
|
||||
local fast = render(true, function()
|
||||
return ChipSynth.renderEffectData(data, sfx, opts())
|
||||
end)
|
||||
check(slow and fast, "effect renders both ways")
|
||||
if slow and fast then
|
||||
local ok, why = same(slow, fast)
|
||||
check(ok, "effect bit-identical " .. (why or ""))
|
||||
check(nonzero(fast) > 100, "effect carries audio")
|
||||
end
|
||||
end
|
||||
|
||||
-- a second, longer effect after a short one: the reused scratch grows
|
||||
do
|
||||
local notes = {}
|
||||
for i = 1, 24 do
|
||||
notes[i] = { squareNote = { len = 16, volume = 15, fade = 0,
|
||||
frequency = 0x600 + i * 8 } }
|
||||
end
|
||||
local long = ChipAsm.sfx{ channels = { { hw = 1, program = notes } } }
|
||||
local slow = render(false, function()
|
||||
return ChipSynth.renderEffectData(data, long, {})
|
||||
end)
|
||||
local fast = render(true, function()
|
||||
return ChipSynth.renderEffectData(data, long, {})
|
||||
end)
|
||||
check(slow and fast and slow.samples > 44100,
|
||||
"long effect renders past the initial scratch")
|
||||
if slow and fast then
|
||||
local ok, why = same(slow, fast)
|
||||
check(ok, "long effect bit-identical " .. (why or ""))
|
||||
end
|
||||
end
|
||||
|
||||
-- synchronous music fallback: the sliced per-tick staging fill queues the
|
||||
-- same 8192-sample buffers, in order, as whole-buffer renders did
|
||||
do
|
||||
love.thread = nil
|
||||
local queued = {}
|
||||
local source = { free = 32, playing = false }
|
||||
function source:getFreeBufferCount() return self.free end
|
||||
function source:queue(sd) self.free = self.free - 1; queued[#queued + 1] = sd end
|
||||
function source:play() self.playing = true end
|
||||
function source:stop() self.playing = false end
|
||||
function source:isPlaying() return self.playing end
|
||||
function source:setVolume() end
|
||||
love.audio = love.audio or {}
|
||||
love.audio.newQueueableSource = function() return source end
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
ChipAudio.playMusic(data, song, true)
|
||||
eq(#queued, 4, "song start queues the initial buffers")
|
||||
local maxTick = 0
|
||||
local consumed = 0
|
||||
for _ = 1, 400 do
|
||||
consumed = consumed + ChipSynth.SAMPLE_RATE / 60
|
||||
while consumed >= 8192 and source.free < 32 do
|
||||
consumed = consumed - 8192
|
||||
source.free = source.free + 1
|
||||
end
|
||||
local before = #queued
|
||||
ChipAudio.update()
|
||||
maxTick = math.max(maxTick, #queued - before)
|
||||
end
|
||||
check(#queued > 40, "the queue keeps filling (" .. #queued .. " buffers)")
|
||||
check(maxTick <= 1, "at most one buffer completes per tick")
|
||||
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
|
||||
local allSame = true
|
||||
for i = 1, #queued do
|
||||
local ref = ChipSynth.soundData(engine, 8192, 2)
|
||||
local ok = same(ref, queued[i])
|
||||
if not ok then allSame = false; break end
|
||||
end
|
||||
check(allSame, "sliced fallback stream bit-identical to whole buffers")
|
||||
-- a starved queue (below the low-water mark) renders a whole buffer's
|
||||
-- worth in one tick
|
||||
source.free = 31
|
||||
local before = #queued
|
||||
ChipAudio.update()
|
||||
check(#queued - before >= 1, "low queue catches up within one tick")
|
||||
ChipAudio.stopMusic()
|
||||
-- no worker: prewarm declines rather than rendering on this thread
|
||||
local fx = { audio = { sfx = { Beep = sfx }, cries = {} } }
|
||||
check(ChipAudio.prewarmSfx(fx, "Beep") == false,
|
||||
"prewarm is a no-op without a worker")
|
||||
eq(ChipAudio._effectStateForTest().pending, 0, "nothing left pending")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user