mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
intro api upgrade (#294)
* intro api upgrade * api updates * fix tests * updated templates
This commit is contained in:
@@ -299,8 +299,13 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
curStats = mon.stats,
|
||||
curTypes = def.types,
|
||||
curMoves = mon.moves,
|
||||
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
|
||||
monPalette(data, mon.species), def.trueColor),
|
||||
sprite = (function()
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(data, mon.species,
|
||||
isPlayer and "back" or "front",
|
||||
{ mon = mon, kind = "battle" })
|
||||
return getImage(path, monPalette(data, mon.species), tc)
|
||||
end)(),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -318,13 +323,16 @@ BattleState.makeBattler = makeBattler
|
||||
function BattleState:speciesSprite(species, isPlayerSide)
|
||||
local def = self.data.pokemon[species]
|
||||
if not def then return nil end
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(self.data, species,
|
||||
isPlayerSide and "back" or "front", { kind = "battle" })
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors = PaletteFX.monPal(self.data, species, true)
|
||||
local name = "GRAYMON"
|
||||
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
||||
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
||||
return getImage(path,
|
||||
colors and { name = name, colors = colors } or nil,
|
||||
def.trueColor)
|
||||
tc)
|
||||
end
|
||||
|
||||
local function markSeen(game, species)
|
||||
@@ -4473,6 +4481,11 @@ function BattleState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
|
||||
-- the vanilla link is a no-op so an empty chain costs nothing.
|
||||
if Runtime.wantsHook("battle.overlay") then
|
||||
Runtime.call("battle.overlay", function() end, self)
|
||||
end
|
||||
end
|
||||
|
||||
return BattleState
|
||||
|
||||
@@ -581,8 +581,12 @@ MoveEffects.full = {
|
||||
},
|
||||
HYPER_BEAM_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- no recharge when the target faints OR its substitute breaks
|
||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
||||
-- Gen 1: no recharge when the target faints OR its substitute breaks.
|
||||
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
|
||||
local ruleset = ctx.battle and ctx.battle.ruleset
|
||||
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
|
||||
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
|
||||
if not skipOnKO or not targetDown then
|
||||
ctx.user.mustRecharge = true
|
||||
end
|
||||
end,
|
||||
|
||||
@@ -16,4 +16,7 @@ return {
|
||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||
enemyUnlimitedPP = true,
|
||||
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
|
||||
-- substitute breaks). Set false to always recharge like Gen 2+.
|
||||
hyperBeamSkipRechargeOnKO = true,
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ return {
|
||||
focusEnergyBug = false,
|
||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||
enemyUnlimitedPP = false,
|
||||
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
|
||||
hyperBeamSkipRechargeOnKO = false,
|
||||
}
|
||||
|
||||
+31
-1
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||
local filterLevel = 0
|
||||
|
||||
local function applyVolume(src)
|
||||
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
|
||||
if not src then return end
|
||||
local vol = VOLUME * volumeScale
|
||||
if Runtime.wantsHook("music.volume") then
|
||||
local ctx = {
|
||||
song = state.current,
|
||||
mapSong = state.mapSong,
|
||||
onBike = state.onBike,
|
||||
surfing = state.surfing,
|
||||
fading = state.fade ~= nil,
|
||||
optionScale = volumeScale,
|
||||
}
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if ok and Game then
|
||||
local ow = Game.overworld
|
||||
if ow and ow.player then
|
||||
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
|
||||
ctx.mapId = ow.map and ow.map.id
|
||||
ctx.tod = ow.tod
|
||||
end
|
||||
end
|
||||
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
|
||||
vol = tonumber(vol) or (VOLUME * volumeScale)
|
||||
if vol < 0 then vol = 0 end
|
||||
end
|
||||
pcall(src.setVolume, src, vol)
|
||||
end
|
||||
|
||||
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
||||
@@ -408,6 +432,12 @@ end
|
||||
-- restores the map theme after a one-shot jingle
|
||||
function Music.update(data)
|
||||
if state.chip then require("src.core.ChipAudio").update() end
|
||||
-- distance / indoor muffling mods re-apply volume every frame while
|
||||
-- subscribed; otherwise applyVolume only runs on song/option changes
|
||||
if Runtime.wantsHook("music.volume") and not state.fade then
|
||||
applyVolume(state.source)
|
||||
applyVolume(state.loopSource)
|
||||
end
|
||||
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
||||
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
||||
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
||||
|
||||
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
local SUPPORTED_REQUIRES = {
|
||||
["src.mods.Semver"] = true,
|
||||
["src.audio.ChipAsm"] = true,
|
||||
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
|
||||
}
|
||||
|
||||
local function scanRequire(name)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Runtime Pokémon art resolution. Content registries freeze after load,
|
||||
-- so a mod that lets the player pick an alternate skin mid-session cannot
|
||||
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
||||
-- sanctioned seam: every battle pic and party icon load goes through
|
||||
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Sprites = {}
|
||||
|
||||
local function samePath(path) return path end
|
||||
|
||||
-- Resolve a battle / menu front or back pic path for `species`.
|
||||
-- side: "front" | "back"
|
||||
-- opts.mon: the live mon when available (per-instance skins)
|
||||
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.path(data, species, side, opts)
|
||||
opts = opts or {}
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if not def then return nil, false end
|
||||
local path = side == "back" and def.spriteBack or def.spriteFront
|
||||
local ctx = {
|
||||
species = species,
|
||||
side = side == "back" and "back" or "front",
|
||||
kind = opts.kind or "battle",
|
||||
mon = opts.mon,
|
||||
trueColor = def.trueColor and true or false,
|
||||
data = data,
|
||||
}
|
||||
if path and Runtime.wantsHook("pokemon.sprite") then
|
||||
local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx)
|
||||
if type(hooked) == "string" and hooked ~= "" then path = hooked end
|
||||
end
|
||||
return path, ctx.trueColor and true or false
|
||||
end
|
||||
|
||||
-- Resolve a party-menu icon image path for `mon`.
|
||||
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
||||
-- def.icon / icons.byDex; the hook may replace it.
|
||||
-- Returns path (possibly nil).
|
||||
function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||
opts = opts or {}
|
||||
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
|
||||
return vanillaPath
|
||||
end
|
||||
local species = mon and mon.species
|
||||
local ctx = {
|
||||
species = species,
|
||||
mon = mon,
|
||||
name = opts.name,
|
||||
data = data,
|
||||
kind = "icon",
|
||||
}
|
||||
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
|
||||
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
|
||||
if type(hooked) == "string" and hooked ~= "" then return hooked end
|
||||
if hooked == nil or hooked == false then return nil end
|
||||
return vanillaPath
|
||||
end
|
||||
|
||||
return Sprites
|
||||
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
|
||||
return math.max(1, math.min(999, v))
|
||||
end
|
||||
|
||||
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
|
||||
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
|
||||
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
|
||||
local SHINY_ATK = {
|
||||
[2] = true, [3] = true, [6] = true, [7] = true,
|
||||
[10] = true, [11] = true, [14] = true, [15] = true,
|
||||
}
|
||||
|
||||
function Stats.isShiny(dvs)
|
||||
if type(dvs) ~= "table" then return false end
|
||||
return (dvs.defense or 0) == 10
|
||||
and (dvs.speed or 0) == 10
|
||||
and (dvs.special or 0) == 10
|
||||
and SHINY_ATK[dvs.attack or 0] == true
|
||||
end
|
||||
|
||||
return Stats
|
||||
|
||||
@@ -10,6 +10,7 @@ local Zoom = require("src.render.Zoom")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -486,6 +487,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||
-- canvas so the playfield sits on top of the border.
|
||||
if Runtime.wantsHook("render.letterbox") then
|
||||
Runtime.call("render.letterbox", function() end, {
|
||||
ww = ww, wh = wh, pw = pw, ph = ph,
|
||||
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
worldActive = self.worldActive and true or false,
|
||||
})
|
||||
end
|
||||
|
||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||
|
||||
+26
-7
@@ -4,19 +4,38 @@
|
||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||
-- Spec: docs/new-features.md (survey zoom)
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Zoom = {}
|
||||
|
||||
Zoom.offset = 0
|
||||
|
||||
-- effective integer scale s' in [1, 2*S]
|
||||
function Zoom.scale(S)
|
||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
||||
end
|
||||
|
||||
-- legal offset range for a given fit scale
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
function Zoom.offsetRange(S)
|
||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||
return 1 - S, S
|
||||
local lo, hi = 1 - S, S
|
||||
if Runtime.wantsHook("zoom.range") then
|
||||
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
|
||||
lo = math.floor(tonumber(lo) or (1 - S))
|
||||
hi = math.floor(tonumber(hi) or S)
|
||||
if lo > hi then lo, hi = hi, lo end
|
||||
end
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
|
||||
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
|
||||
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
|
||||
function Zoom.scale(S)
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local s = S + Zoom.offset
|
||||
local minScale = S + lo
|
||||
local maxScale = math.max(minScale, S + hi)
|
||||
if s < minScale then s = minScale end
|
||||
if s > maxScale then s = maxScale end
|
||||
if s < 0.25 then s = 0.25 end
|
||||
return s
|
||||
end
|
||||
|
||||
function Zoom.clampOffset(offset, S)
|
||||
|
||||
@@ -370,6 +370,7 @@ function BagMenu.new(game, opts)
|
||||
local battle = opts.battle
|
||||
local list
|
||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||
kind = "bag",
|
||||
footer = ("¥%d"):format(game.save.money),
|
||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||
onSelectKey = function(item, l)
|
||||
|
||||
+3
-2
@@ -140,8 +140,9 @@ function Credits:enter()
|
||||
end
|
||||
|
||||
function Credits:monSprite(species)
|
||||
local def = self.game.data.pokemon and self.game.data.pokemon[species]
|
||||
return silhouette(def and def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "credits" })
|
||||
return silhouette(path)
|
||||
end
|
||||
|
||||
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
|
||||
|
||||
@@ -34,7 +34,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
self.def = game.data.pokemon[species]
|
||||
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ kind = "dex" })
|
||||
local ok, img = path and pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
|
||||
@@ -28,10 +28,11 @@ end
|
||||
|
||||
local FLASH_FRAMES = 220
|
||||
|
||||
local function frontSprite(game, species)
|
||||
local def = game.data.pokemon[species]
|
||||
if not (def and def.spriteFront) then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
||||
local function frontSprite(game, species, mon)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ mon = mon, kind = "evolution" })
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
@@ -46,8 +47,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
||||
self.cancelable = (via ~= "TRADE")
|
||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
self.oldSprite = frontSprite(game, mon.species)
|
||||
self.newSprite = frontSprite(game, newSpecies)
|
||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
self.canceled = false
|
||||
|
||||
@@ -118,8 +118,9 @@ end
|
||||
function HallOfFame:spriteFor(species)
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local def = self.game.data.pokemon[species]
|
||||
cached = tryImage(def and def.spriteFront) or false
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "hof" })
|
||||
cached = tryImage(path) or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
|
||||
+96
-7
@@ -3,6 +3,7 @@
|
||||
-- box and the Pokédex.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local ListMenu = {}
|
||||
@@ -15,9 +16,37 @@ function ListMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- frames to wait before key-repeat kicks in, then between repeats
|
||||
local REPEAT_DELAY = 16
|
||||
local REPEAT_RATE = 4
|
||||
|
||||
-- ui.list_menu identity: unhooked opts pass through unchanged
|
||||
local function sameOpts(opts) return opts end
|
||||
|
||||
function ListMenu.new(game, title, items, opts)
|
||||
opts = opts or {}
|
||||
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
|
||||
if Runtime.wantsHook("ui.list_menu") then
|
||||
local hooked = Runtime.call("ui.list_menu", sameOpts, {
|
||||
wrap = opts.wrap,
|
||||
pageJump = opts.pageJump,
|
||||
keyRepeat = opts.keyRepeat,
|
||||
repeatDelay = opts.repeatDelay,
|
||||
repeatRate = opts.repeatRate,
|
||||
}, {
|
||||
game = game,
|
||||
title = title,
|
||||
kind = opts.kind or title,
|
||||
itemCount = items and #items or 0,
|
||||
})
|
||||
if type(hooked) == "table" then
|
||||
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
|
||||
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
|
||||
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
|
||||
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
|
||||
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
|
||||
end
|
||||
end
|
||||
local self = setmetatable({}, ListMenu)
|
||||
self.game = game
|
||||
self.title = title
|
||||
@@ -28,6 +57,12 @@ function ListMenu.new(game, title, items, opts)
|
||||
self.onCancel = opts.onCancel
|
||||
self.footer = opts.footer
|
||||
self.pageJump = opts.pageJump -- Left/Right move a page at a time
|
||||
self.wrap = opts.wrap -- Up on first / Down on last wraps
|
||||
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
|
||||
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
|
||||
self.repeatRate = opts.repeatRate or REPEAT_RATE
|
||||
self.holdDir = nil
|
||||
self.holdFrames = 0
|
||||
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
|
||||
-- scripted mode (the old man tutorial): update() runs the script
|
||||
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
|
||||
@@ -46,6 +81,42 @@ function ListMenu.new(game, title, items, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
local function moveIndex(self, delta)
|
||||
local n = #self.items
|
||||
if n == 0 then return end
|
||||
local next = self.index + delta
|
||||
if self.wrap then
|
||||
next = ((next - 1) % n) + 1
|
||||
else
|
||||
next = math.max(1, math.min(n, next))
|
||||
end
|
||||
self.index = next
|
||||
end
|
||||
|
||||
local function syncScroll(self)
|
||||
if self.index - self.scroll > self.rows then
|
||||
self.scroll = self.index - self.rows
|
||||
end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- edge press or key-repeat tick for a held direction
|
||||
local function navPressed(self, dir)
|
||||
if dir == "up" then
|
||||
moveIndex(self, -1)
|
||||
elseif dir == "down" then
|
||||
moveIndex(self, 1)
|
||||
elseif dir == "left" and self.pageJump then
|
||||
moveIndex(self, -self.rows)
|
||||
elseif dir == "right" and self.pageJump then
|
||||
moveIndex(self, self.rows)
|
||||
else
|
||||
return false
|
||||
end
|
||||
syncScroll(self)
|
||||
return true
|
||||
end
|
||||
|
||||
function ListMenu:update(dt)
|
||||
if self.script then
|
||||
self.script(self)
|
||||
@@ -59,14 +130,20 @@ function ListMenu:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local moved = false
|
||||
if input:wasPressed("up") then
|
||||
self.index = math.max(1, self.index - 1)
|
||||
moved = navPressed(self, "up")
|
||||
self.holdDir, self.holdFrames = "up", 0
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = math.min(#self.items, self.index + 1)
|
||||
moved = navPressed(self, "down")
|
||||
self.holdDir, self.holdFrames = "down", 0
|
||||
elseif self.pageJump and input:wasPressed("left") then
|
||||
self.index = math.max(1, self.index - self.rows)
|
||||
moved = navPressed(self, "left")
|
||||
self.holdDir, self.holdFrames = "left", 0
|
||||
elseif self.pageJump and input:wasPressed("right") then
|
||||
self.index = math.min(#self.items, self.index + self.rows)
|
||||
moved = navPressed(self, "right")
|
||||
self.holdDir, self.holdFrames = "right", 0
|
||||
elseif self.onSelectKey and input:wasPressed("select") then
|
||||
self.onSelectKey(self.items[self.index], self)
|
||||
elseif input:wasPressed("b") then
|
||||
@@ -80,10 +157,22 @@ function ListMenu:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.index - self.scroll > self.rows then
|
||||
self.scroll = self.index - self.rows
|
||||
|
||||
-- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
|
||||
if self.keyRepeat then
|
||||
local dir = self.holdDir
|
||||
if dir and input:isDown(dir) then
|
||||
self.holdFrames = self.holdFrames + 1
|
||||
local afterDelay = self.holdFrames - self.repeatDelay
|
||||
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
|
||||
navPressed(self, dir)
|
||||
end
|
||||
else
|
||||
self.holdDir, self.holdFrames = nil, 0
|
||||
end
|
||||
end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
|
||||
if not moved then syncScroll(self) end
|
||||
end
|
||||
|
||||
-- remove current item (e.g. consumed); keeps cursor valid
|
||||
|
||||
@@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label)
|
||||
return items
|
||||
end
|
||||
|
||||
-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on
|
||||
-- stable step.id values the same way insertBefore anchors on labels.
|
||||
local function stepIndex(steps, id)
|
||||
for i, step in ipairs(steps) do
|
||||
if step.id == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function ModUI.insertStepBefore(steps, anchorId, step)
|
||||
local i = stepIndex(steps, anchorId)
|
||||
table.insert(steps, i or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function ModUI.insertStepAfter(steps, anchorId, step)
|
||||
local i = stepIndex(steps, anchorId)
|
||||
table.insert(steps, i and (i + 1) or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function ModUI.removeStep(steps, id)
|
||||
for i = #steps, 1, -1 do
|
||||
if steps[i].id == id then table.remove(steps, i) end
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
return ModUI
|
||||
|
||||
+40
-9
@@ -5,8 +5,12 @@
|
||||
-- given, a "NEW NAME" + presets menu is shown first
|
||||
-- (engine/menus/main_menu.asm name lists).
|
||||
-- Pops itself from the stack, then calls opts.onDone(name).
|
||||
--
|
||||
-- ui.naming.grid may replace either page; keep an "ED" cell and a
|
||||
-- single-cell case-switch row so confirm / case-flip keep working.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
@@ -37,8 +41,24 @@ local GRID_LOWER = {
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "UPPER CASE" },
|
||||
}
|
||||
local CASE_ROW = 6
|
||||
local ED_ROW, ED_COL = 5, 9
|
||||
|
||||
-- locate the ED confirm cell and the case-switch row on a (possibly
|
||||
-- modded) grid; falls back to vanilla coordinates
|
||||
local function findMeta(grid)
|
||||
local caseRow, edRow, edCol = #grid, 5, 9
|
||||
for r, row in ipairs(grid) do
|
||||
if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE"
|
||||
or row[1] == "lower" or row[1] == "UPPER") then
|
||||
caseRow = r
|
||||
end
|
||||
for c, cell in ipairs(row) do
|
||||
if cell == "ED" then edRow, edCol = r, c end
|
||||
end
|
||||
end
|
||||
return caseRow, edRow, edCol
|
||||
end
|
||||
|
||||
local function sameGrid(grid) return grid end
|
||||
|
||||
function NamingScreen.new(game, opts)
|
||||
opts = opts or {}
|
||||
@@ -86,16 +106,27 @@ function NamingScreen:confirm()
|
||||
end
|
||||
|
||||
function NamingScreen:grid()
|
||||
return self.lower and GRID_LOWER or GRID_UPPER
|
||||
local base = self.lower and GRID_LOWER or GRID_UPPER
|
||||
if not Runtime.wantsHook("ui.naming.grid") then return base end
|
||||
local hooked = Runtime.call("ui.naming.grid", sameGrid, base, {
|
||||
lower = self.lower and true or false,
|
||||
title = self.title,
|
||||
maxLen = self.maxLen,
|
||||
game = self.game,
|
||||
})
|
||||
if type(hooked) ~= "table" or #hooked == 0 then return base end
|
||||
return hooked
|
||||
end
|
||||
|
||||
-- Gen 1 jumps the cursor to ED once the name is full.
|
||||
function NamingScreen:jumpToEnd()
|
||||
self.row, self.col = ED_ROW, ED_COL
|
||||
local _, edRow, edCol = findMeta(self:grid())
|
||||
self.row, self.col = edRow, edCol
|
||||
end
|
||||
|
||||
function NamingScreen:update(dt)
|
||||
local GRID = self:grid()
|
||||
local caseRow, edRow, edCol = findMeta(GRID)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("start") then
|
||||
self:confirm()
|
||||
@@ -107,28 +138,28 @@ function NamingScreen:update(dt)
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
-- wrapping up from the top row lands on the case-switch cell
|
||||
self.row = self.row > 1 and self.row - 1 or CASE_ROW
|
||||
self.row = self.row > 1 and self.row - 1 or caseRow
|
||||
self.col = math.min(self.col, #GRID[self.row])
|
||||
elseif input:wasPressed("down") then
|
||||
self.row = self.row < #GRID and self.row + 1 or 1
|
||||
self.col = math.min(self.col, #GRID[self.row])
|
||||
elseif input:wasPressed("left") then
|
||||
-- no horizontal movement on the case-switch row
|
||||
if self.row ~= CASE_ROW then
|
||||
if self.row ~= caseRow then
|
||||
self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
|
||||
end
|
||||
elseif input:wasPressed("right") then
|
||||
if self.row ~= CASE_ROW then
|
||||
if self.row ~= caseRow then
|
||||
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
table.remove(self.glyphs)
|
||||
elseif input:wasPressed("a") then
|
||||
if self.row == ED_ROW and self.col == ED_COL then
|
||||
if self.row == edRow and self.col == edCol then
|
||||
self:confirm()
|
||||
return
|
||||
end
|
||||
if self.row == CASE_ROW then
|
||||
if self.row == caseRow then
|
||||
self.lower = not self.lower
|
||||
return
|
||||
end
|
||||
|
||||
+357
-72
@@ -1,14 +1,16 @@
|
||||
-- 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:
|
||||
-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the
|
||||
-- overworld walking sprite before the fade to white. Uses the real
|
||||
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText,
|
||||
-- _IntroduceRivalText) with literal fallbacks.
|
||||
-- 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 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")
|
||||
|
||||
@@ -55,12 +57,154 @@ local function tryImage(path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
-- Resolve a pic descriptor to (image, flip).
|
||||
-- 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 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
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), 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
|
||||
end
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, desc.id, "front", { kind = "oak" })
|
||||
return tryImage(path), desc.flip and true or false
|
||||
elseif t == "player" then
|
||||
if speech and speech.playerPic and not desc.path then
|
||||
return speech.playerPic, false
|
||||
end
|
||||
return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false
|
||||
elseif t == "image" then
|
||||
return tryImage(desc.path), desc.flip and true or 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
|
||||
end
|
||||
return nil, 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 = "YOUR NAME?",
|
||||
presetsWho = "player",
|
||||
presetsFallback = { "RED", "ASH", "JACK" },
|
||||
},
|
||||
{
|
||||
id = "ask_rival_name",
|
||||
kind = "say",
|
||||
textKey = "_IntroduceRivalText",
|
||||
pic = "rival",
|
||||
},
|
||||
{
|
||||
id = "name_rival",
|
||||
kind = "name",
|
||||
who = "rival",
|
||||
title = "HIS NAME?",
|
||||
presetsWho = "rival",
|
||||
presetsFallback = { "BLUE", "GARY", "JOHN" },
|
||||
},
|
||||
{
|
||||
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)
|
||||
@@ -69,8 +213,9 @@ function OakSpeech.new(game, onDone)
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
-- literals stay as the fallbacks
|
||||
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
||||
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies]
|
||||
self.demoPic = tryImage(demo and demo.spriteFront)
|
||||
local demoPath = require("src.pokemon.Sprites").path(
|
||||
game.data, self.demoSpecies, "front", { kind = "oak" })
|
||||
self.demoPic = tryImage(demoPath)
|
||||
local constants = game.data.constants or {}
|
||||
self.nameLen = constants.playerNameLength or 7
|
||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||
@@ -87,9 +232,26 @@ function OakSpeech.new(game, onDone)
|
||||
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
|
||||
|
||||
@@ -97,80 +259,187 @@ function OakSpeech:say(key, next)
|
||||
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
|
||||
end
|
||||
|
||||
local STEPS = {
|
||||
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
|
||||
function(self)
|
||||
self.pic = self.oakPic
|
||||
self:revealPic("fade", function()
|
||||
self:say("_OakSpeechText1", function() self:advance() 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 = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
if img then
|
||||
self.pic = img
|
||||
self.picFlip = flip 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
|
||||
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)
|
||||
end,
|
||||
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
|
||||
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
|
||||
-- (MovePicLeft), then its cry sounds and the text prints
|
||||
function(self)
|
||||
elseif kind == "demo" then
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
self.pic = self.demoPic
|
||||
self.picFlip = true
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
||||
end)
|
||||
end,
|
||||
-- 3. the rest of the world-of-POKéMON spiel
|
||||
function(self)
|
||||
self:say("_OakSpeechText2B", function() self:advance() end)
|
||||
end,
|
||||
-- 4. "First, what is your name?" over the player's own pic
|
||||
-- (RedPicFront, oak_speech.asm:86-91) then the naming screen
|
||||
function(self)
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self:say("_IntroducePlayerText", function() self:advance() end)
|
||||
end,
|
||||
function(self)
|
||||
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 = "YOUR NAME?",
|
||||
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }),
|
||||
maxLen = self.nameLen,
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"),
|
||||
presets = presets,
|
||||
maxLen = step.maxLen or self.nameLen,
|
||||
onDone = function(name)
|
||||
self.game.save.player.name = 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,
|
||||
})
|
||||
end,
|
||||
-- 6. the rival introduction and naming
|
||||
function(self)
|
||||
self.pic = self.rivalPic
|
||||
self:say("_IntroduceRivalText", function() self:advance() end)
|
||||
end,
|
||||
function(self)
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = "HIS NAME?",
|
||||
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }),
|
||||
maxLen = self.nameLen,
|
||||
onDone = function(name)
|
||||
self.game.save.player.rival = name
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
-- 8. "your very own POKéMON legend is about to unfold!" over the
|
||||
-- player pic again (oak_speech.asm:105-113)
|
||||
function(self)
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self:say("_OakSpeechText3", function() self:advance() end)
|
||||
end,
|
||||
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames
|
||||
-- into the walking sprite, then fades to white (oak_speech.asm
|
||||
-- .next, lines 115-166). Not skippable, like the DelayFrames
|
||||
-- chain it ports.
|
||||
function(self)
|
||||
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")
|
||||
-- the OakSpeechText3 box holds its last page on screen through the
|
||||
-- shrink (pokered text boxes persist until overwritten)
|
||||
self.shrinkText = self:lastPageLines("_OakSpeechText3")
|
||||
-- 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 }
|
||||
end,
|
||||
}
|
||||
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)
|
||||
@@ -202,15 +471,31 @@ end
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
self.picFlip = false
|
||||
local fn = STEPS[self.step]
|
||||
if fn then
|
||||
fn(self)
|
||||
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
|
||||
@@ -267,8 +552,8 @@ function OakSpeech:draw()
|
||||
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)
|
||||
-- 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
|
||||
|
||||
@@ -95,6 +95,7 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||
path = name and icons.icons and icons.icons[name]
|
||||
end
|
||||
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
||||
if not path then return end
|
||||
if iconImages[path] == nil then
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
|
||||
@@ -30,9 +30,11 @@ end
|
||||
|
||||
function SummaryMenu.new(game, mon)
|
||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
if def and def.spriteFront then
|
||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
end
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
|
||||
@@ -117,8 +117,9 @@ function TitleState:currentSprite()
|
||||
local species = self.cycleSpecies[self.cycleIndex]
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local def = self.game.data.pokemon[species]
|
||||
cached = tryImage(def and def.spriteFront) or false
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "title" })
|
||||
cached = tryImage(path) or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
|
||||
@@ -48,8 +48,9 @@ local function dexOf(game, mon)
|
||||
end
|
||||
|
||||
local function spriteOf(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return tryImage(def and def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "trade" })
|
||||
return tryImage(path)
|
||||
end
|
||||
|
||||
local function expand(game, key, subs)
|
||||
|
||||
@@ -452,6 +452,35 @@ end
|
||||
|
||||
-- name -> name so the map.palette chain has a vanilla link to wrap
|
||||
local function samePalette(name) return name end
|
||||
local function sameTod(tod) return tod end
|
||||
|
||||
-- world.tod default: always DAY. A day/night mod returns "NIGHT",
|
||||
-- "MORNING", etc.; the result is cached on the overworld and handed to
|
||||
-- map.palette as ctx.tod so palette swaps can key off the period.
|
||||
function OverworldState:timeOfDay()
|
||||
local tod = self.tod or "DAY"
|
||||
if not Runtime.wantsHook("world.tod") then return tod end
|
||||
local map = self.map
|
||||
local nextTod = Runtime.call("world.tod", sameTod, tod, {
|
||||
map = map,
|
||||
mapId = map and map.id,
|
||||
x = self.player and self.player.cellX,
|
||||
y = self.player and self.player.cellY,
|
||||
steps = self.todSteps or 0,
|
||||
})
|
||||
if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end
|
||||
if nextTod ~= tod then
|
||||
self.tod = nextTod
|
||||
if Runtime.wants("world.tod_changed") then
|
||||
Runtime.emit("world.tod_changed", {
|
||||
tod = nextTod, previous = tod, mapId = map and map.id,
|
||||
})
|
||||
end
|
||||
else
|
||||
self.tod = nextTod
|
||||
end
|
||||
return self.tod
|
||||
end
|
||||
|
||||
function OverworldState:paletteNameFor(map)
|
||||
local palettes = FieldDefaults.field(Game.data, "palettes")
|
||||
@@ -470,8 +499,9 @@ function OverworldState:paletteNameFor(map)
|
||||
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
|
||||
or palettes.default
|
||||
end
|
||||
local tod = self:timeOfDay()
|
||||
if not Runtime.wantsHook("map.palette") then return name end
|
||||
return Runtime.call("map.palette", samePalette, name, map)
|
||||
return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
|
||||
end
|
||||
|
||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||
@@ -2781,11 +2811,18 @@ end
|
||||
|
||||
function OverworldState:onStepComplete()
|
||||
local p = self.player
|
||||
self.todSteps = (self.todSteps or 0) + 1
|
||||
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
|
||||
-- paletteNameFor reads self.tod on the next paint
|
||||
if Runtime.wantsHook("world.tod") then
|
||||
self:timeOfDay()
|
||||
end
|
||||
|
||||
-- hot path: the payload is only built when something is listening
|
||||
if Runtime.wants("world.stepped") then
|
||||
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
|
||||
tile = self.map:cellTile(p.cellX, p.cellY) })
|
||||
tile = self.map:cellTile(p.cellX, p.cellY),
|
||||
tod = self.tod })
|
||||
end
|
||||
|
||||
-- dismounting a surf: landing on a walkable cell ends it
|
||||
|
||||
+17
-4
@@ -4,6 +4,7 @@
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local Player = {}
|
||||
@@ -82,10 +83,22 @@ function Player:tryMove(dir, map, entities)
|
||||
self.moving = true
|
||||
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
||||
self.progress = 0
|
||||
-- the bicycle doubles walking speed (8 frames per step)
|
||||
local save = require("src.core.Game").save
|
||||
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
-- the bicycle doubles walking speed (8 frames per step); movement.speed
|
||||
-- lets a mod multiply or replace that (running shoes, dash, etc.)
|
||||
local Game = require("src.core.Game")
|
||||
local save = Game.save
|
||||
local frames = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
if Runtime.wantsHook("movement.speed") then
|
||||
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
|
||||
onBike = save and save.onBike or false,
|
||||
surfing = self.surfing and true or false,
|
||||
player = self,
|
||||
input = Game.input,
|
||||
save = save,
|
||||
})
|
||||
end
|
||||
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
|
||||
return "moved"
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user