mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user