mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
Merge remote-tracking branch 'upstream/main'
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
|
||||
|
||||
+56
-14
@@ -38,15 +38,16 @@ BattleState.letterboxWhite = true
|
||||
|
||||
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
||||
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
||||
-- differs; every battler, queue and animation below is shared. The wide
|
||||
-- layout is live only while this battle is the state being drawn on top --
|
||||
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
|
||||
-- goes back with it and the battle underneath is not drawn at all.
|
||||
function BattleState:wideLayout()
|
||||
-- differs; every battler, queue and animation below is shared. Menus and
|
||||
-- prompts pushed during a wide battle keep its wide canvas, while drawing
|
||||
-- their classic 160px UI centred within it (Game:draw).
|
||||
function BattleState:isWideBattleLayout()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
if not options or options.battleLayout ~= "wide" then return false end
|
||||
local stack = self.game.stack
|
||||
return (stack and stack.top and stack:top()) == self
|
||||
return options and options.battleLayout == "wide" or false
|
||||
end
|
||||
|
||||
function BattleState:wideLayout()
|
||||
return self:isWideBattleLayout()
|
||||
end
|
||||
|
||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||
@@ -652,8 +653,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 +
|
||||
@@ -1068,6 +1083,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"
|
||||
@@ -1108,6 +1127,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
|
||||
@@ -1733,7 +1756,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)
|
||||
@@ -3008,6 +3031,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
|
||||
@@ -3092,6 +3126,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")
|
||||
@@ -3912,7 +3949,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"
|
||||
@@ -4824,7 +4864,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)
|
||||
@@ -4833,10 +4875,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)
|
||||
|
||||
+81
-1
@@ -25,6 +25,29 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE
|
||||
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Per-channel mix (edit these)
|
||||
-- Applied on load and whenever this file hot-reloads.
|
||||
-- Runtime: ChipAudio.setChannelVolume / setChannelPitch.
|
||||
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
||||
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
||||
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
|
||||
-- ---------------------------------------------------------------------------
|
||||
local CHANNEL_VOLUME = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 0.25, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
local CHANNEL_PITCH = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 0.5, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
||||
ChipSynth.setChannelPitches(CHANNEL_PITCH)
|
||||
|
||||
-- currentMusic: { source, gen, threaded, started, finished, engine }
|
||||
-- threaded songs stream from the worker (engine is nil here);
|
||||
-- the fallback path owns a local engine and fills the source itself.
|
||||
@@ -152,13 +175,23 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
||||
musicGen = musicGen + 1
|
||||
local gen = musicGen
|
||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||
allowLoops = allowLoops, audio = slimAudio(data) })
|
||||
allowLoops = allowLoops, audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches() })
|
||||
currentMusic = { source = source, gen = gen, threaded = true,
|
||||
started = false, finished = false }
|
||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||
return source
|
||||
end
|
||||
|
||||
local function pushChannelMix()
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
volumes = ChipSynth.getChannelVolumes(),
|
||||
pitches = ChipSynth.getChannelPitches() })
|
||||
end
|
||||
end
|
||||
|
||||
-- move finished buffers from the worker into the Source; start playback once
|
||||
-- the first one lands
|
||||
local function updateThreaded()
|
||||
@@ -270,6 +303,53 @@ function ChipAudio.invalidate()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||
end
|
||||
|
||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||
function ChipAudio.setChannelVolume(hw, scale)
|
||||
ChipSynth.setChannelVolume(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolume(hw)
|
||||
return ChipSynth.getChannelVolume(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelVolumes(volumes)
|
||||
ChipSynth.setChannelVolumes(volumes)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolumes()
|
||||
return ChipSynth.getChannelVolumes()
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitch(hw, scale)
|
||||
ChipSynth.setChannelPitch(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitch(hw)
|
||||
return ChipSynth.getChannelPitch(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitches(pitches)
|
||||
ChipSynth.setChannelPitches(pitches)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitches()
|
||||
return ChipSynth.getChannelPitches()
|
||||
end
|
||||
|
||||
-- aliases for channel 4 (noise / drums)
|
||||
function ChipAudio.setNoiseVolume(scale)
|
||||
ChipAudio.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipAudio.getNoiseVolume()
|
||||
return ChipAudio.getChannelVolume(4)
|
||||
end
|
||||
|
||||
-- a stale song must not keep sounding past the flush that replaced its
|
||||
-- program (20 §2 cache contract, chip music row)
|
||||
Assets.register(ChipAudio.invalidate)
|
||||
|
||||
+124
-47
@@ -14,26 +14,97 @@ 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
|
||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||
|
||||
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||
-- buffer on both the sync path and the worker (via ChipAudio).
|
||||
local channelVolume = { 1, 1, 1, 1 }
|
||||
local channelPitch = { 1, 1, 1, 1 }
|
||||
|
||||
local function clampScale(scale)
|
||||
return math.max(0, tonumber(scale) or 0)
|
||||
end
|
||||
|
||||
local function setChannelTable(table, hw, scale)
|
||||
hw = tonumber(hw)
|
||||
if not hw or hw < 1 or hw > 4 then return end
|
||||
table[hw] = clampScale(scale)
|
||||
end
|
||||
|
||||
local function setChannelTables(table, values)
|
||||
if type(values) ~= "table" then return end
|
||||
for hw = 1, 4 do
|
||||
if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end
|
||||
end
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolume(hw, scale)
|
||||
setChannelTable(channelVolume, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolume(hw)
|
||||
return channelVolume[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolumes(volumes)
|
||||
setChannelTables(channelVolume, volumes)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolumes()
|
||||
return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] }
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitch(hw, scale)
|
||||
setChannelTable(channelPitch, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitch(hw)
|
||||
return channelPitch[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitches(pitches)
|
||||
setChannelTables(channelPitch, pitches)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitches()
|
||||
return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] }
|
||||
end
|
||||
|
||||
-- aliases for the noise/drum layer
|
||||
function ChipSynth.setNoiseVolume(scale)
|
||||
ChipSynth.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getNoiseVolume()
|
||||
return ChipSynth.getChannelVolume(4)
|
||||
end
|
||||
|
||||
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 +112,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 +214,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 +388,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 +400,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 +494,24 @@ 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 pitch = channelPitch[self.hardware] or 1
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch
|
||||
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 +549,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()
|
||||
@@ -498,11 +566,14 @@ function Channel:sample()
|
||||
event.sample = sampleIndex + 1
|
||||
if event.silence then return 0 end
|
||||
|
||||
if event.drum then return self:sampleDrum(event, sampleIndex) end
|
||||
local gain = channelVolume[self.hardware] or 1
|
||||
if event.drum then
|
||||
return self:sampleDrum(event, sampleIndex) * gain
|
||||
end
|
||||
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 * gain
|
||||
end
|
||||
|
||||
local register = event.register
|
||||
@@ -527,7 +598,8 @@ function Channel:sample()
|
||||
end
|
||||
end
|
||||
end
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047))
|
||||
local pitch = channelPitch[self.hardware] or 1
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch
|
||||
if event.wave then frequency = frequency * 0.5 end
|
||||
local phase = self.phase
|
||||
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
@@ -537,13 +609,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 * gain
|
||||
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 * gain
|
||||
end
|
||||
return volume / 15 * gain
|
||||
end
|
||||
|
||||
local Engine = {}
|
||||
@@ -597,8 +674,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 +683,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 +692,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 +702,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 +767,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 +778,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 +788,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
|
||||
|
||||
+72
-11
@@ -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
|
||||
@@ -241,35 +244,93 @@ end
|
||||
-- exactly as the owning state computed it
|
||||
local function sameZones(_, zones) return zones end
|
||||
|
||||
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||
-- but the canvas must not snap to 160px between those states.
|
||||
function Game.wideBattleInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
|
||||
return state
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||
-- sprite and status zones move with the classic UI content.
|
||||
local function centerClassicZones(zones, offset)
|
||||
if not zones or offset == 0 then return zones end
|
||||
local shifted = {}
|
||||
for i, zone in ipairs(zones) do
|
||||
local copy = {}
|
||||
for key, value in pairs(zone) do copy[key] = value end
|
||||
if copy.x == 0 and copy.w == Renderer.WIDTH then
|
||||
copy.w = copy.w + offset * 2
|
||||
else
|
||||
copy.x = (copy.x or 0) + offset
|
||||
end
|
||||
shifted[i] = copy
|
||||
end
|
||||
return shifted
|
||||
end
|
||||
|
||||
function Game:draw()
|
||||
-- the UI canvas clears transparent when the overworld's world pass
|
||||
-- shows through beneath it; opaque full-screen states get the classic
|
||||
-- white clear
|
||||
local base = self.stack:visibleBase()
|
||||
local worldBelow = self.stack.states[base] == self.overworld
|
||||
-- The UI surface is resolved once, before any state draws: the top state
|
||||
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
||||
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
||||
-- pushed over a wide battle brings the screen straight back to 160x144.
|
||||
-- A wide battle holds its 304px surface through every menu or prompt it
|
||||
-- opens. States that do not draw the wide battle composition are centred
|
||||
-- in that surface below, so their classic coordinates and hit testing stay
|
||||
-- unchanged. Outside a battle, including the title screen, the option is
|
||||
-- intentionally inactive because it is a battle-layout setting.
|
||||
local top = self.stack:top()
|
||||
if top and top.uiSize then
|
||||
local wideBattle = Game.wideBattleInStack(self.stack)
|
||||
local classicOffset = 0
|
||||
if wideBattle and wideBattle.uiSize then
|
||||
Renderer:setUISize(wideBattle:uiSize())
|
||||
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
|
||||
elseif top and top.uiSize then
|
||||
Renderer:setUISize(top:uiSize())
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
Renderer:beginFrame(worldBelow)
|
||||
self.stack:draw()
|
||||
for i = self.stack:visibleBase(), #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
if state and state.draw then
|
||||
if classicOffset ~= 0 and not wideState then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(classicOffset, 0)
|
||||
state:draw()
|
||||
love.graphics.pop()
|
||||
else
|
||||
state:draw()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- SGB colorization: the topmost state that knows its palette owns the
|
||||
-- screen (overlays like text boxes inherit from what's beneath them);
|
||||
-- the overworld's world pass colors each visible map area separately
|
||||
local zones, worldZones
|
||||
local zones, worldZones, zoneOwner
|
||||
for i = #self.stack.states, 1, -1 do
|
||||
local s = self.stack.states[i]
|
||||
if s.sgbPalettes then
|
||||
zones = s:sgbPalettes(self)
|
||||
zoneOwner = s
|
||||
break
|
||||
end
|
||||
end
|
||||
if classicOffset ~= 0 and zoneOwner
|
||||
and not (zoneOwner.isWideBattleLayout
|
||||
and zoneOwner:isWideBattleLayout()) then
|
||||
zones = centerClassicZones(zones, classicOffset)
|
||||
end
|
||||
-- 14's render.zones: weather/lighting overlays and custom colorization
|
||||
-- recolor or add zones before the blit
|
||||
if ModRuntime.wantsHook("render.zones") then
|
||||
|
||||
@@ -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
|
||||
+39
-24
@@ -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
|
||||
@@ -109,11 +110,14 @@ local function makePortableFs(dir)
|
||||
}
|
||||
end
|
||||
|
||||
local function detectPortable()
|
||||
if portableChecked then return portableBase end
|
||||
portableChecked = true
|
||||
portableBase = false
|
||||
if not (love and love.filesystem) then return false end
|
||||
-- Every folder a player might reasonably call "the game folder", best first:
|
||||
-- the packaged-app container, the folder holding the executable, then the
|
||||
-- source itself. Portable mode is the case where one of these holds the
|
||||
-- marker; the list itself is just locations, marker or not, which is also
|
||||
-- what the mods panel needs to notice a mod dropped beside the game by hand
|
||||
-- (LauncherMods.strays). Empty on Android/iOS and outside LOVE.
|
||||
function SaveData.gameFolders()
|
||||
if not (love and love.filesystem) then return {} end
|
||||
-- Desktop only: portable mode carries the save (and, since issue #74, the
|
||||
-- ROM cache) in the game folder next to the executable/source. On
|
||||
-- Android/iOS the source is a read-only package with no such folder, so
|
||||
@@ -121,7 +125,7 @@ local function detectPortable()
|
||||
if love.system and love.system.getOS then
|
||||
local osName = love.system.getOS()
|
||||
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
|
||||
return false
|
||||
return {}
|
||||
end
|
||||
end
|
||||
local src = love.filesystem.getSource and love.filesystem.getSource()
|
||||
@@ -149,15 +153,24 @@ local function detectPortable()
|
||||
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
|
||||
-- then the source-base directory (next to a packaged .exe), then the source
|
||||
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
|
||||
-- First one holding the marker wins. Built by appending so a nil (e.g. no
|
||||
-- .app in the path) never truncates the ipairs scan.
|
||||
-- Built by appending so a nil (e.g. no .app in the path) never truncates
|
||||
-- the ipairs scan.
|
||||
local candidates = {}
|
||||
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
|
||||
if appDir then candidates[#candidates + 1] = appDir end
|
||||
if sbd then candidates[#candidates + 1] = sbd end
|
||||
if src then candidates[#candidates + 1] = src end
|
||||
for _, base in ipairs(candidates) do
|
||||
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||
if sbd and sbd ~= "" then candidates[#candidates + 1] = sbd end
|
||||
if src and src ~= "" then candidates[#candidates + 1] = src end
|
||||
return candidates
|
||||
end
|
||||
|
||||
-- The game folder carrying portable.txt, or false. First candidate holding
|
||||
-- the marker wins.
|
||||
local function detectPortable()
|
||||
if portableChecked then return portableBase end
|
||||
portableChecked = true
|
||||
portableBase = false
|
||||
for _, base in ipairs(SaveData.gameFolders()) do
|
||||
if pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||
portableBase = base
|
||||
break
|
||||
end
|
||||
@@ -323,8 +336,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 +350,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 +907,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
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
--
|
||||
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
||||
-- channel and drains produced buffers off "chipaudio_out":
|
||||
-- cmd = "play" { gen, header, allowLoops, audio } start a song
|
||||
-- cmd = "play" { gen, header, allowLoops, audio,
|
||||
-- channelVolumes?, channelPitches? }
|
||||
-- cmd = "stop" halt production
|
||||
-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch
|
||||
-- cmd = "invalidate" drop the bank cache
|
||||
-- cmd = "quit" end the thread
|
||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||
@@ -45,6 +47,12 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
outCh:clear() -- drop any buffers left from the previous song
|
||||
data = { audio = cmd.audio }
|
||||
if cmd.channelVolumes ~= nil then
|
||||
ChipSynth.setChannelVolumes(cmd.channelVolumes)
|
||||
end
|
||||
if cmd.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||
end
|
||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||
{ allowLoops = cmd.allowLoops })
|
||||
if ok then
|
||||
@@ -58,6 +66,9 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
finished = false
|
||||
outCh:clear()
|
||||
elseif cmd.cmd == "channelMix" then
|
||||
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
elseif cmd.cmd == "quit" then
|
||||
|
||||
+47
-20
@@ -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)
|
||||
@@ -126,9 +126,9 @@ local function resolveMount()
|
||||
if okl and lib then
|
||||
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
|
||||
if oks and fn then
|
||||
physfsMountFn = function(d, append)
|
||||
physfsMountFn = function(d, mountPoint, append)
|
||||
if append == nil then append = true end
|
||||
local okr, ret = pcall(fn, d, "", append and 1 or 0)
|
||||
local okr, ret = pcall(fn, d, mountPoint or "", append and 1 or 0)
|
||||
return okr and ret ~= 0
|
||||
end
|
||||
break
|
||||
@@ -145,7 +145,7 @@ end
|
||||
local function mountReadable(dir, append)
|
||||
local fn = resolveMount()
|
||||
if not fn then return false end
|
||||
return fn(dir, append)
|
||||
return fn(dir, "", append)
|
||||
end
|
||||
|
||||
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
|
||||
@@ -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.
|
||||
@@ -385,4 +385,31 @@ function CacheFs.unmountVersion(version)
|
||||
return done
|
||||
end
|
||||
|
||||
-- Mount `dir` at `mountPoint` for the length of `fn()`, then take it back off
|
||||
-- the read path and hand back whatever fn returned.
|
||||
--
|
||||
-- Every other mount here is permanent and lands at the physfs root: this one
|
||||
-- exists to *look* at a folder the game has deliberately not mounted, which
|
||||
-- is a different job. The mods panel uses it to read a mods/ folder sitting
|
||||
-- beside the executable of a non-portable install (LauncherMods.strays).
|
||||
-- Because it unmounts again, and because a non-empty mountPoint keeps the
|
||||
-- tree in its own corner of the namespace while it is up, a folder inspected
|
||||
-- this way can never shadow a game file or change what the running game
|
||||
-- resolves -- which is what makes it safe to point at a folder whose contents
|
||||
-- nobody has validated.
|
||||
--
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
|
||||
-- mount was refused), which callers must treat as "could not look", not as
|
||||
-- "nothing there". An error inside fn still unmounts before it propagates.
|
||||
function CacheFs.withMounted(dir, mountPoint, fn)
|
||||
if not dir or dir == "" then return nil end
|
||||
local mount, unmount = resolveMount(), resolveUnmount()
|
||||
if not (mount and unmount) then return nil end
|
||||
if not mount(dir, mountPoint, true) then return nil end
|
||||
local ok, res = pcall(fn)
|
||||
unmount(dir)
|
||||
if not ok then error(res, 0) end
|
||||
return res
|
||||
end
|
||||
|
||||
return CacheFs
|
||||
|
||||
+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()
|
||||
|
||||
+373
-130
@@ -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
|
||||
@@ -489,6 +490,14 @@ function RomImporter.new(onComplete, opts)
|
||||
forceImport = opts.forceImport or false,
|
||||
onEditSave = opts.onEditSave,
|
||||
android = android,
|
||||
-- Android drag: the launcher is handed no move events at all (main.lua
|
||||
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
|
||||
-- emulation is what "no reliable pointer polling" below refers to.
|
||||
-- love.touch IS pollable, so where it exists a touch drag can be resolved
|
||||
-- inside draw the same way the desktop mouse is. Where it does not, every
|
||||
-- Android path stays exactly as it was: act on press, never arm.
|
||||
touchPollable = android and love.touch ~= nil
|
||||
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
|
||||
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
||||
@@ -514,6 +523,10 @@ function RomImporter.new(onComplete, opts)
|
||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- Page scroll offset (px) for the column under the tab bar -- panel, updater
|
||||
-- banner and footer -- used only while that column is taller than the window
|
||||
-- (see draw()). Clamped against content in draw, reset on a tab change.
|
||||
pageScroll = 0,
|
||||
-- Android SAF: which game tab should receive the next picked_save.sav when
|
||||
-- focus consumes it (set by chooseSaveImport before opening the picker).
|
||||
androidPendingVersion = nil,
|
||||
@@ -542,13 +555,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 +626,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 +647,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 +682,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 +699,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 +754,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 +868,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 +884,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 +993,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 +1017,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 +1032,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
|
||||
|
||||
@@ -1101,18 +1123,22 @@ function RomImporter:_updatePadCursor(dt)
|
||||
self._padCursor.y = math.max(0, math.min(h, ny))
|
||||
end
|
||||
|
||||
-- Right stick scrolls the active list (save slots or mods).
|
||||
-- Right stick scrolls the active list (save slots or mods), or the whole page
|
||||
-- when it is the thing that overflows.
|
||||
local ry = self._padAxis.righty or 0
|
||||
if math.abs(ry) > PAD_DEAD then
|
||||
self:_activatePadCursor()
|
||||
local step = -ry * 480 * dt
|
||||
if self.tab == "mods" then
|
||||
local maxPage = self._pageMax or 0
|
||||
if maxPage > 0 then
|
||||
self.pageScroll = math.max(0, math.min(maxPage, (self.pageScroll or 0) + step))
|
||||
elseif self.tab == "mods" then
|
||||
local maxS = self._modMax or 0
|
||||
if maxS > 0 then
|
||||
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 +1164,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
|
||||
@@ -1371,6 +1397,24 @@ local function roundedCard(x, y, w, h, r)
|
||||
love.graphics.rectangle("line", x, y, w, h, r, r)
|
||||
end
|
||||
|
||||
-- {top, bottom} of the scrolling page viewport, or nil while the page fits and
|
||||
-- nothing scrolls. Written once per frame by draw(); read by the two hit tests
|
||||
-- (`inside` for clicks, `_ptIn` for hover) so a control scrolled out from under
|
||||
-- the pinned header, or past the window bottom, stops responding at the moment
|
||||
-- it stops being visible. Rects that live in the pinned header carry
|
||||
-- `pinned = true` and are exempt.
|
||||
local pageBand = nil
|
||||
|
||||
-- Page-scroll arithmetic, kept pure (no love, no self) so the engine tier can
|
||||
-- pin it: given how tall the column under the tab bar wants to be and how much
|
||||
-- room is left under it, say whether the page scrolls, where it sits, and how
|
||||
-- far it can go. A window that grew back pulls the offset down with it rather
|
||||
-- than leaving the page parked past its own end.
|
||||
function RomImporter.pageScrollFor(naturalH, viewportH, scroll)
|
||||
local maxPage = math.max(0, (naturalH or 0) - math.max(0, viewportH or 0))
|
||||
return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage
|
||||
end
|
||||
|
||||
function RomImporter:draw()
|
||||
local width, height = love.graphics.getDimensions()
|
||||
local s = clamp(height / 768, 0.7, 1.6)
|
||||
@@ -1528,17 +1572,16 @@ function RomImporter:draw()
|
||||
end
|
||||
|
||||
-- Footer (Boi's Club Games logo + trust warning), measured first so the
|
||||
-- content region knows where it must stop. Drawn near the end.
|
||||
-- content region knows where it must stop. Only its height is fixed here:
|
||||
-- it is laid out from a top edge further down, which is the window bottom
|
||||
-- while the page fits and the end of the scrolled content when it does not.
|
||||
local warningWidth = math.min(appW - 32 * s, 640 * s)
|
||||
local _, warningLines = self.warningFont:getWrap(TRUST_WARNING, warningWidth)
|
||||
local warningH = #warningLines * self.warningFont:getHeight()
|
||||
local warningY = height - warningH - 12 * s
|
||||
local bcgW, bcgH = self.bcg:getDimensions()
|
||||
local bcgScale = math.min(math.min(appW - 48 * s, 190 * s) / bcgW, height * 0.06 / bcgH)
|
||||
local bcgDW, bcgDH = bcgW * bcgScale, bcgH * bcgScale
|
||||
local bcgX, bcgY = appX + (appW - bcgDW) / 2, warningY - bcgDH - 6 * s
|
||||
self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH }
|
||||
local footerTop = bcgY - 10 * s
|
||||
local footerH = 10 * s + bcgDH + 6 * s + warningH + 12 * s
|
||||
|
||||
-- Logo: centred over the strip, width clamped, gentle bob + glow pulse. The
|
||||
-- resting metrics fix the tab bar's top so the layout never shifts as it bobs.
|
||||
@@ -1574,36 +1617,53 @@ function RomImporter:draw()
|
||||
-- Content region: from below the tab bar down to the footer, minus the
|
||||
-- updater band when one is showing.
|
||||
local contentTop = tabBarY + tabBarH + 16 * s
|
||||
local contentBottom = footerTop - (bannerActive and (bannerH + 20 * s) or 6 * s)
|
||||
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
|
||||
local cX = appX + padH
|
||||
local cW = appW - 2 * padH
|
||||
local contentBottom = height - footerH - bannerBand
|
||||
local cH = math.max(0, contentBottom - contentTop)
|
||||
|
||||
-- tab bar (rebuilds self.tabRects)
|
||||
-- Page scroll. Everything under the tab bar -- panel, updater banner and
|
||||
-- footer -- is one column: too short a window scrolls it instead of letting
|
||||
-- the panel run under a footer pinned to the window bottom (a stacked
|
||||
-- single-column layout on a phone-shaped window overflows by a card or two).
|
||||
-- The panels report their natural height as they draw, so the decision reads
|
||||
-- the previous frame's measurement, the same one-frame settle the slot and
|
||||
-- mod lists already rely on. While the page fits, `paged` is false and every
|
||||
-- measurement below is what it always was.
|
||||
local viewportH = math.max(0, height - contentTop)
|
||||
self._panelNaturalH = self._panelNaturalH or {}
|
||||
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
|
||||
local paged, pageScroll, maxPage =
|
||||
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
|
||||
self.pageScroll, self._pageMax = pageScroll, maxPage
|
||||
-- read by the hit tests; a scrolled control is live only inside the viewport
|
||||
pageBand = paged and { contentTop, height } or nil
|
||||
|
||||
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
|
||||
-- and it sits above the scrolling viewport.
|
||||
self:_drawTabBar(cX, tabBarY, cW, tabBarH, chip)
|
||||
|
||||
-- content: game panel for a version tab, mods panel for the mods tab
|
||||
if self.tab == "mods" then
|
||||
self:_drawModsPanel(cX, contentTop, cW, cH)
|
||||
else
|
||||
self:_drawGamePanel(self.tab, cX, contentTop, cW, cH)
|
||||
local panelY = contentTop - (paged and self.pageScroll or 0)
|
||||
if paged then
|
||||
love.graphics.setScissor(math.floor(appX), math.floor(contentTop),
|
||||
math.ceil(appW), math.ceil(viewportH))
|
||||
end
|
||||
|
||||
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
|
||||
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
|
||||
local lx, ly = (width - logoDW) / 2, logoY + bob
|
||||
love.graphics.setBlendMode("add")
|
||||
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
|
||||
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
logoScale * 1.05, logoScale * 1.05)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
|
||||
self.shineShader:send("shineW", shineW)
|
||||
love.graphics.setShader(self.shineShader)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale)
|
||||
love.graphics.setShader()
|
||||
-- content: game panel for a version tab, mods panel for the mods tab
|
||||
local panelH
|
||||
if self.tab == "mods" then
|
||||
panelH = self:_drawModsPanel(cX, panelY, cW, cH, paged)
|
||||
else
|
||||
panelH = self:_drawGamePanel(self.tab, cX, panelY, cW, cH, paged)
|
||||
end
|
||||
panelH = panelH or 0
|
||||
self._panelNaturalH[self.tab] = panelH
|
||||
|
||||
-- The updater band and the footer follow the content: pinned to the window
|
||||
-- bottom while the page fits, riding at the end of the scroll when it does not.
|
||||
local bandTop = paged and (panelY + panelH) or contentBottom
|
||||
local footerTop = bandTop + bannerBand
|
||||
|
||||
-- Self-updater banner: a compact pill centred in the reserved band just above
|
||||
-- the footer, on every tab. Same green "Play" treatment on its CTA.
|
||||
@@ -1611,7 +1671,7 @@ function RomImporter:draw()
|
||||
if bannerActive then
|
||||
local bannerW = math.min(appW - 32 * s, 560 * s)
|
||||
local bx = appX + (appW - bannerW) / 2
|
||||
local by = contentBottom + math.max(0, (footerTop - contentBottom - bannerH) / 2)
|
||||
local by = bandTop + math.max(0, (footerTop - bandTop - bannerH) / 2)
|
||||
local r = 12 * s
|
||||
local accent = PAL.gold
|
||||
|
||||
@@ -1692,11 +1752,17 @@ function RomImporter:draw()
|
||||
end
|
||||
|
||||
-- footer: a hairline top border, the BCG mark (inverted to white, glowing
|
||||
-- brighter on hover) + the trust warning with its live bois.icu link.
|
||||
-- brighter on hover) + the trust warning with its live bois.icu link. Laid
|
||||
-- out downward from footerTop, so the same code serves the pinned and the
|
||||
-- scrolled position.
|
||||
love.graphics.setLineWidth(1)
|
||||
col(PAL.cardBorder, 0.18)
|
||||
love.graphics.line(appX + padH, footerTop, appX + appW - padH, footerTop)
|
||||
|
||||
local bcgX, bcgY = appX + (appW - bcgDW) / 2, footerTop + 10 * s
|
||||
local warningY = bcgY + bcgDH + 6 * s
|
||||
self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH }
|
||||
|
||||
local bcgHot = self:_hover(self.bcgButton)
|
||||
love.graphics.setShader(self.invertShader)
|
||||
love.graphics.setBlendMode("add")
|
||||
@@ -1735,6 +1801,35 @@ function RomImporter:draw()
|
||||
end
|
||||
end
|
||||
|
||||
-- End of the scrolling column; the logo and the page scrollbar are pinned and
|
||||
-- draw outside it.
|
||||
if paged then love.graphics.setScissor() end
|
||||
|
||||
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
|
||||
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
|
||||
local lx, ly = (width - logoDW) / 2, logoY + bob
|
||||
love.graphics.setBlendMode("add")
|
||||
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
|
||||
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
logoScale * 1.05, logoScale * 1.05)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
|
||||
self.shineShader:send("shineW", shineW)
|
||||
love.graphics.setShader(self.shineShader)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale)
|
||||
love.graphics.setShader()
|
||||
|
||||
-- page scrollbar: the same thin thumb the lists use, against the app edge
|
||||
if paged then
|
||||
local thumbH = math.max(24 * s, viewportH * (viewportH / naturalH))
|
||||
local thumbY = contentTop + (viewportH - thumbH) * (self.pageScroll / maxPage)
|
||||
col(PAL.cardBorder, 0.35)
|
||||
love.graphics.rectangle("fill", appX + appW - padH * 0.5, thumbY, 3 * s, thumbH,
|
||||
1.5 * s, 1.5 * s)
|
||||
end
|
||||
|
||||
-- CRT scanlines + vignette, over everything
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0)
|
||||
@@ -1830,11 +1925,22 @@ function RomImporter:draw()
|
||||
end
|
||||
|
||||
local function inside(r, x, y)
|
||||
return r and x >= r.x and x <= r.x + r.width and y >= r.y and y <= r.y + r.height
|
||||
if not (r and x >= r.x and x <= r.x + r.width and y >= r.y and y <= r.y + r.height) then
|
||||
return false
|
||||
end
|
||||
-- Page-scroll mode: only the header is pinned, so any other rect is a
|
||||
-- scrolled one and is live only where the viewport actually shows it.
|
||||
if pageBand and not r.pinned and (y < pageBand[1] or y > pageBand[2]) then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:mousepressed(x, y, button)
|
||||
if self._rename then return end -- the rename modal swallows all clicks
|
||||
-- Whether a press can be ARMED and resolved on release, which needs a
|
||||
-- pollable pointer: always on desktop, on Android only where love.touch is.
|
||||
local armDrag = (not self.android) or self.touchPollable
|
||||
-- right-click a save-slot row to rename it (#205); desktop only (touch
|
||||
-- has no secondary button)
|
||||
if button == 2 then
|
||||
@@ -1873,6 +1979,10 @@ function RomImporter:mousepressed(x, y, button)
|
||||
self.tab = t.id
|
||||
self._slotPress = nil -- drop any half-started slot drag on tab change
|
||||
self._modPress = nil -- and any half-started mod toggle press
|
||||
self._pagePress = nil -- and any half-started page pan
|
||||
-- Each tab is its own column of a different length; carrying one tab's
|
||||
-- offset into another lands somewhere arbitrary.
|
||||
self.pageScroll = 0
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -1901,11 +2011,12 @@ function RomImporter:mousepressed(x, y, button)
|
||||
return
|
||||
end
|
||||
-- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap
|
||||
-- on either never also selects the row. On desktop a press only ARMS a row
|
||||
-- click: _updateSlotDrag commits it on release when the pointer did not move
|
||||
-- (a moved pointer scrolls instead). Android has no reliable pointer
|
||||
-- polling, so it selects on press. Edit and Delete fire immediately (small
|
||||
-- fixed targets, no scroll conflict).
|
||||
-- on either never also selects the row. A press only ARMS a row click:
|
||||
-- _updateSlotDrag commits it on release when the pointer did not move (a
|
||||
-- moved pointer scrolls instead). Android arms too wherever love.touch can
|
||||
-- be polled; without that there is nothing to resolve a release with, so it
|
||||
-- keeps selecting on press. Edit and Delete fire immediately (small fixed
|
||||
-- targets, no scroll conflict).
|
||||
for _, r in ipairs(self.slotDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteSlot(self.panelVersion, r.id)
|
||||
@@ -1920,11 +2031,12 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
for _, r in ipairs(self.slotRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
if self.android then
|
||||
if not armDrag then
|
||||
self:_selectSlot(self.panelVersion, r.id)
|
||||
else
|
||||
self._slotPress = { version = self.panelVersion, id = r.id, y0 = y,
|
||||
scroll0 = self.slotScroll[self.panelVersion] or 0, moved = false }
|
||||
scroll0 = self.slotScroll[self.panelVersion] or 0,
|
||||
pageScroll0 = self.pageScroll or 0, moved = false }
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1947,15 +2059,20 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
for _, r in ipairs(self.modRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
if self.android then
|
||||
if not armDrag then
|
||||
self:_toggleMod(r.id)
|
||||
else
|
||||
self._modPress = { id = r.id, y0 = y,
|
||||
scroll0 = self.modScroll or 0, moved = false }
|
||||
self._modPress = { id = r.id, y0 = y, scroll0 = self.modScroll or 0,
|
||||
pageScroll0 = self.pageScroll or 0, moved = false }
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
-- Nothing was hit. On a scrolling page that is a press on empty background,
|
||||
-- which is the natural place to grab and pan from.
|
||||
if armDrag and (self._pageMax or 0) > 0 then
|
||||
self._pagePress = { y0 = y, scroll0 = self.pageScroll or 0 }
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
@@ -1972,9 +2089,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
|
||||
@@ -1987,7 +2104,14 @@ end
|
||||
|
||||
function RomImporter:_ptIn(r)
|
||||
local mx, my = self._mx, self._my
|
||||
return r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height
|
||||
if not (r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height) then
|
||||
return false
|
||||
end
|
||||
-- Same clip the click path applies, so nothing glows outside the viewport.
|
||||
if pageBand and not r.pinned and (my < pageBand[1] or my > pageBand[2]) then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_hover(r)
|
||||
@@ -2116,8 +2240,9 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
||||
col(PAL.bgBot, 0.62)
|
||||
love.graphics.rectangle("fill", cursorX, chipY, chip, chip, r, r)
|
||||
end
|
||||
-- pinned: the tab bar never scrolls, so it stays live above the viewport
|
||||
self.tabRects[#self.tabRects + 1] =
|
||||
{ x = cursorX, y = chipY, width = chip, height = chip, id = t.id }
|
||||
{ x = cursorX, y = chipY, width = chip, height = chip, id = t.id, pinned = true }
|
||||
local segEnd = cursorX + chip
|
||||
if active then
|
||||
love.graphics.setFont(self.tabLabelFont)
|
||||
@@ -2132,7 +2257,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)
|
||||
@@ -2149,12 +2274,20 @@ end
|
||||
|
||||
-- One version's game panel: header (name + status pill), then a responsive
|
||||
-- two-column grid (left: ROM + SAVE FILES cards + Play; right: SAVE SLOT).
|
||||
function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
-- `paged`: the whole page is scrolling (see draw()), so nothing stretches to
|
||||
-- fill `h` -- Play sits right under the SAVE FILES card instead of being pinned
|
||||
-- to the column bottom, and the slot card takes its natural height. Returns
|
||||
-- the panel's natural height either way, which is what draw() measures the page
|
||||
-- against on the next frame.
|
||||
function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
|
||||
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 +2324,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 +2379,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
|
||||
@@ -2281,8 +2414,9 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
-- vertical placement of the left column
|
||||
local romY = bodyTop
|
||||
local saveFilesY = romY + romCardH + 12 * s
|
||||
local leftNaturalH = romCardH + 12 * s + saveFilesH + 12 * s + playH
|
||||
local playY
|
||||
if twoCol then
|
||||
if twoCol and not paged then
|
||||
playY = bodyTop + bodyH - playH -- pinned to the column's bottom
|
||||
else
|
||||
playY = saveFilesY + saveFilesH + 12 * s
|
||||
@@ -2358,18 +2492,30 @@ 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).
|
||||
local slotNaturalH = 0
|
||||
if not locked then
|
||||
if twoCol then
|
||||
self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH)
|
||||
_, slotNaturalH = self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH, paged)
|
||||
else
|
||||
local slotY = playY + playH + 12 * s
|
||||
local slotH = math.max(160 * s, (bodyTop + bodyH) - slotY)
|
||||
self:_drawSaveSlotPanel(version, leftX, slotY, colW, slotH)
|
||||
_, slotNaturalH = self:_drawSaveSlotPanel(version, leftX, slotY, colW, slotH, paged)
|
||||
end
|
||||
end
|
||||
|
||||
-- Natural height: side by side the two columns overlap, stacked they add up.
|
||||
-- Measured from the panel's own top (y), so draw() can compare it against the
|
||||
-- viewport without knowing anything about the cards inside.
|
||||
local bodyNaturalH
|
||||
if twoCol then
|
||||
bodyNaturalH = math.max(leftNaturalH, slotNaturalH)
|
||||
elseif locked then
|
||||
bodyNaturalH = leftNaturalH
|
||||
else
|
||||
bodyNaturalH = leftNaturalH + 12 * s + slotNaturalH
|
||||
end
|
||||
return (bodyTop - y) + bodyNaturalH
|
||||
end
|
||||
|
||||
-- Reload a version's slot list + active id from SaveData (the source of truth).
|
||||
@@ -2446,17 +2592,53 @@ end
|
||||
-- launcher, so a press only ARMS a click (see mousepressed) and this resolves
|
||||
-- it: a pointer that moved past the threshold scrolls; one that did not, on
|
||||
-- release, selects. Desktop only -- Android selects on press instead.
|
||||
-- Where the pointer is this frame and whether it is held, read by polling
|
||||
-- because no move event ever reaches the launcher: the mouse on desktop, the
|
||||
-- first active touch on Android. A nil y means "nothing to read" -- the
|
||||
-- release branches below do not need one.
|
||||
function RomImporter:_pointerHold()
|
||||
if not self.android then return love.mouse.isDown(1), self._my end
|
||||
if not self.touchPollable then return false, nil end
|
||||
local ok, list = pcall(love.touch.getTouches)
|
||||
if not ok or type(list) ~= "table" or list[1] == nil then return false, nil end
|
||||
local ok2, _, ty = pcall(love.touch.getPosition, list[1])
|
||||
if not ok2 or type(ty) ~= "number" then return false, nil end
|
||||
return true, ty
|
||||
end
|
||||
|
||||
function RomImporter:_updateSlotDrag()
|
||||
if self.android then return end
|
||||
local down = love.mouse.isDown(1)
|
||||
if self.android and not self.touchPollable then return end
|
||||
local down, py = self:_pointerHold()
|
||||
py = py or self._my
|
||||
local maxPage = self._pageMax or 0
|
||||
|
||||
-- A press on empty background pans the page while it overflows. Nothing is
|
||||
-- armed by it, so there is no release action to resolve.
|
||||
local pp = self._pagePress
|
||||
if pp then
|
||||
if down then
|
||||
if maxPage > 0 then
|
||||
self.pageScroll = clamp(pp.scroll0 - (py - pp.y0), 0, maxPage)
|
||||
end
|
||||
else
|
||||
self._pagePress = nil
|
||||
end
|
||||
end
|
||||
|
||||
local p = self._slotPress
|
||||
if p then
|
||||
if down then
|
||||
local d = self._my - p.y0
|
||||
local d = py - p.y0
|
||||
if math.abs(d) > 4 * (self._s or 1) then p.moved = true end
|
||||
if p.moved then
|
||||
local maxS = (self._slotMax and self._slotMax[p.version]) or 0
|
||||
self.slotScroll[p.version] = clamp(p.scroll0 - d, 0, maxS)
|
||||
-- Paged, the list has no scroll of its own: the drag pans the page, so
|
||||
-- a swipe that starts on a slot row behaves like one starting beside it.
|
||||
if maxPage > 0 then
|
||||
self.pageScroll = clamp(p.pageScroll0 - d, 0, maxPage)
|
||||
else
|
||||
local maxS = (self._slotMax and self._slotMax[p.version]) or 0
|
||||
self.slotScroll[p.version] = clamp(p.scroll0 - d, 0, maxS)
|
||||
end
|
||||
end
|
||||
else
|
||||
if not p.moved then self:_selectSlot(p.version, p.id) end
|
||||
@@ -2468,10 +2650,14 @@ function RomImporter:_updateSlotDrag()
|
||||
local mp = self._modPress
|
||||
if mp then
|
||||
if down then
|
||||
local d = self._my - mp.y0
|
||||
local d = py - mp.y0
|
||||
if math.abs(d) > 4 * (self._s or 1) then mp.moved = true end
|
||||
if mp.moved then
|
||||
self.modScroll = clamp(mp.scroll0 - d, 0, self._modMax or 0)
|
||||
if maxPage > 0 then
|
||||
self.pageScroll = clamp(mp.pageScroll0 - d, 0, maxPage)
|
||||
else
|
||||
self.modScroll = clamp(mp.scroll0 - d, 0, self._modMax or 0)
|
||||
end
|
||||
end
|
||||
else
|
||||
if not mp.moved then self:_toggleMod(mp.id) end
|
||||
@@ -2485,6 +2671,13 @@ end
|
||||
-- content extent draw computed for that version.
|
||||
function RomImporter:wheelmoved(_, dy)
|
||||
local step = 48 * (self._s or 1)
|
||||
-- An overflowing page scrolls as a whole; the panels' own lists are flattened
|
||||
-- in that mode, so there is never a second scroll region competing for this.
|
||||
local maxPage = self._pageMax or 0
|
||||
if maxPage > 0 then
|
||||
self.pageScroll = clamp((self.pageScroll or 0) - dy * step, 0, maxPage)
|
||||
return
|
||||
end
|
||||
if self.tab == "mods" then
|
||||
local maxS = self._modMax or 0
|
||||
if maxS <= 0 then return end
|
||||
@@ -2501,15 +2694,35 @@ end
|
||||
-- SAVE SLOT card: header ("SAVE SLOT" + "N slots"), a scrollable list of slot
|
||||
-- rows (name + meta, LOADED pill on the active one), and a dashed "+ New save
|
||||
-- slot" button pinned to the bottom. Empty registries show a dashed hint box.
|
||||
function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
-- `paged` (the whole launcher page is scrolling, see draw()) drops the inner
|
||||
-- scroll region: the card grows to its natural height, every row is drawn, and
|
||||
-- the page's own scrollbar is the only one on screen. Returns the height the
|
||||
-- card actually took, which is what the caller measures the page against.
|
||||
function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
|
||||
local s = self._s
|
||||
local pad = 16 * s
|
||||
roundedCard(x, y, w, h, 16 * s)
|
||||
self:_ensureSlots(version)
|
||||
local slots = self.slots[version] or {}
|
||||
local active = self.activeSlot[version]
|
||||
local n = #slots
|
||||
|
||||
-- Row metrics up front: the natural height needs them, and the natural height
|
||||
-- decides the card's height before anything is drawn.
|
||||
local labelH = self.labelFont:getHeight()
|
||||
local newBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 18 * s)
|
||||
local nameH = self.slotNameFont:getHeight()
|
||||
local metaH = self.labelFont:getHeight()
|
||||
local rowPadV = 10 * s
|
||||
local rowH = rowPadV * 2 + nameH + 4 * s + metaH
|
||||
local rowGap = 8 * s
|
||||
local rr = 12 * s
|
||||
-- an empty registry shows a fixed-height dashed hint box instead of rows
|
||||
local totalH = (n > 0) and (n * rowH + (n - 1) * rowGap) or (96 * s)
|
||||
local naturalH = pad + labelH + 12 * s + totalH + 10 * s + newBtnH + pad
|
||||
if paged then h = naturalH end
|
||||
|
||||
roundedCard(x, y, w, h, 16 * s)
|
||||
|
||||
-- header: "SAVE SLOT" (left) + "N slots" / "1 slot" (right)
|
||||
love.graphics.setFont(self.labelFont)
|
||||
col(PAL.labelGray)
|
||||
@@ -2518,11 +2731,9 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
local cw = self.labelFont:getWidth(countTxt)
|
||||
love.graphics.print(countTxt, x + w - pad - cw, y + pad)
|
||||
|
||||
local labelH = self.labelFont:getHeight()
|
||||
local listTop = y + pad + labelH + 12 * s
|
||||
|
||||
-- "+ New save slot" pinned to the card bottom; the list fills the gap above.
|
||||
local newBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 18 * s)
|
||||
local newBtnY = y + h - pad - newBtnH
|
||||
local listBottom = newBtnY - 10 * s
|
||||
local listH = math.max(0, listBottom - listTop)
|
||||
@@ -2542,16 +2753,10 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
self.slotDeleteRects = {}
|
||||
self.slotEditRects = {}
|
||||
elseif listH > 0 then
|
||||
local nameH = self.slotNameFont:getHeight()
|
||||
local metaH = self.labelFont:getHeight()
|
||||
local rowPadV = 10 * s
|
||||
local rowH = rowPadV * 2 + nameH + 4 * s + metaH
|
||||
local rowGap = 8 * s
|
||||
local rr = 12 * s
|
||||
|
||||
-- clamp scroll against the current content extent, and stash the max so the
|
||||
-- wheel handler (which has no geometry) can clamp against the same value
|
||||
local totalH = n * rowH + (n - 1) * rowGap
|
||||
-- wheel handler (which has no geometry) can clamp against the same value.
|
||||
-- Paged, listH already equals totalH, so this is 0 and the wheel falls
|
||||
-- through to the page scroll.
|
||||
local maxScroll = math.max(0, totalH - listH)
|
||||
self._slotMax = self._slotMax or {}
|
||||
self._slotMax[version] = maxScroll
|
||||
@@ -2561,8 +2766,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
self.slotRects = {}
|
||||
self.slotDeleteRects = {}
|
||||
self.slotEditRects = {}
|
||||
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
||||
math.ceil(rw), math.ceil(listH))
|
||||
-- Paged, the page viewport's scissor is already set and nothing here
|
||||
-- overflows the card, so leave it alone rather than replace and clear it.
|
||||
if not paged then
|
||||
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
||||
math.ceil(rw), math.ceil(listH))
|
||||
end
|
||||
for i, slot in ipairs(slots) do
|
||||
local ry = listTop - scroll + (i - 1) * (rowH + rowGap)
|
||||
if ry + rowH >= listTop and ry <= listBottom then
|
||||
@@ -2665,7 +2874,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
end
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
if not paged then love.graphics.setScissor() end
|
||||
|
||||
-- thin scrollbar thumb when the list overflows
|
||||
if maxScroll > 0 then
|
||||
@@ -2689,6 +2898,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
printfB("+ New save slot", nrect.x,
|
||||
nrect.y + (newBtnH - self.saveBtnFont:getHeight()) / 2, nrect.width, "center")
|
||||
self.newSlotRect = nrect
|
||||
return h, naturalH
|
||||
end
|
||||
|
||||
-- Reload the mods list from LauncherMods (the source of truth: it reads the
|
||||
@@ -2697,6 +2907,30 @@ end
|
||||
-- so a still list costs nothing after the first paint.
|
||||
function RomImporter:_refreshMods()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
||||
-- unzipped beside the executable, which an ordinary (non-portable) install
|
||||
-- has no way to read. It happens here rather than behind a button because
|
||||
-- the failure being fixed is one where nothing on screen suggests there is
|
||||
-- anything to press -- the panel just comes up empty. Guarded so a toggle
|
||||
-- or a delete does not re-scan; adoptStrays is idempotent regardless.
|
||||
if not self.modStraysChecked then
|
||||
self.modStraysChecked = true
|
||||
local imported, failed = {}, {}
|
||||
for _, s in ipairs(LauncherMods.adoptStrays() or {}) do
|
||||
table.insert(s.err and failed or imported, s.id)
|
||||
end
|
||||
-- the failure wins the notice: an import that worked speaks for itself in
|
||||
-- the list right below it, one that did not is the only word they get
|
||||
if #imported > 0 then
|
||||
self.modNotice = { ok = true,
|
||||
text = "Imported from the game folder: " .. table.concat(imported, ", ") }
|
||||
end
|
||||
if #failed > 0 then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Found beside the game but could not import: "
|
||||
.. table.concat(failed, ", ") }
|
||||
end
|
||||
end
|
||||
self.mods = LauncherMods.list() or {}
|
||||
end
|
||||
|
||||
@@ -2727,7 +2961,10 @@ end
|
||||
-- install-result / drag-drop notice line, then a scrollable list of mod cards
|
||||
-- (name + badge chip + description, a status chip, and a toggle switch). An
|
||||
-- empty install shows a friendly dashed hint box.
|
||||
function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
-- `paged` behaves as it does on the game panel: no inner scroll region, the
|
||||
-- card list is drawn whole, and the returned natural height is what draw()
|
||||
-- measures the page against.
|
||||
function RomImporter:_drawModsPanel(x, y, w, h, paged)
|
||||
local s = self._s
|
||||
self:_ensureMods()
|
||||
local mods = self.mods or {}
|
||||
@@ -2772,7 +3009,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
|
||||
-- empty state: a dashed box with a centred hint
|
||||
if #mods == 0 then
|
||||
local boxH = math.min(listH, 120 * s)
|
||||
local boxH = paged and (120 * s) or math.min(listH, 120 * s)
|
||||
love.graphics.setLineWidth(math.max(1, 1 * s))
|
||||
col(PAL.cardBorder, 0.45)
|
||||
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
|
||||
@@ -2786,7 +3023,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
self.modRects = {}
|
||||
self.modDeleteRects = {}
|
||||
self._modMax = 0
|
||||
return
|
||||
return (top - y) + boxH
|
||||
end
|
||||
|
||||
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
|
||||
@@ -2823,6 +3060,9 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
end
|
||||
total = total + (#mods - 1) * cardGap
|
||||
|
||||
-- Paged, the list band is the list itself: nothing to clip, nothing to scroll
|
||||
-- here, and the page's scrollbar covers the overflow.
|
||||
if paged then listH = total end
|
||||
local maxScroll = math.max(0, total - listH)
|
||||
self._modMax = maxScroll
|
||||
local scroll = clamp(self.modScroll or 0, 0, maxScroll)
|
||||
@@ -2830,8 +3070,10 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
self.modRects = {}
|
||||
self.modDeleteRects = {}
|
||||
|
||||
love.graphics.setScissor(math.floor(x), math.floor(top),
|
||||
math.ceil(w), math.ceil(listH))
|
||||
if not paged then
|
||||
love.graphics.setScissor(math.floor(x), math.floor(top),
|
||||
math.ceil(w), math.ceil(listH))
|
||||
end
|
||||
local cy = top - scroll
|
||||
for i, m in ipairs(mods) do
|
||||
local L = layout[i]
|
||||
@@ -2927,7 +3169,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
end
|
||||
cy = cy + cardH + cardGap
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
if not paged then love.graphics.setScissor() end
|
||||
|
||||
-- thin scrollbar thumb when the list overflows
|
||||
if maxScroll > 0 then
|
||||
@@ -2936,6 +3178,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
col(PAL.cardBorder, 0.35)
|
||||
love.graphics.rectangle("fill", x + w - 3 * s, thumbY, 3 * s, thumbH, 1.5 * s, 1.5 * s)
|
||||
end
|
||||
return (top - y) + total
|
||||
end
|
||||
|
||||
return RomImporter
|
||||
|
||||
@@ -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
|
||||
|
||||
+138
-3
@@ -16,9 +16,19 @@
|
||||
-- fused build), which is why those mods still loaded while landing in the
|
||||
-- wrong place.
|
||||
--
|
||||
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
||||
-- install, and uninstall paths reach for love.filesystem and SaveData.
|
||||
-- The same split decides where a mod is FOUND, and that has a sharp edge: a
|
||||
-- non-portable install never reads the game folder at all, so a mod unzipped
|
||||
-- next to the executable -- where most games would want it -- is not wrong so
|
||||
-- much as invisible, with an empty panel and no error to explain it.
|
||||
-- adoptStrays looks in those folders anyway (a scoped mount that comes down
|
||||
-- again, CacheFs.withMounted) and copies what it finds into the tree the game
|
||||
-- really reads, so the mistake costs a line of notice rather than a support
|
||||
-- thread.
|
||||
--
|
||||
-- Split in two: the pure derivation (deriveList, locateRoot, pickStrays) has
|
||||
-- no love and no filesystem, so the engine tier can table-drive it; the
|
||||
-- discovery, install, uninstall, and stray-scan paths reach for
|
||||
-- love.filesystem and SaveData.
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
@@ -136,6 +146,28 @@ function LauncherMods.locateRoot(paths)
|
||||
return nil, "no manifest.json found in the .zip"
|
||||
end
|
||||
|
||||
-- pickStrays(found, installed) -> the rows worth adopting, pure.
|
||||
-- found is an array of { id, name, folder, path } in scan order (game folder
|
||||
-- order, then directory order); installed is the id -> true set of what the
|
||||
-- game can already see. An installed id is dropped -- the player has a
|
||||
-- working copy and the loose folder is just where they first put it -- and a
|
||||
-- duplicate id across two game folders keeps the first, the same first-wins
|
||||
-- rule discover() uses. Sorted by id so the notice reads the same every time.
|
||||
function LauncherMods.pickStrays(found, installed)
|
||||
installed = installed or {}
|
||||
local out, seen = {}, {}
|
||||
for _, row in ipairs(found or {}) do
|
||||
local id = row.id
|
||||
if id and not installed[id] and not seen[id] then
|
||||
seen[id] = true
|
||||
out[#out + 1] = { id = id, name = row.name or id,
|
||||
folder = row.folder, path = row.path }
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b) return a.id < b.id end)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- discovery (love.filesystem)
|
||||
|
||||
local function decodeManifest(raw, path)
|
||||
@@ -301,6 +333,109 @@ local function removeTree(path)
|
||||
fs.remove(path)
|
||||
end
|
||||
|
||||
-- ------- strays: mods dropped beside the game that it cannot see
|
||||
|
||||
-- love.filesystem looks in two places for "mods/": the save directory, and --
|
||||
-- portable installs only -- the game folder, which CacheFs mounts. A player
|
||||
-- who unzips a mod next to the executable of an ordinary install, which is
|
||||
-- where very nearly every other game would want it, gets no error and no mod.
|
||||
-- The MODS panel simply stays empty, and there is nothing on screen to
|
||||
-- suggest the files are twenty centimetres away in the wrong folder.
|
||||
--
|
||||
-- The scan mounts each game folder at a private mount point just long enough
|
||||
-- to list mods/ inside it and drops it again (CacheFs.withMounted), so the
|
||||
-- read path the game actually runs on is never touched and a stray can never
|
||||
-- shadow a real file.
|
||||
local STRAY_MOUNT = "stray_scan"
|
||||
|
||||
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
|
||||
-- directory, one mount at a time. Folders that are already the physfs source
|
||||
-- are skipped: their mods/ is discoverable by definition, so anything there is
|
||||
-- installed already and not a stray (this is every `love <gamedir>` dev run).
|
||||
local function eachStrayRoot(fn)
|
||||
local SaveData_ = require("src.core.SaveData")
|
||||
local fs = love and love.filesystem
|
||||
if not fs then return end
|
||||
local source = fs.getSource and fs.getSource()
|
||||
local seen = {}
|
||||
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
|
||||
if not seen[folder] and folder ~= source then
|
||||
seen[folder] = true
|
||||
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
||||
local root = STRAY_MOUNT .. "/mods"
|
||||
if fs.getInfo(root) then fn(root, folder) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Every valid mod folder sitting in a game folder's mods/, in scan order.
|
||||
-- Only reads. The rows carry the mounted path, which is live for the length
|
||||
-- of the mount and dead after it -- copying has to happen inside the same
|
||||
-- scan, which is why adoption is a flag here rather than a second pass.
|
||||
local function findStrays(fs, adopt, installed)
|
||||
local found, adopted = {}, {}
|
||||
eachStrayRoot(function(root, folder)
|
||||
local batch = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems(root)) do
|
||||
local path = root .. "/" .. name
|
||||
local info = fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
local manifest = raw and decodeManifest(raw, path)
|
||||
if manifest then
|
||||
batch[#batch + 1] = { id = manifest.id,
|
||||
name = manifest.name or manifest.id,
|
||||
folder = folder, path = path }
|
||||
end
|
||||
end
|
||||
end
|
||||
-- filtered per mount, so a copy only ever runs for a row that survived
|
||||
-- the pure rules -- and so the second game folder sees the first one's
|
||||
-- ids as taken
|
||||
for _, row in ipairs(LauncherMods.pickStrays(batch, installed)) do
|
||||
if adopt then
|
||||
-- same root pin installZip uses: the mods tree is shared by Red and
|
||||
-- Blue, never version-prefixed (#330)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
local dest = "mods/" .. row.id
|
||||
local copied, copyErr = copyTree(row.path, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then row.err = copyErr or "could not copy the files" end
|
||||
end
|
||||
installed[row.id] = true
|
||||
row.path = nil -- dead once this mount comes down
|
||||
adopted[#adopted + 1] = row
|
||||
found[#found + 1] = row
|
||||
end
|
||||
end)
|
||||
return LauncherMods.pickStrays(found, {})
|
||||
end
|
||||
|
||||
-- The strays, optionally adopted. A folder whose id the game can already see
|
||||
-- is left out: the player has a working copy, and the loose one is just where
|
||||
-- they first put it. Rows that failed to copy come back with .err set.
|
||||
local function scanStrays(adopt)
|
||||
local fs = love and love.filesystem
|
||||
if not fs then return {} end
|
||||
local installed = {}
|
||||
for _, m in ipairs(discover()) do installed[m.id] = true end
|
||||
return findStrays(fs, adopt, installed)
|
||||
end
|
||||
|
||||
-- strays() -> the rows, nothing copied.
|
||||
function LauncherMods.strays() return scanStrays(false) end
|
||||
|
||||
-- adoptStrays() -> the rows, each one copied into the mods tree the game
|
||||
-- really reads (rows carrying .err failed). Idempotent: a second call finds
|
||||
-- the ids installed and returns nothing, so the panel can run this on every
|
||||
-- open without duplicating anything or nagging twice. The loose folder is
|
||||
-- deliberately left where it is -- deleting files outside the save directory
|
||||
-- on the player's behalf is not this function's call to make.
|
||||
function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
|
||||
-- installZip(source) -> true, id | nil, errString
|
||||
-- source is an external path or a love DroppedFile. The archive is validated
|
||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||
|
||||
@@ -493,6 +493,12 @@ R.maps = {
|
||||
width = f.int(1), height = f.int(1),
|
||||
blocks = f.list(f.int(0, 255)),
|
||||
borderBlock = f.opt(f.int(0, 255)),
|
||||
-- A named SGB palette, which wins over the field.palettes cascade
|
||||
-- (OverworldController.lua:506 reads map.def.palette first). Deliberately
|
||||
-- a plain string rather than f.id("palettes"): the ROM-free fixture base
|
||||
-- carries no palettes at all, so an id reference would fail validation for
|
||||
-- a perfectly good mod wherever there is no imported dataset.
|
||||
palette = f.opt(f.str),
|
||||
warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0),
|
||||
destMap = f.str, destWarp = f.int(0) })),
|
||||
objects = f.opt(f.list(f.any)),
|
||||
|
||||
@@ -18,7 +18,8 @@ local function samePath(path) return path end
|
||||
-- side: "front" | "back"
|
||||
-- opts.mon: the live mon when available (per-instance skins)
|
||||
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||
-- | "title" | "oak" | "credits" | "overworld" (informational
|
||||
-- for wrappers)
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.path(data, species, side, opts)
|
||||
opts = opts or {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -116,13 +116,12 @@ local function defaultsSave()
|
||||
end
|
||||
|
||||
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
|
||||
-- way convert.lua did, then stamp the requested version. The 32768-byte
|
||||
-- import template GenSave stashes as `rawImport` and the decode `warnings`
|
||||
-- are dropped here: neither belongs in a serialized slot file (a fresh
|
||||
-- export always starts zero-filled -- see GenSave.lua's header).
|
||||
-- way convert.lua did, then stamp the requested version. Keep the imported
|
||||
-- SRAM image with the slot: Pokémon Red restores its saved current-map cache
|
||||
-- before Continue, and an export needs that unmodeled data to remain bootable.
|
||||
-- Decode warnings are only import diagnostics and do not belong in the slot.
|
||||
local function mergeDefaults(decoded, version)
|
||||
decoded.warnings = nil
|
||||
decoded.rawImport = nil
|
||||
local save = defaultsSave()
|
||||
for k, v in pairs(decoded) do save[k] = v end
|
||||
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
|
||||
|
||||
+87
-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
|
||||
@@ -497,6 +518,17 @@ function Commands.play_cry(ctx, species, waitForButton)
|
||||
ctx.pendingCryWait = waitForButton or nil
|
||||
end
|
||||
|
||||
-- mark_seen <species>: DisplayPokedex (pokedex.asm) records the species as
|
||||
-- seen before opening its entry. Map scripts use this for NPC-driven
|
||||
-- Pokédex previews that do not begin a battle or give the player a Pokémon.
|
||||
function Commands.mark_seen(ctx, species)
|
||||
local dex = ctx.save and ctx.save.pokedex
|
||||
if dex then
|
||||
dex.seen = dex.seen or {}
|
||||
dex.seen[species] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
|
||||
-- battle ended with any of the given results
|
||||
-- ("win"|"lose"|"run"|"caught"), for branches like
|
||||
@@ -561,7 +593,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 +632,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 +783,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 +1025,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)
|
||||
|
||||
+25
-9
@@ -35,14 +35,15 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ kind = "dex" })
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { kind = "dex" })
|
||||
-- `path and pcall(...)` truncates to one value, so img was always nil and
|
||||
-- every dex page drew without its pic (#307); the guard has to be a
|
||||
-- statement for pcall's second return to survive.
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
@@ -55,11 +56,26 @@ function DexEntryMenu:update(dt)
|
||||
end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor)
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
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
|
||||
local y = math.max(0, 60 - sprite:getHeight())
|
||||
love.graphics.draw(sprite, 8, y)
|
||||
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
|
||||
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
|
||||
-- it renders to its own PNG canvas, and a mark left behind there would
|
||||
-- bleed into the next real frame.
|
||||
if trueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
@@ -70,10 +86,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 +100,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
|
||||
+34
-19
@@ -61,7 +61,7 @@ local function tryImage(path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
-- Resolve a pic descriptor to (image, flip).
|
||||
-- Resolve a pic descriptor to (image, flip, trueColor).
|
||||
-- Descriptors:
|
||||
-- "oak" | "rival" | "player" shorthand
|
||||
-- { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
@@ -70,7 +70,7 @@ end
|
||||
-- { type = "image", path = "..." }
|
||||
-- { type = "sprite", id = "SPRITE_RED" }
|
||||
function OakSpeech.resolvePic(game, desc, speech)
|
||||
if desc == nil then return nil, false end
|
||||
if desc == nil then return nil, false, false end
|
||||
if type(desc) == "string" then
|
||||
if desc == "oak" then
|
||||
desc = { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
@@ -86,35 +86,37 @@ function OakSpeech.resolvePic(game, desc, speech)
|
||||
local t = desc.type
|
||||
if t == "trainer" then
|
||||
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
|
||||
return speech.oakPic, false
|
||||
return speech.oakPic, false, false
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false
|
||||
return speech.rivalPic, false, false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), false
|
||||
return tryImage(tr and tr.pic), false, false
|
||||
elseif t == "pokemon" then
|
||||
if speech and desc.id == speech.demoSpecies and speech.demoPic then
|
||||
return speech.demoPic, desc.flip and true or false
|
||||
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
|
||||
end
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, desc.id, "front", { kind = "oak" })
|
||||
return tryImage(path), desc.flip and true or false
|
||||
return tryImage(path), desc.flip and true or false, trueColor
|
||||
elseif t == "player" then
|
||||
if speech and speech.playerPic and not desc.path then
|
||||
return speech.playerPic, false
|
||||
return speech.playerPic, false, speech.playerTrueColor
|
||||
end
|
||||
if desc.path then return tryImage(desc.path), false end
|
||||
return tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })), false
|
||||
if desc.path then return tryImage(desc.path), false, false end
|
||||
local path, trueColor = require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })
|
||||
return tryImage(path), false, trueColor
|
||||
elseif t == "image" then
|
||||
return tryImage(desc.path), desc.flip and true or false
|
||||
return tryImage(desc.path), desc.flip and true or false, false
|
||||
elseif t == "sprite" then
|
||||
local sp = game.data.sprites and game.data.sprites[desc.id]
|
||||
return tryImage(sp and sp.image), desc.flip and true or false
|
||||
return tryImage(sp and sp.image), desc.flip and true or false,
|
||||
sp and sp.trueColor or false
|
||||
end
|
||||
return nil, false
|
||||
return nil, false, false
|
||||
end
|
||||
|
||||
-- Vanilla step list. Ids are the stable anchors mods insert around.
|
||||
@@ -219,15 +221,18 @@ function OakSpeech.new(game, onDone)
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
-- literals stay as the fallbacks
|
||||
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
||||
local demoPath = require("src.pokemon.Sprites").path(
|
||||
local demoPath, demoTrueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, self.demoSpecies, "front", { kind = "oak" })
|
||||
self.demoPic = tryImage(demoPath)
|
||||
self.demoTrueColor = self.demoPic and demoTrueColor or false
|
||||
local constants = game.data.constants or {}
|
||||
self.nameLen = constants.playerNameLength or 7
|
||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" }))
|
||||
local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })
|
||||
self.playerPic = tryImage(playerPath)
|
||||
self.playerTrueColor = self.playerPic and playerTrueColor or false
|
||||
self.shrinkPic1 = tryImage(oakGfx.shrink1
|
||||
or "assets/generated/intro/shrink1.png")
|
||||
self.shrinkPic2 = tryImage(oakGfx.shrink2
|
||||
@@ -278,14 +283,17 @@ end
|
||||
|
||||
function OakSpeech:applyPic(step)
|
||||
if step.pic == nil then return end
|
||||
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
local img, flip, trueColor = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
if img then
|
||||
self.pic = img
|
||||
self.picFlip = flip or false
|
||||
self.picTrueColor = trueColor or false
|
||||
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
|
||||
-- mirror the old fallback: player pic missing → oak
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self.picFlip = false
|
||||
self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor
|
||||
or false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -342,6 +350,7 @@ function OakSpeech:runStep(step)
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
self.pic = self.demoPic
|
||||
self.picFlip = true
|
||||
self.picTrueColor = self.demoTrueColor
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
|
||||
@@ -536,8 +545,10 @@ function OakSpeech:update(dt)
|
||||
s.frame = s.frame + 1
|
||||
if s.frame == 5 then
|
||||
self.pic = self.shrinkPic1 or self.pic
|
||||
self.picTrueColor = false
|
||||
elseif s.frame == 9 then
|
||||
self.pic = self.shrinkPic2 or self.pic
|
||||
self.picTrueColor = false
|
||||
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
|
||||
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
|
||||
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
|
||||
@@ -545,6 +556,7 @@ function OakSpeech:update(dt)
|
||||
Music.fadeOut(10)
|
||||
elseif s.frame == 29 then
|
||||
self.pic = nil
|
||||
self.picTrueColor = false
|
||||
self.walkVisible = true
|
||||
elseif s.frame >= 79 and s.frame <= 102 then
|
||||
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
||||
@@ -584,6 +596,9 @@ function OakSpeech:draw()
|
||||
else
|
||||
love.graphics.draw(self.pic, x + off, y)
|
||||
end
|
||||
if self.picTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(x + off, y, w, h)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.walkVisible and self.walkSheet then
|
||||
|
||||
@@ -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
|
||||
+255
-48
@@ -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)
|
||||
@@ -76,6 +98,30 @@ local function imagePath(entry)
|
||||
return entry
|
||||
end
|
||||
|
||||
-- PaletteFX redraws a true-color rectangle after the palette pass. Red's
|
||||
-- title art is drawn on top of the title mon, so leave its bounds out of the
|
||||
-- rectangle rather than redrawing that art without its title palette.
|
||||
local function markVisibleTrueColor(x, y, w, h, cover)
|
||||
local P = require("src.render.PaletteFX")
|
||||
if not cover then
|
||||
P.markTrueColor(x, y, w, h)
|
||||
return
|
||||
end
|
||||
local cx, cy, cw, ch = cover[1], cover[2], cover[3], cover[4]
|
||||
local right, bottom = x + w, y + h
|
||||
local cright, cbottom = cx + cw, cy + ch
|
||||
local ix1, iy1 = math.max(x, cx), math.max(y, cy)
|
||||
local ix2, iy2 = math.min(right, cright), math.min(bottom, cbottom)
|
||||
if ix1 >= ix2 or iy1 >= iy2 then
|
||||
P.markTrueColor(x, y, w, h)
|
||||
return
|
||||
end
|
||||
if y < iy1 then P.markTrueColor(x, y, w, iy1 - y) end
|
||||
if iy2 < bottom then P.markTrueColor(x, iy2, w, bottom - iy2) end
|
||||
if x < ix1 then P.markTrueColor(x, iy1, ix1 - x, iy2 - iy1) end
|
||||
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
|
||||
end
|
||||
|
||||
function TitleState.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, TitleState)
|
||||
@@ -93,13 +139,40 @@ 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
|
||||
self.sprites = {} -- species -> image or false (load failed)
|
||||
self.sprites = {} -- species -> { image, trueColor } or false (load failed)
|
||||
self.cycleIndex = 1
|
||||
self.timer = 0
|
||||
self.blink = 0
|
||||
@@ -107,6 +180,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,16 +195,94 @@ 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]
|
||||
if cached == nil then
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "title" })
|
||||
cached = tryImage(path) or false
|
||||
local image = tryImage(path)
|
||||
cached = image and { image = image, trueColor = trueColor } or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
return cached and cached.image or nil, cached and cached.trueColor or false
|
||||
end
|
||||
|
||||
local function hasSave()
|
||||
@@ -219,9 +378,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 +414,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 +426,79 @@ 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, spriteTrueColor = 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
|
||||
local x = 40 + math.floor((56 - w) / 2) + slide
|
||||
local y = 136 - h
|
||||
love.graphics.draw(sprite, x, y)
|
||||
-- a full-color mon keeps its own palette through the SGB pass, minus
|
||||
-- the strip Red's OAM covers (#350). Yellow never reaches here: its
|
||||
-- layout has no cycling mon and no Red art (title_yellow.asm).
|
||||
if spriteTrueColor then
|
||||
local cover
|
||||
if self.player then
|
||||
local pw, ph = self.player:getDimensions()
|
||||
cover = { 82, 80, pw, ph }
|
||||
end
|
||||
markVisibleTrueColor(x, y, w, h, cover)
|
||||
end
|
||||
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
|
||||
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
|
||||
|
||||
+20
-4
@@ -52,13 +52,24 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- self.stepFrames overrides the shared 16-frame walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
|
||||
-- command: two cells of travel inside one step's frames
|
||||
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
|
||||
-- pixel span doubles while the frame count does not. Nothing else sets
|
||||
-- either field, so every other object keeps the constant (#410, #409).
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local span = self.hopStep and 2 or 1
|
||||
if self.moving then
|
||||
self.progress = self.progress + 1
|
||||
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
|
||||
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
|
||||
-- pinned to the current cell while walkPhase() cycles.
|
||||
if self.marching then
|
||||
if self.progress >= STEP_FRAMES then
|
||||
if self.progress >= stepLen then
|
||||
self.progress = 0
|
||||
self.moving = false
|
||||
self.marching = false
|
||||
@@ -67,13 +78,18 @@ function NPC:update(map, entities)
|
||||
return
|
||||
end
|
||||
local d = Collision.DELTA[self.facing]
|
||||
self.px = self.cellX * 16 + d[1] * self.progress
|
||||
self.py = self.cellY * 16 + d[2] * self.progress
|
||||
if self.progress >= STEP_FRAMES then
|
||||
-- 1px per frame at the default length; a shortened step scales instead,
|
||||
-- so the cell still lands on a 16px boundary (Player:update does the
|
||||
-- same for the bicycle)
|
||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||
self.px = self.cellX * 16 + d[1] * moved
|
||||
self.py = self.cellY * 16 + d[2] * moved
|
||||
if self.progress >= stepLen then
|
||||
self.cellX, self.cellY = self.targetX, self.targetY
|
||||
self.targetX, self.targetY = nil, nil
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.hopStep = nil
|
||||
self.stepFlip = not self.stepFlip
|
||||
end
|
||||
return
|
||||
|
||||
@@ -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);
|
||||
@@ -792,6 +795,12 @@ function OverworldState:update(dt)
|
||||
if ca.onDone then ca.onDone() end
|
||||
end
|
||||
end
|
||||
-- Yellow's companion hopping up onto the Poke Center counter owns the
|
||||
-- world for its arc, the same way the heal machine below does (#417)
|
||||
if self.pikaHop then
|
||||
require("src.world.PikachuFollower").updateHop(self)
|
||||
return
|
||||
end
|
||||
if self.healAnim then
|
||||
local ha = self.healAnim
|
||||
local ev = OverworldState.stepHealAnim(ha)
|
||||
@@ -870,6 +879,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)
|
||||
@@ -1493,7 +1503,17 @@ function OverworldState:interact()
|
||||
npc = self:npcAtCell(fx2, fy2)
|
||||
end
|
||||
if npc then
|
||||
if not npc.moving then
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id --
|
||||
-- and it answers mid-step too. pikachu_follow.asm walks the follower
|
||||
-- on the player's own step clock, so the original never has it
|
||||
-- mid-tile while the player stands; this port's follow is a frame
|
||||
-- late (the npc loop runs before Player:update lands the step), so
|
||||
-- the not-moving gate used to eat the A press in the frames right
|
||||
-- after landing -- exactly when you turn round to face it (#407).
|
||||
-- talk() lands the follower on its cell first.
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
elseif not npc.moving then
|
||||
self:talkTo(npc)
|
||||
end
|
||||
interacted(self, fx, fy, "npc", npc)
|
||||
@@ -2431,7 +2451,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 +2469,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
|
||||
@@ -2500,41 +2520,56 @@ function OverworldState:nurseHeal(onDone, npc)
|
||||
hello = hello .. "\f"
|
||||
.. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?"))
|
||||
end
|
||||
-- Yellow's companion has its own beat threaded through this sequence
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
|
||||
if not yes then
|
||||
Game.stack:push(TextBox.new(Game, bye, onDone))
|
||||
return
|
||||
end
|
||||
local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.")
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
-- accepting the heal sends the companion up onto the counter to Nurse
|
||||
-- Joy first: pokecenter.asm runs `callfar PikachuWalksToNurseJoy`
|
||||
-- between SetLastBlackoutMap and NeedYourPokemonText, and the hop has
|
||||
-- to finish before the text box goes up because only the top state
|
||||
-- updates. No follower (or not Yellow) calls straight through (#417).
|
||||
Follower.hopToCounter(self, function()
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
-- DisablePikachuOverworldSpriteDrawing: Pikachu goes behind the
|
||||
-- counter with the party for the machine animation
|
||||
Follower.setVisible(self, false)
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
-- EnablePikachuOverworldSpriteDrawing, before the fighting-fit
|
||||
-- line: it comes back on the counter facing the player
|
||||
Follower.setVisible(self, true)
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
end)
|
||||
end }))
|
||||
end
|
||||
|
||||
@@ -2874,6 +2909,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 +2980,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 +4072,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
|
||||
@@ -4393,6 +4436,30 @@ end
|
||||
|
||||
-- screen-space overlays: drawn to the UI canvas at normal scale
|
||||
function OverworldState:drawUI()
|
||||
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
||||
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so
|
||||
-- the front pic holds for the whole beat while the cry and any emote
|
||||
-- bubble play over the world below (#407).
|
||||
if self.emote and self.emote.pikaPic then
|
||||
require("src.render.Font").drawBox(6, 5, 7, 7)
|
||||
-- one image per path, cached: this draws every frame of the hold, and
|
||||
-- a mod skin can move the path between talks
|
||||
if self.pikaPicPath ~= self.emote.pikaPic then
|
||||
local ok, loaded = pcall(love.graphics.newImage, self.emote.pikaPic)
|
||||
self.pikaPicImg = ok and loaded or nil
|
||||
self.pikaPicPath = self.emote.pikaPic
|
||||
end
|
||||
local img = self.pikaPicImg
|
||||
if img then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local w, h = img:getDimensions()
|
||||
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
|
||||
math.floor(48 + (40 - h) / 2))
|
||||
end
|
||||
end
|
||||
|
||||
-- poison step flicker (ChangeBGPalColor0_4Frames: dark for two
|
||||
-- 4-frame pulses)
|
||||
if self.poisonFlash and self.poisonFlash > 0 then
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
-- 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 Collision = require("src.world.Collision")
|
||||
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"
|
||||
-- the idle animations below pose the walk cycle with no step under it,
|
||||
-- which NPC:walkPhase (moving-only) cannot express. An instance field
|
||||
-- shadows the class method, so NPC:pose keeps working unchanged (#411).
|
||||
npc.walkPhase = function(self)
|
||||
local idle = self.idle
|
||||
if idle and idle.phase then return idle.phase % 2 end
|
||||
return NPC.walkPhase(self)
|
||||
end
|
||||
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
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Idle behavior (pikachu_follow.asm Func_fc803 and the Func_fc842 roll it
|
||||
-- hands off to). Standing still, the follower burns down a frame
|
||||
-- counter; at zero it either looks in a random direction (Random & $c,
|
||||
-- another $20 frames later) or, when the buffered follow command puts it
|
||||
-- two or more cells off the player (ComputePikachuFollowCommand's 5-8
|
||||
-- band), rolls one of four in-place animations: a bounce, the walk cycle
|
||||
-- on the spot, a two frame shuffle, or a clockwise spin. Func_fc82e
|
||||
-- drops whichever is running the moment the player takes a step. Nothing
|
||||
-- here plays a bubble or a cry -- those are TalkToPikachu's alone (#411).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local IDLE_LOOK = 0x20 -- Func_fc803's pause between random glances
|
||||
local IDLE_REST = 0x10 -- Func_fc835's pause after an animation ends
|
||||
local IDLE_FRAME = 8 -- frames per sprite frame in Func_fc8f8/92b/95d
|
||||
|
||||
local FACINGS = { "down", "up", "left", "right" }
|
||||
-- Func_fc95d .Facings, the order the spin turns through
|
||||
local CLOCKWISE = { down = "left", left = "up", up = "right", right = "down" }
|
||||
|
||||
-- Pointer_fc8d6, transposed to (dx, dy): the asm stores (y, x) and walks
|
||||
-- the table backwards as the $11 counter runs down, so entry N here is
|
||||
-- what counter N draws. A sway four pixels right then four left with the
|
||||
-- body bobbing up twice, netting zero displacement.
|
||||
local BOUNCE = {
|
||||
{ 0, 0 }, { -1, -2 }, { -2, -4 }, { -3, -2 }, { -4, 0 },
|
||||
{ -3, -2 }, { -2, -4 }, { -1, -2 }, { 0, 0 }, { 1, -2 },
|
||||
{ 2, -4 }, { 3, -2 }, { 4, 0 }, { 3, -2 }, { 2, -4 },
|
||||
{ 1, -2 }, { 0, 0 },
|
||||
}
|
||||
|
||||
local function randomInt(a, b)
|
||||
local rand = love and love.math and love.math.random or math.random
|
||||
return rand(a, b)
|
||||
end
|
||||
|
||||
-- back onto the cell's own pixels: while the follower stands, nothing else
|
||||
-- writes px/py, so the bounce offset has to be undone from here
|
||||
local function idleReset(npc)
|
||||
npc.idle = nil
|
||||
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
||||
end
|
||||
|
||||
-- ComputePikachuFollowCommand: the command the idle state reads back is
|
||||
-- 1-4 while the follower sits within a cell of the player and 5-8 once it
|
||||
-- is two or more off, Y deciding whenever the rows differ. Returns the
|
||||
-- facing those 5-8 encode (Func_fc862 turns that way before it bounces),
|
||||
-- or nil for the near band, which only ever glances.
|
||||
local function strandedFacing(ow, npc)
|
||||
local p = ow.player
|
||||
local dy = p.cellY - npc.cellY
|
||||
if dy ~= 0 then
|
||||
if dy > -2 and dy < 2 then return nil end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
local dx = p.cellX - npc.cellX
|
||||
if dx > -2 and dx < 2 then return nil end
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
|
||||
-- Func_fc842: an even roll over the four PointerTable_fc85a entries
|
||||
local function startIdleAnim(npc, facing)
|
||||
local roll = randomInt(0, 3)
|
||||
if roll == 0 then
|
||||
-- Func_fc862 turns toward the player, then asm_fc87f bounces
|
||||
npc.facing = facing or npc.facing
|
||||
npc.idle = { kind = "bounce", frames = 0x11 }
|
||||
elseif roll == 1 then
|
||||
npc.idle = { kind = "walk", frames = 0x30, tick = 0, phase = 0 }
|
||||
elseif roll == 2 then
|
||||
npc.idle = { kind = "shuffle", frames = 0x20, tick = 0, phase = 0 }
|
||||
else
|
||||
npc.idle = { kind = "spin", frames = 0x20, tick = 0 }
|
||||
end
|
||||
end
|
||||
|
||||
local function idleTick(ow, npc)
|
||||
-- Func_fc82e: a step in progress ends the idle state outright
|
||||
if ow.player.moving then idleReset(npc) return end
|
||||
local idle = npc.idle
|
||||
if not idle then
|
||||
idle = { kind = "wait", frames = IDLE_LOOK }
|
||||
npc.idle = idle
|
||||
end
|
||||
if idle.kind == "wait" then
|
||||
idle.frames = idle.frames - 1
|
||||
if idle.frames > 0 then return end
|
||||
local facing = strandedFacing(ow, npc)
|
||||
if facing then
|
||||
startIdleAnim(npc, facing)
|
||||
else
|
||||
npc.facing = FACINGS[randomInt(1, 4)]
|
||||
idle.frames = IDLE_LOOK
|
||||
end
|
||||
return
|
||||
end
|
||||
if idle.kind == "bounce" then
|
||||
local o = BOUNCE[idle.frames] or BOUNCE[1]
|
||||
npc.px = npc.cellX * 16 + o[1]
|
||||
npc.py = npc.cellY * 16 + o[2]
|
||||
else
|
||||
idle.tick = idle.tick + 1
|
||||
if idle.tick >= IDLE_FRAME then
|
||||
idle.tick = 0
|
||||
if idle.kind == "walk" then
|
||||
-- Func_fc8f8 runs the anim counter through all four frames; the
|
||||
-- top bit is the mirrored foot, which is our stepFlip
|
||||
idle.phase = (idle.phase + 1) % 4
|
||||
npc.stepFlip = idle.phase >= 2
|
||||
elseif idle.kind == "shuffle" then
|
||||
idle.phase = idle.phase == 0 and 1 or 0 -- Func_fc92b's xor $1
|
||||
else
|
||||
npc.facing = CLOCKWISE[npc.facing] or "down"
|
||||
end
|
||||
end
|
||||
end
|
||||
idle.frames = idle.frames - 1
|
||||
if idle.frames <= 0 then
|
||||
-- Func_fc835: a $10 frame rest, then the idle counter again
|
||||
idleReset(npc)
|
||||
npc.idle = { kind = "wait", frames = IDLE_REST }
|
||||
end
|
||||
end
|
||||
|
||||
-- The cell ahead is a ledge the player just hopped (data/tilesets/
|
||||
-- ledge_tiles.asm, the same row match OverworldState:checkLedgeHop makes).
|
||||
-- The follower only ever retraces cells the player stood on, so a ledge
|
||||
-- tile in the trail means the player jumped it (#409).
|
||||
local function ledgeStep(game, ow, cx, cy, dir)
|
||||
local map = ow.map
|
||||
local d = Collision.DELTA[dir]
|
||||
local fx, fy = cx + d[1], cy + d[2]
|
||||
local lx, ly = cx + d[1] * 2, cy + d[2] * 2
|
||||
if not (map:inBounds(fx, fy) and map:inBounds(lx, ly)) then return false end
|
||||
local tileset = map.def.tileset
|
||||
local standing = map:cellTile(cx, cy)
|
||||
local front = map:cellTile(fx, fy)
|
||||
for _, ledge in ipairs(game.data.field.ledges or {}) do
|
||||
if (ledge.tileset or "OVERWORLD") == tileset
|
||||
and ledge.facing == dir and ledge.input == dir
|
||||
and ledge.standingTile == standing and ledge.ledgeTile == front then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
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)
|
||||
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
|
||||
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 follow command is queued the frame the player COMMITS a step, not
|
||||
-- the frame it lands: home/overworld.asm .noCollision sets wWalkCounter
|
||||
-- and calls Func_fcc08 (pikachu_follow.asm Func_fcc42 reads the direction
|
||||
-- of the step just started) before AdvancePlayerSprite, so Pikachu walks
|
||||
-- into the cell the player is vacating during that same step and rests
|
||||
-- exactly one cell behind. Waiting for p.cellX to change put a whole
|
||||
-- extra step between them -- the two-tile gap of issue #410. targetX/Y
|
||||
-- is the committed destination while a step is in flight and nil when
|
||||
-- standing, so a warp or teleport still registers here (and the far > 6
|
||||
-- snap below still catches it).
|
||||
local destX = p.targetX or p.cellX
|
||||
local destY = p.targetY or p.cellY
|
||||
if destX ~= trail.x or destY ~= trail.y then
|
||||
npc.goalX, npc.goalY = trail.x, trail.y
|
||||
trail.x, trail.y = destX, destY
|
||||
end
|
||||
-- standing still with nothing to chase is the idle state (Func_fc803);
|
||||
-- once a step is under way NPC:update owns px/py, so only the idle
|
||||
-- record is dropped here -- never the interpolated pixels
|
||||
if npc.moving then npc.idle = nil return end
|
||||
if not npc.goalX then idleTick(ow, npc) return end
|
||||
local gx, gy = npc.goalX, npc.goalY
|
||||
if npc.cellX == gx and npc.cellY == gy then
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
idleTick(ow, npc)
|
||||
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
|
||||
npc.idle = nil -- the snap already rewrote px/py
|
||||
return
|
||||
end
|
||||
idleReset(npc) -- a real step overrides whatever the idle pose was
|
||||
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)
|
||||
-- the cell ahead is the ledge the player hopped: clear both cells in one
|
||||
-- step instead of stopping on the ledge (#409). pikachu_follow.asm
|
||||
-- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING
|
||||
-- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over
|
||||
-- one normal step's frames -- no arc and no shadow, the hop command only
|
||||
-- doubles the step vector (NPC:update's hopStep span).
|
||||
if ledgeStep(game, ow, npc.cellX, npc.cellY, dir) then
|
||||
local d = Collision.DELTA[dir]
|
||||
npc.targetX, npc.targetY = npc.cellX + d[1] * 2, npc.cellY + d[2] * 2
|
||||
npc.goalX, npc.goalY = npc.targetX, npc.targetY
|
||||
npc.hopStep = true
|
||||
end
|
||||
-- walk at the player's own step length (the bicycle is moot: shouldSpawn
|
||||
-- hides the follower on a bike, ShouldPikachuSpawn's wWalkBikeSurfState
|
||||
-- check), and halve it while more than one cell behind -- that is
|
||||
-- FastPikachuFollow, which pikachu_follow.asm picks whenever two or more
|
||||
-- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer:
|
||||
-- walk counter $4 instead of NormalPikachuFollow's $8).
|
||||
local stepLen = p.stepFramesCur or p.stepFrames or 16
|
||||
if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end
|
||||
npc.stepFrames = stepLen
|
||||
npc.moving = true
|
||||
npc.progress = 0
|
||||
-- this frame's npc:update loop already ran (OverworldState:update walks
|
||||
-- self.npcs, then calls here), so burn the step's first frame now.
|
||||
-- Without it the step costs a frame more than the player's and Pikachu
|
||||
-- trails a pixel further every tile.
|
||||
npc:update(ow.map, ow.entities)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- TalkToPikachu (engine/pikachu/pikachu_emotions.asm + data/pikachu/
|
||||
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
|
||||
-- and voiced PCM clip, and raise the framed Pikachu picture the original
|
||||
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
|
||||
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
|
||||
-- so the front pic stands in for all twenty of them (#407).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- 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)
|
||||
-- pikachu_follow.asm steps the follower on the player's own walk clock,
|
||||
-- so it is never mid-tile while the player stands and can always be
|
||||
-- addressed; this port's follow is a frame late, so land the step here
|
||||
-- rather than answer from between two cells (#407). The emote hold
|
||||
-- returns before the npc update loop, so a follower left mid-step would
|
||||
-- freeze between cells for the whole beat.
|
||||
if npc.moving then
|
||||
npc.cellX, npc.cellY = npc.targetX or npc.cellX, npc.targetY or npc.cellY
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
npc.moving = false
|
||||
npc.progress = 0
|
||||
npc.hopStep = nil
|
||||
end
|
||||
idleReset(npc) -- the bubble anchor reads px/py, and the hold freezes it
|
||||
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)
|
||||
-- pikaemotion_pikapic: every entry in data/pikachu/pikachu_emotions.asm
|
||||
-- ends with one, and its box is the only thing most of them put on
|
||||
-- screen (emotion 5, the fresh-save cell, has no bubble at all). The
|
||||
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
|
||||
-- Sprites.path keeps a mod's replacement skin in play. The scripts'
|
||||
-- 32-58 frame durations bracket the hold below, so it stays at 50.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
ow.emote = {
|
||||
npc = npc, frames = 50, bubble = bi or false, pikaPic = pic,
|
||||
onDone = done,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
|
||||
-- engine/events/pokecenter.asm once the heal is accepted): the companion
|
||||
-- looks up ($36) and hops onto the Poke Center counter. The original
|
||||
-- picks one of three movement scripts by where it stands -- below the
|
||||
-- player (.PikaMovementData1: walk up left, hop up right), left of it
|
||||
-- (.PikaMovementData2: hop up right) or right of it (.PikaMovementData3:
|
||||
-- hop up left) -- and all three land on the counter tile directly in
|
||||
-- front of the player, so the port animates that one hop. Pikachu
|
||||
-- already above the player yields zero movement bytes: no beat (#417).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local HOP_FRAMES = 32 -- the port's ledge-hop arc (Player:pose hopTotal)
|
||||
|
||||
function PikachuFollower.hopToCounter(ow, done)
|
||||
local npc = GameVersion.isYellow() and findFollower(ow) or nil
|
||||
local p = ow.player
|
||||
local cx, cy = p:facingCell()
|
||||
-- the nurse is talked to across a counter tile (OverworldState:interact);
|
||||
-- anything else is the .pikachu_above_player no-op path
|
||||
if not npc or p.facing ~= "up" or not ow.map:isCounterCell(cx, cy) then
|
||||
if done then done() end
|
||||
return
|
||||
end
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
npc.moving, npc.progress, npc.hopStep = false, 0, nil
|
||||
npc.idle = nil
|
||||
npc.facing = "up" -- $36, look up
|
||||
ow.pikaHop = {
|
||||
npc = npc, frames = 0, cellX = cx, cellY = cy, onDone = done,
|
||||
fromX = npc.px, fromY = npc.py, toX = cx * 16, toY = cy * 16,
|
||||
}
|
||||
end
|
||||
|
||||
-- One frame of that hop. OverworldState:update holds the world for it the
|
||||
-- way it holds for the heal machine (only the top state updates, so this
|
||||
-- has to sit between the two text boxes); the arc matches Player:pose's
|
||||
-- ledge hop -- a 10px sine over 32 frames.
|
||||
function PikachuFollower.updateHop(ow)
|
||||
local h = ow.pikaHop
|
||||
if not h then return end
|
||||
h.frames = h.frames + 1
|
||||
local t = math.min(1, h.frames / HOP_FRAMES)
|
||||
h.npc.px = h.fromX + (h.toX - h.fromX) * t
|
||||
h.npc.py = h.fromY + (h.toY - h.fromY) * t
|
||||
- math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
if h.frames < HOP_FRAMES then return end
|
||||
h.npc.cellX, h.npc.cellY = h.cellX, h.cellY
|
||||
h.npc.px, h.npc.py = h.toX, h.toY
|
||||
ow.pikaHop = nil
|
||||
-- the player has not moved, so the trail restarts under his feet and the
|
||||
-- follower only steps back off the counter once he walks away
|
||||
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
||||
if h.onDone then h.onDone() end
|
||||
end
|
||||
|
||||
-- Disable/EnablePikachuOverworldSpriteDrawing around the healing machine
|
||||
-- (engine/events/pokecenter.asm): Pikachu goes behind the counter with the
|
||||
-- party and comes back standing on it, facing the player -- the respawn is
|
||||
-- wPikachuSpawnState = 5, which is .above_player in pikachu_follow.asm,
|
||||
-- followed by `lb bc, 15, 0` (sprite struct 15 is Pikachu, image index 0
|
||||
-- is facing down). ow.entities is the draw list and ow.npcs the update
|
||||
-- list, so dropping it from entities alone hides it in place (#417).
|
||||
function PikachuFollower.setVisible(ow, visible)
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
for i, e in ipairs(ow.entities or {}) do
|
||||
if e == npc then table.remove(ow.entities, i) break end
|
||||
end
|
||||
if visible then
|
||||
npc.facing = "down"
|
||||
table.insert(ow.entities, npc)
|
||||
end
|
||||
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
|
||||
+21
-7
@@ -4,6 +4,7 @@
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
@@ -237,17 +238,30 @@ end
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
local sprite, px, py, facing, phase, flip, hopping = self:pose()
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
||||
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
|
||||
-- the shadow stays on the ground under the jumper, mirrored out of the
|
||||
-- single 8x8 tile the ROM stores -- but the two engines lay it out
|
||||
-- differently, and their shadow.png tiles differ to match.
|
||||
-- RED/BLUE: a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left sits
|
||||
-- 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
||||
-- LedgeHoppingShadowOAMBlock at "lb bc, $54, $48",
|
||||
-- engine/overworld/ledges.asm); its tile is blank above the bottom
|
||||
-- four rows, so the four copies make one 16x16 ellipse.
|
||||
-- YELLOW: a single 16x8 row 4px lower. Its LoadHoppingShadowOAM
|
||||
-- copies only two entries (LedgeHoppingShadowOAM: dbsprite 9,11 and
|
||||
-- dbsprite 10,11 OAM_XFLIP, raw OAM y=88 against RED's $54=84) and
|
||||
-- parks sprites 38/39 offscreen at y=$a0, because its tile is a
|
||||
-- full-height half-ellipse that already fills the row. Mirroring
|
||||
-- that tile downward stacked a second blob under the first (#408).
|
||||
if hopping and self.shadowImg then
|
||||
local yellow = GameVersion.isYellow()
|
||||
local sx = math.floor(self.px - camX)
|
||||
local sy = math.floor(self.py - camY) - 4 + 8
|
||||
local sy = math.floor(self.py - camY) - 4 + 8 + (yellow and 4 or 0)
|
||||
love.graphics.draw(self.shadowImg, sx, sy)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
if not yellow then
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
end
|
||||
end
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user