title screen issues, audio issues, and replacing gf c

This commit is contained in:
bryanthaboi
2026-08-10 14:00:26 -04:00
parent 943ba5dcbf
commit 12c2677dc2
14 changed files with 830 additions and 181 deletions
+5
View File
@@ -1893,6 +1893,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
@@ -1929,6 +1930,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
@@ -1990,9 +1992,11 @@ function BattleState:update(dt)
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
@@ -2031,6 +2035,7 @@ function BattleState:update(dt)
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local pick = moves[self.mimicIndex]
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
+83 -30
View File
@@ -111,6 +111,11 @@ local NOISE_DIVISORS = {
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
}
local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE)
local LPF_ALPHA = 0.8
local MIX_SCALE = 0.5
local function snapTicks(ticks)
return math.floor((ticks * 1470 + 256) / 512)
end
@@ -269,6 +274,7 @@ function Channel.new(engine, spec, options)
phase = 0,
noiseLfsr = 0x7FFF,
noiseClock = 0,
drumTail = nil,
timeTicks = 0,
}, Channel)
end
@@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed)
return math.min(15, volume + steps)
end
local function envelopeRingSamples(volume, fade)
if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end
return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5)
end
local function extendDrumEnvelope(segments)
local last = segments and segments[#segments]
if not last then return segments end
local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade)
if ringEnd > last.endSample then last.endSample = ringEnd end
return segments
end
local function drumAudioEnd(drum)
local last = drum and drum[#drum]
return last and last.endSample or 0
end
function Channel:resetNoise()
self.noiseLfsr = 0x7FFF
self.noiseClock = 0
@@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter)
end
end
end
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0
end
local function sweepCalculation(register, sweep)
@@ -611,21 +635,51 @@ end
function Channel:sample()
while not self.ended
and (not self.event or self.event.sample >= self.event.samples) do
local prev = self.event
self.event = self:nextEvent()
self.phase = 0
self:resetNoise()
if self.event and self.event.drum then
self.drumTail = nil
self:resetNoise()
elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then
-- ..(audio/engine_1.asm ln 197)
self.drumTail = prev
elseif not (self.event and self.event.silence and self.drumTail) then
self.drumTail = nil
self:resetNoise()
end
end
local event = self.event
if not event then return 0 end
local gain = channelVolume[self.hardware] or 1
if not event then
local tail = self.drumTail
if not tail then return 0 end
local sampleIndex = tail.sample
tail.sample = sampleIndex + 1
if sampleIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, sampleIndex) * gain
end
local sampleIndex = event.sample
event.elapsed = sampleIndex / SAMPLE_RATE
event.sample = sampleIndex + 1
if event.silence then return 0 end
local gain = channelVolume[self.hardware] or 1
if event.silence then
local tail = self.drumTail
if not tail then return 0 end
local tailIndex = tail.sample
tail.sample = tailIndex + 1
if tailIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, tailIndex) * gain
end
if event.drum then
return self:sampleDrum(event, sampleIndex) * gain
end
self.drumTail = nil
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
@@ -665,7 +719,8 @@ 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 * gain
local nibble = math.max(0, math.min(15, wave[index] * 8 + 8))
return (nibble / 15) * event.waveLevel * gain
end
local duty = event.duty
if type(duty) == "table" then
@@ -674,7 +729,7 @@ function Channel:sample()
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
return 0
end
return volume / 15 * gain
end
@@ -685,7 +740,7 @@ Engine.__index = Engine
function Engine:noiseInstrument(number)
-- a def-local drum wins over the ROM engine's table for that id
local custom = self.customDrums and self.customDrums[number]
if custom then return custom end
if custom then return extendDrumEnvelope(custom) end
local cached = self.noiseInstruments[number]
if cached then return cached end
@@ -718,6 +773,7 @@ function Engine:noiseInstrument(number)
end
end
extendDrumEnvelope(segments)
self.noiseInstruments[number] = segments
return segments
end
@@ -792,6 +848,8 @@ function Engine.new(data, header, options)
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0,
lpf = 0, lpfLeft = 0, lpfRight = 0,
}, Engine)
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
@@ -826,10 +884,20 @@ function Engine:finished()
return true
end
local function analogOut(engine, input, hpfField, lpfField)
local cap = engine[hpfField]
local hp = input - cap
engine[hpfField] = input - hp * HPF_CHARGE
local prev = engine[lpfField]
local lp = prev + LPF_ALPHA * (hp - prev)
engine[lpfField] = lp
return math.max(-1, math.min(1, lp * MIX_SCALE))
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 / 4))
return analogOut(self, value, "hpfCap", "lpf")
end
function Engine:sampleStereo()
@@ -840,8 +908,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 / 4)),
math.max(-1, math.min(1, right / 4))
return analogOut(self, left, "hpfCapLeft", "lpfLeft"),
analogOut(self, right, "hpfCapRight", "lpfRight")
end
function Engine:sampleChannel(number)
@@ -850,7 +918,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 / 4))
return analogOut(self, selected, "hpfCap", "lpf")
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
@@ -870,22 +938,7 @@ local function soundData(engine, samples, channels)
return result
end
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
-- it is too short to be audible. The caller wraps it in a static
-- love.audio.Source (a playback concern, hence not done here).
--
-- The synthesis is mono (one summed value per frame, unlike the music path's
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
-- position, exactly where the listener sits, is rendered as an ambient sound
-- spread over EVERY output channel the device exposes at gains that differ
-- from the front pair. On an interface with more than two outputs that put
-- the SFX on outputs 5+6 as well, while the 2-channel music source
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
-- spatialization entirely and map onto the front pair, so duplicating the
-- sample costs one buffer's memory and makes effects route exactly like
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
-- and would newly hard-pan any effect whose header issues command 0xEE.
local function renderEffectData(data, header, options)
if not header then return nil end
options = options or {}
+37 -39
View File
@@ -1,12 +1,3 @@
-- Music playback supports compact ROM channel programs synthesized live by
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
-- branch is chosen per song definition, never by a global import flag, so a
-- file-backed song and a chip song coexist in one dataset. Songs with split
-- files chain def.file into def.loopFile in Music.update().
-- Map themes switch on map change; battles override with the battle
-- theme and restore afterwards; riding the bike overrides outdoor map
-- themes with the bike song until dismount.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
@@ -14,20 +5,10 @@ local Music = {}
local VOLUME = 0.7
-- port additions driven by OptionsMenu / save.options: musicVol scales
-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter
-- low-passes the song. Each filter step keeps 40% of the previous
-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter
-- applied twice/three times over.
local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
@@ -115,11 +96,6 @@ function Music.duckForFanfare(src)
end
end
-- Overworld themes where the bike can be ridden (outdoor maps plus the
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
-- for caches built before the importer wrote the table.
local OUTDOOR = {
Music_PalletTown = true,
Music_Cities1 = true,
@@ -218,6 +194,7 @@ end
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
if ctx and ctx.selected then return song end
if not Runtime.wantsHook("music.select") then return song end
return Runtime.call("music.select", function(chosen) return chosen end, song, {
reason = ctx and ctx.reason or "direct",
@@ -234,17 +211,28 @@ end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
ctx = ctx or {}
song = selectSong(song, ctx)
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
-- the same song restarted with channel 1 re-pointed at a stub whose only
-- difference is its `tempo`, so the same label at a different tempo is a
-- different cue and must not be deduped away (#847)
local tempo = ctx and ctx.tempo or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
if ctx.fade and state.source then
local queued = {}
for key, value in pairs(ctx) do queued[key] = value end
queued.fade, queued.selected = nil, true
local pending = { data = data, song = song, loop = loop, ctx = queued }
if state.fade then
state.fade.pending = pending
else
Music.fadeOut(ctx.fade, pending)
end
return
end
if tempo then
-- shallow copy: the registry def is shared, only this playback is slowed
local slowed = {}
@@ -317,24 +305,26 @@ function Music.reload()
Music.stop()
end
-- Ramp the current song's volume to silence, then stop it, mirroring the
-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio +
-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in
-- integer levels, one level every `control` frames, and the music stops
-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
-- to silence). Ticked once per frame from Music.update().
function Music.fadeOut(control)
if not state.source then Music.stop() return end
function Music.fadeOut(control, pending)
if not state.source then
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
control = math.max(1, control or 10)
state.fade = {
control = control,
counter = control, -- frames until the next volume step
level = 7, -- current master-volume level (rAUDVOL nibble)
from = VOLUME * volumeScale, -- level-7 (full) source volume
pending = pending,
}
end
Music.MAP_FADE = 10
-- the song a map should currently play, honoring the bike/surf overrides
local function effectiveMapSong(data, song)
if not song or not outdoorSongs(data)[song] then return song end
@@ -351,14 +341,17 @@ end
-- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing)
function Music.playMap(data, mapId, onBike, surfing, fade)
local song = data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil
state.mapSong = song
state.onBike = not not onBike
state.surfing = not not surfing
local play = effectiveMapSong(data, song)
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
if play then
Music.play(data, play, nil,
{ reason = "map", mapId = mapId, fade = fade })
end
end
-- toggle the surf override mid-map (starting/ending a surf)
@@ -475,8 +468,13 @@ function Music.update(data)
f.counter = f.control
f.level = f.level - 1
if f.level <= 0 then
-- ..(home/fade_audio.asm ln 36)
state.fade = nil
local pending = f.pending
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
local vol = f.from * f.level / 7
+65 -59
View File
@@ -1,29 +1,5 @@
-- Boot splash + attract movie, a faithful port of PlayIntro
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
-- using the real extracted art (data/generated/field.lua `intro` manifest).
--
-- Three frame-counted phases:
-- 1. copyright card, 180 frames (intro.asm:311-312).
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
-- the big star streaks down-left for 40 frames while the studio logo
-- sits centered in the letterbox band (the GAME FREAK logo + letter
-- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228),
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
-- (intro.asm:329-331).
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
-- (GBFadeOutToWhite, home/fade.asm:26-40).
--
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
-- Pops itself and calls onDone() when finished or skipped. All art loads
-- through pcall and every missing graphic degrades to a text/rect
-- fallback, so the movie stays headless-safe.
-- ..(engine/movie/intro.asm ln 8)
-- ..(engine/movie/splash.asm ln 27)
local Font = require("src.render.Font")
local Music = require("src.core.Music")
@@ -76,15 +52,15 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
-- cancel)
-- ..(engine/movie/splash.asm ln 211)
local LOGO_X, LOGO_Y = 72, 56
local TEXT_X, TEXT_Y = 40, 80
-- ..(engine/movie/title.asm ln 390)
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 }
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 }
-- The studio logo (assets/logo/minilogo.png) stands in for both the
-- GAME FREAK logo and its letter row, so it gets the whole band between
-- the letterbox bars (y 32..112) down to where the star waves spawn
-- (y=88): fit it inside this box, centered, aspect preserved.
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
-- the 4 waves of small stars: screen X positions, all spawning at y=88
@@ -155,10 +131,19 @@ function IntroMovie.new(game, onDone)
self.studio = intro.studio or {}
self.skipAll = intro.skip and true or false
local function img(e) return tryImage(e and e.path) end
self.copyright = tryImage("assets/generated/title/copyright.png")
-- studio mark replaces the GAME FREAK logo + splash text entirely; the
-- extracted logo stays as the fallback if the asset is missing
self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png")
local titleCfg = game.data.field and game.data.field.title or {}
self.copyright = img(titleCfg.copyright)
or tryImage("assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyright then
local iw, ih = self.copyright:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = img(titleCfg.gamefreakInc)
or tryImage("assets/generated/title/gamefreak_inc.png")
self.studioLogo = tryImage(self.studio.logo)
if self.studioLogo then
self.studioLogo:setFilter("nearest", "nearest")
local iw, ih = self.studioLogo:getDimensions()
@@ -306,24 +291,12 @@ function IntroMovie:drawSplash()
if self.studioLogo then
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
0, self.studioScale, self.studioScale)
elseif self.logo then
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
else
if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end
if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end
end
love.graphics.setColor(1, 1, 1, 1)
end
if t >= STAR_START and t < FLASH_START then
-- big star: from OAM (160,0) moving +4Y/-4X per frame
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
if t >= WAVES_START then
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
-- +1px per 3-frame substep until the wave loop ends; the lower star in
@@ -351,6 +324,18 @@ function IntroMovie:drawSplash()
end
end
drawBars()
if t >= STAR_START and t < FLASH_START then
-- ..(engine/movie/splash.asm ln 32)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
end
function IntroMovie:drawFight()
@@ -379,17 +364,38 @@ function IntroMovie:drawFight()
end
end
function IntroMovie:drawCopyright()
if self.studio.card or self.studio.credit then
local card = self.studio.card or ""
local credit = self.studio.credit or ""
love.graphics.setColor(0, 0, 0, 1)
Font.draw(card, (160 - #card * 8) / 2, 64)
Font.draw(credit, (160 - #credit * 8) / 2, 80)
elseif self.copyright and self.gfInc then
local function row(seq, x, y)
for _, t in ipairs(seq) do
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
x = x + 8
end
end
for _, y in ipairs({ 56, 72, 88 }) do row(COPY_PREFIX, 16, y) end
row(COPY_NINTENDO, 80, 56)
row(COPY_CREATURES, 80, 72)
love.graphics.draw(self.gfInc, 80, 88)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Nintendo"), 80, 56)
Font.draw(Strings("Creatures inc."), 80, 72)
Font.draw(Strings("GAME FREAK inc."), 16, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
function IntroMovie:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == 1 then
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1)
local credit = self.studio.credit or Strings("bois club")
Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
self:drawCopyright()
elseif self.phase == 2 then
self:drawSplash()
else
+169 -46
View File
@@ -103,7 +103,31 @@ local YELLOW_CYCLE_SPECIES = {
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
-- ..(engine/movie/title.asm ln 227)
local HOLD_FRAMES = 200
local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true }
-- ..(engine/movie/title2.asm ln 13)
local function scrollFrames(steps, offset)
local frames = {}
for _, step in ipairs(steps) do
for _ = 1, step[2] do
frames[#frames + 1] = offset
offset = offset - step[1]
end
end
return frames
end
local OUT_FRAMES = scrollFrames(
{ { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 },
{ 8, 3 }, { 9, 3 } }, 0)
local IN_FRAMES = scrollFrames(
{ { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 },
{ 1, 1 } }, 120)
-- ..(engine/movie/title2.asm ln 85)
local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
local BALL_REST = 100
local function tryImage(path)
if not path then return nil end
@@ -178,6 +202,27 @@ function TitleState.new(game, opts)
or "assets/generated/title/red_version.png")
self.player = tryImage(imagePath(title.player)
or "assets/generated/title/player.png")
-- ..(engine/movie/title2.asm ln 85)
if self.player then
local pw, ph = self.player:getDimensions()
self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph)
self.playerQuads = {
{ love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 },
{ love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 },
{ love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 },
}
end
self.copyImg = tryImage(imagePath(title.copyright)
or "assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyImg then
local iw, ih = self.copyImg:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = tryImage(imagePath(title.gamefreakInc)
or "assets/generated/title/gamefreak_inc.png")
self.blue = GameVersion.isBlue()
self.yellow = GameVersion.isYellow()
or title.layout == "yellow_pikachu"
@@ -203,7 +248,10 @@ function TitleState.new(game, opts)
self.blinkTimer = 0
self.blinkAt = nil
else
self.phase = "loop"
-- ..(engine/movie/title.asm ln 28)
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = true
end
local defaultCycle = self.yellowLayout and { "PIKACHU" }
@@ -216,14 +264,15 @@ function TitleState.new(game, opts)
self.cycleIndex = 1
self.timer = 0
self.blink = 0
self.scrollPhase = "hold"
self.scrollFrame = 1
self.monOffset = 0
self.ballY = BALL_REST
return self
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
if self.phase ~= "loop" then return end
self:startMusic()
end
@@ -240,6 +289,11 @@ end
local DROP_STEPS = {
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
}
local SETTLE_FRAMES = 36
-- ..(engine/movie/title.asm ln 201)
local RIBBON_FRAMES = {}
for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end
-- the boot cinematic up to the interactive loop; one call per frame
function TitleState:updateSequence()
@@ -263,12 +317,23 @@ function TitleState:updateSequence()
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
if self.timer >= SETTLE_FRAMES then
Sound.play(data, "Intro_Whoosh")
self.showBubble = true
self.phase = "bubble"
self.phase = self.yellowLayout and "bubble" or "ribbon"
self.ribbonOffset = RIBBON_FRAMES[1]
self.timer = 0
end
elseif self.phase == "ribbon" then
self.timer = self.timer + 1
local offset = RIBBON_FRAMES[self.timer + 1]
if offset then
self.ribbonOffset = offset
else
self.ribbonOffset = nil
self:startMusic()
self.phase = "loop"
self.timer = 0
end
elseif self.phase == "bubble" then
@@ -432,12 +497,63 @@ function TitleState:openMenu()
game.stack:push(menu)
end
-- ..(engine/movie/title.asm ln 271)
function TitleState:pickNewMon()
if #self.cycleSpecies < 2 then return end
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
function TitleState:setCyclePhase(phase)
self.scrollPhase = phase
self.scrollFrame = 1
self.timer = 0
if phase == "in" then
self:pickNewMon()
self.monOffset = IN_FRAMES[1]
elseif phase == "out" then
self.monOffset = OUT_FRAMES[1]
elseif phase == "ball" then
self.ballY = BALL_FRAMES[1]
else
self.monOffset = 0
end
end
function TitleState:updateCycle()
local phase = self.scrollPhase
if phase == "hold" then
if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end
return
end
local frames = phase == "out" and OUT_FRAMES
or phase == "ball" and BALL_FRAMES or IN_FRAMES
self.scrollFrame = self.scrollFrame + 1
local value = frames[self.scrollFrame]
if value then
if phase == "ball" then self.ballY = value else self.monOffset = value end
return
end
if phase == "out" then
-- ..(engine/movie/title.asm ln 235)
self:setCyclePhase(
STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in")
elseif phase == "ball" then
self:setCyclePhase("in")
else
self:setCyclePhase("hold")
end
end
function TitleState:update(dt)
if self.phase ~= "loop" then
self:updateSequence()
return
end
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
@@ -452,21 +568,7 @@ function TitleState:update(dt)
end
self.timer = self.timer + 1
self.blink = (self.blink + 1) % 60
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
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
end
if self.slideIn and self.slideIn > 0 then
self.slideIn = self.slideIn - 1
end
self:updateCycle()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting);
@@ -478,15 +580,14 @@ function TitleState:update(dt)
end
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.
-- ..(engine/movie/title.asm ln 28)
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
local scrollY = -(self.scy or 0)
-- ..(engine/movie/title.asm ln 28)
local preRibbon = not self.yellowLayout
and (self.phase == "drop" or self.phase == "settle")
if self.logo then
love.graphics.draw(self.logo, 16, 8 + scrollY)
else
@@ -514,27 +615,29 @@ function TitleState:draw()
-- 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
if self.version and not self.yellow and not preRibbon then
local iw, ih = self.version:getDimensions()
local rx = self.ribbonOffset or 0
if self.versionFull then
-- a continuous ribbon (versionRibbon) centers as one piece
love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64)
love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64)
elseif self.blue then
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64)
else
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64)
end
end
local sprite, spriteTrueColor = self:currentSprite()
local sprite, spriteTrueColor
if self.scrollPhase ~= "ball" then
sprite, spriteTrueColor = self:currentSprite()
end
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 x = 40 + math.floor((56 - w) / 2) + self.monOffset
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
@@ -550,13 +653,33 @@ function TitleState:draw()
end
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
elseif self.player then
love.graphics.draw(self.player, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
end
-- ..(engine/movie/title.asm ln 117)
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
function TitleState:drawCopyright(y)
if not self.title.copyrightText and self.copyImg and self.gfInc then
local x = 16
for _, t in ipairs(COPY_PREFIX) do
love.graphics.draw(self.copyImg, self.copyQuads[t], x, y)
x = x + 8
end
love.graphics.draw(self.gfInc, x, y)
return
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
1, 136 + scrollY)
Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y)
love.graphics.setColor(1, 1, 1, 1)
end
+8 -4
View File
@@ -451,8 +451,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
self.keepMusicOnce = nil
if not keepMusic then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
-- ..(home/overworld.asm ln 2346)
local Music = require("src.core.Music")
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
Music.MAP_FADE)
end
-- forced bike/surf tiles fire the moment the player is placed on the
@@ -1093,8 +1095,10 @@ function OverworldState:update(dt)
local mapId = self.pendingSeamMusic
self.pendingSeamMusic = nil
if mapId == self.map.id then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
-- ..(home/overworld.asm ln 677)
local Music = require("src.core.Music")
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
Music.MAP_FADE)
end
end
if stepped and not scripted then
+66
View File
@@ -0,0 +1,66 @@
-- ..(engine/movie/title.asm ln 28)
-- ..(engine/movie/title2.asm ln 13)
-- POKEPORT_DRIVER=tests/drivers/title_cycle_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local shot = 0
local function grab(tag)
shot = shot + 1
U.shot(game, ("%s/title_%02d_%s.png"):format(DIR, shot, tag))
end
U.wait(30)
grab("copyright")
-- ..(engine/movie/splash.asm ln 230)
local movie = game.stack:top()
while movie.phase ~= 2 or movie.timer < 70 do U.wait(1) end
grab("star_topbar")
while movie.timer < 88 do U.wait(1) end
grab("star_middle")
while movie.timer < 100 do U.wait(1) end
grab("star_lowbar")
while movie.timer < 130 do U.wait(1) end
grab("gamefreak")
U.tap(game, "start")
U.wait(2)
local title = game.stack:top()
U.log("top is", tostring(title and title.screenId))
if not (title and title.scrollPhase) then
U.log("no TitleState on top; nothing below can run")
while true do coroutine.yield() end
end
grab("drop_early")
U.wait(14)
grab("drop_late")
while title.phase == "drop" do U.wait(1) end
grab("settle")
while title.phase == "settle" do U.wait(1) end
grab("ribbon_start")
U.wait(10)
U.shot(game, DIR .. "/title_ribbon_mid.png")
while title.phase ~= "loop" do U.wait(1) end
grab("landed")
title.cycleIndex = 1
title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0
title.monOffset = 0
while title.scrollPhase == "hold" do U.wait(1) end
grab("out_a")
U.wait(6)
grab("out_b")
while title.scrollPhase == "out" do U.wait(1) end
U.log("after the scroll out the phase is", title.scrollPhase)
for _ = 1, 5 do
grab("ball")
U.wait(1)
end
while title.scrollPhase == "ball" do U.wait(1) end
grab("in")
U.wait(30)
grab("next_mon")
U.log("captured", DIR)
while true do coroutine.yield() end
end
+6 -2
View File
@@ -65,8 +65,12 @@ function U.newGame(game)
U.wait(5)
U.tap(game, "start") -- skip intro movie
U.wait(10)
U.tap(game, "a") -- title -> menu
U.wait(5)
local title = game.stack:top()
for _ = 1, 60 do
U.tap(game, "a")
U.wait(5)
if game.stack:top() ~= title then break end
end
-- menu: CONTINUE may or may not exist; NEW GAME is first without a save
U.tap(game, "a")
U.wait(10)
+103
View File
@@ -0,0 +1,103 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local data = { audio = {} }
local function pulseSong()
return ChipAsm.song{
channels = { { hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 15, fade = 0 } },
{ octave = 4 },
{ note = "C", len = 15 },
} } },
}
end
local function noiseSong()
return ChipAsm.sfx{
channels = { { hw = 4, program = {
{ noiseNote = { len = 8, volume = 15, fade = 1, parameter = 0x34 } },
} } },
}
end
do
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
local sawNeg, sawPos = false, false
for _ = 1, 512 do
local v = engine.channels[1]:sample()
if v < -1e-12 then sawNeg = true end
if v > 1e-12 then sawPos = true end
end
check(sawPos and not sawNeg,
"pulse DAC is unipolar (high = volume, low = 0)")
end
do
local engine = ChipSynth.newEngine(data, noiseSong(), {
sfx = true, allowLoops = false,
})
local sawNeg, sawPos = false, false
for _ = 1, 2048 do
local v = engine.channels[1]:sample()
if v < -1e-12 then sawNeg = true end
if v > 1e-12 then sawPos = true end
end
check(sawPos and not sawNeg,
"noise DAC is unipolar (LFSR high = volume, low = 0)")
end
local function crossingsAndSign(engine, frames)
local count, prev = 0, nil
local sawNeg, sawPos = false, false
for _ = 1, frames do
local sample = engine:sample()
if sample < -1e-12 then sawNeg = true end
if sample > 1e-12 then sawPos = true end
if prev and prev * sample < 0 then count = count + 1 end
prev = sample
end
return count, sawNeg, sawPos
end
do
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
local count, sawNeg, sawPos = crossingsAndSign(engine, 4000)
check(sawNeg and sawPos, "HPF centers a unipolar pulse around analog 0")
check(count > 20, ("HPF'd pulse crosses zero (%d crossings)"):format(count))
end
do
local engine = ChipSynth.newEngine(data, noiseSong(), {
sfx = true, allowLoops = false,
})
local count, sawNeg, sawPos = crossingsAndSign(engine, 8000)
check(sawNeg and sawPos, "HPF centers noise / drums around analog 0")
check(count > 50, ("HPF'd noise crosses zero (%d crossings)"):format(count))
end
do
local song = pulseSong()
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
local a = ChipSynth.newEngine(data, song, { allowLoops = false })
local base = a.channels[1]:sample()
ChipSynth.setChannelVolume(1, 0.25)
local b = ChipSynth.newEngine(data, song, { allowLoops = false })
local quarter = b.channels[1]:sample()
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
check(base > 0 and math.abs(quarter - base * 0.25) < 1e-9,
"channelVolume still quarters the unipolar DAC level")
end
eq(type(ChipSynth.newEngine), "function", "engine factory still exported")
T.finish("chip analog path")
+82
View File
@@ -0,0 +1,82 @@
-- ..(audio/engine_1.asm ln 197)
-- ..(audio/sfx/noise_instrument01_1.asm ln 1)
-- luajit tests/engine/drum_envelope_ring.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local snare = ChipAsm.song{
channels = {
{ hw = 4, program = {
{ notetype = { speed = 12 } },
{ drum = 1, len = 2 },
{ rest = 16 },
} },
},
drums = {
[1] = {
{ len = 1, volume = 12, fade = 1, parameter = 0x33 },
},
},
}
local engine = ChipSynth.newEngine({ audio = {} }, snare, { allowLoops = false })
local segs = engine:noiseInstrument(1)
local last = segs[#segs]
local ringMs = (last.endSample - last.startSample) / ChipSynth.SAMPLE_RATE * 1000
check(ringMs > 150 and ringMs < 220,
("snare instrument rings ~188ms, not the 17ms note (%0.1fms)"):format(ringMs))
local energyEarly, energyLate, energyEnd = 0, 0, 0
local total = math.floor(ChipSynth.SAMPLE_RATE * 0.25)
for i = 1, total do
local s = engine:sample()
local e = s * s
local t = i / ChipSynth.SAMPLE_RATE
if t < 0.02 then
energyEarly = energyEarly + e
elseif t > 0.05 and t < 0.12 then
energyLate = energyLate + e
elseif t > 0.20 then
energyEnd = energyEnd + e
end
end
check(energyEarly > 0, "snare attack is audible")
check(energyLate > energyEarly * 0.05,
("snare body still sounds at 50-120ms (early=%.4f late=%.4f)")
:format(energyEarly, energyLate))
check(energyEnd < energyLate * 0.1,
"snare has decayed by 200ms")
local hats = ChipAsm.song{
channels = {
{ hw = 4, program = {
{ notetype = { speed = 12 } },
{ drum = 1, len = 2 },
{ drum = 1, len = 2 },
} },
},
drums = {
[1] = {
{ len = 1, volume = 8, fade = 1, parameter = 0x10 },
},
},
}
local hatEngine = ChipSynth.newEngine({ audio = {} }, hats, { allowLoops = false })
local hits = 0
local prev = 0
for _ = 1, math.floor(ChipSynth.SAMPLE_RATE * 0.3) do
local s = math.abs(hatEngine:sample())
if prev < 0.01 and s >= 0.01 then hits = hits + 1 end
prev = s
end
check(hits >= 2, ("two rapid drum_notes both trigger (%d onsets)"):format(hits))
T.finish("drum envelope ring")
+94
View File
@@ -0,0 +1,94 @@
-- ..(home/audio.asm ln 9)
-- ..(home/fade_audio.asm ln 36)
-- luajit tests/engine/map_music_fade.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
function Source:getDuration() return 1 end
local made = {} -- file -> the last source built for it
love.audio = {
newSource = function(file, mode)
made[file] = setmetatable({ file = file, mode = mode }, Source)
return made[file]
end,
}
local Music = require("src.core.Music")
local data = { audio = {
songs = {
Music_Pallet = { file = "pallet.wav" },
Music_Routes1 = { file = "routes1.wav" },
Music_Pewter = { file = "pewter.wav" },
},
mapSongs = {
PALLET_TOWN = "Music_Pallet",
ROUTE_1 = "Music_Routes1",
PEWTER_CITY = "Music_Pewter",
},
} }
local function frames(n)
for _ = 1, n do Music.update(data) end
end
local function playing()
for file, src in pairs(made) do
if src.playing then return file end
end
return "(silence)"
end
local FADE = 7 * Music.MAP_FADE -- 7 volume levels x 10 frames
Music.stop()
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
eq(playing(), "pallet.wav", "the first map after boot starts at once")
local fullVolume = made["pallet.wav"].volume
Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE)
eq(playing(), "pallet.wav", "the new theme waits while the old one fades")
frames(FADE - 1)
eq(playing(), "pallet.wav", "still fading one frame short of silence")
check(made["pallet.wav"].volume < fullVolume,
"the old theme has been ramped down by then")
frames(1)
eq(playing(), "routes1.wav", "the queued theme takes over after 7 * 10 frames")
eq(made["routes1.wav"].volume, fullVolume,
"the new theme starts at full volume, not where the ramp ended")
Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE)
eq(playing(), "routes1.wav", "the same theme keeps playing")
frames(FADE)
eq(playing(), "routes1.wav", "and no fade was armed for it")
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
frames(3 * Music.MAP_FADE)
Music.playMap(data, "PEWTER_CITY", false, false, Music.MAP_FADE)
eq(playing(), "routes1.wav", "the retargeted fade keeps ramping the old theme")
frames(4 * Music.MAP_FADE)
eq(playing(), "pewter.wav", "the ramp lands on the newest map's theme")
Music.playMap(data, "PALLET_TOWN", false, false)
eq(playing(), "pallet.wav", "a fadeless map cue swaps immediately")
T.finish("map_music_fade")
+2 -1
View File
@@ -37,7 +37,7 @@ end
local Y_TITLE = {
"pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png",
"player.png", "copyright.png", "yellow_version.png",
"player.png", "copyright.png", "gamefreak_inc.png", "yellow_version.png",
}
for _, name in ipairs(Y_TITLE) do
seed("yellow/assets/generated/title/" .. name)
@@ -266,6 +266,7 @@ GameVersion.set("blue")
seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes")
seed("blue/assets/generated/title/player.png")
seed("blue/assets/generated/title/copyright.png")
seed("blue/assets/generated/title/gamefreak_inc.png")
local B_INTRO = {
"gf_logo.png", "gf_text.png", "big_star.png",
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
+109
View File
@@ -0,0 +1,109 @@
-- ..(engine/movie/title.asm ln 227)
-- ..(engine/movie/title2.asm ln 13)
-- luajit tests/engine/title_mon_cycle.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
love.math = love.math or {}
local nextPick = 1
love.math.random = function(lo, hi)
nextPick = nextPick % hi + 1
return math.max(lo, nextPick)
end
local TitleState = require("src.ui.TitleState")
local title = TitleState.new(
{ data = {}, input = { wasPressed = function() return false end } }, {})
title.sprites = setmetatable({}, { __index = function() return false end })
eq(title.phase, "drop", "Red/Blue boot into the logo drop, not the loop")
local ribbonSeen = {}
for _ = 1, 400 do
if title.phase == "loop" then break end
title:update(1 / 60)
if title.phase == "ribbon" then
ribbonSeen[#ribbonSeen + 1] = title.ribbonOffset
end
end
eq(title.phase, "loop", "the cinematic lands within 400 frames")
eq(ribbonSeen[1], 112,
"the ribbon is parked off the right edge on its first drawn frame")
eq(ribbonSeen[#ribbonSeen], 4, "and walks in 4px a frame to its rest")
title.cycleIndex = 1 -- CHARMANDER: a starter, so the ball juggle runs
title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0
title.monOffset = 0
local frames = {}
for _ = 1, 260 do
title:update(1 / 60)
frames[#frames + 1] = {
phase = title.scrollPhase, offset = title.monOffset,
ball = title.ballY, mon = title.cycleSpecies[title.cycleIndex],
}
end
local HOLD_FRAMES = 200
local function span(phase)
local first, count = nil, 0
for i, f in ipairs(frames) do
if f.phase == phase then
if not first then first = i end
if first + count == i then count = count + 1 end
end
end
return first, count
end
local holdAt, holdLen = span("hold")
local outAt, outLen = span("out")
local ballAt, ballLen = span("ball")
local inAt, inLen = span("in")
eq(holdAt, 1, "the cycle opens on the hold")
eq(holdLen, HOLD_FRAMES - 1, "ld c, 200 / CheckForUserInterruption")
eq(outAt, HOLD_FRAMES, "the scroll out begins as the 200th hold frame ends")
eq(outLen, 18, "TitleScroll_Out is 2+2+2+2+2+2+3+3 frames")
eq(ballLen, 10, "TitleScroll_WaitBall is two runs of 5")
eq(inLen, 17, "TitleScroll_In is 2+4+4+3+2+1+1 frames")
check(outAt < ballAt and ballAt < inAt, "out, then the ball, then in")
local OUT = { 0, -1, -2, -4, -6, -9, -12, -16, -20, -25, -30, -36, -42,
-50, -58, -66, -75, -84 }
for i, want in ipairs(OUT) do
eq(frames[outAt + i - 1].offset, want,
"TitleScroll_Out offset at frame " .. i)
end
local IN = { 120, 110, 100, 91, 82, 73, 64, 56, 48, 40, 32, 26, 20, 14, 9,
4, 1 }
for i, want in ipairs(IN) do
eq(frames[inAt + i - 1].offset, want, "TitleScroll_In offset at frame " .. i)
end
local BALL = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
for i, want in ipairs(BALL) do
eq(frames[ballAt + i - 1].ball, want, "TitleBallYTable entry " .. i)
end
local outgoing = frames[outAt].mon
eq(outgoing, "CHARMANDER", "the starter is the one that scrolls out")
for i = outAt, inAt - 1 do
eq(frames[i].mon, outgoing,
"the pick does not change before the scroll in, at frame " .. i)
end
local incoming = frames[inAt].mon
check(incoming ~= outgoing, "TitleScreenPickNewMon never repeats the pick")
for i = inAt, inAt + inLen - 1 do
check(frames[i].offset > 0,
"the incoming mon is only ever drawn right of rest, at frame " .. i)
end
eq(frames[inAt + inLen].offset, 0, "and settles at its resting column")
T.finish("title_mon_cycle")
+1
View File
@@ -93,6 +93,7 @@ local titleGame = {
field = { title = { cycleSpecies = { "PIKACHU" } } } },
}
local title = TitleState.new(titleGame, {})
title.phase, title.scy = "loop", 0
local titleSprite, titleTrueColor = title:currentSprite()
check(titleSprite and titleTrueColor,
"title cache keeps a Pokemon sprite's trueColor flag")