-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's -- welcome, the NIDORINO show-off, player and rival naming, and the -- closing "legend is about to unfold" text followed by the shrink-away. -- -- Steps are a data table so mods can reshape the whole speech through -- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a -- mod can insertBefore("name_player", ...) without counting indices. -- Calls onDone() after popping itself. local Assets = require("src.render.Assets") local Sound = require("src.core.Sound") local Music = require("src.core.Music") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") local Font = require("src.render.Font") local Strings = require("src.core.Strings") local OakSpeech = {} OakSpeech.__index = OakSpeech OakSpeech.isOpaque = true -- The speech is a white field with a pic on it, and its dialogue box docks to -- the WINDOW's bottom edge (Renderer:setUIAnchor, via TextBox). The white it -- fills below is only the 160x144 UI canvas, so once the box moved to the -- window edge the two stopped touching: black letterbox showed between the -- bottom of Oak's white and the top of the box he is speaking from. Filling -- the voids with the paper shade -- the same opt-in a battle uses -- puts the -- box back on the field. Not a literal 1,1,1: the canvas is colorized, so -- endFrame matches it with PaletteFX.paperShade. OakSpeech.letterboxWhite = true -- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon -- sprite in from the right. Both play out before the beat's text prints. local FADE_FRAMES = 24 local WIPE_FRAMES = 32 -- naming presets are boot config (field.boot.namePresets), which a total -- conversion replaces; the Red/Blue lists remain the fallback local function namePresets(game, who, fallback) local boot = game.data.field and game.data.field.boot local presets = boot and boot.namePresets and boot.namePresets[who] if type(presets) == "table" and #presets > 0 then return presets end return fallback end -- SGB: generic whole-screen palette (SET_PAL_GENERIC) function OakSpeech:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end local FALLBACKS = { _OakSpeechText1 = Strings.source("Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"), _OakSpeechText2A = Strings.source("This world is\ninhabited by\vcreatures called\vPOKéMON!"), _OakSpeechText2B = Strings.source("\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."), _OakSpeechText3 = Strings.source("{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"), _IntroducePlayerText = Strings.source("First, what is\nyour name?"), _IntroduceRivalText = Strings.source("This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"), _YourNameIsText = Strings.source("Right! So your\nname is {PLAYER}!"), _HisNameIsText = Strings.source("That's right! I\nremember now! His\vname is {RIVAL}!"), } local function textOr(game, key) local t = game.data.text return (t and t[key]) or FALLBACKS[key] end -- through Assets.resolve so an enabled mod's overrides/ shadows these the -- same way it shadows every other generated asset local function tryImage(path) if not path then return nil end local ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) return ok and img or nil end -- Resolve a pic descriptor to (image, flip, trueColor). -- Descriptors: -- "oak" | "rival" | "player" shorthand -- { type = "trainer", id = "OPP_PROF_OAK" } -- { type = "pokemon", id = "PIKACHU", flip = true } -- { type = "player", path = "..." } optional override path -- { type = "image", path = "..." } -- { type = "sprite", id = "SPRITE_RED" } function OakSpeech.resolvePic(game, desc, speech) if desc == nil then return nil, false, false end if type(desc) == "string" then if desc == "oak" then desc = { type = "trainer", id = "OPP_PROF_OAK" } elseif desc == "rival" then desc = { type = "trainer", id = "OPP_RIVAL1" } elseif desc == "player" then desc = { type = "player" } else -- bare species id desc = { type = "pokemon", id = desc } end end local t = desc.type if t == "trainer" then if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then return speech.oakPic, false, false end if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then return speech.rivalPic, false, false end local trainers = game.data.trainers or {} local tr = trainers[desc.id] return tryImage(tr and tr.pic), false, false elseif t == "pokemon" then if speech and desc.id == speech.demoSpecies and speech.demoPic then return speech.demoPic, desc.flip and true or false, speech.demoTrueColor end local path, trueColor = require("src.pokemon.Sprites").path( game.data, desc.id, "front", { kind = "oak" }) return tryImage(path), desc.flip and true or false, trueColor elseif t == "player" then if speech and speech.playerPic and not desc.path then return speech.playerPic, false, speech.playerTrueColor end if desc.path then return tryImage(desc.path), false, false end local path, trueColor = require("src.pokemon.Sprites").playerPath( game.data, "front", { kind = "intro" }) return tryImage(path), false, trueColor elseif t == "image" then return tryImage(desc.path), desc.flip and true or false, false elseif t == "sprite" then local sp = game.data.sprites and game.data.sprites[desc.id] return tryImage(sp and sp.image), desc.flip and true or false, sp and sp.trueColor or false end return nil, false, false end -- Vanilla step list. Ids are the stable anchors mods insert around. function OakSpeech.defaultSteps(speech) return { { id = "oak_welcome", kind = "say", textKey = "_OakSpeechText1", pic = "oak", reveal = "fade", }, { id = "demo_mon", kind = "demo", }, { id = "world_spiel", kind = "say", textKey = "_OakSpeechText2B", }, { id = "ask_player_name", kind = "say", textKey = "_IntroducePlayerText", pic = "player", }, { id = "name_player", kind = "name", who = "player", title = Strings("YOUR NAME?"), presetsWho = "player", presetsFallback = { "RED", "ASH", "JACK" }, }, { -- oak_speech.asm prints YourNameIsText right after the naming screen -- returns ("Right! So your name is RED!"); the port went straight on -- to the rival and dropped it, in every language. id = "confirm_player_name", kind = "say", textKey = "_YourNameIsText", }, { id = "ask_rival_name", kind = "say", textKey = "_IntroduceRivalText", pic = "rival", }, { id = "name_rival", kind = "name", who = "rival", title = Strings("HIS NAME?"), presetsWho = "rival", presetsFallback = { "BLUE", "GARY", "JOHN" }, }, { -- HisNameIsText, the rival's counterpart to the confirmation above id = "confirm_rival_name", kind = "say", textKey = "_HisNameIsText", }, { id = "legend", kind = "say", textKey = "_OakSpeechText3", pic = "player", }, { id = "shrink", kind = "shrink", }, } end -- list helpers for intro.oak_speech.build wrappers (also on ModUI) local function indexOfId(steps, id) for i, step in ipairs(steps) do if step.id == id then return i end end return nil end function OakSpeech.insertBefore(steps, anchorId, step) local i = indexOfId(steps, anchorId) table.insert(steps, i or (#steps + 1), step) return steps end function OakSpeech.insertAfter(steps, anchorId, step) local i = indexOfId(steps, anchorId) table.insert(steps, i and (i + 1) or (#steps + 1), step) return steps end function OakSpeech.removeId(steps, id) for i = #steps, 1, -1 do if steps[i].id == id then table.remove(steps, i) end end return steps end function OakSpeech.new(game, onDone) local self = setmetatable({}, OakSpeech) self.game = game self.onDone = onDone self.step = 0 self.pic = nil self.answers = {} local trainers = game.data.trainers or {} self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic) local oakGfx = (game.data.field and game.data.field.oakSpeech) or {} self.cfg = oakGfx -- the show-off mon and the name length cap come from data; the vanilla -- literals stay as the fallbacks self.demoSpecies = oakGfx.demoSpecies or "NIDORINO" local demoPath, demoTrueColor = require("src.pokemon.Sprites").path( game.data, self.demoSpecies, "front", { kind = "oak" }) self.demoPic = tryImage(demoPath) self.demoTrueColor = self.demoPic and demoTrueColor or false local constants = game.data.constants or {} self.nameLen = constants.playerNameLength or 7 -- RedPicFront (gfx/player/red.png, shared with the trainer card) and -- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png) local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath( game.data, "front", { kind = "intro" }) self.playerPic = tryImage(playerPath) self.playerTrueColor = self.playerPic and playerTrueColor or false self.shrinkPic1 = tryImage(oakGfx.shrink1 or "assets/generated/intro/shrink1.png") self.shrinkPic2 = tryImage(oakGfx.shrink2 or "assets/generated/intro/shrink2.png") -- RedSprite: the walking sprite the pic shrinks into (frame 0 = -- standing, facing down) local playerSprites = (game.data.field and game.data.field.playerSprites) or {} -- The fallback has to read the same guarded table: reaching for -- game.data.sprites.SPRITE_RED after the `and` already found it nil threw. local sprites = game.data.sprites or {} local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED self.walkSheet = tryImage(red and red.image) return self end function OakSpeech:buildSteps() local steps = OakSpeech.defaultSteps(self) local hooked = Runtime.call("intro.oak_speech.build", function(s) return s end, steps, self) if type(hooked) ~= "table" then Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps", type(hooked)) return steps end return hooked end function OakSpeech:enter() -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) Music.play(self.game.data, self.cfg.music or "Music_Routes2") self.answers = {} self.steps = self:buildSteps() if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end self:advance() end function OakSpeech:say(key, next) self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next)) end function OakSpeech:sayText(text, next, opts) self.game.stack:push(TextBox.new(self.game, text, next, opts)) end function OakSpeech:stepText(step) if step.text then return step.text end if step.textKey then return textOr(self.game, step.textKey) end return "" end function OakSpeech:applyPic(step) if step.pic == nil then return end local img, flip, trueColor = OakSpeech.resolvePic(self.game, step.pic, self) if img then self.pic = img self.picFlip = flip or false self.picTrueColor = trueColor or false elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then -- mirror the old fallback: player pic missing → oak self.pic = self.playerPic or self.oakPic self.picFlip = false self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor or false end end function OakSpeech:recordAnswer(step, index, label, value) if value == nil then value = label end if step.saveKey then self.answers[step.saveKey] = value end if Runtime.wants("intro.oak_speech.answered") then Runtime.emit("intro.oak_speech.answered", { speech = self, step = step, index = index, label = label, value = value, saveKey = step.saveKey, }) end end function OakSpeech:afterReveal(step, fn) if step.reveal then self:revealPic(step.reveal, fn) else fn() end end function OakSpeech:runCry(step) local cry = step.cry if not cry then return end if cry == true then if type(step.pic) == "string" and step.pic ~= "oak" and step.pic ~= "rival" and step.pic ~= "player" then cry = step.pic elseif type(step.pic) == "table" and step.pic.type == "pokemon" then cry = step.pic.id else return end end Sound.playCry(self.game.data, cry) end function OakSpeech:runStep(step) local kind = step.kind or "say" if kind == "say" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) self:sayText(self:stepText(step), function() self:advance() end) end) elseif kind == "demo" then -- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A self.pic = self.demoPic self.picFlip = true self.picTrueColor = self.demoTrueColor self:revealPic("wipe", function() Sound.playCry(self.game.data, self.demoSpecies) self:say(Strings("_OakSpeechText2A"), function() self:advance() end) end) elseif kind == "name" then local who = step.who or "player" local presets = step.presets or namePresets(self.game, step.presetsWho or who, step.presetsFallback or { "RED" }) require("src.ui.Screens").push(self.game, "NamingScreen", { title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), presets = presets, maxLen = step.maxLen or self.nameLen, onDone = function(name) if who == "rival" then self.game.save.player.rival = name else self.game.save.player.name = name end self:recordAnswer(step, 1, name, name) self:advance() end, }) elseif kind == "choice" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) local function openMenu() local Menu = require("src.ui.Menu") local items = {} for i, label in ipairs(step.choices or {}) do items[i] = { label = label, onSelect = function() local value = label if step.values and step.values[i] ~= nil then value = step.values[i] end self:recordAnswer(step, i, label, value) self:advance() end, } end self.game.stack:push(Menu.new(self.game, items, { cancelable = step.cancelable == true, tx = step.tx or 4, ty = step.ty or 0, tw = step.tw or 12, th = step.th, })) end local text = self:stepText(step) if text ~= "" then self:sayText(text, openMenu) else openMenu() end end) elseif kind == "yesno" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) self:sayText(self:stepText(step), nil, { defaultNo = step.defaultNo, choice = function(yes) local label = yes and "YES" or "NO" local value = yes if step.values then if yes then value = step.values[1] else value = step.values[2] end end self:recordAnswer(step, yes and 1 or 2, label, value) self:advance() end, }) end) elseif kind == "pic" then -- show a sprite with an optional reveal, no text self:applyPic(step) self:afterReveal(step, function() self:runCry(step) self:advance() end) elseif kind == "shrink" then Sound.play(self.game.data, "Shrink") -- prefer an explicit shrink text key; fall back to the legend beat local key = step.textKey or "_OakSpeechText3" self.shrinkText = self:lastPageLines(key) self.shrink = { frame = 0 } elseif kind == "fn" then -- full escape hatch: step.run(speech, done) if type(step.run) == "function" then step.run(self, function() self:advance() end) else self:advance() end else Logger.warn("oak speech unknown step kind %s (id=%s); skipping", tostring(kind), tostring(step.id)) self:advance() end end -- the last two visible lines of a text's final page, pre-encoded function OakSpeech:lastPageLines(key) local ok, lines = pcall(function() local text = TextBox.substitute(self.game, textOr(self.game, key)) local pages = TextBox.paginate(text) local page = pages[#pages] local out = {} for i = math.max(1, #page - 1), #page do out[#out + 1] = Font.encode(page[i]) end return out end) return ok and lines or nil end -- Reveal the current pic over `dur` frames, then run `next`. Ticked from -- update() while OakSpeech is the top state (before the beat's text box is -- pushed), so the fade/wipe plays out ahead of the text like the ROM. function OakSpeech:revealPic(kind, next) self.picReveal = { kind = kind, t = 0, dur = kind == "fade" and FADE_FRAMES or WIPE_FRAMES, next = next, } end function OakSpeech:advance() self.step = self.step + 1 -- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A -- and 2B over one flipped NIDORINO with no redraw between them -- (oak_speech.asm:80-83), so a pic-less step must not un-mirror what is -- still on screen; only applyPic and the demo step may change it (#397) local steps = self.steps if not steps then -- enter() builds steps; keep a path for callers that advance early steps = self:buildSteps() self.steps = steps end local step = steps[self.step] if step then if Runtime.wants("intro.oak_speech.step") then Runtime.emit("intro.oak_speech.step", { speech = self, step = step, index = self.step, }) end self:runStep(step) else self:finish() end end function OakSpeech:finish() if Runtime.wants("intro.oak_speech.finished") then Runtime.emit("intro.oak_speech.finished", { speech = self, answers = self.answers, }) end -- the map theme starts with the overworld beneath (the original's -- special warp into Pallet Town) local ow = self.game.overworld local mapId = (ow and ow.map and ow.map.id) or (self.game.save.player and self.game.save.player.map) if mapId then Music.playMap(self.game.data, mapId) end self.game.stack:pop() if self.onDone then self.onDone() end end -- Shrink timeline (oak_speech.asm .next): -- frames 1-4 RedPicFront still up (ld c, 4 / DelayFrames) -- frames 5-8 ShrinkPic1 (ld c, 4 / DelayFrames) -- frames 9-28 ShrinkPic2, music fades (wAudioFadeOutControl; ld c, 20) -- frames 29-78 pic area cleared, walking sprite at the standard -- player screen spot (ResetPlayerSpriteData / -- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50) -- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames) function OakSpeech:update(dt) local r = self.picReveal if r then r.t = r.t + 1 if r.t >= r.dur then self.picReveal = nil if r.next then r.next() end end return end if not self.shrink then return end local s = self.shrink s.frame = s.frame + 1 if s.frame == 5 then self.pic = self.shrinkPic1 or self.pic self.picTrueColor = false elseif s.frame == 9 then self.pic = self.shrinkPic2 or self.pic self.picTrueColor = false -- wAudioFadeOutControl = 10: the music ramps to silence over ~70 -- frames (7 levels x 10), reaching 0 just as the fade-to-white -- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149, -- home/fade_audio.asm) Music.fadeOut(10) elseif s.frame == 29 then self.pic = nil self.picTrueColor = false self.walkVisible = true elseif s.frame >= 79 and s.frame <= 102 then self.fadeLevel = math.floor((s.frame - 79) / 8) + 1 elseif s.frame > 102 then -- clear before finish(): a finished-listener that pushes a state gets -- ITS state popped in the speech's place, and a live shrink would call -- finish() again next frame, re-firing the event every frame (#308) self.shrink = nil self:finish() end end function OakSpeech:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) if self.pic then -- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic -- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside -- it like the sprite buffer does ((8 - w) >> 1) tiles across, -- bottom-aligned local w, h = self.pic:getDimensions() local x = 48 + math.floor((8 - w / 8) / 2) * 8 local y = 32 + (7 - h / 8) * 8 local reveal = self.picReveal local off = 0 if reveal and reveal.kind == "fade" then -- FadeInIntroPic: ramp the pic's alpha up over the fade window love.graphics.setColor(1, 1, 1, math.min(1, reveal.t / reveal.dur)) elseif reveal and reveal.kind == "wipe" then -- MovePicLeft: the pic slides in from the right edge to its spot off = math.floor((160 - x) * (1 - math.min(1, reveal.t / reveal.dur))) end if self.picFlip then -- LoadFlippedFrontSpriteByMonIndex mirrors the front sprite -- horizontally (wSpriteFlipped): draw with a negative x scale love.graphics.draw(self.pic, x + off + w, y, 0, -1, 1) else love.graphics.draw(self.pic, x + off, y) end if self.picTrueColor then require("src.render.PaletteFX").markTrueColor(x + off, y, w, h) end love.graphics.setColor(1, 1, 1, 1) end if self.walkVisible and self.walkSheet then -- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40 self.walkQuad = self.walkQuad or love.graphics.newQuad(0, 0, 16, 16, self.walkSheet:getDimensions()) love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60) end if self.shrinkText then -- This is a REPLICA of the dialogue box that just closed, redrawn at -- TextBox's own rect (BOX_TX..BOX_TH = 0,12,20,6) so the last page holds -- while the pic shrinks. The real box rides the bottom anchor, so this -- one has to as well -- otherwise the text visibly jumps up a letterbox -- on the frame the real box is swapped for this copy. local r = self.game and self.game.renderer if r and r.setUIAnchor then r:setUIAnchor(0, 12 * 8, 20 * 8, 6 * 8, "bottom") end Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) for i, line in ipairs(self.shrinkText) do local y = (12 + 2 * i) * 8 for j, code in ipairs(line) do Font.drawCode(code, 8 + (j - 1) * 8, y) end end love.graphics.setColor(1, 1, 1, 1) end if self.fadeLevel then love.graphics.setColor(1, 1, 1, self.fadeLevel / 3) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) end end return OakSpeech