mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
title screen issues, audio issues, and replacing gf c
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user