CLOSES #1211, CLOSES #1228, CLOSES #1229, CLOSES #1232, CLOSES #1251, CLOSES #1265, CLOSES #1267, CLOSES #1276, CLOSES #1279, CLOSES #1282, CLOSES #1293, CLOSES #1296, CLOSES #1303, CLOSES #1329, CLOSES #1338, CLOSES #1341, CLOSES #1343, CLOSES #1344, CLOSES #1368, CLOSES #1385, CLOSES #1388, CLOSES #1389, CLOSES #1391

This commit is contained in:
bryanthaboi
2026-08-16 08:55:40 -04:00
parent 65128e13a4
commit 1151c188a7
50 changed files with 2835 additions and 276 deletions
+20 -10
View File
@@ -583,7 +583,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
@@ -5923,10 +5924,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
@@ -5953,13 +5960,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)
+5 -5
View File
@@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
hitSfx = { sound = "Damage", pitch = 0x20 }
end
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
-- the screen vertically) for a damaging move with no added effect, and
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
-- Bubblebeam and Confusion shake instead of blinking (#354)
-- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once
-- the move has an added effect (#354)
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
-- PlayApplyingAttackAnimation runs on both arms of the wOptions check
-- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384)
local hitFx = { sfx = hitSfx,
animType = user.isPlayer and (added and 5 or 4)
or (added and 2 or 1),
blink = battle:animationsOn() and target or nil }
blink = target }
local totalDealt = 0
local landed, brokeSub = 0, false
+6 -12
View File
@@ -192,14 +192,8 @@ function Runner:loadGfx(names)
end
end
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
-- APPENDED after whatever the script already loaded rather than replacing it,
-- and they always land on the same two fixed tile ids.
--
-- (pokegold's jumptable has these two labels the other way round from the
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
-- is what a script actually writes.)
-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro
-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401)
function Runner:loadBattlerGfx(rows)
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
local slot = 1
@@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows)
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
battler = "player", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7,
battler = "enemy", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6,
battler = "player", rows = rows }
end
--------------------------------------------------------------------------
+80 -31
View File
@@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = {
EFFECT_PARALYZE_HIT = "paralyze",
EFFECT_SLEEP_HIT = "sleep",
EFFECT_CONFUSE_HIT = "confuse",
EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696
}
local function rand(random, n)
@@ -1320,6 +1321,18 @@ function Battle:markMissed()
if self.moveEvent then self.moveEvent.missed = true end
end
-- engine/battle/effect_commands.asm:3615
Battle.AI_FAIL_STATUSES = {
sleep = true, poison = true, toxic = true, paralyze = true,
}
-- engine/battle/effect_commands.asm:3615
function Battle:aiRandomFail(attacker, defender)
if self:sideOf(attacker) ~= "enemy" then return false end
if self:volatile(defender).lockOn then return false end
return rand(self.random, 256) < 64
end
-- One attack, start to finish.
function Battle:useMove(attacker, defender, moveId)
local move = self:findMove(attacker, moveId)
@@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId)
if charge and not charging then
state.chargeMove = moveId
state.vanished = charge.vanish or nil
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table
-- keyed by effect cannot tell them apart and DIG announced itself with
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the
-- effect: `cp DIG` and then the burrow text.
-- engine/battle/effect_commands.asm:5458
if self.moveEvent then self.moveEvent.animParam = 1 end
-- BattleCommand_Charge picks the line off the MOVE, not the shared
-- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464).
local text = charge.text
if moveId == "DIG" then text = "%s dug a hole!" end
self:emit({ kind = "message", text = text:format(name) })
@@ -1811,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
@@ -1853,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)
@@ -2425,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
@@ -2910,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)
@@ -2917,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",
@@ -2953,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")
@@ -4434,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
+3 -3
View File
@@ -387,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
+23 -30
View File
@@ -358,42 +358,33 @@ end
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
-- (constants/script_constants.asm).
-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the
-- routine that prints it is engine code and no script bytecode points at the
-- string, so the extractor never reaches it.
-- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so
-- the extractor never reaches it.
local CONTEST_NICKNAME_PROMPT =
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123)
function Specials.askNickname(vm, mon)
nameMon(vm, mon.species)
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- InitNickname (engine/pokemon/move_mon.asm:1787)
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's blank test (home/string.asm:6-30)
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
end
H.CheckPartyFullAfterContest = function(vm)
local Breeding = require("src.core.gen2.Breeding")
local result, mon =
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
-- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the
-- mon that joined the party and the one that went to the box -- and nowhere
-- else in the contest: BugContest_SetCaughtContestMon merely holds the catch
-- in wContestMon, so this is the only place the player is ever asked.
-- GetPokemonName runs first, which is what {STRBUF} reads.
-- GiveANickname_YesNo runs on both contest arms, party and box
if mon and result ~= BugContest.NO_CATCH then
nameMon(vm, mon.species)
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is
-- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page
-- left standing.
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a
-- fresh catch (the Name Rater is the one that pre-fills), and InitNickname
-- copies the species name back over an empty entry -- so a cancelled
-- keyboard is the same as answering NO.
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's own blank test (home/string.asm:6-30): "zero or more
-- spaces followed by a null". The keyboard's blank cells are real
-- typeable characters, so an all-space entry has to be discarded the
-- same way an empty one is, not stored as a name of spaces.
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
Specials.askNickname(vm, mon)
end
answer(vm, result)
end
@@ -1005,7 +996,7 @@ end
-- check is transcribed here rather than left to the screen, because its two
-- refusals are TEXT and the script has to see them before the machine opens:
-- no coins at all, or no COIN_CASE to hold them.
local COIN_CASE = 0x47 -- constants/item_constants.asm
local COIN_CASE = 0x36 -- constants/item_constants.asm:62
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
local NO_COINS_TEXT = "You have no coins."
@@ -2409,7 +2400,9 @@ local STUB_ROWS = {
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
{ "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" },
-- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to
-- WaitForLinkedFriend and lands on .FriendNotReady
{ "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" },
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
{ "TradeCenter", nil, "link cable: no trade room" },
{ "Colosseum", nil, "link cable: no battle room" },
+6 -1
View File
@@ -544,7 +544,12 @@ local function runCmd(self, cmd, op)
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
if self.givePokeFn then
self.givePokeFn(species, level, item)
local mon = self.givePokeFn(species, level, item)
-- engine/pokemon/move_mon.asm:1753-1757
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
if mon and trainer == 0 then
Specials.askNickname(self, mon)
end
end
elseif op == "checkpoke" then
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
+124 -38
View File
@@ -14,6 +14,7 @@
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
return speciesOrOpts, false
end
local function ownedFor(game, def, forceOwned)
return forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
end
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
local function descPages(game, def, forceOwned)
local e = def.dexEntry or {}
local owned = ownedFor(game, def, forceOwned)
local text = owned and e.text and game.data.text[e.text] or nil
if not text then return nil end
local pages = {}
for chunk in (text .. "\f"):gmatch("(.-)\f") do
local lines = {}
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
end
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
if #lines > 0 then pages[#pages + 1] = lines end
end
if #pages == 0 then return nil end
local last = pages[#pages]
last[#last] = last[#last] .. "."
return pages
end
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
local frameCache = {}
local function frameSheet(game)
local fx = game.data.field and game.data.field.overworldFx
local def = fx and fx.pokedexFrame
local path = def and def.path
if not path then return nil end
local hit = frameCache[path]
if hit ~= nil then return hit or nil end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then
frameCache[path] = false
return nil
end
local iw, ih = img:getDimensions()
local quads = {}
for i = 0, 17 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8, 8, 8, iw, ih)
end
frameCache[path] = { img = img, quads = quads }
return frameCache[path]
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
}
function DexEntryMenu.new(game, speciesOrOpts, onDone)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned,
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.def = game.data.pokemon[species]
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
-- pcall's second return has to survive the guard (#307)
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species)
return self
end
@@ -57,6 +115,11 @@ end
function DexEntryMenu:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
self.page = self.page + 1
return
end
self.game.stack:pop()
if self.onDone then self.onDone() end
end
@@ -64,64 +127,87 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor)
self.spriteTrueColor, self.page)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
-- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
page = page or 1
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
if frame then
local function tile(code, tx, ty)
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
end
-- engine/menus/pokedex.asm:418
for tx = 1, 18 do
tile(0x64, tx, 0)
tile(0x6f, tx, 17)
end
for ty = 1, 16 do
tile(0x66, 0, ty)
tile(0x67, 19, ty)
end
tile(0x63, 0, 0)
tile(0x65, 19, 0)
tile(0x6c, 0, 17)
tile(0x6e, 19, 17)
-- engine/menus/pokedex.asm:445
for tx = 0, 19 do
tile(DIVIDER[tx + 1], tx, 9)
end
end
if sprite then
local y = math.max(0, 60 - sprite:getHeight())
love.graphics.draw(sprite, 8, y)
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
-- it renders to its own PNG canvas, and a mark left behind there would
-- bleed into the next real frame.
-- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
local w, h = sprite:getDimensions()
local x = 8 + math.floor((8 - w / 8) / 2) * 8
local y = 8 + (7 - h / 8) * 8
love.graphics.draw(sprite, x + w, y, 0, -1, 1)
-- the unshaded pass needs the pic bounds (#350)
if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
-- engine/menus/pokedex.asm:454
Font.draw(def.name, 72, 16)
local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
-- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
Font.draw(e.kind or "?", 72, 32)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
-- engine/menus/pokedex.asm:478
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
if e.heightM then
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
else
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 48)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
end
end
local text = owned and e.text and game.data.text[e.text] or nil
local y = 72
if text then
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if y > 132 then break end
Font.draw(line, 8, y)
y = y + 10
local pages = descPages(game, def, forceOwned)
if pages then
-- engine/menus/pokedex.asm:568
local lines = pages[page] or pages[#pages]
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245
if page < #pages then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
Font.draw(Strings("Data unknown."), 8, y)
Font.draw(Strings("Data unknown."), 8, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
+112
View File
@@ -0,0 +1,112 @@
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local HallOfFame = require("src.ui.HallOfFame")
local LeaguePC = {}
LeaguePC.__index = LeaguePC
LeaguePC.isOpaque = true
-- constants/pokemon_data_constants.asm:65
local CAPACITY = 50
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
function LeaguePC:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local mon = self:currentMon()
local c = mon and P.monPal(game.data, mon.species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
end
function LeaguePC.new(game, onDone)
local self = setmetatable({}, LeaguePC)
self.game = game
self.onDone = onDone
self.teams = (game.save and game.save.hallOfFame) or {}
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
self.monIndex = 1
self.sprites = {}
self.spriteTrueColor = {}
self:loadMon()
return self
end
function LeaguePC:currentMon()
local team = self.teams[self.teamIndex]
return team and team[self.monIndex] or nil
end
function LeaguePC:loadMon()
local mon = self:currentMon()
if not mon then return end
local species = mon.species
if self.sprites[species] == nil then
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprites[species] = ok and img or false
self.spriteTrueColor[species] = (ok and img and trueColor) or false
end
require("src.core.Sound").playCry(self.game.data, species)
end
function LeaguePC:close()
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function LeaguePC:update(dt)
local input = self.game.input
if input:wasPressed("b") then
self:close()
return
end
if input:wasPressed("a") then
if not self:currentMon() then
self:close()
return
end
local team = self.teams[self.teamIndex]
if self.monIndex < #team then
self.monIndex = self.monIndex + 1
elseif self.teamIndex < #self.teams then
self.teamIndex = self.teamIndex + 1
self.monIndex = 1
else
self:close()
return
end
self:loadMon()
end
end
function LeaguePC:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local mon = self:currentMon()
if not mon then return end
local img = self.sprites[mon.species]
if img then
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
local w, h = img:getDimensions()
local x = 96 + math.floor((8 - w / 8) / 2) * 8
local y = 40 + (7 - h / 8) * 8
love.graphics.draw(img, x, y)
if self.spriteTrueColor[mon.species] then
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
-- engine/movie/hall_of_fame.asm:159
HallOfFame.drawMonInfo(self, mon)
-- engine/menus/league_pc.asm:102
Font.drawBox(0, 13, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return LeaguePC
+11 -1
View File
@@ -79,7 +79,16 @@ end
function NamingScreen:enter()
if self.presets and #self.presets > 0 then
local Menu = require("src.ui.Menu")
local items = { { label = Strings("NEW NAME") } }
-- engine/movie/oak_speech/oak_speech2.asm:1
self.choosing = true
self.isOpaque = false
local items = { {
label = Strings("NEW NAME"),
onSelect = function()
self.choosing = nil
self.isOpaque = nil
end,
} }
for _, preset in ipairs(self.presets) do
table.insert(items, {
label = preset,
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
end
function NamingScreen:draw()
if self.choosing then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
+32 -15
View File
@@ -220,6 +220,20 @@ function TownMap.new(game, opts)
-- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
-- fly destination (PALLET_TOWN), never the player's current town (#795).
@@ -345,18 +359,16 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- the player's current location blinks (slow phase). Paint it with a
-- palette-safe DARK shade (red 0), not red: this screen composites through
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
-- {165,214,255}, the exact light-blue used for the water and the town-square
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
@@ -389,11 +401,16 @@ function TownMap:draw()
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
-- palette-safe dark, same red-channel shade-remap reason as the primary
-- grid path above (#152); stale-asset builds hit this fallback square
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerSheet then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
end
if selected and self.blink % 16 < 10 then
love.graphics.setColor(0, 0, 0, 1)
+34 -38
View File
@@ -49,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,
@@ -239,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.
@@ -1050,10 +1046,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
@@ -1091,14 +1087,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.
@@ -1106,6 +1110,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
@@ -1391,16 +1396,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
@@ -1422,17 +1425,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
@@ -1854,6 +1852,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
@@ -2463,14 +2466,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
@@ -2932,6 +2927,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"
+4 -18
View File
@@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts)
self.game = game
self.save = opts.save or (game and game.save)
local data = game and game.data or {}
-- Held, not just read into the fields below. CRY resolves its sample
-- through data.audio.cries and AREA resolves nests and landmark names
-- through data.maps / data.landmarks, and every one of those reads
-- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry
-- returned before it reached Sound, and the button did nothing at all.
-- Taken by reference so a mod's merged cry or landmark is the one used.
-- engine/pokedex/pokedex.asm:447
self.data = data
self.dex = opts.pokedex or data.gen2Pokedex
self.pokemon = opts.pokemon or data.pokemon
@@ -866,15 +861,6 @@ function PokedexMenu:drawArea()
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
local G = love.graphics
local table_ = self.data and self.data.landmarks
local byIndex = self.landmarkByIndex
if not byIndex then
byIndex = {}
for _, entry in pairs((table_ and table_.landmarks) or {}) do
if entry and entry.index then byIndex[entry.index] = entry end
end
self.landmarkByIndex = byIndex
end
if #nests == 0 then
-- A species with no grass, water or roamer entry in this region. The cart
@@ -883,11 +869,11 @@ function PokedexMenu:drawArea()
return
end
-- Blinking markers, the way the cart flashes its OBJs.
-- engine/pokegear/pokegear.asm:2427
local on = ((self.areaBlink or 0) % 32) < 20
if cells and on then
for _, index in ipairs(nests) do
local mark = byIndex[index]
local mark = Nests.landmark(self.data, index)
if mark and mark.x and mark.y then
G.setColor(0, 0, 0, 1)
G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5)
@@ -900,7 +886,7 @@ function PokedexMenu:drawArea()
-- Name the first one in words as well as on the map: the flashing dot is
-- unreadable at this size on a modern display, and the landmark name is what
-- a player actually wants off this screen.
local first = byIndex[nests[1]]
local first = Nests.landmark(self.data, nests[1])
if first and first.name then
local name = tostring(first.name):gsub("\n", " ")
self:text(name, 1, 16)
+16 -29
View File
@@ -33,6 +33,7 @@ function NPC.new(data, mapId, objDef)
self.facing = FACING_FROM_RANGE[objDef.range] or "down"
self.moving = false
self.progress = 0
self.animClock = 0
self.stepFlip = false
self.frozen = false -- scripts freeze NPCs while talking
self.wanders = objDef.movement == "WALK"
@@ -52,31 +53,15 @@ function NPC:facePlayer(player)
end
function NPC:update(map, entities)
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
-- the 1px step vector once per call (engine/overworld/movement.asm), but
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
--
-- self.stepFrames overrides the shared walk for an object whose
-- step has to stay in phase with something else: Yellow's follower
-- Pikachu takes the player's own step length, halved while it is more
-- than a cell behind (FastPikachuFollow, engine/pikachu/
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
-- command: two cells of travel inside one step's frames
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
-- pixel span doubles while the frame count does not. Nothing else sets
-- either field, so every other object keeps the constant (#410, #409).
-- engine/overworld/movement.asm:301, 32 frames per NPC cell; stepFrames is
-- the follower's own step length (#410, #409).
local stepLen = self.stepFrames or STEP_FRAMES
local span = self.hopStep and 2 or 1
if self.moving then
self.progress = self.progress + 1
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
-- pinned to the current cell while walkPhase() cycles.
self.animClock = (self.animClock or 0) + 1
-- NPC_CHANGE_FACING (movement.asm ChangeFacingDirection): walk cycle in
-- place, no translation.
if self.marching then
if self.progress >= stepLen then
self.progress = 0
@@ -122,18 +107,20 @@ end
function NPC:walkPhase()
if not self.moving then return 0 end
local stepLen = self.stepFrames or STEP_FRAMES
local p = self.progress % stepLen
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
-- engine/overworld/movement.asm:301
local p = (self.animClock or 0) % 16
return (p >= 4 and p < 12) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
-- phase this frame renders to -- so a render pipeline can pose an NPC
-- without caring which kind of entity it is. An NPC never hops, so the
-- trailing hop flag is always false.
-- Same contract as Player:pose; an NPC never hops, so the trailing hop
-- flag is always false.
function NPC:pose()
local flip = self.stepFlip
if self.moving then
flip = math.floor((self.animClock or 0) / 16) % 2 == 1
end
return self.sprite, self.px, self.py, self.facing,
self:walkPhase(), self.stepFlip, false
self:walkPhase(), flip, false
end
function NPC:draw(camX, camY)
+5 -2
View File
@@ -3090,9 +3090,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()
+59 -12
View File
@@ -1074,11 +1074,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
@@ -2402,12 +2403,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
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
end
@@ -3255,7 +3287,8 @@ function World:surfStartStep(mon)
if audio and audio.runtime and self.map then
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
if p.scriptStep then p:scriptStep(p.facing) end
self.fieldMove = { phase = "step" }
@@ -3565,7 +3598,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)]
@@ -3958,7 +3991,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,
@@ -4021,7 +4054,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())
@@ -4315,6 +4349,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
@@ -5100,7 +5142,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.
@@ -5374,7 +5416,8 @@ function World:runSurf(result)
if audio and audio.runtime and self.map then
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
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)
@@ -5886,6 +5929,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.
@@ -8540,7 +8585,8 @@ function World:setMap(mapId, cx, cy, facing, opts)
if audio and audio.runtime then
if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then
Music.playMap(self.game.data, mapId, nil,
FieldMoves.isSurfing(self.playerState))
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
@@ -8928,7 +8974,8 @@ function World:movePlayer(dir)
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, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(map.id))
end
end
end