mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
-- Shared settled supported player-decision predicate. Checkpoint capture and
|
||||
-- the public auxiliary action deliberately use this one engine-owned rule so
|
||||
-- a tool cannot open at a phase that it could not subsequently checkpoint.
|
||||
-- It exposes no controller; callers receive only the result/reason.
|
||||
|
||||
local BattleSafety = {}
|
||||
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
local function nonempty(value)
|
||||
return type(value) == "table" and next(value) ~= nil
|
||||
end
|
||||
|
||||
local function running(runner)
|
||||
return runner and runner.isRunning and runner:isRunning()
|
||||
end
|
||||
|
||||
local function scriptsBusy(overworld)
|
||||
return running(overworld and overworld.runner)
|
||||
or nonempty(overworld and overworld.parallelRunners)
|
||||
or nonempty(overworld and overworld.pendingScripts)
|
||||
or nonempty(overworld and overworld.parallelQueue)
|
||||
or nonempty(overworld and overworld.scriptMoves)
|
||||
end
|
||||
|
||||
function BattleSafety.inspect(game, battle)
|
||||
if type(game) ~= "table" or type(game.save) ~= "table"
|
||||
or type(game.save.version) ~= "string" then
|
||||
return nil, "not_in_playthrough", "A checkpoint requires an identified active playthrough."
|
||||
end
|
||||
if type(battle) ~= "table" then
|
||||
return nil, "not_battle", "No battle is active."
|
||||
end
|
||||
if battle.kind == "link" then
|
||||
return nil, "link_battle_unsupported", "Network battles cannot be checkpointed."
|
||||
end
|
||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo or battle.noCatch then
|
||||
return nil, "battle_variant_unsupported",
|
||||
"This battle variant does not have a checkpoint contract."
|
||||
end
|
||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||
return nil, "battle_variant_unsupported",
|
||||
"This battle kind does not have a checkpoint contract."
|
||||
end
|
||||
local origin = battle.checkpointOrigin
|
||||
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
or "trainer_encounter"
|
||||
local scriptedOrigin = type(origin) == "table"
|
||||
and origin.kind == "script_battle"
|
||||
if type(origin) ~= "table"
|
||||
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"The battle completion path cannot be reconstructed safely."
|
||||
end
|
||||
local overworld = game.overworld or {}
|
||||
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
|
||||
or (overworld.runner
|
||||
and overworld.runner.isCheckpointBattle
|
||||
and overworld.runner:isCheckpointBattle(battle)))
|
||||
local otherScriptWork = nonempty(overworld.parallelRunners)
|
||||
or nonempty(overworld.pendingScripts) or nonempty(overworld.parallelQueue)
|
||||
or nonempty(overworld.scriptMoves)
|
||||
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
|
||||
or (not scriptedOrigin and scriptsBusy(overworld)) then
|
||||
return nil, "script_busy", "A suspended or queued script cannot be checkpointed."
|
||||
end
|
||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||
return nil, "battle_phase_busy",
|
||||
"Wait for the player command menu before creating a checkpoint."
|
||||
end
|
||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||
if battle[field] ~= nil and battle[field] ~= false then
|
||||
return nil, "battle_phase_busy", "Wait for the current battle action to finish."
|
||||
end
|
||||
end
|
||||
if not battle.player or not battle.enemy or not battle.player.mon
|
||||
or battle.player.mon.hp <= 0
|
||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||
return nil, "battle_phase_busy",
|
||||
"Wait for a supported player decision before creating a checkpoint."
|
||||
end
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
if not battler.mon or battler.shownHP ~= battler.mon.hp
|
||||
or battler.shownStatus ~= battler.mon.status
|
||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||
or battler.faintQueued then
|
||||
return nil, "battle_phase_busy",
|
||||
"Wait for battle status and HP presentation to settle."
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return BattleSafety
|
||||
@@ -21,12 +21,14 @@ local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local BattleSafety = require("src.battle.BattleSafety")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Status = require("src.battle.Status")
|
||||
local Timing = require("src.core.Timing")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local UIVisibility = require("src.battle.UIVisibility")
|
||||
local RomText = require("src.core.RomText")
|
||||
local Strings = require("src.core.Strings")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
@@ -134,9 +136,7 @@ function BattleState:sgbPalettes()
|
||||
end
|
||||
|
||||
function BattleState:bottomUIVisible()
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
self) ~= false
|
||||
return UIVisibility.bottomVisible(self, true)
|
||||
end
|
||||
|
||||
function BattleState:statusHUDVisible()
|
||||
@@ -1962,6 +1962,17 @@ function BattleState:update(dt)
|
||||
self:resolveTurn(locked)
|
||||
return
|
||||
end
|
||||
-- START has no vanilla action at a settled supported player-decision
|
||||
-- boundary. A tool mod may claim this semantic auxiliary action through
|
||||
-- the public hook, receiving only game plus a data-only kind. The shared
|
||||
-- safety predicate keeps every unsupported/forced/animated phase inert.
|
||||
if input:wasPressed("start") and Runtime.wantsHook("battle.menu_auxiliary") then
|
||||
local safe = BattleSafety.inspect(self.game, self)
|
||||
if safe and Runtime.call("battle.menu_auxiliary", function() return false end,
|
||||
self.game, { kind = self.kind }) == true then
|
||||
return
|
||||
end
|
||||
end
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if input:wasPressed("left") then
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Shared visibility rules for battle-owned UI states. Text and choice
|
||||
-- overlays live above BattleState on the state stack, but they are still part
|
||||
-- of its bottom UI layer and must inherit that layer's visibility.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local UIVisibility = {}
|
||||
|
||||
local function enclosingBattle(state)
|
||||
local stack = state and state.game and state.game.stack
|
||||
local states = stack and stack.states
|
||||
local found = false
|
||||
for i = #(states or {}), 1, -1 do
|
||||
local candidate = states[i]
|
||||
if candidate == state then found = true end
|
||||
if found and candidate and candidate.isBattle then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- queryState keeps the existing TextBox contract: a mod may still decide
|
||||
-- visibility for that individual box. ChoiceBox only inherits the enclosing
|
||||
-- battle decision, so field YES/NO prompts never become battle-hook states.
|
||||
function UIVisibility.bottomVisible(state, queryState)
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
local battle = enclosingBattle(state)
|
||||
if battle and battle ~= state
|
||||
and Runtime.call("battle.bottom_ui_visible",
|
||||
function() return true end, battle) == false then
|
||||
return false
|
||||
end
|
||||
if queryState or battle == state then
|
||||
return Runtime.call("battle.bottom_ui_visible",
|
||||
function() return true end, state) ~= false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return UIVisibility
|
||||
+5
-67
@@ -7,6 +7,7 @@ local Version = require("src.core.Version")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local BattleSafety = require("src.battle.BattleSafety")
|
||||
|
||||
local Checkpoint = {}
|
||||
|
||||
@@ -36,72 +37,9 @@ local function scriptsBusy(ow)
|
||||
or nonempty(ow.scriptMoves)
|
||||
end
|
||||
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
local function inspectBattle(ow, battle)
|
||||
if battle.kind == "link" then
|
||||
return refusal("battle", "link_battle_unsupported",
|
||||
"Network battles cannot be checkpointed.")
|
||||
end
|
||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
|
||||
or battle.noCatch then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle variant does not have a checkpoint contract.")
|
||||
end
|
||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle kind does not have a checkpoint contract.")
|
||||
end
|
||||
local origin = battle.checkpointOrigin
|
||||
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
or "trainer_encounter"
|
||||
local scriptedOrigin = type(origin) == "table"
|
||||
and origin.kind == "script_battle"
|
||||
if type(origin) ~= "table"
|
||||
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
|
||||
return refusal("battle", "battle_origin_unsupported",
|
||||
"The battle completion path cannot be reconstructed safely.")
|
||||
end
|
||||
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
|
||||
or (ow.runner
|
||||
and ow.runner.isCheckpointBattle
|
||||
and ow.runner:isCheckpointBattle(battle)))
|
||||
local otherScriptWork = nonempty(ow.parallelRunners)
|
||||
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
||||
or nonempty(ow.scriptMoves)
|
||||
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
|
||||
or (not scriptedOrigin and scriptsBusy(ow)) then
|
||||
return refusal("battle", "script_busy",
|
||||
"A suspended or queued script cannot be checkpointed.")
|
||||
end
|
||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the player command menu before creating a checkpoint.")
|
||||
end
|
||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||
if battle[field] ~= nil and battle[field] ~= false then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the current battle action to finish.")
|
||||
end
|
||||
end
|
||||
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
|
||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for an ordinary player decision before creating a checkpoint.")
|
||||
end
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
if battler.shownHP ~= battler.mon.hp
|
||||
or battler.shownStatus ~= battler.mon.status
|
||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||
or battler.faintQueued then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for battle status and HP presentation to settle.")
|
||||
end
|
||||
end
|
||||
local function inspectBattle(game, battle)
|
||||
local allowed, reason, message = BattleSafety.inspect(game, battle)
|
||||
if not allowed then return refusal("battle", reason, message) end
|
||||
return { canCapture = true, canRestore = true, kind = "battle" }
|
||||
end
|
||||
|
||||
@@ -120,7 +58,7 @@ function Checkpoint.inspect(game)
|
||||
end
|
||||
local top = game.stack and game.stack.top and game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
return inspectBattle(ow, top)
|
||||
return inspectBattle(game, top)
|
||||
end
|
||||
if top ~= ow then
|
||||
return refusal("overworld", "screen_busy",
|
||||
|
||||
+19
-6
@@ -159,14 +159,15 @@ function Game:makeTitleState()
|
||||
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)
|
||||
self.save.player.facing,
|
||||
{ via = "boot", freshBoot = true })
|
||||
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)
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
end
|
||||
end,
|
||||
})
|
||||
@@ -639,7 +640,12 @@ function Game:keypressed(key)
|
||||
return
|
||||
elseif key == "f2" then
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then self:restoreSave(loaded, recovered) end
|
||||
if loaded then
|
||||
-- F2 jumps straight to the loaded save's map/position, with no
|
||||
-- walking transition -- a hard state teleport like Continue, not a
|
||||
-- smooth warp -- whether pressed at the title screen or mid-session.
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
end
|
||||
return
|
||||
elseif key == "-" then
|
||||
self:zoomStep(-1)
|
||||
@@ -1124,7 +1130,7 @@ function Game:applyOptions(opts)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
function Game:restoreSave(loaded, recovered, opts)
|
||||
if ModRuntime.wants("save.loading") then
|
||||
ModRuntime.emit("save.loading", { raw = loaded })
|
||||
end
|
||||
@@ -1157,8 +1163,12 @@ function Game:restoreSave(loaded, recovered)
|
||||
end
|
||||
-- rebuild the state stack from the save
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- freshBoot threads through from the caller (onContinue and F2 both set
|
||||
-- it); a future caller that doesn't ask for it keeps the ordinary
|
||||
-- crossfade by default.
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing)
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||
self.saveReport = report
|
||||
if not SaveData.emptyReport(report) then
|
||||
-- the report screen is a Screens id so mods (or the ui milestone) own
|
||||
@@ -1187,9 +1197,12 @@ function Game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
self:adoptSave(loaded)
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- freshBoot unconditionally: Checkpoint.resume (src/core/Checkpoint.lua)
|
||||
-- is this method's only caller, and it is itself gated to the title
|
||||
-- session (isTitleSession).
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
{ via = "checkpoint", checkpoint = true })
|
||||
{ via = "checkpoint", checkpoint = true, freshBoot = true })
|
||||
end
|
||||
|
||||
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-- the text is exhausted and A is pressed, then calls onDone.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local UIVisibility = require("src.battle.UIVisibility")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
@@ -393,11 +393,7 @@ function TextBox:update(dt)
|
||||
end
|
||||
|
||||
function TextBox:draw()
|
||||
if Runtime.wantsHook("battle.bottom_ui_visible")
|
||||
and Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
self) == false then
|
||||
return
|
||||
end
|
||||
if not UIVisibility.bottomVisible(self, true) then return end
|
||||
-- The dialogue box belongs against the bottom of the screen, not floating
|
||||
-- in the middle of a zoomed-out letterbox. Declared per frame; the
|
||||
-- renderer blits this region to the screen edge and the rest of the UI
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local UIVisibility = require("src.battle.UIVisibility")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
@@ -67,6 +68,7 @@ function ChoiceBox:update(dt)
|
||||
end
|
||||
|
||||
function ChoiceBox:draw()
|
||||
if not UIVisibility.bottomVisible(self, false) then return end
|
||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||
-- rides the same bottom anchor as the dialogue box it sits above, so the
|
||||
-- pair travels together (the anchor keeps each element's gap from the edge)
|
||||
|
||||
@@ -12,6 +12,7 @@ local MODULES = {
|
||||
QuantityBox = "src.ui.QuantityBox",
|
||||
NamingScreen = "src.ui.NamingScreen",
|
||||
PicBox = "src.ui.PicBox",
|
||||
PokemonIcon = "src.ui.PokemonIcon",
|
||||
TextBox = "src.render.TextBox",
|
||||
Font = "src.render.Font",
|
||||
Theme = "src.ui.Theme",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Public read-only Pokemon icon presentation for detached summaries.
|
||||
-- Resolution and rendering deliberately stay engine-owned: PartyMenu already
|
||||
-- composes content icon registrations, per-species definitions, asset
|
||||
-- overrides, and the pokemon.icon hook in one canonical path.
|
||||
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local PokemonIcon = {}
|
||||
|
||||
local function finite(value)
|
||||
return type(value) == "number" and value == value
|
||||
and value ~= math.huge and value ~= -math.huge
|
||||
end
|
||||
|
||||
local function integer(value, minimum)
|
||||
return finite(value) and value % 1 == 0 and value >= minimum
|
||||
end
|
||||
|
||||
function PokemonIcon.draw(game, summary, x, y, opts)
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
if type(game) ~= "table" or type(summary) ~= "table"
|
||||
or type(summary.species) ~= "string" or summary.species == ""
|
||||
or not integer(summary.hp, 0) or not integer(summary.maxHp, 1)
|
||||
or summary.hp > summary.maxHp or not finite(x) or not finite(y)
|
||||
or (opts.selected ~= nil and type(opts.selected) ~= "boolean")
|
||||
or (opts.counter ~= nil and not finite(opts.counter)) then
|
||||
return false, "invalid_pokemon_preview",
|
||||
"Pokemon icon presentation needs species and valid captured HP values."
|
||||
end
|
||||
local ok, message = pcall(PartyMenu.drawIcon, game, {
|
||||
species = summary.species,
|
||||
hp = summary.hp,
|
||||
stats = { hp = summary.maxHp },
|
||||
}, x, y, opts.selected == true, opts.counter or 0)
|
||||
if not ok then
|
||||
return false, "pokemon_icon_failed", tostring(message)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return PokemonIcon
|
||||
@@ -462,8 +462,18 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
if not keepMusic then
|
||||
-- ..(home/overworld.asm ln 2346)
|
||||
local Music = require("src.core.Music")
|
||||
-- opts.freshBoot: switch instantly instead of cross-fading, like every
|
||||
-- other map's PlayDefaultMusic on real hardware -- set only by
|
||||
-- Game.lua's hard state teleports (onContinue, New Game, F2,
|
||||
-- restoreCheckpointSave). Deliberately separate from opts.via ==
|
||||
-- "boot" itself: dev tooling (src/dev/Console.lua's warp verb,
|
||||
-- src/dev/HotReload.lua's reloadMap) reuses that same default for the
|
||||
-- surf-restore/fresh-npc-pool branches above and must keep the
|
||||
-- ordinary crossfade.
|
||||
local fade = Music.MAP_FADE
|
||||
if opts and opts.freshBoot then fade = nil end
|
||||
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
||||
Music.MAP_FADE)
|
||||
fade)
|
||||
end
|
||||
|
||||
-- forced bike/surf tiles fire the moment the player is placed on the
|
||||
|
||||
Reference in New Issue
Block a user