CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362

This commit is contained in:
bryanthaboi
2026-08-16 06:41:56 -04:00
parent 3ee50a27c5
commit 12fdfa1e88
119 changed files with 2555 additions and 42186 deletions
+45 -51
View File
@@ -364,21 +364,17 @@ local function grayImage(img)
return getImage(meta.path) or img
end
-- The blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs
-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black --
-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm:
-- 22-25). The mon pics are drawn OVER the zone pass with their palette
-- already baked in, so darkening them means re-baking through PAL_BLACK the
-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette
-- out of the active pack, exactly like sgbBattlePals, so the zone pass and
-- the pics can never disagree. trueColor art has no DMG shades to remap.
-- SET_PAL_BATTLE_BLACK re-bake of a pic (engine/battle/core.asm:1151,
-- engine/gfx/palettes.asm:22-25); resolved like sgbBattlePals' blackout (#292).
local function blackImage(data, img)
local meta = imageMeta[img]
if not meta or meta.trueColor then return img end
local PaletteFX = require("src.render.PaletteFX")
local pack = PaletteFX.pack(data)
local colors = pack and pack.palettes and pack.palettes.BLACK
if not colors then return img end
local pals = pack and pack.palettes
if not (pals and pals.BLACK) then return img end
local colors = PaletteFX.usesYellowCgb() and pals.BLACK
or PaletteFX.pal(data, "BLACK") or pals.BLACK
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
return getImage(meta.path, { name = name, colors = colors }) or img
end
@@ -2608,6 +2604,9 @@ function BattleState:residualFor(b, opp)
if self.result then return end
if self.player ~= b and self.enemy ~= b then return end
if b.mon.hp <= 0 or opp.mon.hp <= 0 then return end
-- engine/battle/core.asm:435-473
if b.residualDone then return end
b.residualDone = true
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if b.leechSeeded and b.mon.hp > 0 then
@@ -2664,7 +2663,8 @@ function BattleState:endOfTurn()
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
{ self.enemy, self.player, "enemy", playerAlive } }) do
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
if sweep and b.mon.hp > 0 and oppAlive then
if sweep and not b.residualDone and b.mon.hp > 0 and oppAlive then
b.residualDone = true
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
@@ -2678,6 +2678,7 @@ function BattleState:endOfTurn()
-- the Haze move-forfeit only covers the turn Haze was used; if the
-- cured mon had already moved, drop the flag before next turn
b.skipMove = nil
b.residualDone = nil
-- CheckNumAttacksLeft (core.asm:683-697): a trapping counter that
-- hit 0 this turn releases its bit only now, at the end of the turn
if b.trappingTurns and b.trappingTurns <= 0 then
@@ -3892,9 +3893,9 @@ function BattleState:onFaint(battler)
battler.fainted = true
local Sound = require("src.core.Sound")
if battler.isPlayer then
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
-- faint plays its ordinary species cry -- no Faint_Fall
self.faintCry = Sound.playCry(self.data, battler.mon.species)
-- RemoveFaintedPlayerMon: the species cry (core.asm:1040-1042),
-- PikachuCry4 on Yellow (engine/battle/core.asm:1058)
self.faintCry = Sound.playCry(self.data, battler.mon.species, 4)
elseif self.kind ~= "wild" then
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
@@ -5296,24 +5297,28 @@ function BattleState:sgbBattlePals()
local pack = PaletteFX.pack(self.data)
local pals = pack and pack.palettes
if not pals then return nil end
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK:
-- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of
-- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the
-- battle screen -- both HP bars and both mon regions -- goes dark behind
-- the blackout text. picImage re-bakes the pics through the same palette,
-- since those draw over the zone pass rather than through it (#292).
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK
-- (engine/gfx/palettes.asm:22-25); picImage re-bakes through it (#292).
if self.blackedOut and pals.BLACK then
local b = pals.BLACK
local b = PaletteFX.usesYellowCgb() and pals.BLACK
or PaletteFX.pal(self.data, "BLACK") or pals.BLACK
return { [0] = b, [1] = b, [2] = b, [3] = b }
end
-- home/palettes.asm:38
local function bar(b)
if not b then return pals.GREENBAR end
if not b then
return PaletteFX.pal(self.data, "GREENBAR") or pals.GREENBAR
end
local hp = b.shownHP or b.mon.hp
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
return PaletteFX.pal(self.data,
PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx))
or pals.GREENBAR
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
if placeholder or not b then
if PaletteFX.usesYellowCgb() then return pals.MEWMON or pals.GREENBAR end
return PaletteFX.pal(self.data, "MEWMON") or pals.MEWMON or pals.GREENBAR
end
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
local out = {
@@ -5322,23 +5327,6 @@ function BattleState:sgbBattlePals()
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
[3] = mon(self.enemy, self.showEnemyTrainer),
}
-- OG RED: the Game Boy Color drew the whole battle from one BG palette --
-- white paper, black ink -- so every zone shares the same background and
-- outline; only the two mid shades differ per element (green HP bar, red
-- mon pic). The bar/base zones otherwise carry the SGB off-white
-- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
out[i] = { white, c[2], c[3], black }
end
end
return out
end
@@ -5432,23 +5420,29 @@ function BattleState:drawZonePass(src, sx, sy)
love.graphics.setShader()
end
-- colors for one anim-layer OAM sprite at screen pixel (px, py): the
-- zone palette under that pixel's 8x8 attribute cell (the SGB colors
-- the composited picture per cell, so AnimPlayer samples once per cell
-- the tile overlaps), through the OBJ palette the routine ran with
-- (SetAnimationPalette: wAnimPalette = $f0 on SGB, rOBP1 = $6c,
-- ambient rOBP0 = $e4)
-- SetAnimationPalette (engine/battle/animations.asm:551): wAnimPalette = $f0
-- on SGB, $e4 otherwise; rOBP1 = $6c either way
local OBJ_SHADES = {
f0 = { 0, 3, 3 }, -- color 1 -> shade 0, colors 2/3 -> shade 3
f0x = { 3, 0, 3 }, -- $f0 xor %00111100 = $cc: the Master/Ultra ball
-- toss flicker (DoBallTossSpecialEffects)
f0x = { 3, 0, 3 }, -- $f0 xor %00111100 = $cc (DoBallTossSpecialEffects,
-- engine/battle/animations.asm:685)
e4 = { 1, 2, 3 }, -- identity
e4x = { 2, 1, 3 }, -- $e4 xor %00111100 = $d8
obp1 = { 3, 2, 1 }, -- $6c
}
function BattleState:animSpriteColors(s, px, py)
local P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
local PaletteFX = require("src.render.PaletteFX")
local key = s.obp or "f0"
local P
-- engine/battle/animations.asm:551 (.notSGB)
if PaletteFX.usesSpriteObp() then
P = PaletteFX.ogObj()
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
else
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
end
if not P then return nil end
local m = OBJ_SHADES[s.obp or "f0"] or OBJ_SHADES.f0
local m = OBJ_SHADES[key] or OBJ_SHADES.f0
local function c(shade)
local col = P[shade + 1]
return { col[1] / 255, col[2] / 255, col[3] / 255 }
+1 -1
View File
@@ -279,7 +279,7 @@ end
C.dropsub = function(self)
local side = self.env.battleTurn == 0 and "player" or "enemy"
self.picOverride[side] = nil
self.picOverride[side] = false
end
-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the
+19 -6
View File
@@ -1517,10 +1517,10 @@ function Battle:useMove(attacker, defender, moveId)
-- read and cleared by the very next move aimed at it, and while it is up the
-- accuracy roll does not happen at all.
local locked = self:consumeLockOn(defender)
-- SUBSTATUS_X_ACCURACY (an X ACCURACY) grants the same roll bypass,
-- CheckHit's `.XAccuracy` arm, but is not consumed: it lasts until the
-- switch drops the volatile.
-- CheckHit's .XAccuracy and EFFECT_ALWAYS_HIT arms
-- (effect_commands.asm:1572-1579).
local sureHit = locked or self:volatile(attacker).xAccuracy == true
or def.effect == "EFFECT_ALWAYS_HIT"
-- .LockOn runs ahead of .FlyDigMoves and returns a HIT unless the target is
-- flying and the move is one of the three (effect_commands.asm:1563-1567,
@@ -1723,9 +1723,14 @@ function Battle:useMove(attacker, defender, moveId)
text = ("Hit %d time(s)!"):format(landed) })
end
-- Recoil is a quarter of what was dealt; drain heals half of it. Dream
-- Eater's sleep requirement is checkhit's, not this block's, so by the
-- time a drain is paid out the target is known to have been asleep.
-- move_effects/pay_day.asm:13
if def.effect == "EFFECT_PAY_DAY" and dealt > 0 then
self.payDay = (self.payDay or 0) + 2 * (attacker.level or 1)
self:emit({ kind = "message",
text = Strings("Coins scattered\neverywhere!") })
end
-- Recoil is a quarter of what was dealt; drain heals half of it.
if def.effect == "EFFECT_RECOIL_HIT" and dealt > 0 then
local recoil = Effects.recoilDamage(dealt)
attacker.hp = math.max(0, (attacker.hp or 0) - recoil)
@@ -3012,6 +3017,14 @@ function Battle:resolveFaints()
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
self:awardPrizeMoney()
end
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
-- :8014-8042).
local coins = Prize.payDay(self.save, self.payDay, self.amuletCoin)
if coins then
self:emit({ kind = "money", text = Prize.payDayMessage(coins,
self.save.player and self.save.player.name) })
end
self.payDay = nil
self:endBattle("win")
return true
end
+15
View File
@@ -66,6 +66,8 @@ local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!")
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
local SENT_HALF = Strings.source("Sent half to MOM!")
local SENT_ALL = Strings.source("Sent all to MOM!")
-- BattleText_PlayerPickedUpPayDayMoney (data/text/battle.asm:3-8).
local PICKED_UP = Strings.source("%s picked up %s%d!")
-- charmap.asm: the currency glyph, the same one Chrome.money floats in front
-- of a six-digit field.
@@ -220,4 +222,17 @@ function Prize.message(award, playerName)
return Strings(GOT_MONEY, name, YEN, total)
end
-- CheckPayDay (engine/battle/core.asm:8014-8042): the Amulet Coin doubles the
-- accumulated total once, and the wallet is written directly, no Mom split.
function Prize.payDay(save, amount, amuletCoin)
if not (save and save.player) or (amount or 0) <= 0 then return nil end
if amuletCoin then amount = amount * 2 end
setPlayerMoney(save, addToAccount(playerMoney(save), amount))
return amount
end
function Prize.payDayMessage(amount, playerName)
return Strings(PICKED_UP, playerName or "PLAYER", YEN, amount or 0)
end
return Prize
+9 -2
View File
@@ -730,7 +730,7 @@ function Game2:usePartyItem(itemId)
local row = ItemEffects.RESTORE_PP[itemId] or {}
if row.each or mon.isEgg then
self.stack:pop()
finish(ItemEffects.usePpItem(itemId, mon), mon)
finish(ItemEffects.usePpItem(itemId, mon, nil, self.data), mon)
return
end
Screens.push(self, "Gen2MoveDeleter", {
@@ -740,7 +740,7 @@ function Game2:usePartyItem(itemId)
onChoose = function(slot)
self.stack:pop() -- the move list
self.stack:pop() -- the party list
finish(ItemEffects.usePpItem(itemId, mon, slot), mon)
finish(ItemEffects.usePpItem(itemId, mon, slot, self.data), mon)
end,
})
end,
@@ -888,6 +888,12 @@ function Game2:load()
self.data.items = loadGenerated("data/generated/items.lua") or {}
self.data.moves = loadGenerated("data/generated/moves.lua") or {}
self.data.type_chart = loadGenerated("data/generated/type_chart.lua") or {}
-- data/types/type_matchups.asm:112-116: the rows after the `db -2` marker
-- apply by default; Foresight is what cuts the table short at it.
local chart = self.data.type_chart
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
-- The `held_items` registry's merge target: ItemAttributes' last two columns
-- as their own table, so a mod can give an item a held behaviour without
-- owning the whole item record. Built BEFORE mods:load so the registry
@@ -1949,6 +1955,7 @@ function Game2:applyOptions()
touchControls = options.touchControls,
haptics = options.haptics,
})
require("src.core.VideoMode").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
+38 -7
View File
@@ -130,12 +130,23 @@ local SPECIAL = {
evolution = "Music_SafariZone",
}
-- SpecialMapMusic (pokegold home/audio.asm:397)
local SPECIAL_GEN2 = {
surf = "Music_Surf",
bike = "Music_Bicycle",
evolution = "Music_Evolution",
}
-- the label a scene role resolves to; call sites keep their own presence
-- guard on the resolved label
function Music.special(data, key)
local special = data and data.audio and data.audio.special
local label = special and special[key]
if label ~= nil then return label end
if data and data.audio and data.audio.generation == 2
and SPECIAL_GEN2[key] ~= nil then
return SPECIAL_GEN2[key]
end
return SPECIAL[key]
end
@@ -192,6 +203,12 @@ local function startSong(data, def, wantLoop)
return nil, nil, nil, "no chip program and no file"
end
-- Music_MeetRival_Ch{1,2,3}_AlternateStart (audio/alternate_tempo.asm:7)
local RIVAL_ALT_START = {
redblue = { 0x71a2, 0x721d, 0x72b5 },
yellow = { 0x7075, 0x70f0, 0x7188 },
}
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
@@ -219,8 +236,17 @@ function Music.play(data, song, loop, ctx)
local start = ctx and ctx.start or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo
and start == state.start) then return end
if not song then return end
if song == state.current and tempo == state.tempo
and start == state.start then
-- ..(home/audio.asm ln 65)
if state.fade and state.fade.pending then
state.fade = nil
applyVolume(state.source)
applyVolume(state.loopSource)
end
return
end
local def = songDef(data, song)
if not def or state.failed[song] then return end
@@ -247,10 +273,12 @@ function Music.play(data, song, loop, ctx)
and def.bank == 2 and def.address == 17050 then
local started = {}
for key, value in pairs(def) do started[key] = value end
local alt = require("src.core.GameVersion").isYellow()
and RIVAL_ALT_START.yellow or RIVAL_ALT_START.redblue
started.startChannels = {
{ number = 1, address = 0x71a2 },
{ number = 2, address = 0x721d },
{ number = 3, address = 0x72b5 },
{ number = 1, address = alt[1] },
{ number = 2, address = alt[2] },
{ number = 3, address = alt[3] },
}
def = started
end
@@ -340,9 +368,12 @@ end
Music.MAP_FADE = 10
-- the song a map should currently play, honoring the bike/surf overrides
-- the song a map should currently play, honoring the bike/surf overrides;
-- Gen 2 has no outdoor gate (pokegold home/audio.asm:437)
local function effectiveMapSong(data, song)
if not song or not outdoorSongs(data)[song] then return song end
if not song then return song end
local gen2 = data and data.audio and data.audio.generation == 2
if not gen2 and not outdoorSongs(data)[song] then return song end
if state.onBike then
local bike = Music.special(data, "bike")
if bike and songDef(data, bike) then return bike end
+68 -5
View File
@@ -58,6 +58,12 @@ ItemEffects.RESTORE_PP = {
MAX_ELIXER = { amount = "all", each = true },
}
-- engine/items/item_effects.asm:1245 StatExpItemPointerOffsets.
ItemEffects.VITAMIN = {
HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
CARBOS = "speed", CALCIUM = "special",
}
-- EnergypowderEnergyRootCommon / HealPowderEffect: the herb items charge
-- happiness for tasting bitter on top of their heal.
local BITTER = {
@@ -70,6 +76,9 @@ ItemEffects.TEXT_NO_EFFECT = "It won't have any\neffect."
ItemEffects.TEXT_CANT_USE_ON_EGG = "That can't be used\non an EGG."
-- _PPRestoredText (data/text/common_3.asm).
ItemEffects.TEXT_PP_RESTORED = "PP was restored."
-- _PPIsMaxedOutText / _PPsIncreasedText (data/text/common_3.asm).
ItemEffects.TEXT_PP_MAXED = "%s's PP\nis maxed out."
ItemEffects.TEXT_PP_INCREASED = "%s's PP\nincreased."
-- PrintPartyMenuActionText's .MenuActionTexts (engine/pokemon/party_menu.asm),
-- keyed by the class GetItemHealingAction resolves. Each is the two rows the
@@ -235,6 +244,33 @@ local function rareCandy(mon, data)
}
end
-- engine/items/item_effects.asm:1216 StatStrings.
local VITAMIN_LABEL = {
hp = "HEALTH", attack = "ATTACK", defense = "DEFENSE",
speed = "SPEED", special = "SPECIAL",
}
-- engine/items/item_effects.asm:1149 VitaminEffect.
local function vitamin(itemId, mon, data)
local stat = ItemEffects.VITAMIN[itemId]
mon.statExp = mon.statExp or Mon.newStatExp()
local cur = mon.statExp[stat] or 0
if cur >= 25600 then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
mon.statExp[stat] = math.min(Mon.MAX_STAT_EXP, cur + 2560)
local def = data and data.pokemon and data.pokemon[mon.species]
if def and def.baseStats then
mon.stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp)
mon.maxHp = mon.stats.hp
end
Happiness.change(mon, "USEDITEM")
return {
used = true,
text = ("%s's\n%s rose."):format(monName(mon), VITAMIN_LABEL[stat]),
}
end
-- --------------------------------------------------------- held attributes
--
-- The `held_items` registry (src/mods/Schemas.lua), which is the last two
@@ -341,11 +377,8 @@ function ItemEffects.recordFor(itemId, data)
return (merged and merged[itemId]) or ItemEffects.RECORDS[itemId]
end
-- Which family a PACK item runs on a party mon, or nil for anything whose
-- ITEMMENU_PARTY behaviour is not ported (vitamins, PP UP, evolution stones).
-- FULL_RESTORE classifies as "heal"; its full-HP status arm lives inside the
-- record's own `use` the way FullRestoreEffect keeps both halves in one
-- routine.
-- Which family a PACK item runs on a party mon, or nil for an id with no
-- item_effects record (engine/items/pack.asm UseItem, ITEMMENU_PARTY arm).
function ItemEffects.partyAction(itemId, data)
local record = ItemEffects.recordFor(itemId, data)
return record and record.action or nil
@@ -446,6 +479,36 @@ for itemId, row in pairs(ItemEffects.RESTORE_PP) do
end)
end
-- engine/items/item_effects.asm:2320 RestorePPEffect's PP_UP arm.
record("PP_UP", "pp", function(ctx)
local move = (ctx.mon.moves or {})[ctx.slot]
if type(move) ~= "table" or not move.id then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local row = ((ctx.data and ctx.data.moves) or {})[move.id]
local name = (row and row.name) or move.id
-- constants/pokemon_data_constants.asm:216 PP_UP_MASK.
if move.id == "SKETCH" or (move.ppUps or 0) >= 3 then
return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) }
end
local base = (row and row.pp) or move.maxPp
if not base then
return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) }
end
-- engine/items/item_effects.asm:2736 ComputeMaxPP.
local bonus = math.min(math.floor(base / 5), 7)
move.ppUps = (move.ppUps or 0) + 1
move.maxPp = base + move.ppUps * bonus
move.pp = (move.pp or 0) + bonus
return { used = true, text = ItemEffects.TEXT_PP_INCREASED:format(name) }
end)
for itemId in pairs(ItemEffects.VITAMIN) do
record(itemId, "vitamin", function(ctx)
return vitamin(ctx.item, ctx.mon, ctx.data)
end)
end
record("RARE_CANDY", "candy", function(ctx)
return rareCandy(ctx.mon, ctx.data)
end)
+1
View File
@@ -272,6 +272,7 @@ Save.DEFAULT_OPTIONS = {
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
-- means something different, hence the different name.
color = "gbc",
videoMode = "windowed",
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
+10
View File
@@ -560,6 +560,16 @@ local function gen2Rows(opts, hooks)
end)
end
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then
add(Strings("VIDEO MODE"),
function() return VideoMode.modeLabel(opts.videoMode) end,
function(dir)
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
return true
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
+17 -20
View File
@@ -119,13 +119,9 @@ PaletteFX.GBC_OBJ_BLUE = {
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red for Red. White (index 1) and black (index 4) are
-- identical across Red/Blue, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch there.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
-- pal() / usesYellowCgb(), and ogBg() falls back to CGBBase PAL_ROUTE for any
-- remaining whole-screen callers -- never Blue's GBC_BG_BLUE.
-- The active game's OG boot-ROM background palette: blue for Blue, red for Red.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes), so ogBg() falls back to
-- CGBBase PAL_ROUTE there, never Blue's GBC_BG_BLUE.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
if GameVersion.isYellow() then
@@ -354,23 +350,14 @@ function PaletteFX.usesSpriteObp(mode)
return mode == "ogred" and not GameVersion.isYellow()
end
-- ------- post-zone sprite redraw (OG RED)
--
-- In OG RED the world canvas still runs through the whole-screen zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. (SGB used to come through here too; it no longer bakes an object
-- palette at all, so its characters are colorized by the zone like the ground
-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
-- late. Entries carrying `colors` are re-colorized draws (the tall-grass
-- feet overdraw, which must keep hiding sprite feet) issued through the
-- color-0-keyed shade-remap shader. World pass only; cleared per frame.
-- ------- post-zone sprite redraw (OG RED): OBP-baked draws recorded here and
-- replayed by Renderer:endFrame on top of the zone pass (usesSpriteObp, #301)
local spriteRedraws = {}
local uiSpriteRedraws = {}
function PaletteFX.clearSpriteRedraws()
for i = #spriteRedraws, 1, -1 do spriteRedraws[i] = nil end
for i = #uiSpriteRedraws, 1, -1 do uiSpriteRedraws[i] = nil end
end
function PaletteFX.markSpriteRedraw(image, quad, x, y, sx, colors, keyed)
@@ -392,6 +379,16 @@ function PaletteFX.spriteRedraws()
return spriteRedraws
end
function PaletteFX.markUiSpriteRedraw(image, quad, x, y)
if currentPass ~= "ui" then return end
uiSpriteRedraws[#uiSpriteRedraws + 1] =
{ image = image, quad = quad, x = x + markOffsetX, y = y }
end
function PaletteFX.uiSpriteRedraws()
return uiSpriteRedraws
end
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data)
+14
View File
@@ -1017,6 +1017,20 @@ function Renderer:endFrame(zones, worldZones)
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end
end
local uiRedraws = PaletteFX.uiSpriteRedraws()
if uiRedraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(uox, uoy, uvpw, uvph)
for _, r in ipairs(uiRedraws) do
if r.quad then
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
0, Ux, Uy)
else
love.graphics.draw(r.image, uox + r.x * Ux, uoy + r.y * Uy, 0, Ux, Uy)
end
end
love.graphics.setScissor()
end
-- The battle wipe covers the whole surface, letterbox included, so it goes
-- over the finished composite rather than under the UI blit. On hardware
+9
View File
@@ -51,6 +51,7 @@ function TextBox.new(game, text, onDone, opts)
self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo
self.choiceNoSound = opts and opts.noSound
self.money = opts and opts.money
self.auto = opts and opts.auto
self.stay = opts and opts.stay
-- opts.instant: put the LAST page up already typed, with no typewriter and
@@ -457,6 +458,14 @@ function TextBox:draw()
pen = pen + Font.advanceOf(code)
end
end
if self.money then
-- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at
-- hlcoord 11,0, the amount right-aligned on its middle row
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
end
if (self.waiting or (self.done and not self.choice and not self.auto
and not self.stay))
and self.blink < 30 then
+3 -2
View File
@@ -565,10 +565,11 @@ end
-- ---- engine/events/poisonstep.asm -----------------------------------------
-- .PlayPoisonSFX: SFX_POISON, then LoadPoisonBGPals for two frames. The port
-- plays the sound; the two-frame red flash is the renderer's business.
-- .PlayPoisonSFX: SFX_POISON, then LoadPoisonBGPals for four frames.
-- engine/events/poisonstep.asm:101
function H.PlayPoisonSFX(ctx)
call(ctx, "playSfxNamed", SFX_POISON[1], SFX_POISON[2])
call(ctx, "poisonBGFlash")
return nil
end
+7 -5
View File
@@ -134,13 +134,15 @@ function SummaryMenu:draw()
Font.draw(("%03d"):format(def.dex or 0), 24, 56)
if self.page == 1 then
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
-- bracket around the name/HP block, and PrintLevel at (14,2). The
-- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea
-- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
-- (9,2) 5x10 (status_screen.asm:303-305). #280
printLevel(14, 2, mon.level)
drawLineBox(19, 1, 6, 10)
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
-- engine/pokemon/status_screen.asm:120-125
local PaletteFX = require("src.render.PaletteFX")
local barZoned = PaletteFX.shader() ~= nil
and PaletteFX.pal(data, "GREENBAR") ~= nil
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
Font.draw(Strings("STATUS/"), 72, 48)
Font.draw(mon.status or "OK", 128, 48)
+27 -4
View File
@@ -171,6 +171,27 @@ local function markVisibleTrueColor(x, y, w, h, cover)
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
end
-- ..(engine/movie/title.asm ln 321)
local function replayObjSprite(game, image, quad, x, y)
local P = require("src.render.PaletteFX")
if not P.usesSpriteObp() then return end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
local w, h
if quad then
w, h = select(3, quad:getViewport())
else
w, h = image:getDimensions()
end
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
return
end
end
P.markUiSpriteRedraw(image, quad, x, y)
end
function TitleState.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TitleState)
@@ -662,12 +683,11 @@ function TitleState:draw()
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
-- the strip Red's OAM covers (#350). Yellow never reaches here: its
-- layout has no cycling mon and no Red art (title_yellow.asm).
-- SGB: the mon keeps its palette minus the strip Red's OAM covers
-- (#350); Yellow never reaches here (title_yellow.asm)
if spriteTrueColor then
local cover
if playerImage then
if playerImage and not require("src.render.PaletteFX").usesSpriteObp() then
local pw, ph = playerImage:getDimensions()
cover = { 82, 80, pw, ph }
end
@@ -678,10 +698,13 @@ function TitleState:draw()
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3])
replayObjSprite(self.game, playerImage, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
replayObjSprite(self.game, playerImage, self.ballQuad, 82, self.ballY)
elseif playerImage then
love.graphics.draw(playerImage, 82, 80)
replayObjSprite(self.game, playerImage, nil, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
+63 -25
View File
@@ -31,6 +31,7 @@ local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
local Prize = require("src.battle.gen2.Prize")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
@@ -621,6 +622,19 @@ function BattleState:drawPic(mon, back)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
-- GetSubstitutePic (engine/battle_anims/anim_commands.asm:905-960): the
-- doll sits in the mon's own pic box and takes its palette.
local doll, dollQuad
if not (trainerBack or enemyTrainer) then
local over = anim and anim.pic
local up
if over ~= nil then
up = over == "substitute"
else
up = mon and mon.volatile and (mon.volatile.substitute or 0) > 0
end
if up then doll, dollQuad = self:substituteDoll(back) end
end
local G = love.graphics
local w, h = image:getDimensions()
local px, py
@@ -660,6 +674,10 @@ function BattleState:drawPic(mon, back)
px = px + math.floor(w * (1 - scale) / 2)
py = py + math.floor(h * (1 - scale))
end
if doll then
px = (back and 32 or 112) + ((anim and anim.slide) or 0)
py = back and 80 or 40
end
G.setColor(1, 1, 1, 1)
-- No mon on this side at all in the catching tutorial, where the box holds
-- the DUDE's back-pic and nothing else for the whole battle.
@@ -682,6 +700,10 @@ function BattleState:drawPic(mon, back)
-- what is still inside the box rather than drawn over the HUD below it.
local sunk = self:faintSink(side)
local function body()
if doll then
G.draw(doll, dollQuad, px, py)
return
end
if sunk > 0 then
local visible = h - math.floor(sunk / scale)
if visible <= 0 then return end
@@ -701,6 +723,24 @@ function BattleState:drawPic(mon, back)
end
end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
-- enemy's frontpic, facing-UP for the player's backpic.
function BattleState:substituteDoll(back)
if self.subDoll == nil then
local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png")
if ok and image then
local w, h = image:getDimensions()
self.subDoll = { image = image,
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
else
self.subDoll = false
end
end
if not self.subDoll then return nil end
return self.subDoll.image, back and self.subDoll.up or self.subDoll.down
end
-- The top `visible` rows of a pic, for the faint slide. One quad, re-aimed,
-- the way BattleAnimView keeps one blit quad rather than a new one per frame.
function BattleState:cropQuad(image, visible)
@@ -1072,16 +1112,8 @@ function BattleState:stepAnim(input)
end
end
-- NOTE on picHidden and the animation runtime. An animation that ENDS with a
-- pic box cleared could latch it here, and the tilemap argument says it should:
-- BattleAnimRestoreHuds redraws the two HUDs and nothing else. It deliberately
-- does not, because BATTLE_BG_EFFECT_REMOVE_MON and _RETURN_MON are also used
-- by moves whose user is still standing there afterwards -- SUBSTITUTE (the
-- doll takes the box over, and nothing in this port draws one yet), SKY_ATTACK,
-- BEAT_UP, BATON_PASS -- and a blanket latch would make those mons invisible
-- for the rest of the fight. The two moments the cart really does leave the
-- box empty for good are latched explicitly instead: a catch (pushCaught) and
-- a faint (MonFaintedAnimation, in update).
-- REMOVE_MON / RETURN_MON also serve SUBSTITUTE, SKY_ATTACK, BEAT_UP and
-- BATON_PASS, so picHidden is only latched by a catch and a faint.
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
-- HUD update run the moment its animation is done, cut short or not.
@@ -1101,6 +1133,7 @@ function BattleState:animPicState(side)
size = bg.picSize[side],
slide = bg.slide[side] or 0,
shade = bg.monShade[side],
pic = self.anim.picOverride[side],
}
end
@@ -1190,22 +1223,14 @@ function BattleState:advanceQueue()
if event.kind == "level" and event.index then
self.evolvable[event.index] = true
-- GiveExperiencePoints' `.skip_active_mon_update` guard
-- (engine/battle/core.asm:6999-7003): only the mon that is OUT copies its
-- recalculated HP, max HP and level into the battle struct, and only then
-- does `callfar UpdatePlayerHUD` (:7034) redraw the bar. That is a
-- REDRAW, not AnimateHPBar, so the shown HP snaps instead of chasing --
-- without it the bar kept the pre-level-up HP against the new maximum
-- until the next damage or heal event moved it.
-- (engine/battle/core.asm:6999-7003): the OUT mon's shown HP snaps.
local battle = self.battle
local mon = battle and battle.party and battle.party[event.index]
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
-- gets the stats box, not just the mon currently on the field.
self.pendingStatsMon = mon
-- engine/battle/core.asm:7044
-- engine/battle/core.asm:7284
if mon and mon == battle.player then
event.text = nil
event.sfx = nil
event.waitSfx = nil
if self.shownHp then
self.shownHp.player = mon.hp or 0
if self.hpAnim and self.hpAnim.side == "player" then
@@ -1213,9 +1238,6 @@ function BattleState:advanceQueue()
end
end
-- `ld [wBattleMonLevel], a` in the same guarded block (:7018-7020).
-- AnimateExpBar has already walked the number up one level at a time by
-- the time this runs, so this only catches a level gained with no exp
-- crawl behind it.
self.shownLevel = mon.level or self.shownLevel
end
end
@@ -2464,7 +2486,10 @@ function BattleState:pushCaught(enemy, itemId)
self:push({ kind = "dex-entry", species = enemy.species })
end
if self.contest then
return self:contestCatch(enemy)
-- A contest catch still exits through the `.run` arm with result WIN
-- (engine/battle/core.asm:4780-4783), so CheckPayDay runs for it too.
self:contestCatch(enemy)
return self:pushPayDay()
end
save.party = save.party or {}
local toPc = #save.party >= Boxes.PARTY_SIZE
@@ -2520,6 +2545,19 @@ function BattleState:pushCaught(enemy, itemId)
self:push({ kind = "message",
text = self:name(enemy) .. " was sent to BILL's PC." })
end
self:pushPayDay()
end
-- CheckPayDay runs on a capture too: `and $f` keeps the win arm
-- (engine/battle/core.asm:7971-7976, :8014-8042).
function BattleState:pushPayDay()
local save = self.save
local coins = Prize.payDay(save, self.battle.payDay, self.battle.amuletCoin)
self.battle.payDay = nil
if coins then
self:push({ kind = "message",
text = Prize.payDayMessage(coins, save.player and save.player.name) })
end
end
-- CheckWhetherToAskSwitch: a started battle, more than one mon, no link, the
@@ -3029,7 +3067,7 @@ function BattleState:applyPartyItem(itemId, action, mon, slot)
local before = (mon and mon.hp) or 0
local result
if action == "pp" then
result = ItemEffects.usePpItem(itemId, mon, slot)
result = ItemEffects.usePpItem(itemId, mon, slot, data)
else
result = ItemEffects.useOnMon(itemId, mon, data)
end
+11
View File
@@ -172,6 +172,17 @@ local ROWS = {
text = function(options)
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
end },
{ label = "VIDEO MODE", key = "videoMode", port = true,
cycle = function(options, delta)
local VideoMode = require("src.core.VideoMode")
options.videoMode = VideoMode.cycle(options.videoMode, delta)
VideoMode.apply(options.videoMode)
end,
text = function(options)
local VideoMode = require("src.core.VideoMode")
return VideoMode.normalize(options.videoMode) == "borderless"
and "FULL" or "WINDOWED"
end },
{ id = "touchControls", label = "TOUCH PAD", port = true,
text = function(options)
local tc = options.touchControls
+8
View File
@@ -283,6 +283,14 @@ function PartyMenu:finishSwitch()
if self.save and self.save.party == party then
Mail.swapSlots(self.save, from, to)
end
-- engine/pokemon/switchpartymons.asm:38
local data = self.game and self.game.data
local ok, Sound = pcall(require, "src.core.Sound")
if not (ok and data and Sound and Sound.play) then return end
local sfx = data.audio and data.audio.sfx
if sfx and sfx[Sound.resolve(data, "Sfx_SwitchPokemon")] then
pcall(Sound.play, data, "Sfx_SwitchPokemon")
end
end
-- The reopened list: InitPartyMenuNoCancel caps the cursor at the last mon,
+24 -15
View File
@@ -1969,8 +1969,11 @@ function OverworldState:tryBookshelf(fx, fy)
return true
end
if entry.screen then
-- Blue's house shelf opens the TOWN MAP (TownMapText)
pcall(Screens.push, Game, entry.screen)
-- engine/events/hidden_events/town_map.asm:1
Game.stack:push(TextBox.new(Game,
t[entry.text] or t._TownMapText
or romText(Game.data, "_TownMapText", "A TOWN MAP."),
function() pcall(Screens.push, Game, entry.screen) end))
return true
end
local kind = entry.kind
@@ -2136,14 +2139,17 @@ function OverworldState:tryHiddenObject(fx, fy)
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
if self.map.id == "REDS_HOUSE_2F" then
-- The player's bedroom PC is the one location in Red/Blue whose PC
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
-- which runs the PlayerPC predef directly -- item storage, no
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
-- OpenRedsPC (engine/events/hidden_objects/players_pc.asm) runs the
-- PlayerPC predef directly, no DisplayPCMainMenu (#228)
require("src.core.Sound").play(Game.data, "Turn_On_PC")
-- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960)
Screens.push(Game, "PlayerPC", { direct = true })
-- engine/menus/players_pc.asm:16
Game.stack:push(TextBox.new(Game,
(Game.data.text or {})._TurnedOnPC2Text
or romText(Game.data, "_TurnedOnPC2Text", "{PLAYER} turned on\nthe PC."),
function()
-- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960)
Screens.push(Game, "PlayerPC", { direct = true })
end, { instant = true }))
else
self:openPC()
end
@@ -2892,13 +2898,16 @@ function OverworldState:openPC(onDone)
done()
end
table.insert(items, { label = Strings("LOG OFF"), onSelect = logOff })
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
-- tw here (total width) is 16
Game.stack:push(Menu.new(Game, items,
-- BIT_NO_MENU_BUTTON_SOUND for the whole PC session; DisplayPCMainMenu's
-- TextBoxBorder c=14 interior -> tw 16 (engine/overworld/pokecenter_pc.asm)
local menu = Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff,
noSound = true }))
noSound = true })
-- engine/menus/pc.asm:5
Game.stack:push(TextBox.new(Game,
(Game.data.text or {})._TurnedOnPC1Text
or romText(Game.data, "_TurnedOnPC1Text", "{PLAYER} turned on\nthe PC."),
function() Game.stack:push(menu) end))
end
-- The PROF. OAK's PC session (engine/menus/oaks_pc.asm OpenOaksPC): the
+1 -1
View File
@@ -78,7 +78,7 @@ function StepEvents.poisonStep(party)
return { kind = "poisonFaint", fainted = fainted, hurt = hurt, blocks = true }
end
if #hurt > 0 then
-- .PlayPoisonSFX and the two-frame red flash, then `xor a`: no carry, so
-- .PlayPoisonSFX and the four-frame BG flash, then `xor a`: no carry, so
-- the step still counts and the wild roll still happens.
return { kind = "poisonHurt", hurt = hurt, blocks = false }
end
+35 -14
View File
@@ -2123,6 +2123,11 @@ function World:updateShake()
shake.phase = (shake.left % 2 == 0) and shake.amplitude or -shake.amplitude
end
-- engine/events/poisonstep_pals.asm:9
function World:poisonBGFlash()
self.poisonFlash = 4
end
-- Script_warp / Script_warpfacing: a raw destination CELL, distinct from the
-- warp_events World:takeWarp follows. `facing` is nil for `warp` and a
-- Movement direction for `warpfacing` (PLAYERSPRITESETUP_CUSTOM_FACING). A
@@ -2400,7 +2405,9 @@ end
function World:playMapMusic()
local data = self.game and self.game.data
if data and data.audio and data.audio.runtime and self.map then
Music.playMap(data, self.map.id)
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
end
end
@@ -3246,7 +3253,9 @@ function World:surfStartStep(mon)
self:applyPlayerState(FieldMoves.surfType(mon))
local audio = self.game and self.game.data and self.game.data.audio
if audio and audio.runtime and self.map then
Music.playMap(self.game.data, self.map.id)
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
end
if p.scriptStep then p:scriptStep(p.facing) end
self.fieldMove = { phase = "step" }
@@ -5363,7 +5372,9 @@ function World:runSurf(result)
self:applyPlayerState(result.state)
local audio = self.game and self.game.data and self.game.data.audio
if audio and audio.runtime and self.map then
Music.playMap(self.game.data, self.map.id)
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
end
if self.player and self.player.scriptStep then
self.player:scriptStep(self.player.facing)
@@ -8524,13 +8535,12 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- rebuildPeople may have pooled fresh NPCs; give them their colors too.
self:applyPalettes()
local audio = self.game and self.game.data and self.game.data.audio
-- PlayMapMusicBike (home/audio.asm), which is the mapsetup every load uses:
-- a player still on the bike keeps the bike theme across the warp instead of
-- hearing the new map's song. Music.play dedupes the same label, so this is
-- safe on seamless edge crossings.
-- PlayMapMusicBike / SpecialMapMusic (home/audio.asm:335, :397): a biking
-- player keeps the bike theme; Music.play dedupes seamless edge crossings.
if audio and audio.runtime then
if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then
Music.playMap(self.game.data, mapId)
Music.playMap(self.game.data, mapId, nil,
FieldMoves.isSurfing(self.playerState))
end
end
-- Fires with the map fully built and BEFORE the map's own scene script, so a
@@ -8912,14 +8922,13 @@ function World:movePlayer(dir)
if result == "moved"
and Permissions.surfable(map:cellCollision(p.targetX, p.targetY))
== "land" then
-- .ExitWater: GetOutOfWater writes PLAYER_NORMAL and runs
-- UpdatePlayerSprite BEFORE .DoStep, so the player is already off the
-- Lapras for the step that puts them on the beach, and PlayMapMusic then
-- swaps the surfing theme back for the map's own.
-- .ExitWater: GetOutOfWater writes PLAYER_NORMAL before .DoStep, then
-- PlayMapMusic swaps the surf theme back (home/audio.asm:308)
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
local audio = self.game and self.game.data and self.game.data.audio
if audio and audio.runtime then
Music.playMap(self.game.data, map.id)
Music.playMap(self.game.data, map.id, nil,
FieldMoves.isSurfing(self.playerState))
end
end
end
@@ -9159,7 +9168,7 @@ function World:countStep()
elseif event.kind == "poisonFaint" then
self:poisonFaintScript(event)
elseif event.kind == "poisonHurt" then
-- .PlayPoisonSFX alone: the sound and the two-frame red flash, no script.
-- .PlayPoisonSFX alone: the sound and the four-frame BG flash, no script.
CallAsm.run(self, "PlayPoisonSFX")
elseif event.kind == "repel" then
self:repelWoreOff()
@@ -10031,6 +10040,18 @@ function World:draw()
G.setColor(1, 1, 1, 1)
end
-- engine/events/poisonstep_pals.asm:9-42
if self.poisonFlash and self.poisonFlash > 0 then
self.poisonFlash = self.poisonFlash - 1
if GbcPalette.mode == "gbc" then
G.setColor(28 / 31, 21 / 31, 1, 0.55)
else
G.setColor(0, 0, 0, 0.45)
end
G.rectangle("fill", 0, 0, w, h)
G.setColor(1, 1, 1, 1)
end
-- FadeOutToWhite / FadeOutToBlack, held until a FadeInFrom* clears it. On
-- the cart the pair brackets a scripted cutscene's set change (the Elite Four
-- doors, the Radio Tower takeover, Lugia's chamber); the port has no