Merge remote-tracking branch 'origin/dev' into fix1037

# Conflicts:
#	docs/modding.md
This commit is contained in:
bryanthaboi
2026-08-10 14:11:45 -04:00
38 changed files with 1542 additions and 285 deletions
+5
View File
@@ -1893,6 +1893,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
@@ -1929,6 +1930,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
@@ -1990,9 +1992,11 @@ function BattleState:update(dt)
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
@@ -2031,6 +2035,7 @@ function BattleState:update(dt)
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local pick = moves[self.mimicIndex]
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
+5 -5
View File
@@ -281,11 +281,11 @@ MoveEffects.primary = {
failed = true }
end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
-- A Substitute costs one quarter of max HP, rounded down. Do not let
-- the cost consume the user's last HP: the move must fail at the exact
-- boundary as well as below it, or the next turn's HP guard can leave a
-- trainer battle unable to progress.
if user.mon.hp <= cost then
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
failed = true }
end
+83 -30
View File
@@ -111,6 +111,11 @@ local NOISE_DIVISORS = {
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
}
local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE)
local LPF_ALPHA = 0.8
local MIX_SCALE = 0.5
local function snapTicks(ticks)
return math.floor((ticks * 1470 + 256) / 512)
end
@@ -269,6 +274,7 @@ function Channel.new(engine, spec, options)
phase = 0,
noiseLfsr = 0x7FFF,
noiseClock = 0,
drumTail = nil,
timeTicks = 0,
}, Channel)
end
@@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed)
return math.min(15, volume + steps)
end
local function envelopeRingSamples(volume, fade)
if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end
return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5)
end
local function extendDrumEnvelope(segments)
local last = segments and segments[#segments]
if not last then return segments end
local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade)
if ringEnd > last.endSample then last.endSample = ringEnd end
return segments
end
local function drumAudioEnd(drum)
local last = drum and drum[#drum]
return last and last.endSample or 0
end
function Channel:resetNoise()
self.noiseLfsr = 0x7FFF
self.noiseClock = 0
@@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter)
end
end
end
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0
end
local function sweepCalculation(register, sweep)
@@ -611,21 +635,51 @@ end
function Channel:sample()
while not self.ended
and (not self.event or self.event.sample >= self.event.samples) do
local prev = self.event
self.event = self:nextEvent()
self.phase = 0
self:resetNoise()
if self.event and self.event.drum then
self.drumTail = nil
self:resetNoise()
elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then
-- ..(audio/engine_1.asm ln 197)
self.drumTail = prev
elseif not (self.event and self.event.silence and self.drumTail) then
self.drumTail = nil
self:resetNoise()
end
end
local event = self.event
if not event then return 0 end
local gain = channelVolume[self.hardware] or 1
if not event then
local tail = self.drumTail
if not tail then return 0 end
local sampleIndex = tail.sample
tail.sample = sampleIndex + 1
if sampleIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, sampleIndex) * gain
end
local sampleIndex = event.sample
event.elapsed = sampleIndex / SAMPLE_RATE
event.sample = sampleIndex + 1
if event.silence then return 0 end
local gain = channelVolume[self.hardware] or 1
if event.silence then
local tail = self.drumTail
if not tail then return 0 end
local tailIndex = tail.sample
tail.sample = tailIndex + 1
if tailIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, tailIndex) * gain
end
if event.drum then
return self:sampleDrum(event, sampleIndex) * gain
end
self.drumTail = nil
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
@@ -665,7 +719,8 @@ function Channel:sample()
-- a def-local program may omit its wave table entirely
if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * gain
local nibble = math.max(0, math.min(15, wave[index] * 8 + 8))
return (nibble / 15) * event.waveLevel * gain
end
local duty = event.duty
if type(duty) == "table" then
@@ -674,7 +729,7 @@ function Channel:sample()
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
local step = math.floor(phase * 8) % 8
if pattern[step + 1] == 0 then
return -volume / 15 * gain
return 0
end
return volume / 15 * gain
end
@@ -685,7 +740,7 @@ Engine.__index = Engine
function Engine:noiseInstrument(number)
-- a def-local drum wins over the ROM engine's table for that id
local custom = self.customDrums and self.customDrums[number]
if custom then return custom end
if custom then return extendDrumEnvelope(custom) end
local cached = self.noiseInstruments[number]
if cached then return cached end
@@ -718,6 +773,7 @@ function Engine:noiseInstrument(number)
end
end
extendDrumEnvelope(segments)
self.noiseInstruments[number] = segments
return segments
end
@@ -792,6 +848,8 @@ function Engine.new(data, header, options)
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0,
lpf = 0, lpfLeft = 0, lpfRight = 0,
}, Engine)
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
@@ -826,10 +884,20 @@ function Engine:finished()
return true
end
local function analogOut(engine, input, hpfField, lpfField)
local cap = engine[hpfField]
local hp = input - cap
engine[hpfField] = input - hp * HPF_CHARGE
local prev = engine[lpfField]
local lp = prev + LPF_ALPHA * (hp - prev)
engine[lpfField] = lp
return math.max(-1, math.min(1, lp * MIX_SCALE))
end
function Engine:sample()
local value = 0
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
return math.max(-1, math.min(1, value / 4))
return analogOut(self, value, "hpfCap", "lpf")
end
function Engine:sampleStereo()
@@ -840,8 +908,8 @@ function Engine:sampleStereo()
if not event or event.panLeft ~= false then left = left + value end
if not event or event.panRight ~= false then right = right + value end
end
return math.max(-1, math.min(1, left / 4)),
math.max(-1, math.min(1, right / 4))
return analogOut(self, left, "hpfCapLeft", "lpfLeft"),
analogOut(self, right, "hpfCapRight", "lpfRight")
end
function Engine:sampleChannel(number)
@@ -850,7 +918,7 @@ function Engine:sampleChannel(number)
local value = channel:sample()
if channel.number == number then selected = value end
end
return math.max(-1, math.min(1, selected / 4))
return analogOut(self, selected, "hpfCap", "lpf")
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
@@ -870,22 +938,7 @@ local function soundData(engine, samples, channels)
return result
end
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
-- it is too short to be audible. The caller wraps it in a static
-- love.audio.Source (a playback concern, hence not done here).
--
-- The synthesis is mono (one summed value per frame, unlike the music path's
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
-- position, exactly where the listener sits, is rendered as an ambient sound
-- spread over EVERY output channel the device exposes at gains that differ
-- from the front pair. On an interface with more than two outputs that put
-- the SFX on outputs 5+6 as well, while the 2-channel music source
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
-- spatialization entirely and map onto the front pair, so duplicating the
-- sample costs one buffer's memory and makes effects route exactly like
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
-- and would newly hard-pan any effect whose header issues command 0xEE.
local function renderEffectData(data, header, options)
if not header then return nil end
options = options or {}
+37 -39
View File
@@ -1,12 +1,3 @@
-- Music playback supports compact ROM channel programs synthesized live by
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
-- branch is chosen per song definition, never by a global import flag, so a
-- file-backed song and a chip song coexist in one dataset. Songs with split
-- files chain def.file into def.loopFile in Music.update().
-- Map themes switch on map change; battles override with the battle
-- theme and restore afterwards; riding the bike overrides outdoor map
-- themes with the bike song until dismount.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
@@ -14,20 +5,10 @@ local Music = {}
local VOLUME = 0.7
-- port additions driven by OptionsMenu / save.options: musicVol scales
-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter
-- low-passes the song. Each filter step keeps 40% of the previous
-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter
-- applied twice/three times over.
local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
@@ -115,11 +96,6 @@ function Music.duckForFanfare(src)
end
end
-- Overworld themes where the bike can be ridden (outdoor maps plus the
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
-- for caches built before the importer wrote the table.
local OUTDOOR = {
Music_PalletTown = true,
Music_Cities1 = true,
@@ -218,6 +194,7 @@ end
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
if ctx and ctx.selected then return song end
if not Runtime.wantsHook("music.select") then return song end
return Runtime.call("music.select", function(chosen) return chosen end, song, {
reason = ctx and ctx.reason or "direct",
@@ -234,17 +211,28 @@ end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
ctx = ctx or {}
song = selectSong(song, ctx)
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
-- the same song restarted with channel 1 re-pointed at a stub whose only
-- difference is its `tempo`, so the same label at a different tempo is a
-- different cue and must not be deduped away (#847)
local tempo = ctx and ctx.tempo or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
if ctx.fade and state.source then
local queued = {}
for key, value in pairs(ctx) do queued[key] = value end
queued.fade, queued.selected = nil, true
local pending = { data = data, song = song, loop = loop, ctx = queued }
if state.fade then
state.fade.pending = pending
else
Music.fadeOut(ctx.fade, pending)
end
return
end
if tempo then
-- shallow copy: the registry def is shared, only this playback is slowed
local slowed = {}
@@ -317,24 +305,26 @@ function Music.reload()
Music.stop()
end
-- Ramp the current song's volume to silence, then stop it, mirroring the
-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio +
-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in
-- integer levels, one level every `control` frames, and the music stops
-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
-- to silence). Ticked once per frame from Music.update().
function Music.fadeOut(control)
if not state.source then Music.stop() return end
function Music.fadeOut(control, pending)
if not state.source then
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
control = math.max(1, control or 10)
state.fade = {
control = control,
counter = control, -- frames until the next volume step
level = 7, -- current master-volume level (rAUDVOL nibble)
from = VOLUME * volumeScale, -- level-7 (full) source volume
pending = pending,
}
end
Music.MAP_FADE = 10
-- the song a map should currently play, honoring the bike/surf overrides
local function effectiveMapSong(data, song)
if not song or not outdoorSongs(data)[song] then return song end
@@ -351,14 +341,17 @@ end
-- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing)
function Music.playMap(data, mapId, onBike, surfing, fade)
local song = data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil
state.mapSong = song
state.onBike = not not onBike
state.surfing = not not surfing
local play = effectiveMapSong(data, song)
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
if play then
Music.play(data, play, nil,
{ reason = "map", mapId = mapId, fade = fade })
end
end
-- toggle the surf override mid-map (starting/ending a surf)
@@ -475,8 +468,13 @@ function Music.update(data)
f.counter = f.control
f.level = f.level - 1
if f.level <= 0 then
-- ..(home/fade_audio.asm ln 36)
state.fade = nil
local pending = f.pending
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
local vol = f.from * f.level / 7
+46 -1
View File
@@ -25,6 +25,18 @@ local function noEffect(data)
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
end
local function registeredEffect(data, itemDef)
if not data or not itemDef or not itemDef.effect then
return nil
end
if not data.item_effects then
return nil
end
return data.item_effects[itemDef.effect]
end
local HEAL_AMOUNT = {
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
@@ -72,7 +84,18 @@ function ItemEffects.healsHP(id)
end
-- Does this item need a party-member target?
function ItemEffects.needsTarget(id, itemDef)
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
function ItemEffects.needsTarget(id, itemDef, data)
if itemDef and itemDef.needsTarget ~= nil then
return itemDef.needsTarget
end
local effect = registeredEffect(data, itemDef)
if effect and effect.needsTarget ~= nil then
return effect.needsTarget
end
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE"
or id == "RARE_CANDY" or STONES[id]
@@ -147,6 +170,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
local effectDef = registeredEffect(data, itemDef)
if effectDef then
if battle and effectDef.battle == false then
return "failed", { notTime(data, save) }
end
if not battle and effectDef.field == false then
return "failed", { notTime(data, save) }
end
return effectDef.use({
data = data,
save = save,
itemId = itemId,
item = itemDef,
target = target,
battle = battle,
moveIndex = moveIndex,
overworld = ow,
})
end
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
-- (jp nz, ItemUseNotTime)
+7
View File
@@ -592,6 +592,13 @@ R.sprites = {
image = f.path,
frames = f.int(1),
walker = f.opt(f.bool),
-- Optional sheet geometry for mod actors. Defaults match the vanilla
-- 16x16 grounded walker; anchors are measured from each frame's
-- top-left in pixels (default: bottom-center).
frameWidth = f.opt(f.int(1)),
frameHeight = f.opt(f.int(1)),
anchorX = f.opt(f.num),
anchorY = f.opt(f.num),
trueColor = f.opt(f.bool),
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
-- palette assignment without claiming that the image itself came from
+4 -2
View File
@@ -137,8 +137,10 @@ function Font.load(data)
-- typo'd path degrades exactly like a missing page image does above.
if type(def.ttf) == "table" then
local file = def.ttf.file or Font.PLAINPIXEL
local ok, obj = pcall(love.graphics.newFont, file,
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
local size = def.ttf.size or Font.PLAINPIXEL_SIZE
-- The game renders into a pixel-exact canvas, so keep the TTF rasterizer
-- on that same 1x grid instead of inheriting the window DPI on mobile.
local ok, obj = pcall(love.graphics.newFont, file, size, "mono", 1)
if ok and obj then
-- nearest keeps the pixel font crisp under the integer UI scale
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
+137 -34
View File
@@ -1,7 +1,8 @@
-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm).
-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds
-- 6 16x16 frames: stand down/up/left, walk down/up/left
-- (data/sprites/facings.asm). Mod records may opt into another frame size
-- and anchor; the defaults below preserve the original grounded placement.
-- Right-facing frames are horizontal flips of the left frames.
-- Sprites draw 4px above their cell, like the GB engine.
local Assets = require("src.render.Assets")
local PaletteFX = require("src.render.PaletteFX")
@@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK
-- Sprite records are anchored at the point where the actor stands in the
-- world. In the vanilla renderer that point is the bottom-center of a
-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is
-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left
-- in sheet pixels and may be fractional for a sub-pixel art style.
local DEFAULT_FRAME_WIDTH = 16
local DEFAULT_FRAME_HEIGHT = 16
local DEFAULT_ANCHOR_X = 8
local DEFAULT_ANCHOR_Y = 16
local WORLD_ANCHOR_X = 8
local WORLD_ANCHOR_Y = 12
SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH
SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT
SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X
SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y
local function finiteNumber(value)
if type(value) ~= "number" or value ~= value
or value == math.huge or value == -math.huge then
return nil
end
return value
end
local function positiveInteger(value, fallback)
value = finiteNumber(value)
if value and value >= 1 then return math.floor(value) end
return fallback
end
local function numberOr(value, fallback)
return finiteNumber(value) or fallback
end
local function pose(self, facing, walkPhase, stepFlip)
if self.frameCount <= 1 then return 0, false end
local frame = (self.def.walker and walkPhase == 1)
and WALK[facing] or STAND[facing]
frame = frame or 0
-- Preserve the old fallback for a short custom sheet whose pose table
-- names a frame it does not provide.
if not self.frames[frame] then frame = 0 end
local flip = false
if facing == "right" then
flip = true
elseif (facing == "down" or facing == "up")
and walkPhase == 1 and stepFlip then
flip = true
end
return frame, flip
end
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
function SpriteRenderer.new(spriteDef, seed)
@@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed)
self.def = spriteDef
self.seed = seed
self.image = getImage(spriteDef.image)
self.frameCount = positiveInteger(spriteDef.frames, 1)
self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH)
self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT)
self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2)
self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight)
local iw, ih = self.image:getDimensions()
self.frames = {}
for f = 0, spriteDef.frames - 1 do
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih)
for f = 0, self.frameCount - 1 do
self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight,
self.frameWidth, self.frameHeight,
iw, ih)
end
return self
end
-- Return the sheet rectangle and top-left-relative anchor for a frame. The
-- result is a fresh table so a custom render pipeline may annotate it without
-- changing the renderer's shared definition.
function SpriteRenderer:getFrameGeometry(frame)
frame = math.floor(finiteNumber(frame) or 0)
if frame < 0 then frame = 0 end
if frame >= self.frameCount then frame = self.frameCount - 1 end
return {
frame = frame,
x = 0,
y = frame * self.frameHeight,
width = self.frameWidth,
height = self.frameHeight,
anchorX = self.anchorX,
anchorY = self.anchorY,
quad = self.frames[frame],
}
end
-- Return the frame geometry selected by the ordinary 2D pose rules, plus the
-- horizontal mirror state that :draw applies. This is the supported hook for
-- custom render pipelines that need to draw actors with the same pose/flip.
function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip)
local frame, flip = pose(self, facing, walkPhase, stepFlip)
local geometry = self:getFrameGeometry(frame)
geometry.facing = facing
geometry.walkPhase = walkPhase
geometry.stepFlip = stepFlip
geometry.mirror = flip
return geometry
end
-- Screen-space top-left for the actor's current world anchor. World-facing
-- effects such as fishing can use this instead of assuming a 16x16 frame.
function SpriteRenderer:getScreenOrigin(px, py, camX, camY)
local baseX = math.floor(px - camX) + WORLD_ANCHOR_X
local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y
return math.floor(baseX - self.anchorX),
math.floor(baseY - self.anchorY)
end
-- The image this sprite would draw from right now: the plain sheet, or the
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
-- own geometry from the very same image -- the geometry carries sheet pixel
@@ -125,27 +226,32 @@ end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
local function blitFrame(image, quad, x, y, flip, redraw, frameWidth)
frameWidth = frameWidth or DEFAULT_FRAME_WIDTH
if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1)
if redraw then
PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1)
end
else
love.graphics.draw(image, quad, x, y)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
end
end
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
-- bottom tile row of the standing frames with the fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384)
-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim
-- overwrites that row of the standing frames with fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla
-- frames therefore still draw 8 rows, while taller frames keep their larger
-- body and reserve only the overlay row.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image
local redraw = false
-- full-color art claims its 16x16 cell out of the shade-remap pass
-- True-color sheets bypass every palette bake; the screen-space exemption
-- is recorded below once the final frame/height is known.
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16)
image = self.image
elseif PaletteFX.usesGbcPack() then
-- RED++: the world canvas is already true-color (TileRenderer bakes
-- terrain, this bakes the sprite) and the world pass runs unshaded
@@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
-- being colorized by the zone IS the point (#301).
image = getObpImage(self.def.image, PaletteFX.dmgObj())
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- Single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then
blitFrame(image, self.frames[0], x, y, false, redraw)
return
end
local frame = (self.def.walker and walkPhase == 1)
and WALK[facing] or STAND[facing]
local flip = false
if facing == "right" then
flip = true
elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if topHalf then
-- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip)
local quad = self.frames[frame]
local drawHeight = self.frameHeight
if topHalf and self.frameCount > 1 then
self.halfFrames = self.halfFrames or {}
if not self.halfFrames[frame] then
local iw, ih = self.image:getDimensions()
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
self.halfFrames[frame] = love.graphics.newQuad(
0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih)
end
quad = self.halfFrames[frame]
drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
end
blitFrame(image, quad, x, y, flip, redraw)
-- Full-color art claims exactly the portion of the frame that was drawn.
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight)
end
blitFrame(image, quad, x, y, flip, redraw, self.frameWidth)
end
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
@@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip)
self.tileQuads = self.tileQuads or {}
self.tileQuads[path] = self.tileQuads[path]
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw)
end
return SpriteRenderer
+13 -1
View File
@@ -253,6 +253,18 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
return
end
if result == "kept" then
if battle then
list:close()
showMessages(game, payload, function()
battle:itemUsed({})
end)
else
showMessages(game, payload, closePicker)
end
return
end
if result == "consumed" then
consume(game, id)
-- refresh counts in the list
@@ -404,7 +416,7 @@ local function useItem(game, battle, id, list)
showMessages(game, payload)
return
end
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
if ItemEffects.needsTarget(id, def, game.data) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
if def and def.machine then
+4
View File
@@ -67,6 +67,7 @@ local function withdraw(game)
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_withdraw",
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
@@ -114,6 +115,7 @@ local function deposit(game)
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_deposit",
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
@@ -160,6 +162,7 @@ local function release(game)
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_release",
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
@@ -193,6 +196,7 @@ local function changeBox(game)
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_change",
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
+65 -59
View File
@@ -1,29 +1,5 @@
-- Boot splash + attract movie, a faithful port of PlayIntro
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
-- using the real extracted art (data/generated/field.lua `intro` manifest).
--
-- Three frame-counted phases:
-- 1. copyright card, 180 frames (intro.asm:311-312).
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
-- the big star streaks down-left for 40 frames while the studio logo
-- sits centered in the letterbox band (the GAME FREAK logo + letter
-- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228),
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
-- (intro.asm:329-331).
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
-- (GBFadeOutToWhite, home/fade.asm:26-40).
--
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
-- Pops itself and calls onDone() when finished or skipped. All art loads
-- through pcall and every missing graphic degrades to a text/rect
-- fallback, so the movie stays headless-safe.
-- ..(engine/movie/intro.asm ln 8)
-- ..(engine/movie/splash.asm ln 27)
local Font = require("src.render.Font")
local Music = require("src.core.Music")
@@ -76,15 +52,15 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
-- cancel)
-- ..(engine/movie/splash.asm ln 211)
local LOGO_X, LOGO_Y = 72, 56
local TEXT_X, TEXT_Y = 40, 80
-- ..(engine/movie/title.asm ln 390)
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 }
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 }
-- The studio logo (assets/logo/minilogo.png) stands in for both the
-- GAME FREAK logo and its letter row, so it gets the whole band between
-- the letterbox bars (y 32..112) down to where the star waves spawn
-- (y=88): fit it inside this box, centered, aspect preserved.
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
-- the 4 waves of small stars: screen X positions, all spawning at y=88
@@ -155,10 +131,19 @@ function IntroMovie.new(game, onDone)
self.studio = intro.studio or {}
self.skipAll = intro.skip and true or false
local function img(e) return tryImage(e and e.path) end
self.copyright = tryImage("assets/generated/title/copyright.png")
-- studio mark replaces the GAME FREAK logo + splash text entirely; the
-- extracted logo stays as the fallback if the asset is missing
self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png")
local titleCfg = game.data.field and game.data.field.title or {}
self.copyright = img(titleCfg.copyright)
or tryImage("assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyright then
local iw, ih = self.copyright:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = img(titleCfg.gamefreakInc)
or tryImage("assets/generated/title/gamefreak_inc.png")
self.studioLogo = tryImage(self.studio.logo)
if self.studioLogo then
self.studioLogo:setFilter("nearest", "nearest")
local iw, ih = self.studioLogo:getDimensions()
@@ -306,24 +291,12 @@ function IntroMovie:drawSplash()
if self.studioLogo then
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
0, self.studioScale, self.studioScale)
elseif self.logo then
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
else
if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end
if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end
end
love.graphics.setColor(1, 1, 1, 1)
end
if t >= STAR_START and t < FLASH_START then
-- big star: from OAM (160,0) moving +4Y/-4X per frame
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
if t >= WAVES_START then
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
-- +1px per 3-frame substep until the wave loop ends; the lower star in
@@ -351,6 +324,18 @@ function IntroMovie:drawSplash()
end
end
drawBars()
if t >= STAR_START and t < FLASH_START then
-- ..(engine/movie/splash.asm ln 32)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
end
function IntroMovie:drawFight()
@@ -379,17 +364,38 @@ function IntroMovie:drawFight()
end
end
function IntroMovie:drawCopyright()
if self.studio.card or self.studio.credit then
local card = self.studio.card or ""
local credit = self.studio.credit or ""
love.graphics.setColor(0, 0, 0, 1)
Font.draw(card, (160 - #card * 8) / 2, 64)
Font.draw(credit, (160 - #credit * 8) / 2, 80)
elseif self.copyright and self.gfInc then
local function row(seq, x, y)
for _, t in ipairs(seq) do
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
x = x + 8
end
end
for _, y in ipairs({ 56, 72, 88 }) do row(COPY_PREFIX, 16, y) end
row(COPY_NINTENDO, 80, 56)
row(COPY_CREATURES, 80, 72)
love.graphics.draw(self.gfInc, 80, 88)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Nintendo"), 80, 56)
Font.draw(Strings("Creatures inc."), 80, 72)
Font.draw(Strings("GAME FREAK inc."), 16, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
function IntroMovie:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == 1 then
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1)
local credit = self.studio.credit or Strings("bois club")
Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
self:drawCopyright()
elseif self.phase == 2 then
self:drawSplash()
else
+1
View File
@@ -51,6 +51,7 @@ function ListMenu.new(game, title, items, opts)
local self = setmetatable({}, ListMenu)
self.game = game
self.title = title
self.kind = opts.kind or title
self.items = items
self.index = 1
self.scroll = 0
+3
View File
@@ -72,6 +72,7 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
kind = "pc_item_withdraw",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
@@ -110,6 +111,7 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
kind = "pc_item_deposit",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
@@ -131,6 +133,7 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
kind = "pc_item_toss",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
+169 -46
View File
@@ -103,7 +103,31 @@ local YELLOW_CYCLE_SPECIES = {
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
-- ..(engine/movie/title.asm ln 227)
local HOLD_FRAMES = 200
local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true }
-- ..(engine/movie/title2.asm ln 13)
local function scrollFrames(steps, offset)
local frames = {}
for _, step in ipairs(steps) do
for _ = 1, step[2] do
frames[#frames + 1] = offset
offset = offset - step[1]
end
end
return frames
end
local OUT_FRAMES = scrollFrames(
{ { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 },
{ 8, 3 }, { 9, 3 } }, 0)
local IN_FRAMES = scrollFrames(
{ { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 },
{ 1, 1 } }, 120)
-- ..(engine/movie/title2.asm ln 85)
local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
local BALL_REST = 100
local function tryImage(path)
if not path then return nil end
@@ -178,6 +202,27 @@ function TitleState.new(game, opts)
or "assets/generated/title/red_version.png")
self.player = tryImage(imagePath(title.player)
or "assets/generated/title/player.png")
-- ..(engine/movie/title2.asm ln 85)
if self.player then
local pw, ph = self.player:getDimensions()
self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph)
self.playerQuads = {
{ love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 },
{ love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 },
{ love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 },
}
end
self.copyImg = tryImage(imagePath(title.copyright)
or "assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyImg then
local iw, ih = self.copyImg:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = tryImage(imagePath(title.gamefreakInc)
or "assets/generated/title/gamefreak_inc.png")
self.blue = GameVersion.isBlue()
self.yellow = GameVersion.isYellow()
or title.layout == "yellow_pikachu"
@@ -203,7 +248,10 @@ function TitleState.new(game, opts)
self.blinkTimer = 0
self.blinkAt = nil
else
self.phase = "loop"
-- ..(engine/movie/title.asm ln 28)
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = true
end
local defaultCycle = self.yellowLayout and { "PIKACHU" }
@@ -216,14 +264,15 @@ function TitleState.new(game, opts)
self.cycleIndex = 1
self.timer = 0
self.blink = 0
self.scrollPhase = "hold"
self.scrollFrame = 1
self.monOffset = 0
self.ballY = BALL_REST
return self
end
function TitleState:enter()
-- Yellow defers the title theme until after the logo drop and
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
-- WaitForSoundToFinish on PikachuCry1)
if self.yellowLayout then return end
if self.phase ~= "loop" then return end
self:startMusic()
end
@@ -240,6 +289,11 @@ end
local DROP_STEPS = {
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
}
local SETTLE_FRAMES = 36
-- ..(engine/movie/title.asm ln 201)
local RIBBON_FRAMES = {}
for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end
-- the boot cinematic up to the interactive loop; one call per frame
function TitleState:updateSequence()
@@ -263,12 +317,23 @@ function TitleState:updateSequence()
self.dropLeft = nil
end
elseif self.phase == "settle" then
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
self.timer = self.timer + 1
if self.timer >= 36 then
if self.timer >= SETTLE_FRAMES then
Sound.play(data, "Intro_Whoosh")
self.showBubble = true
self.phase = "bubble"
self.phase = self.yellowLayout and "bubble" or "ribbon"
self.ribbonOffset = RIBBON_FRAMES[1]
self.timer = 0
end
elseif self.phase == "ribbon" then
self.timer = self.timer + 1
local offset = RIBBON_FRAMES[self.timer + 1]
if offset then
self.ribbonOffset = offset
else
self.ribbonOffset = nil
self:startMusic()
self.phase = "loop"
self.timer = 0
end
elseif self.phase == "bubble" then
@@ -432,12 +497,63 @@ function TitleState:openMenu()
game.stack:push(menu)
end
-- ..(engine/movie/title.asm ln 271)
function TitleState:pickNewMon()
if #self.cycleSpecies < 2 then return end
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
function TitleState:setCyclePhase(phase)
self.scrollPhase = phase
self.scrollFrame = 1
self.timer = 0
if phase == "in" then
self:pickNewMon()
self.monOffset = IN_FRAMES[1]
elseif phase == "out" then
self.monOffset = OUT_FRAMES[1]
elseif phase == "ball" then
self.ballY = BALL_FRAMES[1]
else
self.monOffset = 0
end
end
function TitleState:updateCycle()
local phase = self.scrollPhase
if phase == "hold" then
if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end
return
end
local frames = phase == "out" and OUT_FRAMES
or phase == "ball" and BALL_FRAMES or IN_FRAMES
self.scrollFrame = self.scrollFrame + 1
local value = frames[self.scrollFrame]
if value then
if phase == "ball" then self.ballY = value else self.monOffset = value end
return
end
if phase == "out" then
-- ..(engine/movie/title.asm ln 235)
self:setCyclePhase(
STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in")
elseif phase == "ball" then
self:setCyclePhase("in")
else
self:setCyclePhase("hold")
end
end
function TitleState:update(dt)
if self.phase ~= "loop" then
self:updateSequence()
return
end
if self.yellowLayout then
if self.phase ~= "loop" then
self:updateSequence()
return -- input is ignored until the cinematic lands (title.asm)
end
self:updateBlink()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
@@ -452,21 +568,7 @@ function TitleState:update(dt)
end
self.timer = self.timer + 1
self.blink = (self.blink + 1) % 60
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
self.timer = 0
-- random pick that never repeats the current one
if #self.cycleSpecies > 1 then
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
end
if self.slideIn and self.slideIn > 0 then
self.slideIn = self.slideIn - 1
end
self:updateCycle()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting);
@@ -478,15 +580,14 @@ function TitleState:update(dt)
end
end
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
-- ..(engine/movie/title.asm ln 28)
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
local scrollY = -(self.scy or 0)
-- ..(engine/movie/title.asm ln 28)
local preRibbon = not self.yellowLayout
and (self.phase == "drop" or self.phase == "settle")
if self.logo then
love.graphics.draw(self.logo, 16, 8 + scrollY)
else
@@ -514,27 +615,29 @@ function TitleState:draw()
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
-- the Yellow fallback layout draws no ribbon at all.
if self.version and not self.yellow then
if self.version and not self.yellow and not preRibbon then
local iw, ih = self.version:getDimensions()
local rx = self.ribbonOffset or 0
if self.versionFull then
-- a continuous ribbon (versionRibbon) centers as one piece
love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64)
love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64)
elseif self.blue then
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64)
else
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64)
end
end
local sprite, spriteTrueColor = self:currentSprite()
local sprite, spriteTrueColor
if self.scrollPhase ~= "ball" then
sprite, spriteTrueColor = self:currentSprite()
end
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
local x = 40 + math.floor((56 - w) / 2) + slide
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
@@ -550,13 +653,33 @@ function TitleState:draw()
end
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
elseif self.player then
love.graphics.draw(self.player, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
end
-- ..(engine/movie/title.asm ln 117)
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
function TitleState:drawCopyright(y)
if not self.title.copyrightText and self.copyImg and self.gfInc then
local x = 16
for _, t in ipairs(COPY_PREFIX) do
love.graphics.draw(self.copyImg, self.copyQuads[t], x, y)
x = x + 8
end
love.graphics.draw(self.gfInc, x, y)
return
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
1, 136 + scrollY)
Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y)
love.graphics.setColor(1, 1, 1, 1)
end
+37 -9
View File
@@ -37,6 +37,10 @@ local mapScripts -- registry of hand-ported map scripts
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps
-- after a wild battle before another random battle can start.
local WILD_ENCOUNTER_GRACE_STEPS = 3
-- Fly animation coord paths (engine/overworld/player_animations.asm):
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
-- Delay3). The port anchors a path on the player's own position instead
@@ -81,8 +85,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- above (screen = tile*8 + pixel - 8/16), measured against the player
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
-- is the delta from the sprite's top-left, which the vanilla
-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that
-- origin while keeping these offsets frame-relative. `tile` indexes the
-- three stacked 8x8 tiles of
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
@@ -224,6 +230,8 @@ function OverworldState:enter(mapId, x, y, facing, opts)
-- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false
self.emote = nil
-- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose
-- exit mat is a LAST_MAP warp
self.lastOutdoor = Game.save.lastOutdoor
@@ -451,8 +459,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 +1103,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
@@ -3472,6 +3484,10 @@ end
function OverworldState:onStepComplete()
local p = self.player
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
if suppressWildEncounter then
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
end
self.todSteps = (self.todSteps or 0) + 1
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
require("src.world.PikachuFollower").onStep(Game.save)
@@ -3584,6 +3600,9 @@ function OverworldState:onStepComplete()
-- wild encounters in grass, on water while surfing, or -- on indoor
-- maps whose tileset is not FOREST -- on EVERY tile
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
-- The cooldown is checked after all other step processing so repel and
-- movement systems continue to advance during the protected steps.
if suppressWildEncounter then return end
local encDef = Game.data.encounters[self.map.id]
local enc
local indoor = Game.data.field.indoorEncounters
@@ -3970,6 +3989,9 @@ end
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
function OverworldState:afterBattle(result, battle)
if battle and battle.kind == "wild" then
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
end
local lead = Game.save.party[1]
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
lead and lead.species or "-", lead and lead.hp or 0,
@@ -4799,9 +4821,15 @@ function OverworldState:drawWorld()
end
end
local quad = self.rodQuads[oam.tile]
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
local rx = p.px - cam.x + oam.dx
local ry = p.py - cam.y - 4 + oam.dy
-- Place the rod against the active sprite's anchored top-left. The
-- vanilla result is still (px-cam, py-cam-4), while custom larger
-- sheets keep the rod attached to their feet.
-- Fishing always uses the on-foot player sheet; read its fields
-- directly so this FX pass does not advance pose-side animation.
local sprite, px, py = p.sprite, p.px, p.py
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
local rx = sx + oam.dx
local ry = sy + oam.dy
love.graphics.setColor(1, 1, 1, 1)
if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
+7 -2
View File
@@ -335,8 +335,13 @@ function Player:draw(camX, camY)
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
if fishTile then
sprite:draw(px, py, camX, camY, facing, 0, false, true)
sprite:drawTile(fishTile, math.floor(px - camX),
math.floor(py - camY) - 4 + 8, facing == "right")
-- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's
-- actual anchored frame origin so larger/custom sheets keep the pose at
-- their feet instead of falling back to the vanilla 16x16 top-left.
local sx, sy = sprite:getScreenOrigin(px, py, camX, camY)
sprite:drawTile(fishTile, sx,
sy + math.max(0, sprite.frameHeight - 8),
facing == "right")
return
end
sprite:draw(px, py, camX, camY, facing, phase, flip)
+97 -15
View File
@@ -8,6 +8,7 @@
local Logger = require("src.core.Logger")
local Assets = require("src.render.Assets")
local MapLoader = require("src.world.MapLoader")
local Party = require("src.pokemon.Party")
local Runtime = require("src.mods.Runtime")
local WorldAPI = {}
@@ -18,6 +19,11 @@ local overviewShades = {}
Assets.register(function() overviewShades = {} end)
local function shadeDigit(sum, pixelCount)
return tostring(math.max(0, math.min(3,
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
end
local function mapTileRows(map)
local tileset = map.tileset
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
@@ -28,30 +34,39 @@ local function mapTileRows(map)
cached = { pixels = pixels, shades = {} }
overviewShades[tileset.image] = cached
end
local rows, perRow = {}, tileset.tilesPerRow
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
for ty = 0, map.heightCells * 2 - 1 do
local row = {}
local row, detailTop, detailBottom = {}, {}, {}
for tx = 0, map.widthCells * 2 - 1 do
local tile = map:tileAt(tx, ty)
local shade = cached.shades[tile]
if shade == nil then
local sum = 0
local shades = cached.shades[tile]
if shades == nil then
local sums = { 0, 0, 0, 0 }
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
sums[quadrant] = sums[quadrant]
+ r * 0.2126 + g * 0.7152 + b * 0.0722
end
end
shade = tostring(math.max(0, math.min(3,
math.floor((1 - sum / 64) * 3 + 0.5))))
cached.shades[tile] = shade
shades = {
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
}
cached.shades[tile] = shades
end
row[#row + 1] = shade
row[#row + 1] = shades[1]
detailTop[#detailTop + 1] = shades[2] .. shades[3]
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
end
rows[#rows + 1] = table.concat(row)
detailRows[#detailRows + 1] = table.concat(detailTop)
detailRows[#detailRows + 1] = table.concat(detailBottom)
end
return rows
return rows, detailRows
end
function WorldAPI.new(game, modId)
@@ -86,10 +101,12 @@ end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify
-- exits and item spots that are still active without exposing world internals.
function WorldAPI:mapOverview()
local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end
local map, rows = ow.map, {}
local map, rows, markers = ow.map, {}, {}
for y = 0, map.heightCells - 1 do
local row = {}
for x = 0, map.widthCells - 1 do
@@ -99,11 +116,33 @@ function WorldAPI:mapOverview()
end
rows[#rows + 1] = table.concat(row)
end
local tileRows = mapTileRows(map)
local def = map.def or {}
for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
end
local game, save = self.game, self.game.save or {}
for _, obj in ipairs(def.objects or {}) do
if obj.item and obj.item ~= "0" and obj.item ~= 0
and ow.objectVisible(save, map.id, obj) then
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
end
end
local hidden = game.data and game.data.field and game.data.field.hiddenItems
for _, item in ipairs(hidden and hidden[map.id] or {}) do
local key = map.id .. "_" .. item.x .. "_" .. item.y
if not (save.hiddenTaken and save.hiddenTaken[key]) then
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end
end
local tileRows, tileDetailRows = mapTileRows(map)
return { mapId = map.id, width = map.widthCells,
height = map.heightCells, rows = rows, tileRows = tileRows,
height = map.heightCells, rows = rows, markers = markers,
tileRows = tileRows,
tileWidth = tileRows and map.widthCells * 2,
tileHeight = tileRows and map.heightCells * 2 }
tileHeight = tileRows and map.heightCells * 2,
tileDetailRows = tileDetailRows,
tileDetailWidth = tileDetailRows and map.widthCells * 4,
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
end
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
@@ -227,6 +266,49 @@ function WorldAPI:queueScript(rows, extra)
return true
end
-- The supported way to start a wild encounter. Hand-rolling this -- build a
-- BattleState, push it -- silently costs evolutions and blackout-on-loss
-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle
-- theme (both owned by pushBattle). Nothing raises when they are missing.
function WorldAPI:startWildBattle(species, level)
local ow = self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not self.game.data.pokemon[species] then
return nil, "unknown species: " .. tostring(species)
end
-- Pokemon.new writes the level through verbatim -- into level, the stat
-- calc and the exp curve -- so a fraction has to be refused here rather
-- than round somewhere downstream. The % test also catches NaN, which
-- passes both range comparisons.
level = tonumber(level)
if not level or level % 1 ~= 0 or level < 1 or level > 100 then
return nil, "level must be a whole number 1..100"
end
-- overworld() resolves the world from UNDER whatever sits on top of it,
-- so from a battle hook this would otherwise stack a second battle over
-- the live one -- and on a loss its afterBattle blacks out and warps
-- with the outer battle still on the stack.
local BattleTransition = require("src.render.BattleTransition")
for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do
if state.awardExp or getmetatable(state) == BattleTransition then
return nil, "a battle is already running"
end
end
if ow.transitioning then return nil, "the world is mid-warp" end
-- BattleState.newWild marks the species SEEN before it reports an empty
-- party, so the party check comes first: a refused call must not leave a
-- Pokedex entry behind.
local save = self.game.save
if not (save and Party.firstHealthy(save.party or {})) then
return nil, "no healthy party"
end
local battle = require("src.battle.BattleState")
.newWild(self.game, species, level)
battle.onFinish = function(result) ow:afterBattle(result, battle) end
ow:pushBattle(battle)
return true
end
-- drop a map's cached instance so the next load re-reads its record; when
-- it is the active map the world reloads around the player in place
function WorldAPI:invalidateMap(mapId)