Merge branch 'dev' into spidercar2

This commit is contained in:
bryanthaboi
2026-08-24 08:48:24 -04:00
61 changed files with 4280 additions and 563 deletions
+106
View File
@@ -2798,6 +2798,111 @@ function BattleState:queueResidual(b, opp)
end
end
local function fieldBattlerView(battler, side)
local types = {}
for index, typeId in ipairs(battler.curTypes or {}) do
types[index] = typeId
end
local mon = battler.mon or {}
return {
side = side,
name = battler.name,
hp = tonumber(mon.hp) or 0,
maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0,
types = types,
vanished = battler.invulnerable and true or false,
}
end
local function publicScalar(value)
local kind = type(value)
if kind == "string" or kind == "boolean" then return value, true end
if kind == "number" and value == value
and value < math.huge and value > -math.huge then
return value, true
end
return nil, false
end
local function publicDataCopy(value, visiting)
local scalar, ok = publicScalar(value)
if ok then return scalar, true end
if type(value) ~= "table" then return nil, false end
visiting = visiting or {}
if visiting[value] then return nil, false end
visiting[value] = true
local copy = {}
for key, child in next, value do
local copiedKey, keyOk = publicScalar(key)
local copiedChild, childOk = publicDataCopy(child, visiting)
if keyOk and childOk then copy[copiedKey] = copiedChild end
end
visiting[value] = nil
return copy, true
end
local function checkpointFieldView(field)
field = field or {}
local view = publicDataCopy({
weather = field.weather,
tokens = field.tokens or {},
})
return view
end
-- Public field residuals are data-only requests. Mods can inspect a detached
-- checkpoint-shaped field view and detached battler views, but only the engine
-- mutates HP, animates the bar, or enters the faint pipeline.
function BattleState:applyFieldResiduals()
if not Runtime.wantsHook("battle.field_residual") then return end
local views = {
player = fieldBattlerView(self.player, "player"),
enemy = fieldBattlerView(self.enemy, "enemy"),
}
local rows = Runtime.call("battle.field_residual", function() return {} end, {
field = checkpointFieldView(self.field),
battlers = views,
turn = self.turnCount or 0,
})
if type(rows) ~= "table" then return end
local fainted = {}
for _, row in ipairs(rows) do
local battler = type(row) == "table" and row.side == "player"
and self.player or type(row) == "table" and row.side == "enemy"
and self.enemy or nil
local amount = type(row) == "table" and row.amount or nil
if battler and battler.mon.hp > 0 and type(amount) == "number"
and amount > 0
and amount < math.huge and amount == math.floor(amount)
and (row.message == nil or type(row.message) == "string") then
amount = math.min(amount, battler.mon.hp)
if type(row.message) == "string" and row.message ~= "" then
self:sayNext(row.message)
end
battler.mon.hp = battler.mon.hp - amount
self:drainNext(battler, battler.mon.hp)
if battler.mon.hp <= 0 then fainted[battler] = true end
end
end
-- A terminal player faint owns a simultaneous field-residual batch. Queue
-- only that authority so its blackout cannot race an enemy EXP/replacement
-- path from the same hook response. Native faint paths remain untouched.
if fainted[self.player]
and not Party.firstHealthy(self:playerPartyView()) then
self:onFaint(self.player)
return
end
-- Otherwise resolve the two sides in engine order after every accepted
-- descriptor has landed. Descriptor order must not decide resolution.
for _, battler in ipairs({ self.player, self.enemy }) do
if fainted[battler] then self:onFaint(battler) end
end
end
function BattleState:endOfTurn()
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
@@ -2851,6 +2956,7 @@ function BattleState:endOfTurn()
b.trappingTurns = nil
end
end
self:applyFieldResiduals()
self:tickTokens()
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
end
+3 -1
View File
@@ -371,7 +371,7 @@ function ChipAudio.shutdown()
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, outCh = nil, nil, nil
workerReady = false
workerReady = nil
end
function ChipAudio.currentSource()
@@ -498,6 +498,8 @@ end
-- program (20 §2 cache contract, chip music row)
Assets.register(ChipAudio.invalidate)
require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown)
-- ---------------------------------------------------------------------------
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
-- ---------------------------------------------------------------------------
+43 -17
View File
@@ -147,29 +147,42 @@ function Game:bootConfig()
return boot
end
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
-- can reshape it), the overworld at the skeleton's spawn, and the intro
-- screen on top. The title menu's NEW GAME row is this; a mod that starts a
-- game on its own terms -- a match, a challenge mode -- calls it directly.
--
-- opts.intro = false skips the newGame screen (Oak's speech) so the player
-- lands straight in the world; the skeleton then has to carry a name and a
-- party, which is the caller's job via save.new_game.
function Game:startNewGame(opts)
local OverworldState = require("src.world.OverworldController")
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing,
{ via = "boot", freshBoot = true })
if not (opts and opts.intro == false) then
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
local OverworldState = require("src.world.OverworldController")
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
local title = factory.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing,
{ via = "boot", freshBoot = true })
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end,
onNewGame = function() self:startNewGame() end,
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
@@ -1382,12 +1395,25 @@ function Game:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
end
if self.world and self.world.release then
pcall(function() self.world:release() end)
end
if self._canvases then
for _, canvas in pairs(self._canvases) do
if canvas and canvas.release then pcall(canvas.release, canvas) end
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
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key
end
end
+16
View File
@@ -2208,9 +2208,25 @@ function Game2:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
end
if self.world and self.world.release then
pcall(function() self.world:release() end)
end
if self._canvases then
for _, canvas in pairs(self._canvases) do
if canvas and canvas.release then pcall(canvas.release, canvas) end
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
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key
end
end
+101
View File
@@ -0,0 +1,101 @@
-- Central session lifecycle orchestrator. Subsystems register teardown hooks
-- at module load (Assets release/invalidate bus, process shutdown below); the
-- host only calls phase entry points.
--
-- Three tiers:
-- mount endMountedSession — GPU release + CacheFs/Data/Runtime/Assets
-- game endGameSession — audio + game:reset; workers stay alive
-- process endProcess — worker shutdown on real app exit (love.quit)
--
-- Hot reload (Assets.flush / installLoader) stays invalidate-only forever.
local SessionLifecycle = {}
local processShutdowns = {}
function SessionLifecycle.registerProcessShutdown(fn)
processShutdowns[#processShutdowns + 1] = fn
end
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
-- version session (save editor or game). GPU release runs before soft
-- invalidate via installLoader(nil).
function SessionLifecycle.endMountedSession(version)
local Assets = require("src.render.Assets")
if Assets.releaseSession then Assets.releaseSession() end
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
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.
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
function SessionLifecycle.endEditorSession(opts)
opts = opts or {}
if opts.app and opts.app.unload then pcall(opts.app.unload) end
flushEditorPackageLoaded()
if opts.version then
SessionLifecycle.endMountedSession(opts.version)
end
end
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
-- live game instance so map/GPU holders are gone before endMountedSession.
function SessionLifecycle.endGameSession(game)
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
end
if package.loaded["src.core.gen2.Clock"] then
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
end
if package.loaded["src.net.Gen1Tls"] then
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
end
if love.audio and love.audio.stop then
pcall(love.audio.stop)
end
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
if game and game.reset then
pcall(function() game:reset() end)
end
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
Input:reset()
TouchControls:reset()
end
function SessionLifecycle.endProcess()
for _, fn in ipairs(processShutdowns) do pcall(fn) end
end
return SessionLifecycle
+247
View File
@@ -0,0 +1,247 @@
-- Engine-owned contract for generated ROM caches.
--
-- A cache is playable only when its versioned marker matches the ROM and every
-- required output for that version exists. Extraction writers may differ by
-- platform, but they must publish through this contract so partial staging
-- cannot look ready to the runtime.
local GameVersion = require("src.core.GameVersion")
local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.MARKER_PATH = "rom-cache.complete"
CacheContract.REQUIRED_FILES = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/text.lua",
"data/generated/field.lua",
"data/generated/battle_anims.lua",
"assets/generated/title/pokemon_logo.png",
"assets/generated/fonts/font.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
"assets/generated/trade/game_boy.png",
}
CacheContract.VERSION_REQUIRED_FILES = {
yellow = {
"assets/generated/battle/trainers/jessie_james.png",
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
},
}
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
gold = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua",
"data/generated/sprites.lua",
"data/generated/scripts.lua",
"data/generated/text.lua",
-- The engine's label-keyed strings are separate from Gen 2 script text.
-- Caches made before RomExtractorGen2:extractText must be rebuilt so
-- src/core/RomText.lua does not silently fall back to built-in wording.
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/tilesets.lua",
"data/generated/audio.lua",
"data/generated/marts.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png",
"assets/generated/title/pokemon_logo.png",
"assets/generated/title/title_screen.png",
"assets/generated/title/hooh.png",
"assets/generated/title/hooh_5.png",
"assets/generated/title/clouds.png",
"assets/generated/title/copyright_splash.png",
"data/generated/oak_speech.lua",
"assets/generated/intro/oak.png",
"assets/generated/intro/cal.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/front/marill.png",
"assets/generated/battle/trainers/falkner.png",
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
"assets/generated/pc/mail_item.png",
-- engine/events/fishing_gfx.asm:23
"assets/generated/emotes/fishing.png",
},
crystal = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua",
"data/generated/sprites.lua",
"data/generated/scripts.lua",
"data/generated/text.lua",
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/encounters.lua",
"data/generated/tilesets.lua",
"data/generated/landmarks.lua",
"data/generated/audio.lua",
"data/generated/marts.lua",
"data/generated/oak_speech.lua",
"data/generated/title.lua",
"data/generated/intro.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png",
"assets/generated/title/crystal_logo.png",
"assets/generated/title/crystal_wordmark.png",
"assets/generated/title/crystal_suicune.png",
"assets/generated/splash/ditto.png",
"assets/generated/intro/chris.png",
"assets/generated/intro/kris.png",
"assets/generated/intro/suicune_run_sprites.png",
"assets/generated/intro/unowns_tiles.png",
"assets/generated/intro/oak.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/sprites/kris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/wooper.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/trainers/falkner.png",
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
"assets/generated/pc/mail_item.png",
"assets/generated/trainer_card/card_f.png",
"data/generated/mobile_gfx.lua",
"assets/generated/battle/player_back_female.png",
"assets/generated/battle/trainers/kris.png",
"assets/generated/battle/trainers/chris.png",
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
"assets/generated/emotes/fishing.png",
},
}
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
function CacheContract.requiredFilesFor(version)
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return CacheContract.REQUIRED_FILES, false
end
function CacheContract.markerFor(version)
return CacheContract.FORMAT .. GameVersion.info(version).sha1
end
-- Keep the process-global CacheFs prefix isolated even when a filesystem
-- adapter raises while probing or publishing. The real CacheFs methods
-- return errors, but this also makes the contract safe for platform adapters
-- that surface I/O failures as Lua errors.
local function withVersionPrefix(version, fs, action)
local saved = fs.prefix
fs.prefix = GameVersion.cachePrefix(version)
local ok, first, second = pcall(action)
fs.prefix = saved
if not ok then return false, first end
return true, first, second
end
function CacheContract.allRequiredFilesExist(version, fs)
fs = fs or require("src.import.CacheFs")
local ok, complete, missing = withVersionPrefix(version, fs, function()
local required, isOverride = CacheContract.requiredFilesFor(version)
local missingPath
for _, path in ipairs(required) do
if not fs.exists(path) then missingPath = path; break end
end
if not missingPath and not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
if not fs.exists(path) then missingPath = path; break end
end
end
return missingPath == nil, missingPath
end)
if not ok then return false, complete end
return complete, missing
end
function CacheContract.readMarker(version, fs)
fs = fs or require("src.import.CacheFs")
local ok, marker, readError = withVersionPrefix(version, fs, function()
return fs.read(CacheContract.MARKER_PATH)
end)
if not ok then return nil, marker end
-- LÖVE may return contents plus a byte count; only a nil contents result
-- makes the auxiliary value an error. CacheFs' portable reader returns
-- just the contents, so this remains adapter-neutral.
if marker == nil then return nil, readError end
return marker
end
function CacheContract.isReady(version, fs)
fs = fs or require("src.import.CacheFs")
if CacheContract.sourceTreeHasData(version) then return true end
local marker, readError = CacheContract.readMarker(version, fs)
if readError or marker ~= CacheContract.markerFor(version) then return false end
return CacheContract.allRequiredFilesExist(version, fs)
end
function CacheContract.publish(version, fs)
fs = fs or require("src.import.CacheFs")
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
if not complete then
-- A caller may be retrying over a partially replaced cache. Do not
-- leave its old marker advertising readiness after this failed check.
local removed, removeError = withVersionPrefix(version, fs, function()
if not fs.remove then
error("cache filesystem cannot remove the completion marker")
end
return fs.remove(CacheContract.MARKER_PATH)
end)
if not removed then
return false, "cache is incomplete; missing " .. tostring(missing)
.. "; could not remove completion marker: " .. tostring(removeError)
end
return false, "cache is incomplete; missing " .. tostring(missing)
end
local changed, ok, err = withVersionPrefix(version, fs, function()
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version))
end)
if not changed then return false, tostring(ok) end
return ok, err
end
function CacheContract.sourceTreeHasData(version)
if not (love and love.filesystem and love.filesystem.getInfo
and love.filesystem.getRealDirectory and love.filesystem.getSource) then
return false
end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local required, isOverride = CacheContract.requiredFilesFor(version)
local source = love.filesystem.getSource()
for _, path in ipairs(required) do
local fullPath = prefix .. path
if love.filesystem.getInfo(fullPath, "file") == nil
or love.filesystem.getRealDirectory(fullPath) ~= source then
return false
end
end
if not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
local fullPath = prefix .. path
if love.filesystem.getInfo(fullPath, "file") == nil
or love.filesystem.getRealDirectory(fullPath) ~= source then
return false
end
end
end
return true
end
return CacheContract
+14 -247
View File
@@ -35,189 +35,12 @@ local function cartOfScope(scope)
return scope:match("^cart_(.+)$")
end
-- Cache generation tag; bump to force every imported version to re-extract.
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
-- v10: maps carry their raw map-header/connection/object bytes and tilesets
-- their Tilesets row (#889), which a .sav export replays so a Continue on
-- real hardware has a map to load; a v9 cache has none of them and exports
-- the same unbootable save as before.
-- Deliberately NOT bumped for the Gold trainer-pic gap: this tag invalidates
-- every version at once, and that gap is Gold-only. A per-version marker in
-- VERSION_REQUIRED_FILES_OVERRIDE.gold re-imports exactly the caches that lack
-- the stage, which is what the Yellow markers below already do for #439/#557.
-- Reach for a bump when the change spans versions or has no single file to
-- point at.
local CACHE_FORMAT = "rom-cache-v10:"
-- The completion marker is written under each version's cache prefix
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
local MARKER_PATH = "rom-cache.complete"
-- The marker a finished import writes for a version: the generation tag plus
-- that version's ROM hash, so both a format bump and a swapped ROM invalidate.
local function markerFor(version)
return CACHE_FORMAT .. GameVersion.info(version).sha1
end
local CacheContract = require("src.import.CacheContract")
local COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
"or a link from the discord that bryanthaboi himself posted, just know " ..
"it might have been tampered with. go to the discord to verify " ..
COMMUNITY_URL .. " (or click the logo above)"
local REQUIRED_FILES = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/text.lua",
"data/generated/field.lua",
"data/generated/battle_anims.lua",
"assets/generated/title/pokemon_logo.png",
"assets/generated/fonts/font.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
-- The trade cinematic's Game Boy / cable art. Caches built before #750
-- carry none of it and fall back to plain rectangles, so listing one of
-- the files re-imports them without a CACHE_FORMAT bump.
"assets/generated/trade/game_boy.png",
}
-- 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
-- Oak's own back pic and the pikapic base frames only exist in caches
-- built after their manifest symbols landed, so an older Yellow cache
-- has to re-import to stop falling back to the old man's back pic and
-- to the battle front pic (#557, #561). Both are gated on manifest
-- symbols in RomExtractor, so these markers must only ever list files
-- tools/rom_manifest_yellow.json can actually produce -- otherwise the
-- cache reads as incomplete and re-importing cannot clear it.
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
},
}
-- Gold Phase 1 writes a thinner cache than Gen 1 (no battle anim sheets,
-- trade art, or field.lua payload yet -- see docs/gold-phase1.md). This
-- list replaces REQUIRED_FILES entirely for that version so a successful
-- Gen 2 extract is not stuck as "incomplete" waiting on Gen 1 markers.
local VERSION_REQUIRED_FILES_OVERRIDE = {
gold = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua", -- Phase 2: forces re-import of Phase 1 caches
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
"data/generated/scripts.lua", -- disassembled map scripts
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
-- The engine's own strings, keyed by label rather than by address. A
-- cache built before RomExtractorGen2:extractText has none, and every
-- line that reads through src/core/RomText.lua would silently keep
-- printing its Lua fallback, so this re-imports those caches rather than
-- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too.
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/tilesets.lua",
"data/generated/audio.lua",
-- Mart shelves + the heal machine art ride the same import, so listing
-- marts.lua alone re-imports the caches from before either existed
-- (empty shop shelves, no Pokecenter light show).
"data/generated/marts.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png", -- the seven other OPTION textbox frames
"assets/generated/title/pokemon_logo.png",
"assets/generated/title/title_screen.png", -- TitleScreenTilemap composition
"assets/generated/title/hooh.png",
"assets/generated/title/hooh_5.png", -- wing-flap frames force re-import
"assets/generated/title/clouds.png",
"assets/generated/title/copyright_splash.png",
"data/generated/oak_speech.lua", -- Oak texts + trainer pics
"assets/generated/intro/oak.png",
"assets/generated/intro/cal.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/front/marill.png", -- Oak speech demo mon
-- The trainer class pics (TrainerPicPointers). FALKNER is row 0 of that
-- table, so a cache that produced any class pic at all produced this one.
-- Listed for the reason the Yellow markers above are: a cache built before
-- the stage existed reads as INCOMPLETE and re-imports itself, so this
-- particular gap cannot survive a tag bump being forgotten again. It
-- costs nothing on a current cache and is the difference between every
-- trainer battle opening with a picture and opening with none.
"assets/generated/battle/trainers/falkner.png",
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
-- before the four ball tiles were extracted (#1502).
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
-- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to
-- advertise these paths even when Slots*LZ / CardFlip* were absent from
-- the manifest, so a cache that never wrote the PNGs still looked
-- complete and SlotMachine crashed on its labelled-cell fallback.
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
"assets/generated/pc/mail_item.png",
-- FishingGFX (engine/events/fishing_gfx.asm:23): the pose rows and the rod
-- tiles, which a cache built before #1708 has none of.
"assets/generated/emotes/fishing.png",
},
crystal = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua",
"data/generated/sprites.lua",
"data/generated/scripts.lua",
"data/generated/text.lua",
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/encounters.lua",
"data/generated/tilesets.lua",
"data/generated/landmarks.lua",
"data/generated/audio.lua",
"data/generated/marts.lua",
"data/generated/oak_speech.lua",
"data/generated/title.lua",
"data/generated/intro.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png",
"assets/generated/title/crystal_logo.png",
"assets/generated/title/crystal_wordmark.png",
"assets/generated/title/crystal_suicune.png",
"assets/generated/splash/ditto.png",
"assets/generated/intro/chris.png",
"assets/generated/intro/kris.png",
"assets/generated/intro/suicune_run_sprites.png",
"assets/generated/intro/unowns_tiles.png",
"assets/generated/intro/oak.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/sprites/kris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/wooper.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/trainers/falkner.png",
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
"assets/generated/pc/mail_item.png",
"assets/generated/trainer_card/card_f.png",
"data/generated/mobile_gfx.lua",
"assets/generated/battle/player_back_female.png",
"assets/generated/battle/trainers/kris.png",
"assets/generated/battle/trainers/chris.png",
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
"assets/generated/emotes/fishing.png",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game.
-- Red, Blue, and Yellow share the same importer flow once listed in
@@ -270,58 +93,6 @@ local PAL = {
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
}
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
local function requiredFilesFor(version)
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return REQUIRED_FILES, false
end
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- blue/, yellow/, gold/).
local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true
local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if not CacheFs.exists(path) then ok = false; break end
end
if ok and not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end
end
end
CacheFs.prefix = saved
return ok
end
-- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow/Gold in their versioned
-- trees. Imported Red caches still live under red/. Check source paths
-- directly so that cache prefix cannot hide Red's source tree, and keep
-- save-dir caches from counting as current source data.
local function sourceTreeHasData(version)
if not love.filesystem.getRealDirectory then return false end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
if not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
end
local path = prefix .. required[1]
local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource()
end
-- ------- ROM cache location
--
-- The extracted cache (data/generated, assets/generated) plus the
@@ -377,10 +148,15 @@ local function purgeSaveDirCache()
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
local required = CacheContract.requiredFilesFor(version)
local hasRequired = false
for _, path in ipairs(required) do
if saveDirHas(prefix .. path) then hasRequired = true; break end
end
if saveDirHas(prefix .. CacheContract.MARKER_PATH) or hasRequired then
removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH)
love.filesystem.remove(prefix .. CacheContract.MARKER_PATH)
end
end
end
@@ -395,13 +171,7 @@ function RomImporter.isReady(version)
-- save-directory copy that would otherwise shadow it at runtime.
purgeSaveDirCache()
end
-- Generated data in a developer checkout / Python build is always current.
if sourceTreeHasData(version) then return true end
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local marker = CacheFs.read(MARKER_PATH)
CacheFs.prefix = saved
return marker == markerFor(version) and allRequiredFilesExist(version)
return CacheContract.isReady(version, CacheFs)
end
function RomImporter.syncAndroidShortcuts(activeVersion)
@@ -1660,12 +1430,9 @@ function RomImporter.new(onComplete, opts)
self.ready[version] = ready
-- a marker present but for an older cache generation / different ROM means
-- "update required" (re-import) rather than a clean first-run choose
local saved = CacheFs.prefix
CacheFs.prefix = info.cachePrefix
local marker = CacheFs.read(MARKER_PATH)
CacheFs.prefix = saved
local marker = CacheContract.readMarker(version, CacheFs)
self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version)
(not ready) and marker ~= nil and marker ~= CacheContract.markerFor(version)
self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
and ".gbc" or ".gb")
@@ -1980,10 +1747,10 @@ function RomImporter:startData(data, displayName)
local cleared, clearError = pcall(function()
removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH)
love.filesystem.remove(prefix .. CacheContract.MARKER_PATH)
CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH)
CacheFs.remove(CacheContract.MARKER_PATH)
end)
CacheFs.prefix = savedPrefix
if not cleared then
@@ -2055,7 +1822,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
-- appear once every required file is in place.
local savedPrefix = CacheFs.prefix
CacheFs.prefix = prefix
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
local ok, writeError = CacheContract.publish(version, CacheFs)
CacheFs.prefix = savedPrefix
if not ok then
error("could not finish the private cache: " .. tostring(writeError))
+54 -15
View File
@@ -1,28 +1,63 @@
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
-- aloud or handwritten never has to be checked twice).
-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's
-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32
-- style alphabet pokeserver room/tournament codes are drawn from
-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or
-- handwritten never has to be checked twice).
--
-- The Gen 1 naming grid cannot stand in for this: it has no digits at all
-- (data/text/alphabets.asm is letters and punctuation), so a room code or
-- an address typed there would be unenterable. That is what this exists
-- for.
--
-- new() takes an optional {length=, charset=} so the same interaction can
-- carry something other than a room code -- a dotted IP over "0123456789.",
-- say. Both default to the room-code shape, so existing callers are
-- unaffected and CodeEntry.LENGTH / CodeEntry.CHARSET still describe them.
local CodeEntry = {}
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
CodeEntry.LENGTH = 6
function CodeEntry.new()
-- state carries its own length/charset so a caller holding two widgets of
-- different shapes cannot have one read the other's alphabet
function CodeEntry.new(opts)
local charset = (opts and opts.charset) or CodeEntry.CHARSET
local length = (opts and opts.length) or CodeEntry.LENGTH
local chars = {}
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
return { chars = chars, pos = 1 }
for i = 1, length do chars[i] = 1 end -- index into charset, 1-based
return { chars = chars, pos = 1, charset = charset, length = length }
end
local N = #CodeEntry.CHARSET
-- Seed the slots from an existing string: prefilling the LAN address means
-- the player scrubs the last octet instead of all twelve digits. Anything
-- not in the charset lands on slot 1's character.
-- Slots past the end of the seed -- and any character the charset does not
-- carry -- land on the charset's blank where it has one, so seeding a
-- 15-slot address widget with "192.168.1.40" reads back as that address and
-- not as "192.168.1.40000". A charset with no blank (the room code's) has
-- nowhere to put one, so those fall back to the first character as before.
function CodeEntry.fromText(text, opts)
local state = CodeEntry.new(opts)
local blank = state.charset:find(" ", 1, true) or 1
for i = 1, state.length do
local ch = tostring(text or ""):sub(i, i)
state.chars[i] = (ch ~= "" and state.charset:find(ch, 1, true)) or blank
end
return state
end
local function charsetOf(state) return state.charset or CodeEntry.CHARSET end
local function lengthOf(state) return state.length or CodeEntry.LENGTH end
function CodeEntry.up(state)
state.chars[state.pos] = state.chars[state.pos] % N + 1
local n = #charsetOf(state)
state.chars[state.pos] = state.chars[state.pos] % n + 1
end
function CodeEntry.down(state)
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
local n = #charsetOf(state)
state.chars[state.pos] = (state.chars[state.pos] - 2) % n + 1
end
function CodeEntry.left(state)
@@ -30,14 +65,18 @@ function CodeEntry.left(state)
end
function CodeEntry.right(state)
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
state.pos = math.min(lengthOf(state), state.pos + 1)
end
-- the character in slot i, which is what a draw loop wants
function CodeEntry.charAt(state, i)
local charset = charsetOf(state)
return charset:sub(state.chars[i], state.chars[i])
end
function CodeEntry.text(state)
local out = {}
for i = 1, CodeEntry.LENGTH do
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
end
for i = 1, lengthOf(state) do out[i] = CodeEntry.charAt(state, i) end
return table.concat(out)
end
+53 -1
View File
@@ -112,6 +112,32 @@ function LinkState.newJoinOnline(game, code)
return self
end
-- Adopt a transport that is ALREADY paired and skip the connect UI: an
-- overworld multiplayer session handing one pair of players off to a battle
-- or a trade. The caller has settled which mode and which side hosts, so
-- all that is left is the hello exchange every link session runs before it
-- commits -- the fingerprint/mod compatibility check still gets its say,
-- exactly as it would have on the LAN or ONLINE path.
--
-- `transport` is anything Session accepts (update/poll/send/close plus the
-- .paired/.closed/.error fields), which is what lets a mod route a battle
-- over a channel of its own. Ownership transfers with it: exitWith closes
-- the session, so the caller's transport is done once this state unwinds.
--
-- opts.forceLevel the level rule, normally chosen on the battleOptions
-- screen; an adopted session has no menu to pick it on
function LinkState.newFromSession(game, transport, mode, isHost, opts)
local self = LinkState.new(game)
self.net = Session.new(transport, { role = isHost and "host" or "guest",
kind = "link" })
self.adopted = true
self.adoptedMode, self.adoptedHost = mode, isHost and true or false
self.forceLevel = opts and opts.forceLevel or nil
self.stage = "adopted"
self:sendHello(isHost and mode or nil)
return self
end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
@@ -203,8 +229,9 @@ function LinkState:decideCompat(mode, isHost)
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
})
if self.verdict == "full" or self.verdict == "vanilla_peer" then
if isHost and mode == "battle" then
if isHost and mode == "battle" and not self.adopted then
-- host picks the level rule now, before the parties are exchanged
-- (an adopted session had it passed in; see newFromSession)
self.stage = "battleOptions"
else
self:startMode(mode, isHost)
@@ -243,6 +270,16 @@ function LinkState:update(dt)
end
end
-- handed over from an already-paired session (LinkState.newFromSession):
-- both hellos are in flight, and the first one to land settles compat and
-- drops us straight into the agreed mode
if self.stage == "adopted" then
if self:pollHello() then
self:decideCompat(self.adoptedMode, self.adoptedHost)
end
return
end
if self.stage == "menu" then
if input:wasPressed("down") then
self.index = self.index % 3 + 1
@@ -497,11 +534,26 @@ function LinkState:update(dt)
return
end
self.game.stack:push(battle)
self.battle = battle
self.stage = "battleRunning"
end
elseif self.stage == "battleRunning" then
if self.game.stack:top() == self then
-- the lockstep copies carry the damage the real party never takes
-- (cable rules), so a mode that wants it -- a tournament ladder, a
-- battle royale -- reads it from here before the state unwinds
local battle = self.battle
if battle and Runtime.wants("link.battle_ended") then
Runtime.emit("link.battle_ended", {
result = battle.result or "ended",
myParty = battle.playerParty,
theirParty = battle.enemyParty,
peerName = self.peerName,
role = self.isHost and "host" or "guest",
})
end
self.battle = nil
self:exitWith(nil) -- battle finished
end
end
+5 -1
View File
@@ -273,7 +273,7 @@ function Loader.new(opts)
modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {},
fs = (opts and opts.fs) or (love and love.filesystem),
cart = opts and opts.cart or nil,
dev = dev,
dev = dev == true,
safeMode = false,
-- Which generation this boot is (1 or 2). Fixed at construction: the
-- active version is set once in main.lua's bootGame before anything
@@ -1148,6 +1148,10 @@ function Loader:_api(mod)
id = modId,
version = mod.manifest.version,
path = mod.path,
-- Fixed at Loader construction and copied as plain data: a sandboxed
-- entry chunk can decide whether to register developer-only diagnostics
-- without receiving the process environment or the loader itself.
developer = loader.dev == true,
-- a deep copy: what a mod does to its own view never reaches the loader
manifest = Merge.deepCopy(mod.manifest),
content = {},
+3 -1
View File
@@ -234,7 +234,9 @@ function Fetch.shutdown()
end
for _, th in ipairs(workers) do pcall(function() th:wait() end) end
workers = {}
cmdCh, resCh, quitCh, ready = nil, nil, nil, false
cmdCh, resCh, quitCh, ready = nil, nil, nil, nil
end
require("src.core.SessionLifecycle").registerProcessShutdown(Fetch.shutdown)
return Fetch
+25 -2
View File
@@ -14,6 +14,8 @@ local Assets = {}
local cache = {}
-- downstream caches that must empty when the search path changes
local invalidators = {}
-- optional GPU release hooks for session end (never run on hot reload flush)
local releasers = {}
-- The loader bridge: overrideOrder() yields mods highest-priority-first
-- and derivedPath(rel) yields an existing save/mod-derived/<id>/<rel>.
@@ -68,8 +70,18 @@ function Assets.imageData(path)
return love.image.newImageData(Assets.resolve(path))
end
function Assets.register(invalidate)
invalidators[#invalidators + 1] = invalidate
-- Register a cache invalidator, or { invalidate = fn, release = fn } when a
-- module caches LOVE Images/Canvases and can eagerly free them at session end.
-- release is optional and is NOT run on flush/invalidate (HotReload safe).
-- MapLoader is the canonical split-hook example: invalidateAll clears tables
-- without GPU release; releaseAll evicts every resident map renderer.
function Assets.register(hooks)
if type(hooks) == "function" then
invalidators[#invalidators + 1] = hooks
return
end
if hooks.invalidate then invalidators[#invalidators + 1] = hooks.invalidate end
if hooks.release then releasers[#releasers + 1] = hooks.release end
end
-- hot reload's single entry point (20-developer-tooling): drop the central
@@ -82,6 +94,17 @@ end
Assets.flush = Assets.invalidate
-- In-process return-to-launcher / editor close: release central Images and
-- run release hooks only. Does not call invalidate hooks (MapLoader must
-- keep invalidateAll separate from releaseAll).
function Assets.releaseSession()
for _, img in pairs(cache) do
if img and img.release then pcall(img.release, img) end
end
cache = {}
for _, fn in ipairs(releasers) do pcall(fn) end
end
-- Loader:load hands over the live mod set once the merge is done. Load
-- order is priority ascending, so the search walks it backwards: the mod
-- that wins the record merge wins the asset lookup too.
+2
View File
@@ -117,6 +117,8 @@ function Renderer:releaseCanvases()
self.worldOverride = nil
end
Renderer.release = Renderer.releaseCanvases
function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
+8
View File
@@ -166,6 +166,14 @@ local function build(game, id, ...)
inst = factory.new(game, ...)
end
inst.screenId = inst.screenId or id
-- Standardized opt-in marker for mod-created options/settings screens.
-- A mod may declare `isModOptions = true` on its screen factory table or
-- on the returned instance. Either way the flag is propagated so that other
-- UI mods can detect mod options screens reliably without brittle screenId
-- string-matching (issue #1697).
if factory.isModOptions and inst.isModOptions == nil then
inst.isModOptions = true
end
return inst
end
+80 -4
View File
@@ -6,6 +6,8 @@ local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData")
local FilePicker = require("src.core.FilePicker")
local SafeArea = require("src.core.SafeArea")
local GamepadMap = require("src.core.GamepadMap")
local PadCursor = require("src.ui.PadCursor")
local Studio = {}
@@ -501,6 +503,7 @@ function Studio.load(opts)
Studio.thumbs = {}
Studio.pointerX, Studio.pointerY = nil, nil
Studio.pointerDown, Studio.touchId = false, nil
PadCursor.reset()
-- Studio always opens on the library. Creating and choosing a skin are
-- first-class tasks, not controls buried inside the editor workspace.
Studio.mode = "library"
@@ -758,6 +761,7 @@ function Studio.unload()
Studio.undoStack, Studio.redoStack = {}, {}
Studio.pointerX, Studio.pointerY = nil, nil
Studio.pointerDown, Studio.touchId = false, nil
PadCursor.reset()
end
-- The Studio is a real touch editor on Android and iOS. Keeping this here,
@@ -2678,7 +2682,12 @@ function Studio.draw()
local f = Studio._frame
Kit.layout(f.w, f.h)
local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
local px, py, padActive = PadCursor.pointer()
if padActive then
mx, my = px, py
elseif Studio.pointerX ~= nil then
mx, my = Studio.pointerX, Studio.pointerY
end
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, W, H, PAL.bg, 1)
@@ -2691,19 +2700,26 @@ function Studio.draw()
Kit.blockClicks = false
Studio.drawOverlay(f.w, f.h)
Kit.endFrame()
PadCursor.draw()
end
-- ---------------------------------------------------------------- input
function Studio.update()
function Studio.update(dt)
PadCursor.update(dt or 0)
local x, y, active = PadCursor.pointer()
if active and Studio.pointerDown then Studio.mousemoved(x, y) end
local wheel = PadCursor.takeWheel()
if wheel ~= 0 then Studio.wheelmoved(0, wheel) end
if not Studio.pendingPlay then return end
Studio.pendingPlay = false
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
if onPlay then onPlay(version, canvas) end
end
function Studio.mousepressed(x, y, button)
function Studio.mousepressed(x, y, button, fromPad)
if button ~= 1 then return end
if not fromPad then PadCursor.yieldToPointer() end
Studio.pointerX, Studio.pointerY = x, y
Studio.pointerDown = true
Studio.clicked = true
@@ -2760,6 +2776,65 @@ function Studio.touchpressed(id, x, y)
return Studio.mousepressed(x, y, 1)
end
local function closeFromPad()
if Studio.confirm then
Studio.confirmNo()
elseif Studio.modal then
Studio.closeModal()
elseif Studio.mode == "editor" then
Studio.backToLibrary()
elseif Studio.onClose then
Studio.onClose()
end
end
local function handlePadAction(action)
if action == "a" then
local x, y = PadCursor.pointer()
Studio.mousepressed(x, y, 1, true)
elseif action == "b" then
closeFromPad()
end
end
function Studio.gamepadpressed(joystick, button)
handlePadAction(PadCursor.gamepadpressed(joystick, button))
end
function Studio.gamepadreleased(joystick, button)
PadCursor.gamepadreleased(joystick, button)
if GamepadMap.mapGamepadButton(button) == "a" then
local x, y = PadCursor.pointer()
Studio.mousereleased(x, y, 1)
end
end
function Studio.gamepadaxis(joystick, axis, value)
PadCursor.gamepadaxis(joystick, axis, value)
end
function Studio.joystickpressed(joystick, button)
handlePadAction(PadCursor.joystickpressed(joystick, button))
end
function Studio.joystickreleased(joystick, button)
PadCursor.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton and GamepadMap.mapGamepadButton(padButton) == "a" then
local x, y = PadCursor.pointer()
Studio.mousereleased(x, y, 1)
end
end
function Studio.joystickaxis(joystick, axis, value)
PadCursor.joystickaxis(joystick, axis, value)
end
function Studio.joystickhat(joystick, hat, direction)
PadCursor.joystickhat(joystick, hat, direction)
end
function Studio.touchmoved(id, x, y)
if Studio.touchId ~= id then return end
return Studio.mousemoved(x, y)
@@ -2774,7 +2849,8 @@ end
function Studio.wheelmoved(_, dy)
if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then
local work = Studio.canvasWorkspace
local mx, my = Studio.pointerX, Studio.pointerY
local mx, my, active = PadCursor.pointer()
if not active then mx, my = Studio.pointerX, Studio.pointerY end
if (not mx or not my) and love and love.mouse and love.mouse.getPosition then
mx, my = love.mouse.getPosition()
end
+32 -4
View File
@@ -138,11 +138,39 @@ function Chrome.printThrough(text, tx, ty, palette, invert)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
love.graphics.setColor(1, 1, 1, 1)
-- Per glyph, not per string: a TTF-mod build still keeps the multi-byte
-- charmap sequences (the naming screen's <PK>/<MN>, the 'd/'l/'s ligatures)
-- and anything the mod names in ttf.tiles (Font.lua's own note on keeping
-- digits tile-based for column alignment) on their ROM tiles, so a string
-- can freely mix the two kinds of glyph.
local ink = pal[4] or { 0, 0, 0 }
local previous = love.graphics.getShader()
GbcPalette.useRaw(pal)
Font.draw(text, tx * 8, ty * 8)
love.graphics.setShader(previous)
local shaded = false
local pen = tx * 8
for _, code in ipairs(Font.encode(text)) do
if code >= Font.TTF_BASE then
-- The shader below recovers a shade from the RED CHANNEL of an already
-- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not.
-- LÖVE's font rasterizer stores glyph coverage as alpha over a plain
-- white texture, which this shader reads back as shade 0 no matter how
-- solid the glyph looks, painting the character the SAME colour as the
-- paper rect just drawn above it: invisible (reported against a real
-- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF
-- glyph has no discrete shade to recover in the first place, so skip
-- the shader for it and tint it with the palette's own ink colour
-- (shade 3, the same entry `rgb = pal3` would have mapped a black tile
-- pixel to) directly.
if shaded then love.graphics.setShader(previous); shaded = false end
love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
elseif not shaded then
love.graphics.setColor(1, 1, 1, 1)
GbcPalette.useRaw(pal)
shaded = true
end
Font.drawCode(code, pen, ty * 8)
pen = pen + Font.advanceOf(code)
end
if shaded then love.graphics.setShader(previous) end
love.graphics.setColor(0, 0, 0, 1)
return width
end
+11 -10
View File
@@ -24,6 +24,7 @@ local Logger = require("src.core.Logger")
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime")
local Save = require("src.core.gen2.Save")
local Strings = require("src.core.Strings")
local MainMenu = {}
MainMenu.__index = MainMenu
@@ -73,14 +74,14 @@ local function sameItems(_, items) return items end
function MainMenu:buildList()
local items = {}
if self.hasSave then
items[#items + 1] = { label = "CONTINUE", value = "continue" }
items[#items + 1] = { label = Strings("CONTINUE"), value = "continue" }
end
items[#items + 1] = { label = "NEW GAME", value = "new" }
items[#items + 1] = { label = "OPTION", value = "option" }
items[#items + 1] = { label = Strings("NEW GAME"), value = "new" }
items[#items + 1] = { label = Strings("OPTION"), value = "option" }
-- Not on the cart: a cartridge is left by switching the console off, and
-- there is no console here. Mirrors the Gen 1 port's title menu
-- (src/ui/TitleState.lua), which adds the same row for the same reason.
items[#items + 1] = { label = "EXIT GAME", value = "exit" }
items[#items + 1] = { label = Strings("EXIT GAME"), value = "exit" }
-- The same hook name and the same (game, items) payload the Gen 1 title
-- menu raises (src/ui/TitleState.lua:openMenu), so one mod's title rows
-- serve both games; only the row shape differs, because Chrome.List reads
@@ -170,7 +171,7 @@ function MainMenu:drawClockBox()
-- minutes; the AM/PM half is drawn by PrintHour itself.
local display = hour % 12
if display == 0 then display = 12 end
local half = hour < 12 and "AM" or "PM"
local half = Strings(hour < 12 and "AM" or "PM")
Chrome.print(("%s:%s %s"):format(
Chrome.number(display, 2), Chrome.number(minute, 2, true), half), 4, 16)
end
@@ -180,15 +181,15 @@ function MainMenu:drawSavePanel()
-- DisplaySaveInfoOnContinue: a box down the right side listing the trainer.
Chrome.textbox(4, 0, 14, 9)
if not summary then
Chrome.print("NO SAVE FILE", 5, 2)
Chrome.print(Strings("NO SAVE FILE"), 5, 2)
return
end
Chrome.print("PLAYER " .. summary.name, 5, 2)
Chrome.print("BADGES", 5, 4)
Chrome.print(Strings("PLAYER %s", summary.name), 5, 2)
Chrome.print(Strings("BADGES"), 5, 4)
Chrome.printRight(tostring(summary.badges), 17, 4)
Chrome.print("POKéDEX", 5, 6)
Chrome.print(Strings("POKéDEX"), 5, 6)
Chrome.printRight(tostring(summary.caught), 17, 6)
Chrome.print("TIME", 5, 8)
Chrome.print(Strings("TIME"), 5, 8)
Chrome.printRight(("%d:%s"):format(
summary.hours, Chrome.number(summary.minutes, 2, true)), 17, 8)
end
+30 -14
View File
@@ -21,6 +21,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local NamingScreen = {}
NamingScreen.__index = NamingScreen
@@ -67,8 +68,15 @@ local BOX_INPUT_LOWER = {
-- case target names the board it SWITCHES TO, not the one it is on: the last
-- row of NameInputUpper is "lower DEL END" and the last row of
-- NameInputLower is "UPPER DEL END" (data/text/name_input_chars.asm).
local BOTTOM_UPPER_LABELS = { "lower", "DEL", "END" }
local BOTTOM_LOWER_LABELS = { "UPPER", "DEL", "END" }
-- Wrapped in Strings.source, not Strings: both tables are built once at
-- require time, before any mod's Strings.load has a catalog to answer from
-- (src/core/Strings.lua's own note on this); drawPanel resolves them live.
local BOTTOM_UPPER_LABELS = {
Strings.source("lower"), Strings.source("DEL"), Strings.source("END"),
}
local BOTTOM_LOWER_LABELS = {
Strings.source("UPPER"), Strings.source("DEL"), Strings.source("END"),
}
-- Cursor tile for each target: NamingScreen_AnimateCursor's .CaseDelEnd adds
-- pixel $00 / $30 / $60 to the cursor's own XCOORD of 24 (`depixel 10, 3`),
-- which is OAM x 24 / 72 / 120 and so screen tile 2 / 8 / 14. The bracket is
@@ -81,11 +89,11 @@ local BOTTOM_CURSOR_TILES = 5
-- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes.
-- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters.
NamingScreen.TYPES = {
player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS",
spriteFemale = "SPRITE_KRIS" },
rival = { prompt = "RIVAL'S NAME?", maxLength = 7, sprite = "SPRITE_RIVAL" },
mom = { prompt = "MOTHER'S NAME?", maxLength = 7, sprite = "SPRITE_MOM" },
box = { prompt = "BOX NAME?", maxLength = 8, isBox = true },
player = { prompt = Strings.source("YOUR NAME?"), maxLength = 7,
sprite = "SPRITE_CHRIS", spriteFemale = "SPRITE_KRIS" },
rival = { prompt = Strings.source("RIVAL'S NAME?"), maxLength = 7, sprite = "SPRITE_RIVAL" },
mom = { prompt = Strings.source("MOTHER'S NAME?"), maxLength = 7, sprite = "SPRITE_MOM" },
box = { prompt = Strings.source("BOX NAME?"), maxLength = 8, isBox = true },
nickname = { prompt = nil, maxLength = 10 },
}
@@ -114,7 +122,7 @@ function NamingScreen.new(game, opts)
or (game and game.save and game.save.player and game.save.player.gender)
self.isBox = opts.isBox or kind.isBox or false
self.maxLength = opts.maxLength or kind.maxLength or 7
self.prompt = opts.prompt or kind.prompt or "NICKNAME?"
self.prompt = opts.prompt or kind.prompt or Strings.source("NICKNAME?")
self.monName = opts.monName
self.onDone = opts.onDone
self.onCancel = opts.onCancel
@@ -504,11 +512,15 @@ function NamingScreen:drawPanel()
end
local pal = self.palette
if self.monName then
-- Nickname header is two lines: "<MON>'S" then "NICKNAME?".
Chrome.printThrough(self.monName .. "'S", 5, 2, pal)
Chrome.printThrough("NICKNAME?", 5, 4, pal)
-- Nickname header is two lines: "<MON>'S" then "NICKNAME?". Kept as two
-- Strings() calls, one per line (Chrome.printThrough draws a single row),
-- with the mon name folded into the first line's own format string so a
-- language whose possessive is not a bare suffix can restructure that
-- line rather than being stuck splicing one on.
Chrome.printThrough(Strings("%s'S", self.monName), 5, 2, pal)
Chrome.printThrough(Strings("NICKNAME?"), 5, 4, pal)
else
Chrome.printThrough(self.prompt, 5, 2, pal)
Chrome.printThrough(Strings(self.prompt), 5, 2, pal)
end
self:drawEntry(5, self.isBox and 4 or 6)
@@ -520,14 +532,18 @@ function NamingScreen:drawPanel()
for col = 0, 8 do
local ch = line[col + 1]
if ch and ch ~= " " and ch ~= "" then
Chrome.printThrough(ch, 2 + col * 2, keyboardTop + row * 2, pal)
-- Same seam the Gen 1 board's cells go through (src/ui/NamingScreen
-- .lua's own Strings(cell)): a script whose alphabet does not fit
-- A-Z can swap a cell's glyph without needing the heavier
-- ui.naming.grid hook this screen also offers.
Chrome.printThrough(Strings(ch), 2 + col * 2, keyboardTop + row * 2, pal)
end
end
end
local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS
local bottomY = keyboardTop + bottom * 2
for i, label in ipairs(labels) do
Chrome.printThrough(label, BOTTOM_LABEL_TX[i], bottomY, pal)
Chrome.printThrough(Strings(label), BOTTOM_LABEL_TX[i], bottomY, pal)
end
local function cursor() self:drawCursorBox(self:cursorTile()) end
+84 -52
View File
@@ -23,6 +23,7 @@ local Logger = require("src.core.Logger")
local Performance = require("src.core.Performance")
local Runtime = require("src.mods.Runtime")
local Save = require("src.core.gen2.Save")
local Strings = require("src.core.Strings")
local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu
@@ -43,43 +44,60 @@ end
-- Each row: the label, the option key it edits, and the cycle of values with
-- the exact strings the cart prints (trailing spaces included -- they are what
-- blank the longer previous value, e.g. "MID " over "SLOW").
-- Labels and cart-original display strings are wrapped in Strings.source so
-- the catalog generator harvests them even though this table is built once
-- at require time, before any mod's Strings.load has a catalog to answer
-- from (src/core/Strings.lua's own note on this). The lookup itself happens
-- live, in drawPanel, through plain Strings(...) calls.
local ROWS = {
{
label = "TEXT SPEED", key = "textSpeed",
label = Strings.source("TEXT SPEED"), key = "textSpeed",
values = { "FAST", "MID", "SLOW" },
display = { FAST = "FAST", MID = "MID ", SLOW = "SLOW" },
},
{
label = "BATTLE SCENE", key = "battleScene",
values = { true, false },
display = { [true] = "ON ", [false] = "OFF" },
},
{
label = "BATTLE STYLE", key = "battleStyle",
values = { "SHIFT", "SET" },
display = { SHIFT = "SHIFT", SET = "SET " },
},
{
label = "SOUND", key = "sound",
values = { "MONO", "STEREO" },
display = { MONO = "MONO ", STEREO = "STEREO" },
},
{
label = "PRINT", key = "print",
values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" },
display = {
LIGHTEST = "LIGHTEST", LIGHTER = "LIGHTER ", NORMAL = "NORMAL ",
DARKER = "DARKER ", DARKEST = "DARKEST ",
FAST = Strings.source("FAST"), MID = Strings.source("MID "),
SLOW = Strings.source("SLOW"),
},
},
{
label = "MENU ACCOUNT", key = "menuAccount",
label = Strings.source("BATTLE SCENE"), key = "battleScene",
values = { true, false },
display = {
[true] = Strings.source("ON "), [false] = Strings.source("OFF"),
},
},
{
label = Strings.source("BATTLE STYLE"), key = "battleStyle",
values = { "SHIFT", "SET" },
display = {
SHIFT = Strings.source("SHIFT"), SET = Strings.source("SET "),
},
},
{
label = Strings.source("SOUND"), key = "sound",
values = { "MONO", "STEREO" },
display = {
MONO = Strings.source("MONO "), STEREO = Strings.source("STEREO"),
},
},
{
label = Strings.source("PRINT"), key = "print",
values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" },
display = {
LIGHTEST = Strings.source("LIGHTEST"), LIGHTER = Strings.source("LIGHTER "),
NORMAL = Strings.source("NORMAL "), DARKER = Strings.source("DARKER "),
DARKEST = Strings.source("DARKEST "),
},
},
{
label = Strings.source("MENU ACCOUNT"), key = "menuAccount",
values = { false, true },
display = { [false] = "OFF", [true] = "ON " },
display = {
[false] = Strings.source("OFF"), [true] = Strings.source("ON "),
},
},
-- FRAME is the textbox border style, 1-8, and prints its number after the
-- word TYPE rather than in the shared value column.
{ label = "FRAME", key = "frame", frame = true },
{ label = Strings.source("FRAME"), key = "frame", frame = true },
-- Everything from here down is the port's, not the cart's. They are the
-- same settings the Gen 1 OPTION screen carries and they drive the same
-- shared modules, so a player who learns them in Red knows them here. The
@@ -87,17 +105,17 @@ local ROWS = {
--
-- The two volume rows clamp at the ends rather than wrapping, the way
-- pokered's text-speed cursor does, so holding left reaches OFF and stays.
{ id = "controls", label = "CONTROLS", port = true,
{ id = "controls", label = Strings.source("CONTROLS"), port = true,
activate = function(game)
require("src.ui.Screens").push(game, "BindingsMenu")
end },
{ label = "MUSIC VOL", key = "musicVol", port = true,
{ label = Strings.source("MUSIC VOL"), key = "musicVol", port = true,
cycle = function(options, delta)
options.musicVol = stepVolume(options.musicVol, delta)
require("src.core.Music").setVolumeLevel(options.musicVol)
end,
text = function(options) return volLabel(options.musicVol) end },
{ label = "SFX VOL", key = "sfxVol", port = true,
{ label = Strings.source("SFX VOL"), key = "sfxVol", port = true,
cycle = function(options, delta)
options.sfxVol = stepVolume(options.sfxVol, delta)
require("src.core.Sound").setVolumeLevel(options.sfxVol)
@@ -105,7 +123,7 @@ local ROWS = {
text = function(options) return volLabel(options.sfxVol) end },
-- Each filter step keeps 40% of the previous step's treble, so 2X and 3X
-- are the 1X low-pass applied twice and three times over.
{ label = "MUSIC FILTER", key = "musicFilter", port = true,
{ label = Strings.source("MUSIC FILTER"), key = "musicFilter", port = true,
cycle = function(options, delta)
options.musicFilter = ((options.musicFilter or 0) + delta) % #FILTERS
require("src.core.Music").setFilterLevel(options.musicFilter)
@@ -119,7 +137,7 @@ local ROWS = {
-- file's own `text`/`cycle`) works here unmodified: OptionsMenu:cycle
-- answers `row.step` first, and drawPanel already reads a function
-- `row.value` -- both written for exactly this kind of shared mod row.
{ id = "performance", label = "PERFORMANCE", port = true,
{ id = "performance", label = Strings.source("PERFORMANCE"), port = true,
value = function(g)
return Performance.label(g.options and g.options.performance)
end,
@@ -129,7 +147,7 @@ local ROWS = {
g:applyOptions()
return true
end },
{ label = "GAME SPEED", key = "speed", port = true,
{ label = Strings.source("GAME SPEED"), key = "speed", port = true,
cycle = function(options, delta)
local GameSpeed = require("src.core.GameSpeed")
options.speed = GameSpeed.cycle(options.speed, delta)
@@ -137,7 +155,7 @@ local ROWS = {
text = function(options)
return require("src.core.GameSpeed").levelLabel(options.speed)
end },
{ label = "ZOOM", key = "zoom", port = true,
{ label = Strings.source("ZOOM"), key = "zoom", port = true,
cycle = function(options, delta, game)
local Zoom = require("src.render.Zoom")
local scale = Zoom.windowFitScale()
@@ -153,7 +171,7 @@ local ROWS = {
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
-- is Gold's default because that is already what the maps call for).
{ label = "VOID FILL", key = "voidFill", port = true,
{ label = Strings.source("VOID FILL"), key = "voidFill", port = true,
cycle = function(options, delta)
local BorderFill = require("src.world.gen2.BorderFill")
BorderFill.setVoidFill(options.voidFill or "fade")
@@ -162,7 +180,7 @@ local ROWS = {
text = function(options)
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
end },
{ label = "TILT", key = "tilt", port = true,
{ label = Strings.source("TILT"), key = "tilt", port = true,
cycle = function(options, delta)
local Tilt = require("src.render.Tilt")
-- Four levels (OFF, 15, 35, 50); left steps back through them.
@@ -177,7 +195,7 @@ local ROWS = {
-- CGB game whose colour comes from its own palettes, so there are no packs
-- to swap -- what there is instead is the choice to turn that colour OFF,
-- down to the grey Game Boy or the green one. GBC is the default.
{ label = "COLOR", key = "color", port = true,
{ label = Strings.source("COLOR"), key = "color", port = true,
cycle = function(options, delta)
local GbcPalette = require("src.render.GbcPalette")
GbcPalette.setMode(options.color or "gbc")
@@ -186,12 +204,26 @@ local ROWS = {
text = function(options)
return require("src.render.GbcPalette").modeLabel(options.color or "gbc")
end },
{ label = Strings.source("GBC FX"), key = "gbcfx", port = true,
cycle = function(options, delta)
local GBCFX = require("src.render.GBCFX")
if not GBCFX.isSupported() then
options.gbcfx = 0
return
end
local level = ((options.gbcfx or 0) + delta) % 5
options.gbcfx = level
GBCFX.setLevel(level)
end,
text = function(options)
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
end },
-- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as
-- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) --
-- ShaderFXScreen is the shared list screen both generations push, `id`
-- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also
-- reaches Gold.
{ id = "shaderfx", label = "SHADER FX", port = true,
{ id = "shaderfx", label = Strings.source("SHADER FX"), port = true,
text = function(options)
local ShaderFX = require("src.render.ShaderFX")
local entry = ShaderFX.activeEntry("main")
@@ -204,7 +236,7 @@ local ROWS = {
-- Dual-shader secondary slot, same shared ShaderFXScreen as the row
-- above, opened on "secondary" instead -- see src/ui/OptionsMenu.lua's
-- mirror of this row for the full rationale.
{ id = "shaderfx2", label = "SHADER FX 2", port = true,
{ id = "shaderfx2", label = Strings.source("SHADER FX 2"), port = true,
text = function(options)
local ShaderFX = require("src.render.ShaderFX")
local entry = ShaderFX.activeEntry("secondary")
@@ -214,7 +246,7 @@ local ROWS = {
activate = function(game)
require("src.ui.Screens").push(game, "ShaderFXScreen", "secondary")
end },
{ label = "VIDEO MODE", key = "videoMode", port = true,
{ label = Strings.source("VIDEO MODE"), key = "videoMode", port = true,
cycle = function(options, delta)
local VideoMode = require("src.core.VideoMode")
options.videoMode = VideoMode.cycle(options.videoMode, delta)
@@ -225,20 +257,20 @@ local ROWS = {
return VideoMode.normalize(options.videoMode) == "borderless"
and "FULL" or "WINDOWED"
end },
{ label = "SCREEN POS", key = "screenPos", port = true,
{ label = Strings.source("SCREEN POS"), key = "screenPos", port = true,
cycle = function(options, delta)
local ScreenPosition = require("src.core.ScreenPosition")
options.screenPos = ScreenPosition.cycle(options.screenPos, delta)
ScreenPosition.setMode(options.screenPos)
end,
text = function(options)
return require("src.core.ScreenPosition").label(options.screenPos)
return Strings(require("src.core.ScreenPosition").label(options.screenPos))
end },
{ id = "touchControls", label = "TOUCH PAD", port = true,
{ id = "touchControls", label = Strings.source("TOUCH PAD"), port = true,
text = function(options)
local tc = options.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and "ON" or "OFF"
return on and Strings("ON") or Strings("OFF")
end,
cycle = function(options, _delta, game)
local tc = type(options.touchControls) == "table" and options.touchControls or {}
@@ -247,13 +279,13 @@ local ROWS = {
require("src.core.TouchControls"):applyOptions(options)
if game and game.persistOptions then game:persistOptions() end
end },
{ id = "touchLayout", label = "TOUCH LAYOUT", port = true,
{ id = "touchLayout", label = Strings.source("TOUCH LAYOUT"), port = true,
activate = function(game)
game.stack:push(require("src.ui.TouchControlsEditor").new(game))
end },
{ id = "haptics", label = "VIBRATION", port = true,
{ id = "haptics", label = Strings.source("VIBRATION"), port = true,
text = function(options)
return require("src.core.TouchControls").hapticLabel(options.haptics)
return Strings(require("src.core.TouchControls").hapticLabel(options.haptics))
end,
cycle = function(options, delta, game)
local TC = require("src.core.TouchControls")
@@ -262,7 +294,7 @@ local ROWS = {
TC.buzz(options.haptics)
if game and game.persistOptions then game:persistOptions() end
end },
{ label = "MAX FPS", key = "fpsCap", port = true,
{ label = Strings.source("MAX FPS"), key = "fpsCap", port = true,
cycle = function(options, delta)
local FrameCap = require("src.core.FrameCap")
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
@@ -273,10 +305,10 @@ local ROWS = {
end },
-- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE
-- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only.
{ label = "BATTLE BG", key = "battleBg", port = true,
{ label = Strings.source("BATTLE BG"), key = "battleBg", port = true,
values = { "white", "black" },
display = { white = "WHITE", black = "BLACK" } },
{ label = "CANCEL", cancel = true },
{ label = Strings.source("CANCEL"), cancel = true },
}
-- The cart's screen is one full-height textbox with every row on it. This one
@@ -460,9 +492,9 @@ function OptionsMenu:drawPanel()
local row = self.rows[i]
if row then
local labelY = 2 + (slot - 1) * 2
Chrome.print(row.label, 2, labelY)
Chrome.print(Strings(row.label), 2, labelY)
if row.frame then
Chrome.print(":TYPE", 10, labelY + 1)
Chrome.print(Strings(":TYPE"), 10, labelY + 1)
Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1)
elseif row.text then
Chrome.print(":", 10, labelY + 1)
@@ -470,7 +502,7 @@ function OptionsMenu:drawPanel()
elseif row.values then
Chrome.print(":", 10, labelY + 1)
local value = self.options[row.key]
local text = row.display and row.display[value] or tostring(value)
local text = row.display and Strings(row.display[value]) or tostring(value)
Chrome.print(text, 11, labelY + 1)
elseif type(row.value) == "function" then
-- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row
+3 -2
View File
@@ -28,6 +28,7 @@ local Font = require("src.render.Font")
local Palettes = require("src.world.gen2.Palettes")
local Phone = require("src.core.gen2.Phone")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Strings = require("src.core.Strings")
local TileSheet = require("src.ui.gen2.TileSheet")
local Pokegear = {}
@@ -1959,7 +1960,7 @@ function Pokegear:drawClock()
self:text(Chrome.number(display, 2), 6, 8)
self:text(":", 8, 8)
self:text(Chrome.number(minute, 2, true), 9, 8)
self:text(hour < 12 and "AM" or "PM", 12, 8)
self:text(Strings(hour < 12 and "AM" or "PM"), 12, 8)
-- The bottom Textbox is part of the card (lb bc, 4, 18 at (0,12)), and
-- PokegearClock_Init prints PokegearPressButtonText straight into it
@@ -2373,7 +2374,7 @@ function Pokegear:drawPlain()
if display == 0 then display = 12 end
Chrome.print(("%s:%s %s"):format(
Chrome.number(display, 2), Chrome.number(minute, 2, true),
hour < 12 and "AM" or "PM"), 5, 9)
Strings(hour < 12 and "AM" or "PM")), 5, 9)
Chrome.print(Clock.daytimeLabel(hour), 5, 11)
elseif id == "radio" then
-- Without the gear sheet there is no dial art, so the frequencies go down
+54 -12
View File
@@ -24,8 +24,10 @@
-- one-line call.
local Chrome = require("src.ui.gen2.Chrome")
local Logger = require("src.core.Logger")
local Save = require("src.core.gen2.Save")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local SaveMenu = {}
SaveMenu.__index = SaveMenu
@@ -55,10 +57,50 @@ local TIME_X, TIME_Y = 13, 8
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save.
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save
-- (src/ui/gen2/PcMenu.lua:savePrompt() reads these two tables' lines[1]/
-- lines[2] directly, so their shape is a cross-file contract: keep them
-- plain, untranslated tables).
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" }
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." }
-- Translatable copies of the two prompts above, one \n-joined key each, used
-- only by this screen's own prompt() below. One key per prompt lets a
-- translation write one whole, freely reordered sentence instead of two
-- fragments translated in isolation, and lets a cart whose own text is a
-- single line (German's SAVING prompt) say so directly by simply omitting
-- the "\n" -- the per-line override style used elsewhere requires a
-- non-empty value for every line, so it can't express "this line is blank".
--
-- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: the
-- translation tooling's string harvester only recognizes a literal inside
-- Strings.source(...), not a computed expression, so a concat call here
-- would quietly never reach a translator. Keep byte-for-byte in sync with
-- OVERWRITE_PROMPT/SAVING_PROMPT above (checked by
-- tests/engine/gen2_save_menu_translation_test.lua).
local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it")
local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.")
-- Splits a translated "line one\nline two" string back into the two-slot
-- table drawPanel's fixed Chrome.print calls expect. No "\n" at all (a
-- single-line message, or German's one-line SAVING prompt) lands whole on
-- the first slot, matching the untranslated code's own { text, "" } shape.
--
-- Only the first "\n" splits, since this box has room for exactly two
-- lines. A third line would otherwise draw as a raw newline byte -- garbage
-- glyph data -- with no other sign anything went wrong, so this warns once
-- per string instead.
local warnedTooManyLines = {}
local function twoLines(text)
local first, second = text:match("^(.-)\n(.*)$")
if second and second:find("\n", 1, true) and not warnedTooManyLines[text] then
warnedTooManyLines[text] = true
Logger.warn("SaveMenu: translation of %q has more than two lines; " ..
"only the first two fit this box", text)
end
return { first or text, second or "" }
end
function SaveMenu:wantsFillScale() return true end
function SaveMenu:drawsWidescreen() return true end
@@ -171,18 +213,18 @@ function SaveMenu:prompt()
if self.phase == "overwrite" then
-- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText
-- when the ID differs. Only the first can happen here.
return OVERWRITE_PROMPT
return twoLines(Strings(OVERWRITE_PROMPT_SOURCE))
end
if self.phase == "saving" then
return SAVING_PROMPT
return twoLines(Strings(SAVING_PROMPT_SOURCE))
end
if self.phase == "done" then
if self.saved then
return { self:playerName() .. " saved", "the game." }
return twoLines(Strings("%s saved\nthe game.", self:playerName()))
end
return { "Could not save.", "" }
return twoLines(Strings("Could not save."))
end
return { "Would you like to", "save the game?" }
return twoLines(Strings("Would you like to\nsave the game?"))
end
function SaveMenu:drawPanel()
@@ -190,10 +232,10 @@ function SaveMenu:drawPanel()
local summary = Save.summary(self.save)
Chrome.box(PANEL_X, PANEL_Y, PANEL_W, PANEL_H)
if summary then
Chrome.print("PLAYER " .. summary.name, LABEL_X, LABEL_Y)
Chrome.print("BADGES", LABEL_X, LABEL_Y + 2)
Chrome.print("POKéDEX", LABEL_X, LABEL_Y + 4)
Chrome.print("TIME", LABEL_X, LABEL_Y + 6)
Chrome.print(Strings("PLAYER %s", summary.name), LABEL_X, LABEL_Y)
Chrome.print(Strings("BADGES"), LABEL_X, LABEL_Y + 2)
Chrome.print(Strings("POKéDEX"), LABEL_X, LABEL_Y + 4)
Chrome.print(Strings("TIME"), LABEL_X, LABEL_Y + 6)
-- PrintNum fills its field from the left, space padded.
Chrome.print(Chrome.number(summary.badges, 2), BADGES_X, BADGES_Y)
Chrome.print(Chrome.number(summary.caught, 3), DEX_X, DEX_Y)
@@ -211,8 +253,8 @@ function SaveMenu:drawPanel()
if self.phase == "confirm" or self.phase == "overwrite" then
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1)
Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3)
Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.choice == 1 and 1 or 3))
end
love.graphics.setColor(1, 1, 1, 1)
+3 -1
View File
@@ -343,7 +343,9 @@ function Check.shutdown()
if cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, stateCh = nil, nil, nil
workerReady = false
workerReady = nil
end
require("src.core.SessionLifecycle").registerProcessShutdown(Check.shutdown)
return Check
+15 -2
View File
@@ -114,12 +114,25 @@ function MapLoader.invalidateAll()
lru = {}
end
-- Eagerly release every resident map's TileRenderer GPU objects. Only safe
-- when nothing live holds map instances (session end after game:reset).
function MapLoader.releaseAll()
local ids = {}
for id in pairs(cache) do ids[#ids + 1] = id end
for _, id in ipairs(ids) do MapLoader.evict(id) end
end
-- kept as the pre-v2 name
MapLoader.clearCache = MapLoader.invalidateAll
-- the cached Map objects own the per-map TileRenderer instances, so a flush
-- that skipped this one would leave live SpriteBatches built from the old
-- search path (14 cache-invalidation contract, rows 1 and 3)
Assets.register(MapLoader.invalidateAll)
-- search path (14 cache-invalidation contract, rows 1 and 3). invalidateAll
-- deliberately does NOT release GPU (hot reload / live overworld); releaseAll
-- is the session-end path only.
Assets.register({
invalidate = MapLoader.invalidateAll,
release = MapLoader.releaseAll,
})
return MapLoader
+15 -1
View File
@@ -246,6 +246,11 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
return out
end
function OverworldState:exit()
self.map = nil
self.neighbors = nil
end
function OverworldState:enter(mapId, x, y, facing, opts)
Game = require("src.core.Game")
Game.overworld = self
@@ -2125,6 +2130,10 @@ function OverworldState:pushableAtCell(cx, cy)
return nil
end
-- world.talk's fallthrough, hoisted so the A press does not build a closure
-- on every press just to have one to hand a hook nobody may have wrapped
local function vanillaTalk(ow, target) ow:talkTo(target) end
-- what the A press resolved to, for world.interacted's listeners
local function interacted(self, fx, fy, kind, target)
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
@@ -2154,7 +2163,12 @@ function OverworldState:interact()
-- talk() lands the follower on its cell first.
require("src.world.PikachuFollower").talk(Game, self, npc)
elseif not npc.moving then
self:talkTo(npc)
-- world.talk: the A press on an object, before the map's text tables
-- get it. A runtime object a mod spawned (WorldAPI:spawnNpc) carries
-- no TEXT_* id, so the vanilla path has nothing to say for it; a mod
-- that owns the object wraps this and simply does not call next().
-- Everything else falls straight through to talkTo as before.
Runtime.call("world.talk", vanillaTalk, self, npc)
end
interacted(self, fx, fy, "npc", npc)
return
+108
View File
@@ -5,6 +5,7 @@
-- quiet no-op, never a crash. Reaching into OverworldState internals
-- stays unsupported; anything a mod legitimately needs belongs here.
local Collision = require("src.world.Collision")
local Logger = require("src.core.Logger")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
@@ -117,6 +118,47 @@ function WorldAPI:current()
facing = p and p.facing }
end
local function validBlockCoordinate(value)
return type(value) == "number" and value == value
and value ~= math.huge and value ~= -math.huge
and value == math.floor(value)
end
-- Read one block from the active Gen 1 map without exposing the mutable block
-- array. Requiring the expected map id makes a stale signature fail closed
-- if a warp or reload moved the player before the caller completed its check.
-- Block coordinates are zero-based, matching replaceBlock.
function WorldAPI:activeBlockAt(mapId, bx, by)
local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end
local map = ow.map
if map.id ~= mapId then return nil, "map is not active" end
if not validBlockCoordinate(bx) or not validBlockCoordinate(by) then
return nil, "invalid block coordinates"
end
local def = map.def
if not def or not validBlockCoordinate(def.width) or def.width <= 0
or not validBlockCoordinate(def.height) or def.height <= 0 then
return nil, "block unavailable"
end
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
return nil, "block coordinates out of bounds"
end
if type(def.blocks) ~= "table" or type(map.blockAt) ~= "function" then
return nil, "block unavailable"
end
local stored = def.blocks[by * def.width + bx + 1]
if not validBlockCoordinate(stored) or stored < 0 then
return nil, "block unavailable"
end
local ok, blockId = pcall(map.blockAt, map, bx, by)
if not ok or not validBlockCoordinate(blockId) or blockId < 0
or blockId ~= stored then
return nil, "block unavailable"
end
return blockId
end
-- Companion UIs may offer party ordering while the player is in free roam.
-- The same guard that makes opening a menu safe keeps scripts, transitions,
-- movement and screens above the overworld from observing a mid-action swap.
@@ -407,6 +449,72 @@ function Handle:position()
return self.npc.cellX, self.npc.cellY
end
-- Walk one tile starting NOW, outside the scripted-movement queue.
--
-- scriptMove queues onto OverworldState.scriptMoves, and a non-empty
-- scriptMoves is how the overworld knows a cutscene is running -- it gates
-- handleInput (OverworldController "local scripted = ... #self.scriptMoves >
-- 0"), so an actor animated that way freezes the player's controls for as
-- long as it walks. That is right for Oak marching to his lab and wrong for
-- an actor that moves on its own schedule: a networked player's ghost, an
-- ambient walker. This is the same per-tile state scriptMove sets, minus
-- the queue and therefore minus the lockout.
--
-- Collision is deliberately not checked. The caller is replaying a move
-- that was already decided somewhere else (validated on the peer's machine,
-- or authored), and re-judging it here would let the two copies disagree
-- about where the actor is. Use canStep first if you want the check.
function Handle:stepNow(dir)
local npc = self.npc
if not Collision.DELTA[dir] then return nil, "bad direction: " .. tostring(dir) end
if npc.moving then return nil, "already moving" end
npc.facing = dir
npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, dir)
npc.moving = true
npc.progress = 0
return true
end
-- Would stepNow land somewhere legal? Exposed separately so a caller that
-- does want the map's opinion can ask for it without giving up the "replay
-- verbatim" default above.
function Handle:canStep(dir)
local ow = self.ow
if not (ow and ow.map) then return false end
return Collision.canMove(ow.map, ow.entities, self.npc, dir) and true or false
end
-- Snap to a cell with no animation: a warp arrival, or a resync that has
-- drifted too far to walk off. Clears any step in flight so the entity
-- cannot land on its old target a frame later.
function Handle:placeAt(x, y, facing)
local npc = self.npc
npc.moving = false
npc.marching = false
npc.targetX, npc.targetY = nil, nil
npc.progress = 0
npc.cellX, npc.cellY = x, y
npc.px, npc.py = x * 16, y * 16
if facing then npc.facing = facing end
return true
end
-- True while a step is still animating, so a driver can pace itself rather
-- than stomping a move in flight.
function Handle:isMoving()
return self.npc.moving and true or false
end
-- Whether the player may walk through this object (Collision.occupied skips
-- passable entities -- Yellow's companion Pikachu is the engine's own user
-- of the flag). It still draws and can still be talked to; it just stops
-- being an obstacle, which is what a dynamic actor wants when standing in a
-- doorway would otherwise wall someone in.
function Handle:setPassable(passable)
self.npc.passable = passable and true or false
return true
end
function WorldAPI:npc(mapId, indexOrName)
local ow = self:overworld()
if not ow then return nil, NO_OVERWORLD end
+31
View File
@@ -5470,6 +5470,37 @@ function World:dropMapImages(mapId)
if self.connectionMaps then self.connectionMaps[mapId] = nil end
end
local function safeRelease(obj)
if obj and obj ~= false and obj.release then pcall(obj.release, obj) end
end
-- Eagerly free session-owned GPU caches. Assets.image-backed atlases are
-- nilled without release; unique bakes, strips, and roof composites are released.
function World:release()
if self.mapImages then
for _, img in pairs(self.mapImages) do safeRelease(img) end
self.mapImages = {}
end
if self.scrollStrips then
for _, strip in pairs(self.scrollStrips) do safeRelease(strip) end
self.scrollStrips = {}
end
safeRelease(self.tiltCanvas)
self.tiltCanvas = nil
if self.grassAtlases then
for _, atlas in pairs(self.grassAtlases) do safeRelease(atlas) end
self.grassAtlases = {}
end
if self.atlasCache then
for key, atlas in pairs(self.atlasCache) do
if key:find("|", 1, true) then safeRelease(atlas) end
end
self.atlasCache = {}
end
self.animQuads = nil
self.connectionMaps = nil
end
-- LoadMapAttributes' refill, for every map the session has edited. Neighbour
-- strips share the same buffer on the cart, so a connection crossing reloads
-- them too: this runs on any setMap, seamless or not.