mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
54c8d2706b
Ship stock engine chords only; community mods own their rebinds, and keys 2/3/5 are claimed by the engine before pipeline hotkeys run. Co-authored-by: Cursor <cursoragent@cursor.com>
979 lines
41 KiB
Lua
979 lines
41 KiB
Lua
-- Central game object: owns the data, renderer, input, state stack, world
|
|
-- and save state. Everything else reaches shared services through here.
|
|
|
|
local Data = require("src.core.Data")
|
|
local FixedStep = require("src.core.FixedStep")
|
|
local Input = require("src.core.Input")
|
|
local Logger = require("src.core.Logger")
|
|
local Renderer = require("src.render.Renderer")
|
|
local SaveData = require("src.core.SaveData")
|
|
local StateStack = require("src.core.StateStack")
|
|
local TouchControls = require("src.core.TouchControls")
|
|
local GamepadMap = require("src.core.GamepadMap")
|
|
local ModLoader = require("src.mods.Loader")
|
|
local ModRuntime = require("src.mods.Runtime")
|
|
local Screens = require("src.ui.Screens")
|
|
|
|
local Game = {}
|
|
|
|
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
|
|
-- module unloaded, so a player boot never touches a byte of dev code
|
|
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
|
|
|
|
-- the boot screen ids (field.boot.screens); a plain function so the
|
|
-- headless harness can borrow makeTitleState onto a stub game
|
|
local function bootScreens(game)
|
|
local boot = game.data and game.data.field and game.data.field.boot
|
|
return (boot and boot.screens) or {}
|
|
end
|
|
|
|
function Game:load()
|
|
self.data = Data
|
|
Data:load()
|
|
|
|
-- Mods are a native engine subsystem. They load after the verified ROM
|
|
-- data exists, so mods can register or override the same definitions that
|
|
-- the rest of the game consumes. A broken mod is reported and skipped by
|
|
-- the loader without preventing the base game from booting.
|
|
self.mods = ModLoader.new()
|
|
self.mods:load(Data)
|
|
self.modStatus = self.mods:status()
|
|
-- render pipelines dispatch off the merged dataset; point them at the
|
|
-- one the mods just merged into before anything can draw a frame
|
|
require("src.render.Pipelines").install(Data)
|
|
|
|
self.input = Input
|
|
Input:init()
|
|
|
|
self.touchControls = TouchControls
|
|
TouchControls:init()
|
|
|
|
self.renderer = Renderer
|
|
Renderer:init()
|
|
|
|
require("src.render.Font").load(Data)
|
|
-- menu cursor/border/geometry constants; field.theme restyles them
|
|
require("src.ui.Theme").load(Data)
|
|
-- the engine's own text, after the merge so a translation mod's catalog
|
|
-- is already in Data.strings; empty on a mod-free boot and skipped
|
|
require("src.core.Strings").load(Data)
|
|
|
|
self.stack = StateStack
|
|
StateStack:init()
|
|
|
|
self.save = SaveData.newGame(self:bootConfig())
|
|
-- seed=true keeps what entry chunks wrote through mod.save before any
|
|
-- save existed; the skeleton fires save.created exactly once
|
|
self:adoptSave(self.save, true)
|
|
ModRuntime.emit("save.created", { save = self.save })
|
|
-- apply the persisted audio + display options before anything plays
|
|
self:applyOptions(self.save.options)
|
|
|
|
FixedStep:init(function(step) self:step(step) end)
|
|
self.fixedStep = FixedStep
|
|
|
|
local OverworldState = require("src.world.OverworldController")
|
|
self.overworld = OverworldState
|
|
|
|
-- Discord Rich Presence: map name / battle status on the player's profile.
|
|
-- Soft-fail: missing Discord / IPC errors must never block boot.
|
|
pcall(function() require("src.core.DiscordPresence").init(self) end)
|
|
|
|
-- every service is up but nothing is on the stack yet; this payload is
|
|
-- the sanctioned way for a mod to obtain the Game object
|
|
ModRuntime.emit("game.ready", { game = self })
|
|
|
|
-- boot into the title screen (engine/movie/title.asm); NEW GAME runs
|
|
-- the Oak speech + naming, CONTINUE restores the save. The headless
|
|
-- autopilot skips straight into the overworld.
|
|
if os.getenv("POKEPORT_AUTOPILOT") then
|
|
StateStack:push(OverworldState, self.save.player.map,
|
|
self.save.player.x, self.save.player.y, self.save.player.facing)
|
|
else
|
|
local titleState = self:makeTitleState()
|
|
-- the copyright splash + attract movie plays before the title
|
|
-- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own
|
|
-- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from
|
|
-- field.boot.screens so a total conversion owns the whole boot
|
|
local splash = require("src.core.GameVersion").isYellow()
|
|
and "YellowIntro" or "IntroMovie"
|
|
Screens.push(self, bootScreens(self).splash or splash, function()
|
|
StateStack:push(titleState)
|
|
end)
|
|
end
|
|
|
|
Logger.info("game loaded")
|
|
-- Scaling bug reports (#87, #208) are unanswerable without these three
|
|
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
|
|
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
|
|
-- looks stretched" into something reproducible.
|
|
if love.graphics and love.graphics.getDimensions then
|
|
local ww, wh = love.graphics.getDimensions()
|
|
local pw, ph = ww, wh
|
|
if love.graphics.getPixelDimensions then
|
|
pw, ph = love.graphics.getPixelDimensions()
|
|
end
|
|
Logger.info(string.format(
|
|
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
|
|
ww, wh, pw, ph, Renderer:fitScale()))
|
|
end
|
|
end
|
|
|
|
-- the merged field.boot: spawn, names, money and the naming presets a
|
|
-- total conversion overrides. Threaded into SaveData so persistence stays
|
|
-- free of a Data dependency.
|
|
function Game:bootConfig()
|
|
local boot = self.data and self.data.field and self.data.field.boot
|
|
-- stamp the running game version onto the boot config so a New Game records
|
|
-- it (SaveData.newGame reads boot.version); this is what routes a Blue
|
|
-- playthrough to save_blue.lua and Blue's version-gated content
|
|
if boot then boot.version = require("src.core.GameVersion").get() end
|
|
return boot
|
|
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.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)
|
|
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
|
function() end)
|
|
end,
|
|
onContinue = function()
|
|
local loaded, recovered = SaveData.load()
|
|
if loaded then
|
|
self:restoreSave(loaded, recovered)
|
|
end
|
|
end,
|
|
})
|
|
title.screenId = title.screenId or "TitleState"
|
|
return title
|
|
end
|
|
|
|
-- QUIT from the START menu: back to the title like a power-cycle,
|
|
-- unsaved progress discarded. TitleState:enter restarts the title
|
|
-- theme; stop() keeps the map song from bleeding over in the meantime.
|
|
function Game:returnToTitle()
|
|
require("src.core.Music").stop()
|
|
while self.stack:top() do self.stack:pop() end
|
|
self.stack:push(self:makeTitleState())
|
|
end
|
|
|
|
function Game:step(dt)
|
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
|
-- the same fixed-step boundary as a physical controller. Run them before
|
|
-- Input:step promotes queued edges so a button chosen here is visible to
|
|
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
|
ModRuntime.call("input.step", function() end, self, dt)
|
|
self.input:step()
|
|
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
|
|
-- audio, whites the palettes out and falls through into Init, i.e. the
|
|
-- power-on boot, so everything unsaved is gone and the title sequence
|
|
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
|
|
-- The check lives here, above stack:update, so it fires from any state:
|
|
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
|
|
-- Input:reset clears the four still-physically-held buttons so the title
|
|
-- screen does not read A as a menu choice on its first frame.
|
|
-- a tool mod (or a harness) may hand Game a stand-in input with no
|
|
-- chord bookkeeping; those simply never soft reset
|
|
if self.input.softResetStep and self.input:softResetStep() then
|
|
Input:reset()
|
|
TouchControls:reset()
|
|
self:returnToTitle()
|
|
return
|
|
end
|
|
-- serviced unconditionally: a link battle's ENet transport must not
|
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
|
-- on top of BattleState (see LinkBattle.new)
|
|
if self.linkNet and not self.linkNet.closed then
|
|
self.linkNet:update()
|
|
end
|
|
self.stack:update(dt)
|
|
-- play time for the trainer card / save screen
|
|
self.save.playTime = (self.save.playTime or 0) + dt
|
|
-- Music.update is NOT serviced here: it decrements fade counters and
|
|
-- drives ChipAudio once per call, so running it inside the logic step
|
|
-- would pitch music and sfx up under fast-forward. Game:update advances
|
|
-- it on its own real-time 60Hz accumulator instead.
|
|
end
|
|
|
|
-- The logic multiplier for this frame. Read live rather than cached so the
|
|
-- Options row takes effect immediately; speedOverride is the --speed /
|
|
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
|
|
-- or screenshot run does not depend on whatever the player last chose.
|
|
function Game:logicSpeed()
|
|
local GameSpeed = require("src.core.GameSpeed")
|
|
-- Link play is always 1X on both machines, and this wins over every other
|
|
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
|
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
|
-- than the opponent it is racing, and drove its own animation/message
|
|
-- queue at a different rate than the peer it is locked to. Nothing about
|
|
-- a match should depend on what either player set this to.
|
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
|
return 1
|
|
end
|
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
|
local opts = self.save and self.save.options
|
|
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
|
end
|
|
|
|
function Game:update(dt)
|
|
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
|
-- Give the accumulator room for one full frame at the current speed,
|
|
-- or the anti-spiral clamp quietly caps every level above ~15X.
|
|
local speed = self:logicSpeed()
|
|
FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5)
|
|
FixedStep:update(dt * speed)
|
|
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
|
|
-- display refresh, so fades and chip synthesis keep their intended tempo
|
|
-- whether we are at 1X, 10X, or running with vsync disabled.
|
|
local step = FixedStep.STEP
|
|
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
|
|
while self.audioAccum >= step do
|
|
self.audioAccum = self.audioAccum - step
|
|
require("src.core.Music").update(Data)
|
|
end
|
|
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
|
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
|
require("src.render.Tilt").update(dt)
|
|
-- mod render pipelines tween on the same real-frame clock, for the same
|
|
-- reason: they are presentational, so fast-forward must not speed them up
|
|
require("src.render.Pipelines").update(dt)
|
|
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
|
-- Steady-state memory backstop: advance the incremental collector one
|
|
-- small step every rendered frame. The heavy GPU objects are now freed
|
|
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
|
|
-- only has to keep ordinary Lua-heap garbage (per-frame tables/closures)
|
|
-- from drifting upward over a long session, and to spread collection out
|
|
-- so the default lazy schedule never batches it into a visible pause.
|
|
if collectgarbage then collectgarbage("step", 1) end
|
|
end
|
|
|
|
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
|
-- exactly as the owning state computed it
|
|
local function sameZones(_, zones) return zones end
|
|
|
|
-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
|
|
-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
|
|
-- during the battle must not drop the dim for a frame.
|
|
function Game.worldBgBattleDim(stack)
|
|
for i = #(stack and stack.states or {}), 1, -1 do
|
|
local state = stack.states[i]
|
|
if state and state.bgMode and state:bgMode() == "world" then
|
|
return state.BG_WORLD_DIM or 0.55
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Does anything on the stack want the surface scaled to FILL the window
|
|
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
|
|
-- integer scale?
|
|
--
|
|
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
|
|
-- because the party menu, bag and text boxes a battle opens must not snap the
|
|
-- surface back to the fixed scale for a frame; the title screen and intro want
|
|
-- it unconditionally, since neither has a world behind it and neither has any
|
|
-- reason to sit in a small box in the middle of a large window.
|
|
function Game.fillScaleInStack(stack)
|
|
for i = #(stack and stack.states or {}), 1, -1 do
|
|
local state = stack.states[i]
|
|
if state and state.wantsFillScale and state:wantsFillScale() then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- A wide battle owns the surface until it leaves the stack. The party,
|
|
-- bag, choice and text states it opens still draw their original 160px UI,
|
|
-- but the canvas must not snap to 160px between those states.
|
|
function Game.wideBattleInStack(stack)
|
|
for i = #(stack and stack.states or {}), 1, -1 do
|
|
local state = stack.states[i]
|
|
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
|
|
return state
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Whether a state on the stack composes its own screen and so wants the
|
|
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
|
-- everything else here: the text box and YES/NO a battle puts up are states
|
|
-- of their own sitting above it, and they are exactly the elements that must
|
|
-- stay inside the battle's composition rather than dock to the window.
|
|
-- UI LAYOUT: is edge docking switched on? Only the explicit "dynamic" turns
|
|
-- it on, so a save written before the option existed -- and any caller with no
|
|
-- save at all, which is most of the headless suites -- gets CENTERED, the
|
|
-- behaviour the port shipped with.
|
|
function Game.dynamicUI(save)
|
|
local options = save and save.options
|
|
return options ~= nil and options.uiLayout == "dynamic"
|
|
end
|
|
|
|
function Game.uiAnchorsHeldInStack(stack)
|
|
for i = #(stack and stack.states or {}), 1, -1 do
|
|
local state = stack.states[i]
|
|
if state and state.holdsUIAnchors then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
|
|
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
|
|
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
|
|
-- the bag) becomes that base, cutting the overworld -- and with it the world
|
|
-- pass -- out of the frame entirely. The backdrop the battle established
|
|
-- then collapses to endFrame's flat black clear for as long as the menu is
|
|
-- up. So a world-bg battle keeps the frame starting from underneath itself
|
|
-- until it leaves the stack, the same hold uiFill and the dim already use.
|
|
--
|
|
-- Only the START of the draw moves. The clear stays keyed to the real
|
|
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
|
|
-- before; what changes is the window AROUND its letterbox, which keeps
|
|
-- showing the map instead of going black. Both menus fill their own
|
|
-- 160x144 field first, so nothing beneath them shows through it.
|
|
function Game.drawBaseInStack(stack, visibleBase)
|
|
local states = stack and stack.states or {}
|
|
for i = visibleBase - 1, 1, -1 do
|
|
local state = states[i]
|
|
if state and state.bgMode and state:bgMode() == "world" then
|
|
-- restart the search from under the battle: the highest opaque state at
|
|
-- or below it (the overworld), not the menu sitting over it
|
|
for j = i, 1, -1 do
|
|
if states[j].isOpaque then return j end
|
|
end
|
|
return 1
|
|
end
|
|
end
|
|
return visibleBase
|
|
end
|
|
|
|
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
|
-- into both margins, keeping the canvas' paper color continuous; narrower
|
|
-- sprite and status zones move with the classic UI content.
|
|
local function centerClassicZones(zones, offset)
|
|
if not zones or offset == 0 then return zones end
|
|
local shifted = {}
|
|
for i, zone in ipairs(zones) do
|
|
local copy = {}
|
|
for key, value in pairs(zone) do copy[key] = value end
|
|
if copy.x == 0 and copy.w == Renderer.WIDTH then
|
|
copy.w = copy.w + offset * 2
|
|
else
|
|
copy.x = (copy.x or 0) + offset
|
|
end
|
|
shifted[i] = copy
|
|
end
|
|
return shifted
|
|
end
|
|
|
|
function Game:draw()
|
|
-- the UI canvas clears transparent when the overworld's world pass
|
|
-- shows through beneath it; opaque full-screen states get the classic
|
|
-- white clear
|
|
local base = self.stack:visibleBase()
|
|
local worldBelow = self.stack.states[base] == self.overworld
|
|
-- a world-bg battle keeps the map drawing under whatever it opened, so the
|
|
-- world pass can run for a frame whose CLEAR is still an opaque menu's
|
|
local drawFrom = Game.drawBaseInStack(self.stack, base)
|
|
local worldDrawn = self.stack.states[drawFrom] == self.overworld
|
|
-- A wide battle holds its 304px surface through every menu or prompt it
|
|
-- opens. States that do not draw the wide battle composition are centred
|
|
-- in that surface below, so their classic coordinates and hit testing stay
|
|
-- unchanged. Outside a battle, including the title screen, the option is
|
|
-- intentionally inactive because it is a battle-layout setting.
|
|
local top = self.stack:top()
|
|
local wideBattle = Game.wideBattleInStack(self.stack)
|
|
local classicOffset = 0
|
|
if wideBattle and wideBattle.uiSize then
|
|
Renderer:setUISize(wideBattle:uiSize())
|
|
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
|
|
elseif top and top.uiSize then
|
|
Renderer:setUISize(top:uiSize())
|
|
else
|
|
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
|
end
|
|
-- BATTLE SIZE: scale the battle surface to the window instead of the
|
|
-- classic integer letterbox. Read from the whole stack, not just the top,
|
|
-- so a party menu or text box opened mid-battle keeps the same surface.
|
|
Renderer.uiFill = Game.fillScaleInStack(self.stack)
|
|
-- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
|
|
-- the stack for the same reason as uiFill above -- a prompt opened during
|
|
-- the battle must not drop the dim for a frame.
|
|
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
|
-- ...and for the same reason the UI's own scale has to know the world is
|
|
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
|
-- steps the UI down with the survey zoom only while a world is behind it,
|
|
-- gated on this frame's world pass -- which the party menu and the bag end
|
|
-- by being opaque. Without this hold they lose the step-down and blit at
|
|
-- full fit scale over a battle drawn at the zoomed-out one.
|
|
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
|
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
|
-- instead of letting them dock to the window edge.
|
|
-- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element
|
|
-- stays inside the 160x144 canvas and the UI does not follow the survey
|
|
-- zoom, so the screen furniture never moves or resizes under the player.
|
|
-- That is the composition the port shipped with. DYNAMIC opts into both
|
|
-- halves: the dialogue box docks to the window's bottom edge, the START
|
|
-- menu to its top right, and the whole UI steps down with the zoom.
|
|
Renderer.uiCentered = not Game.dynamicUI(self.save)
|
|
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
|
|
Renderer:beginFrame(worldBelow)
|
|
for i = drawFrom, #self.stack.states do
|
|
local state = self.stack.states[i]
|
|
local wideState = state and state.isWideBattleLayout
|
|
and state:isWideBattleLayout()
|
|
if state and state.draw then
|
|
if classicOffset ~= 0 and not wideState then
|
|
love.graphics.push()
|
|
love.graphics.translate(classicOffset, 0)
|
|
state:draw()
|
|
love.graphics.pop()
|
|
else
|
|
state:draw()
|
|
end
|
|
end
|
|
end
|
|
-- SGB colorization: the topmost state that knows its palette owns the
|
|
-- screen (overlays like text boxes inherit from what's beneath them);
|
|
-- the overworld's world pass colors each visible map area separately
|
|
local zones, worldZones, zoneOwner
|
|
for i = #self.stack.states, 1, -1 do
|
|
local s = self.stack.states[i]
|
|
if s.sgbPalettes then
|
|
zones = s:sgbPalettes(self)
|
|
zoneOwner = s
|
|
break
|
|
end
|
|
end
|
|
if classicOffset ~= 0 and zoneOwner
|
|
and not (zoneOwner.isWideBattleLayout
|
|
and zoneOwner:isWideBattleLayout()) then
|
|
zones = centerClassicZones(zones, classicOffset)
|
|
end
|
|
-- 14's render.zones: weather/lighting overlays and custom colorization
|
|
-- recolor or add zones before the blit
|
|
if ModRuntime.wantsHook("render.zones") then
|
|
zones = ModRuntime.call("render.zones", sameZones, self, zones)
|
|
end
|
|
-- Keyed to whether the map actually DREW, not to whether it is the clear's
|
|
-- base: an opaque menu over a world-bg battle still renders the world pass
|
|
-- (drawBaseInStack), and leaving worldZones nil there drops endFrame's
|
|
-- world blit onto the UI zone list instead -- the party menu's own HP-bar
|
|
-- palettes, in 160x144 space, smeared across a world-canvas-sized image.
|
|
-- That is the offset, red-for-green map behind the menu.
|
|
if worldDrawn and self.overworld.sgbWorldZones then
|
|
worldZones = self.overworld:sgbWorldZones()
|
|
end
|
|
local viewport = Renderer:endFrame(zones, worldZones)
|
|
-- Persistent tool status is screen-space UI: draw it over the completed
|
|
-- render pipeline with exact playfield/margin geometry, but below mobile
|
|
-- controls. It never becomes an updating game state.
|
|
if ModRuntime.wantsHook("render.hud") then
|
|
ModRuntime.call("render.hud", function() end, self, viewport)
|
|
end
|
|
-- on-screen mobile controls: pure screen-space, over the finished frame
|
|
TouchControls:draw()
|
|
end
|
|
|
|
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
|
|
function Game:zoomStep(delta)
|
|
local Zoom = require("src.render.Zoom")
|
|
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
|
|
local offset = Zoom.step(delta, Renderer:fitScale())
|
|
if self.save and self.save.options then
|
|
self.save.options.zoom = offset
|
|
self:writeOptions()
|
|
end
|
|
end
|
|
|
|
function Game:wheelmoved(_, dy)
|
|
if dy > 0 then
|
|
self:zoomStep(1)
|
|
elseif dy < 0 then
|
|
self:zoomStep(-1)
|
|
end
|
|
end
|
|
|
|
function Game:_cycleSpeed(dir)
|
|
if not (self.save and self.save.options) then return end
|
|
local busy
|
|
local ow = self.overworld
|
|
if ow then
|
|
local top = self.stack:top()
|
|
busy = ow.transitioning
|
|
or (top == ow and (
|
|
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
|
|
or (ow.scriptMoves and #ow.scriptMoves > 0)
|
|
or ow.engaging or ow.emote))
|
|
end
|
|
if busy then return end
|
|
local GameSpeed = require("src.core.GameSpeed")
|
|
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
|
|
self:writeOptions()
|
|
end
|
|
|
|
function Game:keypressed(key)
|
|
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
|
|
self.stack:top():onKeyPressed(key)
|
|
return
|
|
end
|
|
if devMode and key == "f5" then
|
|
require("src.dev.HotReload").run(self)
|
|
return
|
|
end
|
|
if devMode and key == "`" then
|
|
self.stack:push(require("src.dev.Console").new(self))
|
|
return
|
|
end
|
|
if key == "f10" then
|
|
-- toggle: the manager no longer swallows the keyboard, so a second
|
|
-- press reaches this branch and closes it instead of stacking another
|
|
local top = self.stack:top()
|
|
if top and top.screenId == "ManagerState" then
|
|
self.stack:pop()
|
|
else
|
|
Screens.push(self, "ManagerState")
|
|
end
|
|
return
|
|
end
|
|
if key == "f1" then
|
|
self:writeSave()
|
|
return
|
|
elseif key == "f2" then
|
|
local loaded, recovered = SaveData.load()
|
|
if loaded then self:restoreSave(loaded, recovered) end
|
|
return
|
|
elseif key == "-" then
|
|
self:zoomStep(-1)
|
|
return
|
|
elseif key == "=" then
|
|
self:zoomStep(1)
|
|
return
|
|
elseif key == "1" then
|
|
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
|
|
-- R2/L2 on gamepad do the same (see gamepadpressed)
|
|
self:_cycleSpeed(1)
|
|
return
|
|
elseif key == "2" then
|
|
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
|
|
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
|
|
-- hold it while a warp/transition or an on-screen scripted cutscene is
|
|
-- driving the overworld rather than tear the escort's NPCs out mid-move
|
|
local ow = self.overworld
|
|
local top = self.stack:top()
|
|
local busy = ow and (ow.transitioning
|
|
or (top == ow and (
|
|
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
|
|
or (ow.scriptMoves and #ow.scriptMoves > 0)
|
|
or ow.engaging or ow.emote)))
|
|
if not busy then
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
self.save.options.colors = PaletteFX.cycleMode()
|
|
self:writeOptions()
|
|
end
|
|
return
|
|
elseif key == "3" then
|
|
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
|
|
local Tilt = require("src.render.Tilt")
|
|
if Tilt.gateOK(self.stack:top(), self.overworld) then
|
|
self.save.options.tilt = Tilt.cycle()
|
|
self:writeOptions()
|
|
end
|
|
return
|
|
elseif key == "4" then
|
|
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
|
|
local Zoom = require("src.render.Zoom")
|
|
if Zoom.gateOK(self.stack:top(), self.overworld) then
|
|
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
|
|
self:writeOptions()
|
|
end
|
|
return
|
|
elseif key == "5" then
|
|
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
|
-- desktop. Mobile refuses the present shader (issue #136).
|
|
local GBCFX = require("src.render.GBCFX")
|
|
if not GBCFX.isSupported() then return end
|
|
self.save.options.gbcfx = GBCFX.cycle()
|
|
self:writeOptions()
|
|
return
|
|
end
|
|
-- Mod render pipelines claim their hotkeys last, so one can never shadow
|
|
-- an engine display key however a mod declares it (12 §rendering
|
|
-- pipelines). syncOptions writes the whole ladder back, including the
|
|
-- tilt exclusion a world pipeline forces.
|
|
local Pipelines = require("src.render.Pipelines")
|
|
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
|
|
Pipelines.syncOptions(self.save.options)
|
|
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
|
|
self:writeOptions()
|
|
return
|
|
end
|
|
Input:keypressed(key)
|
|
end
|
|
|
|
-- Mod enablement is stored with persistent options. Restarting the actual
|
|
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
|
|
-- the newly selected mod state.
|
|
function Game:restartWithMods()
|
|
require("src.core.HostShell").restart()
|
|
end
|
|
|
|
-- Releases reach Input even while a top state captures raw input: a
|
|
-- swallowed key-up would strand a held-state flag for a key Input saw go
|
|
-- down before the capture armed (the stuck-flag hazard Input:reset
|
|
-- exists for). The top state only OBSERVES the release afterwards,
|
|
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
|
|
-- commit a capture on the key-up (#589).
|
|
function Game:keyreleased(key)
|
|
Input:keyreleased(key)
|
|
local top = self.stack and self.stack:top()
|
|
if top and top.onKeyReleased then top:onKeyReleased(key) end
|
|
end
|
|
|
|
function Game:gamepadpressed(joystick, button)
|
|
-- a controller is being used: the touch overlay steps aside until the
|
|
-- next screen touch (mobile only; a no-op elsewhere)
|
|
TouchControls:noteGamepad()
|
|
-- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
|
|
-- is a display chord on NX) and for the chord path below.
|
|
local selectHeld = Input:isDown("select")
|
|
if not selectHeld and joystick and joystick.isGamepadDown then
|
|
local ok, down = pcall(function()
|
|
return joystick:isGamepadDown("back")
|
|
end)
|
|
selectHeld = ok and down == true
|
|
end
|
|
-- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster,
|
|
-- L2/leftshoulder = slower; same as keyboard hotkey 1). Skip while
|
|
-- Select is held so Select+L can reach displayChordDigit ("7").
|
|
if not selectHeld then
|
|
if button == "rightshoulder" then
|
|
self:_cycleSpeed(1)
|
|
return
|
|
elseif button == "leftshoulder" then
|
|
self:_cycleSpeed(-1)
|
|
return
|
|
end
|
|
end
|
|
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
|
local top = self.stack and self.stack:top()
|
|
if top and top.onGamepadPressed then
|
|
top:onGamepadPressed(button)
|
|
return
|
|
end
|
|
-- Select+face display chords → same digit path as Game:keypressed
|
|
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
|
|
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
|
|
if selectHeld then
|
|
local digit = GamepadMap.displayChordDigit(button)
|
|
if digit then
|
|
self:keypressed(digit)
|
|
return
|
|
end
|
|
end
|
|
Input:gamepadpressed(joystick, button)
|
|
end
|
|
|
|
function Game:gamepadreleased(joystick, button)
|
|
-- same observe-after-Input contract as Game:keyreleased (#589)
|
|
Input:gamepadreleased(joystick, button)
|
|
local top = self.stack and self.stack:top()
|
|
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
|
end
|
|
|
|
function Game:gamepadaxis(joystick, axis, value)
|
|
-- past-deadzone only, so resting-stick drift can't hide the overlay
|
|
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
|
Input:gamepadaxis(joystick, axis, value)
|
|
end
|
|
|
|
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
|
-- generic joystick path anyway: any sensor-style device that still reaches us
|
|
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
|
-- overlay every instant and steer the player by tilt through the axis-1/2
|
|
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
|
-- never as "* Accelerometer".
|
|
local function isAccelerometer(joystick)
|
|
local name = joystick and joystick.getName and joystick:getName()
|
|
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
|
end
|
|
|
|
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
|
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
|
-- as a gamepad reaches the capture: a recognized pad raises BOTH
|
|
-- joystickpressed and gamepadpressed for one press, and the joystick half
|
|
-- would otherwise beat its own gamepadpressed to the armed row and record
|
|
-- "JOY1" for a button the player can plainly see is A. Same predicate as
|
|
-- Input's, kept local here so Game never reaches into Input's internals.
|
|
local function isRawStick(joystick)
|
|
return not (joystick and joystick.isGamepad and joystick:isGamepad())
|
|
end
|
|
|
|
function Game:joystickpressed(joystick, button)
|
|
if isAccelerometer(joystick) then return end
|
|
TouchControls:noteGamepad()
|
|
local top = self.stack and self.stack:top()
|
|
if isRawStick(joystick) and top and top.onJoystickPressed then
|
|
top:onJoystickPressed(button)
|
|
return
|
|
end
|
|
Input:joystickpressed(joystick, button)
|
|
end
|
|
|
|
function Game:joystickreleased(joystick, button)
|
|
if isAccelerometer(joystick) then return end
|
|
-- same observe-after-Input contract as Game:keyreleased (#589): the
|
|
-- capture watches the release, it never owns it, so a held-state flag
|
|
-- Input saw go down before the capture armed cannot be stranded
|
|
Input:joystickreleased(joystick, button)
|
|
local top = self.stack and self.stack:top()
|
|
if isRawStick(joystick) and top and top.onJoystickReleased then
|
|
top:onJoystickReleased(button)
|
|
end
|
|
end
|
|
|
|
function Game:joystickaxis(joystick, axis, value)
|
|
if isAccelerometer(joystick) then return end
|
|
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
|
Input:joystickaxis(joystick, axis, value)
|
|
end
|
|
|
|
function Game:joystickhat(joystick, hat, direction)
|
|
if isAccelerometer(joystick) then return end
|
|
if direction ~= "c" then TouchControls:noteGamepad() end
|
|
Input:joystickhat(joystick, hat, direction)
|
|
end
|
|
|
|
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
|
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
|
-- physically held key won't re-fire keypressed, so trusting leftover
|
|
-- state is worse than asking the player to re-press.
|
|
function Game:focus(f)
|
|
Input:reset()
|
|
TouchControls:reset()
|
|
end
|
|
|
|
function Game:visible(v)
|
|
if v then
|
|
self:onResume()
|
|
else
|
|
Input:reset()
|
|
TouchControls:reset()
|
|
end
|
|
end
|
|
|
|
function Game:onResume()
|
|
Input:reset()
|
|
TouchControls:reset()
|
|
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
|
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
|
-- Desktop/mobile window-visible flips must not kill overworld music.
|
|
if require("src.core.Platform").isNX() then
|
|
require("src.core.ChipAudio").stopMusic()
|
|
end
|
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
|
if SwitchDiagnostics.isEnabled() then
|
|
SwitchDiagnostics.onEvent("lifecycle", { event = "resume" })
|
|
end
|
|
end
|
|
|
|
function Game:recoverInput(event, joystick)
|
|
Input:reset()
|
|
TouchControls:reset()
|
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
|
if SwitchDiagnostics.isEnabled() then
|
|
if joystick then
|
|
SwitchDiagnostics.onJoystickEvent(event, joystick)
|
|
else
|
|
SwitchDiagnostics.onEvent("lifecycle", { event = event })
|
|
end
|
|
end
|
|
end
|
|
|
|
function Game:joystickadded(joystick)
|
|
self:recoverInput("joystickadded", joystick)
|
|
end
|
|
|
|
-- A disconnected/dropped controller can't send the button-up for whatever
|
|
-- it was holding, so drop all input state rather than try to guess which
|
|
-- flags it owned.
|
|
function Game:joystickremoved(joystick)
|
|
self:recoverInput("joystickremoved", joystick)
|
|
TouchControls:joystickremoved()
|
|
end
|
|
|
|
function Game:touchpressed(id, x, y)
|
|
TouchControls:touchpressed(id, x, y)
|
|
end
|
|
|
|
function Game:touchmoved(id, x, y)
|
|
TouchControls:touchmoved(id, x, y)
|
|
end
|
|
|
|
function Game:touchreleased(id, x, y)
|
|
TouchControls:touchreleased(id, x, y)
|
|
end
|
|
|
|
-- Point the loader's mod.save backing at this save's modData so per-mod
|
|
-- state persists with the slot. seedBuckets is boot-only: it keeps what
|
|
-- entry chunks wrote before any save existed, while NEW GAME and
|
|
-- CONTINUE replace the backing outright.
|
|
function Game:adoptSave(save, seedBuckets)
|
|
save.modData = save.modData or {}
|
|
local loader = self.mods
|
|
if not loader then return end
|
|
if seedBuckets then
|
|
for id, bucket in pairs(loader.modSave or {}) do
|
|
if save.modData[id] == nil then save.modData[id] = bucket end
|
|
end
|
|
end
|
|
loader.modSave = save.modData
|
|
end
|
|
|
|
-- Capture the live world state into the save table and persist it.
|
|
-- Options are flushed to options.lua as part of SaveData.save.
|
|
function Game:writeSave()
|
|
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
|
|
-- before captureSave mutates the snapshot or any progress bytes reach disk.
|
|
if ModRuntime.call("save.write", function() return true end, self) == false then
|
|
return false
|
|
end
|
|
if self.overworld and self.overworld.captureSave then
|
|
self.overworld:captureSave(self.save)
|
|
end
|
|
-- stamp here so the save.writing payload carries the exact meta the
|
|
-- file gets; mods snapshot runtime state into their namespace now
|
|
self.save.meta = SaveData.buildMeta(
|
|
self.modStatus and self.modStatus.loaded, self.save.meta)
|
|
if ModRuntime.wants("save.writing") then
|
|
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
|
end
|
|
return SaveData.save(self.save)
|
|
end
|
|
|
|
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
|
-- across New Game without touching the progress save.
|
|
function Game:writeOptions()
|
|
if not (self.save and self.save.options) then return end
|
|
SaveData.saveOptions(self.save.options)
|
|
end
|
|
|
|
-- Push the live options table into audio + display subsystems.
|
|
function Game:applyOptions(opts)
|
|
opts = opts or (self.save and self.save.options) or {}
|
|
local Music = require("src.core.Music")
|
|
local Sound = require("src.core.Sound")
|
|
if Music.applyOptions then Music.applyOptions(opts) end
|
|
if Sound.applyOptions then Sound.applyOptions(opts) end
|
|
require("src.render.PaletteFX").applyOptions(opts)
|
|
require("src.render.Tilt").applyOptions(opts)
|
|
-- after Tilt, so a persisted world pipeline can switch the tilt level it
|
|
-- just restored back off (the two are mutually exclusive)
|
|
require("src.render.Pipelines").applyOptions(opts)
|
|
require("src.render.Zoom").applyOptions(opts)
|
|
require("src.render.TileRenderer").applyOptions(opts)
|
|
-- returns true when a persisted GBC FX level was cleared on mobile
|
|
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
|
require("src.core.VideoMode").applyOptions(opts)
|
|
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
|
-- it has to be the last word on the window (it drops fullscreen to hold)
|
|
require("src.core.FaithfulRes").applyOptions(opts)
|
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
|
-- fpsCap key pace at the standard rate (issue #88)
|
|
require("src.core.FrameCap").applyOptions(opts)
|
|
-- Scale the optional presentation extras to the device's performance
|
|
-- tier. Every heavy feature was just applied from the stored options
|
|
-- above; here we clamp the *live* state down for a weaker device without
|
|
-- rewriting what the player saved, so raising the tier later restores
|
|
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
|
-- default on a normal desktop, and every options.lua predating this
|
|
-- option) clamps nothing, so it is a no-op for the common case.
|
|
local caps = require("src.core.Performance").applyOptions(opts)
|
|
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
|
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
|
local Zoom = require("src.render.Zoom")
|
|
Zoom.allowSurvey = caps.survey
|
|
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
|
if caps.fpsMax then
|
|
local FrameCap = require("src.core.FrameCap")
|
|
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
|
|
end
|
|
Input:applyBindings(opts.bindings)
|
|
TouchControls:applyOptions(opts)
|
|
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
|
if gbcCleared then self:writeOptions() end
|
|
end
|
|
|
|
function Game:restoreSave(loaded, recovered)
|
|
if ModRuntime.wants("save.loading") then
|
|
ModRuntime.emit("save.loading", { raw = loaded })
|
|
end
|
|
-- mod chains replay before validation so a mod repairs its own data
|
|
-- instead of watching it get quarantined; core steps already ran in
|
|
-- SaveData.load and skip on the format guard
|
|
local activeMods = self.modStatus and self.modStatus.loaded
|
|
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
|
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
|
|
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
|
|
if SaveData.needsPostGameRescue(loaded) then
|
|
SaveData.applyPostGameHome(loaded, self:bootConfig())
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
|
|
end
|
|
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
|
local report = SaveData.validate(loaded, self.data)
|
|
report.recovered = recovered
|
|
report.modsDiff = modsDiff
|
|
self.save = loaded
|
|
self:adoptSave(loaded)
|
|
-- SaveData.load already attached the standalone options.lua table
|
|
self:applyOptions(loaded.options)
|
|
-- saves from before OT/ID stamping: backfill with the player's (after
|
|
-- the scrub, so every mon the stamp loop sees is known)
|
|
local stamp = require("src.battle.BattleState").stampOT
|
|
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
|
for _, box in ipairs(loaded.boxes or {}) do
|
|
for _, mon in ipairs(box) do stamp(loaded, mon) end
|
|
end
|
|
-- rebuild the state stack from the save
|
|
while self.stack:top() do self.stack:pop() end
|
|
self.stack:push(self.overworld, loaded.player.map,
|
|
loaded.player.x, loaded.player.y, loaded.player.facing)
|
|
self.saveReport = report
|
|
if not SaveData.emptyReport(report) then
|
|
-- the report screen is a Screens id so mods (or the ui milestone) own
|
|
-- its looks; until one exists the log keeps a quarantine from being
|
|
-- silent
|
|
local ok = pcall(Screens.push, self, "QuarantineReport", report)
|
|
if not ok then
|
|
Logger.warn("load report: %d mons quarantined, %d items removed, %d maps remapped%s",
|
|
#report.lostMons, #report.lostItems, #report.remappedMaps,
|
|
recovered and (", recovered from " .. recovered) or "")
|
|
local notice = SaveData.modsDiffNotice(modsDiff, loaded.meta)
|
|
if notice then Logger.warn("%s", notice) end
|
|
end
|
|
end
|
|
if ModRuntime.wants("save.loaded") then
|
|
ModRuntime.emit("save.loaded",
|
|
{ save = loaded, meta = loaded.meta, modsDiff = modsDiff })
|
|
end
|
|
end
|
|
|
|
return Game
|