CLOSES #1206, CLOSES #1189, CLOSES #1175, CLOSES #1129, CLOSES #1119, CLOSES #1089, CLOSES #1073, CLOSES #1069, CLOSES #1065, CLOSES #1010, CLOSES #990, CLOSES #989, CLOSES #988, CLOSES #978, CLOSES #944, CLOSES #936, CLOSES #1221, CLOSES #1220, CLOSES #1193, CLOSES #1101, CLOSES #1066, CLOSES #1056, CLOSES #1041, CLOSES #984, CLOSES #1225, CLOSES #1214, CLOSES #1011, CLOSES #1035, CLOSES #1219, CLOSES #1048, CLOSES #1047, CLOSES #964, CLOSES #949, CLOSES #1149, CLOSES #1007, CLOSES #914, CLOSES #1115, CLOSES #1146, CLOSES #999, CLOSES #1207, CLOSES #1029, CLOSES #1120, CLOSES #987, CLOSES #983

This commit is contained in:
bryanthaboi
2026-08-13 13:07:20 -04:00
parent 833388c235
commit 37051a26b5
43 changed files with 752 additions and 135 deletions
+34 -13
View File
@@ -613,6 +613,7 @@ local function newBattle(game)
self.phase = "intro"
self.menuIndex = 1
self.moveIndex = 1
self.playerMoveListIndex = 1
self.frame = 0
return self
end
@@ -929,6 +930,11 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text })
end
function BattleState:sayNextWaitSfx(text, sfx)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
@@ -1255,13 +1261,13 @@ function BattleState:updateQueue()
-- no subanimation player: keep the single-sound fallback (with
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
-- attacker's cry -- GetMoveSound/IsCryMove)
if item.anim == "GROWL" or item.anim == "ROAR" then
if self:animationsOn() and (item.anim == "GROWL" or item.anim == "ROAR") then
local attacker = item.attackerIsPlayer and self.player or self.enemy
if attacker then
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
anim and anim.tempo)
end
elseif anim and anim.sound then
elseif self:animationsOn() and anim and anim.sound then
local Sound = require("src.core.Sound")
if Sound.playMove then
Sound.playMove(self.data, anim)
@@ -1361,6 +1367,11 @@ function BattleState:updateQueue()
self.current = nil
end
elseif not (item and item.choice) then
if item and item.waitForLearningSfx and not item.soundStarted then
item.soundStarted = true
self.waitingSound = item.waitForLearningSfx()
return true
end
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1746,6 +1757,7 @@ end
local function sendOutMonCursors(self)
self.menuIndex = 1
self.moveIndex = 1
self.playerMoveListIndex = 1
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
@@ -2044,8 +2056,10 @@ function BattleState:update(dt)
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
else
self.moveSwapIndex = self.moveIndex
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
@@ -2068,6 +2082,7 @@ function BattleState:update(dt)
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
end
@@ -2423,11 +2438,9 @@ function BattleState:resolveTurn(playerAction)
end
local order
if pFirst then
order = { { self.player, self.enemy, playerAction },
{ self.enemy, self.player, enemyAction } }
order = { { true, playerAction }, { false, enemyAction } }
else
order = { { self.enemy, self.player, enemyAction },
{ self.player, self.enemy, playerAction } }
order = { { false, enemyAction }, { true, playerAction } }
end
self.phase = "messages"
@@ -2435,7 +2448,9 @@ function BattleState:resolveTurn(playerAction)
for _, entry in ipairs(order) do
self:act(function()
self:executeAction(entry[1], entry[2], entry[3])
local user = entry[1] and self.player or self.enemy
local target = entry[1] and self.enemy or self.player
self:executeAction(user, target, entry[2])
end)
end
self:act(function() self:endOfTurn() end)
@@ -2945,7 +2960,7 @@ function BattleState:applyHitFx(hit)
Sound.play(self.data, hit.sfx)
end
end
if not t or not self:animationsOn() then return end
if not t then return end
if t == 1 then
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
-- then home for 3, b counting down
@@ -3839,9 +3854,12 @@ function BattleState:awardExp()
participants, alive = 1, { self.player.mon }
end
local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.otId ~= nil and playerId ~= nil
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, mon.traded)
split, traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213).
@@ -3865,7 +3883,7 @@ function BattleState:awardExp()
local text = Strings.source("%s gained\n%d EXP. Points!")
if announce == "expAll" then
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
elseif mon.traded then
elseif traded then
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
end
self:sayNext(Strings(text, name, gained))
@@ -4129,15 +4147,17 @@ function BattleState:learnMove(mon, moveId)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name))
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name), function()
return require("src.core.Sound").play(self.data, "Level_Up")
end)
return
end
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
-- ordered insert so multi-level gains keep each level's checks
-- between its own stat box and the next "grew to level" text
self:uiNext(function()
return self:buildScreen("MoveLearnMenu", mon, moveId)
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
end)
end
@@ -5791,6 +5811,7 @@ function BattleState:drawTextArea()
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
end
end
+1 -2
View File
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2))
local recoil = math.max(1, math.floor(totalDealt / 2))
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil)
end
+7 -8
View File
@@ -348,8 +348,9 @@ MoveEffects.primary = {
target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
battle.data.moves[id].name) }
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
{ TARGET = displayName(target),
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
end,
SPLASH_EFFECT = function(battle)
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
return dist[r + 1]
end
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
-- that amount, so Counter would see the halved value
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
local function drainHalf(label, text)
return function(ctx)
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
ctx.battle.lastDamage = heal
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
@@ -529,9 +529,8 @@ MoveEffects.full = {
RECOIL_EFFECT = {
afterDamage = function(ctx)
-- recoil.asm reads the RAW computed wDamage (not the HP actually
-- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage
-- engine/battle/move_effects/recoil.asm
local recoil = math.max(1, math.floor(ctx.totalDealt
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
+3 -1
View File
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
or tostring(selectedMoveId)
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
"%s's\n%s is\ndisabled!", name(battler), shown))
"%s's\n%s is\ndisabled!", {
USER = name(battler), ["RAM:wNameBuffer"] = shown,
}))
return false, msgs
end
end
+11 -2
View File
@@ -1230,8 +1230,17 @@ function Engine.new(data, header, options)
engine.tempo = header.tempo
engine.tempoLocked = true
end
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local channels = chip and chip.channels or headerChannels(banks, header)
if header.startChannels then
local byNumber = {}
for _, start in ipairs(header.startChannels) do
byNumber[start.number] = start.address
end
for _, spec in ipairs(channels) do
spec.address = byNumber[spec.number] or spec.address
end
end
for _, spec in ipairs(channels) do
local frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then
+18 -2
View File
@@ -689,10 +689,26 @@ function Game2:usePartyItem(itemId)
self:say(result.text)
return
end
self:consumeItem(itemId)
if action == "candy" then
if action == "stone" then
local party = (self.save and self.save.party) or {}
local index
for i, member in ipairs(party) do
if member == mon then index = i break end
end
Screens.push(self, "Gen2EvolutionAnim", {
mon = mon, entry = result.evolution, index = index,
party = party, save = self.save,
force = true,
onDone = function(evolution)
if evolution and evolution.evolved then self:consumeItem(itemId) end
self.stack:pop()
end,
})
elseif action == "candy" then
self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end)
else
self:consumeItem(itemId)
self:say(result.text)
end
end
+17 -2
View File
@@ -64,6 +64,7 @@ state = {
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
tempo = nil, -- alternate-tempo override in force for `current`
start = nil,
failed = {}, -- labels whose def could not be started; logged once
}
@@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx)
song = selectSong(song, ctx)
local tempo = ctx and ctx.tempo or nil
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) then return end
if not song or (song == state.current and tempo == state.tempo
and start == state.start) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
slowed.tempo = tempo
def = slowed
end
if start == "rival" and song == "Music_MeetRival"
and def.bank == 2 and def.address == 17050 then
local started = {}
for key, value in pairs(def) do started[key] = value end
started.startChannels = {
{ number = 1, address = 0x71a2 },
{ number = 2, address = 0x721d },
{ number = 3, address = 0x72b5 },
}
def = started
end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
@@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx)
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
state.tempo = tempo
state.start = start
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
@@ -290,7 +305,7 @@ function Music.stop()
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.tempo = nil
state.tempo, state.start = nil, nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
+8
View File
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
local args = { ... }
if #args == 0 then return text end
if #args == 1 and type(args[1]) == "table" then
local values = args[1]
return (text:gsub("%b{}", function(token)
local value = values[token] or values[token:sub(2, -2)]
return value == nil and token or tostring(value)
end))
end
local slots, named = 0, 0
for token in text:gmatch("%b{}") do
slots = slots + 1
+3 -1
View File
@@ -1834,6 +1834,8 @@ function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
local x, y = boot.startX or 3, boot.startY or 6
local facing = boot.startFacing or "down"
if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
@@ -1844,7 +1846,7 @@ function SaveData.newGame(boot)
map = map,
x = x,
y = y,
facing = boot.startFacing or "down",
facing = facing,
name = boot.playerName or "RED",
rival = boot.rivalName or "BLUE",
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
+14
View File
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
return rareCandy(ctx.mon, ctx.data)
end)
for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE",
"THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do
record(itemId, "stone", function(ctx)
if ctx.mon.item == "EVERSTONE" then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local Evolution = require("src.core.gen2.Evolution")
local entry = Evolution.checkMon(ctx.data, ctx.mon,
{ force = true, item = ctx.item })
if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
return { used = true, evolution = entry }
end)
end
for itemId in pairs(ItemEffects.REVIVE) do
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
end
+11
View File
@@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
if itemId == "POKE_FLUTE" then
if not battle then
if ow and ow.map and ow.map.id == "PEWTER_POKECENTER"
and ow.pikachuPewterSleepScene then
local Follower = require("src.world.PikachuFollower")
local pika = Follower.current(ow)
local player = ow.player
if pika and player
and math.abs(pika.cellX - player.cellX) + math.abs(pika.cellY - player.cellY) == 1 then
return "flute_wake_pikachu", { romText(data, "_PlayedFluteHadEffectText",
"{PLAYER} played the\nPOKé FLUTE.") }
end
end
-- standing next to a not-yet-beaten Snorlax: this is the ONLY way
-- Snorlax wakes -- using the flute from the item-use menu, never
-- just talking to it with the flute in the bag (see
+1
View File
@@ -140,6 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
require("src.core.Sound").play(game.data, "Get_Item1")
game.stack:push(TextBox.new(game,
romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name), nextStep))
+6 -1
View File
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
return obpCache[key]
end
SpriteRenderer.obpImage = getObpImage
-- hot reload drops the sheets; live instances hold their own image, so
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
function SpriteRenderer.invalidate()
@@ -281,7 +283,7 @@ end
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
-- and trailing, so every existing call site is unchanged.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
topHalf, forceFlip)
topHalf, forceFlip, frameOverride)
local x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image
local redraw = false
@@ -329,6 +331,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip)
if frameOverride and self.frames[frameOverride] then
frame, flip = frameOverride, false
end
if forceFlip then flip = true end
local quad = self.frames[frame]
local drawHeight = self.frameHeight
+1
View File
@@ -130,6 +130,7 @@ TextBox.TOKENS = {
STRBUF = function(game) return game.stringBuffer end,
RAM = function(game, arg)
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wNameBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
if arg == "wBoxMonNicks" then return game.boxMonNicks end
+18
View File
@@ -100,6 +100,7 @@ O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
-- 0x2852 (#763, #857).
O.toggleObjectFlags = O.coins + 2 -- 32B
O.hiddenItemFlags = O.townVisited - 27
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
end
end
if data.hiddenItems then
save.hiddenTaken = {}
for i, row in ipairs(data.hiddenItems) do
if bitGet(bytes, O.hiddenItemFlags, i - 1) then
save.hiddenTaken[row[1] .. "_" .. row[2] .. "_" .. row[3]] = true
end
end
end
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
-- into de and rotates it right one bit per iteration with b counting up
@@ -953,6 +963,14 @@ function GenSave.encode(save, data, template)
end
end
if data.hiddenItems then
local taken = save.hiddenTaken or {}
for i, row in ipairs(data.hiddenItems) do
local key = row[1] .. "_" .. row[2] .. "_" .. row[3]
bitSet(buf, O.hiddenItemFlags, i - 1, taken[key] and true or false)
end
end
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
-- save exported from this port is flyable on hardware (#263). A save
-- table with no `visited` key at all says nothing about the set, so leave
+7
View File
@@ -49,6 +49,7 @@ local DATA_MODULES = {
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" },
}
local OPTIONAL_MODULES = { tilesets = true, audio = true }
@@ -64,6 +65,10 @@ local YELLOW_EVENT_FLAGS = {
"src.save_convert.data.event_flags_yellow",
"src/save_convert/data/event_flags_yellow.lua",
}
local YELLOW_HIDDEN_ITEMS = {
"src.save_convert.data.hidden_items_yellow",
"src/save_convert/data/hidden_items_yellow.lua",
}
local function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath)
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
if name ~= "charmap" then
if name == "eventFlags" and gameVersion == "yellow" then
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
elseif name == "hiddenItems" and gameVersion == "yellow" then
spec = YELLOW_HIDDEN_ITEMS
end
local mod = loadCacheTable(gameVersion, spec[2])
if not mod then
+30
View File
@@ -0,0 +1,30 @@
return {
{ "VIRIDIAN_FOREST", 1, 18 }, { "VIRIDIAN_FOREST", 16, 42 },
{ "MT_MOON_B2F", 18, 12 }, { "ROUTE_25", 38, 3 },
{ "ROUTE_9", 14, 7 }, { "SS_ANNE_KITCHEN", 13, 9 },
{ "SS_ANNE_B1F_ROOMS", 3, 1 }, { "ROUTE_10", 9, 17 },
{ "ROUTE_10", 16, 53 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
{ "POKEMON_TOWER_5F", 4, 12 }, { "ROUTE_13", 1, 14 },
{ "ROUTE_13", 16, 13 }, { "POKEMON_MANSION_B1F", 1, 9 },
{ "SAFARI_ZONE_GATE", 10, 1 }, { "SAFARI_ZONE_WEST", 6, 5 },
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "CERULEAN_CAVE_1F", 14, 11 },
{ "CERULEAN_CAVE_B1F", 27, 3 }, { "POWER_PLANT", 17, 16 },
{ "POWER_PLANT", 12, 1 }, { "SEAFOAM_ISLANDS_B2F", 15, 15 },
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "POKEMON_MANSION_1F", 8, 16 },
{ "POKEMON_MANSION_3F", 1, 9 }, { "ROUTE_23", 9, 44 },
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
{ "UNUSED_MAP_6F", 14, 11 }, { "VIRIDIAN_CITY", 14, 4 },
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
{ "ROUTE_17", 8, 121 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "CELADON_CITY", 48, 15 },
{ "ROUTE_25", 10, 1 }, { "MT_MOON_B2F", 33, 9 },
{ "SEAFOAM_ISLANDS_B3F", 9, 16 }, { "VERMILION_CITY", 14, 11 },
{ "CERULEAN_CITY", 15, 8 }, { "ROUTE_4", 40, 3 },
}
@@ -0,0 +1,31 @@
return {
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
{ "POKEMON_MANSION_3F", 1, 9 }, { "POKEMON_MANSION_B1F", 1, 9 },
{ "SAFARI_ZONE_WEST", 6, 5 }, { "CERULEAN_CAVE_2F", 16, 13 },
{ "CERULEAN_CAVE_B1F", 8, 14 }, { "UNUSED_MAP_6F", 14, 11 },
{ "SEAFOAM_ISLANDS_B2F", 15, 15 }, { "SEAFOAM_ISLANDS_B3F", 9, 16 },
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "VIRIDIAN_FOREST", 1, 18 },
{ "VIRIDIAN_FOREST", 16, 42 }, { "MT_MOON_B2F", 18, 12 },
{ "MT_MOON_B2F", 33, 9 }, { "SS_ANNE_B1F_ROOMS", 3, 1 },
{ "SS_ANNE_KITCHEN", 13, 9 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
{ "ROUTE_10", 9, 17 }, { "ROUTE_10", 16, 53 },
{ "POWER_PLANT", 17, 16 }, { "POWER_PLANT", 12, 1 },
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
{ "ROUTE_13", 1, 14 }, { "ROUTE_13", 16, 13 },
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
{ "ROUTE_17", 8, 121 }, { "ROUTE_23", 9, 44 },
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
{ "ROUTE_25", 38, 3 }, { "ROUTE_25", 10, 1 },
{ "ROUTE_4", 40, 3 }, { "ROUTE_9", 14, 7 },
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "VIRIDIAN_CITY", 14, 4 },
{ "CERULEAN_CITY", 15, 8 }, { "CERULEAN_CAVE_1F", 18, 7 },
{ "POKEMON_TOWER_5F", 4, 12 }, { "VERMILION_CITY", 14, 11 },
{ "CELADON_CITY", 48, 15 }, { "SAFARI_ZONE_GATE", 10, 1 },
{ "POKEMON_MANSION_1F", 8, 16 },
}
+17 -3
View File
@@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value)
ctx.save[key] = value
end
function Commands.load_player_starter_name(ctx)
local flags = ctx.save.flags or {}
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
or flags.EVENT_CHOSE_CHARMANDER and "CHARMANDER"
or flags.EVENT_CHOSE_SQUIRTLE and "SQUIRTLE"
or flags.EVENT_CHOSE_BULBASAUR and "BULBASAUR"
or (ctx.save.party and ctx.save.party[1] and ctx.save.party[1].species)
local def = species and ctx.game.data.pokemon[species]
ctx.game.stringBuffer = def and def.name or species or ""
end
function Commands.spawn_pikachu_follower(ctx)
require("src.world.PikachuFollower").onMapEntered(
ctx.game, ctx.overworld, nil, false)
end
local function toggleObject(ctx, mapId, objName, visible)
local save = ctx.save
save.objectToggles = save.objectToggles or {}
@@ -1131,9 +1147,7 @@ end
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
function Commands.play_music(ctx, songId, opts)
local tempo = opts and opts.tempo
require("src.core.Music").play(ctx.game.data, songId, nil,
tempo and { tempo = tempo } or nil)
require("src.core.Music").play(ctx.game.data, songId, nil, opts)
if opts and opts.keep and ctx.overworld then
ctx.overworld.keepMusicOnce = true
end
+19 -7
View File
@@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
return
end
if result == "flute_wake_pikachu" then
require("src.core.Sound").play(game.data, "Pokeflute")
showMessages(game, payload, function()
game.overworld.pikachuPewterSleepScene = nil
end)
return
end
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
if result == "flute_wake" then
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
list:close()
local ow = game.overworld
local p = ow and ow.player
if ow and p then
local fx, fy = p:facingCell()
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
ow:goFishing(id)
return
end
if ow and p and ow:facingIsShoreOrWater() then
ow:goFishing(id)
return
end
showMessages(game, { Strings("No good! It's not\neven near water.") })
return
@@ -176,19 +181,25 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
taught()
else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
if learned then
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
end
if learned then taught() end
end)
end
end
list:close()
teach()
return
end
@@ -322,6 +333,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local mdef = game.data.moves[moveId]
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep)
+17
View File
@@ -119,6 +119,13 @@ local function deposit(game)
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
local Follower = require("src.world.PikachuFollower")
if Follower.isFollowingDisabled(game.overworld)
and Follower.isStarterPikachu(game.save, mon) then
game.stack:push(TextBox.new(game, t._SleepingPikachuText2
or Strings("There isn't any\nresponse...")))
return
end
monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then
list.footer = Strings("You need at least\none POKéMON!")
@@ -167,6 +174,16 @@ local function release(game)
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
if require("src.core.GameVersion").isYellow()
and mon.species == "PIKACHU"
and mon.otId == game.save.player.id
and mon.ot == game.save.player.name then
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name)))
return
end
game.stack:push(TextBox.new(game,
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
defaultNo = true, noSound = true,
+7 -2
View File
@@ -17,12 +17,13 @@ local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
}
function MoveLearnMenu.new(game, mon, newMoveId, onDone)
function MoveLearnMenu.new(game, mon, newMoveId, onDone, learnedSound)
local self = setmetatable({}, MoveLearnMenu)
self.game = game
self.mon = mon
self.newMoveId = newMoveId
self.onDone = onDone
self.learnedSound = learnedSound or "Get_Item1"
self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
@@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned)
self.selecting = false
game.stack:pop()
local msg
local opts
if learned then
-- pokered pages this as four texts in a row; _ForgotAndText carries
-- the "And..." tail
@@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned)
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
.. "\f" .. romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name)
opts = { auto = { sound = function()
return require("src.core.Sound").play(game.data, self.learnedSound)
end, wait = true } }
else
msg = romText(game.data, "_DidNotLearnText",
"%s\ndid not learn\v%s!", name, mdef.name)
end
game.stack:push(TextBox.new(game, msg, function()
if self.onDone then self.onDone(learned) end
end))
end, opts))
end
function MoveLearnMenu:draw()
+23
View File
@@ -72,6 +72,21 @@ end
local function sameItems(_, items) return items end
local function followerUnavailable(game, mon)
local ow = game.overworld
local Follower = require("src.world.PikachuFollower")
return Follower.isFollowingDisabled(ow)
and Follower.isStarterPikachu(game.save, mon)
end
local function refuseUnavailable(self)
self.swapFrom = nil
local TextBox = require("src.render.TextBox")
local t = self.game.data and self.game.data.text or {}
self.game.stack:push(TextBox.new(self.game,
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
end
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -367,6 +382,10 @@ function PartyMenu:update(dt)
self.submenu = nil
elseif input:wasPressed("a") then
local mon = party[self.index]
if followerUnavailable(self.game, mon) then
refuseUnavailable(self)
return
end
local entry = self.subItems[self.subIndex]
local action = entry.action
if not action and entry.onSelect then
@@ -577,6 +596,10 @@ function PartyMenu:update(dt)
if self.onCancel then self.onCancel() end
elseif input:wasPressed("a") and #party > 0 then
local mon = party[self.index]
if followerUnavailable(self.game, mon) then
refuseUnavailable(self)
return
end
if self.softboiledFrom then
local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5)
+21
View File
@@ -104,6 +104,27 @@ local function sell(game)
dialogue = true,
money = function() return game.save.money end,
footer = greet,
onSelectKey = function(item, l)
if not item then return end
if not l.swapIndex then
l.swapIndex = l.index
return
end
local order = Bag.order(game.save)
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
l.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
local rebuilt = {}
for _, id in ipairs(order) do
local def = game.data.items[id]
rebuilt[#rebuilt + 1] = {
value = id,
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
}
end
l.items = rebuilt
end,
onChoose = function(item)
local def = game.data.items[item.value]
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
+2 -1
View File
@@ -34,7 +34,8 @@ function StartMenu.new(game)
-- POKéMON is always listed (draw_start_menu.asm prints it even with
-- an empty party; selecting it then just no-ops)
table.insert(items, { label = Strings("POKéMON"), onSelect = function()
table.insert(items, { label = Strings("POKéMON"),
keepOpen = #game.save.party == 0, onSelect = function()
if #game.save.party == 0 then return end
Screens.push(game, "PartyMenu", { onCancel = reopen })
end })
+15 -8
View File
@@ -200,8 +200,9 @@ function TitleState.new(game, opts)
self.versionFull = imagePath(title.versionRibbon) ~= nil
self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png")
self.player = tryImage(imagePath(title.player)
or "assets/generated/title/player.png")
self.playerPath = imagePath(title.player)
or "assets/generated/title/player.png"
self.player = tryImage(self.playerPath)
-- ..(engine/movie/title2.asm ln 85)
if self.player then
local pw, ph = self.player:getDimensions()
@@ -597,6 +598,12 @@ end
-- ..(engine/movie/title.asm ln 28)
function TitleState:draw()
local PaletteFX = require("src.render.PaletteFX")
local playerImage = self.player
if playerImage and PaletteFX.usesSpriteObp() then
playerImage = require("src.render.SpriteRenderer").obpImage(
self.playerPath, PaletteFX.ogObj())
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = -(self.scy or 0)
@@ -660,8 +667,8 @@ function TitleState:draw()
-- layout has no cycling mon and no Red art (title_yellow.asm).
if spriteTrueColor then
local cover
if self.player then
local pw, ph = self.player:getDimensions()
if playerImage then
local pw, ph = playerImage:getDimensions()
cover = { 82, 80, pw, ph }
end
markVisibleTrueColor(x, y, w, h, cover)
@@ -670,11 +677,11 @@ function TitleState:draw()
-- Red is OAM in the original: he draws over the mon's box edge
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
elseif self.player then
love.graphics.draw(self.player, 82, 80)
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
elseif playerImage then
love.graphics.draw(playerImage, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
+11 -3
View File
@@ -272,9 +272,6 @@ function EvolutionAnim:commit()
evolved.level, evolved)
end
-- One LearnMove call per move the new species picks up at this level. There
-- is no forget screen in the Gold port yet, so a full moveset reports the way
-- src/battle/gen2/Battle.lua's own level-up path does instead of prompting.
function EvolutionAnim:nextLearn()
self.learnIndex = (self.learnIndex or 0) + 1
local moveId = self.pending and self.pending[self.learnIndex]
@@ -289,6 +286,17 @@ function EvolutionAnim:nextLearn()
self.learned[#self.learned + 1] = moveId
self.lines = { self.nick .. " learned", moveName .. "!" }
elseif reason == "full" then
if self.game and self.game.learnMoveOn then
self.phase = "waitingLearn"
return self.game:learnMoveOn(self.evolved, moveId, function(learned)
if learned then
self.learned[#self.learned + 1] = moveId
else
self.full[#self.full + 1] = moveId
end
self:nextLearn()
end)
end
self.full[#self.full + 1] = moveId
self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" }
else
+2 -1
View File
@@ -128,7 +128,8 @@ end
function NPC:draw(camX, camY)
local sprite, px, py, facing, phase, flip = self:pose()
sprite:draw(px, py, camX, camY, facing, phase, flip)
sprite:draw(px, py, camX, camY, facing, phase, flip, nil, nil,
self.frameOverride)
end
return NPC
+22 -2
View File
@@ -1087,6 +1087,7 @@ function OverworldState:update(dt)
-- the player lands on desk Oak.
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -1095,6 +1096,7 @@ function OverworldState:update(dt)
-- the player can never start another step after being spotted.
scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
end
if not scripted and not self.transitioning then
self:handleInput()
@@ -1664,6 +1666,10 @@ end
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
-- (no group means "Not even a nibble!").
function OverworldState:goFishing(rod)
if GameVersion.isYellow() then
Game.save.pikachuEmotionModifier = 2
Game.save.pikachuMood = 0x81
end
local pool, always = fishingPool(Game.data, rod, self.map.id)
local enc
if Runtime.wantsHook("encounter.fishing") then
@@ -2905,6 +2911,11 @@ end
-- POKéMON" and "fighting fit".
function OverworldState:nurseHeal(onDone, npc)
local t = Game.data.text
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
Game.stack:push(TextBox.new(Game,
t._LooksContentText or "PIKACHU looks\ncontent.", onDone))
return
end
local bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "We hope to see\nyou again!")
local hello = t._PokemonCenterWelcomeText
or Strings("Welcome to our\nPOKéMON CENTER!")
@@ -2978,9 +2989,13 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
end))
end
if not npc then farewell() return end
npc.facing = "up"
npc.frameOverride = 3
-- bubble = false is the silent world hold, this port's DelayFrames
self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell }
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
npc.frameOverride = nil
npc:facePlayer(self.player)
farewell()
end }
end))
end
@@ -2992,6 +3007,11 @@ end
-- for the original serial handshake; declining prints "Please come again!"
function OverworldState:cableClubReceptionist(onDone)
local t = Game.data.text
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
Game.stack:push(TextBox.new(Game,
t._LooksContentText or "PIKACHU looks\ncontent.", onDone))
return
end
local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!")
if not Game.save.flags.EVENT_GOT_POKEDEX then
-- CableClubNPC .didNotConnect path before the pokedex
+43 -1
View File
@@ -65,6 +65,17 @@ function PikachuFollower.starterInParty(save, needHealthy)
return nil
end
function PikachuFollower.isStarterPikachu(save, mon)
if not (mon and mon.species == "PIKACHU") then return false end
local player = save.player or {}
return mon.otId == player.id and mon.ot == player.name
end
function PikachuFollower.isFollowingDisabled(ow)
return ow and (ow.pikachuBillsScene or ow.pikachuFanClubScene
or ow.pikachuPewterSleepScene) and true or false
end
-- ModifyPikachuHappiness. mon is the party mon the event applied to for
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
-- WALKING instead require any healthy starter in the party
@@ -199,6 +210,7 @@ function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad)
-- Bill's House owns a short scripted scene that deliberately keeps
-- Pikachu off the normal trailing loop. A new map instance ends it.
ow.pikachuBillsScene = nil
ow.pikachuFanClubScene = nil
remove(ow)
if not shouldSpawn(game, ow) then return end
-- opts.keepPikachu is the follower a connection crossing kept alive:
@@ -410,7 +422,8 @@ end
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
if ow.pikachuBillsScene then return end
if ow.pikachuBillsScene or ow.pikachuFanClubScene
or ow.pikachuPewterSleepScene then return end
local npc = findFollower(ow)
if not npc then
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
@@ -713,6 +726,13 @@ function PikachuFollower.talk(game, ow, npc, done)
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
local save = game.save
local emotion = selectEmotion(game, ow, save)
if ow.pikachuPewterSleepScene then
local finish = done
done = function()
ow.pikachuPewterSleepScene = nil
if finish then finish() end
end
end
local e = EMOTIONS[emotion] or EMOTIONS[1]
if e.turnAway then
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
@@ -798,6 +818,28 @@ local function movePikachu(ow, npc, steps, onDone)
nextStep(1)
end
function PikachuFollower.onFanClubEntered(game, ow)
if not (GameVersion.isYellow() and ow.map
and ow.map.id == "POKEMON_FAN_CLUB") then return end
local starter = PikachuFollower.starterInParty(game.save)
local npc = findFollower(ow)
if not npc or (starter and starter.status) then return end
ow.pikachuFanClubScene = true
ow.pikachuMapScriptActive = true
ow.player.facing = "down"
for _, other in ipairs(ow.npcs or {}) do
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
other.movementStatus = 2
other.facing = "down"
break
end
end
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function()
npc.facing = "up"
end)
end
function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return