mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
yellow alpha
This commit is contained in:
@@ -22,7 +22,7 @@ local NOTES = {
|
||||
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
|
||||
-- grid as the ROM's own drum tables
|
||||
local function snapTicks(ticks)
|
||||
return math.floor((ticks * 735 + 256) / 512)
|
||||
return math.floor((ticks * 1470 + 256) / 512)
|
||||
end
|
||||
|
||||
-- ------- validation
|
||||
|
||||
@@ -630,8 +630,22 @@ end
|
||||
-- player mon; the battle menu appears under the OLD MAN's name and a
|
||||
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
|
||||
-- (one POKé BALL x50). The throw always catches; nothing is kept.
|
||||
function BattleState:makeOldManDemo()
|
||||
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
|
||||
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
|
||||
-- displayed thrower name is a parameter.
|
||||
function BattleState:makeOldManDemo(name)
|
||||
self.demo = true
|
||||
self.demoName = name or "OLD MAN"
|
||||
-- Yellow's Pallet intro runs this before the player owns any mon
|
||||
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
|
||||
-- battle dead for lack of a party. The demo never sends out, draws, or
|
||||
-- acts with the player side; a hidden placeholder battler keeps the
|
||||
-- shared battle phases nil-safe.
|
||||
if not self.player then
|
||||
self.dead = false
|
||||
self.player = makeBattler(self.game.data,
|
||||
Pokemon.new(self.game.data, self.enemy.mon.species, 5), true)
|
||||
end
|
||||
end
|
||||
|
||||
-- Safari Zone battles (engine/battle/core.asm safari sections +
|
||||
@@ -1046,6 +1060,10 @@ function BattleState:computeMusicKind()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
|
||||
-- fights only -- not Lance or the Champion) bumps the companion's
|
||||
-- happiness the moment the battle starts
|
||||
self.isGymLeader = isBoss
|
||||
if self.kind == "trainer" and self.trainer
|
||||
and self.trainer.id == "OPP_RIVAL3" then
|
||||
return "final"
|
||||
@@ -1086,6 +1104,10 @@ function BattleState:enter()
|
||||
end
|
||||
local Music = require("src.core.Music")
|
||||
self.musicKind = self:computeMusicKind()
|
||||
if self.isGymLeader then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(self.game.save, "GYMLEADER")
|
||||
end
|
||||
-- normally already playing: the transition wipe starts the theme
|
||||
-- (audio/play_battle_music.asm runs before the transition, and
|
||||
-- Music.play no-ops on the same song); this covers battles pushed
|
||||
@@ -1711,7 +1733,7 @@ function BattleState:oldManThrow()
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "finish"
|
||||
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
||||
self:say(Strings("OLD MAN used\nPOKé BALL!"))
|
||||
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
-- ItemUseBall's beat before the toss chain (like throwBall)
|
||||
@@ -2986,6 +3008,17 @@ function BattleState:onFaint(battler)
|
||||
self.participants[battler.mon] = nil
|
||||
end
|
||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||
if battler.isPlayer then
|
||||
-- HandlePlayerMonFainted (core.asm:1070-1085): the companion loses
|
||||
-- happiness on its own faint; an enemy 30+ levels above it makes
|
||||
-- that the CARELESSTRAINER hit instead
|
||||
local enemyLevel = self.enemy and self.enemy.mon
|
||||
and self.enemy.mon.level or 0
|
||||
local reason = (enemyLevel - (battler.mon.level or 0)) >= 30
|
||||
and "CARELESSTRAINER" or "FAINTED"
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(self.game.save, reason, battler.mon)
|
||||
end
|
||||
-- the faint slide + cry ride the queue (after the move animation and
|
||||
-- the HP-bar drain, pokered's order); the slide finishes before the
|
||||
-- faint text via a queued hold
|
||||
@@ -3070,6 +3103,9 @@ function BattleState:enemyMonFainted()
|
||||
-- the move-learn checks (experience.asm:245-256)
|
||||
local game = self.game
|
||||
for _, lv in ipairs(levels) do
|
||||
-- experience.asm:248 fires per grew-level text
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "LEVELUP", mon)
|
||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
||||
self:uiNext(function()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
@@ -3890,7 +3926,10 @@ function BattleState:finish()
|
||||
-- way back -- an unrecoverable state, not merely a wrong one.
|
||||
-- playerMonFainted is the path that should have caught this; if we land
|
||||
-- here it did not, so say so rather than silently papering over it.
|
||||
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
|
||||
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
||||
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||
if self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self.game.save.party) then
|
||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||
tostring(self.result))
|
||||
self.result = "lose"
|
||||
@@ -4802,7 +4841,9 @@ function BattleState:drawTextArea()
|
||||
Font.drawCode(Font.BORDER.br, 80, 96)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
|
||||
-- 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)
|
||||
end
|
||||
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
||||
40, 96 + self.moveIndex * 8)
|
||||
@@ -4811,10 +4852,10 @@ function BattleState:drawTextArea()
|
||||
end
|
||||
local sel = self.player.curMoves[self.moveIndex]
|
||||
if sel then
|
||||
local def = self.data.moves[sel.id]
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
Font.draw(Strings("disabled!"), 8, 80)
|
||||
else
|
||||
local def = self.data.moves[sel.id]
|
||||
elseif def then
|
||||
Font.draw(Strings("TYPE/"), 8, 72)
|
||||
-- the type record's display name (a mod type shows its name, and
|
||||
-- PSYCHIC_TYPE prints PSYCHIC like the original)
|
||||
|
||||
+52
-45
@@ -14,15 +14,15 @@ local bit = require("bit")
|
||||
|
||||
local ChipSynth = {}
|
||||
|
||||
local SAMPLE_RATE = 22050
|
||||
local SAMPLE_RATE = 44100
|
||||
local TICKS_PER_SECOND = 15360
|
||||
local FRAME_TICKS = 256
|
||||
local GB_CLOCK = 4194304
|
||||
|
||||
-- one 4096-sample stereo SoundData is the unit both the worker hands off and
|
||||
-- one 8192-sample stereo SoundData is the unit both the worker hands off and
|
||||
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
|
||||
-- (~6s) for stall tolerance (window resize, a long GC pause)
|
||||
local MUSIC_BUFFER_SAMPLES = 4096
|
||||
-- (~6s at 44100) for stall tolerance (window resize, a long GC pause)
|
||||
local MUSIC_BUFFER_SAMPLES = 8192
|
||||
local MUSIC_BUFFER_COUNT = 32
|
||||
|
||||
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
@@ -33,7 +33,13 @@ local PITCHES = {
|
||||
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
||||
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
||||
}
|
||||
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
|
||||
-- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index
|
||||
local WAVE_PATTERN_TABLES = {
|
||||
[0] = {0, 0, 0, 0, 0, 0, 0, 1},
|
||||
[1] = {1, 0, 0, 0, 0, 0, 0, 1},
|
||||
[2] = {1, 0, 0, 0, 0, 1, 1, 1},
|
||||
[3] = {0, 1, 1, 1, 1, 1, 1, 0},
|
||||
}
|
||||
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
|
||||
local NOISE_DIVISORS = {
|
||||
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
|
||||
@@ -41,7 +47,7 @@ local NOISE_DIVISORS = {
|
||||
}
|
||||
|
||||
local function snapTicks(ticks)
|
||||
return math.floor((ticks * 735 + 256) / 512)
|
||||
return math.floor((ticks * 1470 + 256) / 512)
|
||||
end
|
||||
|
||||
local cachedProgramFile
|
||||
@@ -143,7 +149,7 @@ function Channel.new(engine, spec, options)
|
||||
speed = 12,
|
||||
volume = 12,
|
||||
fade = 0,
|
||||
duty = 0.5,
|
||||
duty = 2,
|
||||
octave = 4,
|
||||
waveInstrument = 0,
|
||||
waveLevel = 1,
|
||||
@@ -317,7 +323,7 @@ function Channel:nextEvent()
|
||||
target = self:frequency(bit.band(packed, 0x0F), octave),
|
||||
}
|
||||
elseif command == 0xEC then
|
||||
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
|
||||
self.duty = bit.band(self:byte(), 3)
|
||||
elseif command == 0xED then
|
||||
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
||||
elseif command == 0xEE then
|
||||
@@ -329,10 +335,10 @@ function Channel:nextEvent()
|
||||
elseif command == 0xFC then
|
||||
local packed = self:byte()
|
||||
self.duty = {
|
||||
DUTY[bit.band(bit.rshift(packed, 6), 3)],
|
||||
DUTY[bit.band(bit.rshift(packed, 4), 3)],
|
||||
DUTY[bit.band(bit.rshift(packed, 2), 3)],
|
||||
DUTY[bit.band(packed, 3)],
|
||||
bit.band(bit.rshift(packed, 6), 3),
|
||||
bit.band(bit.rshift(packed, 4), 3),
|
||||
bit.band(bit.rshift(packed, 2), 3),
|
||||
bit.band(packed, 3),
|
||||
}
|
||||
elseif command == 0xFD then
|
||||
self.callStack[#self.callStack + 1] = self.address + 2
|
||||
@@ -423,27 +429,23 @@ function Channel:sampleNoise(parameter)
|
||||
parameter = parameter or 0
|
||||
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
||||
local shift = bit.rshift(parameter, 4)
|
||||
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
if shift >= 14 then return output end
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
local area = 0
|
||||
|
||||
while remaining > 0 do
|
||||
local untilClock = 1 - self.noiseClock
|
||||
local span = math.min(remaining, untilClock)
|
||||
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
area = area + output * span
|
||||
self.noiseClock = self.noiseClock + span
|
||||
remaining = remaining - span
|
||||
if self.noiseClock >= 1 - 1e-12 then
|
||||
self.noiseClock = 0
|
||||
self:clockNoise(width7)
|
||||
if shift < 14 then
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
while remaining > 0 do
|
||||
local untilClock = 1 - self.noiseClock
|
||||
local span = math.min(remaining, untilClock)
|
||||
self.noiseClock = self.noiseClock + span
|
||||
remaining = remaining - span
|
||||
if self.noiseClock >= 1 - 1e-12 then
|
||||
self.noiseClock = 0
|
||||
self:clockNoise(width7)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return area / cycles
|
||||
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
|
||||
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
end
|
||||
|
||||
local function sweepCalculation(register, sweep)
|
||||
@@ -481,7 +483,7 @@ function Channel:sampleDrum(event, sampleIndex)
|
||||
end
|
||||
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
|
||||
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
|
||||
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
|
||||
return self:sampleNoise(segment.parameter) * volume / 15
|
||||
end
|
||||
|
||||
function Channel:sample()
|
||||
@@ -502,7 +504,7 @@ function Channel:sample()
|
||||
local volume = envelopeVolume(
|
||||
event.volume or 0, event.fade or 0, event.elapsed)
|
||||
if event.noise then
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15
|
||||
end
|
||||
|
||||
local register = event.register
|
||||
@@ -537,13 +539,18 @@ function Channel:sample()
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel * 0.55
|
||||
return wave[index] * event.waveLevel
|
||||
end
|
||||
local duty = event.duty
|
||||
if type(duty) == "table" then
|
||||
duty = duty[frame % 4 + 1]
|
||||
end
|
||||
return (phase < duty and 1 or -1) * volume / 15 * 0.5
|
||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||
local step = math.floor(phase * 8) % 8
|
||||
if pattern[step + 1] == 0 then
|
||||
return -volume / 15
|
||||
end
|
||||
return volume / 15
|
||||
end
|
||||
|
||||
local Engine = {}
|
||||
@@ -597,8 +604,8 @@ local function readWaves(banks, audio, engineNumber)
|
||||
for byteIndex = 0, 15 do
|
||||
local packed = romByte(
|
||||
banks, spec.bank, spec.address + wave * 16 + byteIndex)
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
|
||||
end
|
||||
waves[#waves + 1] = values
|
||||
end
|
||||
@@ -606,8 +613,8 @@ local function readWaves(banks, audio, engineNumber)
|
||||
for byteIndex = 0, 15 do
|
||||
local packed = romByte(
|
||||
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
|
||||
end
|
||||
for _ = 1, 4 do waves[#waves + 1] = values end
|
||||
return waves
|
||||
@@ -615,7 +622,7 @@ end
|
||||
|
||||
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
|
||||
-- units) or as the -1..1 samples readWaves produces; the synth wants the
|
||||
-- latter
|
||||
-- latter (LuaGB: (nibble - 8) / 8)
|
||||
local function normalizeWaves(source)
|
||||
local waves = {}
|
||||
for index, values in ipairs(source) do
|
||||
@@ -625,7 +632,7 @@ local function normalizeWaves(source)
|
||||
end
|
||||
local wave = {}
|
||||
for position, value in ipairs(values) do
|
||||
wave[position] = nibbles and (value - 7.5) / 7.5 or value
|
||||
wave[position] = nibbles and (value - 8) / 8 or value
|
||||
end
|
||||
waves[index] = wave
|
||||
end
|
||||
@@ -690,7 +697,7 @@ end
|
||||
function Engine:sample()
|
||||
local value = 0
|
||||
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
||||
return math.max(-1, math.min(1, value * 0.5))
|
||||
return math.max(-1, math.min(1, value / 4))
|
||||
end
|
||||
|
||||
function Engine:sampleStereo()
|
||||
@@ -701,8 +708,8 @@ function Engine:sampleStereo()
|
||||
if not event or event.panLeft ~= false then left = left + value end
|
||||
if not event or event.panRight ~= false then right = right + value end
|
||||
end
|
||||
return math.max(-1, math.min(1, left * 0.5)),
|
||||
math.max(-1, math.min(1, right * 0.5))
|
||||
return math.max(-1, math.min(1, left / 4)),
|
||||
math.max(-1, math.min(1, right / 4))
|
||||
end
|
||||
|
||||
function Engine:sampleChannel(number)
|
||||
@@ -711,7 +718,7 @@ function Engine:sampleChannel(number)
|
||||
local value = channel:sample()
|
||||
if channel.number == number then selected = value end
|
||||
end
|
||||
return math.max(-1, math.min(1, selected * 0.5))
|
||||
return math.max(-1, math.min(1, selected / 4))
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
|
||||
@@ -85,6 +85,13 @@ function Data:seedDefaults()
|
||||
for key, value in pairs(BOOT_DEFAULTS) do
|
||||
if boot[key] == nil then boot[key] = copy(value) end
|
||||
end
|
||||
-- Yellow boots its own attract movie (engine/movie/intro_yellow.asm);
|
||||
-- only the un-overridden default flips, so a total conversion that set
|
||||
-- field.boot.screens.splash keeps its choice on any version.
|
||||
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
|
||||
and require("src.core.GameVersion").isYellow() then
|
||||
boot.screens.splash = "YellowIntro"
|
||||
end
|
||||
-- the naming screen presets the importer already extracts but nothing
|
||||
-- ever read (field.presetNames)
|
||||
if boot.namePresets == nil then
|
||||
|
||||
+7
-4
@@ -90,10 +90,13 @@ function Game:load()
|
||||
self.save.player.x, self.save.player.y, self.save.player.facing)
|
||||
else
|
||||
local titleState = self:makeTitleState()
|
||||
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
|
||||
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
|
||||
-- from field.boot.screens so a total conversion owns the whole boot
|
||||
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
|
||||
-- the copyright splash + attract movie plays before the title
|
||||
-- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own
|
||||
-- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from
|
||||
-- field.boot.screens so a total conversion owns the whole boot
|
||||
local splash = require("src.core.GameVersion").isYellow()
|
||||
and "YellowIntro" or "IntroMovie"
|
||||
Screens.push(self, bootScreens(self).splash or splash, function()
|
||||
StateStack:push(titleState)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
-- Which Gen-1 game this process is running: Red (the historical default) or
|
||||
-- Blue. One source of truth for everything that differs by version -- the
|
||||
-- accepted ROM hash, the import manifest, where the extracted cache lives,
|
||||
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
|
||||
-- screen and palette all agree.
|
||||
-- Which Gen-1 game this process is running: Red (the historical default),
|
||||
-- Blue, or Yellow. One source of truth for everything that differs by
|
||||
-- version -- the accepted ROM hash, the import manifest, where the
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- cache mount, SaveData, title screen and palette all agree.
|
||||
--
|
||||
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
|
||||
-- so existing installs are untouched; Blue is namespaced under blue/ and
|
||||
-- _blue so both can be imported and played side by side.
|
||||
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
|
||||
-- played side by side.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
-- and tests. The active version is a process-global set once at boot from
|
||||
@@ -19,6 +20,7 @@ GameVersion.VERSIONS = {
|
||||
id = "red",
|
||||
label = "Red",
|
||||
displayName = "Pokemon Red",
|
||||
launcherName = "Red", -- game-panel header in the launcher
|
||||
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||
@@ -28,15 +30,26 @@ GameVersion.VERSIONS = {
|
||||
id = "blue",
|
||||
label = "Blue",
|
||||
displayName = "Pokemon Blue",
|
||||
launcherName = "Blue",
|
||||
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
|
||||
manifest = "tools/rom_manifest_blue.json",
|
||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||
},
|
||||
yellow = {
|
||||
id = "yellow",
|
||||
label = "Yellow",
|
||||
displayName = "Pokemon Yellow",
|
||||
launcherName = "Yellow (alpha)",
|
||||
sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1",
|
||||
manifest = "tools/rom_manifest_yellow.json",
|
||||
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
|
||||
},
|
||||
}
|
||||
|
||||
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
|
||||
GameVersion.ORDER = { "red", "blue" }
|
||||
-- Launcher column order.
|
||||
GameVersion.ORDER = { "red", "blue", "yellow" }
|
||||
|
||||
GameVersion.current = "red"
|
||||
|
||||
@@ -53,6 +66,10 @@ function GameVersion.isBlue()
|
||||
return GameVersion.current == "blue"
|
||||
end
|
||||
|
||||
function GameVersion.isYellow()
|
||||
return GameVersion.current == "yellow"
|
||||
end
|
||||
|
||||
-- Metadata for a version id, defaulting to the active one.
|
||||
function GameVersion.info(id)
|
||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Game Boy Printer stand-in. Yellow's printer jobs
|
||||
-- (engine/printer/printer.asm: PrintPokedexEntry and friends) drove a
|
||||
-- serial thermal printer; this port renders the same printout into a PNG
|
||||
-- under prints/ in the save directory instead, and the caller shows a
|
||||
-- dialog with where it landed. Scaled up 4x so the "print" is legible
|
||||
-- on a modern screen.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local Printer = {}
|
||||
|
||||
local SCALE = 4
|
||||
|
||||
-- Render drawFn (which draws a w x h GB-pixel image at 0,0) into
|
||||
-- prints/<name>_<stamp>.png. Returns the save-dir-relative path, or nil
|
||||
-- and an error string (headless / no canvas support degrades gracefully).
|
||||
function Printer.save(name, w, h, drawFn)
|
||||
if not (love.graphics and love.graphics.newCanvas) then
|
||||
return nil, "no graphics"
|
||||
end
|
||||
local ok, canvas = pcall(love.graphics.newCanvas, w * SCALE, h * SCALE)
|
||||
if not ok then return nil, tostring(canvas) end
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.origin()
|
||||
love.graphics.scale(SCALE, SCALE)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local drawOk, drawErr = pcall(drawFn)
|
||||
love.graphics.pop()
|
||||
if not drawOk then return nil, tostring(drawErr) end
|
||||
local data
|
||||
ok, data = pcall(canvas.newImageData, canvas)
|
||||
if not ok then return nil, tostring(data) end
|
||||
love.filesystem.createDirectory("prints")
|
||||
local path = ("prints/%s_%s.png"):format(name, os.date("%Y-%m-%d_%H%M%S"))
|
||||
local encOk, err = pcall(data.encode, data, "png", path)
|
||||
if not encOk then return nil, tostring(err) end
|
||||
Logger.info("printed %s -> %s/%s",
|
||||
name, love.filesystem.getSaveDirectory(), path)
|
||||
return path
|
||||
end
|
||||
|
||||
return Printer
|
||||
+15
-12
@@ -24,10 +24,11 @@ local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveData = {}
|
||||
|
||||
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
|
||||
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
|
||||
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
|
||||
-- global preferences and the mod enable-state, not per-playthrough data).
|
||||
-- Progress files carry the game-version suffix so Red / Blue / Yellow saves
|
||||
-- coexist: Red keeps save.lua / .bak / .tmp exactly as before; Blue is
|
||||
-- save_blue.lua and Yellow is save_yellow.lua (+ .bak/.tmp). options.lua is
|
||||
-- deliberately shared across versions (it holds global preferences and the
|
||||
-- mod enable-state, not per-playthrough data).
|
||||
local OPTIONS_FILENAME = "options.lua"
|
||||
|
||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||
@@ -323,8 +324,9 @@ end
|
||||
-- under options.saveSlots[version]; the active slot is also cached
|
||||
-- process-wide (like GameVersion.current) so the hot saveNames path does
|
||||
-- not re-read options every call. A false cache entry means "no slot in
|
||||
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
|
||||
-- keeps a brand-new install and every pre-slots caller working unchanged.
|
||||
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
|
||||
-- is used, which keeps a brand-new install and every pre-slots caller
|
||||
-- working unchanged.
|
||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- version -> true once resolved this process
|
||||
|
||||
@@ -336,17 +338,18 @@ local function slotNames(version, id)
|
||||
end
|
||||
|
||||
-- the pre-slots flat names a version always used (save.lua for Red,
|
||||
-- save_blue.lua for Blue); still the destination before any slot exists
|
||||
-- save_blue.lua / save_yellow.lua for the others); still the destination
|
||||
-- before any slot exists
|
||||
local function legacyNames(version)
|
||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
-- Slot resolution is only meaningful for versions GameVersion actually knows
|
||||
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
|
||||
-- that has no info entry and therefore no saveSuffix; resolving its legacy
|
||||
-- names would index a nil info table and crash. Treat any unknown version as
|
||||
-- having no slots so the slot APIs degrade to empty/no-op instead.
|
||||
-- (red / blue / yellow). An unknown id has no info entry and therefore no
|
||||
-- saveSuffix; resolving its legacy names would index a nil info table and
|
||||
-- crash. Treat any unknown version as having no slots so the slot APIs
|
||||
-- degrade to empty/no-op instead.
|
||||
local function knownVersion(version)
|
||||
return GameVersion.info(version) ~= nil
|
||||
end
|
||||
@@ -892,7 +895,7 @@ end)
|
||||
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
|
||||
function SaveData.save(data, mods)
|
||||
-- write to the file matching this save's own version, not just the active
|
||||
-- one, so a Blue playthrough always lands in save_blue.lua
|
||||
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
|
||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
||||
if data.options then
|
||||
SaveData.saveOptions(data.options)
|
||||
|
||||
@@ -192,10 +192,45 @@ local function newCrySource(data, species, def)
|
||||
return newFileSource(resolved)
|
||||
end
|
||||
|
||||
-- Yellow's voiced Pikachu clips (audio/pikachu_pcm.asm
|
||||
-- PlayPikachuSoundClip): 1-bit PCM decoded to WAVs at import
|
||||
-- (data.audio.pikaCries = clip count). Returns the source, nil when the
|
||||
-- cache carries no clips (Red/Blue) or headless.
|
||||
function Sound.playPikaCry(data, n)
|
||||
if not love.audio then return nil end
|
||||
local count = data.audio and data.audio.pikaCries
|
||||
if not count then return nil end
|
||||
n = math.max(1, math.min(count, n or 1))
|
||||
local key = "pikacry:" .. n
|
||||
local src = cache[key]
|
||||
if src == false then return nil end
|
||||
if not src then
|
||||
local ok, s = pcall(love.audio.newSource,
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
|
||||
if not ok then
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
src:stop()
|
||||
src:play()
|
||||
played("cry", "PIKACHU_PCM_" .. n, "PIKACHU")
|
||||
return src
|
||||
end
|
||||
|
||||
-- returns the source (nil headless) so callers that block on the cry
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
function Sound.playCry(data, species)
|
||||
if not love.audio then return nil end
|
||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||
-- never used for the species there); clip 1 is the everyday "Pika!"
|
||||
if species == "PIKACHU" then
|
||||
local src = Sound.playPikaCry(data, 1)
|
||||
if src then return src end
|
||||
end
|
||||
local cries = data.audio and data.audio.cries
|
||||
local def = cries and cries[species]
|
||||
if not def then return nil end
|
||||
|
||||
+17
-17
@@ -32,11 +32,11 @@ local CacheFs = {}
|
||||
local SEP = package.config:sub(1, 1)
|
||||
|
||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
|
||||
-- keeps the historical root. The launcher sets it per import / per readiness
|
||||
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
|
||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||
-- onto the un-prefixed paths instead.
|
||||
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
|
||||
-- while a Red import keeps the historical root. The launcher sets it per
|
||||
-- import / per readiness check; it stays "" for Red. Runtime *reads*
|
||||
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
|
||||
-- the active version's subtree onto the un-prefixed paths instead.
|
||||
CacheFs.prefix = ""
|
||||
|
||||
local function withPrefix(rel)
|
||||
@@ -330,16 +330,16 @@ end
|
||||
-- Overlay the active version's extracted cache onto the un-prefixed read
|
||||
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
||||
-- "assets/generated/*") resolve to that version's files. Red lives at the
|
||||
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
|
||||
-- it wins over any Red copy at the root and over the game source. Called
|
||||
-- once at boot, before Game:load (main.lua). Returns true when nothing was
|
||||
-- needed or the mount succeeded.
|
||||
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
|
||||
-- *prepended* so they win over any Red copy at the root and over the game
|
||||
-- source. Called once at boot, before Game:load (main.lua). Returns true
|
||||
-- when nothing was needed or the mount succeeded.
|
||||
function CacheFs.mountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end -- Red: already at the root
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
|
||||
-- The cache root is the portable game folder when active, else LÖVE's OS
|
||||
-- save directory (where love.filesystem wrote blue/...).
|
||||
-- save directory (where love.filesystem wrote blue/... or yellow/...).
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
@@ -355,12 +355,12 @@ function CacheFs.mountVersion(version)
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on a Blue save, close
|
||||
-- it, and press Play on Red: with blue/ still prepended, Red's
|
||||
-- require("data.generated.*") and its generated art would silently resolve to
|
||||
-- Blue's files. Callers must also drop the generated modules from
|
||||
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
|
||||
-- fixes the read path, not what require already cached.
|
||||
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
|
||||
-- close it, and press Play on Red: with that version's subtree still
|
||||
-- prepended, Red's require("data.generated.*") and its generated art would
|
||||
-- silently resolve to the other game's files. Callers must also drop the
|
||||
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
|
||||
-- unmounting alone only fixes the read path, not what require already cached.
|
||||
--
|
||||
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
|
||||
-- because its cache lives at the root and was never overlaid.
|
||||
|
||||
+345
-49
@@ -163,8 +163,12 @@ function RomExtractor:extractTilesets()
|
||||
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
|
||||
blocks[#blocks + 1] = block
|
||||
end
|
||||
-- Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
|
||||
-- (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
|
||||
-- banked; treat ROM0-range pointers as bank 0.
|
||||
local collBank = collisionPointer < 0x4000 and 0 or 1
|
||||
local walkable = sorted(self:readTerminated(
|
||||
0, collisionPointer, 0xFF))
|
||||
collBank, collisionPointer, 0xFF))
|
||||
local warpPointer = self.rom:word(
|
||||
warpPointers.bank, warpPointers.address + (index - 1) * 2)
|
||||
local warpTiles = unique(self:readTerminated(
|
||||
@@ -447,14 +451,26 @@ function RomExtractor:extractSprites()
|
||||
local pointer = self.rom:word(pointerTable.bank, address)
|
||||
local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
|
||||
local bank = self.rom:byte(pointerTable.bank, address + 3)
|
||||
local byteLength = spec.imageWidth * spec.imageHeight / 4
|
||||
local frames = spec.imageHeight / 16
|
||||
local width = spec.imageWidth
|
||||
local height = spec.imageHeight
|
||||
local byteLength = width * height / 4
|
||||
local frames = height / 16
|
||||
local expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
||||
if byteLength ~= expected then
|
||||
-- Commercial ROM sheet length wins over pret PNG atlases (Yellow nurse
|
||||
-- PNG is taller than the 12-tile SpriteSheetPointerTable entry).
|
||||
byteLength = expected
|
||||
assert(byteLength * 4 % width == 0,
|
||||
constName .. ": ROM sprite length not tile-aligned")
|
||||
height = byteLength * 4 / width
|
||||
frames = height / 16
|
||||
expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
||||
end
|
||||
local base = spec.imageBase
|
||||
if not written[base] then
|
||||
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
|
||||
spec.imageWidth, spec.imageHeight,
|
||||
width, height,
|
||||
"sprites/" .. base .. ".png", true)
|
||||
written[base] = true
|
||||
end
|
||||
@@ -862,20 +878,24 @@ function RomExtractor:extractPalettes()
|
||||
local order = self.manifest.paletteOrder
|
||||
local paletteTable = self:symbol("SuperPalettes")
|
||||
local function scale5(value) return round(value * 255 / 31) end
|
||||
local palettes = {}
|
||||
for index, name in ipairs(order) do
|
||||
local colors = {}
|
||||
for color = 0, 3 do
|
||||
local value = self.rom:word(paletteTable.bank,
|
||||
paletteTable.address + (index - 1) * 8 + color * 2)
|
||||
colors[#colors + 1] = {
|
||||
scale5(bit.band(value, 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
||||
}
|
||||
local function readTable(symbol, names)
|
||||
local out = {}
|
||||
for index, name in ipairs(names) do
|
||||
local colors = {}
|
||||
for color = 0, 3 do
|
||||
local value = self.rom:word(symbol.bank,
|
||||
symbol.address + (index - 1) * 8 + color * 2)
|
||||
colors[#colors + 1] = {
|
||||
scale5(bit.band(value, 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
||||
}
|
||||
end
|
||||
out[name] = colors
|
||||
end
|
||||
palettes[name] = colors
|
||||
return out
|
||||
end
|
||||
local palettes = readTable(paletteTable, order)
|
||||
local monsterTable = self:symbol("MonsterPalettes")
|
||||
local monsterPalettes = {}
|
||||
for index, species in ipairs(self.manifest.dexOrder) do
|
||||
@@ -887,6 +907,11 @@ function RomExtractor:extractPalettes()
|
||||
source = "ROM:SuperPalettes + MonsterPalettes",
|
||||
palettes = palettes, order = order, pokemon = monsterPalettes,
|
||||
}
|
||||
-- Yellow (and GBC carts) also carry CGBBasePalettes beside SuperPalettes.
|
||||
if self.symbols["CGBBasePalettes"] then
|
||||
data.cgbBase = readTable(self:symbol("CGBBasePalettes"), order)
|
||||
data.source = data.source .. " + CGBBasePalettes"
|
||||
end
|
||||
self:write("palettes", data)
|
||||
self:tick("Color palettes", 1, 1)
|
||||
return data
|
||||
@@ -915,6 +940,10 @@ function RomExtractor:extractIcons()
|
||||
GRASS = "assets/generated/icons/plant.png",
|
||||
SNAKE = "assets/generated/icons/snake.png",
|
||||
QUADRUPED = "assets/generated/icons/quadruped.png",
|
||||
-- Yellow's ICON_PIKACHU draws from the overworld PikachuSprite sheet
|
||||
-- (data/icon_pointers.asm mon_icon_header PikachuSprite, 0/12);
|
||||
-- only referenced when the manifest's iconOrder includes it
|
||||
PIKACHU = "assets/generated/sprites/pikachu.png",
|
||||
}
|
||||
local frames = {
|
||||
{ "bug", "BugIconFrame1", "BugIconFrame2" },
|
||||
@@ -1048,7 +1077,9 @@ function RomExtractor:extractPokemon()
|
||||
local typeById = self:typesById()
|
||||
local names = self:symbol("MonsterNames")
|
||||
local baseStats = self:symbol("BaseStats")
|
||||
local mewStats = self:symbol("MewBaseStats")
|
||||
-- Red/Blue keep Mew outside BaseStats (pret pokered MewBaseStats).
|
||||
-- Yellow stores Mew as dex 151 inside BaseStats (pret/pokeyellow).
|
||||
local mewStats = self.symbols["MewBaseStats"] and self:symbol("MewBaseStats")
|
||||
local decodedNames = {}
|
||||
for index = 1, #speciesOrder do
|
||||
decodedNames[index] = self.rom:decodeText(
|
||||
@@ -1067,7 +1098,7 @@ function RomExtractor:extractPokemon()
|
||||
local dex = assert(dexBySpecies[species],
|
||||
"missing dex number for " .. species)
|
||||
local row
|
||||
if species == "MEW" then
|
||||
if species == "MEW" and mewStats then
|
||||
row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
|
||||
else
|
||||
row = self.rom:bytes(
|
||||
@@ -1410,6 +1441,142 @@ function RomExtractor:extractText()
|
||||
}
|
||||
end
|
||||
|
||||
function RomExtractor:extractYellowTitleArt()
|
||||
-- pret/pokeyellow engine/movie/title_yellow.asm: the Yellow title is a
|
||||
-- tilemap composition over BOTH tile banks. LoadYellowTitleScreenGFX
|
||||
-- loads PokemonLogoGraphics into vChars2 (BG ids $00-$7F),
|
||||
-- TitlePikachuBGGraphics into vChars1 (ids $80-$EF),
|
||||
-- TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
|
||||
-- OAM tiles), and PokemonLogoCornerGraphics at vChars1 tile $7D (ids
|
||||
-- $FD-$FF). Every tilemap mixes ids from several of those sheets, so a
|
||||
-- single-sheet lookup shows checkerboard garbage where a foreign-bank id
|
||||
-- lands (e.g. blank id $00 = logo tile 0, not Pikachu BG tile 0).
|
||||
if not self.symbols["TitlePikachuBGGraphics"] then return end
|
||||
-- raw sheets, kept for debugging / mod reference
|
||||
self:raw2bpp("TitlePikachuBGGraphics", 128, 32,
|
||||
"title/pikachu_bg.png", { transparent = true })
|
||||
self:raw2bpp("TitlePikachuOBGraphics", 96, 8,
|
||||
"title/pikachu_ob.png", { transparent = true })
|
||||
|
||||
-- Tile counts are the Graphics..GraphicsEnd symbol gaps in pokeyellow.sym.
|
||||
local function sheetTiles(label, count, transparent)
|
||||
local symbol = self:symbol(label)
|
||||
local raw = self.rom:bytes(symbol.bank, symbol.address, count * 16)
|
||||
local tiles = {}
|
||||
for offset = 1, #raw, 16 do
|
||||
local one = {}
|
||||
for i = offset, offset + 15 do one[#one + 1] = raw[i] end
|
||||
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent)
|
||||
end
|
||||
return tiles
|
||||
end
|
||||
local logo = sheetTiles("PokemonLogoGraphics", 115)
|
||||
local corner = sheetTiles("PokemonLogoCornerGraphics", 3)
|
||||
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
||||
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
||||
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
|
||||
local function tileFor(id)
|
||||
if id < 0x80 then return logo[id + 1] end
|
||||
if id < 0xF0 then return bg[id - 0x80 + 1] end
|
||||
if id < 0xFD then return ob[id - 0xF0 + 1] end
|
||||
return corner[id - 0xFD + 1]
|
||||
end
|
||||
-- OAM-style blit: color-0 pixels stay whatever the target already holds
|
||||
-- (ImageWriter.blit copies alpha-0 pixels wholesale, which would punch
|
||||
-- holes into the face under the eye sprites).
|
||||
local function blitSprite(target, tile, tx, ty, flipX)
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local sx = flipX and 7 - x or x
|
||||
local r, g, b, a = tile:getPixel(sx, y)
|
||||
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- cells = { {id, col, row}, ... }; untouched cells stay transparent
|
||||
local function compose(cols, rows, cells)
|
||||
local pose = ImageWriter.blank(cols * 8, rows * 8, 1, 1, 1, 0)
|
||||
for _, cell in ipairs(cells) do
|
||||
local tile = tileFor(cell[1])
|
||||
if tile then ImageWriter.blit(pose, tile, cell[2] * 8, cell[3] * 8) end
|
||||
end
|
||||
return pose
|
||||
end
|
||||
local function mapCells(map, cols, rows)
|
||||
local ids = self.rom:bytes(map.bank, map.address, cols * rows)
|
||||
local cells = {}
|
||||
for index, id in ipairs(ids) do
|
||||
cells[#cells + 1] =
|
||||
{ id, (index - 1) % cols, math.floor((index - 1) / cols) }
|
||||
end
|
||||
return cells
|
||||
end
|
||||
|
||||
-- TitleScreen_PlacePokemonLogo: 16x7 box at (2,1). Yellow's logo sheet
|
||||
-- is deduplicated (unlike Red's sequential rip), so the raw2bpp
|
||||
-- pokemon_logo.png from extractField is scrambled; overwrite it with the
|
||||
-- tilemap composition. Kept opaque: TitleState clears to white behind it.
|
||||
self:save(compose(16, 7,
|
||||
mapCells(self:symbol("TitleScreenPokemonLogoTilemap"), 16, 7)),
|
||||
"title/pokemon_logo.png")
|
||||
|
||||
-- TitleScreen_PlacePikaSpeechBubble: 7x4 box at (6,4) plus the two tail
|
||||
-- tiles $64/$65 the routine pokes at (9,8) -- one row below the box, over
|
||||
-- blank cells of the Pikachu row. Composed 7x5 with the tail at (3,4);
|
||||
-- matteColor0 clears the outside-the-balloon whites, the outline protects
|
||||
-- the interior.
|
||||
local bubbleCells = mapCells(
|
||||
self:symbol("TitleScreenPikaBubbleTilemap"), 7, 4)
|
||||
bubbleCells[#bubbleCells + 1] = { 0x64, 3, 4 }
|
||||
bubbleCells[#bubbleCells + 1] = { 0x65, 4, 4 }
|
||||
self:save(ImageWriter.matteColor0(compose(7, 5, bubbleCells)),
|
||||
"title/pika_bubble.png")
|
||||
|
||||
-- TitleScreen_PlacePikachu: 12x9 box at (4,8) plus the right-ear edge
|
||||
-- tiles it pokes down column 16 (rows 10-13) -- composed 13x9 with those
|
||||
-- at relative column 12, rows 2-5. The open eyes are OAM
|
||||
-- (TitleScreenPikachuEyesOAMData, copied at place time): OB tiles 0-3 at
|
||||
-- screen (56,80)/(88,80) blocks, the left eye x-flipped (attr $22); baked
|
||||
-- into the composition relative to the box origin px(32,64).
|
||||
local pikaCells = mapCells(self:symbol("TitleScreenPikachuTilemap"), 12, 9)
|
||||
pikaCells[#pikaCells + 1] = { 0x96, 12, 2 }
|
||||
pikaCells[#pikaCells + 1] = { 0x9d, 12, 3 }
|
||||
pikaCells[#pikaCells + 1] = { 0xa7, 12, 4 }
|
||||
pikaCells[#pikaCells + 1] = { 0xb1, 12, 5 }
|
||||
local pikachu = ImageWriter.matteColor0(compose(13, 9, pikaCells))
|
||||
-- DoTitleScreenFunction's blink rewrites the eye OAM tile ids with
|
||||
-- `and $f3 / or e` (e = 0 open / 4 half / 8 closed), so the OB sheet
|
||||
-- holds three 4-tile eye sets. Bake the open set into pikachu.png and
|
||||
-- save half/closed as standalone overlays for TitleState's blink.
|
||||
local EYE_LAYOUT = {
|
||||
{ 2, 24, 16, true }, { 1, 32, 16, true },
|
||||
{ 4, 24, 24, true }, { 3, 32, 24, true },
|
||||
{ 1, 56, 16 }, { 2, 64, 16 },
|
||||
{ 3, 56, 24 }, { 4, 64, 24 },
|
||||
}
|
||||
-- Blink overlays for the (24,16)-(71,31) eye band: the BG face is
|
||||
-- eyeless (the eyes are OAM), so each overlay = the blank-face crop
|
||||
-- with the half (+4) / closed (+8) tile set composited color-0
|
||||
-- transparent -- exactly what the hardware shows mid-blink.
|
||||
local overlays = {}
|
||||
for suffix, base in pairs({ eyes_half = 4, eyes_closed = 8 }) do
|
||||
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||
end
|
||||
overlays[suffix] = overlay
|
||||
end
|
||||
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
|
||||
end
|
||||
self:save(pikachu, "title/pikachu.png")
|
||||
for suffix, overlay in pairs(overlays) do
|
||||
self:save(overlay, "title/" .. suffix .. ".png")
|
||||
end
|
||||
end
|
||||
|
||||
function RomExtractor:raw2bpp(label, width, height, relative, options)
|
||||
options = options or {}
|
||||
local expected = width * height / 4
|
||||
@@ -1454,6 +1621,8 @@ function RomExtractor:extractField()
|
||||
"title/copyright.png"); tick()
|
||||
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
|
||||
"title/gamefreak_inc.png"); tick()
|
||||
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
|
||||
self:extractYellowTitleArt(); tick()
|
||||
|
||||
local fallingStar = self:raw2bpp(
|
||||
"FallingStar", 8, 8, "intro/falling_star.png",
|
||||
@@ -1502,35 +1671,71 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(star, "intro/big_star.png"); tick()
|
||||
|
||||
local gengar = self:symbol("FightIntroBackMon")
|
||||
local gengarRaw = self.rom:bytes(
|
||||
gengar.bank, gengar.address, 96 * 16)
|
||||
local gengarTiles = {}
|
||||
for offset = 1, #gengarRaw, 16 do
|
||||
local raw = {}
|
||||
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
||||
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
||||
end
|
||||
for number = 1, 3 do
|
||||
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
||||
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
||||
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
||||
for index, tileId in ipairs(tileIds) do
|
||||
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
||||
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
||||
-- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow
|
||||
-- engine/movie/intro_yellow.asm); write blank placeholders so Title/
|
||||
-- Intro still find the expected paths. Red/Blue keep the tilemap rip.
|
||||
if self.symbols["FightIntroBackMon"] then
|
||||
local gengar = self:symbol("FightIntroBackMon")
|
||||
local gengarRaw = self.rom:bytes(
|
||||
gengar.bank, gengar.address, 96 * 16)
|
||||
local gengarTiles = {}
|
||||
for offset = 1, #gengarRaw, 16 do
|
||||
local raw = {}
|
||||
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
||||
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
||||
end
|
||||
for number = 1, 3 do
|
||||
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
||||
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
||||
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
||||
for index, tileId in ipairs(tileIds) do
|
||||
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
||||
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
||||
end
|
||||
pose = ImageWriter.matteColor0(pose)
|
||||
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
else
|
||||
for number = 1, 3 do
|
||||
self:save(ImageWriter.blank(56, 56, 0, 0, 0, 0),
|
||||
"intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
pose = ImageWriter.matteColor0(pose)
|
||||
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
|
||||
for number, label in ipairs({
|
||||
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
||||
}) do
|
||||
self:raw2bpp(label, 48, 48,
|
||||
"intro/red_nidorino_" .. number .. ".png",
|
||||
{ transparent = true, columns = true })
|
||||
tick()
|
||||
if self.symbols["FightIntroFrontMon"] then
|
||||
for number, label in ipairs({
|
||||
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
||||
}) do
|
||||
self:raw2bpp(label, 48, 48,
|
||||
"intro/red_nidorino_" .. number .. ".png",
|
||||
{ transparent = true, columns = true })
|
||||
tick()
|
||||
end
|
||||
else
|
||||
for number = 1, 3 do
|
||||
self:save(ImageWriter.blank(48, 48, 1, 1, 1, 0),
|
||||
"intro/red_nidorino_" .. number .. ".png"); tick()
|
||||
end
|
||||
end
|
||||
|
||||
-- Optional Yellow-only intro atlas (pret/pokeyellow gfx/yellow_intro.asm).
|
||||
if self.symbols["YellowIntroGraphics1"] then
|
||||
self:raw2bpp("YellowIntroGraphics1", 128, 64,
|
||||
"intro/yellow_intro_1.png")
|
||||
end
|
||||
if self.symbols["YellowIntroGraphics2"] then
|
||||
-- atlas2 doubles as the intro's OBJ tile bank (vChars0); OBJ color 0
|
||||
-- is hardware-transparent, and the BG draws it over a white clear so
|
||||
-- BG cells lose nothing
|
||||
self:raw2bpp("YellowIntroGraphics2", 128, 128,
|
||||
"intro/yellow_intro_2.png", { transparent = true })
|
||||
end
|
||||
-- Yellow intro clouds (intro_yellow.asm YellowIntroCloudGFX): 8 tiles,
|
||||
-- two 4-tile animation frames -- saved 32x16, one frame per row.
|
||||
if self.symbols["YellowIntroCloudGFX"] then
|
||||
self:raw2bpp("YellowIntroCloudGFX", 32, 16, "intro/clouds.png")
|
||||
end
|
||||
|
||||
for number = 1, 2 do
|
||||
self:writeCompressedPic(
|
||||
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
|
||||
@@ -1563,10 +1768,27 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(symbolSheet, "slots/symbols.png"); tick()
|
||||
|
||||
local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
for index, label in ipairs({
|
||||
"ShockEmote", "QuestionEmote", "HappyEmote",
|
||||
}) do
|
||||
-- Emote sheet layout comes from manifest.field.emotionBubbles so the
|
||||
-- versions can differ: Red ships the three shared bubbles, Yellow adds
|
||||
-- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/
|
||||
-- Zzz/FishEmote, used by the PikachuEmotionTable reactions).
|
||||
local EMOTE_SYMBOLS = {
|
||||
EXCLAMATION_BUBBLE = "ShockEmote", QUESTION_BUBBLE = "QuestionEmote",
|
||||
SMILE_BUBBLE = "HappyEmote", SKULL_BUBBLE = "SkullEmote",
|
||||
HEART_BUBBLE = "HeartEmote", BOLT_BUBBLE = "BoltEmote",
|
||||
ZZZ_BUBBLE = "ZzzEmote", FISH_BUBBLE = "FishEmote",
|
||||
}
|
||||
local bubbleDefs = self.manifest.field.emotionBubbles
|
||||
and self.manifest.field.emotionBubbles.bubbles
|
||||
local emoteLabels = {}
|
||||
for _, b in ipairs(bubbleDefs or {}) do
|
||||
emoteLabels[#emoteLabels + 1] = EMOTE_SYMBOLS[b.name]
|
||||
end
|
||||
if #emoteLabels == 0 then
|
||||
emoteLabels = { "ShockEmote", "QuestionEmote", "HappyEmote" }
|
||||
end
|
||||
local emotes = ImageWriter.blank(#emoteLabels * 16, 16, 1, 1, 1, 0)
|
||||
for index, label in ipairs(emoteLabels) do
|
||||
local symbol = self:symbol(label)
|
||||
local image = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
|
||||
@@ -1574,6 +1796,26 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(emotes, "emotes.png"); tick()
|
||||
|
||||
-- Yellow-only: the Surfing Pikachu minigame sheets
|
||||
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
|
||||
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
|
||||
-- 1a is the BG set (water/beach/score tiles, opaque); 1b the OAM pose
|
||||
-- sheet and 1c the intro set (both color-0 transparent).
|
||||
for _, spec in ipairs({
|
||||
{ "SurfingPikachu1Graphics1", 65, 40, false, "minigame/surf_1a.png" },
|
||||
{ "SurfingPikachu1Graphics2", 256, 128, true, "minigame/surf_1b.png" },
|
||||
{ "SurfingPikachu1Graphics3", 144, 96, true, "minigame/surf_1c.png" },
|
||||
}) do
|
||||
if self.symbols[spec[1]] then
|
||||
local symbol = self:symbol(spec[1])
|
||||
local tilesPerRow = spec[3] / 8
|
||||
local image = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(symbol.bank, symbol.address, spec[2] * 16),
|
||||
spec[3], spec[2] / tilesPerRow * 8, spec[4])
|
||||
self:save(image, spec[5])
|
||||
end
|
||||
end
|
||||
|
||||
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
||||
"fx/shadow.png", true); tick()
|
||||
for _, spec in ipairs({
|
||||
@@ -1664,7 +1906,9 @@ end
|
||||
function RomExtractor:extractAudio()
|
||||
self:beginStage("Sound programs")
|
||||
local metadata = copy(self.manifest.audio)
|
||||
local bankOrder = { 2, 8, 31 }
|
||||
-- Yellow adds a fourth music bank ($20: Jessie & James, Surfing
|
||||
-- Pikachu, GB Printer); the manifest names the pack when it needs it.
|
||||
local bankOrder = metadata.programBanks or { 2, 8, 31 }
|
||||
local chunks = {}
|
||||
for index, bank in ipairs(bankOrder) do
|
||||
local first = Rom.offset(bank, 0x4000) + 1
|
||||
@@ -1683,6 +1927,7 @@ function RomExtractor:extractAudio()
|
||||
for name, header in pairs(metadata.musicHeaders) do
|
||||
songs[name] = header
|
||||
end
|
||||
metadata.pikaCries = self:extractPikachuCries()
|
||||
local cries = {}
|
||||
local cryData = metadata.cryData
|
||||
for index, species in ipairs(self.manifest.constants.speciesOrder) do
|
||||
@@ -1709,6 +1954,57 @@ function RomExtractor:extractAudio()
|
||||
return metadata
|
||||
end
|
||||
|
||||
-- Yellow's voiced Pikachu clips (audio/pikachu_cries_pointers.asm
|
||||
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
|
||||
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
|
||||
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
|
||||
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
|
||||
-- or nil when the manifest has no pointer table (Red/Blue).
|
||||
function RomExtractor:extractPikachuCries()
|
||||
if not self.symbols["PikachuCriesPointerTable"] then return nil end
|
||||
local NUM = 42 -- NUM_PIKA_CRIES
|
||||
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
|
||||
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
local lut = {}
|
||||
for byte = 0, 255 do
|
||||
local out = {}
|
||||
for bit = 7, 0, -1 do
|
||||
local on = math.floor(byte / 2 ^ bit) % 2 == 1
|
||||
out[#out + 1] = string.char(on and 0xE0 or 0x20)
|
||||
end
|
||||
lut[byte] = table.concat(out)
|
||||
end
|
||||
local function u16(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
end
|
||||
local function u32(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256,
|
||||
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||
end
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local pointers = self:symbol("PikachuCriesPointerTable")
|
||||
for index = 0, NUM - 1 do
|
||||
local row = self.rom:bytes(pointers.bank, pointers.address + index * 3, 3)
|
||||
local bank, address = row[1], row[2] + row[3] * 256
|
||||
local header = self.rom:bytes(bank, address, 2)
|
||||
local length = header[1] + header[2] * 256
|
||||
local raw = self.rom:bytes(bank, address + 2, length)
|
||||
local samples = {}
|
||||
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
|
||||
local pcm = table.concat(samples)
|
||||
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
|
||||
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
|
||||
.. "data" .. u32(#pcm) .. pcm
|
||||
local ok, err = CacheFs.write(
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
|
||||
wav)
|
||||
if not ok then
|
||||
error("could not write pika cry " .. (index + 1) .. ": " .. tostring(err))
|
||||
end
|
||||
end
|
||||
return NUM
|
||||
end
|
||||
|
||||
function RomExtractor:run()
|
||||
local results = {}
|
||||
results.constants = self:extractConstants()
|
||||
|
||||
+64
-53
@@ -37,8 +37,8 @@ local REQUIRED_FILES = {
|
||||
|
||||
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
|
||||
-- the Claude Design project): a dark neon arcade panel, one column per game.
|
||||
-- Red is live; Blue and Yellow are lit placeholders until those games are
|
||||
-- supported. Values are 0-255 RGB; alpha is applied per draw.
|
||||
-- Red, Blue, and Yellow share the same importer flow once listed in
|
||||
-- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw.
|
||||
local PAL = {
|
||||
-- radial background gradient (bright navy at top-centre -> near black)
|
||||
bgTop = { 22, 34, 74 }, -- #16224a
|
||||
@@ -302,13 +302,13 @@ end
|
||||
-- it directly through love.filesystem -- already mounted at the physfs
|
||||
-- root, so no io.* absolute-path handling is needed.
|
||||
--
|
||||
-- Only a .gb whose SHA maps to a version that is not yet ready counts as
|
||||
-- Only a .gb/.gbc whose SHA maps to a version that is not yet ready counts as
|
||||
-- pending. GameActivity always writes the SAF pick to picked_rom.gb, so a
|
||||
-- naive "first .gb wins" scan would re-import Red when the player tries to
|
||||
-- add Blue (issue #167).
|
||||
-- naive "first ROM wins" scan would re-import Red when the player tries to
|
||||
-- add Blue (issue #167). Yellow carts are typically .gbc.
|
||||
local function findPendingRom(ready)
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then
|
||||
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
|
||||
local data = love.filesystem.read(name)
|
||||
if type(data) == "string" and #data == 1024 * 1024 then
|
||||
local version = GameVersion.forSha1(sha1(data))
|
||||
@@ -360,14 +360,14 @@ local function chooseRom(promptName)
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb"})' 2>/dev/null]])
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb", "gbc"})' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
|
||||
"$d.Filter='Game Boy ROM (*.gb;*.gbc)|*.gb;*.gbc|All files (*.*)|*.*';",
|
||||
-- write the pick as UTF-8: the console's OEM codepage would mangle
|
||||
-- non-ASCII names (Pokémon -> Pok\x82mon) and crash any text draw
|
||||
-- that shows them (#325)
|
||||
@@ -377,11 +377,11 @@ local function chooseRom(promptName)
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" --file-filter="Game Boy ROM | *.gb" 2>/dev/null]])
|
||||
([[zenity --file-selection --title="%s" --file-filter="Game Boy ROM | *.gb *.gbc" 2>/dev/null]])
|
||||
:format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput(
|
||||
[[kdialog --getopenfilename "$HOME" "*.gb|Game Boy ROM" 2>/dev/null]])
|
||||
[[kdialog --getopenfilename "$HOME" "*.gb *.gbc|Game Boy ROM" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -470,10 +470,11 @@ local function updaterAllowed()
|
||||
return true
|
||||
end
|
||||
|
||||
-- The launcher runs Red and Blue as two independent columns. Each dropped or
|
||||
-- The launcher runs each GameVersion as an independent tab. Each dropped or
|
||||
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
|
||||
-- own cache (Red at the root, Blue under blue/), so both can be imported and
|
||||
-- played side by side. onComplete(version) hands the chosen game off to boot.
|
||||
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
|
||||
-- can be imported and played side by side. onComplete(version) hands the
|
||||
-- chosen game off to boot.
|
||||
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
|
||||
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
|
||||
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
|
||||
@@ -542,13 +543,18 @@ function RomImporter.new(onComplete, opts)
|
||||
CacheFs.prefix = saved
|
||||
self.returning[version] =
|
||||
(not ready) and marker ~= nil and marker ~= markerFor(version)
|
||||
self.romName[version] = "pokemon_" .. info.id .. ".gb"
|
||||
self.romName[version] = "pokemon_" .. info.id
|
||||
.. (info.id == "yellow" and ".gbc" or ".gb")
|
||||
end
|
||||
|
||||
-- Android: import a save-dir .gb that is not yet ready (USB drop or a
|
||||
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
|
||||
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
|
||||
-- so a stale picked_rom.gb cannot block the opposite version.
|
||||
if android and not (self.ready.red and self.ready.blue) then
|
||||
-- so a stale picked_rom.gb cannot block another version.
|
||||
local needRom = false
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[version] then needRom = true; break end
|
||||
end
|
||||
if android and needRom then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
end
|
||||
@@ -608,7 +614,7 @@ function RomImporter:focus(f)
|
||||
local version = self.androidPendingExportVersion or self:_savedropTarget()
|
||||
self.androidPendingExportVersion = nil
|
||||
self.saveNotice[version] = { ok = true, text = "Save exported." }
|
||||
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
|
||||
if self.tab == "mods" then self.tab = version end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false)
|
||||
@@ -629,9 +635,13 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.ready.red and self.ready.blue then return end
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
for _, v in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[v] then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:setError(message, version)
|
||||
@@ -660,7 +670,8 @@ local function resetPointerCursor(self)
|
||||
end
|
||||
|
||||
-- Verify + extract a ROM. The version is decided by the ROM's own SHA-1, so
|
||||
-- dropping a Red or Blue cart into either column always lands in the right one.
|
||||
-- dropping a Red, Blue, or Yellow cart into any column always lands in the
|
||||
-- right one.
|
||||
function RomImporter:startData(data, displayName)
|
||||
if self.workState == "working" then return end
|
||||
if type(data) ~= "string" then
|
||||
@@ -676,14 +687,14 @@ function RomImporter:startData(data, displayName)
|
||||
local version = GameVersion.forSha1(actualHash)
|
||||
if not version then
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
|
||||
.. "Red or Blue ROM."):format(actualHash))
|
||||
.. "Red, Blue, or Yellow ROM."):format(actualHash))
|
||||
return
|
||||
end
|
||||
local info = GameVersion.info(version)
|
||||
|
||||
-- Bring the launcher to this version's tab so its progress bar is on screen
|
||||
-- (a dropped cart is routed by SHA-1 regardless of which tab was showing).
|
||||
if self.tab == "red" or self.tab == "blue" or self.tab == "yellow" then
|
||||
if GameVersion.VERSIONS[self.tab] then
|
||||
self.tab = version
|
||||
end
|
||||
self.importing = version
|
||||
@@ -731,7 +742,7 @@ function RomImporter:startData(data, displayName)
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb (picked_rom.gb or a USB copy)
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.android and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
@@ -845,12 +856,12 @@ function RomImporter:chooseMod()
|
||||
end
|
||||
|
||||
-- Which game a dropped .sav imports into: a .sav has no version signature of
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods, or
|
||||
-- the locked yellow placeholder) is showing, default to red -- the always-
|
||||
-- present first game -- rather than guess.
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
|
||||
-- showing, default to red -- the always-present first game -- rather than
|
||||
-- guess.
|
||||
function RomImporter:_savedropTarget()
|
||||
local v = self.tab
|
||||
if v == "red" or v == "blue" then return v end
|
||||
if GameVersion.VERSIONS[v] then return v end
|
||||
return "red"
|
||||
end
|
||||
|
||||
@@ -861,8 +872,7 @@ end
|
||||
-- playable with its game's data present.
|
||||
function RomImporter:_importSave(version, source)
|
||||
if self.workState == "working" then return end
|
||||
if self.tab == "red" or self.tab == "blue" or self.tab == "mods"
|
||||
or self.tab == "yellow" then
|
||||
if GameVersion.VERSIONS[self.tab] or self.tab == "mods" then
|
||||
self.tab = version
|
||||
end
|
||||
if not self.ready[version] then
|
||||
@@ -971,8 +981,8 @@ function RomImporter:choose(version)
|
||||
if self.workState == "working" then return end
|
||||
self.chooseVersion = version or "red"
|
||||
if self.android then
|
||||
-- Prefer a not-yet-imported .gb already in the save dir (USB copy, or a
|
||||
-- fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
-- was the #167 failure mode (second Choose just re-extracted Red).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
@@ -995,8 +1005,8 @@ function RomImporter:choose(version)
|
||||
return
|
||||
end
|
||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||
-- kdialog. Fall back to the same "drop a .gb next to the game" scan used
|
||||
-- on Android, which works when the game is launched as an unpacked
|
||||
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
||||
-- used on Android, which works when the game is launched as an unpacked
|
||||
-- directory (see build-rg34xxsp.sh).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
@@ -1010,13 +1020,13 @@ function RomImporter:choose(version)
|
||||
or "the game folder"
|
||||
self.notice = {
|
||||
version = self.chooseVersion,
|
||||
status = "No file picker. Copy your .gb into:",
|
||||
status = "No file picker. Copy your .gb/.gbc into:",
|
||||
detail = where,
|
||||
}
|
||||
return
|
||||
end
|
||||
if love.system.getOS() ~= "OS X" and love.system.getOS() ~= "Windows" then
|
||||
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
|
||||
self:setError("File selection is unavailable here. Drop the .gb/.gbc file onto the window.")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1112,7 +1122,7 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local next = (self.modScroll or 0) + step
|
||||
self.modScroll = math.max(0, math.min(maxS, next))
|
||||
end
|
||||
elseif self.tab == "red" or self.tab == "blue" then
|
||||
elseif GameVersion.VERSIONS[self.tab] then
|
||||
local maxS = (self._slotMax and self._slotMax[self.tab]) or 0
|
||||
if maxS > 0 then
|
||||
local next = (self.slotScroll[self.tab] or 0) + step
|
||||
@@ -1138,7 +1148,7 @@ function RomImporter:gamepadpressed(_, button)
|
||||
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
|
||||
if self.workState == "working" then return end
|
||||
local version = self.tab
|
||||
if version == "red" or version == "blue" then
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:choose(version) end
|
||||
end
|
||||
end
|
||||
@@ -1972,9 +1982,9 @@ function RomImporter:keypressed(key)
|
||||
if self.workState == "working" then return end
|
||||
if key == "return" or key == "space" or key == "kpenter" then
|
||||
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
||||
-- open its picker. The mods / placeholder tabs have no keyboard action.
|
||||
-- open its picker. The mods tab has no keyboard action.
|
||||
local version = self.tab
|
||||
if version == "red" or version == "blue" then
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:choose(version) end
|
||||
end
|
||||
end
|
||||
@@ -2132,7 +2142,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
||||
end
|
||||
cursorX = segEnd + gap
|
||||
end
|
||||
-- "N of 3 ready" (Red + Blue count; Yellow never ready), hidden if no room
|
||||
-- "N of 3 ready" (Red + Blue + Yellow once in GameVersion.ORDER)
|
||||
local ready = 0
|
||||
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
||||
love.graphics.setFont(self.readyFont)
|
||||
@@ -2152,9 +2162,12 @@ end
|
||||
function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
local s, pulse = self._s, self.pulse
|
||||
self.panelVersion = version
|
||||
local locked = version == "yellow"
|
||||
local info = (not locked) and GameVersion.info(version) or nil
|
||||
local gameName = locked and "Pokemon Yellow" or info.displayName
|
||||
-- Defensive: only lock when the version is absent from GameVersion (never
|
||||
-- solely because id == "yellow").
|
||||
local info = GameVersion.info(version)
|
||||
local locked = info == nil
|
||||
local gameName = info and (info.launcherName or info.displayName)
|
||||
or tostring(version)
|
||||
local ready = (not locked) and self.ready[version] or false
|
||||
|
||||
-- header: name + status pill
|
||||
@@ -2191,12 +2204,13 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
local rightX = twoCol and (x + colW + colGap) or x
|
||||
|
||||
-- ROM card contents by state (rehomes the existing import flow)
|
||||
local dropHint = self.android and "Copy the .gb via USB."
|
||||
or Strings("Or drop the .gb file here.")
|
||||
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
|
||||
local dropHint = self.android and "Copy the .gb/.gbc via USB."
|
||||
or Strings("Or drop the .gb/.gbc file here.")
|
||||
local accent = version == "yellow" and PAL.gold
|
||||
or (version == "red" and PAL.red or PAL.blue)
|
||||
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
||||
if locked then
|
||||
romState, romDetail = "Not supported yet", "Yellow support is on the way."
|
||||
romState, romDetail = "Not supported yet", "Support for this game is on the way."
|
||||
romBtnLabel, romBtnEnabled = "Import unavailable", false
|
||||
else
|
||||
local importing = self.importing == version
|
||||
@@ -2245,7 +2259,6 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
|
||||
-- SAVE FILES card: Import save is live once the ROM is imported (playable);
|
||||
-- Export save is live only when the active slot actually holds a save. The
|
||||
-- locked yellow placeholder has no save backend, so both stay disabled. The
|
||||
-- hint line doubles as the last import/export outcome (green ok / red error).
|
||||
local sfImportEnabled, sfExportEnabled = false, false
|
||||
if not locked then
|
||||
@@ -2358,9 +2371,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
self:_playButton(leftX, playY, colW, playH, gameName, ready, locked)
|
||||
|
||||
-- SAVE SLOT card (right column, or stacked below Play when single-column).
|
||||
-- The locked Yellow placeholder has no save backend (no GameVersion entry, so
|
||||
-- no slots can exist); skip the panel entirely rather than draw an empty,
|
||||
-- non-functional "+ New save slot" on a COMING SOON game.
|
||||
-- Skip only when the version is absent from GameVersion (no save backend).
|
||||
if not locked then
|
||||
if twoCol then
|
||||
self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH)
|
||||
|
||||
@@ -183,6 +183,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
local b = battle.player
|
||||
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
|
||||
-- GuardSpec / DireHit / XStat) on the active companion
|
||||
if itemId ~= "POKE_DOLL" then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "USEDXITEM", b and b.mon)
|
||||
end
|
||||
if itemId == "X_ACCURACY" then
|
||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||
-- (not an accuracy stage)
|
||||
@@ -253,6 +259,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||
end
|
||||
|
||||
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
|
||||
-- CALCIUM): fires once a medicine has a target, before the effect
|
||||
-- resolves -- potions, status cures, revives and vitamins all count,
|
||||
-- RARE_CANDY does not (its success is a LEVELUP bump instead)
|
||||
if target and (HEAL_AMOUNT[itemId] or STATUS_HEAL[itemId]
|
||||
or itemId == "MAX_POTION" or itemId == "FULL_RESTORE"
|
||||
or itemId == "REVIVE" or itemId == "MAX_REVIVE"
|
||||
or VITAMINS[itemId]) then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "USEDITEM", target)
|
||||
end
|
||||
|
||||
local heal = HEAL_AMOUNT[itemId]
|
||||
if heal or itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||
-- a FULL RESTORE on a statused mon already at full HP acts as a
|
||||
@@ -323,12 +341,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local old = target.stats
|
||||
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
||||
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
|
||||
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "LEVELUP", target)
|
||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
{ leveledTo = target.level }
|
||||
end
|
||||
|
||||
if STONES[itemId] then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||
-- The stone is NOT consumed on the refuse path.
|
||||
if target.species == "PIKACHU"
|
||||
and require("src.core.GameVersion").isYellow()
|
||||
and target.ot == save.player.name
|
||||
and target.otId == save.player.id then
|
||||
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||
local raw = data.text and data.text._RefusingText
|
||||
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
|
||||
or Strings("%s\nis refusing!", monName(data, target))
|
||||
return "failed", { line }
|
||||
end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
if evo.method == "ITEM" and evo.item == itemId then
|
||||
|
||||
@@ -382,6 +382,12 @@ function TradeSession:apply(game)
|
||||
Runtime.emit("pokemon.received",
|
||||
{ mon = received, from = "link", peerName = self.peerName })
|
||||
self.party[self.myPick] = received
|
||||
-- PIKAHAPPY_TRADE (engine/link/cable_club.asm:801): trading the
|
||||
-- companion away is the biggest happiness hit and zeroes the mood
|
||||
if game and sent then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "TRADE", sent)
|
||||
end
|
||||
if game and game.save.pokedex then
|
||||
game.save.pokedex.seen[received.species] = true
|
||||
game.save.pokedex.owned[received.species] = true
|
||||
|
||||
@@ -78,8 +78,11 @@ PaletteFX.GBC_OBJ_BLUE = {
|
||||
-- playthrough, red otherwise. White (index 1) and black (index 4) are
|
||||
-- identical across versions, so callers that only touch the endpoints
|
||||
-- (e.g. BattleState's zone white/black snap) need no version branch.
|
||||
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
|
||||
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
|
||||
function PaletteFX.ogBg()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
|
||||
return PaletteFX.GBC_BG
|
||||
end
|
||||
|
||||
@@ -88,8 +91,10 @@ end
|
||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
end
|
||||
|
||||
@@ -311,6 +316,9 @@ end
|
||||
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
|
||||
-- read these from the ROM-imported table or the title ribbon stays red
|
||||
-- and the Game Corner reels keep Red's pink (issue #128).
|
||||
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
|
||||
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
|
||||
-- prefers those over SGB SuperPalettes for named zones.
|
||||
local BLUE_VERSIONED = {
|
||||
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||
}
|
||||
@@ -320,6 +328,11 @@ local function romNamedPal(data, name)
|
||||
return p and p.palettes and p.palettes[name]
|
||||
end
|
||||
|
||||
local function yellowCgbNamedPal(data, name)
|
||||
local p = data and data.palettes
|
||||
return p and p.cgbBase and p.cgbBase[name]
|
||||
end
|
||||
|
||||
-- named palette from the active pack (nil on stale builds / missing name).
|
||||
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
||||
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
|
||||
@@ -329,10 +342,16 @@ end
|
||||
-- GBC_OBJ green), so this stays a BG-only hook.
|
||||
function PaletteFX.pal(data, name)
|
||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
|
||||
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
|
||||
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
if c then return c end
|
||||
@@ -644,7 +663,10 @@ function PaletteFX.modeLabel(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
|
||||
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
|
||||
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
|
||||
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
|
||||
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
|
||||
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
|
||||
return PaletteFX.MODE_LABELS[mode] or "GBC"
|
||||
end
|
||||
|
||||
|
||||
+76
-2
@@ -173,6 +173,27 @@ function Commands.check_item(ctx, itemId)
|
||||
ctx.lastCheck = (ctx.save.inventory[itemId] or 0) > 0
|
||||
end
|
||||
|
||||
-- check_dex_owned <n>: lastCheck = the player owns at least n species
|
||||
-- (the CountSetBits-over-wPokedexOwned gate in Yellow's OaksLabOak1Text)
|
||||
function Commands.check_dex_owned(ctx, n)
|
||||
local owned = 0
|
||||
for _ in pairs(ctx.save.pokedex and ctx.save.pokedex.owned or {}) do
|
||||
owned = owned + 1
|
||||
end
|
||||
ctx.lastCheck = owned >= (n or 1)
|
||||
end
|
||||
|
||||
-- dex_rating: DisplayDexRating (engine/events/pokedex_rating.asm) --
|
||||
-- Oak's seen/owned tally plus the per-decade rating line; blocks until
|
||||
-- the box closes. Headless-safe no-op without an overworld.
|
||||
function Commands.dex_rating(ctx)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
local runner = ctx.runner
|
||||
ow:dexRating(function() runner:resume() end)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
function Commands.jump_if_true(ctx, target)
|
||||
if ctx.lastCheck then return target end
|
||||
end
|
||||
@@ -561,7 +582,10 @@ end
|
||||
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
|
||||
-- script runner is present; mods that pre-set gift.nickname skip it.
|
||||
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
||||
function Commands.give_pokemon(ctx, species, level)
|
||||
-- skipNickname suppresses the AskName prompt: Yellow's lab Pikachu is
|
||||
-- added straight through AddPartyMon (pokeyellow scripts/OaksLab.asm
|
||||
-- OaksLabPlayerReceivedMonText) -- the starter Pikachu keeps its name.
|
||||
function Commands.give_pokemon(ctx, species, level, skipNickname)
|
||||
-- Native mods can transform a gift before the Pokémon object is created.
|
||||
-- This is intentionally an event rather than a special-case starter hook:
|
||||
-- mods can use the same seam for story gifts, fossils, or custom scripts.
|
||||
@@ -597,7 +621,7 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
ctx.boxNum = boxNum
|
||||
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
|
||||
-- and callback-style callers with no script runner to yield on.
|
||||
if not gift.nickname and ctx.runner then
|
||||
if not gift.nickname and not skipNickname and ctx.runner then
|
||||
askNickname(ctx, mon)
|
||||
end
|
||||
if boxNum then
|
||||
@@ -748,7 +772,46 @@ end
|
||||
-- player CHARMANDER -> base+0, SQUIRTLE -> base+1, BULBASAUR -> base+2.
|
||||
-- offsets (flag -> party offset) lets a modded roster remap the pick;
|
||||
-- field.starterCounterpicks is the data-side default when stamped.
|
||||
-- Yellow's rival parties key off wRivalStarter (save.rivalStarter,
|
||||
-- 1 JOLTEON / 2 FLAREON / 3 VAPOREON -- set in oaks_lab_yellow.lua), not
|
||||
-- the player's starter counterpick. Keyed by the Red call-site party so
|
||||
-- the shared story scripts need no version branches:
|
||||
-- Route 22 #1 RIVAL1 4 -> party 2 (fixed; Route22Script_50ed6), and
|
||||
-- a win upgrades FLAREON to JOLTEON (Route22Rival1AfterBattleScript)
|
||||
-- Cerulean RIVAL1 7 -> party 3 (fixed; CeruleanCity.asm:143)
|
||||
-- S.S. Anne RIVAL2 1 -> party 1 (fixed; SSAnne2F.asm:98)
|
||||
-- Tower 2F RIVAL2 4 -> 1 + starter (PokemonTower2F.asm:148)
|
||||
-- Silph 7F RIVAL2 7 -> 4 + starter (SilphCo7F.asm:185)
|
||||
-- Route 22 #2 RIVAL2 10 -> 7 + starter (Route22Script_50ee1)
|
||||
-- Champion RIVAL3 1 -> 0 + starter (ChampionsRoom.asm:69)
|
||||
local YELLOW_RIVAL_PARTIES = {
|
||||
OPP_RIVAL1 = {
|
||||
[4] = { party = 2, upgradeOnWin = { from = 2, to = 1 } },
|
||||
[7] = { party = 3 },
|
||||
},
|
||||
OPP_RIVAL2 = {
|
||||
[1] = { party = 1 }, [4] = { base = 1 },
|
||||
[7] = { base = 4 }, [10] = { base = 7 },
|
||||
},
|
||||
OPP_RIVAL3 = { [1] = { base = 0 } },
|
||||
}
|
||||
|
||||
function Commands.rival_battle(ctx, oppClass, baseParty, offsets)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
if GameVersion.isYellow() then
|
||||
local spec = YELLOW_RIVAL_PARTIES[oppClass]
|
||||
and YELLOW_RIVAL_PARTIES[oppClass][baseParty]
|
||||
if spec then
|
||||
local starter = ctx.save.rivalStarter or 1
|
||||
local party = spec.party or (spec.base + starter)
|
||||
Commands.start_battle(ctx, "trainer", oppClass, party)
|
||||
if spec.upgradeOnWin and ctx.lastBattleResult == "win"
|
||||
and ctx.save.rivalStarter == spec.upgradeOnWin.from then
|
||||
ctx.save.rivalStarter = spec.upgradeOnWin.to
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
offsets = offsets
|
||||
or (ctx.game.data.field and ctx.game.data.field.starterCounterpicks)
|
||||
local offset = 0
|
||||
@@ -951,6 +1014,17 @@ function Commands.stop_music(ctx)
|
||||
require("src.core.Music").stop()
|
||||
end
|
||||
|
||||
-- play_default_music: PlayDefaultMusic -- resume the current map's own
|
||||
-- theme (data.audio.mapSongs) after a cutscene override, keeping the
|
||||
-- bike/surf substitution rules. Headless-safe no-op without an overworld.
|
||||
function Commands.play_default_music(ctx)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
require("src.core.Music").playMap(ctx.game.data, ow.map.id,
|
||||
ctx.save and ctx.save.onBike,
|
||||
ow.player and ow.player.surfing)
|
||||
end
|
||||
|
||||
-- replace_block <bx> <by> <blockId>: the Cut-tree/card-key-door idiom,
|
||||
-- on the current map
|
||||
function Commands.replace_block(ctx, bx, by, blockId)
|
||||
|
||||
@@ -191,8 +191,18 @@ function ScriptRunner:yield()
|
||||
end
|
||||
|
||||
function ScriptRunner:resume(...)
|
||||
if not self.co then return end
|
||||
local ok, err = coroutine.resume(self.co, ...)
|
||||
local co = self.co
|
||||
if not co then return end
|
||||
-- A completion callback can fire synchronously from inside the running
|
||||
-- coroutine (e.g. a battle that finishes during its own stack push when
|
||||
-- the party is already fainted). Resuming a running coroutine is an
|
||||
-- error that would kill the whole script, so land the pending yield
|
||||
-- first and continue on the next update tick instead.
|
||||
if coroutine.status(co) == "running" then
|
||||
self.waitingFrames = 1
|
||||
return
|
||||
end
|
||||
local ok, err = coroutine.resume(co, ...)
|
||||
if not ok then
|
||||
local source = self.ctx and self.ctx.source
|
||||
local where = source
|
||||
@@ -209,8 +219,10 @@ function ScriptRunner:resume(...)
|
||||
self.co = nil
|
||||
self.waitingFrames = nil
|
||||
self.waitingCheck = nil
|
||||
elseif coroutine.status(self.co) == "dead" then
|
||||
self.co = nil
|
||||
-- status via the captured co: a nested resume during the call above may
|
||||
-- already have torn self.co down, and status(nil) would throw
|
||||
elseif coroutine.status(co) == "dead" then
|
||||
if self.co == co then self.co = nil end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -158,15 +158,22 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local moveId = payload
|
||||
local mdef = game.data.moves[moveId]
|
||||
local function teach()
|
||||
-- PIKAHAPPY_USEDTMHM on a successful teach (item_effects.asm:2500)
|
||||
local function taught()
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "USEDTMHM", target)
|
||||
end
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
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
|
||||
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 taught() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
+45
-3
@@ -127,6 +127,9 @@ local function deposit(game)
|
||||
end
|
||||
table.remove(game.save.party, item.value)
|
||||
table.insert(active, mon)
|
||||
-- PIKAHAPPY_DEPOSITED (engine/pokemon/bills_pc.asm:247)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "DEPOSITED", mon)
|
||||
local name = monName(game, mon)
|
||||
game.stringBuffer = name
|
||||
game.boxNumString = tostring(game.save.currentBox)
|
||||
@@ -220,13 +223,43 @@ local function drawChrome(game)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- PrintPCBox (engine/printer/printer.asm): Yellow's box-list print job,
|
||||
-- box number plus each stored mon's name, level and dex number; the PNG
|
||||
-- under prints/ stands in for the printer paper.
|
||||
local function printBox(game)
|
||||
local box = Boxes.active(game.save)
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local h = 32 + math.max(1, #box) * 10
|
||||
local saved, err = Printer.save("box_" .. (game.save.currentBox or 1),
|
||||
160, h, function()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, h)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("BOX No.%d", game.save.currentBox or 1), 8, 8)
|
||||
if #box == 0 then Font.draw(Strings("Empty."), 8, 24) end
|
||||
for i, mon in ipairs(box) do
|
||||
local def = game.data.pokemon[mon.species]
|
||||
Font.draw(mon.nickname or (def and def.name) or tostring(mon.species),
|
||||
8, 14 + i * 10)
|
||||
Font.draw(Strings(":L%d No.%03d", mon.level or 0,
|
||||
def and def.dex or 0), 88, 14 + i * 10)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed BOX %d!\fSaved as\n%s\vin the save\nfolder.",
|
||||
game.save.currentBox or 1, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end
|
||||
|
||||
function BoxMenu.new(game)
|
||||
Boxes.ensure(game.save)
|
||||
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
|
||||
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||
local menu = Menu.new(game, {
|
||||
local items = {
|
||||
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() withdraw(game) end },
|
||||
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
|
||||
@@ -235,10 +268,19 @@ function BoxMenu.new(game)
|
||||
onSelect = function() release(game) end },
|
||||
{ label = Strings("CHANGE BOX"), keepOpen = true,
|
||||
onSelect = function() changeBox(game) end },
|
||||
{ label = Strings("SEE YA!") },
|
||||
}
|
||||
-- Yellow's PRINT BOX item (bills_pc.asm _YELLOW -> PrintPCBox): the
|
||||
-- Game Boy Printer box list becomes a PNG under prints/, like the
|
||||
-- Pokédex PRNT stand-in
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
items[#items + 1] = { label = Strings("PRINT BOX"), keepOpen = true,
|
||||
onSelect = function() printBox(game) end }
|
||||
end
|
||||
items[#items + 1] = { label = Strings("SEE YA!") }
|
||||
local menu = Menu.new(game, items,
|
||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||
-- engine/menus/pokemon_pc.asm)
|
||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true })
|
||||
local baseDraw = menu.draw
|
||||
function menu:draw()
|
||||
baseDraw(self)
|
||||
|
||||
+13
-7
@@ -55,11 +55,17 @@ function DexEntryMenu:update(dt)
|
||||
end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
|
||||
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)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local def = self.def
|
||||
if self.sprite then
|
||||
love.graphics.draw(self.sprite, 8, math.max(0, 60 - self.sprite:getHeight()))
|
||||
if sprite then
|
||||
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
@@ -70,10 +76,10 @@ function DexEntryMenu:draw()
|
||||
Font.draw(e.kind or "?", 72, 20)
|
||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||
-- prints the extra digit everywhere at once
|
||||
local digits = (self.game.data.constants or {}).dexDigits or 3
|
||||
local digits = (game.data.constants or {}).dexDigits or 3
|
||||
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||
local owned = self.forceOwned
|
||||
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
|
||||
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")
|
||||
@@ -84,7 +90,7 @@ function DexEntryMenu:draw()
|
||||
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)
|
||||
end
|
||||
local text = owned and e.text and self.game.data.text[e.text] or nil
|
||||
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
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
|
||||
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
|
||||
-- player's name, shown by the Celadon Mansion 3F game designer once 150
|
||||
-- species are owned. Diploma.render also backs the Yellow-only printed
|
||||
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Diploma = {}
|
||||
Diploma.__index = Diploma
|
||||
Diploma.isOpaque = true
|
||||
|
||||
function Diploma.new(game, onDone)
|
||||
return setmetatable({ game = game, onDone = onDone }, Diploma)
|
||||
end
|
||||
|
||||
function Diploma:update()
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
|
||||
function Diploma.render(game)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
|
||||
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
|
||||
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
|
||||
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
|
||||
local congrats = { -- hlcoord 2,6
|
||||
"Congrats! This", "diploma certifies", "that you have",
|
||||
"completed your", "POKéDEX.",
|
||||
}
|
||||
for i, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
|
||||
end
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Diploma:draw()
|
||||
Diploma.render(self.game)
|
||||
end
|
||||
|
||||
return Diploma
|
||||
@@ -104,6 +104,7 @@ PartyMenu.iconFrames = {
|
||||
FAIRY = { rest = 3, alt = 0 }, -- FairySprite tile 12 <-> tile 0
|
||||
BIRD = { rest = 3, alt = 0 }, -- BirdSprite tile 12 <-> tile 0
|
||||
WATER = { rest = 0, alt = 3 }, -- SeelSprite tile 0 <-> tile 12
|
||||
PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 12
|
||||
}
|
||||
|
||||
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
|
||||
|
||||
+29
-3
@@ -57,7 +57,7 @@ function PokedexMenu.new(game, opts)
|
||||
-- original, QUIT returns to the list
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
game.stack:push(Menu.new(game, {
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
@@ -67,8 +67,34 @@ function PokedexMenu.new(game, opts)
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
{ label = Strings("QUIT") },
|
||||
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
|
||||
}
|
||||
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
|
||||
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
|
||||
-- stood in for by a PNG of the entry page saved under prints/.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT") }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
end,
|
||||
})
|
||||
list.sgbPalettes = PokedexMenu.sgbPalettes
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
-- Surfing Pikachu minigame (engine/minigame/surfing_pikachu.asm): the
|
||||
-- Summer Beach House wave run. Paddle for speed, launch off the wave,
|
||||
-- spin in the air and land flat for points; a crooked landing wipes out
|
||||
-- and ends the run. The scene is built from the real ROM sheets
|
||||
-- (gfx/surfing_pikachu.asm, ripped at import to
|
||||
-- assets/generated/minigame/surf_1a/1b.png): the scalloped water tiles,
|
||||
-- the beach with the palm and the doll hut, the "HP:" score strip with
|
||||
-- the sheet digits, the cloud, and the OAM Pikachu poses -- the air
|
||||
-- tricks quantize to the sheet's rotation frames like the original's
|
||||
-- sprite anims, instead of free-rotating one pose. The original drew
|
||||
-- the big wave with per-scanline scroll tricks (wLYOverrides); here the
|
||||
-- crest profile is a curve filled with the sheet's foam/shade tiles.
|
||||
-- Score model keeps the original's shape (ride ticks + airtime + full
|
||||
-- rotations); high score persists in save.surfingHighScore for the
|
||||
-- beach-house printer.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local SurfingMinigame = {}
|
||||
SurfingMinigame.__index = SurfingMinigame
|
||||
SurfingMinigame.isOpaque = true
|
||||
|
||||
local PIKA_X = 44 -- fixed screen x while riding
|
||||
local RUN_DISTANCE = 3200 -- scroll px from paddle-out to the beach
|
||||
local GRAVITY = 0.14
|
||||
local HORIZON = 24 -- sea starts under the sky strip
|
||||
|
||||
-- surf_1b quads: {x, y, w, h} in sheet pixels (pose pitch is 24x24)
|
||||
local B = {
|
||||
digits = { x = 0, y = 104 }, -- "0123456789", 8x8 each
|
||||
good = { 0, 72, 32, 8 },
|
||||
yeah = { 32, 72, 32, 8 },
|
||||
ohno = { 80, 96, 48, 24 },
|
||||
splash = { 48, 80, 32, 24 },
|
||||
cloud = { 96, 112, 32, 8 },
|
||||
paddle = { { 0, 80, 24, 24 }, { 24, 80, 24, 24 } },
|
||||
}
|
||||
-- rotation frames, 45-degree buckets clockwise from upright
|
||||
local POSES = {
|
||||
[0] = { 48, 0, 24, 24 }, -- upright ride
|
||||
[45] = { 24, 0, 24, 24 }, -- nose down
|
||||
[90] = { 0, 48, 24, 24 }, -- board vertical
|
||||
[135] = { 48, 48, 24, 24 }, -- tumbling
|
||||
[180] = { 72, 48, 24, 24 }, -- upside down
|
||||
[225] = { 48, 48, 24, 24 },
|
||||
[270] = { 0, 48, 24, 24 },
|
||||
[315] = { 0, 0, 24, 24 }, -- tail down
|
||||
}
|
||||
|
||||
-- surf_1a quads (BG tiles)
|
||||
local A = {
|
||||
scallop = { 16, 0, 8, 8 }, -- open-water pattern, row A
|
||||
scallop2 = { 16, 8, 8, 8 }, -- row B variant
|
||||
shade = { 8, 16, 8, 8 }, -- gray dither, wave belly
|
||||
lip = { 24, 0, 8, 8 }, -- foam curl for the crest edge
|
||||
palm = { 8, 32, 8, 8 }, -- palm fronds
|
||||
beach = { 24, 32, 16, 8 }, -- black shore silhouette
|
||||
hut = { 8, 40, 16, 8 }, -- the Pikachu doll hut on the sand
|
||||
hp = { 20, 40, 20, 8 }, -- "HP:" score label
|
||||
}
|
||||
|
||||
-- SGB-style zones: one sea palette over the frame plus a yellow
|
||||
-- OBJ-flavored palette tracking Pikachu's tiles (rectangular attribute
|
||||
-- blocks are all the SGB could do, bleed and all)
|
||||
local SEA_PAL = { { 255, 255, 255 }, { 112, 184, 248 },
|
||||
{ 56, 120, 216 }, { 0, 0, 0 } }
|
||||
local PIKA_PAL = { { 255, 255, 255 }, { 248, 216, 64 },
|
||||
{ 224, 144, 32 }, { 0, 0, 0 } }
|
||||
|
||||
local function newQuad(spec, img)
|
||||
return love.graphics.newQuad(spec[1], spec[2], spec[3], spec[4],
|
||||
img:getDimensions())
|
||||
end
|
||||
|
||||
function SurfingMinigame.new(game, onDone)
|
||||
local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame)
|
||||
self.phase = "ride" -- ride | air | wipeout | results
|
||||
self.t = 0
|
||||
self.distance = 0
|
||||
self.speed = 2
|
||||
self.score = 0
|
||||
self.rideTick = 0
|
||||
self.y = 0 -- air offset above the wave (positive = up)
|
||||
self.vy = 0
|
||||
self.rot = 0 -- degrees, accumulates through the air
|
||||
self.spins = 0
|
||||
self.airFrames = 0
|
||||
self.resultShown = 0
|
||||
self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no..
|
||||
|
||||
local function sheet(path)
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
end
|
||||
self.bg = sheet("assets/generated/minigame/surf_1a.png")
|
||||
self.ob = sheet("assets/generated/minigame/surf_1b.png")
|
||||
if self.bg then
|
||||
self.aq = {}
|
||||
for k, spec in pairs(A) do self.aq[k] = newQuad(spec, self.bg) end
|
||||
end
|
||||
if self.ob then
|
||||
self.bq = {}
|
||||
for k, spec in pairs(B) do
|
||||
if spec[3] then self.bq[k] = newQuad(spec, self.ob) end
|
||||
end
|
||||
self.bq.paddle = { newQuad(B.paddle[1], self.ob),
|
||||
newQuad(B.paddle[2], self.ob) }
|
||||
self.bq.poses = {}
|
||||
for deg, spec in pairs(POSES) do
|
||||
self.bq.poses[deg] = newQuad(spec, self.ob)
|
||||
end
|
||||
self.bq.digit = {}
|
||||
for d = 0, 9 do
|
||||
self.bq.digit[d] = love.graphics.newQuad(B.digits.x + d * 8,
|
||||
B.digits.y, 8, 8, self.ob:getDimensions())
|
||||
end
|
||||
end
|
||||
Music.play(game.data, "Music_SurfingPikachu")
|
||||
return self
|
||||
end
|
||||
|
||||
-- crest height at screen x for the current scroll (two sines so the
|
||||
-- wave rolls instead of looping visibly)
|
||||
function SurfingMinigame:seaY(x)
|
||||
local s = self.distance + x
|
||||
return 92 - 14 * math.sin(s / 26) - 6 * math.sin(s / 9.5)
|
||||
end
|
||||
|
||||
function SurfingMinigame:finishRun()
|
||||
self.phase = "results"
|
||||
local save = self.game.save
|
||||
self.newRecord = self.score > (save.surfingHighScore or 0)
|
||||
if self.newRecord then save.surfingHighScore = self.score end
|
||||
Music.stop()
|
||||
Sound.play(self.game.data, self.newRecord and "Get_Item1" or "Ball_Poof")
|
||||
end
|
||||
|
||||
function SurfingMinigame:update()
|
||||
local input = self.game.input
|
||||
self.t = self.t + 1
|
||||
if self.banner then
|
||||
self.banner.frames = self.banner.frames - 1
|
||||
if self.banner.frames <= 0 then self.banner = nil end
|
||||
end
|
||||
if self.phase == "results" then
|
||||
self.resultShown = self.resultShown + 1
|
||||
if self.resultShown > 30
|
||||
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone(self.score) end
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.phase == "wipeout" then
|
||||
self.splash = (self.splash or 0) + 1
|
||||
if self.splash > 70 then self:finishRun() end
|
||||
return
|
||||
end
|
||||
|
||||
-- the wave scrolls by the current speed; the beach ends the run
|
||||
self.distance = self.distance + 0.8 + self.speed * 0.35
|
||||
if self.distance >= RUN_DISTANCE then
|
||||
-- rode it all the way in: distance bonus like the original's goal
|
||||
self.score = self.score + 500
|
||||
self:finishRun()
|
||||
return
|
||||
end
|
||||
|
||||
if self.phase == "ride" then
|
||||
-- paddling: mash A for speed, it bleeds off on its own
|
||||
if input:wasPressed("a") and self.speed < 8 then
|
||||
self.speed = self.speed + 1
|
||||
end
|
||||
if self.t % 45 == 0 and self.speed > 2 then
|
||||
self.speed = self.speed - 1
|
||||
end
|
||||
self.rideTick = self.rideTick + 1
|
||||
if self.rideTick % 12 == 0 then self.score = self.score + 1 end
|
||||
-- launch off the lip
|
||||
if input:wasPressed("up") then
|
||||
self.phase = "air"
|
||||
self.vy = 1.6 + self.speed * 0.45
|
||||
self.rot, self.spins, self.airFrames = 0, 0, 0
|
||||
Sound.play(self.game.data, "Ledge_Jump")
|
||||
end
|
||||
elseif self.phase == "air" then
|
||||
self.airFrames = self.airFrames + 1
|
||||
self.vy = self.vy - GRAVITY
|
||||
self.y = self.y + self.vy
|
||||
-- tricks: hold either direction to spin
|
||||
local spin = (input:isDown("left") and -6 or 0)
|
||||
+ (input:isDown("right") and 6 or 0)
|
||||
self.rot = self.rot + spin
|
||||
if math.abs(self.rot) >= (self.spins + 1) * 360 then
|
||||
self.spins = self.spins + 1
|
||||
end
|
||||
if self.y <= 0 and self.vy < 0 then
|
||||
self.y = 0
|
||||
local tilt = math.abs(self.rot) % 360
|
||||
if tilt <= 60 or tilt >= 300 then
|
||||
-- clean landing: airtime + full rotations pay out
|
||||
self.score = self.score + self.spins * 100
|
||||
+ math.floor(self.airFrames / 4)
|
||||
self.phase = "ride"
|
||||
self.banner = { quad = self.spins > 0 and "yeah" or "good",
|
||||
frames = 50 }
|
||||
Sound.play(self.game.data, "Cut")
|
||||
else
|
||||
self.phase = "wipeout"
|
||||
self.splash = 0
|
||||
self.banner = { quad = "ohno", frames = 70 }
|
||||
Sound.play(self.game.data, "Faint_Fall")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- draw one 8x8 sheet tile quad at x, y
|
||||
function SurfingMinigame:tile(q, x, y)
|
||||
love.graphics.draw(self.bg, self.aq[q], x, y)
|
||||
end
|
||||
|
||||
function SurfingMinigame:sgbPalettes()
|
||||
local P = require("src.render.PaletteFX")
|
||||
local zones = { P.whole(SEA_PAL) }
|
||||
if self.phase ~= "wipeout" and self.phase ~= "results" then
|
||||
local tx = math.floor((PIKA_X - 12) / 8)
|
||||
local ty = math.floor(math.max(0, self.pikaScreenY or 60) / 8)
|
||||
zones[#zones + 1] = P.zone(PIKA_PAL, tx, ty, tx + 3, ty + 3)
|
||||
end
|
||||
return zones
|
||||
end
|
||||
|
||||
function SurfingMinigame:drawScore(x, y, n)
|
||||
local s = tostring(n)
|
||||
for i = 1, #s do
|
||||
love.graphics.draw(self.ob, self.bq.digit[tonumber(s:sub(i, i))],
|
||||
x + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
|
||||
function SurfingMinigame:draw()
|
||||
local haveSheets = self.bg and self.ob
|
||||
-- sky
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
if not haveSheets then
|
||||
-- cache predates the surf sheets: plain shapes keep it playable
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("SCORE %d", self.score), 4, 4)
|
||||
love.graphics.rectangle("fill", PIKA_X - 8,
|
||||
self:seaY(PIKA_X) - 16 - self.y, 16, 16)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
-- cloud in the sky strip
|
||||
love.graphics.draw(self.ob, self.bq.cloud, 112, 8)
|
||||
|
||||
-- open water: the scalloped pattern tiles the whole sea, phase-locked
|
||||
-- to the scroll so the surface slides
|
||||
local shift = math.floor(self.distance) % 8
|
||||
for ty = HORIZON, 136, 8 do
|
||||
local alt = (ty / 8) % 2 == 0
|
||||
for tx = -8, 160, 8 do
|
||||
self:tile(alt and "scallop" or "scallop2", tx - shift, ty)
|
||||
end
|
||||
end
|
||||
|
||||
-- the wave face: a white patch hugging the ride line (the original
|
||||
-- carved it with per-scanline scroll; the ellipse stands in), with a
|
||||
-- few scallops floating inside and the foam lip along its upper edge
|
||||
local faceY = self:seaY(56) + 10
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.ellipse("fill", 56, faceY, 46, 30)
|
||||
love.graphics.ellipse("fill", 100, faceY + 16, 40, 22)
|
||||
for _, spot in ipairs({ { 30, 8 }, { 70, 16 }, { 48, 22 } }) do
|
||||
self:tile("scallop", 56 - 46 + spot[1] - shift, faceY - 24 + spot[2])
|
||||
end
|
||||
local pikaY = self:seaY(PIKA_X) - 20 - self.y
|
||||
for a = 205, 335, 18 do
|
||||
local r = math.rad(a)
|
||||
local lx = 56 + math.cos(r) * 44 - 4
|
||||
local ly = faceY + math.sin(r) * 28 - 4
|
||||
-- foam that would land inside Pikachu's SGB zone comes out orange;
|
||||
-- leave that patch to the spray ellipse instead
|
||||
if math.abs(lx - PIKA_X) > 28 or math.abs(ly - (pikaY + 12)) > 26 then
|
||||
self:tile("lip", lx, ly)
|
||||
end
|
||||
end
|
||||
self:tile("shade", 92 - shift, faceY + 20)
|
||||
self:tile("shade", 116 - shift, faceY + 24)
|
||||
|
||||
-- beach slides through at the start and again before the goal
|
||||
local beachX
|
||||
if self.distance < 160 then
|
||||
beachX = -self.distance
|
||||
elseif self.distance > RUN_DISTANCE - 200 then
|
||||
beachX = 160 - (self.distance - (RUN_DISTANCE - 200))
|
||||
end
|
||||
if beachX then
|
||||
for tx = 0, 32, 8 do
|
||||
self:tile("beach", beachX + tx, 128)
|
||||
self:tile("beach", beachX + tx, 136)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", beachX + 9, 118, 2, 10)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:tile("palm", beachX + 6, 112)
|
||||
self:tile("hut", beachX + 20, 118)
|
||||
end
|
||||
|
||||
-- Pikachu. The white spray patch under him doubles as the yellow SGB
|
||||
-- zone's backdrop: shade 0 maps to white in both palettes, so the
|
||||
-- attribute-block bleed never shows on the water pattern.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local py = self:seaY(PIKA_X) - 20 - self.y
|
||||
self.pikaScreenY = py -- the yellow SGB zone tracks this
|
||||
love.graphics.ellipse("fill", PIKA_X, py + 12, 25, 21)
|
||||
if self.phase == "wipeout" then
|
||||
love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 16,
|
||||
self:seaY(PIKA_X) - 16)
|
||||
else
|
||||
local quad
|
||||
if self.phase == "ride" and self.speed <= 2
|
||||
and self.distance < 120 then
|
||||
quad = self.bq.paddle[math.floor(self.t / 8) % 2 + 1]
|
||||
else
|
||||
local bucket = math.floor(((self.rot % 360) + 22.5) / 45) % 8 * 45
|
||||
quad = self.bq.poses[bucket] or self.bq.poses[0]
|
||||
end
|
||||
love.graphics.draw(self.ob, quad, PIKA_X - 12, py)
|
||||
end
|
||||
|
||||
-- banner beats: GOOD! / YEAH- / Oh no..
|
||||
if self.banner and self.bq[self.banner.quad] then
|
||||
love.graphics.draw(self.ob, self.bq[self.banner.quad], 60, 40)
|
||||
end
|
||||
|
||||
-- score strip, bottom right: HP: + sheet digits
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 100, 134, 60, 10)
|
||||
love.graphics.draw(self.bg, self.aq.hp, 102, 135)
|
||||
self:drawScore(126, 135, self.score)
|
||||
|
||||
if self.phase == "results" then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 20, 48, 120, 48)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 20.5, 48.5, 119, 47)
|
||||
Font.draw(Strings("SCORE %d", self.score), 32, 56)
|
||||
if self.newRecord then
|
||||
Font.draw(Strings("New record!"), 32, 68)
|
||||
else
|
||||
Font.draw(Strings("HI %d", self.game.save.surfingHighScore or 0),
|
||||
32, 68)
|
||||
end
|
||||
if self.resultShown > 30 then
|
||||
Font.draw(Strings("A: done"), 32, 82)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
return SurfingMinigame
|
||||
+212
-44
@@ -33,11 +33,25 @@ end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
local z
|
||||
if self.yellowLayout then
|
||||
-- Yellow's BlkPacket_Titlescreen (pokeyellow data/sgb/sgb_packets.asm):
|
||||
-- rows 0-7 logo band pal 0 (PAL_LOGO2), rows 8-17 Pikachu + copyright
|
||||
-- pal 2 (PAL_MEWMON), then the two bubble-tail cells at (9,8)-(10,8)
|
||||
-- back on pal 0. No Red/Blue LOGO1 ribbon band.
|
||||
local logoPal = P.pal(game.data, "LOGO2")
|
||||
z = {
|
||||
P.zone(logoPal, 0, 0, 19, 7),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 8, 19, 17),
|
||||
P.zone(logoPal, 9, 8, 10, 8),
|
||||
}
|
||||
else
|
||||
z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
end
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
@@ -61,6 +75,14 @@ local BLUE_CYCLE_SPECIES = {
|
||||
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
|
||||
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
|
||||
}
|
||||
-- Yellow has no TitleMons table (engine/movie/title_yellow.asm is a fixed
|
||||
-- Pikachu title). Until field.title.cycleSpecies is imported, keep a short
|
||||
-- Pikachu-centric list so the Red/Blue cycling UI still has something to show.
|
||||
local YELLOW_CYCLE_SPECIES = {
|
||||
"PIKACHU", "EEVEE", "BULBASAUR", "CHARMANDER", "SQUIRTLE",
|
||||
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
|
||||
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
|
||||
}
|
||||
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
||||
|
||||
local function tryImage(path)
|
||||
@@ -93,9 +115,36 @@ function TitleState.new(game, opts)
|
||||
or "assets/generated/title/red_version.png")
|
||||
self.player = tryImage("assets/generated/title/player.png")
|
||||
self.blue = GameVersion.isBlue()
|
||||
-- Blue cycles its own title mons and prints its ribbon contiguously; a
|
||||
-- field.title.cycleSpecies override (mods / total conversions) still wins.
|
||||
local defaultCycle = self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES
|
||||
self.yellow = GameVersion.isYellow()
|
||||
or title.layout == "yellow_pikachu"
|
||||
-- Yellow title is a fixed Pikachu composition (title_yellow.asm), not
|
||||
-- TitleMons cycling. Prefer composed pikachu.png from the Yellow import.
|
||||
self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu)
|
||||
or "assets/generated/title/pikachu.png") or nil
|
||||
self.yellowBubble = self.yellow and tryImage(imagePath(title.pikaBubble)
|
||||
or "assets/generated/title/pika_bubble.png") or nil
|
||||
self.yellowLayout = self.yellow and self.yellowPikachu ~= nil
|
||||
if self.yellowLayout then
|
||||
-- title.asm boot: hSCY starts at $40 with the logo parked above the
|
||||
-- viewport; .bouncePokemonLogoLoop drops it in with an overshoot
|
||||
-- bounce, then the whoosh, the speech bubble, and PikachuCry1 before
|
||||
-- the title music starts. Blink overlays are the OB tile swaps of
|
||||
-- DoTitleScreenFunction.
|
||||
self.eyesHalf = tryImage("assets/generated/title/eyes_half.png")
|
||||
self.eyesClosed = tryImage("assets/generated/title/eyes_closed.png")
|
||||
self.scy = 0x40
|
||||
self.phase = "drop"
|
||||
self.dropStep, self.dropLeft = 1, nil
|
||||
self.showBubble = false
|
||||
self.blinkTimer = 0
|
||||
self.blinkAt = nil
|
||||
else
|
||||
self.phase = "loop"
|
||||
self.showBubble = true
|
||||
end
|
||||
local defaultCycle = self.yellowLayout and { "PIKACHU" }
|
||||
or (self.yellow and YELLOW_CYCLE_SPECIES)
|
||||
or (self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES)
|
||||
self.cycleSpecies = (type(title.cycleSpecies) == "table"
|
||||
and #title.cycleSpecies > 0)
|
||||
and title.cycleSpecies or defaultCycle
|
||||
@@ -107,6 +156,14 @@ function TitleState.new(game, opts)
|
||||
end
|
||||
|
||||
function TitleState:enter()
|
||||
-- Yellow defers the title theme until after the logo drop and
|
||||
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
|
||||
-- WaitForSoundToFinish on PikachuCry1)
|
||||
if self.yellowLayout then return end
|
||||
self:startMusic()
|
||||
end
|
||||
|
||||
function TitleState:startMusic()
|
||||
local data = self.game.data
|
||||
local song = self.title.music or "Music_TitleScreen"
|
||||
if data.audio and data.audio.songs and data.audio.songs[song] then
|
||||
@@ -114,6 +171,83 @@ function TitleState:enter()
|
||||
end
|
||||
end
|
||||
|
||||
-- .TitleScreenPokemonLogoYScrolls: { dy per frame, frames }; the -3
|
||||
-- rebound step lands with SFX_INTRO_CRASH
|
||||
local DROP_STEPS = {
|
||||
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
|
||||
}
|
||||
|
||||
-- the boot cinematic up to the interactive loop; one call per frame
|
||||
function TitleState:updateSequence()
|
||||
local Sound = require("src.core.Sound")
|
||||
local data = self.game.data
|
||||
if self.phase == "drop" then
|
||||
local step = DROP_STEPS[self.dropStep]
|
||||
if not step then
|
||||
self.phase = "settle"
|
||||
self.timer = 0
|
||||
return
|
||||
end
|
||||
if self.dropLeft == nil then
|
||||
self.dropLeft = step[2]
|
||||
if step[1] == -3 then Sound.play(data, "Intro_Crash") end
|
||||
end
|
||||
self.scy = self.scy + step[1]
|
||||
self.dropLeft = self.dropLeft - 1
|
||||
if self.dropLeft <= 0 then
|
||||
self.dropStep = self.dropStep + 1
|
||||
self.dropLeft = nil
|
||||
end
|
||||
elseif self.phase == "settle" then
|
||||
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
|
||||
self.timer = self.timer + 1
|
||||
if self.timer >= 36 then
|
||||
Sound.play(data, "Intro_Whoosh")
|
||||
self.showBubble = true
|
||||
self.phase = "bubble"
|
||||
self.timer = 0
|
||||
end
|
||||
elseif self.phase == "bubble" then
|
||||
self.timer = self.timer + 1
|
||||
if self.timer >= 3 then
|
||||
self.crySrc = Sound.playPikaCry(data, 1)
|
||||
self.phase = "cry"
|
||||
self.timer = 0
|
||||
end
|
||||
elseif self.phase == "cry" then
|
||||
-- WaitForSoundToFinish before the music starts
|
||||
self.timer = self.timer + 1
|
||||
local playing = self.crySrc and self.crySrc.isPlaying
|
||||
and self.crySrc:isPlaying()
|
||||
if not playing or self.timer > 180 then
|
||||
self.crySrc = nil
|
||||
self:startMusic()
|
||||
self.phase = "loop"
|
||||
self.blinkTimer = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- DoTitleScreenFunction.CheckTimer: an 8-bit frame counter blinks at 0,
|
||||
-- $80 and $90; the blink itself runs half/closed/half over 9 frames
|
||||
function TitleState:updateBlink()
|
||||
local t = self.blinkTimer
|
||||
self.blinkTimer = (t + 1) % 256
|
||||
if t == 0 or t == 0x80 or t == 0x90 then self.blinkAt = 0 end
|
||||
if self.blinkAt then
|
||||
self.blinkAt = self.blinkAt + 1
|
||||
if self.blinkAt > 9 then self.blinkAt = nil end
|
||||
end
|
||||
end
|
||||
|
||||
-- the blink overlay for this frame (nil = open eyes)
|
||||
function TitleState:blinkOverlay()
|
||||
local at = self.blinkAt
|
||||
if not at then return nil end
|
||||
if at <= 3 or at > 6 then return self.eyesHalf end
|
||||
return self.eyesClosed
|
||||
end
|
||||
|
||||
function TitleState:currentSprite()
|
||||
local species = self.cycleSpecies[self.cycleIndex]
|
||||
local cached = self.sprites[species]
|
||||
@@ -219,9 +353,26 @@ function TitleState:openMenu()
|
||||
end
|
||||
|
||||
function TitleState:update(dt)
|
||||
if self.yellowLayout then
|
||||
if self.phase ~= "loop" then
|
||||
self:updateSequence()
|
||||
return -- input is ignored until the cinematic lands (title.asm)
|
||||
end
|
||||
self:updateBlink()
|
||||
local input = self.game.input
|
||||
if input:wasPressed("start") or input:wasPressed("a") then
|
||||
-- .go_to_main_menu voices PikachuCry11 on the way out
|
||||
local Sound = require("src.core.Sound")
|
||||
if not Sound.playPikaCry(self.game.data, 11) then
|
||||
Sound.playCry(self.game.data, "PIKACHU")
|
||||
end
|
||||
self:openMenu()
|
||||
end
|
||||
return
|
||||
end
|
||||
self.timer = self.timer + 1
|
||||
self.blink = (self.blink + 1) % 60
|
||||
if self.timer >= CYCLE_FRAMES then
|
||||
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
|
||||
self.timer = 0
|
||||
-- random pick that never repeats the current one
|
||||
if #self.cycleSpecies > 1 then
|
||||
@@ -238,9 +389,11 @@ function TitleState:update(dt)
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("start") or input:wasPressed("a") then
|
||||
-- the title mon cries when you leave the title (.finishedWaiting)
|
||||
-- the title mon cries when you leave the title (.finishedWaiting);
|
||||
-- Yellow's fixed Pikachu title always cries Pikachu.
|
||||
require("src.core.Sound").playCry(self.game.data,
|
||||
self.cycleSpecies[self.cycleIndex])
|
||||
self.yellowLayout and "PIKACHU"
|
||||
or self.cycleSpecies[self.cycleIndex])
|
||||
self:openMenu()
|
||||
end
|
||||
end
|
||||
@@ -248,50 +401,65 @@ end
|
||||
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
|
||||
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
|
||||
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
|
||||
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
|
||||
-- (4,8) 12x9 — no version ribbon, no cycling mon, no Red OAM.
|
||||
function TitleState:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
|
||||
if self.logo then
|
||||
love.graphics.draw(self.logo, 16, 8)
|
||||
love.graphics.draw(self.logo, 16, 8 + scrollY)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.blue and "POKéMON BLUE" or Strings("POKéMON RED"),
|
||||
(160 - 12 * 8) / 2, 24)
|
||||
local brand = self.yellow and "POKéMON YELLOW"
|
||||
or (self.blue and "POKéMON BLUE" or Strings("POKéMON RED"))
|
||||
Font.draw(brand, (160 - 12 * 8) / 2, 24 + scrollY)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.version then
|
||||
local iw, ih = self.version:getDimensions()
|
||||
if self.blue then
|
||||
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
|
||||
-- The extracted strip packs those eight glyph tiles into image tiles
|
||||
-- 0..7 (tiles 8..9 are blank), so draw that 64px run at px (56, 64).
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
|
||||
else
|
||||
-- Red's strip holds Red+Green+Version glyphs; the tilemap prints
|
||||
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version").
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
||||
if self.yellowLayout then
|
||||
-- everything scrolls together through the logo drop (rSCY): screen
|
||||
-- y = BG y - SCY, so the composition rides at -scy until it lands
|
||||
local dy = scrollY
|
||||
if self.yellowBubble and self.showBubble then
|
||||
love.graphics.draw(self.yellowBubble, 48, 32 + dy)
|
||||
end
|
||||
-- hlcoord 4,8 → px (32, 64); composed 13x9 tile sprite
|
||||
love.graphics.draw(self.yellowPikachu, 32, 64 + dy)
|
||||
local overlay = self:blinkOverlay()
|
||||
if overlay then
|
||||
-- the eye OAM band sits at (56,80) on the landed screen
|
||||
love.graphics.draw(overlay, 32 + 24, 64 + 16 + dy)
|
||||
end
|
||||
else
|
||||
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
|
||||
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
|
||||
-- the Yellow fallback layout draws no ribbon at all.
|
||||
if self.version and not self.yellow then
|
||||
local iw, ih = self.version:getDimensions()
|
||||
if self.blue then
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
|
||||
else
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
||||
end
|
||||
end
|
||||
local sprite = self:currentSprite()
|
||||
if sprite then
|
||||
local w, h = sprite:getDimensions()
|
||||
local slide = (self.slideIn or 0) * 8
|
||||
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
|
||||
136 - h)
|
||||
end
|
||||
if self.player then
|
||||
love.graphics.draw(self.player, 82, 80)
|
||||
end
|
||||
end
|
||||
local sprite = self:currentSprite()
|
||||
if sprite then
|
||||
local w, h = sprite:getDimensions()
|
||||
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
|
||||
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
|
||||
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
|
||||
136 - h)
|
||||
end
|
||||
-- Red is OAM in the original: he draws over the mon's box edge
|
||||
if self.player then
|
||||
love.graphics.draw(self.player, 82, 80)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- the copyright row (tile 2,17); copyrightText because field.title's
|
||||
-- copyright key already names the extracted image strip
|
||||
Font.draw(self.title.copyrightText or Strings("2026 bois club games"), 1, 136)
|
||||
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
|
||||
1, 136 + scrollY)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
-- Yellow's boot attract movie, a faithful port of PlayIntroScene
|
||||
-- (pokeyellow engine/movie/intro_yellow.asm) over the extracted atlases
|
||||
-- (gfx/intro/yellow_intro_1.2bpp -> intro/yellow_intro_1.png, atlas1,
|
||||
-- 16x8 tiles; yellow_intro_2.2bpp -> yellow_intro_2.png, atlas2, 16x16
|
||||
-- tiles; clouds.2bpp -> intro/clouds.png, two 4-tile frames).
|
||||
--
|
||||
-- Faithful pieces: the 18-scene jumptable with its 128/88-frame timers,
|
||||
-- the animated-object system (YellowIntro_AnimatedObjectSpawnStateData /
|
||||
-- Jumptable / FramesData / OAMData, data/sprite_anims/intro_frames.asm +
|
||||
-- intro_oam.asm), the scene-7 per-scanline SCY sine wave
|
||||
-- (YellowIntro_Copy8BitSineWave, +-4px period 32, rotated 1 line/frame),
|
||||
-- the scene-3 SCX ramp to $68, the scene-11 cloud tile flip every 8
|
||||
-- frames, and the scene-14/15/16 BGP strobe / fade sequences
|
||||
-- (YellowIntroPalSequence_f9dd6 / _f9e0a). BGP composes with the SGB
|
||||
-- colorization through sgbPalettes (PalPacket_Generic = MEWMON,
|
||||
-- PalPacket_PikachusBeach = PIKACHUS_BEACH), like the title screen.
|
||||
--
|
||||
-- Deliberately dropped: the CGB-only OBJ-palette pokes of scenes 7/11
|
||||
-- (Func_f98a2 / Func_f98cb recolor 5-6 tiles of the surf/fly sprite),
|
||||
-- OBP-vs-BGP divergence during the strobes (one whole-screen shade map
|
||||
-- stands in for both), and the never-spawned objects $0/$4 (dead code,
|
||||
-- intro_yellow.asm:173).
|
||||
--
|
||||
-- Any of A/B/START skips the whole movie (PlayIntroScene:16-19). Pops
|
||||
-- itself and calls onDone() when finished or skipped.
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local YellowIntro = {}
|
||||
YellowIntro.__index = YellowIntro
|
||||
YellowIntro.isOpaque = true
|
||||
|
||||
-- ------- data tables (data/sprite_anims/intro_oam.asm) ----------------
|
||||
|
||||
-- OAM lists: rows of { dy, dx, tileDelta, flip }
|
||||
local function grid(rows, cols, dy0, dx0, tileForRC)
|
||||
local list = {}
|
||||
for r = 0, rows - 1 do
|
||||
for c = 0, cols - 1 do
|
||||
list[#list + 1] = { dy0 + r * 8, dx0 + c * 8, tileForRC(r, c), false }
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local OAM = {}
|
||||
|
||||
-- Unkn_fa17e: 2x2, tiles +0/+1 over +$10/+$11
|
||||
OAM.fa17e = grid(2, 2, -8, -8, function(r, c) return r * 0x10 + c end)
|
||||
|
||||
-- Unkn_fa18f: 16x32; bottom two rows mirror their left half
|
||||
OAM.fa18f = {
|
||||
{ -16, -8, 0x00 }, { -16, 0, 0x01 },
|
||||
{ -8, -8, 0x10 }, { -8, 0, 0x11 },
|
||||
{ 0, -8, 0x20 }, { 0, 0, 0x20, true },
|
||||
{ 8, -8, 0x21 }, { 8, 0, 0x21, true },
|
||||
}
|
||||
|
||||
-- Unkn_fa1b0: 32x40 (16-wide head rows, 32-wide mirrored body rows)
|
||||
OAM.fa1b0 = {
|
||||
{ -24, -8, 0x00 }, { -24, 0, 0x01 },
|
||||
{ -16, -8, 0x02 }, { -16, 0, 0x03 },
|
||||
{ -8, -16, 0x04 }, { -8, -8, 0x05 }, { -8, 0, 0x06 }, { -8, 8, 0x04, true },
|
||||
{ 0, -16, 0x07 }, { 0, -8, 0x08 }, { 0, 0, 0x08, true }, { 0, 8, 0x07, true },
|
||||
{ 8, -16, 0x09 }, { 8, -8, 0x0a }, { 8, 0, 0x0a, true }, { 8, 8, 0x09, true },
|
||||
{ 16, -16, 0x0b }, { 16, -8, 0x0c }, { 16, 0, 0x0c, true }, { 16, 8, 0x0b, true },
|
||||
}
|
||||
|
||||
-- Unkn_fa201: 6x6 = 48x48, row r uses tiles +$r0..+$r5
|
||||
OAM.fa201 = grid(6, 6, -24, -24, function(r, c) return r * 0x10 + c end)
|
||||
|
||||
-- Unkn_fa292: 5x5 = 40x40, row bases $00,$05,$10,$15,$20
|
||||
local FA292_ROW = { 0x00, 0x05, 0x10, 0x15, 0x20 }
|
||||
OAM.fa292 = {}
|
||||
for r = 0, 4 do
|
||||
for c = 0, 4 do
|
||||
OAM.fa292[#OAM.fa292 + 1] =
|
||||
{ -20 + r * 8, -16 + c * 8, FA292_ROW[r + 1] + c, false }
|
||||
end
|
||||
end
|
||||
|
||||
-- Unkn_fa2f7: 32x8 mirrored streak
|
||||
OAM.fa2f7 = {
|
||||
{ -4, -16, 0x00 }, { -4, -8, 0x01 },
|
||||
{ -4, 0, 0x01, true }, { -4, 8, 0x00, true },
|
||||
}
|
||||
|
||||
-- Unkn_fa308: two mirrored 16x16 clusters, 32px apart
|
||||
OAM.fa308 = {
|
||||
{ -8, -24, 0x00 }, { -8, -16, 0x01 },
|
||||
{ 0, -24, 0x02 }, { 0, -16, 0x03 },
|
||||
{ -8, 8, 0x01, true }, { -8, 16, 0x00, true },
|
||||
{ 0, 8, 0x03, true }, { 0, 16, 0x02, true },
|
||||
}
|
||||
|
||||
-- Unkn_fa329: two mirrored 24x16 clusters
|
||||
OAM.fa329 = {
|
||||
{ -8, -40, 0x00 }, { -8, -32, 0x01 }, { -8, -24, 0x02 },
|
||||
{ 0, -40, 0x10 }, { 0, -32, 0x11 }, { 0, -24, 0x12 },
|
||||
{ -8, 16, 0x02, true }, { -8, 24, 0x01, true }, { -8, 32, 0x00, true },
|
||||
{ 0, 16, 0x12, true }, { 0, 24, 0x11, true }, { 0, 32, 0x10, true },
|
||||
}
|
||||
|
||||
-- frameId -> { atlas2 tile offset, OAM list }
|
||||
local FRAMES = {
|
||||
[0x01] = { 0x96, OAM.fa17e }, [0x02] = { 0x98, OAM.fa17e },
|
||||
[0x03] = { 0x9a, OAM.fa17e },
|
||||
[0x04] = { 0x0c, OAM.fa18f }, [0x05] = { 0x0e, OAM.fa18f },
|
||||
[0x06] = { 0x3c, OAM.fa18f },
|
||||
[0x07] = { 0x60, OAM.fa1b0 }, [0x08] = { 0x70, OAM.fa1b0 },
|
||||
[0x09] = { 0x80, OAM.fa1b0 },
|
||||
[0x0a] = { 0x90, OAM.fa201 }, [0x0b] = { 0x00, OAM.fa201 },
|
||||
[0x0c] = { 0x06, OAM.fa201 },
|
||||
[0x0d] = { 0xc6, OAM.fa292 },
|
||||
[0x0e] = { 0x6d, OAM.fa2f7 },
|
||||
[0x0f] = { 0xf0, OAM.fa308 }, [0x10] = { 0xf4, OAM.fa308 },
|
||||
[0x11] = { 0xf8, OAM.fa308 },
|
||||
[0x12] = { 0x9c, OAM.fa329 }, [0x13] = { 0xec, OAM.fa329 },
|
||||
}
|
||||
|
||||
-- frame scripts (intro_frames.asm): { {frameId, duration}, ..., loop=bool }
|
||||
local FRAMESETS = {
|
||||
[1] = { { 0x01, 4 }, { 0x02, 4 }, { 0x03, 4 }, loop = true },
|
||||
[2] = { { 0x04, 4 }, { 0x05, 4 }, { 0x06, 4 }, loop = true },
|
||||
[3] = { { 0x07, 4 }, { 0x08, 4 }, { 0x09, 4 }, loop = true },
|
||||
[5] = { { 0x0b, 32 } },
|
||||
[6] = { { 0x0c, 32 } },
|
||||
[7] = { { 0x0d, 32 } },
|
||||
[8] = { { 0x0e, 32 } },
|
||||
[9] = { { 0x0f, 31 }, { 0x11, 2 }, { 0x0f, 2 }, { 0x11, 2 },
|
||||
{ 0x0f, 31 }, { 0x11, 2 }, { 0x0f, 23 }, { 0x10, 32 } },
|
||||
[10] = { { 0x12, 4 }, { 0x13, 4 }, loop = true },
|
||||
}
|
||||
|
||||
-- object id -> { frameset, seq } (YellowIntro_AnimatedObjectSpawnStateData;
|
||||
-- seq indexes the movement jumptable)
|
||||
local SPAWN = {
|
||||
[1] = { 1, "static" }, [2] = { 2, "static" }, [3] = { 3, "static" },
|
||||
[5] = { 5, "surf" }, [6] = { 6, "fly" }, [7] = { 7, "static" },
|
||||
[8] = { 8, "bar" }, [9] = { 9, "static" }, [10] = { 10, "static" },
|
||||
}
|
||||
|
||||
-- speed-bar spawn rows (YellowIntroFlyingSpeedBarData; first byte is X --
|
||||
-- the source's "; y, x, speed" comment is wrong)
|
||||
local SPEED_BARS = {
|
||||
{ 0xD0, 0x20, 2 }, { 0xF0, 0x30, 4 }, { 0xD0, 0x40, 6 },
|
||||
{ 0xC0, 0x50, 8 }, { 0xE0, 0x60, 8 }, { 0xC0, 0x70, 6 },
|
||||
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
|
||||
}
|
||||
|
||||
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
|
||||
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
|
||||
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
|
||||
|
||||
-- scene-10 BG tilemaps (gfx/intro/unknown_f9b6e/f9be6/f9bf2.tilemap)
|
||||
local SKY_MAP = {
|
||||
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x60,0x61,0x62 },
|
||||
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00 },
|
||||
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00,0x00 },
|
||||
{ 0x60,0x61,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00,0x00,0x00 },
|
||||
{ 0x00,0x00,0x63,0x60,0x61,0x60,0x61,0x02,0x02,0x02,0x02,0x60,0x61,0x62,0x00,0x00,0x00,0x00,0x00,0x00 },
|
||||
{ 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x63,0x62,0x63,0x62,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 },
|
||||
}
|
||||
local BADGE_MAP = {
|
||||
{ 0x30, 0x31, 0x32, 0x33 }, { 0x40, 0x41, 0x42, 0x43 },
|
||||
{ 0x50, 0x51, 0x52, 0x53 },
|
||||
}
|
||||
local MARK_MAP = { { 0x12, 0x13 }, { 0x22, 0x23 } }
|
||||
|
||||
-- scene-14 strobe (YellowIntroPalSequence_f9dd6): 13 groups of
|
||||
-- $e4,$c0,$c0,$e4 with the 52nd byte replaced by the terminator
|
||||
local STROBE_SEQ = {}
|
||||
for i = 1, 51 do
|
||||
local m = (i - 1) % 4
|
||||
STROBE_SEQ[i] = (m == 1 or m == 2) and 0xC0 or 0xE4
|
||||
end
|
||||
-- scene-16 fade to white (YellowIntroPalSequence_f9e0a)
|
||||
local FADE_SEQ = { 0xE4, 0x90, 0x90, 0x40, 0x40, 0x00, 0x00 }
|
||||
|
||||
-- Func_fa079's sine bob (Unkn_fa0aa, sine_table 32). The ROM table's
|
||||
-- `dw sin(x)` truncates the 1.0 peak to $0000, which pops the sprite 8px
|
||||
-- for one frame at every crest (a=16 / a=48) on hardware; the true peak
|
||||
-- is restored here so the balloon glide loops smoothly.
|
||||
local function bobOffset(phase)
|
||||
local a = phase % 64
|
||||
local half = a % 32
|
||||
local v = math.floor(8 * math.sin(math.pi * half / 32))
|
||||
return a < 32 and v or -v
|
||||
end
|
||||
|
||||
local function tryImage(path)
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
-- ------- state --------------------------------------------------------
|
||||
|
||||
function YellowIntro.new(game, onDone)
|
||||
local self = setmetatable({}, YellowIntro)
|
||||
self.game = game
|
||||
self.onDone = onDone
|
||||
self.finished = false
|
||||
self.scene = 0
|
||||
self.timer = 0
|
||||
self.seqIndex = 0
|
||||
self.scx = 0
|
||||
self.bgp = 0xE4
|
||||
self.palName = "MEWMON" -- PalPacket_Generic
|
||||
self.objects = {}
|
||||
self.cloudFrame = 0
|
||||
|
||||
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
|
||||
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
|
||||
self.clouds = tryImage("assets/generated/intro/clouds.png")
|
||||
self.quads = {}
|
||||
|
||||
-- 32x32 BG tile grid (vBGMap0); signed addressing: id < $80 -> atlas1,
|
||||
-- id >= $80 -> atlas2 (LCDC $e3, bit4 = 0)
|
||||
self.bg = {}
|
||||
self:bgLetterbox()
|
||||
self.bgDirty = true
|
||||
self.wave = nil
|
||||
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
|
||||
self.bgCanvas = ok and canvas or nil
|
||||
|
||||
-- Yellow boots exactly like Red up to the attract movie: the copyright
|
||||
-- card and the GAME FREAK shooting-star splash play first. Reuse
|
||||
-- IntroMovie's phases 1-2 and take over where its Gengar fight (phase
|
||||
-- 3) would begin; a skip press during the pre-roll skips everything.
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
local pre = IntroMovie.new(game, nil)
|
||||
local baseStart = pre.startPhase
|
||||
pre.finish = function(m)
|
||||
if m.finished then return end
|
||||
m.finished = true
|
||||
self.pre = nil
|
||||
self:finish()
|
||||
end
|
||||
pre.startPhase = function(m, phase)
|
||||
if phase == 3 then
|
||||
m.finished = true
|
||||
self.pre = nil
|
||||
self:beginScenes()
|
||||
else
|
||||
baseStart(m, phase)
|
||||
end
|
||||
end
|
||||
self.pre = pre
|
||||
return self
|
||||
end
|
||||
|
||||
function YellowIntro:sgbPalettes(game)
|
||||
if self.pre then return self.pre:sgbPalettes(game) end
|
||||
local P = require("src.render.PaletteFX")
|
||||
local pal = P.pal(game.data, self.palName)
|
||||
if not pal then return nil end
|
||||
-- rBGP composed with the SGB colors: shade i displays palette color
|
||||
-- ((bgp >> 2i) & 3), whole screen (one map stands in for BGP and OBP)
|
||||
local bgp = self.bgp
|
||||
local map = {}
|
||||
for i = 0, 3 do
|
||||
map[i] = math.floor(bgp / 4 ^ i) % 4
|
||||
end
|
||||
return { P.whole(P.permute(pal, map)) }
|
||||
end
|
||||
|
||||
-- ------- BG helpers ---------------------------------------------------
|
||||
|
||||
function YellowIntro:bgFill(id)
|
||||
for y = 0, 31 do
|
||||
local row = self.bg[y] or {}
|
||||
self.bg[y] = row
|
||||
for x = 0, 31 do row[x] = id end
|
||||
end
|
||||
self.bgDirty = true
|
||||
end
|
||||
|
||||
-- Func_f9e5f: rows 0-3 / 14-17 tile $01, rows 4-13 tile $00
|
||||
function YellowIntro:bgLetterbox()
|
||||
self:bgFill(0x01)
|
||||
for y = 4, 13 do
|
||||
for x = 0, 31 do self.bg[y][x] = 0x00 end
|
||||
end
|
||||
for y = 18, 31 do
|
||||
for x = 0, 31 do self.bg[y][x] = 0x00 end
|
||||
end
|
||||
self.bgDirty = true
|
||||
end
|
||||
|
||||
function YellowIntro:bgBlit(col, row, map)
|
||||
for r, line in ipairs(map) do
|
||||
for c, id in ipairs(line) do
|
||||
self.bg[(row + r - 1) % 32][(col + c - 1) % 32] = id
|
||||
end
|
||||
end
|
||||
self.bgDirty = true
|
||||
end
|
||||
|
||||
function YellowIntro:quadFor(image, tile)
|
||||
local key = image
|
||||
local cacheByImage = self.quads[key]
|
||||
if not cacheByImage then
|
||||
cacheByImage = {}
|
||||
self.quads[key] = cacheByImage
|
||||
end
|
||||
local quad = cacheByImage[tile]
|
||||
if not quad then
|
||||
local iw, ih = image:getDimensions()
|
||||
quad = love.graphics.newQuad(
|
||||
(tile % 16) * 8, math.floor(tile / 16) * 8, 8, 8, iw, ih)
|
||||
cacheByImage[tile] = quad
|
||||
end
|
||||
return quad
|
||||
end
|
||||
|
||||
function YellowIntro:rebuildBgCanvas()
|
||||
if not self.bgCanvas then return end
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(self.bgCanvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for y = 0, 31 do
|
||||
for x = 0, 31 do
|
||||
local id = self.bg[y][x]
|
||||
local image, tile
|
||||
if id < 0x80 then
|
||||
image, tile = self.atlas1, id
|
||||
else
|
||||
image, tile = self.atlas2, id
|
||||
end
|
||||
if image then
|
||||
-- scene-11 cloud animation retargets BG tiles $60-$63 at the
|
||||
-- clouds sheet (VBlank copy to $9600); frame = clouds row 0/1
|
||||
if self.clouds and id >= 0x60 and id <= 0x63 then
|
||||
local cw, ch = self.clouds:getDimensions()
|
||||
love.graphics.draw(self.clouds,
|
||||
love.graphics.newQuad((id - 0x60) * 8, self.cloudFrame * 8,
|
||||
8, 8, cw, ch), x * 8, y * 8)
|
||||
else
|
||||
love.graphics.draw(image, self:quadFor(image, tile), x * 8, y * 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
love.graphics.pop()
|
||||
self.bgDirty = false
|
||||
end
|
||||
|
||||
-- ------- objects ------------------------------------------------------
|
||||
|
||||
function YellowIntro:spawn(id, x, y)
|
||||
local spec = SPAWN[id]
|
||||
local obj = {
|
||||
id = id, frameset = spec[1], seq = spec[2],
|
||||
x = x, y = y, xoff = 0, yoff = 0,
|
||||
step = 1, wait = 0, held = false,
|
||||
fieldB = 0, fieldC = 0,
|
||||
}
|
||||
local script = FRAMESETS[obj.frameset]
|
||||
obj.wait = script[1][2]
|
||||
self.objects[#self.objects + 1] = obj
|
||||
return obj
|
||||
end
|
||||
|
||||
function YellowIntro:clearObjects()
|
||||
self.objects = {}
|
||||
end
|
||||
|
||||
local function updateFrameScript(obj)
|
||||
if obj.held then return end
|
||||
obj.wait = obj.wait - 1
|
||||
if obj.wait > 0 then return end
|
||||
local script = FRAMESETS[obj.frameset]
|
||||
if obj.step >= #script then
|
||||
if script.loop then
|
||||
obj.step = 1
|
||||
obj.wait = script[1][2]
|
||||
else
|
||||
obj.held = true -- endanim: hold last frame forever
|
||||
end
|
||||
return
|
||||
end
|
||||
obj.step = obj.step + 1
|
||||
obj.wait = script[obj.step][2]
|
||||
end
|
||||
|
||||
function YellowIntro:updateObjects()
|
||||
for _, obj in ipairs(self.objects) do
|
||||
if obj.seq == "bar" then
|
||||
-- Func_fa062: constant velocity, 8-bit wrap
|
||||
obj.x = (obj.x + obj.fieldB) % 256
|
||||
elseif obj.seq == "surf" then
|
||||
-- Func_fa014, including the original's Y = X + 1 quirk: the
|
||||
-- comparison register still holds X when Y is written, so the
|
||||
-- sprite rides a 45-degree diagonal until X parks at $58
|
||||
if obj.x ~= 0x58 then
|
||||
obj.x = (obj.x + 4) % 256
|
||||
obj.y = (obj.x + 1) % 256
|
||||
end
|
||||
elseif obj.seq == "fly" then
|
||||
-- Func_fa02b: rise 2px/frame to Y=$58, then a +-8px sine bob
|
||||
-- with a 64-frame period (and the truncated-peak notch)
|
||||
if obj.fieldB == 0 then
|
||||
if obj.y ~= 0x58 then
|
||||
obj.y = (obj.y - 2) % 256
|
||||
else
|
||||
obj.fieldB = 1
|
||||
end
|
||||
end
|
||||
if obj.fieldB == 1 then
|
||||
obj.yoff = bobOffset(obj.fieldC)
|
||||
obj.fieldC = obj.fieldC + 1
|
||||
end
|
||||
end
|
||||
updateFrameScript(obj)
|
||||
end
|
||||
end
|
||||
|
||||
function YellowIntro:drawObjects()
|
||||
if not self.atlas2 then return end
|
||||
for _, obj in ipairs(self.objects) do
|
||||
local frameId = FRAMESETS[obj.frameset][obj.step][1]
|
||||
local frame = FRAMES[frameId]
|
||||
if frame then
|
||||
local base, list = frame[1], frame[2]
|
||||
for _, entry in ipairs(list) do
|
||||
local dy, dx, delta, flip = entry[1], entry[2], entry[3], entry[4]
|
||||
-- OAM position: screen = (X + dx - 8, Y + dy - 16)
|
||||
local px = (obj.x + obj.xoff + dx - 8) % 256
|
||||
local py = (obj.y + obj.yoff + dy - 16) % 256
|
||||
if px < 160 and py < 144 then
|
||||
local quad = self:quadFor(self.atlas2, base + delta)
|
||||
if flip then
|
||||
love.graphics.draw(self.atlas2, quad, px + 8, py, 0, -1, 1)
|
||||
else
|
||||
love.graphics.draw(self.atlas2, quad, px, py)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- scenes -------------------------------------------------------
|
||||
|
||||
-- setup scenes run once and advance immediately; wait scenes count their
|
||||
-- timer down (YellowIntro_CheckFrameTimerDecrement: N running frames,
|
||||
-- expiry actions on frame N+1)
|
||||
function YellowIntro:startScene(scene)
|
||||
self.scene = scene
|
||||
local t = self
|
||||
if scene == 0 then
|
||||
-- running pika 1 over the boot letterbox
|
||||
t.palName = "MEWMON"
|
||||
t.scx = 0
|
||||
t:bgLetterbox()
|
||||
t:spawn(1, 0x58, 0x58)
|
||||
t.timer = 130
|
||||
t.scene = 1
|
||||
elseif scene == 2 then
|
||||
-- pikachu kick: 6x6 atlas2 block parked at BG col 20 row 6 (scrolled
|
||||
-- in by scene 3) + 8 speed bars
|
||||
t:bgFill(0x00)
|
||||
local block = {}
|
||||
for r = 0, 5 do
|
||||
local line = {}
|
||||
for c = 0, 5 do line[c + 1] = 0x90 + r * 0x10 + c end
|
||||
block[r + 1] = line
|
||||
end
|
||||
t:bgBlit(20, 6, block)
|
||||
for _, bar in ipairs(SPEED_BARS) do
|
||||
local obj = t:spawn(8, bar[1], bar[2])
|
||||
obj.fieldB = bar[3]
|
||||
end
|
||||
t.palName = "PIKACHUS_BEACH"
|
||||
t.timer = 128
|
||||
t.scene = 3
|
||||
elseif scene == 4 then
|
||||
-- running pika 2
|
||||
t:clearObjects()
|
||||
t.scx = 0
|
||||
t:bgLetterbox()
|
||||
t:spawn(2, 0x58, 0x58)
|
||||
t.palName = "MEWMON"
|
||||
t.timer = 128
|
||||
t.scene = 5
|
||||
elseif scene == 6 then
|
||||
-- surfing pika over the wavy sea (per-scanline SCY sine)
|
||||
t.scx = 0
|
||||
t.wave = {}
|
||||
for i = 0, 255 do t.wave[i] = WAVE[i % 32 + 1] end
|
||||
t:bgFill(0x10)
|
||||
for y = 0, 2 do
|
||||
for x = 0, 31 do t.bg[y][x] = 0x00 end
|
||||
end
|
||||
for x = 0, 31 do t.bg[3][x] = x % 2 == 0 and 0x20 or 0x21 end
|
||||
t:spawn(5, 0xF8, 0x40)
|
||||
t.palName = "PIKACHUS_BEACH"
|
||||
t.bgDirty = true
|
||||
t.timer = 88
|
||||
t.scene = 7
|
||||
elseif scene == 8 then
|
||||
-- running pika 3
|
||||
t:clearObjects()
|
||||
t.wave = nil
|
||||
t.scx = 0
|
||||
t:bgLetterbox()
|
||||
t:spawn(3, 0x58, 0x58)
|
||||
t.palName = "MEWMON"
|
||||
t.timer = 128
|
||||
t.scene = 9
|
||||
elseif scene == 10 then
|
||||
-- flying pika over clouds + badge + mark
|
||||
t:clearObjects()
|
||||
t.scx = 0
|
||||
t:bgFill(0x00)
|
||||
for y = 0, 7 do
|
||||
for x = 0, 31 do t.bg[y][x] = 0x02 end
|
||||
end
|
||||
t:bgBlit(0, 8, SKY_MAP)
|
||||
t:bgBlit(12, 4, BADGE_MAP)
|
||||
t:bgBlit(3, 7, MARK_MAP)
|
||||
t:spawn(6, 0x58, 0x98)
|
||||
t.palName = "PIKACHUS_BEACH"
|
||||
t.timer = 128
|
||||
t.scene = 11
|
||||
elseif scene == 12 then
|
||||
-- pika close-up: 12x8 atlas1 paste at BG (5,6) + fixups
|
||||
t:clearObjects()
|
||||
t.scx = 0
|
||||
t:bgLetterbox()
|
||||
local paste = {}
|
||||
for r = 0, 7 do
|
||||
local line = {}
|
||||
for c = 0, 11 do line[c + 1] = 0x04 + r * 0x10 + c end
|
||||
paste[r + 1] = line
|
||||
end
|
||||
t:bgBlit(5, 6, paste)
|
||||
t.bg[6][4] = 0x03
|
||||
t.bg[7][4] = 0x74
|
||||
t.bg[13][5] = 0x00
|
||||
t:spawn(9, 0x58, 0x60)
|
||||
t.palName = "MEWMON"
|
||||
t.timer = 128
|
||||
t.scene = 13
|
||||
elseif scene == 14 then
|
||||
-- thunderbolt strobe; timer reused as the sequence index
|
||||
t.seqIndex = 0
|
||||
t.scene = 14
|
||||
elseif scene == 15 then
|
||||
t.timer = 40
|
||||
t.scene = 15
|
||||
elseif scene == 16 then
|
||||
t.seqIndex = 0
|
||||
t.scene = 16
|
||||
elseif scene == 17 then
|
||||
t.timer = 64
|
||||
t.scene = 17
|
||||
end
|
||||
end
|
||||
|
||||
function YellowIntro:enter()
|
||||
if self.pre then return end -- pre-roll first; beginScenes takes over
|
||||
self:beginScenes()
|
||||
end
|
||||
|
||||
-- InitYellowIntroGFXAndMusic: the movie's own music starts with scene 0
|
||||
function YellowIntro:beginScenes()
|
||||
local data = self.game.data
|
||||
local songs = data.audio and data.audio.songs
|
||||
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
|
||||
or songs.Music_IntroBattle and "Music_IntroBattle")
|
||||
if song then pcall(Music.play, data, song, false) end
|
||||
self:startScene(0)
|
||||
end
|
||||
|
||||
function YellowIntro:finish()
|
||||
if self.finished then return end
|
||||
self.finished = true
|
||||
pcall(Music.stop)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
function YellowIntro:update(dt)
|
||||
if self.finished then return end
|
||||
if self.pre then
|
||||
self.pre:update(dt)
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
self:finish()
|
||||
return
|
||||
end
|
||||
|
||||
local scene = self.scene
|
||||
if scene == 1 or scene == 5 or scene == 9 then
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(scene + 1)
|
||||
end
|
||||
elseif scene == 3 then
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(4)
|
||||
end
|
||||
elseif scene == 7 then
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
self.scx = (self.scx + 2) % 256
|
||||
-- rotate the sine phase 1 scanline per frame
|
||||
local first = self.wave[0]
|
||||
for i = 0, 254 do self.wave[i] = self.wave[i + 1] end
|
||||
self.wave[255] = first
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(8)
|
||||
end
|
||||
elseif scene == 11 then
|
||||
if self.timer > 0 then
|
||||
-- cloud tiles swap every 8 frames (YellowIntroScene11)
|
||||
if self.timer % 8 == 0 then
|
||||
local frame = math.floor(self.timer / 8) % 2
|
||||
if frame ~= self.cloudFrame then
|
||||
self.cloudFrame = frame
|
||||
self.bgDirty = true
|
||||
end
|
||||
end
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(12)
|
||||
end
|
||||
elseif scene == 13 then
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
-- spawn the thunderbolt over the close-up (object $A stays with $9)
|
||||
self:spawn(10, 0x58, 0x68)
|
||||
self:startScene(14)
|
||||
end
|
||||
elseif scene == 14 then
|
||||
self.seqIndex = self.seqIndex + 1
|
||||
local v = STROBE_SEQ[self.seqIndex]
|
||||
if v then
|
||||
self.bgp = v
|
||||
else
|
||||
-- .expired: everything despawns, letterbox returns, logo/face
|
||||
-- object $7 appears for the strobe scene
|
||||
self:clearObjects()
|
||||
self:bgLetterbox()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
end
|
||||
elseif scene == 15 then
|
||||
if self.timer > 0 then
|
||||
if self.timer % 4 == 0 then
|
||||
-- rBGP ^= $03: flips how the two lightest shades display
|
||||
self.bgp = self.bgp == 0xE4 and 0xE7 or 0xE4
|
||||
end
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self.bgp = 0xE4
|
||||
self:startScene(16)
|
||||
end
|
||||
elseif scene == 16 then
|
||||
self.seqIndex = self.seqIndex + 1
|
||||
local v = FADE_SEQ[self.seqIndex]
|
||||
if v then
|
||||
self.bgp = v
|
||||
else
|
||||
self:startScene(17)
|
||||
end
|
||||
elseif scene == 17 then
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:finish()
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
self:updateObjects()
|
||||
if self.bgDirty then self:rebuildBgCanvas() end
|
||||
end
|
||||
|
||||
function YellowIntro:draw()
|
||||
if self.pre then
|
||||
self.pre:draw()
|
||||
return
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
if self.bgCanvas then
|
||||
if self.wave then
|
||||
-- per-scanline SCY override, LY $10-$7F only (scene 7's VBlank
|
||||
-- copy covers just that band; the rest render unshifted). Each
|
||||
-- strip wraps horizontally like the BG map: SCX climbs past 96
|
||||
-- during the scene and a single quad would clamp at the canvas
|
||||
-- edge and smear the right of the screen.
|
||||
local cw, ch = self.bgCanvas:getDimensions()
|
||||
local sx = self.scx % 256
|
||||
local w1 = math.min(160, 256 - sx)
|
||||
for ly = 0, 143 do
|
||||
local dy = (ly >= 16 and ly < 128) and self.wave[ly] or 0
|
||||
local sy = (ly + dy) % 256
|
||||
love.graphics.draw(self.bgCanvas,
|
||||
love.graphics.newQuad(sx, sy, w1, 1, cw, ch), 0, ly)
|
||||
if w1 < 160 then
|
||||
love.graphics.draw(self.bgCanvas,
|
||||
love.graphics.newQuad(0, sy, 160 - w1, 1, cw, ch), w1, ly)
|
||||
end
|
||||
end
|
||||
else
|
||||
local cw, ch = self.bgCanvas:getDimensions()
|
||||
local sx = self.scx % 256
|
||||
love.graphics.draw(self.bgCanvas,
|
||||
love.graphics.newQuad(sx, 0, math.min(160, 256 - sx), 144, cw, ch),
|
||||
0, 0)
|
||||
if sx > 96 then
|
||||
-- horizontal wrap (scene 3 scrolls the kick block in from col 20)
|
||||
love.graphics.draw(self.bgCanvas,
|
||||
love.graphics.newQuad(0, 0, 160 - (256 - sx), 144, cw, ch),
|
||||
256 - sx, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
self:drawObjects()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return YellowIntro
|
||||
@@ -15,9 +15,11 @@ end
|
||||
|
||||
-- entities: array of anything with cellX/cellY (and optional targetX/targetY
|
||||
-- while mid-step, so nobody walks into a cell being entered).
|
||||
-- e.passable entities never block (Yellow's companion Pikachu: the player
|
||||
-- walks straight through and it re-trails, pikachu_follow.asm).
|
||||
function Collision.occupied(entities, cx, cy, ignore)
|
||||
for _, e in ipairs(entities) do
|
||||
if e ~= ignore then
|
||||
if e ~= ignore and not e.passable then
|
||||
if (e.cellX == cx and e.cellY == cy) or
|
||||
(e.targetX == cx and e.targetY == cy) then
|
||||
return e
|
||||
|
||||
@@ -343,6 +343,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.pendingSeamMusic = nil
|
||||
self.entities = { self.player }
|
||||
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
||||
-- Yellow's companion Pikachu trails the player (never in
|
||||
-- self.entities: it does not block movement, pikachu_follow.asm)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
@@ -870,6 +873,7 @@ function OverworldState:update(dt)
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
npc:update(self.map, self.entities)
|
||||
end
|
||||
require("src.world.PikachuFollower").update(Game, self)
|
||||
|
||||
for _, g in ipairs(self.ghosts) do
|
||||
g.npc:update(g.map, g.peers)
|
||||
@@ -1494,7 +1498,12 @@ function OverworldState:interact()
|
||||
end
|
||||
if npc then
|
||||
if not npc.moving then
|
||||
self:talkTo(npc)
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
else
|
||||
self:talkTo(npc)
|
||||
end
|
||||
end
|
||||
interacted(self, fx, fy, "npc", npc)
|
||||
return
|
||||
@@ -2431,7 +2440,7 @@ end
|
||||
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
|
||||
-- the completion line with seen AND owned counts, then the per-decade
|
||||
-- rating text.
|
||||
function OverworldState:dexRating()
|
||||
function OverworldState:dexRating(onDone)
|
||||
require("src.core.Sound").play(Game.data, "Pokedex_Rating")
|
||||
local seen, owned = 0, 0
|
||||
for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end
|
||||
@@ -2449,7 +2458,7 @@ function OverworldState:dexRating()
|
||||
completion = completion
|
||||
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating))
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone))
|
||||
end
|
||||
|
||||
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
|
||||
@@ -2874,6 +2883,9 @@ function OverworldState:applyFieldPoison()
|
||||
mon.hp = 0
|
||||
mon.status = nil -- the original clears status on the faint
|
||||
table.insert(fainted, mon)
|
||||
-- callfar_ModifyPikachuHappiness PIKAHAPPY_PSNFNT (poison.asm)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "PSNFNT", mon)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2942,6 +2954,8 @@ end
|
||||
function OverworldState:onStepComplete()
|
||||
local p = self.player
|
||||
self.todSteps = (self.todSteps or 0) + 1
|
||||
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
||||
require("src.world.PikachuFollower").onStep(Game.save)
|
||||
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
|
||||
-- paletteNameFor reads self.tod on the next paint
|
||||
if Runtime.wantsHook("world.tod") then
|
||||
@@ -4032,6 +4046,9 @@ function OverworldState:drawWorld()
|
||||
-- the "!" bubble above a trainer who spotted the player
|
||||
local function fxEmote()
|
||||
if not (self.emote and self.emote.npc) then return end
|
||||
-- bubble = false is a silent hold (a Pikachu emotion that plays a
|
||||
-- cry with no bubble still pauses the world for its beat)
|
||||
if self.emote.bubble == false then return end
|
||||
local npc = self.emote.npc
|
||||
local ex = npc.px - cam.x + 4
|
||||
local ey = npc.py - cam.y - 14
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
-- Yellow's overworld companion Pikachu (pokeyellow engine/pikachu/
|
||||
-- pikachu_follow.asm ShouldPikachuSpawn / SpawnPikachu_, plus the
|
||||
-- talk-to-it mood beat of engine/pikachu/pikachu_emotions.asm
|
||||
-- TalkToPikachu). The follower is an NPC-shaped entity that lives in
|
||||
-- ow.npcs (so the standard update/draw walk cycle runs) but never in
|
||||
-- ow.entities -- like the original it does not block the player: walk
|
||||
-- onto its cell and it simply trails to the cell you vacated.
|
||||
--
|
||||
-- Happiness rides in save.pikachuHappiness (wPikachuHappiness, seeded 90
|
||||
-- by init_player_data.asm) and mood in save.pikachuMood (wPikachuMood,
|
||||
-- neutral 128). modifyHappiness below is the full ModifyPikachuHappiness
|
||||
-- port (engine/events/pikachu_happiness.asm): the HappinessChangeTable
|
||||
-- delta picked by the current happiness hundred-band, then the
|
||||
-- PikachuMoods byte nudging the mood; onStep is poison.asm's
|
||||
-- UpdatePikachuHappinessAndMood (256-step coin-flip WALKING bump, mood
|
||||
-- converging by 1 per step toward 128).
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local PikachuFollower = {}
|
||||
|
||||
local INDEX = 99 -- synthetic object index, clear of any map's real objects
|
||||
|
||||
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
||||
|
||||
-- wPikachuHappiness boot value (engine/movie/oak_speech/
|
||||
-- init_player_data.asm: happiness = 90)
|
||||
local function happiness(save)
|
||||
if save.pikachuHappiness == nil then save.pikachuHappiness = 90 end
|
||||
return save.pikachuHappiness
|
||||
end
|
||||
|
||||
function PikachuFollower.bumpHappiness(save, delta)
|
||||
save.pikachuHappiness =
|
||||
math.max(0, math.min(255, happiness(save) + delta))
|
||||
end
|
||||
|
||||
-- HappinessChangeTable (engine/events/pikachu_happiness.asm): delta by
|
||||
-- happiness band (<100 / <200 / rest), plus the PikachuMoods target byte
|
||||
-- ($80 leaves the mood alone). Keys mirror the PIKAHAPPY_* constants.
|
||||
local HAPPINESS_CHANGES = {
|
||||
LEVELUP = { 5, 3, 2, mood = 0x8a },
|
||||
USEDITEM = { 5, 3, 2, mood = 0x83 },
|
||||
USEDXITEM = { 1, 1, 0, mood = 0x80 },
|
||||
GYMLEADER = { 3, 2, 1, mood = 0x80 },
|
||||
USEDTMHM = { 1, 1, 0, mood = 0x94 },
|
||||
WALKING = { 2, 1, 1, mood = 0x80 },
|
||||
DEPOSITED = { -3, -3, -5, mood = 0x62 },
|
||||
FAINTED = { -1, -1, -1, mood = 0x6c },
|
||||
PSNFNT = { -5, -5, -10, mood = 0x62 },
|
||||
CARELESSTRAINER = { -5, -5, -10, mood = 0x6c },
|
||||
TRADE = { -10, -10, -20, mood = 0x00 },
|
||||
}
|
||||
|
||||
-- the companion mon: a healthy (or any) party PIKACHU stands in for the
|
||||
-- original's OT-checked starter, same approximation as shouldSpawn
|
||||
function PikachuFollower.starterInParty(save, needHealthy)
|
||||
for _, mon in ipairs(save.party or {}) do
|
||||
if mon.species == "PIKACHU"
|
||||
and (not needHealthy or (mon.hp or 0) > 0) then
|
||||
return mon
|
||||
end
|
||||
end
|
||||
return nil
|
||||
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
|
||||
-- (IsStarterPikachuAliveInOurParty).
|
||||
function PikachuFollower.modifyHappiness(save, reason, mon)
|
||||
if not GameVersion.isYellow() then return end
|
||||
local row = HAPPINESS_CHANGES[reason]
|
||||
if not row then return end
|
||||
if reason == "GYMLEADER" or reason == "WALKING" then
|
||||
if not PikachuFollower.starterInParty(save, true) then return end
|
||||
elseif not (mon and mon.species == "PIKACHU") then
|
||||
return
|
||||
end
|
||||
local h = happiness(save)
|
||||
local band = h < 100 and 1 or h < 200 and 2 or 3
|
||||
save.pikachuHappiness = math.max(0, math.min(255, h + row[band]))
|
||||
-- PikachuMoods: bytes above $80 only ever raise the mood (and defer to
|
||||
-- a pending scripted emotion modifier), bytes below only lower it
|
||||
local b = row.mood
|
||||
if b ~= 0x80 then
|
||||
local mood = save.pikachuMood or 128
|
||||
if b > 0x80 then
|
||||
if mood < b and not save.pikachuEmotionModifier then
|
||||
save.pikachuMood = b
|
||||
end
|
||||
elseif mood > b then
|
||||
save.pikachuMood = b
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- UpdatePikachuHappinessAndMood (engine/events/poison.asm): every 256th
|
||||
-- step a coin flip on the WALKING bump; every step the mood converges by
|
||||
-- 1 toward the neutral 128.
|
||||
function PikachuFollower.onStep(save)
|
||||
if not GameVersion.isYellow() then return end
|
||||
save.pikachuWalkSteps = ((save.pikachuWalkSteps or 0) + 1) % 256
|
||||
local rand = love and love.math and love.math.random or math.random
|
||||
if save.pikachuWalkSteps == 0 and rand(0, 1) == 1 then
|
||||
PikachuFollower.modifyHappiness(save, "WALKING")
|
||||
end
|
||||
local mood = save.pikachuMood or 128
|
||||
if mood < 128 then
|
||||
save.pikachuMood = mood + 1
|
||||
elseif mood > 128 then
|
||||
save.pikachuMood = mood - 1
|
||||
end
|
||||
end
|
||||
|
||||
-- ShouldPikachuSpawn, approximated: Yellow, the lab gift happened, and a
|
||||
-- healthy Pikachu is in the party (the original checks the starter's OT
|
||||
-- identity; a traded second Pikachu standing in is accepted here).
|
||||
-- Surfing and biking hide the follower (BIT_PIKACHU_SPAWN flags).
|
||||
local function shouldSpawn(game, ow)
|
||||
if not GameVersion.isYellow() then return false end
|
||||
local save = game.save
|
||||
if not (save.flags and save.flags.EVENT_GOT_STARTER) then return false end
|
||||
if save.onBike or (ow.player and ow.player.surfing) then return false end
|
||||
if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then
|
||||
return false
|
||||
end
|
||||
for _, mon in ipairs(save.party or {}) do
|
||||
if mon.species == "PIKACHU" and (mon.hp or 0) > 0 then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function makeFollower(game, ow, x, y, facing)
|
||||
local NPC = require("src.world.NPC")
|
||||
local npc = NPC.new(game.data, ow.map.id, {
|
||||
index = INDEX, name = "PIKACHU_FOLLOWER", sprite = "SPRITE_PIKACHU",
|
||||
movement = "STAY", range = "NONE", x = x, y = y,
|
||||
})
|
||||
npc.pikachuFollower = true
|
||||
npc.passable = true -- never blocks a step (Collision.occupied)
|
||||
npc.facing = facing or "down"
|
||||
return npc
|
||||
end
|
||||
|
||||
local function findFollower(ow)
|
||||
for i, npc in ipairs(ow.npcs or {}) do
|
||||
if npc.pikachuFollower then return npc, i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function remove(ow)
|
||||
local npc, i = findFollower(ow)
|
||||
if not npc then return end
|
||||
table.remove(ow.npcs, i)
|
||||
for j, e in ipairs(ow.entities or {}) do
|
||||
if e == npc then table.remove(ow.entities, j) break end
|
||||
end
|
||||
end
|
||||
|
||||
-- spawn cell: directly behind the player's facing when that cell is
|
||||
-- walkable, else the player's own cell (it trails out on the next step)
|
||||
local function spawnCell(ow)
|
||||
local p = ow.player
|
||||
local dx = p.facing == "left" and 1 or p.facing == "right" and -1 or 0
|
||||
local dy = p.facing == "up" and 1 or p.facing == "down" and -1 or 0
|
||||
local bx, by = p.cellX + dx, p.cellY + dy
|
||||
if ow.map:inBounds(bx, by) and ow.map:isWalkableCell(bx, by) then
|
||||
return bx, by
|
||||
end
|
||||
return p.cellX, p.cellY
|
||||
end
|
||||
|
||||
function PikachuFollower.onMapEntered(game, ow)
|
||||
remove(ow)
|
||||
if not shouldSpawn(game, ow) then return end
|
||||
local x, y = spawnCell(ow)
|
||||
local npc = makeFollower(game, ow, x, y, ow.player.facing)
|
||||
table.insert(ow.npcs, npc)
|
||||
-- entities is the draw list; passable keeps it out of collision
|
||||
table.insert(ow.entities, npc)
|
||||
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
||||
end
|
||||
|
||||
-- one follow step per frame: chase the cell the player last vacated
|
||||
-- (pikachu_follow.asm keeps it one walk step behind)
|
||||
function PikachuFollower.update(game, ow)
|
||||
local npc = findFollower(ow)
|
||||
if not npc then
|
||||
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
|
||||
return
|
||||
end
|
||||
if not shouldSpawn(game, ow) then
|
||||
remove(ow)
|
||||
return
|
||||
end
|
||||
local p = ow.player
|
||||
local trail = ow.pikachuTrail
|
||||
if not trail then
|
||||
trail = { x = p.cellX, y = p.cellY }
|
||||
ow.pikachuTrail = trail
|
||||
end
|
||||
-- the player left the trailing cell: it becomes Pikachu's next goal
|
||||
if p.cellX ~= trail.x or p.cellY ~= trail.y then
|
||||
npc.goalX, npc.goalY = trail.x, trail.y
|
||||
trail.x, trail.y = p.cellX, p.cellY
|
||||
end
|
||||
if npc.moving or not npc.goalX then return end
|
||||
local gx, gy = npc.goalX, npc.goalY
|
||||
if npc.cellX == gx and npc.cellY == gy then
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
return
|
||||
end
|
||||
-- fell more than a screen behind (forced movement, warp math): snap
|
||||
local far = math.abs(npc.cellX - gx) + math.abs(npc.cellY - gy)
|
||||
if far > 6 then
|
||||
npc.cellX, npc.cellY = gx, gy
|
||||
npc.px, npc.py = gx * 16, gy * 16
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
return
|
||||
end
|
||||
local dir
|
||||
if npc.cellX < gx then dir = "right"
|
||||
elseif npc.cellX > gx then dir = "left"
|
||||
elseif npc.cellY < gy then dir = "down"
|
||||
else dir = "up" end
|
||||
npc.facing = dir
|
||||
npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
|
||||
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
|
||||
npc.moving = true
|
||||
npc.progress = 0
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- TalkToPikachu (engine/pikachu/pikachu_emotions.asm + data/pikachu/
|
||||
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
|
||||
-- and voiced PCM clip. The face-pic animation half of each emotion
|
||||
-- (pikaemotion_pikapic) has no port; the bubble + clip carry the beat.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
|
||||
-- clip (bubble names are the *_BUBBLE constants; nil cry = silent).
|
||||
-- turnAway is pikaemotion_9 (face away from the player, emotion 30).
|
||||
local EMOTIONS = {
|
||||
[1] = {},
|
||||
[2] = { bubble = "SMILE_BUBBLE", cry = 35 },
|
||||
[3] = { cry = 40 },
|
||||
[4] = { cry = 29 },
|
||||
[5] = { cry = 31 },
|
||||
[6] = { bubble = "SKULL_BUBBLE" },
|
||||
[7] = { cry = 1 },
|
||||
[8] = { cry = 39 },
|
||||
[9] = { bubble = "SKULL_BUBBLE", cry = 6 },
|
||||
[10] = { bubble = "HEART_BUBBLE", cry = 5 },
|
||||
[11] = { bubble = "ZZZ_BUBBLE", cry = 37 },
|
||||
[12] = {},
|
||||
[13] = {},
|
||||
[14] = { bubble = "BOLT_BUBBLE", cry = 10 },
|
||||
[15] = { cry = 34 },
|
||||
[16] = { cry = 33 },
|
||||
[17] = { cry = 13 },
|
||||
[18] = {},
|
||||
[19] = { bubble = "HEART_BUBBLE", cry = 33 },
|
||||
[20] = { bubble = "HEART_BUBBLE", cry = 5 },
|
||||
[21] = { bubble = "FISH_BUBBLE" },
|
||||
[22] = { cry = 4 },
|
||||
[23] = { cry = 19 },
|
||||
[24] = { bubble = "EXCLAMATION_BUBBLE" },
|
||||
[25] = { bubble = "BOLT_BUBBLE", cry = 35 },
|
||||
[26] = { bubble = "ZZZ_BUBBLE", cry = 37 },
|
||||
[27] = { cry = 9 },
|
||||
[28] = { cry = 15 },
|
||||
[29] = { cry = 5 },
|
||||
[30] = { bubble = "HEART_BUBBLE", cry = 5, turnAway = true },
|
||||
[31] = { cry = 19 },
|
||||
[32] = { cry = 26 },
|
||||
}
|
||||
|
||||
-- GetPikaPicAnimationScriptIndex (engine/pikachu/pikachu_pic_animation
|
||||
-- .asm): mood picks the column (PikachuMoodLookupTable), happiness the
|
||||
-- row (PikaPicAnimationScriptPointerLookupTable); the cell is the
|
||||
-- emotion index.
|
||||
local MOOD_THRESHOLDS = { 40, 127, 128, 210, 255 }
|
||||
local MOOD_MATRIX = {
|
||||
{ limit = 50, 14, 14, 6, 13, 13 },
|
||||
{ limit = 100, 9, 9, 5, 12, 12 },
|
||||
{ limit = 130, 3, 3, 1, 8, 8 },
|
||||
{ limit = 160, 3, 3, 4, 15, 15 },
|
||||
{ limit = 200, 17, 17, 7, 2, 2 },
|
||||
{ limit = 250, 17, 17, 16, 10, 10 },
|
||||
{ limit = 255, 17, 17, 19, 20, 20 },
|
||||
}
|
||||
|
||||
-- wPikachuEmotionModifier values 1-5 (MapSpecificPikachuExpression
|
||||
-- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction
|
||||
local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 }
|
||||
|
||||
local function moodEmotion(save)
|
||||
local mood = save.pikachuMood or 128
|
||||
local column = 5
|
||||
for i, threshold in ipairs(MOOD_THRESHOLDS) do
|
||||
if mood <= threshold then column = i break end
|
||||
end
|
||||
local h = happiness(save)
|
||||
local row = MOOD_MATRIX[#MOOD_MATRIX]
|
||||
for _, r in ipairs(MOOD_MATRIX) do
|
||||
if h <= r.limit then row = r break end
|
||||
end
|
||||
return row[column]
|
||||
end
|
||||
|
||||
-- MapSpecificPikachuExpression + TalkToPikachu's selection order
|
||||
local function selectEmotion(game, ow, save)
|
||||
local mapId = ow.map.id
|
||||
-- Fan Club / Pewter Center map beats (the Bill's-house event variant
|
||||
-- is owned by that map's script)
|
||||
if mapId == "POKEMON_FAN_CLUB" then return 30 end
|
||||
if mapId == "PEWTER_POKECENTER" then return 26 end
|
||||
local starter = PikachuFollower.starterInParty(save)
|
||||
if starter then
|
||||
if starter.status == "SLP" then return 11 end
|
||||
if starter.status then return 28 end
|
||||
end
|
||||
if mapId:find("POKEMON_TOWER_", 1, true) == 1 then return 22 end
|
||||
local modifier = save.pikachuEmotionModifier
|
||||
if modifier and MODIFIER_EMOTIONS[modifier] then
|
||||
save.pikachuEmotionModifier = nil
|
||||
return MODIFIER_EMOTIONS[modifier]
|
||||
end
|
||||
return moodEmotion(save)
|
||||
end
|
||||
|
||||
local function bubbleIndex(game, name)
|
||||
local sheet = game.data.field and game.data.field.emotionBubbles
|
||||
for i, b in ipairs(sheet and sheet.bubbles or {}) do
|
||||
if b.name == name then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function PikachuFollower.talk(game, ow, npc, done)
|
||||
npc:facePlayer(ow.player)
|
||||
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
|
||||
local save = game.save
|
||||
local emotion = selectEmotion(game, ow, save)
|
||||
local e = EMOTIONS[emotion] or EMOTIONS[1]
|
||||
if e.turnAway then
|
||||
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
|
||||
end
|
||||
local Sound = require("src.core.Sound")
|
||||
if e.cry then
|
||||
if not Sound.playPikaCry(game.data, e.cry) then
|
||||
Sound.playCry(game.data, "PIKACHU")
|
||||
end
|
||||
end
|
||||
-- caches built before the Yellow bubble sheet only carry the three
|
||||
-- shared bubbles; a missing crop degrades to a silent hold
|
||||
local bi = e.bubble and bubbleIndex(game, e.bubble)
|
||||
ow.emote = {
|
||||
npc = npc, frames = 50, bubble = bi or false,
|
||||
onDone = done,
|
||||
}
|
||||
end
|
||||
|
||||
-- npc the player is facing, when it is the follower (interact hook)
|
||||
function PikachuFollower.at(ow, cx, cy)
|
||||
local npc = findFollower(ow)
|
||||
if npc and not npc.moving and npc.cellX == cx and npc.cellY == cy then
|
||||
return npc
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return PikachuFollower
|
||||
Reference in New Issue
Block a user