Merge pull request #1723 from 1Jamie/bullet-train-anim-launcher-lc-yellow-pallet-g1-link-flow

This commit is contained in:
bryanthaboi
2026-08-22 18:37:09 -04:00
committed by GitHub
16 changed files with 1090 additions and 215 deletions
+46 -18
View File
@@ -82,6 +82,43 @@ end
local editorHost, editorVersion, editorWindow local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor local closeEditor -- forward declaration: openEditor hands it to the editor
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
-- version session (save editor or game). closeEditor and returnToLauncher
-- both go through here so neither path can forget a singleton the other resets.
local function teardownMountedSession(version)
if version then
require("src.import.CacheFs").unmountVersion(version)
end
require("src.core.Data"):unloadGenerated()
local Runtime = require("src.mods.Runtime")
if Runtime.reset then Runtime.reset() end
local Assets = require("src.render.Assets")
if Assets.installLoader then Assets.installLoader(nil) end
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
end
-- Evict every save-editor module from package.loaded without a hardcoded
-- panel whitelist. Flat require names (App, Party, …) resolve under
-- tools/save-editor/; path-style keys may also appear. A key is flushed
-- when it names a save-editor path or when tools/save-editor/{panels/}K.lua
-- exists for a flat name K -- new panels are picked up automatically.
local function flushEditorPackageLoaded()
local fs = love and love.filesystem
local function isEditorFlat(name)
if not (fs and fs.getInfo) then return false end
if name:find("[./]") then return false end
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
end
for k in pairs(package.loaded) do
if type(k) == "string"
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
package.loaded[k] = nil
end
end
end
-- The editor's modules use flat names (require("Kit"), require("Party")), so -- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be -- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files -- love.filesystem's path, not package.path: in a packaged build these files
@@ -180,9 +217,9 @@ local function openEditor(version, slotId)
if EditorApp.unload then pcall(EditorApp.unload) end if EditorApp.unload then pcall(EditorApp.unload) end
EditorApp = nil EditorApp = nil
if version then if version then
require("src.import.CacheFs").unmountVersion(version) teardownMountedSession(version)
require("src.core.Data"):unloadGenerated()
end end
flushEditorPackageLoaded()
restoreWindow() restoreWindow()
Importer = editorHost Importer = editorHost
editorHost = nil editorHost = nil
@@ -197,21 +234,17 @@ end
-- Back to the launcher. Everything the editor mounted or cached has to come -- Back to the launcher. Everything the editor mounted or cached has to come
-- back out: the version overlay (CacheFs) and the generated modules require -- back out: the version overlay (CacheFs) and the generated modules require
-- cached behind it (Data), or pressing Play on the OTHER game would boot it -- cached behind it (Data), or pressing Play on the OTHER game would boot it
-- with this one's data. -- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
-- next Edit or Play does not inherit the editor's dead mod loader.
function closeEditor() function closeEditor()
local version = editorVersion local version = editorVersion
editorMode = false editorMode = false
if EditorApp and EditorApp.unload then EditorApp.unload() end if EditorApp and EditorApp.unload then EditorApp.unload() end
EditorApp = nil EditorApp = nil
if version then if version then
require("src.import.CacheFs").unmountVersion(version) teardownMountedSession(version)
require("src.core.Data"):unloadGenerated()
end
for k in pairs(package.loaded) do
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
package.loaded[k] = nil
end
end end
flushEditorPackageLoaded()
editorVersion = nil editorVersion = nil
restoreWindow() restoreWindow()
Importer = editorHost Importer = editorHost
@@ -333,16 +366,11 @@ local function returnToLauncher()
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get() local currentVersion = GameVersion.get()
if currentVersion then teardownMountedSession(currentVersion)
require("src.import.CacheFs").unmountVersion(currentVersion)
end
require("src.core.Data"):unloadGenerated()
local Runtime = require("src.mods.Runtime") if Game.reset then
if Runtime.reset then pcall(function() Game:reset() end)
Runtime.reset()
end end
Game = nil Game = nil
autopilot = nil autopilot = nil
driverCo = nil driverCo = nil
+22
View File
@@ -1381,4 +1381,26 @@ function Game:restoreCheckpointBattle(battle)
if battle.resumeCheckpoint then battle:resumeCheckpoint() end if battle.resumeCheckpoint then battle:resumeCheckpoint() end
end end
-- Drop every session-owned field so the next Game:load() starts clean when
-- the process returns to the launcher in-place (Android / intent_game).
-- main.lua must not guess field names: new systems (Game.network, …) are
-- cleared automatically because only functions (methods) are kept.
function Game:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
end
if self.renderer and self.renderer.releaseCanvases then
pcall(function() self.renderer:releaseCanvases() end)
end
local keys = {}
for key, value in pairs(self) do
if type(value) ~= "function" then
keys[#keys + 1] = key
end
end
for _, key in ipairs(keys) do
self[key] = nil
end
end
return Game return Game
+18
View File
@@ -2123,4 +2123,22 @@ function Game2:joystickremoved()
TouchControls:joystickremoved() TouchControls:joystickremoved()
end end
-- In-process return-to-launcher (Android / intent_game): drop session fields
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
-- Methods live on the class table; pairs(self) only sees instance state.
function Game2:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
end
local keys = {}
for key, value in pairs(self) do
if type(value) ~= "function" then
keys[#keys + 1] = key
end
end
for _, key in ipairs(keys) do
self[key] = nil
end
end
return Game2 return Game2
+22 -8
View File
@@ -614,14 +614,18 @@ function CacheFs.mountVersion(version)
return true return true
end end
-- Undo mountVersion. A process normally mounts exactly one version and then -- Undo mountVersion in LIFO order relative to mountVersion: generated-tree
-- boots it, but the launcher can open the save editor on one game's save, -- overlays first (assets, then data -- reverse of mountGeneratedTrees), then
-- close it, and press Play on another: with the first version's subtree -- the version folder. PHYSFS resolves by stack order; peeling the wrong
-- still prepended, the other's require("data.generated.*") and generated -- layer first can leave another version's generated files winning a name.
-- art would silently resolve to the first game's files. Callers must also --
-- drop the generated modules from package.loaded -- A process normally mounts exactly one version and then boots it, but the
-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read -- launcher can open the save editor on one game's save, close it, and press
-- path, not what require already cached. -- Play on another: with the first version's subtree still prepended, the
-- other's require("data.generated.*") and generated art would silently
-- resolve to the first game's files. Callers must also drop the generated
-- modules from package.loaded (src.core.Data:unloadGenerated) -- unmounting
-- alone only fixes the read path, not what require already cached.
-- --
-- Returns true when nothing was mounted or the unmount took. -- Returns true when nothing was mounted or the unmount took.
function CacheFs.unmountVersion(version) function CacheFs.unmountVersion(version)
@@ -633,6 +637,16 @@ function CacheFs.unmountVersion(version)
base = love.filesystem.getSaveDirectory() base = love.filesystem.getSaveDirectory()
end end
local done = false local done = false
-- LIFO vs mountGeneratedTrees: assets/generated, then data/generated.
if love.filesystem and love.filesystem.unmount then
local generated = {
prefix .. "assets/generated",
prefix .. "data/generated",
}
for _, src in ipairs(generated) do
done = love.filesystem.unmount(src) or done
end
end
local fn = resolveUnmount() local fn = resolveUnmount()
if fn and base then if fn and base then
done = fn(base .. SEP .. sub) or done done = fn(base .. SEP .. sub) or done
+12 -6
View File
@@ -397,9 +397,12 @@ function PaletteFX.uiSpriteRedraws()
end end
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua, -- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes. -- Yellow uses data/palettes_yellow.lua, everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data) function PaletteFX.pack(data)
if PaletteFX.usesGbcPack() then if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
if y then return y end
elseif PaletteFX.usesGbcPack() then
local g = PaletteFX.gbcPack() local g = PaletteFX.gbcPack()
if g then return g end if g then return g end
end end
@@ -449,24 +452,27 @@ function PaletteFX.pal(data, name)
local fromRom = romNamedPal(data, name) local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end if fromRom then return fromRom end
end end
if GameVersion.isYellow() then
if PaletteFX.usesYellowCgb() then if PaletteFX.usesYellowCgb() then
local fromCgb = yellowCgbNamedPal(data, name) local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end if fromCgb then return fromCgb end
end end
local p = PaletteFX.pack(data)
local c = p and p.palettes and p.palettes[name]
if c then return c end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack() local y = PaletteFX.yellowPack()
local yc = y and y.palettes and y.palettes[name] local yc = y and y.palettes and y.palettes[name]
if yc then return yc end if yc then return yc end
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end end
local p = PaletteFX.pack(data)
local c = p and p.palettes and p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() then if PaletteFX.usesGbcPack() then
return romNamedPal(data, name) return romNamedPal(data, name)
end end
return nil return nil
end end
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns. -- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species -- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a -- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
+20
View File
@@ -100,10 +100,30 @@ local function positionLift(ph, contentPx, dpiY, cut)
return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY) return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY)
end end
-- Free GPU canvases immediately. Overwriting the Lua reference alone leaves
-- VRAM allocated until LOVE's GC runs, which is too slow when Android loops
-- Play → launcher → Play in one process.
local function releaseCanvas(canvas)
if canvas and canvas.release then pcall(canvas.release, canvas) end
end
function Renderer:releaseCanvases()
releaseCanvas(self.canvas); self.canvas = nil
releaseCanvas(self.battleHUDCanvas); self.battleHUDCanvas = nil
releaseCanvas(self.worldCanvas); self.worldCanvas = nil
releaseCanvas(self.uprightCanvas); self.uprightCanvas = nil
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
end
function Renderer:init() function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same -- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels. -- reason -- worldViewSize() already works in drawable pixels.
-- Release any prior session's surfaces before reallocating (in-process
-- return-to-launcher reuses this Renderer singleton).
self:releaseCanvases()
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest") self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.battleHUDCanvas = nil self.battleHUDCanvas = nil
+7
View File
@@ -951,6 +951,13 @@ function TileRenderer.invalidate()
frameImages = {} frameImages = {}
toggleImages = {} toggleImages = {}
stripData = {} stripData = {}
-- RED++ per-map atlases are large Images; clear and release so an
-- in-process Play → launcher → Play loop does not keep every prior
-- session's bake in VRAM / Lua heap.
for key, img in pairs(gbcAtlasCache) do
safeRelease(img)
gbcAtlasCache[key] = nil
end
end end
Assets.register(TileRenderer.invalidate) Assets.register(TileRenderer.invalidate)
+509 -115
View File
@@ -34,10 +34,12 @@ local DEFAULT_ART = {
cableBall = "assets/generated/trade/cable_ball.png", cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png", cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png", bubble = "assets/generated/trade/bubble.png",
moveAnim0 = "assets/generated/battle/anims/move_anim_0.png",
balls = "assets/generated/battle/balls.png",
} }
local function tryImage(path) local function tryImage(path)
if not path then return nil end if not (path and love and love.graphics and love.graphics.newImage) then return nil end
local ok, img = pcall(love.graphics.newImage, path) local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil return ok and img or nil
end end
@@ -89,6 +91,92 @@ local SEQ = {
"done", "done",
} }
-- FrameBlocks 03..08 definition for move_anim_0.png (128x40, 16 tiles/row, 8x8 per tile)
local FRAME_BLOCKS = {
-- FrameBlock03: Pokeball normal (16x16)
[3] = {
{ tile = 2, dx = 0, dy = 0, xflip = false, yflip = false },
{ tile = 2, dx = 8, dy = 0, xflip = true, yflip = false },
{ tile = 18, dx = 0, dy = 8, xflip = false, yflip = false },
{ tile = 18, dx = 8, dy = 8, xflip = true, yflip = false },
},
-- FrameBlock04: Pokeball tilted left (16x16)
[4] = {
{ tile = 6, dx = 0, dy = 0, xflip = false, yflip = false },
{ tile = 7, dx = 8, dy = 0, xflip = false, yflip = false },
{ tile = 22, dx = 0, dy = 8, xflip = false, yflip = false },
{ tile = 23, dx = 8, dy = 8, xflip = false, yflip = false },
},
-- FrameBlock05: Pokeball tilted right (16x16)
[5] = {
{ tile = 7, dx = 0, dy = 0, xflip = true, yflip = false },
{ tile = 6, dx = 8, dy = 0, xflip = true, yflip = false },
{ tile = 23, dx = 0, dy = 8, xflip = true, yflip = false },
{ tile = 22, dx = 8, dy = 8, xflip = true, yflip = false },
},
-- FrameBlock06: Small Poof smoke cloud (32x32)
[6] = {
{ tile = 0x23, dx = 8, dy = 0, xflip = false, yflip = false },
{ tile = 0x32, dx = 0, dy = 8, xflip = false, yflip = false },
{ tile = 0x33, dx = 8, dy = 8, xflip = false, yflip = false },
{ tile = 0x23, dx = 16, dy = 0, xflip = true, yflip = false },
{ tile = 0x33, dx = 16, dy = 8, xflip = true, yflip = false },
{ tile = 0x32, dx = 24, dy = 8, xflip = true, yflip = false },
{ tile = 0x32, dx = 0, dy = 16, xflip = false, yflip = true },
{ tile = 0x33, dx = 8, dy = 16, xflip = false, yflip = true },
{ tile = 0x23, dx = 8, dy = 24, xflip = false, yflip = true },
{ tile = 0x33, dx = 16, dy = 16, xflip = true, yflip = true },
{ tile = 0x32, dx = 24, dy = 16, xflip = true, yflip = true },
{ tile = 0x23, dx = 16, dy = 24, xflip = true, yflip = true },
},
-- FrameBlock07: Medium Poof smoke cloud (32x32)
[7] = {
{ tile = 0x20, dx = 0, dy = 0, xflip = false, yflip = false },
{ tile = 0x21, dx = 8, dy = 0, xflip = false, yflip = false },
{ tile = 0x30, dx = 0, dy = 8, xflip = false, yflip = false },
{ tile = 0x31, dx = 8, dy = 8, xflip = false, yflip = false },
{ tile = 0x21, dx = 16, dy = 0, xflip = true, yflip = false },
{ tile = 0x20, dx = 24, dy = 0, xflip = true, yflip = false },
{ tile = 0x31, dx = 16, dy = 8, xflip = true, yflip = false },
{ tile = 0x30, dx = 24, dy = 8, xflip = true, yflip = false },
{ tile = 0x30, dx = 0, dy = 16, xflip = false, yflip = true },
{ tile = 0x31, dx = 8, dy = 16, xflip = false, yflip = true },
{ tile = 0x20, dx = 0, dy = 24, xflip = false, yflip = true },
{ tile = 0x21, dx = 8, dy = 24, xflip = false, yflip = true },
{ tile = 0x31, dx = 16, dy = 16, xflip = true, yflip = true },
{ tile = 0x30, dx = 24, dy = 16, xflip = true, yflip = true },
{ tile = 0x21, dx = 16, dy = 24, xflip = true, yflip = true },
{ tile = 0x20, dx = 24, dy = 24, xflip = true, yflip = true },
},
-- FrameBlock08: Large Poof smoke cloud (32x32)
[8] = {
{ tile = 0x22, dx = 0, dy = 0, xflip = false, yflip = false },
{ tile = 0x23, dx = 8, dy = 0, xflip = false, yflip = false },
{ tile = 0x32, dx = 0, dy = 8, xflip = false, yflip = false },
{ tile = 0x33, dx = 8, dy = 8, xflip = false, yflip = false },
{ tile = 0x23, dx = 16, dy = 0, xflip = true, yflip = false },
{ tile = 0x22, dx = 24, dy = 0, xflip = true, yflip = false },
{ tile = 0x33, dx = 16, dy = 8, xflip = true, yflip = false },
{ tile = 0x32, dx = 24, dy = 8, xflip = true, yflip = false },
{ tile = 0x32, dx = 0, dy = 16, xflip = false, yflip = true },
{ tile = 0x33, dx = 8, dy = 16, xflip = false, yflip = true },
{ tile = 0x22, dx = 0, dy = 24, xflip = false, yflip = true },
{ tile = 0x23, dx = 8, dy = 24, xflip = false, yflip = true },
{ tile = 0x33, dx = 16, dy = 16, xflip = true, yflip = true },
{ tile = 0x32, dx = 24, dy = 16, xflip = true, yflip = true },
{ tile = 0x23, dx = 16, dy = 24, xflip = true, yflip = true },
{ tile = 0x22, dx = 24, dy = 24, xflip = true, yflip = true },
},
}
-- BallMoveDistances1 (engine/battle/animations.asm:881)
-- Pokéball jumps up into the open link cable nozzle: -12, -12, -8 (3 frames each)
local BALL_SUCTION_DISTANCES = { -12, -12, -8 }
-- BallMoveDistances2 (engine/battle/animations.asm:922)
-- Incoming Pokéball bounces out of nozzle onto the ground: 11, 12, -12, -7, 7, 12, -8, 8 (5 frames each)
local BALL_BOUNCE_DISTANCES = { 11, 12, -12, -7, 7, 12, -8, 8 }
function TradeAnim.new(game, opts) function TradeAnim.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, TradeAnim) local self = setmetatable({}, TradeAnim)
@@ -120,6 +208,8 @@ function TradeAnim.new(game, opts)
cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall), cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall),
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt), cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
bubble = tryImage(art.bubble or DEFAULT_ART.bubble), bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
moveAnim0 = tryImage(DEFAULT_ART.moveAnim0),
balls = tryImage(DEFAULT_ART.balls),
} }
self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent) self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent)
self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received) self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received)
@@ -136,6 +226,14 @@ function TradeAnim.new(game, opts)
self.monVisible = true self.monVisible = true
self.waitingText = false self.waitingText = false
self.cableFlash = false self.cableFlash = false
self.activeBallBlock = nil
self.activeBallX = 0
self.activeBallY = 0
self.activePoofBlock = nil
self.activePoofX = 0
self.activePoofY = 0
self.wx = 0
self.slidingBox = false
return self return self
end end
@@ -151,52 +249,100 @@ function TradeAnim:advance()
self.sub = nil self.sub = nil
self.flash = false self.flash = false
self.cableFlash = false self.cableFlash = false
self.poof = nil self.activeBallBlock = nil
self.activePoofBlock = nil
self.slidingBox = false
self.wx = 0
if self.phase == "done" then if self.phase == "done" then
self.game.stack:pop() self.game.stack:pop()
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
elseif self.phase == "show_enemy" then elseif self.phase == "show_enemy" then
self.monVisible = false self.monVisible = false
self.activeBallBlock = 4
self.activeBallX, self.activeBallY = 72, 40
self.activePoofBlock = nil
self.cableSlideOut = 0
elseif self.phase == "transfer_lr" then elseif self.phase == "transfer_lr" then
-- wBaseCoord $54, $1c OAM -> screen (76, 12) -- BaseCoord $54, $1c -> Screen (92, 28) for 16x16 icon (center 100, 36)
self.monX, self.monY = 76, 12 self.monX, self.monY = 92, 28
self.scx = 0
elseif self.phase == "transfer_rl" then elseif self.phase == "transfer_rl" then
-- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen -- BaseCoord $64, $44 -> Screen (108, 68) for 16x16 icon (center 116, 76)
self.monX, self.monY = 92, 52 self.monX, self.monY = 108, 68
self.scx = 160 self.scx = 256
elseif self.phase == "ball_enter" then elseif self.phase == "ball_enter" then
-- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable) -- Pokéball sits at BaseCoord $48 (Screen 72, 64) before shake and suction
self.activeBallBlock = 3
self.activeBallX, self.activeBallY = 72, 64
self.ballX, self.ballY = 0x60, 0x20 self.ballX, self.ballY = 0x60, 0x20
elseif self.phase == "open_cable" or self.phase == "open_cable2" then elseif self.phase == "open_cable" or self.phase == "open_cable2" then
-- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64 -- SCX $a0 (160) -> $f0 (240): cable slides in from right, open end rests at x64
self.scx = 0x50 self.scx = 80
if self.phase == "open_cable" then
self.activeBallBlock = 3
self.activeBallX, self.activeBallY = 72, 64
end
Sound.play(self.game.data, "Heal_HP") Sound.play(self.game.data, "Heal_HP")
end end
end end
function TradeAnim:say(text, delay, thenFn) function TradeAnim:skipHeld()
self.waitingText = true return self.game.input:wasPressed("a") or self.game.input:wasPressed("b")
self.game.stack:push(TextBox.new(self.game, text, function()
self.waitingText = false
if thenFn then thenFn() else self:advance() end
end, { auto = { delay = delay or 80 } }))
end end
function TradeAnim:skipHeld() function TradeAnim:drawFrameBlock(blockId, x, y)
return self.game.input:wasPressed("a") or self.game.input:isDown("a") local block = FRAME_BLOCKS[blockId]
if not block then return end
if self.img.moveAnim0 then
local iw, ih = self.img.moveAnim0: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 sx = tile.xflip and -1 or 1
local sy = tile.yflip and -1 or 1
local ox = tile.xflip and 8 or 0
local oy = tile.yflip and 8 or 0
love.graphics.draw(self.img.moveAnim0, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy)
end
else
if blockId == 3 or blockId == 4 or blockId == 5 then
love.graphics.setColor(0.9, 0.2, 0.2, 1)
love.graphics.arc("fill", x + 8, y + 8, 7, math.pi, 0)
love.graphics.setColor(0.9, 0.9, 0.9, 1)
love.graphics.arc("fill", x + 8, y + 8, 7, 0, math.pi)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.circle("line", x + 8, y + 8, 7)
love.graphics.line(x + 1, y + 8, x + 15, y + 8)
love.graphics.circle("fill", x + 8, y + 8, 2)
love.graphics.setColor(1, 1, 1, 1)
elseif blockId == 6 or blockId == 7 or blockId == 8 then
local r = (blockId - 5) * 5
love.graphics.setColor(0.8, 0.8, 0.8, 0.8)
love.graphics.circle("fill", x + 16, y + 16, r)
love.graphics.setColor(1, 1, 1, 1)
end
end
end end
function TradeAnim:update(dt) function TradeAnim:update(dt)
if self.waitingText or self.phase == "done" then return end if self.waitingText or self.phase == "done" then return end
local skip = self.game.input:wasPressed("a") local skip = self:skipHeld()
self.t = self.t + 1 self.t = self.t + 1
local p = self.phase local p = self.phase
if p == "show_player" then if p == "show_player" then
-- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon) -- Trade_ShowPlayerMon:
-- 1. Slide from SCX $7e (126) -> 0 at 2px/frame (63 frames)
-- 2. Hold 80 frames
-- 3. TRADE_BALL_POOF_ANIM at BaseCoord $72 (Screen 64, 48): 3 frames x 6 ticks = 18 ticks
-- 4. TRADE_BALL_DROP_ANIM: mon vanishes, ball drops $41 (72, 56) -> $48 (72, 64) over 36 ticks
-- 5. PlayCry while ball sits on ground at (72, 64)
if not self.sub then self.sub = "slide" end if not self.sub then self.sub = "slide" end
if self.sub == "slide" then if self.sub == "slide" then
self.scx = math.max(0, 0x86 - self.t * 2) self.scx = math.max(0, 126 - self.t * 2)
if skip or self.scx <= 0 then if skip or self.scx <= 0 then
self.scx = 0 self.scx = 0
self.sub = "hold" self.sub = "hold"
@@ -206,52 +352,131 @@ function TradeAnim:update(dt)
if skip or self.t >= 80 then if skip or self.t >= 80 then
self.sub = "poof" self.sub = "poof"
self.t = 0 self.t = 0
self.monVisible = false
Sound.play(self.game.data, "Ball_Poof") Sound.play(self.game.data, "Ball_Poof")
end end
elseif self.sub == "poof" then elseif self.sub == "poof" then
self.poof = math.max(0, 16 - self.t) local step = math.floor(self.t / 6)
if skip or self.t >= 16 then if step >= 3 or skip then
self.sub = "ball_drop"
self.t = 0
self.activePoofBlock = nil
self.monVisible = false
else
self.activePoofBlock = 6 + step
self.activePoofX, self.activePoofY = 64, 48
end
elseif self.sub == "ball_drop" then
if skip or self.t >= 36 then
self.sub = "cry"
self.t = 0
self.activeBallBlock = 3
self.activeBallX, self.activeBallY = 72, 64
Sound.playCry(self.game.data, self.sent.species) Sound.playCry(self.game.data, self.sent.species)
else
local step = math.floor(self.t / 6)
if step == 0 then
self.activeBallBlock = 3
self.activeBallX, self.activeBallY = 72, 56
else
local blocks = { 3, 4, 3, 5, 3 }
self.activeBallBlock = blocks[step] or 3
self.activeBallX, self.activeBallY = 72, 64
end
end
elseif self.sub == "cry" then
if skip or self.t >= 40 then
self.sub = nil self.sub = nil
self:advance() self:advance()
end end
end end
elseif p == "open_cable" or p == "open_cable2" then elseif p == "open_cable" or p == "open_cable2" then
-- 20 steps of 4px, matching the SCX loop -- Trade_DrawOpenEndOfLinkCable: SCX $a0 (160) -> $f0 (240) in 20 steps of 4px
self.scx = math.max(0, 0x50 - self.t * 4) self.scx = math.max(0, 80 - self.t * 4)
if skip or self.scx <= 0 then if skip or self.scx <= 0 then
self.scx = 0 self.scx = 0
self:advance() self:advance()
end end
elseif p == "ball_enter" then elseif p == "ball_enter" then
-- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0 -- Trade_AnimateBallEnteringLinkCable:
if self.t < 20 then -- 1. TRADE_BALL_SHAKE_ANIM (16 ticks) + TradeShakePokeball suction jump (-12, -12, -8 over 9 ticks):
if skip then self.t = 20 end -- - Shakes at (72, 64) for 16 ticks (FrameBlock 04, 03, 05, 03)
return -- - Jumps up: Y=52 (3 ticks), Y=40 (3 ticks), Y=32 (3 ticks)
-- - Ball cleared, SFX_TRADE_MACHINE plays
-- 2. DelayFrames 10 (chills for 10 frames)
-- 3. Ball inside horizontal cable: 16 steps of +4px every 3 frames (48 frames) with Tink sfx & bulge toggle
-- 4. Delay3
if not self.sub then self.sub = "shake" end
if self.sub == "shake" then
if skip or self.t >= 16 then
self.sub = "jump_up"
self.t = 0
else
local shakeBlocks = { 4, 3, 5, 3 }
local step = math.floor(self.t / 4) + 1
self.activeBallBlock = shakeBlocks[step] or 3
self.activeBallX, self.activeBallY = 72, 64
end
elseif self.sub == "jump_up" then
local step = math.floor(self.t / 3) + 1
if step > #BALL_SUCTION_DISTANCES or skip then
self.sub = "chill"
self.t = 0
self.activeBallBlock = nil
Sound.play(self.game.data, "Trade_Machine")
else
local yPos = 64
for i = 1, step do
yPos = yPos + BALL_SUCTION_DISTANCES[i]
end
self.activeBallBlock = 3
self.activeBallX, self.activeBallY = 72, yPos
end
elseif self.sub == "chill" then
if skip or self.t >= 10 then
self.sub = "suction"
self.t = 0
self.ballX = 0x60
end
elseif self.sub == "suction" then
local step = math.floor(self.t / 3)
if step >= 16 or skip then
self.sub = "exit_pause"
self.t = 0
self.ballX = 0xA0
else
self.ballX = 0x60 + step * 4
self.flash = (step % 2 == 1)
if self.t % 3 == 0 and step < 16 then
Sound.play(self.game.data, "Tink")
end
end
elseif self.sub == "exit_pause" then
if skip or self.t >= 3 then
self.sub = nil
self:advance()
end end
local step = self.t - 20
if step % 3 == 0 then
self.ballX = 0x60 + math.floor(step / 3) * 4
self.flash = not self.flash
if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end
end end
if skip then self.ballX = 0xA0 end
if self.ballX >= 0xA0 then self:advance() end
elseif p == "transfer_lr" then elseif p == "transfer_lr" then
-- scroll left GB off while the mon rides the cable, then the sprite -- 16 units of 16px (256px total scroll) at 2px/frame = 128 frames (SCX 0 -> 256)
-- itself moves right and down into the right GB (Trade_AnimMonMoveVertical) if self.t <= 128 then
if self.t <= 80 then self.scx = math.min(256, self.t * 2)
self.scx = math.min(160, self.t * 2) self.monX, self.monY = 92, 28
self.monX, self.monY = 76, 12 elseif self.t <= 128 + 32 then
elseif self.t <= 80 + 32 then local step = math.floor((self.t - 128) / 8) + 1
self.monX = 76 + math.floor((self.t - 80) / 8) * 4 step = math.min(4, step)
elseif self.t <= 80 + 64 then self.scx = 256
self.monX = 92 self.monX = 92 + step * 4
self.monY = 12 + math.floor((self.t - 112) / 8) * 10 self.monY = 28
elseif self.t <= 128 + 64 then
local step = math.floor((self.t - 160) / 8) + 1
step = math.min(4, step)
self.scx = 256
self.monX = 108
self.monY = 28 + step * 10
else else
self:advance() self:advance()
return return
@@ -260,17 +485,22 @@ function TradeAnim:update(dt)
if skip then self:advance() end if skip then self:advance() end
elseif p == "transfer_rl" then elseif p == "transfer_rl" then
-- mirror: sprite climbs out of the right GB, then the screen scrolls -- 64 frames vertical ascent + 128 frames horizontal scroll (SCX 256 -> 0)
-- back to the left GB
if self.t <= 32 then if self.t <= 32 then
self.scx = 160 local step = math.floor(self.t / 8) + 1
self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10 step = math.min(4, step)
self.scx = 256
self.monX = 108
self.monY = 68 - step * 10
elseif self.t <= 64 then elseif self.t <= 64 then
self.monX = 92 - math.floor((self.t - 32) / 8) * 4 local step = math.floor((self.t - 32) / 8) + 1
self.monY = 12 step = math.min(4, step)
elseif self.t <= 64 + 80 then self.scx = 256
self.scx = math.max(0, 160 - (self.t - 64) * 2) self.monX = 108 - step * 4
self.monX, self.monY = 76, 12 self.monY = 28
elseif self.t <= 64 + 128 then
self.scx = math.max(0, 256 - (self.t - 64) * 2)
self.monX, self.monY = 92, 28
else else
self:advance() self:advance()
return return
@@ -282,54 +512,185 @@ function TradeAnim:update(dt)
if skip or self.t >= 100 then self:advance() end if skip or self.t >= 100 then self:advance() end
elseif p == "went_to" then elseif p == "went_to" then
if not self.sub then
self.sub = "text"
self.t = 0
local text = expand(self.game, "_TradeWentToText", { local text = expand(self.game, "_TradeWentToText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent), ["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
["RAM:wLinkEnemyTrainerName"] = self.enemyName, ["RAM:wLinkEnemyTrainerName"] = self.enemyName,
}) })
self:say(text, 200) self.dialogText = text
end
if self.sub == "text" then
if skip or self.t >= 200 then
self.sub = "slide_hold"
self.t = 0
end
elseif self.sub == "slide_hold" then
if skip or self.t >= 50 then
self.sub = "slide_off"
self.t = 0
self.slidingBox = true
end
elseif self.sub == "slide_off" then
self.wx = math.min(160, self.t * 2)
if skip or self.wx >= 160 then
self.wx = 160
self.sub = "slide_end_pause"
self.t = 0
end
elseif self.sub == "slide_end_pause" then
if skip or self.t >= 10 then
self.slidingBox = false
self.dialogText = nil
self.sub = nil
self:advance()
end
end
elseif p == "for_sends" then elseif p == "for_sends" then
local a = expand(self.game, "_TradeForText", { if not self.sub then
self.sub = "for_text"
self.t = 0
self.dialogText = expand(self.game, "_TradeForText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent), ["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
}) })
local b = expand(self.game, "_TradeSendsText", { end
if self.sub == "for_text" then
if skip or self.t >= 80 then
self.sub = "sends_text"
self.t = 0
self.dialogText = expand(self.game, "_TradeSendsText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName, ["RAM:wLinkEnemyTrainerName"] = self.enemyName,
["RAM:wNameBuffer"] = nameOf(self.game, self.received), ["RAM:wNameBuffer"] = nameOf(self.game, self.received),
}) })
self.waitingText = true end
self.game.stack:push(TextBox.new(self.game, a, function() elseif self.sub == "sends_text" then
self.game.stack:push(TextBox.new(self.game, b, function() if skip or self.t >= 80 then
self.waitingText = false self.dialogText = nil
self.sub = nil
self:advance() self:advance()
end, { auto = { delay = 80 } })) end
end, { auto = { delay = 80 } })) end
elseif p == "farewell" then elseif p == "farewell" then
local a = expand(self.game, "_TradeWavesFarewellText", { if not self.sub then
self.sub = "farewell_text"
self.t = 0
self.dialogText = expand(self.game, "_TradeWavesFarewellText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName, ["RAM:wLinkEnemyTrainerName"] = self.enemyName,
}) })
local b = expand(self.game, "_TradeTransferredText", { end
if self.sub == "farewell_text" then
if skip or self.t >= 80 then
self.sub = "transferred_text"
self.t = 0
self.dialogText = expand(self.game, "_TradeTransferredText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received), ["RAM:wNameBuffer"] = nameOf(self.game, self.received),
}) })
self.waitingText = true end
self.game.stack:push(TextBox.new(self.game, a, function() elseif self.sub == "transferred_text" then
self.game.stack:push(TextBox.new(self.game, b, function() if skip or self.t >= 80 then
self.waitingText = false self.sub = "slide_hold"
self.t = 0
end
elseif self.sub == "slide_hold" then
if skip or self.t >= 50 then
self.sub = "slide_off"
self.t = 0
self.slidingBox = true
end
elseif self.sub == "slide_off" then
self.wx = math.min(160, self.t * 2)
if skip or self.wx >= 160 then
self.wx = 160
self.sub = "slide_end_pause"
self.t = 0
end
elseif self.sub == "slide_end_pause" then
if skip or self.t >= 10 then
self.slidingBox = false
self.dialogText = nil
self.sub = nil
self:advance() self:advance()
end, { auto = { delay = 80 } })) end
end, { auto = { delay = 80 } })) end
elseif p == "show_enemy" then elseif p == "show_enemy" then
if self.t == 1 then -- Trade_ShowEnemyMon (engine/movie/trade.asm:354):
Sound.play(self.game.data, "Ball_Poof") -- 1. TRADE_BALL_TILT_ANIM with TradeJumpPokeball (8 bounce steps, 5 ticks each = 40 ticks):
-- - Starts at BaseCoord $84 (Screen X=72, Y=40)
-- - Moves Y by BallMoveDistances2 (+11, +12, -12, -7, +7, +12, -8, +8)
-- - On each step: Cable slides off-screen right by 8px
-- - Plays SFX_SWAP on impacts
-- 2. ClearScreen, Info box at (4, 10), Mon front sprite at (7, 2)
-- 3. TRADE_BALL_POOF_ANIM: FrameBlock 06, 07, 08 (18 ticks), SFX_BALL_POOF, revealing mon
-- 4. PlayCry
-- 5. Trade_Delay100
-- 6. Info box cleared, PrintTradeTakeCareText (80 ticks)
-- 7. Trade_Delay100 -> finish
if not self.sub then
self.sub = "ball_bounce"
self.t = 0
self.cableSlideOut = 0
self.activeBallBlock = 4
self.activeBallX, self.activeBallY = 72, 40
end
if self.sub == "ball_bounce" then
local step = math.floor(self.t / 5) + 1
if step > #BALL_BOUNCE_DISTANCES or skip then
self.sub = "poof"
self.t = 0
self.activeBallBlock = nil
self.monVisible = true self.monVisible = true
elseif self.t == 20 then self.cableSlideOut = nil
Sound.play(self.game.data, "Ball_Poof")
else
local yPos = 40
for i = 1, step do
yPos = yPos + BALL_BOUNCE_DISTANCES[i]
end
self.activeBallBlock = 4
self.activeBallX, self.activeBallY = 72, yPos
self.cableSlideOut = (step - 1) * 8
if self.t % 5 == 0 and (BALL_BOUNCE_DISTANCES[step] == 12 or step == #BALL_BOUNCE_DISTANCES) then
Sound.play(self.game.data, "Swap")
end
end
elseif self.sub == "poof" then
local step = math.floor(self.t / 6)
if step >= 3 or skip then
self.sub = "cry"
self.t = 0
self.activePoofBlock = nil
Sound.playCry(self.game.data, self.received.species) Sound.playCry(self.game.data, self.received.species)
elseif self.t >= 120 or skip then else
local text = expand(self.game, "_TradeTakeCareText", { self.activePoofBlock = 6 + step
self.activePoofX, self.activePoofY = 64, 48
end
elseif self.sub == "cry" then
if skip or self.t >= 100 then
self.sub = "take_care"
self.t = 0
self.dialogText = expand(self.game, "_TradeTakeCareText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received), ["RAM:wNameBuffer"] = nameOf(self.game, self.received),
}) })
self:say(text, 80) end
elseif self.sub == "take_care" then
if skip or self.t >= 80 then
self.sub = "delay_end"
self.t = 0
self.dialogText = nil
end
elseif self.sub == "delay_end" then
if skip or self.t >= 100 then
self.sub = nil
self:advance()
end
end end
end end
end end
@@ -337,10 +698,18 @@ end
local function drawCableHoriz(self, y, x0, x1) local function drawCableHoriz(self, y, x0, x1)
local w = math.max(0, x1 - x0) local w = math.max(0, x1 - x0)
if w <= 0 then return end if w <= 0 then return end
if self.cableFlash then
love.graphics.setColor(0.65, 0.65, 0.65, 1)
else
love.graphics.setColor(1, 1, 1, 1)
end
if self.img.cableHoriz then if self.img.cableHoriz then
local iw, ih = self.img.cableHoriz:getDimensions() local iw, ih = self.img.cableHoriz:getDimensions()
local quad = love.graphics.newQuad(0, 0, math.min(w, iw), ih, iw, ih) for x = x0, x1 - 1, iw do
love.graphics.draw(self.img.cableHoriz, quad, x0, y) local drawW = math.min(iw, x1 - x)
local quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
love.graphics.draw(self.img.cableHoriz, quad, x, y)
end
elseif self.img.cableSeg then elseif self.img.cableSeg then
for x = x0, x1 - 8, 8 do for x = x0, x1 - 8, 8 do
love.graphics.draw(self.img.cableSeg, x, y) love.graphics.draw(self.img.cableSeg, x, y)
@@ -348,13 +717,11 @@ local function drawCableHoriz(self, y, x0, x1)
else else
love.graphics.setColor(0.2, 0.2, 0.2, 1) love.graphics.setColor(0.2, 0.2, 0.2, 1)
love.graphics.rectangle("fill", x0, y + 1, w, 6) love.graphics.rectangle("fill", x0, y + 1, w, 6)
love.graphics.setColor(1, 1, 1, 1)
end end
love.graphics.setColor(1, 1, 1, 1)
end end
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
-- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the
-- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm)
Font.drawBox(4, boxTy, 12, 8) Font.drawBox(4, boxTy, 12, 8)
local y0 = boxTy * 8 local y0 = boxTy * 8
local no = ("No.%03d"):format(dexOf(self.game, mon)) local no = ("No.%03d"):format(dexOf(self.game, mon))
@@ -368,16 +735,6 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
-- Trade_WriteCircledMonOAM: the mon crosses the cable as its party-menu
-- sprite (wMonPartySpriteSpecies -> WriteMonPartySpriteOAMBySpecies), not as
-- its battle pic, and Trade_AnimCircledMon flips both it and the ring to
-- their second frame every step. The ring is four OAM blocks --
-- Trade_CircleOAMBlocks .OAMBlock0-3 at (8,8) (24,8) (8,24) (24,24) with the
-- X/Y flips -- so the 16x32 bubble sheet holds one quadrant per frame and the
-- circle it makes is 32x32 around the 16x16 icon. The icon rides OAM
-- block 0 and the circle blocks 1-4 (Trade_WriteCircleOAMBlock counts a up
-- from 1), and the lower OAM index wins overlap on DMG, so the icon draws
-- on top of the circle's filled interior. #750
function TradeAnim:drawIconInBubble(mon, x, y) function TradeAnim:drawIconInBubble(mon, x, y)
if self.img.bubble then if self.img.bubble then
if not self.bubbleQuad then if not self.bubbleQuad then
@@ -415,11 +772,14 @@ function TradeAnim:drawGameBoy(x, y)
end end
function TradeAnim:drawLeftGB() function TradeAnim:drawLeftGB()
-- cable from GB to the right edge if self.cableFlash then
love.graphics.setColor(0.65, 0.65, 0.65, 1)
end
if self.img.cableConn then if self.img.cableConn then
love.graphics.draw(self.img.cableConn, 88, 32) love.graphics.draw(self.img.cableConn, 88, 32)
end end
drawCableHoriz(self, 32, 96, 160) love.graphics.setColor(1, 1, 1, 1)
drawCableHoriz(self, 32, 96, 256)
self:drawGameBoy(40, 24) self:drawGameBoy(40, 24)
Font.drawBox(4, 12, 9, 4) Font.drawBox(4, 12, 9, 4)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
@@ -429,6 +789,9 @@ end
function TradeAnim:drawRightGB() function TradeAnim:drawRightGB()
drawCableHoriz(self, 32, 0, 112) drawCableHoriz(self, 32, 0, 112)
if self.cableFlash then
love.graphics.setColor(0.65, 0.65, 0.65, 1)
end
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
if self.img.cableVert then if self.img.cableVert then
for i = 1, 4 do for i = 1, 4 do
@@ -437,6 +800,7 @@ function TradeAnim:drawRightGB()
end end
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end
love.graphics.setColor(1, 1, 1, 1)
self:drawGameBoy(56, 64) self:drawGameBoy(56, 64)
Font.drawBox(6, 0, 9, 4) Font.drawBox(6, 0, 9, 4)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
@@ -444,6 +808,24 @@ function TradeAnim:drawRightGB()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
function TradeAnim:drawDialog()
if not self.dialogText then return end
love.graphics.push()
if self.slidingBox then
love.graphics.translate(self.wx, 0)
end
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local lines = {}
for line in self.dialogText:gmatch("[^\n]+") do
lines[#lines + 1] = line
end
if lines[1] then Font.draw(lines[1], 8, 112) end
if lines[2] then Font.draw(lines[2], 8, 128) end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.pop()
end
function TradeAnim:draw() function TradeAnim:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -452,10 +834,7 @@ function TradeAnim:draw()
if p == "show_player" then if p == "show_player" then
love.graphics.push() love.graphics.push()
love.graphics.translate(-self.scx, 0) love.graphics.translate(-self.scx, 0)
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
-- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then if self.monVisible and self.sentSprite then
-- engine/movie/trade.asm:751
love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1) love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1)
if self.sentSpriteTrueColor then if self.sentSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor( require("src.render.PaletteFX").markTrueColor(
@@ -464,25 +843,32 @@ function TradeAnim:draw()
end end
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10) self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
love.graphics.pop() love.graphics.pop()
if self.poof and self.poof > 0 then
love.graphics.setColor(0, 0, 0, self.poof / 16) if self.activePoofBlock then
love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0)) self:drawFrameBlock(self.activePoofBlock, self.activePoofX, self.activePoofY)
love.graphics.setColor(1, 1, 1, 1) end
if self.activeBallBlock then
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
end end
elseif p == "open_cable" or p == "open_cable2" then elseif p == "open_cable" or p == "open_cable2" then
love.graphics.push() love.graphics.push()
love.graphics.translate(self.scx, 0) love.graphics.translate(self.scx, 0)
if self.img.openCable then if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0 love.graphics.draw(self.img.openCable, 64, 16)
end end
love.graphics.pop() love.graphics.pop()
if p == "open_cable" and self.activeBallBlock then
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
end
elseif p == "ball_enter" then elseif p == "ball_enter" then
if self.img.openCable then if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16) love.graphics.draw(self.img.openCable, 64, 16)
end end
-- OAM coords carry a (+8, +16) hardware offset if self.activeBallBlock then
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
elseif self.sub == "suction" or self.sub == "exit_pause" then
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall) local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
or self.img.cableBall or self.img.cableBall
if ball then if ball then
@@ -492,36 +878,44 @@ function TradeAnim:draw()
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6) love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
end
elseif p == "transfer_lr" or p == "transfer_rl" then elseif p == "transfer_lr" or p == "transfer_rl" then
-- two-screen world: left GB scene at x0, right GB scene at x160;
-- the mon icon stays in screen space like the OAM sprite it ports
love.graphics.push() love.graphics.push()
love.graphics.translate(-self.scx, 0) love.graphics.translate(-self.scx, 0)
self:drawLeftGB() self:drawLeftGB()
love.graphics.translate(160, 0) love.graphics.translate(256, 0)
self:drawRightGB() self:drawRightGB()
love.graphics.pop() love.graphics.pop()
local mon = p == "transfer_lr" and self.sent or self.received local mon = p == "transfer_lr" and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY) self:drawIconInBubble(mon, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "show_enemy" then elseif p == "show_enemy" then
if self.cableSlideOut and self.img.openCable then
love.graphics.push()
love.graphics.translate(self.cableSlideOut, 0)
love.graphics.draw(self.img.openCable, 64, 16)
love.graphics.pop()
end
if self.monVisible and self.recvSprite then if self.monVisible and self.recvSprite then
-- engine/movie/trade.asm:751
love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1) love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1)
if self.recvSpriteTrueColor then if self.recvSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor( require("src.render.PaletteFX").markTrueColor(
56, 16, self.recvSprite:getDimensions()) 56, 16, self.recvSprite:getDimensions())
end end
end end
if self.monVisible and self.sub ~= "take_care" and self.sub ~= "delay_end" then
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10) self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end end
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws if self.activeBallBlock then
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
end
if self.activePoofBlock then
self:drawFrameBlock(self.activePoofBlock, self.activePoofX, self.activePoofY)
end
end
self:drawDialog()
end end
return TradeAnim return TradeAnim
+150 -31
View File
@@ -22,12 +22,14 @@
-- PAL_BG_YELLOW, out of a TOWN palette set at the current time of day -- PAL_BG_YELLOW, out of a TOWN palette set at the current time of day
-- (the routine pushes wEnvironment, forces TOWN, and pops it back). -- (the routine pushes wEnvironment, forces TOWN, and pops it back).
-- --
-- Two hardware details are not reproduced. The four player OBJs all carry -- Two hardware details from the cart are faithfully reproduced:
-- OAM_PRIO, so on the cart they sit behind background colours 1-3 and only -- 1. Viewport wrapping: the 256x144 background scrolls in 3 LY override bands,
-- show through the window's colour 0; here they are drawn straight over the -- wrapped strictly within the 160x144 GB screen so pixels do not bleed into
-- background, which looks the same everywhere the window is transparent and -- the widescreen surround.
-- differs only if the sprite drifts over solid train tiles. And the ride -- 2. OAM Priority (OAM_PRIO): the four player OBJs sit behind background
-- cannot be skipped, exactly as on the cart: the loop reads no input at all. -- colours 1-3 and only show through the window's colour 0. This is drawn
-- via a base background pass, the player sprite, and an overlay pass where
-- shade 0 is transparent and solid train tiles mask the sprite.
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome") local Chrome = require("src.ui.gen2.Chrome")
@@ -46,6 +48,30 @@ local BG_W = 256 -- TILEMAP_WIDTH * 8
local TILES_PER_ROW = 16 -- every 2bpp sheet the importer writes local TILES_PER_ROW = 16 -- every 2bpp sheet the importer writes
local BLACK = { 0, 0, 0 } local BLACK = { 0, 0, 0 }
-- Overlay shader for OAM_PRIO: shade 0 is transparent; shades 1-3 are solid
-- palette colors, masking any sprites beneath solid background tiles.
local OVERLAY_SHADER_SOURCE = [[
extern vec3 pal0;
extern vec3 pal1;
extern vec3 pal2;
extern vec3 pal3;
vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) {
vec4 px = Texel(tex, uv);
float shade = floor((1.0 - px.r) * 3.0 + 0.5);
vec3 rgb = pal0;
if (shade > 2.5) {
rgb = pal3;
} else if (shade > 1.5) {
rgb = pal2;
} else if (shade > 0.5) {
rgb = pal1;
}
float alpha = shade < 0.5 ? 0.0 : px.a;
return vec4(rgb, alpha) * tint;
}
]]
function MagnetTrainRide:wantsFillScale() return true end function MagnetTrainRide:wantsFillScale() return true end
-- opts: toGoldenrod (the wScriptVar the officer's script set), onDone() -- opts: toGoldenrod (the wScriptVar the officer's script set), onDone()
@@ -97,7 +123,7 @@ function MagnetTrainRide:palette(slot)
end end
-------------------------------------------------------------------------- --------------------------------------------------------------------------
-- Sheets -- Sheets & Shaders
-------------------------------------------------------------------------- --------------------------------------------------------------------------
local function sheetFor(path) local function sheetFor(path)
@@ -159,22 +185,66 @@ function MagnetTrainRide:playerQuad(vtile)
return quad return quad
end end
function MagnetTrainRide:overlayShader()
if self.ovShader ~= nil then return self.ovShader or nil end
if not (love and love.graphics and love.graphics.newShader) then
self.ovShader = false
return nil
end
local ok, result = pcall(love.graphics.newShader, OVERLAY_SHADER_SOURCE)
self.ovShader = ok and result or false
return self.ovShader or nil
end
local function palChannel(colors, index)
local c = colors and colors[index]
if not c then return 0, 0, 0 end
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
end
function MagnetTrainRide:useOverlayShader(colors)
local sh = self:overlayShader()
if not sh then return false end
local resolved = GbcPalette.resolve(colors)
for i = 0, 3 do
local u = "pal" .. i
if sh:hasUniform(u) then
local r, g, b = palChannel(resolved, i + 1)
sh:send(u, { r, g, b })
end
end
love.graphics.setShader(sh)
return true
end
-------------------------------------------------------------------------- --------------------------------------------------------------------------
-- The baked background -- The baked backgrounds (Base + OAM_PRIO Overlay)
-------------------------------------------------------------------------- --------------------------------------------------------------------------
-- DrawMagnetTrain plus SetMagnetTrainPals, rendered once into a 256x144 -- DrawMagnetTrain plus SetMagnetTrainPals, rendered once into 256x144 canvases.
-- canvas. Tiles are drawn palette group by palette group so the three -- Base canvas contains the full opaque background; Overlay canvas contains
-- palettes cost three shader switches rather than one per tile. -- solid shades 1-3 with shade 0 transparent for OAM_PRIO window masking.
function MagnetTrainRide:background() function MagnetTrainRide:backgrounds()
if self.bgCanvas ~= nil then return self.bgCanvas or nil end if self.bgCanvas ~= nil then
return self.bgCanvas or nil, self.bgOverlayCanvas or nil
end
self.bgCanvas = false self.bgCanvas = false
self.bgOverlayCanvas = false
local rows = self.ride:tilemap() local rows = self.ride:tilemap()
local sheet = self:tileSheet() local sheet = self:tileSheet()
if not (rows and sheet) then return nil end if not (rows and sheet) then return nil, nil end
local ok, canvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H)
if not ok then return nil end local ok1, baseCanvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H)
canvas:setFilter("nearest", "nearest") if not ok1 then return nil, nil end
baseCanvas:setFilter("nearest", "nearest")
local ok2, overlayCanvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H)
if ok2 and overlayCanvas then
overlayCanvas:setFilter("nearest", "nearest")
else
overlayCanvas = nil
end
local groups = {} local groups = {}
for row = 1, #rows do for row = 1, #rows do
@@ -188,11 +258,12 @@ function MagnetTrainRide:background()
local G = love.graphics local G = love.graphics
local previous = G.getCanvas() local previous = G.getCanvas()
-- A canvas does not reset the transform: without this the map lands under G.push("all")
-- the renderer's letterbox scale and off the edge.
G.push()
G.origin() G.origin()
G.setCanvas(canvas) G.setScissor()
-- 1. Base background canvas (shades 0-3)
G.setCanvas(baseCanvas)
G.clear(0, 0, 0, 1) G.clear(0, 0, 0, 1)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
local shader = GbcPalette.available() local shader = GbcPalette.available()
@@ -205,10 +276,35 @@ function MagnetTrainRide:background()
end end
end end
if shader then GbcPalette.clear() end if shader then GbcPalette.clear() end
-- 2. Overlay canvas for OAM_PRIO (shades 1-3 solid, shade 0 transparent)
if overlayCanvas then
G.setCanvas(overlayCanvas)
G.clear(0, 0, 0, 0)
G.setColor(1, 1, 1, 1)
local ovShader = self:overlayShader()
for slot, list in pairs(groups) do
local colors = self:palette(slot)
if ovShader and colors then self:useOverlayShader(colors) end
for _, cell in ipairs(list) do
local quad = sheet.quads[cell[1]]
if quad then G.draw(sheet.image, quad, cell[2], cell[3]) end
end
end
if ovShader then G.setShader() end
end
G.setCanvas(previous) G.setCanvas(previous)
G.pop() G.pop()
self.bgCanvas = canvas
return canvas self.bgCanvas = baseCanvas
self.bgOverlayCanvas = overlayCanvas
return baseCanvas, overlayCanvas
end
function MagnetTrainRide:background()
local base = self:backgrounds()
return base
end end
-------------------------------------------------------------------------- --------------------------------------------------------------------------
@@ -240,19 +336,24 @@ function MagnetTrainRide:update(_dt)
end end
end end
function MagnetTrainRide:drawBackground() -- Draws the 3 LY override bands strictly wrapped inside the 160px GB screen.
local canvas = self:background() function MagnetTrainRide:drawBands(canvas)
if not canvas then return end if not canvas then return end
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
self.bandQuad = self.bandQuad self.bandQuad = self.bandQuad
or love.graphics.newQuad(0, 0, BG_W, 1, BG_W, SCREEN_H) or love.graphics.newQuad(0, 0, SCREEN_W, 1, BG_W, SCREEN_H)
for _, band in ipairs(self.ride:bands()) do for _, band in ipairs(self.ride:bands()) do
local top, bottom, scx = band[1], band[2], band[3] % BG_W local top, bottom, scx = band[1], band[2], band[3] % BG_W
local height = bottom - top + 1 local height = bottom - top + 1
self.bandQuad:setViewport(0, top, BG_W, height, BG_W, SCREEN_H) local w1 = math.min(SCREEN_W, BG_W - scx)
G.draw(canvas, self.bandQuad, -scx, top) self.bandQuad:setViewport(scx, top, w1, height, BG_W, SCREEN_H)
G.draw(canvas, self.bandQuad, -scx + BG_W, top) G.draw(canvas, self.bandQuad, 0, top)
if w1 < SCREEN_W then
local w2 = SCREEN_W - w1
self.bandQuad:setViewport(0, top, w2, height, BG_W, SCREEN_H)
G.draw(canvas, self.bandQuad, w1, top)
end
end end
end end
@@ -277,6 +378,7 @@ function MagnetTrainRide:drawPlayer()
if colors then GbcPalette.use(colors) end if colors then GbcPalette.use(colors) end
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
for _, entry in ipairs(oam) do for _, entry in ipairs(oam) do
if entry.x >= -8 and entry.x < SCREEN_W and entry.y >= -16 and entry.y < SCREEN_H then
local quad = self:playerQuad(entry.tile) local quad = self:playerQuad(entry.tile)
if quad then if quad then
G.draw(sheet.image, quad, G.draw(sheet.image, quad,
@@ -284,9 +386,11 @@ function MagnetTrainRide:drawPlayer()
entry.xflip and -1 or 1, 1) entry.xflip and -1 or 1, 1)
end end
end end
end
if colors then GbcPalette.clear() end if colors then GbcPalette.clear() end
end end
-- The backdrop is BG colour 0 of the gray palette the train body uses; on the -- The backdrop is BG colour 0 of the gray palette the train body uses; on the
-- cart it is what shows wherever nothing was drawn. -- cart it is what shows wherever nothing was drawn.
function MagnetTrainRide:backdrop() function MagnetTrainRide:backdrop()
@@ -299,13 +403,24 @@ function MagnetTrainRide:drawPanel()
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
self:drawBackground()
local base, overlay = self:backgrounds()
-- 1. Base background (bushes, train body, window aperture)
self:drawBands(base)
-- 2. Player sprite (OAM_PRIO: sits behind train body, visible through window)
self:drawPlayer() self:drawPlayer()
-- 3. Train body / frame overlay (colors 1-3 mask sprite; color 0 is transparent)
self:drawBands(overlay)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
end end
function MagnetTrainRide:draw() function MagnetTrainRide:draw()
local G = love.graphics
G.push("all")
G.setScissor(0, 0, SCREEN_W, SCREEN_H)
self:drawPanel() self:drawPanel()
G.pop()
end end
-- MagnetTrain_LoadGFX_PlayMusic opens on ClearBGPalettes / ClearSprites -- MagnetTrain_LoadGFX_PlayMusic opens on ClearBGPalettes / ClearSprites
@@ -323,7 +438,8 @@ function MagnetTrainRide:drawWidescreen(winW, winH)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push("all")
G.setScissor(ox, oy, SCREEN_W * scale, SCREEN_H * scale)
G.translate(ox, oy) G.translate(ox, oy)
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
@@ -331,3 +447,6 @@ function MagnetTrainRide:drawWidescreen(winW, winH)
end end
return MagnetTrainRide return MagnetTrainRide
@@ -77,4 +77,26 @@ do
check(Runtime.modRequire == nil, "Runtime.reset clears modRequire") check(Runtime.modRequire == nil, "Runtime.reset clears modRequire")
end end
-- 4. returnToLauncher / closeEditor also clear Assets.loader (orphaned Loader)
do
local Assets = require("src.render.Assets")
Assets.installLoader({
overrideOrder = function() return { { id = "x", path = "mods/x" } } end,
derivedPath = function() return nil end,
})
check(Assets.loader ~= nil, "Assets.loader installed for the session")
Assets.installLoader(nil)
check(Assets.loader == nil, "session teardown clears Assets.loader")
end
-- 5. Gen1 Game:reset exists so main.lua need not guess field names
do
local Game = require("src.core.Game")
check(type(Game.reset) == "function", "Game:reset is defined for in-process return")
Game.mods = { stale = true }
Game:reset()
check(Game.mods == nil, "Game:reset clears session fields")
check(type(Game.load) == "function", "Game:reset keeps methods")
end
T.finish("android_exit_to_launcher_test") T.finish("android_exit_to_launcher_test")
@@ -57,6 +57,36 @@ eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
eq(love.filesystem.read("data/generated/constants.lua"), "return {}", eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
"post-mount probe reads red data at the un-prefixed path") "post-mount probe reads red data at the un-prefixed path")
-- unmountVersion peels generated overlays (LIFO) then the version folder, so
-- a later Play/Edit on another game cannot resolve this version's files.
do
local mounts = love.filesystem._mounts
local archives = {}
for _, m in ipairs(mounts) do archives[#archives + 1] = m.archive end
local function indexOf(name)
for i, a in ipairs(archives) do if a == name then return i end end
end
local dataIdx = indexOf("red/data/generated")
local assetsIdx = indexOf("red/assets/generated")
check(dataIdx and assetsIdx, "mountVersion recorded generated-tree overlays")
check(assetsIdx > dataIdx,
"assets/generated was mounted after data/generated (LIFO unmount peels it first)")
end
check(CacheFs.unmountVersion("red") == true, "unmountVersion(red) returns true")
check(love.filesystem.read("assets/generated/fonts/font.png") == nil,
"post-unmount probe no longer sees red assets at the un-prefixed path")
check(love.filesystem.read("data/generated/constants.lua") == nil,
"post-unmount probe no longer sees red data at the un-prefixed path")
do
local left = {}
for _, m in ipairs(love.filesystem._mounts) do
if tostring(m.archive):find("red", 1, true) then
left[#left + 1] = m.archive
end
end
eq(#left, 0, "no red/ mounts remain after unmountVersion")
end
-- no legacy cache at all: migration is a no-op, not an error -- no legacy cache at all: migration is a no-op, not an error
love.filesystem.remove("red/rom-cache.complete") love.filesystem.remove("red/rom-cache.complete")
love.filesystem.remove("red/data/generated/maps.lua") love.filesystem.remove("red/data/generated/maps.lua")
@@ -0,0 +1,111 @@
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
-- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush.
-- luajit tests/engine/launcher_session_teardown_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Runtime = require("src.mods.Runtime")
local Assets = require("src.render.Assets")
local LegacyCompat = require("src.mods.LegacyCompat")
local Game = require("src.core.Game")
local Renderer = require("src.render.Renderer")
local StateStack = require("src.core.StateStack")
-- ---- Game:reset drops instance state, keeps methods ----------------------
do
StateStack:init()
StateStack:push({ name = "stale" })
Game.mods = { id = "orphan" }
Game.save = { money = 1 }
Game.network = { live = true } -- future field: must not need a whitelist
Game.stack = StateStack
Game.renderer = Renderer
Renderer.canvas = love.graphics.newCanvas(8, 8)
Game:reset()
check(type(Game.load) == "function", "Game:reset keeps methods")
check(type(Game.reset) == "function", "Game:reset keeps itself")
check(Game.mods == nil, "Game:reset clears mods")
check(Game.save == nil, "Game:reset clears save")
check(Game.network == nil, "Game:reset clears arbitrary future fields")
check(Game.stack == nil, "Game:reset clears stack reference")
check(Game.renderer == nil, "Game:reset clears renderer reference")
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
end
-- ---- Renderer:init releases prior canvases before realloc ----------------
do
local first = love.graphics.newCanvas(16, 16)
Renderer.canvas = first
Renderer.battleHUDCanvas = love.graphics.newCanvas(16, 16)
Renderer.worldCanvas = love.graphics.newCanvas(16, 16)
Renderer.uprightCanvas = love.graphics.newCanvas(16, 16)
Renderer:init()
check(first.released == true,
"Renderer:init Object:release()s the previous primary canvas")
check(Renderer.canvas ~= nil and Renderer.canvas ~= first,
"Renderer:init allocates a fresh primary canvas")
check(Renderer.canvas.released ~= true,
"the new primary canvas is not released")
-- second init also releases the one just created
local second = Renderer.canvas
Renderer:init()
check(second.released == true,
"a second Renderer:init releases the canvas from the prior init")
end
-- ---- Shared singleton teardown contract (closeEditor / returnToLauncher)
do
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
Assets.installLoader({
overrideOrder = function() return {} end,
derivedPath = function() return nil end,
})
LegacyCompat.reports = { some_mod = { order = {} } }
-- Mirrors main.lua teardownMountedSession without mounting CacheFs.
require("src.core.Data"):unloadGenerated()
Runtime.reset()
Assets.installLoader(nil)
LegacyCompat.reset()
check(Runtime.errors == nil, "teardown clears Runtime.errors")
check(Assets.loader == nil, "teardown clears Assets.loader")
eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports")
end
-- ---- Editor package.loaded discovery flush (no panel whitelist) ---------
do
love.filesystem.write("tools/save-editor/App.lua", "return {}")
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
package.loaded["App"] = { stale = true }
package.loaded["NewPanel"] = { stale = true }
package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep
local function isEditorFlat(name)
if name:find("[./]") then return false end
return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
end
for k in pairs(package.loaded) do
if type(k) == "string"
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
package.loaded[k] = nil
end
end
check(package.loaded["App"] == nil, "discovery flush drops flat App")
check(package.loaded["NewPanel"] == nil,
"discovery flush drops a new panel without a hardcoded list")
check(package.loaded["src.core.Data"] ~= nil,
"discovery flush leaves engine modules alone")
love.filesystem.remove("tools/save-editor/App.lua")
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
end
T.finish("launcher_session_teardown_test")
+12
View File
@@ -266,6 +266,18 @@ do
"and with no hook at all the special is a no-op, not a clobber") "and with no hook at all the special is a no-op, not a clobber")
end end
-- ---- MagnetTrainRide UI screen --------------------------------------------
do
local MagnetTrainRide = require("src.ui.gen2.MagnetTrainRide")
check(MagnetTrainRide.isOpaque, "MagnetTrainRide is opaque")
local ride = MagnetTrainRide.new(nil, { toGoldenrod = false })
check(ride:wantsFillScale(), "wantsFillScale returns true")
check(ride:drawsWidescreen(), "drawsWidescreen returns true")
check(type(ride.drawBands) == "function", "drawBands exists")
check(type(ride.backgrounds) == "function", "backgrounds exists for OAM_PRIO")
end
-- ---- the arrival, from `warpcheck` to the officer's line ------------------- -- ---- the arrival, from `warpcheck` to the officer's line -------------------
-- --
-- The ride is only half the feature: the two station scripts end -- The ride is only half the feature: the two station scripts end
+3 -1
View File
@@ -56,7 +56,9 @@ stub.graphics = {
end, end,
newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end,
newCanvas = function(w, h) newCanvas = function(w, h)
return setmetatable({ w = w, h = h, setFilter = noop }, Image) local canvas = setmetatable({ w = w, h = h, setFilter = noop, released = false }, Image)
function canvas:release() self.released = true end
return canvas
end, end,
newSpriteBatch = function(image, size) newSpriteBatch = function(image, size)
local batch = { image = image, sprites = {} } local batch = { image = image, sprites = {} }
+22
View File
@@ -143,6 +143,28 @@ local ok, err = pcall(function()
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON", check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
"validate keeps the modded mon while the mod is enabled") "validate keeps the modded mon while the mod is enabled")
-- Second editor session in the same process (launcher Close → Edit again):
-- host teardown must leave Runtime/Assets clean and Data unloaded so the
-- next App.load remounts mods instead of serving vanilla catalogs.
App.unload()
if Data._pristineKeys or Data.pokemon then Data:unloadGenerated() end
Runtime.reset()
Assets.installLoader(nil)
package.loaded["App"] = nil
package.loaded["Catalog"] = nil
App = require("App")
App.load(tmpPath)
S = App.getState()
loaded = S.mods:status().loaded
check(#loaded == 1 and loaded[1].id == "zz_editor_fixture",
"fixture mod loads again after host-style teardown")
hasSpecies = false
for _, id in ipairs(S.cat.species) do
if id == "EDITMON" then hasSpecies = true end
end
check(hasSpecies, "second App.load catalog still carries the mod's mon")
check(Data.pokemon.EDITMON ~= nil, "second merge lands EDITMON in Data again")
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only -- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
-- manifest stays out of Gold (no ROM cache / App.load("gold") required). -- manifest stays out of Gold (no ROM cache / App.load("gold") required).
local ModTargets = require("src.mods.ModTargets") local ModTargets = require("src.mods.ModTargets")
+48
View File
@@ -0,0 +1,48 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("yellow title palette")
local check, eq = S.check, S.eq
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local TitleState = require("src.ui.TitleState")
GameVersion.set("yellow")
local prevMode = PaletteFX.mode
-- Test 1: SGB mode
PaletteFX.setMode("gbc")
local mewmonSgb = PaletteFX.pal(nil, "MEWMON")
check(mewmonSgb ~= nil, "MEWMON palette exists in SGB mode on Yellow")
-- In Yellow SGB SuperPalettes: MEWMON color 1 is Yellow {255, 247, 181}, color 2 is Red {222, 132, 132}
eq(mewmonSgb[1][1], 255, "SGB: Color 0 is white R=255")
eq(mewmonSgb[2][1], 255, "SGB: Color 1 is yellow R=255")
eq(mewmonSgb[3][1], 222, "SGB: Color 2 is red R=222")
-- Test 2: ADVANCED mode (redpp)
PaletteFX.setMode("redpp")
local mewmonAdv = PaletteFX.pal(nil, "MEWMON")
check(mewmonAdv ~= nil, "MEWMON palette exists in ADVANCED mode on Yellow")
-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu palette!
eq(mewmonAdv[1][1], 255, "ADVANCED: Color 0 is white R=255 (Pikachu eye sclera)")
eq(mewmonAdv[2][1], 255, "ADVANCED: Color 1 is yellow R=255 (Pikachu body)")
check(mewmonAdv[3][3] < 150, "ADVANCED: Color 2 is not purple (B < 150, red cheeks)")
-- Test 3: OG YELLOW mode (ogred on Yellow)
PaletteFX.setMode("ogred")
local mewmonOg = PaletteFX.pal(nil, "MEWMON")
check(mewmonOg ~= nil, "MEWMON palette exists in OG YELLOW mode")
eq(mewmonOg[1][1], 255, "OG YELLOW: Color 0 is white")
eq(mewmonOg[2][1], 255, "OG YELLOW: Color 1 is yellow")
-- Test 4: TitleState sgbPalettes
local fakeGame = { data = { palettes = PaletteFX.yellowPack() } }
local ts = { yellowLayout = true }
local zones = TitleState.sgbPalettes(ts, fakeGame)
check(zones ~= nil and #zones >= 3, "TitleState produces zones for Yellow")
eq(zones[1].colors, PaletteFX.pal(fakeGame.data, "LOGO2"), "Zone 1 uses LOGO2")
eq(zones[2].colors, PaletteFX.pal(fakeGame.data, "MEWMON"), "Zone 2 uses MEWMON (Pikachu yellow/red)")
PaletteFX.setMode(prevMode)
GameVersion.set("red")
S.finish()