mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
Merge branch 'dev' into spidercar2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user