CLOSES #339, CLOSES #354, CLOSES #360, CLOSES #372, CLOSES #373, CLOSES #374, CLOSES #375, CLOSES #378, CLOSES #379, CLOSES #383, CLOSES #384, CLOSES #385, CLOSES #391, CLOSES #392, CLOSES #393, CLOSES #394, CLOSES #395, CLOSES #396, CLOSES #397, CLOSES #398, CLOSES #413, CLOSES #420, CLOSES #423, CLOSES #424, CLOSES #425, CLOSES #426, CLOSES #427, CLOSES #429, CLOSES #430, CLOSES #431, CLOSES #433, CLOSES #435, CLOSES #436, CLOSES #438, CLOSES #439, CLOSES #441, CLOSES #442, CLOSES #444

This commit is contained in:
bryanthaboi
2026-07-30 11:14:51 -04:00
parent 0f581e2f69
commit 4b9a326f6b
46 changed files with 4171 additions and 173 deletions
+64 -7
View File
@@ -4,7 +4,7 @@
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
-- until one side is out, then finish. Pops itself and calls
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
-- onFinish("win"|"lose"|"run"|"caught").
--
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
-- bide, recharge, confusion, screens, substitute, transform, ...) is
@@ -547,6 +547,19 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
end
end
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
-- the class and the name "ROCKET" but battle behind their own pic
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
-- in a Yellow cache extracted after #439, so an older cache keeps the
-- grunt pic until it is re-imported.
function BattleState.trainerPicPath(trainer, oppClass, partyIndex)
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
and trainer.picJessieJames then
return trainer.picJessieJames
end
return trainer.pic
end
function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
@@ -608,7 +621,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON"))
self.trainerPic = getImage(
BattleState.trainerPicPath(self.trainer, oppClass, partyIndex),
namedPalette(game.data, "MEWMON"))
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
return self
end
@@ -1115,10 +1130,34 @@ function BattleState:battleKind()
end
function BattleState:enter()
if self.dead then
-- Out of useable POKéMON before the battle even starts. pokered does not
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
-- player blacks out and afterBattle's "lose" path revives the party at the
-- last heal point. Handing the map back with a 0 HP party instead bricked
-- the save: every later encounter aborted here too and sighted trainers
-- re-engaged forever (#425). self.dead alone is not the test --
-- makeOldManDemo and LinkBattle install a player battler after the
-- constructor flagged the battle dead.
if self.dead and not self.player then
local name = self.game.save.player.name
self.result = "lose"
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
if self.onFinish then self.onFinish("skipped") end
require("src.core.Music").restoreMap(self.data)
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
local onFinish = self.onFinish
local function blackedOut()
if onFinish then onFinish("lose") end
end
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
-- afterBattle keeps the player in the lab, so print nothing there
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
-- playerMonFainted queues on the battle screen; there is no battle
-- screen to queue them on here, so they print over the map.
self.game.stack:push(require("src.render.TextBox").new(self.game,
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
.. Strings("%s blacked\nout!", name), blackedOut))
return
end
local Music = require("src.core.Music")
@@ -1956,6 +1995,15 @@ function BattleState:resolveSwitch(newMon)
end
function BattleState:endOfTurn()
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
-- sweep and the trapping-counter release are skipped on the turn a
-- Teleport escape (or a win/loss/capture) settles it (#441). The
-- turn_ended hook still fires: mods count turns, not residuals.
if self.result then
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
return
end
-- sideToxic mirrors w*ToxicCounter: it advances only while the
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
-- cure clears the flag but NOT the side counter, and a fresh Toxic
@@ -2626,6 +2674,12 @@ function BattleState:syncShownStatus()
end
function BattleState:executeAction(user, target, action)
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
-- the turn where it lands and the second mover never moves. self.result
-- is only ever set once the battle is over (run/win/lose/caught), and the
-- faint cases are already covered by the HP guard below (#441)
if self.result then return end
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end
@@ -3920,12 +3974,15 @@ function BattleState:throwBall(ball)
self:act(function() self:endOfTurn() end)
return
end
if self.ghost then
if self.ghost or self.noCatch then
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
-- the ball is thrown (TossBallAnimation still picks the arc from
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
-- ($10 anim data, no wobbles), and the turn is spent like any
-- failed throw
-- failed throw. battle.noCatch is the .notOldManBattle half of the
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
-- so it is not a ghost battle any more (#444)
self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext(Strings("It dodged the\nthrown BALL!"))
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
+5 -2
View File
@@ -32,17 +32,20 @@ local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
-- Volume: 1 = authentic, 0 = mute, >1 boosts
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
-- countermelodies an octave low (#429), and ChipSynth already applies the
-- wave channel's own hardware octave (frequency * 0.5).
-- ---------------------------------------------------------------------------
local CHANNEL_VOLUME = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.25, -- wave
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
local CHANNEL_PITCH = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.5, -- wave
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
+42 -3
View File
@@ -179,6 +179,38 @@ local function resolveUnmount()
return physfsUnmountFn
end
-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir`
-- is already somewhere in the search path. withMounted needs it because
-- PHYSFS_mount reports success for an already-mounted directory without
-- adding a second entry, so its unmount would drop a mount it did not make
-- (#413).
local physfsMountPointFn = nil
local function resolveMountPoint()
if physfsMountPointFn ~= nil then return physfsMountPointFn end
physfsMountPointFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountPointFn end
pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_getMountPoint end)
if oks and fn then
physfsMountPointFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= nil and ret ~= ffi.NULL
end
break
end
end
end
return physfsMountPointFn
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
@@ -398,13 +430,20 @@ end
-- resolves -- which is what makes it safe to point at a folder whose contents
-- nobody has validated.
--
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
-- mount was refused), which callers must treat as "could not look", not as
-- "nothing there". An error inside fn still unmounts before it propagates.
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
-- mount was refused, or `dir` is already on the read path), which callers must
-- treat as "could not look", not as "nothing there". An error inside fn still
-- unmounts before it propagates.
function CacheFs.withMounted(dir, mountPoint, fn)
if not dir or dir == "" then return nil end
local mount, unmount = resolveMount(), resolveUnmount()
if not (mount and unmount) then return nil end
-- A directory already in the search path cannot be borrowed: PHYSFS_mount
-- returns success without adding an entry, and the unmount below would then
-- remove the mount somebody else is relying on -- for the portable game
-- folder, the one that makes its cache and mods readable at all (#413)
local mountedAt = resolveMountPoint()
if mountedAt and mountedAt(dir) then return nil end
if not mount(dir, mountPoint, true) then return nil end
local ok, res = pcall(fn)
unmount(dir)
+10
View File
@@ -1307,6 +1307,16 @@ function RomExtractor:extractTrainers()
}
self:tick("Trainers", index, #order)
end
-- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic
-- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has
-- the symbol, so Red and Blue skip this (#439).
if self.symbols.JessieJamesPic then
local relative = "battle/trainers/jessie_james.png"
self:writeCompressedPic("JessieJamesPic", relative)
if out.OPP_ROCKET then
out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative
end
end
self:write("trainers", out)
return out
end
+172 -48
View File
@@ -35,6 +35,13 @@ local REQUIRED_FILES = {
"assets/generated/audio/programs.bin",
}
-- Files only one version's cache carries. A version that predates one of
-- them re-imports on its own, without dragging the other versions through a
-- CACHE_FORMAT bump.
local VERSION_REQUIRED_FILES = {
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439
}
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
-- the Claude Design project): a dark neon arcade panel, one column per game.
-- Red, Blue, and Yellow share the same importer flow once listed in
@@ -99,6 +106,9 @@ local function allRequiredFilesExist(version)
for _, path in ipairs(REQUIRED_FILES) do
if not CacheFs.exists(path) then ok = false; break end
end
for _, path in ipairs(ok and VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end
end
CacheFs.prefix = saved
return ok
end
@@ -321,18 +331,46 @@ local function findPendingRom(ready)
return nil
end
-- GameActivity always writes the SAF pick to picked_rom.gb, so a leftover
-- under that exact basename is the file the player just chose and
-- findPendingRom silently refused: wrong size, or a hacked/overdumped cart
-- whose SHA-1 matches no known version ([b]/[BF] dumps never will). Route it
-- through startData so the launcher says which of the two it was instead of
-- staying on "No ROM imported" with no message at all (issue #442), and drop
-- the file so the next tap starts from a clean slate. A cart that is simply
-- already imported is not an error -- #167 skips it on purpose -- so leave
-- that one alone.
local function consumePickedRomError(self)
local preferred = "picked_rom.gb"
if not love.filesystem.getInfo(preferred, "file") then return false end
local data = love.filesystem.read(preferred)
if type(data) == "string" and #data == 1024 * 1024 then
local version = GameVersion.forSha1(sha1(data))
if version and self.ready[version] then return false end
end
love.filesystem.remove(preferred)
if type(data) ~= "string" then
self:setError("The picked file could not be read. Reopen the picker and "
.. "choose the ROM with the Files (Documents) app.")
return true
end
self:startData(data, preferred)
return true
end
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
-- copies (Choose / Import); focus only consumes the SAF basename so a random
-- leftover archive is never auto-installed on every refocus.
local function findPendingMod(preferAny)
local function findPendingMod(preferAny, skip)
local preferred = "picked_mod.zip"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
if name:lower():match("%.zip$") and not (skip and skip[name])
and love.filesystem.getInfo(name, "file") then
return name
end
end
@@ -340,20 +378,36 @@ local function findPendingMod(preferAny)
end
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
local function findPendingSav(preferAny)
local function findPendingSav(preferAny, skip)
local preferred = "picked_save.sav"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
if name:lower():match("%.sav$") and not (skip and skip[name])
and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
-- Retire an Android pick once it has been through the installer / importer,
-- whether or not it worked: a pick left on disk wins the scans above forever,
-- so the next tap re-runs the same failing file and the picker never reopens
-- (#420). The SAF basename is GameActivity's own copy of the pick and is
-- always deleted; a USB copy is the player's file, so a failed one is only
-- skipped for the rest of the session.
local function consumePick(self, name, safName, ok)
if ok or name == safName then
love.filesystem.remove(name)
return
end
self.pickSkip = self.pickSkip or {}
self.pickSkip[name] = true
end
local function chooseRom(promptName)
promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM"
@@ -568,7 +622,13 @@ function RomImporter.new(onComplete, opts)
end
if android and needRom then
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
if name then
self:startData(data, name)
else
-- The picker runs as its own activity and Android may kill us while it
-- is up, so a rejected pick can outlive the focus handler (#442).
consumePickedRomError(self)
end
end
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
@@ -629,28 +689,53 @@ function RomImporter:focus(f)
if self.tab == "mods" then self.tab = version end
return
end
local modName = findPendingMod(false)
if modName then
self:_installMod(modName)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(modName)
-- The SAF pick failed inside GameActivity, which wrote pick_error.flag with
-- the destination basename in it: some OEM shells (ColorOS) let a third-party
-- archive manager win the ACTION_OPEN_DOCUMENT chooser and hand back a URI
-- this app has no permission to read, and until #442 that returned to a
-- launcher that said nothing at all.
local pickError = love.filesystem.getInfo("pick_error.flag", "file")
and love.filesystem.read("pick_error.flag")
if pickError then
love.filesystem.remove("pick_error.flag")
local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory()
if pickError:find("picked_mod", 1, true) then
self.modNotice = { ok = false, text = text }
elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false, text = text }
else
self:setError(text)
end
return
end
local savName = findPendingSav(false)
local modName = findPendingMod(false, self.pickSkip)
if modName then
self:_installMod(modName)
consumePick(self, modName, "picked_mod.zip",
self.modNotice and self.modNotice.ok)
return
end
local savName = findPendingSav(false, self.pickSkip)
if savName then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(savName)
end
consumePick(self, savName, "picked_save.sav",
self.saveNotice[version] and self.saveNotice[version].ok)
return
end
for _, v in ipairs(GameVersion.ORDER) do
if not self.ready[v] then
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
if name then
self:startData(data, name)
else
consumePickedRomError(self)
end
return
end
end
@@ -698,8 +783,9 @@ function RomImporter:startData(data, displayName)
local actualHash = sha1(data)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
.. "Red, Blue, or Yellow ROM."):format(actualHash))
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
.. "Red, Blue, or Yellow dump; patched, trimmed or \"fixed\" dumps "
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
return
end
local info = GameVersion.info(version)
@@ -849,12 +935,11 @@ end
function RomImporter:chooseMod()
if self.workState == "working" then return end
if self.android then
local name = findPendingMod(true)
local name = findPendingMod(true, self.pickSkip)
if name then
self:_installMod(name)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(name)
end
consumePick(self, name, "picked_mod.zip",
self.modNotice and self.modNotice.ok)
return
end
if not love.system.pickFile("mod") then
@@ -908,13 +993,12 @@ end
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
if self.android then
local name = findPendingSav(true)
local name = findPendingSav(true, self.pickSkip)
if name then
self.androidPendingVersion = version
self:_importSave(version, name)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(name)
end
consumePick(self, name, "picked_save.sav",
self.saveNotice[version] and self.saveNotice[version].ok)
return
end
self.androidPendingVersion = version
@@ -999,6 +1083,8 @@ function RomImporter:choose(version)
local name, data = findPendingRom(self.ready)
if name then
self:startData(data, name)
elseif consumePickedRomError(self) then
return -- a rejected pick explains itself instead of silently reopening
elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path as a friendly notice, not an
@@ -1415,6 +1501,32 @@ function RomImporter.pageScrollFor(naturalH, viewportH, scroll)
return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage
end
-- Every hit rect mousepressed dispatches on, cleared before any panel draws:
-- each rect is rebuilt only by the panel that draws its control, so whatever a
-- frame does not draw must not stay clickable. Missing the Delete rects here
-- let a click on the mods tab land on the game tab's save Delete label (#433).
function RomImporter:_resetFrameRects()
self.romButtonRect = nil
self.playButtonRect = nil
self.tabRects = {}
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
-- version with no panel drawn this frame) cannot inherit last frame's rows.
self.slotRects = nil
self.slotEditRects = nil
self.slotDeleteRects = nil
self.newSlotRect = nil
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
-- last frame's mod toggles / Delete labels / import button.
self.modRects = nil
self.modDeleteRects = nil
self.modImportRect = nil
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
self.saveImportRect = nil
self.saveExportRect = nil
self.saveFolderRect = nil
end
function RomImporter:draw()
local width, height = love.graphics.getDimensions()
local s = clamp(height / 768, 0.7, 1.6)
@@ -1433,23 +1545,7 @@ function RomImporter:draw()
end
self._hoverEnabled = self._padCursorActive or not self.android
self._anyHover = false
self.romButtonRect = nil
self.playButtonRect = nil
self.tabRects = {}
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
-- version with no panel drawn this frame) cannot inherit last frame's rows.
self.slotRects = nil
self.slotEditRects = nil
self.newSlotRect = nil
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
-- last frame's mod toggles / import button.
self.modRects = nil
self.modImportRect = nil
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
self.saveImportRect = nil
self.saveExportRect = nil
self.saveFolderRect = nil
self:_resetFrameRects()
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
local fontKey = ("%dx%d"):format(width, height)
@@ -1936,6 +2032,16 @@ local function inside(r, x, y)
return true
end
-- A Delete label is armed by one click and commits on a second one on the same
-- target; it disarms on any other press and after this many seconds, because
-- nothing in the launcher can undo a delete (#433).
local DELETE_CONFIRM_SECONDS = 4
local function armedDelete(a, kind, id, version)
return a ~= nil and a.kind == kind and a.id == id and a.version == version
and (love.timer.getTime() - a.t) <= DELETE_CONFIRM_SECONDS
end
function RomImporter:mousepressed(x, y, button)
if self._rename then return end -- the rename modal swallows all clicks
-- Whether a press can be ARMED and resolved on release, which needs a
@@ -1955,6 +2061,10 @@ function RomImporter:mousepressed(x, y, button)
return
end
if button ~= 1 then return end
-- Any press that is not the second click on an armed Delete disarms it, so
-- take the arm off self up front and let the Delete loops below re-arm.
local armed = self._confirmDelete
self._confirmDelete = nil
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
love.system.openURL(COMMUNITY_URL)
return
@@ -2019,7 +2129,12 @@ function RomImporter:mousepressed(x, y, button)
-- targets, no scroll conflict).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
if armedDelete(armed, "slot", r.id, self.panelVersion) then
self:_deleteSlot(self.panelVersion, r.id)
else
self._confirmDelete = { kind = "slot", id = r.id,
version = self.panelVersion, t = love.timer.getTime() }
end
return
end
end
@@ -2053,7 +2168,11 @@ function RomImporter:mousepressed(x, y, button)
end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteMod(r.id)
if armedDelete(armed, "mod", r.id, nil) then
self:_deleteMod(r.id)
else
self._confirmDelete = { kind = "mod", id = r.id, t = love.timer.getTime() }
end
return
end
end
@@ -2792,8 +2911,11 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
col(dhot and PAL.red or PAL.warning)
love.graphics.print(delText, delX, delY)
-- Armed by a first click, the label asks before a second one commits;
-- the box stays sized to "Delete" so the row never reflows (#433).
local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version)
col((darmed or dhot) and PAL.red or PAL.warning)
love.graphics.printf(darmed and "Sure?" or delText, delX, delY, delW, "center")
local rightReserve = delW + 18 * s
-- Edit label, immediately left of Delete: opens the bundled save
@@ -3150,8 +3272,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
col(dhot and PAL.red or PAL.warning)
love.graphics.print("Delete", delX, delY)
-- Same two-click arm as a save slot's Delete (#433).
local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil)
col((darmed or dhot) and PAL.red or PAL.warning)
love.graphics.printf(darmed and "Sure?" or "Delete", delX, delY, L.delW, "center")
-- hit rects clipped to the visible list band
local vy = math.max(trect.y, top)
+5 -2
View File
@@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version)
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
-- launcher imports before the cache is mounted on the un-prefixed paths, so
-- SaveConvert cannot find the generated tables by itself here (#420).
local save, convertErr = SaveConvert.importSav(bytes, version, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
local bytes, exportErr = SaveConvert.exportSav(save, version)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
+22 -4
View File
@@ -168,6 +168,21 @@ function LauncherMods.pickStrays(found, installed)
return out
end
-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the
-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot
-- the mounted portable game folder (CacheFs.root()); a fused build has both,
-- and they are different paths (the archive inside the executable vs the
-- folder beside it). Either one's mods/ is readable already, so nothing in
-- it is a stray -- and the portable folder must additionally never be handed
-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory
-- already in the search path WITHOUT adding a second entry, so the paired
-- unmount tears down the one real mount and the panel loses every mod in the
-- game folder (#413).
function LauncherMods.isReadableRoot(folder, source, cacheRoot)
if not folder or folder == "" then return false end
return folder == source or folder == cacheRoot
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
@@ -349,17 +364,20 @@ end
local STRAY_MOUNT = "stray_scan"
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
-- directory, one mount at a time. Folders that are already the physfs source
-- are skipped: their mods/ is discoverable by definition, so anything there is
-- installed already and not a stray (this is every `love <gamedir>` dev run).
-- directory, one mount at a time. Folders already on the read path are
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
-- dev run, and the portable game folder CacheFs mounted, where re-mounting is
-- what used to drop the mount (#413).
local function eachStrayRoot(fn)
local SaveData_ = require("src.core.SaveData")
local fs = love and love.filesystem
if not fs then return end
local source = fs.getSource and fs.getSource()
local cacheRoot = CacheFs.root()
local seen = {}
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
if not seen[folder] and folder ~= source then
if not seen[folder]
and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
seen[folder] = true
CacheFs.withMounted(folder, STRAY_MOUNT, function()
local root = STRAY_MOUNT .. "/mods"
+73 -25
View File
@@ -11,9 +11,14 @@
-- tables load through `require`, exactly how src/core/Data.lua pulls the
-- generated modules -- which resolves under both plain luajit (package.path
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
-- build, with an OS-path fallback for odd working directories. The only
-- place `love` is referenced is inside a guarded fallback, so running under
-- stock Lua never touches it.
-- build, with an OS-path fallback for odd working directories. `love` is only
-- ever referenced inside guarded fallbacks, so running under stock Lua never
-- touches it.
--
-- When a caller names the game a save belongs to, the generated tables come
-- out of that version's ROM cache through CacheFs instead: the launcher does
-- its importing before the cache is mounted onto the un-prefixed paths, so
-- require alone cannot see them there (#420).
local GenSave = require("src.save_convert.GenSave")
@@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath)
:format(requirePath, requirePath, filePath)
end
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
-- The four generated tables live in one game's ROM cache, and the launcher
-- reaches this code before that cache is on the un-prefixed read path:
-- CacheFs.mountVersion only runs from main.lua's bootGame (after Play), and
-- Blue/Yellow keep their cache under GameVersion.cachePrefix. So a bare
-- require sees Red's copy at best, and nothing at all in a fused portable
-- build (the game folder is only readable through CacheFs's PhysFS mount) --
-- read the tables out of the cache whenever the caller names the game the
-- save belongs to, and let the require path above cover everything else
-- (#420).
local function loadCacheTable(gameVersion, filePath)
if not (gameVersion and love and love.filesystem) then return nil end
if not filePath:match("^data/generated/") then return nil end
local okc, CacheFs = pcall(require, "src.import.CacheFs")
local okg, GameVersion = pcall(require, "src.core.GameVersion")
if not (okc and okg and type(CacheFs) == "table") then return nil end
local info = GameVersion.VERSIONS[gameVersion]
if not info then return nil end
-- CacheFs.prefix is launcher-owned global state (it points at whatever
-- import last ran), so borrow it for this read and put it back.
local saved = CacheFs.prefix
CacheFs.prefix = info.cachePrefix
local okr, bytes = pcall(CacheFs.read, filePath)
CacheFs.prefix = saved
if not (okr and type(bytes) == "string") then return nil end
local chunk = loadstring(bytes, "@" .. info.cachePrefix .. filePath)
if not chunk then return nil end
local okx, mod = pcall(chunk)
if okx and type(mod) == "table" then return mod end
return nil
end
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
-- require-resolved set): Yellow's tables are not Red's, so one import must
-- never be handed the previous import's data (#420).
local crosswalks = {} -- [key] = { pokemon=, moves=, items=, maps=, eventFlags= }
local charmapReady
local function ensureData()
if not crosswalk then
local function ensureData(gameVersion)
local key = gameVersion or "*"
if not crosswalks[key] then
local data = {}
for key, spec in pairs(DATA_MODULES) do
if key ~= "charmap" then
local mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
data[key] = mod
for name, spec in pairs(DATA_MODULES) do
if name ~= "charmap" then
local mod = loadCacheTable(gameVersion, spec[2])
if not mod then
local e
mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
end
data[name] = mod
end
end
crosswalk = data
crosswalks[key] = data
end
if not charmapReady then
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
@@ -84,13 +128,14 @@ local function ensureData()
GenSave.setCharmap(cm)
charmapReady = true
end
return crosswalk
return crosswalks[key]
end
-- Exposed for the CLI/tests so they can share the exact data set the codec
-- uses (and so a caller can pre-warm the cache). Returns data, err.
function SaveConvert.loadData()
return ensureData()
-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM
-- cache the generated tables come from. Returns data, err.
function SaveConvert.loadData(gameVersion)
return ensureData(gameVersion)
end
-- ------------------------------------------------------------------
@@ -138,20 +183,22 @@ SaveConvert.mergeDefaults = mergeDefaults
-- Public API
-- ------------------------------------------------------------------
-- importSav(bytes, version) -> saveTable, err
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
-- over the new-game defaults and tagged with `version`, ready to hand to
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
-- message (never raises).
function SaveConvert.importSav(bytes, version)
-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow")
-- names the game the save is being imported for, which is what selects the
-- crosswalk tables; omit it to take whatever `require` resolves. On any
-- failure returns nil + a message (never raises).
function SaveConvert.importSav(bytes, version, gameVersion)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
local data, derr = ensureData()
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, decoded = pcall(GenSave.decode, bytes, data)
@@ -170,16 +217,17 @@ function SaveConvert.importSav(bytes, version)
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable) -> bytes, err
-- exportSav(saveTable, gameVersion) -> bytes, err
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
-- if the table still carries the stashed import template (saveTable.rawImport)
-- GenSave reproduces every unmodeled region from it; otherwise those regions
-- are zero-filled. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable)
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
-- importSav. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable, gameVersion)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local data, derr = ensureData()
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
if not ok then return nil, "encode failed: " .. tostring(bytes) end
+10 -3
View File
@@ -43,12 +43,13 @@ function SummaryMenu.new(game, mon)
Stats.ensure(game.data.pokemon[mon.species], mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local Sprites = require("src.pokemon.Sprites")
local path = Sprites.path(game.data, mon.species, "front",
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
end
self.spriteTrueColor = self.sprite and trueColor or false
require("src.core.Sound").playCry(game.data, mon.species)
return self
end
@@ -111,8 +112,14 @@ function SummaryMenu:draw()
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
-- negative x scale anchored at the pic's right edge). #280
if self.sprite then
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
local pw, ph = self.sprite:getDimensions()
local py = math.max(0, 56 - ph)
love.graphics.draw(self.sprite, 8 + pw, py, 0, -1, 1)
-- a full-color pic has to sit out the SGB monPal recolor, so mark the
-- rect the mirrored draw covers for the unshaded pass (#430)
if self.spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(8, py, pw, ph)
end
end
local HudTiles = require("src.render.HudTiles")
love.graphics.setColor(0, 0, 0, 1)
+5 -1
View File
@@ -574,10 +574,14 @@ function YellowIntro:beginScenes()
self:startScene(0)
end
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
-- .go_to_title_screen, where the A/B/START skip lands too): Music_YellowIntro
-- outlasts the scenes and plays on into the title setup until title.asm's
-- StopAllMusic before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands
-- in for (#436)
function YellowIntro:finish()
if self.finished then return end
self.finished = true
pcall(Music.stop)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
+23 -7
View File
@@ -366,7 +366,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
-- Yellow's companion Pikachu trails the player (never in
-- self.entities: it does not block movement, pikachu_follow.asm)
require("src.world.PikachuFollower").onMapEntered(Game, self)
require("src.world.PikachuFollower").onMapEntered(Game, self, opts)
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
@@ -899,7 +899,12 @@ function OverworldState:update(dt)
-- the emotion-bubble pause holds the world for a beat
if self.emote then
self.emote.frames = self.emote.frames - 1
if self.emote.frames <= 0 then
-- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm)
-- cuts a pikapic beat short on A or B; the "!" bubble hold has no such
-- check, so only the pikapic marks itself skippable (#424)
local cut = self.emote.skippable
and (Game.input:wasPressed("a") or Game.input:wasPressed("b"))
if cut or self.emote.frames <= 0 then
local done = self.emote.onDone
self.emote = nil
if done then done() end
@@ -1291,7 +1296,14 @@ function OverworldState:crossConnection(dir, conn)
-- still protects the visible step when neighbor rebuild or the sync
-- fallback stalls, and avoids the rare one-frame volume spike from a
-- song swap mid-step.
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
-- Yellow's follower crosses the seam as one continuous walk, so hand the
-- live instance through setMap (which rebuilds self.npcs) instead of
-- letting it respawn behind the player (#427)
local PikachuFollower = require("src.world.PikachuFollower")
local pika = PikachuFollower.current(self)
local fromX, fromY = p.cellX, p.cellY
self:setMap(conn.map, x, y, p.facing,
{ seamless = true, keepMusic = true, keepPikachu = pika })
self.pendingSeamMusic = conn.map
-- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step
@@ -1301,6 +1313,8 @@ function OverworldState:crossConnection(dir, conn)
local d = DIRVEC[dir]
p.cellX, p.cellY = x - d[1], y - d[2]
p.px, p.py = p.cellX * 16, p.cellY * 16
-- same translation for the follower and the cell it is chasing
PikachuFollower.rebase(self, p.cellX - fromX, p.cellY - fromY)
self.camera:follow(p.px, p.py)
p.facing = dir
p.targetX, p.targetY = x, y
@@ -4461,9 +4475,10 @@ function OverworldState:drawUI()
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so
-- the front pic holds for the whole beat while the cry and any emote
-- bubble play over the world below (#407).
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
-- front pic stands in for every frame of the script; PikachuFollower
-- .picLift lifts it on the runs that draw the alternate pose, and the
-- script's own duration times the beat (#407, #424).
if self.emote and self.emote.pikaPic then
require("src.render.Font").drawBox(6, 5, 7, 7)
-- one image per path, cached: this draws every frame of the hold, and
@@ -4477,8 +4492,9 @@ function OverworldState:drawUI()
if img then
love.graphics.setColor(1, 1, 1, 1)
local w, h = img:getDimensions()
local lift = require("src.world.PikachuFollower").picLift(self.emote)
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
math.floor(48 + (40 - h) / 2))
math.floor(48 + (40 - h) / 2) - lift)
end
end
+160 -9
View File
@@ -181,9 +181,29 @@ local function spawnCell(ow)
return p.cellX, p.cellY
end
function PikachuFollower.onMapEntered(game, ow)
-- the live follower, for a caller that has to carry it across a setMap
-- that rebuilds ow.npcs (OverworldState:crossConnection, #427)
function PikachuFollower.current(ow)
local npc = findFollower(ow)
return npc
end
function PikachuFollower.onMapEntered(game, ow, opts)
remove(ow)
if not shouldSpawn(game, ow) then return end
-- opts.keepPikachu is the follower a connection crossing kept alive:
-- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4
-- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText
-- takes .normal_spawn_state -- map coords rebased, sprite data and
-- follow command buffer left alone. Re-list the same instance and let
-- rebase() shift its cell; a warp arrives without it and respawns
-- behind the player, the full spawn path of that same routine.
local keep = opts and opts.keepPikachu
if keep then
table.insert(ow.npcs, keep)
table.insert(ow.entities, keep)
return
end
local x, y = spawnCell(ow)
local npc = makeFollower(game, ow, x, y, ow.player.facing)
table.insert(ow.npcs, npc)
@@ -271,6 +291,14 @@ end
local function idleTick(ow, npc)
-- Func_fc82e: a step in progress ends the idle state outright
if ow.player.moving then idleReset(npc) return end
-- Every counter below burns one unit per UpdateSprites call, and a
-- standing OverworldLoop spends two DelayFrames on each pass (home/
-- overworld.asm: OverworldLoop delays, falls into OverworldLoopLessDelay
-- which delays again, then .noDirectionButtonsPressed loops back), so the
-- whole Func_fc803 family runs at half this port's 60Hz fixed step: the
-- first glance is $20 CALLS, 64 frames, not 32 (#424).
npc.idleClock = ((npc.idleClock or 0) + 1) % 2
if npc.idleClock ~= 0 then return end
local idle = npc.idle
if not idle then
idle = { kind = "wait", frames = IDLE_LOOK }
@@ -339,6 +367,28 @@ local function ledgeStep(game, ow, cx, cy, dir)
return false
end
-- Slide the follower into the connected map's coordinate frame by the
-- delta crossConnection applied to the player: the two maps are one
-- continuous world, so a seam is a pure translation. MapX/MapY in
-- wSpritePikachuStateData2 are all .normal_spawn_state rewrites there,
-- never the pixel coords, which is why the original walks through a seam
-- instead of popping (#427).
function PikachuFollower.rebase(ow, dx, dy)
local npc = findFollower(ow)
if npc then
npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy
npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16
if npc.targetX then npc.targetX = npc.targetX + dx end
if npc.targetY then npc.targetY = npc.targetY + dy end
if npc.goalX then npc.goalX = npc.goalX + dx end
if npc.goalY then npc.goalY = npc.goalY + dy end
-- Func_fc82e: the player is taking a step, so any idle pose is over
if npc.idle then idleReset(npc) end
end
local trail = ow.pikachuTrail
if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end
end
-- one follow step per frame: chase the cell the player last vacated
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
@@ -371,8 +421,27 @@ function PikachuFollower.update(game, ow)
local destX = p.targetX or p.cellX
local destY = p.targetY or p.cellY
if destX ~= trail.x or destY ~= trail.y then
npc.goalX, npc.goalY = trail.x, trail.y
trail.x, trail.y = destX, destY
local stepDir = destY > trail.y and "down" or destY < trail.y and "up"
or destX > trail.x and "right" or "left"
-- A ledge hop commits TWO steps (checkLedgeHop -> scriptMove(p, dir, 2),
-- the two simulated presses of HandleLedges) but only ONE follow
-- command: Func_fcc08 sees BIT_LEDGE_OR_FISHING and defers to
-- Func_fcc64, which appends the $5-$8 hop on the takeoff step and
-- appends nothing on the landing step (bit 6 of
-- wPikachuOverworldStateFlags toggles between the two). With no command
-- behind it the hop cannot leave the buffer -- Func_fcc92 only pops once
-- a second command is queued -- so Pikachu walks up to the cell the
-- player took off from, waits there two cells behind (the Func_fc842
-- idle rolls), and hops one player step later (#424, after #409).
if trail.ledgeHop == stepDir then
trail.ledgeHop = nil
trail.x, trail.y = destX, destY
else
trail.ledgeHop = ledgeStep(game, ow, trail.x, trail.y, stepDir)
and stepDir or nil
npc.goalX, npc.goalY = trail.x, trail.y
trail.x, trail.y = destX, destY
end
end
-- standing still with nothing to chase is the idle state (Func_fc803);
-- once a step is under way NPC:update owns px/py, so only the idle
@@ -404,7 +473,9 @@ function PikachuFollower.update(game, ow)
npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
-- the cell ahead is the ledge the player hopped: clear both cells in one
-- step instead of stopping on the ledge (#409). pikachu_follow.asm
-- step instead of stopping on the ledge (#409). The trail above holds
-- this back until the player commits a further step, so it fires from the
-- cell on top of the ledge, a step late (#424). pikachu_follow.asm
-- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING
-- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over
-- one normal step's frames -- no arc and no shadow, the hop command only
@@ -422,7 +493,12 @@ function PikachuFollower.update(game, ow)
-- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer:
-- walk counter $4 instead of NormalPikachuFollow's $8).
local stepLen = p.stepFramesCur or p.stepFrames or 16
if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end
-- the hop is never a Fast step: Func_fc7aa jumps to Func_fca0a on the $4
-- movement status BEFORE it asks AreThereAtLeastTwoSteps..., so its two
-- cells ride one normal step's frames even though the goal is two away.
if far > 1 and not npc.hopStep then
stepLen = math.max(1, math.floor(stepLen / 2))
end
npc.stepFrames = stepLen
npc.moving = true
npc.progress = 0
@@ -500,6 +576,60 @@ local MOOD_MATRIX = {
-- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction
local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 }
-- ExecutePikaPicAnimScript spends a Delay3 on every pass of its loop
-- (pikachu_pic_animation.asm PikaPicAnimTimerAndJoypad), so one script tick
-- is three 60Hz frames: the flat 50 frame hold this port used was under a
-- third of even the shortest script (#424).
local PIKAPIC_TICK = 3
local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run
-- pikaemotion_pikapic's script id per emotion: emotion N takes
-- PikaPicAnimScript N, except the four listed here (data/pikachu/
-- pikachu_emotions.asm).
local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 }
-- Per script: pikapic_setduration's tick count, and for the scripts whose
-- overlay is a whole second pose, that frameset's run lengths in ticks
-- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script
-- N reaches as frameset N+5, or N+6 from script 10 up). The list alternates
-- pikaframedelay (the base pic alone) and pikaframe (the overlay) starting
-- with a delay, so a frameset that opens on a pikaframe opens with a zero
-- here; the frameset restarts until pikapic_looptofinish runs the duration
-- out. Scripts 1, 2, 3, 5, 6, 8 and 9 are left without a list on purpose:
-- their overlays (PikaAnimTilemap_14 to _22) only paint a few tiles over a
-- pic that otherwise stands still, so with no tiles to paint the port has
-- nothing to show for them and must not bob the whole picture instead.
local PIKAPIC = {
[1] = { dur = 40 },
[2] = { dur = 44 },
[3] = { dur = 80 },
[4] = { dur = 70, seq = { 8, 8, 20, 8 } },
[5] = { dur = 32 },
[6] = { dur = 50 },
[7] = { dur = 58, seq = { 0, 8, 2, 8, 2, 8 } },
[8] = { dur = 44 },
[9] = { dur = 56 },
[10] = { dur = 56, seq = { 8, 11, 5 } },
[11] = { dur = 100, seq = { 20, 8, 20, 8 } },
[12] = { dur = 50, seq = { 13, 12, 100, 8 } },
[13] = { dur = 50, seq = { 5, 5, 5, 5, 100 } },
[14] = { dur = 40, seq = { 2, 2, 2, 2 } },
[15] = { dur = 50, seq = { 5, 5, 5, 5 } },
[16] = { dur = 32, seq = { 0, 8, 100 } },
[17] = { dur = 100, seq = { 10, 3, 3, 3, 100 } },
[18] = { dur = 32, seq = { 3, 100, 8, 8 } },
[19] = { dur = 44, seq = { 0, 6, 6, 6, 6 } },
[20] = { dur = 50, seq = { 8, 12, 8, 12 } },
[21] = { dur = 40, seq = { 8, 104 } },
[22] = { dur = 40, seq = { 8, 100 } },
[23] = { dur = 70, seq = { 16, 16, 16, 16 } },
[24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } },
[25] = { dur = 50, seq = { 6, 106 } },
[26] = { dur = 100, seq = { 20, 8, 20, 116 } },
[27] = { dur = 30, seq = { 4, 100 } },
[28] = { dur = 64, seq = { 12, 12, 12, 100 } },
}
local function moodEmotion(save)
local mood = save.pikachuMood or 128
local column = 5
@@ -579,17 +709,38 @@ function PikachuFollower.talk(game, ow, npc, done)
-- ends with one, and its box is the only thing most of them put on
-- screen (emotion 5, the fresh-save cell, has no bubble at all). The
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
-- Sprites.path keeps a mod's replacement skin in play. The scripts'
-- 32-58 frame durations bracket the hold below, so it stays at 50.
-- Sprites.path keeps a mod's replacement skin in play.
local Sprites = require("src.pokemon.Sprites")
local pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
local hold = anim.dur * PIKAPIC_TICK
ow.emote = {
npc = npc, frames = 50, bubble = bi or false, pikaPic = pic,
onDone = done,
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done,
}
end
-- Where the framed pic sits this frame. The overlay a pikaframe run draws
-- is a second full-body pose (PikaAnimTilemap_23 and up replace all 5x5
-- tiles) out of gfx/pikachu/unknown_*, which the cache does not carry, so
-- the port lifts the one pic it has for the length of those runs -- the jump
-- the happy emotions make inside the box (#424, still on #407's stand-in).
function PikachuFollower.picLift(emote)
local seq = emote and emote.pikaSeq
if not seq then return 0 end
local loop = 0
for _, run in ipairs(seq) do loop = loop + run end
if loop <= 0 then return 0 end
local elapsed = math.max(0, (emote.pikaTotal or 0) - (emote.frames or 0))
local tick = math.floor(elapsed / PIKAPIC_TICK) % loop
for i, run in ipairs(seq) do
if tick < run then return i % 2 == 0 and PIKAPIC_LIFT or 0 end
tick = tick - run
end
return 0
end
-- ---------------------------------------------------------------------
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
-- engine/events/pokecenter.asm once the heal is accepted): the companion