mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
fix merge conflicts with dev
This commit is contained in:
+18
-18
@@ -1619,26 +1619,26 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
end
|
||||
|
||||
-- Damage that skips the formula entirely (constantdamage).
|
||||
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random, def)
|
||||
-- Damage that skips the formula entirely. The move's own power goes with
|
||||
-- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
|
||||
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
|
||||
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
|
||||
def.power)
|
||||
if fixed then
|
||||
-- StaticDamage's effect script runs resettypematchup after constantdamage
|
||||
-- (data/moves/effects.asm): immunities still miss (Sonic Boom vs Ghost).
|
||||
-- Flat damage is otherwise unscaled — no STAB / effectiveness multiply.
|
||||
if def.effect == "EFFECT_STATIC_DAMAGE" then
|
||||
local defTypes = (self:speciesDef(defender) or {}).types or defender.types
|
||||
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
||||
local mult = Damage.typeMultiplier(def.type, defTypes, matchups)
|
||||
if mult == 0 then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message",
|
||||
text = "It doesn't affect " .. self:monName(defender) .. "..." })
|
||||
return
|
||||
end
|
||||
-- The constant-damage effect list carries `resettypematchup` instead of
|
||||
-- `stab`, and that command misses the move outright when the matchup byte
|
||||
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
|
||||
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
|
||||
local defenderTypes = (self:speciesDef(defender) or {}).types
|
||||
or defender.types
|
||||
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
||||
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message",
|
||||
text = "It doesn't affect " .. self:monName(defender) .. "..." })
|
||||
return
|
||||
end
|
||||
self:dealDamage(attacker, defender, fixed, {
|
||||
move = def, moveId = moveId, effectiveness = 10,
|
||||
})
|
||||
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@@ -179,10 +179,9 @@ end
|
||||
|
||||
-- --------------------------------------------------------------- fixed damage
|
||||
|
||||
-- BattleCommand_ConstantDamage / LevelDamage / SuperFang / Psywave /
|
||||
-- StaticDamage (Sonic Boom, Dragon Rage): skip the damage formula entirely.
|
||||
-- `move` is optional; EFFECT_STATIC_DAMAGE reads move.power (20 / 40).
|
||||
function Effects.fixedDamage(effect, attacker, defender, random, move)
|
||||
-- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
|
||||
-- one command for the whole SuperFang / Psywave / StaticDamage list.
|
||||
function Effects.fixedDamage(effect, attacker, defender, random, power)
|
||||
if effect == "EFFECT_LEVEL_DAMAGE" then
|
||||
return math.max(1, attacker.level or 1)
|
||||
end
|
||||
@@ -190,12 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random, move)
|
||||
return math.max(1, math.floor((defender.hp or 1) / 2))
|
||||
end
|
||||
if effect == "EFFECT_PSYWAVE" then
|
||||
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
|
||||
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
|
||||
return math.max(1, (random and random(ceiling) or 0) + 1)
|
||||
-- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
|
||||
-- top of the range is that ceiling minus one (effect_commands.asm:3163).
|
||||
local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
|
||||
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
|
||||
end
|
||||
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
|
||||
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
|
||||
-- (20 and 40) IS the damage, never a formula input
|
||||
-- (effect_commands.asm:3157-3161).
|
||||
if effect == "EFFECT_STATIC_DAMAGE" then
|
||||
return math.max(1, (move and move.power) or 1)
|
||||
return math.max(1, math.floor(power or 0))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -313,6 +313,16 @@ local function equalData(a, b)
|
||||
return okA and okB and encodedA == encodedB
|
||||
end
|
||||
|
||||
local function normalizeVerificationMetadata(actual, expected)
|
||||
if type(actual) == "table" and type(actual.identity) == "table"
|
||||
and type(expected) == "table" and type(expected.identity) == "table" then
|
||||
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime
|
||||
-- state. A fresh recapture stamps the running engine, so normalize only
|
||||
-- that metadata before differential verification.
|
||||
actual.identity.engineVersion = expected.identity.engineVersion
|
||||
end
|
||||
end
|
||||
|
||||
local function firstDifference(a, b, path)
|
||||
path = path or "$"
|
||||
if type(a) ~= type(b) then return path .. " (type)" end
|
||||
@@ -394,6 +404,7 @@ function Checkpoint.restore(game, checkpoint)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
normalizeVerificationMetadata(restored, validated)
|
||||
if restored and equalData(restored, validated) then
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
@@ -474,6 +485,7 @@ function Checkpoint.resume(game, checkpoint)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
normalizeVerificationMetadata(restored, validated)
|
||||
if restored and equalData(restored, validated) then
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Optional native-host display lifecycle.
|
||||
--
|
||||
-- The engine always owns simulation and drawing. A packaged host may install
|
||||
-- one backend to observe per-frame updates and prepare/finalize a render target
|
||||
-- around an otherwise unchanged draw. With no backend installed every method
|
||||
-- is a no-op, which is the normal desktop and mobile path.
|
||||
--
|
||||
-- Backend methods are optional:
|
||||
-- backend:update(dt)
|
||||
-- backend:beginFrame(kind, subject)
|
||||
-- backend:endFrame(kind, subject)
|
||||
--
|
||||
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
|
||||
-- the object whose existing draw method runs between beginFrame and endFrame.
|
||||
local HostDisplay = {}
|
||||
|
||||
local backend
|
||||
|
||||
function HostDisplay.setBackend(value)
|
||||
if value ~= nil and type(value) ~= "table" then
|
||||
error("host display backend must be a table or nil", 2)
|
||||
end
|
||||
backend = value
|
||||
end
|
||||
|
||||
function HostDisplay.update(dt)
|
||||
local fn = backend and backend.update
|
||||
if fn then return fn(backend, dt) end
|
||||
end
|
||||
|
||||
function HostDisplay.beginFrame(kind, subject)
|
||||
local fn = backend and backend.beginFrame
|
||||
if fn then return fn(backend, kind, subject) end
|
||||
end
|
||||
|
||||
function HostDisplay.endFrame(kind, subject)
|
||||
local fn = backend and backend.endFrame
|
||||
if fn then return fn(backend, kind, subject) end
|
||||
end
|
||||
|
||||
return HostDisplay
|
||||
+31
-8
@@ -335,11 +335,11 @@ function SaveData.defaultOptions()
|
||||
-- TouchControls.normalizeConfig folds it into both orientations on load.
|
||||
touchControls = { enabled = true },
|
||||
-- Haptic feedback level for on-screen pad presses (#806):
|
||||
-- off | light | medium | heavy, mapped to a love.system.vibrate
|
||||
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
|
||||
-- overlay itself defaulting on, so an options.lua predating this key
|
||||
-- gets the tick without going looking for the row. Inert wherever the
|
||||
-- overlay never appears (desktop) or LOVE has no vibrator.
|
||||
-- off | light | normal | strong, mapped to a love.system.vibrate duration
|
||||
-- in src/core/TouchControls.lua. LIGHT by default, so an options.lua
|
||||
-- predating this key gets the tick without going looking for the row.
|
||||
-- Inert wherever the overlay never appears (desktop) or LOVE has no
|
||||
-- vibrator.
|
||||
haptics = "light",
|
||||
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
|
||||
-- locale where the platform exposes one; otherwise DateTime falls back to
|
||||
@@ -818,9 +818,19 @@ end
|
||||
function SaveData.slotSummary(save)
|
||||
if type(save) ~= "table" then return nil, nil end
|
||||
local name = save.player and save.player.name or nil
|
||||
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
|
||||
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
|
||||
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
|
||||
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
|
||||
local dexCount = 0
|
||||
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
||||
dexCount = dexCount + 1
|
||||
if gen2 then
|
||||
for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
|
||||
if has then dexCount = dexCount + 1 end
|
||||
end
|
||||
else
|
||||
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
||||
dexCount = dexCount + 1
|
||||
end
|
||||
end
|
||||
-- playTime is a plain seconds count in a Gen 1 save but a
|
||||
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
|
||||
@@ -838,8 +848,21 @@ function SaveData.slotSummary(save)
|
||||
end
|
||||
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60)
|
||||
local badges
|
||||
if gen2 then
|
||||
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
|
||||
-- (engine/menus/intro_menu.asm:461-469).
|
||||
badges = 0
|
||||
local p = save.player or {}
|
||||
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
|
||||
for _, has in pairs(p.kantoBadges or {}) do
|
||||
if has then badges = badges + 1 end
|
||||
end
|
||||
else
|
||||
badges = Badges.count(nil, save)
|
||||
end
|
||||
return name, {
|
||||
badges = Badges.count(nil, save),
|
||||
badges = badges,
|
||||
timeText = timeText,
|
||||
dexCount = dexCount,
|
||||
}
|
||||
|
||||
@@ -105,17 +105,21 @@ end
|
||||
-- would be dropped on every editor save.
|
||||
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
|
||||
-- duration preset: Android runs the platform vibrator for exactly that long,
|
||||
-- while iOS ignores the duration and fires the fixed system vibration, so
|
||||
-- there the three levels all read as simply on.
|
||||
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
|
||||
-- while iOS maps each duration to a matching Taptic Engine impact.
|
||||
TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
|
||||
TouchControls.HAPTIC_DEFAULT = "light"
|
||||
|
||||
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
|
||||
local HAPTIC_SECONDS = {
|
||||
off = 0, light = 0.012, normal = 0.025, strong = 0.045,
|
||||
}
|
||||
local HAPTIC_LABELS = {
|
||||
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
|
||||
off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
|
||||
}
|
||||
|
||||
function TouchControls.normalizeHaptics(level)
|
||||
if level == "physical" then return "light" end
|
||||
if level == "medium" then return "normal" end
|
||||
if level == "heavy" then return "strong" end
|
||||
if HAPTIC_SECONDS[level] then return level end
|
||||
return TouchControls.HAPTIC_DEFAULT
|
||||
end
|
||||
|
||||
@@ -8,6 +8,9 @@ local Version = {
|
||||
-- "-dev" placeholder; CI stamps the real X.Y.Z into
|
||||
-- the packed game.love only, never the working tree.
|
||||
shell = 1, -- native-shell contract this build implements
|
||||
payloadHost = "love", -- native host family for in-place Lua payloads.
|
||||
-- A payload must name the same family; this prevents
|
||||
-- mounting code packaged for a different native host.
|
||||
minShell = 1, -- lowest shell contract that can RUN this payload.
|
||||
-- Bump only when a payload needs a newer native
|
||||
-- binary (e.g. a LOVE version bump); an older shell
|
||||
|
||||
@@ -764,9 +764,14 @@ end
|
||||
function Save.summary(save)
|
||||
if type(save) ~= "table" then return nil end
|
||||
local badges = 0
|
||||
-- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges
|
||||
-- (engine/menus/intro_menu.asm:461-469).
|
||||
for _, has in pairs(save.player and save.player.badges or {}) do
|
||||
if has then badges = badges + 1 end
|
||||
end
|
||||
for _, has in pairs(save.player and save.player.kantoBadges or {}) do
|
||||
if has then badges = badges + 1 end
|
||||
end
|
||||
local caught = 0
|
||||
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
|
||||
if has then caught = caught + 1 end
|
||||
|
||||
@@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
|
||||
-- The core rows. Helper modules are required lazily under pcall: they are
|
||||
-- pure label/cycle tables, but the launcher must never die because a render
|
||||
-- module grew a dependency on live game data.
|
||||
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
|
||||
--
|
||||
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
|
||||
-- so `opts` is whichever table the gear is editing and the rows never have to
|
||||
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
|
||||
-- of them, so a phone player could turn the pad and the buzz off in Red and
|
||||
-- had no way to reach either in Gold.
|
||||
local function addTouchRows(rows, add, opts, hooks)
|
||||
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
|
||||
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local show = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
if show then
|
||||
add(Strings("TOUCH PAD"),
|
||||
function()
|
||||
local tc = opts.touchControls
|
||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||
return on and Strings("ON") or Strings("OFF")
|
||||
end,
|
||||
function()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled = tc.enabled == false
|
||||
opts.touchControls = tc
|
||||
return true
|
||||
end)
|
||||
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
|
||||
-- the row buzzes once at the level being selected.
|
||||
local okTC, TC = pcall(require, "src.core.TouchControls")
|
||||
if okTC then
|
||||
add(Strings("VIBRATION"),
|
||||
function() return Strings(TC.hapticLabel(opts.haptics)) end,
|
||||
function(dir)
|
||||
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
|
||||
TC.buzz(opts.haptics)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
|
||||
-- button on the game panel, once per game -- but the overlay layout is
|
||||
-- global (options.touchControls.layouts), so three tabs offered three
|
||||
-- buttons that edited the same thing while crowding the column that has to
|
||||
-- hold Play. It belongs with the other control rows, behind the gear.
|
||||
-- The host owns the editor screen, so the row only fires when a hook was
|
||||
-- supplied (the standalone save editor opens this model with none).
|
||||
if hooks and hooks.editTouchControls then
|
||||
rows[#rows + 1] = {
|
||||
label = Strings("TOUCH CONTROLS"),
|
||||
actionLabel = Strings("Edit"),
|
||||
action = function()
|
||||
hooks.editTouchControls()
|
||||
-- The editor replaces the whole screen: nothing left to persist here
|
||||
-- beyond what the caller already saved on the way out.
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local function coreRows(opts, hooks)
|
||||
local rows = {}
|
||||
local function add(label, value, step)
|
||||
@@ -236,60 +298,7 @@ local function coreRows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
|
||||
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
|
||||
do
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local show = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
if show then
|
||||
add(Strings("TOUCH PAD"),
|
||||
function()
|
||||
local tc = opts.touchControls
|
||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||
return on and Strings("ON") or Strings("OFF")
|
||||
end,
|
||||
function()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled = tc.enabled == false
|
||||
opts.touchControls = tc
|
||||
return true
|
||||
end)
|
||||
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
|
||||
-- the row buzzes once at the level being selected.
|
||||
local okTC, TC = pcall(require, "src.core.TouchControls")
|
||||
if okTC then
|
||||
add(Strings("VIBRATION"),
|
||||
function() return Strings(TC.hapticLabel(opts.haptics)) end,
|
||||
function(dir)
|
||||
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
|
||||
TC.buzz(opts.haptics)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
|
||||
-- button on the game panel, once per game -- but the overlay layout is
|
||||
-- global (options.touchControls.layouts), so three tabs offered three
|
||||
-- buttons that edited the same thing while crowding the column that has to
|
||||
-- hold Play. It belongs with the other control rows, behind the gear.
|
||||
-- The host owns the editor screen, so the row only fires when a hook was
|
||||
-- supplied (the standalone save editor opens this model with none).
|
||||
if hooks and hooks.editTouchControls then
|
||||
rows[#rows + 1] = {
|
||||
label = Strings("TOUCH CONTROLS"),
|
||||
actionLabel = Strings("Edit"),
|
||||
action = function()
|
||||
hooks.editTouchControls()
|
||||
-- The editor replaces the whole screen: nothing left to persist here
|
||||
-- beyond what the caller already saved on the way out.
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
addTouchRows(rows, add, opts, hooks)
|
||||
|
||||
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
|
||||
-- (src/core/Input.lua:applyBindings layers options.bindings over the
|
||||
@@ -462,7 +471,7 @@ end
|
||||
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
|
||||
local GEN2_KEY = "gold"
|
||||
|
||||
local function gen2Rows(opts)
|
||||
local function gen2Rows(opts, hooks)
|
||||
local rows = {}
|
||||
local function add(label, value, step)
|
||||
rows[#rows + 1] = { label = label, value = value, step = step }
|
||||
@@ -551,6 +560,8 @@ local function gen2Rows(opts)
|
||||
end)
|
||||
end
|
||||
|
||||
addTouchRows(rows, add, opts, hooks)
|
||||
|
||||
return rows
|
||||
end
|
||||
|
||||
@@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version)
|
||||
opts[GEN2_KEY] = block
|
||||
end
|
||||
sections = {
|
||||
{ title = Strings("OPTIONS"), rows = gen2Rows(block) },
|
||||
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
|
||||
}
|
||||
else
|
||||
sections = {
|
||||
|
||||
@@ -2652,11 +2652,14 @@ function RomImporter:_openSettings()
|
||||
-- hook rather than reaching for main.lua's handler itself. Closing the
|
||||
-- settings panel FIRST persists the pending edits (_closeSettings saves)
|
||||
-- and leaves no modal behind the editor to return to.
|
||||
-- The tab rides along: the editor persists the layout into that game's own
|
||||
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
||||
local hooks = {}
|
||||
if self.onEditTouchControls then
|
||||
local version = self.tab
|
||||
hooks.editTouchControls = function()
|
||||
self:_closeSettings()
|
||||
self.onEditTouchControls()
|
||||
self.onEditTouchControls(version)
|
||||
end
|
||||
end
|
||||
-- The tab the gear was opened on decides the row set: Gold reads a
|
||||
@@ -2741,6 +2744,13 @@ function RomImporter:keypressed(key)
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
-- inside the modal branch so text fields retain exclusive keyboard input.
|
||||
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
|
||||
return
|
||||
end
|
||||
if key == "escape" then
|
||||
if self._findDetails then
|
||||
self._findDetails = nil
|
||||
|
||||
@@ -266,7 +266,10 @@ function OakSpeech.new(game, onDone)
|
||||
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
|
||||
-- standing, facing down)
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
|
||||
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED
|
||||
-- The fallback has to read the same guarded table: reaching for
|
||||
-- game.data.sprites.SPRITE_RED after the `and` already found it nil threw.
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED
|
||||
self.walkSheet = tryImage(red and red.image)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -501,7 +501,7 @@ local function buildRows(game)
|
||||
return true
|
||||
end },
|
||||
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
|
||||
-- MEDIUM / HEAVY, where the intensity is a vibration duration --
|
||||
-- NORMAL / STRONG, where the intensity is a vibration duration --
|
||||
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
|
||||
-- since the only thing that buzzes is a virtual button press.
|
||||
{ id = "haptics", label = Strings("VIBRATION"),
|
||||
@@ -515,7 +515,7 @@ local function buildRows(game)
|
||||
o.haptics = TC.cycleHaptics(o.haptics, dir)
|
||||
TC:applyOptions(o)
|
||||
-- sample the level being selected: stepping the row is the only way
|
||||
-- to compare LIGHT against HEAVY without leaving the menu
|
||||
-- to compare LIGHT against STRONG without leaving the menu
|
||||
TC.buzz(o.haptics)
|
||||
return true
|
||||
end },
|
||||
|
||||
+54
-11
@@ -106,6 +106,10 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?")
|
||||
local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.")
|
||||
local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?")
|
||||
|
||||
-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231).
|
||||
local TEXT_ENEMY_ABOUT_TO_USE = Strings.source(
|
||||
"%s\nis about to use\v%s.\fWill %s\nchange POKéMON?")
|
||||
|
||||
-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165).
|
||||
local TEXT_ASK_FORGET_MOVE = Strings.source(
|
||||
"%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves."
|
||||
@@ -548,6 +552,14 @@ BattleState.PLAYER_PIC_TILES = 6
|
||||
-- box's own bottom centre.
|
||||
local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 }
|
||||
|
||||
-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on
|
||||
-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port
|
||||
-- carries both as the volatile `vanished` flag.
|
||||
function BattleState.isVanished(mon)
|
||||
local volatiles = mon and mon.volatile
|
||||
return (volatiles and volatiles.vanished) and true or false
|
||||
end
|
||||
|
||||
function BattleState:drawPic(mon, back)
|
||||
-- During the intro slide the player-side pic belongs to presentSlide's
|
||||
-- backpic overlay, not to the baked bands (see BattleAnimView).
|
||||
@@ -571,6 +583,13 @@ function BattleState:drawPic(mon, back)
|
||||
-- The box is empty either because the animation running right now has
|
||||
-- cleared it, or because the last one ENDED with it cleared (picHidden).
|
||||
if (anim and anim.hidden) or self.picHidden[side] then return end
|
||||
-- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it
|
||||
-- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the
|
||||
-- stored attack (engine/battle/effect_commands.asm:2113-2117).
|
||||
if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon)
|
||||
and not (self.vanishAnim and self.vanishAnim == self.anim) then
|
||||
return
|
||||
end
|
||||
local G = love.graphics
|
||||
local w, h = image:getDimensions()
|
||||
local px, py
|
||||
@@ -582,10 +601,12 @@ function BattleState:drawPic(mon, back)
|
||||
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
|
||||
boxTiles = BattleState.PLAYER_PIC_TILES
|
||||
else
|
||||
-- Bottom-aligned and horizontally centred inside the 7x7 box.
|
||||
-- PadFrontpic pads a short pic and never a long one
|
||||
-- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the
|
||||
-- box's own corner at hlcoord 12, 0 rather than to a negative offset.
|
||||
local box = BattleState.ENEMY_PIC_TILES * 8
|
||||
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2)
|
||||
py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h)
|
||||
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2))
|
||||
py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h)
|
||||
boxTiles = BattleState.ENEMY_PIC_TILES
|
||||
end
|
||||
-- One tile per two frames to the right, SlideBattlePicOut's own step.
|
||||
@@ -903,11 +924,12 @@ function BattleState:startAnim(key, opts)
|
||||
param = opts.param or 0,
|
||||
sfxOrder = audio.sfxOrder,
|
||||
ballPalette = opts.ballPalette,
|
||||
-- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3
|
||||
-- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit
|
||||
-- on the mon's volatile table, not on the mon itself.
|
||||
flying = {
|
||||
player = self.battle and self.battle.player
|
||||
and self.battle.player.vanished or false,
|
||||
enemy = self.battle and self.battle.enemy
|
||||
and self.battle.enemy.vanished or false,
|
||||
player = BattleState.isVanished(self.battle and self.battle.player),
|
||||
enemy = BattleState.isVanished(self.battle and self.battle.enemy),
|
||||
},
|
||||
hooks = {
|
||||
-- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls
|
||||
@@ -1365,6 +1387,13 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser
|
||||
-- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw
|
||||
-- the take-off or the burrow on the turn the substatus goes up; the box
|
||||
-- only empties once THIS animation is done with.
|
||||
if BattleState.isVanished(self:activeMon(event.side)) then
|
||||
self.vanishAnim = self.anim
|
||||
end
|
||||
elseif event.kind == "damage" and event.side then
|
||||
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
|
||||
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
|
||||
@@ -1864,6 +1893,20 @@ function BattleState:update(_dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one
|
||||
-- (engine/battle/core.asm:3302-3305), so the mon is named and read first.
|
||||
if self.phase == "shift-intro" then
|
||||
if self.messageTimer > 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
self:nextPage()
|
||||
if not self.messagePages then self.phase = "ask-shift" end
|
||||
return
|
||||
end
|
||||
|
||||
-- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls
|
||||
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
|
||||
if self.phase == "ask-shift" then
|
||||
@@ -2464,13 +2507,13 @@ end
|
||||
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
|
||||
function BattleState:offerShiftSwitch(mon)
|
||||
self.shiftIndex = 1
|
||||
self.phase = "ask-shift"
|
||||
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
|
||||
local player = (self.save and self.save.player and self.save.player.name)
|
||||
or "GOLD"
|
||||
self.message = trainer .. " is about to use " .. self:name(mon)
|
||||
.. ". Will " .. player .. " change POKéMON?"
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
-- The `para` splits this in two (data/text/battle.asm:222-231): the incoming
|
||||
-- mon is NAMED on its own page, and only the second carries the yes/no box.
|
||||
self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player))
|
||||
self.phase = self.messagePages and "shift-intro" or "ask-shift"
|
||||
end
|
||||
|
||||
function BattleState:answerUseNextMon(yes)
|
||||
|
||||
+48
-13
@@ -80,7 +80,8 @@ local function purgeBundledModules()
|
||||
end
|
||||
end
|
||||
|
||||
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
|
||||
-- Boot.probePayload(rel)
|
||||
-- -> { engine = string, minShell = number, payloadHost = string } | nil, err
|
||||
--
|
||||
-- Mount the archive at rel (a save-directory-relative path) on an isolated
|
||||
-- mountpoint, read its src/core/Version.lua by executing the source with
|
||||
@@ -104,24 +105,50 @@ function Boot.probePayload(rel)
|
||||
if type(v) ~= "table" or type(v.engine) ~= "string" then
|
||||
return nil, "payload has no usable Version table"
|
||||
end
|
||||
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
|
||||
return {
|
||||
engine = v.engine,
|
||||
minShell = tonumber(v.minShell) or 1,
|
||||
payloadHost = type(v.payloadHost) == "string" and v.payloadHost or "love",
|
||||
}
|
||||
end
|
||||
|
||||
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
|
||||
-- Pure host gate shared by boot selection and the download worker. Missing
|
||||
-- payloadHost fields mean "love" so payloads made before this contract remain
|
||||
-- compatible with ordinary LOVE packages.
|
||||
function Boot.canHost(info, bundledShell, bundledPayloadHost)
|
||||
if type(info) ~= "table" then return false end
|
||||
local payloadHost = type(info.payloadHost) == "string"
|
||||
and info.payloadHost or "love"
|
||||
local host = type(bundledPayloadHost) == "string"
|
||||
and bundledPayloadHost or "love"
|
||||
return payloadHost == host and (tonumber(info.minShell) or 1)
|
||||
<= (tonumber(bundledShell) or 1)
|
||||
end
|
||||
|
||||
local function samePayloadHost(info, bundledPayloadHost)
|
||||
local payloadHost = type(info.payloadHost) == "string"
|
||||
and info.payloadHost or "love"
|
||||
local host = type(bundledPayloadHost) == "string"
|
||||
and bundledPayloadHost or "love"
|
||||
return payloadHost == host
|
||||
end
|
||||
|
||||
-- Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
|
||||
-- -> chosen | nil, toDelete
|
||||
--
|
||||
-- Pure (no love.*): decide which payload to run and which to delete.
|
||||
-- candidates is a list of { name = , engine = , minShell = }.
|
||||
-- candidates is a list of { name = , engine = , minShell = , payloadHost = }.
|
||||
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
|
||||
-- whose minShell <= bundledShell (a payload the running shell can host).
|
||||
-- whose payloadHost matches and minShell <= bundledShell.
|
||||
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
|
||||
-- already ship), or superseded by the chosen one (not newer than chosen).
|
||||
-- A payload newer than the chosen one but unrunnable here (minShell too
|
||||
-- high) is kept: a future shell upgrade may be able to run it.
|
||||
function Boot.select(candidates, bundledEngine, bundledShell)
|
||||
-- A newer incompatible payload is kept: a matching host or future shell
|
||||
-- may be able to run it.
|
||||
function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
|
||||
local chosen
|
||||
for _, c in ipairs(candidates) do
|
||||
local newer = Semver.compare(c.engine, bundledEngine) > 0
|
||||
local runnable = (c.minShell or 1) <= bundledShell
|
||||
local runnable = Boot.canHost(c, bundledShell, bundledPayloadHost)
|
||||
if newer and runnable then
|
||||
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
|
||||
chosen = c
|
||||
@@ -132,9 +159,15 @@ function Boot.select(candidates, bundledEngine, bundledShell)
|
||||
local toDelete = {}
|
||||
for _, c in ipairs(candidates) do
|
||||
if not (chosen and c.name == chosen.name) then
|
||||
local stale = Semver.compare(c.engine, bundledEngine) <= 0
|
||||
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
|
||||
stale = true
|
||||
local stale = false
|
||||
-- Never clean up another host family's payloads. A shared save directory
|
||||
-- may be opened by multiple native packages, and only the matching host
|
||||
-- can decide whether one of its own archives is stale.
|
||||
if samePayloadHost(c, bundledPayloadHost) then
|
||||
stale = Semver.compare(c.engine, bundledEngine) <= 0
|
||||
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
|
||||
stale = true
|
||||
end
|
||||
end
|
||||
if stale then toDelete[#toDelete + 1] = c.name end
|
||||
end
|
||||
@@ -223,6 +256,7 @@ local function runInner(args)
|
||||
name = entry,
|
||||
engine = info.engine,
|
||||
minShell = info.minShell,
|
||||
payloadHost = info.payloadHost,
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -230,7 +264,8 @@ local function runInner(args)
|
||||
end
|
||||
|
||||
local Version = require("src.core.Version")
|
||||
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
|
||||
local chosen, toDelete = Boot.select(candidates, Version.engine,
|
||||
Version.shell, Version.payloadHost)
|
||||
|
||||
for _, victim in ipairs(toDelete) do
|
||||
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
|
||||
|
||||
@@ -153,8 +153,9 @@ local function gatePasses(rel)
|
||||
local info = Boot.probePayload(rel)
|
||||
if not info then return true end
|
||||
local shell = (Version and Version.shell) or 1
|
||||
if info.minShell and info.minShell > shell then return false end
|
||||
return true
|
||||
local payloadHost = (Version and Version.payloadHost) or "love"
|
||||
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
|
||||
return not (info.minShell and info.minShell > shell)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1397,14 +1397,16 @@ function OverworldState:checkLedgeHop(dir)
|
||||
return false
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
||||
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
|
||||
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
|
||||
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
|
||||
return true
|
||||
end
|
||||
if not Collision.occupied(self.entities, lx, ly, p)
|
||||
and self.map:isWalkableCell(lx, ly) then
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
||||
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
|
||||
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
|
||||
self:scriptMove(p, dir, 2)
|
||||
return true
|
||||
end
|
||||
@@ -3515,10 +3517,21 @@ end
|
||||
|
||||
function OverworldState:onStepComplete()
|
||||
local p = self.player
|
||||
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
|
||||
if suppressWildEncounter then
|
||||
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
|
||||
-- Defaulted: a state built without the constructor (a mod harness, a test
|
||||
-- fixture) reaches this before :234 ever ran, and nil > 0 threw the step.
|
||||
local grace = self.wildEncounterGraceSteps or 0
|
||||
if grace > 0 then
|
||||
self.wildEncounterGraceSteps = grace - 1
|
||||
end
|
||||
-- TryDoWildEncounter's first guard is `ld a, [wNPCMovementScriptPointerTable
|
||||
-- Num] / and a / ret nz` (engine/battle/wild_encounters.asm:3-9): a step the
|
||||
-- player did not take never rolls, which is why Oak's escort walks to the lab
|
||||
-- through Pallet's grass without being jumped.
|
||||
local runner = self.runner
|
||||
local scripted = (runner and runner.isRunning and runner:isRunning())
|
||||
or #(self.scriptMoves or {}) > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
local suppressWildEncounter = grace > 0 or scripted and true or false
|
||||
self.todSteps = (self.todSteps or 0) + 1
|
||||
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
||||
require("src.world.PikachuFollower").onStep(Game.save)
|
||||
|
||||
@@ -3946,7 +3946,8 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
|
||||
local ctx = {
|
||||
mapId = map and map.id,
|
||||
terrain = terrain,
|
||||
rng = love.math.random,
|
||||
-- Same guard World:rockRandom uses: a headless suite has no love global.
|
||||
rng = (love and love.math and love.math.random) or math.random,
|
||||
kind = kind,
|
||||
daytime = self.daytime,
|
||||
environment = map and map.def and map.def.environment,
|
||||
|
||||
Reference in New Issue
Block a user