Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment

This commit is contained in:
James Hall
2026-08-16 19:30:19 -05:00
committed by GitHub
233 changed files with 8377 additions and 45512 deletions
+264
View File
@@ -0,0 +1,264 @@
-- Read-only Gen 1 battle state for companion UIs and accessibility mods.
local Damage = require("src.battle.Damage")
local ItemEffects = require("src.inventory.ItemEffects")
local TypeChart = require("src.battle.TypeChart")
local BattleAPI = {}
BattleAPI.__index = BattleAPI
function BattleAPI.new(game)
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
end
local function activeBattle(game)
local states = game and game.stack and game.stack.states or {}
for i = #states, 1, -1 do
if states[i].isBattleState then return states[i], states[#states] end
end
end
local function monCopy(data, mon, active)
if not mon then return nil end
local def = data.pokemon[mon.species]
return {
species = mon.species,
name = mon.nickname or (def and def.name) or mon.species,
level = mon.level, hp = mon.hp,
maxHp = mon.stats and mon.stats.hp or mon.hp,
status = mon.status, active = active and true or false,
}
end
local function visibleMessage(battle, top)
local source = top and top.isTextBox and top or top == battle and battle
local lines = source and source.visibleText and source:visibleText()
if not lines then return nil end
local copy = {}
for i, line in ipairs(lines) do copy[i] = tostring(line) end
return copy
end
local function signature(game, battle, top)
if not battle then return "none" end
local parts = { tostring(battle), tostring(top), battle.phase or "",
tostring(battle.turnCount or 0), tostring(#(battle.queue or {})),
tostring(battle.current), tostring(battle.msgWaiting),
tostring(battle.msgPrompt), tostring(battle.menuIndex),
tostring(battle.mimicIndex),
tostring(battle.safari and battle.safari.balls),
tostring(battle.ghost), tostring(battle.noCatch),
table.concat(visibleMessage(battle, top) or {}, "\n") }
for _, battler in ipairs({ battle.player, battle.enemy }) do
local mon = battler and battler.mon
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon and mon.hp)
parts[#parts + 1] = tostring(mon and mon.status)
parts[#parts + 1] = table.concat(battler and battler.curTypes or {}, ",")
end
for _, mon in ipairs(game.save.party or {}) do
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon.hp)
parts[#parts + 1] = tostring(mon.status)
end
local inventory = {}
for id, count in pairs(game.save.inventory or {}) do
if ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id) then
inventory[#inventory + 1] = id .. "=" .. tostring(count)
end
end
table.sort(inventory)
for _, item in ipairs(inventory) do parts[#parts + 1] = item end
return table.concat(parts, "|")
end
function BattleAPI:_revision(battle, top)
local nextSignature = signature(self.game, battle, top)
if nextSignature ~= self.signature then
self.signature = nextSignature
self.revision = self.revision + 1
end
return self.revision
end
local IMMUNITY_ONLY = { SPECIAL_DAMAGE_EFFECT = true,
SUPER_FANG_EFFECT = true, OHKO_EFFECT = true }
local function movePreview(battle, move, def)
local record = battle.effectRecord and battle:effectRecord(def.effect)
local power, typeMult = def.power or 0
if power > 0 and move.id ~= "COUNTER" then
local raw = TypeChart.effectiveness(def.type, battle.enemy.curTypes or {})
if IMMUNITY_ONLY[def.effect] then
typeMult = raw == 0 and 0 or 10
elseif not (record and record.chooseDamage) then
typeMult = raw
end
end
local hitChance
if record and record.neverMiss then
hitChance = 100
elseif not (record and record.gate)
and (power > 0 or (record and record.accuracyChecked)) then
hitChance = battle.enemy.invulnerable and 0
or Damage.accuracyChance(battle.ruleset, def,
battle.player, battle.enemy)
end
local displayPower = power > 0 and move.id ~= "COUNTER"
and not IMMUNITY_ONLY[def.effect]
and not (record and record.chooseDamage) and power or nil
return typeMult, hitChance, displayPower
end
local function moveCopies(game, battle)
local out = {}
for slot, move in ipairs((battle.player and battle.player.curMoves) or {}) do
local def = game.data.moves[move.id] or {}
local mult, hitChance, displayPower = movePreview(battle, move, def)
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
pp = move.pp,
maxPp = (def.pp or move.pp or 0)
+ (move.ppUps or 0) * math.floor((def.pp or 0) / 5),
type = def.type, power = def.power, accuracy = def.accuracy,
displayPower = displayPower, hitChance = hitChance,
effectiveness = mult,
disabled = battle.player.disabledSlot == slot }
end
return out
end
local function itemCopies(game, battle, catchable)
local out = {}
for id, count in pairs(game.save.inventory or {}) do
if count > 0
and (ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id)) then
local def = game.data.items[id] or {}
local ball = ItemEffects.isBall(id)
out[#out + 1] = { id = id, name = def.name or id, count = count,
ball = ball, needsTarget = not ball,
catchChance = ball and catchable and battle.catchChance
and battle:catchChance(id) or nil }
end
end
table.sort(out, function(a, b) return a.name < b.name end)
return out
end
local function mimicCopies(game, battle)
local out = {}
for i, move in ipairs(battle.mimicMoves or {}) do
local def = game.data.moves[move.id] or {}
out[i] = { index = i, slot = move.slot, id = move.id,
name = def.name or move.id }
end
return out
end
function BattleAPI:snapshot()
local game = self.game
local battle, top = activeBattle(game)
if not battle then return nil end
local kind = battle:battleKind()
local supported = kind ~= "oldman" and kind ~= "link"
local catchable = kind == "wild" and not battle.ghost and not battle.noCatch
local forcedParty = top and top.isPartyMenu and top.battle == battle
and top.forceSwitch
local canAdvance = supported and ((top == battle
and battle.phase == "messages" and battle.current
and (battle.msgWaiting or battle.msgPrompt))
or (top and top.isTextBox and not top.choice
and (top.waiting or top.done)))
local prompt = "locked"
if canAdvance then prompt = "advance"
elseif supported and forcedParty then prompt = "party"
elseif supported and top == battle and kind == "safari"
and battle.phase == "menu" then prompt = "safari"
elseif supported and top == battle and battle.phase == "mimicSelect" then
prompt = "mimic"
elseif supported and top == battle and battle.phase == "menu" then
prompt = "menu"
elseif supported and top == battle and battle.phase == "moveSelect" then
prompt = "moves"
end
local party = {}
for i, mon in ipairs(game.save.party or {}) do
party[i] = monCopy(game.data, mon,
battle.player and battle.player.mon == mon)
party[i].slot = i
end
return { revision = self:_revision(battle, top), kind = kind,
catchable = catchable, prompt = prompt,
message = visibleMessage(battle, top), turn = battle.turnCount or 0,
player = monCopy(game.data, battle.player and battle.player.mon, true),
enemy = monCopy(game.data, battle.enemy and battle.enemy.mon, true),
party = party, moves = moveCopies(game, battle),
items = itemCopies(game, battle, catchable),
safariBalls = battle.safari and battle.safari.balls or nil,
mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex }
end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local battle, top = activeBattle(self.game)
if not battle then return nil, "no battle" end
if intent.revision ~= self:_revision(battle, top) then
return nil, "stale battle context"
end
local kind = battle:battleKind()
if kind == "oldman" or kind == "link" then
return nil, "battle kind is not controllable"
end
if top ~= battle then return nil, "battle menu is covered" end
local ok, err
if intent.kind == "safari" then
if kind ~= "safari" then return nil, "safari menu is not active" end
ok, err = battle:chooseSafari(intent.action)
elseif kind == "safari" then
return nil, "battle kind is not controllable"
elseif intent.kind == "mimic" then
ok, err = battle:chooseMimic(intent.index)
elseif intent.kind == "menu" then
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = battle:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if battle.phase ~= "moveSelect" then
return nil, "move menu is not active"
end
if battle.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.curMoves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle.player.disabledSlot == intent.slot then
return nil, "move is disabled"
end
ok, err = battle:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = battle:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI
+194 -120
View File
@@ -38,6 +38,7 @@ local romText = RomText
local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
BattleState.isBattleState = true
-- Category identity for per-category GAME SPEED (RFC 0007), the same
-- style OverworldController.isOverworld already uses. Every battle --
@@ -183,6 +184,13 @@ function BattleState:caughtMarkerVisible()
function() return false end, self) == true
end
function BattleState:catchChance(ball, rateOverride)
if Runtime.wantsHook("catch.rate") then return nil end
return Catching.chance(ball, self.enemy.mon, self.enemy.def, rateOverride,
{ ballDef = self:ballDef(ball), statuses = self.data.statuses,
battle = self })
end
function BattleState:moveGridNavigation()
if self:wideLayout() then return true end
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
@@ -391,21 +399,17 @@ local function grayImage(img)
return getImage(meta.path) or img
end
-- The blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs
-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black --
-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm:
-- 22-25). The mon pics are drawn OVER the zone pass with their palette
-- already baked in, so darkening them means re-baking through PAL_BLACK the
-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette
-- out of the active pack, exactly like sgbBattlePals, so the zone pass and
-- the pics can never disagree. trueColor art has no DMG shades to remap.
-- SET_PAL_BATTLE_BLACK re-bake of a pic (engine/battle/core.asm:1151,
-- engine/gfx/palettes.asm:22-25); resolved like sgbBattlePals' blackout (#292).
local function blackImage(data, img)
local meta = imageMeta[img]
if not meta or meta.trueColor then return img end
local PaletteFX = require("src.render.PaletteFX")
local pack = PaletteFX.pack(data)
local colors = pack and pack.palettes and pack.palettes.BLACK
if not colors then return img end
local pals = pack and pack.palettes
if not (pals and pals.BLACK) then return img end
local colors = PaletteFX.usesYellowCgb() and pals.BLACK
or PaletteFX.pal(data, "BLACK") or pals.BLACK
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
return getImage(meta.path, { name = name, colors = colors }) or img
end
@@ -606,7 +610,8 @@ end
local function stampOT(save, mon)
save.player.id = save.player.id or math.random(0, 65535)
mon.ot = mon.ot or save.player.name
mon.otId = mon.otId or save.player.id
-- engine/battle/experience.asm:69
if not mon.traded then mon.otId = mon.otId or save.player.id end
end
BattleState.stampOT = stampOT
@@ -1193,7 +1198,7 @@ function BattleState:startMessage(item)
local npos = text:find("[\n\v]", pos)
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
local codes = Font.encode(chunk)
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
self.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk }
self.total = self.total + #codes
if not npos then break end
cont = text:sub(npos, npos) == "\v"
@@ -1226,6 +1231,18 @@ function BattleState:beginMsgLine()
self.shown[#self.shown + 1] = {}
end
function BattleState:visibleText()
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
return nil
end
local out, count = {}, #(self.shown or {})
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
local line = self.lines and self.lines[i]
if line then out[#out + 1] = line.text or "" end
end
return #out > 0 and out or nil
end
function BattleState:updateQueue()
if self.waitingUI then
if self.game.stack:top() ~= self then return true end
@@ -1958,6 +1975,109 @@ function BattleState:playerHasPP()
return false
end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if not self.player or not self.player.mon or self.player.mon.hp <= 0
or self:menuLockedAction(self.player) then
return nil, "battle menu is not ready"
end
self:clearTurnFlinches()
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- A scared turn still ticks the player's residual effects.
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- Trapping, Bide, and similar locks skip the move list.
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
elseif not self:playerHasPP() then
-- No usable PP goes straight to Struggle.
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
else
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
end
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
local move = self.player.curMoves[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
if self.player.disabledSlot == index then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif move.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = index
self:resolveTurn(move)
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
function BattleState:chooseSafari(action)
if self.phase ~= "menu" or not self.safari then
return nil, "safari menu is not active"
end
if self.safari.balls <= 0 then return nil, "no safari balls remain" end
local index = SAFARI_ACTION_INDEX[action]
if not index then return nil, "invalid safari action" end
self.menuIndex = index
self:safariAction(action)
return true
end
function BattleState:chooseMimic(index)
if self.phase ~= "mimicSelect" then
return nil, "mimic menu is not active"
end
if type(index) ~= "number" or index % 1 ~= 0 then
return nil, "invalid mimic slot"
end
local pick = self.mimicMoves and self.mimicMoves[index]
local ctx = self.mimicCtx
if not pick or not ctx then return nil, "invalid mimic slot" end
self.mimicIndex = index
self.mimicMoves, self.mimicCtx = nil, nil
self.phase = "messages"
self.nextInsert = 0 -- the copy's anim + text go to the queue head
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
return true
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
@@ -2079,7 +2199,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
end
@@ -2127,42 +2247,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- the scared turn still ticks the player's residual (PrintGhostText
-- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279)
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
return
end
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
else
self:openParty()
end
self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
end
return
end
@@ -2191,8 +2276,7 @@ function BattleState:update(dt)
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
self:cancelMove()
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
@@ -2200,19 +2284,7 @@ function BattleState:update(dt)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif mv.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
self:chooseMove(self.moveIndex)
end
return
end
@@ -2235,12 +2307,7 @@ function BattleState:update(dt)
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local pick = moves[self.mimicIndex]
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
self.phase = "messages"
self.nextInsert = 0 -- the copy's anim + text go to the queue head
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
self:chooseMimic(self.mimicIndex)
end
return
end
@@ -2635,6 +2702,9 @@ function BattleState:residualFor(b, opp)
if self.result then return end
if self.player ~= b and self.enemy ~= b then return end
if b.mon.hp <= 0 or opp.mon.hp <= 0 then return end
-- engine/battle/core.asm:435-473
if b.residualDone then return end
b.residualDone = true
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if b.leechSeeded and b.mon.hp > 0 then
@@ -2691,7 +2761,8 @@ function BattleState:endOfTurn()
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
{ self.enemy, self.player, "enemy", playerAlive } }) do
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
if sweep and b.mon.hp > 0 and oppAlive then
if sweep and not b.residualDone and b.mon.hp > 0 and oppAlive then
b.residualDone = true
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
@@ -2705,6 +2776,7 @@ function BattleState:endOfTurn()
-- the Haze move-forfeit only covers the turn Haze was used; if the
-- cured mon had already moved, drop the flag before next turn
b.skipMove = nil
b.residualDone = nil
-- CheckNumAttacksLeft (core.asm:683-697): a trapping counter that
-- hit 0 this turn releases its bit only now, at the end of the turn
if b.trappingTurns and b.trappingTurns <= 0 then
@@ -3919,9 +3991,9 @@ function BattleState:onFaint(battler)
battler.fainted = true
local Sound = require("src.core.Sound")
if battler.isPlayer then
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
-- faint plays its ordinary species cry -- no Faint_Fall
self.faintCry = Sound.playCry(self.data, battler.mon.species)
-- RemoveFaintedPlayerMon: the species cry (core.asm:1040-1042),
-- PikachuCry4 on Yellow (engine/battle/core.asm:1058)
self.faintCry = Sound.playCry(self.data, battler.mon.species, 4)
elseif self.kind ~= "wild" then
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
@@ -5323,24 +5395,28 @@ function BattleState:sgbBattlePals()
local pack = PaletteFX.pack(self.data)
local pals = pack and pack.palettes
if not pals then return nil end
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK:
-- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of
-- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the
-- battle screen -- both HP bars and both mon regions -- goes dark behind
-- the blackout text. picImage re-bakes the pics through the same palette,
-- since those draw over the zone pass rather than through it (#292).
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK
-- (engine/gfx/palettes.asm:22-25); picImage re-bakes through it (#292).
if self.blackedOut and pals.BLACK then
local b = pals.BLACK
local b = PaletteFX.usesYellowCgb() and pals.BLACK
or PaletteFX.pal(self.data, "BLACK") or pals.BLACK
return { [0] = b, [1] = b, [2] = b, [3] = b }
end
-- home/palettes.asm:38
local function bar(b)
if not b then return pals.GREENBAR end
if not b then
return PaletteFX.pal(self.data, "GREENBAR") or pals.GREENBAR
end
local hp = b.shownHP or b.mon.hp
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
return PaletteFX.pal(self.data,
PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx))
or pals.GREENBAR
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
if placeholder or not b then
if PaletteFX.usesYellowCgb() then return pals.MEWMON or pals.GREENBAR end
return PaletteFX.pal(self.data, "MEWMON") or pals.MEWMON or pals.GREENBAR
end
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
local out = {
@@ -5349,23 +5425,6 @@ function BattleState:sgbBattlePals()
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
[3] = mon(self.enemy, self.showEnemyTrainer),
}
-- OG RED: the Game Boy Color drew the whole battle from one BG palette --
-- white paper, black ink -- so every zone shares the same background and
-- outline; only the two mid shades differ per element (green HP bar, red
-- mon pic). The bar/base zones otherwise carry the SGB off-white
-- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
out[i] = { white, c[2], c[3], black }
end
end
return out
end
@@ -5459,23 +5518,29 @@ function BattleState:drawZonePass(src, sx, sy)
love.graphics.setShader()
end
-- colors for one anim-layer OAM sprite at screen pixel (px, py): the
-- zone palette under that pixel's 8x8 attribute cell (the SGB colors
-- the composited picture per cell, so AnimPlayer samples once per cell
-- the tile overlaps), through the OBJ palette the routine ran with
-- (SetAnimationPalette: wAnimPalette = $f0 on SGB, rOBP1 = $6c,
-- ambient rOBP0 = $e4)
-- SetAnimationPalette (engine/battle/animations.asm:551): wAnimPalette = $f0
-- on SGB, $e4 otherwise; rOBP1 = $6c either way
local OBJ_SHADES = {
f0 = { 0, 3, 3 }, -- color 1 -> shade 0, colors 2/3 -> shade 3
f0x = { 3, 0, 3 }, -- $f0 xor %00111100 = $cc: the Master/Ultra ball
-- toss flicker (DoBallTossSpecialEffects)
f0x = { 3, 0, 3 }, -- $f0 xor %00111100 = $cc (DoBallTossSpecialEffects,
-- engine/battle/animations.asm:685)
e4 = { 1, 2, 3 }, -- identity
e4x = { 2, 1, 3 }, -- $e4 xor %00111100 = $d8
obp1 = { 3, 2, 1 }, -- $6c
}
function BattleState:animSpriteColors(s, px, py)
local P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
local PaletteFX = require("src.render.PaletteFX")
local key = s.obp or "f0"
local P
-- engine/battle/animations.asm:551 (.notSGB)
if PaletteFX.usesSpriteObp() then
P = PaletteFX.ogObj()
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
else
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
end
if not P then return nil end
local m = OBJ_SHADES[s.obp or "f0"] or OBJ_SHADES.f0
local m = OBJ_SHADES[key] or OBJ_SHADES.f0
local function c(shade)
local col = P[shade + 1]
return { col[1] / 255, col[2] / 255, col[3] / 255 }
@@ -5913,10 +5978,16 @@ function BattleState:drawTextArea()
Font.drawCode(Font.BORDER.h, 32, 96)
Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.player.curMoves) do
-- unknown ids (mod-injected moves) print raw instead of crashing
local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
-- engine/battle/misc.asm:37
for i = 1, 4 do
local mv = self.player.curMoves[i]
if mv then
-- unknown ids (mod-injected moves) print raw instead of crashing
local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
else
Font.draw("-", 48, 96 + i * 8)
end
end
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
@@ -5943,13 +6014,16 @@ function BattleState:drawTextArea()
end
end
elseif self.phase == "mimicSelect" then
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:
-- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names
-- single-spaced from (2,8), cursor at column 1
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517):
-- 16x6 box at (0,7), names from (2,8), cursor at column 1
Font.drawBox(0, 7, 16, 6)
love.graphics.setColor(0, 0, 0, 1)
for i, m in ipairs(self.mimicMoves) do
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
-- engine/battle/misc.asm:37
for i = 1, 4 do
local m = self.mimicMoves[i]
local def = m and self.data.moves[m.id]
Font.draw(m and (def and def.name or tostring(m.id)) or "-",
16, (7 + i) * 8)
end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
+29 -14
View File
@@ -27,6 +27,17 @@ Catching.BALLS = BALLS
-- divisor, which is what the old per-field `or` defaults resolved to
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
local function stockFactors(def, targetMon, targetDef, rateOverride, statuses)
local rate = rateOverride or targetDef.catchRate
local record = Status.recordFor(statuses, targetMon.status)
local statusBonus = record and record.catchBonus or 0
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
local f = math.min(255, math.floor(math.floor(
targetMon.stats.hp * 255 / factor) / hpQuarter))
return rate, statusBonus, f, record
end
function Catching.registerInto(registry, _, owner)
for id, record in pairs(BALLS) do
registry:register(id, record, owner)
@@ -41,21 +52,9 @@ end
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
if def.autoCatch then return true, 3 end
local randMax = def.randMax
local rate = rateOverride or targetDef.catchRate
-- the status subtraction and the wobble bonus come off the merged
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
local s = targetMon.status
local record = Status.recordFor(statuses, s)
local statusBonus = record and record.catchBonus or 0
-- HP factor (X)
local maxhp = targetMon.stats.hp
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
-- the intermediate in 16 bits); capping early collapses the value
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
local rate, statusBonus, f, record = stockFactors(
def, targetMon, targetDef, rateOverride, statuses)
local function shakes()
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
@@ -80,6 +79,22 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status
return false, shakes()
end
-- Exact stock catch probability for read-only previews. A custom attempt
-- function may do anything, so nil is safer than presenting a plausible lie.
function Catching.chance(ball, targetMon, targetDef, rateOverride, opts)
opts = opts or {}
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
if def.attempt then return nil end
if def.autoCatch then return 100 end
local rate, statusBonus, f = stockFactors(
def, targetMon, targetDef, rateOverride, opts.statuses)
local outcomes = def.randMax + 1
local automatic = math.min(outcomes, math.max(0, statusBonus))
local passed = math.min(outcomes, math.max(0, rate + statusBonus + 1))
return (automatic + (passed - automatic) * (f + 1) / 256)
* 100 / outcomes
end
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
-- opts (all optional): ballDef = the merged ball record, statuses = the
+22 -12
View File
@@ -83,25 +83,35 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
return rng(0, 255) < b
end
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still
-- misses on 255.
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
rng = rng or love.math.random
-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
-- threshold public lets read-only UIs preview the same rules the roll uses.
function Damage.accuracyThreshold(ruleset, move, attacker, defender)
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
-- (MoveHitTest returns before any accuracy math, 1/256 included)
if attacker.xAccuracy then return true end
if attacker.xAccuracy then return 256 end
local acc = math.floor(move.accuracy * 255 / 100)
local accuracyStage = attacker.stages and attacker.stages.accuracy or 0
local evasionStage = defender.stages and defender.stages.evasion or 0
-- CalcHitChance scales by the accuracy stage and the evasion stage as
-- two separate ratio multiplications, clamping each result
acc = math.min(255, Stats.applyStage(acc,
attacker.stages and attacker.stages.accuracy or 0))
acc = math.min(255, Stats.applyStage(acc,
-(defender.stages and defender.stages.evasion or 0)))
acc = math.min(255, Stats.applyStage(acc, accuracyStage))
acc = math.min(255, Stats.applyStage(acc, -evasionStage))
if not ruleset.oneIn256Miss and move.accuracy >= 100
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then
return true
and accuracyStage >= evasionStage then
return 256
end
return acc
end
function Damage.accuracyChance(ruleset, move, attacker, defender)
return Damage.accuracyThreshold(ruleset, move, attacker, defender) * 100 / 256
end
-- Accuracy test: rand(0..255) < the shared ruleset-aware threshold.
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
rng = rng or love.math.random
local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender)
if acc == 256 then return true end
return rng(0, 255) < acc
end
+5 -5
View File
@@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
hitSfx = { sound = "Damage", pitch = 0x20 }
end
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
-- the screen vertically) for a damaging move with no added effect, and
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
-- Bubblebeam and Confusion shake instead of blinking (#354)
-- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once
-- the move has an added effect (#354)
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
-- PlayApplyingAttackAnimation runs on both arms of the wOptions check
-- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384)
local hitFx = { sfx = hitSfx,
animType = user.isPlayer and (added and 5 or 4)
or (added and 2 or 1),
blink = battle:animationsOn() and target or nil }
blink = target }
local totalDealt = 0
local landed, brokeSub = 0, false
+7 -13
View File
@@ -192,14 +192,8 @@ function Runner:loadGfx(names)
end
end
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
-- APPENDED after whatever the script already loaded rather than replacing it,
-- and they always land on the same two fixed tile ids.
--
-- (pokegold's jumptable has these two labels the other way round from the
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
-- is what a script actually writes.)
-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro
-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401)
function Runner:loadBattlerGfx(rows)
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
local slot = 1
@@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows)
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
battler = "player", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7,
battler = "enemy", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6,
battler = "player", rows = rows }
end
--------------------------------------------------------------------------
@@ -279,7 +273,7 @@ end
C.dropsub = function(self)
local side = self.env.battleTurn == 0 and "player" or "enemy"
self.picOverride[side] = nil
self.picOverride[side] = false
end
-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the
+99 -37
View File
@@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = {
EFFECT_PARALYZE_HIT = "paralyze",
EFFECT_SLEEP_HIT = "sleep",
EFFECT_CONFUSE_HIT = "confuse",
EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696
}
local function rand(random, n)
@@ -1320,6 +1321,18 @@ function Battle:markMissed()
if self.moveEvent then self.moveEvent.missed = true end
end
-- engine/battle/effect_commands.asm:3615
Battle.AI_FAIL_STATUSES = {
sleep = true, poison = true, toxic = true, paralyze = true,
}
-- engine/battle/effect_commands.asm:3615
function Battle:aiRandomFail(attacker, defender)
if self:sideOf(attacker) ~= "enemy" then return false end
if self:volatile(defender).lockOn then return false end
return rand(self.random, 256) < 64
end
-- One attack, start to finish.
function Battle:useMove(attacker, defender, moveId)
local move = self:findMove(attacker, moveId)
@@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId)
if charge and not charging then
state.chargeMove = moveId
state.vanished = charge.vanish or nil
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table
-- keyed by effect cannot tell them apart and DIG announced itself with
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the
-- effect: `cp DIG` and then the burrow text.
-- engine/battle/effect_commands.asm:5458
if self.moveEvent then self.moveEvent.animParam = 1 end
-- BattleCommand_Charge picks the line off the MOVE, not the shared
-- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464).
local text = charge.text
if moveId == "DIG" then text = "%s dug a hole!" end
self:emit({ kind = "message", text = text:format(name) })
@@ -1517,10 +1530,10 @@ function Battle:useMove(attacker, defender, moveId)
-- read and cleared by the very next move aimed at it, and while it is up the
-- accuracy roll does not happen at all.
local locked = self:consumeLockOn(defender)
-- SUBSTATUS_X_ACCURACY (an X ACCURACY) grants the same roll bypass,
-- CheckHit's `.XAccuracy` arm, but is not consumed: it lasts until the
-- switch drops the volatile.
-- CheckHit's .XAccuracy and EFFECT_ALWAYS_HIT arms
-- (effect_commands.asm:1572-1579).
local sureHit = locked or self:volatile(attacker).xAccuracy == true
or def.effect == "EFFECT_ALWAYS_HIT"
-- .LockOn runs ahead of .FlyDigMoves and returns a HIT unless the target is
-- flying and the move is one of the three (effect_commands.asm:1563-1567,
@@ -1723,9 +1736,14 @@ function Battle:useMove(attacker, defender, moveId)
text = ("Hit %d time(s)!"):format(landed) })
end
-- Recoil is a quarter of what was dealt; drain heals half of it. Dream
-- Eater's sleep requirement is checkhit's, not this block's, so by the
-- time a drain is paid out the target is known to have been asleep.
-- move_effects/pay_day.asm:13
if def.effect == "EFFECT_PAY_DAY" and dealt > 0 then
self.payDay = (self.payDay or 0) + 2 * (attacker.level or 1)
self:emit({ kind = "message",
text = Strings("Coins scattered\neverywhere!") })
end
-- Recoil is a quarter of what was dealt; drain heals half of it.
if def.effect == "EFFECT_RECOIL_HIT" and dealt > 0 then
local recoil = Effects.recoilDamage(dealt)
attacker.hp = math.max(0, (attacker.hp or 0) - recoil)
@@ -1806,20 +1824,20 @@ function Battle:useMove(attacker, defender, moveId)
-- Defense Curl arms Rollout as well as raising Defense.
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
-- Stat changes: the primary ones always land, the *_HIT ones roll the
-- move's effect chance after a hit that connected.
--
-- A refused primary change is a failure the cart detects BEFORE its anim
-- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` /
-- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400),
-- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER
-- `attackup` / `attackdown` in the effect list (data/moves/effects.asm,
-- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs
-- after `moveanim` (AttackDownHit), so the animation has already played.
-- A refused primary change writes wAttackMissed (effect_commands.asm:4191,
-- :4380-4400); the *_HIT twins animate first and must stay unmarked.
local change = Effects.STAT_CHANGES[def.effect]
if change then
local target = change[3] == "self" and attacker or defender
if not self:changeStageAgainstMist(attacker, target, change[1], change[2])
-- CheckMist first (effect_commands.asm:4290), then .ComputerMiss (:4318)
local misted = target ~= attacker and (change[2] or 0) < 0
and self:volatile(target).mist
if not misted and change[3] == "foe"
and def.effect ~= "EFFECT_ACCURACY_DOWN_HIT"
and self:aiRandomFail(attacker, target) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
elseif not self:changeStageAgainstMist(attacker, target, change[1], change[2])
then
self:markMissed()
end
@@ -1848,19 +1866,21 @@ function Battle:useMove(attacker, defender, moveId)
local record = Battle.moveEffectRecordFor(self.data, def.effect)
local status = record and record.kind == "primary" and record.status or nil
if status and (def.power or 0) == 0 then
-- Every status command's already-statused / immune arm ends on
-- AnimateFailedMove (BattleCommand_Poison's `.failed`,
-- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay,
-- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the
-- success path (:3752), and the effect scripts carry no moveanim of their
-- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS
-- branch below is the opposite case: that move already hit and already
-- animated, so a refused secondary must leave the event unmarked.
if not self:applyStatus(defender, status, attacker) then self:markMissed() end
-- A refused primary status is a failed move (effect_commands.asm:3748,
-- :6656); a refused secondary already animated and stays unmarked (:3752).
if Battle.AI_FAIL_STATUSES[status]
and self:aiRandomFail(attacker, defender) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
elseif not self:applyStatus(defender, status, attacker) then
self:markMissed()
end
else
local secondary = record and record.kind == "secondary"
and record.status or nil
if secondary and (defender.hp or 0) > 0 then
-- engine/battle/effect_commands.asm:6325
if secondary and (defender.hp or 0) > 0
and not self:safeguarded(defender) then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
self:applyStatus(defender, secondary, attacker)
@@ -2420,6 +2440,15 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
text = self:monName(attacker) .. "'s DEFENSE rose!" })
end
-- engine/battle/move_effects/safeguard.asm:1
Battle.MOVE_EFFECTS.EFFECT_SAFEGUARD = function(self, attacker)
local side = self.screens[self:sideOf(attacker)]
if (side.safeguard or 0) > 0 then return fail(self) end
side.safeguard = Battle.SCREEN_TURNS
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s covered by a veil!" })
end
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
-- one body. A non-Ghost user trades a stage of Speed for one each of Attack
-- and Defense, refused only when BOTH raises are already capped; a Ghost
@@ -2905,6 +2934,11 @@ function Battle.statusPenaltyFor(data, mon, stat, value)
return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
end
-- engine/battle/effect_commands.asm:6325
function Battle:safeguarded(mon)
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
function Battle:applyStatus(mon, status, source)
@@ -2912,7 +2946,14 @@ function Battle:applyStatus(mon, status, source)
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
-- in the volatile beside the major status, so a confused mon can still be
-- burned and a switch shakes the confusion off.
if status == "confuse" then return self:applyConfusion(mon) end
if status == "confuse" then return self:applyConfusion(mon, nil, source) end
-- engine/battle/effect_commands.asm:6338
if source and self:sideOf(source) ~= self:sideOf(mon)
and self:safeguarded(mon) then
self:emit({ kind = "message",
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
return false
end
-- One major status at a time.
if mon.status then
self:emit({ kind = "message",
@@ -2948,8 +2989,15 @@ end
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
Battle.BERSERK_GENE_CONFUSE_TURNS = 256
function Battle:applyConfusion(mon, turns)
function Battle:applyConfusion(mon, turns, source)
if (mon.hp or 0) <= 0 then return false end
-- engine/battle/effect_commands.asm:6338
if source and self:sideOf(source) ~= self:sideOf(mon)
and self:safeguarded(mon) then
self:emit({ kind = "message",
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
return false
end
local state = self:volatile(mon)
if (state.substitute or 0) > 0 then return false end
local held = self:heldEffect(mon, "confuse")
@@ -3012,6 +3060,14 @@ function Battle:resolveFaints()
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
self:awardPrizeMoney()
end
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
-- :8014-8042).
local coins = Prize.payDay(self.save, self.payDay, self.amuletCoin)
if coins then
self:emit({ kind = "money", text = Prize.payDayMessage(coins,
self.save.player and self.save.player.name) })
end
self.payDay = nil
self:endBattle("win")
return true
end
@@ -4421,14 +4477,20 @@ Battle.SCREEN_FALL_TEXT = {
function Battle:tickScreens()
for _, side in ipairs({ "player", "enemy" }) do
local screens = self.screens[side]
for _, field in ipairs({ "lightScreen", "reflect" }) do
for _, field in ipairs({ "lightScreen", "reflect", "safeguard" }) do
if (screens[field] or 0) > 0 then
screens[field] = screens[field] - 1
if screens[field] <= 0 then
screens[field] = nil
self:emit({ kind = "message",
text = Battle.SCREEN_SIDE_LABEL[side]
.. Battle.SCREEN_FALL_TEXT[field] })
if field == "safeguard" then
-- engine/battle/core.asm:1527
self:emit({ kind = "message",
text = self:monName(self[side]) .. "'s SAFEGUARD faded!" })
else
self:emit({ kind = "message",
text = Battle.SCREEN_SIDE_LABEL[side]
.. Battle.SCREEN_FALL_TEXT[field] })
end
end
end
end
+172
View File
@@ -0,0 +1,172 @@
-- Read-only Gen 2 battle state with the same shape as mod.battle on Gen 1.
local BattleAPI = {}
BattleAPI.__index = BattleAPI
function BattleAPI.new(game)
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
end
local function activeBattle(game)
local states = game and game.stack and game.stack.states or {}
local battle
for i = #states, 1, -1 do
local state = states[i]
if state.screenId == "Gen2BattleState" or state.isGen2BattleState then
battle = state
break
end
end
return battle, states[#states]
end
local function monCopy(data, mon, active)
if not mon then return nil end
local def = data and data.pokemon and data.pokemon[mon.species]
return { species = mon.species,
name = mon.nickname or (def and def.name) or mon.species,
level = mon.level, hp = mon.hp,
maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or mon.hp,
status = mon.status, active = active and true or false }
end
local function messageCopy(screen)
if not screen.message then return nil end
local lines = {}
for line in tostring(screen.message):gmatch("[^\n]+") do
lines[#lines + 1] = line
end
return #lines > 0 and lines or nil
end
local function signature(game, screen, top)
if not screen then return "none" end
local battle = screen.battle or {}
local parts = { tostring(screen), tostring(top), tostring(screen.phase),
tostring(screen.message), tostring(screen.messageTimer),
tostring(screen.menuIndex), tostring(screen.moveIndex),
tostring(battle.turn), tostring(battle.over), tostring(battle.outcome) }
for _, mon in ipairs({ battle.player, battle.enemy }) do
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon and mon.hp)
parts[#parts + 1] = tostring(mon and mon.status)
end
for _, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon.hp)
parts[#parts + 1] = tostring(mon.status)
end
return table.concat(parts, "|")
end
function BattleAPI:_revision(screen, top)
local nextSignature = signature(self.game, screen, top)
if nextSignature ~= self.signature then
self.signature = nextSignature
self.revision = self.revision + 1
end
return self.revision
end
local function moveCopies(game, battle)
local out = {}
for slot, move in ipairs((battle.player and battle.player.moves) or {}) do
local def = (game.data.moves or {})[move.id] or {}
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
pp = move.pp, maxPp = move.maxPp or def.pp or move.pp,
type = def.type, power = def.power, accuracy = def.accuracy,
disabled = battle:moveDisabled(battle.player, move.id) }
end
return out
end
local function battleKind(screen)
if screen.tutorial then return "oldman" end
return screen.battle and screen.battle.wild and "wild" or "trainer"
end
function BattleAPI:snapshot()
local game = self.game
local screen, top = activeBattle(game)
if not screen or not screen.battle then return nil end
local battle = screen.battle
local prompt = "locked"
if top == screen and screen.phase == "menu" then
prompt = "menu"
elseif top == screen and screen.phase == "moves" then
prompt = "moves"
elseif top == screen and screen.message then
prompt = "advance"
elseif top and top.screenId == "Gen2PartyMenu" then
prompt = "party"
end
local party = {}
for i, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
party[i] = monCopy(game.data, mon, mon == battle.player)
party[i].slot = i
end
return { revision = self:_revision(screen, top), kind = battleKind(screen),
catchable = battle.wild and not screen.tutorial, prompt = prompt,
message = messageCopy(screen), turn = battle.turn or 0,
player = monCopy(game.data, battle.player, true),
enemy = monCopy(game.data, battle.enemy, true),
party = party, moves = moveCopies(game, battle),
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it
-- until the engine can expose the same semantic item records as Gen 1.
items = {} }
end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local screen, top = activeBattle(self.game)
if not screen or not screen.battle then return nil, "no battle" end
if intent.revision ~= self:_revision(screen, top) then
return nil, "stale battle context"
end
if screen.tutorial then return nil, "battle kind is not controllable" end
if top ~= screen then return nil, "battle menu is covered" end
local battle = screen.battle
local ok, err
if intent.kind == "menu" then
if screen.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = screen:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if screen.phase ~= "moves" then return nil, "move menu is not active" end
if screen.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.moves and battle.player.moves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle:moveDisabled(battle.player, move.id) then
return nil, "move is disabled"
end
ok, err = screen:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = screen:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI
+12 -10
View File
@@ -72,11 +72,11 @@ function Pool:reset()
for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end
-- wBGP / wOBP0 / wOBP1, as DMG palette bytes.
self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL
-- Per-battler state the CGB paths write instead of touching wBGP: a DMG
-- shade byte the view remaps that battler's pic through, whether the pic is
-- hidden outright, and which of the six BG squares it is drawn at.
-- Per-battler state the CGB paths write instead of touching wBGP: shade
-- byte, hidden flag, lifted tile rows, and which BG square it is drawn at.
self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL }
self.hidden = { player = false, enemy = false }
self.liftedRows = { player = nil, enemy = nil }
self.picSize = { player = nil, enemy = nil }
self.slide = { player = 0, enemy = 0 }
-- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the
@@ -488,6 +488,7 @@ local function runPicResize(self, st, script)
self.picSize[side] = step
self.hidden[side] = false
end
self.liftedRows[side] = nil
incJt(st)
elseif jt >= 1 and jt <= 2 then
incJt(st)
@@ -545,7 +546,7 @@ end
-- The two battler-pic objects: the animation borrows the mon's own tiles as
-- an OBJ so it can be moved without touching the tilemap.
local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
local function battlerObj(self, st, objectPlayer, objectEnemy, rows)
local jt = st.jt
if jt == 0 then
if self:flyDig(st) then
@@ -562,25 +563,26 @@ local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
}
elseif jt == 1 then
incJt(st)
-- The rows the OBJ now covers are cleared out of the tilemap so the mon
-- is not drawn twice.
self.hidden[self:sideKey(st)] = clearRows
-- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers
-- come out of the tilemap, and .five never puts them back.
self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)]
elseif jt >= 2 and jt <= 4 then
incJt(st)
elseif jt == 5 then
self.hidden[self:sideKey(st)] = false
endEffect(st)
end
end
E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st)
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW",
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true)
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
{ player = { 0, 1 }, enemy = { 6, 1 } })
end
E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st)
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true)
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW",
{ player = { 0, 2 }, enemy = { 5, 2 } })
end
-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler
+15
View File
@@ -66,6 +66,8 @@ local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!")
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
local SENT_HALF = Strings.source("Sent half to MOM!")
local SENT_ALL = Strings.source("Sent all to MOM!")
-- BattleText_PlayerPickedUpPayDayMoney (data/text/battle.asm:3-8).
local PICKED_UP = Strings.source("%s picked up %s%d!")
-- charmap.asm: the currency glyph, the same one Chrome.money floats in front
-- of a six-digit field.
@@ -220,4 +222,17 @@ function Prize.message(award, playerName)
return Strings(GOT_MONEY, name, YEN, total)
end
-- CheckPayDay (engine/battle/core.asm:8014-8042): the Amulet Coin doubles the
-- accumulated total once, and the wallet is written directly, no Mom split.
function Prize.payDay(save, amount, amuletCoin)
if not (save and save.player) or (amount or 0) <= 0 then return nil end
if amuletCoin then amount = amount * 2 end
setPlayerMoney(save, addToAccount(playerMoney(save), amount))
return amount
end
function Prize.payDayMessage(amount, playerName)
return Strings(PICKED_UP, playerName or "PLAYER", YEN, amount or 0)
end
return Prize
+7 -1
View File
@@ -6,6 +6,7 @@ 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 GameViewport = require("src.render.GameViewport")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls")
@@ -484,6 +485,7 @@ local function centerClassicZones(zones, offset)
end
function Game:draw()
GameViewport.begin(1)
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
-- white clear
@@ -597,7 +599,9 @@ function Game:draw()
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
GameViewport.finish(self)
-- OS-window chrome: keep the pad full-size and above any composed companion
-- view instead of capturing and shrinking it with the game viewport.
TouchControls:draw()
end
@@ -973,8 +977,10 @@ local function pointerUnclaimed() return false end
-- coordinates are LOVE window units, the same space render.hud's viewport
-- and the touch overlay lay out in
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
})
end
+33 -20
View File
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
-- The mod event/hook buses. Gold reaches them through Runtime like every
-- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua.
@@ -73,8 +74,8 @@ end
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
-- hooks below never used to fire here; it is not a reason they should not. A
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
-- below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
-- PAYLOAD, at the Gen 1 point in the order:
@@ -86,6 +87,8 @@ end
-- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
-- render.window final OS-window composition (GameViewport.lua:145)
--
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
-- world pass and the UI into ONE canvas, not two -- the call site says so and
@@ -730,7 +733,7 @@ function Game2:usePartyItem(itemId)
local row = ItemEffects.RESTORE_PP[itemId] or {}
if row.each or mon.isEgg then
self.stack:pop()
finish(ItemEffects.usePpItem(itemId, mon), mon)
finish(ItemEffects.usePpItem(itemId, mon, nil, self.data), mon)
return
end
Screens.push(self, "Gen2MoveDeleter", {
@@ -740,7 +743,7 @@ function Game2:usePartyItem(itemId)
onChoose = function(slot)
self.stack:pop() -- the move list
self.stack:pop() -- the party list
finish(ItemEffects.usePpItem(itemId, mon, slot), mon)
finish(ItemEffects.usePpItem(itemId, mon, slot, self.data), mon)
end,
})
end,
@@ -888,6 +891,12 @@ function Game2:load()
self.data.items = loadGenerated("data/generated/items.lua") or {}
self.data.moves = loadGenerated("data/generated/moves.lua") or {}
self.data.type_chart = loadGenerated("data/generated/type_chart.lua") or {}
-- data/types/type_matchups.asm:112-116: the rows after the `db -2` marker
-- apply by default; Foresight is what cuts the table short at it.
local chart = self.data.type_chart
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
-- The `held_items` registry's merge target: ItemAttributes' last two columns
-- as their own table, so a mod can give an item a held behaviour without
-- owning the whole item record. Built BEFORE mods:load so the registry
@@ -1191,9 +1200,7 @@ function Game2:frameFit(w, h)
dpi = tonumber(love.window.getDPIScale()) or 1
end
local pw, ph = w * dpi, h * dpi
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
pw, ph = GameViewport.pixelDimensions()
return scale, ox, oy, dpi, pw, ph
end
@@ -1211,18 +1218,15 @@ function Game2:viewport(w, h)
}
end
-- The screen-space layer, in the Gen 1 order: render.hud and then the
-- on-screen pad (src/core/Game.lua:521 and :524, either side of
-- Renderer:endFrame). Both are window-space, both sit over the finished
-- frame -- post passes, letterbox and all -- and neither ever enters the game
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
-- true of the composed frame a mod owns as well as of the plain one.
-- The render.hud layer, in Gen 1's order over the finished game frame. The
-- on-screen pad is drawn separately after GameViewport.finish, because it is
-- OS-window chrome and must not be captured or scaled with this canvas.
--
-- render.hud: persistent tool status. The call is fenced with
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
-- mod render callback: a subscriber that returns cleanly but leaves a shader
-- bound, the canvas redirected or the colour changed must not corrupt the next
-- frame -- or, now, the pad drawn immediately after it.
-- frame.
function Game2:drawHud(w, h)
if ModRuntime.wantsHook("render.hud") then
local G = love.graphics
@@ -1230,10 +1234,6 @@ function Game2:drawHud(w, h)
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
G.pop()
end
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
-- forces it, and nothing ever while a controller is in use.
TouchControls:draw()
end
-- render.letterbox: SGB borders and custom void art in the bars around the
@@ -1357,9 +1357,9 @@ end
-- is being shown on. Mod post-processes fold in between the two, where
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
-- is then drawn over, rather than something that smears the grid itself.
function Game2:draw()
function Game2:drawViewportFrame()
local G = love.graphics
local w, h = G.getDimensions()
local w, h = GameViewport.dimensions()
local GBCFX = require("src.render.GBCFX")
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
@@ -1471,6 +1471,16 @@ function Game2:draw()
self:drawHud(w, h)
end
function Game2:draw()
GameViewport.begin(2)
GameViewport.setTarget()
self:drawViewportFrame()
GameViewport.finish(self)
-- OS-window chrome: draw after companion composition so viewport layouts
-- neither shrink nor cover the touch pad.
TouchControls:draw()
end
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
@@ -1797,8 +1807,10 @@ end
-- coordinates are LOVE window units, the same space render.hud's viewport is in
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
})
end
@@ -1949,6 +1961,7 @@ function Game2:applyOptions()
touchControls = options.touchControls,
haptics = options.haptics,
})
require("src.core.VideoMode").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
+74 -10
View File
@@ -272,6 +272,38 @@ function HostShell.quote(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- Launch another instance of this packaged app without waiting for it. The
-- same path works on all process-capable desktop hosts; only the shell's
-- background spelling differs. Source checkouts include their game folder,
-- while fused releases and AppImages already carry it in the executable.
function HostShell.spawnSelfDetached(args)
if not require("src.core.Platform").canSpawnProcess() then return false end
local fs = love and love.filesystem
if not (fs and fs.getExecutablePath) then return false end
local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
if type(executable) ~= "string" or executable == "" then return false end
local argv = {}
local fused = fs.isFused and fs.isFused()
if not os.getenv("APPIMAGE") and not fused and fs.getSource then
argv[#argv + 1] = fs.getSource()
end
for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
local command = HostShell.quote(executable)
for _, value in ipairs(argv) do
command = command .. " " .. HostShell.quote(value)
end
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Windows" then
command = 'start "" /b ' .. command .. " >NUL 2>&1"
else
command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
end
local ok, _, code = os.execute(command)
return ok == true or ok == 0 or code == 0
end
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
-- `curl --version` process on every single fetch -- twice for a GET through
-- the Android-bridge fallback -- which doubled the number of spawns the lock
@@ -428,10 +460,43 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if type(body) ~= "string" then return nil, "missing body" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
-- --data-binary @- keeps the payload out of argv (command-line length
-- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never
-- "rw". Stage the request body so the response can stay on a read
-- pipe. The staging directory comes from the OS temp contract, never
-- tmpnam(): the CRT's tmpnam() can return a name relative to the process
-- working directory, and a game installed under Program Files has no
-- writable CWD -- io.open would fail before curl ever runs and postLog
-- would silently drop the send. TEMP/TMP are per-user writable on
-- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem:
-- the sandbox-era transport stays on plain io/os.
local function stagingPath()
local dir = os.getenv("TEMP") or os.getenv("TMP")
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
local sep = dir:find("\\") and "\\" or "/"
return dir .. sep .. ("gen1recomp-post-%d.tmp"):format(
(os.time() % 1000000) * 100 + math.random(0, 99))
end
local bodyPath = stagingPath()
local bodyFile, bodyOpenErr = io.open(bodyPath, "wb")
if not bodyFile then
pcall(os.remove, bodyPath)
return nil, "could not create request body: " .. tostring(bodyOpenErr)
end
local bodyOk, bodyErr = pcall(function()
assert(bodyFile:write(body))
assert(bodyFile:close())
end)
if not bodyOk then
pcall(function() bodyFile:close() end)
pcall(os.remove, bodyPath)
return nil, "could not write body: " .. tostring(bodyErr)
end
-- --data-binary @<file> keeps the payload out of argv (command-line length
-- limits on Windows) and preserves every byte including trailing
-- newlines. No -f, matching httpGet: the response body is discarded
-- anyway, and curl's stderr carries the real diagnosis on failure.
-- newlines. The body is staged above because io.popen cannot be opened
-- for both writing and reading. No -f, matching httpGet: the response
-- body is discarded anyway, and curl's stderr carries the diagnosis.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
@@ -441,18 +506,17 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
end
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
.. "--data-binary @- "
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd, "rw")
if not pipe then return nil, "could not run curl" end
local writeOk, werr = pcall(pipe.write, pipe, body)
if not writeOk then
HostShell.pclose(pipe)
return nil, "could not write body: " .. tostring(werr)
local pipe = HostShell.popen(cmd)
if not pipe then
pcall(os.remove, bodyPath)
return nil, "could not run curl"
end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
pcall(os.remove, bodyPath)
if not readOk then
return nil, fetchError(url, nil, tostring(out))
end
+41 -10
View File
@@ -130,12 +130,23 @@ local SPECIAL = {
evolution = "Music_SafariZone",
}
-- SpecialMapMusic (pokegold home/audio.asm:397)
local SPECIAL_GEN2 = {
surf = "Music_Surf",
bike = "Music_Bicycle",
evolution = "Music_Evolution",
}
-- the label a scene role resolves to; call sites keep their own presence
-- guard on the resolved label
function Music.special(data, key)
local special = data and data.audio and data.audio.special
local label = special and special[key]
if label ~= nil then return label end
if data and data.audio and data.audio.generation == 2
and SPECIAL_GEN2[key] ~= nil then
return SPECIAL_GEN2[key]
end
return SPECIAL[key]
end
@@ -192,6 +203,12 @@ local function startSong(data, def, wantLoop)
return nil, nil, nil, "no chip program and no file"
end
-- Music_MeetRival_Ch{1,2,3}_AlternateStart (audio/alternate_tempo.asm:7)
local RIVAL_ALT_START = {
redblue = { 0x71a2, 0x721d, 0x72b5 },
yellow = { 0x7075, 0x70f0, 0x7188 },
}
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
@@ -219,8 +236,17 @@ function Music.play(data, song, loop, ctx)
local start = ctx and ctx.start or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo
and start == state.start) then return end
if not song then return end
if song == state.current and tempo == state.tempo
and start == state.start then
-- ..(home/audio.asm ln 65)
if state.fade and state.fade.pending then
state.fade = nil
applyVolume(state.source)
applyVolume(state.loopSource)
end
return
end
local def = songDef(data, song)
if not def or state.failed[song] then return end
@@ -247,10 +273,12 @@ function Music.play(data, song, loop, ctx)
and def.bank == 2 and def.address == 17050 then
local started = {}
for key, value in pairs(def) do started[key] = value end
local alt = require("src.core.GameVersion").isYellow()
and RIVAL_ALT_START.yellow or RIVAL_ALT_START.redblue
started.startChannels = {
{ number = 1, address = 0x71a2 },
{ number = 2, address = 0x721d },
{ number = 3, address = 0x72b5 },
{ number = 1, address = alt[1] },
{ number = 2, address = alt[2] },
{ number = 3, address = alt[3] },
}
def = started
end
@@ -340,9 +368,12 @@ end
Music.MAP_FADE = 10
-- the song a map should currently play, honoring the bike/surf overrides
-- the song a map should currently play, honoring the bike/surf overrides;
-- Gen 2 has no outdoor gate (pokegold home/audio.asm:437)
local function effectiveMapSong(data, song)
if not song or not outdoorSongs(data)[song] then return song end
if not song then return song end
local gen2 = data and data.audio and data.audio.generation == 2
if not gen2 and not outdoorSongs(data)[song] then return song end
if state.onBike then
local bike = Music.special(data, "bike")
if bike and songDef(data, bike) then return bike end
@@ -356,9 +387,9 @@ end
-- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing, fade)
local song = data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil
function Music.playMap(data, mapId, onBike, surfing, fade, song)
song = song or (data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId]) or nil
state.mapSong = song
state.onBike = not not onBike
state.surfing = not not surfing
+8 -2
View File
@@ -7,12 +7,14 @@
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local GameViewport = require("src.render.GameViewport")
local SafeArea = {}
function SafeArea.rect()
function SafeArea.windowRect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
ww, wh = GameViewport.fullDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
@@ -55,4 +57,8 @@ function SafeArea.rect()
return x, y, w, h
end
function SafeArea.rect()
return GameViewport.localSafeRect(SafeArea.windowRect())
end
return SafeArea
+7 -7
View File
@@ -339,7 +339,7 @@ end
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket()
local _, _, sw, sh = SafeArea.rect()
local _, _, sw, sh = SafeArea.windowRect()
local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o]
@@ -363,7 +363,7 @@ end
-- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633).
function TouchControls:layout()
local ox, oy, sw, sh = SafeArea.rect()
local ox, oy, sw, sh = SafeArea.windowRect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
@@ -397,7 +397,7 @@ end
-- Move one control to a screen-space point and persist its normalized
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ox, oy, sw, sh = SafeArea.rect()
local ox, oy, sw, sh = SafeArea.windowRect()
local L = self:layout()
local zone = L[name]
if not zone then return end
@@ -608,10 +608,10 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher
-- layout editor under preview mode.
-- OS-window space, called after GameViewport.finish so the overlay rides on
-- top of the game, companion composition and post-processing without being
-- captured or scaled with any game viewport. Also used by the launcher layout
-- editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
+68 -5
View File
@@ -58,6 +58,12 @@ ItemEffects.RESTORE_PP = {
MAX_ELIXER = { amount = "all", each = true },
}
-- engine/items/item_effects.asm:1245 StatExpItemPointerOffsets.
ItemEffects.VITAMIN = {
HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
CARBOS = "speed", CALCIUM = "special",
}
-- EnergypowderEnergyRootCommon / HealPowderEffect: the herb items charge
-- happiness for tasting bitter on top of their heal.
local BITTER = {
@@ -70,6 +76,9 @@ ItemEffects.TEXT_NO_EFFECT = "It won't have any\neffect."
ItemEffects.TEXT_CANT_USE_ON_EGG = "That can't be used\non an EGG."
-- _PPRestoredText (data/text/common_3.asm).
ItemEffects.TEXT_PP_RESTORED = "PP was restored."
-- _PPIsMaxedOutText / _PPsIncreasedText (data/text/common_3.asm).
ItemEffects.TEXT_PP_MAXED = "%s's PP\nis maxed out."
ItemEffects.TEXT_PP_INCREASED = "%s's PP\nincreased."
-- PrintPartyMenuActionText's .MenuActionTexts (engine/pokemon/party_menu.asm),
-- keyed by the class GetItemHealingAction resolves. Each is the two rows the
@@ -235,6 +244,33 @@ local function rareCandy(mon, data)
}
end
-- engine/items/item_effects.asm:1216 StatStrings.
local VITAMIN_LABEL = {
hp = "HEALTH", attack = "ATTACK", defense = "DEFENSE",
speed = "SPEED", special = "SPECIAL",
}
-- engine/items/item_effects.asm:1149 VitaminEffect.
local function vitamin(itemId, mon, data)
local stat = ItemEffects.VITAMIN[itemId]
mon.statExp = mon.statExp or Mon.newStatExp()
local cur = mon.statExp[stat] or 0
if cur >= 25600 then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
mon.statExp[stat] = math.min(Mon.MAX_STAT_EXP, cur + 2560)
local def = data and data.pokemon and data.pokemon[mon.species]
if def and def.baseStats then
mon.stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp)
mon.maxHp = mon.stats.hp
end
Happiness.change(mon, "USEDITEM")
return {
used = true,
text = ("%s's\n%s rose."):format(monName(mon), VITAMIN_LABEL[stat]),
}
end
-- --------------------------------------------------------- held attributes
--
-- The `held_items` registry (src/mods/Schemas.lua), which is the last two
@@ -341,11 +377,8 @@ function ItemEffects.recordFor(itemId, data)
return (merged and merged[itemId]) or ItemEffects.RECORDS[itemId]
end
-- Which family a PACK item runs on a party mon, or nil for anything whose
-- ITEMMENU_PARTY behaviour is not ported (vitamins, PP UP, evolution stones).
-- FULL_RESTORE classifies as "heal"; its full-HP status arm lives inside the
-- record's own `use` the way FullRestoreEffect keeps both halves in one
-- routine.
-- Which family a PACK item runs on a party mon, or nil for an id with no
-- item_effects record (engine/items/pack.asm UseItem, ITEMMENU_PARTY arm).
function ItemEffects.partyAction(itemId, data)
local record = ItemEffects.recordFor(itemId, data)
return record and record.action or nil
@@ -446,6 +479,36 @@ for itemId, row in pairs(ItemEffects.RESTORE_PP) do
end)
end
-- engine/items/item_effects.asm:2320 RestorePPEffect's PP_UP arm.
record("PP_UP", "pp", function(ctx)
local move = (ctx.mon.moves or {})[ctx.slot]
if type(move) ~= "table" or not move.id then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local row = ((ctx.data and ctx.data.moves) or {})[move.id]
local name = (row and row.name) or move.id
-- constants/pokemon_data_constants.asm:216 PP_UP_MASK.
if move.id == "SKETCH" or (move.ppUps or 0) >= 3 then
return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) }
end
local base = (row and row.pp) or move.maxPp
if not base then
return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) }
end
-- engine/items/item_effects.asm:2736 ComputeMaxPP.
local bonus = math.min(math.floor(base / 5), 7)
move.ppUps = (move.ppUps or 0) + 1
move.maxPp = base + move.ppUps * bonus
move.pp = (move.pp or 0) + bonus
return { used = true, text = ItemEffects.TEXT_PP_INCREASED:format(name) }
end)
for itemId in pairs(ItemEffects.VITAMIN) do
record(itemId, "vitamin", function(ctx)
return vitamin(ctx.item, ctx.mon, ctx.data)
end)
end
record("RARE_CANDY", "candy", function(ctx)
return rareCandy(ctx.mon, ctx.data)
end)
+1
View File
@@ -272,6 +272,7 @@ Save.DEFAULT_OPTIONS = {
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
-- means something different, hence the different name.
color = "gbc",
videoMode = "windowed",
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
+10
View File
@@ -617,6 +617,16 @@ local function gen2Rows(opts, hooks)
end)
end
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then
add(Strings("VIDEO MODE"),
function() return VideoMode.modeLabel(opts.videoMode) end,
function(dir)
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
return true
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
+6
View File
@@ -83,6 +83,12 @@ function ItemEffects.healsHP(id)
or id == "REVIVE" or id == "MAX_REVIVE"
end
function ItemEffects.isBattleMedicine(id)
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
or id == "MAX_POTION" or id == "FULL_RESTORE"
or id == "REVIVE" or id == "MAX_REVIVE"
end
-- Does this item need a party-member target?
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
function ItemEffects.needsTarget(id, itemDef, data)
+10 -2
View File
@@ -1308,7 +1308,7 @@ function Loader:_api(mod)
-- mod.world materializes on first touch, like the image helper above: a
-- headless load must not drag the world stack in, and the Game the facade
-- acts on is still being wired when the entry chunk runs
local world
local world, battle
setmetatable(api, { __index = function(_, key)
-- mod.game is the live service owner, resolved per generation the way
-- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2
@@ -1317,9 +1317,17 @@ function Loader:_api(mod)
-- entry chunk runs. This is what a mod should hold instead of requiring
-- src.core.Game, which under Gold hands back a table nothing instantiated.
if key == "game" then return loader:_game() end
local game = loader:_game()
if key == "battle" then
if battle then return battle end
local module = game and engineRequire(loader.generation == 2
and "src.battle.gen2.BattleAPI" or "src.battle.BattleAPI")
if not module then return nil end
battle = module.new(game)
return battle
end
if key ~= "world" then return nil end
if world then return world end
local game = loader:_game()
-- one facade name, one arm per generation: Gold's world is not a stack
-- state and its flags are a bitfield, so the resolution differs even
-- where the method set does not (src/world/gen2/WorldAPI.lua)
+43 -1
View File
@@ -986,12 +986,45 @@ end
function ManagerState:buildOptionRows(m, schema)
local rows = {}
local modId = m.id
local byKey, visibilityKeys = {}, {}
for _, row in ipairs(schema) do
if type(row) == "table" and type(row.key) == "string" then
byKey[row.key] = row
local condition = row.visible_if
if type(condition) == "table" and type(condition.key) == "string" then
visibilityKeys[condition.key] = true
end
end
end
local function visible(row)
local condition = row.visible_if
if condition == nil then return true end
if type(condition) ~= "table" or type(condition.key) ~= "string" then
return false
end
local dependency = byKey[condition.key] or { key = condition.key }
local value = self:optionValue(modId, dependency)
if condition.equals ~= nil then return value == condition.equals end
if condition.not_equals ~= nil then return value ~= condition.not_equals end
return false
end
local function refresh(key)
if not visibilityKeys[key] then return end
local preferred = rows[self.cursor] and rows[self.cursor].id
self.optionRows = self:buildOptionRows(m, schema)
for index, candidate in ipairs(self.optionRows) do
if candidate.id == preferred then self.cursor = index break end
end
self.cursor = clampIndex(self.cursor, #self.optionRows)
end
for _, row in ipairs(schema) do
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
or not OPTION_TYPES[row.type] then
-- malformed rows are skipped, reported where the errors screen reads
Runtime.reportError(modId, "options row skipped: "
.. tostring(type(row) == "table" and (row.key or row.type) or row))
elseif not visible(row) then
-- Keep the row in the schema and stored options, only hide its menu row.
elseif row.type == "toggle" then
rows[#rows + 1] = { id = row.key, label = row.label or row.key,
value = function()
@@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema)
end,
step = function()
self:setOption(modId, row.key, not self:optionValue(modId, row))
refresh(row.key)
return true
end }
elseif row.type == "choice" then
@@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema)
end
index = clampIndex(index + dir, #choices)
self:setOption(modId, row.key, choices[index][2])
refresh(row.key)
return true
end }
elseif row.type == "number" then
@@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema)
step = function(_, dir)
local cur = tonumber(self:optionValue(modId, row)) or 0
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
refresh(row.key)
return true
end,
activate = function()
@@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema)
max = row.max or 99,
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
onDone = function(qty)
if qty then self:setOption(modId, row.key, clamp(qty)) end
if qty then
self:setOption(modId, row.key, clamp(qty))
refresh(row.key)
end
end,
}))
end }
@@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema)
default = self:optionValue(modId, row),
onDone = function(name)
self:setOption(modId, row.key, name)
refresh(row.key)
end,
}))
end }
@@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema)
self:setOption(modId, row.key, row.default)
end
end
self.optionRows = self:buildOptionRows(m, schema)
self.cursor = clampIndex(self.cursor, #self.optionRows)
self:notify("DEFAULTS RESTORED")
end }
return rows
+8 -3
View File
@@ -32,9 +32,14 @@ local Net = {}
Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30
-- A log body ceiling. Debug logs are kilobytes, and a server operator has no
-- reason to accept a mod uploading arbitrary megabytes to its endpoint.
Net.MAX_BODY = 65536
-- A log body ceiling. A diagnostic ring (boot evidence + recent lines +
-- status) routinely exceeds 64 KiB on a long session, so the ceiling is
-- 512 KiB: generous for real support logs, still far under the 5 MiB the
-- reference loghook endpoint accepts, and small enough that a misbehaving
-- mod cannot upload arbitrary megabytes. The body is staged to a file and
-- streamed by the transport, so the ceiling is a budget, not a memory
-- spike; callers that stay under it never notice it.
Net.MAX_BODY = 512 * 1024
local function fetch()
return require("src.net.Fetch")
+125
View File
@@ -0,0 +1,125 @@
-- Minimal second-window process for src/render/DesktopScreen.lua.
local DesktopCompanion = {}
function DesktopCompanion.install(config)
local enet = require("enet")
local host = assert(enet.host_create())
local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
local image, sourceW, sourceH, preference
local background = { 0, 0, 0, 1 }
local connected, commandedQuit = false, false
local pointerDown = false
local started = love.timer.getTime()
local lastContact = started
local function send(kind, payload)
if not connected then return end
pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
end
local function receiveFrame(data)
local prefix = "F" .. config.token .. "\n"
if data:sub(1, #prefix) ~= prefix then return end
local split = data:find("\n", #prefix + 1, true)
if not split then return end
local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
:match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
local ok, raw = pcall(love.data.decompress, "string", "lz4",
data:sub(split + 1))
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
if not made then return end
if not image or sourceW ~= w or sourceH ~= h then
if image and image.release then image:release() end
image = love.graphics.newImage(pixels)
else
image:replacePixels(pixels)
end
sourceW, sourceH, preference = w, h, mode
image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
mode:find("cover", 1, true) and "linear" or "nearest")
background = {
math.floor(rgb / 0x10000) % 0x100 / 255,
math.floor(rgb / 0x100) % 0x100 / 255,
rgb % 0x100 / 255, 1,
}
end
local function service()
while true do
local event = host:service(0)
if not event then break end
if event.type == "connect" then
connected, lastContact = true, love.timer.getTime()
send("H")
elseif event.type == "receive" then
lastContact = love.timer.getTime()
if event.data == "Q" .. config.token then
commandedQuit = true
love.event.quit()
elseif event.data ~= "P" .. config.token then
receiveFrame(event.data)
end
elseif event.type == "disconnect" then
love.event.quit()
end
end
end
local function placement()
if not image then return 0, 0, 1 end
local ww, wh = love.graphics.getDimensions()
local cover = preference and preference:find("cover", 1, true)
local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
end
local function input(action, x, y)
if not image then return false end
local dx, dy, scale = placement()
local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
send("I", ("\n%s,%d,%d"):format(action, sx, sy))
return true
end
function love.update()
service()
local t = love.timer.getTime()
if (not connected and t - started > 5) or t - lastContact > 5 then
love.event.quit()
end
end
function love.draw()
love.graphics.clear(background[1], background[2], background[3], background[4])
if not image then return end
local x, y, scale = placement()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, x, y, 0, scale, scale)
end
function love.mousepressed(x, y, button)
if button == 1 then pointerDown = input("down", x, y) end
end
function love.mousereleased(x, y, button)
if button == 1 and pointerDown then
if not input("up", x, y) then send("I", "\ncancel,0,0") end
pointerDown = false
end
end
function love.touchpressed(_, x, y) input("down", x, y) end
function love.touchreleased(_, x, y) input("up", x, y) end
function love.keypressed(key)
if key == "escape" then love.event.quit() end
end
function love.quit()
if not commandedQuit then send("C") end
pcall(peer.disconnect_now, peer)
end
end
return DesktopCompanion
+137
View File
@@ -0,0 +1,137 @@
-- Cross-platform desktop secondary display. LOVE owns one window, so a
-- second minimal instance of this same app owns the companion window. ENet
-- is bundled with LOVE; binding it to loopback keeps frames and input local.
local Platform = require("src.core.Platform")
local HostShell = require("src.core.HostShell")
local okEnet, enet = pcall(require, "enet")
local DesktopScreen = {}
local state = {
enabled = false, blocked = false, host = nil, peer = nil,
token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
}
local function now()
return love and love.timer and love.timer.getTime and love.timer.getTime()
or os.clock()
end
local function destroy(sendQuit)
if state.peer and sendQuit then
pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
end
if state.peer then pcall(state.peer.disconnect_now, state.peer) end
if state.host then pcall(state.host.destroy, state.host) end
state.host, state.peer, state.token, state.port = nil, nil, nil, nil
state.touches = {}
end
local function token()
local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
local digest = love.data.hash("sha256", seed)
return love.data.encode("string", "hex", digest):sub(1, 24)
end
local function start()
if state.host or state.blocked or now() < state.retryAt then
return state.host ~= nil
end
local base = 49152 + math.floor(now() * 1000) % 12000
for attempt = 0, 31 do
local port = 49152 + (base - 49152 + attempt * 37) % 12000
local ok, host = pcall(enet.host_create,
("127.0.0.1:%d"):format(port), 1, 2)
if ok and host then
state.host, state.port, state.token = host, port, token()
local launched = HostShell.spawnSelfDetached({
("--display-companion=%d,%s"):format(port, state.token),
})
if launched then return true end
destroy(false)
break
end
end
state.retryAt = now() + 1
return false
end
local function service()
if not state.enabled or state.blocked then return end
if not state.host and not start() then return end
while state.host do
local ok, event = pcall(state.host.service, state.host, 0)
if not ok then
destroy(false)
state.retryAt = now() + 1
return
end
if not event then break end
if event.type == "receive" then
local data = event.data or ""
if data == "H" .. state.token then
state.peer = event.peer
elseif event.peer == state.peer
and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
state.touches[#state.touches + 1] = data:sub(#state.token + 3)
elseif event.peer == state.peer and data == "C" .. state.token then
state.blocked = true
destroy(false)
return
end
elseif event.type == "disconnect" and event.peer == state.peer then
destroy(false)
state.retryAt = now() + 1
return
end
end
if state.peer and now() >= state.heartbeatAt then
state.heartbeatAt = now() + 1
pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
end
end
function DesktopScreen.usable()
return okEnet and enet ~= nil and Platform.canSpawnProcess()
end
function DesktopScreen.available()
return DesktopScreen.detected()
end
function DesktopScreen.detected()
service()
return state.peer ~= nil
end
function DesktopScreen.push(imageData, w, h, background, preference)
service()
if not state.peer or not imageData or not imageData.getString then return false end
w, h = tonumber(w), tonumber(h)
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
local ok, raw = pcall(imageData.getString, imageData)
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
local packed = love.data.compress("string", "lz4", raw, 1)
local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
return sent
end
function DesktopScreen.pollTouch()
service()
return table.remove(state.touches, 1)
end
function DesktopScreen.setEnabled(on)
on = on == true
if on == state.enabled then
if on then service() end
return
end
state.enabled = on
state.blocked = false
if on then start(); service() else destroy(true) end
end
return DesktopScreen
+185
View File
@@ -0,0 +1,185 @@
-- Optional game viewport inside the OS window. A layout mod may reserve any
-- window-space rectangle through render.viewport; the game then renders as if
-- that rectangle were its whole display. With no subscriber this module is a
-- pass-through and allocates no canvas.
local Runtime = require("src.mods.Runtime")
local Viewport = {
rect = nil,
full = nil,
canvas = nil,
generation = nil,
frameActive = false,
}
local function finite(value)
return type(value) == "number" and value == value
and value > -math.huge and value < math.huge
end
local function realMetrics()
local G = love.graphics
local w, h = G.getDimensions()
local pw, ph = w, h
if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
local dpiX = w > 0 and pw / w or 1
local dpiY = h > 0 and ph / h or 1
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return math.max(1, w), math.max(1, h),
math.max(1, pw), math.max(1, ph), dpiX, dpiY
end
local function clampRect(value, w, h)
if type(value) ~= "table" then
return { x = 0, y = 0, width = w, height = h }
end
local x = finite(value.x) and math.floor(value.x) or 0
local y = finite(value.y) and math.floor(value.y) or 0
local rw = finite(value.width) and math.floor(value.width) or w
local rh = finite(value.height) and math.floor(value.height) or h
x = math.max(0, math.min(x, w - 1))
y = math.max(0, math.min(y, h - 1))
rw = math.max(1, math.min(rw, w - x))
rh = math.max(1, math.min(rh, h - y))
return { x = x, y = y, width = rw, height = rh }
end
local function sameSize(canvas, w, h)
return canvas and canvas:getWidth() == w and canvas:getHeight() == h
end
function Viewport.begin(generation)
local w, h, pw, ph, dpiX, dpiY = realMetrics()
local context = {
width = w, height = h, pixelWidth = pw, pixelHeight = ph,
dpiX = dpiX, dpiY = dpiY, generation = generation,
}
local requested
if Runtime.wantsHook("render.viewport") then
requested = Runtime.call("render.viewport", function(ctx)
return { x = 0, y = 0, width = ctx.width, height = ctx.height }
end, context)
end
local rect = clampRect(requested, w, h)
Viewport.full = context
Viewport.rect = rect
Viewport.generation = generation
local active = type(requested) == "table" and requested.capture == true
or rect.x ~= 0 or rect.y ~= 0
or rect.width ~= w or rect.height ~= h
Viewport.frameActive = active
if active then
if not sameSize(Viewport.canvas, rect.width, rect.height) then
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
Viewport.canvas:setFilter("nearest", "nearest")
end
else
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return rect
end
function Viewport.active()
return Viewport.frameActive == true and Viewport.canvas ~= nil
end
function Viewport.dimensions()
if Viewport.active() then
return Viewport.rect.width, Viewport.rect.height
end
return love.graphics.getDimensions()
end
function Viewport.pixelDimensions()
if Viewport.active() then
if Viewport.canvas.getPixelDimensions then
local w, h = Viewport.canvas:getPixelDimensions()
return math.max(1, w), math.max(1, h)
end
return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
end
if love.graphics.getPixelDimensions then
return love.graphics.getPixelDimensions()
end
return love.graphics.getDimensions()
end
function Viewport.fullDimensions()
if Viewport.full then return Viewport.full.width, Viewport.full.height end
return love.graphics.getDimensions()
end
function Viewport.target()
return Viewport.canvas
end
function Viewport.setTarget()
love.graphics.setCanvas(Viewport.canvas)
end
function Viewport.toLocal(x, y)
local rect = Viewport.rect
if not rect then return x, y, true end
local lx, ly = x - rect.x, y - rect.y
return lx, ly,
lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
end
function Viewport.localSafeRect(x, y, w, h)
local rect = Viewport.rect
if not Viewport.active() or not rect then return x, y, w, h end
local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
local x2 = math.min(x + w, rect.x + rect.width)
local y2 = math.min(y + h, rect.y + rect.height)
if x2 <= x1 or y2 <= y1 then
return 0, 0, rect.width, rect.height
end
return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
end
function Viewport.finish(game)
if not Viewport.active() then return end
local G = love.graphics
local rect, full = Viewport.rect, Viewport.full
G.setCanvas()
G.push("all")
G.origin()
G.setScissor()
G.setShader()
G.setBlendMode("alpha")
G.clear(0, 0, 0, 1)
local context = {
canvas = Viewport.canvas,
x = rect.x, y = rect.y, width = rect.width, height = rect.height,
windowWidth = full.width, windowHeight = full.height,
dpiX = full.dpiX, dpiY = full.dpiY,
generation = Viewport.generation,
}
Runtime.call("render.window", function(_, ctx)
G.setColor(1, 1, 1, 1)
G.draw(ctx.canvas, ctx.x, ctx.y)
end, game, context)
G.pop()
end
function Viewport.reset()
Viewport.frameActive = false
Viewport.rect = nil
Viewport.full = nil
Viewport.generation = nil
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return Viewport
+17 -20
View File
@@ -119,13 +119,9 @@ PaletteFX.GBC_OBJ_BLUE = {
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red for Red. White (index 1) and black (index 4) are
-- identical across Red/Blue, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch there.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
-- pal() / usesYellowCgb(), and ogBg() falls back to CGBBase PAL_ROUTE for any
-- remaining whole-screen callers -- never Blue's GBC_BG_BLUE.
-- The active game's OG boot-ROM background palette: blue for Blue, red for Red.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes), so ogBg() falls back to
-- CGBBase PAL_ROUTE there, never Blue's GBC_BG_BLUE.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
if GameVersion.isYellow() then
@@ -354,23 +350,14 @@ function PaletteFX.usesSpriteObp(mode)
return mode == "ogred" and not GameVersion.isYellow()
end
-- ------- post-zone sprite redraw (OG RED)
--
-- In OG RED the world canvas still runs through the whole-screen zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. (SGB used to come through here too; it no longer bakes an object
-- palette at all, so its characters are colorized by the zone like the ground
-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
-- late. Entries carrying `colors` are re-colorized draws (the tall-grass
-- feet overdraw, which must keep hiding sprite feet) issued through the
-- color-0-keyed shade-remap shader. World pass only; cleared per frame.
-- ------- post-zone sprite redraw (OG RED): OBP-baked draws recorded here and
-- replayed by Renderer:endFrame on top of the zone pass (usesSpriteObp, #301)
local spriteRedraws = {}
local uiSpriteRedraws = {}
function PaletteFX.clearSpriteRedraws()
for i = #spriteRedraws, 1, -1 do spriteRedraws[i] = nil end
for i = #uiSpriteRedraws, 1, -1 do uiSpriteRedraws[i] = nil end
end
function PaletteFX.markSpriteRedraw(image, quad, x, y, sx, colors, keyed)
@@ -392,6 +379,16 @@ function PaletteFX.spriteRedraws()
return spriteRedraws
end
function PaletteFX.markUiSpriteRedraw(image, quad, x, y)
if currentPass ~= "ui" then return end
uiSpriteRedraws[#uiSpriteRedraws + 1] =
{ image = image, quad = quad, x = x + markOffsetX, y = y }
end
function PaletteFX.uiSpriteRedraws()
return uiSpriteRedraws
end
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data)
+19 -6
View File
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
@@ -69,11 +70,9 @@ Renderer.UPRIGHT_MARGIN = 160
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square).
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local ww, wh = GameViewport.dimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
pw, ph = GameViewport.pixelDimensions()
local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end
@@ -745,7 +744,7 @@ end
-- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
@@ -1103,6 +1102,20 @@ function Renderer:endFrame(zones, worldZones)
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end
end
local uiRedraws = PaletteFX.uiSpriteRedraws()
if uiRedraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(uox, uoy, uvpw, uvph)
for _, r in ipairs(uiRedraws) do
if r.quad then
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
0, Ux, Uy)
else
love.graphics.draw(r.image, uox + r.x * Ux, uoy + r.y * Uy, 0, Ux, Uy)
end
end
love.graphics.setScissor()
end
-- The battle wipe covers the whole surface, letterbox included, so it goes
-- over the finished composite rather than under the UI blit. On hardware
@@ -1136,7 +1149,7 @@ function Renderer:endFrame(zones, worldZones)
end
if present then
love.graphics.setCanvas()
GameViewport.setTarget()
-- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the
+27 -6
View File
@@ -1,11 +1,11 @@
-- Bridge to native secondary-display output (Android Presentation). The C
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
-- this stays inert and the renderer keeps the in-window stacked layout.
-- Shared secondary-display facade. Android uses its native Presentation
-- bridge; process-capable desktop hosts fall back to a companion window.
-- Everything is guarded, so unsupported hosts keep the in-window layout.
local SecondScreen = {}
local C = nil
local ffi = nil
local desktop = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
@@ -37,17 +37,36 @@ do
end
end
if not C then
local ok, backend = pcall(require, "src.render.DesktopScreen")
if ok and backend and backend.usable and backend.usable() then
desktop = backend
log("desktop companion backend ready")
end
end
function SecondScreen.usable()
return C ~= nil
return C ~= nil or desktop ~= nil
end
function SecondScreen.available()
if desktop then return desktop.available() end
if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0
end
function SecondScreen.push(imageData, w, h)
-- A connected display is not necessarily the current Presentation yet. This
-- distinction lets a companion retry its first frame after hotplug/re-target.
function SecondScreen.detected()
if desktop then return desktop.detected() end
return SecondScreen.available()
end
function SecondScreen.push(imageData, w, h, background, preference)
if desktop then
return desktop.push(imageData, w, h, background, preference)
end
if not C or not imageData then return false end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
@@ -57,6 +76,7 @@ end
-- Returns the oldest queued secondary-display event as "action,x,y", where
-- coordinates are in the submitted frame's pixel space.
function SecondScreen.pollTouch()
if desktop then return desktop.pollTouch() end
if not C then return nil end
local ok, event = pcall(function()
return C.love_android_poll_secondary_touch()
@@ -66,6 +86,7 @@ function SecondScreen.pollTouch()
end
function SecondScreen.setEnabled(on)
if desktop then return desktop.setEnabled(on) end
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
+19
View File
@@ -51,6 +51,7 @@ function TextBox.new(game, text, onDone, opts)
self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo
self.choiceNoSound = opts and opts.noSound
self.money = opts and opts.money
self.auto = opts and opts.auto
self.stay = opts and opts.stay
-- opts.instant: put the LAST page up already typed, with no typewriter and
@@ -253,6 +254,16 @@ function TextBox:beginLine()
table.insert(self.shown, {})
end
function TextBox:visibleText()
local page = self.pages[self.pageIndex]
if not page then return nil end
local out, count = {}, #(self.shown or {})
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
if page[i] ~= nil then out[#out + 1] = page[i] end
end
return #out > 0 and out or nil
end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
@@ -457,6 +468,14 @@ function TextBox:draw()
pen = pen + Font.advanceOf(code)
end
end
if self.money then
-- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at
-- hlcoord 11,0, the amount right-aligned on its middle row
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
end
if (self.waiting or (self.done and not self.choice and not self.auto
and not self.stay))
and self.blink < 30 then
+3 -2
View File
@@ -565,10 +565,11 @@ end
-- ---- engine/events/poisonstep.asm -----------------------------------------
-- .PlayPoisonSFX: SFX_POISON, then LoadPoisonBGPals for two frames. The port
-- plays the sound; the two-frame red flash is the renderer's business.
-- .PlayPoisonSFX: SFX_POISON, then LoadPoisonBGPals for four frames.
-- engine/events/poisonstep.asm:101
function H.PlayPoisonSFX(ctx)
call(ctx, "playSfxNamed", SFX_POISON[1], SFX_POISON[2])
call(ctx, "poisonBGFlash")
return nil
end
+23 -30
View File
@@ -358,42 +358,33 @@ end
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
-- (constants/script_constants.asm).
-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the
-- routine that prints it is engine code and no script bytecode points at the
-- string, so the extractor never reaches it.
-- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so
-- the extractor never reaches it.
local CONTEST_NICKNAME_PROMPT =
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123)
function Specials.askNickname(vm, mon)
nameMon(vm, mon.species)
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- InitNickname (engine/pokemon/move_mon.asm:1787)
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's blank test (home/string.asm:6-30)
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
end
H.CheckPartyFullAfterContest = function(vm)
local Breeding = require("src.core.gen2.Breeding")
local result, mon =
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
-- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the
-- mon that joined the party and the one that went to the box -- and nowhere
-- else in the contest: BugContest_SetCaughtContestMon merely holds the catch
-- in wContestMon, so this is the only place the player is ever asked.
-- GetPokemonName runs first, which is what {STRBUF} reads.
-- GiveANickname_YesNo runs on both contest arms, party and box
if mon and result ~= BugContest.NO_CATCH then
nameMon(vm, mon.species)
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is
-- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page
-- left standing.
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a
-- fresh catch (the Name Rater is the one that pre-fills), and InitNickname
-- copies the species name back over an empty entry -- so a cancelled
-- keyboard is the same as answering NO.
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's own blank test (home/string.asm:6-30): "zero or more
-- spaces followed by a null". The keyboard's blank cells are real
-- typeable characters, so an all-space entry has to be discarded the
-- same way an empty one is, not stored as a name of spaces.
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
Specials.askNickname(vm, mon)
end
answer(vm, result)
end
@@ -1005,7 +996,7 @@ end
-- check is transcribed here rather than left to the screen, because its two
-- refusals are TEXT and the script has to see them before the machine opens:
-- no coins at all, or no COIN_CASE to hold them.
local COIN_CASE = 0x47 -- constants/item_constants.asm
local COIN_CASE = 0x36 -- constants/item_constants.asm:62
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
local NO_COINS_TEXT = "You have no coins."
@@ -2409,7 +2400,9 @@ local STUB_ROWS = {
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
{ "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" },
-- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to
-- WaitForLinkedFriend and lands on .FriendNotReady
{ "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" },
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
{ "TradeCenter", nil, "link cable: no trade room" },
{ "Colosseum", nil, "link cable: no battle room" },
+6 -1
View File
@@ -544,7 +544,12 @@ local function runCmd(self, cmd, op)
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
if self.givePokeFn then
self.givePokeFn(species, level, item)
local mon = self.givePokeFn(species, level, item)
-- engine/pokemon/move_mon.asm:1753-1757
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
if mon and trainer == 0 then
Specials.askNickname(self, mon)
end
end
elseif op == "checkpoke" then
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
+124 -38
View File
@@ -14,6 +14,7 @@
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
return speciesOrOpts, false
end
local function ownedFor(game, def, forceOwned)
return forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
end
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
local function descPages(game, def, forceOwned)
local e = def.dexEntry or {}
local owned = ownedFor(game, def, forceOwned)
local text = owned and e.text and game.data.text[e.text] or nil
if not text then return nil end
local pages = {}
for chunk in (text .. "\f"):gmatch("(.-)\f") do
local lines = {}
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
end
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
if #lines > 0 then pages[#pages + 1] = lines end
end
if #pages == 0 then return nil end
local last = pages[#pages]
last[#last] = last[#last] .. "."
return pages
end
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
local frameCache = {}
local function frameSheet(game)
local fx = game.data.field and game.data.field.overworldFx
local def = fx and fx.pokedexFrame
local path = def and def.path
if not path then return nil end
local hit = frameCache[path]
if hit ~= nil then return hit or nil end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then
frameCache[path] = false
return nil
end
local iw, ih = img:getDimensions()
local quads = {}
for i = 0, 17 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8, 8, 8, iw, ih)
end
frameCache[path] = { img = img, quads = quads }
return frameCache[path]
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
}
function DexEntryMenu.new(game, speciesOrOpts, onDone)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned,
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.def = game.data.pokemon[species]
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
-- pcall's second return has to survive the guard (#307)
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species)
return self
end
@@ -57,6 +115,11 @@ end
function DexEntryMenu:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
self.page = self.page + 1
return
end
self.game.stack:pop()
if self.onDone then self.onDone() end
end
@@ -64,64 +127,87 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor)
self.spriteTrueColor, self.page)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
-- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
page = page or 1
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
if frame then
local function tile(code, tx, ty)
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
end
-- engine/menus/pokedex.asm:418
for tx = 1, 18 do
tile(0x64, tx, 0)
tile(0x6f, tx, 17)
end
for ty = 1, 16 do
tile(0x66, 0, ty)
tile(0x67, 19, ty)
end
tile(0x63, 0, 0)
tile(0x65, 19, 0)
tile(0x6c, 0, 17)
tile(0x6e, 19, 17)
-- engine/menus/pokedex.asm:445
for tx = 0, 19 do
tile(DIVIDER[tx + 1], tx, 9)
end
end
if sprite then
local y = math.max(0, 60 - sprite:getHeight())
love.graphics.draw(sprite, 8, y)
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
-- it renders to its own PNG canvas, and a mark left behind there would
-- bleed into the next real frame.
-- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
local w, h = sprite:getDimensions()
local x = 8 + math.floor((8 - w / 8) / 2) * 8
local y = 8 + (7 - h / 8) * 8
love.graphics.draw(sprite, x + w, y, 0, -1, 1)
-- the unshaded pass needs the pic bounds (#350)
if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
-- engine/menus/pokedex.asm:454
Font.draw(def.name, 72, 16)
local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
-- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
Font.draw(e.kind or "?", 72, 32)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
-- engine/menus/pokedex.asm:478
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
if e.heightM then
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
else
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 48)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
end
end
local text = owned and e.text and game.data.text[e.text] or nil
local y = 72
if text then
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if y > 132 then break end
Font.draw(line, 8, y)
y = y + 10
local pages = descPages(game, def, forceOwned)
if pages then
-- engine/menus/pokedex.asm:568
local lines = pages[page] or pages[#pages]
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245
if page < #pages then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
Font.draw(Strings("Data unknown."), 8, y)
Font.draw(Strings("Data unknown."), 8, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
+112
View File
@@ -0,0 +1,112 @@
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local HallOfFame = require("src.ui.HallOfFame")
local LeaguePC = {}
LeaguePC.__index = LeaguePC
LeaguePC.isOpaque = true
-- constants/pokemon_data_constants.asm:65
local CAPACITY = 50
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
function LeaguePC:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local mon = self:currentMon()
local c = mon and P.monPal(game.data, mon.species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
end
function LeaguePC.new(game, onDone)
local self = setmetatable({}, LeaguePC)
self.game = game
self.onDone = onDone
self.teams = (game.save and game.save.hallOfFame) or {}
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
self.monIndex = 1
self.sprites = {}
self.spriteTrueColor = {}
self:loadMon()
return self
end
function LeaguePC:currentMon()
local team = self.teams[self.teamIndex]
return team and team[self.monIndex] or nil
end
function LeaguePC:loadMon()
local mon = self:currentMon()
if not mon then return end
local species = mon.species
if self.sprites[species] == nil then
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprites[species] = ok and img or false
self.spriteTrueColor[species] = (ok and img and trueColor) or false
end
require("src.core.Sound").playCry(self.game.data, species)
end
function LeaguePC:close()
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function LeaguePC:update(dt)
local input = self.game.input
if input:wasPressed("b") then
self:close()
return
end
if input:wasPressed("a") then
if not self:currentMon() then
self:close()
return
end
local team = self.teams[self.teamIndex]
if self.monIndex < #team then
self.monIndex = self.monIndex + 1
elseif self.teamIndex < #self.teams then
self.teamIndex = self.teamIndex + 1
self.monIndex = 1
else
self:close()
return
end
self:loadMon()
end
end
function LeaguePC:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local mon = self:currentMon()
if not mon then return end
local img = self.sprites[mon.species]
if img then
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
local w, h = img:getDimensions()
local x = 96 + math.floor((8 - w / 8) / 2) * 8
local y = 40 + (7 - h / 8) * 8
love.graphics.draw(img, x, y)
if self.spriteTrueColor[mon.species] then
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
-- engine/movie/hall_of_fame.asm:159
HallOfFame.drawMonInfo(self, mon)
-- engine/menus/league_pc.asm:102
Font.drawBox(0, 13, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return LeaguePC
+11 -1
View File
@@ -79,7 +79,16 @@ end
function NamingScreen:enter()
if self.presets and #self.presets > 0 then
local Menu = require("src.ui.Menu")
local items = { { label = Strings("NEW NAME") } }
-- engine/movie/oak_speech/oak_speech2.asm:1
self.choosing = true
self.isOpaque = false
local items = { {
label = Strings("NEW NAME"),
onSelect = function()
self.choosing = nil
self.isOpaque = nil
end,
} }
for _, preset in ipairs(self.presets) do
table.insert(items, {
label = preset,
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
end
function NamingScreen:draw()
if self.choosing then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
+2 -16
View File
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
end
if self.softboiledFrom then
local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5)
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
or user.hp <= heal then
self.softboiledFrom = nil
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
else
user.hp = user.hp - heal
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
self.softboiledFrom = nil
require("src.core.Sound").play(self.game.data, "Heal_HP")
local def = self.game.data.pokemon[mon.species]
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
end
self.softboiledFrom = nil
self.game.overworld:useSoftboiledFieldMove(user, mon)
elseif self.swapFrom then
if self.swapFrom ~= self.index then
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
+7 -5
View File
@@ -134,13 +134,15 @@ function SummaryMenu:draw()
Font.draw(("%03d"):format(def.dex or 0), 24, 56)
if self.page == 1 then
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
-- bracket around the name/HP block, and PrintLevel at (14,2). The
-- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea
-- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
-- (9,2) 5x10 (status_screen.asm:303-305). #280
printLevel(14, 2, mon.level)
drawLineBox(19, 1, 6, 10)
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
-- engine/pokemon/status_screen.asm:120-125
local PaletteFX = require("src.render.PaletteFX")
local barZoned = PaletteFX.shader() ~= nil
and PaletteFX.pal(data, "GREENBAR") ~= nil
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
Font.draw(Strings("STATUS/"), 72, 48)
Font.draw(mon.status or "OK", 128, 48)
+27 -4
View File
@@ -171,6 +171,27 @@ local function markVisibleTrueColor(x, y, w, h, cover)
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
end
-- ..(engine/movie/title.asm ln 321)
local function replayObjSprite(game, image, quad, x, y)
local P = require("src.render.PaletteFX")
if not P.usesSpriteObp() then return end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
local w, h
if quad then
w, h = select(3, quad:getViewport())
else
w, h = image:getDimensions()
end
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
return
end
end
P.markUiSpriteRedraw(image, quad, x, y)
end
function TitleState.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TitleState)
@@ -662,12 +683,11 @@ function TitleState:draw()
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
-- the strip Red's OAM covers (#350). Yellow never reaches here: its
-- layout has no cycling mon and no Red art (title_yellow.asm).
-- SGB: the mon keeps its palette minus the strip Red's OAM covers
-- (#350); Yellow never reaches here (title_yellow.asm)
if spriteTrueColor then
local cover
if playerImage then
if playerImage and not require("src.render.PaletteFX").usesSpriteObp() then
local pw, ph = playerImage:getDimensions()
cover = { 82, 80, pw, ph }
end
@@ -678,10 +698,13 @@ function TitleState:draw()
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3])
replayObjSprite(self.game, playerImage, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
replayObjSprite(self.game, playerImage, self.ballQuad, 82, self.ballY)
elseif playerImage then
love.graphics.draw(playerImage, 82, 80)
replayObjSprite(self.game, playerImage, nil, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
+32 -15
View File
@@ -220,6 +220,20 @@ function TownMap.new(game, opts)
-- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
-- fly destination (PALLET_TOWN), never the player's current town (#795).
@@ -345,18 +359,16 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- the player's current location blinks (slow phase). Paint it with a
-- palette-safe DARK shade (red 0), not red: this screen composites through
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
-- {165,214,255}, the exact light-blue used for the water and the town-square
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
@@ -389,11 +401,16 @@ function TownMap:draw()
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
-- palette-safe dark, same red-channel shade-remap reason as the primary
-- grid path above (#152); stale-asset builds hit this fallback square
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerSheet then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
end
if selected and self.blink % 16 < 10 then
love.graphics.setColor(0, 0, 0, 1)
+54 -16
View File
@@ -150,6 +150,10 @@ end
-- animation (most of them) skips the canvas entirely.
local function needsCanvas(runner)
local bg = runner.bg
-- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays
-- out of the BG until the next pic redraw, so those frames stay baked too.
local lifted = bg.liftedRows
if lifted and (lifted.player or lifted.enemy) then return true end
if bg.scx ~= 0 or bg.scy ~= 0 then return true end
if not bg.lcdc then return false end
if bg.lyEnd <= bg.lyStart then return false end
@@ -229,13 +233,12 @@ end
-- times, and the grouping is by VALUE so a table that happens to repeat costs
-- nothing extra.
local function bgpBands(bg)
local base = bg.bgp or GbcPalette.BGP_IDENTITY
local order, bands = {}, {}
for row = 0, SCREEN_H - 1 do
local inWindow = row >= bg.lyStart and row < bg.lyEnd
-- Outside the window the register still reads whatever wBGP holds, which
-- for every effect that aims hLCDCPointer at rBGP is the identity.
local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY)
or GbcPalette.BGP_IDENTITY
-- Outside the window the register still reads whatever wBGP holds.
local byte = inWindow and (bg.lyBackup[row] or base) or base
local band = bands[byte]
if not band then
band = { byte = byte, rows = {} }
@@ -244,24 +247,56 @@ local function bgpBands(bg)
end
band.rows[#band.rows + 1] = row
end
-- Identity first so the fillBackground below it happens before any blit and
-- the common band is the one drawn from the first bake.
-- The base band first so the fillBackground below it happens before any blit
-- and the common band is the one drawn from the first bake.
table.sort(order, function(a, b)
if a.byte == b.byte then return false end
if a.byte == GbcPalette.BGP_IDENTITY then return true end
if b.byte == GbcPalette.BGP_IDENTITY then return false end
if a.byte == base then return true end
if b.byte == base then return false end
return a.rows[1] < b.rows[1]
end)
return order
end
-- Runs `drawBg` (the battle panel) and then puts it on screen through the
-- animation's BG registers. Returns without a canvas when nothing is
-- displacing anything, which is the common case and costs nothing.
function BattleAnimView:present(runner, drawBg)
-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
function BattleAnimView:panelPalettes(battle)
local list = {}
local shades = {}
for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end
list[#list + 1] = shades
local function bracket(pair)
if not (pair and pair[1] and pair[2]) then return end
list[#list + 1] = {
{ 255, 255, 255 },
{ pair[1][1], pair[1][2], pair[1][3] },
{ pair[2][1], pair[2][2], pair[2][3] },
{ 0, 0, 0 },
}
end
for _, side in ipairs({ "player", "enemy" }) do
local mon = battle and battle[side]
local colors = mon
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
if colors then list[#list + 1] = colors end
end
local hpBar = self.palettes and self.palettes.hpBar
if hpBar then
bracket(hpBar.green)
bracket(hpBar.yellow)
bracket(hpBar.red)
end
bracket(self.palettes and self.palettes.expBar)
return list
end
-- Runs `drawBg` (the battle panel) and puts it on screen through the
-- animation's BG registers; skips the canvas when nothing needs one.
function BattleAnimView:present(runner, drawBg, battle)
if not (love and love.graphics) then return end
local bg = runner.bg
if not needsCanvas(runner) then
local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY
and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil
if not invert and not needsCanvas(runner) then
drawBg()
return
end
@@ -292,9 +327,10 @@ function BattleAnimView:present(runner, drawBg)
self:bake(drawBg, nil)
-- A shifted scanline exposes whatever the BG map holds beside the pic, which
-- outside the two pic boxes is the blank tile. Without this the exposed
-- strip is the canvas's own transparency and every shake shows a seam.
local remapped = invert
and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp)
-- A shifted scanline exposes the blank tile beside the pic boxes; without
-- this the exposed strip is the canvas's own transparency.
self:fillBackground()
G.setColor(1, 1, 1, 1)
-- hSCX / hSCY move the whole background; the per-scanline overrides only
@@ -314,6 +350,7 @@ function BattleAnimView:present(runner, drawBg)
self:blitRow(row, dx, dy)
end
end
if remapped then GbcPalette.clear() end
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to
-- permute and the entry's BRIGHTNESS is the only thing left to reproduce.
if bg.lcdc == "BGP" then
@@ -417,5 +454,6 @@ end
BattleAnimView.SCREEN_W = SCREEN_W
BattleAnimView.SCREEN_H = SCREEN_H
BattleAnimView.needsCanvas = needsCanvas
return BattleAnimView
+244 -112
View File
@@ -31,6 +31,7 @@ local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
local Prize = require("src.battle.gen2.Prize")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
@@ -48,6 +49,9 @@ BattleState.isOpaque = true
-- the victory jingle can keep looping through the post-win prompts.
local MESSAGE_FRAMES = 48
-- engine/battle/effect_commands.asm:6661
local MOVE_DELAY_FRAMES = 40
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
@@ -121,6 +125,8 @@ local TEXT_ASK_FORGET_MOVE = Strings.source(
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
-- $e1/$e2), which is what makes it fit a six-tile column.
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
local MENU_ACTION = { FIGHT = "fight", ["<PK><MN>"] = "party",
PACK = "item", RUN = "run" }
local MENU_BOX_X = 8
local MENU_COL_SPACING = 6
@@ -236,15 +242,8 @@ function BattleState.new(game, opts)
self.menuIndex = 1
self.moveIndex = 1
self.picCache = {}
-- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_
-- ReturnMon's last row (what swallows a mon into a thrown ball) and
-- MonFaintedAnimation both clear the box and neither puts anything back: it
-- stays blank until something DRAWS a pic into it, which on the cart is only
-- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon).
-- Without this latch the pic came back the instant the animation let go of
-- the screen, so a caught mon stood there through "Gotcha!" and a fainted one
-- popped back up for its own faint line. See stepAnim for why it is latched
-- at those two moments rather than off the runner's own last frame.
-- Which side's pic box stays EMPTY until a send-out redraws it: a catch
-- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide.
self.picHidden = { player = false, enemy = false }
-- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in
-- from opposite sides before the first message.
@@ -621,6 +620,19 @@ function BattleState:drawPic(mon, back)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
-- GetSubstitutePic (engine/battle_anims/anim_commands.asm:905-960): the
-- doll sits in the mon's own pic box and takes its palette.
local doll, dollQuad
if not (trainerBack or enemyTrainer) then
local over = anim and anim.pic
local up
if over ~= nil then
up = over == "substitute"
else
up = mon and mon.volatile and (mon.volatile.substitute or 0) > 0
end
if up then doll, dollQuad = self:substituteDoll(back) end
end
local G = love.graphics
local w, h = image:getDimensions()
local px, py
@@ -649,7 +661,7 @@ function BattleState:drawPic(mon, back)
-- the mon drawn at this frame.
local scale = self:picScale(path, mon, back)
if anim then
px = px + (anim.slide or 0)
if not self.liftedPass then px = px + (anim.slide or 0) end
local resized = anim.size and PIC_RESIZE_TILES[anim.size]
if resized then scale = scale * (resized / boxTiles) end
end
@@ -660,6 +672,10 @@ function BattleState:drawPic(mon, back)
px = px + math.floor(w * (1 - scale) / 2)
py = py + math.floor(h * (1 - scale))
end
if doll then
px = (back and 32 or 112) + ((anim and anim.slide) or 0)
py = back and 80 or 40
end
G.setColor(1, 1, 1, 1)
-- No mon on this side at all in the catching tutorial, where the box holds
-- the DUDE's back-pic and nothing else for the whole battle.
@@ -682,6 +698,10 @@ function BattleState:drawPic(mon, back)
-- what is still inside the box rather than drawn over the HUD below it.
local sunk = self:faintSink(side)
local function body()
if doll then
G.draw(doll, dollQuad, px, py)
return
end
if sunk > 0 then
local visible = h - math.floor(sunk / scale)
if visible <= 0 then return end
@@ -694,11 +714,75 @@ function BattleState:drawPic(mon, back)
-- A mod-supplied pic that says it is already coloured is drawn as it is:
-- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands
-- back to its own draw site.
if colors and not trueColor and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
local function paint()
if colors and not trueColor and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
end
local lifted = anim and anim.lifted
if not lifted then
paint()
return
end
-- engine/battle_anims/bg_effects.asm:448-465: the ClearBoxed band is off the BG.
local bandY = (back and BattleState.PLAYER_PIC_TILE_Y
or BattleState.ENEMY_PIC_TILE_Y) * 8 + lifted[1] * 8
local bandH = lifted[2] * 8
local psx, psy, psw, psh
if G.getScissor then psx, psy, psw, psh = G.getScissor() end
if self.liftedPass then
G.setScissor(0, bandY, 160, bandH)
paint()
else
if bandY > 0 then
G.setScissor(0, 0, 160, bandY)
paint()
end
local below = 144 - bandY - bandH
if below > 0 then
G.setScissor(0, bandY + bandH, 160, below)
paint()
end
end
if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end
end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
-- enemy's frontpic, facing-UP for the player's backpic.
function BattleState:substituteDoll(back)
if self.subDoll == nil then
local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png")
if ok and image then
local w, h = image:getDimensions()
self.subDoll = { image = image,
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
else
self.subDoll = false
end
end
if not self.subDoll then return nil end
return self.subDoll.image, back and self.subDoll.up or self.subDoll.down
end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
-- enemy's frontpic, facing-UP for the player's backpic.
function BattleState:substituteDoll(back)
if self.subDoll == nil then
local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png")
if ok and image then
local w, h = image:getDimensions()
self.subDoll = { image = image,
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
else
self.subDoll = false
end
end
if not self.subDoll then return nil end
return self.subDoll.image, back and self.subDoll.up or self.subDoll.down
end
-- The top `visible` rows of a pic, for the faint slide. One quad, re-aimed,
@@ -1008,10 +1092,10 @@ function BattleState:afterAnimFor(side)
return "ANIM_PLAYER_DAMAGE"
end
function BattleState:animForMove(moveId, side)
function BattleState:animForMove(moveId, side, param)
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true,
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
})
if started then
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
@@ -1049,14 +1133,22 @@ function BattleState:animForId(idName, side, param)
})
end
-- data/moves/animations.asm:379
function BattleState:latchCaughtPic()
local anim = self.anim
if anim and anim.animId == "ANIM_THROW_POKE_BALL"
and self.ballThrow and self.ballThrow.caught then
self.picHidden.enemy = true
end
end
-- One logic frame of a running animation. B cuts it short, the way holding B
-- pages a text box.
function BattleState:stepAnim(input)
if not self.anim then return end
if input and (input:wasPressed("b") or input:wasPressed("start")) then
-- Cut short: the BG effects never reached their own last step, so the
-- tilemap is whatever they had got to and nothing is latched -- the
-- explicit latches (a catch) are the only ones that survive a skip.
-- Cut short: only the explicit latches (a caught mon) survive a skip.
self:latchCaughtPic()
self.anim = nil
-- Cart still reaches the after-anim arm after a move script ends; a skip
-- of the move should not drop the hit shake that follows it.
@@ -1064,6 +1156,7 @@ function BattleState:stepAnim(input)
return self:endSendOutAnim()
end
if not self.anim:step() then
self:latchCaughtPic()
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means
-- the OAM outlives the script, so keep the runner for drawing too.
if not self.anim.keepSprites then self.anim = nil end
@@ -1072,16 +1165,8 @@ function BattleState:stepAnim(input)
end
end
-- NOTE on picHidden and the animation runtime. An animation that ENDS with a
-- pic box cleared could latch it here, and the tilemap argument says it should:
-- BattleAnimRestoreHuds redraws the two HUDs and nothing else. It deliberately
-- does not, because BATTLE_BG_EFFECT_REMOVE_MON and _RETURN_MON are also used
-- by moves whose user is still standing there afterwards -- SUBSTITUTE (the
-- doll takes the box over, and nothing in this port draws one yet), SKY_ATTACK,
-- BEAT_UP, BATON_PASS -- and a blanket latch would make those mons invisible
-- for the rest of the fight. The two moments the cart really does leave the
-- box empty for good are latched explicitly instead: a catch (pushCaught) and
-- a faint (MonFaintedAnimation, in update).
-- REMOVE_MON / RETURN_MON also serve SUBSTITUTE, SKY_ATTACK, BEAT_UP and
-- BATON_PASS, so picHidden is only latched by a catch and a faint.
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
-- HUD update run the moment its animation is done, cut short or not.
@@ -1098,9 +1183,11 @@ function BattleState:animPicState(side)
local bg = self.anim.bg
return {
hidden = bg.hidden[side],
lifted = bg.liftedRows and bg.liftedRows[side] or nil,
size = bg.picSize[side],
slide = bg.slide[side] or 0,
shade = bg.monShade[side],
pic = self.anim.picOverride[side],
}
end
@@ -1190,22 +1277,14 @@ function BattleState:advanceQueue()
if event.kind == "level" and event.index then
self.evolvable[event.index] = true
-- GiveExperiencePoints' `.skip_active_mon_update` guard
-- (engine/battle/core.asm:6999-7003): only the mon that is OUT copies its
-- recalculated HP, max HP and level into the battle struct, and only then
-- does `callfar UpdatePlayerHUD` (:7034) redraw the bar. That is a
-- REDRAW, not AnimateHPBar, so the shown HP snaps instead of chasing --
-- without it the bar kept the pre-level-up HP against the new maximum
-- until the next damage or heal event moved it.
-- (engine/battle/core.asm:6999-7003): the OUT mon's shown HP snaps.
local battle = self.battle
local mon = battle and battle.party and battle.party[event.index]
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
-- gets the stats box, not just the mon currently on the field.
self.pendingStatsMon = mon
-- engine/battle/core.asm:7044
-- engine/battle/core.asm:7284
if mon and mon == battle.player then
event.text = nil
event.sfx = nil
event.waitSfx = nil
if self.shownHp then
self.shownHp.player = mon.hp or 0
if self.hpAnim and self.hpAnim.side == "player" then
@@ -1213,9 +1292,6 @@ function BattleState:advanceQueue()
end
end
-- `ld [wBattleMonLevel], a` in the same guarded block (:7018-7020).
-- AnimateExpBar has already walked the number up one level at a time by
-- the time this runs, so this only catches a level gained with no exp
-- crawl behind it.
self.shownLevel = mon.level or self.shownLevel
end
end
@@ -1367,16 +1443,14 @@ function BattleState:advanceQueue()
end
if event.text then
self.message = event.text
-- Lines that must not hold the queue for A/B:
-- move UsedMoveText -> text_end, then moveanim
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
-- box's WaitPressAorB is the real hold
-- experience keeps the wait: _ExpPointsText ends in `prompt`
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
-- so the bar crawls under the line and A dismisses it before the battle
-- can end.
-- move/level lines do not hold for A/B (battle.asm:336-343); experience
-- keeps the wait (common_1.asm:1660-1665).
if event.kind == "move" or event.kind == "level" then
self.messageTimer = 0
-- engine/battle/effect_commands.asm:1958-1961
if event.kind == "move" and event.missed then
self.messageDelay = MOVE_DELAY_FRAMES
end
else
self.messageTimer = MESSAGE_FRAMES
end
@@ -1398,17 +1472,12 @@ function BattleState:advanceQueue()
if event.waitSfx then self.waitSfx = event.sfx end
end
end
-- The move's own animation plays over its "used X!" line, which is where
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
-- event.missed on every wAttackMissed path.
-- engine/battle/effect_commands.asm:1958: a missed move burns the delay
-- and plays nothing; the after-anim chain is animForMove / stepAnim's.
if event.kind == "move" and not event.missed then
self.afterAnimPlayed = nil
self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side) then
if not self:animForMove(event.move, event.side, event.animParam) then
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
-- (anim_commands.asm:55-72 .disabled fallthrough).
local options = self.game and self.game.options
@@ -1660,6 +1729,64 @@ function BattleState:playerMoves()
return (self.battle and self.battle.player and self.battle.player.moves) or {}
end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if choice == "fight" then
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
self:submit({ kind = "move", move = Battle.STRUGGLE })
else
self.phase = "moves"
-- MoveSelectionScreen reopens on the last used move, clamped if the
-- moveset shrank since then.
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
end
elseif choice == "run" then
self:submit({ kind = "run" })
elseif choice == "item" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moves" then return nil, "move menu is not active" end
local move = self:playerMoves()[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
self.moveSwapIndex = nil
if (move.pp or 0) <= 0 then
self:refuseMove(TEXT_NO_PP_LEFT)
elseif self.battle:moveDisabled(self.battle.player, move.id) then
self:refuseMove(TEXT_MOVE_DISABLED)
else
self:submit({ kind = "move", move = move.id })
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moves" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under
-- the cursor, and A or B clears the mark without swapping (the A arm opens
@@ -1772,6 +1899,11 @@ function BattleState:update(_dt)
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
-- before any PromptButton wait so the bar does not sit frozen until A.
if self:stepExpAnim() then return end
-- engine/battle/effect_commands.asm:6661
if (self.messageDelay or 0) > 0 then
self.messageDelay = self.messageDelay - 1
return
end
if self.messageTimer > 0 then
if self.tutorial then
-- PromptButton waits for the button; the tutorial cannot press it, so
@@ -1833,37 +1965,7 @@ function BattleState:update(_dt)
or self.menuIndex - 2
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
local choice = MENU[self.menuIndex]
if choice == "FIGHT" then
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
-- :5058-5059): a mon with nothing to spend never sees the list.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
return self:submit({ kind = "move", move = Battle.STRUGGLE })
end
self.phase = "moves"
-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1
-- (engine/battle/core.asm:5111) and the A-press writes the picked row
-- back, so the list reopens on the move used last turn; only
-- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than
-- reset, for a moveset that shrank (Mimic, a forgotten slot).
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
elseif choice == "RUN" then
self:submit({ kind = "run" })
elseif choice == "PACK" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
else
self:openParty()
end
self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
end
return
end
@@ -1884,22 +1986,12 @@ function BattleState:update(_dt)
elseif input:wasPressed("b") then
-- B leaves the list, and a mark never survives it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
self.phase = "menu"
self:cancelMove()
elseif input:wasPressed("a") then
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
-- cancels a pending swap rather than performing it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
local move = moves[self.moveIndex]
if not move then return end
-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen`
-- (engine/battle/core.asm:5213-5246): neither spends the turn.
if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end
if self.battle:moveDisabled(self.battle.player, move.id) then
return self:refuseMove(TEXT_MOVE_DISABLED)
end
self:submit({ kind = "move", move = move.id })
self:chooseMove(self.moveIndex)
end
return
end
@@ -2421,14 +2513,6 @@ function BattleState:pushCaught(enemy, itemId)
local save = self.save
self.battle.over = true
self.battle.outcome = "caught"
-- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught
-- arm ends on the return-mon BG effect, which leaves the enemy pic box
-- cleared, and PokeBallEffect never draws a frontpic again -- there is no
-- send-out left in a battle that is already over. Latched here as well as
-- from the animation's own last step so that a throw the player skipped with
-- B (BattleAnimRunScript has no such skip; this port does) cannot put the
-- caught mon back on the field for the "Gotcha!" line.
self.picHidden.enemy = true
-- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to
-- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's
-- whole effect; its catch rate is a plain ball's. It applies on the box
@@ -2464,7 +2548,10 @@ function BattleState:pushCaught(enemy, itemId)
self:push({ kind = "dex-entry", species = enemy.species })
end
if self.contest then
return self:contestCatch(enemy)
-- A contest catch still exits through the `.run` arm with result WIN
-- (engine/battle/core.asm:4780-4783), so CheckPayDay runs for it too.
self:contestCatch(enemy)
return self:pushPayDay()
end
save.party = save.party or {}
local toPc = #save.party >= Boxes.PARTY_SIZE
@@ -2520,6 +2607,19 @@ function BattleState:pushCaught(enemy, itemId)
self:push({ kind = "message",
text = self:name(enemy) .. " was sent to BILL's PC." })
end
self:pushPayDay()
end
-- CheckPayDay runs on a capture too: `and $f` keeps the win arm
-- (engine/battle/core.asm:7971-7976, :8014-8042).
function BattleState:pushPayDay()
local save = self.save
local coins = Prize.payDay(save, self.battle.payDay, self.battle.amuletCoin)
self.battle.payDay = nil
if coins then
self:push({ kind = "message",
text = Prize.payDayMessage(coins, save.player and save.player.name) })
end
end
-- CheckWhetherToAskSwitch: a started battle, more than one mon, no link, the
@@ -2874,6 +2974,7 @@ function BattleState:useItem(itemId)
-- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything
-- pushed above is drained only once the ball has finished wobbling.
self:startBallAnim(self:ballAnimParam(itemId), itemId)
if caught and not self.anim then self.picHidden.enemy = true end
self.message = nil
self.messageTimer = 0
self.phase = "resolving"
@@ -3029,7 +3130,7 @@ function BattleState:applyPartyItem(itemId, action, mon, slot)
local before = (mon and mon.hp) or 0
local result
if action == "pp" then
result = ItemEffects.usePpItem(itemId, mon, slot)
result = ItemEffects.usePpItem(itemId, mon, slot, data)
else
result = ItemEffects.useOnMon(itemId, mon, data)
end
@@ -3382,6 +3483,36 @@ function BattleState:drawScene()
end
end
-- data/battle_anims/objects.asm:390-397: the lifted band rides at ABSOLUTE_X,
-- outside the scanline blit, so the attacker's SCX never moves it.
function BattleState:drawLiftedRows()
local battle = self.battle
if not battle then return end
local enemy = self:animPicState("enemy")
local player = self:animPicState("player")
local enemyLift = enemy and enemy.lifted
local playerLift = player and player.lifted
if not (enemyLift or playerLift) then return end
local G = love.graphics
if not self.liftCanvas then
self.liftCanvas = G.newCanvas(160, 144)
self.liftCanvas:setFilter("nearest", "nearest")
end
local previous = G.getCanvas()
G.setCanvas(self.liftCanvas)
G.clear(0, 0, 0, 0)
G.push()
G.origin()
self.liftedPass = true
if enemyLift then self:drawPic(battle.enemy, false) end
if playerLift then self:drawPic(battle.player, true) end
self.liftedPass = nil
G.pop()
G.setCanvas(previous)
G.setColor(1, 1, 1, 1)
G.draw(self.liftCanvas, 0, 0)
end
function BattleState:drawSceneBody()
local panel = function() self:drawPanel() end
if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then
@@ -3402,7 +3533,8 @@ function BattleState:drawSceneBody()
return
end
if self.anim and self.animView then
self.animView:present(self.anim, panel)
self.animView:present(self.anim, panel, self.battle)
self:drawLiftedRows()
self.animView:drawObjects(self.anim, self.battle)
return
end
+2 -1
View File
@@ -28,6 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end
function BattleTransition:draw()
local w, h = love.graphics.getDimensions()
local w, h = GameViewport.dimensions()
self:drawWidescreen(w, h)
end
+11
View File
@@ -172,6 +172,17 @@ local ROWS = {
text = function(options)
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
end },
{ label = "VIDEO MODE", key = "videoMode", port = true,
cycle = function(options, delta)
local VideoMode = require("src.core.VideoMode")
options.videoMode = VideoMode.cycle(options.videoMode, delta)
VideoMode.apply(options.videoMode)
end,
text = function(options)
local VideoMode = require("src.core.VideoMode")
return VideoMode.normalize(options.videoMode) == "borderless"
and "FULL" or "WINDOWED"
end },
{ id = "touchControls", label = "TOUCH PAD", port = true,
text = function(options)
local tc = options.touchControls
+8
View File
@@ -283,6 +283,14 @@ function PartyMenu:finishSwitch()
if self.save and self.save.party == party then
Mail.swapSlots(self.save, from, to)
end
-- engine/pokemon/switchpartymons.asm:38
local data = self.game and self.game.data
local ok, Sound = pcall(require, "src.core.Sound")
if not (ok and data and Sound and Sound.play) then return end
local sfx = data.audio and data.audio.sfx
if sfx and sfx[Sound.resolve(data, "Sfx_SwitchPokemon")] then
pcall(Sound.play, data, "Sfx_SwitchPokemon")
end
end
-- The reopened list: InitPartyMenuNoCancel caps the cursor at the last mon,
+4 -18
View File
@@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts)
self.game = game
self.save = opts.save or (game and game.save)
local data = game and game.data or {}
-- Held, not just read into the fields below. CRY resolves its sample
-- through data.audio.cries and AREA resolves nests and landmark names
-- through data.maps / data.landmarks, and every one of those reads
-- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry
-- returned before it reached Sound, and the button did nothing at all.
-- Taken by reference so a mod's merged cry or landmark is the one used.
-- engine/pokedex/pokedex.asm:447
self.data = data
self.dex = opts.pokedex or data.gen2Pokedex
self.pokemon = opts.pokemon or data.pokemon
@@ -866,15 +861,6 @@ function PokedexMenu:drawArea()
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
local G = love.graphics
local table_ = self.data and self.data.landmarks
local byIndex = self.landmarkByIndex
if not byIndex then
byIndex = {}
for _, entry in pairs((table_ and table_.landmarks) or {}) do
if entry and entry.index then byIndex[entry.index] = entry end
end
self.landmarkByIndex = byIndex
end
if #nests == 0 then
-- A species with no grass, water or roamer entry in this region. The cart
@@ -883,11 +869,11 @@ function PokedexMenu:drawArea()
return
end
-- Blinking markers, the way the cart flashes its OBJs.
-- engine/pokegear/pokegear.asm:2427
local on = ((self.areaBlink or 0) % 32) < 20
if cells and on then
for _, index in ipairs(nests) do
local mark = byIndex[index]
local mark = Nests.landmark(self.data, index)
if mark and mark.x and mark.y then
G.setColor(0, 0, 0, 1)
G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5)
@@ -900,7 +886,7 @@ function PokedexMenu:drawArea()
-- Name the first one in words as well as on the map: the flashing dot is
-- unreadable at this size on a modern display, and the landmark name is what
-- a player actually wants off this screen.
local first = byIndex[nests[1]]
local first = Nests.landmark(self.data, nests[1])
if first and first.name then
local name = tostring(first.name):gsub("\n", " ")
self:text(name, 1, 16)
+2 -1
View File
@@ -17,6 +17,7 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local SafeArea = require("src.core.SafeArea")
local GameViewport = require("src.render.GameViewport")
local Layout = {}
@@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
W, H = love.graphics.getDimensions()
W, H = GameViewport.dimensions()
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)
+16 -29
View File
@@ -33,6 +33,7 @@ function NPC.new(data, mapId, objDef)
self.facing = FACING_FROM_RANGE[objDef.range] or "down"
self.moving = false
self.progress = 0
self.animClock = 0
self.stepFlip = false
self.frozen = false -- scripts freeze NPCs while talking
self.wanders = objDef.movement == "WALK"
@@ -52,31 +53,15 @@ function NPC:facePlayer(player)
end
function NPC:update(map, entities)
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
-- the 1px step vector once per call (engine/overworld/movement.asm), but
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
--
-- self.stepFrames overrides the shared walk for an object whose
-- step has to stay in phase with something else: Yellow's follower
-- Pikachu takes the player's own step length, halved while it is more
-- than a cell behind (FastPikachuFollow, engine/pikachu/
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
-- command: two cells of travel inside one step's frames
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
-- pixel span doubles while the frame count does not. Nothing else sets
-- either field, so every other object keeps the constant (#410, #409).
-- engine/overworld/movement.asm:301, 32 frames per NPC cell; stepFrames is
-- the follower's own step length (#410, #409).
local stepLen = self.stepFrames or STEP_FRAMES
local span = self.hopStep and 2 or 1
if self.moving then
self.progress = self.progress + 1
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
-- pinned to the current cell while walkPhase() cycles.
self.animClock = (self.animClock or 0) + 1
-- NPC_CHANGE_FACING (movement.asm ChangeFacingDirection): walk cycle in
-- place, no translation.
if self.marching then
if self.progress >= stepLen then
self.progress = 0
@@ -122,18 +107,20 @@ end
function NPC:walkPhase()
if not self.moving then return 0 end
local stepLen = self.stepFrames or STEP_FRAMES
local p = self.progress % stepLen
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
-- engine/overworld/movement.asm:301
local p = (self.animClock or 0) % 16
return (p >= 4 and p < 12) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
-- phase this frame renders to -- so a render pipeline can pose an NPC
-- without caring which kind of entity it is. An NPC never hops, so the
-- trailing hop flag is always false.
-- Same contract as Player:pose; an NPC never hops, so the trailing hop
-- flag is always false.
function NPC:pose()
local flip = self.stepFlip
if self.moving then
flip = math.floor((self.animClock or 0) / 16) % 2 == 1
end
return self.sprite, self.px, self.py, self.facing,
self:walkPhase(), self.stepFlip, false
self:walkPhase(), flip, false
end
function NPC:draw(camX, camY)
+48 -18
View File
@@ -9,6 +9,7 @@ local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion")
local GameViewport = require("src.render.GameViewport")
local Logger = require("src.core.Logger")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
@@ -827,6 +828,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose)
return true
end
function OverworldState:useSoftboiledFieldMove(user, target)
local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0
if not user or not user.stats or not target or not target.stats
or target == user or target.hp <= 0
or target.hp >= target.stats.hp or user.hp <= heal then
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
return false
end
user.hp = user.hp - heal
target.hp = math.min(target.stats.hp, target.hp + heal)
require("src.core.Sound").play(Game.data, "Heal_HP")
local def = Game.data.pokemon[target.species]
Game.stack:push(TextBox.new(Game,
Strings("%s's HP\nwas restored!", target.nickname or def.name)))
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions
@@ -1969,8 +1987,11 @@ function OverworldState:tryBookshelf(fx, fy)
return true
end
if entry.screen then
-- Blue's house shelf opens the TOWN MAP (TownMapText)
pcall(Screens.push, Game, entry.screen)
-- engine/events/hidden_events/town_map.asm:1
Game.stack:push(TextBox.new(Game,
t[entry.text] or t._TownMapText
or romText(Game.data, "_TownMapText", "A TOWN MAP."),
function() pcall(Screens.push, Game, entry.screen) end))
return true
end
local kind = entry.kind
@@ -2136,14 +2157,17 @@ function OverworldState:tryHiddenObject(fx, fy)
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
if self.map.id == "REDS_HOUSE_2F" then
-- The player's bedroom PC is the one location in Red/Blue whose PC
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
-- which runs the PlayerPC predef directly -- item storage, no
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
-- OpenRedsPC (engine/events/hidden_objects/players_pc.asm) runs the
-- PlayerPC predef directly, no DisplayPCMainMenu (#228)
require("src.core.Sound").play(Game.data, "Turn_On_PC")
-- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960)
Screens.push(Game, "PlayerPC", { direct = true })
-- engine/menus/players_pc.asm:16
Game.stack:push(TextBox.new(Game,
(Game.data.text or {})._TurnedOnPC2Text
or romText(Game.data, "_TurnedOnPC2Text", "{PLAYER} turned on\nthe PC."),
function()
-- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960)
Screens.push(Game, "PlayerPC", { direct = true })
end, { instant = true }))
else
self:openPC()
end
@@ -2892,13 +2916,16 @@ function OverworldState:openPC(onDone)
done()
end
table.insert(items, { label = Strings("LOG OFF"), onSelect = logOff })
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
-- tw here (total width) is 16
Game.stack:push(Menu.new(Game, items,
-- BIT_NO_MENU_BUTTON_SOUND for the whole PC session; DisplayPCMainMenu's
-- TextBoxBorder c=14 interior -> tw 16 (engine/overworld/pokecenter_pc.asm)
local menu = Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff,
noSound = true }))
noSound = true })
-- engine/menus/pc.asm:5
Game.stack:push(TextBox.new(Game,
(Game.data.text or {})._TurnedOnPC1Text
or romText(Game.data, "_TurnedOnPC1Text", "{PLAYER} turned on\nthe PC."),
function() Game.stack:push(menu) end))
end
-- The PROF. OAK's PC session (engine/menus/oaks_pc.asm OpenOaksPC): the
@@ -3081,9 +3108,12 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
end))
end
if not npc then farewell() return end
npc.frameOverride = 3
-- engine/events/pokecenter.asm:36-39; Yellow's walk-down pose when the
-- sheet has it (pokeyellow engine/events/pokecenter.asm:82-88)
local yellow = GameVersion.isYellow()
npc.frameOverride = (yellow and npc.sprite.frames[3]) and 3 or 1
-- bubble = false is the silent world hold, this port's DelayFrames
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
self.emote = { npc = npc, frames = yellow and 40 or 20, bubble = false, onDone = function()
npc.frameOverride = nil
npc:facePlayer(self.player)
farewell()
@@ -5119,7 +5149,7 @@ function OverworldState:drawWorld()
-- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run.
local pw, ph = love.graphics.getDimensions()
local pw, ph = GameViewport.dimensions()
local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
+95 -4
View File
@@ -37,6 +37,57 @@ local function validPartySlot(party, slot)
and party[slot] ~= nil
end
local function outside(game, ow)
return Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets"))
end
local function knows(mon, moveId)
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then return true end
end
return false
end
local function monInfo(game, mon, slot)
local def = game.data.pokemon[mon.species] or {}
return { slot = slot, species = mon.species,
name = mon.nickname or def.name or mon.species, level = mon.level,
hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp }
end
local function softboiledSources(game)
local party, sources = game.save.party or {}, {}
for sourceSlot, source in ipairs(party) do
local heal = source.stats and math.floor(source.stats.hp / 5) or 0
if knows(source, "SOFTBOILED") and source.hp > heal then
local info = monInfo(game, source, sourceSlot)
info.targets = {}
for targetSlot, target in ipairs(party) do
if target ~= source and target.hp > 0 and target.stats
and target.hp < target.stats.hp then
info.targets[#info.targets + 1] = monInfo(game, target, targetSlot)
end
end
if #info.targets > 0 then sources[#sources + 1] = info end
end
end
return sources
end
local function flyDestinationAvailable(game, mapId)
local field, save = game.data.field or {}, game.save
for _, id in ipairs(field.flyOrder or {}) do
if id == mapId then
local def = game.data.maps and game.data.maps[id]
return not not (save.visited and save.visited[id]
and field.flyWarps and field.flyWarps[id]
and def and Map.isFlyTown(def))
end
end
return false
end
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
end
@@ -95,8 +146,10 @@ end
-- are listed; callers receive copied labels and never inspect world internals.
function WorldAPI:availableFieldActions()
local game, ow, out = self.game, self:overworld(), {}
if not (game and game.save and ow and ow.map and ow.player)
or not acceptsMenuInput(game, ow) then return out end
if not (game and game.save and ow and ow.map and ow.player) then
return out, NO_OVERWORLD
end
if not acceptsMenuInput(game, ow) then return out, "world is busy" end
local save, inventory = game.save, game.save.inventory or {}
local items = game.data and game.data.items or {}
@@ -139,10 +192,14 @@ function WorldAPI:availableFieldActions()
and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" }
end
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets")) then
if ow:partyKnows("TELEPORT") and outside(game, ow) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end
local sources = softboiledSources(game)
if #sources > 0 then
out[#out + 1] = { id = "softboiled", label = "SOFTBOILED",
sources = sources }
end
return out
end
@@ -185,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts)
elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut()
return true
elseif id == "softboiled" then
local sourceSlot = opts and tonumber(opts.sourceSlot)
local targetSlot = opts and tonumber(opts.targetSlot)
local allowed
for _, source in ipairs(found.sources or {}) do
if source.slot == sourceSlot then
for _, target in ipairs(source.targets or {}) do
if target.slot == targetSlot then allowed = true break end
end
end
end
if not allowed then return nil, "softboiled target unavailable" end
if ow:useSoftboiledFieldMove(game.save.party[sourceSlot],
game.save.party[targetSlot]) then return true end
end
return nil, "field action unavailable"
end
-- FLY needs a destination choice, so it is exposed separately from the
-- immediate actions above. The request is still checked against the same
-- visited-town list as the native Town Map picker before the world may warp.
function WorldAPI:canFly()
local game, ow = self.game, self:overworld()
return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY"))
end
function WorldAPI:flyTo(mapId)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not self:canFly() then return nil, "fly unavailable" end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
if not flyDestinationAvailable(game, mapId) then
return nil, "destination unavailable"
end
ow:flyTo(mapId)
return true
end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
+2 -2
View File
@@ -6,6 +6,7 @@ local Assets = require("src.render.Assets")
local BorderFill = require("src.world.gen2.BorderFill")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
local PixelCanvas = require("src.render.PixelCanvas")
local MapPreview = {}
@@ -96,9 +97,8 @@ function MapPreview.bake(baker, map, daytime)
local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
local okCanvas, canvas = pcall(PixelCanvas.new, pw, ph, "nearest")
if not okCanvas or not canvas then return nil end
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
local quads = {}
local function quadFor(tile)
local q = quads[tile]
+1 -1
View File
@@ -78,7 +78,7 @@ function StepEvents.poisonStep(party)
return { kind = "poisonFaint", fainted = fainted, hurt = hurt, blocks = true }
end
if #hurt > 0 then
-- .PlayPoisonSFX and the two-frame red flash, then `xor a`: no carry, so
-- .PlayPoisonSFX and the four-frame BG flash, then `xor a`: no carry, so
-- the step still counts and the wild roll still happens.
return { kind = "poisonHurt", hurt = hurt, blocks = false }
end
+99 -31
View File
@@ -47,6 +47,7 @@ local NPC = require("src.world.gen2.Npc")
local Party = require("src.pokemon.Party")
local Permissions = require("src.world.gen2.Permissions")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Player = require("src.world.gen2.Player")
local Pokerus = require("src.core.gen2.Pokerus")
local Roamers = require("src.core.gen2.Roamers")
@@ -1074,11 +1075,12 @@ function World:load()
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.seen[mon.species] = true
save.pokedex.caught[mon.species] = true
-- AddPartyMon's `.registerunowndex` runs on the same path, so a gifted
-- Unown lands in the form list too (nothing in Gold gives one, but the
-- cart's check is on the species, not on where it came from).
-- AddPartyMon's `.registerunowndex` runs on the same path, so a
-- gifted Unown lands in the form list too (move_mon.asm:347).
Unown.registerCatch(save, mon)
end
-- engine/pokemon/move_mon.asm:1632-1645
return mon
end,
giveItem = function(itemIndex, qty)
local data = self.game and self.game.data
@@ -2123,6 +2125,11 @@ function World:updateShake()
shake.phase = (shake.left % 2 == 0) and shake.amplitude or -shake.amplitude
end
-- engine/events/poisonstep_pals.asm:9
function World:poisonBGFlash()
self.poisonFlash = 4
end
-- Script_warp / Script_warpfacing: a raw destination CELL, distinct from the
-- warp_events World:takeWarp follows. `facing` is nil for `warp` and a
-- Movement direction for `warpfacing` (PLAYERSPRITESETUP_CUSTOM_FACING). A
@@ -2397,10 +2404,43 @@ function World:rollWild()
return { species = def.index, level = roll.level }
end
-- GetMapMusic (home/map.asm:2550)
function World.mapMusicLabel(audio, musicByte, rocketsMahogany, rocketsRadioTower)
if type(musicByte) ~= "number" then return nil end
local MUSIC_MAHOGANY_MART = 100 -- constants/music_constants.asm:100
local RADIO_TOWER_MUSIC = 0x80 -- constants/music_constants.asm:109
local order = audio and audio.musicOrder
local songs = audio and audio.songs
local label
if musicByte == MUSIC_MAHOGANY_MART then
label = rocketsMahogany and "Music_RocketHideout" or "Music_CherrygroveCity"
elseif musicByte >= RADIO_TOWER_MUSIC then
label = rocketsRadioTower and "Music_RocketTheme"
or (order and order[(musicByte - RADIO_TOWER_MUSIC) + 1])
else
return nil
end
if label and label ~= "Music_Nothing" and songs and songs[label] then
return label
end
return nil
end
function World:mapMusicSong(mapId)
local audio = self.game and self.game.data and self.game.data.audio
local def = self.maps and self.maps[mapId]
-- ENGINE_ROCKETS_IN_MAHOGANY / _RADIO_TOWER (data/events/engine_flags.asm:40,:36)
return World.mapMusicLabel(audio, def and def.music,
self:engineFlag(22), self:engineFlag(18))
end
function World:playMapMusic()
local data = self.game and self.game.data
if data and data.audio and data.audio.runtime and self.map then
Music.playMap(data, self.map.id)
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
end
@@ -3246,7 +3286,10 @@ function World:surfStartStep(mon)
self:applyPlayerState(FieldMoves.surfType(mon))
local audio = self.game and self.game.data and self.game.data.audio
if audio and audio.runtime and self.map then
Music.playMap(self.game.data, self.map.id)
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
if p.scriptStep then p:scriptStep(p.facing) end
self.fieldMove = { phase = "step" }
@@ -3556,7 +3599,7 @@ function World:rareWildMon()
local entry = self.encounters and self.encounters.grass
and map and self.encounters.grass[map.id]
if not entry or not entry.slots then return nil end
local key = (self.daytime == "DARK") and "NITE" or (self.daytime or "DAY")
local key = self.tod or "DAY"
local slots = entry.slots[key] or entry.slots.DAY
if not slots then return nil end
local rare = slots[4 + math.random(3)]
@@ -3949,7 +3992,7 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
-- 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,
daytime = self.tod,
environment = map and map.def and map.def.environment,
tables = tables,
data = self.game and self.game.data,
@@ -4012,7 +4055,8 @@ function World:tryWildEncounter()
if onWater then
rate = Encounter.waterRate(tables, map.id)
else
rate = Encounter.grassRate(tables, map.id, self.daytime)
-- engine/overworld/wildmons.asm:283
rate = Encounter.grassRate(tables, map.id, self.tod)
end
-- ApplyMusicEffectOnEncounterRate runs first (wildmons.asm:213-215).
rate = World.musicEncounterRate(rate, Music.mapSong())
@@ -4306,6 +4350,14 @@ function World:rollFishing(rod)
-- the flag and nothing about the map changes. Roamers.Swarm.fishing is the
-- same store CheckSwarmFlag clears when the swarm expires.
local swarm = Roamers.Swarm.fishing(game.save)
-- engine/events/fish.asm:24-30
local groupRow = self.encounters.fishGroups
and self.encounters.fishGroups[
Encounter.fishGroupFor(self.encounters, group, swarm)]
if groupRow and groupRow.chance
and not Encounter.triggers(groupRow.chance, nil) then
return "nibble"
end
local roll
if Runtime.wantsHook("encounter.fishing") then
-- Gen 1's three arguments, in Gen 1's order: the rod, the map, and the
@@ -5091,7 +5143,7 @@ function World:sweetScentEncounter()
local tables = self:wildTables()
local onWater = FieldMoves.encounterTable(collision) == "water"
local rate = onWater and Encounter.waterRate(tables, map.id)
or Encounter.grassRate(tables, map.id, self.daytime)
or Encounter.grassRate(tables, map.id, self.tod)
if not (rate and rate > 0) then return false end
-- CheckEncounterRoamMon, the first thing ChooseWildEncounter itself does:
-- a beast REPLACES the map's own slot rather than adding to it.
@@ -5363,7 +5415,10 @@ function World:runSurf(result)
self:applyPlayerState(result.state)
local audio = self.game and self.game.data and self.game.data.audio
if audio and audio.runtime and self.map then
Music.playMap(self.game.data, self.map.id)
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
if self.player and self.player.scriptStep then
self.player:scriptStep(self.player.facing)
@@ -5875,6 +5930,8 @@ function World:startBattle(opts, onDone)
-- World:startCatchTutorial sets it.
tutorial = opts.tutorial,
onDone = function(outcome)
-- WildBattleScript's reloadmapafterbattle (engine/overworld/events.asm:1158-1162)
self.wildCooldown = 5
self.battleActive = nil
game.stack:pop()
-- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2.
@@ -7491,7 +7548,7 @@ function World:interactBody()
end
function World:fitScale()
local w, h = love.graphics.getDimensions()
local w, h = require("src.render.GameViewport").dimensions()
return math.max(1, math.floor(math.min(w / 160, h / 144)))
end
@@ -7556,8 +7613,9 @@ function World:bakeMapImage(map, daytime, flicker)
local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32
local canvas = love.graphics.newCanvas(pw, ph)
canvas:setFilter("nearest", "nearest")
-- Map pixels, not the screen's: a DPI-scaled canvas bakes them non-square
-- (#208, see src/render/PixelCanvas.lua).
local canvas = PixelCanvas.new(pw, ph, "nearest")
local quads = {}
local function quadFor(tile)
local q = quads[tile]
@@ -8084,12 +8142,11 @@ function World:scrollStrip(mapDef, tileset, tile, scroll)
local cached = self.scrollStrips[key]
if cached ~= nil then return cached or nil end
local atlas = self:atlasFor(mapDef)
local ok, canvas = pcall(love.graphics.newCanvas, 8, 8 * 8)
local ok, canvas = pcall(PixelCanvas.new, 8, 8 * 8, "nearest")
if not (atlas and ok and canvas) then
self.scrollStrips[key] = false
return nil
end
canvas:setFilter("nearest", "nearest")
local perRow = tileset.tilesPerRow or 16
local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
local aw, ah = atlas:getDimensions()
@@ -8318,7 +8375,7 @@ function World:rebuildNeighbors()
self.neighbors = {}
if not self.map then return end
local s = self:zoomScale()
local ww, wh = love.graphics.getDimensions()
local ww, wh = require("src.render.GameViewport").dimensions()
local vw = math.ceil(ww / s)
local vh = math.ceil(wh / s)
if vw % 2 ~= 0 then vw = vw + 1 end
@@ -8524,13 +8581,13 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- rebuildPeople may have pooled fresh NPCs; give them their colors too.
self:applyPalettes()
local audio = self.game and self.game.data and self.game.data.audio
-- PlayMapMusicBike (home/audio.asm), which is the mapsetup every load uses:
-- a player still on the bike keeps the bike theme across the warp instead of
-- hearing the new map's song. Music.play dedupes the same label, so this is
-- safe on seamless edge crossings.
-- PlayMapMusicBike / SpecialMapMusic (home/audio.asm:335, :397): a biking
-- player keeps the bike theme; Music.play dedupes seamless edge crossings.
if audio and audio.runtime then
if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then
Music.playMap(self.game.data, mapId)
Music.playMap(self.game.data, mapId, nil,
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(mapId))
end
end
-- Fires with the map fully built and BEFORE the map's own scene script, so a
@@ -8912,14 +8969,14 @@ function World:movePlayer(dir)
if result == "moved"
and Permissions.surfable(map:cellCollision(p.targetX, p.targetY))
== "land" then
-- .ExitWater: GetOutOfWater writes PLAYER_NORMAL and runs
-- UpdatePlayerSprite BEFORE .DoStep, so the player is already off the
-- Lapras for the step that puts them on the beach, and PlayMapMusic then
-- swaps the surfing theme back for the map's own.
-- .ExitWater: GetOutOfWater writes PLAYER_NORMAL before .DoStep, then
-- PlayMapMusic swaps the surf theme back (home/audio.asm:308)
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
local audio = self.game and self.game.data and self.game.data.audio
if audio and audio.runtime then
Music.playMap(self.game.data, map.id)
Music.playMap(self.game.data, map.id, nil,
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(map.id))
end
end
end
@@ -9159,7 +9216,7 @@ function World:countStep()
elseif event.kind == "poisonFaint" then
self:poisonFaintScript(event)
elseif event.kind == "poisonHurt" then
-- .PlayPoisonSFX alone: the sound and the two-frame red flash, no script.
-- .PlayPoisonSFX alone: the sound and the four-frame BG flash, no script.
CallAsm.run(self, "PlayPoisonSFX")
elseif event.kind == "repel" then
self:repelWoreOff()
@@ -9685,7 +9742,7 @@ function World:drawGround(s)
if canvas then
bw, bh = canvas:getDimensions()
else
bw, bh = G.getDimensions()
bw, bh = require("src.render.GameViewport").dimensions()
end
BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, self.map.id)
@@ -9889,8 +9946,7 @@ function World:drawTilted(w, h, s, gw, gh)
if self.tiltCanvas and self.tiltCanvas.release then
self.tiltCanvas:release()
end
self.tiltCanvas = G.newCanvas(gw, gh)
self.tiltCanvas:setFilter("linear", "linear")
self.tiltCanvas = PixelCanvas.new(gw, gh, "linear")
end
local previous = G.getCanvas()
@@ -9946,7 +10002,7 @@ end
function World:draw()
local G = love.graphics
local w, h = G.getDimensions()
local w, h = require("src.render.GameViewport").dimensions()
self:refreshColorMode()
G.clear(0.07, 0.05, 0.02, 1)
@@ -10031,6 +10087,18 @@ function World:draw()
G.setColor(1, 1, 1, 1)
end
-- engine/events/poisonstep_pals.asm:9-42
if self.poisonFlash and self.poisonFlash > 0 then
self.poisonFlash = self.poisonFlash - 1
if GbcPalette.mode == "gbc" then
G.setColor(28 / 31, 21 / 31, 1, 0.55)
else
G.setColor(0, 0, 0, 0.45)
end
G.rectangle("fill", 0, 0, w, h)
G.setColor(1, 1, 1, 1)
end
-- FadeOutToWhite / FadeOutToBlack, held until a FadeInFrom* clears it. On
-- the cart the pair brackets a scripted cutscene's set change (the Elite Four
-- doors, the Radio Tower takeover, Lugia's chamber); the port has no
+4 -2
View File
@@ -78,8 +78,10 @@ end
-- collision and fishing rules.
function WorldAPI:availableFieldActions()
local world, game, out = self:overworld(), self.game, {}
if not (world and game and game.save and world.map and world.player)
or not world:acceptsMenuInput() then return out end
if not (world and game and game.save and world.map and world.player) then
return out, NO_OVERWORLD
end
if not world:acceptsMenuInput() then return out, "world is busy" end
local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then